MySQL 如何解决幻读(MVCC 原理分析)

5,477 阅读15分钟

image-20211214212939553

1. 什么是 MVCC

在之前的文章中详细的介绍了 MySQL 中的事务和隔离级别,在并发访问数据库造成的问题(脏读、不可重复读、幻读),而 MVCC 就是在尽量减少锁使用的情况下高效避免这些问题。

MySQL 四大隔离级别:

隔离级别脏读不可重复读幻读
READ UNCOMMITTED:未提交读可能发生可能发生可能发生
READ COMMITTED:已提交读解决可能发生可能发生
REPEATABLE READ:可重复读解决解决可能发生
SERIALIZABLE:可串行化解决解决解决

MVCC 全称 Multi-Version Concurrency Control,即多版本并发控制,主要是为了提高数据库的并发性能。

同一行数据平时发生读写请求时,会上锁阻塞住。但 MVCC 用更好的方式去处理读写请求,做到在发生读写请求冲突时不用加锁

这个读是指的快照读,而不是当前读,当前读是一种加锁操作,是悲观锁。

那它到底是怎么做到读写不用加锁的,快照读和当前读是指什么?

2. 快照读和当前读

  • 快照读

    快照读,读取的是快照数据,不加锁的普通 SELECT 都属于快照读。

    SELECT * FROM table WHERE ...
    
  • 当前读

    当前读就是读的是最新数据,而不是历史的数据,加锁的 SELECT,或者对数据进行增删改都会进行当前读。

    SELECT * FROM table LOCK IN SHARE MODE;
    SELECT FROM table FOR UPDATE;
    INSERT INTO table values ...
    DELETE FROM table WHERE ...
    UPDATE table SET ...
    

3. 为什么使用 MVCC

在数据库并发场景中,只有读-读之间的操作才可以并发执行,读-写写-读写-写操作都要阻塞,这样就会导致 MySQL 的并发性能极差。

采用了 MVCC 机制后,只有写写之间相互阻塞,其他三种操作都可以并行,这样就可以提高了 MySQL 的并发性能。

也就是说 MVCC 具体解决了以下问题:

  1. 并发读-写时:可以做到读操作不阻塞写操作,同时写操作也不会阻塞读操作。
  2. 解决脏读幻读不可重复读等事务隔离问题,但不能解决上面的写-写(需要加锁)问题。

4. MVCC机制的原理

它的实现原理主要是版本链undo日志Read View来实现的。

4.1 版本链

在之前对 InnoDB 存储引擎的介绍了数据页的行格式,对于使用它的表来说,表中的聚簇索引都包含三个隐藏列:

列名是否必须说明
row_id创建的表中有主键或者非 NULL的 UNIQUE 键时都不会包含 row_id 列
trx_id事务ID,每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务 id 赋值给 trx_id 隐藏列
roll_pointer回滚指针,每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息

现在有这样一张表:

CREATE TABLE `user` (
  `id` bigint NOT NULL COMMENT '主键',
  `name` varchar(20) DEFAULT NULL COMMENT '姓名',
  `sex` char(1) DEFAULT NULL COMMENT '性别',
  `age` varchar(10) DEFAULT NULL COMMENT '年龄',
  `url` varchar(40) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `suf_index_url` (`name`(3)) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;

插入如下一条数据:

INSERT INTO `user` (`id`, `name`, `sex`, `age`, `url`) 
VALUES ('1', 'ayue', '1', '18', 'https://javatv.net');

假设插入该记录的事务 id 为 60,那么此刻该条记录的示意图如下所示:

image-20211208203939945

假设之后有两个事务 id 分别为 80、120 的事务对这条记录进行 UPDATE 操作,操作流程如下:

Trx 80Trx 120
BEGIN
BEGIN
UPDATE user SET name = 'a' where id = '1'
UPDATE user SET name = 'y' where id = '1'
COMMIT
UPDATE user SET name = 'u' where id = '1'
UPDATE user SET name = 'e' where id = '1'
COMMIT

每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志也都有一个 roll_pointer 属性(INSERT 操作对应的 undo 日志没有该属性,因为该记录并没有更早的版本),可以将这些 undo 日志都连起来,串成一个链表,所以现在的情况就像下图一样:

image-20211209091759865

对该记录每次更新后,都会将旧值放到一条 undo 日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。

另外,每个版本中还包含生成该版本时对应的事务 id。于是可以利用这个记录的版本链来控制并发事务访问相同记录的行为,那么这种机制就被称之为多版本并发控制(MVCC)

4.2 undo日志

undo log 主要用于记录数据被修改之前的日志,在表信息修改之前先会把数据拷贝到undo log里。当事务进行回滚时可以通过 undo log 里的日志进行数据还原。(MySQL 中的日志

Undo log 的用途

  • 保证事务进行rollback时的原子性和一致性,当事务进行回滚的时候可以用undo log的数据进行恢复
  • 用于MVCC快照读的数据,在MVCC多版本控制中,通过读取undo log历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据版本

undo log主要分为两种

  • insert undo log

    代表事务在insert新记录时产生的undo log , 只在事务回滚时需要,并且在事务提交后可以被立即丢弃。

  • update undo log

    事务在进行 update 或 delete 时产生的 undo log, 不仅在事务回滚时需要,在快照读时也需要。所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除。

4.3 ReadView

上面说到了,改动的记录都存在在 undo 日志中,那如果一个日志需要查询行记录,需要读取哪个版本的行记录呢?

1️⃣ 对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。

2️⃣ 对于使用 SERIALIZABLE 隔离级别的事务来说,InnoDB 使用加锁的方式来访问记录,不存在并发问题。

3️⃣ 而对于使用 READ COMMITTEDREPEATABLE READ 隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的

核心问题就是: READ COMMITTEDREPEATABLE READ 隔离级别在不可重复读和幻读上的区别在哪里?这两种隔离级别对应的不可重复读幻读都是指同一个事务在两次读取记录时出现不一致的情况,这两种隔离级别关键是需要判断版本链中的哪个版本是当前事务可见的。

ReadView 就是用来解决这个问题的,可以帮助我们解决可见性问题。 事务进行快照读操作的时候就会产生 Read View,它保存了当前事务开启时所有活跃的事务列表

:这里的活跃指的是未提交的事务。

每一个事务在启动时,都会生成一个 ReadView,用来记录一些内容,ReadView 中主要包含 4 个比较重要的属性:

属性说明
m_ids生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表
min_trx_id生成 ReadView 时当前系统中活跃的读写事务中最小的事务 id 也就是 m_ids 中的最小值
max_trx_id生成 ReadView 时系统中应该分配给下一个事务的 id 值
creator_trx_id生成该 ReadView 的事务的事务 id,指定当前的 ReadView 属于哪个事务

其中,max_trx_id并不是指m_ids中的最大值,因为事务 id 是递增分配的,假如现在有 id 为 1,2,3 这三个事务,之后 id 为 3 的事务提交了。那么一个新的读事务在生成 ReadView 时,m_ids 就包括 1 和 2,min_trx_id 的值就是 1,max_trx_id 的值就是 4。

再有了 ReadView 之后,在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:

  1. trx_id = creator_trx_id可访问

    如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

  2. trx_id < min_trx_id可访问

    如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。

  3. trx_id >= max_trx_id不可访问

    如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。

  4. min_trx_id <= trx_id < max_trx_id存在 m_ids 列表中不可访问

    如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

  5. 某个版本的数据对当前事务不可见

    如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。

在 MySQL 中,READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同

4.4 RC 和 ReadView

在 READ COMMITTED 级别下每次读取数据前都生成一个 ReadView。

假如现在系统里同时开启两个事务 id 分别为 80、120 的事务在执行,且使用 READ COMMITTED 隔离级别。

1️⃣ trx_id = 80

# 查看当前隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
​
# 修改当前会话隔离级别为 READ COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
​
# 开启事务
BEGINUPDATE `user` SET `name` = 'a' WHERE id = 1;
​
UPDATE `user` SET `name` = 'y' WHERE id = 1;

此刻,表 user 中 id 为 1 的记录得到的版本链表如下所示:

image-20211209163250319

在不提交的情况下,使用 READ COMMITTED 隔离级别的事务去查询:

# 查看当前隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
​
# 修改当前会话隔离级别为 READ COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
​
# 开启事务
BEGINSELECT * FROM `user` WHERE id = 1;

很明显,结果为ayue

对于这个查询语句的执行过程如下:

在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内容就是[80, 120]min_trx_id 为 80,max_trx_id 为 121,由于这是一个 SELECT 语句,所以并不会产生 creator_trx_id ,因此我们可以默认它为 0。

然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是y,该版本的 trx_id = 80,在 m_ids 列表内,不符合可见性要求,因此根据 roll_pointer 跳到下一个版本。

而在下一个版本中,name 的值为a,该版本的 trx_id = 80,还是不符合可见性要求,因此根据 roll_pointer 跳到下一个版本。

在这个版本中,name 的值为ayue,该版本的 trx_id = 60,符合可见性要求trx_id < min_trx_id,所以返回给用户的版本就是这条列 name 为ayue的记录。

然后,我们把事务 trx_id = 80 的事务提交(COMMIT) ,然后再到事务 trx_id = 120的事务中更新一下表 user 中 id 为 1 的记录。

2️⃣ trx_id = 120

# 查看当前隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
​
# 修改当前会话隔离级别为 READ COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
​
# 开启事务
BEGINUPDATE `user` SET `name` = 'u' WHERE id = 1;
​
UPDATE `user` SET `name` = 'e' WHERE id = 1;

此刻,表 user 中 id 为 1 的记录得到的版本链表如下所示:

image-20211209091759865

此时,trx_id = 80已提交,trx_id = 120未提交,使用 READ COMMITTED 隔离级别的事务去查询:

# 查看当前隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
​
# 修改当前会话隔离级别为 READ COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
​
# 开启事务
BEGINSELECT * FROM `user` WHERE id = 1;

很明显,结果为y

此时,SELECT 语句又会单独生成一个 ReadView,且其中的 m_ids 的内容为[120],因为trx_id = 80这个事务已经提交,所以再次生成快照的时候就不存在了。

同理,此时按照可见性要求,在trx_id = 120的事务未提交的情况下,读到的数据为 name = y

而当事务 trx_id = 120也提交之后,再次去查询得到的就是正确结果了。

总的来说就是,使用 READ COMMITTED 隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView。

总结一下:

READ COMMITTED,可能产生不可重复读的现象,如上面测试的情况:

  1. 事务trx_id = 80trx_id = 120同时开启的情况下,trx_id = 80提交了

整体流程

trx_id = 80select_80trx_id = 120select_120
1BEGIN
2BEGIN
3BEGIN
4BEGIN
5UPDATE user SET name = 'a' where id = 1
6UPDATE user SET name = 'y' where id = 1
7SELECT * FROM user WHERE id = 1
8COMMIT
9SELECT * FROM user WHERE id = 1
10UPDATE user SET name = 'u' where id = 1
11UPDATE user SET name = 'e' where id = 1
12SELECT * FROM user WHERE id = 1
13COMMIT
14SELECT * FROM user WHERE id = 1

注:以上测试都是在 READ COMMITTED 级别下。

4.5 RR 和 ReadView

在 REPEATABLE READ 级别下在第一次读取数据时生成一个 ReadView。

还是在同样的条件下,开启两个事务 trx_id = 80trx_id = 120,流程同上,一模一样(SQL语句和步骤),只是隔离级别为RR。

当事务trx_id = 80执行修改操作并在提交之前,版本链表如下所示:

image-20211209163250319

此时执行 SELECT 语句会先生成一个 ReadView,ReadView的m_ids 列表的内容就是[80,120]min_trx_id = 80max_trx_id = 121creator_trx_id = 0

根据可见性原则,此时的 trx_idmin_trx_idmax_trx_id之间且在m_ids 列表中,不符合,所以会随着版本链往下找,直到trx_id = 60,找出的值为 ayue

然后提交

提交之后在去事务trx_id = 120中执行上述更新操作,版本链表如下所示:

image-20211209091759865

由于之前在执行 SELECE 语句时已经生成过 ReadView 了,所以此时直接复用之前的 ReadView,之前的 ReadView 的 m_ids 列表的内容就是[80, 120]min_trx_id = 80max_trx_id = 121creator_trx_id = 0

而当前的事务 id 为 120,在min_trx_idmax_trx_id之间且在m_ids列表中,所以不符合可见性原则,则会继续随着版本链往下找,当找到事务 id 为 80时,同样不满足,所以继续往下直到trx_id = 60,满足条件,返回的值为 ayue

所以说 RR 解决了不可重复读问题

5. MVCC下的幻读现象

通过上面分分析我们知道,REPEATABLE READ 隔离级别下 MVCC 可以解决不可重复读问题。

那么幻读呢?MVCC 是怎么解决的?

幻读是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录,而这个记录来自另一个事务添加的新记录,也就是说幻读是指新插入的行。 如:

银行 A 开启了一个事务窗口,查询当前系统中有没有 "ayue" 用户,发现没有,银行 B 也开启了一个事务窗口,查询当前系统中也没有 "ayue" 用户,银行 A 先创建 "ayue" 用户并且提交,由于可重复读取,银行 B 在一次事务中必须保证查询的数据一致性,因此查询不到 "ayue",结果银行 B 窗口认为 "ayue" 没有被注册想注册 "ayue" 用户,就创建 "ayue" 用户结果发现系统提示 "ayue" 用户已经被注册",但是在本次事务中又查询不到 "ayue",就好像出现幻觉一样。

为什么在 RR 级别下会出现幻读的情况呢?

仍然上面的那条数据,此时我们先查询一下数据库:

image-20211210111725651

然后我们开启 2 个事务,事务 A 和事务 B。

1️⃣ 事务 A

BEGIN;
​
SELECT * FROM `user` WHERE id = 2

很明显,这个时候数据表中并没有 id = 2的数据。

image-20211210112258314

2️⃣ 事务 B

然后,我们在事务 B 中插入一条id = 2的数据。

BEGIN;
​
INSERT INTO `user` (`id`, `name`, `sex`, `age`, `url`) 
VALUES ('2', 'ayue', '1', '18', 'https://javatv.net');
​
COMMIT;

然后回到事务 A,执行:

SELECT * FROM `user` WHERE id = 2;

此时为了保证 RR 下的可重复读,还是查询不到数据的。

但是我们 A 事务下执行以下操作,却发现能够修改成功:

UPDATE `user` SET name = 'a' WHERE id = 2;

image-20211210112844702

再次在 A 事务下执行:

SELECT * FROM `user` WHERE id = 2;

image-20211210113035832

肿么肥事?事务 A 之前都不能查询出id=2的数据,现在竟然可以查到了,很明显出现了幻读现象。

分析一下:

在 REPEATABLE READ 隔离级别下,事务 A 第一次执行普通的 SELECT 语句时生成了一个 ReadView(且在 RR 下只会生成一个 RV),之后事务 B 向 user 表中新插入一条记录并提交。

ReadView 并不能阻止事务 A 执行 UPDATE 或者 DELETE 语句来改动这个新插入的记录(由于事务 B 已经提交,因此改动该记录并不会造成阻塞),但是这样一来,这条新记录的 trx_id 隐藏列的值就变成了事务 A 的事务 id。之后 A 再使用普通的 SELECT 语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返回给客户端。

因为这个特殊现象的存在,我们也可以认为 MVCC 并不能完全禁止幻读

6. 如何解决幻读

第 2 点中,我们知道数据库的读操作分为当前读快照读,而在 RR 隔离级别下,MVCC 解决了在快照读的情况下的幻读,而在实际场景中,我们可能需要读取实时的数据,比如在银行业务等特殊场景下,必须是需要读取到实时的数据,此时就不能快照读。

那么有什么方法来解决这个问题呢?

毫无疑问,在并发场景下,我们可以通过加锁的方式来实现当前读,而在 MySQL 中则是通过Next-Key Locks来解决幻读的问题。(关于 MySQL 中的锁的介绍可以看看这篇文章:一文了解 MySQL 中的锁)。

Next-Key Locks包含两部分:记录锁(行锁,Record Lock),间隙锁(Gap Locks)。记录锁是加在索引上的锁,间隙锁是加在索引之间的

6.1 Record Lock

记录锁,单条索引记录上加锁

Record Lock 锁住的永远是索引,不包括记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。

记录锁是有 S 锁和 X 锁之分的,当一个事务获取了一条记录的 S 型记录锁后,其他事务也可以继续获取该记录的 S 型记录锁,但不可以继续获取 X 型记录锁;当一个事务获取了一条记录的 X 型记录锁后,其他事务既不可以继续获取该记录的 S 型记录锁,也不可以继续获取 X 型记录锁。

6.2 Gap Locks

间隙锁,对索引前后的间隙上锁,不对索引本身上锁。前开后开区间。

MySQL 在 REPEATABLE READ 隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用 MVCC 方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有问题,就是事务在第一次执行读取操作时,那些幻影记录尚 不存在,我们无法给这些幻影记录加上记录锁。所以我们可以使用间隙锁对其上锁。

如存在这样一张表:

CREATE TABLE test (
    id INT (1) NOT NULL AUTO_INCREMENT,
    number INT (1) NOT NULL COMMENT '数字',
    PRIMARY KEY (id),
    KEY number (number) USING BTREE
) ENGINE = INNODB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8;
​
# 插入以下数据
INSERT INTO test VALUES (1, 1); 
INSERT INTO test VALUES (5, 3); 
INSERT INTO test VALUES (7, 8); 
INSERT INTO test VALUES (11, 12);

如下:

开启一个事务 A:

BEGIN;
​
SELECT * FROM test WHERE number = 3 FOR UPDATE;

此时,会对((1,1),(5,3))((5,3),(7,8))之间上锁。

image-20211214115101096

如果此时在开启一个事务 B 进行插入数据,如下:

BEGIN;
​
# 阻塞
INSERT INTO test (id, number) VALUES (2,2); 

结果如下:

image-20211214151211227

为什么不能插入?因为记录(2,2)要 插入的话,在索引 number上,刚好落在((1,1),(5,3))((5,3),(7,8))之间,是有锁的,所以不允许插入。 如果在范围外,当然是可以插入的,如:

INSERT INTO test (id, number) VALUES (8,8); 

另外,既然涉及到索引,那么索引对间隙锁会产生什么影响

  1. 对主键或唯一索引,如果当前读时,where 条件全部精确命中(=或in),这种场景本身就不会出现幻读,所以只会加行记录锁,也就是说间隙锁会退化为行锁(记录锁)。
  2. 非唯一索引列,如果 where 条件部分命中(>、<、like等)或者全未命中,则会加附近间隙锁。例如,某表数据如下,非唯一索引2,6,9,9,11,15。如下语句要操作非唯一索引列 9 的数据,间隙锁将会锁定的列是(6,11],该区间内无法插入数据。
  3. 对于没有索引的列,当前读操作时,会加全表间隙锁,生产环境要注意。

6.3 Next-Key Locks

next-key locks 是索引记录上的记录锁和索引记录之前的间隙上的间隙锁的组合,包括记录本身,每个 next-key locks前开后闭区间(同样说明锁住的范围更大,影响并发度),也就是说间隙锁只是锁的间隙,没有锁住记录行,next-key locks 就是间隙锁基础上锁住右边界行

默认情况下,InnoDB 以 REPEATABLE READ 隔离级别运行。在这种情况下,InnoDB 使用 Next-Key Locks 锁进行搜索和索引扫描,这可以防止幻读的发生。

7. 总结

我们说 MVCC 在可重复读(RR)的隔离级别解决了以下问题:

  1. 并发读-写时:可以做到读操作不阻塞写操作,同时写操作也不会阻塞读操作;
  2. 解决脏读幻读不可重复读等事务隔离问题。

而对于 RR 和 RC 这两个隔离级别的不同:

生成 ReadView 的时机不同,RC 在每一次进行普通 SELECT 操作前都会生成一个 ReadView,而 RR 只在第一次进行普通 SELECT 操作前生成一个 ReadView,之后的查询操作都重复使用这个 ReadView 就好了,从而基本上可以避免幻读现象。

但是,对于幻读来说,还存在当前读和快照读的情况:

  1. RR 隔离级别下间隙锁才有效,RC 隔离级别下没有间隙锁
  2. RR 隔离级别下为了解决幻读问题:快照读依靠MVCC控制,当前读通过间隙锁解决
  3. 间隙锁和行锁合称 Next-Key Locks,每个 Next-Key Locks 是前开后闭区间;
  4. 间隙锁的引入,可能会导致同样语句锁住更大的范围,影响并发度。