MVCC多版本并发控制详解
MVCC 是 MySQL(尤其是 InnoDB 存储引擎)实现高并发、高性能的核心机制之一。理解了 MVCC,就理解了 MySQL 事务隔离级别的底层实现。
什么是 MVCC?
MVCC,全称 Multi-Version Concurrency Control,即多版本并发控制。
- 核心思想: 为了应对高并发,不再让读写操作相互阻塞,而是通过一种“快照”的方式,为每个事务在开始时提供一个一致性的数据视图。
- 实现方式: 在每一行记录的背后,InnoDB 会保存多个版本的数据(通过 Undo Log 实现)。当不同的事务同时读写同一行时,它们看到的是这行数据在不同时间点的“快照”,而不是直接操作同一份数据本身。这样就实现了非阻塞的读操作,写操作也只需要锁定必要的行,极大提高了并发性能。
MVCC 的底层支撑:关键概念
要理解 MVCC,必须先了解以下几个核心概念:
a. 隐藏字段
InnoDB 为每一行数据(记录)都添加了三个用户看不见的隐藏字段:
- DB_TRX_ID (6字节): 事务ID。表示最后一次插入或更新该行记录的事务ID。删除在 InnoDB 内部也被视为一次更新,会设置一个特殊的标记位。
- DB_ROLL_PTR (7字节): 回滚指针。指向该行记录上一个版本在 Undo Log 中的地址。所有旧版本的数据都通过这个指针连接成一个版本链(类似一个链表)。
- DB_ROW_ID (6字节): 行ID。如果表没有定义主键,InnoDB 会自动生成一个隐藏的主键 using this field。它与 MVCC 关系不大,主要是用于构建聚簇索引。
b. Undo Log (回滚日志)
- 作用: 用于存储数据被修改之前的旧值(旧版本)。
- 与 MVCC 的关系: 当执行
UPDATE
或DELETE
操作时,旧版本的数据并不会被立即物理删除,而是会被拷贝到 Undo Log 中,形成一个版本链。这个版本链就是 MVCC 实现多版本数据的核心。 - 生命周期: 当事务提交后,对应的 Undo Log 不会立刻删除,因为可能还有其他正在运行的事务需要读取这个旧版本的数据。只有当系统中没有比这个 Undo Log 更早的读视图(ReadView)时,它才会被 Purge 线程清理掉。
c. Read View (读视图)
- 是什么: Read View 是事务在进行快照读(普通的
SELECT
语句)时产生的一个一致性视图。它决定了这个事务能看到哪个版本的数据,看不到哪个版本的数据。 - 关键组成部分: 一个 Read View 主要包含以下几个关键属性:
- m_ids: 生成 Read View 时,系统中所有活跃(尚未提交)的事务ID的集合。
- min_trx_id:
m_ids
中的最小值。 - max_trx_id: 生成 Read View 时,系统应该分配给下一个事务的ID(即当前最大事务ID + 1)。
- creator_trx_id: 创建这个 Read View 的事务的ID(只有执行写操作的事务才有ID,只读事务的ID为0)。
MVCC 的工作流程:版本链与可见性算法
现在我们结合以上概念,来看一个 SELECT
语句是如何通过 MVCC 找到它应该看到的那个数据版本的。
假设我们有一行数据,经过多个事务修改,形成了一个版本链(通过 DB_ROLL_PTR
指针连接)。
版本 | 事务ID (DB_TRX_ID) | 数据内容 | 回滚指针 (DB_ROLL_PTR) |
---|---|---|---|
V4 | 105 | ... |
指向 V3 在 Undo Log 中的位置 |
V3 | 102 | ... |
指向 V2 在 Undo Log 中的位置 |
V2 | 101 | ... |
指向 V1 在 Undo Log 中的位置 |
V1 | 100 | ... |
NULL |
现在,一个 事务ID=103 的事务发起了一个快照读(SELECT
),它生成了一个 Read View,假设此时:
m_ids
= [101, 102] (事务101和102都还未提交)min_trx_id
= 101max_trx_id
= 104creator_trx_id
= 103
这个事务会从最新的版本 V4 开始,沿着版本链依次判断每个版本是否对自己可见。可见性判断规则如下:
- 如果被访问版本的
DB_TRX_ID
等于creator_trx_id
,说明这是当前事务自己修改的,可见。 - 如果被访问版本的
DB_TRX_ID
小于min_trx_id
,说明这个版本是在当前 Read View 创建之前就已经提交的,可见。 - 如果被访问版本的
DB_TRX_ID
大于等于max_trx_id
,说明这个版本是在当前 Read View 创建之后才开启的事务修改的,不可见。 - 如果被访问版本的
DB_TRX_ID
在min_trx_id
和max_trx_id
之间(即m_ids
的范围内),则需要判断DB_TRX_ID
是否在m_ids
列表中:- 如果在,说明创建 Read View 时,修改这个版本的事务还处于活跃状态(未提交),该版本不可见。
- 如果不在,说明创建 Read View 时,修改这个版本的事务已经被提交了,该版本可见。
如果某个版本对当前事务不可见,就顺着回滚指针 DB_ROLL_PTR
找到上一个版本,重复上述判断规则,直到找到第一个可见的版本为止。
套用到我们的例子:
- 判断 V4 (
trx_id=105
):- 105 >=
max_trx_id=104
,满足规则3,不可见。找上一个版本 V3。
- 105 >=
- 判断 V3 (
trx_id=102
):- 102 在
min_trx_id=101
和max_trx_id=104
之间。 - 检查
m_ids=[101,102]
,102 在列表中,满足规则4,不可见。找上一个版本 V2。
- 102 在
- 判断 V2 (
trx_id=101
):- 101 在
min_trx_id=101
和max_trx_id=104
之间。 - 检查
m_ids=[101,102]
,101 在列表中,满足规则4,不可见。找上一个版本 V1。
- 101 在
- 判断 V1 (
trx_id=100
):- 100 <
min_trx_id=101
,满足规则2,可见。
- 100 <
最终,事务103 读到的就是 V1 这个旧版本的数据。
MVCC 与不同事务隔离级别的交互
MVCC 的行为会根据事务的隔离级别发生变化,主要体现在 Read View 生成的时机不同。
隔离级别 | 生成 Read View 的时机 | MVCC 行为说明 |
---|---|---|
读未提交 (READ UNCOMMITTED) | N/A | 根本不使用 MVCC。直接读取数据的最新版本,无论是否提交,所以会脏读。 |
读已提交 (READ COMMITTED) | 每次执行快照读(SELECT)时 | 每次 SELECT 都会生成一个新的 Read View。这保证了每次读都能看到最新已经提交的数据,解决了脏读,但可能导致不可重复读和幻读。 |
可重复读 (REPEATABLE READ) | 第一次执行快照读(SELECT)时 | 只在第一次 SELECT 时生成一个 Read View,后续所有的 SELECT 操作都复用这个 Read View。这保证了在整个事务中,看到的数据内容是一致的(第一次读时的快照),解决了不可重复读。通过 Next-Key Lock 机制在一定程度上缓解了幻读。 |
串行化 (SERIALIZABLE) | N/A | 不使用 MVCC。所有读操作都会对记录加共享锁(S锁),读写相互阻塞,通过强制事务串行来解决问题。 |
举例说明 RR 和 RC 的区别:
假设账户余额为 100,有两个事务:
事务A(ID=101):启动后查询余额,得到 100。
事务B(ID=102):更新余额为 200 并提交。
事务A:再次查询余额。
在 RC 级别下:
- 事务A的第二次查询会重新生成一个 Read View。此时系统中已无活跃事务,事务B(102)已提交。根据规则,它能看见 102 修改的版本,所以查询结果为 200。(不可重复读)
在 RR 级别下:
- 事务A的第二次查询会复用第一次生成的 Read View。在那个旧的 Read View 中,事务B(102)可能还是活跃的(或在其之后),所以根据可见性规则,它看不到 102 提交的版本,只能看到更早的版本,查询结果依然是 100。(可重复读)
总结:MVCC 的优缺点
优点:
- 高并发: 读不加锁,读写操作不互相阻塞,大大提高了系统的并发处理能力。
- 保证一致性: 提供了基于时间点的一致性视图,保证事务的隔离性。
缺点:
- 额外存储: 需要额外的空间(Undo Log)来存储旧版本数据。
- 维护开销: 需要维护版本链,并且 Purge 线程需要定期清理不再需要的旧版本数据。
- 部分问题: 在 RR 级别下,MVCC 并不能完全解决幻读问题(需要通过 Next-Key Lock 来解决)。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 技术之路!
评论