11 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-05-16 00:00 |
MVCC 原理
概述
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现非阻塞读的核心机制。它让读写不冲突——大多数情况下,SELECT 完全不需要等待 UPDATE 持有排他锁。
[!TIP] 一句话理解 MVCC MVCC 的本质是让每个事务看到「一个时间点的快照」,而不是磁盘上实时的数据。就像给数据库拍照——你在照片里看到的是拍那一瞬间的样子,之后别人怎么改都不会影响你。
背后的三个支撑字段
每行记录额外隐藏了两个字段:
| 字段 | 类型 | 作用 |
|---|---|---|
| DB_TRX_ID | 6 bytes | 最近修改该行的事务 ID |
| DB_ROLL_PTR | 7 bytes | 回滚指针,指向对应的 Undo Log |
此外,二级索引的叶子节点还保存了主键值(而不是完整数据),这也是 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(版本链)
当一行被 UPDATE 时,InnoDB 不会直接修改原数据,而是:
- 在 Undo Log 中保留旧版本
- 在新行记录的 DB_ROLL_PTR 上链接到旧版本
- 更新 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 落在这个区间之外的,可以直接通过两条边界判断得出结论;只有落在这个区间的,才需要进一步检查是否在 m_ids 中。这样可以减少不必要的列表扫描。
可见性判断规则
给定一行记录的 trx_id,判断它对某个 Read View 是否可见:
flowchart TD
A["行记录的 trx_id"] --> 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创建时还在运行"]
style V1 fill:#00D866,color:#fff
style V2 fill:#00D866,color:#fff
style V3 fill:#00D866,color:#fff
style V4 fill:#EE5A24,color:#fff
四个规则的速记口诀
| 规则 | 条件 | 结论 | 通俗解释 |
|---|---|---|---|
| 早提交 | trx_id < min_trx_id |
✅ 可见 | 比你早起的人已经下班走了 → 你能看到 |
| 晚启动 | trx_id >= max_trx_id |
✅ 可见 | 比你晚来的人还没到办公室 → 你看不到他写的东西 |
| 已退出 | trx_id ∉ m_ids |
✅ 可见 | 名单上没有此人 → 他已经提交了 |
| 正活跃 | trx_id ∈ m_ids |
❌ 不可见 | 正在工位上坐着呢 → 别看他未完成的产出 |
[!TIP] 判断顺序很重要 实际代码中先比较
min_trx_id和max_trx_id这两条边界,因为这是 O(1) 的比较操作。只有在落在两个边界之间时,才去扫描 m_ids 列表做成员检查。这种设计在高并发场景下能显著减少 CPU 开销。
RC 与 RR 的关键区别
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 仍不可见
UL->>UL: 继续往前追溯
loop 直到找到可见版本或链表结束
end
end
end
[!QUESTION] 版本链遍历会不会很慢? 理论上是的——如果一行被频繁 UPDATE,版本链会很长。但实践中很少出现这种情况,因为:
- 大多数业务场景下一行数据的 UPDATE 频率不高
- InnoDB 有 purge thread 定期清理不可见的旧版本,缩短版本链长度
- 可以通过合理选择隔离级别(如 RC 模式下的 ReadView 更灵活)来减少回溯次数
实战验证
-- 会话 A(RR 模式,MySQL 默认)
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1;
-- 读到 balance = 500
-- 会话 B
START TRANSACTION;
UPDATE accounts SET balance = 1000 WHERE id = 1;
COMMIT;
-- 会话 A(同一个事务内再次查)
SELECT * FROM accounts WHERE id = 1;
-- 仍然读到 500!← ReadView 不让看 T_B 的改动
-- 但如果用当前读呢?
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 读到 1000 ← 当前读不走 MVCC,直接拿最新版本
关联笔记
- hhs/MySQL/24-隔离级别与可见性 — 隔离级别的定义及 RC vs RR 的选择
- hhs/MySQL/23-ACID 与原子性实现 — Undo Log 的结构和生命周期
- hhs/MySQL/26-锁机制总览 — MVCC 与锁的配合(一致性读 vs 当前读)
- hhs/MySQL/28-一致性读与当前读 — 什么情况下走 MVCC、什么情况下需要加锁