MySQL MVCC 可见性判断规则实现分析

引言

MySQL MVCC 可见性判断规则实现分析网上有很多文章,但是看过后始终不理解,因此整理了本篇文章。

概念

MVCC

事务写操作中加写锁,因此并发写时会发生锁等待,这个现象可以接受。

但是如果读写操作冲突,比如先读后写或先写后读也发生锁等待,将严重影响吞吐,这个现象无法接受,因此出现了 MVCC。

MVCC(multi version concurrency control,多版本并发控制)的主要作用包括:

  • 实现事务的隔离性,同一时刻维护同一行数据的多个版本,因此每个事务中可以看到一个一致的、不受其他并发事务影响的数据快照;
  • 解决读写冲突,允许同一行数据并发读写,原因是数据读取的是快照,因此其他事务修改不影响读取,也就是快照读(Consistent Nonlocking Reads,一致性非锁定读取,一致性读取)。

那么特定的事务可以看到哪个版本的数据呢?这就是 MVCC 的核心问题 - 可见性判断规则,也是本文讨论的主题。

作为事务隔离性和一致性实现的不同方式,锁是一种悲观的并发控制机制,MVCC 是一种乐观的并发控制机制

可见性判断规则讨论

原则上对于特定的事务,事务开启之前的提交可见,否则不可见。显然,这就是事务隔离级别【读提交】的定义了。

因此假如可以对比事务的开始时间与数据事务的提交时间,如果判断开始时间晚于提交时间,就不可见,个人理解理论上是可行的。

文章 MVCC学习圣经:一文穿透MySQL MVCC,吊打面试官 中提出了相似的观点,并举例说明,如下图所示。

其中对于事务 4 分别判断其他快照数据版本的可见性:

  • 事务 1,可见,原因是事务提交时间早于事务 4 的开始时间;
  • 事务 2、3,不可见,原因是事务提交时间晚于事务 4 的开始时间;
  • 事务 5,不可见,同样原因是事务提交时间晚于事务 4 的开始时间,实际上该事务开始时间也晚于事务 4。

当然这种实现需要以下前提:

  • 数据行中保存事务的提交时间
  • 事务中保存事务的开始时间
  • 所有事务的开始时间与提交时间均不重复

实际上以上假设并不一定都成立,而 MySQL 中采用的实现并不是根据时间戳判断,而是根据版本号(事务 ID)判断

事务 ID 的优点是单调递增(全局唯一 ID),缺点是可以表示事务开始的顺序,却无法表示事务提交的顺序

针对该问题,实现中又引入了活跃事务 ID 列表,具体将在下文中讲解。

实现分析

MVCC 的实现基于以下两部分:

  • undo log,用于保存多个版本的数据,其中 基于隐藏字段回滚指针链接多个版本
  • ReadView,用于判断特定事务是否可见指定版本的数据,其中 基于隐藏字段事务 ID 确认更新数据的事务

下面具体分析 MVCC 的实现与可见性判断规则。

实现

undo log

MySQL 中多个版本的数据保存在 undo log 中,如下图所示,通过指针链接多个版本。

其中:

  • 数据行的隐藏字段回滚指针(roll_pointer)保存前一个版本的 undo log;
  • 每个 undo log 也都有 roll_pointer 属性又指向更早的版本。

多个回滚指针将数据行的多个版本连接成一个链表,称为版本链,版本链的头节点就是当前记录最新的值。因此通过当前记录与 undo log 可以定位到记录的历史版本

但是需要注意的是并没有保存每个历史版本的完整数据,而是每次根据 undo log 实时构造历史版本数据,undo log 中仅保存更新列的旧值。

这样实现的优点是可以减少磁盘空间占用,缺点是构造数据时消耗 CPU,显然这是典型的以时间换空间的思想。

数据行 trx_id 属性

undo log 中保存数据行的多个版本,其中使用事务 ID 作为版本号。

MySQL 中每行数据都有一个隐藏列 trx_id,用于保存最近一次改动当前数据的事务 ID。

关于事务 ID,需要注意以下两点:

  • 事务 ID 的生成时间,需要注意的是 读事务 ID 等于 0,只有当事务第一次写入时才会将读事务转换成写事务,并分配事务 ID
  • 数据行中更新事务 ID 的时间,并不是在事务提交时,而是在事务执行过程中,在修改索引中行数据时同步更新隐藏字段,包括事务 ID 与回滚指针,对应 row_upd_index_entry_sys_field 函数。

这里可以回顾下前文的假设,是否可以对比事务的开始时间与数据行中保存的事务的提交时间,现在可以知道数据行中当前保存的并不是提交时间,只是执行时间。

可见性判断规则

可见性判断规则是判断特定事务是否可见被访问的数据行版本的记录,期间从数据行中获取修改被访问版本的事务 ID。

这时候就有了两个事务 ID,分别对应当前事务与修改被访问版本的事务,其中当前事务不变,根据版本链遍历每个版本的事务。

而当前事务在启动时保存以下数据:

  • creator_trx_id,表示事务 ID;
  • trx_ids,表示活跃(未提交)事务 ID 列表,其中不包括当前事务和已提交事务;
  • up_limit_id,表示活跃事务列表中的最小事务 ID,如果事务列表为空,up_limit_id 等于 low_limit_id;
  • low_limit_id,表示给下一个事务分配的事务 ID,因此不是活跃事务列表中的最大事务 ID。

因此 如果被访问版本的事务满足以下两种场景之一时,当前事务不可见,包括:

  • 未开启的事务,也就是事务开启时间晚于当前最大事务,判断条件是大于等于 low_limit_id;
  • 已开启未提交的事务,判断条件是在 trx_ids 中。

相反, 如果被访问版本的事务满足以下两种场景之一时,当前事务可见,包括:

  • 是当前事务,判断条件是等于 creator_trx_id;
  • 已提交事务,判断条件是小于 up_limit_id。

如果当前事务对于被访问版本不可见,就使用 undo log 获取上一个版本的数据重新判断。如果所有版本都没有可见的数据,返回 NULL。

到这里才可以发现可见性判断规则实现的巧妙之处,其中完全用到了事务启动时保存的四个属性,这里提到的事务启动时保存的属性其实被封装保存在 ReadView 中

文章 MVCC学习圣经:一文穿透MySQL MVCC,吊打面试官 中对 ReadView 属性的作用进行了分类,便于理解可见性判断规则的实现。

ReadView

ReadView(一致性视图)中并没有保存用户数据,而是保存事务 ID,比如活跃事务 ID 列表,用于可见性判断

本节关注另一个问题,ReadView 什么时候生成?

对于普通的 select 语句,在第一次执行查询语句时生成 ReadView。

但是需要需要注意的是 select 语句属于只读事务,事务 ID 等于 0,因此 select 语句有生成 ReadView,并没有分配事务 ID

当然这样并不影响可见性判断。原因是可见性判断中唯一使用到当前事务 ID 的场景是等于 creator_trx_id,但是当前事务还没有写入,因此不会出现这种情况。

文章 完整版:Innodb到底是怎么加锁的 中有简单介绍 select 语句生成 ReadView 的过程,对应 row_search_mvcc 函数,相关代码如下所示。

 // select_lock_type == LOCK_NONE 表示不加锁
 } elseif (prebuilt->select_lock_type == LOCK_NONE) {
/* This is a consistent read */
/* Assign a read view for the query */

// 对普通的SELECT的处理,在查询开启前需要生成ReadView
// 创建readview,但是事务id等于0,原因是selec是只读事务,第一次写入时分配事务id
if (!srv_read_only_mode) {
   trx_assign_read_view(trx);
  }

  prebuilt->sql_stat_start = FALSE;
 } else {
wait_table_again:
// 对加锁读的语句的处理,在首次读取记录(prebuilt->sql_stat_start表示是否是首次读取)前,需要添加表级别的意向锁(IS或IX锁)。
  err = lock_table(0, index->table,
     prebuilt->select_lock_type == LOCK_S
     ? LOCK_IS : LOCK_IX, thr);

  prebuilt->sql_stat_start = FALSE;
 }

其中:

  • prebuilt->select_lock_type 表示加锁的类型, LOCK_NONE 表示不加锁, LOCK_S 表示加 S 锁(比如执行 SELECT … LOCK IN SHARE MODE 时), LOCK_X 表示加 X 锁(比如执行 SELECT … FOR UPDATE、DELETE、UPDATE 时);
  • 对于 select 语句,不加锁,对应只读事务,第一次执行查询语句时调用 trx_assign_read_view 函数生成 ReadView;
  • 对于写入语句,加锁,第一次读取记录时给表加意向锁。

事务隔离级别 RC 与 RR 中 ReadView 的生成与关闭时间不同,从而实现不同的数据可见性,也就是不同的隔离性。其中:

  • RR,事务第一次执行 select 语句时生成 ReadView,在事务结束时关闭,因此 事务执行期间读取到的数据保持一致,除非当前事务更新数据;
  • RC,事务的每条 select 语句都会生成 ReadView,并在 select 语句执行结束时关闭,因此 语句执行期间读取到的数据保持一致,不同语句之间数据不一致。

因此对于默认事务隔离级别 RR,ReadView 和事务是一对一的关系。

关于 ReadView,还有一个问题,那就是一致性视图的范围,下面进行测试。

测试前查看表中的前两行数据。

mysql> select id, c from t where id in (2,3);
+----+------+
| id | c    |
+----+------+
|  2 | NULL |
|  3 |    3 |
+----+------+
2 rows in set (0.00 sec)

测试流程见下表,主要流程是事务 1 中先查询第一行,然后事务 2 中更新第二行。

session 1 session 2
begin;
select c from t where id=2; // 2
begin;
select c from t where id=3; // 3
update t set c=1 where id=3;
select c from t where id=3; // 1
commit;
select c from t where id=3; // 3

其中:

  • 事务 1 在事务 2 更新提交后依然查询到历史数据,表明 一致性快照是整个实例,并不仅是查询的数据。从 MVCC 的实现也很好理解,可见性判断使用的是 ReadView 中保存的事务列表,与具体的数据没关系,因此对应整个实例;
  • 事务 2 在更新前后分别查询,查询结果不同,并不符合可重复读事务级别的定义,原因是当被访问版本的事务是当前事务时可见。

使用一致性视图的典型场景是逻辑备份,而且其中使用 START TRANSACTION /*!40100 WITH CONSISTENT SNAPSHOT */ 语法,这种语法有什么特殊之处吗?

文章 第 02 期 [事务] BEGIN 语句会马上启动事务吗? 中详细介绍了开启事务的多种语法,其中:

  • BEGIN 语法用于开启读写事务,但是 并不会启动事务,也不需要立即创建一致性视图,事务的启动将延迟至实际需要时。因此适用于业务 SQL;

  • START TRANSACTION WITH CONSISTENT SNAPSHOT 语法同样用于开启读写事务,但是会 先启动事务,然后立即创建一致性视图。因此适用于逻辑备份。

结论

MVCC 为解决读写冲突,主要需要解决两个问题:

  • 如何保存多个版本的数据?
  • 如何判断特定事务可以看到哪个版本的数据?

MySQL 中将多个版本的历史数据保存在 undo log 中,但是并没有保存完整的数据,而是保存更新列的旧值。

可见性判断基于版本号(事务 ID)实现,具体事务 ID 信息保存在 ReadView(一致性视图)中。因此视图中保存的是事务 ID,快照中保存的是业务数据,准确的说是 undo log。

可见性判断针对的是当前事务与被访问版本的事务,其中当前事务不变,被访问版本沿版本链持续遍历。

可见性判断的原则是创建 ReadView(事务)之前的提交可见,其他不可见。

当前事务保存 4 个属性,详见下表。

属性 事务分类 是否可见
当前事务 ID 同一个事务
活跃事务 ID 列表 未提交事务
最小活跃事务 ID 已提交事务
下一个事务 ID 未开启事务

下面以一个事务从开启举例说明:

  • 当前事务执行 BEGIN 语句,事务 ID 等于 0,未分配事务 ID,未创建 ReadView;
  • 执行第一条查询语句,创建 ReadView, 一致性视图的空间范围是整个实例,时间范围是事务执行期间
  • 执行第一条写入语句,将读事务转换成写事务,分配事务 ID, undo log 中保存更新列的旧值,数据行中保存事务 ID
  • 其他事务如果访问当前事务写入的数据,发现数据行保存的事务未提交,因此不可见,根据 undo log 构造历史版本数据,判断可见,返回历史数据;
  • 当前事务查询刚写入的数据,可见性判断时发现写和读是同一个事务,因此可见,这里不满足可重复读事务隔离的定义;
  • 当前事务提交,关闭 ReadView,释放锁,删除事务对象,undo log 由 purge 线程清理。

显然,为理解 MVCC 中的可见性判断规则,需要注意以下几点:

  • 创建 ReadView 的时间、关闭 ReadView 的时间
  • 生成事务 ID 的时间、更新事务 ID 的时间
  • 一致性视图的范围

待办

  • MVCC 与未提交事务
  • 二级索引 MVCC
  • MVCC PG

参考教程

原文阅读