Spring 事务传播行为:REQUIRED、REQUIRES_NEW、NESTED 等语义(089)

Spring与Spring Boot约 13 分钟阅读7,029 字符
知识库文档 90 / 251

一、承接 088:代理能拦截方法,但"事务"具体是怎么被管理的

088 讲完 JDK 动态代理和 CGLIB 之后,只差最后一环没打通:@Transactional 标注的方法,最终是通过代理拦截调用、在方法执行前后插入"开启事务/提交/回滚"逻辑——这个拦截器叫 TransactionInterceptor,本质上和 087 讲的 @Around 通知没有本质区别。但事务这个话题真正的难点,不在"怎么拦截",而在当一个事务方法调用另一个事务方法时,两者的事务边界应该怎么划分——这就是"事务传播行为"(Propagation)要解决的问题,也是面试里最容易被问倒的一块。

二、先厘清一个核心概念:物理事务 vs 逻辑事务

数据库连接层面只有一种"事务":一次 BEGIN 到一次 COMMIT/ROLLBACK,这叫物理事务(Physical Transaction),绑定在一个具体的数据库连接上。但 Spring 的事务管理是方法级的:一个方法标了 @Transactional,进入这个方法时可能会开一个新事务,也可能"加入"一个已经存在的事务——这种方法级的、可以嵌套、可以叠加的事务边界,叫逻辑事务(Logical Transaction)。

java
@Service
public class OrderService {
    @Autowired private InventoryService inventoryService;

    @Transactional // 逻辑事务 A
    public void createOrder(String item) {
        // ... 插入订单记录
        inventoryService.deduct(item); // 调用另一个事务方法
    }
}

@Service
public class InventoryService {
    @Transactional // 逻辑事务 B
    public void deduct(String item) {
        // ... 扣减库存
    }
}

createOrder 调用 deduct 时,逻辑事务 A 和逻辑事务 B 是否共享同一个物理事务(同一个数据库连接、同一次提交/回滚),完全取决于 deduct 方法上配置的传播行为——这正是本文要讲的核心。

文章配图
文章配图

三、七种传播行为逐个拆解

Spring 定义了 7 种传播行为(Propagation 枚举),核心可以分成三类:加入现有事务、开启独立新事务、不参与事务。

java
public enum Propagation {
    REQUIRED,      // 默认值:有事务就加入,没有就新建
    SUPPORTS,      // 有事务就加入,没有就以非事务方式运行
    MANDATORY,     // 必须有事务,没有就抛异常
    REQUIRES_NEW,  // 无论是否有事务,都挂起当前事务,强制开启一个全新的独立事务
    NOT_SUPPORTED, // 挂起当前事务,以非事务方式运行
    NEVER,         // 必须没有事务,否则抛异常
    NESTED         // 如果有事务,在当前事务内建一个"嵌套事务"(基于 Savepoint)
}

用一张对照表理清"外层有事务时"每种传播行为的实际行为:

传播行为 外层已有事务时 外层没有事务时
REQUIRED(默认) 加入外层事务 新建事务
SUPPORTS 加入外层事务 以非事务方式运行
MANDATORY 加入外层事务 抛出 IllegalTransactionStateException
REQUIRES_NEW 挂起外层事务,新建独立事务 新建事务
NOT_SUPPORTED 挂起外层事务,以非事务方式运行 以非事务方式运行
NEVER 抛出 IllegalTransactionStateException 以非事务方式运行
NESTED 在外层事务内建立 Savepoint 保存点 新建事务(行为等同 REQUIRED)

文章配图
文章配图

四、REQUIRED vs REQUIRES_NEW:面试最爱的对比点

这两者的区别不是"要不要开新事务"这么简单,核心差异在提交/回滚的独立性:

java
@Service
public class OrderService {
    @Autowired private LogService logService;

    @Transactional
    public void createOrder(String item) {
        saveOrder(item);
        logService.writeLog("订单创建:" + item); // 假设这里抛异常
    }
}

@Service
public class LogService {
    // 场景 A:REQUIRED(默认)
    @Transactional(propagation = Propagation.REQUIRED)
    public void writeLog(String msg) {
        // 插入日志记录
        throw new RuntimeException("日志服务异常");
    }

    // 场景 B:REQUIRES_NEW
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void writeLog(String msg) {
        // 插入日志记录
        throw new RuntimeException("日志服务异常");
    }
}

场景 A(REQUIRED):writeLog 加入 createOrder 的同一个物理事务,writeLog 内部抛异常会导致整个物理事务被标记为回滚(rollbackOnly),最终 saveOrder 插入的订单记录也会被一起回滚——即使 createOrder 自己 try-catch 吞掉了这个异常,事务依然会回滚(因为 rollbackOnly 标记已经被打上,提交时 Spring 会检测到这个标记直接触发回滚而不是真正提交)。

场景 B(REQUIRES_NEW):writeLog 会先把 createOrder 的物理事务挂起(suspend,挂起的本质是把当前事务绑定的资源如连接、TransactionSynchronizationManager 中的状态暂存起来),开启一个全新的、独立的物理事务来执行日志插入;writeLog 抛出的异常只会导致这个新事务自己回滚(日志记录不会被插入),但外层 createOrder 的事务在 writeLog 返回后被恢复(resume),如果外层没有再抛异常,saveOrder 插入的订单记录依然会被正常提交——这正是"日志、审计、通知类操作不应该拖累主业务事务"这一常见需求的标准解法。

文章配图
文章配图

五、NESTED:容易被忽略但很重要的第三种形态

NESTED 和 REQUIRES_NEW 经常被混淆,但底层实现完全不同:REQUIRES_NEW 是两个完全独立的物理事务(两个数据库连接层面的事务),而 NESTED 是同一个物理事务内部,通过数据库的 Savepoint(保存点)机制模拟出的嵌套结构。

java
@Transactional
public void createOrder(String item) {
    saveOrder(item);
    try {
        inventoryService.deductNested(item); // NESTED
    } catch (Exception e) {
        // 捕获异常后,外层事务不受影响,可以继续执行、正常提交
        log.warn("库存扣减失败,已回滚到保存点,订单仍然创建", e);
    }
}

@Transactional(propagation = Propagation.NESTED)
public void deductNested(String item) {
    // 扣减逻辑,如果这里抛异常,只会回滚到进入这个方法前设置的 Savepoint
}

NESTED 的关键行为:deductNested 抛出异常时,事务管理器会执行 rollback(savepoint),只撤销 deductNested 内部做的数据库操作,回滚点之前(saveOrder)的操作完全不受影响,外层事务捕获异常后可以继续正常提交——这是 REQUIRED 做不到的(REQUIRED 一旦内层抛异常,整个物理事务被标记 rollbackOnly,外层即使捕获异常也无法正常提交,提交时会抛 UnexpectedRollbackException)。

需要特别注意的限制:NESTED 依赖数据库/JDBC 驱动对 Savepoint 的支持(DataSourceTransactionManager 支持,但如果底层用的是 JTA 事务管理器,NESTED 通常不受支持,会在启动或运行期报错),这也是为什么 NESTED 在实际项目里出现频率远低于 REQUIRED 和 REQUIRES_NEW。

文章配图
文章配图

六、源码链路:事务管理器怎么根据传播行为分派逻辑

java
// AbstractPlatformTransactionManager#getTransaction(简化后的核心分派逻辑)
public TransactionStatus getTransaction(TransactionDefinition definition) {
    Object transaction = doGetTransaction();
    if (isExistingTransaction(transaction)) {
        // 已经存在事务的情况下,根据传播行为分别处理
        return handleExistingTransaction(definition, transaction);
    }
    // 走到这里说明当前没有事务
    if (definition.getPropagationBehavior() == PROPAGATION_MANDATORY) {
        throw new IllegalTransactionStateException("MANDATORY 但当前没有事务");
    } else if (definition.getPropagationBehavior() == PROPAGATION_REQUIRED
            || definition.getPropagationBehavior() == PROPAGATION_REQUIRES_NEW
            || definition.getPropagationBehavior() == PROPAGATION_NESTED) {
        // 挂起(此时其实没有可挂起的事务,suspendedResources 为 null),然后开启新事务
        SuspendedResourcesHolder suspendedResources = suspend(null);
        return startTransaction(definition, transaction, suspendedResources);
    } else {
        // SUPPORTS / NOT_SUPPORTED / NEVER:以空事务状态运行
        return prepareTransactionStatus(definition, null, true, ...);
    }
}

// handleExistingTransaction 简化(已存在事务时的分支)
private TransactionStatus handleExistingTransaction(TransactionDefinition definition, Object transaction) {
    if (definition.getPropagationBehavior() == PROPAGATION_NEVER) {
        throw new IllegalTransactionStateException("NEVER 但当前已有事务");
    }
    if (definition.getPropagationBehavior() == PROPAGATION_NOT_SUPPORTED) {
        Object suspendedResources = suspend(transaction); // 挂起当前事务
        return prepareTransactionStatus(definition, null, false, ...);
    }
    if (definition.getPropagationBehavior() == PROPAGATION_REQUIRES_NEW) {
        SuspendedResourcesHolder suspendedResources = suspend(transaction); // 挂起
        return startTransaction(definition, transaction, suspendedResources); // 开新事务
    }
    if (definition.getPropagationBehavior() == PROPAGATION_NESTED) {
        // 用 Savepoint 实现嵌套,不挂起,不新建物理事务
        DefaultTransactionStatus status = prepareTransactionStatus(definition, transaction, false, ...);
        status.createAndHoldSavepoint();
        return status;
    }
    // REQUIRED / SUPPORTS / MANDATORY:直接加入现有事务,什么都不用做
    return prepareTransactionStatus(definition, transaction, false, ...);
}

从源码能直接读出一个关键规律:只有**REQUIRES_NEW**会真正调用**suspend()**挂起当前事务并开启一个全新的物理事务;NESTED 虽然行为上"看起来独立",但底层根本不挂起、不新开物理事务,只是在当前事务上打一个 Savepoint;而 REQUIRED/SUPPORTS/MANDATORY 在已有事务时什么额外动作都不做,纯粹是"共享"。

文章配图
文章配图

七、事务上下文怎么跨方法传递:

一个自然的疑问:createOrder 和 deduct 是两个不同的方法调用,甚至是两个不同的 Bean,Spring 怎么知道"当前是不是已经在一个事务里"?答案是 **TransactionSynchronizationManager**,它内部用一组 ThreadLocal 变量维护"当前线程绑定的事务资源"(数据库连接、Savepoint 状态等):

java
// TransactionSynchronizationManager 内部结构(简化)
public abstract class TransactionSynchronizationManager {
    private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("Transactional resources");
    private static final ThreadLocal<TransactionSynchronization> synchronizations = ...;
    // ...

    public static Object getResource(Object key) {
        Map<Object, Object> map = resources.get();
        return (map != null ? map.get(key) : null);
    }
}

正因为是 ThreadLocal,事务上下文的传递有一个非常重要的边界:只在同一个线程内有效。如果 deduct 方法是在一个新起的线程(比如 @Async 异步方法、手动 new Thread()、线程池提交的任务)里执行的,TransactionSynchronizationManager 在新线程里读到的资源是空的,REQUIRED 传播行为会表现成"新建一个事务"而不是"加入外层事务"——这也是"事务不生效"排查里,除了 087 讲的自调用问题之外,另一个高频根因:跨线程场景下,事务边界天然被切断,传播行为的"加入"语义完全失效。

文章配图
文章配图

八、常见误区:自调用导致传播行为完全不生效

这是 087 讲过的"AOP 自调用失效"问题在事务场景下的具体表现,专门拿出来强调是因为它在事务话题上出现频率极高:

java
@Service
public class OrderService {
    @Transactional(propagation = Propagation.REQUIRED)
    public void createOrder(String item) {
        saveOrder(item);
        this.writeLogNested(item); // 自调用!不经过代理
    }

    @Transactional(propagation = Propagation.NESTED)
    public void writeLogNested(String item) {
        // 这里配置的 NESTED 完全不会生效!
        // 因为 this.writeLogNested() 是直接调用当前对象的方法,
        // 根本没有经过 Spring 生成的事务代理对象,
        // TransactionInterceptor 不会被触发,writeLogNested 会被当成普通方法直接执行,
        // 沿用 createOrder 当前所在的事务上下文,等同于隐式的 REQUIRED,NESTED 配置形同虚设
    }
}

排查这类问题的标志性特征是:"配置的传播行为(比如 NESTED/REQUIRES_NEW)看起来完全没生效,行为上就像所有方法都是同一个 REQUIRED 事务"——这几乎总是自调用导致的,解决方式和 087 讲的一致:拆分到另一个 Bean、注入自身的代理引用、或用 AopContext.currentProxy()。

文章配图
文章配图

九、总结

  1. 物理事务是数据库层面的一次 BEGIN-COMMIT,逻辑事务是 Spring 方法级的事务边界,一个方法调用另一个方法时,两个逻辑事务是否共享同一个物理事务由传播行为决定。
  2. REQUIRED(默认)与内层共享物理事务,内层异常会通过 rollbackOnly 标记拖累外层一起回滚;REQUIRES_NEW 挂起外层、开启完全独立的新物理事务,内层回滚不影响外层。
  3. NESTED 基于同一物理事务内的 Savepoint 机制,回滚粒度可以做到"只撤销嵌套部分",但依赖具体事务管理器/数据库支持,JTA 场景通常不支持。
  4. 事务上下文通过**TransactionSynchronizationManager**的**ThreadLocal**在同一线程内传递,跨线程(@Async、手动开线程)场景下事务边界天然被切断。
  5. 自调用会让传播行为配置完全失效,本质原因和 087 讲的 AOP 自调用失效完全一致——都是因为没有经过代理对象。

延伸阅读

  • 相关源码:AbstractPlatformTransactionManager#getTransaction/handleExistingTransaction、TransactionSynchronizationManager、DataSourceTransactionManager
  • 本系列相关篇目:086(循环依赖)、087(Spring AOP 原理)、088(JDK 动态代理与 CGLIB)、090-092(Spring 事务后续系列)

提问回答模块(面试官视角,不计入阅读时长)

以下问题不是对文章内容的复述,而是模拟面试官会如何在这个话题上继续追问、绕开常见答案去考察真正的理解深度。

Q1:REQUIRED 场景下,内层方法抛出的异常被外层用 try-catch 捕获吞掉了,事务最终会提交还是回滚?为什么"捕获了异常"没能挽救事务?

会回滚,而且提交时会直接抛出 UnexpectedRollbackException。根因在于 rollbackOnly 标记是在内层事务方法抛异常、被 TransactionInterceptor 的异常处理逻辑捕获时就已经写入 TransactionStatus 的,这个标记是绑定在物理事务上的全局状态,不会因为外层 Java 代码用 try-catch 挡住了异常的传播就被撤销。外层方法即使正常 return,最外层负责提交的 TransactionInterceptor 在调用 commit() 前会检查这个标记,一旦发现被标记为 rollbackOnly,就会转而调用 rollback(),并抛出 UnexpectedRollbackException 告知调用方"你以为的提交并没有真正发生"。这道题最容易被想当然地答成"捕获了异常事务就没事了",而恰恰是 REQUIRED 传播行为下最反直觉的一点。

Q2:如果一个 REQUIRES_NEW 的方法在事务挂起(suspend)期间,外层事务持有的数据库连接会发生什么?两个物理事务用的是同一个数据库连接吗?

不是同一个连接。suspend() 的本质操作是把当前线程通过 TransactionSynchronizationManager 绑定的资源(包括当前占用的 Connection)从 ThreadLocal 中"摘下来"暂存到一个 SuspendedResourcesHolder 对象里,而不是真的把物理连接归还给连接池;随后 REQUIRES_NEW 会从连接池重新获取一个新的连接来开启全新的物理事务。这意味着挂起期间,外层事务原本占用的连接依然被这个挂起的上下文"攥在手里"(不会被连接池回收复用),只是暂时不在 ThreadLocal 里可见——如果连接池容量设置得比较小,高并发场景下大量使用 REQUIRES_NEW 会导致连接数成倍消耗,这是实际生产环境里 REQUIRES_NEW 滥用导致连接池耗尽的一个常见根因,而且很容易被误判成"连接泄漏"去排查了很久。

Q3:NESTED 传播行为依赖 Savepoint,如果外层事务的隔离级别是 READ_UNCOMMITTED,NESTED 内部的回滚会不会影响到已经被其他并发事务"脏读"到的数据?

这个问题触及 Savepoint 回滚的本质:Savepoint 回滚只撤销当前这一个物理事务内、在该保存点之后所做的修改,它和隔离级别控制的"事务之间能看到彼此哪些未提交/已提交的数据"是完全不同的维度。如果隔离级别是 READ_UNCOMMITTED,其他并发事务确实有可能在 NESTED 部分提交之前的中间状态就读到了这些尚未确定的修改(脏读),即使之后这部分被回滚到 Savepoint,那些已经脏读到旧数据的并发事务也不会自动收到通知或修正——这是数据库隔离级别层面的问题,不是 Spring 事务传播机制能解决的,反而说明 NESTED 配合较低隔离级别使用时,"看起来内部失败不影响外部"的安全感是有前提条件的,隔离级别不够高时依然可能引发数据一致性问题。

Q4:**@Transactional**标注在一个 private 方法上,或者标注在一个类内部被 this 调用的 public 方法上,事务传播行为会不会生效?两种情况是同一个原因吗?

两种情况表现相似(事务都不生效)但根因有微妙差异。private 方法上的 @Transactional 完全不会生效,因为无论是 JDK 动态代理还是 CGLIB,代理层的方法拦截都要求方法在代理类层面"可见且可重写/可实现",private 方法在子类里既不可重写也无法被接口声明,这是 088 讲过的代理机制的天然边界,属于结构性不可行。而"标注在 public 方法但被 this 内部调用"的情况,方法本身完全具备被代理的条件,只是这一次具体的调用没有经过代理对象——这是调用路径问题,不是方法本身不可代理。区分这两者的关键问题是:"如果我把这个方法从另一个 Bean 里通过注入的引用调用,事务会生效吗?"对 private 方法答案仍然是不会(根本没法被代理捕捉到),对自调用场景答案是会(只要走的是代理对象)。

Q5:如果用 XA 协议做分布式事务(比如同时操作两个不同的数据库),Spring 的这套传播行为语义还完全适用吗?REQUIRES_NEW 在分布式事务场景下意味着什么?

在使用 JtaTransactionManager 的场景下,传播行为的语义框架本身不变(仍然是 REQUIRED/REQUIRES_NEW/NESTED 这一套枚举和分支逻辑),但底层实现和"代价"完全不同:JTA 环境下 REQUIRES_NEW 挂起当前事务、开启新事务,意味着要向事务协调器(Transaction Coordinator)申请一个全新的分布式事务上下文,涉及跨多个资源管理器(Resource Manager)的两阶段提交(2PC)协调,开销远高于单一数据库场景下"换一个 JDBC 连接"这么简单。而 NESTED 在纯 JTA 环境下通常根本不受支持——因为 Savepoint 是特定数据库/JDBC 驱动的能力,标准 JTA 规范和大多数 JTA 事务管理器的实现都没有对分布式事务的"部分回滚"提供原生支持,如果代码里对一个 JTA 管理的方法配置了 NESTED,实际运行时通常会在事务管理器判定阶段直接抛出 NestedTransactionNotSupportedException 一类的异常。这道题考察的是"传播行为是一套抽象语义,不代表所有底层事务管理器都能等价地实现它"这一层认知。

Q6:假设你在做代码评审,看到一个方法同时标注了**@Transactional(propagation = Propagation.REQUIRES_NEW)**和**@Async**,你会提出什么质疑?

会直接质疑这个组合的可行性和意图:@Async 让方法在一个全新的、由线程池管理的线程里执行,而 Spring AOP 对同一个方法上多个注解驱动的横切逻辑(事务代理、异步代理)本身是分层叠加的两层代理,执行顺序和线程切换的时机需要格外小心——如果调用方是同步方法内部直接调用这个 @Async 方法,实际执行时"异步切换线程"和"事务传播判断"谁先谁后,会直接决定 REQUIRES_NEW 到底是在原线程里生效还是在新线程里生效(原线程的事务上下文对新线程不可见,TransactionSynchronizationManager 是 ThreadLocal 的,089 正文第七节讲过这一点)。更实际的问题是:既然 REQUIRES_NEW 本身语义上就是"我要一个完全独立、不受外层影响的事务",而 @Async 也已经在做"独立执行、不阻塞调用方"这件事,两者叠加很可能是开发者对两个机制的语义理解有重叠误判,评审时应该反问一句:"你是想要一个独立的事务,还是想要异步执行,还是两者都要——如果都要,你确认过事务边界最终落在了哪个线程上吗?"