VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

Spring 事务失效的八个瞬间:过代理、抛异常、认线程——Java 工兵连第 5 篇

「Java 工兵连」第 5 篇,讲 @Transactional 失效——不是八股文集合,是一套统一的破案框架。网上这类文章动辄罗列十几条,背不住的原因是没讲根因。真相是:Spring 声明式事务 = AOP 代理 + 一个盯着异常的拦截器,八条失效场景,条条挂在九个字上:

过代理,抛异常,认线程。

一、先建直觉:门卫模型

@Transactional 不是魔法贴纸,是给 Bean 套了一个门卫(代理对象)。门卫的工作流程:

  1. 有人从正门刷卡进来(外部经过代理调用方法)→ 门卫开启事务,放行你的业务代码;
  2. 业务执行完,门卫看结果:抛了 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 独立小事务,别污染外层的大事务。

老兵点评

  1. 八条失效一张图:排查事务问题时别背清单,按九字总纲走三问——这次调用经过代理了吗?异常按规则到达门卫了吗?事务开在当前线程和这套引擎上吗?三问之内必中。
  2. 团队最好的防御是规范而不是记性:rollbackFor = Exception.class 进代码模板、事务方法必须 public 且不标 final、跨 Bean 拆分事务边界——三条写进 Code Review checklist,八条坑废掉七条。
  3. 「事务为什么失效」考的其实是「事务怎么生效」。能讲清 AOP 代理、ThreadLocal 绑定和回滚规则的人,失效场景是推演出来的,不用背。

带走三句话

  1. 九字总纲:过代理、抛异常、认线程——自调用/非 public/final 是没过门;吞异常/受检不配 rollbackFor 是没喊对;传播挂起/跨线程/MyISAM 是门开错地方。
  2. 默认回滚只有 RuntimeException 和 Error——rollbackFor = Exception.class 应该是团队默认值。
  3. 事务绑定 ThreadLocal:跨线程永远不在同一事务里;内层 rollbackOnly 会让外层提交时炸出 UnexpectedRollbackException。

本篇是「Java 工兵连」第 5 篇。→ 上一篇:第 4 篇 · JVM 分代与 G1 一次讲透 | 继续阅读:第 6 篇 · MySQL 索引最左前缀 | 浏览整个专栏 →。

❦ 终 ❦

← 回到首页全部归档 →