ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多线程事务回滚失效?Spring事务原理与工程化解决方案

多线程事务回滚失效?Spring事务原理与工程化解决方案 去年面试一个中级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设置成falsedoCleanup则恢复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(ListOrderDTO list) { list.parallelStream().forEach(dto - { orderMapper.insert(dto); // 跑到第5条抛异常 }); }这段代码看着没什么问题但执行逻辑和你想的完全不一样。Transactional是在主线程上开启事务的而parallelStream内部用的是ForkJoinPool.commonPoolforEach的lambda会在ForkJoinPool线程上执行。这些子线程里ThreadLocal干净得发亮根本没有事务上下文。此时orderMapper.insert拿到的连接是子线程从连接池重新借出来的新连接且处于默认的自动提交模式。于是发生的事就是一条成功立刻提交写到第5条抛异常异常抛回主线程主线程事务管理器发现主线程有事务尝试回滚但主线程自己压根没有执行任何SQL。结果是前面4条已经落库了后面全部没写没有一次像样的回滚发生。要理解这个问题你可以想象一列火车车头是主线程车厢是数据子线程是接驳公交车。火车司机说“这趟车全部到站再统一放行”但公交车乘客根本不听他的每到一个站就自己下车走了。3.2 陷阱二多线程里各自Transactional以为“总有一个事务能兜底”另一种常见写法是批量导入逻辑放在子线程里每个子线程的处理入口带Transactionalpublic void batchImport(ListOrderDTO 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(ListOrderDTO list) { ListFutureValidateResult futures new ArrayList(); ExecutorService pool Executors.newFixedThreadPool(8); try { for (OrderDTO dto : list) { futures.add(pool.submit(() - validateAndNormalize(dto))); } // 这里同步等待所有校验结果有异常直接向上抛 ListOrderDTO readyList new ArrayList(); for (FutureValidateResult 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(ListOrderDTO list) { ListLong committedIds new ArrayList(); int batchSize 100; for (int i 0; i list.size(); i batchSize) { ListOrderDTO batch list.subList(i, Math.min(i batchSize, list.size())); try { ListLong 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根据业务选择补偿策略如果要求更高一点比如必须保证库存预占可以用TCCTry-Confirm-Cancel。Try阶段先预留库存主业务成功则Confirm正式扣减主业务失败则Cancel释放预留。这套方案对业务侵入较大每个接口都要写Try/Confirm/Cancel三套逻辑但可控性最好。SAGA则是一条长事务拆解为多个正向步骤和逆向步骤第3步失败就依次执行第2步、第1步的补偿逻辑。它不需要预留资源适合业务流程长、各步骤完全独立的场景。做这套设计时我建议你列表格把“正向操作”和“补偿操作”写明白操作正向补偿下单插入订单状态为“待支付”将订单状态改为“已取消”扣库存库存数量减1锁定记录库存数量加1创建支付单插入支付单支付单状态改为“关闭”注意用状态字段标记而不是物理删除这是所有补偿方案的基础。真到了要回滚数据的那一天你会发现“能不能找到一条数据并修改它的状态”比“把这条数据从数据库里抹掉”重要得多。6. 常见问题与排查技巧实录这部分我整理一下多年排查“多线程事务”问题时的笔记。很多问题不是因为代码看不懂而是因为定位方向错了。现象常见原因快速排查方向子线程里抛异常主线程回滚无效子线程各自有独立Connection在子线程执行SQL前打印当前线程名、Connection HASH批量插入部分成功但无异常parallelStream的lambda在ForkJoinPool线程执行子线程自动提交用try-catch包住插入打印线程名看autoCommitTransactional完全不生效同类内部方法调用动态代理没经过检查是否通过this调用改为注入自身代理或拆到另一个Service多线程更新同一行导致死锁并发事务互相等待行锁查数据库死锁日志给更新语句加唯一排序事务日志满报9002SQL Server事务日志文件增长受限先备份日志再收缩日志文件厘清大事务占用Kafka消费端多线程消费导致消息乱序同一个分区的消息被并行消费分区内单线程串行处理多线程只能对应多个分区多线程里开启全局事务但分支不生效Seata XID没有传递到子线程在线程启动时RootContext.setXID或使用自动传递的线程池装饰器排查这类问题我有一个固定套路先在入口打印当前线程的Thread ID在SQL执行前后打印从数据源拿到的Connection HASH。如果发现主线程和子线程拿到的连接不是同一个那事务无论如何都不会一起回滚。先把这个事实确认了再回头改方案不要瞎改代码。还有一个细节很多人吃过亏Spring的Transactional默认只回滚RuntimeException和Error如果子线程抛出的是受检异常比如Exception主线程即使捕获到也不会触发回滚。所以项目里建议统一抛运行时异常并在事务方法上显式写上rollbackFor Exception.class避免踩这个暗坑。另外如果你用的连接池是HikariCP注意它的最大池大小和最小空闲配置。多线程并发时每个线程都会去池子里借连接池子小的话大量线程会卡在等待连接上表现上就是“事务好慢”实际是连接不够用。批量任务开始时可以把池的大小临时调大跑完再调回来但要注意别影响其他业务。最后分享一点我自己在实际工程里的体会处理了几年“多线程 事务”相关的问题我最大的感受是大多数场景都不是真的需要“回滚”而是需要一个“清晰的失败标记”。数据库事务的回滚是系统给我们的一个安全撤退通道但它是为单线程的串行世界设计的。到了多线程到了微服务到了跨库分布式这条路就断了你得自己用业务手段把后路修出来。如果你的系统里出现“多线程回滚失败”的线上问题不要急着找“能不能跨线程回滚”的银弹而是先看两件事业务上能不能接受部分成功、有没有补偿和重试的通道。能接受就按批次、按分片去推进不能接受就回到单线程事务把那一段关键路径用最简单的方式写完。架构设计里没有那么多炫技空间多数的“高难度解法”反而是埋隐患的地方。这个观点在我后来设计订单、库存、支付三端的一致性方案时反复被验证把提交点收窄把失败路径暴露出来比试图让所有线程共享一个回滚开关要可靠得多。
返回列表