文章摘抄自林晓斌老师《MySQL实战45讲》。首先说明一下, 这些加锁规则我没在别的地方看到过有类似的总结, 以前我自己判断的时候都是想着代码里面的实现来脑补的。 这次为了总结成不看代码的同学也能理解的规则, 是我又重新刷了代码临时总结出来的。 所以, 这个规则有以下两条前提说明:
因为间隙锁在可重复读隔离级别下才有效, 所以本篇文章接下来的描述, 若没有特殊说明, 默认是可重复读隔离级别。我总结的加锁规则里面, 包含了两个“原则”、 两个“优化”和一个“bug”。
我还是以上篇文章的表t为例, 和你解释一下这些规则。 表t的建表语句和初始化语句如下。
下面示例的建表语句
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);
但session C要插入一个(7,7,7)的记录, 就会被session A的间隙锁(5,10)锁住。
需要注意, 在这个例子中, lock in share mode只锁覆盖索引, 但是如果是for update就不一样了。 执行 for update时, 系统会认为你接下来要更新数据, 因此会顺便给主键索引上满足条件的行加上行锁。
这个例子说明, 锁是加在索引上的; 同时, 它给我们的指导是, 如果你要用lock in share mode来给行加读锁避免数据被更新的话, 就必须得绕过覆盖索引的优化, 在查询字段中加入索引中不存在的字段。 比如, 将session A的查询语句改成select d from t where c=5 lock in share mode。你可以自己验证一下效果。
举例之前, 你可以先思考一下这个问题: 对于我们这个表t, 下面这两条查询语句, 加锁范围相同吗?
mysql> select * from t where id=10 for update;
mysql> select * from t where id>=10 and id<11 for update;
你可能会想, id定义为int类型, 这两个语句就是等价的吧? 其实, 它们并不完全等价。在逻辑上, 这两条查语句肯定是等价的, 但是它们的加锁规则不太一样。 现在, 我们就让session A执行第二个查询语句, 来看看加锁效果。
现在我们就用前面提到的加锁规则,来分析一下 session A 会加什么锁呢?
- 开始执行的时候,要找到第一个 id=10 的行,因此本该是 next-key lock(5,10]。 根据优化 >1, 主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
- 范围查找就往后继续找,找到 id=15 这一行停下来,因此需要加 next-key lock(10,15]。所以,session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock(10,15]。这样,session B 和 session C 的结果你就能理解了。这里你需要注意一点,首次 session A 定位查找 id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。
这次session A用字段c来判断, 加锁规则跟案例三唯一的不同是: 在第一次用c=10定位记录的时候, 索引c上加了(5,10]这个next-key lock后, 由于索引c是非唯一索引, 没有优化规则, 也就是说不会蜕变为行锁, 因此最终sesion A加的锁是, 索引c上的(5,10] 和(10,15] 这两个next-key lock。
所以从结果上来看, sesson B要插入(8,8,8)的这个insert语句时就被堵住了。这里需要扫描到c=15才停止扫描, 是合理的, 因为InnoDB要扫到c=15, 才知道不需要继续往后找了。
session A是一个范围查询, 按照原则1的话, 应该是索引id上只加(10,15]这个next-keylock, 并
且因为id是唯一键, 所以循环判断到id=15这一行就应该停止了。
但是实现上, InnoDB会往前扫描到第一个不满足条件的行为止, 也就是id=20。 而且由于这是个
范围扫描, 因此索引id上的(15,20]这个next-keylock也会被锁上。
所以你看到了, session B要更新id=20这一行, 是会被锁住的。 同样地, session C要插入id=16
的一行, 也会被锁住。
照理说, 这里锁住id=20这一行的行为, 其实是没有必要的。 因为扫描到id=15, 就可以确定不用
往后再找了。 但实现上还是这么做了, 因此我认为这是个bug。
我也曾找社区的专家讨论过, 官方bug系统上也有提到, 但是并未被verified。 所以, 认为这是
bug这个事儿, 也只能算我的一家之言, 如果你有其他见解的话, 也欢迎你提出来。
接下来的例子, 是为了更好地说明“间隙”这个概念。 这里, 我给表t插入一条新记录:
insert into t values(30,10,30);
新插入的这一行c=10, 也就是说现在表里有两个c=10的行。 那么, 这时候索引c上的间隙是什么状态了呢? 你要知道, 由于非唯一索引上包含主键的值, 所以是不可能存在“相同”的两行的。
可以看到, 虽然有两个c=10, 但是它们的主键值id是不同的(分别是10和30) , 因此这两个c=10的记录之间, 也是有间隙的。
图中我画出了索引c上的主键id。 为了跟间隙锁的开区间形式进行区别, 我用(c=10,id=30)这样的形式, 来表示索引上的一行。
现在, 我们来看一下案例六。这次我们用delete语句来验证。 注意, delete语句加锁的逻辑, 其实跟select ... for update 是类似的, 也就是我在文章开始总结的两个“原则”、 两个“优化”和一个“bug”。
这时, session A在遍历的时候, 先访问第一个c=10的记录。 同样地, 根据原则1, 这里加的是(c=5,id=5)到(c=10,id=10)这个next-keylock。
然后, session A向右查找, 直到碰到(c=15,id=15)这一行, 循环才结束。 根据优化2, 这是一个等值查询, 向右查找到了不满足条件的行, 所以会退化成(c=10,id=10) 到 (c=15,id=15)的间隙锁。也就是说, 这个delete语句在索引c上的加锁范围, 就是下图中蓝色区域覆盖的部分。
这个蓝色区域左右两边都是虚线, 表示开区间, 即(c=5,id=5)和(c=15,id=15)这两行上都没有锁。
案例七:limit 语句加锁
例子6也有一个对照案例, 场景如下所示:
这个例子里, session A的delete语句加了 limit 2。 你知道表t里c=10的记录其实只有两条, 因此加不加limit 2, 删除的效果都是一样的, 但是加锁的效果却不同。 可以看到, session B的insert语句执行通过了, 跟案例六的结果不同。这是因为, 案例七里的delete语句明确加了limit 2的限制, 因此在遍历到(c=10, id=30)这一行之后, 满足条件的语句已经有两条, 循环就结束了。
因此, 索引c上的加锁范围就变成了从(c=5,id=5)到(c=10,id=30)这个前开后闭区间, 如下图所示:
可以看到, (c=10,id=30) 之后的这个间隙并没有在加锁范围里, 因此insert语句插入c=12是可以执行成功的。
这个例子对我们实践的指导意义就是, 在删除数据的时候尽量加limit。 这样不仅可以控制删除数据的条数, 让操作更安全, 还可以减小加锁的范围。
前面的例子中, 我们在分析的时候, 是按照next-keylock的逻辑来分析的, 因为这样分析比较方便。 最后我们再看一个案例, 目的是说明: next-keylock实际上是间隙锁和行锁加起来的结果。
你一定会疑惑, 这个概念不是一开始就说了吗? 不要着急, 我们先来看下面这个例子:
现在, 我们按时间顺序来分析一下为什么是这样的结果。
你可能会问, session B的next-keylock不是还没申请成功吗?
其实是这样的, session B的“加next-keylock(5,10] ”操作, 实际上分成了两步, 先是加(5,10)的间隙锁, 加锁成功; 然后加c=10的行锁, 这时候才被锁住的。也就是说, 我们在分析加锁规则的时候可以用next-keylock来分析。 但是要知道, 具体执行的时候, 是要分成间隙锁和行锁两段来执行的。
小结
这里我再次说明一下, 我们上面的所有案例都是在可重复读隔离级别(repeatable-read)下验证的。 同时, 可重复读隔离级别遵守两阶段锁协议, 所有加锁的资源, 都是在事务提交或者回滚的时候才释放的。
在最后的案例中, 你可以清楚地知道next-keylock实际上是由间隙锁加行锁实现的。 如果切换到读提交隔离级别(read-committed)的话, 就好理解了, 过程中去掉间隙锁的部分, 也就是只剩下行锁的部分。
其实读提交隔离级别在外键场景下还是有间隙锁, 相对比较复杂, 我们今天先不展开。另外, 在读提交隔离级别下还有一个优化, 即: 语句执行过程中加上的行锁, 在语句执行完成后, 就要把“不满足条件的行”上的行锁直接释放了, 不需要等到事务提交。也就是说, 读提交隔离级别下, 锁的范围更小, 锁的时间更短, 这也是不少业务都默认使用读提交隔离级别的原因。
不过, 我希望你学过今天的课程以后, 可以对next-keylock的概念有更清晰的认识, 并且会用加锁规则去判断语句的加锁范围。
在业务需要使用可重复读隔离级别的时候, 能够更细致地设计操作数据库的语句, 解决幻读问题的同时, 最大限度地提升系统并行处理事务的能力。