ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

多线程下事务回滚彻底失效?Spring事务与并发实践的四个解决方案

多线程下事务回滚彻底失效?Spring事务与并发实践的四个解决方案 1. 先说结论多线程里的“事务”根本不是你想的那样问个直击灵魂的问题你有一个方法加了Transactional方法里开了几个子线程去批量处理数据其中一个子线程抛了异常整个事务能回滚吗答案是大概率不能。我第一次被这个问题问住的时候脑子里的第一反应是“Spring事务不是自动管理提交和回滚吗”后来真正去看了Spring的源码和事务实现机制才发现这个问题的本质根本不是“事务有多难”而是多线程和事务这两个东西天然就不该混在一起。为什么因为Spring的事务本质上是把数据库的连接Connection绑定到当前线程上的通过ThreadLocal保存事务的commit和rollback都由这个线程上的同一个Connection完成。一旦你把任务丢到子线程里子线程拿到的连接是另外新建的它跟主线程的事务根本不在一个连接上。你主线程里的事务还没提交子线程里的数据已经提交了或者主线程要回滚的时候子线程已经查不到你主线程里还没提交的数据了。所以这篇文章我要把这件事彻底讲透多线程下事务为什么失效、怎么绕开这些坑、业务上什么场景该用什么方案、以及真正遇到“多线程回滚”的需求时手上该有几套能打的方案。适合正准备接并发改造需求的开发同学、正在背多线程和事务面试题的人以及被线上半成功半失败数据折磨过的写码人。看完你能直接落到代码里去试。2. 底层拆解Spring事务到底是怎么实现“回滚”的2.1 事务回滚的前提把两次请求绑到同一根线上我先用一句话解释Spring事务的核心Transactional生效的前提是同一个线程里的数据库操作共用同一个Connection。Spring的DataSourceTransactionManager在开启事务时会从DataSource里拿一个连接然后把连接放进TransactionSynchronizationManager这个类内部的ThreadLocal里。后面所有走MyBatis、JdbcTemplate、JPA的数据库操作只要在同一个线程内执行都会自动从这个ThreadLocal里取连接。事务提交或回滚本质就是在这个连接上执行commit()或rollback()。为了更直观我画个简单的流程线程A 数据库连接C 调用Transactional方法 → 从连接池取连接C存到ThreadLocal 执行SQL1 → 使用ThreadLocal中的连接C 执行SQL2 → 使用ThreadLocal中的连接C 方法正常结束 → 连接C.commit() 方法抛异常 → 连接C.rollback()这里有个最关键的概念事务的“上下文”是和线程绑死的。Spring的传播机制PROPAGATION_REQUIRED、PROPAGATION_REQUIRES_NEW等本质上是在“当前线程能不能复用已有事务”这件事上做文章。一旦跨线程这些语义全部归零。2.2 子线程为什么会丢掉“事务上下文”我们来看一个极其常见的翻车代码Transactional public void batchProcess(ListInteger ids) { ids.forEach(id - { new Thread(() - { // 假设这里调用了业务方法内部执行了update操作 bizService.update(id); }).start(); }); }不出意外的话这个Transactional一点用都没有。原因有这么几层第一子线程里的bizService.update(id)如果要走Spring的AOP代理确实会生成事务但它生成的是一个新事务不是主线程那个。这个新事务的开头和结尾都在子线程内完成主线程的事务还没结束子线程的事务可能已经提交了。第二如果子线程里没有加任何Transactional那子线程的数据库操作是自动提交的也就是每执行一条SQLConnection直接commit掉。主线程回滚根本拦不住子线程已经提交的结果。第三即使你把ThreadLocal里的连接强行复制给子线程也不可行。因为Connection对象不是线程安全的多个线程共享同一个连接会导致SQL请求交错、游标错乱、无法预期的事务隔离行为甚至直接把连接搞坏。所以结论先立住想让多线程里的操作像单线程那样一起回滚必须放弃“自动传播事务”的幻想老老实实自己控制子线程里的事务边界或者干脆把多线程改成单线程。2.3 事务注解不生效的另一个隐形杀手自调用说到这必须插一个坑因为它和多线程经常同时出现同一个类内部调用事务注解是失效的。Service public class OrderService { Transactional public void createOrder() { // 事务逻辑 updateStock(); } Transactional public void updateStock() { // 另一个事务逻辑但在同一个类里被调用 } }当createOrder()内部调用updateStock()时走的是this.updateStock()没有经过Spring的代理对象Transactional不生效。这个情况我见过很多次有人说“我明明两个方法都加了事务注解为什么一个回滚另一个不回滚”排查到最后发现是自调用。放到多线程场景下更痛苦主线程的入口方法虽然是Transactional但内部通过Async注解去调另一个线程里的方法那个方法上的Transactional就算生效也只是子线程自己的本地事务。两个地方的逻辑合起来根本没有“同一个事务”的关系。3. 多线程场景下事务回滚的常见翻车现场3.1 场景一主线程等子线程子线程失败但主线程不知道流水线改造时为了加快速度把原本单线程循环的批量操作拆成多线程并发处理。看起来是提速了但问题是Transactional public void batchCreateOrders(ListOrder orders) { ExecutorService pool Executors.newFixedThreadPool(10); for (Order order : orders) { pool.submit(() - createOrder(order)); } pool.shutdown(); pool.awaitTermination(1, TimeUnit.MINUTES); }这里有个非常隐蔽的坑如果你只提交任务但不检查每个Future的返回结果子线程抛出的异常会被线程池吞掉或者只能用它自己的UncaughtExceptionHandler处理主线程根本感知不到。即使你能感知到异常主线程此时也已经决定提交事务了。正确做法是收集Future在最后统一get()如果有任何一个任务抛异常主线程就主动throw一个RuntimeException触发主事务回滚。但注意这个回滚只能回滚主线程之前在数据库上的操作子线程的操作不受影响因为子线程用的是自己的连接如果子线程的方法上带Transactional它自己早就提交了。3.2 场景二一半成功一半失败数据“骑墙”最常见的线上表现是一个批量跑批任务处理了100条数据前40条成功后60条失败。重跑的时候前40条被重复处理了或者资源已经扣减了一次又扣一次最后对不上账。为什么会有这种“骑墙”数据因为你在分批拉数据时用了一个表每处理一条就更新一个状态字段。主线程的事务回滚了但子线程已经把状态字段从“待处理”改成“处理中”/“已处理”。你回滚主线程它只是把本地内存里还没提交的东西撤掉子线程在独立连接上做的更新早就落库了。这种问题我在实际项目里排起来特别费劲因为数据库连接池给每个线程分配了一个连接不同连接的事务隔离级别不同如果你用的是默认的READ_COMMITTED或者REPEATABLE_READ子线程里能看到主线程未提交的数据吗在多数数据库下看不到。这会导致一种诡异的“互相看不到”现象主线程往表A插了一批数据子线程查询表A查不到因为还没提交于是认为数据不存在又插了一遍最终导致主键冲突或者重复数据。3.3 场景三事务超时与连接池耗尽多线程事务比单线程事务更容易碰到连接池耗尽的问题。你想想一个主线程事务占着一个Connection不放又开了10个子线程每个子线程都去连接池申请连接事务没结束前所有连接都不能释放。下一批请求进来时连接池没了就会一直等把线程池和数据库连接池同时打满。我见过一个极端案例核心流程开了20个线程并发处理每个线程内又调了一个远程Feign接口远程接口响应慢导致线程阻塞数据库连接一直被占着最后全站接口超时。这个问题的根源不在事务回滚而在于**“把事务时间拉得太长”**。后面我会讲一个死规矩事务方法里绝对不碰远程调用绝对不做耗时的IO。3.4 场景四分布式环境下的“假多线程真多库”还有一种情况你以为只是在应用层开了多线程实际上数据已经分散在了多个数据库实例上。订单在订单库库存是独立的库存库支付在支付库。这个时候你要回滚靠一个DataSourceTransactionManager肯定不够因为它只能管一个数据源。这时候问题升级成了分布式事务。多线程的回滚难度有多大分布式事务的回滚难度就是它的三次方你不仅要协调多个线程还要协调多个数据库、多个中间件、甚至多个微服务。这也是为什么必须引入专门方案后面我会专门出一节讲透。4. 把代码拉出来四套可落地的多线程事务回滚方案4.1 方案A最狠的“单线程化”——把并发从入口拍死先说最实用、最少出错的方案也是我上线跑批任务时最常用的兜底策略事务方法内部不要强行开多线程。如果并发只是为了提速你完全可以把“慢”的部分限制在无状态的数据准备阶段而把真正需要原子性的写操作留在单线程事务里。举个例子你有10万条数据要批量更新最开始可能是循环里调WebService去查业务数据再写回数据库。你可以把10万条数据的“查业务数据”这个步骤拆成多线程并发执行结果放到一个ConcurrentHashMap或队列里全部拿到结果后再回到主线程用一个单线程事务统一写库。这样回滚逻辑变得极简单写库阶段如果失败主事务直接回滚干干净净。public void batchProcess(ListLong ids) { // 阶段1并行准备数据千万别开事务 ListBizData preparedData parallelFetchFromRemote(ids); // 阶段2单线程事务统一落库 transactionTemplate.execute((TransactionStatus status) - { for (BizData data : preparedData) { orderMapper.updateByBizData(data); } return null; }); }这个方案的优点是不需要引入复杂框架思维负担小。缺点是写库阶段如果本身很耗时依然会形成长事务。所以更适合“准备慢、写入快”的场景。4.2 方案B使用TransactionTemplate在子线程里手动管理事务如果你必须让子线程去执行数据库写操作那就别再依赖Transactional了改用TransactionTemplate让每个子线程自己开启一个事务自己负责提交或回滚。这在很多老的Spring项目里都能直接跑不需要额外依赖。核心写法是包装一个任务类让每个子任务内部都有独立的事务边界Service public class BatchHandler { Autowired private TransactionTemplate transactionTemplate; public void processInParallel(ListLong ids) { ExecutorService pool Executors.newFixedThreadPool(8); ListFutureBoolean futures new ArrayList(); for (Long id : ids) { futures.add(pool.submit(() - processSingle(id))); } // 最后统一检查 for (FutureBoolean future : futures) { try { Boolean success future.get(30, TimeUnit.SECONDS); if (!success) { // 记录业务失败但这里不会回滚其它线程的数据 } } catch (Exception e) { // 记录失败原因并触发补偿 } } } private Boolean processSingle(Long id) { // 每个子线程内部独立事务成功提交失败回滚 return transactionTemplate.execute(status - { try { orderMapper.updateStatus(id, PROCESSING); stockMapper.deduct(id); return Boolean.TRUE; } catch (Exception e) { status.setRollbackOnly(); // 显式标记回滚 log.error(处理失败 id{}, id, e); return Boolean.FALSE; } }); } }这里要重点说明每个子线程的失败只会回滚它自己那条数据的变更但你无法回滚其他线程已经成功提交的数据。所以这种方案适合“子事务之间互相独立、失败只影响单条数据”的业务不适合需要“要么全部成功要么全部失败”的整体一致性场景。很多人会问那我能不能让子线程不提交等所有线程都成功后再统一提交理论上有一种“反向提交”模式子线程拿到Connection时不提交挂起主线程等所有子线程都成功后再统一提交有一个失败就全部rollback。这种做法在少量子线程的场景下可以写但它有致命缺点事务时间太长、连接占用太久、数据库的锁会锁住大量记录并发一高必出事。我强烈不建议在生产环境这么搞。4.3 方案CCompletableFuture 自定义回滚逻辑手动补偿与其纠结如何让多个子事务“原子提交”不如接受现实多线程环境无法做到严格原子性只能做“事务补偿”。具体思路是每个子线程正常提交自己的事务并记录操作日志当某个子线程失败后主线程启动一个“补偿器”对已经成功的线程执行反向操作。这种模式在订单-库存场景非常典型下单时扣减库存和写订单分到两个子线程一个线程扣库存成功一个线程写订单失败。这个时候你没法把库存的扣减“回滚”掉但你可以执行一次“加回库存”的补偿。ExecutorService pool Executors.newFixedThreadPool(2); FutureBoolean stockFuture pool.submit(() - stockService.deduct(stockReq)); FutureBoolean orderFuture pool.submit(() - orderService.create(orderReq)); Boolean stockOk stockFuture.get(); Boolean orderOk orderFuture.get(); if (stockOk !orderOk) { // 补偿把库存加回去 stockService.compensateAdd(stockReq); }当然这里compensateAdd必须有幂等性因为你无法保证补偿只执行一次。补偿接口的重试、重复执行、幂等键这些都要设计好。补偿的日志也要落库方便排查。这个方案不能叫“回滚”但它是分布式场景下唯一实用的大规模一致性方案几乎所有生产环境的订单、库存、积分、余额操作都在用。4.4 方案D引入分布式事务框架Seata AT 模式如果你所在的公司没有历史包袱允许引入新中间件而且你的业务真的需要多库、多服务同时保持一致那可以直接上Seata。Seata AT模式的核心是三个角色事务协调器TC、事务管理器TM、资源管理器RM。它通过一阶段业务SQL执行二阶段全局提交/回滚的方式实现了对业务代码侵入性较弱的全局事务。用Seata的时候有一个很有意思的坑它的全局事务框架内各个本地事务是分布在不同线程、不同服务里的但它不要求运行在同一线程上。它会通过RootContext和事务分支ID来关联上下文。如果你自己手动开了线程池并且子线程不在全局事务的调用链上那么子线程里的操作不会被纳入全局事务照样无法回滚。结合Seata时最稳的做法是保持事务上下文在线程间传递或者直接用Seata官方支持的Dubbo/Spring Cloud调用链尽量避免裸线程池。很多答案说“Seata管多线程”其实不够准确Seata管的是跨服务、跨库的本地事务链靠的是上下文传递不是并发线程本身。我个人的建议是如果你们组内还没有引入分布式事务框架单纯为了一个批量任务去上Seata性价比不高因为会带来运维和性能开销。先尝试上面方案A/B/C的组合大多数场景够用了。5. 从“多线程回滚”升级到“分布式事务”订单与库存一致性5.1 你以为你缺的是回滚其实缺的是“最终一致性”单独说多线程事务其实是有极限的。真正的难点在业务层当一次操作涉及多个微服务、多个数据源你不能靠数据库回滚只能靠设计上的对账与补偿。订单和库存就是最典型、最常被拿来考的组合。一个原则性问题要反复强调订单系统和库存系统绝不可能共享一个数据库事务。所以你的目标不能是“让数据库把所有操作都回滚”而应该是“保证在任何一个环节发生失败后系统能自己修复到一致状态”。常见的有本地消息表、事务消息、TCC、SAGA我把它们放一起对比方案核心思路一致性强度侵入性推荐场景本地消息表业务操作和消息落库放在同一个本地事务里异步发送消息下游消费最终一致中业务和消息中间件没有强原子性要求事务消息RocketMQ消息先half发送本地事务成功后再commit失败则rollback消息最终一致低有RocketMQ基础设施消息不可丢TCC预留/确认/取消三阶段业务做补偿较强一致高需要强隔离性对中间状态敏感SAGA长事务拆成本地事务链失败执行反向操作最终一致中长流程、多步骤、可容忍中间态Seata AT自动生成回滚日志代理数据源拦截SQL较强一致低无法改业务接口、数据库类型支持良好我见过不少团队想用TCC一统天下结果被“预留资源”这一步整得头大因为很多业务接口做不到“预留”这个语义。相反的SAGA和事务消息在实际落地中反而更常见因为它们对业务的扰动小接受“中间状态”的存在。5.2 事务消息到底怎么解决“服务A成功、服务B失败”的问题我用一个简单的例子说明事务消息怎么巧解分布式的“回滚”订单服务创建订单先向RocketMQ发送一条半消息half message此时消息处于不可见状态。订单服务在自己的本地事务里插入订单记录同时插入一条“订单创建消息”关联记录。本地事务成功后RocketMQ确认并commit这条消息下游库存服务订阅到消息后扣减库存。如果库存扣减失败那就走MQ的重试队列或者进入死信队列由对账任务最终发现并补偿。这套方案的核心是你不能回滚已经落库的订单但你可以通过消息机制保证“库存扣减”这个动作一定会被重试直到成功。如果订单要取消那就发一条“取消订单”消息下游把库存加回去。这不就是“回滚”本质上是把回滚动作变成一条新的正向指令通过消息驱动完成补偿。这是我在生产环境里最推荐的一种思路因为消息中间件多数团队都在用不需要额外引入高成本的协调器。5.3 给订单库存跑批的一个实战参考假设有个场景批量关闭超时订单需要同时释放库存。你会怎么做我的做法是分两步走第一步把所有需要关闭的订单ID先查出来放入一个并发队列。 第二步用CompletableFuture并行处理每个订单的子任务每个子任务内部是一个本地事务更新订单状态为“已关闭”同时把库存字段加回去。每个子任务内部自行处理失败重试整体跑完后出一个失败报表由定时任务再次补偿。这样就能保证大部分情况能做对极端情况下通过对账任务发现遗漏最终还是能收敛到一致。这里有一个细节并发更新库存时要用乐观锁或者条件更新比如UPDATE stock SET quantity quantity 1 WHERE id ? AND quantity 0。如果不加条件两个子线程同时给同一款商品加库存可能导致超卖或库存数据错误。别以为回滚能救你有时候问题是并发本身不是事务。6. 环境与工具准备从零搭一个能复现的多线程事务实验纸上谈兵没啥意思我建议你直接搭一个最小项目来复现这些问题亲眼看一次子线程事务不回滚印象才深。6.1 技术栈选择JDK 1.8Spring Boot 2.7MyBatis-Plus 3.5MySQL 5.7/8.0建两个表一个订单表一个库存表用最经典的“创建订单扣库存”去复现。6.2 最小可复现代码RestController RequestMapping(/demo) RequiredArgsConstructor public class DemoController { private final OrderService orderService; GetMapping(/create) public String create() { orderService.createOrderAndStock(); return success; } PostMapping(/async-fail) public String asyncFail() throws Exception { orderService.createByAsync(); return done; } }Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private StockMapper stockMapper; Autowired private TransactionTemplate transactionTemplate; Autowired private AsyncOrderService asyncOrderService; Transactional public void createOrderAndStock() { orderMapper.insert(new Order(1L, 待支付, 100)); stockMapper.deduct(1L, 10); // 模拟异常 if (true) { throw new RuntimeException(test rollback); } } public void createByAsync() { // 主线程开了个异步任务 asyncOrderService.asyncInsert(orderMapper, stockMapper); // 主线程先提交了 } }Service public class AsyncOrderService { Async public void asyncInsert(OrderMapper orderMapper, StockMapper stockMapper) { orderMapper.insert(new Order(2L, 异步待支付, 100)); stockMapper.deduct(2L, 10); throw new RuntimeException(子线程异常); } }当你调用/demo/create时因为异常发生在同一个线程内数据库操作全部回滚订单和库存都不变。当你调用/demo/async-fail时子线程里的订单插入和库存扣减已经提交异常不会导致回滚你会在表里看到那条“待支付”订单和库存已经被扣了。这个实验每次都能复现非常适合用来给团队新人讲“为什么不能异步处理事务”。搭配这篇文章看效果最好。6.3 需要看的日志和关键参数启动项目时开启MyBatis的SQL日志logging: level: com.example.mapper: debug跑/demo/async-fail接口时你会看到两个关键现象子线程的SQL日志输出之后紧接着就有一个Connection的commit日志你再怎么在主线程抛异常也看不到rollback日志。这就是直观的“回滚失效现场”。7. 常见问题排查实录多线程事务回滚的典型火葬场7.1 “我加了Transactional为啥数据没回滚”检查方法是不是被同一个类里的方法调用了。如果是请用AopContext.currentProxy()或者拆到另一个Bean。检查方法是不是private、static或者finalSpring代理无法拦截这些。检查你抛的异常有没有被捕获。Spring默认只对RuntimeException和Error回滚如果catch住了异常并吞掉事务照样提交。检查事务管理器和数据源是不是同一个。多数据源配置下事务可能挂在另一个数据源上。7.2 “子线程抛异常了但主线程还是提交了”因为子线程和主线程是两套独立连接主线程事务只管自己。解决思路把子线程的异常收集到Future.get()里主线程一旦发现异常抛出RuntimeException让主事务回滚。子线程的操作不会回来但至少能减少脏数据量。剩下的交给补偿任务。7.3 “数据库事务日志已满消息 9002”搜索热词里有这个消息我工作中也遇到过。SQL Server场景下事务日志已满绝大多数是因为一个长事务占用日志一直不释放比如你开了个大事务又开了很多子线程连接池和日志一起被拖死。处理方法-- 先查看日志使用情况 DBCC SQLPERF(LOGSPACE); -- 缩小日志文件前提是日志备份或简单恢复模式 DBCC SHRINKFILE(数据库日志文件逻辑名, 目标大小MB);但这句话只能治标。你要治本就得把事务时间压短避免长事务特别是别在多线程环境下让主事务等子线程等太久。日志膨胀的本质是一个事务跨越时间太长Checkpoint无法推进日志无法截断。7.4 “多线程批量插入导致主键重复/内容错乱”优先检查事务隔离级别以及子线程是否真的用了独立连接。检查是否把Connection或者SqlSession共享给了多个线程这种操作极容易出诡异问题。确认连接池配置maximum-pool-size最好不少于并发线程数不然连接请求会排队。7.5 “分布式环境下一个服务成功了另一个失败怎么回滚”不要指望去改数据库了不可能也不应该。最佳路径我上面说了用消息驱动补偿。订单成功、库存失败时要么让库存侧重试要么让订单侧发起“取消订单”最终把账平掉。如果两边都成功了但最终对账发现错误那就再补一个定时对账任务把脏数据清洗掉。7.6 “Kafka消费端多线程如何保证消息顺序性”这个问题的本质和事务回滚相似一旦你开了多线程消费消息在多个线程里处理顺序就不可控事务边界也乱。如果你真的必须用多线程消费且要保持顺序要么按key哈希到固定线程要么把“顺序敏感”的部分收敛到单线程处理其他无状态操作并行。你不可能在乱序的情况下再要求“回滚到某个一致状态”这本身就是矛盾的。8. 给开发者的几条血泪经验写多线程事务更像是做“取舍艺术”而不是技术炫技。我踩过太多坑有几条硬经验值得分享第一事务边界必须与线程边界对齐。要么一个线程管一个事务要么一个事务全程单线程。任何试图打破这条边界的黑魔法都会给你留一堆不确定性的债。第二能不做“跨线程原子回滚”就不做。你以为是回滚实际上是在和数据库死磕。换个思路把“失败后再做补偿”作为架构的直接表达代码会简单可靠得多。第三测试多线程事务必须有故障注入。不要只测正常链路要模拟“线程A成功线程B失败”的半成功场景要模拟下游超时、数据库连接池耗尽、事务日志满这些边界要开并发压测去看最终数据一致性。培养这种测试习惯比记住任何旋钮都有用。第四监控日志里一定要打印事务ID或业务ID。多线程排查时最怕的就是没有关联标识出了Bug根本不知道哪个事务对应哪批数据。我在每个事务入口都加一个业务TraceId线程名也带上批次号出了事一把梭看得清清楚楚。最后再分享一个小技巧如果你在面试中遇到“多线程事务怎么回滚”这个问题别急着背答案。先反问他“你是指同一个应用里的多线程并发处理本地事务还是跨服务的分布式事务”这两个场景的解法完全不同。然后顺着我上面讲的方案分情况作答本地场景用TransactionTemplate控制子线程事务、主线程感知异常分布式场景用事务消息或SAGA做补偿。能这样拆解面试官大概率会认为你是有真实项目经验的。
返回列表