第十章:第19节 MySQL进阶篇——InnoDB(RR模式下)行锁实现方式(GAP锁)

更新于:2018-07-19 10:54:09

承接上节内容,继续看:


5)组合五:id主键+RR

 

前面的四个组合,都是在Read Committed隔离级别下的加锁行为,接下来的四个组合,是在Repeatable Read隔离级别下的加锁行为。

 

组合五,id列是主键列,Repeatable Read隔离级别,针对delete from t1 where id = 10; 这条SQL,加锁与组合一:[id主键,Read Committed]一致。


6)组合六:id唯一索引+RR

 

组合六的加锁,与组合二:[id唯一索引,Read Committed]一致。两个X锁,id唯一索引满足条件的记录上一个,对应的聚簇索引上的记录一个。



7)组合七:id非唯一索引+RR

 

“RC”隔离级别允许幻读,上节的演示证明了这一点。而“RR”隔离级别,不允许存在幻读。是不是这样?看演示:


前提:



给“age”字段加个普通索引,目前是“RR”隔离级别。


1.png


(上边)两次的普通select获取的结果集行数是一样的,所以在“RR”下普通select不会出现“幻读”问题,与“RC”下不一样。


换成“当前读”,给select加个“排他锁”:


1.png


分析:

1、(上边)3执行完“加锁读”,(下边)4执行插入数据时出现了“锁等待”,显然这个地方被(上边)影响了。

2、(上边)5提交事务,(下边)6插入数据获取锁成功。


所以在“RR”下加锁select也没有出现“幻读”问题,因为其他事务压根插不进去新数据。


是不是很纳闷?为什么会这样呢?在“RC”下这样的情况(下边)插入数据时可没有出现“锁等待”。这个是什么锁呢?问题的答案,就在组合七中揭晓。

 

组合七,Repeatable Read隔离级别,id上有一个非唯一索引,执行delete from t1 where id = 10; 假设选择id列上的索引进行条件过滤,最后的加锁行为,是怎么样的呢?同样看下面这幅图:


800.jpg


此图,相对于组合三:[id列上非唯一锁,Read Committed]看似相同,其实却有很大的区别。最大的区别在于,这幅图中多了一个GAP锁,而且GAP锁看起来也不是加在记录上的,倒像是加载两条记录之间的位置,GAP锁有何用?


其实这个多出来的GAP锁,就是RR隔离级别,相对于RC隔离级别,不会出现幻读的关键。确实,GAP锁锁住的位置,也不是记录本身,而是两条记录之间的GAP。如何保证两次“当前读”返回一致的记录,那就需要在第一次“当前读”与第二次“当前读”之间,其他的事务不会插入新的满足条件的记录并提交。为了实现这个功能,GAP锁应运而生。

 

如图中所示,有哪些位置可以插入新的满足条件的项 (id = 10),考虑到B+树索引的有序性,满足条件的项一定是连续存放的。记录[6,c]之前,不会插入id=10的记录;[6,c]与[10,b]间可以插入[10, aa];[10,b]与[10,d]间,可以插入新的[10,bb],[10,c]等;[10,d]与[11,f]间可以插入满足条件的[10,e],[10,z]等;而[11,f]之后也不会插入满足条件的记录。因此,为了保证[6,c]与[10,b]间,[10,b]与[10,d]间,[10,d]与[11,f]不会插入新的满足条件的记录,MySQL选择了用GAP锁,将这三个GAP给锁起来。

 

Insert操作,如insert [10,aa],首先会定位到[6,c]与[10,b]间,然后在插入前,会检查这个GAP是否已经被锁上,如果被锁上,则Insert不能插入记录。因此,通过第一遍的“当前读”,不仅将满足条件的记录锁上 (X锁),与组合三类似。同时还是增加3把GAP锁,将可能插入满足条件记录的3个GAP给锁上,保证其他事务的Insert不能插入新的id=10的记录,也就杜绝了同一事务的第二次“当前读”出现“幻读”。


所以我们上面的演示出现的问题就是“GAP锁”在作怪

 

有心的朋友看到这儿,可以会问:既然防止幻读,需要靠GAP锁的保护,为什么组合五、组合六,也是RR隔离级别,却不需要加GAP锁呢?

 

回答这个问题,也很简单。GAP锁的目的,是为了防止同一事务的两次“当前读”出现幻读的情况。而组合五,id是主键;组合六,id是unique键,都能够保证唯一性。一个等值查询,最多只能返回一条记录,而且新的相同取值的记录,一定不会在新插入进来,因此也就避免了GAP锁的使用。


其实,针对此问题,还有一个更深入的问题:如果组合五、组合六下,针对SQL:select * from t1 where id = 10 for update; 第一次“当前读”,没有找到满足查询条件的记录,那么GAP锁是否还能够省略?


我们来做下演示:


“user_name”列有唯一索引



 1.png


当(上边)3唯一索引读取到值时,(下边)插入不受锁影响。


1.png


分析:

当(上边)3唯一索引未读到值,(下边)4插入未受影响,可5插入受影响了,受哪个影响了?肯定是“GAP锁”。


从目前我们的数据来看,在唯一索引“user_name”中,如果有“aaa”会放在有序数据的最前面,当前索引中有序数据第一个数据是“eeee2”,因为索引中暂无“aaa”,所以会在“eeee2”前面加个“GAP锁”,这意味着有可能排在“eeee2”之前的数据都暂时插不进去。“abc”会排到“eeee2”之前,所以插入数据“abc”时就出现了锁等待状态。


额外说明:如果where子句是“多值精确匹配”、“范围匹配”的当前读,它的结果集没有约束为唯一的,假如执行计划使用到了索引,是很容易触发“GAP锁”的。


结论:Repeatable Read隔离级别下,id列上有一个非唯一索引,对应SQL:delete from t1 where id = 10; 首先,通过id索引定位到第一条满足查询条件的记录,加记录上的X锁,加GAP上的GAP锁,然后加主键聚簇索引上的记录X锁,然后返回;然后读取下一条,重复进行。直至进行到第一条不满足条件的记录[11,f],此时,不需要加记录X锁,但是仍旧需要加GAP锁,最后返回结束。


8)组合八:id无索引+RR


组合八,Repeatable Read隔离级别下的最后一种情况,id列上没有索引。此时SQL:delete from t1 where id = 10; 没有其他的路径可以选择,只能进行全表扫描。最终的加锁情况,如下图所示:


800.jpg


如图,这是一个很恐怖的现象。首先,聚簇索引上的所有记录,都被加上了X锁。其次,聚簇索引每条记录间的间隙(GAP),也同时被加上了GAP锁。这个示例表,只有6条记录,一共需要6个记录锁,7个GAP锁。试想,如果表上有1000万条记录呢?

 

在这种情况下,这个表上,除了不加锁的“快照读”,其他任何加锁的并发SQL,均不能执行,不能更新,不能删除,不能插入,全表被锁死。看演示:



“user_name”列没有加索引,“age”有索引


1.png

分析:

(上边)3 select加了“排他锁”,由于select没有用到索引,锁全表,所以(下边)4执行出现了锁等待状态。“RR”与“RC”不一样,没有做优化。

 

结论:在Repeatable Read隔离级别下,如果进行全表扫描的“当前读”,那么会锁上表中的所有记录,同时会锁上聚簇索引内的所有GAP,杜绝所有的并发 更新/删除/插入 操作。


9)组合九:Serializable

 

对于SQL:delete from t1 where id = 10; 来说,Serializable隔离级别与Repeatable Read隔离级别完全一致,因此不做介绍。

 

Serializable隔离级别,影响的是“普通读” 。


比如:select * from t1 where id = 10;


在RC,RR隔离级别下,这个SQL都是“快照读”,不加锁。在Serializable隔离级别,这个SQL会加共享锁(S锁),也就是说“快照读”不复存在,MVCC并发控制降级为Lock-Based CC。

 

结论:在MySQL/InnoDB中,所谓的读不加锁,并不适用于所有的情况,而是隔离级别相关的。Serializable隔离级别,读不加锁就不再成立,所有的读操作,都是“当前读”。


拓展:


RR隔离级别下,where条件是一个组合索引(a,b),a,b都是int类型。例如:


select * from t1 where a = 1 and b = 1 for update;

select * from t1 where a = 1 and b = 2 for update;


在两个事务中分别执行,会不会出现“锁等待”现象?


答案:如果两条SQL语句完整地使用了索引(索引长度为8),是不会出现“锁等待”的。


像下面的SQL语句:


select * from t1 where a = 1 for update;

select * from t1 where a = 1 and b = 2 for update;


第一条SQL语句使用的索引长度为4,第二天SQL语句使用的索引长度为8,这种情况就会出现“锁等待”的。


本节学习代码》》》