跳过导航

Spring 事务传播行为不是背枚举:7 种模式的真实应用与踩坑实验

约 7 分钟...次浏览
专栏Spring 深度解析第 7 篇

事务传播行为回答的不是“要不要事务”,而是:当前方法被调用时,如果线程上已经存在事务,它应该加入、挂起、嵌套,还是直接拒绝?

理解传播行为必须从调用链出发。单独调用一个标注 REQUIRES_NEW 的方法时,它看起来和 REQUIRED 没有区别;只有外层已有事务时,差异才出现。

一、七种传播行为总览

传播行为已有事务没有事务
REQUIRED加入新建
REQUIRES_NEW挂起外层并新建新建
SUPPORTS加入非事务执行
NOT_SUPPORTED挂起非事务执行
MANDATORY加入抛异常
NEVER抛异常非事务执行
NESTED创建保存点新建事务

最常用的是 REQUIREDREQUIRES_NEWNESTED 只在明确理解数据库保存点及事务管理器支持时使用。

二、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);
    }
}

外层事务被挂起,审计服务获取新的数据库连接并独立提交。因此外层回滚不会撤销审计记录。

注意三个代价:

  1. 同一线程在调用期间可能同时占用外层和内层两条连接。
  2. 并发高时连接池需求放大,可能互相等待。
  3. 独立事务看不到外层尚未提交的数据,反之也要考虑隔离级别。

如果 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 是保存点方案而不是独立事务。不要为了“保险”随意更换传播级别。

分享:
文章作者:狼码纪
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。文章可能参考了其他优秀文章,如有侵权请联系删除。