大白话解释 MySQL InnoDB 行锁机制
大白话解释 MySQL InnoDB 行锁机制
介绍
我在学习 MySQL 原理时,大部分内容看书、网上的文章都基本能理解,唯独 InnoDB 的行锁,加锁规则看着很多,什么从 Next-key Lock 退化成间隙锁或者记录锁巴拉巴拉的,一旦陷入这些规则内容里,很容易看着看着就晕了。
今天写这篇文章的目的是为了分享加锁规则背后的思路,不用特地记这些规则,只需要画出图跟着这种思路就能想出加了哪些锁。
那么加锁规则背后的思路是什么呢?或者说这么加锁是为了达到什么目的呢?
可以简单用一句话总结:
为了避免幻读的出现,并减少加锁影响的范围。
那么什么是幻读呢?
一个事务内两次同样的查询,结果不一样。
比如我们在一个事务内,第一次查询数据库里年龄大于 20 的人,返回了两条记录,第二次查询返回不是两条(可能大于两条或者小于两条),这就是幻读。
为了容易理解,本文默认加的锁都是 X 锁(因为加 S 锁的话只能读,不会出现幻读的情况),隔离级别为可重复读,MySQL 版本是 8.0.27。
数据准备
假设我们有如下的数据:
其中 id 是主键(唯一索引),age 是年龄(普通索引),name 是名称(无索引)。
这个图中的数据就是主键索引的排列图,注意因为索引数据都是存在 MySQL 的页(默认16 KB)里的,每页里的记录会组成一条链表,这个链表的头尾固定是虚拟的节点。
如果不懂的话,建议先了解一下 MySQL InnoDB 的索引结构后再回来看。
age 的普通索引数据结构排列图:
大家注意一下,这里 name 行置灰了,因为 InnoDB 的非聚簇索引的叶子节点上是只存主键的,这里我为了理解方便画了上去,实际上是不存在的。
前置知识
这里简单介绍一点这篇文章的前置知识。
数据删除
假设我们删除贝吉塔(id = 10)
就变成下面这样:
数据插入
如果我们新增一列:
insert into lock_test(id, age, name) values(7, 20, '布玛');
首先会在主键索引增加一条记录:
然后在其他索引位置添加记录,在本文的数据例子中只有一个 age 索引(根据索引排序,如果值重复则按照主键值排序):
什么时候会加行锁
在 InnoDB 中有以下几种情况会加行锁:
-
select ... for update
锁定读,会对符合条件的记录加上行锁,本文举的例子都是锁定读。
普通 select 语句是快照读,根据 MVCC 机制确定读取的记录版本内容。
-
delete...
删除语句。
-
update...
更新语句。
行锁分类
Record Lock 记录锁
记录锁顾名思义,就是加在记录上的锁。如果一条记录被加了记录锁,那么有别的用户想对同一条记录加锁的时候就会阻塞等待。
比如下图对悟空这条记录加上了锁(本文中红色代表记录锁),我们都知道,删除记录前是需要获得锁的,加了记录锁,在释放锁前(一般是在事务提交时),这条记录就不允许被删除。
Gap Lock 间隙锁
间隙锁时加在某一个记录上的,会锁住这条记录跟前一条记录的间隙,在持有间隙锁的期间,不允许在这个间隙中插入新纪录。
下图的例子就是给悟空加上了间隙锁,锁住了悟空跟前一条记录的间隙。
如果这时插入 id < 0 的新纪录,会阻塞等待直到间隙锁释放后才会插入成功。
也就是说在持有间隙锁时,不会有新纪录插入这个间隙。
Next-key Lock
Next-key Lock = 记录锁 + 间隙锁,下图就是给悟空这条记录加上 Next-key Lock:
结合上面两种锁的效果分析,在锁释放前我们可以得出以下结论:
- 悟空这个节点不能被编辑和删除
- 悟空和前一个节点的间隙不允许新增记录
分析加锁思路
我们分析加锁的思路:
为了避免幻读的出现,并减少加锁影响的范围。
可以分析出目的有两个:
- 避免出现幻读
- 把加锁影响的范围降到最低
我们要牢记这个两个目的。
加锁影响的范围降到最低的意思是避免幻读时,使用加锁范围最小的锁
就是我们要灵活运用上面提到的三种锁:
- 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 条。
为了达到这两个目的:
- 避免出现幻读
- 把加锁影响的范围降到最低
你可以看着数据图先自己思考下如何达到达到目的,然后再看下面内容。
我们只需要给比克加上间隙锁,禁止插入数据就能避免幻读。
这些就是唯一索引等值查询的加锁规则,我觉得只要记住两个目的,还是很容易能分析出来加了什么锁的。
- 避免出现幻读
- 把加锁影响的范围降到最低
顺便附上网上文章写的的加锁规则,现在看是不是比较清晰明白了呢?
- 当查询的记录是「存在」的,在索引树上定位到这一条记录后,将该记录的索引中的 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)
你可以看着数据图,想着两个目的,自己思考下怎么加锁,然后再往下看。
- 避免出现幻读
- 把加锁影响的范围降到最低
第一次查询会返回一条记录(悟空),要保证下次查询还是只返回这条记录,所以得这么做:
- 保证这条记录不被删除,悟空这条记录加上记录锁
- 保证只返回一条记录,这条记录的两侧不能插入数据,悟空和比克加上间隙锁
小于等于
看小于等于的情况。
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 的记录,所以不用
大于
接着看大于的例子,其实原理跟上面是差不多的。
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,可以按照逻辑理解为虚拟节点不能删除。
大于等于
接着看大于等于的例子
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 的记录。
普通索引
上面分析完了唯一索引的加锁分析思路,现在我们接着来分析普通索引,在我们的数据例子中,普通索引就是 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)
可以看到返回了两条数据,那么我们应该怎么加锁,使得索引数据下次查询还是只有这两条呢?可以先自己想想。
在普通索引上,我们给比克和贝吉塔建立了 Next-key Lock,给饺子建立了间隙锁。很符合直觉吧,这样子就能保证再次查询的时候,age 索引只会有这两条数据。
这里说了只保证了 age 索引,因为我们在 age 索引上没有 name 字段,所以查询的时候还需要回表到主键索引上取数据。所以主键索引上也要锁住这两条数据,防止这两条数据被删除。
刚刚的等值查询是数据存在的情况,那么数据如果不存在呢?
begin;
select * from lock_test where age = 19 for update;
Empty set (0.00 sec)
只要防止事务提交之前(事务提交的时候锁也会释放)插入 age = 19 的记录就可以了。
只需要在比克这条记录加上间隙锁:
范围查询
范围搜索还是分为大于小于的情况。
小于
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 是这样子加锁的:
这就引出了一个特殊的地方,在普通索引范围查找的时候,是会查找到第一条不符合条件的记录,并且加上 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)
加锁的情况都是一样的。
普通索引加锁:
不管是查找 age <= 17 或者 age <=19,第一条不符合条件的记录都是比克(age = 20)。
主键索引加锁:
大于
后面的例子跟上面小于等于都是一样的思路,就不赘述了。
begin;
select * from lock_test where age > 20 for update;
+----+------+--------+
| id | age | name |
+----+------+--------+
| 25 | 31 | 饺子 |
| 30 | 35 | 沙鲁 |
+----+------+--------+
2 rows in set (0.00 sec)
普通索引索引:
主键索引:
大于等于
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)
普通索引:
主键索引:
总结
从上面的分析可以看出来,在唯一索引的等值查找、范围查找和普通索引的等值查找,我们只需要牢记两个目的,看着图就能很容易的想到加锁的方法。
- 避免出现幻读
- 把加锁影响的范围降到最低
唯一有点特殊的是普通索引的范围查找,似乎违背了第二条原则:把加锁影响的范围降到最低。
按理说,只要将第一条不符合条件的记录加上间隙锁,既可以避免幻读,也可以降加锁的影响范围降到最低。至于为什么要给第一条不符合条件的记录(克林)加上记录锁,我还不清楚,如果有大神懂这个可以在评论区解释下(ORZ)。
参考资料
-
小林的 MySQL 是怎么加锁的(看完本文可以看看这篇文章,写得更详细,还有用命令分析加了什么行级锁)
转载自:https://juejin.cn/post/7208745710764949560