场景:一行 @Transactional 背后的复杂度

@Transactional 大概是最容易写、也最容易写错的注解。很多线上事故——数据回滚了一半、本该回滚却提交了、嵌套调用结果出乎意料——根源都在于对传播行为失效条件的理解停留在"加上就行"。这篇文章把事务的实现机制、七种传播行为、典型失效场景一次讲透。

机制:声明式事务靠的是 AOP

@Transactional 是声明式事务,其本质是 AOP 的一个特例。Spring 用 TransactionInterceptor 这个环绕通知,在目标方法前后织入事务的开启、提交、回滚逻辑:

1
2
3
4
5
6
代理方法被调用
└─ TransactionInterceptor 介入
├─ 根据传播行为决定:开新事务 / 加入现有事务 / 挂起 ...
├─ 调用目标方法
├─ 正常返回 → commit
└─ 抛出需回滚的异常 → rollback

事务的"开启"实际是:从 DataSource 拿一个 Connection,设置 autoCommit=false,并把这个 Connection 绑定到当前线程(通过 TransactionSynchronizationManagerThreadLocal)。后续同一线程内所有数据库操作只要走的是 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class OrderService {
@Transactional // REQUIRED
public void placeOrder(Order o) {
orderDao.insert(o);
logService.record(o); // 见下,REQUIRES_NEW
// 若此处抛异常,placeOrder 回滚,但 record 已独立提交
}
}

@Service
public class LogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(Order o) {
logDao.insert(buildLog(o));
}
}

注意 REQUIRES_NEW 会占用两个数据库连接(外层被挂起的连接 + 内层新连接)。在高并发下,如果连接池偏小,大量 REQUIRES_NEW 嵌套可能耗尽连接池甚至自我死锁——外层持有连接等内层,内层却拿不到新连接。这是一个隐蔽的容量陷阱。

回滚规则:默认只回滚 RuntimeException

一个高频踩坑:Spring 默认只对 RuntimeExceptionError 回滚,对受检异常(Checked Exception)不回滚。

1
2
3
4
5
@Transactional
public void doWork() throws IOException {
jdbc.update(...); // 已执行
throw new IOException(); // 受检异常,事务照样 commit!数据落库了
}

想让受检异常也回滚,必须显式声明:@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、避免自调用,从代码结构上规避失效。
  • 消息/缓存的"事务后操作"用 TransactionSynchronizationafterCommit 回调,确保发消息发生在事务真正提交之后,避免"消息发出去了但事务回滚了"的数据不一致。

小结

声明式事务 = AOP 代理 + 线程绑定连接 + 传播规则。传播行为的核心是"如何对待已有事务",其中 REQUIRES_NEW(独立连接)和 NESTED(savepoint,共享连接)的差异要刻进肌肉记忆。失效场景几乎都指向两个根因:代理没生效(自调用、非 public、未托管)和语义不匹配(受检异常未配 rollbackFor、异常被吞、切线程、引擎不支持)。把这两条主线抓住,事务就不再玄学。