Skip to content

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_ID6 字节最近修改该行的事务 ID(递增)
DB_ROLL_PTR7 字节回滚指针,指向 Undo Log 中的上一个版本
DB_ROW_ID6 字节隐式自增 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与事务索引**

Knowledge4J — Java 知识库