MySQL实践篇-为什么只改一行语句,锁那么多?

为什么只改一行语句,锁那么多?

主要说明 语句的加锁规则。

MySQL后面的版本可能会改变加锁策略,所以这个规则只限于截止到现在的最新版本,即5.X系列<=5.7.24,8.0系列<=8.0.13。

因为间隙锁在可重复读隔离级别下才有效,所以以下的描述中,除了特别说明,默认是可重复读隔离级别。

加锁规则里面,包含了两个“原则”、“两个优化”和一个“bug”

  1. 原则1:加锁的基本单位是next-key lock。next-key lock是前开后闭区间。
  2. 原则2:查找过程中访问到的对象才会加锁。
  3. 优化1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
  4. 优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
  5. 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
1
2
3
4
5
6
7
8
9
10
11

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=7的记录,所以用上面提到的加锁规则判断一下的话:

  1. 根据原则1,加锁的单位是next-key lock,sessionA加锁的范围是(5,10];
  2. 同时根据优化2,这是一个等值查询(id=7),而id=10不满足查询条件,next-key lock 退化成间隙锁,因此最终加锁的范围是(5,10)

所以,sessionB要往这个间隙里面插入id=8的记录会被锁住,但是sessionC修改的id=10这行是可以的。

案例二:非唯一索引等值锁

关于覆盖索引上的锁:

看到这个例子,有一种“该锁的不锁,不该锁的乱锁”的感觉?

这里sessionA给索引c上的c=5的这行加上读锁。

  1. 根据原则1,加锁的单位是next-key lock,因此会给(0,5]加上next-key lock。
  2. 要注意c是普通索引,因此仅访问c=5这条记录是不能马上停下来的,需要向右遍历,查到c=10才放弃。根据原则2,访问到的都要加锁,因此要给(5,10]加上next-key lock。
  3. 但是同时这个符合优化2:等值判断,向右遍历,最后一个值不满足c=5这个等值条件,因此退化成间隙锁(5,10).
  4. 根据原则2,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么sessionB的update语句可以执行完成。

但sessionC要插入一个(7,7,7)的记录,就会被sessionA的间隙锁(5,10)锁住。

需要注意,这个例子中,lock in share mode只锁覆盖索引,但是如果是for update就不一样了。执行for update时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。

这个例子说明,锁是加在索引上的;同时,它给我们的指导是,如果你要用lock in share mode来给行加读锁避免数据被更新的话,就必须绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。

案例三:主键索引范围锁

关于范围查询

思考:下面两条查询语句,加锁范围相同吗?

1
2
3

mysql> select * from t where id=10 for update;
mysql> select * from t where id>=10 and id<11 for update;

逻辑上,这两条查询语句肯定是等价的,但是它们的加锁规则不太一样。

  1. 开始执行的时候,要找到第一个id=10的行,因此本该是next-key lock(5,10]。根据优化1,主键id上的等值条件,退化成行锁,只加了id=10这一行的行锁。
  2. 范围查找就往后继续找,找到id=15这行停下来,因此需要加next-key lock(10,15]。

所以sessionA这时候加锁的范围就是主键索引上,行锁id=10和next-key lock(10,15]。这样,sessionB和sessionC的结果就容易理解了。

这里需要注意,首次sessionA定位查找id=10的行的时候,是当作等值查询来判断的,而向右扫描到id=15的时候,用的是范围查询判断。

案例四:非唯一索引范围锁

这次sessionA用字段c来判断。加锁规则跟案例三唯一的不同是:在第一次用c=10定位记录的时候,索引c上加了(5,10]这个next-key lock后,由于索引c是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终sessionA加的锁是,索引c的(5,10]和(10,15]这两个next-key lock。

这里扫描到c=15才能停止扫描,是合理的,因为InnoDB要扫到c=15,才知道不需要就绪往后找了。

案例五:唯一索引范围锁bug

sessionA是一个范围查询,按照原则1的话,应该是索引id上只加(10,15]这个next-key lock,并且因为id是唯一键,所以循环判断到id15这一行就应该停止。

但是现实上,InnoDB会往前扫到第一个不满足条件为止,也就是id=20.而且由于这是范围扫描,因此索引id上的(15,20]这个next-key lock也会被锁上。

所以看到了,sessionB要更新id=20这一行,是会被锁住的。同样的,sessionC要插入id=16的行,也会被锁住的。

照理说,这里锁住id=20这行的行为,其实是没有必要的。因为扫描到id=15,就可以确定不用再往后找了。但实现上还是这么做了,因此认为这是个bug。

案例六:非唯一索引上存在“等值”的例子

为了更好地说明“间隙”这个概念。这里,我给表插入一条新记录。

1
2

mysql> insert into t values(30,10,30);

新插入的这一行c=10,也就是说现在表里有两个c=10的行。那么,这时候索引c上的间隙时候什么状态呢?由于非唯一索引上包含主键的值,所以是不可能存在“相同”的两行的。

可以看到,虽然有两个c=10,但是它们的主键值id是不同的(分别是10和30),因此这两个c=10的记录之间,也是有间隙的。

用delete语句来验证。注意:delete语句加锁的逻辑,其实跟select…for update是类似的,也就是在文章开头总结的两个原则两个优化和一个bug。

这是sessionA在遍历的时候,先访问第一个c=10的记录,根据规则1,这里加的是(c=5,id=5)到(c=10,id=10)这个next-key lock。

然后,sessionA向右查找,直到碰到(c=15,id=15)这一行,循环才结束。根据优化2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成(c=10,id=10)到(c=15,id=15)的间隙锁。

也就说这个delete语句在索引c上的加锁范围,就是下面蓝色的区域。

这个蓝色区域左右两边都是虚线,表示开区间。

案例七:limit语句加锁


这个例子里,sessionA的delete语句加了limit2。你知道表t里c=10的记录其实i之i有两条,因此加不加limit2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,sessionB的insert语句执行通过了,跟案例六的结果不同。

这是因为,案例七的delete语句明确加了limit2的限制,因此在遍历到(c=10,id=30)这一行之后,满足条件的语句已经有两条,循环就结束了。

因此,索引c上的加锁范围就变成了从(c=5,id=5)到(c=10,id=30)这个前开后闭区间。

可以看到,(c=10,id=30)之后的这个间隙并没有在加锁范围里,因此insert语句插入c=12是可以执行成功的。

这个例子对我们的指导意义:在删除数据的时候尽量加limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加搜的范围。

案例八:一个死锁的例子

说明:next-key lock 实际上是间隙锁和行锁加起来的结果

  1. sessionA启动事务后执行查询语句加lock in share mode,在索引c上加next-key lock(5,10]和(10,15)间隙锁;
  2. sessionB的update语句也要在索引c上加next-key lock (5,10],进入锁等待;
  3. 然后sessionA要插入一行(8,8,8)这一行,被sessionB的间隙锁锁住。由于出现了死锁,InnoDB 让sessionB回滚。

sessionB的next-key lock 不是没有申请成功吗?

其实,sessionB的“加 next-key lock(5,10]”操作,实际上分成了两步,先是加(5,10)的间隙锁,加锁成功;然后加c=10的行锁,这时候才被锁住的。

也就说,我们在分析加锁规则的时候可以用next-key lock 来分析。但是要知道,具体执行的时候是要分成间隙锁和行锁两段来执行。