文章目录

MySQL Undo Log 在 MVCC 中的版本链构建与 ReadView 可见性判断

发布于 2026-05-27 06:11:07 · 浏览 35 次 · 评论 0 条

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 字节)。

UPDATEDELETE 操作发生时,InnoDB 不会直接覆盖原数据,而是:

  1. 将修改前的旧数据写入 Undo Log 段。
  2. 在 Undo Log 记录中保存旧行的完整内容以及指向更早版本的指针。
  3. 更新行记录中的 DB_TRX_ID 为新事务 ID,并将 DB_ROLL_PTR 指向新的 Undo Log 记录。

这样,同一个数据行的多个版本通过 DB_ROLL_PTR 串联成一条单向链表,头节点(最新版本)位于聚簇索引中,后续节点依次指向历史版本。

2. 构建版本链的具体步骤

步骤 1:事务 T1 插入一条记录(id=1, name='Alice')。
此时行记录中 DB_TRX_ID = T1DB_ROLL_PTRNULL。没有旧版本,链长仅 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 = T3DB_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):

graph LR C["聚簇索引(T4, deleted)"] -->|DB_ROLL_PTR| U3["Undo Record(T3, name='Cathy')"] U3 --> U2["Undo Record(T2, name='Bob')"] U2 --> U1["Undo Record(T1, name='Alice')"] U1 --> NULL["NULL"]

关键点

  • 每个 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 = T5max_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 = T3T3 < 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=101max_trx_id=103m_ids=[101,102]

t5 时刻,事务 A 第二次 SELECT。由于 REPEATABLE READ,复用同一 ReadView。此时版本链中最新版本是由事务 B(trx_id=102)修改的,但 102 仍在 m_ids 中(因为 t5 时事务 B 并未提交),因此看不到该版本,仍返回修改前的版本。

t7 时刻,事务 B 已提交,但 ReadView 仍相同(t3 创建的)。根据规则,trx_id=102102 >= min_trx_id102 < 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_TRXSHOW 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 实现原理,并在实际调优中做出更准确的决策。

评论 (0)

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

扫一扫,手机查看

扫描上方二维码,在手机上查看本文