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:一个物理事务,多个逻辑作用域
只有最外层的逻辑事务结束时,Spring 才会真正提交或回滚连接。内层方法「结束」只是退出了自己的逻辑作用域。
UnexpectedRollbackException 是怎么来的
@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); // 以为吞掉异常就能继续提交
}
}
}执行过程:
pointService.add抛出运行时异常,它的事务拦截器按规则把共享的物理事务标记为 rollback-only。- 外层
catch住了异常,placeOrder正常返回。 - 外层拦截器准备提交,发现事务已被标记为只能回滚,于是回滚,并抛出
UnexpectedRollbackException。
实测结果和上面一致:外层收到 UnexpectedRollbackException,订单没有落库。官方文档对这个行为的解释是:调用方不应该在事务实际没有提交时,误以为提交成功了。所以这个异常是在保护你,而不是 bug。
如果加积分失败确实可以忽略,有三种正确改法:
| 改法 | 做法 | 适用场景 |
|---|---|---|
| 内层不参与事务回滚 | 在 PointService.add 上用 noRollbackFor 声明该异常不回滚 | 异常表示业务上的「未执行」,没有写入半截数据 |
| 局部回滚 | 内层改为 NESTED,失败时只回滚到保存点 | 使用 JDBC 事务,且内层可能已经写入部分数据 |
| 移出主事务 | 主事务提交后再异步加积分,失败重试 | 加积分本就不需要与下单强一致 |
四、REQUIRES_NEW:独立提交的代价
4.1 连接池死锁
外层事务在内层执行期间仍然持有连接 A,内层还要再拿一个连接 B。假设连接池大小为 10,恰好有 10 个请求同时进入外层事务并占满连接,它们都会卡在获取连接 B 上,直到超时。
实测把连接池设为 2 个连接,让两个请求同时进入外层事务再调用 REQUIRES_NEW:两个请求都等满连接超时(实验里设为 1 秒)后失败,异常是 CannotCreateTransactionException。Spring 官方文档专门提醒:除非连接池大小至少比并发线程数多 1,否则不要使用 REQUIRES_NEW。实际系统中很难保证这一点,所以要尽量缩短外层事务,或者把独立提交的逻辑挪到事务之外。
4.2 业务语义:独立提交会不会留下假事实
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
auditService.record("ORDER_CREATED", order.id()); // REQUIRES_NEW,立刻提交
paymentClient.freeze(order); // 抛异常,订单回滚
}审计记录已经独立提交,写着「订单已创建」,但订单本身被回滚了。这条记录是真是假,取决于它的语义:
- 如果它记录的是尝试(「用户尝试下单」),独立提交是对的,失败的尝试也需要留痕;
- 如果它记录的是结果(「订单已创建」),就应该在主事务提交后再写。
后一种情况可以用事务同步回调:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
auditService.record("ORDER_CREATED", event.orderId());
}需要注意,AFTER_COMMIT 回调执行时进程可能崩溃,所以它适合「丢了可以接受或可以对账补偿」的操作。如果通知必须可靠送达,应使用事务型 Outbox(见 上下文集成):在同一个本地事务中写业务表和待发送事件表,再由独立任务投递。Kafka 场景的做法见 Kafka 不丢、不重与 Exactly Once。
五、NESTED:保存点,而不是新事务
@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_NEW | NESTED |
|---|---|---|
| 物理事务 | 两个 | 一个 |
| 数据库连接 | 两个 | 一个 |
| 内层回滚 | 不影响外层 | 回滚到保存点,外层继续 |
| 外层回滚 | 内层已提交的数据保留 | 内层数据一起回滚 |
| 锁 | 内外层是两个事务,可能互相等待对方持有的锁 | 同一事务内,没有这个问题 |
| 支持范围 | 大多数事务管理器 | 官方文档说明只适用于 JDBC 资源事务 |
REQUIRES_NEW 还有一个隐蔽问题:如果外层已经对某行加了锁,内层独立事务再去更新同一行,会等外层释放锁,而外层在等内层返回,结果是一直等到锁超时。InnoDB 看到的是两个事务之间的普通锁等待,不会当作死锁立即回滚其中一个。实测(锁等待超时设为 2 秒)内层等满 2 秒后抛出 CannotAcquireLockException;MySQL 的默认值是 50 秒,这段时间里内外两个事务各占着一个连接,都被卡住。NESTED 在同一个事务里,不会出现这种情况。
六、回滚规则
6.1 默认规则与显式声明
默认只有 RuntimeException 和 Error 会触发回滚。如果业务异常是受检异常,需要显式声明:
@Transactional(rollbackFor = PaymentRejectedException.class)
public void confirmPayment(PaymentCommand command) throws PaymentRejectedException {
// ...
}Spring Framework 6.2 新增了全局开关,项目统一使用受检业务异常时可以考虑:
@Configuration
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
class TransactionConfig {
}不要为了省事在每个方法上都写 rollbackFor = Exception.class 而不去想语义。有些受检异常代表「业务上拒绝但数据是一致的」,比如校验失败后写了一条拒绝记录,这时回滚反而会丢掉这条记录。
6.2 吞掉异常等于告诉 Spring 成功了
@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、幂等与对账来保证。
八、怎么选
- 默认使用
REQUIRED。 绝大多数业务方法只需要这一种。 - 需要独立提交的「尝试记录」或失败日志,才考虑
REQUIRES_NEW,并确认连接池余量和锁冲突。 - 需要记录「成功结果」或发通知,用
AFTER_COMMIT回调,要求可靠时用 Outbox。 - 批量处理中需要局部回滚,在 JDBC 事务下使用
NESTED。 - 任何传播行为都不能替代「短事务」:远程调用、文件上传、人工确认都不应该放在数据库事务里。
九、常见误区
- 「内层方法
catch了异常就不会影响外层」:REQUIRED下内层拦截器已经把共享事务标记为回滚,外层提交时会抛UnexpectedRollbackException。 - 「
REQUIRES_NEW更安全,用它隔离一下」:它多占一个连接,还可能与外层事务互相等锁。 - 「
NESTED和REQUIRES_NEW差不多」:前者是保存点,外层回滚时内层数据一起回滚;后者是独立事务,已提交的数据不会回滚。 - 「
@Transactional加在private方法上也行」:代理模式下不会生效,且不会报错。 - 「传播行为能管住远程调用」:本地事务管不到其他进程。
小结
理解事务传播,关键是分清物理事务和逻辑事务:REQUIRED 共享一个物理事务,所以内层失败会波及外层;REQUIRES_NEW 用额外的连接换来独立提交;NESTED 用保存点换来局部回滚。选择时先问业务语义——这条记录在主流程失败时应不应该留下——再看技术代价:连接、锁和事务时长。
配套实验
- codesphere-labs/spring/transaction-propagation:
UnexpectedRollbackException、noRollbackFor、NESTED 与 REQUIRED 的批量导入对照、REQUIRES_NEW 的独立提交、连接池耗尽与同行锁等待、rollbackOn、AFTER_COMMIT(验证记录)
参考资料