likes
comments
collection
share

大白话解释 MySQL InnoDB 行锁机制

作者站长头像
站长
· 阅读数 8

大白话解释 MySQL InnoDB 行锁机制

介绍

我在学习 MySQL 原理时,大部分内容看书、网上的文章都基本能理解,唯独 InnoDB 的行锁,加锁规则看着很多,什么从 Next-key Lock 退化成间隙锁或者记录锁巴拉巴拉的,一旦陷入这些规则内容里,很容易看着看着就晕了。

今天写这篇文章的目的是为了分享加锁规则背后的思路,不用特地记这些规则,只需要画出图跟着这种思路就能想出加了哪些锁。

那么加锁规则背后的思路是什么呢?或者说这么加锁是为了达到什么目的呢?

可以简单用一句话总结:

为了避免幻读的出现,并减少加锁影响的范围。

那么什么是幻读呢?

一个事务内两次同样的查询,结果不一样。

比如我们在一个事务内,第一次查询数据库里年龄大于 20 的人,返回了两条记录,第二次查询返回不是两条(可能大于两条或者小于两条),这就是幻读。

为了容易理解,本文默认加的锁都是 X 锁(因为加 S 锁的话只能读,不会出现幻读的情况),隔离级别为可重复读,MySQL 版本是 8.0.27。

数据准备

假设我们有如下的数据:

大白话解释 MySQL InnoDB 行锁机制 其中 id 是主键(唯一索引),age 是年龄(普通索引),name 是名称(无索引)。

这个图中的数据就是主键索引的排列图,注意因为索引数据都是存在 MySQL 的页(默认16 KB)里的,每页里的记录会组成一条链表,这个链表的头尾固定是虚拟的节点。

如果不懂的话,建议先了解一下 MySQL InnoDB 的索引结构后再回来看。

age 的普通索引数据结构排列图: 大白话解释 MySQL InnoDB 行锁机制 大家注意一下,这里 name 行置灰了,因为 InnoDB 的非聚簇索引的叶子节点上是只存主键的,这里我为了理解方便画了上去,实际上是不存在的。

前置知识

这里简单介绍一点这篇文章的前置知识。

数据删除

假设我们删除贝吉塔(id = 10)

就变成下面这样:

大白话解释 MySQL InnoDB 行锁机制 大白话解释 MySQL InnoDB 行锁机制

数据插入

如果我们新增一列:

insert into lock_test(id, age, name) values(7, 20, '布玛');

首先会在主键索引增加一条记录:

大白话解释 MySQL InnoDB 行锁机制 然后在其他索引位置添加记录,在本文的数据例子中只有一个 age 索引(根据索引排序,如果值重复则按照主键值排序):

大白话解释 MySQL InnoDB 行锁机制

什么时候会加行锁

在 InnoDB 中有以下几种情况会加行锁:

  • select ... for update

    锁定读,会对符合条件的记录加上行锁,本文举的例子都是锁定读。

    普通 select 语句是快照读,根据 MVCC 机制确定读取的记录版本内容。

  • delete...

    删除语句。

  • update...

    更新语句。

行锁分类

Record Lock 记录锁

记录锁顾名思义,就是加在记录上的锁。如果一条记录被加了记录锁,那么有别的用户想对同一条记录加锁的时候就会阻塞等待。

比如下图对悟空这条记录加上了锁(本文中红色代表记录锁),我们都知道,删除记录前是需要获得锁的,加了记录锁,在释放锁前(一般是在事务提交时),这条记录就不允许被删除。

大白话解释 MySQL InnoDB 行锁机制

Gap Lock 间隙锁

间隙锁时加在某一个记录上的,会锁住这条记录跟前一条记录的间隙,在持有间隙锁的期间,不允许在这个间隙中插入新纪录。

下图的例子就是给悟空加上了间隙锁,锁住了悟空跟前一条记录的间隙。

如果这时插入 id < 0 的新纪录,会阻塞等待直到间隙锁释放后才会插入成功。

也就是说在持有间隙锁时,不会有新纪录插入这个间隙。

大白话解释 MySQL InnoDB 行锁机制

Next-key Lock

Next-key Lock = 记录锁 + 间隙锁,下图就是给悟空这条记录加上 Next-key Lock:

大白话解释 MySQL InnoDB 行锁机制

结合上面两种锁的效果分析,在锁释放前我们可以得出以下结论:

  • 悟空这个节点不能被编辑和删除
  • 悟空和前一个节点的间隙不允许新增记录

分析加锁思路

我们分析加锁的思路:

为了避免幻读的出现,并减少加锁影响的范围。

可以分析出目的有两个:

  1. 避免出现幻读
  2. 把加锁影响的范围降到最低

我们要牢记这个两个目的。

加锁影响的范围降到最低的意思是避免幻读时,使用加锁范围最小的锁

就是我们要灵活运用上面提到的三种锁:

  • Record Lock
  • Gap Lock
  • Next-key Lock

因为唯一索引和普通索引的区别是能否有重复值,这个区别所对应的加锁分析思路会不一样,所以我们分开一个个讨论。

唯一索引

让我们先来分析一下唯一索引的情况,唯一索引的特点就是索引值没有重复的。

数据如图:

查询分两种等值查询和范围查询。

等值查询

我们执行以下语句:

begin;
select * from lock_test where id = 0 for update;

+----+------+--------+
| id | age  | name   |
+----+------+--------+
|  0 |   15 | 悟空 |
+----+------+--------+
1 row in set (0.00 sec)

第一次查询后,返回一条记录,我们看着上面那幅图,想一件事:

执行了这条 SQL 后,在事务提交前,如何避免幻读?

只要我们第二次查询,返回的还是一条记录,这就避免了幻读。那么我们只需要把 id = 0 这条记录锁起来,让别人删除不了就行(因为删除记录也是要加锁的)。

因为是唯一索引,所以不用担心别的用户再插入了一条 id 为 0 的记录导致出现幻读的情况。

效果图如下:

刚刚那是我们加锁的数据正好存在数据库里的情况,如果不存在呢?

begin;
select * from lock_test where id = 3 for update;

Empty set (0.01 sec)

第一次查询是 0 条记录,第二次查询为了避免幻读所以也得是 0 条。

为了达到这两个目的:

  1. 避免出现幻读
  2. 把加锁影响的范围降到最低

你可以看着数据图先自己思考下如何达到达到目的,然后再看下面内容。

我们只需要给比克加上间隙锁,禁止插入数据就能避免幻读。

大白话解释 MySQL InnoDB 行锁机制

这些就是唯一索引等值查询的加锁规则,我觉得只要记住两个目的,还是很容易能分析出来加了什么锁的。

  1. 避免出现幻读
  2. 把加锁影响的范围降到最低

顺便附上网上文章写的的加锁规则,现在看是不是比较清晰明白了呢?

  • 当查询的记录是「存在」的,在索引树上定位到这一条记录后,将该记录的索引中的 next-key lock 会退化成「记录锁」
  • 当查询的记录是「不存在」的,在索引树找到第一条大于该查询记录的记录后,将该记录的索引中的 next-key lock 会退化成「间隙锁」

范围查询

范围查询有大于、小于两种情况,对于分析思路来说是一样的。

小于

我们先来看看小于的情况。

begin;
select * from lock_test where id < 5 for update;

+----+------+--------+
| id | age  | name   |
+----+------+--------+
|  0 |   15 | 悟空 |
+----+------+--------+
1 row in set (0.00 sec)

你可以看着数据图,想着两个目的,自己思考下怎么加锁,然后再往下看。

  1. 避免出现幻读
  2. 把加锁影响的范围降到最低

第一次查询会返回一条记录(悟空),要保证下次查询还是只返回这条记录,所以得这么做:

  1. 保证这条记录不被删除,悟空这条记录加上记录锁
  2. 保证只返回一条记录,这条记录的两侧不能插入数据,悟空和比克加上间隙锁

大白话解释 MySQL InnoDB 行锁机制

小于等于

看小于等于的情况。

begin;
select * from lock_test where id <= 5 for update;

+----+------+--------+
| id | age  | name   |
+----+------+--------+
|  0 |   15 | 悟空 |
|  5 |   20 | 比克 |
+----+------+--------+
2 rows in set (0.00 sec)

第一次查询会返回悟空和比克两条记录。

如何保证下次查询还是只有这两条呢?

  • 这两条记录不能被删掉,两条记录加记录锁

  • 不能新增 id <= 5 的记录,悟空和比克加间隙锁

  • 以上两条结合起来,就是给悟空和比克加上 Next-key Lock

    为什么贝吉塔不用加间隙锁?因为是唯一索引,不可能再插入一条 id = 5 的记录,所以不用

大白话解释 MySQL InnoDB 行锁机制

大于

接着看大于的例子,其实原理跟上面是差不多的。

begin;
select * from lock_test where id > 20 for update;

+----+------+--------+
| id | age  | name   |
+----+------+--------+
| 25 |   31 | 饺子 |
| 30 |   35 | 沙鲁 |
+----+------+--------+
2 rows in set (0.00 sec)

第一次查询会返回饺子和沙鲁两条记录。

如何保证下次查询还是只有这两条呢?

  • 这两条记录不能被删掉
  • 不能新增 id > 20 的记录

所以给 饺子、沙鲁、最大虚拟节点 加上 Next-key Lock。

我通过 SQL 命令查询锁的状态,最大虚拟节点就是加的 Next-key Lock,可以按照逻辑理解为虚拟节点不能删除。

大白话解释 MySQL InnoDB 行锁机制

大于等于

接着看大于等于的例子

begin;
select * from lock_test where id >= 20 for update;

+----+------+--------+
| id | age  | name   |
+----+------+--------+
| 20 |   17 | 克林 |
| 25 |   31 | 饺子 |
| 30 |   35 | 沙鲁 |
+----+------+--------+
3 rows in set (0.00 sec)

跟上面大于的例子相比,就是多锁了条记录的差别。

为什么不用给克林加上间隙锁?因为是唯一索引,不会再插入一条 id = 20 的记录。

大白话解释 MySQL InnoDB 行锁机制

普通索引

上面分析完了唯一索引的加锁分析思路,现在我们接着来分析普通索引,在我们的数据例子中,普通索引就是 age。

其实原理都是一样的,只是普通索引因为允许重复的值,需要在唯一索引的思路上多考虑这个因素。

age 索引数据排列如下:

等值查询

begin;
select * from lock_test where age = 20 for update;

+----+------+-----------+
| id | age  | name      |
+----+------+-----------+
|  5 |   20 | 比克    |
| 10 |   20 | 贝吉塔 |
+----+------+-----------+
2 rows in set (0.00 sec)

可以看到返回了两条数据,那么我们应该怎么加锁,使得索引数据下次查询还是只有这两条呢?可以先自己想想。 大白话解释 MySQL InnoDB 行锁机制

在普通索引上,我们给比克和贝吉塔建立了 Next-key Lock,给饺子建立了间隙锁。很符合直觉吧,这样子就能保证再次查询的时候,age 索引只会有这两条数据。

这里说了只保证了 age 索引,因为我们在 age 索引上没有 name 字段,所以查询的时候还需要回表到主键索引上取数据。所以主键索引上也要锁住这两条数据,防止这两条数据被删除。

大白话解释 MySQL InnoDB 行锁机制

刚刚的等值查询是数据存在的情况,那么数据如果不存在呢?

begin;
select * from lock_test where age = 19 for update;

Empty set (0.00 sec)

只要防止事务提交之前(事务提交的时候锁也会释放)插入 age = 19 的记录就可以了。

只需要在比克这条记录加上间隙锁:

大白话解释 MySQL InnoDB 行锁机制

范围查询

范围搜索还是分为大于小于的情况。

小于
begin;
select * from lock_test where age < 17 for update;

+----+------+--------+
| id | age  | name   |
+----+------+--------+
| 15 |    7 | 悟饭 |
|  0 |   15 | 悟空 |
+----+------+--------+
2 rows in set (0.00 sec)

额,按照我的分析,普通索引应该是这样加锁才对:

大白话解释 MySQL InnoDB 行锁机制

主键索引是这么加锁的:

大白话解释 MySQL InnoDB 行锁机制

可是在我查了加锁情况后,主键索引和我们预想的一样,但是普通索引有点不同,MySQL 是这样子加锁的:

大白话解释 MySQL InnoDB 行锁机制

这就引出了一个特殊的地方,在普通索引范围查找的时候,是会查找到第一条不符合条件的记录,并且加上 Next-key Lock。

在上面的例子中要查找的是 age < 17 的记录,那么第一条不符合条件的记录就是 age = 17 的记录。

这里就当作个特殊点记住吧。

小于等于

按照上面发现的特殊的地方,我们来分析这个例子:

在普通索引范围查找的时候,是会查找到第一条不符合条件的记录,并且加上 Next-key Lock。

数据存在的情况:

begin;
select * from lock_test where age <= 17 for update;

+----+------+--------+
| id | age  | name   |
+----+------+--------+
| 15 |    7 | 悟饭 |
|  0 |   15 | 悟空 |
| 20 |   17 | 克林 |
+----+------+--------+
3 rows in set (0.00 sec)

数据不存在的情况:

begin;
select * from lock_test where age <= 19 for update;


+----+------+--------+
| id | age  | name   |
+----+------+--------+
| 15 |    7 | 悟饭 |
|  0 |   15 | 悟空 |
| 20 |   17 | 克林 |
+----+------+--------+
3 rows in set (0.00 sec)

加锁的情况都是一样的。

普通索引加锁:

大白话解释 MySQL InnoDB 行锁机制 不管是查找 age <= 17 或者 age <=19,第一条不符合条件的记录都是比克(age = 20)。

主键索引加锁:

大白话解释 MySQL InnoDB 行锁机制

大于

后面的例子跟上面小于等于都是一样的思路,就不赘述了。

begin;
select * from lock_test where age > 20 for update;

+----+------+--------+
| id | age  | name   |
+----+------+--------+
| 25 |   31 | 饺子 |
| 30 |   35 | 沙鲁 |
+----+------+--------+
2 rows in set (0.00 sec)

普通索引索引:

大白话解释 MySQL InnoDB 行锁机制

主键索引:

大白话解释 MySQL InnoDB 行锁机制

大于等于
begin;
select * from lock_test where age >= 20 for update;

+----+------+-----------+
| id | age  | name      |
+----+------+-----------+
|  5 |   20 | 比克    |
| 10 |   20 | 贝吉塔 |
| 25 |   31 | 饺子    |
| 30 |   35 | 沙鲁    |
+----+------+-----------+
4 rows in set (0.00 sec)

普通索引:

大白话解释 MySQL InnoDB 行锁机制

主键索引:

大白话解释 MySQL InnoDB 行锁机制

总结

从上面的分析可以看出来,在唯一索引的等值查找、范围查找和普通索引的等值查找,我们只需要牢记两个目的,看着图就能很容易的想到加锁的方法。

  1. 避免出现幻读
  2. 把加锁影响的范围降到最低

唯一有点特殊的是普通索引的范围查找,似乎违背了第二条原则:把加锁影响的范围降到最低。

按理说,只要将第一条不符合条件的记录加上间隙锁,既可以避免幻读,也可以降加锁的影响范围降到最低。至于为什么要给第一条不符合条件的记录(克林)加上记录锁,我还不清楚,如果有大神懂这个可以在评论区解释下(ORZ)。

参考资料