明明 catch 了异常,下单还是全回滚:UnexpectedRollbackException 是怎么来的
引言
3 月压测第二天,下单成功率从 99.2% 掉到 87.3%,一天 2,136 单创建失败。
报错不是"库存不足",也不是"锁超时",而是一个很多人第一次见的异常:
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
(Spring 6.x 的提示语;5.x 是 Transaction rolled back because it has been marked as rollback-only,意思一样。)
更让人看不懂的是代码:发积分那段明明包在 try-catch 里,异常被吞了,主流程还打了一行 log.warn("add points failed, continue")。日志确实打出来了,说明 catch 生效;但事务最后还是回滚了,订单没建成。
先给结论:catch 能拦住异常往外抛,拦不住内层事务已经把你标记成 rollback-only。 这篇文章从这起事故出发讲清 7 种传播行为,重点拆开 REQUIRED 和 REQUIRES_NEW——它们差的不只是一个"新"字,而是一条连接,以及随之而来的死锁和连接池风险。
一、第一反应:先查"事务是不是没生效"
我的第一个怀疑是注解没起作用,三条路都查了。
@Transactional 失效的经典原因是自调用:同类内部 this.method() 不走代理,注解形同虚设。翻调用链,这里是 Controller → OrderService → PointService,跨 bean 调用,走的是 CGLIB 代理,注解生效。
第二个怀疑是回滚规则配错。默认只回滚 RuntimeException 和 Error,checked exception 不回滚;这里是运行时异常,规则也对。
第三个怀疑是死锁。查了 DB 日志,没有 Lock wait timeout,也没有死锁检测记录,排除。
三条都排除后,我盯住了异常的全类名。UnexpectedRollbackException 不是业务代码抛的,也不是数据库抛的,是 Spring 在提交阶段自己抛的。它的含义非常精确:整个事务已经被标记为 rollback-only,提交时只能静默回滚;为了防止你以为"提交成功",框架补抛一个异常提醒。
另一个佐证:orderMapper.insert 的 SQL 确实执行过,binlog 能看到对应的 undo 记录,但事务回滚后数据被抹掉。事务是生效的,只是"被判了回滚"。方向立刻从"事务没生效"转到"谁把它标成了回滚"。
二、根因:内层没往外抛,但已经把外层判了死刑
出事的代码结构很常见:
@Service
public class OrderService {
@Transactional // 默认 REQUIRED
public void createOrder(OrderCmd cmd) {
orderMapper.insert(cmd); // ① 写订单
try {
pointService.addPoints(cmd.getUserId()); // ② 发积分,也是 REQUIRED
} catch (Exception e) {
log.warn("add points failed, continue", e); // ③ 吞掉异常
}
orderMapper.updateStatus(cmd.getId(), "CREATED");
}
}
PointService.addPoints 上的 @Transactional 默认也是 REQUIRED。外层已经有事务,内层不再新建,而是加入同一个物理事务——共用一条连接、一个 commit。
积分抛运行时异常时,同时发生两件事:
第一件你看得见:异常被 catch,业务线程继续往下跑,日志也打出来了。
第二件你看不见:异常穿过代理的那一刻,TransactionInterceptor 已经把事务状态标记成 rollback-only。这个标记发生在异常离开方法的一瞬间,早于你的 catch。
之后外层走到 commit,AbstractPlatformTransactionManager.processCommit 一看到 rollbackOnly,只能回滚,并抛出 UnexpectedRollbackException。
所以这行 catch 的语义被很多人误解了:它拦的是"异常对象",拦不住"已经写进事务状态里的判决"。REQUIRED 一旦加入外层事务,内层就没有独立回滚的能力——它要么跟着外层一起提交,要么把外层一起拉下水。
Spring 这么设计是有道理的:内层加入外层事务之后,它并不知道调用方会不会 catch,也不知道后面还有没有别的逻辑。如果允许"内层失败、外层照常提交",那就会出现一半数据成功、一半数据失败的事务,这比整体回滚危险得多。所以它选择最保守的做法——一旦内层要求回滚,就把整个事务标死,让提交阶段必须失败。这个标记不是 bug,是框架在替你做"宁可全回滚,不许半成功"的兜底。
三、七种传播行为,差别就在"连接"和"事务边界"
| 传播行为 | 外层有事务 | 外层没事务 | 连接 |
|---|---|---|---|
| REQUIRED | 加入外层 | 新建一个 | 共用外层那条 |
| SUPPORTS | 加入外层 | 以非事务方式执行 | 共用外层那条 |
| MANDATORY | 加入外层 | 抛 IllegalTransactionStateException | 共用外层那条 |
| REQUIRES_NEW | 挂起外层,新建 | 新建一个 | 另借一条 |
| NOT_SUPPORTED | 挂起外层,非事务执行 | 非事务执行 | 外层连接被挂起 |
| NEVER | 抛 IllegalTransactionStateException | 非事务执行 | 不使用事务 |
| NESTED | 建 savepoint | 等同 REQUIRED | 共用外层那条 |
真正决定行为差异的只有两件事:内层有没有独立的事务边界,以及它用的是哪条连接。
REQUIRED、SUPPORTS、MANDATORY 三种都是"加入",事务边界只有最外层那一个。它们之间的区别只在于"外层没有事务时怎么办":REQUIRED 自己建一个,SUPPORTS 就这么跑,MANDATORY 直接抛异常。MANDATORY 的价值在于强制调用方必须开事务,适合那些"脱离事务就必然写出脏数据"的方法,比如多表一致性写入。
SUPPORTS 和 NOT_SUPPORTED 看起来像凑数,实际各有明确位置。SUPPORTS 适合"有事务更好、没有也能跑"的查询类方法,比如读一次配置缓存,它不会因为调用方没开事务就报错,也不会凭空多开一个事务拖慢链路。NOT_SUPPORTED 和 NEVER 两个更偏防御:前者用于"这段代码绝不能在大事务里跑"的场景,比如调用第三方接口或做一次大批量导出,挂起外层事务能避免长事务把连接占住;后者是给"我断言这里一定没有事务"的代码用的,一旦有人从带事务的链路调进来,直接抛异常把问题暴露在测试环境,而不是等到线上才发现。
NESTED 是唯一一个"在同一个事务里划出一小块"的选项。它不借新连接,而是用 JDBC 的 savepoint:内层失败回滚到 savepoint,外层捕获后仍可继续提交。JDBC 驱动必须支持 savepoint,MySQL 的 InnoDB 支持,DataSourceTransactionManager 也支持;切到 JTA 事务管理器就不支持了。
REQUIRES_NEW 才是这 7 种里唯一的"真·新事务":挂起外层、从连接池再借一条、独立提交。它的独立性是有代价的,代价在下一章。
四、实战:REQUIRES_NEW 的两个坑,一个比一个贵
4.1 坑一:连接翻倍,还可能把自己锁死
事故当晚我们的第一版修复就踩了这个坑。想法很朴素:积分失败不该连累订单,那就给 addPoints 加 REQUIRES_NEW。改完压测,下单成功率回来了,但接口 P99 从 80ms 涨到 900ms,HikariCP 的连接等待计数一小时 6 万次。
原因就是连接:外层事务占着一条连接不放手,内层又要一条,单线程瞬间吃两条。并发一上来,池子被"自己等自己"占满,所有请求都卡在拿连接上。我们用一个新的性能问题,换掉了一个一致性问题。
HikariCP 配 20 条连接,10 个并发下单线程就能把池子占满,第 11 个线程开始等连接,而等连接又不在事务超时保护的范围里——这种现场最迷惑人:数据库 CPU 不高,应用却全线卡住,监控上只有"获取连接等待时间"这一条在涨。
更难查的是锁。内外层操作同一行时,内层 REQUIRES_NEW 会去抢外层尚未提交的行锁:
@Transactional // 外层,先扣减账户余额
public void pay(long userId) {
accountMapper.debit(userId, 100); // 持有该行锁,未提交
pointService.grant(userId); // REQUIRES_NEW
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void grant(long userId) {
accountMapper.updateLevel(userId); // 等外层那把锁 → 等到超时
}
结果就是内层等外层、外层等内层,innodb_lock_wait_timeout 默认 50 秒,超时后抛异常,外层跟着回滚。判断标准很简单:REQUIRES_NEW 的方法,绝对不能碰外层已经写过的行。 做不到就别用。
4.2 坑二:提交了不该提交的东西
这起事故的反面同样成立。我们另一个团队把审计日志方法标成 REQUIRES_NEW,本意是"业务失败也要留痕",结果订单回滚了、日志却提交了,对账时出现大量"有审计记录、无对应订单"的脏数据。
REQUIRES_NEW 的正确使用场景其实很窄:内层结果与外层成败无关,且失败后可以单独接受。 典型是操作留痕、失败记录、幂等登记表、调用外部接口的请求流水。这些数据"多一条"无害,"少一条"才麻烦。
还有一个容易忽略的副作用:新事务是另一条连接、另一个事务快照,它读不到外层尚未提交的数据。外层刚 insert 的订单,在 REQUIRES_NEW 里按订单号查询会返回空,在 RR 隔离级别下更是如此。所以它也不能用来"读自己刚写的数据",这类需求只能留在同一个事务里。
如果内层是业务数据(积分、库存、优惠券),正确做法不是换传播行为,而是把强一致的需求交给消息:本地事务提交后发事件,下游异步消费并幂等处理。这条链路的细节在《Kafka 事务消息实战:跨 Topic 原子写入 + Exactly-Once 语义怎么用》里讲过,核心是"别指望一个注解解决跨资源的一致性"。
如果只是想"局部失败不影响整体",又都在同一个库,NESTED 更合适——它不借连接,也就没有死锁和连接池那两个坑:
@Transactional
public void createOrder(OrderCmd cmd) {
orderMapper.insert(cmd);
try {
pointService.addPoints(cmd.getUserId());
} catch (Exception e) {
// 内层已回滚到 savepoint,外层继续提交订单
log.warn("grant points failed, order kept", e);
}
}
@Transactional(propagation = Propagation.NESTED)
public void addPoints(long userId) { ... }
用 NESTED 要接受一个限制:它只回滚 savepoint 之后的数据,且一旦执行了 DDL 或者切了数据源,savepoint 会失效,回滚范围可能超出预期。
五、避坑清单
- 看到
UnexpectedRollbackException,别去查连接池和锁,先找内层调用——它几乎总是"内层 REQUIRED 抛了异常,外层又 catch 了"。 - catch 不能救 REQUIRED。想隔离,只能换 REQUIRES_NEW 或 NESTED,或者把这段逻辑挪出事务。
- REQUIRES_NEW 别碰外层写过的行,同库同行的等待 50 秒才超时,现场表现像"应用卡死"。
- 别用传播行为兜业务一致性。跨资源的强一致只能靠消息 + 幂等 + 对账,注解管不了。
@Transactional只对 public 方法、且必须走代理。同类内部自调用不生效,这条是另一个高频坑,和传播行为无关。
收尾
传播行为只回答一个问题:内层这段代码,是加入外层的事务,还是自己开一个。答案取决于"它失败时该不该连累外层",不取决于你想让它多独立。
你们项目里有没有为了"失败也留痕"顺手加过 REQUIRES_NEW?后来有没有遇到连接池打满或者脏数据?
关注我,每周拆一个真实生产故障。
标题:明明 catch 了异常,下单还是全回滚:UnexpectedRollbackException 是怎么来的
作者:jiangyi
地址:http://jiangyi.space/articles/2026/11/03/1793504914027.html
公众号:服务端技术精选
评论