Spring 事务传播机制完全指南:自调用失效 + 7 种传播行为 + 9 张图,一次讲透

5 阅读1分钟

Spring 事务传播机制完全指南

从自调用失效到 7 种传播行为,配 8 张图把事务底层讲透

一文速览: 本文围绕 Spring 事务的两大核心 — 传播行为(Propagation) 与 自调用失效陷阱 展开。先讲清楚事务三大接口职责,再逐个拆解 7 种 Propagation 的执行流程,最后聚焦"为什么 @Transactional 加了等于没加"的几个常见坑。

图 1

一、为什么 Spring 事务总让人头大?

很多同学第一次用 @Transactional 都很顺 — 加注解,方法跑在事务里,异常就回滚。一旦遇到嵌套调用 、跨服务调用 、或者不同传播行为混用 ,就开始懵:为啥日志显示事务开启了,数据库数据却没回滚?

根因往往不在数据库,而在于你误以为事务注解一定生效 。本文会帮你把链路彻底打通,先讲三大核心接口的分工。

二、Spring 事务三大核心接口

Spring 事务抽象层由三个接口撑起来,每个职责单一,组合起来才完成一次事务声明周期。

1. PlatformTransactionManager — 平台无关的事务管理器

核心抽象,屏蔽 JDBC / Hibernate / JTA 等底层差异。最常用的实现是 DataSourceTransactionManager(基于 JDBC)。它定义四个关键方法:

    public interface PlatformTransactionManager {
        // 拿到当前事务状态(如果没有则返回 NEW 状态)
        TransactionStatus getTransaction(TransactionDefinition def);
    
        // 提交
        void commit(TransactionStatus status);
    
        // 回滚
        void rollback(TransactionStatus status);
    }

2. TransactionDefinition — 事务定义(配置信息)

描述一个事务的静态属性 :传播行为、隔离级别、超时时间、是否只读、回滚规则。你写 @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED, timeout = 30),最终都会解析成这个对象。

3. TransactionStatus — 事务运行时状态

描述一次具体事务的运行时信息 :是不是新事务、是否有保存点、是否完成、是否回滚。方法执行期间事务拦截器拿着这个对象判断下一步动作。

图 2

三、7 种传播行为全解

下面这张表先给你一个全局印象,后面再逐个拆开讲。

传播行为外部有事务外部无事务典型场景
REQUIRED (默认)加入新建90% 业务增删改
SUPPORTS加入不开事务纯查询可选事务
MANDATORY加入抛异常必须由调用方开启事务
REQUIRES_NEW挂起外部,开新的新建主从分流、独立日志
NOT_SUPPORTED挂起外部,不开不开事务批量导入时禁用事务
NEVER抛异常不开事务不允许在事务内调用
NESTED嵌套(Savepoint)新建子任务失败不影响主

记忆口诀: "需"求新(REQUIRED / REQUIRES_NEW)、支持不支持(SUPPORTS / NOT_SUPPORTED)、强制从不(MANDATORY / NEVER)、嵌套(NESTED)。

四、REQUIRED:你写 90% 的事务都是它

行为: 最常用也是默认。若外部已有事务就加入;否则自己开一个新的。加入意味着用同一个 Connection,所有操作在同一个事务边界 里 — 一起提交、一起回滚。

图 3

实战注意

由于 REQUIRED 用同一事务,doInner 抛异常会导致整个 doOuter 回滚 ,即使 doOuter 自己 try-catch 了也没用 — 异常已被事务管理器标记 rollback-only。

    @Service
    public class OrderService {
        @Autowired private StockService stockService;
    
        @Transactional
        public void placeOrder(Order o) {
            saveOrder(o);              // 1. 插入订单
            try {
                stockService.deduct(o); // 2. 扣库存(内部抛异常)
            } catch (Exception e) {
                log.warn(\"扣库存失败\");
            }
            // 关键:上面 catch 救不了你,事务已经被标记 rollback-only
            // 走到这里 commit 时会抛 UnexpectedRollbackException
        }
    }

踩坑警告: try-catch 子方法异常后还想继续提交?在 REQUIRED 下几乎不可能 。要么改 REQUIRES_NEW,要么在子方法里自己吞掉(不重新抛出,不算"业务异常"),要么用 NESTED 加保存点。

五、REQUIRES_NEW:彻底独立的"小金库"

行为: 无论外部有没有事务,都 开一个新事务,并把外部事务挂起 (保存原事务状态,用另一条 Connection)。等当前方法提交/回滚完,再恢复外部事务。

这是实现"主流程失败不影响日志落地""主流程要回滚子任务必须落"这类需求的关键开关。

图 4

实战:日志一定落地

    @Service
    public class OrderService {
        @Autowired private AuditLogService logService;
    
        @Transactional
        public void placeOrder(Order o) {
            try {
                saveOrder(o);
            } catch (Exception e) {
                logService.auditFail(o.getId(), e.getMessage()); // REQUIRES_NEW
                throw e;
            }
        }
    }
    
    @Service
    public class AuditLogService {
        @Transactional(propagation = Propagation.REQUIRES_NEW)
        public void auditFail(String orderId, String reason) { /* ... */ }
    }

即使 placeOrder 整体回滚,auditFail 已经提交进数据库 ,靠 REQUIRES_NEW 隔离出来的那条独立 Connection 完成提交。

性能提示: REQUIRES_NEW 要切换 Connection、释放/获取锁,性能比 REQUIRED 低一个量级 。一次请求里循环调用 REQUIRES_NEW 方法要慎重 — 连接池很容易被打满。

六、NESTED:保存点级别的"局部回滚"

行为: 外部有事务时,开一个嵌套事务 (JDBC Savepoint)。外部没有事务时退化成 REQUIRED。区别于 REQUIRES_NEW:

  • NESTED 是同一连接 上的保存点,提交时跟外层一起提交;
  • REQUIRES_NEW 是不同连接 ,独立提交。

图 5

典型场景:批量导入 100 行数据,某一行报错时跳过该行,整体继续;每行就是一个 NESTED。注意 它依赖数据库支持 SAVEPOINT(MySQL InnoDB 默认支持)。

七、致命陷阱:自调用失效

讲传播行为至此,最常被忽视的最致命陷阱 必须单独说 — 同一个类内的方法自调用 ,@Transactional 会完全失效 。

金科玉律:@Transactional 的本质是通过 AOP 代理调用 。同类内 this.xxx() 直接调用绕过代理 ,注解等于不存在。

    @Service
    public class OrderService {
        public void placeOrder(Order o) {
            checkStock(o);    // ❌ 失效!this 调用走不到代理
            saveOrder(o);
        }
    
        @Transactional
        public void checkStock(Order o) { /* 库存校验 */ }
    }

上面的 checkStock 不会执行在事务里,因为 this 引用没有走 CGLIB / JDK 代理对象。要解决这类问题,三种标准方案:

图 6

解决方案 1:注入自身代理(推荐)

    @Service
    public class OrderService {
        @Autowired
        private OrderService self;   // 注入的是代理对象
    
        public void placeOrder(Order o) {
            self.checkStock(o);      // ✅ 走代理
            saveOrder(o);
        }
    
        @Transactional
        public void checkStock(Order o) { /* ... */ }
    }

注意 self 不能用 this,要靠 Spring 注入进来。注入时通过 @Lazy 避免循环依赖。

解决方案 2:暴露代理(AopContext)

    @EnableAspectJAutoProxy(exposeProxy = true)  // 全局开启
    @Service
    public class OrderService {
        public void placeOrder(Order o) {
            OrderService self = (OrderService) AopContext.currentProxy();
            self.checkStock(o);
        }
    }

解决方案 3:拆类 — 把要事务保护的方法挪到独立 Bean

结构上更清晰,最稳妥的反模式是类里包打天下。Spring 官方也推荐按职责拆分。

面试常考点: "@Transactional 失效场景有哪些?" 标准答案至少 6 条:1) 自调用、2) 非 public 方法、3) final / static 方法、4) 未被 Spring 管理的类(new 出来的)、5) 异常被 catch 吃掉、6) 抛错类型不在 rollbackFor 范围内。

八、rollbackFor:默认只回滚 RuntimeException

Spring 默认 只对 RuntimeException / Error 回滚 ,遇到 checked exception(IOException 等)不自动回滚。

    @Transactional  // 等同于 rollbackFor = Exception.class 的反义
    public void importFile() throws IOException {  // 编译期异常
        parseFile();   // 抛 IOException
        saveData();
    }

上面这段在 IOException 抛出后不会回滚 ,因为 Spring 认为 checked exception 是业务可预期 的。要回滚就显式指定:

    @Transactional(rollbackFor = Exception.class)
    public void importFile() throws IOException { /* ... */ }

经验法则: 从方法抛出去 的所有 checked 异常,如果调用方需要回滚一致性,就显式写 rollbackFor = Exception.class,免得某个新同事加了 throw new IOException 又踩坑。

九、传播行为选型决策树

图 7

简单口诀:

  • 普通增删改 → REQUIRED(别乱加别的)
  • 日志/审计必须落地 → REQUIRES_NEW
  • 主子任务可独立失败 → NESTED
  • 纯查询 → 不加注解或 @Transactional(readOnly=true)
  • 绝不让别人有事务 → NEVER

十、隔离级别 vs 传播机制:别再混

这两个概念正交 ,但经常被混淆:传播机制 管"是否加入 / 何时开新事务",隔离级别 管"事务与事务之间看见的数据"。

概念关注点作用维度
传播机制 Propagation新事务 vs 加入 vs 嵌套事务边界
隔离级别 Isolation脏读/不可重复读/幻读事务之间可见性
锁 Lock并发修改互斥数据访问互斥

Spring 的 Isolation.READ_COMMITTED 等值会下推到 JDBC,由数据库引擎实现。MySQL InnoDB 默认 REPEATABLE_READ,与 JDBC 标准不同。跨服务调用时,每个微服务都可以设自己的隔离级别,但只在受其管理的 Connection 范围内生效。

图 8

十一、实战 checklist:上线前自检 5 条

  1. 🔍 所有 @Transactional 方法是不是 public? 非 public 不走代理。
  2. 🔍 有没有同类自调用? 有就拆类或注入 self。
  3. 🔍 抛出的异常是不是 RuntimeException? 否则显式 rollbackFor。
  4. 🔍 try-catch 后是否重新 throw? 吞了 + 不 throw = 无法回滚。
  5. 🔍 跨微服务调用 要走 OpenFeign / Dubbo,事务传播只在同进程内有效,不能穿透 HTTP。

再补 3 个常被忽略的自检项:

  • final / static 方法: 代理无法覆盖(final 不可继承、static 属于类不属于实例),@Transactional 直接被无视。
  • private 方法: 同上,无法走代理,注解完全失效。
  • 未被 Spring 管理的对象: 如果业务类用 new 实例化,或用 final class 限定,Spring 容器压根没接管,自然没事务。

一句话总结:@Transactional 只在 Spring 容器创建并经过 AOP 代理的 public 方法上生效 ,四个条件缺一不可。

十二、总结

Spring 事务传播机制表面是 7 个枚举值,本质 只有三件事:要不要新开事务、和谁一起提交、能不能局部回滚 。理解清楚这三点,再记住 REQUIRES_NEW(独立连接)与 NESTED(Savepoint)的差别,工作中 90% 的事务难题都能在一分钟内想明白。


下一篇预告:Spring Boot 启动流程与自动配置原理 — 从 SpringApplication.run() 到自动配置类的加载顺序、@Conditional 系列注解原理、为什么自己的 starter 一行注解就生效。