首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >【Spring】事务没回滚的四问排查路径:代理、异常、线程、底层

【Spring】事务没回滚的四问排查路径:代理、异常、线程、底层

原创
作者头像
爪哇碎碎念
发布于 2026-11-11 11:02:20
发布于 2026-11-11 11:02:20
380
举报

事务没回滚,先看注解很容易看错方向。本文把声明式事务当成一条演进路线来读:先回到手写 commit/rollback 的年代,再看模板回调、注解代理两次尝试各自的代价,最后落到一张能直接照着问的四问清单。文中 3 组实测是纯 JDK 17 单文件实验,可直接复现;Spring 工程与数据库部分按 Spring 6.x / Boot 3.x 默认行为描述,属于机制推演,未做端到端实测。


如果回到没有声明式事务的年代

最早的事务边界写在业务代码里,长这样:

代码语言:java
复制
Connection c = dataSource.getConnection();
try {
    c.setAutoCommit(false);
    insertOrder(c);
    insertItem(c);
    c.commit();
} catch (SQLException e) {
    c.rollback();
    throw e;
} finally {
    c.close();
}

类比一下:这像去银行办事要自己带复核章,每填一张单据自己盖一次。章就在手里,盖没盖一目了然,这是它最大的好处。

专业定义:这段代码把「事务开始、提交、异常回滚、连接归还」四件事全部显式写在调用方,事务边界与业务语句处在同一个栈帧里。

类比局限:真实业务不是一张单据。一个方法有分支、有重试、还会调用别的 Bean,手写 commit 的位置就得跟着改;漏一次 rollback,或者异常被上层 catch 吞掉,半截数据就留在库里了。更麻烦的是,每个入口都要重复这 8 行,人一累就会写错。

痛点很明确:不是事务做不到,是事务边界太容易写漏。于是有人想了个办法——把这几行收进一个固定模板。


第一次尝试:把事务收进模板回调

Spring 很早就有编程式事务,典型写法是 TransactionTemplate(Spring 1.x 起可用):

代码语言:java
复制
transactionTemplate.execute(status -> {
    insertOrder();
    insertItem();
    return null;
});

边界清楚了:进回调就开启,正常返回就提交,抛异常就回滚,连接由模板归还。手写时代最容易漏的两件事被收进了框架。

代价一:业务代码必须写成回调,方法签名被事务模板绑住。

代价二:每个需要事务的入口都得显式调用模板,事务边界仍然是「你调用它的那一行」。异步线程、私有方法、别的 Bean 内部调用,模板都伸不进去。

代价三:它和手写 commit 一样,属于「代码里看得见」的方案,无法用一行声明覆盖一整类方法。

于是第二次尝试的方向就定了:把事务从代码里挪到调用路径上。


第二次尝试:把事务挪到注解上

代码语言:java
复制
@Transactional
public void createOrder() {
    insertRow();
}

注解本身不做任何事。容器启动时,Spring 给带事务注解的 Bean 套一层代理对象;只有当调用穿过这个代理时,TransactionInterceptor 才会在方法前后开启、提交或回滚事务。

这一步的收益是巨大的:事务从「每个方法写 8 行」变成「一个注解」。

代价也很隐蔽:拦截点从「你写的那几行」变成了「这次调用有没有穿过代理对象」。自调用、private 方法、异常被吞、另起线程,这些在手写时代根本不是问题的写法,现在全成了失效入口。

类比更新一下:章不再在你手里,而是挂在银行收单窗口;单据必须经过那个窗口才会被盖章。

类比局限:Spring 事务不只是「盖不盖章」,还带传播行为、隔离级别、超时与只读标记;同一线程里多个事务方法往往会合并进同一个物理事务。所以「有没有盖章」只是第一问,不是全部。


今天的答案:四问排查清单

核心定义:@Transactional 生效需要同时满足四件事——调用穿过 Spring 注入的代理对象、异常满足默认回滚规则、事务上下文仍绑定当前线程、底层存储引擎支持事务。8 种常见失效都可以归进这四类边界。

排查顺序固定为四问:

顺序

问什么

典型失效

1

调用穿过代理了吗

同类自调用、private / final 方法、没交给容器管理

2

异常按规则抛出了吗

受检异常未配 rollbackFor、异常被 catch 吞掉

3

还在同一线程吗

子线程、@Async、线程池里另起任务

4

底层支持事务吗

非事务引擎、传播行为设为 NOT_SUPPORTED

下面 3 组实验固定这四个机制。环境:OpenJDK 17.0.20.1(Temurin / Windows),纯 JDK 单文件源码模式,不依赖 Spring 容器。

实验一:自调用不穿过代理。用 JDK 动态代理模拟「只有经过代理才打印事务边界」:

代码语言:java
复制
public void createOrder() {
    System.out.println("  createOrder start");
    insertRow();            // this.insertRow(),绕过了代理
    System.out.println("  createOrder end");
}
代码语言:bash
复制
[TX] BEGIN  createOrder
  createOrder start
  insertRow write db
  createOrder end
[TX] COMMIT createOrder
[TX] BEGIN  insertRow
  insertRow write db
[TX] COMMIT insertRow

[TX] BEGIN 一共出现 2 次;内部那次 insertRow 前后没有 BEGIN/COMMIT。注解加在 insertRow 上也不会生效,因为调用根本没经过代理对象。

实验二:默认回滚规则。等价于 Spring 默认判定:

代码语言:java
复制
static boolean shouldRollback(Throwable ex) {
    return ex instanceof RuntimeException || ex instanceof Error;
}
代码语言:bash
复制
RuntimeException       rollback=true
Exception              rollback=false
Error                  rollback=true

受检异常默认不回滚,要显式写 rollbackFor = Exception.class;异常被 catch 掉、方法正常返回,拦截器同样看不到需要回滚的信号。

实验三:上下文绑在线程上:

代码语言:bash
复制
main  : tx-1001
child : null

事务状态存放在 ThreadLocal 里,子线程读不到。另起线程执行写库,既不在原事务里,也不会被原事务回滚。

边界声明:以上 3 组是纯 JDK 机制实验,不是 Spring Boot + 真实数据库的端到端验证。Spring 侧结论按 Spring 6.x / Boot 3.x 默认行为推演,数据库以 MySQL 8.0 + InnoDB 为例。


被淘汰的方案还有用吗

旧方案没有被淘汰,只是换了位置。

方案

边界可见性

对自调用

适合场景

手写 commit/rollback

最高,全在眼前

有效

脱离容器的脚本、一次性数据修复

TransactionTemplate

高,在回调处

有效

需要精确控制边界、类内部自调用

@Transactional

低,在代理上

失效

常规 Service 入口,声明式覆盖

它和四问清单的关系:第 1 问查出「自调用绕过代理」时,最短解法往往不是拆 Bean,而是把这一段改回编程式事务。另外两条出路是把方法挪到另一个 Bean 由代理调用,或开启 exposeProxy 后用 AopContext.currentProxy();前者更干净,后者把 AOP 细节泄漏进业务代码。


一路上的副产品

演进每一步都留下一批必须一起理解的概念,逐个写清关系:

  • JDK 动态代理:它是注解能拦住调用的地基——没有代理对象,就没有拦截点,也就没有第 1 问。
  • Spring AOP / TransactionInterceptor:它是把注解翻译成 BEGIN/COMMIT 的那一层;第 1 问本质上是在问「这次调用到没到它」。
  • 传播行为 REQUIRED / SUPPORTS / NOT_SUPPORTED:它决定多个事务方法串起来时是合并还是挂起;NOT_SUPPORTED 会主动让事务不生效,属于第 4 问。
  • 隔离级别与超时:它决定同一个物理事务里的可见性与持锁时长;这也是「章在窗口」那个类比的局限所在。
  • TransactionSynchronizationManager / ThreadLocal:它解释第 3 问——为什么 main 读得到 tx-1001、child 读到 null。
  • rollbackFor / noRollbackFor:它是对默认回滚规则的显式修正,直接对应第 2 问。
  • setRollbackOnly():它是异常被 catch 之后仍能标记回滚的补救手段,属于第 2 问的兜底。
  • InnoDB 与 MyISAM:它是第 4 问的物理前提;引擎不支持事务时,代理再正确也回滚不了。
  • TransactionTemplate:它是绕过代理的编程式解法,也是「被淘汰方案仍有价值」的证据。

前置知识

  • 接口代理与类加载:知道 Proxy.newProxyInstance 返回的是另一个对象,才明白 this 和 proxy 不是一回事。它和第 1 问的关系是:自调用用的正是 this。
  • 受检异常与运行时异常:Exception 与 RuntimeException 的分界,直接决定第 2 问的默认结果。
  • ThreadLocal 基本语义:每个线程一份副本,是第 3 问的全部答案。
  • 数据库事务的 ACID 与引擎差异:知道事务最终由存储引擎执行,才不会把第 4 问漏掉。

如果你今天要选型

按四问顺序排查,不要一上来就改注解参数:

  1. 先看调用链:这个方法是不是从另一个 Bean 注入进来的?是不是 private / final?
  2. 再看异常路径:抛的是哪种异常?有没有被 catch 后正常返回?
  3. 再看线程:写库动作是不是被丢进了线程池或异步方法?
  4. 最后看存储:表引擎是否支持事务?传播行为是不是 NOT_SUPPORTED?

常见坑与面试高频,各给一句答案:

  • 同类方法自调用:this.method() 不经过代理,改成跨 Bean 调用或编程式事务。
  • private / final 方法:代理无法覆盖,注解写了也不会生效。
  • 受检异常不回滚:默认只回滚 RuntimeException 与 Error,显式补 rollbackFor。
  • 异常被吞:拦截器收不到异常信号,需要重新抛出或 setRollbackOnly()。
  • @Async 里写库:不在原线程事务上下文内,需重新设计事务边界。
  • 只读事务误用:@Transactional(readOnly = true) 不改变「是否开启事务」,别当成关闭开关。

收尾:一条时间线带走

代码语言:mermaid
复制
graph LR
  A[手写 commit/rollback] --> B[TransactionTemplate 模板回调]
  B --> C["@Transactional 代理拦截"]
  C --> D[四问排查清单]
  C --> E[自调用绕过代理]
  C --> F[默认回滚规则]
  C --> G[ThreadLocal 线程绑定]
  C --> H[底层引擎支持]
  E --> D
  F --> D
  G --> D
  H --> D

三条结论带走:

  1. 注解只是「申请」,真正生效的前提是这次调用穿过 Spring 注入的代理对象。
  2. 默认只回滚 RuntimeException 与 Error,受检异常要显式配置。
  3. 事务上下文绑在 ThreadLocal 上,跨线程会断;最后还要确认底层引擎支持事务。

延伸阅读顺序:JDK 动态代理与反射 → Spring AOP 代理创建流程 → 事务传播行为 → TransactionSynchronizationManager → InnoDB 事务与隔离级别 → 编程式事务与 TransactionTemplate。

这篇只覆盖单机单库场景;分布式事务的边界与代价是另一条线。文中实验输出与结论如有偏差,欢迎在评论区指正。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 如果回到没有声明式事务的年代
  • 第一次尝试:把事务收进模板回调
  • 第二次尝试:把事务挪到注解上
  • 今天的答案:四问排查清单
  • 被淘汰的方案还有用吗
  • 一路上的副产品
  • 前置知识
  • 如果你今天要选型
  • 收尾:一条时间线带走
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档