mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2997 字
8 分钟
MySQL MVCC 原理
2024-06-10

事务原理与隔离级别 中讲到,REPEATABLE READ 之所以能让事务内多次查询结果一致,靠的是 MVCC(多版本并发控制)。但隔离级别只是定义了”应该看到什么”,真正决定”能看到哪个版本”的是 MVCC 的可见性判断算法。

本文拆解 InnoDB MVCC 的核心机制:版本链如何用 Undo Log 串联、ReadView 如何界定快照边界、可见性判断的三种情况、快照读与当前读的区别,以及 RC 与 RR 下 ReadView 生成时机的差异。最后延伸到 SSI(Serializable Snapshot Isolation),看 PostgreSQL 如何在 MVCC 基础上检测写偏序实现可串行化。锁机制与死锁排查见 MySQL 锁机制与死锁

前置知识#

Important

一、MVCC 的核心思想#

MVCC(Multi-Version Concurrency Control,多版本并发控制)是现代数据库实现隔离性的主要机制。基本思想是:读操作不阻塞写操作,写操作不阻塞读操作。通过为每行数据维护多个版本,读操作访问历史快照,写操作创建新版本。

这与传统的锁方案形成对比。在严格两阶段锁(S2PL)下,读操作需要加共享锁,会阻塞写操作。MVCC 让读操作走快照路径,完全不持锁,写操作只锁住被修改的行,两者互不干扰。代价是每行数据需要存储多个版本,占用额外空间。

flowchart LR subgraph G1["写操作路径"] W["执行 UPDATE"] --> W2["旧值写入 Undo Log\n新值写入数据页\n版本链串联"] end subgraph G2["读操作路径"] R["执行 SELECT 快照读"] --> R2["读 ReadView\n沿版本链查找可见版本\n不加锁"] end G1 -.->|"读写不互相阻塞"| G2

二、快照读与当前读#

MVCC 将读操作分为两类,理解它们的区别是理解隔离级别行为的关键:

读类型描述是否加锁示例
快照读(Snapshot Read)读取 MVCC 快照中的历史版本普通 SELECT
当前读(Current Read)读取最新已提交数据并加锁SELECT ... FOR UPDATE / UPDATE / DELETE
-- 快照读:不加锁,读 MVCC 快照
SELECT * FROM accounts WHERE id = 1;
-- 当前读:加锁,读最新数据
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 排他锁
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE; -- 共享锁
Note

UPDATEDELETEINSERT 都是当前读。UPDATE 必须读到最新数据才能正确修改,所以它会加排他锁并读取最新版本,而非读快照。这就是为什么 REPEATABLE READ 下快照读不幻读,但 SELECT ... FOR UPDATE 这种当前读会幻读,除非配合 Next-Key Lock。

三、版本链#

3.1 InnoDB 的版本链结构#

MVCC 为每行数据维护一条版本链,每个版本包含事务 ID 和指向旧版本的指针。InnoDB 的每行数据在聚簇索引的记录头中隐藏了三个字段:

  • DB_TRX_ID:最后修改该行的事务 ID
  • DB_ROLL_PTR:指向 Undo Log 中该行的上一版本
  • DB_ROW_ID:没有主键时 InnoDB 自动生成的行 ID

修改数据时,旧版本被写入 Undo Log,新版本留在数据页中,通过 DB_ROLL_PTR 串联成链:

graph LR subgraph V["版本链(InnoDB Undo Log)"] V3["版本3<br/>balance=3000<br/>trx_id=103"] V2["版本2<br/>balance=4000<br/>trx_id=102"] V1["版本1<br/>balance=5000<br/>trx_id=101"] V3 -->|"roll_ptr"| V2 V2 -->|"roll_ptr"| V1 end PAGE["数据页<br/>存最新版本 V3"] -.->|"roll_ptr 指向"| V3 style V3 fill:#e8f5e9,stroke:#2e7d32 style V2 fill:#fff3e0,stroke:#e65100 style V1 fill:#fce4ec,stroke:#c62828

数据页中只存最新版本,旧版本全部在 Undo Log 中。读操作如果发现最新版本不可见,就沿 roll_ptr 回溯,直到找到可见版本。

3.2 PostgreSQL 的版本链对比#

PostgreSQL 的 MVCC 实现截然不同。它采用 Append-Only 方案,每行数据包含 xmin(插入该行的事务 ID)和 xmax(删除/更新该行的事务 ID,初始为 0)。更新操作不是就地修改,而是插入新行并标记旧行的 xmax

对比维度MySQL InnoDBPostgreSQL
版本存储Undo Log(旧版本在回滚段)Append-Only(新版本直接插入表)
版本链方向从新到旧(通过 roll_ptr 回溯)从旧到新(通过 ctid 指向新版本)
数据页内容只存最新版本新旧版本混在一起
空间回收Purge 线程清理 Undo LogVACUUM 清理死元组
更新方式就地更新 + Undo Log 记录旧值插入新行 + 标记旧行为过期
Important

两种 MVCC 方案的根本取舍:InnoDB 的 Undo Log 方案读性能更好,数据页中只有最新版本,无需过滤死元组;PostgreSQL 的 Append-Only 方案写性能更好,无需维护 Undo Log,但读需要过滤死元组,且 VACUUM 是必须的维护操作。

四、ReadView 与可见性判断#

4.1 ReadView 的结构#

MVCC 的算法核心是可见性判断:给定一个 ReadView(快照),判断版本链上的哪个版本对当前事务可见。InnoDB 的 ReadView 包含四个关键字段:

  • m_ids:生成 ReadView 时所有活跃(未提交)事务的 ID 列表
  • min_trx_idm_ids 中的最小值
  • max_trx_id:下一个将分配的事务 ID(即当前最大事务 ID + 1)
  • creator_trx_id:创建该 ReadView 的事务 ID
graph LR subgraph RV["ReadView"] M["m_ids: 活跃事务列表\n例: 102, 104"] MIN["min_trx_id: 102"] MAX["max_trx_id: 106"] CREATOR["creator_trx_id: 103"] end V["版本链上的版本\ntrx_id = ?"] --> J{"可见性判断"} J -->|"trx_id == creator_trx_id"| R1["可见\n自己修改的"] J -->|"trx_id 小于 min_trx_id"| R2["可见\nReadView 前已提交"] J -->|"trx_id 大于等于 max_trx_id"| R3["不可见\nReadView 后才开启"] J -->|"trx_id 在 m_ids 列表中"| R4["不可见\n事务未提交"] J -->|"trx_id 不在 m_ids 列表中"| R5["可见\n已提交"] style R1 fill:#c8e6c9,stroke:#2e7d32 style R2 fill:#c8e6c9,stroke:#2e7d32 style R3 fill:#ffcdd2,stroke:#c62828 style R4 fill:#ffcdd2,stroke:#c62828 style R5 fill:#c8e6c9,stroke:#2e7d32

4.2 可见性判断规则#

从版本链最新版本开始,沿 roll_ptr 回溯,对每个版本的 trx_id 执行以下判断:

  1. trx_id == creator_trx_id,可见。这是当前事务自己修改的版本。
  2. trx_id < min_trx_id,可见。该版本在 ReadView 创建前已提交。
  3. trx_id >= max_trx_id,不可见。该版本在 ReadView 创建后才开始。
  4. trx_idm_ids 列表中,不可见。该版本对应的事务在 ReadView 创建时还活跃未提交。
  5. trx_id 不在 m_ids 列表中,可见。该版本对应的事务在 ReadView 创建前已提交。

如果一个版本不可见,就沿 roll_ptr 找上一个版本,重复上述判断,直到找到可见版本或版本链到底。

4.3 可见性判断实例#

假设有版本链 V3(trx_id=103)、V2(trx_id=102)、V1(trx_id=101),当前 ReadView 的 min_trx_id=102max_trx_id=104m_ids={102, 103}

graph LR subgraph V["版本链"] V3["V3<br/>trx_id=103"] V2["V2<br/>trx_id=102"] V1["V1<br/>trx_id=101"] V3 --> V2 --> V1 end RV["ReadView<br/>m_ids = 102, 103<br/>min = 102, max = 104"] V3 -.->|"103 在 m_ids 中\n不可见"| V2 V2 -.->|"102 在 m_ids 中\n不可见"| V1 V1 -.->|"101 小于 min\n可见"| OK["读到 V1"] style V3 fill:#ffcdd2,stroke:#c62828 style V2 fill:#ffcdd2,stroke:#c62828 style V1 fill:#c8e6c9,stroke:#2e7d32 style OK fill:#c8e6c9,stroke:#2e7d32

事务 102 和 103 都在活跃列表中,它们的修改不可见,读操作最终落到 V1(trx_id=101,在 ReadView 创建前已提交)。

五、ReadView 生成时机:RR 与 RC 的差异#

ReadView 的生成时机是区分 RC 和 RR 的关键。同一个可见性判断算法,因为生成时机不同,产生了截然不同的隔离行为。

隔离级别ReadView 生成时机效果
READ COMMITTED每次执行 SELECT 时每次查询看到最新已提交数据
REPEATABLE READ事务内第一次 SELECT 时事务内所有查询看到同一快照
-- READ COMMITTED:每次 SELECT 生成新 ReadView
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- 第一次 SELECT:生成 ReadView 1
SELECT balance FROM accounts WHERE id = 1; -- 5000
-- 另一事务提交修改后
-- 第二次 SELECT:生成 ReadView 2(包含新提交的数据)
SELECT balance FROM accounts WHERE id = 1; -- 4000
COMMIT;
-- REPEATABLE READ:整个事务共用一个 ReadView
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
-- 第一次 SELECT:生成 ReadView,整个事务复用
SELECT balance FROM accounts WHERE id = 1; -- 5000
-- 另一事务提交修改后
-- 第二次 SELECT:复用同一个 ReadView
SELECT balance FROM accounts WHERE id = 1; -- 5000(不变)
COMMIT;

RC 每次查询都生成新 ReadView,活跃事务列表更新,所以能看到其他事务新提交的数据。RR 在事务内第一次快照读时生成 ReadView,此后复用,其他事务提交的数据对它不可见,所以查询结果一致。

Note

InnoDB 的 RR 在事务第一次快照读时生成 ReadView,而非 BEGIN 时。这意味着 BEGIN 之后、第一次 SELECT 之前,其他事务提交的修改仍然可见。如果需要 BEGIN 时就固定快照,可以使用 START TRANSACTION WITH CONSISTENT SNAPSHOT,这在逻辑备份工具(如 mydumper、mysqldump --single-transaction)中被用来保证一致性读。

六、Undo 段膨胀与运维#

6.1 长事务与 Undo 膨胀#

MVCC 的代价是旧版本不能立即删除。只要还有事务的 ReadView 可能需要某个版本,该版本就必须保留在 Undo Log 中。长事务持有 ReadView 的时间越长,Undo Log 中积累的旧版本就越多,这就是 Undo 段膨胀。

flowchart TD A["长事务开始"] --> B["持有 ReadView 不释放"] B --> C["其他事务不断修改数据\n产生大量 Undo Log 旧版本"] C --> D["Purge 线程无法清理\n这些旧版本仍可能被长事务读到"] D --> E["Undo 表空间持续增长\nibdata 文件或 undo 表空间膨胀"] E --> F["磁盘空间告警\n回滚段不足"]

Undo 膨胀的直接危害是磁盘空间被占用,间接危害是版本链变长,快照读需要回溯更多版本才能找到可见数据,查询性能下降。

6.2 Undo 表空间管理#

MySQL 8.0 默认使用独立 Undo 表空间(innodb_undo_tablespaces),可以用 innodb_undo_log_truncate 控制自动截断:

ONOptionalboolean

开启后,Undo 表空间超过阈值时自动截断回收空间。MySQL 8.0 默认开启。

innodb_max_undo_log_sizeOptionalinteger

单个 Undo 表空间文件的最大大小,默认 1GB,超过后触发截断。

排查 Undo 膨胀的常用命令:

-- 查看 Undo 表空间状态
SELECT SPACE, NAME AS tablespace_name, SPACE_TYPE, STATE, FILE_SIZE, ALLOCATED_SIZE
FROM information_schema.INNODB_TABLESPACES
WHERE SPACE_TYPE = 'Undo';
-- 查看活跃的最旧事务(持有最旧 ReadView 的事务)
SELECT trx_id, trx_started, trx_state, trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC LIMIT 5;

trx_started 时间最早的事务,很可能就是持有最旧 ReadView、阻止 Undo 清理的元凶。Kill 掉它之后,Purge 线程会开始清理积累的旧版本。

Warning

长事务是 MVCC 的天敌。监控 innodb_trx 视图中运行时间超过阈值的事务,及时告警,是防止 Undo 膨胀的主动手段。应用层也应避免在事务中夹杂耗时操作(如 HTTP 调用、文件处理),把事务缩短到只包含必要的数据库操作。

七、SSI:可串行化快照隔离#

7.1 为什么需要 SSI#

事务原理与隔离级别 中提到,SERIALIZABLE 通过 S2PL 实现时性能很差:所有读操作加共享锁,直到事务提交才释放,并发度极低。SSI(Serializable Snapshot Isolation)提供了另一种思路:在 MVCC 快照隔离的基础上,检测写偏序冲突,只回滚真正产生冲突的事务。

7.2 写偏序的必要条件#

写偏序的必要条件是两个事务之间存在 rw(读-写)冲突:事务 T1 读了某数据,事务 T2 写了该数据。如果两个并发事务之间存在双向 rw 冲突,就可能产生写偏序。

假设两个事务的操作如下:

  • 事务 T1:读取 X,然后写入 Y
  • 事务 T2:读取 Y,然后写入 X

分析冲突关系:

  • T1 读 X 与 T2 写 X 构成 rw 冲突
  • T2 读 Y 与 T1 写 Y 构成 rw 冲突
  • 双向 rw 冲突 → 可能写偏序 → 回滚其中一个

SSI 的关键观察是:单方向的 rw 冲突不构成写偏序,只有双向 rw 冲突才需要处理。大多数 OLTP 事务之间不存在双向 rw 冲突,因此 SSI 的回滚率远低于 S2PL 的阻塞率。

7.3 PostgreSQL 的 SSI 实现#

PostgreSQL 从 9.1 版本开始支持 SSI,是第一个在生产数据库中实现 SSI 的系统。其实现基于以下机制:

  1. SI(快照隔离):每个事务看到一致的快照,读不阻塞写
  2. rw 冲突追踪:记录事务之间的读写依赖关系
  3. 危险结构检测:检测两个 rw 冲突形成的三事务环
  4. 回滚决策:选择代价最小的事务回滚
-- PostgreSQL SSI 示例
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 事务 T1
BEGIN;
SELECT COUNT(*) FROM on_call WHERE on_duty = true; -- 返回 2
-- SSI 记录:T1 读取了 on_call 表中 on_duty=true 的行
-- 事务 T2(并发)
BEGIN;
SELECT COUNT(*) FROM on_call WHERE on_duty = true; -- 返回 2
UPDATE on_call SET on_duty = false WHERE doctor = 'Bob';
-- SSI 记录:T2 写入了 T1 读取的行 → rw 冲突
-- 事务 T1
UPDATE on_call SET on_duty = false WHERE doctor = 'Alice';
-- SSI 检测到双向 rw 冲突 → 回滚 T1
-- ERROR: could not serialize access due to read/write dependencies

7.4 SSI 与 S2PL 对比#

对比维度S2PLSSI
读操作加共享锁,阻塞写不加锁,读快照
写操作加排他锁,阻塞读写只在提交时检测冲突
冲突处理阻塞等待回滚冲突事务
只读事务受写事务阻塞完全不受影响
适用场景冲突频繁冲突稀少(大多数 OLTP)

SSI 的优势在于只读事务永远不会被阻塞或回滚。在大多数 OLTP 场景中,读写冲突并不频繁,SSI 的回滚率远低于 S2PL 的阻塞率。

Note

MySQL 的 InnoDB 至今未实现 SSI,SERIALIZABLE 隔离级别仍走 S2PL 路径,所有读操作加共享锁。如果业务需要可串行化但 S2PL 性能不可接受,常见做法是在应用层用乐观锁或显式锁来防止写偏序,而非依赖 MySQL 的 SERIALIZABLE。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

MySQL MVCC 原理
https://blog.souloss.cn/posts/middleware/db/mysql-mvcc/
作者
Souloss
发布于
2024-06-10
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时