MySQL Undo Log 在 MVCC 中的版本链构建与 ReadView 可见性判断
理解 MVCC 的核心组件
多版本并发控制(MVCC)是 MySQL InnoDB 引擎实现高并发读写的关键机制。它通过保留数据行的多个历史版本,让读操作无需等待写操作,从而提升性能。实现 MVCC 的两大核心组件是 Undo Log(回滚日志)和 ReadView(一致性视图)。Undo Log 负责构建数据行的版本链,ReadView 则判断哪个版本对当前事务可见。
阶段一:版本链的构建
1. 认识 Undo Log 的存储结构
每个数据行在 InnoDB 中都有两个隐藏字段:
DB_TRX_ID:最近修改该行的事务 ID(6 字节)。DB_ROLL_PTR:指向 Undo Log 的回滚指针(7 字节)。
当 UPDATE 或 DELETE 操作发生时,InnoDB 不会直接覆盖原数据,而是:
- 将修改前的旧数据写入 Undo Log 段。
- 在 Undo Log 记录中保存旧行的完整内容以及指向更早版本的指针。
- 更新行记录中的
DB_TRX_ID为新事务 ID,并将DB_ROLL_PTR指向新的 Undo Log 记录。
这样,同一个数据行的多个版本通过 DB_ROLL_PTR 串联成一条单向链表,头节点(最新版本)位于聚簇索引中,后续节点依次指向历史版本。
2. 构建版本链的具体步骤
步骤 1:事务 T1 插入一条记录(id=1, name='Alice')。
此时行记录中 DB_TRX_ID = T1,DB_ROLL_PTR 为 NULL。没有旧版本,链长仅 1。
步骤 2:事务 T2 执行 UPDATE 操作,将 name 改为 'Bob'。
InnoDB 内部执行以下动作:
- 拷贝旧数据:将修改前的行(
id=1, name='Alice')写入 Undo Log 段,生成一条 Undo Record。该记录包含旧行的所有字段以及指向更早版本的指针(当前为 NULL)。 - 更新聚簇索引:修改行记录中的
DB_TRX_ID = T2,并将DB_ROLL_PTR指向刚才创建的 Undo Record。 - 此时版本链:
聚簇索引(T2, name='Bob')→Undo Record(T1, name='Alice')→ NULL。
步骤 3:事务 T3 再次 UPDATE,将 name 改为 'Cathy'。
重复步骤 2 的流程:
- 将当前行(
name='Bob')拷贝为 Undo Record,并记录其DB_TRX_ID = T2。 - 更新聚簇索引:
DB_TRX_ID = T3,DB_ROLL_PTR指向新 Undo Record。 - 版本链变为:
聚簇索引(T3, name='Cathy')→Undo Record(T2, name='Bob')→Undo Record(T1, name='Alice')→ NULL。
步骤 4:事务 T4 执行 DELETE 操作。
DELETE 在 InnoDB 中本质是 UPDATE:将行标记为已删除(设置 DELETE_BIT),并写入 Undo Log。删除前的数据作为历史版本加入链。
版本链继续延长,最前的节点始终是当前聚簇索引中的行(即使被标记删除,仍可通过 DB_ROLL_PTR 访问历史版本)。
3. 版本链的图示(Mermaid)
以下 mermaid 图展示了经过 T1→T2→T3→T4 后的版本链结构(假设 T4 执行 DELETE):
关键点:
- 每个 Undo Record 都记录了修改该版本的事务 ID。
- 最新版本在聚簇索引头节点。
- 版本链的尾部(最旧版本)指向
NULL。
阶段二:ReadView 与可见性判断
1. 什么是 ReadView?
ReadView 是事务执行 SELECT(快照读)时创建的一个“快照”,它记录了当前系统中所有活跃事务(未提交的事务)的 ID 列表。其核心目的是:判断版本链中哪个版本对当前事务“可见”。
ReadView 包含四个关键属性:
m_ids:当前活跃事务 ID 列表(无序)。min_trx_id:活跃事务列表中的最小 ID。max_trx_id:系统已分配的最大事务 ID + 1(即下一个将分配的事务 ID)。creator_trx_id:创建该 ReadView 的事务自己的 ID。
2. 可见性判断规则
当 SELECT 操作需要读取某一行时,它从聚簇索引的最新版本开始,沿着版本链向前遍历。对于每个版本,获取其 DB_TRX_ID,记作 trx_id,然后按以下顺序判断:
规则 1:如果 trx_id == creator_trx_id,表示该版本由当前事务自己修改,可见。
规则 2:如果 trx_id < min_trx_id,表示该版本在创建 ReadView 时已经提交(因为所有比 min_trx_id 小的事务要么已提交,要么已回滚),可见。
规则 3:如果 trx_id >= max_trx_id,表示该版本在创建 ReadView 之后才开始,属于未来事务,不可见。
规则 4:如果 min_trx_id <= trx_id < max_trx_id,需要检查 trx_id 是否在 m_ids 中:
- 如果在
m_ids中,说明该版本的事务在创建 ReadView 时仍活跃(未提交),不可见。 - 如果不在
m_ids中,说明该版本的事务已经在创建 ReadView 之前提交,可见。
总结:可见版本必须是创建 ReadView 时已提交的版本(包括当前事务自己修改的),且不能是未来版本。
3. 判断过程的伪代码
def is_visible(trx_id, read_view):
if trx_id == read_view.creator_trx_id:
return True
if trx_id < read_view.min_trx_id:
return True
if trx_id >= read_view.max_trx_id:
return False
# min_trx_id <= trx_id < max_trx_id
if trx_id in read_view.m_ids:
return False
else:
return True
4. 遍历版本链的完整流程
假设有一个事务 T5 执行 SELECT * FROM users WHERE id=1,此时版本链如下(沿用上例,T4 已提交):
| 版本 | trx_id | 内容 |
|---|---|---|
| 聚簇索引 | T4 | deleted |
| Undo 1 | T3 | name='Cathy' |
| Undo 2 | T2 | name='Bob' |
| Undo 3 | T1 | name='Alice' |
第一步:T5 创建 ReadView。假设当前活跃事务为 [T5](仅 T5 自己),则:
min_trx_id = T5,max_trx_id = T5+1(假设下一分配 ID 为 T6),m_ids = [T5],creator_trx_id = T5。
第二步:从聚簇索引开始判断。
trx_id = T4。执行规则:T4 < min_trx_id(T5)→ 规则 2 成立 → 可见? 注意:T4 虽然小于 T5,但 T4 已经提交(因为不在 m_ids 中)。但是该版本标记为 deleted(DELETE),因此查询结果会返回空(行已被删除)。但可见性判断时,版本本身是可见的,只是行内容为删除标记。InnoDB 在读取到 deleted 版本时,会继续向后查找更旧的未删除版本,直到找到第一个未删除且可见的版本。
第三步:由于聚簇索引版本已删除,继续遍历 Undo 1(T3)。
trx_id = T3。T3 < min_trx_id(T5)→ 规则 2 成立 → 可见。name='Cathy'未被删除。因此 T5 读取到的结果是name='Cathy'。
注意:如果 Undo 1 也标记删除,则会继续向后查找。可见性判断只决定版本是否“有效”(未因事务隔离而不可见),而删除状态由 DELETE_BIT 单独标记。
5. 不同隔离级别下的 ReadView 行为
- READ COMMITTED:每次 SELECT 都重新创建 ReadView(即获取最新的活跃事务列表)。因此能读取到已提交的最新版本。
- REPEATABLE READ:只在事务第一次 SELECT 时创建 ReadView,后续所有 SELECT 复用同一个 ReadView。因此整个事务期间看到的数据都是一致的(基于首次快照)。
6. 实例演示:REPEATABLE READ 下的可见性
假设时间线如下:
| 时间 | 事务 A | 事务 B |
|---|---|---|
| t1 | 开始(id=101) | - |
| t2 | - | 开始(id=102) |
| t3 | 第一次SELECT(创建ReadView) | - |
| t4 | - | UPDATE id=1 SET name='Dave'(未提交) |
| t5 | 第二次SELECT | - |
| t6 | - | COMMIT |
| t7 | 第三次SELECT | - |
在 t3 时刻,事务 A 创建 ReadView。此时活跃事务:[101, 102](假设 101 和 102 都在运行)。则:
min_trx_id=101,max_trx_id=103,m_ids=[101,102]。
t5 时刻,事务 A 第二次 SELECT。由于 REPEATABLE READ,复用同一 ReadView。此时版本链中最新版本是由事务 B(trx_id=102)修改的,但 102 仍在 m_ids 中(因为 t5 时事务 B 并未提交),因此看不到该版本,仍返回修改前的版本。
t7 时刻,事务 B 已提交,但 ReadView 仍相同(t3 创建的)。根据规则,trx_id=102,102 >= min_trx_id 且 102 < max_trx_id,且 102 在 m_ids 中(虽然现在已提交,但 ReadView 只记录 t3 时的状态),所以 仍然不可见。这就实现了可重复读。
阶段三:Undo Log 的清理与版本链截断
Undo Log 不会无限增长。当没有任何事务需要访问某个历史版本时(即该版本的所有后续版本都已可见,且所有可能引用该版本的事务都已结束),InnoDB 的 purge 线程会回收 Undo Log 空间。
清理条件:
- 对于 UPDATE 产生的 Undo Record:当系统中所有活跃事务的最早 ReadView 都不再需要该记录时,即可清除。
- 对于 DELETE 产生的 Undo Record:必须等到所有可能看到该删除标记的事务都结束后,才可真正删除行数据。
实际影响:长事务会阻止 Undo Log 的清理,导致 Undo 表空间膨胀,甚至引发磁盘空间不足。因此生产环境应避免长时间运行的只读事务。
实战:如何通过版本链分析死锁或脏读
当遇到不可重复读或幻读时,可以检查 InnoDB 的 information_schema.INNODB_TRX 和 SHOW ENGINE INNODB STATUS 来查看当前事务和版本链。但更常用的方法是利用 SELECT ... FOR UPDATE 加锁(当前读)来强制读取最新版本,从而规避 MVCC 的隔离效果。
示例:在 REPEATABLE READ 下,两个事务同时修改同一行可能导致死锁。分析版本链:
- 事务 A 先读取(快照读),获得版本 v1。
- 事务 B 更新,创建 v2(未提交)。
- 事务 A 更新(当前读),需要获取 v2 的锁,但 v2 被 B 持有,导致等待或死锁。
理解版本链能帮助定位此类问题。
关键记忆点
- 版本链方向:从头节点(最新)向尾节点(最旧)遍历。
- ReadView 时间点:REPEATABLE READ 在首次 SELECT 创建,READ COMMITTED 每次 SELECT 创建。
- 可见性规则:版本必须由已提交事务创建,且不能是未来事务。
- DELETE 与 UPDATE 区别:DELETE 相当于特殊的 UPDATE(标记删除),版本链中保留旧数据直到 purge。
通过掌握 Undo Log 的版本链构建与 ReadView 的可见性判断,你已经能够深入理解 MySQL 的 MVCC 实现原理,并在实际调优中做出更准确的决策。

暂无评论,快来抢沙发吧!