Spring 事务失效的八个瞬间:过代理、抛异常、认线程——Java 工兵连第 5 篇
「Java 工兵连」第 5 篇,讲 @Transactional 失效——不是八股文集合,是一套统一的破案框架。网上这类文章动辄罗列十几条,背不住的原因是没讲根因。真相是:Spring 声明式事务 = AOP 代理 + 一个盯着异常的拦截器,八条失效场景,条条挂在九个字上:
过代理,抛异常,认线程。
一、先建直觉:门卫模型
@Transactional 不是魔法贴纸,是给 Bean 套了一个门卫(代理对象)。门卫的工作流程:
- 有人从正门刷卡进来(外部经过代理调用方法)→ 门卫开启事务,放行你的业务代码;
- 业务执行完,门卫看结果:抛了
RuntimeException/Error→ 回滚;正常返回或受检异常且未配置 → 提交。
所以失效只有三种可能:人没从正门进(门卫根本没看见这次调用)、出了事没喊对(门卫没收到回滚信号)、门开错了地方(事务根本没开,或开在别的线程/引擎上)。对着下面这张图逐条看。
二、根因一:没过门(①②③)
① 同类自调用——最高频的坑
@Service
public class OrderService {
public void createOrder() {
// ... 下单逻辑
this.saveRecord(); // ✗ 失效!this 是原始对象,不是代理
}
@Transactional
public void saveRecord() { ... }
}
调用链是 this.saveRecord()——this 指向原始对象,绕过了代理门卫,saveRecord 上的注解形同虚设。修法三选一:
// 修法一:注入自己(自注入),用代理对象调用
@Autowired private OrderService self;
public void createOrder() { self.saveRecord(); } // ✓ 走了代理
// 修法二:拆到另一个 Bean —— 事务方法放 RecordService,天然跨对象调用
// 修法三:改用编程式事务,最直白
transactionTemplate.executeWithoutResult(tx -> saveRecord());
② 方法不是 public
Spring 官方文档明确:@Transactional 默认只在 public 方法上生效。protected、private、包级私有方法上贴了也白贴(AspectJ 字节码增强模式除外,但极少有人用)。没报错、没警告,静默失效——所以最阴。
③ final / static / private
Spring Boot 默认用 CGLIB(继承你的类生成子类做代理)。final 方法不能被覆写、static 属于类不属于实例、private 子类不可见——子类代理根本插不进手。老代码里挺常见:某工具方法顺手标 final,多年后加上事务注解,怎么都不生效。
三、根因二:出了事没喊对(④⑤)
④ 异常被 try-catch 吃掉
@Transactional
public void pay() {
try {
chargeApi.call(); // 抛了异常
} catch (Exception e) {
log.error("支付失败", e); // ✗ 吞了!门卫看到的是「正常返回」→ 提交
}
}
门卫只认从方法里抛出来的异常。要么 rethrow,要么手动拉响警报:
catch (Exception e) {
log.error("支付失败", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); // ✓ 保留现场+回滚
throw e; // 或者干脆重抛
}
⑤ 受检异常默认不回滚
最反直觉的一条:默认只回滚 RuntimeException 和 Error。IOException、SQLException 这类受检异常抛出去,事务照常提交——这源自 EJB 时代的历史约定,Spring 全盘继承了。所以规矩很简单,每个 @Transactional 都写 rollbackFor:
@Transactional(rollbackFor = Exception.class) // 团队规范级别的要求,不是可选项
public void pay() throws IOException { ... }
四、根因三:门开错了地方(⑥⑦⑧)
⑥ 传播行为把事务挂起了
PROPAGATION_NOT_SUPPORTED 的语义就是「以非事务方式执行」——方法里贴了 @Transactional 也被自己声明的传播行为挂起。NEVER 更直接:发现已有事务直接抛异常。传播行为是「事务怎么接力」的合同,签之前先看条款(默认 REQUIRED:有就加入、没有就新建,覆盖 90% 场景)。
⑦ 跨线程:事务跟着线程走
Spring 事务的 Connection 绑定在 ThreadLocal 上。@Async、自己 new 的线程、线程池、CompletableFuture 里的操作——都在别的线程,等于在门卫的辖区外办事:不同事务、独立提交,外层回滚拉不回它们;子线程抛异常,外层也无感。需要「多线程一起回滚」时,正确答案是重构为串行事务、或用消息/补偿(Saga)做最终一致,而不是指望 ThreadLocal 穿线程。
⑧ 地基塌了:引擎与容器
三个基建检查:数据库表引擎是 MyISAM 就没有事务(MySQL 8 默认 InnoDB,但手工建的老表未必);对象是自己 new 出来的就没被 Spring 托管,注解无处生效;传统 XML 工程忘写 <tx:annotation-driven/>(Boot 项目自动配置,不踩这条)。
五、彩蛋:回滚了,但你不知道
一个反向陷阱送给你:内层事务(REQUIRED 加入同一事务)抛异常被外层 catch 住继续走,提交时炸出 UnexpectedRollbackException。原因:内层已经把共享事务标记为 rollbackOnly,外层以为一切正常要提交,Spring 只能抛异常告诉你「这单早就被内层判退了」。破解:内层若本意是「可失败」,给它 REQUIRES_NEW 独立小事务,别污染外层的大事务。
老兵点评
- 八条失效一张图:排查事务问题时别背清单,按九字总纲走三问——这次调用经过代理了吗?异常按规则到达门卫了吗?事务开在当前线程和这套引擎上吗?三问之内必中。
- 团队最好的防御是规范而不是记性:
rollbackFor = Exception.class进代码模板、事务方法必须 public 且不标 final、跨 Bean 拆分事务边界——三条写进 Code Review checklist,八条坑废掉七条。 - 「事务为什么失效」考的其实是「事务怎么生效」。能讲清 AOP 代理、ThreadLocal 绑定和回滚规则的人,失效场景是推演出来的,不用背。
带走三句话
- 九字总纲:过代理、抛异常、认线程——自调用/非 public/final 是没过门;吞异常/受检不配 rollbackFor 是没喊对;传播挂起/跨线程/MyISAM 是门开错地方。
- 默认回滚只有 RuntimeException 和 Error——
rollbackFor = Exception.class应该是团队默认值。 - 事务绑定 ThreadLocal:跨线程永远不在同一事务里;内层 rollbackOnly 会让外层提交时炸出
UnexpectedRollbackException。
本篇是「Java 工兵连」第 5 篇。→ 上一篇:第 4 篇 · JVM 分代与 G1 一次讲透 | 继续阅读:第 6 篇 · MySQL 索引最左前缀 | 浏览整个专栏 →。
❦ 终 ❦