Note 16. 数据一致性:Spring 事务管理深度解析
Note 16. 数据一致性:Spring 事务管理深度解析
ProriseNote 16. 数据一致性:Spring 事务管理深度架构解析
摘要:在简单的 @Transactional 注解背后,隐藏着 Spring AOP、数据库连接池、JDBC 规范以及数据库内核(Undo/Redo Log、MVCC)的复杂协作。本章将从“上帝视角”透视事务的全生命周期。我们将不再满足于“会用”,而是要深入理解 TransactionSynchronizationManager 如何管理连接,剖析 InnoDB 如何通过 Read View 实现隔离,掌握传播行为背后的 堆栈逻辑,并彻底解决 AOP 代理导致的 自调用失效 等生产级难题。
本章环境与版本锁定
| 技术组件 | 版本号 | 核心类/参数说明 |
|---|---|---|
| Spring Boot | 3.3.0 | spring-tx 模块 |
| MySQL | 8.0.33 | InnoDB 引擎 (支持 ACID) |
| HikariCP | 5.1.0 | maximum-pool-size (连接池上限) |
| JDK | 21 LTS | 虚拟线程支持 (Loom) |
16.1. 深度透视:事务的物理本质与 Spring 的封装
很多开发者认为事务是 Spring 的一项“服务”,通过几个注解就能凭空产生。这是错误的。事务是数据库(Database)本身的一种机制,Spring 只是一个精明的“中间商”和“指挥官”。
16.1.1. 裸奔的 JDBC:事务的本来面目
在没有任何框架(Spring/MyBatis)包裹时,一个标准的 JDBC 事务包含 5 个硬核步骤。理解这个过程,是理解 Spring 事务源码的基础。
1 | // 0. 从连接池获取物理连接 (TCP 连接) |
16.1.2. Spring 的魔法:ThreadLocal 与 资源同步
你可能会问:“我在 Service 层的方法里根本没传 Connection 对象,Spring 是怎么保证三条 SQL 用的是同一个数据库连接的?”
答案在于 Spring 的核心类:TransactionSynchronizationManager。
- 资源绑定:当事务开启时,Spring 会从
DataSource拿出一个连接,并将其包装为ConnectionHolder。 - 线程绑定:利用 JDK 的
ThreadLocal,将这个ConnectionHolder绑定到当前线程(Thread)上。 - 智能获取:MyBatis 或 JPA 在执行 SQL 时,会先通过
DataSourceUtils.getConnection()去ThreadLocal查找:“当前线程有没有正在进行的事务连接?”- 有:直接复用(保证了 ACID)。
- 无:新建一个连接(自动提交模式)。
sequenceDiagram
participant S as Service Method
participant TM as TransactionManager
participant TSM as TransactionSynchronizationManager (ThreadLocal)
participant DS as DataSource (HikariCP)
S->> TM: @Transactional 方法开始
TM->> DS: 给我一个 Connection!
DS-->> TM: 返回 Connection@1001
TM->> TM: setAutoCommit(false)
TM->> TSM: bindResource(ThreadA, Connection@1001)
note right of TSM: 此时连接与线程 A 绑定
S->> S: 执行 SQL 1 (MyBatis)
S->> TSM: getResource() -> Connection@1001
S->> S: 执行 SQL 2 (MyBatis)
S->> TSM: getResource() -> Connection@1001
S->> TM: 方法结束,无异常
TM->> TSM: unbindResource()
TM->> DS: commit() & close()
16.2. 实战与进阶:@Transactional 的详细配置
@Transactional 看起来简单,但其参数配置直接决定了生产环境的稳定性。
16.2.1. 异常回滚的陷阱:rollbackFor
默认行为:Spring 默认 仅 在抛出 RuntimeException (如 NullPointerException) 或 Error (如 OutOfMemoryError) 时回滚。
致命坑点:如果你的代码抛出了 Checked Exception(如 IOException, SQLException, ExecutionException),Spring 会默认认为“这是预料之中的异常”,进而提交事务!
生产级代码模版:
1 | // ❌ 危险写法:IO 异常发生时,数据库不会回滚! |
16.2.2. 性能倍增器:readOnly = true
很多教程说 readOnly=true 只是为了代码可读性,这是大错特错的。在读多写少的系统中,这个参数是性能优化的关键。
Spring 层面(ORM 优化):如果你使用的是 JPA/Hibernate,开启
readOnly=true后,Spring 会将 Hibernate Session 的 FlushMode 设置为MANUAL。这意味着:查询出来的对象,不再进行脏检查(Dirty Checking)。- 性能提升:对于查询大量数据的列表接口,内存消耗和 CPU 占用可降低 30% 以上。
MySQL 层面(MVCC 优化):对于 InnoDB 引擎,如果一个事务被标记为“只读”,数据库 不需要 为它分配一个唯一的 Transaction ID(事务 ID)。
- 性能提升:减少了内部锁结构和 Undo Log 的部分开销,降低了高并发下的资源竞争。
16.2.3. 拒绝死锁:timeout
场景:某个复杂的报表统计 SQL 因为索引失效,执行了 20 秒。此时它一直占着数据库连接(Connection)和行锁(Row Lock)。如果并发量上来,连接池会被迅速耗尽,导致系统雪崩。
机制:Spring 并不支持真正的“中断 SQL 执行”(这依赖于 JDBC 驱动的具体实现)。
Spring 的 timeout 实现原理是:
- 在 SQL 执行前,检查
deadline(截止时间)。如果当前时间 > deadline,直接抛出TransactionTimedOutException。 - 设置 JDBC Statement 的
setQueryTimeout,让数据库层面去控制 SQL 执行时间。
1 | // 设定 3 秒超时,防止慢 SQL 拖垮整个微服务 |
16.3. 挑战并发核心:隔离级别 (Isolation) 与 MVCC
在微服务高并发场景下,多个事务同时操作同一行数据是家常便饭。为了防止数据错乱,数据库提供了隔离级别。
16.3.1. 并发三宗罪
| 问题 | 描述 | 严重等级 |
|---|---|---|
| 脏读 (Dirty Read) | 读到了别人 没提交 的数据。如果对方回滚了,你读到的就是“鬼数据”。 | ⭐⭐⭐⭐⭐ (最严重) |
| 不可重复读 | 同一个事务里,先后两次读取同一行,结果不一样(被别人中途修改提交了)。 | ⭐⭐⭐ (导致逻辑不一致) |
| 幻读 (Phantom Read) | 统计时发现有 5 条记录,准备批量更新时,突然发现变成了 6 条(被别人中途插入了)。 | ⭐⭐ (常见于统计类业务) |
16.3.2. 隔离级别深度解析
Spring 通过 isolation 属性透传给数据库。
READ_UNCOMMITTED (读未提交)
- 原理:不做任何隔离,直接读最新数据。
- 评价:裸奔。生产环境绝对禁止。
READ_COMMITTED (RC, 读已提交)
- 原理:每次执行 SELECT 语句时,都会重新生成一个 Read View(快照)。
- 特点:能防脏读,防不了不可重复读。
- 适用:大多数互联网大厂(如阿里)的默认选择。因为相比于 RR,它的锁粒度更小,并发度更高。
REPEATABLE_READ (RR, 可重复读)
- 原理:在事务开启后的第一次 SELECT 时生成 Read View,之后整个事务期间复用这个快照。
- 特点:MySQL 的默认级别。它配合 Next-Key Lock(间隙锁)在一定程度上解决了幻读问题。
- 代价:为了防幻读,锁的范围更大,死锁概率略高。
SERIALIZABLE (串行化)
- 原理:将所有的 SELECT 隐式转换为
SELECT ... LOCK IN SHARE MODE。 - 评价:性能极差,除非是核弹发射程序,否则别用。
- 原理:将所有的 SELECT 隐式转换为
如何选择?
如果你的业务可以容忍“在这个事务里读是 100 块,过一会读变成了 200 块(因为别人转进来了)”,建议配置为 READ_COMMITTED 以获得更高吞吐量。
1 | # application.yml 全局配置 |
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),开启新事务。
底层实现:
TransactionManager将当前的TransactionStatus(包含旧连接)挂起到一个栈中。- 从连接池请求一个新的
Connection。 - 在新连接上执行
conn.setAutoCommit(false)。 - 方法结束后,提交/回滚新事务,关闭新连接。
- 从栈中恢复旧的
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 |
|
解决方案:
- 注入自己(最常用):
1
2private OrderService self;
public void createOrder() { self.saveData(); } - 获取当前代理(这种方式需开启
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 |
|
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 |
|
模版 B:手动控制型 (适用于需要 Try-Catch 但又要回滚)
场景:你希望捕获异常给前端返回友好的 Result,但数据库必须回滚。
1 |
|
模版 C:事件驱动型 (适用于事务提交后发消息)
场景:防止“消息发出去了,数据库却回滚了”。
1 |
|
16.7.3. 事务失效排查心法
当你发现“配置了事务却不生效”时,请按以下顺序进行 “灵魂四问”:
- 问对象:
- “我调用的是代理对象(Proxy)还是目标对象(Target)?”
- 如果是
this.method(),直接判定失效。请注入self或拆分 Service。
- 问权限:
- “方法是
public的吗?” - Private/Protected 方法上的注解在默认代理模式下是无效的。
- “方法是
- 问异常:
- “抛出的异常是
RuntimeException吗?” - 如果是
Exception(Checked) 且没配rollbackFor,Spring 会无视并提交。 - “异常被我 catch 掉了吗?”
- 如果 Catch 了且没抛出,也没手动
setRollbackOnly,事务会正常提交。
- “抛出的异常是
- 问线程:
- “这是在主线程执行的吗?”
- 如果在事务方法里
new Thread(),新线程是无事务状态(自动提交)。
最后的忠告:事务是数据库资源的 “加锁行为”。请永远记住:事务范围越小越好。不要在 @Transactional 方法里进行 HTTP 请求、文件 IO 或 复杂耗时计算。这些操作不涉及数据库,却会一直占着数据库连接不释放,直到连接池耗尽,系统崩溃。
把非 DB 操作移到事务外面,是高并发优化的第一步。








