一、写在前面
说实话,刚开始工作那会儿,我对事务的理解就四个字:加个注解。
@Transactional public void transfer() { ... }"加了@Transactional,数据就安全了!"——这是我当时唯一的认知。至于为什么安全?不知道。Java事务和MySQL事务啥关系?不清楚。MVCC只知道叫多版本并发控制,具体细节不太清楚!!
后来线上出了一次事故:两个线程同时查同一条记录,一个改了余额,另一个读到的还是旧值。leader 让我排查,我对着日志看了半天,一脸懵。
从那之后,我才下决心把这些底层的东西彻底搞明白。今天这篇文章,就是我的踩坑笔记,希望能帮到和我当年一样迷糊的同学。
咱们先理清一个最基础的问题:Java 事务和 MySQL 事务,到底谁管谁?
二、Java事务 vs MySQL事务:谁是老板?
2.1 一个生活中的类比
去过餐厅吧?咱们把这件事放到餐厅里看:
Spring 事务管理器 ≈ 餐厅经理 MySQL InnoDB ≈ 后厨团队 连接池 ≈ 后厨的灶台经理(Spring 事务管理器)不炒菜,但他决定:
什么时候开火(开启事务)
什么时候出菜上桌(提交 commit)
哪桌的菜做砸了,倒掉重来(回滚 rollback)
真正的切菜、颠勺、摆盘,全是后厨(MySQL InnoDB)在干。
2.2 @Transactional 到底做了什么?
咱们直接看伪代码,一目了然:
// Spring 事务管理器做了这些事(简化版) Connection conn = dataSource.getConnection(); // 从连接池借一个连接 conn.setAutoCommit(false); // 关闭自动提交 → 事务开始! try { yourBusinessMethod(); // 执行你写的业务代码 conn.commit(); // 一切顺利 → 提交 } catch (Exception e) { conn.rollback(); // 出岔子了 → 回滚 } finally { conn.setAutoCommit(true); // 还原设置 connectionPool.returnConnection(conn); // 把连接还回池子里 }看出来了吧?Spring 做的所有事情,本质就是操作了一个java.sql.Connection对象。它没有缓存数据,没有写日志,没有加锁——这些活儿全是 MySQL 干的。
2.3 对比表一目了然
| Java/Spring 事务 | MySQL 事务 | |
|---|---|---|
| 角色 | 指挥官,负责协调 | 执行者,真正干活 |
| 干了啥 | 借连接、关autoCommit、调commit/rollback | 写Undo/Redo Log、加行锁、改数据页、刷盘 |
| 数据存在哪 | 啥也没存,Java端不缓存任何数据 | Undo Log、Redo Log、Buffer Pool、数据文件 |
| ACID谁保证 | 不保证,它只负责"喊口令" | 100%由MySQL保证 |
💡核心结论:Java 事务是"协调者",MySQL 事务是"执行者"。离开了 MySQL,Spring 的 @Transactional 啥也保证不了。
2.4 一个最容易踩的坑
没有@Transactional的时候,MySQL 就没有事务了吗?
不是。MySQL 默认autoCommit = true,每条 SQL 自己就是个事务,执行完立刻自动提交。
// ❌ 没加 @Transactional,灾难场景 accountMapper.deduct(fromId, 100); // 扣款 → 立刻提交了 // ---- 此时服务器崩了 ---- accountMapper.add(toId, 100); // 没执行到 // 结果:钱扣了,但对方没收到。数据不一致! // ✅ 加了 @Transactional @Transactional public void transfer() { accountMapper.deduct(fromId, 100); // 没提交,在事务中 accountMapper.add(toId, 100); // 没提交,在事务中 } // 正常结束 → 两条一起提交 // 中间崩了 → 两条一起回滚 // 数据始终一致 ✅所以@Transactional的价值不是"提供了事务",而是把多条 SQL 绑进同一个事务里。
三、重头戏来了:MVCC 到底是什么鬼?
好,铺垫完了。接下来是本文的重头戏——MVCC(Multi-Version Concurrency Control,多版本并发控制)。
这个东西,我第一次看官方文档的时候,差点怀疑自己的智商。什么隐藏字段、什么Undo Log链、什么ReadView快照……每个字都认识,连一起就不认识了。
后来我想明白了一个道理:不是你笨,是文档写得太干。咱们换个方式来理解。
3.1 先说 MVCC 要解决的问题
想象一个场景:你和同事同时在看同一份 Excel 表格(数据库里的同一行数据)。
你在看:张三的余额 = 1000 元 同事在改:张三的余额改成 500 元(还没保存)
问题来了:你看到的应该是 1000 还是 500?
如果让你看到 500(同事还没提交的中间状态),那就是脏读。 如果同事改完保存了,你再查一次发现变了,同一事务里两次读结果不一样,那就是不可重复读。
这两种情况都很头疼。那怎么办?
最暴力的方案:加锁。你要读?等着,等我改完你再读。这样数据确实一致了,但并发性能直接归零——所有人排队等锁,这数据库还用不用了?
MVCC 的思路完全不同:不加锁,给你看"快照"。
3.2 一个比喻:照相馆的快照
把 MVCC 想象成一家照相馆。
你在某一个瞬间(事务开始的那一刻),照相馆数据库里的所有数据拍了一张合照。之后不管别人怎么修改数据,你看到的永远是那张照片上的样子。
张三余额 = 1000?照片上就是 1000。
同事把余额改成 500 了?不好意思,我只看照片,照片上是 1000。
同事改完又改成 800 了?照片上还是 1000。
你的视角被"定格"在了拍照那一刻。这就保证了可重复读——同一个事务里,不管读几次,读到的都一样。
这就是 MVCC 的核心思想:读不加锁,读到的是某个历史版本(快照),而不是当前实时值。
划重点:MVCC 让"读"和"写"互不阻塞。你读你的快照,我改我的数据,大家各干各的,谁也不等谁。并发性能直接起飞。
3.3 MVCC 三件套:隐藏字段 + Undo Log + ReadView
光知道"快照"还不够,咱们得搞清楚:MySQL 是怎么实现这个快照机制的?
这就涉及到 MVCC 的三个核心组件。别怕,咱们一个一个来。
① 隐藏字段:每行数据自带的"身份证"
MySQL 的 InnoDB 引擎在你建的表之外,偷偷给每一行数据加了几个隐藏字段(你看不到,但它们一直在):
┌─────────────────────────────────────────────────────────┐ │ 你看到的字段 │ │ id │ name │ balance │ ├─────────────────────────────────────────────────────────┤ │ InnoDB 偷偷加的隐藏字段 │ │ DB_TRX_ID │ DB_ROLL_PTR │ DB_ROW_ID │ └─────────────────────────────────────────────────────────┘| 隐藏字段 | 大白话解释 |
|---|---|
| DB_TRX_ID | 最近一次修改这行数据的事务ID。就像快递上贴的"最后一个经手人"标签 |
| DB_ROLL_PTR | 回滚指针,指向这行数据的"上一个版本"存在哪里。就像快递上贴的"上一个经手人地址" |
| DB_ROW_ID | 隐藏主键(没有主键时自动生成,先不管它) |
DB_TRX_ID告诉你"谁最后动过这行数据",DB_ROLL_PTR告诉你"想找上一个版本去哪儿找"。
② Undo Log:数据的"版本链"
每次修改一行数据,InnoDB 不会直接覆盖旧值,而是把旧值存到一个叫Undo Log(回滚日志)的地方。然后通过DB_ROLL_PTR这个指针,把新旧版本串成一条链。
举个例子,假设张三的余额被改了三次:
当前数据(最新版本): ┌──────────────────────────────────────────────────┐ │ name: 张三 | balance: 800 | TRX_ID: 300 │ │ ROLL_PTR ──→ 指向上一个版本 │ └──────────────┬───────────────────────────────────┘ │ ▼ Undo Log 中的版本: ┌──────────────────────────────────────────────────┐ │ name: 张三 | balance: 500 | TRX_ID: 200 │ │ ROLL_PTR ──→ 指向更早的版本 │ └──────────────┬───────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────┐ │ name: 张三 | balance: 1000 | TRX_ID: 100 │ │ ROLL_PTR ──→ NULL(最早版本了) │ └──────────────────────────────────────────────────┘这条链就叫版本链。InnoDB 要找历史版本,就沿着这条链往下找就行了。
这就是为什么回滚可以很快——不用改磁盘上的数据文件,顺着链找到旧值恢复就行。
③ ReadView:决定"你能看哪个版本"的规则
好,现在版本链有了,问题来了:我的事务应该看这条链上的哪个版本?
这就靠ReadView(可以理解为一个"快照清单")来决定了。
ReadView本质上是事务在某个时刻生成的一张清单,记录了当时数据库里所有活跃(还没提交)的事务ID:
ReadView { m_ids: [200, 300] ← 生成快照时,还没提交的事务ID列表 min_trx_id: 200 ← 这些事务里最小的ID max_trx_id: 301 ← 下一个将要分配的事务ID creator_trx_id: 400 ← 我自己的事务ID }当你要读某一行数据时,InnoDB 会拿这行的DB_TRX_ID和 ReadView 里的规则做比较,来判断这个版本对你是否可见:
判断逻辑(简化版): 1. DB_TRX_ID == creator_trx_id? → 这是我自己改的,当然看得到 ✅ 2. DB_TRX_ID < min_trx_id? → 这个版本在我快照之前就提交了,看得到 ✅ 3. DB_TRX_ID >= max_trx_id? → 这个版本是快照之后才出现的,看不到 ❌ → 沿着版本链往前找更老的版本 4. DB_TRX_ID 在 m_ids 列表中? → 这个事务在我拍快照时还没提交,看不到 ❌ → 沿着版本链往前找 5. DB_TRX_ID 不在 m_ids 列表中? → 这个事务在我拍快照前已经提交了,看得到 ✅🎯 用人话说就是:我只看在我拍照之前就已经"修好图"的版本,那些正在P图中的(未提交的)、或者在我拍完照之后才开始P的,我一律不看。
3.4 另一个比喻:Git 的版本控制
如果你觉得上面的"照相馆"还不够直观,咱们再换个 Git 的视角:
你的 Git 仓库(数据库): commit 3: balance = 800 (TRX_ID: 300) ← HEAD(当前版本) commit 2: balance = 500 (TRX_ID: 200) commit 1: balance = 1000 (TRX_ID: 100) 你的事务在 commit 2 的时候"git checkout"了 → 不管后面有没有 commit 3,你的 HEAD 一直指着 500 → 别人推了 commit 4 也不影响你 → 你永远看到的是 balance = 500ReadView 就像 git log,告诉你哪些 commit 已经 push 了(已提交),哪些还在别人的本地分支(未提交)。你只看已经 push 的。
四、实战演练:RR 隔离级别下 MVCC 是怎么工作的?
说了这么多理论,咱们来个真实的 SQL 场景,走一遍 MVCC 的判断过程。
MySQL 默认隔离级别是可重复读(Repeatable Read, RR),这也是 MVCC 大显身手的场景。
场景:读写并发
-- 初始状态:张三的 balance = 1000 -- 假设该行的 DB_TRX_ID = 100(事务100已经提交了) -- 时刻T1:事务A(ID=200)开始 BEGIN; -- 事务A 生成 ReadView: m_ids=[200] (此时只有自己是活跃的) -- 时刻T2:事务B(ID=300)开始并修改数据【这个时刻还没提交】 BEGIN; UPDATE account SET balance = 500 WHERE name = '张三'; -- 此时张三那行:DB_TRX_ID = 300, balance = 500 -- 旧版本(1000)通过 ROLL_PTR 存在 Undo Log 里 -- 时刻T3:事务A 再次读张三的余额【事务B更改完还没提交】 SELECT balance FROM account WHERE name = '张三'; -- 结果是什么?1000 还是 500?走一遍 MVCC 判断:
事务A 的 ReadView: { m_ids: [200], min_trx_id: 200, max_trx_id: 201 } 当前最新版本的 DB_TRX_ID = 300 判断: 300 >= max_trx_id(201)? → YES! → 这个版本是快照之后才产生的,对事务A不可见 ❌ → 沿着版本链往前找 → 找到 Undo Log 中的旧版本:DB_TRX_ID = 100, balance = 1000 100 < min_trx_id(200)? → YES! → 这个版本在快照之前就提交了,可见 ✅ 最终结果:事务A 读到 balance = 1000 ✅✅事务B 改了数据但还没提交,事务A 完全不受影响,读到的还是旧值。这就是防止了脏读和不可重复读。
如果事务B提交了呢?
-- 时刻T4:事务B 提交 COMMIT; -- 事务300 提交了 -- 时刻T5:事务A 再次读 SELECT balance FROM account WHERE name = '张三'; -- 结果是什么?结果还是 1000!
因为事务A的 ReadView 是在 T1 时刻生成的。在 RR 隔离级别下,整个事务生命周期内只生成一次 ReadView,不会因为别人提交了就更新快照。
所以就算事务B已经提交了,300 依然不在事务A的 m_ids 里,但它依然 >= max_trx_id,依然不可见。
这就是"可重复读"的秘密:ReadView 在事务第一次读的时候生成,之后一直复用同一个,所以每次读的结果都一样。
对比:RC 隔离级别呢?
如果隔离级别是读已提交(Read Committed, RC),情况就不一样了:
RC 的区别:每次 SELECT 都会重新生成一个新的 ReadView。
所以在时刻T5,事务A 重新生成 ReadView:{ m_ids: [200], min_trx_id: 200, max_trx_id: 301 } DB_TRX_ID = 300 300 < max_trx_id(301)? → YES 300 在 m_ids 中? → NO(300已经提交了) → 可见 ✅ 结果:balance = 500(读到了事务B提交后的新值)同一个场景,RR 读到 1000,RC 读到 500。区别就在于 ReadView 什么时候生成。
| 隔离级别 | ReadView 生成时机 | 效果 |
|---|---|---|
| RC(读已提交) | 每次 SELECT 都重新生成 | 能读到其他事务已提交的最新数据 |
| RR(可重复读) | 事务中第一次 SELECT 时生成,之后复用 | 整个事务看到的数据始终一致 |
五、一张图总结 MVCC 的工作流程
事务A 发起 SELECT │ ▼ ┌─────────────────┐ │ 有 ReadView 了吗?│ └────┬───────┬────┘ │ NO │ YES ▼ ▼ 生成新 复用已有 ReadView ReadView │ │ └──┬────┘ ▼ 读取当前行数据,获取 DB_TRX_ID │ ▼ 用 ReadView 的规则判断: 这个版本对我可见吗? │ ┌────┴────┐ │ │ YES NO │ │ ▼ ▼ 返回这个 沿版本链往前 版本的值 找上一个版本 │ ▼ 继续判断,直到找到 可见的版本为止六、最后唠两句
回顾一下咱们今天聊的东西:
Java 事务是"嘴",MySQL 事务是"手"。Spring 借连接、喊口令(commit/rollback),MySQL 负责写日志、加锁、改数据,分工很明确。
MVCC 的本质就是"快照读"。事务开始时拍一张照片,之后不管外面怎么风吹雨打,你只看照片上的数据。
MVCC 三件套:隐藏字段是"标签",Undo Log 是"相册"(版本链),ReadView 是"取景框"(决定你看到哪个版本)。
RR vs RC 的区别:RR 拍一次照用到老,RC 每次 SELECT 都重新拍。
其实 MVCC 还有很多细节没展开——比如当前读 vs 快照读的区别、间隙锁(Gap Lock)、以及什么情况下 RR 也会出现幻读……但一篇文章塞太多容易消化不了,咱们下次再聊。
MVCC 进阶:快照读 vs 当前读、幻读与 Next-Key Lock
如果这篇文章对你有帮助,点个赞收藏一下,别让它在收藏夹里吃灰就行!!!