Spring事务传播行为与失效场景

@Transactional 注解无疑是我们处理数据库事务最得力的助手。然而,在复杂的业务链路下,仅仅知道“加上注解就能回滚”是远远不够的,我们还需要掌握事务的传播行为以及各种隐蔽的失效场景。

@Transactional 是如何工作的

要理解事务为何生效、又为何失效,首先必须明白 @Transactional 是如何工作的。

@Transactional 注解的实现高度依赖于 Spring AOP 的动态代理机制——当一个标记了该注解的方法被调用时,Spring 并不会直接执行这个方法,而是 生成一个代理对象来包裹原始对象

在 Java 中,方法永远是属于某个类的实例(对象)的 —— 你无法单独调用一个 “脱离对象的方法”。调用方法的本质是 “给某个对象发指令,让它执行对应的行为”。

所以当我们说 “标记 @Transactional 的方法被调用” 时,这个调用动作的载体必然是 该方法所属的对象。Spring 要拦截这个方法的执行、添加事务逻辑,就必须先拦截对这个对象的方法调用。

所有对该方法的外部调用,都会先经过这个代理对象。代理对象负责开启事务,随后调用原始对象的实际方法,最后根据方法的执行结果来决定是提交还是回滚事务。

这个代理对象的核心逻辑是:

  1. 持有原始对象的引用
  2. 所有对原始对象方法的调用,都会先经过代理对象
  3. 代理对象会判断当前调用的方法是否标记了 @Transactional:
    1. 如果是:先执行事务开启逻辑 → 调用原始对象的该方法 → 执行事务提交 / 回滚;
    2. 如果不是:直接转发调用到原始对象的方法,不做额外处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Spring生成的代理对象(简化版)
public class AccountServiceProxy extends AccountService {
// 持有原始对象
private AccountService target;

@Override
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
// 代理逻辑:开启事务(这是@Transactional的核心)
System.out.println("开启数据库事务");
try {
// 调用原始对象的transferMoney方法
target.transferMoney(fromId, toId, amount);
// 提交事务
System.out.println("提交事务");
} catch (Exception e) {
// 回滚事务
System.out.println("回滚事务");
throw e;
}
}

@Override
public BigDecimal queryBalance(Long userId) {
// 无事务,直接调用原始对象的方法
return target.queryBalance(userId);
}
}

记住这个结论:事务生效的前提是,方法的调用必须经过 Spring 代理对象的拦截

事务传播行为

接下来稍微叉出去,讲一下另一个东西。

在复杂的微服务或模块化设计中,经常会出现“事务方法 A 调用事务方法 B”的情况。此时,被调用的方法是应该加入现有的事务,还是另起炉灶?这就是 事务传播行为(Propagation) 要解决的问题。

Spring 在 Propagation 枚举中定义了 7 种行为,我们可以 按“对当前事务的态度”将其分为三类

融入型

  • REQUIRED(默认):同生共死,如果当前存在事务,则加入该事务;若没有,则新建一个。它们在物理层面上共享同一个数据库连接。只要调用链中任何一个方法抛出异常,整个事务(内外层)都会被标记为 rollback-only,最终统一回滚。适用于 90% 强调原子性的增删改场景。
  • SUPPORTS:墙头草。有事务就加入,没事务就以非事务方式运行。常用于单纯的查询操作。
  • MANDATORY:寄生虫。必须在事务中运行,如果当前没事务,直接抛出异常。适用于缺乏独立业务语义、强制要求上层开启事务的 DAO 层方法。

独立型

  • REQUIRES_NEW:完全隔离,各玩各的。总是创建一个新的独立事务。如果当前有事务,Spring 会将外层事务的资源解绑并挂起,开启全新的数据库连接。内外层事务完全隔离。内层回滚不影响外层(除非异常抛出未被捕获),外层回滚也不影响内层已提交的数据。适用场景于审计日志入库(业务失败日志也要记录)、序列号生成。

    • 风险:高并发下同时持有两个连接,易耗尽连接池;操作同行数据易引发死锁。
  • NOT_SUPPORTED:暂停事务。强制挂起当前事务,以非事务方式运行。适用于发送邮件/短信等耗时且无需事务的操作。

  • NEVER:事务洁癖。以非事务方式运行,若存在事务则直接抛异常。

嵌套型

NESTED:设存档,失败重来。依赖 JDBC 的 Savepoint 技术,与外层共享物理连接。如果有事务,则在嵌套事务中运行;没有则类似 REQUIRED。内层回滚仅回到保存点,不影响外层;但外层回滚会连带内层一起回滚。适用于非核心步骤(如次要数据更新)失败可忽略,但不应拖累主业务的场景。

盘点 Spring 事务的四类失效场景

理解了原理和机制,我们来看看实战中那些导致事务失效的元凶。基于 AOP 的底层逻辑,失效原因通常可以归结为以下几类:

AOP 代理绕过

1.同类内部自调用:当类中一个 无事务的方法 A 直接 调用同类中有事务的方法 B(即 this.methodB())时,因为 直接访问的是原始对象,绕过了代理类,事务彻底失效。

解决方法:

  1. 将方法拆分到新 Service 类(最优雅)
  2. 使用 @Lazy 注入自身代理对象
  3. 开启 expose-proxy 后使用 AopContext.currentProxy() 调用。

2.方法访问权限非 public:无论是 JDK 动态代理(只能代理接口)还是 CGLIB(重写父类),Spring AOP 规范 默认只拦截 public 方法。加在 privateprotected 方法上的事务注解形同虚设。

CGLIB 确实 无法 拦截 private(Java 语言规范:子类不可见父类私有成员),但技术上 CGLIB 完全可以 拦截 protected 方法(通过 ASM 字节码生成子类重写 protected 方法)。

所以说 Spring 选择不拦截 protected,是 框架层面的故意限制,而非技术不能。

两个层面来讲,JDK 代理天然处理 public 方法(接口方法默认 public),所以 JDK 代理实际上 永远接触不到非 public 方法。为了保证「无论底层用 JDK 还是 CGLIB,AOP 的行为表现完全一致」,Spring 选择统一。

其次,「为什么你要在 private 方法上加事务?」Private 方法是类内部实现细节,AOP 是 外部横切关注点

如果允许 @Transactional 作用于 private 方法,等于鼓励「通过外部切面修改对象内部状态」,这彻底摧毁了封装。

3.方法被 finalstatic 修饰final 导致 CGLIB 无法重写子类方法;static 方法属于类而非实例,AOP 无法代理。

4.目标对象未被 Spring 容器管理:AOP 代理的前提是:目标对象必须是 Spring 容器管理的 Bean。如果类没有通过 @Component/@Service/@Repository/@Controller 等注解声明,也没有通过 XML 配置、@Bean 方法等方式注册到 Spring 容器中,那么 Spring 不会为这个类生成代理对象。此时 @Transactional 注解只是一个 “无意义的标记”,Spring 事务切面根本无法感知到这个类的存在,事务自然完全失效。

异常处理不当

  • 异常被 try-catch 吞没:Spring 默认感知到异常才会回滚。如果你在方法内 catch 了异常却没有重新抛出,Spring 会认为执行成功从而提交事务。
  • rollbackFor 属性未设置:默认情况下,事务只对 RuntimeExceptionError 回滚。如果抛出受检异常(如 IOException),默认会提交事务。
  • 隐蔽的 UnexpectedRollbackException:在 REQUIRED 模式下,如果内层方法抛出异常,全局事务已被标记为 rollback-only。此时若外层方法 catch 了异常并试图正常提交,Spring 检测到状态不一致,就会抛出此异常。
  1. 重新 throw e,或手动调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
  2. 养成好习惯,始终指定 @Transactional(rollbackFor = Exception.class)
  3. 若内层失败不应影响外层,应将内层事务改为 REQUIRES_NEWNESTED

线程与上下文断裂

多线程或异步调用:Spring 事务信息默认通过 ThreadLocal 与当前线程绑定。在事务方法内新开线程或调用 @Async 方法,子线程无法获取父线程的事务上下文,操作将游离于事务之外。

解决方案: 这是一个复杂的问题,通常需要使用支持事务传播的异步任务执行器(如 TransactionAwareTaskDecorator)或手动在子线程中管理事务。对于大多数场景,建议避免在事务方法内部进行复杂的异步或多线程数据库操作。

架构与环境配置错误

  • 数据库引擎不支持:即使代码完美,如果 MySQL 表使用的是 MyISAM 这种不支持事务的引擎,事务自然无效(需改为 InnoDB)。
  • 动态数据源切换冲突:读写分离架构下,若数据源切换的 AOP 切面执行顺序晚于事务切面,事务会先在主库拿到连接,导致后续的读操作也全部落在主库上。必须通过 @Order 确保数据源切换的优先级更高。

Spring事务传播行为与失效场景
https://gavinmo1.github.io/2026/03/09/Spring事务传播行为与失效场景/
作者
Gavin
发布于
2026年3月9日
许可协议