MySQL MVCC 全面指南
适用版本:MySQL 5.7/8.0(InnoDB 存储引擎)
读者定位:后端工程师、DBA、面试/写作准备
TL;DR
- MVCC(Multi-Version Concurrency Control,多版本并发控制)让 读不加锁 成为可能:大多数
SELECT在 RC(Read Committed)与 RR(Repeatable Read)下都能无锁读取快照。 - 核心构件:Undo Log(版本链) 、Read View(可见性规则) 、隐藏列(
trx_id、roll_pointer、DB_ROW_ID)。 - RC 与 RR 区别:RC 每条语句生成一个新的 Read View;RR 在事务开始时生成一个 Read View 并复用,从而保证可重复读。
SELECT ... FOR UPDATE/LOCK IN SHARE MODE属于 当前读(Current Read),需要加锁,读取最新版本,不走快照。- 长事务会拖垮 Purge,导致 Undo 膨胀、历史版本链过长,带来空间与性能问题。 “短事务 + 及时提交” 是黄金法则。
1. 为什么需要 MVCC?
在并发场景下,传统“加锁读”会放大锁竞争、降低吞吐。MVCC 用版本链 + 可见性判断,让读者看到自己“该看到”的历史快照,减少锁冲突,提高读性能。
典型目标:
- 读写并发:读者无锁读取旧版本;写者创建新版本(不阻塞读)。
- 隔离性保障:在不同隔离级别下避免脏读、不可重复读、幻读等问题。
2. InnoDB 中 MVCC 的三大核心
2.1 隐藏列(Row Header)
trx_id:最后一次修改该行记录的事务 ID。roll_pointer:指向 Undo Log 的指针,通过它可以追溯历史版本(形成 版本链)。DB_ROW_ID:隐藏自增主键(当表没有主键时存在)。
这三者不会出现在用户表结构中,但在 InnoDB 的页结构里真实存在。
2.2 Undo Log(回滚日志/历史版本)
每次 UPDATE/DELETE 都会写 Undo Log,保存被修改前的旧值,使得:
- 发生回滚时,能够恢复到修改前;
- 读快照时,能沿
roll_pointer回溯到对当前事务可见的历史版本。
Purge 线程负责清理对所有活跃事务都不可见的历史版本,释放空间。
2.3 Read View(可见性判断)
Read View 记录当时活跃事务的 ID 集合,与最小/最大已分配事务 ID,用于判断“某个版本是否对当前事务可见”。
判断规则(直观版):
- 若版本的
trx_id小于 Read View 的最小活跃事务 ID,则 可见(该修改早已提交)。 - 若
trx_id大于等于 Read View 的最大分配事务 ID,则 不可见(未来版本)。 - 否则:若
trx_id不在活跃事务集合中,说明该事务已提交,可见;若在集合中,不可见,需沿版本链继续回溯。
3. 两种读:一致性读 vs 当前读
3.1 一致性读(Consistent Read)
- 普通
SELECT(无锁)默认是一致性读,读取的是 快照版本。 - 使用 Undo Log 回溯到 Read View 可见的版本,不加锁。
3.2 当前读(Current Read)
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE等需要读取最新版本并对记录加锁,避免并发写冲突与幻读,属于当前读。- 会触发 记录锁(Record Lock) 、间隙锁(Gap Lock) 、Next-Key Lock 等(在 RR 下尤其重要)。
4. 隔离级别与 MVCC 的关系
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | Read View 创建时机 | 备注 |
|---|---|---|---|---|---|
| Read Uncommitted | 可能 | 可能 | 可能 | 基本不构建 | 几乎不用于生产 |
| Read Committed | 否 | 可能 | 可能 | 每条语句 | Oracle 默认为 RC |
| Repeatable Read(InnoDB 默认) | 否 | 否 | 通过 Next-Key Lock 避免 | 事务开始 | MySQL 特性:RR + Next-Key 锁避免幻读 |
| Serializable | 否 | 否 | 否 | 强制加锁 | 吞吐最低 |
注:在 MySQL RR 下,一致性读可避免“幻读现象”(快照里看不到新行),但当前读(需要加锁)仍需借助 Next-Key Lock 防止并发插入造成幻影。
5. RC 与 RR 的核心区别(面试高频)
- RC:每次
SELECT都创建新的 Read View,看见最新已提交的数据。因此同一事务内两次查询可能返回不同结果(不可重复读)。 - RR:事务开始时创建 Read View,并在整个事务期间复用,保证可重复读。同一事务内相同查询结果一致(除非用当前读显式加锁读取最新)。
6. 一个可运行的演示场景
假设表:
CREATE TABLE t_demo (
id INT PRIMARY KEY,
v INT
) ENGINE=InnoDB;
INSERT INTO t_demo VALUES (1, 100);
事务 A(RR 隔离级别)
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT v FROM t_demo WHERE id=1; -- 读到 100,创建 Read View
-- 不提交,保持事务开启
事务 B 更新并提交
-- 会话 B
UPDATE t_demo SET v=200 WHERE id=1;
COMMIT;
事务 A 再次读取
-- 会话 A
SELECT v FROM t_demo WHERE id=1; -- 仍读到 100(快照一致性读)
SELECT v FROM t_demo WHERE id=1 FOR UPDATE; -- 读到 200(当前读,最新版本并加锁)
COMMIT;
结论:RR 下,普通 SELECT 读到旧版本;加锁的当前读读到最新版本。
若将 A 的隔离级别改为 RC,则第二次普通 SELECT 就能读到 200(因为语句级 Read View)。
7. 幻读与 Next-Key Lock(RR 语义)
幻读:同一事务的两次范围查询,第二次多出“新插入的行”。
在 RR 下:
- 一致性读的快照本身看不到新行(“读到的世界”静止)。
- 但涉及更新/删除/锁查询的当前读需阻止并发插入,InnoDB 使用 Next-Key Lock(记录锁 + 间隙锁)锁住范围,避免“幻影”。
示例:
-- 会话 A
START TRANSACTION;
SELECT * FROM t_demo WHERE id BETWEEN 10 AND 20 FOR UPDATE; -- 锁住 (10,20] 范围
-- 会话 B
INSERT INTO t_demo VALUES (15, 1); -- 被阻塞,直到 A 提交或回滚
8. Purge、长事务与性能/空间问题
- Purge:清理 Undo 中“对所有活跃事务都不可见”的历史版本,释放空间。
- 长事务:若一个事务长时间不提交,它的 Read View 会让很多旧版本一直“可见”,导致 Purge 无法清理,Undo 膨胀、
history list length激增、I/O 与查询退化。
实务建议:
-
保持事务简短(开启就尽快提交);避免在事务中进行交互/人工等待。
-
打开
autocommit=1(默认),非必要不要显式长事务。 -
监控:
information_schema.INNODB_TRX(或sys.schema_trx)查看活跃/最长事务;performance_schema/sys库下相关视图;- 关注
SHOW ENGINE INNODB STATUS中History list length。
-
定期审查慢 SQL 与批处理任务,避免批量大事务(可拆分批次提交)。
9. 典型问题与排查思路
9.1 读到“旧值”是 Bug 吗?
-
在 RR 下,普通
SELECT读快照是预期行为。若你需要最新值,请使用:- 当前读:
SELECT ... FOR UPDATE/LOCK IN SHARE MODE; - 或提升到 RC,并接受语义变化(可能出现不可重复读)。
- 当前读:
9.2 为什么插入被阻塞?
- 你可能持有
Next-Key Lock(范围锁)。检查是否有FOR UPDATE/UPDATE的范围条件,确认是否命中索引(否则会锁全表)。
9.3 Purge 不动、磁盘暴涨
- 排查长事务:
INNODB_TRX找出trx_started最早的事务,定位业务代码; - 排查是否有未提交的只读事务/游标;
- 按需考虑
innodb_undo_tablespaces、Undo 表空间清理策略(8.0 已改进)。
9.4 RC/RR 与二级索引回表
- 一致性读在二级索引范围扫描时仍可借助 Undo 回溯到可见版本;当前读则需要合适的索引避免大范围 Next-Key 锁。
10. 与日志/复制的交互(简述)
-
Redo Log:物理日志,保障崩溃恢复(持久化)。
-
Undo Log:逻辑“反向”日志,保障回滚与 MVCC 快照。
-
Binlog:主从复制/审计。
- ROW 格式能精确记录行变化;
- 事务提交采用“两阶段提交(2PC)”协调 Redo 与 Binlog 一致性;
- 长事务也会导致从库延迟与空间占用。
11. 最佳实践清单(可直接贴到文末)
- ✅ 绝大多数读用普通
SELECT(一致性读),避免滥用FOR UPDATE。 - ✅ 必须读到最新且需要保护修改时,使用当前读(并保证命中索引,减少锁范围)。
- ✅ 事务要短,避免在事务里等待 I/O、RPC、人工输入。
- ✅ 将批处理拆分小批次分段提交。
- ✅ 监控
INNODB_TRX、History list length、Undo 表空间大小与长事务。 - ✅ 对热点行采用“更新合并/幂等重试/版本号”策略,减少写写冲突。
- ✅ 谨慎切换隔离级别:RC 更实时,RR 更稳定(MySQL 默认)。
12. 实操 SQL 片段(可复制)
12.1 会话级设置隔离级别
-- 只对当前会话生效
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- 或 READ COMMITTED
START TRANSACTION; -- 之后首次 SELECT 创建/绑定 Read View
12.2 观察活跃事务与阻塞
SELECT * FROM information_schema.INNODB_TRX\G
SELECT * FROM information_schema.INNODB_LOCKS; -- 8.0 起多用 performance_schema/sys
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
12.3 典型问题定位
-- 查最长运行事务
SELECT trx_id, trx_started, trx_state, trx_rows_locked, trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started
LIMIT 5;
13. 常见误区速查
- 误区:RR 就不会有幻读。➡ 更精确说法:一致性读不会看到新插入的行;但需要锁的当前读仍需依赖 Next-Key 锁来防止幻影。
- 误区:FOR UPDATE 总能减少锁。➡ 实际上它增加了锁(当前读),用于保护写一致性,滥用会放大冲突。
- 误区:MVCC 能避免所有锁等待。➡ 写写冲突依然需要锁;热点更新仍会产生竞争。
- 误区:RC 一定优于 RR。➡ 取舍问题:RC 更“新鲜”,RR 一致性更强,也更贴近 MySQL 社区默认语义。
14. 配图(思维导图/示意,Markdown 版)
[客户端 SELECT]
|
v (一致性读)
[Read View] --(trx 可见性规则)--> [沿 roll_pointer 回溯 Undo]
| |
| v
+-- 可见 --> 返回历史版本 <--------- [版本链]
[客户端 UPDATE/DELETE]
|
v (当前读 + 锁)
[最新版本写入] --> [生成 Undo] --> [版本链头]
|
v
[Purge 清理不可见历史]
15. 结语
MVCC 是 InnoDB 并发控制的“灵魂”。理解 版本链 + Read View + 当前读/一致性读,并把握 RC/RR 的取舍,配合短事务与监控,才能写出既稳又快的数据库业务。
欢迎在评论区补充你在生产环境里踩过的 MVCC 坑与解决思路!