This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md
T
2026-05-21 20:00:46 +08:00

311 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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["用户定义列<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
```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、什么情况下需要加锁