图 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 的坑是「想一起回滚却滚不动」,内层早已独立落库。
图 2:先画「谁挂了谁不能活」,再选传播行为。
其余五个级别与可能的追问。
按官方枚举速览:SUPPORTS 有事务就加入、没有就非事务执行;MANDATORY 强制要求已存在事务否则抛异常;NOT_SUPPORTED 以非事务方式执行并挂起当前事务;NEVER 要求绝不许有事务;NESTED 在当前事务里开嵌套事务(基于保存点,内层回滚不影响外层已做的部分,但外层回滚会带着内层一起回——默认只有特定事务管理器支持)。
追问一:「为什么同一个类里 this 调用另一个事务方法,传播不生效?」声明式事务靠代理实现,自调用绕过了代理,事务切面根本没机会触发——这是面试里传播问题最常见的连问。追问二:「计费扣款和任务状态流转该用哪种?」画清楚业务依赖——「内层失败外层必须跟着失败」用 REQUIRED;「内层无论成败都要留痕」用 REQUIRES_NEW。AI 平台的计费与任务流转常在一个请求里嵌套多个事务,传播行为选错,就会出现「内层失败把外层一起回滚」的资损级故障。
你在项目里踩过 UnexpectedRollbackException 吗?当时是传播行为选错了,还是异常处理吞掉了关键信号?欢迎聊聊。