Files
2026-05-24 11:42:38 +08:00

13 KiB
Raw Permalink Blame History

tags, create time
tags create time
MySQL
MVCC
Read View
Undo Log
多版本并发控制
2026-05-16 00:00

MVCC 原理

概述

MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现非阻塞读的核心机制。它让读写不冲突——大多数情况下,SELECT 完全不需要等待 UPDATE 持有排他锁。

[!TIP] 一句话理解 MVCC MVCC 的本质是让每个事务看到「一个时间点的快照」,而不是磁盘上实时的数据。就像给数据库拍照——你在照片里看到的是拍那一瞬间的样子,之后别人怎么改都不会影响你。

行记录的隐藏字段

每行记录在用户定义的列之外,InnoDB 还悄悄塞了两个隐藏字段:

字段 类型 作用
DB_TRX_ID 6 bytes 最近修改该行的事务 ID
DB_ROLL_PTR 7 bytes 回滚指针,指向对应的 Undo Log

[!NOTE] 二级索引与 MVCC 的关系 二级索引的叶子节点不存储完整行数据,只保存主键值。因此通过二级索引查数据时,必须「回表」到聚簇索引才能拿到完整的行记录(包括 DB_TRX_ID)。这意味着 MVCC 的可见性判断始终发生在聚簇索引层面。

flowchart LR
    subgraph Row["行记录物理结构"]
        User["用户定义列<br/>id, name, email..."]
        TrxId["DB_TRX_ID<br/>事务 ID (6 bytes)"]
        RollPtr["DB_ROLL_PTR<br/>回滚指针 (7 bytes)"]
        RowId["DB_ROW_ID<br/>隐藏行 ID (6 bytes)"]
    end

    User --- TrxId --- RollPtr --- RowId
    
    style TrxId fill:#C44569,color:#fff
    style RollPtr fill:#FF9F43,color:#000

[!NOTE] 为什么需要 DB_TRX_ID? 每一行被某个事务插入或修改时,InnoDB 都需要记住是哪个事务所改的。这样在查 Read View 的时候才知道:"哦,这行是 T2 改的,而我的 Read View 创建时 T2 还没提交"——于是就不给你看这行。

Version Chain(版本链)

[!TIP] 用 Git 理解版本链 版本链就像 Git 的提交历史——每次 UPDATE 相当于一次 git commit,旧版本不会被覆盖,而是通过 DB_ROLL_PTR(类似 parent commit 指针)串联起来。你想看某个历史版本?沿着指针往前「checkout」就行了。

当一行被 UPDATE 时,InnoDB 不会直接修改原数据,而是:

  1. 在 Undo Log 中保留旧版本
  2. 在新行记录的 DB_ROLL_PTR 上链接到旧版本
  3. 更新 DB_TRX_ID 为新事务 ID
flowchart LR
    subgraph UndoLog["Undo Log(旧版本)"]
        UL["name='Alice'\nTRX_ID=100\nROLL_PTR=NULL"]
    end

    subgraph CurrentRow["当前行(新版本)"]
        CR["name='Alice_New'\nTRX_ID=200\nROLL_PTR->Undo#1"]
    end
    
    CR -.->|"ROLL_PTR"| UL
    
    style CR fill:#C44569,color:#fff
    style UL fill:#FF9F43,color:#000

[!QUESTION] 为什么不能原地覆盖? 如果直接修改原行,之前的事务就看不到自己的"历史视角"了。把旧版本存到 Undo Log 并用指针串联起来,才能同时支持多个事务各自不同的可见性需求。

版本链的多次更新

flowchart LR
    V1["V1: Alice\nTRX_ID=100"] -->|"Roll Ptr"| V2["V2: Alice_New\nTRX_ID=200"]
    V2 -->|"Roll Ptr"| V3["V3: Alice_Latest\nTRX_ID=300"]
    
    style V1 fill:#AAB7B8,color:#fff
    style V2 fill:#FF9F43,color:#000
    style V3 fill:#C44569,color:#fff

Version Chain 是一条按 TRX_ID 递增方向排列的链表:越靠后的版本越新。查找时从最新一行往前遍历,找到第一个对当前 Read View 可见的版本即可停止。

Read View 的构成

[!TIP] Read View 是什么? 你可以把 Read View 想象成事务执行 SELECT 时向数据库申请的「时间窗口」。InnoDB 根据这个时间窗口判断:哪些数据在我进入这个窗口之前就已经存在,哪些是在我进来之后才产生的。

Read View 是一个「可见性规则集合」,决定了哪些版本对当前事务可见:

// InnoDB 内部 ReadView 结构(伪代码)
type ReadView struct {
	m_ids        []uint64 // 创建时活跃的事务 ID 列表
	min_trx_id   uint64   // m_ids 中的最小值
	max_trx_id   uint64   // 创建时下一个将被分配的 trx_id
	creator_trx_id uint64 // 创建该 ReadView 的事务 ID
}
flowchart TD
    A["事务 T5 执行 SELECT"] --> B["创建 ReadView"]
    B --> C["m_ids = {T3, T5, T7}\n当前正在运行的事务"]
    B --> D["min_trx_id = 3\n活跃事务中最小的 ID"]
    B --> E["max_trx_id = 8\n下一个将分配的 ID"]
    B --> F["creator_trx_id = 5\n我自己的事务 ID"]
    
    style C fill:#00B6BC,color:#fff

[!NOTE] min_trx_id / max_trx_id 的作用区间 这两个字段一起定义了一个半开区间 [min_trx_id, max_trx_id)。trx_id 落在这个区间之外的,可以直接通过两条边界判断得出结论——小于 min 的一定可见,大于等于 max 的一定不可见;只有落在这个区间的,才需要进一步检查是否在 m_ids 中。这样可以减少不必要的列表扫描。

可见性判断规则

给定一行记录的 trx_id,判断它对某个 Read View 是否可见:

flowchart TD
    A["行记录的 trx_id"] --> S{"trx_id == creator_trx_id?"}
    S -->|是| V0["✅ 可见\n我自己改的,当然看得到"]
    
    S -->|否| B{"trx_id < min_trx_id?"}
    B -->|是| V1["✅ 可见\n在 ReadView 创建前已提交"]
    
    B -->|否| C{"trx_id >= max_trx_id?"}
    C -->|是| V2["❌ 不可见\n在 ReadView 创建后才启动"]
    
    C -->|否| D{"trx_id 在 m_ids 中?"}
    D -->|否| V3["✅ 可见\n不在活跃列表 → 已提交"]
    D -->|是| V4["❌ 不可见\n创建 ReadView 时还在运行"]
    
    style V0 fill:#00B6BC,color:#fff
    style V1 fill:#00D866,color:#fff
    style V2 fill:#EE5A24,color:#fff
    style V3 fill:#00D866,color:#fff
    style V4 fill:#EE5A24,color:#fff

五个规则的速记口诀

规则 条件 结论 通俗解释
自己改的 trx_id == creator_trx_id ✅ 可见 自己写的代码自己当然能看见
早提交 trx_id < min_trx_id ✅ 可见 比你早到的人已经提交下班了 → 你能看到他的产出
晚启动 trx_id >= max_trx_id ❌ 不可见 比你晚到的人还在加班 → 他的产出你还没资格看
已退出 trx_id ∉ m_ids ✅ 可见 名单上没有此人 → 说明他已经提交退出了
正活跃 trx_id ∈ m_ids ❌ 不可见 正在工位上坐着呢 → 别看他未完成的半成品

[!TIP] 判断顺序很重要 实际代码中先检查是否是自己改的(trx_id == creator_trx_id),然后比较 min_trx_id 和 max_trx_id 这两条边界,因为这些都是 O(1) 的比较操作。只有 trx_id 落在两个边界之间时,才去扫描 m_ids 列表做成员检查。这种分层判断设计在高并发场景下能显著减少 CPU 开销。

RC 与 RR 的关键区别

[!TIP] 一个生活化的比喻 想象你在一家餐厅看菜单:

  • RC(读已提交) = 每次抬头看菜单,都是最新的版本。厨师中途改了价格,你下次看就能看到新价格。
  • RR(可重复读) = 你坐下来那一刻菜单就「定格」了。不管厨师之后怎么改菜单,你这顿饭看到的永远是第一眼的版本。

核心差异就一句话:ReadView 创建时机不同。

flowchart LR
    subgraph RC["RC: 每次 SELECT 新建 ReadView"]
        RC1["第1次 SELECT → ReadView #1\nm_ids={T2, T4}"]
        RC2["第2次 SELECT → ReadView #2\nm_ids={} ← T2 已提交!"]
        RC3["第3次 SELECT → ReadView #3\n看到了 T2 的数据 ✨\n也看到了 T4 的数据 ✨"]
    end

    subgraph RR["RR: 首次 SELECT 创建一次,全事务复用"]
        RR1["第1次 SELECT → ReadView #1\nm_ids={T2, T4}"]
        RR2["第2次 SELECT → 复用 ReadView #1\nm_ids 不变"]
        RR3["第3次 SELECT → 复用 ReadView #1\nT2 和 T4 始终不可见 🔒"]
    end
    
    style RC3 fill:#FF9F43,color:#000
    style RR3 fill:#00D866,color:#fff

[!IMPORTANT] 为什么 RR 叫"可重复读"? 核心原因就在这里——整个事务期间只有一份 ReadView,所以你每次看到的都是同一个快照。不管别的线程怎么改、怎么提交,你的视图始终冻结在第一次 SELECT 的那一刻。

MVCC 与 DML 的关系

很多开发者只知道 MVCC 处理 SELECT,其实 INSERT、DELETE 同样深度依赖 MVCC 机制:

INSERT — 写入新版本

INSERT 操作非常简单:插入的行自带当前事务的 trx_id,并设置 ROLL_PTR = NULL(因为是首个版本)。插入后,其他事务是否能看到这行,取决于它们的 Read View。

-- 会话 A 插入
START TRANSACTION;  -- trx_id = 500
INSERT INTO users (name) VALUES ('Bob');
COMMIT;

-- 会话 B(在 A 提交后查询)
START TRANSACTION;  -- trx_id = 600
SELECT * FROM users WHERE name = 'Bob';
-- ✅ 正常查到 Bob —— 因为 500 < min_trx_id(600),已提交

DELETE — 标记删除而非物理移除

DELETE 并不真正删除行记录,而是在 undo log 中创建一个标记为"已删除"的版本,然后在新行的 DB_TRX_ID 上标记删除。后续 SELECT 遍历时发现该行被删除且不可见,就直接跳过。

flowchart LR
    Before["DELETE 前的行\nname='Alice'\nTRX_ID=200\nROLL_PTR→旧版本"]
    After["DELETE 后的行(仍存活)\nname='Alice'  marked deleted\nTRX_ID=300\nROLL_PTR→Undo#1"]
    Purg["Purge Worker 异步清理\n满足条件的不可见记录\n才会真正从磁盘删除"]
    
    After --> Purg
    
    style Before fill:#FF9F43,color:#000
    style After fill:#EE5A24,color:#fff
    style Purg fill:#AAB7B8,color:#fff

[!WARNING] DELETE 不会立即释放空间 被 DELETE 的行仍然存储在磁盘中,直到 Purge Worker 异步回收。这就是为什么大量 DELETE 后需要用 OPTIMIZE TABLE 重建表来真正释放空间。

UPDATE — INSERT + DELETE 的组合

UPDATE 本质上是先逻辑删除旧行(加 deleted flag),再插入新行。因此 UPDATE 比纯 INSERT 多出 undo log 的版本链维护开销。

flowchart TD
    U1["原行: name='Alice'\nTRX_ID=100"] --> U2["逻辑删除旧版本\n(写 undo log)\n设置 deleted flag"]
    U2 --> U3["插入新版本\nTRX_ID=200\nname='Alice_New'\nROLL_PTR→undo"]
    
    style U1 fill:#FF9F43,color:#000
    style U3 fill:#C44569,color:#fff

完整的版本查找过程

sequenceDiagram
    participant T as 事务 T5
    participant V as ReadView
    participant Row as 行记录(最新版)
    participant UL as Undo Log 版本链

    T->>V: 获取当前 ReadView
    V->>Row: 检查 row.trx_id
    alt trx_id 可见
        Row-->>T: 返回当前版本
    else trx_id 不可见
        V->>UL: Follow ROLL_PTR 找上一个版本
        UL->>UL: 检查上一版的 trx_id
        alt 可见
            UL-->>T: 返回该版本
        else 仍不可见
            loop 直到找到可见版本或链表结束
                UL->>UL: 继续往前追溯
            end
        end
    end

[!QUESTION] 版本链遍历会不会很慢? 理论上是的——如果一行被频繁 UPDATE,版本链会很长。但实践中很少出现这种情况,因为:

  • 大多数业务场景下一行数据的 UPDATE 频率不高
  • InnoDB 有 purge thread 定期清理不可见的旧版本,缩短版本链长度
  • 可以通过合理选择隔离级别(如 RC 模式下的 ReadView 更灵活)来减少回溯次数

实战验证

-- 会话 A(RR 模式,MySQL 默认)
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;                       -- 事务 T_A, trx_id = 500

SELECT * FROM accounts WHERE id = 1;
-- 第一次 SELECT → 创建 ReadView
--   m_ids = {500}          (只有自己在跑)
--   min_trx_id = 500
--   max_trx_id = 501
-- 读到 balance = 500(行的 trx_id=400 < 500 → 早提交规则 → ✅ 可见)

-- 会话 B
START TRANSACTION;                       -- 事务 T_B, trx_id = 600
UPDATE accounts SET balance = 1000 WHERE id = 1;
COMMIT;                                  -- 行的 trx_id 被更新为 600

-- 会话 A(同一个事务内再次查)
SELECT * FROM accounts WHERE id = 1;
-- RR 模式 → 复用第一次的 ReadView(m_ids={500}, max_trx_id=501)
-- 行的 trx_id=600 >= 501 → 晚启动规则 → ❌ 不可见
-- 沿 ROLL_PTR 找到旧版本 trx_id=400 < 500 → ✅ 可见
-- 所以仍然读到 balance = 500!

-- 但如果用当前读呢?
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 当前读不走 MVCC,直接读磁盘上的最新行 → 读到 balance = 1000

关联笔记