03.1 - MVCC 实现原理
定位:MVCC 是 InnoDB 实现"读不阻塞写、写不阻塞读"的核心机制,理解它才能理解 MySQL 的并发控制 面试高频度:⭐⭐⭐⭐⭐ 考查方式:MVCC 实现细节、Read View 创建规则、RC 和 RR 下 MVCC 的行为差异
一、这是什么?为什么需要它?
是什么
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现高并发事务的核心机制。它让读操作不加锁也能看到一致的数据快照。
为什么需要 MVCC?
没有 MVCC 的数据库是怎样的?
- 读和写不能同时进行——要么读阻塞写,要么写阻塞读
- 并发读只能靠加锁保证一致性——性能极差
MVCC 的解决方案:
- 每行数据在内存中有多个版本(通过 Undo Log 维护)
- 每个事务启动时创建一个Read View(快照)
- 读操作根据 Read View 的规则,找到该事务"应该看到"的版本
- 读不需要等待写,写不需要等待读
核心 insight:MVCC 的本质是"用空间换时间"——用额外的存储空间(Undo Log 版本链)换取并发读写的性能。这是数据库领域最成功的 trade-off 之一。
二、原理拆解
2.1 行记录的隐藏列
InnoDB 中,每行记录除了用户定义的列,还有三个隐藏列:
sql
-- 用户看到的行:
-- id | name | age
-- ────┼────────┼──────
-- 1 │ 'Tom' │ 25
-- InnoDB 实际存储的行:
-- id | name | age | DB_TRX_ID | DB_ROLL_PTR | DB_ROW_ID
-- ────┼────────┼──────┼────────────┼──────────────┼──────────
-- 1 │ 'Tom' │ 25 | 100 | 0x7F3A... | (可选)
-- ↑ 隐藏列 ↑ ↑
-- 最后修改 指向 Undo Log 中
-- 此行的事务ID 上一个版本的指针| 隐藏列 | 大小 | 说明 |
|---|---|---|
| DB_TRX_ID | 6 字节 | 最近修改该行的事务 ID(递增) |
| DB_ROLL_PTR | 7 字节 | 回滚指针,指向 Undo Log 中的上一个版本 |
| DB_ROW_ID | 6 字节 | 隐式自增 ID(没有主键时作为聚簇索引) |
2.2 Undo Log 版本链
版本链(从最新到最旧):
当前行 (最新):
DB_TRX_ID=150, name='Tom', age=30
│
│ DB_ROLL_PTR
▼
Undo Log 版本 v2:
┌─────────────────────┐
│ DB_TRX_ID=120 │ ← 事务 120 修改后产生的旧版本
│ name='Tom' │
│ age=25 │
│ DB_ROLL_PTR=0x7F2 │
└──────────┬──────────┘
│
▼
Undo Log 版本 v1:
┌─────────────────────┐
│ DB_TRX_ID=100 │ ← 事务 100 插入时产生的初始版本
│ name='Tom' │
│ age=20 │
│ DB_ROLL_PTR=NULL │
└─────────────────────┘版本链的增长:
- 每次 UPDATE 产生一个新的 Undo Log 版本(不是覆盖旧数据)
- 旧版本仍然保存在 Undo Log 中
- 只有当所有事务都不再需要该版本时,Purge 线程才会清理
2.3 Read View(读视图)
Read View 是 MVCC 的"判定规则"——它决定一个事务在读取数据时,版本链中的哪个版本是可见的。
三个关键全局变量
↑ 事务 ID 递增
│
│ max_trx_id (当前已分配的最大事务ID+1)
│ │
│ │ 活跃事务列表 (m_ids)
│ │ [201, 202, 205]
│ │ ↑ 当前未提交的事务
│ │
│ min_trx_id (活跃事务中最小的ID)
│ │
│ │
│ ┌───┴──────────────────────────────────┐
│ │ 已提交的/尚未开始的事务区域 │
│ └──────────────────────────────────────┘
│
↓ trx_id 0可见性判定规则
Read View 创建时记录:
{
m_ids: [201, 202, 205], ← 当前活跃事务列表
min_trx_id: 201, ← 活跃事务中最小 ID
max_trx_id: 206, ← 最大事务 ID + 1
creator_trx_id: 200 ← 创建该 Read View 的事务 ID
}
判定一条记录的 DB_TRX_ID:
│
├── DB_TRX_ID == creator_trx_id? → ✅ 可见(自己修改的)
│
├── DB_TRX_ID < min_trx_id? → ✅ 可见(已提交,或在 Read View 创建前已提交)
│
├── DB_TRX_ID >= max_trx_id? → ❌ 不可见(Read View 创建后启动的事务)
│
├── DB_TRX_ID IN m_ids? → ❌ 不可见(未提交)
│
└── 否则 → ✅ 可见(已提交且不在活跃列表中)如果不可见,沿着 DB_ROLL_PTR 链向下一个旧版本查找,直到找到可见版本。
2.4 RC 与 RR 的 Read View 区别
这是面试中 最高频的 MVCC 考点:
sql
-- 假设事务隔离级别为 RR 或 RC
-- 数据: id=1, name='Tom', age=25
-- 时间线:
T1: BEGIN; -- 事务 200 开始
T2: UPDATE users SET age=30 WHERE id=1; COMMIT; -- 事务 201
T3: SELECT * FROM users WHERE id=1; -- 事务 200 的读操作RR 下(REPEATABLE READ):
事务 200 开始时的第一次 SELECT 创建 Read View:
Read View = {m_ids: [200], min_trx_id: 200, max_trx_id: 201}
↑ 注意:事务 200 自己的 ID 也在 m_ids 中,但 creator_trx_id 优先
当 T3 读取时:
DB_TRX_ID=201, 201 NOT IN m_ids, 201 < max_trx_id
→ 事务 201 已提交 → 不可见(因为默认不加锁的快照读只能看到
Read View 创建时的快照...等等,这个逻辑是错的)
让我修正:
在 RR 下,Read View 在事务的第一次读时创建,且**整个事务期间不变**
T1: BEGIN; -- 此时没有 Read View
T2: 事务 201 UPDATE + COMMIT
T3: 事务 200 的 SELECT:
→ 创建 Read View: {m_ids: [200], min_trx_id: 200, max_trx_id: 202}
→ DB_TRX_ID=201, 201 >= max_trx_id(202)? 不,201 < 202
→ 201 IN m_ids? m_ids 是 [200], 201 不在其中
→ 201 < min_trx_id(200)? 不
→ 所以 201 不在 m_ids 中且 201 < max_trx_id → 可见?实际上我需要更精确地理解这个过程。让我重新组织:
RR 下的 Read View:
- Read View 在事务第一次读时创建(对,就是第一次 SELECT 时)
- 之后整个事务期间复用同一个 Read View
- 所以后续无论其他事务怎么改,看到的都是同一个快照
RC 下的 Read View:
- 每条语句都创建新的 Read View
- 所以每次 SELECT 都能看到其他事务已经提交的最新数据
RR(REPEATABLE READ):
BEGIN ─── SELECT ─── UPDATE(其他事务) ─── SELECT ─── COMMIT
│ │
└─── Read View 创建 ─────────────────┘
(复用同一个)
RC(READ COMMITTED):
BEGIN ─── SELECT ─── UPDATE(其他事务) ─── SELECT ─── COMMIT
│ │
Read View v1 Read View v2
(较旧的快照) (包含已提交事务)验证:
sql
-- 事务 A
BEGIN;
SELECT * FROM users WHERE id = 1; -- 看到 age=25
-- 事务 B (在其他会话)
UPDATE users SET age=30 WHERE id=1;
COMMIT;
-- 事务 A 再次查询
SELECT * FROM users WHERE id = 1;
-- RR 下: 还是 age=25(复用 Read View)
-- RC 下: age=30(新 Read View,看到事务 B 的提交)三、图解全景
MVCC 读过程全解
用户查询: SELECT * FROM users WHERE id=1
│
▼
┌─────────────┐
│ 开始读取行 v3 │ ← 当前最新版本
└──────┬──────┘
│
┌──────┴──────┐
│ DB_TRX_ID=150│
└──────┬──────┘
│
┌──────┴──────────────────────────┐
│ 根据 Read View 判定可见性 │
│ │
│ Read View = { │
│ creator_trx_id: 200, │
│ min_trx_id: 200, │
│ max_trx_id: 210, │
│ m_ids: [200, 205, 208] │ ← 活跃事务
│ } │
└──────┬──────────────────────────┘
│
┌──────────┴──────────┐
▼ ▼
DB_TRX_ID=150 < min_trx_id=200?
Yes! ✅ 这个版本我可以看到
│
▼
┌─────────────────┐
│ 返回版本 v3 数据 │
│ name='Tom' │
│ age=30 │
└─────────────────┘
│
▼
┌─────────────────────────────────┐
│ 如果 DB_TRX_ID 不可见: │
│ 沿着 DB_ROLL_PTR → 版本 v2 │
│ → 再次做可见性判定 → 重复 │
│ 直到找到可见版本或全部不可见(NULL) │
└─────────────────────────────────┘四、实战验证
验证 MVCC 效果
sql
-- 创建测试表
CREATE TABLE test_mvcc (
id INT PRIMARY KEY,
val INT
) ENGINE=InnoDB;
INSERT INTO test_mvcc VALUES (1, 10);测试 RR 隔离级别下的 MVCC:
sql
-- 会话 A (事务 1)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT * FROM test_mvcc WHERE id = 1; -- val=10
-- 此时切换到会话 B(事务 2)
UPDATE test_mvcc SET val = 20 WHERE id = 1;
COMMIT;
-- 切回会话 A
SELECT * FROM test_mvcc WHERE id = 1;
-- 预期: val=10(RR 下快照不变)
COMMIT; -- 事务 1 结束
SELECT * FROM test_mvcc WHERE id = 1;
-- 预期: val=20(新事务看到最新数据)测试 RC 隔离级别的差异:
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT * FROM test_mvcc WHERE id = 1; -- val=10
-- 会话 B 更新并提交
-- UPDATE + COMMIT (val=30)
SELECT * FROM test_mvcc WHERE id = 1;
-- 预期: val=30(RC 下读最新已提交版本)监控 MVCC 状态
sql
-- 查看当前活跃事务
SELECT * FROM information_schema.INNODB_TRX\G
-- 查看 Undo Log 使用情况(8.0)
SELECT * FROM information_schema.INNODB_UNDO_LOGS;
-- 查看历史列表长度(太长表示 Purge 线程跟不上)
SHOW ENGINE INNODB STATUS\G
-- 看 HISTORY LIST LENGTH 值五、面试视角
| 追问 | 答案要点 |
|---|---|
| MVCC 解决了什么问题? | 让"读不阻塞写"、"写不阻塞读",通过多版本实现高并发 |
| Read View 的可见性规则? | creator_trx_id 可见;< min_trx_id 可见;>= max_trx_id 不可见;在 m_ids 中不可见;都不在则可见 |
| RR 和 RC 的 MVCC 区别? | RR 整个事务复用同一个 Read View(一致性快照);RC 每条语句创建新的 Read View |
| 快照读和当前读的区别? | 快照读(普通 SELECT)读历史版本不加锁;当前读(SELECT ... FOR UPDATE/UPDATE/DELETE)读最新版本加锁 |
| RR 怎么解决幻读? | 普通 SELECT 用 MVCC 快照读(幻行不可见),SELECT ... FOR UPDATE 用间隙锁阻止插入 |
| MVCC 与 Undo Log 的关系? | Undo Log 存储行的旧版本,MVCC 通过 DB_ROLL_PTR 遍历版本链 |
| Undo Log 什么时候清理? | Purge 线程回收不再被任何事务需要的旧版本 |
📚 相关链接
- 隔离级别:**事务隔离级别** — RC vs RR 的完整对比
- ACID 实现:**事务ACID实现** — 事务保证的完整图景
- Undo Log:**Undo Log 详解**
- 锁机制:**锁机制**
- ← 返回 **SQL与事务索引**