这是《MySQL 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 MVCC 是 InnoDB 在读写并发中的核心机制。它让普通读不加行锁,通过 Undo 版本链和 Read View 判断哪个版本对当前事务可见。
16.1 为什么需要 MVCC
锁_ONLY 方案:
写事务持锁期间,读事务必须等待
MVCC 方案:
写事务创建新版本
读事务按快照选择可见版本
收益:
- 读不阻塞写;
- 写不阻塞一致性读;
- 查询能看到稳定快照;
- 减少锁等待;
- 支持不同隔离级别。
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;
这个设计能同时满足:
- 普通查询稳定;
- 修改基于最新数据;
- 关键路径防止丢失更新。
也解释了“先普通 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 需要时清理。
长事务风险:
- Undo Log 持续增长;
- Undo 表空间膨胀;
- 历史版本扫描变慢;
- 查询可能沿长版本链回溯;
- Purge 延迟;
- 主从延迟加剧;
- 回滚成本极高。
查看历史列表长度:
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%';
治理:
- 缩短事务;
- 外部调用移出事务;
- 大任务分批提交;
- 监控事务年龄;
- 避免连接池悬挂事务;
- 定期审计慢事务。
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 不是免费:
- 每次修改维护 Undo;
- 长版本链降低读性能;
- 大量更新产生更多历史版本;
- 删除只是标记,清理有延迟;
- 空间回收有滞后;
- 快照读可能扫描多个版本。
优化建议:
- 短事务;
- 避免热点行反复更新;
- 计数类热点数据移到 Redis;
- 状态变化合并写入;
- 大量 UPDATE 分批;
- 历史数据归档;
- 监控 History list。
本章小结
MVCC 通过隐藏事务 ID、回滚指针、Undo 版本链和 Read View 实现一致性读。RC 每次读创建新快照,RR 复用第一次快照。普通 SELECT 是快照读,UPDATE 和锁定读是当前读。长事务会阻止旧版本清理,导致 Undo 膨胀和 Purge 延迟。
思考题
DB_TRX_ID和DB_ROLL_PTR分别有什么作用?- RC 和 RR 的可见性判断有什么区别?
- 为什么 RR 下普通 SELECT 结果稳定,UPDATE 却能影响新行?
- 长事务为什么会导致 History list 增长?
- 如何把一个包含外部调用的大事务拆成安全短事务?