1. 先聊聊我为什么突然想写这篇事务文章事情是这样的前阵子帮一个朋友排查线上订单数据错乱的问题现象很典型用户下单成功订单生成了库存也扣了但积分没加上可订单状态却显示已完成。他第一反应是积分服务挂了结果查了半天日志发现积分接口根本没被调用——不是挂了而是整个方法在前半段就抛异常回滚了可订单表里数据却明明白白躺着。这就很诡异了如果回滚了为什么订单还在如果没回滚为什么积分没加这个问题的答案就是Spring事务的经典坑同一个类里方法自调用事务注解失效。但深入追下去我发现背后牵扯出来的东西远不止这一个坑——自动回滚的触发条件、异常被catch吞掉后事务为什么照样回滚或者为什么没回滚、REQUIRES_NEW怎么实现部分提交、Savepoint有什么用……一圈捋下来我觉得很有必要把这些东西系统整理一遍。这篇文章我不打算照搬官方文档我按一条实际业务线来讲订单创建接口里面依次做三件事——保存订单、扣减库存、发放积分。围绕这条线把自动回滚、手动回滚、部分回滚三种场景全部过一遍。代码都是Spring Boot 2.7 MyBatis-Plus MySQL 8.0的组合你们可以直接照着跑。读完这篇再碰到事务问题你能有自己的排查思路而不是百度完照着抄还抄错。2. 自动回滚默认行为和两个最容易翻车的细节2.1 让事务自己滚先搞明白它默认什么时候滚Spring声明式事务用起来简单在方法上加个Transactional就完事了。但很多人不知道这个注解的默认行为有个大前提只对RuntimeException和Error回滚对受检异常Checked Exception不回滚。什么叫受检异常就是编译器强制你必须处理的那种比如IOException、SQLException。什么叫非受检异常NullPointerException、IllegalArgumentException、自定义的继承RuntimeException的异常这些编译期不强制你处理。我打个比方你在厨房炒菜业务方法突然锅着火了运行时异常正常人第一反应是关火把菜倒了回滚。但如果是发现酱油瓶空了受检异常你顶多喊一声没酱油了菜该炒还是继续炒不回滚。Spring的设计逻辑就是这个受检异常通常代表业务上可预期的情况比如库存不足而运行时异常通常代表不可预期的系统问题所以前者默认提交、后者默认回滚。Service public class OrderService { Transactional public void createOrder(Order order) { orderMapper.insert(order); inventoryMapper.reduceStock(order.getProductId(), order.getCount()); // 这里会抛运行时异常默认自动回滚上面两条SQL都不生效 if (order.getAmount() 0) { throw new RuntimeException(订单金额异常); } } }上面这段代码一旦金额为负抛了RuntimeException订单和库存两条更新都会回滚。这是最理想、最标准的自动回滚场景——不用写任何额外代码Spring的AOP拦截到异常标记当前事务为rollback-only最终连接上执行rollback。2.2 rollbackFor的把戏为什么你加了注解却还在部分提交如果你在方法里抛的是业务异常比如商品售罄Exception但那是个受检异常默认情况下Spring不会回滚。很多人踩的坑就是方法上写了Transactional里面抛了个自定义受检异常结果前一步的SQL已经提交了后一步失败了数据就半残了。修正方式很简单两种// 方式一在注解里明确指定回滚哪些异常 Transactional(rollbackFor Exception.class) public void createOrder(Order order) throws SoldOutException { orderMapper.insert(order); inventoryMapper.reduceStock(order.getProductId(), order.getCount()); // 库存没了抛受检异常因为声明了rollbackFor Exception.class所以照样回滚 throw new SoldOutException(商品已售罄); } // 方式二让自定义异常继承RuntimeException public class SoldOutException extends RuntimeException { public SoldOutException(String message) { super(message); } }我个人的习惯是方式一因为RollbackFor指定为Exception.class最省心不用每次定义新异常都要想着继承哪个父类。但要注意rollbackFor Exception.class意味着任何异常都回滚如果方法里确实存在某些异常抛了但希望前面的数据保留的特殊情况那就要配合下一节的手动回滚策略来处理了。2.3 同一条业务线不同数据源的事务边界怎么划订单创建接口如果只连一个库那上面的自动回滚就够了。但很多公司生产环境是分库的——订单库、库存库各走各的数据源这时候Transactional管不住的。Spring的事务管理器DataSourceTransactionManager只管一个数据源你配了主数据源它就只管理主数据源上的连接。跨数据源想要原子性得引入分布式事务方案比如Seata AT模式这个后面我单独开一节讲。还有一个特别容易忽略的点MySQL的存储引擎。如果哪张表的引擎是MyISAM那不管你怎么加Transactional它都不会回滚——因为MyISAM本身就不支持事务。排查线上问题的时候先确认一下表引擎是不是InnoDB别在事务问题上折腾半天最后发现是表结构的问题。3. 手动回滚异常被Catch掉了你怎么让事务强行滚3.1 手动回滚的灵魂是setRollbackOnly自动回滚确实省心但业务上总有需要手动干预的时候。最常见的一种场景你在事务方法里调第三方接口第三方接口返回了一个错误码没有抛异常或者说你在catch里把它吞了但根据业务规则这个错误码代表整个操作必须全部撤销。代码长这样Transactional(rollbackFor Exception.class) public void createOrderWithExternalCheck(Order order) { orderMapper.insert(order); inventoryMapper.reduceStock(order.getProductId(), order.getCount()); boolean checkResult externalRiskService.check(order.getUserId()); // 第三方接口返回false没抛异常 if (!checkResult) { // 手动标记当前事务为回滚 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } }setRollbackOnly()干的事是把当前事务标记为只能回滚、不能提交。后续无论方法正常结束还是继续执行多少条SQL最终提交的时候事务管理器发现这个标记直接执行rollback之前的插入、扣减全部撤销。这个方法我最早见到的时候觉得挺绕的——为什么不直接throw一个异常呢原因很现实有些代码路径上异常被上层catch住了你要是throw出去上层逻辑就走不到了。但你又确实需要通知事务层这单得撤那就只能通过setRollbackOnly这个后门。3.2 catch住异常后为什么事务照样回滚这背后的UnexpectedRollbackException这个知识点是面试常客也是线上问题高发区。看这段代码Transactional(rollbackFor Exception.class) public void createOrder(Order order) { try { orderMapper.insert(order); inventoryMapper.reduceStock(order.getProductId(), order.getCount()); // 模拟库存不足抛异常 throw new RuntimeException(库存不足); } catch (RuntimeException e) { // 我捕获了异常只是记日志不往外抛 log.error(扣库存失败, e); } }直觉上你会觉得异常被我catch了方法正常返回事务应该提交吧但实际运行的时候你会收到一个异常叫UnexpectedRollbackException意思是事务意外回滚。为什么因为Spring的AOP逻辑是这样的事务拦截器在整个方法执行期间监控事务状态。到commit阶段发现事务已经被标记为rollback-only。这个标记是谁打的是Spring事务底层的TransactionAspectSupport在捕获到异常时打的不是更准确的说是事务内部的资源管理器在检测到异常时把连接标记了。但无论标记来自哪里结果就是你catch住了异常方法看起来正常返回了但事务最终依然回滚了而且Spring还额外给你抛出UnexpectedRollbackException来通知你伙计事务没提交成。这个行为很多人在第一次遇到时会懵明明我没往上抛异常啊怎么程序还是报错了理解了上面这个机制你就明白了——Spring对事务方法内catch住异常这件事的处理不是以你catch没catch为准而是以事务状态为准。想要catch住异常同时事务不滚那要做的不是catch而是让这个异常不触发事务的回滚标记比如在事务边界内做一些特殊处理下一节的部分回滚方案会涉及。3.3 编程式事务TransactionTemplate把控制权握在自己手里声明式事务说白了是AOP代理帮你在方法四周自动包了begin/commit/rollback。你要是嫌这个黑盒不好控制可以用Spring提供的编程式事务模板TransactionTemplate一切手动来清晰直白。Service public class OrderService { Autowired private TransactionTemplate transactionTemplate; public void createOrderByTemplate(Order order) { transactionTemplate.execute(status - { try { orderMapper.insert(order); inventoryMapper.reduceStock(order.getProductId(), order.getCount()); boolean ok externalRiskService.check(order.getUserId()); if (!ok) { // 手动回滚 status.setRollbackOnly(); return null; } return null; } catch (Exception e) { // 手动回滚 status.setRollbackOnly(); throw e; } }); } }TransactionTemplate.execute里的代码会运行在一个事务中回调里你可以通过TransactionStatus.setRollbackOnly()随时决定事务走向。我这个项目里用它的场景是一个方法里需要执行两段相互独立的事务操作比如保存订单和记录操作日志日志表不允许跟着业务回滚声明式事务管不住这种需求模板一把梭很方便。4. 部分回滚让某些操作不受全局事务牵连4.1 需求复盘同一个接口里谁该滚、谁不该滚回到全文的核心——部分回滚。先定义一个真实需求createOrder方法里我要做三件事——保存订单、扣库存、发积分。业务上明确要求保存订单失败全部回滚扣库存失败全部回滚发积分失败订单和库存照常提交积分发放单独记录失败原因下次重试这个需求如果整个方法就一个Transactional那无论如何做不到积分失败但其他提交。因为事务的原子性决定了要么全成、要么全没。要实现这种部分事务成功、部分事务独立的效果核心思路是把允许失败但不影响主体的操作放到独立事务里。4.2 REQUIRES_NEW独立事务各滚各的Spring事务传播行为里有7种平时99%的时间你只会用到REQUIRED但部分回滚这个需求必须请出**REQUIRES_NEW**。REQUIRES的意思如果当前有事务就加入没有就新建默认行为。REQUIRES_NEW的意思不管当前有没有事务都挂起当前事务新开一个独立事务。新事务的commit/rollback不受外层事务影响——外层回滚内层已提交的数据也不会跟着回滚。代码结构上把发积分这个方法抽到另一个Bean里标注Transactional(propagation Propagation.REQUIRES_NEW)Service public class PointService { // 独立事务发积分失败只回滚积分自己不影响外面 Transactional(propagation Propagation.REQUIRES_NEW, rollbackFor Exception.class) public void grantPoints(Long userId, int points) { pointMapper.insert(new PointRecord(userId, points, ORDER)); } }然后在OrderService里这样调用Transactional(rollbackFor Exception.class) public void createOrder(Order order) { orderMapper.insert(order); inventoryMapper.reduceStock(order.getProductId(), order.getCount()); try { pointService.grantPoints(order.getUserId(), 100); } catch (Exception e) { // 积分失败不影响订单和库存的主流程 log.error(发放积分失败稍后重试, e); // 这里可以把失败记录落库方便对账补偿 pointRetryMapper.insert(new PointRetryRecord(order.getUserId(), 100)); } }这里有个隐藏知识点内层REQUIRES_NEW方法抛了受检异常外层能catch住但外层事务不受影响照样提交。因为内层事务在它自己方法出口就提交或回滚了它抛出来的异常只是作为普通Java异常传给了外层外层catch住后不往外抛外层事务就正常提交。从实际运行效果来看这个方案完美满足积分失败但订单要成的需求。但要注意一个副作用REQUIRES_NEW会挂起外层事务如果内层事务执行时间较长外层数据库连接这段时间是持有但闲置的。长事务场景下这会放大连接池压力建议积分这种操作控制好执行时间别让它成为性能瓶颈。4.3 同类自调用失效为什么REQUIRES_NEW放在同一个类里不生效这块是全网问的最多的坑必须单独拎出来讲。如果你图省事把grantPoints写进OrderService这个类里而不是单独的PointServiceService public class OrderService { Transactional(rollbackFor Exception.class) public void createOrder(Order order) { orderMapper.insert(order); inventoryMapper.reduceStock(order.getProductId(), order.getCount()); // 同类调用REQUIRES_NEW不生效 this.grantPoints(order.getUserId(), 100); } Transactional(propagation Propagation.REQUIRES_NEW, rollbackFor Exception.class) public void grantPoints(Long userId, int points) { pointMapper.insert(new PointRecord(userId, points, ORDER)); } }这段代码跑起来积分如果抛了异常订单和库存照样回滚——REQUIRES_NEW完全没生效。原因一句话Spring事务基于AOP动态代理代理对象拦截了方法调用才能开启新事务。但this.grantPoints()是直接调用原始对象的方法绕过了代理注解上的传播行为自然形同虚设。怎么解决三个方案拆到不同的Bean里用Spring注入的代理对象调用我推荐这个代码结构最清晰上文就是这种写法。通过AopContext拿到当前代理再调用需要在启动类或配置里加EnableAspectJAutoProxy(exposeProxy true)Transactional(rollbackFor Exception.class) public void createOrder(Order order) { orderMapper.insert(order); inventoryMapper.reduceStock(order.getProductId(), order.getCount()); // 从AopContext里取当前代理 ((OrderService) AopContext.currentProxy()).grantPoints(order.getUserId(), 100); }注入自身代理Autowired或Lazy注入OrderService自身用注入的实例调用Service public class OrderService { // 注入自己代理 Autowired Lazy private OrderService self; Transactional(rollbackFor Exception.class) public void createOrder(Order order) { // ... self.grantPoints(order.getUserId(), 100); } }方案三有一点要注意Spring的循环依赖处理是有条件的一旦出现构造器注入的循环依赖会直接启动失败。我实际项目里最常用方案一把独立事务的操作封装成独立Service职责也清楚后面做单元测试也方便Mock。方案二和三适合你不想拆类的时候临时用生产环境不建议铺开。4.4 用Savepoint实现同事务内部分回滚的进阶玩法除了REQUIRES_NEW开独立事务Spring还提供了一种更轻量的部分回滚手段——Savepoint保存点。它允许你在事务中间打一个标记之后如果某段操作出问题只回滚到这个保存点而不是整个事务。Spring里怎么用没有直接暴露在Transactional里得通过TransactionTemplate配合Object savepoint status.createSavepoint()来实现Autowired private TransactionTemplate transactionTemplate; public void complexBusiness(Order order) { transactionTemplate.execute(status - { orderMapper.insert(order); // 已经执行不想回滚 Object savepoint status.createSavepoint(); try { // 这段操作如果失败只回滚到savepoint保留上面的insert inventoryMapper.reduceStock(order.getProductId(), order.getCount()); pointService.grantPoints(order.getUserId(), 100); } catch (Exception e) { // 手动回滚到保存点而不是回滚整个事务 status.rollbackToSavepoint(savepoint); log.error(部分操作失败已回滚到保存点, e); } // 其他操作继续... return null; }); }这段代码的效果是如果扣库存或发积分失败前面的订单插入不会丢只把savepoint之后的变更撤销。这在某些历史记录不能丢、主业务可重试的场景下非常实用。不过Savepoint需要数据库支持MySQL InnoDB是支持的但ORACLE和老版本的某些数据库行为会有差异跨数据库前先确认一下。对比一下两个方案维度REQUIRES_NEWSavepoint事务数量两个独立事务同一个事务内标记外层回滚对内层影响内层已提交不回滚全部一起回滚未commit适用场景独立模块解耦积分、日志同一事务内保护性回滚实现复杂度简单拆Service加注解需要TransactionTemplate编码我实际做业务的时候需要积分失败不影响订单这种跨功能的场景用REQUIRES_NEW需要同一个主业务里保护某个前置操作不被滚掉时用Savepoint。两者没有谁更优看场景。5. 事务失效排查从一次线上事故看五个高频根因5.1 事故复盘订单表里躺着不该存在的数据文章开头说的那个朋友遇到的线上问题最终的根因就是同类自调用——他有两段业务逻辑写在同一个Service里外加一层 try-catch 把异常吞了导致Transactional完全没有接管。我帮他排查的时候走了一条很典型的链路你们以后遇到事务问题也可以按这个顺序查第一步确认方法有没有被Spring代理接管。方法所在的类必须是Spring管理的Bean有Service/Component注解并且调用方注入的是Bean对象而不是自己new出来的对象。自己new的类上加注解等于白加这个最基础但也是最容易犯的。第二步看方法是public还是private。Spring官方明确说了Transactional只对public方法生效。为什么因为代理机制默认只能拦截public方法。private方法连被代理覆盖的机会都没有。如果你在面试里听到Spring事务为什么失效的经典八股private方法必居其一。第三步检查异常有没有被吞。方法内部try-catch把异常吃了、不往外抛那AOP拦截器就感知不到异常事务自然判定为正常结束然后提交。这是我见过最多的失效场景。要保留异常又不影响调用方至少得throw new RuntimeException(e)或者按本文第3节方式处理。第四步确认MySQL表引擎。SHOW TABLE STATUS WHERE Name your_table;看Engine列如果是MyISAM万事皆休先改表引擎再讨论事务。第五步检查连接是否参与了事务。如果你在事务方法里手动获取了数据库连接比如DataSourceUtils.getConnection操作了一把又手动设置autoCommit true那会把Spring托管的事务连接搞乱。排查这类问题可以看事务日志SQLServer的话网传的事务日志已满也和这个有一定关联——不是回滚问题而是长事务堆积日志导致磁盘爆满日志查看用DBCC LOGINFO或fn_dblog之类的命令MySQL的话SHOW ENGINE INNODB STATUS看看当前事务状态。5.2 替换掉你的事务不生效百度方案一个完整的可复现测试模板排查失效问题我建议你别在业务代码里盲猜而是写一个最小可复现的测试用例直接验证事务行为。我这边的模板长这样SpringBootTest public class TransactionTest { Autowired private OrderService orderService; Test public void testRollback() { Order order new Order(); order.setUserId(10001L); order.setProductId(20001L); order.setCount(2); order.setAmount(new BigDecimal(199.00)); // 内部会抛异常预期事务回滚 assertThrows(RuntimeException.class, () - orderService.createOrder(order)); // 验证数据库里没有订单数据 Long count orderMapper.selectCount(new QueryWrapperOrder() .eq(user_id, 10001L)); assertEquals(0L, count); } Test public void testPartialCommit() { Order order new Order(); order.setUserId(10002L); order.setProductId(20002L); order.setCount(1); order.setAmount(new BigDecimal(99.00)); // 积分发放失败但订单/库存已提交 orderService.createOrderWithPointFailure(order); Long orderCount orderMapper.selectCount(new QueryWrapperOrder() .eq(user_id, 10002L)); // 订单存在说明确实提交了 assertEquals(1L, orderCount); } }通过这两个测试用例你就能一眼分辨当前实现到底是全量回滚、完全失效还是部分提交。我建议把这套测试用JUnit5写好放进CI里事务这块有了自动化回归以后改代码心里就有底了。6. 进阶认知事务级别、分布式事务和日志查看的实战补课6.1 事务隔离级别别等到脏数据出现在线上才开始学Transactional注解上可以配隔离级别isolation默认是Isolation.DEFAULT在MySQL里对应REPEATABLE_READ。这玩意很多人不爱配置因为觉得默认就行但一旦出现并发写同一行数据隔离级别就不够用了。常用级别我这给个速查表隔离级别脏读不可重复读幻读锁范围READ_UNCOMMITTED可能可能可能行锁读不加锁READ_COMMITTED避免可能可能行锁REPEATABLE_READ避免避免可能InnoDB可避免大部分行锁间隙锁SERIALIZABLE避免避免避免表锁级别性能差我在订单扣库存场景里并发扣减是必须用悲观锁或者乐观锁的。事务隔离级别解决不了超卖问题但可以搭配SELECT ... FOR UPDATE悲观锁或者版本号乐观锁来搞定。具体选择看业务强一致选悲观锁吞吐优先选乐观锁前提是你的业务能接受更新失败的冲突重试。6.2 分布式事务订单与库存跨库时怎么保证一致性热搜词里订单与库存分布式事务出现频率很高我猜你们也在纠结这块。如果是跨数据库实例真正意义上的分布式事务内置的DataSourceTransactionManager管不了。有两个主流方案方案一Seata AT模式。这是国内最常用的方案思路是业务SQL照写Seata框架在背后记录undo_log通过全局事务管理器协调各个分支事务某个分支失败就逆向补偿。入侵性低代码改造量小性能损耗在可接受范围。适合团队规模不大、不想动业务代码的项目。方案二本地消息表 定时任务补偿。核心思想把扣库存和发积分做成最终一致中间通过MQ传递消息积分服务消费消息幂等执行。如果消息没消费成功靠定时任务扫描本地消息表重新投递。这是最稳的柔性事务方案但代码量要比Seata多不少。我的建议超过两个数据源但业务还在单体阶段优先考虑本地消息表MQS这条思路。Seata引入后会带来部署复杂度、全局锁性能开销等一系列问题小项目容易被反噬。真正是微服务架构、接口之间通过RPC互相调用那再考虑Seata或TCC模式。6.3 事务日志查看线上排查事务问题的最后一公里Spring Boot项目里查看事务日志首先要确保开启相关日志级别在application.yml里配logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: debug org.springframework.transaction.interceptor.TransactionInterceptor: debug这样控制台会打印事务的开始、提交、回滚信息比如DEBUG ... TransactionInterceptor : Getting transaction for [OrderService.createOrder] DEBUG ... DataSourceTransactionManager : Creating new transaction with name [OrderService.createOrder] DEBUG ... DataSourceTransactionManager : Participating in existing transaction DEBUG ... DataSourceTransactionManager : Initiating transaction commit排查到底有没有回滚最快的方法就是这个比在代码里打断点还快。日志里看到Rolling back字样就说明事务确实回滚了。看到Committing说明提交了。到数据库层面SQLServer的事务日志查看可以用DBCC OPENTRAN看最早的活动事务用fn_dblog看日志记录明细而网上常常报的数据库的事务日志已满这个错误本质是日志文件增长到了配置上限且无法自动增长常见原因是某个长事务一直没提交导致日志无法截断。处理方式不是直接收缩日志那只是治标而是找到并处理掉那个卡住的长事务。MySQL的话通过information_schema.innodb_trx表可以查到当前正在运行的事务和它们持有的锁。7. 收尾我踩过这么多坑之后的个人体会写到这里我最后分享一个自己摸索了很久才养成的习惯。每次给方法加Transactional之前先问自己三个问题这个方法是public吗调用方注入的是Spring代理对象吗方法内部会不会有异常被吞掉如果三个答案都是肯定的你才能放心地把事务交给Spring。这三个问题过滤掉了80%的线上事务事故。另外一个我从实际项目里总结出来的小技巧事务方法尽量保持瘦身。里面别做远程调用、别做大量耗时的计算、别做循环里嵌套操作数据库。事务期间数据库连接是独占的长事务会把连接池占满拖垮整个应用。真要处理耗时逻辑把数据查出来放到事务外计算再把结果放回事务里做更新。这是我见过很多高性能项目都在用的模式。最后说一下扩展方向本文说的部分回滚主要还是单机事务范畴如果你们公司业务已经拆了微服务订单、库存、积分分别在三个服务里那事务问题的形态就变成了跨服务数据一致性这时候需要去了解Seata的AT/TCC模式或者彻底转向最终一致性方案。先把本文里的基本功练扎实再往分布式方向走你会发现很多概念是共通的。