0%

mysql MVCC 介绍

简介

MVCC (multiversion concurrency control),多版本并发控制,主要是通过在每一行记录中增加三个字段,与 undo log 中相关记录配合使用,同时加上可见性算法,使得各个事务可以在不加锁的情况下能够同时地读取到某行记录上的准确值(这个值对不同的事务而言可能是不同的)。使用 MVCC,在不加锁的情况下也能读取到准确的数据,大大提高了并发效率。

事务

提到 MVCC,必须提到事务。关于事务,有四个特性,即我们常说的 ACID。

  • 原子性(Atomicity):表示事务要么全部执行,要么全部不执行,这是一个不可分割的最小单元
  • 一致性(Consistency):表示事务总是从一个一致的状态转移到另一个一致的状态
  • 隔离性(Isolation):表示各个事务之间相关隔离,互不影响
  • 持久性(Durability):指一个事务一旦被提交,它对数据库的改变就是永久性的,即使后续数据库发生故障也不会有影响

而事务隔离性又分为四种级别:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)、串行化(serializable)。

  • 读未提交:指一个事务还没有提交,它本身所做的修改就能被其他事务所看到。在这种情况下,会产生脏读、幻读和不可重复读的问题。
  • 读提交:指一个事务提交之后,它本身所做的修改就能被其他事务所看到。在这种情况下,解决了「读未提交」的脏读问题,但是仍然会产生幻读和不可重复读的问题。
  • 可重复读:指在同一个事务之中,读到的数据是一致的。这种隔离级别下,可以解决脏读和不可重复读的问题,但是仍然存在幻读的问题。
  • 串行化:指多个事务中,如果读写锁冲突时,后访问的事务必须等前一个事务执行完成后才能继续执行。这种隔离级别最高,也解决了脏读、幻读和不可重复读的问题。但是其也大大限制了并发的程度。

关于这四种隔离级别的差异,可以通过以下例子(例子来源于:林晓斌:MySQL实战45讲)来加以说明。

假设存在一张表,里面只有一个字段和一条记录,值是 1,现在发生以下的操作

时刻 事务A 事务B
t1 启动事务,查询得到值 1
t2 启动事务
t3 查询得到值 1
t4 将 1 改成 2
t5 查询得到值 V1
t6 提交事务
t7 查询得到值 V2
t8 提交事务
t9 查询得到值 V3

针对不同的隔离级别,V1、V2、V3 读到的值不同。

在「读未提交」的隔离级别下,由于 t4 时刻事务 B 将值改成了 2,虽然 B 还没提交事务,但是此时的修改对其他事务是可见的,所以 V1、V2、V3 查询到的值都是 2。

在「读提交」的隔离级别下,t4 时刻修改了值,但是在 t5 时刻,事务 B 还没有提交,此时事务 A 读取到的值还是老的值,所以 V1 是 1,而在 t7 时刻,由于事务 B 已经在 t6 时刻提交了,此时事务 B 所做的修改对其他的事务都可见,所以事务 A 在 t7 时刻能看到事务 B 的修改,此时 V2 的值为 2,当然 V3 的值也为 2。

在「可重复读」的隔离级别下,遵循 “事务在执行期间看到的数据必须是前后一致” 的要求,所以无论事务 B 是否修改值,也无论事务 B 是否提交,事务 A 在没提交前读到的值都是相同的,即 V1 和 V2 的值都是 1,当 A 事务提交后,再次查询时,事务 B 的修改就能被 A 看到了,所以 V3 的值为 2。

在「串行化」的隔离级别下,当事务 B 在 t4 时刻执行更新时,由于与事务 A 操作的是同一行,且出现读写冲突,此时事务 B 被会阻塞,等待事务 A 执行完毕后,再执行事务 B,所以 V1 和 V2 的值是 1,V3 的值是 2。

MVCC

更新操作

在数据库表的记录中,每一个记录都会添加三个字段:

  • DB_TRX_ID:6个字节,表示最近一次修改本记录的事务ID

  • DB_ROLL_PTR :7 个字节,回滚指针,指向回滚段中的 undo log record,用于找出这个记录的上个修改版本的数据。

  • DB_ROW_ID:6 个字节,一个单调递增的 ID,确定表中记录的唯一性。

当对某个记录进行更新时,会将当前记录写入 undo log 中,并更新当前记录中 DB_ROLL_PTR 字段值,使其指向刚才的 undo log record,然后更新当前记录相关字段值,同时更新 DB_TRX_ID 字段,记录执行更新操作的事务 ID。简略的更新过程大致如下所示

mysql-mvcc-update

查询操作

由上面的更新操作可以得知,数据库表记录始终记录着最新的更新结果,那对于「可重复读」和「读提交」的隔离级别的事务,它是如何保证在开启本事务后,其他事务对记录进行了更新操作,而本事务仍然能够读取到准确的值(不是表记录的最新值,而是历史版本的值)的?从更新操作中可以得知,通过循环遍历 DB_ROLL_PTR 可以拿到当前记录的历史版本(当然,只是活跃的事务,如果当前记录没有相关事务在操作,则会清理 undo log,就不能拿到历史版本数据了) 。但是这么多历史版本的数据,究竟哪个版本的数据才是当前事务所要的呢?这时就要判断当前版本的数据是否对当前事务可见了。

在开启事务时,会将当前活跃的事务(已经开启了事务,但是还没有提交)的事务 ID 放在一个数组里面,同时记录数组里面最小的事务 ID 为「低水位」,记录当前系统已经创建的事务ID 的最大值加一为「高水位」。这三者组成了一个事务的一致性视图(read-view)。当事务要查询某个记录的数据时,实际上就是拿该记录的事务ID(包括历史版本的事务ID)和这个一致性视图进行比较,直到某个版本的数据是可见的为止。其查询过程如下:

  • 读取的记录的事务ID小于低水位,说明这个版本的数据在开启本事务前已经提交,是可见的,直接返回这个数据
  • 读取的记录的事务ID大于高水位,说明这个版本的数据在开启本事务后提交的,不可见,从记录中取出 DB_ROLL_PTR 指向的记录并读取其事务 ID,开始下一轮的判断
  • 读取的记录的事务ID介于低水位和高水位中间,此时判断事务ID是否在一致性视图的事务数组中:
    • 如果不在,说明这个版本的数据在开启本事务前已经提交,是可见的,直接返回这个数据
    • 如果在,说明这个版本的数据是由开启事务后的其他活跃事务提交的,对本事务是不可见的,因此需要从记录中取出 DB_ROLL_PTR 指向的记录并读取其事务 ID,开始下一轮的判断

其判断过程的流程图大致如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
start=>start: Start
readTxId=>operation: 读取记录中的事务ID(DB_TRX_ID)
isLessLow=>condition: 事务ID小于低水位
isMoreHigh=>condition: 事务ID大于高水位
isInArray=>condition: 事务ID在一致
性事务数组中
nextRecode=>operation: 根据 DB_ROLL_PTR 取出历史版本记录
return=>operation: 返回当前记录数据
end=>end

start->readTxId->isLessLow(yes,right)->return
isLessLow(no)->isMoreHigh(yes)->nextRecode
isMoreHigh(no)->isInArray(yes)->nextRecode
isInArray(no)->return
nextRecode(left)->readTxId
return->end

关于判断数据可见性,除了上述用高水位、低水位和事务视图数组结合判断之外,可以简化成以下规则判断:

  • 对于当前事务中的数据,可见
  • 对于其他事务中的数据
    • 如果版本未提交,不可见
    • 如果版本已经提交,且是在创建本事务视图后提交的,不可见
    • 如果版本已经提交,且是在创建本事务视图前提交的,可见

例子

现在用一个例子(此例子来自:林晓斌:MySQL实战45讲)来对上述查找过程进行说明。假设在「可重复读」的隔离级别下,有以下的表结构和数据。

1
2
3
4
5
6
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`k` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);

假设进行以下的操作(事务C 的 update 操作完即自动提交事务),在进行以下操作前,假设当前活跃的事务 ID 为 99,记录(1,1)的 DB_TRX_ID 值是 90。则事务 A 的视图数组是 [99, 100],事务 B 的视图数组是 [99, 100, 101],事务 C 的视图数组是 [99, 100, 101, 102]

事务A(事务ID:100) 事务B(事务ID:101) 事务C(事务ID:102)
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update t set k = k + 1 where id = 1;
update t set k = k + 1 where id = 1;
select k from t where id = 1;
select k from t where id = 1;
commit;
commit;

当事务 A 执行查询语句时,其查询数据逻辑图(此图来自:林晓斌:MySQL实战45讲)如下所示

mysql-mvcc-query

其查找过程如下,首先,获取记录的事务ID(101),比高水位大,不可见,所以取出记录的上一个历史版本,获取其事务ID(102),比高水位大,不可见,再获取记录的上一个历史版本,获取其事务ID(90),比低水位小,可见,所以返回这个记录中的 k 字段的值 1。

当然,也可以用简化版本来判断。过程如下,首先,获取记录(1,3),还没有提交,不可见,取出上一个历史版本(1,2),(1,2)已经提交,但是在本事务视图创建后提交的,不可见,继续取出上一个历史版本(1,1),(1,1)已经提交,且是在本事务视图创建前提交的,可见,所以最终返回 k 的值是 1。

此处需要额外关注的是,事务 B 的更新操作,是在当前记录的最新值上更新的,并不是在历史数据上更新的,否则会丢失事务 B 的更新操作。其实,更新数据都是先读后写的,而且这个读,是读的当前值,称为“当前读”。

如果是在「读提交」的隔离级别下,处理逻辑类似,只是生成一致性视图的情况不同:

  • 在「可重复度」隔离级别下,只需要在事务开始的时间创建一致性视图,之后事务里的其他查询都共用这个一致性视图
  • 在「读提交」隔离级别下,每一个语句执行前都会重新算出一个新的视图

所以上述例子,如果是在「读提交」隔离级别下,事务 A 在执行查询语句时,会创建新的一致性视图,此时一致性视图中的活跃事务ID数组是 [99, 100, 101],其查找过程如下,读取当前记录事务 ID(101),在视图数组中,不可见,取出上一个历史版本记录,读取事务ID(102),介于低水位和高水位之间,且不在视图数组中,可见,所以返回记录的 k 值 2。

其他

  • 四种隔离级别,只有「读提交」和「可重复度」两个隔离级别能够使用 MVCC,因此也只有这两个隔离级别会创建一致性视图(read-view)。因为「读提交」隔离级别下每次都是读取的最新记录,所以不用 MVCC,也不用创建一致性视图;「串行化」隔离级别,则是用加锁方式来实现并发的,也不用 MVCC ,所以也不用创建一致性视图。关于「可重复度」和「读提交」两个隔离级别下一致性视图的差别,主要体现在:「可重复度」隔离级别下的一致性视图是在启动事务时创建的,创建后,本事务共用一个视图;而「可读提交」隔离级别下的一致性视图是在执行 SQL 时创建的,每一个 SQL 都会单独创建一个视图,并不会共用。
  • 当前读(current read),每次读取的都是记录的最新数据,主要包含以下 SQL 语句
    • select … lock in share mode
    • select … for update
    • insert
    • update
    • delete
  • 快照读(snapshot read),可能读取记录的历史版本数据,主要用于 MVCC 中的简单的 select (不包括 select … lock in share mode,select … for update),保证事务读取的一致性。

参考资料

[1] 林晓斌. 事务隔离:为什么你改了我还看不见?[J/OL]. https://time.geekbang.org/column/article/68963 ,2018-11-19
[2] 林晓斌. 事务隔离:事务到底是隔离的还是不隔离的?[J/OL]. https://time.geekbang.org/column/article/70562 ,2018-11-30
[3] MySQL官方文档: https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html