欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

MySQL 笔记整理(20) --幻读是什么,幻读有什么问题?

程序员文章站 2022-05-03 11:51:25
笔记记录自林晓斌(丁奇)老师的《MySQL实战45讲》 (本篇内图片均来自丁奇老师的讲解,如有侵权,请联系我删除) 20) --幻读是什么,幻读有什么问题? 我们先来看看表结构和初始化数据: 表t除主键id外还有一个索引c,初始化语句在表中插入了6行数据。那么如果有下面这样一段语句 请问是怎么加锁的 ......

笔记记录自林晓斌(丁奇)老师的《mysql实战45讲》

(本篇内图片均来自丁奇老师的讲解,如有侵权,请联系我删除)

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);

  表t除主键id外还有一个索引c,初始化语句在表中插入了6行数据。那么如果有下面这样一段语句

begin;
select * from t where d=5 for update;
commit;

  请问是怎么加锁的,加的锁又是什么时候释放的呢?由于for update,上面的语句会在执行完成select之后加一个写锁,而且由于两阶段锁协议,这个写锁会在执行commit语句的时候释放。由于字段d上没有索引,因此这条查询语句会做全表扫描。那么,其他被扫描到的,但是不满足条件的5行记录上,会不会也被加锁呢?我们知道,innodb的默认隔离级别是可重复读,所以本文接下来没有特殊说明的部分,都是设定在可重复读隔离级别下的。

幻读是什么?

  我们不妨来分析一下,如果只在d=5,也就是id=5这一行上加锁,其他行上不加锁,会怎么样。我们来看一下这种情况的场景,注意,这里是符合刚才假设的,只在查询的那一行加锁,其他行不加锁的情况。

MySQL 笔记整理(20) --幻读是什么,幻读有什么问题?

  由上图可以看到,在session a中执行了三次查询,分别是q1,q2和q3,他们的查询语句都相同,但是返回结果都不同。其中q3读到id=1这一行的现象,被称为“幻读”。也就是说,幻读值得是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。这里需要对“幻读”额外说明一下:

  1. 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。(之前有提到过,update语句是“当前读”,select 语句如果加锁,也是“当前读”)
  2. 上面的session b的修改结果,被 session a之后的select语句(q2,q3)用“当前读”看到,不能称为“幻读”。“幻读”仅专指“新插入的行”。

  因为这三次查询都加了for update,都是当前读。根据规则,就是要能读到所有已经提交的记录的最新值,并且session b和session c的两条语句执行完成后就会提交,所以q2和q3就是应该看到这两个事务的操作效果,而且也看到了,这跟事务的可见性规则并不矛盾。但这是不是真的没有问题呢?不,这还真有一些问题。

幻读有什么问题?

  首先是语义上的问题。session a在t1时刻的查询里包含for update,意思是“我要把所有d=5的行锁住,不准别的事务进行读写操作”。但实际上,这个语义被破坏掉了。如果这样还不够明显,可以想象一下,在t2时刻session b中如果添加这样一条语句:update t set c = 5 where id = 0;session a的语义是 所有d=5的行锁住,不准别的事务进行读写操作。但在t2时刻,session b中id=0这一行没有被session a的声明锁住,同时,由于是在同一个事务中,对id=0(d=5)这一行的更新操作也能正常执行。

  其次,是数据一致性的问题。我们知道,锁的设计是为了保证数据的一致性。而这一致性,不止是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性。为了说明这个问题,我们给session a在t1时刻再加上一个更新语句,即:update t set d = 100 where d = 5;

MySQL 笔记整理(20) --幻读是什么,幻读有什么问题?

  update的加锁语义和select ...for update是一致的,所以这时候加上这条update语句也很合理。session a声明说“要给d=5的这条语句加上锁”,也就是为了要更新数据,新加的这条update语句就把它认为加上了锁的这一行的d值修改成100.我们来分析一下上图执行完成之后,数据库里会是什么结果。

  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的这一行加锁”导致的。所以我们可以认为上面的设定不合理,需要更改。那要怎么改呢,我们把扫描中碰到的行,也都加上写锁,再来看看执行效果。

MySQL 笔记整理(20) --幻读是什么,幻读有什么问题?

  由于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),也就是说幻读的问题还是没有解决。为什么我们已经这么“凶残”地把所有记录都加上锁了,还是阻止不了这样的问题呢?原因其实很简单,t3时刻,我们给所有行加锁的时候,id=1这一行还不存在,不存在自然我们的锁对它也没有任何办法。也就是说,即使所有记录都加上了锁,还是阻止不了新插入的记录。这也是为什么“幻读”会被单独拿出来解决的原因。

如何解决幻读?

  产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读的问题,innodb只好引入新的锁,也就是间隙锁(gap lock)。顾名思义,间隙锁,锁的是两个值直接的间隙。比如文章开头的表t,初始化插入了6个记录,这就产生了7个间隙。

MySQL 笔记整理(20) --幻读是什么,幻读有什么问题?

  表t主键索引上的行锁和间隙锁

  这样,当你执行select * from t where d = 5 for update的时候,就不止是给数据库中已有的6个记录加上了行锁,还同时加了7个间隙锁。这样就确保了无法再插入新的记录。也就是说,这一行行的扫描结果中,不仅给行加上了锁,也给行两边的空隙加上了间隙锁。所以,行是可以加锁的实体,行与行之间的间隙,也是可以加锁的实体。但是间隙锁和我们之前碰到过的锁都不太一样。比如行锁,分成读锁和写锁。斜土就是这两种类型锁的冲突关系:

MySQL 笔记整理(20) --幻读是什么,幻读有什么问题?

  也就是说,跟行锁有冲突关系的是“另一个行锁”。但是间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。这句话不是很容易理解,我们来举个例子:

MySQL 笔记整理(20) --幻读是什么,幻读有什么问题?

  这里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-try lock。需要注意的是,结合上面的表t的初始化数据,最后一个区间是 (25, +supremum]。仍是前开后闭的。你可能会好奇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。

MySQL 笔记整理(20) --幻读是什么,幻读有什么问题?

  你看到了,其实都不需要用到后面的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的间隙锁挡住了,死锁。

  因此,间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。当然,以上的内容都是建立在可重复读隔离级别下的,如果你吧隔离级别更改成读提交,就不会有间隙锁了。但同时,你可能需要解决出现的数据和日志不一致问题。需要把binlog格式设置为row,这也是不少公司使用的配置组合。