MySQL MVCC原理深入探索
阅读原文时间:2021年10月06日阅读:1

我们知道:事务是具备ACID特性的。但是对于【I】隔离性而言,可以根据不同的场景,权衡使用不同的隔离级别。在MySQL中实现了SQL标准中的四大隔离级别,同时对应了可能会出现的不同问题:

隔离级别

脏读

不可重复读

幻读

READ UNCOMMITTED(读未提交)

可能

可能

可能

READ COMMITTED(读已提交)

不可能

可能

可能

REPEATABLE READ(可重复读)

不可能

不可能

*_可能_

SERIALIZABLE(可串行化)

不可能

不可能

不可能

其中,需要特别注意的一点是:也是与SQL标准规定不一样的一个地方,MySQL中RR可重复读的隔离级别下,是基本上不会出现幻读问题(这个地方会出现一些特殊的场景,后面会介绍)。

访问MySQL数据一般就是增删改查操作,对应的是insert、delete、update和select语句。

对于insert、delete、update这三种类型的语句而言,需要先定位到最新的数据;对于select语句,也有select … lock in share mode(MySQL8.0中增加了SELECT … FOR SHARE)/select… for update的操作,同样需要读取最新的数据,同时必须堵塞其他并发事务修改当前记录,所以需要进行加锁。 这些操作在MySQL中统一被称为——锁定读Locking Reads),又称为当前读。

而对于普通的select语句,MySQL则根据不同的隔离级别,进行不同的处理:

  • READ UNCOMMITTED(读未提交):读取最新的记录
  • READ COMMITTED(读已提交):读取最新提交事务的记录
  • REPEATABLE READ(可重复读):读取当前事务在开始前的记录或者被自己事务修改的记录
  • SERIALIZABLE(可串行化):读取最新的记录,同时也加上读锁,就是select … lock in share mode

可以看到,对于READ COMMITTED(读已提交)和 REPEATABLE READ(可重复读)这两种隔离级别来说,普通的select是不需要加锁的,并且受到事务发生时机的影响。可以利用事务保存的历史版本数据,控制版本访问权限,这种方案就称之为——MVCC(Multi-Version Concurrency Control),而MVCC下的select查询,被称为——非锁定读(Consistent Nonlocking Reads),又称为快照读。

生成的快照就被称之为ReadView(一致性视图)

利用MVCC提供的快照读能力,可以实现在不加锁的情况下,解决读写冲突时的堵塞问题,极大提升并发查询性能,同时也保证了事务的隔离性。这是在并发性能和一致性之间做的一次trade-off。

前面提到,MVCC是在RC和RR隔离级别下使用历史版本数据实现事务隔离性的,下面我们来看下具体的案例分析。

首先先创建表 t1:

CREATE TABLE `t1` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `c` (`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

#插入一条数据
INSERT INTO `t1` (`id`, `c`, `d`) VALUES ('5', '5', '5');

RR级别场景

事务隔离级别为RR:

SELECT @@tx_isolation

REPEATABLE-READ

1.当事务1执行语句:start transaction with consistent snapshot;会创建一个ReadView,最后进行查询id=5的时候,仍然会读取到快照时的数据,也就是(5,5,5)这条数据

2.事务2同样使用start transaction with consistent snapshot开启了一个ReadView,但是由于事务中使用了update语句更新了这条数据,所以最后事务2查询id=5的数据时,结果是(5,2,5)

RC级别场景

事务隔离级别为RC:

set session transaction isolation level read committed;

SELECT @@tx_isolation

READ-COMMITTED

同样是对于这个案例:

  1. 对于事务2,查询id=5的结果不变,还是(5,2,5)

  2. 对于事务1,由于隔离级别是 READ COMMITTED,可以查询到其他事务已提交的记录,也就是说ReadView是在查询语句开始时才生成的,不是事务开始前,所以查询id=5的结果也是(5,2,5)

    小结一下:

  • RR级别下,MVCC的一致性视图(快照)是只在事务开始(第一次查询语句)时生成;
  • RC级别下,MVCC的一致性视图(快照)是在每次查询语句前时生成;

3.1 多版本的数据从哪里来——Undo Log

An undo log is a collection of undo log records associated with a single read-write transaction. An undo log record contains information about how to undo the latest change by a transaction to a clustered index record. If another transaction needs to see the original data as part of a consistent read operation, the unmodified data is retrieved from undo log records.

undo log是与单个读写事务相关联的撤消日志记录的集合。undo log记录可以明确如何撤消事务对聚集索引记录的最新更改。如果另一个事务需要通过一致性读取操作查看原始数据,可以从undo log记录中查询未修改前的数据。

也就是说,undo log是为了记录如何回滚事务而生成的。数据每进行一次增删改,就会对应一条或多条undo log。 innoDB中使用不同的undo log类型对insert、delete和update进行区分。

3.1.1 插入操作对应的undo log

TRX_UNDO_INSERT_REC:是插入操作对应的undo log类型,因为插入操作对应的撤销逻辑是删除,所以只需要把这条记录的主键id记录下来。

3.1.2 删除操作对应的undo log

由于MVCC的存在,被删除的记录并不会被真正的删除, 而是进行delete mark操作——只是将行记录上的删除标记位delete_flag改为1,后面在通过purge操作把记录加入垃圾链表中,待后续进行空间复用。生成删除语句对应的undo log类型为TRX_UNDO_DELETE_MARK_REC,相比于TRX_UNDO_INSERT_REC,TRX_UNDO_DELETE_MARK_REC不仅保存了主键id,也保存了相关的索引列信息,用来对删除过程中一些中间状态的清理。

3.1.3 更新操作对应的undo log

更新操作对应的undo log除了会记录主键和索引列信息之外,还会把被更新前各个字段的信息记录下来,还有指向旧记录的DB_ROLL_PTR和DB_TRX_ID。

  • 不更新主键,且被更新的列存储空间不发生变化:直接在原有行记录上面更新。
  • 不更新主键,且被更新的列存储空间发生了变化:先删除旧记录(不是delete mark,而是直接删除),再插入新记录。
  • 更新主键:旧记录进行delete mark,再插入新记录。
  • 过程中更新了二级索引:对旧的二级索引进行delete mark,插入新的二级索引记录。

通过以上3种不同操作对应的undo log,可以得到事务开始前的的历史记录。那么这些记录是如何关联起来的呢?

3.2 旧版本如何关联——行记录隐藏字段和版本链

对于InnoDB引擎的表,聚簇索引记录中包含了两个必要的隐藏字段:

  • DB_TRX_ID:一个事务每次对某条聚簇索引记录进行改动(插入、更新或删除)时,会把该事务id赋值给该字段
  • DB_ROLL_PTR:每次对某条聚簇索引记录进行改动时,会把旧版本写入undo日志中,DB_ROLL_PTR就是指向这条记录上一个版本的指针。

还是对于表 t1:

CREATE TABLE `t1` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `c` (`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

1.插入一条数据

INSERT INTO `t1` (`id`, `c`, `d`) VALUES ('5', '5', '5');

此时版本只有一个:

2.执行了3次变更sql后:

update t1 set c=4 where id=5;
update t1 set c=3 where id=5;
update t1 set c=3 where id=5;

对应的undo版本链就变成:

结合前面的undo log来看,每次进行记录变更的时候,都会把旧值放到一条undo log里面,同时利用DB_ROLL_PTR把多个版本的undo log组成一个版本链,并保持每个版本对应的事务id。

那么如何判断版本链中哪个才是当前事务可见的正确版本呢?

3.3 如何正确访问不同版本数据——Read View访问规则

3.3.1 Read View

Read View就是事务进行快照读操作的时候生成的,是一个逻辑层面的概念。

Read View主要包含以下几部分内容:

  • m_ids: 表示生成Read View时,系统中正在活跃的事务id列表。
  • up_limit_id:表示生成Read View时,系统中正在活跃的最小事务id,也就是m_ids中的最小值。
  • low_limit_id:表示生成Read View时,系统还未分配的下一个事务 ID ,也就是目前已出现过的事务 ID 的最大值 + 1。
  • creator_trx_id:生成Read View的事务id

判断逻辑如下:

  1. 若版本数据的DB_TRX_ID属性值和creator_trx_id相同,代表是本事务修改的记录,可以访问。
  2. 若版本数据的DB_TRX_ID属性值小于up_limit_id,代表是在生成Read View前就已经提交的事务,可以访问。
  3. 若版本数据的DB_TRX_ID属性值大于low_limit_id,代表是在生成Read View之后才开启的事务,不可以访问。
  4. 若版本数据的DB_TRX_ID属性值在up_limit_id和low_limit_id之间,需要再判断DB_TRX_ID是否在m_ids里面,如果在,则代表是在生成Read View时,该版本数据所在的事务还未提交,不可以访问;如果不在,则代表是在生成Read View时,该版本数据所在的事务已经提交,可以访问。

3.3.2 覆盖索引下的MVCC

由于DB_TRX_ID和DB_ROLL_PTR都是在聚簇索引记录中的,当事务中使用覆盖索引进行查询时,如何能确认正确的数据版本呢?

二级索引页上有一个PAGE_MAX_TRX_ID的属性,记录该页面变更时的最大事务id,如果PAGE_MAX_TRX_ID的值是在Read View创建前提交的,那么该页的全部索引都可见;如果不是,则需要根据二级索引的主键进行回表,进行再次判断。

小结一下:

对于一个 Read View 来说,除了自己的更新总是可见以外,有三种情况:

  • 事务未提交的记录版本,不可见;
  • 事务已提交的记录版本,但是是在Read View创建后提交的,不可见;
  • 版本已提交,而且是在Read View创建前提交的,可见。

再结合前文提到的:

  • RR级别下,MVCC的一致性视图(快照)是只在事务开始(第一次查询语句)时生成;
  • RC级别下,MVCC的一致性视图(快照)是在每次查询语句前时生成;

就能找到正确的版本数据,实现RR和RC事务隔离级别的快照读机制。

因为MVCC是通过回溯undo log的方式,将符合访问条件的历史版本查询出来,而undo log文件是保存在磁盘中,是否会影响查询性能?

由于undo log是为了回滚未成功提交的事务和MVCC存在的,如果事务成功提交,以及当系统里最早的Read View不再访问它们的时候,就会使用purge操作进行清除,所以undo log的生存周期一般来说不会太长,但是使用时,我们仍需要避免使用长事务。

回到最开始提到的:MySQL中RR可重复读的隔离级别下,是基本上不会出现幻读问题。但是是否可以完全解决幻读问题呢?

在RR可重复读级别下, InnoDB使用了MVCC解决【快照读】场景下的幻读问题,而在【当前读】场景下, 是使用gap lock间隙锁的方式解决幻读问题。

下面还是使用表 t1 模拟一个案例,在RR级别下开启两个事务会话:

CREATE TABLE `t1` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `c` (`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `codemavs`.`t1` (`id`, `c`, `d`) VALUES ('0', '0', '0');
INSERT INTO `codemavs`.`t1` (`id`, `c`, `d`) VALUES ('1', '1', '1');
INSERT INTO `codemavs`.`t1` (`id`, `c`, `d`) VALUES ('5', '5', '5');

1、事务1【1.1】开启了快照读后,查询结果是(1,1,1)和(5,5,5);

2、事务2把了id=0的记录中c的字段更新为5;

3、事务1【1.2】中使用了select…for update的当前读,此时返回的结果不再是(1,1,1)和(5,5,5),而是 (0,5,0),(1,1,1)和(5,5,5),中间多出了(0,5,0)这条记录。

这是一种特殊的场景:在同一个事务中,先使用了快照读, 再使用当前读,两次的结果可能出现不一致。

可知RR级别下先使用非锁定读,再使用锁定读时,可能会出现幻读问题,这在MySQL中是允许出现的。

MVCC是RC和RR隔离级别下利用【快照读】提升了读写并发的整体性能,主要通过Undo Log、版本链和Read View访问规则来实现。