所谓幻读,即一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行,这个回答估计大伙儿已经背烂了,但是它具体有什么后果呢?为什么会被 MySQL 单独拎出来解决呢?MySQL 又是如何解决的呢?
当前读和快照读
在了解幻读以及 MySQL 是如何解决幻读这个问题前,我们需要知道,什么是当前读、什么是快照读。
快照读:读取快照中的数据,不需要进行加锁。看到快照这两个字,各位肯定马上就想到 MVCC 了,是这样,MVCC 作用于读取已提交和可重复读(默认)这两个隔离级别,这俩隔离级别下的普通 select 操作就是快照读
当前读:读取的是最新版本的数据, 并且对读取的记录加锁, 阻塞其他事务同时改动相同记录,避免出现安全问题。
除了读取已提交和可重复读这俩隔离级别下的普通 select 操作,其余操作都是当前读:
代码语言:javascript复制select...lock in share mode (共享读锁)
select...for update
update, delete, insert
举个例子,来直观感受下快照读和当前读,以可重复读隔离级别为例,假设我们目前有一张 user 表,有如下的数据:
开启两个事务:
事务 1 先来个快照读看看:
没毛病,很正常。
事务 2 修改 age = 99,然后提交:
你猜,这时候,事务 1 快照读出来的数据应该是多少?
是的,仍然是旧数据:
那事务 1 “当前读” 出来的数据肯定是最新的了:
幻读到底有什么问题
所谓幻读,即一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。
在可重复读隔离级别下,普通的查询是快照读,当前事务是不会看到别的事务插入的数据的。因此,幻读问题在 “当前读” 下才会出现。
也有很多的文章说用 MVCC 来解决 "快照读" 下的幻读问题,我觉得没必要纠结吧,甚至可能面试官更可能愿意听到 MVCC 的原理。 不过,话说回来,没有 MVCC 机制,哪来快照读这个东西?
那么,幻读到底有什么问题?它具体有什么后果呢?
不多废话,直接上例子:
我们有一张表 user(id, username, age),已经有两条数据 (1, "Jack", 20), (2, "Tom", 18)
有如下三个事务:
众所周知,select for update 语句会加行锁,假设,注意这里是假设奥!!!假设事务 1 的 select * from user where name = 'Jack' for update
只在 id = 1 的这一行上加行锁
可以看到,事务 1 执行了三次查询,都是要查出 name = "Jack" 的记录行。注意我们假设这里只在 name = 'Jack' 行上加行锁
- 第一次查询只返回了 id = 1 这一行
- 在第二次查询之前,事务 2 把 id = 2 这一行的 name 值改成了 "Jack",因此事务 1 第二次查询的结果是 id = 1 和 id = 2 这两行
- 在第三次查询之前,事务 3 插入了一个 name = "Jack" 的新数据,因此事务 1 第三次查询的结果是 id = 1、id = 2 以及 id = 3 这三行
显然,第三次查询读到的 id = 3 这一行的现象,就是幻读
但其实从逻辑上来说,这似乎是没有问题的。
因为这三个查询都是加了 for update,都是当前读。而当前读的规则,就是要能读到所有已经提交的记录的最新值,所以第二次查询和第三次查询就是应该看到事务 2 和事务 3 的操作效果。
那么,幻读到底有啥问题?
首先是语义上的。事务 1 在第一次查询的时候就声明了,我要把所有 name = "Jack" 的行锁住,拒绝别的事务对 name = "Jack" 的行进行读写操作。
但是,实际上,这个语义被破坏了,举个例子,我再往事务 2 里加一条 SQL 语句(黄色框框):
事务 2 的第二条语句的意思是 "把 id = 2 这一行的 age 值改成了 40",这行的 name 值是 "Jack"。
而在这之前,事务 1 只是给 id = 5 的这一行加了行锁,并没有给 id = 2 这行加锁。所以,事务 2 是可以执行这条 update 语句的。
这样,事务 2 先将 id = 2 的 name 改为 Jack,然后再将 age 改为 40,破坏了事务 1 对要把所有 "name = Jack 的行锁住" 的声明
其次,最重要的是,是数据一致性的问题。
众所周知,加锁是为了保证数据的一致性,这个一致性,不仅包括数据的一致性,还包括数据和日志的一致性,举个例子:
给事务 1 再加上一条 SQL 语句(黄色框框)
我在上图中圈出了四个时刻, T1 T2 T3 和 T4,我们来分析下经过这四个时刻的数据库状态:
- 经过 T1 时刻,id = 1 这一行变成 (1, Tom, 20),注意这是在 T4 才正式提交的
- 经过 T2 时刻,id = 2 这一行变成 (2, Jack, 40)
- 经过 T3 时刻,表里面多了一行 (3, Jack, 30)
再来看看这时候 binlog 日志里面的内容,binlog 就是记录下我们做了哪些操作嘛:
T2 时刻,事务 2 提交,写入了 2 条 update 语句;
代码语言:javascript复制update user set name = "Jack" where id = 2
update user set age = "40" where id = 2 /*(2, Jack, 40)*/
T3 时刻,事务 3 提交,写入了 1 条语句;
代码语言:javascript复制insert into user values(3, "Jack", 30) /*(3, Jack, 30)*/
T4 时刻,事务 1 提交,binlog 中写入了 update user set name = "Tom" where name = "Jack"
这条语句
update user set name = "Tom" where name = "Jack"
就是说,把所有 name = Jack 的行,都给我改成 name = "Tom"
这样,问题就来喽,binlog 一般都是用于备库同步主库的对吧,这个 binlog 一执行,那岂不是原先 (2, Jack, 40) 和 (3, Jack, 30) 这两行的 name 全都变成了 Tom。
也就是说,id = 2 和 id = 3 这两行,发生了数据不一致。
注意!这个数据不一致到底是怎么发生的?是假设事务 1 的 select * from user where name = 'Jack' for update
只在 id = 1 的这一行上加行锁导致的。
很显然,分析到这里,我们已经明白,只锁这一行是不合理的。那好办,让 select for update 把所有扫描到的行都给锁住不就行了?
这样,事务 2 在 T2 时刻就会被阻塞住,直到事务 1 在 T4 时刻 commit 释放锁
image-20211129105557057
由于 session A 把所有的行都加了写锁,所以 session B 在执行第一个 update 语句的时候就被锁住了。需要等到 T6 时刻 session A 提交以后,session B 才能继续执行。
But,这样看似没问题,是否真的没问题呢?
来看 binlog,执行序列是这样的:
代码语言:javascript复制事务 3:
insert into user values(3, "Jack", 30) /*(3, Jack, 30)*/
事务 1:
update user set name = "Tom" where name = "Jack"
事务 2:
update user set name = "Jack" where id = 2
update user set age = "40" where id = 2 /*(2, Jack, 40)*/
可以看到,事务 2 的问题确实是解决了,Jack 保住了,仍然是 (2, Jack, 40)
但是!!!注意事务 3,在数据库里面的结果是 (3, "Jack", 30),而根据 binlog 的执行结果是 (3, Tom, 30),也就是说幻读的问题还是没有解决。
那为什么我们已经把所有能够扫描到的记录都加上了锁,还是阻止不了 id = 3 这一行的插入和更新呢?
很简单。在我们给所有行加锁的时候,id = 3 这一行还不存在,数据库扫描不到,也就当然加不上锁了。
这也是为什么幻读问题会被单独拿出来解决的原因,即使我们把所有的的记录都加上锁,还是阻止不了新插入的记录。
MySQL 如何解决幻读
现在你知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,操作的是锁住的行之间的 “间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。
这样,当你执行 select * from user where name = 'Jack' for update 的时候,就不止是给数据库中已有的 n 个记录加上了行锁,还同时加了 n 1 个间隙锁(这两个合起来也成为 Next-Key Lock 临键锁)。也就是说,在数据库一行行扫描的过程中,不仅扫描到的行加上了行锁,还给行两边的空隙也加上了锁。这样就确保了无法再插入新的记录。
这里多提一嘴,update、delete 语句用不上索引是很恐怖的。 对非索引字段进行 select .. for update、update 或者 delete 操作,由于没有索引,走全表查询,就会对所有行记录 以及 所有间隔 都进行上锁。而对于索引字段进行上述操作,只有索引字段本身和附近的间隔会被加锁。
总结下 MySQL 解决幻读的手段:
隔离级别:可重复读
- 快照读 MVCC 当前读 Next-Lock Key(只在可重复读隔离级别下生效)
隔离级别:SERIALIZABLE
- 在这个隔离级别下,事务在读操作时,先加表级别的共享锁,直到事务结束才释放;事务在写操作时,先加表级别的排它锁,直到事务结束才释放。也就是说,串行化锁定了整张表,幻读不存在的
最后放上这道题的背诵版: