深蓝黑背景封面:左侧大标题「嵌套调用,事务边界画在哪」,副标题「REQUIRED 与 REQUIRES_NEW 的回滚语义差别」;右侧原创示意图——上下两条调用链对比:上面外层事务一个大括号包住内层方法,标注 REQUIRED 同生共死;下面外层事务进行到一半挂起,旁边新生一个独立小事务括号,标注 REQUIRES_NEW 自立门户;左上标注「面试经典 · Java 后端」,右上标注「Day 08 · 11-05」 图 1:本文封面,同生共死还是自立门户。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:讲讲 Spring 事务的传播属性。传播属性回答的问题很具体:一个带事务的方法被另一个带事务的方法调用时,内层方法的事务边界画在哪。Spring 定义了七种传播行为,官方文档里最常考的是前两个的对照。

REQUIRED:有就加入,没有就新建。

这是默认行为。官方文档的表述:如果当前已存在外层事务,就参与其中;没有,就在本作用域新建一个物理事务。典型场景是 service 门面方法编排若干 repository 方法,所有底层资源都要参与同一个 service 级事务。注意一个文档明确点出的细节:参与进来的内层事务会静默忽略自己声明的隔离级别、超时和只读标志,跟随外层。

真正的考点在回滚。REQUIRED 下,每个方法各有自己的「逻辑事务作用域」,但默认全部映射到同一条物理事务。内层方法抛出运行时异常时,内层作用域会把共享事务标记为 rollback-only;外层即使 catch 住了这个异常、正常走到提交,提交也会失败——因为外层并没有自己决定回滚,这个「被内层静默触发的回滚」对外层是意外的,框架此时抛出 UnexpectedRollbackException。文档解释了动机:决不能让调用方误以为提交成功了,实际上回滚了。

REQUIRES_NEW:挂起外层,另起炉灶。

官方文档一句话:创建一个新事务,如果当前存在事务就把它挂起。内外层是两条独立的物理事务(通常意味着两条数据库连接):内层先提交、先落库,之后外层再失败回滚,也动不了内层已经提交的数据。典型用途是「无论业务成败都要留下痕迹」的操作日志、审计记录。

代价也要讲清楚:多占一条连接(连接池压力翻倍)、内层先提交后外层失败会留下不一致,需要业务补偿或幂等兜底。

两种行为的回滚边界,用代码演示。

下面这段教学模型复刻官方文档描述的语义(不是真实框架),用 Node.js 22.15.0 直接运行(node txpropagation.js):

'use strict';

// 模拟 Spring 事务传播的核心语义(教学模型,复刻官方文档描述的行为):
// 物理事务对象 = 一条数据库连接上的一次 begin/commit/rollback。
// REQUIRED 下内外层共用同一个物理事务;REQUIRES_NEW 会挂起外层、新开一个。

class PhysicalTx {
  constructor(conn) { this.conn = conn; this.rollbackOnly = false; this.done = null; }
}

// 场景一:REQUIRED 嵌套——内层异常被外层 catch,但共享事务已被标记
function scenarioRequired() {
  const log = [];
  const tx = new PhysicalTx('conn-1'); // 外层开启物理事务
  log.push('外层方法开始:开启物理事务(conn-1)');
  try {
    // 内层方法 REQUIRED:不新开事务,加入外层同一个物理事务
    log.push('内层方法 REQUIRED:加入外层事务,共用 conn-1');
    throw new Error('库存不足'); // 内层抛出运行时异常
  } catch (e) {
    // Spring 的行为:内层抛出运行时异常,会把共享事务标记为 rollback-only
    tx.rollbackOnly = true;
    log.push(`内层异常:${e.message},共享事务被标记 rollback-only`);
    log.push('外层 catch 住了异常,以为可以正常收尾');
  }
  // 外层走到提交:事务已是 rollback-only,提交被拒绝
  if (tx.rollbackOnly) {
    tx.done = 'rollback';
    log.push('外层提交 -> 实际执行回滚,并抛出 UnexpectedRollbackException');
  } else {
    tx.done = 'commit';
    log.push('外层提交成功');
  }
  return log;
}

// 场景二:REQUIRES_NEW 嵌套——内层独立事务先提交,外层后失败
function scenarioRequiresNew() {
  const log = [];
  const outer = new PhysicalTx('conn-1');
  log.push('外层方法开始:开启物理事务(conn-1)');
  log.push('内层方法 REQUIRES_NEW:外层挂起,另开物理事务(conn-2)');
  const inner = new PhysicalTx('conn-2');
  inner.done = 'commit'; // 内层正常结束:独立提交,立即落库
  log.push('内层事务独立提交(conn-2),外层恢复');
  // 外层随后失败回滚
  outer.done = 'rollback';
  log.push('外层抛异常:回滚(conn-1)');
  log.push(`最终:内层已 ${inner.done},外层已 ${outer.done} —— 两处数据不一致,需补偿/幂等兜底`);
  return log;
}

console.log('=== REQUIRED:内层异常 + 外层 catch ===');
console.log(scenarioRequired().join('\n'));
console.log('\n=== REQUIRES_NEW:内层先提交,外层后失败 ===');
console.log(scenarioRequiresNew().join('\n'));

实测输出:

=== REQUIRED:内层异常 + 外层 catch ===
外层方法开始:开启物理事务(conn-1)
内层方法 REQUIRED:加入外层事务,共用 conn-1
内层异常:库存不足,共享事务被标记 rollback-only
外层 catch 住了异常,以为可以正常收尾
外层提交 -> 实际执行回滚,并抛出 UnexpectedRollbackException

=== REQUIRES_NEW:内层先提交,外层后失败 ===
外层方法开始:开启物理事务(conn-1)
内层方法 REQUIRES_NEW:外层挂起,另开物理事务(conn-2)
内层事务独立提交(conn-2),外层恢复
外层抛异常:回滚(conn-1)
最终:内层已 commit,外层已 rollback —— 两处数据不一致,需补偿/幂等兜底

两个场景正好是两个方向的坑:REQUIRED 的坑是「catch 了也没用」,共享事务早已被标记,UnexpectedRollbackException 就是框架在拦你;REQUIRES_NEW 的坑是「想一起回滚却滚不动」,内层早已独立落库。

对照加陷阱图:上方对照 REQUIRED 与 REQUIRES_NEW 在四个维度上的差别——事务归属(共用还是新开)、连接使用(同一条还是两条)、外层回滚影响内层(会还是不会)、内层异常影响外层(默认会还是不影响已提交部分)。中部左侧讲经典陷阱:REQUIRED 下内层抛异常即使被外层 catch,事务已被标记 rollback-only,提交时照样报 UnexpectedRollback;右侧讲 REQUIRES_NEW 的代价:多占一条连接、内层先提交后外层再失败会留下不一致,需要补偿或幂等。下方一行带过其余传播级别:SUPPORTS、NOT_SUPPORTED、NESTED 各自的定位。底部提示:选传播行为前,先画清楚「谁挂了谁不能活」的业务依赖关系 图 2:先画「谁挂了谁不能活」,再选传播行为。

其余五个级别与可能的追问。

按官方枚举速览:SUPPORTS 有事务就加入、没有就非事务执行;MANDATORY 强制要求已存在事务否则抛异常;NOT_SUPPORTED 以非事务方式执行并挂起当前事务;NEVER 要求绝不许有事务;NESTED 在当前事务里开嵌套事务(基于保存点,内层回滚不影响外层已做的部分,但外层回滚会带着内层一起回——默认只有特定事务管理器支持)。

追问一:「为什么同一个类里 this 调用另一个事务方法,传播不生效?」声明式事务靠代理实现,自调用绕过了代理,事务切面根本没机会触发——这是面试里传播问题最常见的连问。追问二:「计费扣款和任务状态流转该用哪种?」画清楚业务依赖——「内层失败外层必须跟着失败」用 REQUIRED;「内层无论成败都要留痕」用 REQUIRES_NEW。AI 平台的计费与任务流转常在一个请求里嵌套多个事务,传播行为选错,就会出现「内层失败把外层一起回滚」的资损级故障。

你在项目里踩过 UnexpectedRollbackException 吗?当时是传播行为选错了,还是异常处理吞掉了关键信号?欢迎聊聊。