MVCC(多版本并发控制)是InnoDB实现一致性读的核心机制,主要用于解决读写并发冲突,让读操作尽量不阻塞写操作。它通过隐藏字段、undo log版本链和Read View三个核心组件,结合可见性判断算法,为快照读提供数据版本可见性。RC和RR隔离级别的区别在于Read View的创建时机不同,RC每次快照读都新建Read View,而RR通常复用第一次快照读创建的Read View,从而实现可重复读。
首先,什么是 MVCC
MVCC 的中文是多版本并发控制,它是 InnoDB 在处理普通 SELECT 这类快照读时实现一致性读的核心机制,主要服务于读已提交(RC) 和 可重复读(RR) 这两个隔离级别。
并发事务里常见的冲突主要有两类:
- 读写冲突:事务 A 在读一行数据,事务 B 同时修改这行数据
- 写写冲突:两个事务同时修改同一行数据
写写冲突仍然需要依赖锁来保证串行化;MVCC 主要解决的是读写并发时,如何让读操作尽量不阻塞写操作。
需要注意,MVCC 主要用于快照读,不适用于所有读操作。像 SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE 这类当前读,仍然要结合锁来工作。
MVCC 的三个核心点
隐藏字段
trx_id:最后一次修改该记录版本的事务 IDroll_pointer:指向该记录上一个历史版本的 undo log 指针
undo log 版本链
一行记录每次被修改时,旧版本不会立刻消失,而是被写入 undo log,再通过
roll_pointer串起来,形成一条从新到旧的版本链。Read View
Read View 可以理解为事务做快照读时拿到的一份“可见性规则”。事务会基于这份规则,从版本链上判断哪个版本对自己可见。
Read View 中几个关键字段
m_ids:创建 Read View 时,系统中活跃的读写事务 ID 集合min_trx_id:m_ids中最小的事务 IDmax_trx_id:创建 Read View 时,系统将要分配给下一个事务的 IDcreator_trx_id:创建这个 Read View 的事务 ID
这里要特别注意:
- 记录上的
trx_id,表示“这个版本是由哪个事务生成的” creator_trx_id,表示“当前正在读取数据的事务是谁”
这两个不是同一个概念。
可见性判断算法
当事务进行快照读时,会从版本链的最新版本开始判断可见性。这里参与判断的 trx_id,指的是“当前正在检查的那个记录版本的事务 ID”,不是当前读请求所属事务的 ID。
判断规则可以概括为:
如果版本的
trx_id == creator_trx_id,该版本可见原因:这是当前事务自己生成的版本,当前事务当然可以看到。
如果版本的
trx_id < min_trx_id,该版本可见原因:说明生成这个版本的事务,在 Read View 创建前就已经提交了。
如果版本的
trx_id >= max_trx_id,该版本不可见原因:说明这个版本对应的事务,是在 Read View 创建之后才开始的。
如果
min_trx_id <= trx_id < max_trx_idtrx_id在m_ids中:不可见,说明该事务在创建 Read View 时仍然活跃,尚未提交trx_id不在m_ids中:可见,说明该事务在创建 Read View 前已经提交
如果某个版本不可见,就顺着版本链继续找更老的版本,直到找到第一个可见版本为止。
RC 和 RR 的本质区别
RC(读已提交)
每次执行快照读时,都会创建新的 Read View,所以同一个事务里两次普通
SELECT,可能看到不同的已提交结果,这就是不可重复读产生的原因。RR(可重复读)
默认情况下,事务中的第一次快照读会创建 Read View,后续快照读复用同一个 Read View,因此同一个事务里的多次普通
SELECT通常会看到同一份快照。
这里的表述要稍微严谨一点:
- RR 下“保持一致”的是普通快照读看到的历史快照
- 如果当前事务自己更新了数据,那么它仍然可以看到自己更新后的结果
- 如果执行的是当前读,也不走这套快照可见性逻辑
另外,InnoDB 中 RR 并不是“事务一启动就一定创建 Read View”。通常是第一次执行快照读时才创建;如果显式使用了 START TRANSACTION WITH CONSISTENT SNAPSHOT,则会在事务开始时创建一致性视图。
举例说明版本选择过程
假设有三个事务并发执行:
- 事务 101:较早启动,修改了某行数据,但还没有提交
- 事务 102:稍后修改了同一行数据,并且已经提交
- 事务 103:当前事务,正在执行一次快照读
假设这行数据原本的最后已提交版本是 trx_id=100。
之后:
- 事务 102 修改该行,生成一个新版本,
trx_id=102 - 事务 101 又基于更新后的数据生成更“新”的版本,
trx_id=101,但它尚未提交
于是版本链可以表示为:
(101) -> (102) -> (100)
其中 (101) 是当前记录上最新的版本。
现在事务 103 在执行第一次快照读时创建 Read View。此时:
- 活跃事务只有 101,所以
m_ids = {101} min_trx_id = 101max_trx_id = 104,表示下一个将被分配的事务 ID 是 104creator_trx_id = 103
事务 103 读取这行数据时,会这样判断:
先看最新版本
(101)trx_id=101落在min_trx_id <= trx_id < max_trx_id区间内,并且 101 在m_ids中,说明该版本对应的事务在创建 Read View 时仍然活跃,因此不可见。顺着版本链看下一个版本
(102)trx_id=102也还是落在min_trx_id <= trx_id < max_trx_id区间内,但 102 不在m_ids中,说明它在 Read View 创建前已经提交,因此这个版本可见。返回版本
(102)的数据
所以,事务 103 读到的是事务 102 提交后的值,而不是事务 101 尚未提交的值。
如果隔离级别是 RC,那么事务 103 每次执行快照读都会重新创建 Read View:
- 只要事务 101 还没提交,新的 Read View 中仍然会把 101 视为活跃事务,因此看不到版本 101
- 一旦事务 101 提交,下一次快照读重新生成 Read View 时,101 不再出现在活跃事务列表中,此时就可能读到版本 101