MVCC 原理深度解析

面试官问:"MVCC 是什么?MySQL 怎么实现快照读的?"

小刘说:"MVCC 是多版本并发控制,用来解决并发读写问题。"

面试官追问:"那 MySQL InnoDB 是怎么实现的?"

小刘说:"...用 undo log?"

面试官继续追问:"undo log 和 ReadView 是什么关系?ReadView 包含什么内容?"

小刘想了很久,勉强说:"ReadView 记录了当前所有活跃的事务 ID?"

面试官点点头:"那可见性是怎么判断的?"

小刘彻底卡住了。

MVCC 是 MySQL InnoDB 实现高并发读写的核心技术,也是面试中的高频深水区题。这道题能答清楚的候选人,对 InnoDB 的理解已经超过了 90% 的面试者。

一、MVCC 是什么 🔴

1.1 并发控制的两大流派

MySQL InnoDB 使用两套并发控制机制:

  1. 悲观锁(LBCC,Lock-Based Concurrency Control):读写冲突时,加锁阻塞。问题是并发度低。
  2. 乐观并发控制(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 的每行数据都有两个隐藏列:

隐藏列大小作用
trx_id6 字节最近修改这行的事务 ID
roll_pointer7 字节指向 undo log 记录的指针
┌──────────────────────────────────────────────────────┐
│ 用户表数据行                                            │
├──────────────────────────────────────────────────────┤
│ 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 生成时机不同

隔离级别ReadView 生成时机效果
读已提交(RC)每次快照读都生成新 ReadView能看到其他事务已提交的修改
可重复读(RR)事务开始时生成,之后复用事务内始终读同一版本

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 并发控制有源码级别的理解。


级别考察重点期望回答
P5概念理解MVCC 是多版本并发控制,解决读写不冲突
P6机制理解隐藏列、undo log 链、ReadView 结构和可见性判断
P7深度细节RC vs RR 的 ReadView 差异、undo log GC、MVCC 的代价