311 lines
13 KiB
Markdown
311 lines
13 KiB
Markdown
|
|
---
|
|||
|
|
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、什么情况下需要加锁
|