ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring @Transactional 事务失效的七种原因与排查指南

Spring @Transactional 事务失效的七种原因与排查指南

1. 从一次线上事故说起:为什么加了注解事务还是没生效

去年帮一个团队排查线上问题,场景很典型:用户下单后扣了库存,结果订单表写入失败,库存却没回滚。代码里明明在 Service 方法上加了@Transactional,日志里也看不到任何报错,但数据就是不一致。这种"注解加了等于没加"的情况,几乎每个用 Spring 的团队都遇到过至少一次。

@Transactional是 Spring 声明式事务的核心注解,它的设计初衷是让开发者不用手写begin、commit、rollback这些模板代码,只靠一个注解就能把事务边界声明清楚。但它的生效依赖一整套代理机制和运行时条件,任何一个环节不满足,事务就会静默失效——注意是"静默",不报错、不警告,这才是最坑的地方。

这篇文章面向的是已经用过 Spring 事务、但被失效问题坑过的后端开发者。我会把实际项目中最常见的七种失效原因逐一拆开,每一种都讲清楚为什么会失效、怎么快速判断是不是这个原因、怎么改。文中所有案例都做了脱敏处理,代码是通用写法,你可以直接对照自己的项目排查。读完之后,你应该能建立起一套"看到事务不生效,按什么顺序去查"的排查直觉,而不是靠猜。

先说一个贯穿全文的底层认知:Spring 的@Transactional本质上是基于代理的 AOP。你调用的是一个被代理包装过的对象,代理在方法执行前后织入事务的开启与提交逻辑。所以凡是"绕过了代理"或者"代理织入的条件不满足"的情况,事务都会失效。后面七种原因,本质上都是这个底层机制在不同场景下的具体表现。

2. 七种失效原因逐条拆解

2.1 同类内部方法调用:最常见的"自调用陷阱"

这是出现频率最高的一种。看下面这段代码:

@Service public class OrderService { public void createOrder(Order order) { // 直接调用本类的方法 this.saveOrder(order); } @Transactional public void saveOrder(Order order) { // 写订单 + 扣库存 } }

createOrder调用saveOrder,走的是this.saveOrder(),也就是当前对象的引用,而不是代理对象。事务逻辑织在代理对象上,this指向的是原始目标对象,代理根本没机会介入,所以saveOrder上的@Transactional完全不起作用。

判断方法很简单:如果失效的方法是被同一个类里的另一个方法调用的,基本可以锁定是这个原因。修复方式有三种,按推荐程度排序:

  1. 把方法拆到另一个 Bean 里。这是最干净的做法,让调用方注入目标 Bean,通过代理调用。事务边界本来就应该跨类划分,硬塞在一个类里往往是职责没拆干净。
  2. 注入自己。通过@Autowired或构造器把当前 Bean 注入进来,用注入的引用去调用方法。注意 Spring 默认允许循环依赖的时代已经过去,构造器注入自己会报循环依赖,得用@Lazy或者 setter 注入绕开。
  3. 用AopContext.currentProxy()。需要开启@EnableAspectJAutoProxy(exposeProxy = true),然后((OrderService) AopContext.currentProxy()).saveOrder(order)。这个方式能用但不够优雅,属于应急手段。

提示:同类自调用失效是"静默"的典型代表,方法照常执行,只是没有事务。排查时优先怀疑它。

2.2 方法不是 public:代理根本织不进去

Spring 的声明式事务,默认只对public 方法生效。如果你把@Transactional加在private、protected或者包级私有的方法上,代理不会拦截,事务自然不生效。

@Transactional private void doSomething() { // 事务不会生效 }

这里要理解一个细节:Spring 的 AOP 代理有两种实现,JDK 动态代理基于接口,CGLIB 基于子类继承。无论哪种,都无法代理非 public 方法——JDK 代理只能代理接口里声明的方法(都是 public),CGLIB 虽然能通过子类覆盖,但对 private 方法无能为力,对 protected 和包级方法在跨包场景下也有问题。所以 Spring 干脆统一规定:非 public 方法上的@Transactional直接忽略。

有意思的是,如果你用的是 AspectJ 的编译期/加载期织入(而不是 Spring 的代理模式),非 public 方法是可以支持事务的。但绝大多数项目用的都是默认的代理模式,所以这条规则要记牢。

判断方法:检查失效方法是不是 public。修复就是改成 public,或者把事务逻辑挪到一个 public 方法里。这里有个容易忽略的点——接口方法默认是 public,但如果你在实现类里把方法声明成了非 public,编译都过不了,所以这种情况更多出现在没有接口的类里。

2.3 异常类型不匹配:默认只回滚运行时异常

这是第二高频的坑。看这段:

@Transactional public void updateStatus() throws Exception { // 业务逻辑 throw new Exception("检查异常"); }

默认情况下,Spring 事务只在遇到RuntimeException和Error时回滚,遇到受检异常(Checked Exception)不回滚。也就是说,上面这段代码抛了Exception,事务会正常提交,数据已经写进去了。

为什么这么设计?因为 Spring 遵循了 EJB 时代的事务约定:受检异常代表"可预期的业务情况",调用方应该处理它,不一定要回滚;运行时异常代表"程序出错了",应该回滚。这个约定在今天看来有点反直觉,但它是默认行为,必须记住。

修复方式是在注解上显式指定回滚的异常类型:

@Transactional(rollbackFor = Exception.class) public void updateStatus() throws Exception { // ... }

反过来,如果你想让某个运行时异常不回滚,可以用noRollbackFor。实际项目里我建议统一加上rollbackFor = Exception.class,除非你有明确的理由不回滚某些异常。这样能避免"以为会回滚结果没回滚"的意外。

还有一个更隐蔽的变体:异常被 catch 了没往外抛。

@Transactional public void doBusiness() { try { // 业务逻辑,可能抛异常 } catch (Exception e) { log.error("出错了", e); // 没有重新抛出 } }

异常在方法内部被吞掉,代理感知不到任何异常,自然认为方法正常结束,于是提交事务。这种情况排查起来最费劲,因为日志里明明有错误记录,但数据就是没回滚。修复要么把异常重新抛出,要么在 catch 里手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。

2.4 事务传播行为配置错误:REQUIRES_NEW 与嵌套的坑

传播行为(Propagation)决定了当前方法遇到已有事务时该怎么办。默认是REQUIRED,意思是"有事务就加入,没有就新建"。大部分场景用默认值没问题,但有两种配置容易出问题。

第一种是REQUIRES_NEW。它表示"总是新建一个事务,挂起当前事务"。看这个场景:

@Transactional public void outerMethod() { // 写主表 innerService.innerMethod(); // 内部是 REQUIRES_NEW // 后续逻辑抛异常 throw new RuntimeException("外层失败"); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void innerMethod() { // 写日志表 }

外层抛异常回滚,但innerMethod因为开了新事务,它写的数据不会跟着回滚。如果你期望的是"要么全成功要么全失败",这个配置就错了。REQUIRES_NEW适合那种"无论主流程成败都要记录"的场景,比如操作日志、审计记录。

第二种是NESTED。它表示"在当前事务里开一个保存点(savepoint),内层失败回滚到保存点,外层可以选择继续或整体回滚"。注意NESTED依赖数据库对保存点的支持,而且它和REQUIRES_NEW的语义完全不同——NESTED是同一个物理事务,REQUIRES_NEW是两个独立事务。很多人把这两个搞混,导致回滚范围不符合预期。

判断方法:检查失效或行为异常的方法上有没有配propagation,以及配置的语义是否符合业务预期。修复就是根据实际需求选对传播行为,拿不准就用默认的REQUIRED。

2.5 数据库引擎不支持事务:MyISAM 的隐形陷阱

这一条属于"代码全对但就是不生效"的类型。MySQL 早期默认的存储引擎是 MyISAM,它不支持事务。如果你建表时没指定引擎,或者从老项目迁移过来,表可能是 MyISAM 的。这种情况下,无论你的 Spring 配置多正确,事务都不会生效,因为底层数据库根本不支持。

判断方法:

SHOW TABLE STATUS LIKE 'your_table_name';

看Engine字段是不是InnoDB。如果是MyISAM,改成InnoDB:

ALTER TABLE your_table_name ENGINE = InnoDB;

除了 MySQL,其他数据库也有类似情况。比如某些嵌入式数据库、或者配置成了非事务性的存储方案。另外,如果你用的是分库分表中间件,要确认中间件对事务的支持情况——有些中间件在跨库场景下只支持弱事务甚至不支持事务。

这一条之所以要单独列出来,是因为它跳出了 Java 代码层面,排查时容易被忽略。我见过团队花两天查代码,最后发现是建表时引擎选错了。

2.6 多数据源下事务管理器没配对

项目里配了多个数据源时,每个数据源需要各自的事务管理器。如果你在操作数据源 A 的方法上加了@Transactional,但没指定用哪个事务管理器,Spring 会用默认的那个,结果就是"事务管理器管的是 B 库,你操作的是 A 库",事务自然不生效。

@Transactional(transactionManager = "orderTransactionManager") public void saveOrder() { // 操作订单库 }

判断方法:确认项目里是不是有多个DataSource和多个PlatformTransactionManager。如果有,检查@Transactional有没有通过transactionManager属性指定正确的事务管理器。修复就是显式指定,或者通过@Primary标注一个默认的。

这里有个进阶坑:跨数据源的事务。Spring 的声明式事务默认只能管一个数据源,如果你在一个方法里同时操作两个库,想让它原子性回滚,需要引入分布式事务方案(如 XA、TCC、消息最终一致性等)。普通的@Transactional做不到跨库原子性,这一点要心里有数。

2.7 事务方法被 final 或 static 修饰

CGLIB 代理是通过继承目标类并覆盖方法来实现的。如果方法是final的,子类无法覆盖,代理就织不进去;如果是static的,它属于类而不属于对象,代理机制同样无能为力。

@Transactional public final void finalMethod() { // 事务不生效 }

判断方法:检查失效方法有没有final或static修饰符。修复就是去掉这些修饰符。这种情况在实际项目里不算高频,但在一些用了代码生成工具、或者继承了某些框架基类的场景下会出现——基类把方法定义成了 final,子类想加事务就加不上。

顺带提一个相关的:如果类本身被final修饰,CGLIB 无法继承这个类,代理创建会直接失败(启动时报错),这个反而容易发现,不像方法级 final 那样静默。

3. 一张表理清七种原因的排查优先级

排查事务失效,最忌讳东一榔头西一棒子。我习惯按"从代码到配置再到环境"的顺序过一遍,下面这张表是我自己用的排查清单,按命中概率从高到低排列:

序号失效原因典型特征快速验证方式修复方向
1同类自调用方法被本类其他方法调用看调用方是不是this.xxx()拆 Bean / 注入自己 / AopContext
2异常类型不匹配抛了受检异常或异常被吞看异常类型和 catch 块加rollbackFor/ 重新抛出
3方法非 public方法修饰符是 private/protected看方法签名改 public 或挪位置
4传播行为配错用了 REQUIRES_NEW/NESTED看注解的 propagation按业务语义重选
5多数据源没配对项目有多个数据源看有几个事务管理器指定 transactionManager
6引擎不支持代码全对但不回滚SHOW TABLE STATUS改 InnoDB
7final/static 方法方法有 final/static看修饰符去掉修饰符

这张表的用法是:从第 1 行往下查,命中哪条就处理哪条。实际经验里,前三条能覆盖八成以上的失效场景。第 6、7 条虽然概率低,但一旦遇到特别费时间,所以放在清单里备查。

注意:排查时不要只盯着@Transactional那一行代码看,要顺着"调用链"和"代理链"两个维度一起查。调用链回答"方法是怎么被调到的",代理链回答"代理有没有机会介入"。

4. 三个容易被忽略的进阶细节

4.1 事务与异步、缓存的叠加效应

@Async和@Transactional同时出现在一个方法上时,顺序很关键。如果@Async先生效,方法会被丢到另一个线程执行,而事务的ThreadLocal绑定在当前线程上,新线程里根本没有事务上下文,@Transactional就失效了。这两个注解的叠加需要特别小心,通常建议拆成两个方法,一个负责异步调度,一个负责事务逻辑。

缓存注解@Cacheable和事务叠加也有类似问题。@Cacheable在方法执行前查缓存,如果命中就直接返回,方法体根本不执行,事务代理虽然开了事务但没有任何数据库操作,等于空转。更麻烦的是,如果缓存写入和数据库写入不在同一个事务里,可能出现"缓存更新了但数据库回滚了"的不一致。这类问题不在本文七种原因之内,但排查事务相关 bug 时值得一并考虑。

4.2 只读事务不是"性能开关"

@Transactional(readOnly = true)经常被当成性能优化手段到处加。它的真实作用是给底层一个提示:这个事务里不会有写操作。数据库驱动和连接池可以据此做一些优化,比如不开启写相关的资源。但它不会阻止你写数据——如果你在只读事务里执行了 update,某些数据库会直接报错,某些则默默执行。所以别把它当保险用,它只是一个提示。

另外,只读事务在配合主从复制时有用:可以路由到从库。但如果你的框架没做读写分离路由,加readOnly = true基本没有实际收益,反而增加了理解成本。

4.3 事务超时与连接池的联动

@Transactional(timeout = 5)设置的是事务超时时间,单位秒。超时后事务会被标记回滚。但要注意,这个超时是从事务开启到提交的总时长,如果方法里有远程调用、文件 IO 等耗时操作,很容易触发超时。而且超时后的回滚行为依赖底层数据库和驱动的实现,不是所有场景都能干净回滚。

更隐蔽的是连接池配置。如果连接池的最大连接数太小,高并发下事务方法拿不到连接会阻塞,阻塞时间算不算进事务超时、会不会导致连接泄漏,这些都要结合具体连接池(如 HikariCP、Druid)的参数一起看。事务问题和连接池问题经常纠缠在一起,排查时要一起看日志。

5. 我自己的排查习惯与几条硬经验

排查事务问题这么多年,我养成了几个固定习惯,分享出来供参考。

第一,先看调用链再看注解。很多人一上来就盯着@Transactional的参数看,其实大部分问题出在"方法压根没走代理"上。用 IDE 的调用层级功能(Call Hierarchy)把调用链拉出来,看看是不是同类自调用,这一步能省掉大量时间。

第二,打开 Spring 的事务日志。把org.springframework.transaction的日志级别调到DEBUG,你能看到事务的开启、提交、回滚全过程。如果日志里根本没有"Creating new transaction"之类的输出,说明事务压根没启动,问题在代理层;如果有开启但没回滚,问题在异常处理或传播行为。这一招比读代码快得多。

第三,写单元测试验证回滚。别靠肉眼看代码判断事务会不会生效,写个集成测试,故意在事务方法里抛异常,然后断言数据库里没有数据。这种测试跑一次只要几秒,但能挡住绝大多数回归问题。我习惯给每个涉及多表写入的 Service 方法都配一个回滚测试。

第四,统一规范降低踩坑概率。团队里约定几条硬规则:事务方法一律 public;一律加rollbackFor = Exception.class;禁止同类自调用事务方法;多数据源必须显式指定事务管理器。这些规则写进代码规范,配合静态检查工具(如自定义的 ArchUnit 规则或 Checkstyle 规则)自动拦截,比事后排查划算得多。

最后说一个心态上的经验:事务失效之所以让人头疼,是因为它不报错。所以排查时不要假设"代码写了就一定生效",而要假设"默认不生效,我需要证明它生效了"。带着这个心态去查,你会更快找到问题。数据库层面也可以用SELECT @@autocommit确认自动提交是否关闭,用SHOW ENGINE INNODB STATUS看最近的事务情况,这些底层手段在关键时刻能提供代码层面看不到的信息。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进