了解MVCC之前,我们首先需要了解以下两个概念:一致性非锁定读和锁定读,了解这两个概念之后我们在逐步分析MVCC。
一致性非锁定读和锁定读
一致性非锁定读(快照读)
对于 一致性非锁定读的实现,通常做法是加一个版本号或者时间戳字段,在更新数据的同时版本号 + 1 或者更新时间戳。查询时,将当前可见的版本号与对应记录的版本号进行比对,如果记录的版本小于可见版本,则表示该记录可见。
在 InnoDB
存储引擎中,MVCC
就是对非锁定读的实现。如果读取的行正在执行 DELETE
或 UPDATE
操作,这时读取操作不会去等待行上锁的释放。相反地,InnoDB
存储引擎会去读取行的一个快照数据,对于这种读取历史数据的方式,我们叫它快照读 。
在 Repeatable Read
和 Read Committed
两个隔离级别下,如果是执行普通的 select
语句(不包括 select ... lock in share mode
,select ... for update
)则会使用 一致性非锁定读(MVCC)
。并且在 Repeatable Read
下 MVCC
实现了可重复读和防止部分幻读。
锁定读(当前读)
如果执行的是下列语句,就是 锁定读
select ... lock in share mode
select ... for update
insert
、update
、delete
操作
在锁定读下,读取的是数据的最新版本,这种读也被称为 当前读
。锁定读会对读取到的记录加锁:
select ... lock in share mode
:对记录加S
锁,其它事务也可以加S
锁,如果加x
锁则会被阻塞select ... for update
、insert
、update
、delete
:对记录加X
锁,且其它事务不能加任何锁
在一致性非锁定读下,即使读取的记录已被其它事务加上 X
锁,这时记录也是可以被读取的,即读取的快照数据。上面说了,在 Repeatable Read
下 MVCC
防止了部分幻读,这边的 “部分” 是指在 一致性非锁定读
情况下,只能读取到第一次查询之前所插入的数据(根据 Read View 判断数据可见性,Read View 在第一次查询时生成)。但是!如果是 当前读
,每次读取的都是最新数据,这时如果两次查询中间有其它事务插入数据,就会产生幻读。所以, InnoDB
在实现Repeatable Read
时,如果执行的是当前读,则会对读取的记录使用 Next-key Lock
,来防止其它事务在间隙间插入数据。
MVCC 具体实现
MVCC
的实现主要依赖于:隐藏字段、Read View、undo log。在内部实现中,InnoDB
通过数据行的 DB_TRX_ID
和 Read View
来判断数据的可见性,如不可见,则通过数据行的 DB_ROLL_PTR
找到 undo log
中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 Read View
之前已经提交的修改和该事务本身做的修改。
隐藏字段
在内部,InnoDB
存储引擎为每行数据添加了三个隐藏字段:
DB_TRX_ID(6字节)
:表示最后一次插入或更新该行的事务 id。此外,delete
操作在内部被视为更新,只不过会在记录头Record header
中的deleted_flag
字段将其标记为已删除。DB_ROLL_PTR(7字节)
回滚指针,指向该行的undo log
。如果该行未被更新,则为空。DB_ROW_ID(6字节)
:如果没有设置主键且该表没有唯一非空索引时,InnoDB
会使用该 id 来生成聚簇索引。
ReadView
class ReadView {
/* ... */
private:
trx_id_t m_low_limit_id; /* 大于等于这个 ID 的事务均不可见 */
trx_id_t m_up_limit_id; /* 小于这个 ID 的事务均可见 */
trx_id_t m_creator_trx_id; /* 创建该 Read View 的事务ID */
trx_id_t m_low_limit_no; /* 事务 Number, 小于该 Number 的 Undo Logs 均可以被 Purge */
ids_t m_ids; /* 创建 Read View 时的活跃事务列表 */
m_closed; /* 标记 Read View 是否 close */
}
ReadView主要是用来做可见性判断,里面保存了 “当前对本事务不可见的其他活跃事务”
主要有以下字段:
m_low_limit_id
:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见。m_up_limit_id
:活跃事务列表m_ids
中最小的事务 ID,如果m_ids
为空,则m_up_limit_id
为m_low_limit_id
。小于这个 ID 的数据版本均可见。m_ids
:Read View
创建时其他未提交的活跃事务 ID 列表。创建Read View
时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids
不包括当前事务自己和已提交的事务(正在内存中)。m_creator_trx_id
:创建该Read View
的事务 ID。
undo log
undo log
主要有两个作用:
- 当事务回滚时用于将数据恢复到修改前的样子。
- 另一个作用是
MVCC
,当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过undo log
读取之前的版本数据,以此实现非锁定读。
在 InnoDB
存储引擎中 undo log
分为两种: insert undo log
和 update undo log
:
insert undo log
:指在insert
操作中产生的undo log
。因为insert
操作的记录只对事务本身可见,对其他事务不可见,故该undo log
可以在事务提交后直接删除。不需要进行purge
操作。
insert
时的数据初始状态:
update undo log
:update
或delete
操作中产生的undo log
。该undo log
可能需要提供MVCC
机制,因此不能在事务提交时就进行删除。提交时放入undo log
链表,等待purge线程
进行最后的删除。
数据第一次被修改时:
数据第二次被修改时:
不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log
成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。
数据可见性算法(重要)
在 InnoDB
存储引擎中,创建一个新事务后,执行每个 select
语句前,都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号。其实简单的说保存的是系统中当前不应该被本事务看到的其他事务 ID 列表(即 m_ids)。当用户在这个事务中要读取某个记录行的时候,InnoDB
会将该记录行的 DB_TRX_ID
与 Read View
中的一些变量及当前事务 ID 进行比较,判断是否满足可见性条件。具体判断流程如下所示:
-
如果记录 DB_TRX_ID < m_up_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之前就提交了,所以该记录行的值对当前事务是可见的
-
如果 DB_TRX_ID >= m_low_limit_id,那么表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照之后才修改该行,所以该记录行的值对当前事务不可见。跳到步骤 5
-
m_ids 为空,则表明在当前事务创建快照之前,修改该行的事务就已经提交了,所以该记录行的值对当前事务是可见的
-
如果 m_up_limit_id <= DB_TRX_ID < m_low_limit_id,表明最新修改该行的事务(DB_TRX_ID)在当前事务创建快照的时候可能处于“活动状态”或者“已提交状态”;所以就要对活跃事务列表 m_ids 进行查找(源码中是用的二分查找,因为是有序的)
-
如果在活跃事务列表 m_ids 中能找到 DB_TRX_ID,表明:① 在当前事务创建快照前,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了,但没有提交;或者 ② 在当前事务创建快照后,该记录行的值被事务 ID 为 DB_TRX_ID 的事务修改了。这些情况下,这个记录行的值对当前事务都是不可见的。跳到步骤 5
-
在活跃事务列表中找不到,则表明“id 为 trx_id 的事务”在修改“该记录行的值”后,在“当前事务”创建快照前就已经提交了,所以记录行对当前事务可见
-
-
在该记录行的 DB_ROLL_PTR 指针所指向的
undo log
取出快照记录,用快照记录的 DB_TRX_ID 跳到步骤 1 重新开始判断,直到找到满足的快照版本或返回空
RC 和 RR 隔离级别下 MVCC 的差异
在面试过程中,这个问题感觉还是很常见的,所以还是有必要说一下。
虽然两者都使用MVCC,但它们生成 Read View
的时机却不同:
- 在 RC 隔离级别下的
每次select
查询前都生成一个Read View
(m_ids 列表) - 在 RR 隔离级别下只在事务开始后
第一次select
数据前生成一个Read View
(m_ids 列表)
如何解决不可重复读?
上面我们已经说明了RC和RR两个隔离级别使用MVCC的区别在于生成ReadView
的时机不同,我们接下来通过一个例子深入理解一下。
举个例子:
101事务 | 102事务 | 103事务 | |
---|---|---|---|
T1 | begin | ||
T2 | begin | begin | |
T3 | update user set name = 张三 where id = 1; | ||
T4 | update user set name = 李四 where id = 1; | select * from user where id = 1; | |
T5 | commit | update user set name =王五 where id = 1; | |
T6 | select * from user where id = 1; | ||
T7 | update user set name =赵六 where id = 1; | ||
T8 | commit | ||
T9 | select * from user where id = 1; | ||
T10 | commit |
RC模式
-
假设时间线来到 T4 ,那么此时数据行 id = 1 的版本链为:
由于 RC 级别下每次查询都会生成
Read View
,并且事务 101、102 并未提交,此时103
事务生成的Read View
中活跃的事务m_ids
为:[101,102] ,m_low_limit_id
为:104,m_up_limit_id
为:101,m_creator_trx_id
为:103- 此时最新记录的
DB_TRX_ID
为 101,m_up_limit_id <= 101 < m_low_limit_id,所以要在m_ids
列表中查找,发现DB_TRX_ID
存在列表中,那么这个记录不可见。 - 根据
DB_ROLL_PTR
找到undo log
中的上一版本记录,上一条记录的DB_TRX_ID
还是 101,不可见。 - 继续找上一条
DB_TRX_ID
为 1,满足 1 < m_up_limit_id,可见,所以事务 103 查询到数据为name = 菜花
。
- 此时最新记录的
-
时间线来到T6:
因为在 RC 级别下,重新生成
Read View
,这时事务 101 已经提交,102 并未提交,所以此时Read View
中活跃的事务m_ids
:[102] ,m_low_limit_id
为:104,m_up_limit_id
为:102,m_creator_trx_id
为:103- 此时最新记录的
DB_TRX_ID
为 102,m_up_limit_id <= 102 < m_low_limit_id,所以要在m_ids
列表中查找,发现DB_TRX_ID
存在列表中,那么这个记录不可见 - 根据
DB_ROLL_PTR
找到undo log
中的上一版本记录,上一条记录的DB_TRX_ID
为 101,满足 101 < m_up_limit_id,记录可见,所以在T6
时间点查询到数据为name = 李四
,与时间 T4 查询到的结果不一致,不可重复读!
- 此时最新记录的
-
时间线来到T9:
继续重新生成
Read View
, 这时事务 101 和 102 都已经提交,所以 m_ids 为空,则 m_up_limit_id = m_low_limit_id = 104,最新版本事务 ID 为 102,满足 102 < m_low_limit_id,可见,查询结果为name = 赵六
。
可以发现,由于每次查询开始时都会重新生成ReadView
,所以导致不可重复读。
RR模式
在可重复读级别下,只会在事务开始后第一次读取数据时生成一个 Read View(m_ids 列表不变)。
-
T4时间线:
在当前执行
select
语句时生成一个Read View
,此时m_ids
:[101,102] ,m_low_limit_id
为:104,m_up_limit_id
为:101,m_creator_trx_id
为:103此时和 RC 级别下一样:
- 最新记录的
DB_TRX_ID
为 101,m_up_limit_id <= 101 < m_low_limit_id,所以要在m_ids
列表中查找,发现DB_TRX_ID
存在列表中,那么这个记录不可见。 - 根据
DB_ROLL_PTR
找到undo log
中的上一版本记录,上一条记录的DB_TRX_ID
还是 101,不可见。 - 继续找上一条
DB_TRX_ID
为 1,满足 1 < m_up_limit_id,可见,所以事务 103 查询到数据为name = 菜花
。
- 最新记录的
-
T6时间线:
在 RR 级别下只会生成一次
Read View
,所以此时依然沿用m_ids
:[101,102] ,m_low_limit_id
为:104,m_up_limit_id
为:101,m_creator_trx_id
为:103- 最新记录的
DB_TRX_ID
为 102,m_up_limit_id <= 102 < m_low_limit_id,所以要在m_ids
列表中查找,发现DB_TRX_ID
存在列表中,那么这个记录不可见。 - 根据
DB_ROLL_PTR
找到undo log
中的上一版本记录,上一条记录的DB_TRX_ID
为 101,不可见。 - 继续根据
DB_ROLL_PTR
找到undo log
中的上一版本记录,上一条记录的DB_TRX_ID
还是 101,不可见。 - 继续找上一条
DB_TRX_ID
为 1,满足 1 < m_up_limit_id,可见,所以事务 103 查询到数据为name = 菜花
。
- 最新记录的
-
T9时间线:
此时情况跟 T6 完全一样,由于已经生成了
Read View
,此时依然沿用m_ids
:[101,102] ,所以查询结果依然是name = 菜花
。
所以由上面的例子可见,在RR模式下能解决不可重复读问题。
如何解决幻读?
InnoDB
存储引擎在 RR 级别下通过 MVCC
和 Next-key Lock
来解决幻读问题:
1、执行普通 select
,此时会以 MVCC
快照读的方式读取数据
在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 Read View
,并使用至事务提交。所以在生成 Read View
之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的 “幻读”。
2、执行 select…for update/lock in share mode、insert、update、delete 等当前读
在当前读下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读!InnoDB
使用 Next-key Lock
来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。
以上便是MVCC相关内容,如有错误请及时指正。