学习Mysql, 总会有一座绕不过去的大山, 那就是锁。理论上,锁的花样再多,也超不出操作系统课上讲的那些范畴,但是Mysql锁让我翻车了。
在Mysql中锁的粒度可分为:表级锁,行级锁,间隙锁 三种。表级锁和行级锁都没什么太难理解的地方。只有间隙锁我无法准确理解其设计意图,而且我试验下来的现象让我觉得很诡异。
那么为什么会有间隙锁这种东西呢,按大部分能查到的资料表示,间隙锁的引入是为了解决在RR隔离级别的幻读问题。
下面来看一个实例,首先创建一个Table:
Create Table: CREATE TABLE `foo` (
`uid` int(11) NOT NULL,
`age` int(11) NOT NULL,
PRIMARY KEY (`uid`),
KEY `age` (`age`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
insert into foo values(1,1),(4,4),(7,7),(9,9);
然后,开两个mysql客户端(M1,M2),其执行顺序如下:
M1: begin;
M1: select * from foo were uid > 1 and uid < 5 for update;
M2: begin;insert into foo values(2,2);commit;
M1: select * from foo were uid > 1 and uid < 5 for update;
M1: commit;
如果在M1第一次执行select语句时只加行锁,那么锁住的就只有uid=4这一行。 在M1第二次执行select语句时,由于M2插入了一条(2,2), 因此会多查询出一条(2,2)的记录。 这就会产生幻读。
mysql的解决方案是:使用间隙锁,将uid在间隙区间(1,4),(4,7)的全部加锁,这样当M2在insert行数据(2,2)甚至(6,6)会被锁阻塞以防止M1出现幻读。
如果事情到这里完美结束,那我也不会翻车了,再看另外三条sql语句:
M1: begin;
M1: select * from foo were age = 4 for update;
M2: begin;insert into foo values(6,6);commit;
M1: select * from foo were age = 4 for update;
M1: commit;
手动执行一下就会发现M2会被锁阻塞住,这是因为他对age加了间隙锁(锁是加在索引上的)。
由于锁是加在索引上的, 按照我第一反应,直接对age=4这一条索引加锁就解决问题了,为什么要加间隙锁?
我查了很久,才找到一个很少有人提到但很重要的点二级索引中存储的主键,会参于二级索引排序,比如age索引进行排序时,实际用的是(age,uid)来进行排序。而之所以会使用uid参与排序我想大部分原因应该是B+树内不允许存储相同的值。使用age,uid进行拼接之后可以保证所有的二级索引,在B+树中的值一定是惟一的。
换句话说,我们无法单纯的锁住age=4这一条件,因为可能会存在(age,uid)= (4,1)/(4,2)/(4,5)等任意索引。
二级索引在拼接时,由于age在前uid在后,因此age的值在一定程度上就代表了整个索引值。这也是为什么间隙锁可以锁住age=4这一条件。
为了验证上述说法正确性,来看如下sql:
M1: begin;
M1: select * from foo were age = 4 for update;
M2: begin;update foo set age = 2 where uid = 1;commit;
M1: select * from foo were age = 4 for update;
M1: commit;
先简单分析一下 : 1. age是非惟一二级索引 2. 二级索引在内部实现是由age,uid拼接之后才参与排序的 3. 间隙锁住了(age,uid) = (1,1) ~ (4,4)的开区间 4. M2执行的语句是想插入一个二级索引值(2,1)
根据间隙锁原理,我们可以推段出M2会被间隙锁给阻塞住,而事实也正是这样。
ps. 二级索引中存储的主键会参于二级索引排序,这一点我认为非常重要。不知道为什么很多参考书都有意无意略过去了。
------------------------------------------------------------------------------------------------------------------------------
总结:
Mysql MVCC解决了RR事务的可重复读问题,使用间隙锁解决了RR级别的幻读问题
Mysql的间隙锁是为了在RR级别解决幻读问题而引入的,间隙锁是gap lock ,而mysql 用的是间隙锁和gap锁的结合,也就是next-key lock,而在不同的索引上,mysql加锁的方式也不一样:
唯一索引上:如果条件为=5 ,间隙锁退化为行锁,也就是只会锁住条件中的那一行对象,如果是>5,则会添加一个[5, ∞) 的一个next-key锁,5这个行锁和(5,∞)这个间隙锁
普通索引上:如果条件为5,那么mysql会通过5查询左右两边的一个间隙,也就是比5小的第一个值和比5大的第一个值,然后加一个间隙锁,比如数据库还有两条数据的索引值为 3 和 7,那么mysql会加一个(3-5)[5](5-7]的这么一个间隙锁,为什么普通索引需要这么加,那是因为普通索引是可以重复的,这里引入之前的一句话
二级索引中存储的主键,会参于二级索引排序,比如age索引进行排序时,实际用的是(age,uid)来进行排序。而之所以会使用uid参与排序我想大部分原因应该是B+树内不允许存储相同的值。使用age,uid进行拼接之后可以保证所有的二级索引,在B+树中的值一定是惟一的。
换句话说,我们无法单纯的锁住age=4这一条件,因为可能会存在(age,uid)= (4,1)/(4,2)/(4,5)等任意索引。
二级索引在拼接时,由于age在前uid在后,因此age的值在一定程度上就代表了整个索引值。这也是为什么间隙锁可以锁住age=4这一条件。
间隙锁是一个左开右闭的一个区间,比如上面的例子,等值查询的时候 where c = 5,那么会加一个(3,7]的一个左开右闭间隙锁,如果我们插入c=3的一条记录是不会阻塞的,但是如果我们插入一条c=7的记录,那是会阻塞的
无索引:因为mysql的锁都是在索引上,如果没有索引则是使用表锁