一、从一个问题开始:为什么需要 MVCC?
想象一个简单的银行账户表,两个事务同时操作:
- 事务 A:正在读取你的账户余额,准备显示在网页上。
- 事务 B:同时给你的账户转账 100 元,正在修改余额。
在没有 MVCC 的传统锁机制下,为了保证数据一致性,要么让事务 A 等待事务 B 完成(加读锁),要么让事务 B 等待事务 A 完成(加写锁)。这种"读写互斥"的设计在高并发场景下会严重影响系统性能。
MVCC(Multi-Version Concurrency Control,多版本并发控制)正是为了解决这个问题而生。它允许数据库同时保留数据的多个版本,让读操作读取旧版本(快照),写操作创建新版本,从而实现 "读不阻塞写,写不阻塞读" 的理想并发模型。
二、MVCC 核心概念速览
2.1 什么是 MVCC?
MVCC 是一种并发控制方法,通过为数据行维护多个历史版本,使得事务可以在不加锁的情况下读取到一致的数据快照。InnoDB 存储引擎是 MySQL 中实现 MVCC 的典型代表。
2.2 两种读取方式
在理解 MVCC 前,必须先分清两种读取方式:
| 读取方式 | 特点 | 加锁情况 | 典型语句 |
|---|---|---|---|
| 快照读 | 读取数据的可见版本(可能是历史版本) | 不加锁 | 普通 SELECT |
| 当前读 | 读取数据的最新版本 | 加锁 | SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT |
MVCC 主要作用于快照读,而当前读则需要依赖锁机制来保证一致性。
三、MVCC 的三大核心组件
MVCC 的实现依赖于三个关键组件:隐藏字段、Undo Log 版本链和Read View(读视图)。
3.1 隐藏字段
InnoDB 为每行数据添加了三个隐藏字段:
| 隐藏字段 | 大小 | 含义 |
|---|---|---|
| DB_TRX_ID | 6 字节 | 最近修改该行的事务 ID |
| DB_ROLL_PTR | 7 字节 | 回滚指针,指向 Undo Log 中的上一个版本 |
| DB_ROW_ID | 6 字节 | 隐藏主键(当表没有主键时自动生成) |
3.2 Undo Log 版本链
当一行数据被更新时,InnoDB 不会直接覆盖原数据,而是:
- 将修改前的数据写入 Undo Log
- 更新当前行的
DB_TRX_ID为新事务 ID - 将
DB_ROLL_PTR指向上一个版本的 Undo Log 记录
这样,通过 DB_ROLL_PTR 指针,所有历史版本被串联成一个版本链,链头是最新版本,链尾是最早版本。
示例:假设初始版本由事务 1 插入,后续事务 2、3、4 依次更新,形成的版本链如下:
最新版 ← 版本4 (trx_id=4) ← 版本3 (trx_id=3) ← 版本2 (trx_id=2) ← 版本1 (trx_id=1)3.3 Read View(读视图)
Read View 是快照读执行时判断数据可见性的依据,它记录了 Read View 生成时系统中活跃事务的信息。
Read View 包含四个重要字段:
| 字段 | 含义 |
|---|---|
| m_ids | 当前活跃事务的 ID 集合 |
| min_trx_id | 最小活跃事务 ID |
| max_trx_id | 预分配事务 ID(当前最大事务 ID + 1) |
| creator_trx_id | 创建该 Read View 的事务 ID |
四、可见性判断规则
当事务通过快照读访问一条记录时,会沿着版本链从新到旧依次判断每个版本是否可见。判断规则如下:
| 条件 | 可见性 | 说明 |
|---|---|---|
DB_TRX_ID == creator_trx_id | ✅ 可见 | 当前事务修改的版本 |
DB_TRX_ID < min_trx_id | ✅ 可见 | 修改事务在 Read View 生成前已提交 |
DB_TRX_ID > max_trx_id | ❌ 不可见 | 修改事务在 Read View 生成后才开始 |
min_trx_id <= DB_TRX_ID <= max_trx_id | 需判断 | 若 DB_TRX_ID 不在 m_ids 中则可见,否则不可见 |
规则总结:一个版本对当前事务可见,当且仅当修改该版本的事务在 Read View 生成时已经提交,或者就是当前事务本身。
五、RC 与 RR 隔离级别的 MVCC 实现差异
5.1 两种隔离级别下的 Read View 生成策略
| 隔离级别 | Read View 生成策略 |
|---|---|
| READ COMMITTED(RC) | 事务中每次执行快照读都会生成一个新的 Read View |
| REPEATABLE READ(RR) | 事务中第一次执行快照读时生成 Read View,后续复用 |
5.2 对比示例
假设有以下事务执行顺序:
- 事务 3、4、5 同时开始(均未提交)
- 事务 5 执行第一次查询
- 事务 3 提交
- 事务 5 执行第二次查询
RC 级别下:
- 第一次查询:Read View 中 m_ids={3,4,5},读不到事务 3 的修改
- 第二次查询:生成新的 Read View,此时 m_ids={4,5},事务 3 已提交,因此可以读到事务 3 的修改 → 会产生不可重复读
RR 级别下:
- 第一次查询:Read View 中 m_ids={3,4,5}
- 第二次查询:复用第一次的 Read View,m_ids 仍为 {3,4,5},事务 3 即使已提交也不可见 → 解决了不可重复读
六、MVCC 如何解决并发问题
6.1 解决脏读
脏读是指读到未提交事务的数据。MVCC 通过 Read View 机制确保只能看到已提交事务生成的版本。由于 Read View 中记录了活跃事务 ID 集合,未提交事务的修改对当前事务不可见。
6.2 解决不可重复读
在 RR 隔离级别下,由于整个事务复用同一个 Read View,无论其他事务如何提交,事务内多次读取的结果始终保持一致,从而解决了不可重复读。
6.3 幻读的处理
幻读是指在同一事务内执行两次范围查询,第二次查询看到了其他事务插入的新行。
MVCC 在快照读场景下可以解决幻读,因为 Read View 机制使得新插入的行对当前事务不可见。
但在当前读场景下(如 SELECT ... FOR UPDATE),需要使用 Next-Key Lock(临键锁) 来锁定范围和间隙,防止其他事务插入新行。
七、实践中的注意事项
7.1 长事务的影响
长事务会导致 Undo Log 无法及时清理,因为可能存在需要读取历史版本的老事务。这会造成:
- Undo Log 膨胀,占用大量磁盘空间
- 版本链过长,查询性能下降
7.2 二级索引的处理
对于二级索引,MVCC 的处理方式与聚集索引不同。二级索引记录不包含 DB_TRX_ID 和 DB_ROLL_PTR 隐藏列。当需要判断可见性时,必须通过回表到聚集索引来获取完整的事务信息。
7.3 特殊情况:当前事务修改了其他事务更新的行
如果一个事务执行了当前读(如 UPDATE),它会读到最新版本的数据。随后该事务内的快照读也能看到自己刚刚修改的数据,即使这与 Read View 的初始快照不一致。这是 MVCC 设计中的合理例外。
八、总结
MVCC 是 MySQL InnoDB 实现高并发的核心技术,其核心思想可以概括为:
- 三个组件:隐藏字段构建基础,Undo Log 形成版本链,Read View 提供判断依据
- 两种读取:快照读依赖 MVCC 不加锁,当前读依赖锁机制保一致
- 一个规则:只能看到 Read View 生成时已提交的事务或自身事务的修改
- 两级差异:RC 级别每次查询生成新 Read View,RR 级别复用第一次生成的 Read View
通过这种精巧的设计,MVCC 使得 MySQL 能够在保证事务隔离性的同时,最大限度地提高并发性能,成为现代关系型数据库并发控制的典范。
延伸思考:虽然 MVCC 解决了读写冲突,但在高并发写入场景下,仍可能出现锁等待、死锁等问题。下一篇文章我们将深入探讨 MySQL 的锁机制,敬请期待。
暂无评论