Spring 事务传播行为不是背枚举:7 种模式的真实应用与踩坑实验
事务传播行为回答的不是“要不要事务”,而是:当前方法被调用时,如果线程上已经存在事务,它应该加入、挂起、嵌套,还是直接拒绝?
理解传播行为必须从调用链出发。单独调用一个标注 REQUIRES_NEW 的方法时,它看起来和 REQUIRED 没有区别;只有外层已有事务时,差异才出现。
一、七种传播行为总览
| 传播行为 | 已有事务 | 没有事务 |
|---|---|---|
REQUIRED | 加入 | 新建 |
REQUIRES_NEW | 挂起外层并新建 | 新建 |
SUPPORTS | 加入 | 非事务执行 |
NOT_SUPPORTED | 挂起 | 非事务执行 |
MANDATORY | 加入 | 抛异常 |
NEVER | 抛异常 | 非事务执行 |
NESTED | 创建保存点 | 新建事务 |
最常用的是 REQUIRED 和 REQUIRES_NEW,NESTED 只在明确理解数据库保存点及事务管理器支持时使用。
二、REQUIRED:共享一个物理事务
@Transactional
public void createOrder() {
orderRepository.insert(...);
inventoryService.deduct(...); // 默认 REQUIRED
}
两个方法属于不同逻辑事务边界,但底层共享同一个物理事务。任何一处未处理的运行时异常都会导致整体回滚。
危险场景是内部方法把共享事务标记为 rollback-only,外层却捕获异常并尝试正常返回:
@Transactional
public void createOrder() {
try {
inventoryService.deduct();
} catch (RuntimeException e) {
log.warn("忽略库存失败", e);
}
orderRepository.insert(...);
}
最终提交阶段可能抛出 UnexpectedRollbackException。原因不是 Spring 随机回滚,而是内层失败已经把共享物理事务标记为只能回滚,外层到提交时才发现。
三、REQUIRES_NEW:独立事务并非没有成本
典型场景是无论主业务成功与否,都希望保存审计记录:
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action, String result) {
auditRepository.insert(action, result);
}
}
外层事务被挂起,审计服务获取新的数据库连接并独立提交。因此外层回滚不会撤销审计记录。
注意三个代价:
- 同一线程在调用期间可能同时占用外层和内层两条连接。
- 并发高时连接池需求放大,可能互相等待。
- 独立事务看不到外层尚未提交的数据,反之也要考虑隔离级别。
如果 50 个请求都占着外层连接等待新连接,而连接池最大值也是 50,就可能形成资源僵局。使用 REQUIRES_NEW 时应结合最大并发和嵌套深度评估连接池。
四、NESTED:保存点,不是新事务
NESTED 通常在同一物理事务中建立 savepoint:
@Transactional(propagation = Propagation.NESTED)
public void importOne(Row row) {
repository.insert(row);
}
内部失败可以回滚到保存点,外层决定是否继续。但外层最终回滚时,内部“成功”的数据也会一起回滚。
它与 REQUIRES_NEW 的本质区别:
REQUIRES_NEW = 两个物理事务,内层可独立提交
NESTED = 一个物理事务中的保存点,无法脱离外层最终结果
并非所有事务管理器和数据库操作都支持嵌套保存点。DataSourceTransactionManager 对单个 JDBC DataSource 是典型支持者;JpaTransactionManager 的保存点能力主要面向底层 JDBC 连接,JPA EntityManager 的持久化上下文状态不会自动跟随保存点完整回滚,因此不能把它当成通用的“JPA 子事务”。使用前必须通过真实数据库集成测试验证。
五、SUPPORTS 与 NOT_SUPPORTED
SUPPORTS 有事务就加入,没有就普通执行,常用于纯查询辅助方法。但同一方法在不同调用环境下可能表现不同,增加推理难度。
NOT_SUPPORTED 会挂起现有事务,适合明确不希望长耗时操作占用事务的场景。例如生成大报表不应在数据库事务中长时间运行。但它不等于“只读”或“更快”,只是明确非事务执行。
六、MANDATORY 与 NEVER:用约束暴露错误调用
MANDATORY 要求调用方必须已有事务,否则立即抛异常。它适合只允许作为事务步骤使用的底层组件。
NEVER 则要求调用时不能存在事务,适用于明确禁止事务包裹的逻辑。这两种模式使用不多,但能把隐式假设变成运行时约束。
七、传播行为为什么看起来没生效
最常见原因仍是自调用:
@Transactional
public void outer() {
inner();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() { ... }
inner() 没有经过代理,Spring 无法挂起外层并创建新事务。必须由另一个 Bean 的代理调用,或重新设计事务边界。
另一个常见误区是只看方法注解,不确认实际使用哪个事务管理器。多数据源下两个方法可能根本不在同一资源事务里。
八、三个真实场景如何选择
场景 1:下单与扣库存必须同成同败
使用默认 REQUIRED,前提是操作同一数据库或同一事务资源。跨数据库、跨服务不能靠传播行为实现原子性。
场景 2:主业务失败也必须记录失败日志
可以让独立 AuditService 使用 REQUIRES_NEW。若审计量大或允许最终一致,更适合事务 Outbox 后异步写审计系统。
场景 3:批量导入允许单行失败
小批量且数据库支持保存点时可评估 NESTED。大批量导入更推荐分批事务、失败记录和可重跑机制,避免一个超大外层事务。
九、测试传播行为
测试必须跨 Bean,并检查最终数据库状态:
@Service
class OuterService {
@Transactional
public void execute() {
orderRepository.insert("A");
auditService.record("A"); // REQUIRES_NEW
throw new RuntimeException("rollback outer");
}
}
预期结果:订单 A 不存在,审计 A 存在。测试时还应开启事务日志观察:
logging.level.org.springframework.transaction=TRACE
十、生产检查清单
- 调用是否真正跨过 Spring 代理?
- 内外层是逻辑事务还是独立物理事务?
- 异常被谁捕获,何时设置 rollback-only?
REQUIRES_NEW的连接池容量是否覆盖并发与嵌套深度?NESTED的事务管理器、驱动和数据库是否支持保存点?- 是否误把本地事务传播当成跨服务分布式事务?
- 是否用真实数据库集成测试验证最终状态?
总结
选择传播行为时,应先回答三个问题:内层是否必须独立提交、外层失败时内层数据是否保留、调用链是否真的经过代理。大多数业务使用 REQUIRED 即可;REQUIRES_NEW 适合明确的独立提交语义但会消耗额外连接;NESTED 是保存点方案而不是独立事务。不要为了“保险”随意更换传播级别。
相关文章
@Transactional 为什么会失效?从代理边界到回滚规则的系统排查
系统梳理 Spring 事务失效的常见场景,包括自调用、非容器对象、异常被吞、回滚规则、多线程、错误事务管理器与数据库限制,并给出验证和修复方案。
配置中心动态刷新是如何实现的?有哪些一致性风险?
从 Spring Environment、配置绑定和作用域代理出发,分析动态刷新中的半新半旧、集群版本漂移、回滚失败与安全治理。
Spring 中如何正确传播用户、租户和链路上下文?
系统分析 ThreadLocal、MDC、SecurityContext 在异步线程、CompletableFuture、Reactor 和虚拟线程中的传播与清理方案。