MySQLNotes

第 16 章:MVCC 实现

zjc 于 2026-01-16 发布

这是《MySQL 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 MVCC 是 InnoDB 在读写并发中的核心机制。它让普通读不加行锁,通过 Undo 版本链和 Read View 判断哪个版本对当前事务可见。

16.1 为什么需要 MVCC

锁_ONLY 方案:

写事务持锁期间,读事务必须等待

MVCC 方案:

写事务创建新版本
读事务按快照选择可见版本

收益:

  1. 读不阻塞写;
  2. 写不阻塞一致性读;
  3. 查询能看到稳定快照;
  4. 减少锁等待;
  5. 支持不同隔离级别。

MVCC 不代表没有锁。当前读、唯一性检查、外键检查和部分 DML 仍然需要锁。

16.2 隐藏列

InnoDB 行记录包含与 MVCC 相关的隐藏信息:

隐藏列 含义
DB_TRX_ID 最近修改该行的事务 ID
DB_ROLL_PTR 指向 Undo Log 版本链
DB_ROW_ID 无主键且无合适唯一索引时的行 ID

逻辑示意:

当前行:
  balance=100, trx_id=80, roll_ptr -> undo_record_2

undo_record_2:
  balance=90, trx_id=70, roll_ptr -> undo_record_1

undo_record_1:
  balance=80, trx_id=60, roll_ptr=NULL

更新流程:

1. 写 Redo;
2. 保存旧值到 Undo;
3. 修改当前行;
4. 当前行写新 trx_id;
5. roll_ptr 指向旧版本;

16.3 Undo 版本链

每次修改都会形成旧版本。查询如果看不到最新版本,就沿 roll_ptr 向历史追溯。

示例:

CREATE TABLE mvcc_accounts (
    id INT PRIMARY KEY,
    balance DECIMAL(12, 2) NOT NULL
) ENGINE=InnoDB;

INSERT INTO mvcc_accounts VALUES (1, 100);
UPDATE mvcc_accounts SET balance = 200 WHERE id = 1;
UPDATE mvcc_accounts SET balance = 300 WHERE id = 1;

逻辑版本:

最新:300
历史:200 -> 100 -> 插入初始版本

MySQL 不提供直接查看隐藏列的普通 SQL。理解版本链主要通过行为实验和 Undo 日志指标。

16.4 Read View

Read View 是事务判断版本可见性的快照。关键信息包括:

名称 含义
creator trx_id 创建该 Read View 的事务
active list 创建瞬间仍活跃的事务 ID
min limit 活跃列表最小事务 ID
next limit 创建瞬间下一个将要分配的事务 ID

简化判断规则:

如果 DB_TRX_ID == creator:
    可见,自己修改的数据

如果 DB_TRX_ID < min limit:
    可见,创建快照前已提交

如果 DB_TRX_ID >= next limit:
    不可见,快照创建后才开始

如果 DB_TRX_ID 在活跃区间:
    已不在活跃列表且小于 next limit:可见
    仍在活跃列表或未提交:不可见

不可见时,沿 Undo 版本链继续向前找,直到找到可见版本或无版本。

16.5 RC 与 RR 的差别

READ COMMITTED

每条一致性读创建新的 Read View:

SELECT 1 -> Read View A
SELECT 2 -> Read View B

其他事务提交后,后续 SELECT 可以看到新数据。

REPEATABLE READ

事务中第一次一致性读创建 Read View,后续普通 SELECT 复用:

SELECT 1 -> 创建 Read View
SELECT 2 -> 复用
SELECT 3 -> 复用

因此同一行普通 SELECT 结果可重复。

实验:

-- Session A,RR
START TRANSACTION;
SELECT balance FROM mvcc_accounts WHERE id = 1;
-- Session B
UPDATE mvcc_accounts SET balance = 999 WHERE id = 1;
-- Session A 再读
SELECT balance FROM mvcc_accounts WHERE id = 1;
-- RR:仍看到旧值
-- RC:看到 999

16.6 快照读与当前读并存

RR 下:

START TRANSACTION;

SELECT balance FROM mvcc_accounts WHERE id = 1;
-- 快照:100

SELECT balance FROM mvcc_accounts WHERE id = 1 FOR UPDATE;
-- 当前读:读取最新提交值并加锁

COMMIT;

这个设计能同时满足:

  1. 普通查询稳定;
  2. 修改基于最新数据;
  3. 关键路径防止丢失更新。

也解释了“先普通 SELECT 再 UPDATE”可能不符合直觉:SELECT 看到旧快照,UPDATE 是当前读。

16.7 MVCC 与幻读

RR 的普通 SELECT 使用一致快照,多次读取不会看到新提交的行,因此不会出现快照意义上的幻影。

但混合当前读时可能出现现象:

-- Session A
START TRANSACTION;
SELECT COUNT(*) FROM mvcc_accounts WHERE id > 0;
-- Session B
INSERT INTO mvcc_accounts VALUES (2, 500);
COMMIT;
-- Session A
SELECT COUNT(*) FROM mvcc_accounts WHERE id > 0; -- 仍是旧数量
UPDATE mvcc_accounts SET balance = balance + 1 WHERE id > 0; -- 当前读包含新行
SELECT COUNT(*) FROM mvcc_accounts WHERE id > 0; -- 可能看到被自己更新后的新行
COMMIT;

这不是简单的“MVCC 失效”,而是快照读和当前读规则不同。需要防止额外行被并发插入时,应使用锁定读或依赖唯一约束。

16.8 长事务与 Undo 膨胀

旧版本不能在仍有 Read View 需要时清理。

长事务风险:

  1. Undo Log 持续增长;
  2. Undo 表空间膨胀;
  3. 历史版本扫描变慢;
  4. 查询可能沿长版本链回溯;
  5. Purge 延迟;
  6. 主从延迟加剧;
  7. 回滚成本极高。

查看历史列表长度:

SHOW ENGINE INNODB STATUS\G

关注:

History list length
PURGE
TRANSACTIONS

MySQL 8.0 查看 undo:

SELECT
  space,
  name,
  file_type
FROM information_schema.INNODB_TABLESPACES
WHERE name LIKE '%undo%';

治理:

  1. 缩短事务;
  2. 外部调用移出事务;
  3. 大任务分批提交;
  4. 监控事务年龄;
  5. 避免连接池悬挂事务;
  6. 定期审计慢事务。

16.9 Purge 机制

Purge 负责清理不再需要的 Undo 版本:

1. 找到最老 Read View;
2. 判断哪些旧版本无人可见;
3. 清理 delete-mark 记录;
4. 回收 Undo 空间;

相关变量:

SHOW VARIABLES LIKE 'innodb_purge_threads';
SHOW VARIABLES LIKE 'innodb_purge_batch_size';
SHOW VARIABLES LIKE 'innodb_max_purge_lag';

不要在没有瓶颈证据时随意调整。多数 Purge 延迟来自长事务和写入模式,而不是线程数。

16.10 MVCC 性能影响

MVCC 不是免费:

  1. 每次修改维护 Undo;
  2. 长版本链降低读性能;
  3. 大量更新产生更多历史版本;
  4. 删除只是标记,清理有延迟;
  5. 空间回收有滞后;
  6. 快照读可能扫描多个版本。

优化建议:

  1. 短事务;
  2. 避免热点行反复更新;
  3. 计数类热点数据移到 Redis;
  4. 状态变化合并写入;
  5. 大量 UPDATE 分批;
  6. 历史数据归档;
  7. 监控 History list。

本章小结

MVCC 通过隐藏事务 ID、回滚指针、Undo 版本链和 Read View 实现一致性读。RC 每次读创建新快照,RR 复用第一次快照。普通 SELECT 是快照读,UPDATE 和锁定读是当前读。长事务会阻止旧版本清理,导致 Undo 膨胀和 Purge 延迟。

思考题

  1. DB_TRX_IDDB_ROLL_PTR 分别有什么作用?
  2. RC 和 RR 的可见性判断有什么区别?
  3. 为什么 RR 下普通 SELECT 结果稳定,UPDATE 却能影响新行?
  4. 长事务为什么会导致 History list 增长?
  5. 如何把一个包含外部调用的大事务拆成安全短事务?