明明 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://www.jiangyi.space/articles/2026/11/03/1793504914027.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消