幻读是什么,幻读有什么问题?
🌰:
1 |
|
这个表除了主键id外,还有一个索引c,初始化语句在表中插入了6行数据。
问题:下列语句序列,是怎么加锁的,加的锁又是什么时候释放的呢?
1 |
|
这个语句会命中d=5的这一行,对应的主键id=5,因此在select语句执行完成后,id=5这一行会加一个写锁,而且由于两阶段锁协议,这个写锁会执行commit语句的时候释放。
由于字段d上没有索引,因此这条查询语句会做全表扫描,那么,其他被扫描到的,但是不满足条的5行记录上,会不会被加锁呢?
InnoDB的默认事务隔离级别是可重复读,所以我们都是在可重读读隔离级别下讨论问题。
幻读是什么?
如果只在id=5这一行加锁,而其他行不加锁的话;
假设是以下场景:
可以看到sessionA里执行了三次查询,分别是Q1、Q2和Q3。它们的SQL语句相同,都是select * from t where d=5 for update。这个语句的意思就是,查所有d=5的行,而且使用的是当前读,并且加上了写锁。
- Q1只返回id=5这一行;
- 在T2时刻,sessionB 把id=0这一行的d值改成了5,因此T3时刻Q2查出来的是id=0和id=5这两行。
- 在T4时刻,sessionC又插入一行(1,1,5),因此T5时刻Q3查出来的是id=0、id=1和id=5这两行。
其中Q3读到id=1这一行的现象,被称为幻读。也就是说,幻读值的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。
对“幻读”做一个说明:
- 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的,因此,幻读在“当前读“才会出现。
- 上面sessionB的修改结果,被sessionA之后的select语句当作”当前读“看到,不能称为幻读。幻读仅专指”新插入的行“
因为这个三个查询都是加入了for update,都是当前读。而当前读的规则,就是要能读到所有已经提交的记录的最新值。并且 sessionB和sessionC的两条语句,执行后就会提交,所以Q2和Q3就是应该看到和这个两个事务的操作效果,而且也看到了,这个跟事务的可见性规则并不矛盾。
幻读有什么问题?
首先语义上:sessionA在T1时刻就声明了,“我要把所有d=5的行锁住,不准别的事务进行读写操作”。而实际上,这个语义被破坏了。
sessionB的第二条语句update t set c=5 where id=0,语义是“我把id=0、d=5这一行的c值,改成5”。
由于在T1时刻,sessionA还只是给id=5这一行加了行锁,并没有给id=0这行加上锁。因此,sessionB在T2时刻,是可以执行这两条update语句的。这样,就破坏了sessionA里Q1语句要锁住所有d=5的行的加锁声明。
sessionC也是一样的道理,对id=1这行的修改,也是破坏了Q1的加锁声明。
数据一致性问题。我们知道,锁的设计是为了保证数据的一致性。而这个一致性,不只是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性。
为了说明这个问题,我给session A在T1时刻再加上一个更新语句,即:update t set d=100 where d=5。
update的加锁语义和select…for update是一致的,所以这个时候加上这条update语句也很合理。sessionA声明说“要给d=5的语句加上锁”,就是为了要更新数据,新加的这条update语句就是把它认为加上了锁的这行的d值修改成了100。
- 经过T1时刻,id=5这一行变成(5,5,100),当然这个结果最终是在T6时刻正式提交的;
- 经过T2时刻,id=0这一行变成(0,5,5);
- 经过T4时刻,表里面多了一行(1,5,5);
- 其他行跟这个执行序列无关,保持不变。
这样看,这些数据也没啥问题,但是我们再来看看这个时候binlog里面的内容。
- T2时刻,sessionB事务提交,写入两条语句;
- T4时刻,sessionC事务提交,写入两条语句;
- T6时刻,sessionA事务提交,写入了update t set d=100 where d=5这条语句。
1 |
|
这个语句序列,不论是拿到备库去执行,还是以后用binlog来克隆一个库,这三行的结果,都变成了(0,5,100)、(1,5,100)和(5,5,100)。
也就是说,id=0和id=1这两行,发生了数据不一致。
我们来分析一下,数据不一致到底是怎么引入的,假设“select * from where d=5 for update 这条语句只给d=5这一行,也就是id=5这一行加锁”导致的。
所以上面的假设都不合理,改成扫描过程中碰到的行,也都加上写锁。再看看执行效果。
由于sessionA把所有的行都加了写锁,所以sessionB在执行一个update语句的时候就被锁住了。需要等到T6时刻sessionA提交以后,sessionB才能继续执行。
这样对于id=0这一行,在数据库里的最终结果还是(0,0,5)。在binlog里面,执行序列是这样的。
1 |
|
可以看到,按照日志的顺序执行行,id=0这一行的最终结果也是(0,0,5)所以,id=0这一行的问题解决了。
但同时,id=1这一行,在数据库里面的结果是(1,5,5),而根据binlog的执行结果是(1,5,100),也就是说幻读的问题还是没有解决。
也就说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因。
如何解决幻读?
产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB只好引入新的锁,也就是间隙锁(Gap Lock)。
顾名思义,间隙锁,锁的就两个值之间的空隙。比如文章开头的表t,初始化插入6个记录,这就产生了7个间隙。
这样当你select * from t where d=5 for update的时候,就不止是给数据库中已有的6个记录加上行锁。还同时加了7个间隙锁。这样就确保无法再插入新的记录。
也就说这时候,在一行行扫描的过程中,不仅仅将给行上加了行锁,还给行两边的空隙,也加上了间隙锁。
数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。但是间隙锁跟我们之间碰到过的锁都不太一样。
比如行锁,分成读锁和写锁。
也就说,跟行锁冲突关系的是“另外一个行锁”
但是间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间不存在冲突关系。
这里sessionB并不会被堵住。因为表t里并没有c=7这个记录,因此sessionA加的是间隙锁(5,10)。而sessionB也是在这个间隙加的间隙锁。它们有共同的目标,即:保护和这个间隙,不允许插入值。但是它们之间不冲突。
间隙锁和行锁合称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的引入,帮助我们解决了幻读的问题,但同时也带来了一些“困扰”。
业务逻辑:任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新它的数据。
1 |
|
可能你会说,这个不是insert…on duplicate key update就能解决吗?但其实在有多个唯一键的时候,这个方法是不能满足这个需求的。
这个逻辑一旦有并发,就会碰到死锁。这个逻辑每次操作前用for update锁起来,已经是最严格的模式了,怎么会有死锁呢?
其实都不需要用到的update语句,就已经形成死锁了。我们按照语句执行顺序来分析一下:
- sessionA执行select…for update语句,由于id=9这一行并不存在,因此会加上间隙锁(5,10)
- sessionB执行select…for update语句,同样会加上间隙锁(5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
- sessionB试图插入一行(9,9,9),被sessionA的间隙锁挡住了,只好进入等待;
- sessionA试图插入一行(9,9,9),被sessionB的间隙锁挡住了。
至此,两个sessiion进入相互等待状态,形成死锁。当然,InnoDB的死锁检测马上就发现了这对死锁关系,让sessionA的insert报错返回了。
间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的
间隙锁是在可重读隔离级别下才会生效的。所以,如果把隔离级别设置成读提交的话,就没有间隙锁了,但同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置为row。