三年前我处理过一个相当典型的线上问题:用户订单支付成功,订单也正常生成了,但扣减库存那一步失败,结果库存还是被扣了,造成超卖。我翻代码翻了好久,关键方法上明明加了@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(List<Long> 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。因为没有显示配置rollbackFor,Spring只会把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(List<Long> userIds) { userIds.parallelStream().forEach(userId -> { // 这里是另一个线程执行 userService.updateUser(userId); }); }主线程里batchProcess虽然开了事务,但parallelStream提交的任务在ForkJoinPool的线程中执行,每个子线程拿到的连接不属于主事务。如果某一条updateUser失败,主线程事务不会感知,也不会回滚其他已完成的操作。
正确的做法是:把事务边界放进去,让每个子任务的执行方法自己带事务。
public void batchProcess(List<Long> 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事务、基于消息的最终一致性、TCC(Try-Confirm-Cancel)等。它们实现复杂,而且并不都适合所有业务场景。我的建议是:先分辨业务到底需不需要强一致。绝大部分场景下,通过本地消息表、定时对账、幂等补偿等方式达到最终一致,比直接上分布式事务框架靠谱得多,系统复杂度也不会失控。
6. 从日志到代理对象:一套可以落地的排查流程与验证清单
6.1 打开Spring事务日志,看拦截器是否介入
排查事务问题,第一条路永远是看日志。在application.yml里把事务相关日志级别调高:
logging: level: org.springframework.transaction: TRACE org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG然后复现一次事务场景。如果事务管理器正常工作,日志里会出现类似:
Acquired Connection [HikariProxyConnection@123] 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 |
| 受检异常未配置rollbackFor | IOException等抛了仍提交 | 看异常类型和注解回滚规则 | 加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失效的底层逻辑之后,再回头看那些五花八门的“为什么没回滚”问题,其实大部分都是同一个套路:代理没有被触发,或者异常信号没有按预期传播到拦截器。遇到问题别急着加各种奇怪的配置,先把代理调用链理清楚,再去看异常和事务边界的设置,基本都能找到答案。