--- tags: [MySQL, MVCC, Read View, Undo Log, 多版本并发控制] create time: 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 的可见性判断始终发生在聚簇索引层面。 ```mermaid flowchart LR subgraph Row["行记录物理结构"] User["用户定义列
id, name, email..."] TrxId["DB_TRX_ID
事务 ID (6 bytes)"] RollPtr["DB_ROLL_PTR
回滚指针 (7 bytes)"] RowId["DB_ROW_ID
隐藏行 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 ```mermaid 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 并用指针串联起来,才能同时支持多个事务各自不同的可见性需求。 ### 版本链的多次更新 ```mermaid 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 是一个「可见性规则集合」,决定了哪些版本对当前事务可见: ```go // 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 } ``` ```mermaid 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 是否可见: ```mermaid 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 创建时机不同**。 ```mermaid 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。 ```sql -- 会话 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 遍历时发现该行被删除且不可见,就直接跳过。 ```mermaid 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 的版本链维护开销。 ```mermaid 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 ``` ## 完整的版本查找过程 ```mermaid 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 更灵活)来减少回溯次数 ## 实战验证 ```sql -- 会话 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 ``` ## 关联笔记 - [[hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性]] — 隔离级别的定义及 RC vs RR 的选择 - [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] — Undo Log 的结构和生命周期 - [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — MVCC 与锁的配合(一致性读 vs 当前读) - [[hhs/MySQL/06-事务与并发控制/28-一致性读与当前读]] — 什么情况下走 MVCC、什么情况下需要加锁