Spring事务失效的8种场景全解析:从自调用到异常捕获,原理与排查实践

发布时间:2026/9/24 21:24:58
Spring事务失效的8种场景全解析:从自调用到异常捕获,原理与排查实践 上周代码评审一个同事指着自己的Service方法问我“我明明加了Transactional结果接口报错了数据却还是进去了事务根本没用啊。”我扫了一眼代码——方法写在Service类里是public没有被catch表是InnoDB配置看起来也没问题。最后定位到问题根源他在同一个类里的另一个方法里直接调用了这个带注解的方法也就是所谓的“自调用”。事务在那一瞬间就已经注定失效了。这个例子特别典型因为它代表了一个普遍认知误区很多人把Transactional当成一个“开关”以为只要方法上挂了注解事务就一定生效。但Spring声明式事务的底牌是AOP动态代理不是“魔法”。不搞懂这条主线今天你避开这8个场景明天还会踩第9个坑。这篇文章我直接按场景拆每个场景都讲清楚三件事它为什么会失效、底层机制是什么、怎么改才是对的。文章偏实战适合正在用Spring Boot写业务、以及面试前想彻底搞懂事务失效原理的同学。1. 先拆掉底牌声明式事务靠的是AOP代理不是注解魔法1.1 数据库事务原本是什么样先回到最底层。在JDBC操作里一段事务代码长这样Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); // 执行SQL preparedStatement.executeUpdate(); // 其他SQL conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.close(); }核心就三件事关掉自动提交、执行SQL、最后commit或rollback。数据库事务本身并没有多神秘它就是围绕Connection做的状态管理。1.2 Spring把“事务”翻译成环绕通知Spring做的事情是把上面这段样板代码抽出来做成一个统一的处理逻辑。当你在方法上加了TransactionalSpring会为这个Bean生成一个代理对象。代理对象在执行你的业务方法之前先开启事务业务方法正常return就commit方法抛异常就rollback。这个逻辑在我的理解里就是一个加了事务逻辑的AOP环绕通知。链路是这样的外部调用者 - 代理对象 - 开启事务 - 执行业务方法 - 正常/异常 - commit/rollback如果外部调用者拿到的不是代理对象那所有这些“开启事务、回滚”的逻辑就全部不存在。这就是绝大多数事务失效问题的总根源。1.3 代理对象和原始对象到底差在哪里Spring容器里保存的Bean默认是一个代理对象而不是你写的那个类的原始实例。当你调用bean.someMethod()时执行的是代理对象里被增强过的方法。判断一个Bean是不是代理对象最简单的办法是在事务方法里打个断点然后看this.getClass()。如果是类似com.sun.proxy.$Proxy121JDK动态代理或者com.demo.service.OrderService$$EnhancerBySpringCGLIB$$xxxCGLIB代理说明是代理类如果就是OrderService本身说明这个Bean根本没有被代理事务必然不生效。Spring Boot 2.x之后默认使用CGLIB代理不管类有没有实现接口都会生成代理子类。Spring Framework老项目里如果类实现了接口默认走JDK动态代理只能代理接口方法。这个差异在排查问题时偶尔会碰到后面我会细说。2. 八种失效场景逐个拆给你看一调用链问题2.1 场景一同一个类里自己调自己绕过了代理这是我在实际项目里见过最多的坑没有之一。看这段代码Service public class OrderService { public void order() { // 业务逻辑校验、计算价格等 this.createOrder(); // 直接调用本类方法 } Transactional public void createOrder() { // 写订单表、扣库存、扣余额 } }order()方法里通过this调用了createOrder()。这个this是谁是原始对象不是代理对象。因为Spring的代理机制只对“从外部进入Bean的调用”生效内部方法之间通过this调用是绕过了代理的。英文里这叫self-invocation中文社区一般叫“自调用”或“同类内部调用”是事务失效场景里的头号种子选手。解决办法有三种我按推荐程度排序拆分到另一个Service最推荐职责也清晰Service public class OrderService { Autowired private OrderCreateService orderCreateService; public void order() { orderCreateService.createOrder(); } }注入自身代理对象Service public class OrderService { Autowired private OrderService self; public void order() { self.createOrder(); // 调用的是代理对象 } }注意这里自己注入自己会有循环依赖Spring Boot 2.6默认禁止循环依赖建议加Lazy或者干脆用第一种方法。使用AopContext暴露的代理对象public void order() { ((OrderService) AopContext.currentProxy()).createOrder(); }使用这种方式要在启动类上配置EnableAspectJAutoProxy(exposeProxy true)代码里也有点“魔法味道”我一般不太推荐。2.2 场景二方法不是public官方明确不支持Transactional注解官方文档明确写着When using proxies, you should apply the Transactional annotation only to methods with public visibility.翻译过来就是利用代理实现事务时只支持public方法。如果你把注解加在private、protected或者默认包可见性的方法上事务不会生效。这是由代理机制决定的。JDK动态代理是基于接口CGLIB是基于类继承生成子类都会受限于Java访问控制。Spring在创建代理时会判断目标方法是否可被代理非public方法不会进入事务增强逻辑。还有个细节值得注意如果方法虽然是public的但类本身是final的那么CGLIB无法生成子类如果方法是final的CGLIB无法重写这个方法。这两种情况下代理都无法增强事务也会静默失效。IDE其实已经能帮你发现问题在IDEA或STS里把一个非public方法加上TransactionalIDE会给出警告“Method annotated with Transactional is not public...”只是很多人没注意。2.3 场景三final方法或static方法代理根本增强不了这个场景和上一个密切相关我单独拎出来说。CGLIB是通过生成目标类的子类来实现代理的子类必须能重写父类方法。但如果方法被final修饰子类没法重写如果类被final修饰连子类都生成不了。static方法更不用说了它属于类而不是实例代理机制完全管不到。实际代码中的表现是你在一个static方法上加Transactional编译器不报错运行时不报错但它就是什么都没发生。数据照样写进去异常照样不会回滚。还有一点容易忽略当你把一个类标注为final时Spring Boot 2.x默认的CGLIB代理方式会直接启动报错比如无法生成代理子类但方法级final不会报错它只是“静默失效”。我自己的经验是在Service层基本不需要写final方法或static方法如果确实有工具类的需求把方法放到独立的Util类里不要和事务方法混在一起。3. 八种失效场景逐个拆给你看二异常传播问题3.1 场景四try-catch把异常吞了事务压根不知道再看一个在真实项目里反复出现的写法Transactional public void createOrder() { try { orderDao.insert(order); stockService.deduct(); throw new RuntimeException(库存不足); } catch (Exception e) { log.error(下单失败, e); // 没有重新抛出 } }这段代码的结果是订单写进去了库存扣了然后一切被当作“正常流程”提交了。因为你把异常捕获后没有重新抛出Spring事务的环绕通知根本感知不到方法内部发生了什么它看到的返回值是正常的自然就commit了。很多同事和我讨论这个问题时都会说“我明明在catch里打了日志知道出错了为什么没回滚”原因就是你没把异常还给框架。修复的核心原则只有一个让异常能够从这个方法抛出去。有两种修法捕获后重新抛出RuntimeExceptioncatch (Exception e) { log.error(下单失败, e); throw new RuntimeException(e); }手动把事务标记为回滚catch (Exception e) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); log.error(下单失败, e); }第二种方式适合“我确实需要捕获异常做一些事情但又不希望事务提交”的场景。注意setRollbackOnly之后如果方法最后继续抛出异常没问题如果没抛异常事务也会回滚。3.2 场景五抛的是checked异常默认不回滚这个问题在面试里被问烂了但业务代码里还是会反复踩。Spring默认的事务回滚规则是只对RuntimeException运行时异常和Error进行回滚对checked异常受检异常不回滚。所以下面这段代码并不会回滚Transactional public void createOrder() throws Exception { orderDao.insert(order); throw new Exception(库存不足); // checked异常默认不回滚 }Spring的默认行为从设计初衷上看是认为“受检异常代表一种可以被业务处理的预期情况比如余额不足、库存为0这些可能希望流程继续或者做补偿操作而不是直接回滚已执行的SQL”。但这种设计在国内的业务系统里经常被误解绝大多数场景下我们就是希望所有异常都能回滚。正确写法是显式指定回滚类型Transactional(rollbackFor Exception.class) public void createOrder() throws Exception { orderDao.insert(order); throw new Exception(库存不足); }更推荐的做法是开启事务时一行rollbackFor写全Transactional(rollbackFor Exception.class)这样无论RuntimeException还是checked异常只要方法没接住统统回滚。3.3 场景六传播行为配置不当事务被“挂起”或被忽略Spring事务传播行为是另一个经常被配置错的点。它描述的是当一个事务方法去调用另一个事务方法时这个新方法该怎么处理事务。默认的传播行为是REQUIRED含义是如果当前已经有事务就加入没有就新建一个。这个默认值在绝大多数场景下是对的问题往往出在有人“主动”修改了传播行为。下面这张表是几个最常用的传播行为传播行为含义事务是否生效REQUIRED默认有事务就加入没有就新建正常REQUIRES_NEW挂起当前事务无论有没有都新建一个新方法独立事务SUPPORTS有事务就加入没事务就以非事务方式执行可能失效NOT_SUPPORTED以非事务方式执行有事务则挂起事务被忽略NEVER必须以非事务方式执行有事务则抛异常事务被抛异常打断NESTED有事务则嵌套事务没有则新建嵌套回滚点前一段时间遇到一个业务场景同事在一个大事务方法里调用了一个子方法为了“独立”把子方法配成了REQUIRES_NEW。结果子方法抛异常后只有子方法自己的数据回滚了外层大事务之前做的其他操作却提交了。业务上看就是“部分成功”找了大半天。这类问题的排查思路是确认你配的传播行为到底是你真正想要的。如果只是为了让事务生效用默认REQUIRED就行不要随手改。4. 八种失效场景逐个拆给你看三环境与隔离问题4.1 场景七数据库引擎不支持事务这个场景在现代化项目里已经比较少见但在老系统里仍然能遇到。MySQL的MyISAM引擎根本不支持事务。你加不加Transactional都不会有回滚。判断方法很简单SHOW TABLE STATUS WHERE Name user; -- 看Engine字段是MyISAM还是InnoDB或者直接看建表语句SHOW CREATE TABLE user;如果Engine是MyISAM解决办法就是把表转换成InnoDBALTER TABLE user ENGINE InnoDB;还有一个容易被忽略的边界MySQL的DDL语句比如CREATE、ALTER、DROP、TRUNCATE本身不支持事务就算你在事务方法里写了这些操作一旦执行就无法回滚。这不是Spring的问题是数据库层面的能力边界。代码里如果事务里混有DDL不要对它抱有任何“出错了能回滚”的期望。4.2 场景八多线程环境下事务被拆散Spring事务和线程是强绑定的。底层实现里事务上下文就是那个Connection是放在ThreadLocal里的。这意味着主线程开启的事务子线程拿不到子线程里执行的数据库操作天然不在主线程事务的保护范围内。典型的错误代码Transactional public void createOrder() { orderDao.insert(order); new Thread(() - { stockService.deduct(); // 这里不在主事务里 }).start(); // 主事务回滚了子线程操作不会回滚 }这种问题在异步化改造、消息推送、报表导出场景里很容易出现。你说它“事务失效”吧主线程的事务其实是生效的但子线程里数据没被保护从业务结果上看就是“回滚不完整”。正确的姿势是要么把子线程里的数据库操作移回主线程执行要么让子线程自己开启独立事务比如在子线程里单独注入一个Service方法配REQUIRED自己开事务。如果是Spring Async异步方法同样要注意异步线程和调用线程之间没有任何事务传递关系。4.3 配置层面的“伪失效”事务管理器没配对严格来说这不算“事务失效”但我在排查中经常需要确认这一点所以放在一起说。Spring Boot里如果你只配置了一个数据源DataSourcTransactionManager会被自动配置Transactional直接生效。但在多数据源项目里每个数据源需要对应独立的事务管理器Transactional默认按名字查找如果没找到就会按类型匹配类型有多个时就可能选错。如果事务配置不对现象通常是某个方法加了Transactional完全没反应但其他方法又是好的。多数据源下的正确写法是Transactional(transactionManager orderTransactionManager) public void createOrder() { // ... }需要先声明两个事务管理器Bean public PlatformTransactionManager orderTransactionManager(Qualifier(orderDataSource) DataSource ds) { return new DataSourceTransactionManager(ds); } Bean public PlatformTransactionManager userTransactionManager(Qualifier(userDataSource) DataSource ds) { return new DataSourceTransactionManager(ds); }还有一类“伪失效”来自老Spring项目如果项目是纯Spring MVC没有开启tx:annotation-driven/或EnableTransactionManagement那么Transactional也是不生效的。Spring Boot因为自动配置了这个问题不常见但排查事务问题时值得花30秒确认一下。5. 事务失效的排查方法论5分钟定位问题5.1 一个能证明事务是否生效的最小Demo遇到“事务不生效”的投诉我第一件事不是改代码而是写一个最小Demo去验证当前环境到底有没有问题。下面这个类可以直接扔进项目里跑Service public class TxCheckService { Autowired private JdbcTemplate jdbcTemplate; Transactional(rollbackFor Exception.class) public void shouldRollback() { jdbcTemplate.update(insert into tx_demo(name) values (?), rollback-test); throw new RuntimeException(故意失败看数据是否回滚); } }调用这个方法后去数据库查SELECT * FROM tx_demo WHERE name rollback-test;如果查不到记录说明事务基础能力正常如果记录存在说明事务并没有生效问题一定出在“代理未开启”“方法调用链异常”“数据源配置有问题”中的某一个环节。5.2 排查链路从环境到代理到异常我把平时排查“事务回滚失败”的检查顺序列成了下面这个清单按这个顺序操作90%的问题能在五分钟内定位。确认表引擎查SHOW TABLE STATUSEngine是不是InnoDB。MyISAM直接排除。确认Bean被Spring管理类上有没有Service/Component没加的话Spring容器里压根没有这个Bean代理更不存在。确认方法可见性和修饰符方法是不是public、是不是final、是不是static。确认调用链路是不是同类内this调用或通过final/static方法间接调了事务方法。确认异常真的抛出去了内部有没有catch包住不抛有没有在catch里吞掉。确认异常类型如果抛的是Exception有没有配rollbackFor。确认事务管理器配置多数据源时transactionManager有没有指定对。确认代理方式如果应用里配置了Spring AOP和切面也可能影响代理方式这一步看启动日志或断点判断。这8步做完剩下的就是一些冷门边界问题比如自定义了拦截器提前提交事务、代理对象类型与注入类型不匹配等这些属于少数情况可以单独深入排查。5.3 调试技巧看一眼案发现场排查过程中最有用的一个调试技巧是——在事务方法的第一行打一个断点然后看this.getClass()的运行时类型。如果是xxx$$EnhancerBySpringCGLIB$$xxx说明这是CGLIB代理代理链路正常。如果是com.sun.proxy.$Proxyxxx说明是JDK动态代理正常。如果this就是你的类本身比如OrderService1234说明这个Bean根本没有被代理接下来要查调用方拿到的到底是不是Spring容器里的Bean。另一个有用的角度是看调用栈。在事务方法内打一个断点查看当前线程的调用栈如果能看到TransactionInterceptor或TransactionAspectSupport.invokeWithinTransaction说明代理逻辑已经介入看不到的话说明事务根本没有插进来。6. 工程实践让事务在真实项目里“耐用”6.1 事务该放哪一层事务最好只放在Service层理由有三个。第一Controller层是处理请求和参数的地方不该关心事务细节。第二DAO层的方法粒度太细单条SQL操作本身就带自己的锁和提交行为在DAO层加事务很容易导致事务范围过窄真正需要原子性的业务操作没被覆盖。第三事务放在Service层能够让“业务操作”与“事务边界”保持一致一个用例一个事务。正确的分层习惯是Controller接收参数、做参数校验Service负责业务编排和事务控制DAO只做数据读写。不要把事务注解加到DAO层更不要加到Controller上。6.2 事务粒度不要写大事务一个真实案例同事负责的导入功能一次导入一万条数据每条数据要检查、转换、写入、更新关联表。他在导入方法上直接加了Transactional结果压测时数据库连接池被打爆了。原因是一个事务内执行了一万条数据的几十次SQLConnection一直被占用事务时间太长连接池里的连接都被占满后续请求全部排队。这里有两个建议能缩小事务范围就缩小。不需要在同一个事务里的操作比如远程调用、文件解析、网络IO移到事务外。大批量数据不要“循环内一条条开事务”也不要“所有数据共享一个大事务”。更好的方式是分批处理每批一个事务失败后记录失败明细。还有一类代码要注意和自调用是“兄弟”问题public void batchCreate(ListOrder orders) { for (Order order : orders) { this.createOrder(order); // 自调用createOrder上的事务全部失效 } }解决方案是把createOrder拆分到另一个Service。拆分之后要意识到现在是每循环一次都开一个新事务。如果数据量大可以考虑自己控制批量边界比如每100条作为一个事务批次这样性能和可靠性可以兼顾。6.3 事务和锁的配合事务里如果要用悲观锁SELECT ... FOR UPDATE或乐观锁version字段要注意事务边界和锁释放时机。悲观锁依赖事务提交或回滚来释放锁所以锁定的查询必须和后续的更新操作放在同一个事务里否则锁被提前释放或者根本不会释放。乐观锁则要确保更新时带上版本号条件否则update影响行数为0时要知道是冲突了。在实际项目里事务锁最容易出现的错误是“查了没锁更新时却发现已经被别人改过了”或者“锁的范围太大拖慢整个表”。这两种问题不在事务失效的范畴但排查事务问题时经常被一起提出来这里提醒一句。6.4 事务与异步、多线程的边界前面聊过Spring事务绑定在当前线程Async方法、new Thread手动创建的线程都拿不到调用方的事务。真实项目里更容易出问题的是在事务方法里发了MQ消息事务方法执行完后MQ的消费方立刻去查数据库。如果事务还没提交消费方可能查不到数据。这不是事务失效而是事务和消息不在同一个“一致性边界”里。解决方案通常是把消息发送放到事务提交后比如Spring的事务同步器TransactionSynchronizationManager.registerSynchronization在afterCommit里再发送消息。类似的边界还有事务方法里做了缓存更新如果事务回滚了但缓存已经更新了就会出现数据不一致。处理办法是保证缓存更新也放在事务提交后执行或者接受最终一致性的方案。7. 给新人的Final建议把八种失效场景和背后的原理重新过一遍你会发现真正需要记的东西其实就两条第一调用者拿到的Bean到底是不是代理对象第二异常有没有正确传播到代理的环绕通知里。这两条理解了事务失效问题可以覆盖90%以上。我自己的习惯是写完一个带事务的方法后顺手做三件事确认方法是不是public、确认有没有同类自调用、确认异常有没有被吞掉。这个习惯帮我提前拦截了不少上线后才暴露的问题。如果项目里用了MyBatis-Plus、JPA这类框架事务注解的用法基本一致但它们的一些内部方法调用比如同一Mapper里方法互调也可能踩到类似的坑排查思路是一样的。最后如果你还在看这篇文章大概率已经被事务失效折腾过几次了。没关系这个坑不是只有你踩过。把原理吃透把排查流程记牢后面再遇到事务问题你会感谢今天花时间的自己。