@Transactional 加了等于没加?面试连环问背后的 5 个失效场景

场景:面试官连环追问 @Transactional「加了就生效吗」,标准答案背后漏掉生产事务不生效的 5 类根因 路径:考题 → 质疑 → 路径 → 真相 → 升级

面试官问:"方法上加个 @Transactional,抛异常是不是就自动回滚了?"标准答案很顺口:是,@Transactional 是声明式事务,方法抛出 RuntimeException 就回滚,不用手动 commit/rollback。

这个答案没错,但它只覆盖了 30%。

直到线上一次对账——订单已支付、余额也扣了、库存却没减,三条 SQL 在同一个"事务"里只生效了两条。我才理解,@Transactional 生效的前提是一整套代理拦截机制,面试答案只讲了"加注解"这一半。

上篇讲了 serialVersionUID 是门牌号不是保险箱,这篇我们回到 Spring 里最被高估的一个注解——@Transactional。

【考题】面试连环问开场:方法加了 @Transactional,就生效了吗

面试第一问:标准答案长这样

先看面试时背诵的标准版本——"方法上加了注解,抛异常自动回滚,就这么简单":

面试标准答案:@Transactional 抛异常自动回滚

这套答案面试及格没问题。但面试官不会停在这里——真正的连环问,是从这句话的每个词里挖出来的:

  • "方法"——那我把方法改成 private,还生效吗?
  • "抛异常"——被 catch 住的异常,还算数吗?检查异常呢?
  • "自动回滚"——我在新线程里调,事务跟得过去吗?

一个词,一把刀。每一把都能捅穿标准答案。

标准答案的第一处模糊:注解 ≠ 生效

"加了注解就回滚"这句话默认了一个前提:Spring 真的能拦到这个方法的调用

@Transactional 能工作,靠的不是注解本身,而是 Spring 容器启动时为 Bean 生成的代理对象——调用方法时先经过代理,代理开启事务、捕获异常决定回滚还是提交,然后才进入你的方法体。注解只是告诉代理"这个方法要管",真正干活的是代理。

面试答案把这个"代理"环节整个跳过了——于是后面每一个追问,都打在同一个盲区上:当代理拦不到这个调用时,注解就是一行摆设

【质疑】生产事故:一个"事务"里,三条 SQL 只生效了两条

事故现场:对账发现 316 笔订单"已支付"但库存没减

本文事故基于某电商订单服务的生产现场还原(JDK 8 + Spring Boot 2.x,4C8G 容器,MySQL 5.7 InnoDB,已脱敏)。

该服务的下单接口 payOrder() 上标了 @Transactional,事务里依次执行三条 SQL:扣减用户余额 → 扣减商品库存 → 订单置为已支付。某日对账,财务发现 316 笔订单状态为"已支付",但对应库存完全没有扣减,余额却已经扣了。

对账发现部分成功:订单已支付、库存未扣

消息中间件消费日志也显示同一个特征——每笔异常订单的库存扣减语句都抛了错,但被 catch 吞掉了:

异常被 catch 吞掉的日志

为什么"抛异常就回滚"没拦住:异常压根没抛出去

关键就在这——不是回滚没执行,而是代理压根没收到异常

payOrder() 里库存扣减那行抛了 DataAccessException,但代码里 try { ... } catch (Exception e) { log.error(...) } 把它接住了,方法正常返回。代理一看"方法正常结束",判定成功,提交事务。余额、订单、库存的改动里,只有真正执行成功的两条被提交了。

标准答案说"抛异常就回滚"——前提是异常得穿过代理。异常被 catch 吞掉,回滚信号就死了。

这里藏着全文最反直觉的一个点:事务判定的是"有没有异常穿过代理",不是"有没有发生错误"。 业务代码里错误发生了、日志也打了,但只要异常没穿透到代理,事务就当什么都没发生——提交。

为什么面试不会触发、生产会?面试里方法体就两行,从不写 try-catch;生产代码里异常处理是标配——特别是那种"出错了也要把订单建起来"的业务逻辑,恰恰是事务失效的高发地。

复盘钩子:这次只踩中 1 个场景,另外 4 个就埋在同样的代码里

这次事故的直接根因是"异常被吞"这一个场景。但事后复盘,我们把 payOrder() 所在的 Service 层翻了一遍——同一批"看着没问题"的写法里,另外 4 个失效场景全都在。这 5 个场景不是孤立的知识点,它们共享同一个病根:对"代理"这个环节的忽视

【路径】面试中这么答,才能满分

加分答案:5 种失效场景脱口而出

面试官听到"加 @Transactional 抛异常就回滚"只会觉得你会背。加上这段,才证明你懂边界:

面试加分答案:@Transactional 5 种失效场景

面试话术模板:"@Transactional 生效依赖 Spring 代理拦截。只要代理拦不到这个调用,注解就是摆设。我遇到过 5 种失效场景:private 方法、同类自调用、异常被吞、检查异常没配 rollbackFor、多线程异步调用。根因分两类——代理根本没拦到,和代理拦到了但没收到回滚信号。"

面试官追问:两种根因,怎么分?

追问到这里,你已经在吊打标准答案了。两类根因的区别一句话说清:

  • 代理失效类:private 方法、同类自调用、多线程——代理根本没参与这次调用,注解自然不生效
  • 回滚判定类:异常被吞、检查异常——代理参与了,但没拿到"要回滚"的信号

两类根因:代理失效 vs 回滚判定

【真相】对比分析:面试答案 vs 生产真相

标准答案对在哪

  • 方法加注解 + 抛 RuntimeException + 方法不是 private + 不是同类调用——这个组合下,@Transactional 确实能回滚,面试答的流程没有错。
  • 代理机制真实存在:Spring 确实靠 AOP 代理拦截实现声明式事务,这个底层原理是共识。

标准答案漏了哪:5 种失效场景逐个解剖

场景 1:面试官追问——"那我把方法改成 private 呢?"

场景 1:@Transactional 放在 private 方法上

标准答案说"方法上加注解",可没限定方法类型。你一答"private 也可以",面试官就知道你只背了皮毛。

Spring 的声明式事务靠 AOP 代理拦截,代理分两种:JDK 动态代理(目标类实现接口时)和 CGLIB(生成目标类的子类)。无论哪种,private 方法都不可见——JDK 代理只能看到接口里的方法,CGLIB 子类无法 override private 方法,代理根本看不到它,注解形同虚设。生产里常见于"我给它加了 @Transactional 但忘了它是 private"。

场景 2:面试官追问——"那我在同类里调它呢?"

场景 2:同类自调用 this.xxx() 绕过代理

"同一个类里 A 方法调 B 方法,B 上有注解"——这题能坑死一半人。payOrder() 内部 this.deductBalance() 调用的是原始对象的方法,不是代理对象——代理拦不到同类内部的调用。这是生产中最隐蔽的一种,代码 review 都看不出来。

场景 3:面试官追问——"那我把异常 catch 住呢?"

场景 3:异常被 catch 吞掉

这是本文事故的直接根因。事务是"有没有异常穿过代理"来判定的,不是"有没有发生错误"来判定的。 被 catch 住的异常,代理感知不到。

场景 4:面试官追问——"那抛的是检查异常呢?"

场景 4:检查异常没配 rollbackFor

@Transactional 默认 rollbackFor = {RuntimeException.class, Error.class}检查异常(IOException 等)抛出来不会触发回滚,事务照常提交。面试常说"抛异常就回滚",但这个"异常"默认只算 RuntimeException。生产里调外部接口抛检查异常是常态,忘了配 rollbackFor = Exception.class 就踩坑。

场景 5:面试官追问——"那我在新线程里调呢?"

场景 5:多线程异步调用

Spring 事务把连接绑定在当前线程的 ThreadLocal 里。新线程里没有这个事务上下文——它要么以无事务(autocommit)方式执行,要么(经代理调用且带 @Transactional 时)开启独立事务,无论哪种都不受主线程事务约束——主线程回滚,新线程照常生效。事务的边界是线程,不是方法。

把 5 个场景归一下类,两类根因一目了然:

5 种失效场景的两类根因归类

左边三个是代理根本没拦到(private、同类调用、多线程),右边两个是代理拦到了但没收到回滚信号(异常被吞、检查异常)——记住这个分类,面试怎么追问都能对答如流。

为什么面试不会触发、生产会:对比表

维度 面试环境 生产环境
方法修饰符 清一色 public private / final 混着来
调用方式 直接 new 对象调 Bean 注入,也可能同类互调
异常处理 方法体两行,不写 try-catch 异常兜底是标配,还可能吞异常
异常类型 顺手抛 RuntimeException 外部接口抛检查异常很常见
线程模型 单线程演示 线程池、异步、消息消费并存

面试答案 vs 生产真相 逐项对比

【升级】面试这么答 + 生产中这么用

面试总结话术

面试官再把这五个问题抛一遍,你就能完整接住:

面试官:加了 @Transactional 抛异常就回滚,对吗? 你:对,但前提是 Spring 代理真的拦到了这个调用。它的边界就是代理的边界——代理拦不到的(private、同类调用、新线程)它管不着;代理能拦到但没收到异常(异常被吞、检查异常没配 rollbackFor)它也不回滚。所以生产里有五查:方法必须 public、别同类互调、别吞异常、检查异常配 rollbackFor、别跨线程调。

这一问五答收下来,面试官能立刻判断:这个人不是背答案,是真的在生产里踩过坑。

大多数生产事故不是因为你不知道事务原理——是因为你知道的标准答案没覆盖到"代理拦不到"那个场景。

生产中这么用:5 条自查铁律

生产 5 条自查铁律

这五条做到位,@Transactional 面试题和它背后的生产事故,就都不会再坑你。

发布前 30 秒检查清单

每次提 MR,git diff 里凡是带了 @Transactional 的方法,花 30 秒过一遍这张表:

检查点 失效结果 修法
方法是不是 private/final 代理拦不到,注解失效 改 public
是不是同类方法内部调用 绕过代理,注解失效 拆 Service 或注入本 Bean
方法里有没有 try-catch 吞异常 回滚信号被吞 捕获后 rethrow
有没有可能抛检查异常 默认不回滚 配 rollbackFor = Exception.class
方法里有没有 new Thread / @Async 子任务独立事务 异步逻辑拆出去
方法里有没有远程调用 长事务锁资源 远程调用移出事务

对照完,你就能在 30 秒里判断:这次 MR 里的事务,到底是不是真的生效。


@Transactional 不是"加个注解就万事大吉"——它是把业务代码和事务边界绑在一起的承诺,而承诺生效的前提是代理真的在拦截。

标准答案没有错,只是它在面试的环境下成立,在生产的规模下不一定成立。想自己复现这 5 种失效的,demo/ 目录里 bash run-demo.sh 用 JDK 动态代理把五种场景全跑一遍——跑完你就再也不会说"加个注解就完了"。

下篇我们聊 Spring 循环依赖面试答"三级缓存"就够了?生产排障才见真章,把"标准答案"和"生产真相"再对一遍。

🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:Ai拆代码的曹操