Skip to content

Spring 事务传播:从调用边界理解 REQUIRED、REQUIRES_NEW 与 NESTED ​

传播行为回答的只有一个问题:一个事务方法调用另一个事务方法时,里面那个方法用外面的事务、开一个新事务,还是设一个保存点。它不是隔离级别,也不会让远程调用变成分布式事务。

七种传播行为的定义很容易记住,真正让线上出事故的却是另外几个问题:内层方法抛了异常、外层 catch 住了,为什么提交时报 UnexpectedRollbackException?REQUIRES_NEW 为什么能把连接池耗尽?审计日志独立提交,会不会留下一条「订单成功」的假记录?

本文基于 Spring Framework 7 的官方文档,正文示例是省略了依赖注入的片段。文中的传播行为都在 Spring Boot 4.1.1(Spring Framework 7.0.9)和 MySQL 8.4.11 上实测过,完整程序见文末配套实验。

一、先说结论 ​

  • 传播行为作用于「经过 Spring 代理的方法调用」。 同一个类里的方法互相调用(自调用)默认不经过代理,传播行为不会生效。
  • 区分物理事务与逻辑事务。 物理事务是数据库连接上真实的一次 BEGIN ... COMMIT;逻辑事务是 Spring 为每个 @Transactional 方法维护的作用域。多个逻辑事务可以共享一个物理事务。
  • 三个高频选项:
    • REQUIRED(默认):有就加入,没有就新建。内外层共享同一个物理事务,要么一起提交,要么一起回滚。
    • REQUIRES_NEW:挂起外层,新开一个独立的物理事务,需要另一个数据库连接。
    • NESTED:在同一个物理事务里设置保存点(savepoint),内层可以只回滚到保存点。官方文档说明它只适用于 JDBC 资源事务,典型实现是 DataSourceTransactionManager。
  • 默认回滚规则:RuntimeException 和 Error 回滚,受检异常不回滚。Spring Framework 6.2 起可以通过 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) 全局修改。

二、七种传播行为一张表 ​

传播行为当前有事务当前无事务一句话理解
REQUIRED加入新建默认值,同一个业务动作的多个步骤
REQUIRES_NEW挂起当前,新建新建必须独立提交的记录
NESTED创建保存点新建可局部回滚的子步骤
SUPPORTS加入非事务执行有没有都行
NOT_SUPPORTED挂起当前,非事务执行非事务执行明确不想在事务里执行
MANDATORY加入抛异常断言调用方必须已开启事务
NEVER抛异常非事务执行断言调用方不能在事务中

后四种可以理解为「对已有事务的态度」:允许、拒绝、强制、禁止。实际项目中用得最多的是前三种,MANDATORY 适合用在仓储层或领域服务上,防止有人漏开事务。

三、REQUIRED:一个物理事务,多个逻辑作用域 ​

物理事务 · 连接 A · 一次 BEGIN … COMMIT逻辑作用域 · OrderService.placeOrder()StockService.deduct()REQUIRED · 加入外层事务PointService.add()抛出 RuntimeException→ 共享事务被标记为 rollback-only外层 catch 住异常后尝试提交 → 发现 rollback-only → 回滚并抛出 UnexpectedRollbackException
图 1 · REQUIRED 下多个逻辑作用域共享同一个物理事务,内层留下的回滚标记会在外层提交时生效

只有最外层的逻辑事务结束时,Spring 才会真正提交或回滚连接。内层方法「结束」只是退出了自己的逻辑作用域。

UnexpectedRollbackException 是怎么来的 ​

java
@Service
class OrderService {
    private final PointService pointService;

    @Transactional
    public void placeOrder(Order order) {
        orderRepository.save(order);
        try {
            pointService.add(order.userId(), order.points()); // REQUIRED,抛出 RuntimeException
        } catch (RuntimeException e) {
            log.warn("加积分失败,忽略", e);                     // 以为吞掉异常就能继续提交
        }
    }
}

执行过程:

  1. pointService.add 抛出运行时异常,它的事务拦截器按规则把共享的物理事务标记为 rollback-only。
  2. 外层 catch 住了异常,placeOrder 正常返回。
  3. 外层拦截器准备提交,发现事务已被标记为只能回滚,于是回滚,并抛出 UnexpectedRollbackException。

实测结果和上面一致:外层收到 UnexpectedRollbackException,订单没有落库。官方文档对这个行为的解释是:调用方不应该在事务实际没有提交时,误以为提交成功了。所以这个异常是在保护你,而不是 bug。

如果加积分失败确实可以忽略,有三种正确改法:

改法做法适用场景
内层不参与事务回滚在 PointService.add 上用 noRollbackFor 声明该异常不回滚异常表示业务上的「未执行」,没有写入半截数据
局部回滚内层改为 NESTED,失败时只回滚到保存点使用 JDBC 事务,且内层可能已经写入部分数据
移出主事务主事务提交后再异步加积分,失败重试加积分本就不需要与下单强一致

四、REQUIRES_NEW:独立提交的代价 ​

连接 A连接 B调用 audit.record()内层返回BEGIN · insert order挂起(连接仍被占用)恢复 · COMMITBEGIN · insert log · COMMIT同一请求同时占用 2 个连接时间
图 2 · REQUIRES_NEW 挂起外层事务,但不释放外层连接;连接池被外层占满时,内层拿不到连接 B

4.1 连接池死锁 ​

外层事务在内层执行期间仍然持有连接 A,内层还要再拿一个连接 B。假设连接池大小为 10,恰好有 10 个请求同时进入外层事务并占满连接,它们都会卡在获取连接 B 上,直到超时。

实测把连接池设为 2 个连接,让两个请求同时进入外层事务再调用 REQUIRES_NEW:两个请求都等满连接超时(实验里设为 1 秒)后失败,异常是 CannotCreateTransactionException。Spring 官方文档专门提醒:除非连接池大小至少比并发线程数多 1,否则不要使用 REQUIRES_NEW。实际系统中很难保证这一点,所以要尽量缩短外层事务,或者把独立提交的逻辑挪到事务之外。

4.2 业务语义:独立提交会不会留下假事实 ​

java
@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    auditService.record("ORDER_CREATED", order.id()); // REQUIRES_NEW,立刻提交
    paymentClient.freeze(order);                      // 抛异常,订单回滚
}

审计记录已经独立提交,写着「订单已创建」,但订单本身被回滚了。这条记录是真是假,取决于它的语义:

  • 如果它记录的是尝试(「用户尝试下单」),独立提交是对的,失败的尝试也需要留痕;
  • 如果它记录的是结果(「订单已创建」),就应该在主事务提交后再写。

后一种情况可以用事务同步回调:

java
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
    auditService.record("ORDER_CREATED", event.orderId());
}

需要注意,AFTER_COMMIT 回调执行时进程可能崩溃,所以它适合「丢了可以接受或可以对账补偿」的操作。如果通知必须可靠送达,应使用事务型 Outbox(见 上下文集成):在同一个本地事务中写业务表和待发送事件表,再由独立任务投递。Kafka 场景的做法见 Kafka 不丢、不重与 Exactly Once。

五、NESTED:保存点,而不是新事务 ​

java
@Transactional
public void importBatch(List<Row> rows) {
    for (Row row : rows) {
        try {
            rowImporter.importOne(row);   // @Transactional(propagation = NESTED)
        } catch (InvalidRowException e) {
            failures.add(row, e);         // 只回滚这一行写入的数据,继续下一行
        }
    }
    batchRepository.markFinished();
}

实测导入 3 行、第 2 行写入后校验失败:NESTED 下提交后留下第 1、3 行;同样的代码改成 REQUIRED,外层提交时抛出 UnexpectedRollbackException,一行都不留。

NESTED 与 REQUIRES_NEW 的差别:

对比项REQUIRES_NEWNESTED
物理事务两个一个
数据库连接两个一个
内层回滚不影响外层回滚到保存点,外层继续
外层回滚内层已提交的数据保留内层数据一起回滚
锁内外层是两个事务,可能互相等待对方持有的锁同一事务内,没有这个问题
支持范围大多数事务管理器官方文档说明只适用于 JDBC 资源事务

REQUIRES_NEW 还有一个隐蔽问题:如果外层已经对某行加了锁,内层独立事务再去更新同一行,会等外层释放锁,而外层在等内层返回,结果是一直等到锁超时。InnoDB 看到的是两个事务之间的普通锁等待,不会当作死锁立即回滚其中一个。实测(锁等待超时设为 2 秒)内层等满 2 秒后抛出 CannotAcquireLockException;MySQL 的默认值是 50 秒,这段时间里内外两个事务各占着一个连接,都被卡住。NESTED 在同一个事务里,不会出现这种情况。

六、回滚规则 ​

6.1 默认规则与显式声明 ​

默认只有 RuntimeException 和 Error 会触发回滚。如果业务异常是受检异常,需要显式声明:

java
@Transactional(rollbackFor = PaymentRejectedException.class)
public void confirmPayment(PaymentCommand command) throws PaymentRejectedException {
    // ...
}

Spring Framework 6.2 新增了全局开关,项目统一使用受检业务异常时可以考虑:

java
@Configuration
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
class TransactionConfig {
}

不要为了省事在每个方法上都写 rollbackFor = Exception.class 而不去想语义。有些受检异常代表「业务上拒绝但数据是一致的」,比如校验失败后写了一条拒绝记录,这时回滚反而会丢掉这条记录。

6.2 吞掉异常等于告诉 Spring 成功了 ​

java
@Transactional
public void transfer(long from, long to, BigDecimal amount) {
    try {
        accountRepository.debit(from, amount);
        accountRepository.credit(to, amount);
    } catch (DataAccessException e) {
        log.error("转账失败", e);   // 没有重新抛出,事务会提交,可能只扣了款
    }
}

事务拦截器只能看到方法是否抛出异常。确实需要捕获时,要么重新抛出,要么调用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 显式标记回滚,要么把可预期的失败设计成返回值,不在事务中途失败。

七、传播行为的边界 ​

  • 只对代理调用生效。 自调用、private 方法不会被拦截,完整的失效场景见 Spring AOP 为什么会失效。Spring Framework 6.0 起,基于类的代理也支持 protected 和包可见方法;接口代理的方法仍须是 public 且定义在接口中。
  • 命令式事务绑定线程。 事务资源保存在当前线程上,@Async、CompletableFuture.supplyAsync、手动创建的线程都拿不到外层事务。
  • 响应式事务绑定 Reactor Context。 使用 Mono / Flux 返回值时,事务随订阅上下文传递,操作必须留在同一条响应式链路中。
  • 不跨进程传播。 HTTP 或 RPC 调用不会带上本地事务,远程服务的一致性需要 Saga、Outbox、幂等与对账来保证。

八、怎么选 ​

  1. 默认使用 REQUIRED。 绝大多数业务方法只需要这一种。
  2. 需要独立提交的「尝试记录」或失败日志,才考虑 REQUIRES_NEW,并确认连接池余量和锁冲突。
  3. 需要记录「成功结果」或发通知,用 AFTER_COMMIT 回调,要求可靠时用 Outbox。
  4. 批量处理中需要局部回滚,在 JDBC 事务下使用 NESTED。
  5. 任何传播行为都不能替代「短事务」:远程调用、文件上传、人工确认都不应该放在数据库事务里。

九、常见误区 ​

  • 「内层方法 catch 了异常就不会影响外层」:REQUIRED 下内层拦截器已经把共享事务标记为回滚,外层提交时会抛 UnexpectedRollbackException。
  • 「REQUIRES_NEW 更安全,用它隔离一下」:它多占一个连接,还可能与外层事务互相等锁。
  • 「NESTED 和 REQUIRES_NEW 差不多」:前者是保存点,外层回滚时内层数据一起回滚;后者是独立事务,已提交的数据不会回滚。
  • 「@Transactional 加在 private 方法上也行」:代理模式下不会生效,且不会报错。
  • 「传播行为能管住远程调用」:本地事务管不到其他进程。

小结 ​

理解事务传播,关键是分清物理事务和逻辑事务:REQUIRED 共享一个物理事务,所以内层失败会波及外层;REQUIRES_NEW 用额外的连接换来独立提交;NESTED 用保存点换来局部回滚。选择时先问业务语义——这条记录在主流程失败时应不应该留下——再看技术代价:连接、锁和事务时长。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权