深入理解 MySQL InnoDB MVCC(多版本并发控制)
适用版本:MySQL 5.7 / 8.0
标签:MySQL、InnoDB、MVCC、事务隔离、ReadView、版本链、快照读
一、为什么需要 MVCC?
1.1 并发读写的冲突问题
数据库面临的最基本矛盾是:多个事务同时读写同一份数据时,如何保证数据的正确性?
最朴素的解法是加锁:写操作加写锁,读操作加读锁,读写之间互相阻塞。这在并发量低时没问题,但在高并发场景下,读操作(往往是大多数)会频繁等待写锁释放,吞吐量极差。
InnoDB 的解法是 MVCC(Multi-Version Concurrency Control,多版本并发控制):
写操作不阻塞读操作,读操作也不阻塞写操作。读操作读取数据在某个时间点的"历史快照",而不是最新版本,从而实现读写并发。
这就是为什么 MySQL 的 SELECT 默认不加锁,却依然能在并发写入时读到一致的数据。
1.2 MVCC 解决了哪些问题?
MVCC 是实现以下两种事务隔离级别的核心机制:
- READ COMMITTED(读已提交):每次读取都能看到已提交事务的最新数据,不同时间点的读可能看到不同的值。
- REPEATABLE READ(可重复读):在同一个事务中,多次读取同一行数据的结果始终相同,不受其他事务提交的影响。这是 InnoDB 的默认隔离级别。
MVCC 解决了两类经典问题:
| 问题 | 说明 | MVCC 是否解决 |
|---|---|---|
| 脏读(Dirty Read) | 读到其他事务未提交的数据 | ✓ 解决 |
| 不可重复读(Non-Repeatable Read) | 同一事务两次读同一行结果不同 | ✓ 在 RR 级别解决 |
| 幻读(Phantom Read) | 同一事务两次查询返回的行数不同 | △ 快照读解决,当前读需要 Gap Lock |
1.3 MVCC 的核心思想
不直接修改或删除数据,而是保留数据的历史版本。每次修改产生一个新版本,旧版本通过链表保留。读操作根据自身事务的"时间戳"决定应该看到哪个版本,不同事务可以同时看到数据在不同时刻的状态,互不干扰。
二、MVCC 的三个核心组成部分
MVCC 的实现依赖三个关键机制的协同工作:隐藏列、版本链、ReadView。理解这三者是理解 MVCC 的全部。
2.1 隐藏列
InnoDB 的每行记录(聚簇索引)都隐含两个对用户不可见的系统字段:
trx_id(事务 ID)
记录最后一次修改这行数据的事务 ID。每个事务在启动时都会从全局单调递增的计数器获取一个唯一的事务 ID,数值越大表示事务越新。
roll_pointer(回滚指针)
指向该行上一个版本的 Undo Log 记录。通过这个指针,可以沿着版本链向前追溯,找到该行任意历史时刻的值。
还有一个隐藏字段 row_id,在没有显式主键时作为隐式主键使用,与 MVCC 关系不大,本文不展开。
行记录结构(简化):
┌──────────┬──────────────────────────────┬──────────────┬────────────────┐
│ row_id │ 业务字段(id, name...) │ trx_id │ roll_pointer │
│ (隐藏) │ │ (最后修改者) │ (→上一版本) │
└──────────┴──────────────────────────────┴──────────────┴────────────────┘
2.2 版本链
每次对一行记录执行 UPDATE 或 DELETE 时,InnoDB 不会原地覆盖旧值,而是:
- 将旧版本的完整内容写入 Undo Log
- 新版本的
roll_pointer指向这条 Undo Log
多次修改之后,这些 Undo Log 记录通过 roll_pointer 首尾相连,形成一条版本链(Version Chain),链头是最新版本,链尾是最早版本:
当前行(最新版本)
trx_id = 300, amount = 800
roll_pointer ──────────────────────────────────┐
↓
Undo Log(版本 2)
trx_id = 200, amount = 500
roll_pointer ─────────────┐
↓
Undo Log(版本 1)
trx_id = 100, amount = 200
roll_pointer = NULL(初始版本)
版本链的本质:同一行数据在不同事务修改后的完整历史记录。
2.3 ReadView(读视图)
版本链记录了数据的所有历史,但读操作应该读哪个版本?这由 ReadView 来决定。
ReadView 是在事务执行快照读(普通 SELECT)时生成的一份"活跃事务快照",它记录了在这一时刻,哪些事务正在进行、哪些已经提交、哪些还没开始。
ReadView 包含以下四个关键字段:
| 字段 | 说明 |
|---|---|
m_ids |
生成 ReadView 时,系统中所有活跃事务(已启动但未提交)的事务 ID 列表 |
min_trx_id |
m_ids 中最小的事务 ID,即最老的活跃事务 |
max_trx_id |
生成 ReadView 时,系统应该分配给下一个新事务的 ID(当前已分配的最大 ID + 1) |
creator_trx_id |
创建这个 ReadView 的事务自身的 ID |
有了 ReadView,就可以定义版本可见性规则 :对于版本链上的某个版本,其 trx_id 为 X,判断当前事务是否可以看到这个版本:
规则一:X == creator_trx_id
→ 该版本由当前事务自己修改,可见。
规则二:X < min_trx_id
→ 该版本由一个在 ReadView 生成之前就已提交的事务修改,可见。
规则三:X >= max_trx_id
→ 该版本由一个在 ReadView 生成之后才启动的事务修改,不可见。
规则四:min_trx_id ≤ X < max_trx_id
→ 如果 X 在 m_ids 中:该事务在 ReadView 生成时仍活跃(未提交),不可见。
→ 如果 X 不在 m_ids 中:该事务在 ReadView 生成之前已经提交,可见。
判断逻辑(流程化表述):
对版本链从链头(最新)向链尾(最旧)逐个遍历:
找到第一个对当前事务"可见"的版本,返回该版本的数据。
如果遍历到链尾仍未找到,说明该行对当前事务完全不可见。
三、READ COMMITTED 与 REPEATABLE READ 的区别
MVCC 在这两个隔离级别下的行为差异,完全来自于 ReadView 的生成时机不同:
| 隔离级别 | ReadView 生成时机 |
|---|---|
| READ COMMITTED | 每次执行快照读(SELECT)时,重新生成一个新的 ReadView |
| REPEATABLE READ | 在事务中第一次执行快照读时生成 ReadView,之后整个事务复用同一个 ReadView |
这一个时机的区别,导致了完全不同的读取行为:
- RC:每次 SELECT 都拿最新的活跃事务快照,能看到在此之前已提交的所有修改,所以两次查询可能读到不同的值(不可重复读)。
- RR:整个事务复用同一份快照,看到的始终是事务开始时的数据状态,其他事务之后的提交对本事务不可见(可重复读)。
四、快照读与当前读
在深入举例之前,需要区分两种读取方式,因为 MVCC 只作用于其中一种。
4.1 快照读(Snapshot Read)
普通的 SELECT 语句就是快照读。它通过 ReadView + 版本链读取历史版本,不加任何锁,不阻塞其他事务的写操作。MVCC 的所有魔法都发生在快照读中。
sql
-- 快照读(不加锁)
SELECT * FROM orders WHERE id = 1;
4.2 当前读(Current Read)
某些操作要求读取数据的最新版本,并且对读取的记录加锁,防止其他事务修改。这类读称为当前读:
sql
-- 当前读(加锁,读最新版本)
SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- 加排他锁(X 锁)
SELECT * FROM orders WHERE id = 1 LOCK IN SHARE MODE; -- 加共享锁(S 锁)
INSERT INTO ...;
UPDATE ...;
DELETE ...;
当前读不走 MVCC ,它直接读取最新数据并加锁。幻读问题在当前读场景下需要依赖 Gap Lock(间隙锁) 来解决,不在 MVCC 的管辖范围内。
五、完整举例:READ COMMITTED 隔离级别
我们用具体的事务时序来演示 RC 级别下 MVCC 的工作过程。
场景设定
sql
-- 初始数据
CREATE TABLE accounts (
id INT PRIMARY KEY,
name VARCHAR(50),
balance DECIMAL(10,2)
);
INSERT INTO accounts VALUES (1, 'Alice', 1000.00);
INSERT INTO accounts VALUES (2, 'Bob', 500.00);
当前数据库中,假设最新已提交事务 ID 为 99,所以 id=1 这行的初始状态为:
id=1, name='Alice', balance=1000.00
trx_id = 99(插入时的事务 ID)
roll_pointer = NULL
三个事务并发时序
下面三个事务并发执行,时间从上往下推进:
时间线 事务 A(trx_id=100) 事务 B(trx_id=200) 事务 C(trx_id=300)
─────────────────────────────────────────────────────────────────────────────────────
T1 BEGIN
T2 BEGIN
T3 BEGIN
T4 UPDATE accounts
SET balance = 2000
WHERE id = 1
(修改但未提交)
T5 SELECT balance
FROM accounts
WHERE id = 1
← 读到多少?
T6 COMMIT
T7 SELECT balance
FROM accounts
WHERE id = 1
← 读到多少?
T8 UPDATE accounts
SET balance = 3000
WHERE id = 1
(修改但未提交)
T9 SELECT balance
FROM accounts
WHERE id = 1
← 读到多少?
T10 COMMIT
T11 SELECT balance
FROM accounts
WHERE id = 1
← 读到多少?
版本链演进
T4 时刻(事务 A 执行 UPDATE 后,未提交):
当前行(最新版本,未提交):
trx_id = 100, balance = 2000
roll_pointer ──────────────────────────────┐
↓
Undo Log(版本 1)
trx_id = 99, balance = 1000
roll_pointer = NULL
T8 时刻(事务 A 已提交,事务 C 修改后未提交),版本链延长为三层:
当前行(最新版本,未提交):
trx_id = 300, balance = 3000
roll_pointer ──────────────────────────────┐
↓
Undo Log(版本 2)
trx_id = 100, balance = 2000
roll_pointer ─────────────┐
↓
Undo Log(版本 1)
trx_id = 99, balance = 1000
roll_pointer = NULL
T5 时刻:事务 B 第一次 SELECT(READ COMMITTED)
RC 级别每次 SELECT 都重新生成 ReadView。T5 时刻,事务 A(100)已 BEGIN 但未提交,事务 B(200)和 C(300)均活跃:
ReadView(T5 时刻):
m_ids = {100, 200, 300}
min_trx_id = 100
max_trx_id = 301
creator_trx_id = 200
从版本链链头开始判断:
版本 trx_id=100, balance=2000
→ X=100 在 m_ids 中,活跃事务,不可见,往下走
版本 trx_id=99, balance=1000
→ X=99 < min_trx_id=100,规则二,可见 ✓
T5 结论:事务 B 读到 balance = 1000
事务 A 还未提交,读不到它的修改,符合"不读脏数据"。
T7 时刻:事务 B 第二次 SELECT(READ COMMITTED)
事务 A 在 T6 已提交。RC 级别重新生成新的 ReadView:
ReadView(T7 时刻):
m_ids = {200, 300} ← 事务 A(100)已提交,不在活跃列表
min_trx_id = 200
max_trx_id = 301
creator_trx_id = 200
版本链此时链头仍是 trx_id=100(A 修改的 balance=2000)。判断:
版本 trx_id=100, balance=2000
→ X=100 < min_trx_id=200,规则二,可见 ✓
T7 结论:事务 B 读到 balance = 2000
事务 A 已提交,RC 级别立刻可以看到。这就是"不可重复读":T5 读到 1000,T7 读到 2000。
T9 时刻:事务 B 第三次 SELECT(READ COMMITTED)
事务 C(300)已修改但未提交。RC 再次生成新 ReadView:
ReadView(T9 时刻):
m_ids = {200, 300}
min_trx_id = 200
max_trx_id = 301
creator_trx_id = 200
判断版本链(此时链头已是 trx_id=300, balance=3000):
版本 trx_id=300, balance=3000
→ X=300 在 m_ids 中,活跃事务,不可见,往下走
版本 trx_id=100, balance=2000
→ X=100 < min_trx_id=200,规则二,可见 ✓
T9 结论:事务 B 读到 balance = 2000
事务 C 未提交,看不到 C 的修改。
T11 时刻:事务 B 第四次 SELECT(READ COMMITTED)
事务 C 在 T10 已提交。RC 再次生成新 ReadView:
ReadView(T11 时刻):
m_ids = {200} ← A 和 C 均已提交,只剩 B 自己
min_trx_id = 200
max_trx_id = 301
creator_trx_id = 200
判断:
版本 trx_id=300, balance=3000
→ X=300,不在 m_ids 中,且 X < max_trx_id=301
→ 介于 min 和 max 之间,且不在 m_ids 中,规则四:可见 ✓
T11 结论:事务 B 读到 balance = 3000
RC 级别完整结果:
| 时刻 | 事务 B 读到 | 原因 |
|---|---|---|
| T5 | 1000 | 事务 A 未提交,不可见 |
| T7 | 2000 | 事务 A 已提交,RC 立刻可见 |
| T9 | 2000 | 事务 C 未提交,不可见 |
| T11 | 3000 | 事务 C 已提交,RC 立刻可见 |
同一个事务中四次读取读到了三个不同的值,这就是 RC 级别的"不可重复读"现象。
六、完整举例:REPEATABLE READ 隔离级别
使用完全相同的时序,把事务 B 的隔离级别改为 REPEATABLE READ。
RR 级别的核心规则:事务 B 在 T5 第一次 SELECT 时生成 ReadView,之后整个事务复用这一个 ReadView,永不重新生成。
T5 时刻:事务 B 第一次 SELECT,生成 ReadView
T5 时刻系统中三个事务(100、200、300)均处于活跃状态:
ReadView(T5 时刻,整个事务期间复用此快照):
m_ids = {100, 200, 300}
min_trx_id = 100
max_trx_id = 301
creator_trx_id = 200
判断(与 RC 的 T5 相同):
版本 trx_id=100, balance=2000 → 在 m_ids 中,不可见
版本 trx_id=99, balance=1000 → X < min_trx_id,可见 ✓
T5 结论:事务 B 读到 balance = 1000
T7 时刻:事务 B 第二次 SELECT
事务 A(100)已在 T6 提交。但 RR 级别复用 T5 的 ReadView,不重新生成。
继续使用 ReadView(T5 快照):
m_ids = {100, 200, 300} ← 100 仍在列表中!
判断版本链(链头仍是 trx_id=100, balance=2000):
版本 trx_id=100, balance=2000
→ X=100 在 m_ids 中,不可见(即使事务 A 已提交!)
版本 trx_id=99, balance=1000
→ X=99 < min_trx_id=100,可见 ✓
T7 结论:事务 B 读到 balance = 1000
RR 的关键所在:m_ids 里的 trx_id=100 代表"ReadView 生成时这个事务还没提交",即便之后提交了,ReadView 不更新,所以 B 仍然看不到 A 的修改。可重复读实现。
T9 时刻:事务 B 第三次 SELECT
事务 C(300)已修改但未提交,继续复用 T5 的 ReadView:
版本 trx_id=300, balance=3000
→ X=300 在 m_ids 中,不可见
版本 trx_id=100, balance=2000
→ X=100 在 m_ids 中,不可见
版本 trx_id=99, balance=1000
→ X=99 < min_trx_id,可见 ✓
T9 结论:事务 B 读到 balance = 1000
T11 时刻:事务 B 第四次 SELECT
事务 C(300)在 T10 已提交,继续复用 T5 的 ReadView:
版本 trx_id=300, balance=3000
→ X=300 在 m_ids 中(即使 C 已提交!),不可见
... 最终仍然只有 trx_id=99 的版本可见 ✓
T11 结论:事务 B 读到 balance = 1000
RR 级别完整结果:
| 时刻 | 事务 B 读到 | 原因 |
|---|---|---|
| T5 | 1000 | A 未提交,不可见 |
| T7 | 1000 | ReadView 复用,A 虽提交但在 m_ids 中,不可见 |
| T9 | 1000 | ReadView 复用,C 虽修改但在 m_ids 中,不可见 |
| T11 | 1000 | ReadView 复用,C 虽提交但在 m_ids 中,不可见 |
四次读取结果完全一致,可重复读得以实现。
RC 与 RR 结果对比
| 时刻 | RC 读到 | RR 读到 | 关键差异 |
|---|---|---|---|
| T5 | 1000 | 1000 | 两者相同(A 未提交,均不可见) |
| T7 | 2000 | 1000 | A 提交后,RC 重新生成 ReadView 可见,RR 复用旧快照不可见 |
| T9 | 2000 | 1000 | C 未提交均不可见,但 RC 已能看到 A 的修改 |
| T11 | 3000 | 1000 | C 提交后,RC 重新生成 ReadView 可见,RR 复用旧快照不可见 |
七、事务读取自己的修改
一个容易被忽略的场景:事务读取自己尚未提交的修改。
sql
-- 事务 A,trx_id = 400
BEGIN;
UPDATE accounts SET balance = 9999 WHERE id = 1; -- 修改但未提交
SELECT balance FROM accounts WHERE id = 1; -- 应该读到 9999 还是旧值?
版本链链头是 trx_id=400, balance=9999。根据规则一:
X = 400 == creator_trx_id = 400 → 自己修改的版本,可见 ✓
结论:事务 A 读到 balance = 9999。
事务能读到自己做的修改,这符合直觉。规则一的存在正是为了处理这个场景,它优先于其他规则判断。
八、RR 级别下的幻读问题
8.1 快照读层面:MVCC 解决了幻读
在 RR 级别,普通 SELECT 使用固定的 ReadView,其他事务新插入的行的 trx_id 大于 max_trx_id,或在 m_ids 中,本事务看不到这些新行,快照读层面的幻读被 MVCC 解决。
sql
-- 事务 A,RR 级别,trx_id=500
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 101; -- 返回 2
-- 此时事务 B(trx_id=600)提交了一条 user_id=101 的新记录
SELECT COUNT(*) FROM orders WHERE user_id = 101;
-- 依然返回 2(新行的 trx_id=600 >= max_trx_id,不可见)
COMMIT;
8.2 当前读层面:幻读依然存在
如果在同一事务中混用快照读和当前读,就会出现幻读:
sql
-- 事务 A,RR 级别
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 101;
-- 快照读,返回 2(ReadView 固定)
-- 事务 B 提交:INSERT INTO orders VALUES(99, 101, 500, 'paid')
SELECT COUNT(*) FROM orders WHERE user_id = 101 FOR UPDATE;
-- 当前读,不走 MVCC,直接读最新数据,返回 3!
-- 幻读出现:同一事务,相同条件,两次读行数不同
COMMIT;
要在当前读场景彻底消灭幻读,需要 Next-Key Lock(临键锁)= Record Lock + Gap Lock,对范围加间隙锁,阻止其他事务向该范围插入新行。这是锁机制的职责,超出了 MVCC 的范畴。
九、四种隔离级别与 MVCC 的关系
| 隔离级别 | 脏读 | 不可重复读 | 幻读(快照读) | 幻读(当前读) | ReadView 策略 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | ✗ 存在 | ✗ 存在 | ✗ 存在 | ✗ 存在 | 不用 MVCC,直接读最新版本 |
| READ COMMITTED | ✓ 解决 | ✗ 存在 | ✗ 存在 | ✗ 存在 | 每次 SELECT 生成新 ReadView |
| REPEATABLE READ | ✓ 解决 | ✓ 解决 | ✓ 解决 | ✗ 存在 | 首次 SELECT 生成,事务内复用 |
| SERIALIZABLE | ✓ 解决 | ✓ 解决 | ✓ 解决 | ✓ 解决 | 不用 MVCC,全程加锁串行执行 |
InnoDB 默认隔离级别是 REPEATABLE READ,通过 MVCC 解决了绝大多数并发问题,仅在当前读的幻读场景下需要配合 Next-Key Lock。
十、MVCC 的注意事项与最佳实践
10.1 长事务是 MVCC 的天敌
MVCC 的版本链依赖 Undo Log 保留历史数据。如果存在一个长时间运行的事务,它的 ReadView 或活跃事务状态会阻止 Purge 线程清理旧版本:
- 版本链越来越长,每次读取需要遍历更多版本,查询变慢
- Undo 表空间持续膨胀,磁盘占用增加
sql
-- 查看长事务(运行超过 60 秒)
SELECT trx_id, trx_started, trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY trx_started ASC;
最佳实践:事务尽量短,用完即提交,避免在事务中夹杂耗时的应用层操作(如等待用户输入、调用外部 API)。
10.2 只读事务也会影响 Purge
很多人以为纯 SELECT 不产生 Undo Log,对系统没有影响。实际上,在 RR 隔离级别下,一个长时间运行的只读事务会持有一个固定的 ReadView,这个 ReadView 时间点之后产生的所有 Update Undo Log 都无法被 Purge 清理,同样会导致 Undo 膨胀。
对策:将报表、统计类的长时间查询的隔离级别调低为 READ COMMITTED,或将其迁移到只读副本。
10.3 监控 History List Length
sql
-- 查看 History List Length(Undo Log 积压量)
-- 在 SHOW ENGINE INNODB STATUS\G 的 TRANSACTIONS 部分:
-- History list length 1234 ← 正常应 < 1000,持续增长说明有长事务
-- 也可以查 INNODB_METRICS
SELECT NAME, COUNT
FROM information_schema.INNODB_METRICS
WHERE NAME = 'trx_rseg_history_len';
十一、总结
MVCC 的全部原理可以用一句话概括:
通过隐藏列(trx_id + roll_pointer)将同一行数据的历史修改串成版本链,读操作在执行时生成 ReadView,根据活跃事务列表判断版本链上哪个版本对自己可见,从而实现无锁的并发读。
三个核心组件各司其职:
- 隐藏列:每行数据携带"最后修改者"(trx_id)和"指向历史版本的指针"(roll_pointer),是版本链的基础设施。
- 版本链(Undo Log 构成):保存数据的所有历史版本,是 MVCC 的"时间机器",让读操作可以回溯到任意历史时刻。
- ReadView:事务执行快照读时生成的活跃事务快照,定义了当前事务的可见性边界,是 MVCC 判断"读哪个版本"的决策核心。
RC 和 RR 隔离级别的根本区别只有一点:
ReadView 是每次 SELECT 都重新生成(RC),还是整个事务只生成一次、复用到底(RR)。
这一个时机的差异,决定了是否存在不可重复读,也是理解两种隔离级别行为的最核心钥匙。