事务内核:@Transactional 的实现原理与传播机制
转账这件事,你最怕的不是失败,而是「钱扣了,对方没收到」。数据库里一次业务往往要写好几行数据(扣款一行、入账一行、流水一行),如果写到第二行时程序崩了、断电了、余额不够报错了,前面那一行能不能收回去?答案就是事务:把这几条 SQL 捆成一个包裹,要么整包成功(提交 COMMIT),要么整包撤销(回滚 ROLLBACK),中间出任何事都不留半成品。Spring 的 @Transactional 干的就是这件事——你只写一行注解,框架替你把「开始 → 执行 → 提交或回滚 → 归还连接」全包了。这一篇讲三件事:谁替你按下的提交键(代理 + 事务管理器)、方法套方法时事务怎么处置(七种传播行为)、以及为什么你的注解有时静默失效。
先把五个词一句话解释清楚(全文都会用到):
- 事务:一组 SQL 的「同生共死合同」。要么全生效,要么当从没发生过
- 回滚:把这次连接里已经写下的改动全部撤回,像没写过一样
- 代理:框架偷偷塞给你对象的一个「包装壳」。别人调你的方法时先经过壳,壳才有机会开启和提交事务
- 连接池:预先建好、反复借还的数据库通道集合。借一条用一条,用完必须还——借不到就得排队等
- 传播行为:外层方法已经有事务了,内层方法是「并入这场会」还是「自己单开一场」的规则
事务就像去 ATM 转账:机器先把你的钱从 A 账户划出来,再往 B 账户加。如果划完钱突然停电,钱不能凭空消失——所以银行的做法是两笔操作绑成一个「要么都记、要么都不记」的动作,中途出事就整笔作废,你的余额回到原点。回滚就是「这笔根本没发生过」,提交就是「两边账本同时改定」。

上图用两把尺子量完了七种传播行为:横向问「内层是并入外层,还是独立执行」,纵向问「这个方法到底要不要事务」。以后遇到「日志必须留下」「批量导入别拖垮主流程」这类需求,先在这张图里找到属于自己的那一格,再决定写哪个枚举值。第五节会把它变成可运行的代码,第十节让你亲手把七种全切一遍。
学完这一篇,你应该能回答三个问题:
- 一行
@Transactional注解自己不会提交事务,那到底是谁在什么时候替我按下了 COMMIT? - 外层方法调内层方法,内层的异常为什么能把外层已经写好的数据一起拖没?想保住其中一行该怎么办?
- 我的注解明明写了却没生效——五种静默失效的原因里,我踩的是哪一个?怎么用一行日志配置当场确认?
事务这个词听起来很重,其实目标很朴素:把一组操作当成一个「不可分割的整体」,要么全成功,要么全失败。业内用 ACID 四个字母概括:
| 特性 | 白话版 |
|---|---|
| 原子性(Atomicity) | 同生共死:要么都提交,要么都回滚 |
| 一致性(Consistency) | 从一个正确状态,变到另一个正确状态 |
| 隔离性(Isolation) | 并发事务互不打扰,像排队一样 |
| 持久性(Durability) | 提交之后,断电也不丢 |
「隔离」最难,因为完全隔离就是串行,性能太差。于是有了四档隔离级别,级别越高越安全、并发越差:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE READ | 不可能 | 不可能 | 可能(MySQL InnoDB 基本避免) |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
MySQL 的 InnoDB 默认是 REPEATABLE READ,并靠 MVCC + 间隙锁把大部分幻读也一并解决——这也是「MySQL 同级别比 Oracle 更稳」这个说法的来源。Oracle / PostgreSQL 默认则是 READ COMMITTED。
隔离级别像图书馆的阅览规则。SERIALIZABLE 是「一次只放一个人进阅览室」,绝对安静但队伍排到天黑;READ UNCOMMITTED 是「随便看别人正在写的草稿」,快却可能读到后来被划掉的内容;REPEATABLE READ 则给你拍了一张进门那一刻的快照——里面怎么改都与你无关,你看到的永远是那张照片。这张照片在 MySQL 里就叫 MVCC(多版本并发控制:每行数据留着若干历史版本,各事务按自己的时间点挑一个版本读)。
先看没有注解时,一个转账要写多少样板代码(JDBC 版):
public void transfer(Long from, Long to, BigDecimal amount) { Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); // 1. 关闭自动提交,事务开始 accountDao.deduct(conn, from, amount); // 2. 扣款 accountDao.add(conn, to, amount); // 3. 入账 conn.commit(); // 4. 成功就提交 } catch (Exception e) { conn.rollback(); // 5. 失败就回滚 throw new RuntimeException(e); } finally { conn.setAutoCommit(true); conn.close(); // 6. 归还连接 }}六个步骤、一次 try-catch-finally,每个涉及事务的方法都得抄一遍,还极易漏掉某一步(比如忘了 rollback)。用 @Transactional 之后:
@Transactionalpublic void transfer(Long from, Long to, BigDecimal amount) { accountDao.deduct(from, amount); accountDao.add(to, amount); // 提交、回滚、连接归还——全由框架接管}一行注解干掉了六步样板。那么问题来了:注解只是元数据,JVM 不会因为一行注解就去提交事务。到底是谁,在什么时候,替我们执行了那六步? 这就是下一节要拆的内核。
在拆内核之前,先确认这套机器真的在你的 classpath 上——@Transactional 静默罢工的第一大成因,是压根没有事务管理器。别去抄别人的 pom,勾一遍就知道每一行依赖各自撑着哪一层:勾 Data JPA 或 JDBC 会看到 EntityManagerFactory / JdbcTemplate 各自对应的实现类出现在依赖树里,只勾 MySQL 而忘勾它们,注解就是一个装饰。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
第一层:代理层——把注解变成「切面」。 @EnableTransactionManagement 开启事务管理能力,它导入 ProxyTransactionManagementConfiguration,注册了一个 BeanFactoryTransactionAttributeSourceAdvisor。这个 Advisor 会去扫描所有带 @Transactional 的 Bean,为它们生成 AOP 代理。
@Configuration@EnableTransactionManagement // 打开事务开关(Spring Boot 自动配置里已默认开启)public class TxConfig { // 关键在于:它导入配置注册了一个 Advisor: // BeanFactoryTransactionAttributeSourceAdvisor // → 该 Advisor 匹配 @Transactional 方法,为其生成代理}第二层:拦截层——代理里跑的是 TransactionInterceptor。 当外部调用 transfer() 时,实际先进入代理,代理链上的 TransactionInterceptor.invoke() 会围绕目标方法做「环绕通知」:进入前开启事务,正常返回后提交,抛异常则回滚。
// TransactionInterceptor 的核心逻辑(高度简化)public Object invoke(MethodInvocation invocation) throws Throwable { TransactionAttribute attr = getTransactionAttribute(invocation); // 读出注解元数据 return invokeWithinTransaction(invocation.getMethod(), targetClass, () -> { // 开启事务 → 调用目标方法 → 提交/回滚,全在这一层编排 return invocation.proceed(); });}第三层:执行层——PlatformTransactionManager 真正碰数据库。 它是最底层的执行者,接口只有三个方法:getTransaction(开启)、commit(提交)、rollback(回滚)。Spring Boot 默认给你装配的是 DataSourceTransactionManager。
public interface PlatformTransactionManager { TransactionStatus getTransaction(TransactionDefinition definition); // 开启/加入事务 void commit(TransactionStatus status); // 提交 void rollback(TransactionStatus status); // 回滚}它内部做了三件事:从连接池取连接 → 关闭自动提交(setAutoCommit(false))→ 提交或回滚后归还连接。此外,它还会把这条连接绑定到 TransactionSynchronizationManager(底层用 ThreadLocal),供同一线程内的后续操作复用——这正是下一节的关键。

动画给的是全景,但「第几步才借连接、第几步才真的执行你的代码」这种事,读是读不进去的,得点。下面这台单步调试台的左边就是那七行,右边同步刷新「此刻的变量」和「调用栈」——连点下一步,重点盯第 ④⑤ 步:连接不是每次都借,而是只在这一笔事务新建时借一次;再盯第 ⑥ 步——你的业务代码直到这里才第一次被执行,前面五步全在它外面转。
orderService.create(order); // ① 调用打到代理,不是打到你的对象TransactionInterceptor.invoke() // ② 环绕通知:事务的编排全在这一层tm.getTransaction(def) // ③ 先问传播行为:加入已有,还是新建conn = dataSource.getConnection() // ④ 只有「新建」这一支才去借连接conn.setAutoCommit(false); bind(holder) // ⑤ 关自动提交,并把连接绑到当前线程result = method.invoke(target) // ⑥ 你的业务代码在这里才第一次执行commit / rollback → unbind → returnConn // ⑦ 提交或回滚,然后解绑、归还| orderService | OrderServiceImpl$$SpringCGLIB$$0 |
| 调用方线程 | http-nio-8080-exec-7 |
OrderController.createproxy.create一个常见疑问:一个事务里调了 10 次 DAO,难道要取 10 次数据库连接吗?不会。因为事务管理器在开启事务时,已经把这条连接绑定到了当前线程的 ThreadLocal 里:
// 开启事务时(DataSourceTransactionManager.doBegin 简化版)Connection conn = dataSource.getConnection();conn.setAutoCommit(false);// 关键:把连接绑定到当前线程TransactionSynchronizationManager.bindResource(dataSource, new ConnectionHolder(conn));// 之后任何 DAO 操作要连接时(DataSourceUtils.getConnection 简化版)ConnectionHolder holder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource);if (holder != null) { return holder.getConnection(); // 复用同一线程已绑定的连接}return dataSource.getConnection(); // 没有事务才新建- 同线程复用:只要还在同一个事务里,DAO 拿到的永远是那条已绑定的连接
- 跨线程断裂:一旦开新线程,ThreadLocal 取不到,就会去新建连接——于是新线程里的操作不在原事务内,这解释了下文「多线程导致事务失效」
- 归还时机:提交或回滚后解绑 ThreadLocal,再把连接还回连接池
把左右两栏并排放在一起,第四节和第六节那两个「看不见」的现象就同时解释了:左边是默认情形(同线程、同连接、同生死),右边是换线程和换事务的情形——REQUIRES_NEW 借的第二条连接、@Async 里那条无人认领的连接,都落在右栏:

右栏不是「异常」,而是另一种正常。它提醒一件事:判断「这两行写入在不在同一个事务里」,别看它们是不是同一个方法,要看它们跑在哪条线程上、绑的是哪条连接。
传播行为(Propagation)回答一个问题:当一个事务方法调用另一个事务方法时,事务该怎么处置? 一共七种:
| 传播类型 | 无事务时 | 有事务时 | 典型场景 |
|---|---|---|---|
REQUIRED(默认) | 新建事务 | 加入当前事务 | 绝大多数增删改查 |
REQUIRES_NEW | 新建事务 | 挂起当前,另起新事务 | 独立审计日志、发消息记录 |
NESTED | 新建事务 | 建嵌套(保存点)子事务 | 部分失败只回滚子事务 |
SUPPORTS | 不用事务 | 加入当前事务 | 查询类方法 |
NOT_SUPPORTED | 不用事务 | 挂起当前,非事务执行 | 大量 IO 且不关心一致性 |
MANDATORY | 抛异常 | 加入当前事务 | 强制要求上层已有事务 |
NEVER | 不用事务 | 抛异常 | 绝不允许在事务中执行 |
三类最需要对比记忆:
- REQUIRED(默认):内外是同一个事务。内层一旦抛异常标记回滚,整个外层也一起回滚——「同生共死」。
- REQUIRES_NEW:内外两个独立事务。外层会先被挂起,内层跑在自己的新事务里;内层提交或回滚,互不影响外层。
- NESTED:内层是外层的嵌套子事务,靠数据库「保存点(Savepoint)」实现。内层失败只回滚到保存点,外层可以继续,也可选择整体回滚。
传播行为就是开会的规矩。REQUIRED 是「既然你已经在开会了,我就并进你这个会,散会时一起出决议」——会上任何一个人翻脸,整场会议的纪要全部作废。REQUIRES_NEW 是「你先休会,我去隔壁单开一场,我那场的结果先落盘,回来你继续开你的」——所以同一时刻会议室要占用两间、连接也要借两条。NESTED 更像「大会里插一个小分组」:小分组谈崩了只撤销这一段的发言(回滚到保存点),大会随时可以宣布散会(整体回滚)。NOT_SUPPORTED 则是「你们开会你们的,我出去打个电话」,全程不进会议记录。
搞清「并入」和「另开」的区别,光看文字最容易记反。下面这段动画把两条路径并排画出来——注意中间那一步:REQUIRES_NEW 让 conn1 和 conn2 同时活着,而 REQUIRED 自始至终只有一条连接:

用一个订单 + 审计日志的经典案例看差异:
@Servicepublic class OrderService { @Autowired private OrderDao orderDao; @Autowired private AuditService auditService; @Transactional // 外层:REQUIRED public void create(Order order) { orderDao.insert(order); try { auditService.log("created order " + order.getId()); // 内层:REQUIRES_NEW } catch (Exception e) { // 日志失败不应拖垮下单:捕获并吞掉 System.out.println("audit failed, ignored"); } // 若这里抛异常,外层整体回滚 }}@Servicepublic class AuditService { // 独立事务:外层无论成败,只要这行执行到就提交 @Transactional(propagation = Propagation.REQUIRES_NEW) public void log(String message) { auditDao.insert(message); }}# 若 AuditService.log 用 REQUIRED(默认):事务: BEGIN → insert order → insert audit → COMMIT# 若 order 后续抛异常 → 两个 insert 一起回滚(日志也没了)# 用 REQUIRES_NEW:事务A: BEGIN → insert order ──挂起A──事务B: BEGIN → insert audit → COMMIT事务A: ──恢复A── (继续) → COMMIT 或 ROLLBACK# A 回滚不影响 B,审计日志照样留下要点:REQUIRES_NEW 需要一个新的数据库连接(原连接被挂起占用)。外层若持有连接池资源且并发高,大量 REQUIRES_NEW 可能耗尽连接池——这是它的隐藏成本,别滥用。
隐藏成本具体是多少,取决于这个池上限。把它拖一遍就能看懂「为什么事务代码没改,接口却开始卡」:一个请求占 1 条是常态,占 2 条是 REQUIRES_NEW,而上限为 2 的池根本容不下两个并发请求各挂一次内层事务。
- HikariCP 的默认上限就是 10,这个数字不是随便取的
- 纯 REQUIRED 场景一个请求只用一条,10 条能扛住 10 并发
- 有两三层 REQUIRES_NEW 嵌套时,先按「线程数 × 最深嵌套」估一遍
- 要动这个数之前,先看事务能不能缩短——那才是主因
@Transactional 失效是面试和事故的常客。下面这张表覆盖绝大多数情况:
| 场景 | 原因 | 解法 |
|---|---|---|
同类方法自调用(this.method()) | 走的是原始对象,没经过代理 | 拆到另一个 Bean;或注入自身代理;或用 AopContext |
方法不是 public | 代理默认只增强 public 方法 | 改成 public |
| 异常被 catch 吞掉 | 代理收不到异常,无从回滚 | 别吞;或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
| 抛的是受检异常 | 默认只对 RuntimeException / Error 回滚 | @Transactional(rollbackFor = Exception.class) |
| 多线程内操作 | 新线程拿不到原线程的 ThreadLocal 连接 | 新线程里自己开启事务;或统一在主线程事务内完成 |
| 数据库引擎不支持 | 如 MyISAM 不支持事务 | 改用 InnoDB |
| finally 里 return | return 会掩盖异常,代理判定为成功 | 别在 finally 里 return |
| 未被 Spring 管理 | 自己 new 的对象,没进容器 | 交给容器管理 |
重点解释两个:
第一,同类自调用。因为自调用用的是 this,绕过了代理:
@Servicepublic class UserService { public void outer() { this.inner(); // ❌ 直接走 this,注解失效!事务不生效 } @Transactional public void inner() { // 事务本该在这里开启,但根本没走到代理 }}@Servicepublic class UserService { @Autowired private UserService self; // 注入自己的代理 public void outer() { self.inner(); // ✅ 走代理,事务生效 } @Transactional public void inner() { /* ... */ }}第二,异常类型不匹配。默认只回滚 RuntimeException 及其子类;一个 throws IOException 受检异常抛出,事务不会回滚:
// ❌ 受检异常默认不回滚@Transactionalpublic void create() throws IOException { dao.insert(); throw new IOException("boom"); // 事务依然提交,数据脏了}// ✅ 显式指定回滚的异常类型@Transactional(rollbackFor = Exception.class)public void create() throws IOException { dao.insert(); throw new IOException("boom"); // 现在会回滚}坑:这两条几乎承包了「事务失效」八成的生产问题。记住两句话:自调用不走代理;受检异常默认不回滚。
表格里最后那两行(吞异常、受检异常)症状一模一样——数据老老实实提交了,一句报错都没有——但成因是三道不同的关卡。先看动画把裁决顺序走一遍,再点下面这张图一格一格确认自己卡在哪一格:

两个不常用但很值的属性:
// readOnly=true:声明只读,可用于优化;timeout 单位是秒@Transactional(readOnly = true, timeout = 5)public List<Order> listOrders(Long userId) { return orderDao.findByUser(userId);}readOnly = true:向底层提示「本事务只读」。在 Hibernate 里会关闭脏检查、不做 flush,能省一些开销;但它不是一个硬性保护,别指望它拦住误写(是否生效取决于实现与数据库)。真正硬性的只读要靠数据库账号权限。timeout:事务超时秒数,超时抛TransactionTimedOutException并回滚,用于防止慢 SQL 长时间占着连接。
注意:readOnly 在多数实现里只是优化提示,不保证「真的只读」。要强制只读,请在数据库层面用只读账号,而不是只依赖注解属性。
一句话说清绑定关系:@Transactional 只对它当时那个 PlatformTransactionManager 管理的连接生效。默认单数据源时,全局只有一个数据源、一个事务管理器,一切正常。
一旦你配了多数据源(A 库 + B 库),@Transactional 默认只会开启其中一个数据源的事务;另一个数据源上的写入不在同一事务内,回滚时不会跟着回滚。要想跨库原子提交,就必须引入分布式事务:
- Seata(AT 模式):通过全局事务 ID 协调多个分支事务,业务侵入小,是主流方案
- 本地消息表:业务库写数据 + 写一张消息表(同一本地事务),再由定时任务投递并保证最终一致
- 最大努力通知 / TCC:分别适合对一致性要求不同、需要预留资源的场景
分布式事务是另一个大话题,这里只给你一个心智模型——单库单表靠本地事务,跨库跨服务就得靠全局事务或最终一致性方案。别指望一行 @Transactional 跨越两个数据库。
事务是「看不见」的,但把日志打开就一目了然。Spring 的 AbstractPlatformTransactionManager 会打印事务的开启 / 提交 / 回滚:
logging: level: org.springframework.transaction: TRACE # 打开事务内核日志 org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG打开后你会看到这样一整段「事务的一生」,正好对应前面那七步:
Creating new transaction with name [...OrderService.create]: PROPAGATION_REQUIRED, ISOLATION_DEFAULT # 1. 决定传播行为,创建事务Acquired Connection [HikariProxyConnection@...] for JDBC transactionSwitching JDBC Connection [...] to manual commit # 2. 关闭自动提交Initiating transaction commit # 3. 准备提交Committing JDBC transaction on Connection [...] # 4. 真正 COMMITReleasing JDBC Connection [...] back to DataSource # 5. 归还连接若中途异常,则是另一条路径:
Initiating transaction rollbackRolling back JDBC transaction on Connection [...]Releasing JDBC Connection [...] back to DataSource提示:排查「事务到底有没有生效」,最直接的办法就是开这段日志。看到 Creating new transaction / Initiating transaction commit,就说明代理与事务管理器都在正常工作。
把上面那两行 yaml 扩成一份能跑的完整配置:勾「数据源」「JPA」「日志」「Profile」,看事务内核日志与 open-in-view、show-sql 各落在哪一节——注意 JPA 那一栏会顺带把 spring.jpa.hibernate.ddl-auto 写出来,第十五节那条 TransactionRequiredException 就诞生在这几个开关的组合里。
server:
port: 8080
spring:
application:
name: demo-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
logging:
level:
root: INFO
com.example.demoservice: DEBUG
org.springframework.jdbc.core.JdbcTemplate: DEBUG # 打 SQL 与参数
file:
name: logs/app.log
logback:
rollingpolicy: { max-file-size: 50MB, max-history: 14 }
日志开起来只是第一步,真正的功夫在于一眼看懂它证明了什么。这六行是事务排查里出现频率最高的句子,来玩一局:左边点日志行,右边点它到底告诉你什么——配错当场解释,比背这张表快得多。
理论说完,来一次可交互复现。下面用订单表(t_order)和日志表(t_log)两张表,切换不同选项,观察它们的最终状态:
演示里重点看「外层回滚」这一档——用默认 REQUIRED 时,两张表一起回滚;用 REQUIRES_NEW 时,日志表留下了一行,订单表却回滚了。这就是两种传播行为在生产里最直观的差距。
第十节那个演示只有三档,够看清「一起回滚」和「日志留下」的差别,但七种传播行为全貌、以及它们各自吃掉几条连接还得换更细的实验台。下面五个实验按「先看全表 → 再看最常见的失效 → 再看代价 → 再看边界该落在哪 → 最后看切面抢顺序」排。
第一个是七种传播行为的总控台。建议顺序:先 required 看并入,再 requires_new 看挂起恢复,然后 nested 看保存点只回滚子段,最后 supports / not_supported 看「没有事务也能活」:
第二个回答新手最难自己想通的一件事:同一个类里 this.method() 为什么会让注解变成装饰。选「同类自调用」,注意通知链(拦截器序列)那一栏直接空了——不是事务管理器罢工,而是请求根本没经过代理:
第三个把第五节末尾那句「REQUIRES_NEW 隐藏成本」变成能看见的数字。选「达到上限排队」和「等待超时」,你会看到外层事务占着一条连接不放、内层为另开事务苦等第二条连接的现场:
第四个回答一个评审时最常吵的问题:@Transactional 到底该标在哪一层。标 Controller 会让一次 HTTP 请求攥着连接睡完所有慢 IO;标 Repository 又太碎,一个业务动作被拆成好几笔互不相干的提交。切「越层调用代价」还能看到事务被越层调用撕开的现场:
第五个是第八节多数据源话题的近亲,也是「事务切面和我自己写的 @Aspect 谁先跑」这条追问的标准答案。顺序错了,你会看到耗时统计里混进了 COMMIT 的时间,或者权限校验跑在事务里、失败了却已经写下半行数据:
实验做到这里,可以换成命令行自己敲了。下面这台控制台连着浏览器里的同一个 Java 内核,回显全部由内核算出来——先 beans 确认容器里真的装了事务管理器,再逐条把三种结局和两种传播跑一遍:
lab tx rollback 和 lab tx requires_new 要连着敲才有对比——前者的输出里两张表一起被清空,后者的审计表照样留下一行。同一个业务方法,差别只在传播枚举那一个字。
传播行为选错,不会在编译期报错,只会在生产以两种相反的症状爆发:该独立的没独立(审计日志跟着业务一起消失)、该并入的没并入(一个次要写入独占第二条连接,把池拖穿)。下面这个沙盘把「内层方法想干什么」做成一个开关,切一档就能同屏看到事务边界、连接占用和故障后果三处变化:
@Transactional ← 什么都不用写,默认 REQUIRED事务数:1 连接占用:conn1 × 1内层抛异常 → 外层一起回滚 ✔ 正是你要的# 判据:内层失败让业务数据变脏吗?会变脏 → 就该一起回滚
沙盘里的四档分别对应「同生共死 / 必须留下 / 别拖累我 / 只读不加锁」四种真实诉求。做决定的顺序应该是先问「内层失败我该不该心疼」,再问「要不要多占一条连接」,而不是反过来记枚举名。
先来一道热身题,考的是第六节那张失效表的头号常客:
再来一道综合题,把第五节的传播行为和第十一节的连接代价串起来:
新手最崩溃的时刻是「注解写了,数据却没能回滚,而且一句报错都没有」。下面这些片段都能原样复制去搜索。
先给一张 @Transactional 不生效的五个原因清单,逐条自查,命中率极高:
- 自调用:同类里
this.method(),绕过代理(第六节) - 方法不是 public:代理默认只增强公开方法,改成
protected/private等于没写 - Bean 没进容器:自己
new XxxService()出来的对象没有壳,注解只是装饰 - 异常被吞或被包装:
catch后不再抛出,或包成不含 runtime 语义的返回值,代理判定「成功」 - 异常类型不在回滚规则里:受检异常(
IOException/SQLException)默认不回滚,需要rollbackFor
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
TransactionRequiredException: Executing an update/delete query(JPA) | 写操作跑在没有事务的上下文里,EntityManager 拒绝执行 | 给该 Service 方法加 @Transactional;若是 Repository 自定义 @Modifying 查询,确认调用方也在事务里 | 本篇第三、六节 |
Cannot transaction on closed connection / Connection has been closed | 事务早已结束、连接已归还,代码却又在这条连接上执行 commit()/懒加载取数(典型是自调用 + 异步线程) | 检查是否跨线程用了原事务;异步任务里重新开启自己的事务;顺便看连接池是否设了过短的 maxLifetime | 本篇第四、六节 |
getConnection was not registered for synchronization because DataSource is not transactional | 事务管理器管的 DataSource 与实际取连接的 DataSource 不是同一个(多数据源手滑) | 确认 @Transactional(transactionManager = "...") 指对了管理器;两套 DataSource 不可能被一个本地事务覆盖 | 本篇第八节 |
| 没有任何异常,但受检异常抛出后数据仍然写进了库 | 默认只对 RuntimeException / Error 回滚,throws IOException 被当成「正常返回」 | 写成 @Transactional(rollbackFor = Exception.class),团队规范里直接固定这一句 | 本篇第六节 |
NestedTransactionNotSupportedException: Nested transactions are not supported by this JDBC driver | 用了 NESTED,但驱动/连接池不支持保存点,或被包装层吞掉了 savepoint API | 换成 REQUIRES_NEW(语义差一点但能跑),或去掉代理包装;MySQL Connector/J + InnoDB 是支持保存点的 | 本篇第五节 |
TransactionTimedOutException: Transaction timed out: deadline was ... | 命中了 timeout 属性,事务跑太久被强制回滚 | 先看是不是慢 SQL 或在事务里发了 HTTP;把外部 IO 移出事务,而不是盲目调大 timeout | 本篇第七节 |
Connection is busy: result set is not closed / 池被占满、请求长时间排队 | 事务太长把连接长期占住,或手动取连接后没还 | 开 leakDetectionThreshold 定位泄漏点;给只读长查询单独用短事务;核对池上限与并发量 | 第 28 篇 |
判断「事务到底有没有开」,永远先看第九节那段 TRACE 日志里有没有 Creating new transaction。没有这一行 = 代理没拦到你,后面的排查全都不用做;有这一行 = 事务确实在跑,那就去查异常类型和传播设置。
上面表里第一条 TransactionRequiredException 是最值得练的一次,因为它罕见地把「代理在场」和「事务缺席」同时写在了栈里。先别看答案,点出你认为的凶手帧:
跑批任务每天凌晨把统计表清零。本地单测一切正常,上线第一天就抛这句,而且业务栈只有短短几层。
用两个内存版账户复现「要么都成、要么都不成」,并且亲眼看到回滚。完整可跑代码(不连数据库,靠日志说话):
package com.example.tx;import java.util.ArrayList;import java.util.List;/** 假装的 DAO:所有写入先进缓冲区,commit 才落盘,rollback 直接丢弃 */public class AccountDao { private final List<String> buffer = new ArrayList<>(); private final List<String> disk = new ArrayList<>(); public void deduct(long id, int amount) { buffer.add("UPDATE account SET money=money-" + amount + " WHERE id=" + id); } public void add(long id, int amount) { buffer.add("UPDATE account SET money=money+" + amount + " WHERE id=" + id); } public void commit() { disk.addAll(buffer); buffer.clear(); } public void rollback() { buffer.clear(); } public List<String> disk() { return disk; }}package com.example.tx;public class TransferService { private final AccountDao dao = new AccountDao(); /** 手写事务的六步,对应第二节那段 JDBC 代码 */ public void transfer(long from, long to, int amount, boolean blowUp) { System.out.println("[tx] BEGIN"); // 1. 开启 try { dao.deduct(from, amount); // 2. 扣款 if (blowUp) throw new IllegalStateException("余额校验失败"); dao.add(to, amount); // 3. 入账 dao.commit(); // 4. 提交 System.out.println("[tx] COMMIT"); } catch (RuntimeException e) { dao.rollback(); // 5. 回滚 System.out.println("[tx] ROLLBACK -> " + e.getMessage()); } System.out.println("[pool] connection returned"); // 6. 归还连接 System.out.println("[disk] " + dao.disk()); } public static void main(String[] args) { TransferService svc = new TransferService(); svc.transfer(1, 2, 100, false); System.out.println("---"); svc.transfer(1, 2, 50, true); }}预期输出(务必自己跑一遍再往下读):
[tx] BEGIN[tx] COMMIT[disk] [UPDATE account SET money=money-100 WHERE id=1, UPDATE account SET money=money+100 WHERE id=2]---[tx] BEGIN[tx] ROLLBACK -> 余额校验失败[disk] [UPDATE account SET money=money-100 WHERE id=1, UPDATE account SET money=money+100 WHERE id=2]看第二组:BEGIN 之后只写了扣款就炸了,磁盘上没有多出半行——这就是原子性。把 blowUp 改成 false 再跑一次,两条 update 才会一起出现。
目标:把上面这个手写事务改造成「扣款成功但入账失败时,账目仍然自洽」,并顺手验证传播行为。
提示:只做两件事——① 新增一个 AuditDao,把它的 insert 放在独立「子事务」里(自己一份 buffer / disk);② 在 transfer 的 catch 分支里先提交审计、再回滚业务,模拟 REQUIRES_NEW。
你会观察到三件事:外层 ROLLBACK 之后,审计盘片上依然多了一行(对应第十三节那道综合题的正确答案);如果把审计的提交挪到回滚之后但共用同一份 buffer,那一行就消失了(等价于 REQUIRED);最后把业务方法拆成两个类互相调用,你会立刻理解「代理只在调用的入口处生效」这件事为什么和自调用无关——因为你已经没有代理了,全靠手动编排。
做一个真的 Spring Boot 小程序:AccountService.transfer() 转账 + 写审计日志 + 发一条「站内通知」(用一个 sleep(300) 的假方法代替),要求事务边界安排得既正确又省连接。
验收清单:
- [ ] 转账的两个账户写在同一个
@Transactional方法里,中途抛异常时两边余额都回到原点 - [ ] 审计日志用
REQUIRES_NEW,故意让转账失败后,日志表里仍能查到那一条 - [ ] 「发通知」这个慢操作不在事务里(挪到
NOT_SUPPORTED或事务提交后的监听器),你能说清为什么不能让它占着连接睡 300ms - [ ] 所有可能吞异常的
catch都补上了rollbackFor = Exception.class或显式setRollbackOnly() - [ ] 打开
logging.level.org.springframework.transaction: TRACE,贴出一次成功与一次回滚的日志片段,能指出Creating new transaction/Participating in existing transaction/Suspending current transaction各出现在哪 - [ ] 压测一下:并发 50 线程转账,HikariCP 的
active与awaiting指标没持续走高(说明挂起没把池吃穿)
不看资料,说出 @Transactional 生效需要的三层接力分别是谁——AOP 代理、TransactionInterceptor、PlatformTransactionManager。少说一层就回第三节。
能用「开会」类比讲清 REQUIRED / REQUIRES_NEW / NESTED 的区别吗?关键词必须是「并入」「休会另开」「保存点」,并且顺嘴说出第二条连接的代价。
@Transactional 静默失效的五个原因,能在 30 秒内列全吗?列不全就先回第十四节那张清单,再去看第六节的表。
受检异常默认会不会触发回滚?正确的注解写法是哪一句?答错就重做第十六节第一档。
怎么用最少的配置确认「我的事务到底开没开」?答案应落在 Creating new transaction 这一行日志上。
注解不会自己提交,代理拦截才有事务;一条连接绑一线程,自调用一律绕过;受检异常默认不回滚,rollbackFor 显式写清;要留凭据就挂起另开,别忘了池里多占一条。
把这篇压缩成五句话——注解之所以生效,靠的是 AOP 代理 + TransactionInterceptor + PlatformTransactionManager 三层接力;同一个事务共用一个连接,因为它绑定在 ThreadLocal 上;七种传播行为里,REQUIRED 是同一事务、REQUIRES_NEW 是独立事务、NESTED 是保存点;失效八成来自「自调用不走代理」和「受检异常默认不回滚」;看不见的事务,用 TRACE 日志一看便知。掌握这五句,你就能在评审时一眼看出事务写得对不对。