likes
comments
collection
share

可重复读隔离级别发生幻读场景(图解分析)

作者站长头像
站长
· 阅读数 16

MySQL 可重复读隔离级别并未完全解决幻读

MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读 RR」,但是它采用了如下两种策略,从而在很大程度上避免幻读现象:

  • 针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
  • 针对当前读(select ... for update 以及增删改等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。

上面两个方案只是很大程度上避免幻读现象,并不是完全解决幻读问题,在当前读、快照读混用的情况下,还是会造成幻读现象,具体见下文分析。

什么是幻读?

首先来看看 MySQL 文档是怎么定义幻读(Phantom Read)的:

翻译:当同一个查询在不同的时间产生不同的结果集时,事务中就会出现所谓的幻象问题。例如,如果 SELECT 执行了两次,但第二次返回了第一次没有返回的行,则该行是“幻像”行。

举个例子,假设一个事务在 T1 时刻和 T2 时刻分别执行了下面查询语句,途中没有执行其他任何语句:

 SELECT * FROM t_test WHERE id > 100;

只要 T1 和 T2 时刻执行产生的结果集是不相同的,那就发生了幻读的问题,比如:

快照读如何避免幻读?

可重复读隔离级是由 MVCC(多版本并发控制)实现的,实现的方式是开始事务后(执行 begin 语句后),在执行第一个查询语句后,会创建一个 Read View,后续的查询语句利用这个 Read View,通过这个 Read View 就可以在 undo log 版本链找到事务开始时的数据,所以事务过程中每次查询的数据都是一样的,即使中途有其他事务插入了新纪录,是查询不出来这条数据的,所以就很好了避免幻读问题。

当前读如何避免幻读?

MySQL 里除了普通select查询是快照读,其他都是当前读,比如 update、insert、delete,这些语句执行前都会查询最新版本的数据,然后再做进一步的操作。另外,select ... for update 这种查询语句是当前读,每次执行的时候都是读取最新的数据。

这很好理解,假设你要 update 一个记录,另一个事务已经 delete 这条记录并且提交事务了,这样不是会产生冲突吗,所以 update 的时候肯定要知道最新的数据。

所以,Innodb 引擎为了解决「可重复读」隔离级别使用「当前读」而造成的幻读问题,就引出了临键锁

假设,表中有一个范围 id 为(3,5]间隙锁,那么其他事务就无法插入 id = 4 这条记录了,这样就有效的防止幻读现象的发生。


RR隔离级别发生幻读现象的场景

可重复读隔离级别下虽然很大程度上避免了幻读,但是还是没有能完全解决幻读,主要发生在当前读、快照读混用的情况下,下面两个例子都是混用造成的。

场景一

还是以这张表作为例子:

可重复读隔离级别发生幻读场景(图解分析)

事务 A 执行查询 id = 5 的记录,此时表中是没有该记录的,所以查询不出来。

 # 事务 A
 mysql> begin;
 Query OK, 0 rows affected (0.00 sec)
 ​
 mysql> select * from t_stu where id = 5;
 Empty set (0.01 sec)

然后事务 B 插入一条 id = 5 的记录,并且提交了事务。

 # 事务 B
 mysql> begin;
 Query OK, 0 rows affected (0.00 sec)
 ​
 mysql> insert into t_stu values(5, '小美', 18);
 Query OK, 1 row affected (0.00 sec)
 ​
 mysql> commit;
 Query OK, 0 rows affected (0.00 sec)

然后,事务 A 对 id = 5 这条记录进行更新,然后再次查询 id = 5 的记录,此时会发现事务 A 能查询到 id = 5 的记录,由于第一次查询结果是empty,第二次查到了,从而出现幻读。

 # 事务 A
 mysql> update t_stu set name = '小王' where id = 5;
 Query OK, 1 row affected (0.01 sec)
 Rows matched: 1  Changed: 1  Warnings: 0
 ​
 mysql> select * from t_stu where id = 5;
 +----+--------------+------+
 | id | name         | age  |
 +----+--------------+------+
 |  5 | 小王         |   18 |
 +----+--------------+------+
 1 row in set (0.00 sec)

上述过程的具体分析:

首先回顾一下,查询一条记录,基于MVCC,是怎样的流程:

  1. 获取事务自己的版本号,即事务ID(trx_id)
  2. 获取 Read View
  3. 基于索引或全表查询得到的数据,然后 Read View 中的事务版本号进行比较。
  4. 如果不符合 Read View 的可见性规则, 即就需要 Undo log 中寻找历史快照;
  5. 最后返回符合规则的数据

基于上述流程,我们开始分析:

第一步,RR 隔离级别下,开启事务A,执行普通 select 查询,假设事务A的 trx_id = 1,且当前只有这一个事务,生成 Read View(如下):

(tip:后续事务A,执行当前读时都采用该 Read View)

可重复读隔离级别发生幻读场景(图解分析)

事务A第一次查询的时候,在索引/全表上是查不到数据的,因为不存在,所以不进行 Read View 中的可见性判断。

第二步,开启事务B,事务B的 trx_id = 2,创建并提交 id=5 记录,生成了如下的记录:

可重复读隔离级别发生幻读场景(图解分析)

第三步,事务A进行 update 操作,RR 级别下 update 会获取邻键锁,由于事务B已经提交,所以事务A可以正常获取锁并修改 id=5 的数据,生成的新纪录的 trx_id 是事务A的 id=1 。然后新旧记录通过 roll_pointer 指针相连,形成版本链,如下:

可重复读隔离级别发生幻读场景(图解分析)

第四步,当事务A再次执行查询操作时,根据快照读,查询之前的 Read View ,发现id=5这条数据上的隐藏列 trx_id 是自己,因此就能看到最新的这条数据,于是就发生了幻读。

场景二

  • T1 时刻:事务 A 先执行「快照读语句」:select * from t_test where id > 100 得到了 3 条记录。
  • T2 时刻:事务 B 往插入一个 id= 200 的记录并提交;
  • T3 时刻:事务 A 再执行「当前读语句」 select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象。

要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update 这类当前读的语句,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。