You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

320 lines
18 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 20 | 幻读是什么,幻读有什么问题?
在上一篇文章最后,我给你留了一个关于加锁规则的问题。今天,我们就从这个问题说起吧。
为了便于说明问题,这一篇文章,我们就先使用一个小一点儿的表。建表和初始化语句如下(为了便于本期的例子说明,我把上篇文章中用到的表结构做了点儿修改):
```
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
```
这个表除了主键id外还有一个索引c初始化语句在表中插入了6行数据。
上期我留给你的问题是,下面的语句序列,是怎么加锁的,加的锁又是什么时候释放的呢?
```
begin;
select * from t where d=5 for update;
commit;
```
比较好理解的是这个语句会命中d=5的这一行对应的主键id=5因此在select 语句执行完成后id=5这一行会加一个写锁而且由于两阶段锁协议这个写锁会在执行commit语句的时候释放。
由于字段d上没有索引因此这条查询语句会做全表扫描。那么其他被扫描到的但是不满足条件的5行记录上会不会被加锁呢
我们知道InnoDB的默认事务隔离级别是可重复读所以本文接下来没有特殊说明的部分都是设定在可重复读隔离级别下。
# 幻读是什么?
现在我们就来分析一下如果只在id=5这一行加锁而其他行的不加锁的话会怎么样。
下面先来看一下这个场景(注意:这是我假设的一个场景):
![](https://static001.geekbang.org/resource/image/5b/8b/5bc506e5884d21844126d26bbe6fa68b.png)
图 1 假设只在id=5这一行加行锁
可以看到session A里执行了三次查询分别是Q1、Q2和Q3。它们的SQL语句相同都是select \* from t where d=5 for update。这个语句的意思你应该很清楚了查所有d=5的行而且使用的是当前读并且加上写锁。现在我们来看一下这三条SQL语句分别会返回什么结果。
1. Q1只返回id=5这一行
2. 在T2时刻session B把id=0这一行的d值改成了5因此T3时刻Q2查出来的是id=0和id=5这两行
3. 在T4时刻session C又插入一行1,1,5因此T5时刻Q3查出来的是id=0、id=1和id=5的这三行。
其中Q3读到id=1这一行的现象被称为“幻读”。也就是说幻读指的是一个事务在前后两次查询同一个范围的时候后一次查询看到了前一次查询没有看到的行。
这里,我需要对“幻读”做一个说明:
1. 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
2. 上面session B的修改结果被session A之后的select语句用“当前读”看到不能称为幻读。幻读仅专指“新插入的行”。
如果只从第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562)我们学到的事务可见性规则来分析的话上面这三条SQL语句的返回结果都没有问题。
因为这三个查询都是加了for update都是当前读。而当前读的规则就是要能读到所有已经提交的记录的最新值。并且session B和sessionC的两条语句执行后就会提交所以Q2和Q3就是应该看到这两个事务的操作效果而且也看到了这跟事务的可见性规则并不矛盾。
但是,这是不是真的没问题呢?
不,这里还真就有问题。
# 幻读有什么问题?
**首先是语义上的。**session A在T1时刻就声明了“我要把所有d=5的行锁住不准别的事务进行读写操作”。而实际上这个语义被破坏了。
如果现在这样看感觉还不明显的话我再往session B和session C里面分别加一条SQL语句你再看看会出现什么现象。
![](https://static001.geekbang.org/resource/image/7a/07/7a9ffa90ac3cc78db6a51ff9b9075607.png)
图 2 假设只在id=5这一行加行锁--语义被破坏
session B的第二条语句update t set c=5 where id=0语义是“我把id=0、d=5这一行的c值改成了5”。
由于在T1时刻session A 还只是给id=5这一行加了行锁 并没有给id=0这行加上锁。因此session B在T2时刻是可以执行这两条update语句的。这样就破坏了 session A 里Q1语句要锁住所有d=5的行的加锁声明。
session C也是一样的道理对id=1这一行的修改也是破坏了Q1的加锁声明。
**其次,是数据一致性的问题。**
我们知道,锁的设计是为了保证数据的一致性。而这个一致性,不止是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性。
为了说明这个问题我给session A在T1时刻再加一个更新语句update t set d=100 where d=5。
![](https://static001.geekbang.org/resource/image/dc/92/dcea7845ff0bdbee2622bf3c67d31d92.png)
图 3 假设只在id=5这一行加行锁--数据一致性问题
update的加锁语义和select …for update 是一致的所以这时候加上这条update语句也很合理。session A声明说“要给d=5的语句加上锁”就是为了要更新数据新加的这条update语句就是把它认为加上了锁的这一行的d值修改成了100。
现在我们来分析一下图3执行完成后数据库里会是什么结果。
1. 经过T1时刻id=5这一行变成 (5,5,100)当然这个结果最终是在T6时刻正式提交的;
2. 经过T2时刻id=0这一行变成(0,5,5);
3. 经过T4时刻表里面多了一行(1,5,5);
4. 其他行跟这个执行序列无关,保持不变。
这样看这些数据也没啥问题但是我们再来看看这时候binlog里面的内容。
1. T2时刻session B事务提交写入了两条语句
2. T4时刻session C事务提交写入了两条语句
3. T6时刻session A事务提交写入了update t set d=100 where d=5 这条语句。
我统一放到一起的话,就是这样的:
```
update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/
insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/
update t set d=100 where d=5;/*所有d=5的行d改成100*/
```
你应该看出问题了。这个语句序列不论是拿到备库去执行还是以后用binlog来克隆一个库这三行的结果都变成了 (0,5,100)、(1,5,100)和(5,5,100)。
也就是说id=0和id=1这两行发生了数据不一致。这个问题很严重是不行的。
到这里,我们再回顾一下,**这个数据不一致到底是怎么引入的?**
我们分析一下可以知道这是我们假设“select \* from t where d=5 for update这条语句只给d=5这一行也就是id=5的这一行加锁”导致的。
所以我们认为,上面的设定不合理,要改。
那怎么改呢?我们把扫描过程中碰到的行,也都加上写锁,再来看看执行效果。
![](https://static001.geekbang.org/resource/image/34/47/34ad6478281709da833856084a1e3447.png)
图 4 假设扫描到的行都被加上了行锁
由于session A把所有的行都加了写锁所以session B在执行第一个update语句的时候就被锁住了。需要等到T6时刻session A提交以后session B才能继续执行。
这样对于id=0这一行在数据库里的最终结果还是 (0,5,5)。在binlog里面执行序列是这样的
```
insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/
update t set d=100 where d=5;/*所有d=5的行d改成100*/
update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/
```
可以看到按照日志顺序执行id=0这一行的最终结果也是(0,5,5)。所以id=0这一行的问题解决了。
但同时你也可以看到id=1这一行在数据库里面的结果是(1,5,5)而根据binlog的执行结果是(1,5,100)也就是说幻读的问题还是没有解决。为什么我们已经这么“凶残”地把所有的记录都上了锁还是阻止不了id=1这一行的插入和更新呢
原因很简单。在T3时刻我们给所有行加锁的时候id=1这一行还不存在不存在也就加不上锁。
**也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,**这也是为什么“幻读”会被单独拿出来解决的原因。
到这里,其实我们刚说明完文章的标题 :幻读的定义和幻读有什么问题。
接下来我们再看看InnoDB怎么解决幻读的问题。
# 如何解决幻读?
现在你知道了产生幻读的原因是行锁只能锁住行但是新插入记录这个动作要更新的是记录之间的“间隙”。因此为了解决幻读问题InnoDB只好引入新的锁也就是间隙锁(Gap Lock)。
顾名思义间隙锁锁的就是两个值之间的空隙。比如文章开头的表t初始化插入了6个记录这就产生了7个间隙。
![](https://static001.geekbang.org/resource/image/e7/61/e7f7ca0d3dab2f48c588d714ee3ac861.png)
图 5 表t主键索引上的行锁和间隙锁
这样,当你执行 select \* from t where d=5 for update的时候就不止是给数据库中已有的6个记录加上了行锁还同时加了7个间隙锁。这样就确保了无法再插入新的记录。
也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。
现在你知道了,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。但是间隙锁跟我们之前碰到过的锁都不太一样。
比如行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。
![](https://static001.geekbang.org/resource/image/c4/51/c435c765556c0f3735a6eda0779ff151.png)
图6 两种行锁间的冲突关系
也就是说,跟行锁有冲突关系的是“另外一个行锁”。
但是间隙锁不一样,**跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。**间隙锁之间都不存在冲突关系。
这句话不太好理解,我给你举个例子:
![](https://static001.geekbang.org/resource/image/7c/98/7c37732d936650f1cda7dbf27daf7498.png)
图7 间隙锁之间不互锁
这里session B并不会被堵住。因为表t里并没有c=7这个记录因此session A加的是间隙锁(5,10)。而session B也是在这个间隙加的间隙锁。它们有共同的目标保护这个间隙不允许插入值。但它们之间是不冲突的。
间隙锁和行锁合称next-key lock每个next-key lock是前开后闭区间。也就是说我们的表t初始化以后如果用select \* from t for update要把整个表所有记录锁起来就形成了7个next-key lock分别是 (-∞,0\]、(0,5\]、(5,10\]、(10,15\]、(15,20\]、(20, 25\]、(25, +supremum\]。
> 备注这篇文章中如果没有特别说明我们把间隙锁记为开区间把next-key lock记为前开后闭区间。
你可能会问说这个supremum从哪儿来的呢
这是因为+∞是开区间。实现上InnoDB给每个索引加了一个不存在的最大值supremum这样才符合我们前面说的“都是前开后闭区间”。
**间隙锁和next-key lock的引入帮我们解决了幻读的问题但同时也带来了一些“困扰”。**
在前面的文章中,就有同学提到了这个问题。我把他的问题转述一下,对应到我们这个例子的表来说,业务逻辑这样的:任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新它的数据,代码如下:
```
begin;
select * from t where id=N for update;
/*如果行不存在*/
insert into t values(N,N,N);
/*如果行存在*/
update t set d=N set id=N;
commit;
```
可能你会说这个不是insert … on duplicate key update 就能解决吗?但其实在有多个唯一键的时候,这个方法是不能满足这位提问同学的需求的。至于为什么,我会在后面的文章中再展开说明。
现在,我们就只讨论这个逻辑。
这个同学碰到的现象是这个逻辑一旦有并发就会碰到死锁。你一定也觉得奇怪这个逻辑每次操作前用for update锁起来已经是最严格的模式了怎么还会有死锁呢
这里我用两个session来模拟并发并假设N=9。
![](https://static001.geekbang.org/resource/image/df/be/df37bf0bb9f85ea59f0540e24eb6bcbe.png)
图8 间隙锁导致的死锁
你看到了其实都不需要用到后面的update语句就已经形成死锁了。我们按语句执行顺序来分析一下
1. session A 执行select … for update语句由于id=9这一行并不存在因此会加上间隙锁(5,10);
2. session B 执行select … for update语句同样会加上间隙锁(5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
3. session B 试图插入一行(9,9,9)被session A的间隙锁挡住了只好进入等待
4. session A试图插入一行(9,9,9)被session B的间隙锁挡住了。
至此两个session进入互相等待状态形成死锁。当然InnoDB的死锁检测马上就发现了这对死锁关系让session A的insert语句报错返回了。
你现在知道了,**间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的**。其实,这还只是一个简单的例子,在下一篇文章中我们还会碰到更多、更复杂的例子。
你可能会说,为了解决幻读的问题,我们引入了这么一大串内容,有没有更简单一点的处理方法呢。
我在文章一开始就说过如果没有特别说明今天和你分析的问题都是在可重复读隔离级别下的间隙锁是在可重复读隔离级别下才会生效的。所以你如果把隔离级别设置为读提交的话就没有间隙锁了。但同时你要解决可能出现的数据和日志不一致问题需要把binlog格式设置为row。这也是现在不少公司使用的配置组合。
前面文章的评论区有同学留言说他们公司就使用的是读提交隔离级别加binlog\_format=row的组合。他曾问他们公司的DBA说你为什么要这么配置。DBA直接答复说因为大家都这么用呀。
所以,这个同学在评论区就问说,这个配置到底合不合理。
关于这个问题本身的答案是,如果读提交隔离级别够用,也就是说,业务不需要可重复读的保证,这样考虑到读提交下操作数据的锁范围更小(没有间隙锁),这个选择是合理的。
但其实我想说的是,配置是否合理,跟业务场景有关,需要具体问题具体分析。
但是如果DBA认为之所以这么用的原因是“大家都这么用”那就有问题了或者说迟早会出问题。
比如说大家都用读提交可是逻辑备份的时候mysqldump为什么要把备份线程设置成可重复读呢这个我在前面的文章中已经解释过了你可以再回顾下第6篇文章[《全局锁和表锁 :给表加个字段怎么有这么多阻碍?》](https://time.geekbang.org/column/article/69862)的内容)
然后,在备份期间,备份线程用的是可重复读,而业务线程用的是读提交。同时存在两种事务隔离级别,会不会有问题?
进一步地,这两个不同的隔离级别现象有什么不一样的,关于我们的业务,“用读提交就够了”这个结论是怎么得到的?
如果业务开发和运维团队这些问题都没有弄清楚,那么“没问题”这个结论,本身就是有问题的。
# 小结
今天我们从上一篇文章的课后问题说起,提到了全表扫描的加锁方式。我们发现即使给所有的行都加上行锁,仍然无法解决幻读问题,因此引入了间隙锁的概念。
我碰到过很多对数据库有一定了解的业务开发人员他们在设计数据表结构和业务SQL语句的时候对行锁有很准确的认识但却很少考虑到间隙锁。最后的结果就是生产库上会经常出现由于间隙锁导致的死锁现象。
行锁确实比较直观判断规则也相对简单间隙锁的引入会影响系统的并发度也增加了锁分析的复杂度但也有章可循。下一篇文章我就会为你讲解InnoDB的加锁规则帮你理顺这其中的“章法”。
作为对下一篇文章的预习,我给你留下一个思考题。
![](https://static001.geekbang.org/resource/image/0d/3d/0d796060073668ca169166a8903fbf3d.png)
图9 事务进入锁等待状态
如果你之前没有了解过本篇文章的相关内容一定觉得这三个语句简直是风马牛不相及。但实际上这里session B和session C的insert 语句都会进入锁等待状态。
你可以试着分析一下,出现这种情况的原因是什么?
这里需要说明的是这其实是我在下一篇文章介绍加锁规则后才能回答的问题是留给你作为预习的其中session C被锁住这个分析是有点难度的。如果你没有分析出来也不要气馁我会在下一篇文章和你详细说明。
你也可以说说你的线上MySQL配置的是什么隔离级别为什么会这么配置你有没有碰到什么场景是必须使用可重复读隔离级别的呢
你可以把你的碰到的场景和分析写在留言区里,我会在下一篇文章选取有趣的评论跟大家一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我们在本文的开头回答了上期问题。有同学的回答中还说明了读提交隔离级别下在语句执行完成后是只有行锁的。而且语句执行完成后InnoDB就会把不满足条件的行行锁去掉。
当然了c=5这一行的行锁还是会等到commit的时候才释放的。
评论区留言点赞板:
> @薛畅 、@张永志同学给出了正确答案。而且提到了在读提交隔离级别下,是只有行锁的。
> @帆帆帆帆帆帆帆帆、@欧阳成 对上期的例子做了验证需要说明一下需要在启动配置里面增加performance\_schema=on才能用上这个功能performance\_schema库里的表才有数据。