十二:InnoDB MVCC(多版本并发控制)

深入理解 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 不会原地覆盖旧值,而是:

  1. 将旧版本的完整内容写入 Undo Log
  2. 新版本的 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)。

这一个时机的差异,决定了是否存在不可重复读,也是理解两种隔离级别行为的最核心钥匙。

相关推荐
数据库小组4 小时前
NineData 社区版慢 SQL 功能能做什么?给 DBA 的一套本地化治理工具
数据库·sql·dba·慢sql·数据库管理工具·ninedata·迁移工具
马里马里奥-4 小时前
文献阅读:LinkAlign:面向真实世界大规模多数据库文本转SQL任务的可扩展模式链接方法
数据库·sql
0xDevNull5 小时前
MySQL的索引下推(ICP)
sql·mysql
次旅行的库5 小时前
【问渠哪得清如许-数据分析】学习笔记-上
数据库·笔记·sql·学习·postgresql·数据分析
天天进步20155 小时前
WrenAI 深度解析:算法视角:wren-ai-service 如何利用 RAG 与 Metadata 提升 SQL 准确率?
人工智能·sql·算法
林月明5 小时前
【Coze基础】Excel保存CSV文件时其设置为UTF-8编码,将数据导入数据库中
数据库·sql·oracle·excel·code·学习经验
人道领域5 小时前
【苍穹外卖】深度解析:商品浏览四大核心接口设计(附完整数据流转图)
java·数据库·后端·sql
white-persist5 小时前
【Js逆向 python】Web JS 逆向全体系详细解释
运维·服务器·前端·javascript·网络·python·sql
小江的记录本17 小时前
【SQL】多表关系与冷热数据(全维度知识体系)
数据库·sql·mysql·数据库开发·数据库架构