☰
多线程事务回滚失效?Spring事务原理与工程化解决方案
2026/9/30 4:36:04 网站建设 项目流程

去年面试一个中级Java岗位,我问了这么一个问题:一张订单表,一次导入操作要拆给20个线程去处理,每个线程里各自开启事务插入数据,跑到第7个线程时发现数据有问题抛了异常,前面6个线程已经提交成功,这些数据怎么办?有人答“用全局事务”,有人答“把回滚写在catch里”,还有人沉默了半天说“回滚不了吧”。

这个问题的答案,其实比面试题本身更有意思——它背后是Spring事务的ThreadLocal机制、连接池的工作方式,以及分布式时代对“事务”二字的重新定义。如果你也是写Java的,日常会用@Transactional,但没细想过“多线程”和“事务”这两个词放一起会发生什么,这篇文章值得看完。我会从Spring事务的底层实现说起,把多线程场景下回滚失效的原因讲透,再给出几种真正能落地的方案。无论你是面试前突击,还是手头有批量任务要处理,都能找到能直接抄走的东西。

1. 先拆问题:你问的到底是哪一种“多线程事务”

“多线程的事务回滚”这个词,其实涵盖了三种完全不同的场景,很多人混为一谈,导致方案选型时南辕北辙。

第一种叫“伪多线程事务”。主线程开了一堆子线程,每个子线程里跑一段带@Transactional的代码,各自获取数据库连接,各自开启事务,谁都看不见谁。这种场景下,回滚这件事本质上在“各回各家”——线程A失败,线程B根本没收到信号,更不可能把A的数据撤销。如果你拿着这个问题去问资深DBA,他大概率会告诉你:这不是一个事务,这是多个并行的独立小事务。

第二种叫“单一事务的多线程化”。意思是,我希望这20个线程的操作,提交点只有一个,要么全部落库,要么全部不留痕迹。要做到这一点,必须让多个线程共享同一个事务上下文,也就是共享同一个数据库连接。问题来了:官方的事务管理器和连接池都不是为这个场景设计的。Spring事务基于ThreadLocal,把Connection和事务状态绑定在当前线程上,到了子线程那边,ThreadLocal是空的,数据源会再去连接池借一条连接,事务就悄悄断开了。也就是说,“跨线程共享一个事务”在Spring默认机制里是行不通的,需要自己动手做不少额外工作。

第三种叫“分布式事务”。多线程并不是核心难点,数据分散在多个数据库、多个服务才是重点。下单要扣库存、写订单、记账,三件事落在三个系统的三张表上,本地事务管不了别家的事情。此时讨论回滚,已经不是在讨论数据库的rollback,而是在讨论补偿、冲正、对账和最终一致性。

我建议你先把上面三种场景区分清楚。因为网上关于“多线程事务回滚”的帖子,有八成是在第一种场景里告诉你“回滚不了”,剩下两成在讲第二种场景的变通方案,真正涉及第三种场景的,又往往直接跳到了Seata、TCC之类重武器。这篇文章我会按这个顺序逐步展开,先讲清楚底层机制,再给可落地的代码,最后聊架构层面的取舍。

2. 为什么Spring事务到了子线程就“不认账”

2.1 从ThreadLocal到Connection:事务是怎么绑定到线程上的

Spring的声明式事务,表面上就是@Transactional一个注解,背后是AOP动态代理在起作用。方法被代理拦截后,事务拦截器会从ThreadLocal里拿出来当前线程的事务状态,找到了就复用,找不到就开启一个全新的。而这个事务状态的核心,就是当前线程绑定着的数据库Connection。

TransactionSynchronizationManager这个类,你可以把它理解为一个“前台登记表”。它内部维护了一个ThreadLocal Map,键是DataSource实例,值是一个ConnectionHolder。整个事务期间,凡是这个线程内通过DataSourceUtils.getConnection去取连接的操作,都会先看这个ThreadLocal——有连接就用它,没有连接就去连接池再借一条,然后登记到ThreadLocal里。事务提交或回滚后,解除绑定,连接归还连接池。

底层DataSourceTransactionManager的doBegin和doCleanupAfterCompletion,就是围绕这个逻辑在转。doBegin将连接从自动提交模式切换到手动提交,并把autoCommit设置成false;doCleanup则恢复autoCommit,释放绑定关系,归还连接。这一整套流程,它服务的对象永远只有一个:“当前线程”。

所以Spring事务的本质可以精简成一句话:当前线程 + 当前数据源上的一个Connection。事务的所有行为,包括回滚,都发生在这个Connection上。

2.2 为什么子线程里没有“前台登记表”

这里要说到ThreadLocal的特性。顺带说一个生活化的理解方式:每个线程都有一间独立的工作室,ThreadLocal就是工作室里的保险柜,工作结束,保险柜随工作室一起销毁。父线程的保险柜,子线程是绝对看不见的,不管你是new Thread还是线程池里的线程,都一样。

理解了这一点,你就能明白多线程下@Transactional“失效”的根本原因了:

  • 主线程里方法被@Transactional包裹,主线程的ThreadLocal里登记了一条连接,事务已开启。
  • 进入子线程后,子线程的ThreadLocal是空的。如果你在子线程里调用某一个被@Transactional注解的方法,那无非是在子线程的保险柜里重新登记一条新连接,开启一个新事务;如果你调用的是普通方法,那数据库操作直接在自动提交模式下执行,一条SQL一提交。
  • 主线程等待所有子线程执行完毕,异常抛回主线程,主线程上的事务执行回滚。但是抱歉,子线程里那些操作要么已提交,要么自动提交,主线程一个都管不着。

Spring官方并没有提供一套“跨线程事务传播”的现成方案。网上有些文章会建议你手动把主线程的Connection丢进一个静态Map,让子线程去拿。这种歪招要真的落到生产环境,我只能说风险极大:Connection本身不是线程安全的,一个连接同一时刻只能被一个线程握在手里;子线程并发去调,要么串行排队,要么数据错乱,连接池状态还会被搞坏。两年内你一定会在某个SQL执行报错或数据不一致的凌晨,想起这篇文章的警告。

3. 多线程回滚失败场景的经典写法,你踩过几个

3.1 陷阱一:parallelStream里“假装有事务”

我见过最多的错法,是在方法上加了@Transactional,方法体内用parallelStream去做批量插入:

@Transactional public void batchImport(List<OrderDTO> list) { list.parallelStream().forEach(dto -> { orderMapper.insert(dto); // 跑到第5条抛异常 }); }

这段代码看着没什么问题,但执行逻辑和你想的完全不一样。@Transactional是在主线程上开启事务的,而parallelStream内部用的是ForkJoinPool.commonPool,forEach的lambda会在ForkJoinPool线程上执行。这些子线程里ThreadLocal干净得发亮,根本没有事务上下文。此时orderMapper.insert拿到的连接,是子线程从连接池重新借出来的新连接,且处于默认的自动提交模式。

于是发生的事就是:一条成功,立刻提交;写到第5条抛异常,异常抛回主线程,主线程事务管理器发现主线程有事务,尝试回滚,但主线程自己压根没有执行任何SQL。结果是前面4条已经落库了,后面全部没写,没有一次像样的回滚发生。

要理解这个问题,你可以想象一列火车,车头是主线程,车厢是数据,子线程是接驳公交车。火车司机说“这趟车全部到站再统一放行”,但公交车乘客根本不听他的,每到一个站就自己下车走了。

3.2 陷阱二:多线程里各自@Transactional,以为“总有一个事务能兜底”

另一种常见写法是,批量导入逻辑放在子线程里,每个子线程的处理入口带@Transactional:

public void batchImport(List<OrderDTO> list) { ExecutorService pool = Executors.newFixedThreadPool(8); list.forEach(dto -> pool.submit(() -> insertOne(dto))); pool.shutdown(); } @Transactional public void insertOne(OrderDTO dto) { orderMapper.insert(dto); }

这种情况下,每个子线程都有自己独立的事务,第5个线程失败,它只需回滚自己的那一条,另外7个线程的插入早就提交了。从调用方视角看,batchImport方法整体失败了,但数据却插进去一大半。

更有迷惑性的写法是,子线程执行完以后,主线程发现失败,再用一个@Transactional方法去“按ID删掉成功的数据”。这算是一种补偿,不是回滚。补偿本身就带风险:万一删除的逻辑又失败一半呢?幂等呢?删除和新增之间数据被其他服务读走了呢?你以为在修Bug,其实是在用另一个Bug掩盖前一个Bug。

3.3 绕开陷阱的正确姿势一:合并提交点,把多线程变成单线程

如果数据量不算失控,最稳妥的做法,永远是把“写库”这件事串行化,让事务只有一个提交点。又想把校验、清洗、调用外部接口这种耗时操作并行掉,又想落库时是一个事务,就分成两个阶段:

public void importWithParallelCheck(List<OrderDTO> list) { List<Future<ValidateResult>> futures = new ArrayList<>(); ExecutorService pool = Executors.newFixedThreadPool(8); try { for (OrderDTO dto : list) { futures.add(pool.submit(() -> validateAndNormalize(dto))); } // 这里同步等待所有校验结果,有异常直接向上抛 List<OrderDTO> readyList = new ArrayList<>(); for (Future<ValidateResult> future : futures) { readyList.add(future.get().getData()); } // 全部校验通过后,单线程、单事务落库 txTemplate.executeWithoutResult(status -> { readyList.forEach(orderMapper::insert); }); } catch (Exception e) { // 落库前抛异常,无任何脏数据,无需回滚 throw new ImportException("批量导入失败", e); } finally { pool.shutdownNow(); } }

所谓“把提交点做少”,落实到实操层就是这么一句话:能并行的并行,但最后的写库阶段,不管你前面开了多少线程,必须回到同一个线程里执行。这样事务语义完整,回滚也干脆。

3.4 绕开陷阱的正确姿势二:TransactionTemplate + 批次提交 + 补偿清单

当数据量真的很大,比如单次导入几十万条,串行插入确实太慢,那就得接受“分批提交”的现实。这里我推荐编程式事务,也就是TransactionTemplate,把事务边界精确控制在每个批次上:

public void batchImportWithPartialSuccess(List<OrderDTO> list) { List<Long> committedIds = new ArrayList<>(); int batchSize = 100; for (int i = 0; i < list.size(); i += batchSize) { List<OrderDTO> batch = list.subList(i, Math.min(i + batchSize, list.size())); try { List<Long> batchIds = batch.stream().map(OrderDTO::getId).toList(); txTemplate.executeWithoutResult(status -> { batch.forEach(orderMapper::insert); }); committedIds.addAll(batchIds); } catch (Exception e) { log.error("批次落库失败, startIndex={}, committedIds={}", i, committedIds, e); // 通过补偿任务把committedIds里的数据做逆操作 compensationService.reverse(committedIds); throw new BatchImportException("批量导入失败,已回滚已提交批次", e); } } }

注意,这里我用了“补偿”而不是“回滚”。因为每个批次的事务一旦提交,数据库层面就再也回不去了,你只有用业务逆操作来冲正。在做设计时就要想清楚:这个数据能被逻辑删除吗?插入之前有没有可能再查一次之前是否插入过?如果有可以逆操作的字段最好,没有的话就要在业务表设计时加上status、batch_id这些方便对账的标记字段。

我用这种方式处理过一个导单系统,几十万条订单分批次入库,某批次失败,就把已有批次全部标记为“导入失败”,让用户能重新发起导入。数据本身还在,但状态已被修正,用户不会看到“一半有单、一半没单”的脏数据。

4. 真要“多个线程一个事务”,能怎么做

4.1 先明确一个前提:真回滚必须共享同一个Connection

如果你非要“20个线程全部成功才提交、任何一个失败就全部回滚”,那这20个线程就必须使用同一条数据库连接,并且事务只能在这一条连接上开启一次。因为数据库回滚是以事务为单位的,一个事务对应一条连接上的一串操作序列。不同连接上的操作,根本组不成一个事务。

那么能不能让多个线程共用一条连接?技术上是存在的。你可以手动创建一个Connection,然后把autoCommit关掉,把这条连接放到一个自定义ThreadLocal里,让子线程通过包装好的工具类去拿这条连接。理论上子线程只要串行执行,不并发使用同一条连接,最后在主线程统一commit或rollback,确实可以做到“整体回滚”。

但我不建议你在生产环境这么做,原因很现实:

  • Connection不是线程安全的,多线程一旦并发操作同一条连接,轻则数据交叉,重则连接池判定连接失效。
  • Spring的事务拦截器、MyBatis的SqlSession都未必认你这条手动塞进去的连接,需要大量定制。
  • 事务一开,连接就一直被占着,如果子线程里有慢SQL,连接池会很快被拖垮。

所以你问“能不能多线程一个事务”,我的回答是:能,但工程上极其不划算。想清楚你到底是“必须”还是“以为必须”整体回滚。绝大多数批量场景,都是可以接受“有重试、有标记、有补偿”的最终一致性的。

4.2 退而求其次:多线程分片,每片独立事务,最后失败重跑

很多批处理框架,比如Spring Batch、xxl-job分片任务,用的都是这个模型。把10万条数据切成10片,每片10000条,交给10个线程去跑,每片一个独立事务。某一片失败了,框架会把这一片重新调度到其他机器或线程重跑。

这个模型里没有“整体回滚”,但有一套“整体完成”的判断逻辑:所有分片都成功,才向主任务汇报成功;有一个分片失败,就把它标记为失败,触发重试或报警。业务上通常可以接受“某一片失败但其他片已提交”,前提是这些分片之间不能有强依赖。比如导入数据,片与片互相独立,OK;比如批量更新同一批账户余额,片与片操作同一行就可能死锁,不适合分片。

4.3 编程式事务的一个关键技巧:把事务边界“画清楚”

有些场景要求的其实是“多线程协作,但只有一个提交点”,而这个提交点未必非要落在数据库事务上。比如你先在多线程里各自把数据写入一张临时业务表,这个阶段不用事务,失败无所谓;全部执行完后,再用一个事务把临时表数据通过 SQL 批量转移到正式表:

txTemplate.executeWithoutResult(status -> { jdbcTemplate.update("INSERT INTO order_table SELECT * FROM temp_order_table WHERE batch_id = ?", batchId); jdbcTemplate.update("DELETE FROM temp_order_table WHERE batch_id = ?", batchId); });

你看,多线程做的事只到“写临时表”,没有真正的业务事务,最终转移才开启事务。这个模式我建议你在设计复杂导入功能时优先考虑,它把并行计算和事务回滚彻底解耦了,两边的问题都好排查。

4.4 再进一步:跨库跨服务的“局部回滚”,引入分布式事务概念

如果你的子线程里面不止同一个库,有的写订单库,有的扣库存库,那就不能再用本地事务的思维了,因为跨库没有共享事务管理器。这时候可选的方案有XA两阶段提交,或者Seata AT模式这类分布式事务中间件。

XA的思路是:多个资源管理器先各自prepare,都准备好了再统一commit;任何一个prepare失败,就通知所有人rollback。它看起来很美好,但性能开销大,数据库锁持有的时间也很长,不适合高并发核心链路,一般银行老系统里才比较常见。

Seata的AT模式本质上是不用侵入业务SQL,在一阶段直接提交本地事务并记录undo_log,二阶段如果全局失败,就根据undo_log逆向补偿数据。这套机制比XA轻量,但在多线程场景下同样有一个现实问题:全局事务ID怎么传给子线程?Seata要求同一个全局事务的所有分支都必须持有相同的XID,子线程里需要手动把XID传递过去。如果用的是自己的线程池,就必须用Seata提供的装饰器,或自己在线程启动时set一下XID,否则分支事务会被当成新的全局事务处理。

多线程场景我很少推荐一上来就上分布式事务中间件,因为它的适用面是“跨服务”。如果只是本服务内部多个线程,先回到4.1的思路去评估需求,比引入分布式事务更实在。

5. 订单与库存:一个绕不开的经典事务案例

5.1 为什么靠“回滚”解决不了问题

聊到这里,我想专门聊聊“订单与库存”这个所有人都在讲的例子。下单服务扣减库存,订单中心写订单,支付中心创建支付单。如果这三个动作在同一个数据库里,用本地事务就能搞定;一旦它们分别在订单库、库存库、支付库,本地事务就失效了,你需要分布式事务。

但分布式事务的核心不是“回滚”,而是“最终一致 + 补偿”。我们可以接受瞬时库存扣了但订单没写成功这种不一致,但不能长期没有人纠正它。

5.2 本地消息表:最朴素也最实用的方案

一种经典做法是本地消息表。下单时,在同一本地事务里,往订单表写入订单数据,同时往消息表插入一条“扣减库存”的消息。这两个动作成功,则本地提交。后台有一个独立任务轮询消息表,把消息发送给库存服务,库存服务收到后做扣减并返回确认,消息表里对应记录被标记为成功。

这套方案的好处是,只要本地事务提交成功,消息一定存在;消息发送失败?任务会不断重试;库存服务处理失败?可以返工。它没法做到“下单失败就立刻把库存恢复”,但业务上往往不需要这样——库存扣减失败,人工介入或自动重试都能兜底。

5.3 事务消息:把“消息”当成事务的一部分

RocketMQ的事务消息把上面这个思路移动到了消息中间件里。发送方先发一条“半消息”,消息在Broker中暂存,暂不可消费;发送方执行本地事务(下单、扣库存);如果本地事务成功,向Broker提交确认,消息变为可消费;如果本地事务失败,则向Broker发送回滚指令,消息被丢弃。

这个方案的排查点集中在Broker和本地事务的回查机制:半消息长时间没有确认,Broker会主动回查发送方,发送方需要提供事务执行结果。你要保证自己的业务方法幂等,回查幂等,消费方接收消息也要幂等。

5.4 TCC与SAGA:根据业务选择补偿策略

如果要求更高一点,比如必须保证库存预占,可以用TCC(Try-Confirm-Cancel)。Try阶段先预留库存,主业务成功则Confirm正式扣减,主业务失败则Cancel释放预留。这套方案对业务侵入较大,每个接口都要写Try/Confirm/Cancel三套逻辑,但可控性最好。

SAGA则是一条长事务拆解为多个正向步骤和逆向步骤,第3步失败,就依次执行第2步、第1步的补偿逻辑。它不需要预留资源,适合业务流程长、各步骤完全独立的场景。

做这套设计时,我建议你列表格把“正向操作”和“补偿操作”写明白:

操作正向补偿
下单插入订单状态为“待支付”将订单状态改为“已取消”
扣库存库存数量减1,锁定记录库存数量加1
创建支付单插入支付单支付单状态改为“关闭”

注意,用状态字段标记,而不是物理删除,这是所有补偿方案的基础。真到了要回滚数据的那一天,你会发现“能不能找到一条数据并修改它的状态”,比“把这条数据从数据库里抹掉”重要得多。

6. 常见问题与排查技巧实录

这部分我整理一下多年排查“多线程事务”问题时的笔记。很多问题不是因为代码看不懂,而是因为定位方向错了。

现象常见原因快速排查方向
子线程里抛异常,主线程回滚无效子线程各自有独立Connection在子线程执行SQL前打印当前线程名、Connection HASH
批量插入部分成功但无异常parallelStream的lambda在ForkJoinPool线程执行,子线程自动提交用try-catch包住插入,打印线程名,看autoCommit
@Transactional完全不生效同类内部方法调用,动态代理没经过检查是否通过this调用,改为注入自身代理或拆到另一个Service
多线程更新同一行导致死锁并发事务互相等待行锁查数据库死锁日志,给更新语句加唯一排序
事务日志满,报9002SQL Server事务日志文件增长受限先备份日志,再收缩日志文件,厘清大事务占用
Kafka消费端多线程消费导致消息乱序同一个分区的消息被并行消费分区内单线程串行处理,多线程只能对应多个分区
多线程里开启全局事务但分支不生效Seata XID没有传递到子线程在线程启动时RootContext.setXID,或使用自动传递的线程池装饰器

排查这类问题,我有一个固定套路:先在入口打印当前线程的Thread ID,在SQL执行前后打印从数据源拿到的Connection HASH。如果发现主线程和子线程拿到的连接不是同一个,那事务无论如何都不会一起回滚。先把这个事实确认了,再回头改方案,不要瞎改代码。

还有一个细节很多人吃过亏:Spring的@Transactional默认只回滚RuntimeException和Error,如果子线程抛出的是受检异常(比如Exception),主线程即使捕获到,也不会触发回滚。所以项目里建议统一抛运行时异常,并在事务方法上显式写上rollbackFor = Exception.class,避免踩这个暗坑。

另外,如果你用的连接池是HikariCP,注意它的最大池大小和最小空闲配置。多线程并发时,每个线程都会去池子里借连接,池子小的话,大量线程会卡在等待连接上,表现上就是“事务好慢”,实际是连接不够用。批量任务开始时可以把池的大小临时调大,跑完再调回来,但要注意别影响其他业务。

最后分享一点我自己在实际工程里的体会

处理了几年“多线程 + 事务”相关的问题,我最大的感受是:大多数场景都不是真的需要“回滚”,而是需要一个“清晰的失败标记”。数据库事务的回滚,是系统给我们的一个安全撤退通道,但它是为单线程的串行世界设计的。到了多线程,到了微服务,到了跨库分布式,这条路就断了,你得自己用业务手段把后路修出来。

如果你的系统里出现“多线程回滚失败”的线上问题,不要急着找“能不能跨线程回滚”的银弹,而是先看两件事:业务上能不能接受部分成功、有没有补偿和重试的通道。能接受,就按批次、按分片去推进;不能接受,就回到单线程事务,把那一段关键路径用最简单的方式写完。架构设计里没有那么多炫技空间,多数的“高难度解法”,反而是埋隐患的地方。

这个观点,在我后来设计订单、库存、支付三端的一致性方案时反复被验证:把提交点收窄,把失败路径暴露出来,比试图让所有线程共享一个回滚开关,要可靠得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询