Note 16. 数据一致性:Spring 事务管理深度解析


Note 16. 数据一致性:Spring 事务管理深度架构解析

摘要:在简单的 @Transactional 注解背后,隐藏着 Spring AOP、数据库连接池、JDBC 规范以及数据库内核(Undo/Redo Log、MVCC)的复杂协作。本章将从“上帝视角”透视事务的全生命周期。我们将不再满足于“会用”,而是要深入理解 TransactionSynchronizationManager 如何管理连接,剖析 InnoDB 如何通过 Read View 实现隔离,掌握传播行为背后的 堆栈逻辑,并彻底解决 AOP 代理导致的 自调用失效 等生产级难题。

本章环境与版本锁定

技术组件版本号核心类/参数说明
Spring Boot3.3.0spring-tx 模块
MySQL8.0.33InnoDB 引擎 (支持 ACID)
HikariCP5.1.0maximum-pool-size (连接池上限)
JDK21 LTS虚拟线程支持 (Loom)

16.1. 深度透视:事务的物理本质与 Spring 的封装

很多开发者认为事务是 Spring 的一项“服务”,通过几个注解就能凭空产生。这是错误的。事务是数据库(Database)本身的一种机制,Spring 只是一个精明的“中间商”和“指挥官”。

16.1.1. 裸奔的 JDBC:事务的本来面目

在没有任何框架(Spring/MyBatis)包裹时,一个标准的 JDBC 事务包含 5 个硬核步骤。理解这个过程,是理解 Spring 事务源码的基础。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 0. 从连接池获取物理连接 (TCP 连接)
Connection conn = dataSource.getConnection();

try {
// 1. 【核心动作】关闭自动提交
// 这一步告诉 MySQL:"接下来的话我没说完之前,别写入磁盘"
// 对应 MySQL 命令:SET AUTOCOMMIT = 0;
conn.setAutoCommit(false);

// 2. 业务执行(可能包含多条 SQL)
statement.executeUpdate("UPDATE account SET balance = balance - 100 WHERE id = A");
statement.executeUpdate("UPDATE account SET balance = balance + 100 WHERE id = B");

// 3. 【核心动作】提交事务
// 此时,MySQL 将 Redo Log 刷盘,释放行锁,数据对其他会话可见
// 对应 MySQL 命令:COMMIT;
conn.commit();

} catch (Exception e) {
// 4. 【核心动作】回滚事务
// 利用 Undo Log 将数据恢复到步骤 1 之前的快照
// 对应 MySQL 命令:ROLLBACK;
conn.rollback();
throw e;
} finally {
// 5. 【资源释放】重置状态并归还连接
// 必须恢复 autoCommit 为 true,否则归还到池里的连接会“污染”下一次请求
conn.setAutoCommit(true);
conn.close(); // 归还给 HikariCP
}

16.1.2. Spring 的魔法:ThreadLocal 与 资源同步

你可能会问:“我在 Service 层的方法里根本没传 Connection 对象,Spring 是怎么保证三条 SQL 用的是同一个数据库连接的?”

答案在于 Spring 的核心类:TransactionSynchronizationManager

  1. 资源绑定:当事务开启时,Spring 会从 DataSource 拿出一个连接,并将其包装为 ConnectionHolder
  2. 线程绑定:利用 JDK 的 ThreadLocal,将这个 ConnectionHolder 绑定到当前线程(Thread)上。
  3. 智能获取:MyBatis 或 JPA 在执行 SQL 时,会先通过 DataSourceUtils.getConnection()ThreadLocal 查找:“当前线程有没有正在进行的事务连接?”
    • :直接复用(保证了 ACID)。
    • :新建一个连接(自动提交模式)。

16.2. 实战与进阶:@Transactional 的详细配置

@Transactional 看起来简单,但其参数配置直接决定了生产环境的稳定性。

16.2.1. 异常回滚的陷阱:rollbackFor

默认行为:Spring 默认 在抛出 RuntimeException (如 NullPointerException) 或 Error (如 OutOfMemoryError) 时回滚。
致命坑点:如果你的代码抛出了 Checked Exception(如 IOException, SQLException, ExecutionException),Spring 会默认认为“这是预料之中的异常”,进而提交事务

生产级代码模版

1
2
3
4
5
6
7
// ❌ 危险写法:IO 异常发生时,数据库不会回滚!
@Transactional
public void riskyMethod() throws IOException { ... }

// ✅ 标准写法:无论是受检异常还是运行时异常,通通回滚
@Transactional(rollbackFor = Exception.class)
public void safeMethod() throws Exception { ... }

16.2.2. 性能倍增器:readOnly = true

很多教程说 readOnly=true 只是为了代码可读性,这是大错特错的。在读多写少的系统中,这个参数是性能优化的关键。

  1. Spring 层面(ORM 优化):如果你使用的是 JPA/Hibernate,开启 readOnly=true 后,Spring 会将 Hibernate Session 的 FlushMode 设置为 MANUAL。这意味着:查询出来的对象,不再进行脏检查(Dirty Checking)

    • 性能提升:对于查询大量数据的列表接口,内存消耗和 CPU 占用可降低 30% 以上。
  2. MySQL 层面(MVCC 优化):对于 InnoDB 引擎,如果一个事务被标记为“只读”,数据库 不需要 为它分配一个唯一的 Transaction ID(事务 ID)。

    • 性能提升:减少了内部锁结构和 Undo Log 的部分开销,降低了高并发下的资源竞争。

16.2.3. 拒绝死锁:timeout

场景:某个复杂的报表统计 SQL 因为索引失效,执行了 20 秒。此时它一直占着数据库连接(Connection)和行锁(Row Lock)。如果并发量上来,连接池会被迅速耗尽,导致系统雪崩。

机制:Spring 并不支持真正的“中断 SQL 执行”(这依赖于 JDBC 驱动的具体实现)。
Spring 的 timeout 实现原理是:

  1. 在 SQL 执行前,检查 deadline(截止时间)。如果当前时间 > deadline,直接抛出 TransactionTimedOutException
  2. 设置 JDBC Statement 的 setQueryTimeout,让数据库层面去控制 SQL 执行时间。
1
2
3
// 设定 3 秒超时,防止慢 SQL 拖垮整个微服务
@Transactional(rollbackFor = Exception.class, timeout = 3)
public void complexQuery() { ... }

16.3. 挑战并发核心:隔离级别 (Isolation) 与 MVCC

在微服务高并发场景下,多个事务同时操作同一行数据是家常便饭。为了防止数据错乱,数据库提供了隔离级别。

16.3.1. 并发三宗罪

问题描述严重等级
脏读 (Dirty Read)读到了别人 没提交 的数据。如果对方回滚了,你读到的就是“鬼数据”。⭐⭐⭐⭐⭐ (最严重)
不可重复读同一个事务里,先后两次读取同一行,结果不一样(被别人中途修改提交了)。⭐⭐⭐ (导致逻辑不一致)
幻读 (Phantom Read)统计时发现有 5 条记录,准备批量更新时,突然发现变成了 6 条(被别人中途插入了)。⭐⭐ (常见于统计类业务)

16.3.2. 隔离级别深度解析

Spring 通过 isolation 属性透传给数据库。

  1. READ_UNCOMMITTED (读未提交)

    • 原理:不做任何隔离,直接读最新数据。
    • 评价:裸奔。生产环境绝对禁止
  2. READ_COMMITTED (RC, 读已提交)

    • 原理:每次执行 SELECT 语句时,都会重新生成一个 Read View(快照)。
    • 特点:能防脏读,防不了不可重复读。
    • 适用大多数互联网大厂(如阿里)的默认选择。因为相比于 RR,它的锁粒度更小,并发度更高。
  3. REPEATABLE_READ (RR, 可重复读)

    • 原理:在事务开启后的第一次 SELECT 时生成 Read View,之后整个事务期间复用这个快照。
    • 特点MySQL 的默认级别。它配合 Next-Key Lock(间隙锁)在一定程度上解决了幻读问题。
    • 代价:为了防幻读,锁的范围更大,死锁概率略高。
  4. SERIALIZABLE (串行化)

    • 原理:将所有的 SELECT 隐式转换为 SELECT ... LOCK IN SHARE MODE
    • 评价:性能极差,除非是核弹发射程序,否则别用。

如何选择?
如果你的业务可以容忍“在这个事务里读是 100 块,过一会读变成了 200 块(因为别人转进来了)”,建议配置为 READ_COMMITTED 以获得更高吞吐量。

1
2
3
4
5
6
# application.yml 全局配置
spring:
transaction:
default-timeout: 30
# 推荐大厂配置:READ_COMMITTED
isolation: READ_COMMITTED

16.4. 编排的艺术:事务传播行为 (Propagation)

ServiceA 调用 ServiceB 时,事务如何传递?这是 Spring 独有的概念,数据库层面并没有“传播行为”这一说。

16.4.1. REQUIRED (默认值):融合

逻辑:如果当前有事务,就加入;没有就新建。
底层实现:Spring 不会向数据库发送新的 BEGIN 命令,而是直接复用当前的 Connection

致命连锁反应:假设 A 调用 B。B 报错回滚。即便 A 捕获了异常,A 也 无法提交。因为 B 在回滚时,已经将底层的 Connection 标记为 rollback-only。A 尝试提交时,Spring 会检测到这个标记,抛出 UnexpectedRollbackException

16.4.2. REQUIRES_NEW:隔离

逻辑:挂起当前事务,创建一个全新的连接(Connection),开启新事务。
底层实现

  1. TransactionManager 将当前的 TransactionStatus(包含旧连接)挂起到一个栈中。
  2. 从连接池请求一个新的 Connection
  3. 在新连接上执行 conn.setAutoCommit(false)
  4. 方法结束后,提交/回滚新事务,关闭新连接。
  5. 从栈中恢复旧的 TransactionStatus

适用场景

  • 审计日志:不管业务是否失败,记录日志的操作必须成功。
  • ID 生成器:防止主业务回滚导致 ID 回退。

16.4.3. NESTED:嵌套 (少见但有用)

逻辑:如果当前有事务,则在当前事务中创建一个 Savepoint (保存点)
底层实现SAVEPOINT sp_01
效果:如果 B 报错,只会回滚到 sp_01,A 可以选择捕获异常并尝试其他逻辑,A 之前的操作 不会被回滚注意:这需要数据库支持 Savepoint 特性(MySQL InnoDB 支持)。


16.5. 核心避坑:AOP 代理失效的四大原理

这是面试必问,生产必挂的重灾区。

16.5.1. 根本原因:动态代理的“影子”

Spring 的 @Transactional 是通过 AOP 实现的。在容器启动时,Spring 不会把原始的 UserService 注入进去,而是注入一个 代理对象 (Proxy)

  • Proxy 对象:持有 TransactionInterceptor(事务拦截器)。
  • Target 对象:原始的业务代码。

当你调用 userService.method() 时,实际路径是:
Caller -> Proxy.method() -> TransactionInterceptor.invoke() (开启事务) -> Target.method() (执行业务)。

16.5.2. 失效场景一:自调用 (This 之殇)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class OrderService {

// 方法 A:没有事务注解
public void createOrder() {
// ❌ 错误:直接调用内部方法
// 本质是 this.saveData(),this 指向 Target 对象
// 直接绕过了 Proxy 代理层,没有经过事务拦截器!
saveData();
}

@Transactional
public void saveData() { ... }
}

解决方案

  1. 注入自己(最常用):
    1
    2
    @Autowired @Lazy private OrderService self;
    public void createOrder() { self.saveData(); }
  2. 获取当前代理(这种方式需开启 exposeProxy=true):
    1
    ((OrderService) AopContext.currentProxy()).saveData();

16.5.3. 失效场景二:非 Public 方法

原理

  • JDK 动态代理:只能代理接口方法(默认 public)。
  • CGLIB 代理:通过继承生成子类。因为 private 方法无法被子类重写(Override),所以 CGLIB 无法拦截它。

结论@Transactional 只能加在 public 方法上。

16.5.4. 失效场景三:多线程逃逸

原理:数据库连接是绑定在 ThreadLocal 上的。如果你在事务方法里 new Thread(),新线程的 ThreadLocal 是空的。它会去连接池拿一个新的连接,和主线程的事务 毫无关系


16.6. 高级扩展:事务同步钩子 (TransactionSynchronization)

需求:在事务提交 成功后,发送 MQ 消息通知下游。如果在事务内部发 MQ,可能会出现“MQ 发出去了,但事务最后回滚了”的数据不一致问题。

解决方案:使用 TransactionSynchronizationManager 注册一个回调钩子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Transactional
public void finishOrder() {
// 1. 数据库操作
orderMapper.updateStatus("FINISHED");

// 2. 注册事务提交后的回调
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 3. 只有事务真正 commit 成功后,才会执行这里
mqProducer.send("订单完成");
}
}
);
}

16.7. 本章总结:生产级事务架构师手册

我们从 JDBC 的物理连接出发,一路拆解了 Spring 的 AOP 代理、传播机制以及数据库的 MVCC 原理。为了让你在实际开发中不仅能“写得出来”,还能“扛得住并发”,我将本章核心浓缩为以下三份作战清单。

16.7.1. 核心决策矩阵

在写下 @Transactional 之前,请先对照下表回答三个问题:“要不要回滚?”“数据隔离性要求多高?”“事务怎么传播?”

决策维度场景特征推荐配置架构代价
异常回滚涉及任何 IO、SQL、网络异常rollbackFor = Exception.class无代价,必须遵守的铁律。
读取优化纯查询操作 (List/Get)readOnly = true收益极大。跳过 JPA 脏检查,减少 MySQL 锁竞争。
隔离级别报表统计、资金核对 (严谨)REPEATABLE_READ (默认)锁范围大,吞吐量略低,但数据绝对一致。
隔离级别高并发秒杀、社交点赞 (速度)READ_COMMITTED吞吐量高。需在业务层容忍“不可重复读”现象。
传播行为核心业务 (订单、支付)REQUIRED (默认)命运共同体,一损俱损。
传播行为旁路逻辑 (日志、埋点)REQUIRES_NEW多占用一个数据库连接,需注意连接池容量。

16.7.2. “Copy-Paste” 级代码模版

不要每次都手写事务配置,以下是三种最经典场景的标准写法,建议直接纳入团队的代码规范。

模版 A:标准严谨型 (适用于 90% 的业务)
特点:全异常回滚、防超时。

1
2
3
4
5
6
7
@Transactional(
rollbackFor = Exception.class, // 1. 捕获所有异常
timeout = 10 // 2. 10 秒超时,防止慢 SQL 拖垮连接池
)
public void submitOrder(OrderDTO order) {
// 业务逻辑...
}

模版 B:手动控制型 (适用于需要 Try-Catch 但又要回滚)
场景:你希望捕获异常给前端返回友好的 Result,但数据库必须回滚。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional(rollbackFor = Exception.class)
public Result<String> createSafe() {
try {
userMapper.insert(user);
int i = 1 / 0; // 模拟异常
return Result.ok();
} catch (Exception e) {
log.error("创建失败", e);
// 【关键动作】手动标记回滚,否则 Spring 认为你处理了异常,会提交事务!
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return Result.fail("系统繁忙");
}
}

模版 C:事件驱动型 (适用于事务提交后发消息)
场景:防止“消息发出去了,数据库却回滚了”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Transactional(rollbackFor = Exception.class)
public void finishPayment() {
orderMapper.updateStatus("PAID");

// 【关键动作】注册同步钩子,确保 DB 提交成功后再发 MQ
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
mqProducer.send("PAYMENT_SUCCESS");
}
}
);
}

16.7.3. 事务失效排查心法

当你发现“配置了事务却不生效”时,请按以下顺序进行 “灵魂四问”

  1. 问对象
    • “我调用的是代理对象(Proxy)还是目标对象(Target)?”
    • 如果是 this.method(),直接判定失效。请注入 self 或拆分 Service。
  2. 问权限
    • “方法是 public 的吗?”
    • Private/Protected 方法上的注解在默认代理模式下是无效的。
  3. 问异常
    • “抛出的异常是 RuntimeException 吗?”
    • 如果是 Exception (Checked) 且没配 rollbackFor,Spring 会无视并提交。
    • “异常被我 catch 掉了吗?”
    • 如果 Catch 了且没抛出,也没手动 setRollbackOnly,事务会正常提交。
  4. 问线程
    • “这是在主线程执行的吗?”
    • 如果在事务方法里 new Thread(),新线程是无事务状态(自动提交)。

最后的忠告:事务是数据库资源的 “加锁行为”。请永远记住:事务范围越小越好。不要在 @Transactional 方法里进行 HTTP 请求文件 IO复杂耗时计算。这些操作不涉及数据库,却会一直占着数据库连接不释放,直到连接池耗尽,系统崩溃。
把非 DB 操作移到事务外面,是高并发优化的第一步。