MySQL MVCC 多版本实现完全指南
MVCC 的"多版本"并非抽象概念,而是实实在在地存储在磁盘和内存中的物理数据结构 。它由聚簇索引行记录 、Undo Log 段 和回滚指针共同构成一个完整的版本链。本文将深入 InnoDB 底层,剖析多版本是如何被创建、链接、读取和回收的。
一、多版本的物理载体:聚簇索引 + 隐藏字段
多版本的"当前版本"存储在聚簇索引(Clustered Index)的叶子节点 中,而"历史版本"则存储在 Undo Log 段中。两者通过指针串联。
1.1 行记录的隐藏字段(物理存在)
InnoDB 中每行聚簇索引记录(Row Format)都包含三个用户不可见的隐藏列:
| 隐藏字段 | 长度 | 作用 |
|---|---|---|
DB_TRX_ID |
6 字节 | 最近修改该行的事务 ID。每次更新都会更新为此值。 |
DB_ROLL_PTR |
7 字节 | 回滚指针,指向该行在 Undo Log 中的前一个版本的物理地址。 |
DB_ROW_ID |
6 字节 | 行 ID,仅在表无主键且无唯一非空索引时,InnoDB 用它作为聚簇索引键。 |
关键点 :事务 ID(
DB_TRX_ID)是全局递增 的。修改操作(INSERT/UPDATE/DELETE)只有在真正修改数据时才会分配事务 ID,只读事务不分配。
1.2 版本链的物理结构
当前行(聚簇索引叶子节点)
┌─────────────────────────────┐
│ id = 1, name = '张三' │
│ age = 22 │
│ DB_TRX_ID = 10 │ ← 最后修改的事务ID
│ DB_ROLL_PTR = 0x00A1 ──────┼──┐
└─────────────────────────────┘ │
│
▼
┌─────────────────────────┐
│ Undo Log 段 (磁盘) │
│ version 2 (trx_id=5) │
│ name='张三', age=21 │
│ DB_ROLL_PTR = 0x00B2 ──┼──┐
└─────────────────────────┘ │
│
▼
┌─────────────────────────┐
│ Undo Log 段 (磁盘) │
│ version 1 (trx_id=3) │
│ name='张三', age=20 │
│ DB_ROLL_PTR = NULL │
└─────────────────────────┘
这就是单向链表结构,最新版本在最前面(当前行),历史版本按修改时间倒序排列。
二、多版本的生成机制:Undo Log 的两种类型
InnoDB 为不同的操作生成不同类型的 Undo Log,这决定了它们的生命周期。
| Undo 类型 | 触发操作 | 存储内容 | 生命周期 |
|---|---|---|---|
| TRX_UNDO_INSERT | INSERT 操作 |
新行的主键(用于回滚时删除) | 事务提交后立即被 Purge 线程物理删除(因为 Insert 记录对其他事务不可见,无需用于 MVCC 快照读) |
| TRX_UNDO_UPDATE | UPDATE 或 DELETE 操作 |
修改前的完整行数据副本(旧版本) | 事务提交后不能立即删除,需等待所有可能读取该历史版本的 Read View 关闭后,由 Purge 线程清理 |
DELETE 的真相 :
DELETE操作不会立即物理删除数据。InnoDB 会将当前行标记为"已删除"(标记DELETE_MASK),并将旧行数据写入 Undo Log。实际的物理删除由 Purge 线程异步完成。
三、DML 操作如何生成新版本(逐步拆解)
3.1 INSERT 操作
sql
INSERT INTO user (id, name, age) VALUES (1, '张三', 20);
- 在聚簇索引中插入新行。
- 设置
DB_TRX_ID= 当前事务 ID(假设为 3)。 - 设置
DB_ROLL_PTR= NULL(无历史版本)。 - 生成
TRX_UNDO_INSERT类型 Undo Log(仅记录主键 1)。 - 事务提交后,Insert Undo Log 立即释放,无法被用于快照读。
3.2 UPDATE 操作(关键步骤)
sql
UPDATE user SET age = 21 WHERE id = 1; -- 当前事务 ID = 5
执行步骤:
- 加排他锁(X Lock) 锁定该行。
- 将当前行完整数据(age=20, DB_TRX_ID=3) 拷贝到 Undo Log 段,生成
TRX_UNDO_UPDATE日志。 - 修改当前行 :将
age改为 21,DB_TRX_ID改为 5,DB_ROLL_PTR指向第 2 步生成的 Undo Log 地址。 - 写入 Redo Log(保证持久性)。
- 释放排他锁(实际在事务提交时释放)。
3.3 DELETE 操作
sql
DELETE FROM user WHERE id = 1; -- 当前事务 ID = 8
执行步骤:
- 加排他锁锁定该行。
- 将当前行完整数据拷贝到 Undo Log,生成
TRX_UNDO_UPDATE类型日志(删除视为特殊的更新,因为需要保留旧版本供 MVCC 使用)。 - 标记当前行的
DELETE_BIT(删除标记位)为 1,但物理上仍保留该行在聚簇索引中。 - 修改
DB_TRX_ID= 8,DB_ROLL_PTR指向 Undo Log。 - 事务提交后,该行对
Read View不可见(因已标记删除),但物理删除由 Purge 线程延迟执行。
四、多版本的读取:版本链遍历算法
当执行快照读(普通 SELECT)时,InnoDB 遵循严格的可见性判断算法 ,从当前行开始,沿着 DB_ROLL_PTR 逐个版本回溯,直到找到第一个对当前 Read View 可见的版本。
判断逻辑(简化版伪代码):
对于版本链中的每个版本,其 trx_id = V:
如果 V == 当前事务ID(自己改的)→ 可见(返回该版本)
如果 V < min_trx_id(Read View中最小活跃事务ID)→ 可见(已提交)
如果 V >= max_trx_id(下一个分配的事务ID)→ 不可见(未来事务,继续回溯)
如果 V 在 m_ids(活跃事务列表)中 → 不可见(未提交,继续回溯)
否则 → 可见(已提交)
如果当前版本不可见,则沿 DB_ROLL_PTR 回溯到上一个 Undo Log 版本,重复上述过程。
RC 与 RR 的遍历区别
- RC :每次
SELECT都新建 Read View,所以每次都会从版本链头部开始,寻找最新已提交的版本。这导致了"不可重复读"------两次查询可能找到不同版本。 - RR :事务第一次
SELECT时创建 Read View,后续复用。因此即使其他事务提交了新版本,Read View中的m_ids依旧包含它们(因为事务启动时它们活跃),所以遍历时会跳过这些新版本,直接找到旧的可见版本,保证结果一致。
五、多版本的清理:Purge 线程
历史版本不能无限增长,否则磁盘会被撑爆。Purge 线程负责物理删除不再需要的 Undo Log 版本。
5.1 判断逻辑(哪个版本可以删?)
当一个 Undo Log 记录满足以下所有条件时,可被物理清除:
- 该事务已经提交或回滚。
- 没有任何当前活跃的 Read View 需要访问该 Undo Log 版本。
- 系统会维护一个最小活跃事务 ID(
low_limit_id) 。如果一个 Undo Log 的trx_id小于当前所有活跃事务的最小trx_id,则所有 Read View 都不可能再读取到它,可以安全删除。
- 系统会维护一个最小活跃事务 ID(
5.2 删除过程
- Purge 线程从 Undo Log 段中找到可清理的历史版本。
- 如果是
DELETE标记的行,执行物理删除(从聚簇索引叶子节点中移除)。 - 如果是
UPDATE的旧版本,直接从 Undo Log 段中回收空间(标记为可重用)。 - 同时,如果该版本是辅助索引(Secondary Index)的记录,也要删除对应的辅助索引条目。
性能陷阱 :如果存在长事务(长时间不提交),系统的最小活跃事务 ID 会非常靠前,导致 Purge 线程无法清理这些事务之后产生的所有 Undo Log,Undo 表空间会持续膨胀,直到磁盘写满。
六、辅助索引(Secondary Index)如何处理多版本?
重要差异 :辅助索引不包含 DB_TRX_ID 和 DB_ROLL_PTR 隐藏字段。这意味着 MVCC 无法直接通过辅助索引判断版本可见性。
处理策略:
- 当前读 (如
SELECT ... FOR UPDATE):通过辅助索引找到主键,再回表到聚簇索引加锁。 - 快照读 (普通
SELECT):- 从辅助索引 B+ 树中定位到叶子节点(包含主键值)。
- 根据主键回表到聚簇索引。
- 在聚簇索引的版本链上执行前述可见性判断,找到合适的可见版本。
性能影响:如果辅助索引查询范围很大,回表次数多,且版本链很长,会导致严重的随机 I/O。
七、多版本实现总结
| 维度 | 说明 |
|---|---|
| 存储位置 | 当前版本 → 聚簇索引叶子节点;历史版本 → Undo Log 段(系统表空间/独立 Undo 表空间) |
| 版本链结构 | 聚簇索引的 DB_ROLL_PTR 指向 Undo Log 版本,Undo Log 版本间也通过指针串联,形成从新到旧的单向链表 |
| 版本生成 | INSERT 生成轻量级 Insert Undo(提交即删);UPDATE/DELETE 生成 Update Undo(需保留供 MVCC 使用) |
| 版本读取 | 通过 Read View + DB_TRX_ID 对比,从链头向后遍历,寻找第一个可见版本 |
| 版本回收 | Purge 线程异步执行,基于系统最小活跃事务 ID 决定清理哪些旧版本 |
| 核心风险 | 长事务会阻塞 Purge,导致 Undo 膨胀和性能退化 |
八、核心流程图(逻辑示意)
#mermaid-svg-rrMOm38I4Gsdm5NU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rrMOm38I4Gsdm5NU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rrMOm38I4Gsdm5NU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rrMOm38I4Gsdm5NU .error-icon{fill:#552222;}#mermaid-svg-rrMOm38I4Gsdm5NU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rrMOm38I4Gsdm5NU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rrMOm38I4Gsdm5NU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rrMOm38I4Gsdm5NU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rrMOm38I4Gsdm5NU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rrMOm38I4Gsdm5NU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rrMOm38I4Gsdm5NU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rrMOm38I4Gsdm5NU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rrMOm38I4Gsdm5NU .marker.cross{stroke:#333333;}#mermaid-svg-rrMOm38I4Gsdm5NU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rrMOm38I4Gsdm5NU p{margin:0;}#mermaid-svg-rrMOm38I4Gsdm5NU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rrMOm38I4Gsdm5NU .cluster-label text{fill:#333;}#mermaid-svg-rrMOm38I4Gsdm5NU .cluster-label span{color:#333;}#mermaid-svg-rrMOm38I4Gsdm5NU .cluster-label span p{background-color:transparent;}#mermaid-svg-rrMOm38I4Gsdm5NU .label text,#mermaid-svg-rrMOm38I4Gsdm5NU span{fill:#333;color:#333;}#mermaid-svg-rrMOm38I4Gsdm5NU .node rect,#mermaid-svg-rrMOm38I4Gsdm5NU .node circle,#mermaid-svg-rrMOm38I4Gsdm5NU .node ellipse,#mermaid-svg-rrMOm38I4Gsdm5NU .node polygon,#mermaid-svg-rrMOm38I4Gsdm5NU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rrMOm38I4Gsdm5NU .rough-node .label text,#mermaid-svg-rrMOm38I4Gsdm5NU .node .label text,#mermaid-svg-rrMOm38I4Gsdm5NU .image-shape .label,#mermaid-svg-rrMOm38I4Gsdm5NU .icon-shape .label{text-anchor:middle;}#mermaid-svg-rrMOm38I4Gsdm5NU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rrMOm38I4Gsdm5NU .rough-node .label,#mermaid-svg-rrMOm38I4Gsdm5NU .node .label,#mermaid-svg-rrMOm38I4Gsdm5NU .image-shape .label,#mermaid-svg-rrMOm38I4Gsdm5NU .icon-shape .label{text-align:center;}#mermaid-svg-rrMOm38I4Gsdm5NU .node.clickable{cursor:pointer;}#mermaid-svg-rrMOm38I4Gsdm5NU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rrMOm38I4Gsdm5NU .arrowheadPath{fill:#333333;}#mermaid-svg-rrMOm38I4Gsdm5NU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rrMOm38I4Gsdm5NU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rrMOm38I4Gsdm5NU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rrMOm38I4Gsdm5NU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rrMOm38I4Gsdm5NU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rrMOm38I4Gsdm5NU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rrMOm38I4Gsdm5NU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rrMOm38I4Gsdm5NU .cluster text{fill:#333;}#mermaid-svg-rrMOm38I4Gsdm5NU .cluster span{color:#333;}#mermaid-svg-rrMOm38I4Gsdm5NU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rrMOm38I4Gsdm5NU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rrMOm38I4Gsdm5NU rect.text{fill:none;stroke-width:0;}#mermaid-svg-rrMOm38I4Gsdm5NU .icon-shape,#mermaid-svg-rrMOm38I4Gsdm5NU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rrMOm38I4Gsdm5NU .icon-shape p,#mermaid-svg-rrMOm38I4Gsdm5NU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rrMOm38I4Gsdm5NU .icon-shape .label rect,#mermaid-svg-rrMOm38I4Gsdm5NU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rrMOm38I4Gsdm5NU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rrMOm38I4Gsdm5NU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rrMOm38I4Gsdm5NU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
用户发起快照读 SELECT
生成 Read View
读取聚簇索引当前行
当前行是否对 Read View 可见?
直接返回当前行数据
通过 DB_ROLL_PTR 定位到 Undo Log 版本
Undo Log 版本是否可见?
返回该历史版本数据
继续回溯上一个 Undo Log 版本
一句话总结 MVCC 多版本实现:
MVCC 的多版本本质上是"用空间换时间"------在聚簇索引中保留最新行,在 Undo Log 段中通过回滚指针串联旧版本链,借助事务 ID 和 Read View 实现无锁快照读,再通过后台 Purge 线程异步回收过期的历史版本。理解这条链的生成、遍历和回收,是掌握 MySQL 事务与高性能并发优化的关键。