MySQL MVCC 全面指南

124 阅读8分钟

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_idroll_pointerDB_ROW_ID)。
  • RC 与 RR 区别:RC 每条语句生成一个新的 Read ViewRR 在事务开始时生成一个 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,用于判断“某个版本是否对当前事务可见”。

判断规则(直观版):

  1. 若版本的 trx_id 小于 Read View 的最小活跃事务 ID,则 可见(该修改早已提交)。
  2. trx_id 大于等于 Read View 的最大分配事务 ID,则 不可见(未来版本)。
  3. 否则:若 trx_id 不在活跃事务集合中,说明该事务已提交,可见;若在集合中,不可见,需沿版本链继续回溯。

3. 两种读:一致性读 vs 当前读

3.1 一致性读(Consistent Read)

  • 普通 SELECT(无锁)默认是一致性读,读取的是 快照版本
  • 使用 Undo Log 回溯到 Read View 可见的版本,不加锁

3.2 当前读(Current Read)

  • SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETE 等需要读取最新版本并对记录加锁,避免并发写冲突与幻读,属于当前读
  • 会触发 记录锁(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 与查询退化。

实务建议:

  1. 保持事务简短(开启就尽快提交);避免在事务中进行交互/人工等待。

  2. 打开 autocommit=1(默认),非必要不要显式长事务。

  3. 监控:

    • information_schema.INNODB_TRX(或 sys.schema_trx)查看活跃/最长事务;
    • performance_schema / sys 库下相关视图;
    • 关注 SHOW ENGINE INNODB STATUSHistory list length
  4. 定期审查慢 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_TRXHistory 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 坑与解决思路!