场景:一行 @Transactional 背后的复杂度
@Transactional 大概是最容易写、也最容易写错的注解。很多线上事故——数据回滚了一半、本该回滚却提交了、嵌套调用结果出乎意料——根源都在于对传播行为和失效条件的理解停留在"加上就行"。这篇文章把事务的实现机制、七种传播行为、典型失效场景一次讲透。
机制:声明式事务靠的是 AOP
@Transactional 是声明式事务,其本质是 AOP 的一个特例。Spring 用 TransactionInterceptor 这个环绕通知,在目标方法前后织入事务的开启、提交、回滚逻辑:
1 | 代理方法被调用 |
事务的"开启"实际是:从 DataSource 拿一个 Connection,设置 autoCommit=false,并把这个 Connection 绑定到当前线程(通过 TransactionSynchronizationManager 的 ThreadLocal)。后续同一线程内所有数据库操作只要走的是 Spring 的 DataSource,就会复用这个绑定的连接,从而处于同一事务。
这点至关重要:事务是绑定线程的。一旦逻辑切换了线程(异步、线程池),事务上下文就丢了。
七种传播行为:决定"如何对待已有事务"
传播行为(propagation)回答的问题是:当前已经有事务时,我该怎么办;没有事务时,我又该怎么办。
| 传播行为 | 已有事务 | 无事务 |
|---|---|---|
| REQUIRED(默认) | 加入 | 新建 |
| REQUIRES_NEW | 挂起旧的,新建 | 新建 |
| NESTED | 嵌套(savepoint) | 新建 |
| SUPPORTS | 加入 | 非事务运行 |
| NOT_SUPPORTED | 挂起,非事务运行 | 非事务运行 |
| MANDATORY | 加入 | 抛异常 |
| NEVER | 抛异常 | 非事务运行 |
最常用且最易混淆的是前三个。
REQUIRED vs REQUIRES_NEW vs NESTED
- REQUIRED:大家在同一个物理事务里。内层方法抛异常,整个事务一起回滚——这是默认行为,也是大多数场景想要的。
- REQUIRES_NEW:内层方法独立开一个物理事务(独立连接),挂起外层。内层提交后即使外层回滚,内层也已落库;反之内层回滚不影响外层。典型用途:记录操作日志,业务回滚但日志要保留。
- NESTED:借助数据库 savepoint,内层是外层的一个"子保存点"。内层回滚只回到 savepoint,外层可继续;但外层回滚会连带内层一起回滚(因为它们是同一个物理连接)。这和 REQUIRES_NEW 的本质区别在于:NESTED 共享连接,REQUIRES_NEW 是两条独立连接。
1 |
|
注意 REQUIRES_NEW 会占用两个数据库连接(外层被挂起的连接 + 内层新连接)。在高并发下,如果连接池偏小,大量 REQUIRES_NEW 嵌套可能耗尽连接池甚至自我死锁——外层持有连接等内层,内层却拿不到新连接。这是一个隐蔽的容量陷阱。
回滚规则:默认只回滚 RuntimeException
一个高频踩坑:Spring 默认只对 RuntimeException 和 Error 回滚,对受检异常(Checked Exception)不回滚。
1 |
|
想让受检异常也回滚,必须显式声明:@Transactional(rollbackFor = Exception.class)。很多团队会把这个作为强制规约。
另一个相关的坑:在事务方法里 try-catch 吞掉了异常,异常没抛出方法之外,TransactionInterceptor 感知不到失败,自然不会回滚。
事务失效的典型场景
事务失效几乎都可归结为"AOP 代理没生效"或"异常/连接语义不匹配"。逐一列举:
1. 自调用(this 调用)。 同类内 A 方法调 B 方法,this.b() 绕过代理,B 上的 @Transactional 失效。这是 AOP 的固有限制(详见前文 IoC/AOP 篇)。解决:拆 Bean、注入自身代理、或 AopContext.currentProxy()。
2. 方法非 public。 基于代理的事务,默认只对 public 方法生效。private/protected/包级方法即使加了注解也不拦截(CGLIB 子类无法重写它们)。
3. 异常被吞或抛出受检异常未配 rollbackFor。 见上节。
4. 切换了线程。 在 @Transactional 方法里 new Thread() 或丢进线程池执行 DB 操作,新线程拿不到 ThreadLocal 里的连接,等于在事务外操作,主线程回滚也管不到它。
5. 数据库引擎不支持事务。 比如 MySQL 用了 MyISAM 引擎,@Transactional 形同虚设,不会报错但完全无效。生产务必用 InnoDB。
6. 多数据源未正确配置事务管理器。 多个 DataSource 时,@Transactional 默认绑定某个 PlatformTransactionManager,如果操作的是另一个数据源,事务不生效。需用 @Transactional("txManager2") 明确指定。
7. @Transactional 加在了未被 Spring 管理的对象上。 自己 new 出来的实例没有经过容器,没有代理,注解不生效。
工程权衡与最佳实践
- 事务范围越小越好。把远程调用、复杂计算、发消息这些慢且可能失败的非 DB 操作移出事务方法。长事务会长时间占用连接、放大锁竞争、提升死锁概率。一个常见反模式是在事务里调用第三方 HTTP 接口,接口慢则连接被一直占着。
- 谨慎 REQUIRES_NEW,评估连接池容量,避免嵌套耗尽连接。
- 统一回滚策略,团队约定
rollbackFor = Exception.class,避免受检异常不回滚的隐患。 - 事务方法保持 public、避免自调用,从代码结构上规避失效。
- 消息/缓存的"事务后操作"用
TransactionSynchronization的afterCommit回调,确保发消息发生在事务真正提交之后,避免"消息发出去了但事务回滚了"的数据不一致。
小结
声明式事务 = AOP 代理 + 线程绑定连接 + 传播规则。传播行为的核心是"如何对待已有事务",其中 REQUIRES_NEW(独立连接)和 NESTED(savepoint,共享连接)的差异要刻进肌肉记忆。失效场景几乎都指向两个根因:代理没生效(自调用、非 public、未托管)和语义不匹配(受检异常未配 rollbackFor、异常被吞、切线程、引擎不支持)。把这两条主线抓住,事务就不再玄学。