在第 14 篇文章中,我们介绍了 MySQL 数据库中常用的几种隔离机制,并且介绍了由于隔离机制所产生的几种读现象。

大部分的读现象我们可以通过提高事务的隔离机制来解决,但是有一种读现象很特殊,它就是幻读

今天,我们用一篇文章,来具体介绍一下幻读是怎样产生的、幻读会导致什么问题以及如何解决幻读所产生的问题。

幻读是怎么产生的

在了解幻读是怎么产生的之前,我们首先创建一个数据表,方便用来测试:

  1. CREATE TABLE `t1`.`test` (
  2. `id` int NOT NULL AUTO_INCREMENT,
  3. `a` int(11) NULL,
  4. `b` int(11) NULL,
  5. PRIMARY KEY (`id`)
  6. ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci;

该数据表有三个字段,分别是 id、a 和 b。

顺便,我们在该数据表中插入几条测试数据。

  1. INSERT INTO test VALUES (10,10,10);
  2. INSERT INTO test VALUES (20,20,20);
  3. INSERT INTO test VALUES (30,30,30);
  4. INSERT INTO test VALUES (40,40,40);
  5. INSERT INTO test VALUES (50,50,50);
  6. INSERT INTO test VALUES (60,60,60);

首先,我们回顾一下幻读,它体现出来的最主要的一个现象是:同一个事务中,多次查询同一条数据时显示的结果不一致。具体如下:

  1. -- 在事务1中查询
  2. mysql> begin;
  3. Query OK, 0 rows affected (0.00 sec)
  4. mysql> select * from test where a = 20 for update;
  5. +----+------+------+
  6. | id | a | b |
  7. +----+------+------+
  8. | 20 | 20 | 20 |
  9. +----+------+------+
  10. 1 row in set (0.00 sec)

此时,我们可以看到的是,查询出来一条相关的数据。

然后,我们在一个新的事务中修改一条数据,具体如下:

更新数据之前不要使用 select … for update,修改数据之后再使用 select … for update ,模拟修改数据的场景。

  1. -- 在事务 2 中修改一条数据
  2. mysql> update test set a = 20 where b = 50;
  3. Query OK, 1 row affected (0.00 sec)
  4. Rows matched: 1 Changed: 1 Warnings: 0

此时,可以看到的是我们将 b = 50 的这条数据的a字段的值修改成了20,那么在事务 1 中查询相关的数据会是什么结果呢?具体如下:

  1. -- 在事务1中查询
  2. mysql> select * from test where a = 20 for update;
  3. +----+------+------+
  4. | id | a | b |
  5. +----+------+------+
  6. | 20 | 20 | 20 |
  7. | 50 | 20 | 50 |
  8. +----+------+------+
  9. 2 rows in set (0.00 sec)

此时,我们可以看到的是,同一个事务、同一条 SQL ,最终的结果却是不同;除此之外,我们新增一条数据是不是也是相同的结果呢?具体如下:

  1. -- 在事务 3 中新增一条数据
  2. mysql> insert into test values (80,20,80);
  3. Query OK, 1 row affected (6.88 sec)
  4. -- 在事务 1 中查询
  5. mysql> select * from test where a = 20 for update;
  6. +----+------+------+
  7. | id | a | b |
  8. +----+------+------+
  9. | 20 | 20 | 20 |
  10. | 50 | 20 | 50 |
  11. | 80 | 20 | 80 |
  12. +----+------+------+
  13. 3 rows in set (0.00 sec)

跟我们设想的结果相同,新增一条数据之后,我们可以使用排他锁立即查询相关的数据,这个时候我们形象地将其称为:幻读。

幻读导致了什么问题

上面,我们通过给 select 加上排他锁(for update)的方式模拟修改数据的场景;也就是说,在当时的情况下,如果我们采用相同条件的 SQL 修改相关的数据的话,那么修改的是最新的数据。

此时,我们会问有什么问题吗?确实有问题!

不过,在介绍具体问题之前,我们首先通过一个案例,来给你介绍幻读会导致哪些问题。

我们将上述的 SQL 变一下,在事务 1 中修改部分数据,具体如下:

  1. -- 在事务1中我们修改部分数据
  2. mysql> begin;
  3. Query OK, 0 rows affected (0.00 sec)
  4. mysql> update test set b = 11 where b = 10;
  5. Query OK, 1 row affected (0.00 sec)
  6. Rows matched: 1 Changed: 1 Warnings: 0

然后在事务 2 中修改部分数据,具体如下:

  1. mysql> update test set b = 10 where b = 80;
  2. Query OK, 0 rows affected (0.00 sec)
  3. Rows matched: 0 Changed: 0 Warnings: 0

再然后,我们在事务 3 中新增部分数据,具体如下:

  1. mysql> insert into test values (100,100,10);
  2. Query OK, 1 row affected (0.00 sec)

此时,我们再来提交事务 1,具体如下:

  1. mysql> commit;
  2. Query OK, 0 rows affected (0.00 sec)

从这流程中,我们可能会产生一个疑问,感觉这个过程似乎是没有问题的,对吗?

OK,那么我们来查询一下该数据表中的数据。具体如下:

  1. mysql> select * from test;
  2. +-----+------+------+
  3. | id | a | b |
  4. +-----+------+------+
  5. | 10 | 10 | 11 |
  6. ...... -- 此处省略部分数据
  7. | 80 | 20 | 10 |
  8. | 100 | 100 | 10 |
  9. +-----+------+------+
  10. 10 rows in set (0.00 sec)

如果,此时你是操作事务 1 的同学,你会不会感觉到很奇怪呢?明明你执行的 SQL 的主要作用是将所有b = 10的数据的 b 字段的数值修改成了 11 ,那么怎么还会出现两个 b 字段的数值仍然是 10

好,为了解释这个问题,我们把上方的 SQL 简单描述一下。

  • 第一个步骤:执行update test set b = 11 where b = 10;,但是该执行的结果在步骤二和三执行之后才提交。
  • 第二个步骤:执行update test set b = 10 where b = 80;
  • 第三个步骤:执行insert into test values (100,100,10);
  • 第四个步骤:提交第一个步骤执行结果。

为了方便理解,我用普通的 SQL 再来解释一遍,具体如下:

  1. update test set b = 11 where b = 10;
  2. update test set b = 10 where b = 80;
  3. insert into test values (100,100,10);

注意:这个时候,有朋友就会问了,它不是在第四个步骤才提交吗?应该会修改所有的数据的呀?这个其实是受 MVCC 的影响,步骤一会先修改快照中的值,然后再将快照中的值 merge 过来。

此时,幻读问题就给我们造成了数据不一致的问题,这个问题对于 MySQL 这类关系型的数据库来说是非常严重的问题。

MySQL 数据库为了解决这个问题,提供了一个新的锁机制,即:间隙锁

如何解决幻读所产生的影响

我们都知道,为了解决 MySQL 数据库在并发时的数据安全问题,它为我们提供了一个锁机制,我们之前学习了行锁。行锁:顾名思义就是操作一行锁定一行。

结合上面我们解释的幻读现象。我们来一起分析一下,出现幻读现象最主要的原因是当我们在一个事务中操作部分内容时,有其他事务在我们本次操作的范围之内添加了新的操作,进而导致了幻读。

那么,换句话说,如果我们想解决幻读现象所导致的问题时,那么就必须保证在一个事务中操作部分数据的时候,其他事务禁止修改该部分数据。

如果我们能够达到这一效果,就能够彻底解决幻读现象。

MySQL 数据库为了解决这个问题,给我们提供了一个新的锁机制,即间隙锁

在详细介绍间隙锁之前,我们说明一下,为什么不能使用行锁?

行锁只锁定部分数据,而不能锁定某一个范围,例如:我们修改b=10的相关数据时,行锁无法控制其他事务添加b=10的相关数据,所以不可取。

那么什么是间隙锁呢?它是怎么解决幻读的呢?

间隙锁,顾名思义就是会锁定某一个范围,这个范围中的数据全部都归该锁控制,以至于其他事务无法操作相关范围的数据,以此达到解决幻读的目的。

为了方便理解,我们给大家详细解释一下(将数据表中的数据还原成最初的数据)。

回到上面,如果我们操作的 SQL 为:update test set b = 11 where b = 10;

注意:此时,MySQL 数据库会将相关的数据分为几个区间,分别是:(-∞, 10]、(10, 20]、(10, 30]、(10, 40]、(10, 50]、(10, 60] 以及 (60, +∞)。MySQL 间隙锁是一个左开右闭的区间。

那么,MySQL 不仅会锁定b=10的相关数据,还会锁定相关的 7 区间,此时如果你想修改相关数据和新增相关的数据时,都是阻塞状态。

总结

今天,我们学习了幻读相关的知识。首先我们介绍了幻读是怎么形成的,其次我们又分析了幻读可能会导致的问题,最后我们解释了如何解决幻读所导致的问题。

在实际应用过程中,我们应该尽量避免过多地使用间隙锁,因为间隙锁的性能消耗比较高,如果想要提高性能就少用。