我们知道在MySQL中存在幻读的情况,也就是一个事务在读取某个范围内的记录时,发现了另一个事务在该范围内新增了记录(或者删除了记录),导致两次读取的记录数量不一致,进而产生了“幻觉”一般的现象。也就是说,幻读是指在多个事务同时读取同一范围内的记录时所产生的矛盾现象。
MySQL为了解决幻读一般采用快照读和间隙锁的方式,其中快照读在之前的文章已经多次提及,本篇文章重点介绍间隙锁。
间隙锁意如其名,就是锁定符合条件但是实际不存在的记录,也就是一定的区间,防止其他事务在某个事务执行期间向该区间插入新的记录。
为清楚梳理间隙锁的作用,我们在本文中使用的示例表如下:
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);
在示例表中执行如下语句:
begin;
select * from t where d=5 for update;
commit;
语句中的select for update就是为了在查询时,对相关语句进行加锁,避免其他用户对该表进行插入、修改、删除等操作,造成表的不一致。
d=5这一行对应主键为Id=5,执行select语句后改行会被加写锁,并在commit后释放。但是由于d列没有索引,所以会被全表扫描,这时候真实的加锁逻辑为:
RC级别下,只会在满足条件的行加行锁(直至事务commit/rollback才会释放),不满足条件的是先加锁然后再直接释放锁;
RR级别下会加行锁+全表间隙锁(next-key lock是左开右闭,间隙锁是左开右开);
这里可以先记住这个逻辑,我们在下面的文章中会逐步开始介绍。
1 幻读
1.1 幻读是什么
注意,如下的结论都是假设存在,从而引入间隙锁的概念。
如果没有间隙锁,只有行锁,即:上面的语句只会锁住:id=5的这一行数据,那么就会出现如下图所示的场景:
图片
for update在当前读可以理解为:MySQL认为for update已经给当前的行加了写锁,因此没有必要再进行快照读,但是这样会造成幻读的问题。
如果没有间隙锁,就会出现如下的结果:
Q3读到id=1这一行的现象就是”幻读“,即:在同一个事务中,两次读取到的数据不一致的情况可称为幻读和不可重复读,其中幻读针对insert导致的数据不一致,不可重复读针对的delete/update导致的数据不一致。注意:这里的读指的是当前读,比如查询语句中包含for update、in share mode,以及修改删除语句都会开启当前读,否则就是快照读。
- 快照读:指的是在语句执行之前或者在事务开始的时候创建一个一致性视图,后面的读都是基于这个视图,不会再去查询最新的值;
- 当前读:指的是更新之前必须先查询当前的值,因此叫做当前读,比如说:select for update或者select in share mode;
SELECT ... LOCK IN SHARE MODE走的是IS锁(意向共享锁),即在符合条件的rows上都加了共享锁,这样的话,其他session可以读取这些记录,也可以继续添加IS锁,但是无法修改这些记录直到你这个加锁的session执行完成(否则直接锁等待超时)。
SELECT ... FOR UPDATE 走的是IX锁(意向排它锁),即在符合条件的rows上都加了排它锁,其他session也就无法在这些记录上添加任何的S锁或X锁。如果不存在一致性非锁定读的话,那么其他session是无法读取和修改这些记录的,但是innodb有非锁定读(快照读并不需要加锁),for update之后并不会阻塞其他session的快照读取操作;
除了select ...lock in share mode和select ... for update这种显示加锁的查询操作。 通过对比,发现for update的加锁方式无非是比lock in share mode的方式多阻塞了select...lock in share mode的查询方式,并不会阻塞快照读
1.2 幻读的问题
1.2.1 语义上的问题
sessionA在T1时刻声明:把所有d=5的行锁住,不允许其他的事务进行读写操作,但是sessionB和sessionC却能够随意改变语义,新增或者通过修改了对应行的值。
图片
1.2.2 数据一致性问题
锁的设计不仅仅是数据库内存数据状态的一致性,还包括数据与日志在逻辑上的一致性。
图片
如果没有间隙锁,上面的操作在binlog的记录(binlog是在commit提交时进行记录)就是:
/** session B提交语句 */
update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/
/** session C提交语句 */
insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/
/** session A提交语句 */
update t set d=100 where d=5;/*所有d=5的行,d改成100*/
使用该binlog恢复或者备份,三行中d=100,出现异常;
进一步,我们增加写锁。
图片
在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)*/
2 幻读的解决方法
2.1 next-key lock
因此上面的幻读产生的原因就是说,行锁只是锁住了行,但是新插入记录这个动作,要更新的是记录之间的间隙。这也是InnoDB引入间隙锁(Gap Lock)的原因。
图片
间隙锁的增加逻辑为:
mysql> select * from t where id>=10 and id insert into t values(30,10,30);
图片
虽然有两个c=10的索引,但是主键不同,因此,c=10记录存在间隙。
图片
sessionA在遍历的时候,先访问到第一个c=10的记录,根据原则1,加锁为:(c=5,id=5)到(c=10,id=10)这个next-key lock,即c的索引为(5,10]。
然后sessionA向右查找,直至(c=15,id=15),循环结束。根据优化2,等值查询,退化为(c=10,id=10)到(c=15,id=15)的间隙锁,即c的索引为(10,15);
主键索引上,增加了行锁id=10和id=30;
因此,索引c上的加锁范围为下图蓝色区域:
图片
蓝色两边是虚线,表示开区间,即 (c=5,id=5) 和 (c=15,id=15) 这两行上都没有锁。
这里再次举例: 如果session b插入(4,5,50),不会被锁,如果插入(6,5,50) 会被锁住,因为二级索引的叶子节点存储的是主键值,二级索引的叶子节点也是有序的,这样6,5,50根据二级索引来排的话 是在5,5,10后面的 。
3.7 limit语句加锁
图片
sessionA的delete语句加了limit 2,表内只有两条数据,删除效果一样,但是加锁效果不同。
delete语句加了limit 2的限制,遍历到(c=10,id=30)这一行之后,满足条件的语句已经有两条,循环结束。因此,索引c的加锁范围变成了(c=5,id=5) 到(c=10,id=30) 这个前开后闭区间。
图片
因此说,在执行删除的时候尽量加Limit,但是这里需要注意的是,删除的行数不清楚,可能会带来业务的bug。
3.8 一个死锁的例子
图片
- sessionA启动事务后执行查询语句加lock in share mode,在索引c加next-key lock(5,10]和间隙锁(10,15);
- sessionB的update语句也要在索引c上加next-key lock(5,10],进入锁等待;
- 然后sessionA要再插入(8,8,8)这一行,被sessionB的间隙锁锁住。由于出现了死锁,InnoDB让sessionB回滚;
session B 的“加 next-key lock(5,10] ”操作,实际上分成了两步,先是加 (5,10) 的间隙锁,加锁成功;然后加 c=10 的行锁,这时候才被锁住的。也就是说,我们在分析加锁规则的时候可以用 next-key lock 来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。
就算分成了两步,为什么session B加(5,10)就能成功呢?session A不是加了(5, 10]的锁吗? 前面应该也是提到过的,间隙锁和间隙锁之间并不冲突,间隙锁和insert到这个间隙的语句才会冲突,因此session B加间隙锁(5, 10)是可以成功的,但是如果往(5, 10)里面插入的话会被阻塞。 但是如果直接加next-key lock(5, 10],那么肯定是会被阻塞的,因此这个例子确实说明,加锁的步骤是分两步的,先是间隙锁,后是行锁。而且只要理解了间隙锁和行锁之间冲突的原则是不一样的,也就很容易理解这两个锁并不是一起加的了。