【第21天】每天一个MySQL知识点,百日打怪升级
InnoDB MVCC 机制详解 —— 快照读、当前读、Undo 链
大家好,我是一名拥有10年以上经验的DBA老兵(没有那多)。
做这个系列,源于一个朴素的愿望:把踩过的坑、总结的经验系统化输出,希望能帮到刚入行或想进阶的兄弟们。
让我们开始今天的第21天内容。
昨天讲了行锁 vs 表锁,有兄弟问了一个特别好的问题:
"RR级别下,SELECT不是不加锁吗?那怎么做到的可重复读?"
不加锁,还能保证每次读到同样的结果——听起来像魔法。但这不是魔法,是 MVCC(Multi-Version Concurrency Control,多版本并发控制)。
说白了:InnoDB 不直接覆盖旧数据,而是在旧数据旁边写一个新版本,读的人看旧版本,写的人更新版本。
跟游戏存档似的——你读的是存盘的那个版本,别人在玩最新的进度,互不干扰。(不过游戏存档是静态拷贝,Read View 是动态判定规则,不存具体数据,只存判据。)
MVCC 到底解决了什么问题?
先想一个场景:事务 A 和事务 B 同时开启。
事务 A: SELECT * FROM user WHERE id = 1; -- 读到 name = 'Alice'
事务 B: UPDATE user SET name = 'Bob' WHERE id = 1;
事务 B: COMMIT;
事务 A: SELECT * FROM user WHERE id = 1; -- 应该读到什么?
没有 MVCC 的话,事务 A 会读到 'Bob'——违反了 RR 隔离级别的承诺("同一个事务里,两次读结果必须一致")。
InnoDB 的方案是:事务 A 第一次读的时候,给它拍一张"快照"。后续所有读,都从这张快照里取,不管数据被改了多少次。
这就引出了两个核心概念。
两个读:快照读 vs 当前读
MVCC 把 SELECT 分成了两种:
| 读类型 | 对应 SQL | 读哪个版本 |
|---|---|---|
| 快照读(Snapshot Read) | 普通 SELECT |
读事务快照建立时的版本(RR 下:普通 BEGIN 在第一次快照读时建立;WITH CONSISTENT SNAPSHOT 在 BEGIN 时建立) |
| 当前读(Current Read) | SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE |
读行的最新版本(未提交的会阻塞等待),并加锁 |
快照读:不加锁,只管读。RR 下读到事务快照建立时的数据版本,RC 下读到当前语句执行那一刻的快照。
当前读:加锁,读最新版。每次必须拿到最新的数据,如果该行正被其他事务修改(未提交),会阻塞等待锁释放,目的是让修改操作看到真实的世界。
-- 快照读:不加锁,读快照
SELECT * FROM user WHERE id = 1;
-- 当前读:加锁,读最新
SELECT * FROM user WHERE id = 1 FOR UPDATE;
SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE;
UPDATE user SET name = 'Bob' WHERE id = 1; -- 也是当前读
DELETE FROM user WHERE id = 1; -- 也是当前读
面试必问:
- 快照读和当前读有什么区别?
- RR 级别下,UPDATE 读到的数据会不会比 SELECT 更新?
- MVCC 如何实现 RC 和 RR 的差异?
📝 面试解答:
Q: 快照读和当前读有什么区别?
快照读是普通 SELECT,不加锁,从 Read View 中读取行的历史版本。当前读是 SELECT ... FOR UPDATE / LOCK IN SHARE MODE 以及 UPDATE/DELETE,读取行的最新版本(未提交的事务会阻塞等待)并加锁。RR 下当前读能看到比快照读更新的数据。
Q: RR 级别下,UPDATE 语句读取到的数据版本,会不会比普通 SELECT 读取到的更'新'?
会的。这就是经典的"更新丢失"场景的防护方式——UPDATE 本身就是当前读,它总是读取最新已提交版本并加锁。所以如果你先 SELECT(快照读)看到旧值,然后执行 UPDATE,InnoDB 会用当前读拿到别人改过的最新值再做修改。如果你用了
SELECT ... FOR UPDATE,那等锁释放后读到最新值再 UPDATE,效果一样。
Q: MVCC 如何实现 RC 和 RR 的差异?
核心在 Read View 的创建时机。RR 下:普通
BEGIN后在第一次快照读时创建 Read View,整个事务期间复用;使用START TRANSACTION WITH CONSISTENT SNAPSHOT则在 BEGIN 时就创建。RC 下:每条语句执行时都重新创建 Read View。所以 RR 能做到可重复读,RC 只能做到语句级别的读已提交。
Read View:MVCC 的"时光相机"
Read View 是 MVCC 最核心的数据结构。你可以把它理解成事务第一次快照读那一刻的"全局数据快照登记簿"。
它记录了四个核心属性:
| 属性 | 含义 | 白话版 |
|---|---|---|
m_ids |
创建 Read View 时,所有未提交的事务ID列表 | 这些事务还没结束,它们改的数据我看不见 |
min_trx_id |
m_ids 中的最小值 |
最小的"未提交"编号 |
max_trx_id |
创建 Read View 时,系统下一个即将分配的事务ID(即当前已分配最大事务ID + 1) | 大于等于这个编号的事务我全不认(还没开始) |
creator_trx_id |
创建这个 Read View 的事务自己的ID | 我自己的修改我当然能看见 |
有了 Read View,InnoDB 判断一条记录的可见性就变成了"查户口":
-- 对于一条版本链上的数据,判断当前版本对当前事务是否可见:
-- 1. 如果版本的事务ID == creator_trx_id → 自己改的,可见
-- 2. 如果版本的事务ID < min_trx_id → 已提交的旧事务,可见
-- 3. 如果版本的事务ID >= max_trx_id → 未来事务,不可见
-- 4. 如果版本的事务ID 在 m_ids 中 → 未提交的事务,不可见
-- 不可见就沿着 Undo 链往上找,直到找到可见的版本
这个算法是 MVCC 的灵魂。它不锁任何行,就能实现事务隔离。 一句话总结就是:Read View 把所有事务分成三类——自己(可见)、已提交的老事务(可见)、未提交或未来的事务(不可见)。相比锁机制,MVCC 让读和写完全不冲突——你写你的,我读我的快照。
Undo Log 版本链:数据的时间旅行
InnoDB 的行结构里,有三个隐藏字段:
| 字段 | 含义 |
|---|---|
DB_TRX_ID |
最近修改这行的事务ID |
DB_ROLL_PTR |
回滚指针,指向 Undo Log 中的旧版本记录 |
DB_ROW_ID |
行ID,没有主键时 InnoDB 用它组织聚簇索引(不参与 MVCC 可见性判定) |
每次 UPDATE 一个行,InnoDB 不是直接覆盖数据,而是:
- 把当前行的旧值写入 Undo Log
- 更新行数据,把
DB_ROLL_PTR指向 Undo Log 中的旧版本 - 更新
DB_TRX_ID为当前事务ID
这样就形成了一条版本链:
当前行 (trx_id=20, name='Bob')
↑ DB_ROLL_PTR
旧版本1 (trx_id=15, name='Alice') ← 在 Undo Log 里
↑ DB_ROLL_PTR
旧版本2 (trx_id=10, name='Charlie') ← 在 Undo Log 里
Read View 沿着这条链从最新版本往下找,找到一个"对你可见"的版本就返回。版本链越长,查找越慢。
所以长事务是 MVCC 的天敌——事务一直不提交,它的 Read View 就一直在,Purge 线程不能清理这个 Read View 可能需要的任何旧版本(哪怕产生这些旧版本的事务早就提交了)。结果版本链越积越长,最终拖垮性能。
我在生产上见过最夸张的:一张表每行约 2KB,业务高峰期每秒约 50 次 UPDATE,一个事务跑了 8 小时没提交,这期间所有旧版本都被长事务的 Read View"保释"着不能 Purge。Undo 表空间飙到 200GB。长事务提交后,Purge 线程清理了 40 分钟才释放干净。
隔离级别的可视化对比
用一个具体的例子看 MVCC 在两个级别下的表现差异:
表数据:id=1, name='Alice', trx_id=10
时间线:
T1: 事务A (trx_id=20) BEGIN;
-- 执行第一条 SELECT(RR 下 Read View 在此创建)
T2: 事务B (trx_id=21) BEGIN;
T3: 事务B UPDATE user SET name='Bob' WHERE id=1; -- 提交版本 trx_id=21
T4: 事务B COMMIT;
T5: 事务A 再次 SELECT name FROM user WHERE id=1;
-- RR: 读到 'Alice'(Read View 在 T1 创建时 B 还没开始,看不见 T2 之后的事务)
-- RC: 读到 'Bob'(Read View 在 T5 重新创建,能看见已提交的 T4)
不过注意:RC 的 Read View 虽然每条语句重新创建,但它仍然只认已提交的数据——m_ids 中记录的是"此时未提交的事务",决不会读到脏数据。RC 和 RR 的区别是"要不要把同一版本保持到事务结束",而不是"能不能读未提交的数据"。
是不是发现了一件事?
RC 下没有"可重复读"的烦恼,但也意味着你的事务不安全。 同一个事务里,前后两次 SELECT 结果可能不同。
我踩过这个坑:一个报表统计任务跑 5 分钟,第一条 SELECT 和最后一条 SELECT 看到的"同一时间点"的数据不同——因为 RC 下每条语句都重新拍照。
最常见的 MVCC 认知误区
误区一:MVCC 对所有 SELECT 都有效
不对。SELECT ... FOR UPDATE 不走 MVCC,走当前读。 所以你在 RR 下用 FOR UPDATE 锁住的记录,不会被 MVCC 保护——你读到的是最新已提交版本。
这在幂等性校验的场景特别坑:
-- 事务A:
BEGIN;
SELECT * FROM order WHERE order_no = 'ORD001' FOR UPDATE;
-- 读到的是最新数据
-- 如果事务B已经插入了一条 ORD001,你会读到它(阻塞等B提交)
很多人以为 FOR UPDATE 和普通 SELECT 效果一样,只是加了锁——其实读的数据版本都不一样。
误区二:UPDATE 读取行时跟 SELECT 一样走快照读
很多人以为 UPDATE 和 SELECT 的读取机制一样,只是多了加锁。实际上 UPDATE 本身就是当前读,读取最新已提交版本并加锁。
不过有个细节——半一致性读(semi-consistent read):
当 UPDATE 的 WHERE 条件没有可用索引、需要全表扫描时,InnoDB 不会对所有扫描到的行都加 X 锁。它会先用当前读(最新已提交版本)判断 WHERE 条件是否满足——不满足的行直接跳过,不加锁;满足条件的行再加 X 锁并继续处理。
注意:半一致性读是 RR 级别下的一种优化,目的是避免无索引 UPDATE 时对全表所有行加锁。如果 WHERE 条件能走索引(聚簇索引或二级索引),InnoDB 直接通过索引定位行并加锁,不需要这个优化。
另外,在 RC 隔离级别下,UPDATE 的读取行为天然就是"半一致性"的——它总是读取最新已提交版本,这与 RR 级别下的选择性优化不同。
误区三:Undo Log 只在回滚时用
Undo Log 一半的使命是回滚,另一半就是 MVCC 版本链。即使你永远不回滚,只要 MVCC 需要读取旧版本,Undo Log 就活得好好的。所以 Undo 表空间一直涨不一定是有回滚,也可能是有长事务在阻止 Purge 线程清理。
一个完整的 MVCC 可见性判定流程
把前面所有概念串起来,看一条 SELECT 的执行路径:
1. InnoDB 收到 SELECT id=1 的请求
2. 从聚簇索引找到 id=1 的记录行
3. 拿到行上的 DB_TRX_ID = 21
4. 拿着 trx_id=21 去和当前事务的 Read View 比较:
┌─ 21 就是当前事务自己?→ 读这个版本
├─ 21 < min_trx_id? → 已提交,读这个版本
├─ 21 >= max_trx_id? → 不可见,沿 DB_ROLL_PTR 找上一个版本
├─ 21 在 m_ids 中? → 未提交,不可见,沿链向上
└─ 21 不在 m_ids 中? → 已提交,读这个版本
5. 如果当前版本不可见,重复第 4 步
6. 直到找到可见的版本,或者遍历到链尾返回 NULL
这个判定每一条 SELECT 都在做。数据量不大时几乎没成本,但版本链深了就很慢。
把 MVCC 的组件关系画成一张图,就是这样的:
Read View (快照登记簿)
│
┌─────┴─────┐
│ Check │
│ creator? │
│ < min? │
│ >= max? │
│ in m_ids? │
└─────┬─────┘
│
不可见则回溯 ▼
┌───────────┴───────────┐
│ Undo 版本链 │
│ │
│ 当前行 (DB_TRX_ID │
│ DB_ROLL_PTR) │
│ ↑ │
│ 旧版本1 (Undo Log) │
│ ↑ │
│ 旧版本2 (Undo Log) │
│ ↑ │
│ 旧版本3 ... │
└───────────────────────┘
Read View 定规则,版本链提供数据,两者配合实现了无锁的隔离读。
🤖 AI实战工具箱:让AI帮你造 MVCC 测试
MVCC 也是一样——原理看得懂,现象你得亲手跑一遍才记得住。
场景一:验证 RR 下快照读的行为
把下面这段粘给 AI:
帮我写一组 MySQL SQL,用两个终端演示 InnoDB RR 隔离级别下的 MVCC 快照读行为。创建一张 user 表(id 主键,name 字段),插入一条数据。终端 A 开启事务后先用 SELECT 读取数据,终端 B 修改并提交,终端 A 再次 SELECT——验证 RR 下两次读取结果一致(可重复读)。用 SLEEP 控制时序,每个步骤加注释说明 Read View 的变化。
场景二:验证快照读 vs 当前读的差异
帮我写一组 MySQL SQL,演示 RR 级别下快照读和当前读的差异。终端 A 先 SELECT(快照读)拿到旧值,然后执行 SELECT ... FOR UPDATE(当前读)拿到最新值。终端 B 在中间修改并提交数据。用 SLEEP 控制时序,注释说明两次读取为什么结果不同。
场景三:模拟长事务导致的 Undo 膨胀
帮我写一组 MySQL SQL,模拟长事务导致 Undo Log 无法清理的场景。开启一个事务但不提交,执行 3 次 UPDATE 修改同一行数据(每次修改让版本链增加 1 层)。然后查一下当前行的 Undo 信息(通过 information_schema 或 SHOW ENGINE INNODB STATUS),验证版本链的长度。最后提交事务,再次查看 Undo 状态。每个步骤加注释说明 Undo 的生命周期。
验证思路:跑完脚本后重点观察两次 SELECT 的结果是否一致(场景一)、FOR UPDATE 是否看到了更新后的值(场景二)、COMMIT 前后 Undo 空间的差异(场景三)。
思考题
🤔 互动时间:
- RR 级别下,事务 A 快照读读到 name='Alice',然后事务 B 提交了 name='Bob',事务 A 执行
UPDATE user SET name='CCC' WHERE name='Alice'——能更新到行吗?更新后 name 是多少?(提示:考虑 name 列有无索引两种情形。有索引时直接定位加锁,当前读拿到的是最新值 'Bob',条件不满足,不会更新;无索引时全表扫描 + 半一致性读,用快照判断条件,读到的是 'Alice',但加锁后真正改的是否还是这个版本?你可以用 AI 工具箱的场景一生成脚本来验证。) - 如果一个事务一直不提交,另一个事务执行快照读——性能会下降吗?为什么?
- 为什么 MySQL 在 RC 级别下不用 Next-Key Lock、只用 Record Lock?这和 MVCC 有什么关系?
总结
🎯 面试考点
- MVCC 核心思想:读不阻塞写,写不阻塞读。通过多版本实现隔离,而不是通过锁
- 快照读 vs 当前读:普通 SELECT 读快照,FOR UPDATE/UPDATE/DELETE 读最新并加锁
- Read View:事务第一次快照读时的"数据全景登记簿"(使用
WITH CONSISTENT SNAPSHOT则在 BEGIN 时创建),RR 下全局只创建一次,RC 下每条语句重新创建 - Undo 版本链:每条记录串联了自己的修改历史,Read View 沿着链找可见版本
- 可见性判定:根据事务ID与 Read View 的比较结果决定读哪个版本——核心就四个判断
- 长事务危害:版本链不能清理,Undo 膨胀,MVCC 性能下降
下期预告:事务死锁的成因与避免 —— 面试必问!
有问题欢迎评论区交流,明天见!