事务没回滚,先看注解很容易看错方向。本文把声明式事务当成一条演进路线来读:先回到手写 commit/rollback 的年代,再看模板回调、注解代理两次尝试各自的代价,最后落到一张能直接照着问的四问清单。文中 3 组实测是纯 JDK 17 单文件实验,可直接复现;Spring 工程与数据库部分按 Spring 6.x / Boot 3.x 默认行为描述,属于机制推演,未做端到端实测。
最早的事务边界写在业务代码里,长这样:
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 起可用):
transactionTemplate.execute(status -> {
insertOrder();
insertItem();
return null;
});边界清楚了:进回调就开启,正常返回就提交,抛异常就回滚,连接由模板归还。手写时代最容易漏的两件事被收进了框架。
代价一:业务代码必须写成回调,方法签名被事务模板绑住。
代价二:每个需要事务的入口都得显式调用模板,事务边界仍然是「你调用它的那一行」。异步线程、私有方法、别的 Bean 内部调用,模板都伸不进去。
代价三:它和手写 commit 一样,属于「代码里看得见」的方案,无法用一行声明覆盖一整类方法。
于是第二次尝试的方向就定了:把事务从代码里挪到调用路径上。
@Transactional
public void createOrder() {
insertRow();
}注解本身不做任何事。容器启动时,Spring 给带事务注解的 Bean 套一层代理对象;只有当调用穿过这个代理时,TransactionInterceptor 才会在方法前后开启、提交或回滚事务。
这一步的收益是巨大的:事务从「每个方法写 8 行」变成「一个注解」。
代价也很隐蔽:拦截点从「你写的那几行」变成了「这次调用有没有穿过代理对象」。自调用、private 方法、异常被吞、另起线程,这些在手写时代根本不是问题的写法,现在全成了失效入口。
类比更新一下:章不再在你手里,而是挂在银行收单窗口;单据必须经过那个窗口才会被盖章。
类比局限:Spring 事务不只是「盖不盖章」,还带传播行为、隔离级别、超时与只读标记;同一线程里多个事务方法往往会合并进同一个物理事务。所以「有没有盖章」只是第一问,不是全部。
核心定义:@Transactional 生效需要同时满足四件事——调用穿过 Spring 注入的代理对象、异常满足默认回滚规则、事务上下文仍绑定当前线程、底层存储引擎支持事务。8 种常见失效都可以归进这四类边界。
排查顺序固定为四问:
顺序 | 问什么 | 典型失效 |
|---|---|---|
1 | 调用穿过代理了吗 | 同类自调用、 |
2 | 异常按规则抛出了吗 | 受检异常未配 |
3 | 还在同一线程吗 | 子线程、 |
4 | 底层支持事务吗 | 非事务引擎、传播行为设为 |
下面 3 组实验固定这四个机制。环境:OpenJDK 17.0.20.1(Temurin / Windows),纯 JDK 单文件源码模式,不依赖 Spring 容器。
实验一:自调用不穿过代理。用 JDK 动态代理模拟「只有经过代理才打印事务边界」:
public void createOrder() {
System.out.println(" createOrder start");
insertRow(); // this.insertRow(),绕过了代理
System.out.println(" createOrder end");
}[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 默认判定:
static boolean shouldRollback(Throwable ex) {
return ex instanceof RuntimeException || ex instanceof Error;
}RuntimeException rollback=true
Exception rollback=false
Error rollback=true受检异常默认不回滚,要显式写 rollbackFor = Exception.class;异常被 catch 掉、方法正常返回,拦截器同样看不到需要回滚的信号。
实验三:上下文绑在线程上:
main : tx-1001
child : null事务状态存放在 ThreadLocal 里,子线程读不到。另起线程执行写库,既不在原事务里,也不会被原事务回滚。
边界声明:以上 3 组是纯 JDK 机制实验,不是 Spring Boot + 真实数据库的端到端验证。Spring 侧结论按 Spring 6.x / Boot 3.x 默认行为推演,数据库以 MySQL 8.0 + InnoDB 为例。
旧方案没有被淘汰,只是换了位置。
方案 | 边界可见性 | 对自调用 | 适合场景 |
|---|---|---|---|
手写 | 最高,全在眼前 | 有效 | 脱离容器的脚本、一次性数据修复 |
| 高,在回调处 | 有效 | 需要精确控制边界、类内部自调用 |
| 低,在代理上 | 失效 | 常规 Service 入口,声明式覆盖 |
它和四问清单的关系:第 1 问查出「自调用绕过代理」时,最短解法往往不是拆 Bean,而是把这一段改回编程式事务。另外两条出路是把方法挪到另一个 Bean 由代理调用,或开启 exposeProxy 后用 AopContext.currentProxy();前者更干净,后者把 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 问的兜底。TransactionTemplate:它是绕过代理的编程式解法,也是「被淘汰方案仍有价值」的证据。Proxy.newProxyInstance 返回的是另一个对象,才明白 this 和 proxy 不是一回事。它和第 1 问的关系是:自调用用的正是 this。Exception 与 RuntimeException 的分界,直接决定第 2 问的默认结果。ThreadLocal 基本语义:每个线程一份副本,是第 3 问的全部答案。按四问顺序排查,不要一上来就改注解参数:
private / final?catch 后正常返回?NOT_SUPPORTED?常见坑与面试高频,各给一句答案:
this.method() 不经过代理,改成跨 Bean 调用或编程式事务。private / final 方法:代理无法覆盖,注解写了也不会生效。RuntimeException 与 Error,显式补 rollbackFor。setRollbackOnly()。@Async 里写库:不在原线程事务上下文内,需重新设计事务边界。@Transactional(readOnly = true) 不改变「是否开启事务」,别当成关闭开关。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三条结论带走:
RuntimeException 与 Error,受检异常要显式配置。ThreadLocal 上,跨线程会断;最后还要确认底层引擎支持事务。延伸阅读顺序:JDK 动态代理与反射 → Spring AOP 代理创建流程 → 事务传播行为 → TransactionSynchronizationManager → InnoDB 事务与隔离级别 → 编程式事务与 TransactionTemplate。
这篇只覆盖单机单库场景;分布式事务的边界与代价是另一条线。文中实验输出与结论如有偏差,欢迎在评论区指正。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。