尧图精选

Spring事务失效排查指南:@Transactional为何没有回滚

🕒 发布时间:2026/10/1 4:51:36 📁 来源:尧图网络
三年前我处理过一个相当典型的线上问题用户订单支付成功订单也正常生成了但扣减库存那一步失败结果库存还是被扣了造成超卖。我翻代码翻了好久关键方法上明明加了Transactional事务注解数据库操作看着也在同一个方法里为什么数据就回滚不了后来才明白事务注解失效这件事原因可以五花八门但绝大多数时候都和“这个事务到底有没有被代理拦截到”直接相关。这篇文章就围绕Transactional事务失效的几类常见原因把我踩过的坑、排查过的路径和最终验证手段全部摊开讲清楚希望能帮被同类问题折磨过的人节省排查时间。1. 自调用与内部this引用事务注解失效的第一大杀手1.1 为什么会发生同类自调用失效这是我踩的第一个坑也是日常开发中出现频率最高的失效场景。直接看代码Service public class OrderService { public void createOrder(OrderDTO dto) { // 业务逻辑生成订单 saveOrder(dto); // 后续扣减库存 deductStock(dto.getSkuIds()); } Transactional(rollbackFor Exception.class) public void saveOrder(OrderDTO dto) { orderMapper.insert(dto); if (dto.getSkuIds() null) { throw new RuntimeException(参数异常回滚); } } }表面上看起来没问题saveOrder有事务注解抛异常应该回滚。但实际测试下来orderMapper.insert(dto)的插入操作没有回滚数据照样进了库。原因在于Spring事务是基于AOP动态代理实现的。Spring容器启动时会为加了Transactional方法的Bean生成一个代理对象事务拦截器挂在代理对象上。外部调用方注入的其实是代理对象调用createOrder也是先经过代理对象。但当createOrder内部通过this.saveOrder(dto)调用时这里的this指向的是原始的目标对象根本不是代理对象。代理没机会介入事务拦截器自然也就不会执行注解就形同虚设了。1.2 怎么确认是不是自调用问题确认方式很简单。在createOrder方法和saveOrder方法里分别加日志打印当前对象类型System.out.println(this.getClass().getName());如果打印出来是com.example.service.OrderService而不是com.example.service.OrderService$$EnhancerBySpringCGLIB$$...说明当前执行的确实是原始对象不是代理对象事务拦截器根本没挂上来。还有一种方式在saveOrder方法里调用TransactionSynchronizationManager.isActualTransactionActive()判断当前线程是否处于事务状态。返回false基本就坐实了自调用导致的事务失效。1.3 自调用问题的解决方案解决方案有几种我按推荐程度排个序。第一种拆Bean把事务方法挪到另一个Service里。Service public class OrderService { Autowired private StockService stockService; public void createOrder(OrderDTO dto) { saveOrder(dto); stockService.deductStock(dto.getSkuIds()); } Transactional(rollbackFor Exception.class) public void saveOrder(OrderDTO dto) { orderMapper.insert(dto); } } Service public class StockService { Transactional(rollbackFor Exception.class) public void deductStock(ListLong skuIds) { stockMapper.deduct(skuIds); } }让事务方法所在的类被独立注入这样调用方的引用就是代理对象事务能生效。这也是最清晰、符合Spring设计习惯的做法。第二种把方法注入自身用注入的引用去调。Service public class OrderService { Autowired Lazy private OrderService self; public void createOrder(OrderDTO dto) { self.saveOrder(dto); } Transactional(rollbackFor Exception.class) public void saveOrder(OrderDTO dto) { orderMapper.insert(dto); } }用Lazy注入是为了避免循环依赖带来的启动问题。这种方式在不想拆类的时候比较实用但坦白说团队里如果代码风格不统一很容易越写越乱我一般只把它当兜底方案。第三种放弃注解改用编程式事务。Service public class OrderService { Autowired private TransactionTemplate transactionTemplate; public void createOrder(OrderDTO dto) { transactionTemplate.execute(status - { orderMapper.insert(dto); deductStock(dto.getSkuIds()); return null; }); } }编程式事务TransactionTemplate的好处是事务边界完全由代码控制不存在自调用失效的问题比较适合事务逻辑集中在某个方法内部的场景。缺点是事务代码和业务代码耦合在一起可读性稍差而且团队成员要理解TransactionTemplate的用法有一定学习成本。2. 异常处理不当受检异常、被吞异常与回滚策略的真实边界2.1 受检异常没有导致回滚这是最常见的误解很多人以为“方法抛了异常事务就会回滚”但Spring事务默认的回滚规则里只有在抛出RuntimeException或Error时才回滚受检异常checked exception默认不回滚。Transactional public void createOrder(OrderDTO dto) throws IOException { orderMapper.insert(dto); if (dto.getCouponId() ! null) { throw new IOException(模拟IO异常); } }在这个例子里orderMapper.insert(dto)已经执行成功然后抛出IOException。因为没有显示配置rollbackForSpring只会把RuntimeException和Error视为回滚条件IOException是受检异常事务会正常提交订单数据照样入库。这类问题在调用第三方HTTP接口、文件操作、解析外部报文时特别容易踩中。我见过一个case某定时任务读取文件后批量写入数据库写库过程中文件解析抛了ParseException结果一部分数据入库了一部分没入数据对不上排查了大半天才定位到是受检异常不触发回滚。2.2 异常被try-catch吞掉事务默默提交这个比受检异常更隐蔽。很多业务代码里开发者在事务方法内为了不影响后续逻辑把异常拦截吞掉Transactional(rollbackFor Exception.class) public void refund(RefundDTO dto) { try { refundService.doRefund(dto); } catch (Exception e) { log.error(退款失败记录日志, e); } // 后面的代码继续执行 orderService.updateRefundStatus(dto.getOrderId()); }refundService.doRefund抛了异常但被catch住只打日志事务拦截器根本没收到异常信号最终事务正常提交。updateRefundStatus也跟着一起提交了。如果业务要求“退款失败时整个事务回滚”这种写法必然出问题。2.3 正确配置rollbackFor与异常处理策略事务注解的最稳妥写法是显式指定回滚条件Transactional(rollbackFor Exception.class)这表示遇到任何Exception都回滚。更进一步团队应该约定一个规范事务方法内尽量不要自己catch异常并吞掉让异常冒泡给Spring事务拦截器处理。如果确实需要记录日志后继续要么把这段操作拆出事务方法要么catch后重新抛出RuntimeException确保事务感知到异常。自定义业务异常统一继承RuntimeException不要继承Exception。public class BizException extends RuntimeException { public BizException(String message) { super(message); } }顺手提一下noRollbackFor。它表示“即使出现某些异常也不回滚”。比如一个方法里既有写库操作又有发送短信操作短信发送失败不影响订单保存就可以在rollbackFor覆盖Exception的同时给短信异常配置noRollbackFor。注意这和捕获异常的语义不一样捕获异常会吞掉异常信号而noRollbackFor是让异常继续上抛但事务照常提交适合“操作失败但需要事务保留”的场景。3. 方法修饰符与Bean边界private、static、final和new出来的对象3.1 private方法加事务注解本质上是无效的Spring Boot默认使用CGLIB代理也就是通过生成目标类的子类来创建代理对象。代理逻辑依赖方法的重写和拦载而private方法不会被子类继承和重写所以Spring在解析注解的时候对private方法上的Transactional根本不会生成事务拦截逻辑。即使你非常确定自调用不会发生private方法加注解也是白加。我之前见过有人把private辅助方法上标了Transactional还奇怪为什么事务不生效。记住一个结论Transactional只对可以被代理的外部调用方法有效最稳的方法可见性就是public。如果设计上确实需要把某个子逻辑模块化就单独建一个Bean而不是怼一个private方法。3.2 static与final方法为什么也不行static方法属于类本身不经过实例对象派发事务拦截器拿不到实例自然无法生效。很多人把工具类里的static方法加上Transactional想实现“静态方法里操作数据库也事务化”这是走不通的。final方法的问题在于CGLIB生成子类时无法重写final方法事务拦截逻辑挂不上去。虽然Spring在解析final方法时一般会直接忽略事务注解并打警告日志但这类代码一旦上线排查成本比较高。3.3 new出来的对象与容器Bean的边界Spring管理的是容器里的Bean。如果直接new一个类出来这个对象完全不在Spring容器的管辖范围内事务注解当然不会生效。public class RefundService { Transactional(rollbackFor Exception.class) public void refund() { ... } } // 错误用法自己new出来的对象 RefundService service new RefundService(); service.refund();这种写法常见于一些没有完全交给Spring管理的代码里比如自己写的定时任务类没有加Component或Service然后在内部直接new依赖去调用又比如某个类里为了省事直接new了一个Mapper去操作数据库。不仅事务失效连依赖注入也是空白的。解决办法很简单把类交给Spring管理依赖用Autowired注入。3.4 一个容易被忽略的误导IDE警告其实IDE对private方法标Transactional是有警告提示的比如“Method annotated with Transactional is not eligible for Spring Data JPA”之类。但实际开发中很多人看到警告也不当回事或者IDE配置了隐藏部分提示导致问题线上才暴露。我的习惯是所有事务方法写完后先看类名是不是代理类这个方法能不能从外部被调用再往下写业务。4. 事务传播行为与多线程拆散事务边界4.1 REQUIRES_NEW带来的假象子事务失败外层却提交了事务传播行为是Spring事务非常核心的机制很多事务失效的案例表面上看是“回滚失败”实际是传播行为没有理解对。Service public class OrderService { Transactional(rollbackFor Exception.class) public void createOrder(OrderDTO dto) { orderMapper.insert(dto); couponService.useCoupon(dto.getCouponId()); } } Service public class CouponService { Transactional(propagation Propagation.REQUIRES_NEW, rollbackFor Exception.class) public void useCoupon(Long couponId) { couponMapper.consume(couponId); throw new RuntimeException(优惠券使用失败); } }couponService.useCoupon抛了异常但REQUIRES_NEW会挂起外层事务自己独立开启一个新事务。这个新事务执行失败会回滚自己不影响外层事务。外层orderMapper.insert(dto)照常提交。很多人测试时发现“我明明抛异常了为什么订单还是入库了”然后怀疑事务失效——其实不是失效而是传播行为决定了这个结果。如果你的业务要求“优惠券使用失败订单也要回滚”就不要用REQUIRES_NEW用默认的REQUIRED让子方法加入外层事务反过来如果业务要求“子任务失败不能拖累主流程”才选择REQUIRES_NEW。顺带提一下NESTED。它和REQUIRES_NEW不一样NESTED是嵌套事务基于数据库的savepoint实现。外层事务的提交或回滚会连带嵌套事务一起处理但嵌套事务的回滚不会影响外层事务已完成的操作。某些复杂的批处理场景里可以用NESTED做部分模块回滚的兜底。4.2 多线程环境下事务上下文根本不会传递Spring事务管理器把事务信息保存在ThreadLocal里。也就是说事务和线程是绑定的。一旦代码里开了新线程新线程里的数据库操作会拿到新的数据库连接和主线程里的事务完全没有关系。一个典型场景Transactional(rollbackFor Exception.class) public void batchProcess(ListLong userIds) { userIds.parallelStream().forEach(userId - { // 这里是另一个线程执行 userService.updateUser(userId); }); }主线程里batchProcess虽然开了事务但parallelStream提交的任务在ForkJoinPool的线程中执行每个子线程拿到的连接不属于主事务。如果某一条updateUser失败主线程事务不会感知也不会回滚其他已完成的操作。正确的做法是把事务边界放进去让每个子任务的执行方法自己带事务。public void batchProcess(ListLong userIds) { userIds.parallelStream().forEach(userId - { try { userService.updateUserInTransaction(userId); } catch (Exception e) { log.error(update failed, userId{}, userId, e); } }); } Service public class UserService { Transactional(rollbackFor Exception.class) public void updateUserInTransaction(Long userId) { userMapper.updateByUserId(userId); } }这样每个子任务在各自线程里独立开事务失败回滚自己的不影响其他任务。虽然语义和“全量事务”不同但多线程场景下本来就不适合也不应该强求共享一个大事务。批处理任务的产出结论、错误记录、失败重试这些都可以单独落库不要塞进同一个事务里。4.3 Async配合Transactional一起用会怎么发展Async的原理也是通过代理实现的方法执行被提交到线程池。如果同一个方法上既标注了Async又标注了Transactional事务是在异步执行的线程里创建的。也就是说异步方法自己的数据库操作是有事务的但它与调用方的操作不在同一事务里。Async Transactional(rollbackFor Exception.class) public void asyncProcess() { // 这里的数据库操作在独立事务中执行 }这个点经常被误解有人以为调用方事务会覆盖异步方法的事务导致异步方法失败也回滚调用方的数据。实际上这两个事务相互独立。如果确实需要强一致性就要重新设计方案比如本地消息表加定时任务而不是把所有操作塞进一个注解里。5. 底层设施拖后腿不支持事务的引擎与错误的事务管理器配置5.1 MyISAM等存储引擎根本不支持事务Transactional注解只是告诉Spring“这个方法应该运行在事务中”但底层数据库要不要事务、能不能事务取决于存储引擎。MySQL常见的存储引擎中InnoDB支持事务MyISAM不支持。如果表结构是历史遗留项目里用MyISAM建的那么不管Service层的事务注解写得多规范数据操作都不会有原子性。一条SQL成功、下一条SQL失败前面的不会回滚。检查一张表用的什么引擎SHOW TABLE STATUS LIKE t_order;或者直接看建表语句SHOW CREATE TABLE t_order;现在新项目基本都是InnoDB但接手老系统时千万别想当然遇到事务不生效先确认一下表的存储引擎没有坏处。另外某些业务场景下如果使用了MEMORY引擎的临时表同样没有事务支持。5.2 Spring Boot单数据源的自动事务管理器失灵Spring Boot在单数据源情况下会自动配置DataSourceTransactionManager一般不用手动创建。但手动配置数据源时如果不小心把DataSourceTransactionManager搞丢了事务注解就会静默失效。比如下面这种极简但常见的配置错误Configuration public class DataSourceConfig { Bean public DataSource dataSource() { return DruidDataSourceBuilder.create().build(); } Bean public JdbcTemplate jdbcTemplate(DataSource dataSource) { return new JdbcTemplate(dataSource); } }搭建完项目后你发现Transactional怎么都不生效。因为Spring Boot自动配置DataSourceTransactionManager的时机依赖单数据源存在当你手动定义DataSource后某些配置组合下事务管理器没有被正确装配。最直接的办法是显式声明事务管理器Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }注意多个数据源时尤其要留意。Spring Boot没法自动判断该用哪个数据源的事务管理器必须明确指定。Transactional注解可以指定transactionManager属性比如Transactional(transactionManager orderTransactionManager)。如果不指定Spring会按类型寻找PlatformTransactionManager多数据源场景下很容易选错管理器导致某个库的操作不在事务管理范围内。5.3 分布式事务是个更大的话题一旦数据源分布到多个数据库比如订单库和库存库不在同一个实例上单机事务管理器就管不住“跨库一致性”了。Transactional即使加了也只能保证单一数据源范围内的事务跨库回滚无能为力。这种情况需要引入分布式事务方案常见的有基于XA的JTA事务、基于消息的最终一致性、TCCTry-Confirm-Cancel等。它们实现复杂而且并不都适合所有业务场景。我的建议是先分辨业务到底需不需要强一致。绝大部分场景下通过本地消息表、定时对账、幂等补偿等方式达到最终一致比直接上分布式事务框架靠谱得多系统复杂度也不会失控。6. 从日志到代理对象一套可以落地的排查流程与验证清单6.1 打开Spring事务日志看拦截器是否介入排查事务问题第一条路永远是看日志。在application.yml里把事务相关日志级别调高logging: level: org.springframework.transaction: TRACE org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG然后复现一次事务场景。如果事务管理器正常工作日志里会出现类似Acquired Connection [HikariProxyConnection123] for JDBC transaction Participating in existing transaction Initiating transaction commit Initiating transaction rollback如果自调用导致代理失效方法执行时根本不会有这些日志。通过日志的有无可以快速判断是“事务没被创建”还是“事务创建了但没触发回滚”。这两种情况的排查方向完全不同。6.2 用代码判断当前对象是否代理对象在可能失效的事务方法里临时加一行验证代码Transactional(rollbackFor Exception.class) public void testTransaction() { System.out.println(proxy? (this.getClass().getName().contains(EnhancerBySpringCGLIB) || this.getClass().getName().contains(Proxy))); System.out.println(actual transaction active? TransactionSynchronizationManager.isActualTransactionActive()); }如果proxy为false就是代理没有生成重点检查Service、Component等注解和Bean装配情况如果proxy为true但actualTransactionActive为false检查是不是方法被异步线程执行了、事务管理器选错、底层数据源不支持事务等情况。6.3 事务失效场景速查表失效场景典型表现排查方向解决方案同类内部this调用方法加了注解异常没回滚打印this.getClass判断是否是代理类拆Bean、注入self、TransactionTemplate受检异常未配置rollbackForIOException等抛了仍提交看异常类型和注解回滚规则加rollbackFor Exception.class或自定义RuntimeException异常被catch吞掉日志有error但数据还是提交看方法内是否有try-catch让异常冒泡或catch后重新抛RuntimeException修饰符问题private/static/final方法加了注解不生效检查方法修饰符改成public实例方法并交给Spring管理new出来的对象Bean注解没作用检查对象是否从容器获取交给Spring容器管理多线程导致事务上下文丢失新线程操作不在事务内检查是否新开线程/ForkJoinPool子任务方法自己开事务存储引擎不支持表是MyISAM但期待回滚查表引擎改InnoDB必要时做数据迁移多数据源事务管理器选错某数据源操作不参与事务检查transactionManager注入显式指定transactionManager传播行为影响REQUIRES_NEW子任务失败外层照常提交查看传播行为配置调整传播行为或重新设计事务边界6.4 一套可复用的线上排查SOP根据这些年踩坑的经验我遇到事务失效问题时固定按以下顺序排查确认方法能被代理拦截检查类是否在Spring容器中方法是否为public实例方法。确认调用链上是否绕过了代理有没有同类this调用有没有手动new对象。确认异常真的抛到了事务拦截器方法内有没有catch异常类型是否在rollbackFor范围内。确认事务管理器装配正确单数据源看自动配置多数据源看transactionManager属性。确认数据库和表支持事务存储引擎、连接串和账户权限。确认线程上下文是否跨线程执行是否混用了Async。这套流程看起来基础但绝大多数事务问题都逃不出这六步。我见过有人拿着JProfiler分析性能、怀疑GC影响数据回滚最后发现只是同一个类里this调用的问题。花几分钟按顺序走一遍比漫无目的地瞎猜要高效得多。还有一个单测层面的技巧。Spring Test里Transactional默认是回滚的可以用它来验证某个方法的事务行为是否和预期一致。写一个简单的集成测试调用目标方法故意造异常断言数据库数据没有变化。如果测试里回滚了而线上不生效那问题多半出在调用链、代理或运行环境如果测试里也没回滚就能快速定位到代码层面。搞明白Transactional失效的底层逻辑之后再回头看那些五花八门的“为什么没回滚”问题其实大部分都是同一个套路代理没有被触发或者异常信号没有按预期传播到拦截器。遇到问题别急着加各种奇怪的配置先把代理调用链理清楚再去看异常和事务边界的设置基本都能找到答案。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →