MVCC 三件套:隐藏字段(TRX_ID,ROLL_PTR,ROW_ID), undolog, ReadView.
RC 和 RR 区别在于,RC 下每次 select 都创建 ReadView,RR 只在第一次创建。

InnoDB 隐藏数据列

MVCC 的实现依赖于:隐藏字段、Read View、undo log。在内部实现中,InnoDB 通过数据行的 DB_TRX_IDRead 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; 
 trx_id_t m_up_limit_id;  
 trx_id_t m_creator_trx_id; 
 trx_id_t m_low_limit_no; 
 ids_t m_ids;   
 m_closed;    /* 标记 Read View 是否 close */
}

Read View 主要是用来做可见性判断,里面保存了 “当前对本事务不可见的其他活跃事务”

主要有以下字段:

  • m_low_limit_id :目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见。
  • m_up_limit_id :活跃事务列表 m_ids 中最小的事务 ID,如果 m_ids 为空,则 m_up_limit_idm_low_limit_id。小于这个 ID 的数据版本均可见。
  • m_creator_trx_id :创建该 Read View 的事务 ID。
  • m_idsRead View 创建时其他未提交的活跃事务 ID 列表。创建 Read View 时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids 不包括当前事务自己和已提交的事务(正在内存中)。
  • m_low_limit_no : 事务 Number, 小于该 Number 的 Undo Logs 均可以被 Purge

事务可见性示意图

trans_visible|600

Undolog 日志

undo log 主要有两个作用:

  • 当事务回滚时用于将数据恢复到修改前的样子
  • 另一个作用是 MVCC ,当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过 undo log 读取之前的版本数据,以此实现非锁定读

InnoDB 存储引擎中 undo log 分为两种: insert undo logupdate undo log

  1. ** insert undo log ** :指在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对其他事务不可见,故该 undo log 可以在事务提交后直接删除。不需要进行 purge 操作

** insert 时的数据初始状态:**

|600

  1. ** update undo log ** :updatedelete 操作中产生的 undo log。该 undo log 可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。提交时放入 undo log 链表,等待 purge 线程 进行最后的删除

数据第一次被修改时:

|600

数据第二次被修改时:

|600

不同事务或者相同事务的对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。

数据可见性算法

InnoDB 存储引擎中,创建一个新事务后,执行每个 select 语句前,都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号。其实简单的说保存的是系统中当前不应该被本事务看到的其他事务 ID 列表(即 m_ids)。当用户在这个事务中要读取某个记录行的时候,InnoDB 会将该记录行的 DB_TRX_IDRead View 中的一些变量及当前事务 ID 进行比较,判断是否满足可见性条件

具体的比较算法如下:图源

|600|600

  • trx_id == creator_trx_id,访问自己修改的记录,可以访问。
  • trx_id < up_limit_id,当前版本在本事务生成之前已经提交,可以访问。
  • trx_id >= low_limit_id,当前版本对应的事务在本事务生成之后生成的,不可访问。
  • low_limit_id <= trx_id < up_limit_id,判断是否在 trx_ids 列表中。
    • 在列表中,创建 ReadView 时,当前版本对应的事务仍在活跃,不可访问。
    • 不在列表中,创建 ReadView 时,当前版本对应的事务已被提交,可以访问。

如果不可访问,则根据 DB_ROLL_PTR 指针所指向的 undo log 取出快照记录,用快照记录的 trx_id 重新判断,直到找到满足的快照版本或返回空。

RC 和 RR 隔离级别下 MVCC 的差异/不可重复读

在事务隔离级别 RCRR (InnoDB 存储引擎的默认事务隔离级别)下,InnoDB 存储引擎使用 MVCC (非锁定一致性读),但它们生成 Read View 的时机却不同

  • 在 RC 隔离级别下的 每次select 查询前都生成一个 Read View (m_ids 列表)。阻止了脏读的发生,但会出现不可重复读。
  • 在 RR 隔离级别下只在事务开始后 第一次select 数据前生成一个 Read View (m_ids 列表), 解决了不可重复读和幻读 **。

举个例子:

MVCC 在 MySQL 的 InnoDB 中的实现-11.jpg|600

RC 情况

  1. 假设时间线来到 T4 ,那么此时数据行 id = 1 的版本链为
    |600

由于 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 = 菜花
  1. ** 时间线来到 T6 ,数据的版本链为 :**

markdown|600

因为在 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 查询到的结果不一致,不可重复读!

  1. ** 时间线来到 T9 ,数据的版本链为 :**

markdown|600

重新生成 Read View,这时事务 101 和 102 都已经提交,所以 m_ids 为空,则 m_up_limit_id = m_low_limit_id = 104,最新版本事务 ID 为 102,满足 102 < m_low_limit_id,可见,查询结果为 name = 赵六

RR 情况

  1. ** 在 T4 情况下的版本链为 :**

markdown|600

在当前执行 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 = 菜花
  1. ** 时间点 T6 情况下 :**

markdown|600

在 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 = 菜花

  1. 时间点 T9 情况下:

markdown|600

此时情况跟 T6 完全一样,由于已经生成了 Read View,此时依然沿用 ** m_ids :[101, 102]** ,所以查询结果依然是 name = 菜花

MVCC 幻读的解决方式

InnoDB 存储引擎在 RR 级别下通过 MVCCNext-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 来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会发生幻读