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 上实测过,完整程序见文末配套实验。

理解传播行为的关键是分清物理事务和逻辑事务:物理事务是数据库连接上真实的一次 BEGIN ... COMMIT,逻辑事务是 Spring 为每个 @Transactional 方法维护的作用域,多个逻辑事务可以共享一个物理事务。选择时先问业务语义(这条记录在主流程失败时应不应该留下),再看技术代价:连接、锁和事务时长。

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

传播行为当前有事务当前无事务一句话理解
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

3.1 连接池死锁 ​

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

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

3.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 在同一个事务里,不会出现这种情况。

五、回滚规则 ​

5.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 而不去想语义。有些受检异常代表「业务上拒绝但数据是一致的」,比如校验失败后写了一条拒绝记录,这时回滚反而会丢掉这条记录。

5.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. 任何传播行为都不能替代「短事务」:远程调用、文件上传、人工确认都不应该放在数据库事务里。

配套实验

参考资料

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