事务内核:@Transactional 的实现原理与传播机制

bee2026-10-0862 分钟0 次阅读
一个注解凭什么让方法自动提交或回滚?从代理拦截、连接绑定 ThreadLocal 讲到七种传播行为、失效场景大全与分布式事务预告。
1 / 148
小节
〇、30 秒看懂
2 / 148

转账这件事,你最怕的不是失败,而是「钱扣了,对方没收到」。数据库里一次业务往往要写好几行数据(扣款一行、入账一行、流水一行),如果写到第二行时程序崩了、断电了、余额不够报错了,前面那一行能不能收回去?答案就是事务:把这几条 SQL 捆成一个包裹,要么整包成功(提交 COMMIT),要么整包撤销(回滚 ROLLBACK),中间出任何事都不留半成品。Spring 的 @Transactional 干的就是这件事——你只写一行注解,框架替你把「开始 → 执行 → 提交或回滚 → 归还连接」全包了。这一篇讲三件事:谁替你按下的提交键(代理 + 事务管理器)、方法套方法时事务怎么处置(七种传播行为)、以及为什么你的注解有时静默失效。

3 / 148

先把五个词一句话解释清楚(全文都会用到):

4 / 148
  • 事务:一组 SQL 的「同生共死合同」。要么全生效,要么当从没发生过
  • 回滚:把这次连接里已经写下的改动全部撤回,像没写过一样
  • 代理:框架偷偷塞给你对象的一个「包装壳」。别人调你的方法时先经过壳,壳才有机会开启和提交事务
  • 连接池:预先建好、反复借还的数据库通道集合。借一条用一条,用完必须还——借不到就得排队等
  • 传播行为:外层方法已经有事务了,内层方法是「并入这场会」还是「自己单开一场」的规则
5 / 148
类比

事务就像去 ATM 转账:机器先把你的钱从 A 账户划出来,再往 B 账户加。如果划完钱突然停电,钱不能凭空消失——所以银行的做法是两笔操作绑成一个「要么都记、要么都不记」的动作,中途出事就整笔作废,你的余额回到原点。回滚就是「这笔根本没发生过」,提交就是「两边账本同时改定」。

6 / 148
架构图
图 · 七种传播行为怎么取舍
图 · 七种传播行为怎么取舍
7 / 148

上图用两把尺子量完了七种传播行为:横向问「内层是并入外层,还是独立执行」,纵向问「这个方法到底要不要事务」。以后遇到「日志必须留下」「批量导入别拖垮主流程」这类需求,先在这张图里找到属于自己的那一格,再决定写哪个枚举值。第五节会把它变成可运行的代码,第十节让你亲手把七种全切一遍。

8 / 148

学完这一篇,你应该能回答三个问题:

9 / 148
  • 一行 @Transactional 注解自己不会提交事务,那到底是谁在什么时候替我按下了 COMMIT?
  • 外层方法调内层方法,内层的异常为什么能把外层已经写好的数据一起拖没?想保住其中一行该怎么办?
  • 我的注解明明写了却没生效——五种静默失效的原因里,我踩的是哪一个?怎么用一行日志配置当场确认?
10 / 148
小节
一、ACID 与隔离级别速查
11 / 148

事务这个词听起来很重,其实目标很朴素:把一组操作当成一个「不可分割的整体」,要么全成功,要么全失败。业内用 ACID 四个字母概括:

12 / 148
对照表
特性白话版
原子性(Atomicity)同生共死:要么都提交,要么都回滚
一致性(Consistency)从一个正确状态,变到另一个正确状态
隔离性(Isolation)并发事务互不打扰,像排队一样
持久性(Durability)提交之后,断电也不丢
13 / 148

「隔离」最难,因为完全隔离就是串行,性能太差。于是有了四档隔离级别,级别越高越安全、并发越差:

14 / 148
对照表
隔离级别脏读不可重复读幻读
READ UNCOMMITTED可能可能可能
READ COMMITTED不可能可能可能
REPEATABLE READ不可能不可能可能(MySQL InnoDB 基本避免)
SERIALIZABLE不可能不可能不可能
15 / 148
说明

MySQL 的 InnoDB 默认是 REPEATABLE READ,并靠 MVCC + 间隙锁把大部分幻读也一并解决——这也是「MySQL 同级别比 Oracle 更稳」这个说法的来源。Oracle / PostgreSQL 默认则是 READ COMMITTED。

16 / 148
类比

隔离级别像图书馆的阅览规则。SERIALIZABLE 是「一次只放一个人进阅览室」,绝对安静但队伍排到天黑;READ UNCOMMITTED 是「随便看别人正在写的草稿」,快却可能读到后来被划掉的内容;REPEATABLE READ 则给你拍了一张进门那一刻的快照——里面怎么改都与你无关,你看到的永远是那张照片。这张照片在 MySQL 里就叫 MVCC(多版本并发控制:每行数据留着若干历史版本,各事务按自己的时间点挑一个版本读)。

17 / 148
小节
二、从手动事务的痛苦到 @Transactional
18 / 148

先看没有注解时,一个转账要写多少样板代码(JDBC 版):

19 / 148
java
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. 归还连接    }}
20 / 148

六个步骤、一次 try-catch-finally,每个涉及事务的方法都得抄一遍,还极易漏掉某一步(比如忘了 rollback)。用 @Transactional 之后:

21 / 148
java
@Transactionalpublic void transfer(Long from, Long to, BigDecimal amount) {    accountDao.deduct(from, amount);    accountDao.add(to, amount);    // 提交、回滚、连接归还——全由框架接管}
22 / 148

一行注解干掉了六步样板。那么问题来了:注解只是元数据,JVM 不会因为一行注解就去提交事务。到底是谁,在什么时候,替我们执行了那六步? 这就是下一节要拆的内核。

23 / 148

在拆内核之前,先确认这套机器真的在你的 classpath 上——@Transactional 静默罢工的第一大成因,是压根没有事务管理器。别去抄别人的 pom,勾一遍就知道每一行依赖各自撑着哪一层:勾 Data JPA 或 JDBC 会看到 EntityManagerFactory / JdbcTemplate 各自对应的实现类出现在依赖树里,只勾 MySQL 而忘勾它们,注解就是一个装饰。

24 / 148
生成器
生成器事务这套机器需要哪几行依赖pom.xml3 / 6
先只勾 JDBC 看最小可用组合,再叠 JPA / MySQL / H2;注意最后 Test 那一行的 scope——它和第 34 篇的集成测试直接相关
产物
<?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>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
JDBC只要模板类和 HikariCP,不想被 ORM 绑住时的最小选择。
H2 内存库runtime scope,让本地启动和测试不用真库;生产 profile 记得排除。
Testscope=test;@SpringBootTest、MockMvc、AssertJ 都在里面,漏了就找不到 @Test。
25 / 148
小节
三、实现原理:三层机器拆解
26 / 148
架构图
图 1 · 一个注解背后的四层机器
图 1 · 一个注解背后的四层机器
27 / 148

第一层:代理层——把注解变成「切面」。 @EnableTransactionManagement 开启事务管理能力,它导入 ProxyTransactionManagementConfiguration,注册了一个 BeanFactoryTransactionAttributeSourceAdvisor。这个 Advisor 会去扫描所有带 @Transactional 的 Bean,为它们生成 AOP 代理。

28 / 148
java
@Configuration@EnableTransactionManagement   // 打开事务开关(Spring Boot 自动配置里已默认开启)public class TxConfig {    // 关键在于:它导入配置注册了一个 Advisor:    // BeanFactoryTransactionAttributeSourceAdvisor    // → 该 Advisor 匹配 @Transactional 方法,为其生成代理}
29 / 148

第二层:拦截层——代理里跑的是 TransactionInterceptor。 当外部调用 transfer() 时,实际先进入代理,代理链上的 TransactionInterceptor.invoke() 会围绕目标方法做「环绕通知」:进入前开启事务,正常返回后提交,抛异常则回滚。

30 / 148
java
// TransactionInterceptor 的核心逻辑(高度简化)public Object invoke(MethodInvocation invocation) throws Throwable {    TransactionAttribute attr = getTransactionAttribute(invocation);  // 读出注解元数据    return invokeWithinTransaction(invocation.getMethod(), targetClass, () -> {        // 开启事务 → 调用目标方法 → 提交/回滚,全在这一层编排        return invocation.proceed();    });}
31 / 148

第三层:执行层——PlatformTransactionManager 真正碰数据库。 它是最底层的执行者,接口只有三个方法:getTransaction(开启)、commit(提交)、rollback(回滚)。Spring Boot 默认给你装配的是 DataSourceTransactionManager。

32 / 148
java
public interface PlatformTransactionManager {    TransactionStatus getTransaction(TransactionDefinition definition);  // 开启/加入事务    void commit(TransactionStatus status);                               // 提交    void rollback(TransactionStatus status);                             // 回滚}
33 / 148

它内部做了三件事:从连接池取连接 → 关闭自动提交(setAutoCommit(false))→ 提交或回滚后归还连接。此外,它还会把这条连接绑定到 TransactionSynchronizationManager(底层用 ThreadLocal),供同一线程内的后续操作复用——这正是下一节的关键。

34 / 148
原理动画
动图 · 一笔事务的生命周期
动图 · 一笔事务的生命周期
35 / 148

动画给的是全景,但「第几步才借连接、第几步才真的执行你的代码」这种事,读是读不进去的,得点。下面这台单步调试台的左边就是那七行,右边同步刷新「此刻的变量」和「调用栈」——连点下一步,重点盯第 ④⑤ 步:连接不是每次都借,而是只在这一笔事务新建时借一次;再盯第 ⑥ 步——你的业务代码直到这里才第一次被执行,前面五步全在它外面转。

36 / 148
单步调试台
单步台单步走完 TransactionInterceptor:提交键到底在哪一步被按下1 / 7
按 ①→⑦ 连点下一步。第 ③ 步决定加入还是新建,第 ⑥ 步才是你写的代码,第 ⑦ 步才是那句 COMMIT
被调试的代码
1orderService.create(order); // ① 调用打到代理,不是打到你的对象
2TransactionInterceptor.invoke() // ② 环绕通知:事务的编排全在这一层
3tm.getTransaction(def) // ③ 先问传播行为:加入已有,还是新建
4conn = dataSource.getConnection() // ④ 只有「新建」这一支才去借连接
5conn.setAutoCommit(false); bind(holder) // ⑤ 关自动提交,并把连接绑到当前线程
6result = method.invoke(target) // ⑥ 你的业务代码在这里才第一次执行
7commit / rollback → unbind → returnConn // ⑦ 提交或回滚,然后解绑、归还
此刻的变量
orderServiceOrderServiceImpl$$SpringCGLIB$$0
调用方线程http-nio-8080-exec-7
调用栈
1OrderController.create
2proxy.create
1先确认前提:注入进来的不是你自己 new 的对象,而是容器造好的替身。类名里带 SpringCGLIB,后面六步才有机会跑;如果打出来是干干净净的 com.example.OrderServiceImpl,说明代理根本没生成,注解等同于注释。
37 / 148
小节
四、事务与连接的生命周期:为什么不需要重复取连接
38 / 148

一个常见疑问:一个事务里调了 10 次 DAO,难道要取 10 次数据库连接吗?不会。因为事务管理器在开启事务时,已经把这条连接绑定到了当前线程的 ThreadLocal 里:

39 / 148
代码对照
代码java
// 开启事务时(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,再把连接还回连接池
40 / 148

把左右两栏并排放在一起,第四节和第六节那两个「看不见」的现象就同时解释了:左边是默认情形(同线程、同连接、同生死),右边是换线程和换事务的情形——REQUIRES_NEW 借的第二条连接、@Async 里那条无人认领的连接,都落在右栏:

41 / 148
架构图
图 · 一条连接怎么被线程记住
图 · 一条连接怎么被线程记住
42 / 148
说明

右栏不是「异常」,而是另一种正常。它提醒一件事:判断「这两行写入在不在同一个事务里」,别看它们是不是同一个方法,要看它们跑在哪条线程上、绑的是哪条连接。

43 / 148
小节
五、七种传播行为全解
44 / 148

传播行为(Propagation)回答一个问题:当一个事务方法调用另一个事务方法时,事务该怎么处置? 一共七种:

45 / 148
对照表
传播类型无事务时有事务时典型场景
REQUIRED(默认)新建事务加入当前事务绝大多数增删改查
REQUIRES_NEW新建事务挂起当前,另起新事务独立审计日志、发消息记录
NESTED新建事务建嵌套(保存点)子事务部分失败只回滚子事务
SUPPORTS不用事务加入当前事务查询类方法
NOT_SUPPORTED不用事务挂起当前,非事务执行大量 IO 且不关心一致性
MANDATORY抛异常加入当前事务强制要求上层已有事务
NEVER不用事务抛异常绝不允许在事务中执行
46 / 148

三类最需要对比记忆:

47 / 148
  • REQUIRED(默认):内外是同一个事务。内层一旦抛异常标记回滚,整个外层也一起回滚——「同生共死」。
  • REQUIRES_NEW:内外两个独立事务。外层会先被挂起,内层跑在自己的新事务里;内层提交或回滚,互不影响外层。
  • NESTED:内层是外层的嵌套子事务,靠数据库「保存点(Savepoint)」实现。内层失败只回滚到保存点,外层可以继续,也可选择整体回滚。
48 / 148
类比

传播行为就是开会的规矩。REQUIRED 是「既然你已经在开会了,我就并进你这个会,散会时一起出决议」——会上任何一个人翻脸,整场会议的纪要全部作废。REQUIRES_NEW 是「你先休会,我去隔壁单开一场,我那场的结果先落盘,回来你继续开你的」——所以同一时刻会议室要占用两间、连接也要借两条。NESTED 更像「大会里插一个小分组」:小分组谈崩了只撤销这一段的发言(回滚到保存点),大会随时可以宣布散会(整体回滚)。NOT_SUPPORTED 则是「你们开会你们的,我出去打个电话」,全程不进会议记录。

49 / 148

搞清「并入」和「另开」的区别,光看文字最容易记反。下面这段动画把两条路径并排画出来——注意中间那一步:REQUIRES_NEW 让 conn1 和 conn2 同时活着,而 REQUIRED 自始至终只有一条连接:

50 / 148
原理动画
动图 · REQUIRED 加入 vs REQUIRES_NEW 挂起恢复
动图 · REQUIRED 加入 vs REQUIRES_NEW 挂起恢复
51 / 148

用一个订单 + 审计日志的经典案例看差异:

52 / 148
java
@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);    }}
53 / 148
代码对照
代码text
# 若 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 可能耗尽连接池——这是它的隐藏成本,别滥用。

54 / 148

隐藏成本具体是多少,取决于这个池上限。把它拖一遍就能看懂「为什么事务代码没改,接口却开始卡」:一个请求占 1 条是常态,占 2 条是 REQUIRES_NEW,而上限为 2 的池根本容不下两个并发请求各挂一次内层事务。

55 / 148
参数调节台
调节台连接池上限:一个数字同时牵住事务与并发
spring.datasource.hikari.maximum-pool-size
10条连接当前 1 – 200
默认值:多数中小并发的合理起点
  • HikariCP 的默认上限就是 10,这个数字不是随便取的
  • 纯 REQUIRED 场景一个请求只用一条,10 条能扛住 10 并发
  • 有两三层 REQUIRES_NEW 嵌套时,先按「线程数 × 最深嵌套」估一遍
  • 要动这个数之前,先看事务能不能缩短——那才是主因
等待连接12%
池占用74%
先问「一个请求最多同时挂几条连接」,再决定上限——顺序反了,你就在用池容量掩盖长事务。
56 / 148
小节
六、失效场景大全
57 / 148

@Transactional 失效是面试和事故的常客。下面这张表覆盖绝大多数情况:

58 / 148
对照表
场景原因解法
同类方法自调用(this.method())走的是原始对象,没经过代理拆到另一个 Bean;或注入自身代理;或用 AopContext
方法不是 public代理默认只增强 public 方法改成 public
异常被 catch 吞掉代理收不到异常,无从回滚别吞;或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
抛的是受检异常默认只对 RuntimeException / Error 回滚@Transactional(rollbackFor = Exception.class)
多线程内操作新线程拿不到原线程的 ThreadLocal 连接新线程里自己开启事务;或统一在主线程事务内完成
数据库引擎不支持如 MyISAM 不支持事务改用 InnoDB
finally 里 returnreturn 会掩盖异常,代理判定为成功别在 finally 里 return
未被 Spring 管理自己 new 的对象,没进容器交给容器管理
59 / 148

重点解释两个:

60 / 148

第一,同类自调用。因为自调用用的是 this,绕过了代理:

61 / 148
java
@Servicepublic class UserService {    public void outer() {        this.inner();      // ❌ 直接走 this,注解失效!事务不生效    }    @Transactional    public void inner() {        // 事务本该在这里开启,但根本没走到代理    }}
62 / 148
java
@Servicepublic class UserService {    @Autowired private UserService self;   // 注入自己的代理    public void outer() {        self.inner();      // ✅ 走代理,事务生效    }    @Transactional    public void inner() { /* ... */ }}
63 / 148

第二,异常类型不匹配。默认只回滚 RuntimeException 及其子类;一个 throws IOException 受检异常抛出,事务不会回滚:

64 / 148
代码对照
代码java
// ❌ 受检异常默认不回滚@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");   // 现在会回滚}
解读

坑:这两条几乎承包了「事务失效」八成的生产问题。记住两句话:自调用不走代理;受检异常默认不回滚。

65 / 148

表格里最后那两行(吞异常、受检异常)症状一模一样——数据老老实实提交了,一句报错都没有——但成因是三道不同的关卡。先看动画把裁决顺序走一遍,再点下面这张图一格一格确认自己卡在哪一格:

66 / 148
原理动画
动图 · 提交还是回滚:拦截器的连环裁决
动图 · 提交还是回滚:拦截器的连环裁决
67 / 148
交互图解
流程异常抛出后,代理是怎么决定「提不提交」的1 / 6
从 ① 点到 ⑥。第 ③④ 格是默认规则,第 ⑤ 格是你的 rollbackFor,第 ⑥ 格是最阴的那条路
→
→
→
→
→
① 方法正常返回
没有任何裁决要做:代理看到返回值就走提交分支。这一格提醒一件反直觉的事——**提交是默认结局,回滚才是特例**,所以「数据写进去了」本身不能证明你的异常被处理过。
全部看懂了一句话裁决顺序:能不能逃出方法体 → 逃出来的类型在不在名单里 → 在就回滚,不在就提交。
68 / 148
小节
七、只读事务与超时
69 / 148

两个不常用但很值的属性:

70 / 148
代码对照
代码java
// 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 在多数实现里只是优化提示,不保证「真的只读」。要强制只读,请在数据库层面用只读账号,而不是只依赖注解属性。

71 / 148
小节
八、多数据源事务与分布式事务预告
72 / 148

一句话说清绑定关系:@Transactional 只对它当时那个 PlatformTransactionManager 管理的连接生效。默认单数据源时,全局只有一个数据源、一个事务管理器,一切正常。

73 / 148

一旦你配了多数据源(A 库 + B 库),@Transactional 默认只会开启其中一个数据源的事务;另一个数据源上的写入不在同一事务内,回滚时不会跟着回滚。要想跨库原子提交,就必须引入分布式事务:

74 / 148
  • Seata(AT 模式):通过全局事务 ID 协调多个分支事务,业务侵入小,是主流方案
  • 本地消息表:业务库写数据 + 写一张消息表(同一本地事务),再由定时任务投递并保证最终一致
  • 最大努力通知 / TCC:分别适合对一致性要求不同、需要预留资源的场景
75 / 148
说明

分布式事务是另一个大话题,这里只给你一个心智模型——单库单表靠本地事务,跨库跨服务就得靠全局事务或最终一致性方案。别指望一行 @Transactional 跨越两个数据库。

76 / 148
小节
九、观测技巧:打开 TRACE 日志看事务生命周期
77 / 148

事务是「看不见」的,但把日志打开就一目了然。Spring 的 AbstractPlatformTransactionManager 会打印事务的开启 / 提交 / 回滚:

78 / 148
yaml
logging:  level:    org.springframework.transaction: TRACE     # 打开事务内核日志    org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
79 / 148

打开后你会看到这样一整段「事务的一生」,正好对应前面那七步:

80 / 148
text
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. 归还连接
81 / 148

若中途异常,则是另一条路径:

82 / 148
代码对照
代码text
Initiating transaction rollbackRolling back JDBC transaction on Connection [...]Releasing JDBC Connection [...] back to DataSource
解读

提示:排查「事务到底有没有生效」,最直接的办法就是开这段日志。看到 Creating new transaction / Initiating transaction commit,就说明代理与事务管理器都在正常工作。

83 / 148

把上面那两行 yaml 扩成一份能跑的完整配置:勾「数据源」「JPA」「日志」「Profile」,看事务内核日志与 open-in-view、show-sql 各落在哪一节——注意 JPA 那一栏会顺带把 spring.jpa.hibernate.ddl-auto 写出来,第十五节那条 TransactionRequiredException 就诞生在这几个开关的组合里。

84 / 148
生成器
生成器事务排查一次配齐:数据源 + JPA + 日志application.yml2 / 5
先只勾 Logging 拿到上面那两行的完整版;再叠 DataSource(看 HikariCP 那几个上限正好对应第五节的滑块);勾 Profile 会看到 dev / prod 两份事务日志级别该差多少
产物
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 }
勾了这些,代价与理由在这里
datasource池参数写在这里才生效;写在代码里 new HikariDataSource() 就白配了。
logging级别可按包精细控制;logging.level.root=DEBUG 会把三方库全打爆,别在生产这么干。
85 / 148

日志开起来只是第一步,真正的功夫在于一眼看懂它证明了什么。这六行是事务排查里出现频率最高的句子,来玩一局:左边点日志行,右边点它到底告诉你什么——配错当场解释,比背这张表快得多。

86 / 148
配对闯关
闯关事务 TRACE 日志:这一行到底证明了什么已配对 0/6 · 配错 0
六行都来自 AbstractPlatformTransactionManager 与 DataSourceTransactionManager 的真实输出,别靠位置猜——两列都打乱了
先点左边一个
87 / 148
小节
十、亲手跑一遍传播行为
88 / 148

理论说完,来一次可交互复现。下面用订单表(t_order)和日志表(t_log)两张表,切换不同选项,观察它们的最终状态:

89 / 148
内核实验
TeaVM亲手跑一遍传播行为未启动
依次切换「正常提交」「外层回滚」「REQUIRES_NEW」,对比两张表的最终状态
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
90 / 148
要点

演示里重点看「外层回滚」这一档——用默认 REQUIRED 时,两张表一起回滚;用 REQUIRES_NEW 时,日志表留下了一行,订单表却回滚了。这就是两种传播行为在生产里最直观的差距。

91 / 148
小节
十一、上手实验:把七种传播行为挨个按一遍
92 / 148

第十节那个演示只有三档,够看清「一起回滚」和「日志留下」的差别,但七种传播行为全貌、以及它们各自吃掉几条连接还得换更细的实验台。下面五个实验按「先看全表 → 再看最常见的失效 → 再看代价 → 再看边界该落在哪 → 最后看切面抢顺序」排。

93 / 148

第一个是七种传播行为的总控台。建议顺序:先 required 看并入,再 requires_new 看挂起恢复,然后 nested 看保存点只回滚子段,最后 supports / not_supported 看「没有事务也能活」:

94 / 148
内核实验
TeaVM七种传播行为逐个切未启动
切到「回滚规则」那一档,亲眼看看抛受检异常时事务为什么纹丝不动
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
95 / 148

第二个回答新手最难自己想通的一件事:同一个类里 this.method() 为什么会让注解变成装饰。选「同类自调用」,注意通知链(拦截器序列)那一栏直接空了——不是事务管理器罢工,而是请求根本没经过代理:

96 / 148
内核实验
TeaVM自调用绕过了什么未启动
对比 JDK / CGLIB 两档能看到代理确实存在,再切「同类自调用」看拦截链消失
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
97 / 148

第三个把第五节末尾那句「REQUIRES_NEW 隐藏成本」变成能看见的数字。选「达到上限排队」和「等待超时」,你会看到外层事务占着一条连接不放、内层为另开事务苦等第二条连接的现场:

98 / 148
内核实验
TeaVM嵌套事务要借两条连接未启动
先「命中空闲连接」看顺利的样子,再切「等待超时」看池被挂起的外层连接占满时应用怎么卡死
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
99 / 148

第四个回答一个评审时最常吵的问题:@Transactional 到底该标在哪一层。标 Controller 会让一次 HTTP 请求攥着连接睡完所有慢 IO;标 Repository 又太碎,一个业务动作被拆成好几笔互不相干的提交。切「越层调用代价」还能看到事务被越层调用撕开的现场:

100 / 148
内核实验
TeaVM事务边界落在哪一层未启动
先看「一次下单自上而下」确认调用方向,再切这一档看边界标在 Controller / Service / Repository 三层各自后果,最后对照第七节的 readOnly
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
101 / 148

第五个是第八节多数据源话题的近亲,也是「事务切面和我自己写的 @Aspect 谁先跑」这条追问的标准答案。顺序错了,你会看到耗时统计里混进了 COMMIT 的时间,或者权限校验跑在事务里、失败了却已经写下半行数据:

102 / 148
内核实验
TeaVM事务切面和自定义切面谁包谁未启动
切这一档看 TransactionInterceptor 与你的 @Aspect 的嵌套顺序;再切「顺序颠倒的坑」看反了之后日志里哪几行会串位
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
103 / 148

实验做到这里,可以换成命令行自己敲了。下面这台控制台连着浏览器里的同一个 Java 内核,回显全部由内核算出来——先 beans 确认容器里真的装了事务管理器,再逐条把三种结局和两种传播跑一遍:

104 / 148
内核控制台
105 / 148
说明

lab tx rollback 和 lab tx requires_new 要连着敲才有对比——前者的输出里两张表一起被清空,后者的审计表照样留下一行。同一个业务方法,差别只在传播枚举那一个字。

106 / 148
小节
十二、沙盘:这段代码该选哪种传播行为
107 / 148

传播行为选错,不会在编译期报错,只会在生产以两种相反的症状爆发:该独立的没独立(审计日志跟着业务一起消失)、该并入的没并入(一个次要写入独占第二条连接,把池拖穿)。下面这个沙盘把「内层方法想干什么」做成一个开关,切一档就能同屏看到事务边界、连接占用和故障后果三处变化:

108 / 148
沙盘
沙盘传播行为选择器:先说需求,再挑枚举
运行结果
@Transactional ← 什么都不用写,默认 REQUIRED
事务数:1 连接占用:conn1 × 1
内层抛异常 → 外层一起回滚 ✔ 正是你要的
# 判据:内层失败让业务数据变脏吗?会变脏 → 就该一起回滚
绝大多数增删改查停在这一格。别为了「看起来高级」乱换枚举。
109 / 148
说明

沙盘里的四档分别对应「同生共死 / 必须留下 / 别拖累我 / 只读不加锁」四种真实诉求。做决定的顺序应该是先问「内层失败我该不该心疼」,再问「要不要多占一条连接」,而不是反过来记枚举名。

110 / 148
小节
十三、随堂自测
111 / 148

先来一道热身题,考的是第六节那张失效表的头号常客:

112 / 148
随堂自测
随堂自测同一个 Service 类里,`outer()` 用 `this.inner()` 调用了标着 `@Transactional` 的 `inner()`,结果事务没生效。最准确的原因是?
先自己选一个,选中立刻告诉你对不对
113 / 148

再来一道综合题,把第五节的传播行为和第十一节的连接代价串起来:

114 / 148
随堂自测
随堂自测外层 `create()` 是 REQUIRED,里面调用 REQUIRES_NEW 的 `log()`;`log()` 插入成功并提交,随后外层抛 RuntimeException。最终数据库里是什么状态?
先自己选一个,选中立刻告诉你对不对
115 / 148
小节
十四、常见报错速查
116 / 148

新手最崩溃的时刻是「注解写了,数据却没能回滚,而且一句报错都没有」。下面这些片段都能原样复制去搜索。

117 / 148

先给一张 @Transactional 不生效的五个原因清单,逐条自查,命中率极高:

118 / 148
  1. 自调用:同类里 this.method(),绕过代理(第六节)
  2. 方法不是 public:代理默认只增强公开方法,改成 protected / private 等于没写
  3. Bean 没进容器:自己 new XxxService() 出来的对象没有壳,注解只是装饰
  4. 异常被吞或被包装:catch 后不再抛出,或包成不含 runtime 语义的返回值,代理判定「成功」
  5. 异常类型不在回滚规则里:受检异常(IOException / SQLException)默认不回滚,需要 rollbackFor
119 / 148
对照表
报错原文(片段)真实原因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 篇
120 / 148
提示

判断「事务到底有没有开」,永远先看第九节那段 TRACE 日志里有没有 Creating new transaction。没有这一行 = 代理没拦到你,后面的排查全都不用做;有这一行 = 事务确实在跑,那就去查异常类型和传播设置。

121 / 148

上面表里第一条 TransactionRequiredException 是最值得练的一次,因为它罕见地把「代理在场」和「事务缺席」同时写在了栈里。先别看答案,点出你认为的凶手帧:

122 / 148
报错急救
报错急救TransactionRequiredException: Executing an update/delete query
JPA 写操作跑在没有事务的线程上:栈里有代理,却没有事务

跑批任务每天凌晨把统计表清零。本地单测一切正常,上线第一天就抛这句,而且业务栈只有短短几层。

jakarta.transaction.TransactionRequiredException: Executing an update/delete query
at org.hibernate.internal.AbstractSharedSessionContract.checkTransactionNeededForUpdateOperation(AbstractSharedSessionContract.java:51)
at org.hibernate.query.spi.AbstractQuery.executeUpdate(AbstractQuery.java:64)
at com.bee.order.dao.OrderStatDao.resetDaily(OrderStatDao.java:47)
at com.bee.order.service.ReportService.resetWithinTx(ReportService.java:71)
at com.bee.order.service.ReportService.refresh(ReportService.java:63)
at com.bee.order.service.ReportService$$SpringCGLIB$$0.refresh(<generated>)
at com.bee.order.job.DailyJob.run(DailyJob.java:29)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
123 / 148
小节
十五、决策卡:审计日志必须留下,怎么安排事务边界
124 / 148
决策
决策系统要求「无论业务成功还是失败,都要留下一条不可丢失的审计日志」,审计日志写数据库,与业务操作发生在同一个方法里。该怎么办?
125 / 148
小节
十六、动手练习
126 / 148
小节
第一档 · 照做
127 / 148

用两个内存版账户复现「要么都成、要么都不成」,并且亲眼看到回滚。完整可跑代码(不连数据库,靠日志说话):

128 / 148
java
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; }}
129 / 148
java
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);    }}
130 / 148

预期输出(务必自己跑一遍再往下读):

131 / 148
text
[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]
132 / 148

看第二组:BEGIN 之后只写了扣款就炸了,磁盘上没有多出半行——这就是原子性。把 blowUp 改成 false 再跑一次,两条 update 才会一起出现。

133 / 148
小节
第二档 · 变体
134 / 148

目标:把上面这个手写事务改造成「扣款成功但入账失败时,账目仍然自洽」,并顺手验证传播行为。

135 / 148

提示:只做两件事——① 新增一个 AuditDao,把它的 insert 放在独立「子事务」里(自己一份 buffer / disk);② 在 transfer 的 catch 分支里先提交审计、再回滚业务,模拟 REQUIRES_NEW。

136 / 148

你会观察到三件事:外层 ROLLBACK 之后,审计盘片上依然多了一行(对应第十三节那道综合题的正确答案);如果把审计的提交挪到回滚之后但共用同一份 buffer,那一行就消失了(等价于 REQUIRED);最后把业务方法拆成两个类互相调用,你会立刻理解「代理只在调用的入口处生效」这件事为什么和自调用无关——因为你已经没有代理了,全靠手动编排。

137 / 148
小节
第三档 · 造一个
138 / 148

做一个真的 Spring Boot 小程序:AccountService.transfer() 转账 + 写审计日志 + 发一条「站内通知」(用一个 sleep(300) 的假方法代替),要求事务边界安排得既正确又省连接。

139 / 148

验收清单:

140 / 148
  • [ ] 转账的两个账户写在同一个 @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 指标没持续走高(说明挂起没把池吃穿)
141 / 148
小节
十七、要点自查
142 / 148
自检

不看资料,说出 @Transactional 生效需要的三层接力分别是谁——AOP 代理、TransactionInterceptor、PlatformTransactionManager。少说一层就回第三节。

143 / 148
自检

能用「开会」类比讲清 REQUIRED / REQUIRES_NEW / NESTED 的区别吗?关键词必须是「并入」「休会另开」「保存点」,并且顺嘴说出第二条连接的代价。

144 / 148
自检

@Transactional 静默失效的五个原因,能在 30 秒内列全吗?列不全就先回第十四节那张清单,再去看第六节的表。

145 / 148
自检

受检异常默认会不会触发回滚?正确的注解写法是哪一句?答错就重做第十六节第一档。

146 / 148
自检

怎么用最少的配置确认「我的事务到底开没开」?答案应落在 Creating new transaction 这一行日志上。

147 / 148
口诀

注解不会自己提交,代理拦截才有事务;一条连接绑一线程,自调用一律绕过;受检异常默认不回滚,rollbackFor 显式写清;要留凭据就挂起另开,别忘了池里多占一条。

148 / 148
总结

把这篇压缩成五句话——注解之所以生效,靠的是 AOP 代理 + TransactionInterceptor + PlatformTransactionManager 三层接力;同一个事务共用一个连接,因为它绑定在 ThreadLocal 上;七种传播行为里,REQUIRED 是同一事务、REQUIRES_NEW 是独立事务、NESTED 是保存点;失效八成来自「自调用不走代理」和「受检异常默认不回滚」;看不见的事务,用 TRACE 日志一看便知。掌握这五句,你就能在评审时一眼看出事务写得对不对。