MVCC 原理深度解析
面试官问:"MVCC 是什么?MySQL 怎么实现快照读的?"
小刘说:"MVCC 是多版本并发控制,用来解决并发读写问题。"
面试官追问:"那 MySQL InnoDB 是怎么实现的?"
小刘说:"...用 undo log?"
面试官继续追问:"undo log 和 ReadView 是什么关系?ReadView 包含什么内容?"
小刘想了很久,勉强说:"ReadView 记录了当前所有活跃的事务 ID?"
面试官点点头:"那可见性是怎么判断的?"
小刘彻底卡住了。
MVCC 是 MySQL InnoDB 实现高并发读写的核心技术,也是面试中的高频深水区题。这道题能答清楚的候选人,对 InnoDB 的理解已经超过了 90% 的面试者。
一、MVCC 是什么 🔴
1.1 并发控制的两大流派
MySQL InnoDB 使用两套并发控制机制:
- 悲观锁(LBCC,Lock-Based Concurrency Control):读写冲突时,加锁阻塞。问题是并发度低。
- 乐观并发控制(MVCC,Multi-Version Concurrency Control):每个事务看到的是数据的历史版本,不阻塞读写。问题是维护多个版本有开销。
MVCC 的核心思想:读写不冲突。读操作不加锁,写操作也不阻塞读操作。
1.2 MVCC 解决的场景
-- 场景:财务报表查询
-- 用户 A:实时更新订单数据
-- 用户 B:同时查询财务报表
-- 没有 MVCC:
-- 用户 A 更新订单时加锁,用户 B 查询被阻塞
-- 有 MVCC:
-- 用户 A 更新订单,生成新版本
-- 用户 B 看到的是旧版本(快照),不被阻塞
-- 两者互不干扰
1.3 MVCC 只作用于快照读
-- 快照读(Snapshot Read):MVCC
SELECT * FROM orders; -- 不加锁,读历史版本
-- 当前读(Current Read):锁机制
SELECT * FROM orders FOR UPDATE; -- 加锁,读最新版本
INSERT INTO orders ...; -- 加锁
UPDATE orders ...; -- 加锁
重要结论:MVCC 只解决快照读的并发问题。当前读需要加锁保证正确性。
1.4 ❌ 错误示范
候选人原话:"MVCC 就是给每行数据加一个版本号。"
问题诊断:描述过于简化。MVCC 是一套完整的机制,包括隐藏列、undo log 链、ReadView 和可见性判断算法,不是简单加个版本号。
候选人原话 2:"MVCC 可以完全替代锁。"
问题诊断:错误。MVCC 只解决读读和读写并发。写写并发仍然需要锁。当前读也需要锁。
【面试官心理】
这道题我能问到非常深的层次。我会问:"ReadView 的生成时机在 RC 和 RR 下有什么区别?可见性判断的四个条件分别是什么?"能完整答出来的候选人不到 5%。
二、隐藏列与 undo log 链 🔴
2.1 InnoDB 行的隐藏列
InnoDB 的每行数据都有两个隐藏列:
┌──────────────────────────────────────────────────────┐
│ 用户表数据行 │
├──────────────────────────────────────────────────────┤
│ id: 1 │ name: Tom │ balance: 1000 │ (隐藏列) │
│ 真实数据 │ trx_id: 50 │
│ │ roll_pointer: → │
└──────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ undo log 记录(balance 从 800 改成 1000) │
├──────────────────────────────────────────────────────┤
│ old_value: balance=800 │
│ next_undo: → │
└──────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ 更早的 undo log(INSERT 记录) │
└──────────────────────────────────────────────────────┘
2.2 undo log 链的构建
-- T1: INSERT INTO users (id, name) VALUES (1, 'Tom');
-- undo log: 记录了 INSERT 的反向操作(DELETE)
-- T2: UPDATE users SET balance = 800 WHERE id = 1;
-- undo log: 记录了旧值 800 -> 新值 1000
-- T3: UPDATE users SET balance = 1000 WHERE id = 1;
-- undo log: 记录了旧值 1000 -> 新值 1200
-- roll_pointer 指向这条 undo log
-- 最终数据结构:
-- 当前行: balance = 1200, roll_pointer → T3 undo log (旧值 1000)
-- T3 undo log → T2 undo log (旧值 800)
-- T2 undo log → T1 undo log (INSERT 记录)
-- T1 undo log → null (链尾)
2.3 版本链的遍历
-- 事务 T4 想读取 id=1 的 balance
-- 步骤:
-- 1. 看当前行 balance=1200,trx_id=100(T3 事务)
-- 2. 判断可见性(见下一节)
-- 3. 如果不可见,沿 roll_pointer 找到 T3 undo log
-- 4. 在 T3 undo log 中找到旧值 balance=1000
-- 5. 再判断 trx_id=50(T2 事务)的可见性
-- 6. 重复,直到找到可见版本或链尾
三、ReadView 机制 🔴
3.1 ReadView 的构成
ReadView(读视图)是事务在执行快照读时生成的快照,记录了当前所有"不可见"的事务 ID。
-- ReadView 结构
struct ReadView {
m_ids; // 当前活跃(未提交)事务 ID 列表
min_trx_id; // 活跃事务中的最小 ID
max_trx_id; // 创建 ReadView 时的最大事务 ID + 1
creator_trx_id; // 当前事务自己的 ID
}
3.2 可见性判断算法
-- 判断一行数据对当前事务是否可见的四个条件:
IF (row.trx_id == creator_trx_id) {
-- 条件1:这行是自己修改的,可以看见
return VISIBLE;
}
IF (row.trx_id < min_trx_id) {
-- 条件2:这行是在 ReadView 生成前提交的,可以看见
return VISIBLE;
}
IF (row.trx_id >= max_trx_id) {
-- 条件3:这行是在 ReadView 生成后开启的,不可见
return INVISIBLE;
}
IF (row.trx_id IN m_ids) {
-- 条件4:这行是由活跃事务修改的,不可见
-- 需要沿 roll_pointer 找更早的版本
return INVISIBLE;
}
3.3 可见性判断图解
时间线: T10 T20 T30 T40 T50 T60
| | | | | |
事务: T1 ───┼─────┼─────┼─────┼─────┼──
│
ReadView 创建时刻(T1 快照读时)
│
├── min_trx_id = 10
├── max_trx_id = 55
└── m_ids = {T30} (T30 未提交)
可见性判断:
- trx_id=5 (T10 前提交的) → 可见 ✅
- trx_id=15 (T20 提交的) → 可见 ✅
- trx_id=35 (T30 修改的) → 不可见 ❌ (在 m_ids 中)
- trx_id=45 (T40 修改的) → 不可见 ❌ (>= max_trx_id)
四、RC 和 RR 的区别 🟡
4.1 ReadView 生成时机不同
4.2 实际案例对比
-- 初始数据:balance = 1000
-- T1: BEGIN(可重复读/读已提交)
-- T2: BEGIN; UPDATE users SET balance = 500; COMMIT;
-- T1: SELECT balance FROM users WHERE id = 1;
-- RC(读已提交):
-- 第二次 SELECT 时生成新 ReadView
-- T2 已提交,不在 m_ids 中
-- 能看到 T2 的修改,balance = 500
-- RR(可重复读):
-- 第二次 SELECT 复用事务开始时的 ReadView
-- ReadView 中记录了 T2(如果 T2 在 ReadView 创建时还未提交)
-- 看不到 T2 的修改,balance = 1000
五、生产避坑 🟡
5.1 MVCC 不能解决写写冲突
-- T1: UPDATE users SET balance = 500 WHERE id = 1;
-- T2: UPDATE users SET balance = 800 WHERE id = 1;
-- T2 会被 T1 的行锁阻塞,直到 T1 提交或回滚
MVCC 解决的是读-写和写-读的并发问题。写-写并发仍然需要锁机制。
5.2 长事务导致 undo log 堆积
-- T1: BEGIN;
-- T1: SELECT * FROM users; -- 生成 ReadView
-- ... T2~T100 大量事务修改 users 表 ...
-- T1: ... 长时间不提交 ...
-- 问题:undo log 链越来越长,MVCC 遍历成本增加
⚠️
MVCC 的代价是维护多版本数据。长事务会导致 undo log 不断累积,不仅占用磁盘空间,还会拖慢 MVCC 的遍历效率。线上应避免长事务。
【面试官心理】
MVCC 是 MySQL 面试中的深水区。这道题我能从多个角度追问:ReadView 结构、可见性判断四条件、RC vs RR 的区别、undo log GC 机制。能答出 80% 以上的候选人,说明他对 InnoDB 并发控制有源码级别的理解。