MySQL MVCC(多版本并发控制)原理完全指南
MVCC 是 InnoDB 存储引擎最核心的技术之一,也是 MySQL 能够在高并发场景下保持卓越性能的“秘密武器”。如果说锁是解决并发问题的“ brute force”(暴力手段),那 MVCC 就是“优雅的妥协”——让读写互不阻塞,在保证一致性的前提下最大化并发能力。
一、什么是 MVCC?
MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种不用加锁就能实现事务隔离的并发控制机制。它的核心思想非常简洁:
写操作不直接覆盖旧数据,而是生成一个新版本;读操作根据事务的隔离级别和时间戳,选择合适的版本来读。
这样一来,读操作永远不用等待写操作,写操作也不用等待读操作,读写互不阻塞。
生活类比:你在写一份文档,每隔一段时间保存一个历史版本。别人想读的时候,可以选择读最新版,也可以选择读某个历史版。你写你的,他读他的,互不干扰。
重要前提:InnoDB 的 MVCC仅在READ COMMITTED(RC)和REPEATABLE READ(RR)两个隔离级别下生效。READ UNCOMMITTED直接读最新版本,SERIALIZABLE靠锁机制保证一致性,都不使用 MVCC。
二、MVCC 的三大核心组件
MVCC 的实现依赖于三个紧密配合的组件:隐藏字段 + Undo Log 版本链 + Read View。
2.1 隐藏字段(Hidden Columns)
InnoDB 为每一行数据悄悄添加了三个“看不见”的隐藏字段:
| 隐藏字段 | 长度 | 核心作用 |
|---|---|---|
DB_TRX_ID | 6 字节 | 最后修改该行的事务 ID,全局递增 |
DB_ROLL_PTR | 7 字节 | 回滚指针,指向 Undo Log 中该行的上一个版本 |
DB_ROW_ID | 6 字节 | 隐藏主键,当表没有主键和唯一非空索引时自动生成 |
关键认知:
DB_ROW_ID不是 MVCC 必需的,只有当表没有显式主键时才会用到。纯只读事务(只有 SELECT)不会分配事务 ID,其DB_TRX_ID默认为 0。
2.2 Undo Log 版本链(Version Chain)
Undo Log 是 MVCC 的“数据载体”。每一次对数据行的修改,都会生成一条对应的 Undo Log,通过DB_ROLL_PTR将所有历史版本串联成一条单向链表。
版本链生成过程示例:
-- ① 插入初始数据(事务ID=3)INSERTINTOuser(id,name,age)VALUES(1,'张三',20);-- 此时:DB_TRX_ID=3,DB_ROLL_PTR=NULL,版本链只有初始版本-- ② 第一次更新(事务ID=5)UPDATEuserSETage=21WHEREid=1;-- 生成 undo log(age=20, DB_TRX_ID=3)-- 当前行:age=21, DB_TRX_ID=5, DB_ROLL_PTR → undo log-- ③ 第二次更新(事务ID=8)UPDATEuserSETage=22WHEREid=1;-- 生成新的 undo log(age=21, DB_TRX_ID=5)-- 当前行:age=22, DB_TRX_ID=8, DB_ROLL_PTR → 新 undo log最终形成的版本链结构:
当前行 (age=22, DB_TRX_ID=8) ↑ DB_ROLL_PTR Undo Log V2 (age=21, DB_TRX_ID=5) ↑ DB_ROLL_PTR Undo Log V1 (age=20, DB_TRX_ID=3) ↑ DB_ROLL_PTR NULL(初始版本)2.3 Read View(读视图/一致性视图)
如果说 Undo Log 版本链是 MVCC 的“数据载体”,那么Read View 就是 MVCC 的“决策大脑”。它定义了当前事务能看到哪些数据版本、不能看到哪些数据版本。
Read View 的四个核心字段:
| 字段 | 含义 |
|---|---|
m_ids | 生成 Read View 时,当前所有活跃的未提交读写事务 ID 集合 |
min_trx_id | m_ids中的最小值(最早的未提交事务) |
max_trx_id | 生成 Read View 时,系统下一个要分配的事务 ID(非活跃事务最大值!) |
creator_trx_id | 生成当前 Read View 的事务自己的 ID(只读事务为 0) |
⚠️常见误区纠正:
max_trx_id不是活跃事务的最大值,而是下一个要分配的事务 ID。比如当前活跃事务 ID 是 2、3、5,那么max_trx_id = 6,而不是 5。这个认知错误会直接导致可见性判断完全错乱。
三、可见性判断算法(核心)
有了 Read View 和版本链,InnoDB 会按照固定规则,从版本链的最新版本开始,依次判断每个版本是否可见,直到找到第一个符合规则的版本。
判断规则(优先级从高到低):
对于版本链中某个版本,其 DB_TRX_ID = trx_id: ① 如果 trx_id == creator_trx_id → ✅ 可见(当前事务自己修改的版本,当然可见) ② 如果 trx_id < min_trx_id → ✅ 可见(该版本在生成 Read View 前已提交) ③ 如果 trx_id >= max_trx_id → ❌ 不可见(该版本的事务在生成 Read View 后才启动) ④ 如果 min_trx_id <= trx_id < max_trx_id → 判断 trx_id 是否在 m_ids 中: - 在 m_ids 中 → ❌ 不可见(该事务仍未提交) - 不在 m_ids 中 → ✅ 可见(该事务在生成 Read View 前已提交) ⑤ 如果当前版本不可见 → 沿着 DB_ROLL_PTR 找到上一个版本,重复上述判断直观理解:
| 条件 | 结论 |
|---|---|
trx_id < min_trx_id | 该事务早就提交了→ 可见 |
trx_id in m_ids | 该事务还在运行→ 不可见 |
trx_id >= max_trx_id | 该事务未来才启动→ 不可见 |
trx_id == creator_trx_id | 自己改的→ 可见 |
四、RC 与 RR 的核心差异:Read View 生成时机
RC 和 RR 隔离级别的本质区别,就在于Read View 的生成时机不同。
| 对比项 | RC(读已提交) | RR(可重复读) |
|---|---|---|
| Read View 生成时机 | 每次 SELECT都生成新的 Read View | 事务中第一次 SELECT时生成,整个事务复用 |
| 能否看到其他事务提交的修改 | ✅ 能(每次查询都看到最新提交) | ❌ 不能(只看到事务启动前的数据) |
| 不可重复读 | ❌ 存在 | ✅ 已解决 |
| 幻读(快照读) | ❌ 存在 | ✅ 已解决(InnoDB 特殊实现) |
| 并发性能 | 更高 | 稍低 |
4.1 RC 级别下的表现
RC 级别下,每次 SELECT 都生成新的 Read View,因此只能看到已经提交的事务修改(解决了脏读),但因为每次 Read View 都是最新的,会出现不可重复读。
示例:
| 时间 | 会话A(RC) | 会话B(RC) |
|---|---|---|
| T1 | BEGIN | BEGIN |
| T2 | SELECT → age=20 | |
| T3 | UPDATE age=21(未提交) | |
| T4 | SELECT → age=20(脏读解决) | |
| T5 | COMMIT | |
| T6 | SELECT → age=21(不可重复读!) |
T6 时,会话A 生成了新的 Read View,会话B 已提交,所以看到了新数据。
4.2 RR 级别下的表现
RR 级别下,事务内第一次 SELECT 生成 Read View 后,整个事务复用,因此看不到其他事务在事务启动后提交的修改,解决了不可重复读。
示例:
| 时间 | 会话A(RR) | 会话B(RR) |
|---|---|---|
| T1 | BEGIN | BEGIN |
| T2 | SELECT → age=20(生成 Read View) | |
| T3 | UPDATE age=21;COMMIT | |
| T4 | SELECT → age=20(复用 Read View,看不到提交!) |
T3 时会话B 虽然提交了,但会话A 复用第一次的 Read View,根据可见性规则,会话B 的trx_id在m_ids中,因此修改不可见。
五、快照读 vs 当前读
MVCC 把 SELECT 操作分成了两种:
| 读类型 | 对应 SQL | 读哪个版本 | 是否加锁 |
|---|---|---|---|
| 快照读(Snapshot Read) | 普通SELECT | 读 Read View 指向的快照版本(历史数据) | ❌ 不加锁 |
| 当前读(Current Read) | SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT | 读行的最新版本 | ✅ 加锁 |
关键理解:
- 快照读:不加锁,只管读。RR 下读到事务快照建立时的数据版本,RC 下读到当前语句执行那一刻的快照。
- 当前读:加锁,读最新版。每次必须拿到最新的数据,如果该行正被其他事务修改(未提交),会阻塞等待锁释放。
-- 快照读:不加锁,读快照SELECT*FROMuserWHEREid=1;-- 当前读:加锁,读最新SELECT*FROMuserWHEREid=1FORUPDATE;SELECT*FROMuserWHEREid=1LOCKINSHAREMODE;UPDATEuserSETname='Bob'WHEREid=1;-- 也是当前读DELETEFROMuserWHEREid=1;-- 也是当前读六、MVCC 与锁的协作
MVCC 和锁不是替代关系,而是协作关系:
| 冲突类型 | 解决方案 |
|---|---|
| 读-写冲突 | MVCC(读写互不阻塞) |
| 写-写冲突 | 锁机制(行锁、间隙锁、Next-Key Lock) |
在 RR 级别下,InnoDB 通过MVCC + Next-Key Lock的组合彻底解决了幻读问题:
- MVCC 层面:整个事务复用同一个 Read View,看不到其他事务新增的行
- 锁层面:
Next-Key Lock(记录锁 + 间隙锁)阻止其他事务在查询范围内插入新数据
七、MVCC 的局限性与注意事项
| 局限性 | 说明 |
|---|---|
| 只能解决读写冲突 | 写-写冲突仍然需要通过锁来解决 |
| Undo Log 占用空间 | 大量历史版本占用磁盘空间,需要 Purge 线程清理 |
| 长事务是杀手 | 长事务导致 Undo Log 无法被清理,可能撑爆磁盘 |
| 不支持 DDL | DDL 操作加表级锁,会阻塞所有读写操作 |
生产环境黄金法则:
事务一定要短小精悍。长事务在 RR 级别下会长时间持有 Read View,导致对应的 Undo Log 版本无法被 Purge 线程清理,可能引发磁盘爆满和性能雪崩。
八、总结
| 核心要点 | 关键描述 |
|---|---|
| 本质 | 通过维护数据行的多个历史版本,实现读写互不阻塞的无锁并发控制 |
| 三大组件 | 隐藏字段(DB_TRX_ID+DB_ROLL_PTR)+ Undo Log 版本链 + Read View |
| 核心算法 | 通过 Read View 的可见性规则,从版本链中找到当前事务能看到的版本 |
| RC vs RR | RC:每次 SELECT 新建 Read View;RR:事务内复用第一次的 Read View |
| 快照读 vs 当前读 | 快照读(普通 SELECT)不加锁读历史版本;当前读(FOR UPDATE/UPDATE)加锁读最新版 |
| 适用级别 | 仅在 RC 和 RR 下生效 |
一句话记住 MVCC:
MVCC 是 InnoDB 的“时光机”——通过 Undo Log 保存每个数据行的所有历史版本,再通过 Read View 为每个事务拍一张“快照”,让不同事务在同一时刻看到同一行数据的不同版本,从而实现读写互不阻塞的高性能并发控制。
面试高频考点:
- MVCC 解决了什么问题?——读写冲突,让读不阻塞写、写不阻塞读
- RC 和 RR 在 MVCC 实现上有什么区别?——Read View 生成时机不同
- 快照读和当前读有什么区别?——快照读不加锁读历史版本,当前读加锁读最新版
- InnoDB 的 RR 级别如何解决幻读?——MVCC(复用 Read View)+ Next-Key Lock