讲之前,先唠点5毛钱的基础小知识。我们都知道 MySQL 有全局锁、表记锁和行级别锁,其中行级锁加锁规则比较复杂,不同的场景,加锁的形式还不同。

需要明确的是:对记录加锁时,加锁的基本单位是 next-key lock,它是由记录锁和间隙锁组合而成的,next-key lock 是前开后闭区间,而间隙锁是前开后开区间。而 next-key lock 在一些场景下会退化成记录锁或间隙锁。

先回顾一下:锁类型

  • 共享锁 (S锁):假设事务T1对数据A加上共享锁,事务T2可以读数据A,不能修改数据A。
  • 排他锁 (X锁):假设事务T1对数据A加上排他锁,事务T2不能读也不能修改数据A。
  • 意向共享锁 (IS锁):事务在获取(任何一行/或全表)S锁前,一定会先在所在的表上加IS锁。
  • 意向排他锁 (IX锁):事务在获取(任何一行/或全表)X锁前,一定会先在所在的表上加IX锁。

了解行级锁:MySQL InnoDB支持三种行锁定方式

InnoDB默认加锁方式是next-key。如果某个加锁操作未使用到索引,该锁则会退化为表锁。

  1. 行锁 (Record Lock):存在唯一索引中 (包含主键索引),锁是在加索引上而不是行上的,也就是key。innodb一定存在聚簇索引,因此行锁最终都会落到聚簇索引上!
  2. 间隙锁 (Gap Lock):存在非唯一索引中,锁定索引记录间隙,确保索引记录的间隙不变。
  3. 临键锁 (Next-Key Lock) :一种特殊的间隙锁 = 行锁+间隙锁,除了锁住记录本身,还会锁住索引之间的间隙,即锁定一段左开右闭的索引区间

✈ 那么何时使用行锁,何时产生间隙锁?

  1. 只使用唯一索引查询,并且只锁定一条记录时,innoDB会使用行锁。
  2. 只使用唯一索引查询,但是检索条件是范围检索,或者是唯一检索但检索结果不存在(试图锁住不存在的数据)时,会产生 Next-Key Lock(临键锁)。
  3. 使用普通索引检索时,不管是何种查询,只要加锁,都会产生间隙锁(Gap Lock)。
  4. 同时使用唯一索引和普通索引时,由于数据行是优先根据普通索引排序,再根据唯一索引排序,所以也会产生间隙锁。

☁ 扩展小知识:为什么要有间隙锁?

是为了防止幻读,其主要通过两个方面实现这个目的

(1)防止间隙内有新数据被插入
(2)防止已存在的数据,更新成间隙内的数据

RU和 RC事务隔离级别下的加锁

前导:RURC不存在间隙锁!如果隔离级别为RURC,无论条件列上是否有索引,都不会锁表,只锁行!而所谓的“锁表”,其原理是通过行锁+间隙锁来实现的。

假设我们的数据表如下:

id (主键索引)

name (无索引)

age (普通索引)

1

张三

18

2

李四

20

3

王五

30

7

赵六

20

 查看以下6种查询:等值/范围  加锁情况

-- 不加锁,快照读
select * from table where id = 2 ; 

-- 不加锁,快照读
select * from table where id < 2 ;

-- 在id=2的聚簇索引上(主键索引),加S共享锁,为当前读。
select * from table where id = 2 lock in share mode;

-- 满足id>2的所有记录,其记录的聚簇索引上(主键索引),加S共享锁,为当前读。
select * from table where id < 2 lock in share mode;

-- 在id=2的聚簇索引上,加X排他锁,为当前读。
select * from table where id = 2 for update;

-- 满足id>2的所有记录,其记录的聚簇索引上(主键索引),加X排他锁,为当前读。
select * from table where id < 2 for update;

➳ 解析:这2种隔离级别下,无论是范围查询还是等值查询,只存在2种加锁类型:读锁、写锁。

注意:如果上述where条件中换成无索引的name,与本例中条件是主键索引的加锁情况一致。实际内部不一样,在RC/RU隔离级别中,MySQL Server做了优化。在条件列没有索引的情况下,尽管通过聚簇索引来扫描全表,进行全表加锁。但MySQL Server层会进行 过滤把不符合条件的锁当即释放掉,因此看起来最终结果一样。实际内部多了一个释放不符合条件的锁的过程而已

 

RR级别下加锁范围:环境准备

不同版本的加锁规则存在差异,将以 MySQL 8.0.25 为例,验证 next-key lock 加锁范围。

MySQL 版本:8.0.27 

隔离级别:可重复读(RR)

存储引擎:InnoDB

构建数据表:

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_mysql临键锁和间隙锁详解

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_数据库_02

▎非唯一索引的等值查询

当我们用非唯一索引进行等值查询的时候,查询的记录存不存在,加锁的规则也会不同。

  1.  查询的记录存在:除了会加 next-key lock 外,还额外加Gap Lock间隙锁,即加两把锁。
  2.  记录不存在时:只会加 next-key lock,然后会退化为Gap Lock间隙锁,即只会加一把锁。

☛ 等值查询:值存在

针对非唯一索引等值查询,查询的值存在,加2把锁: next-key lock + 间隙锁。

1. 假设下面2个事务,进行对数据的操作顺序如下

时间顺序

事务A

事务B

1

begin;  -- 开始事务

2

select * from demo where age=21 lock in share mode; 

-- 查询age=21的记录,并加读锁(共享锁)

3

begin;  -- 开始事务


4

.... 

此处事务B操作,下方展示

5

commit ; -- 提交事务

∞ 事务A加锁变化过程

  ① 对普通索引 age 加上 next-key lock,临键锁本身是左开右闭的区间,即范围是(19,21],也就是说,除了锁定age=21 这条记录本身,还锁定了索引节点上 age=21 前面的那个间隙。

  

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_数据库_03

 ②  因为是非唯一索引,且查询的记录是存在的(加锁规则1),所以还会加上Gap Lock间隙锁,规则是向下遍历到第一个不符合条件的值才能停止,因此间隙锁的范围是(21,24)

  

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_mysql_04

 ➳ 结论:事务A的普通索引 age上共有两个锁,分别是 next-key lock(19,21] 和 间隙锁(21,24) 。

2. 假设事务B的数据操作如下

得知age共有2个锁,其锁范围 (19,21]、(21,24),也就是总范围是:(19,24),如果另外一个事务B在此锁范围内进行数据的操作会如何?

» 测试第一把锁范围:age临键锁(19-21]

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_数据库_05

  示例a:针对锁范围 (19,21],其上区间边界值age=19的数据修改

update demo set name='梅花十三' where age=19; -- 成功

  示例b:针对锁范围 (19,21],其上区间边界值age=19,相同数据插入

insert into demo(id, age, name) VALUES (4,19,'张三');  -- 成功

insert into demo(id, age, name) VALUES (7,19,'张三');  -- 失败

insert into demo(id, age, name) VALUES (20,19,'张三'); -- 失败

思考:为什么age相同的情况都是19,其id=4的记录能够插入成功,而id=7或20的插入失败!

✦ 解析:插入的数据在区间的边界值,则根据主键来判断锁定范围。针对锁范围 (19,21] ,其锁相对应的id范围是(5,8),如果新插入的数据,其age值是上区间边界值19,则其插入的主键必须是小于 age=19所对应的id值5。主键<5的可以插入,>5的不可以,否则插入失败。

  

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_mysql_06


 

  示例c:插入/修改 (19,21] 区间内的数据——20

insert into demo(id, age, name) VALUES (4,20,'张三'); -- 失败

insert into demo(id, age, name) VALUES (7,20,'张三'); -- 失败

update demo set name='梅花' where age=20; -- 成功(不存在的记录,修改无意义)

注:age=20在临键锁(19-21]范围内,因此插入的id是否大于其边界值[id=5,age=19]都是失败的

  示例d:插入/修改 (19,21] 的后闭范围—— 21

insert into demo(id, age, name) VALUES (7,21,'张三'); -- 失败

update demo set name='梅花update' where age=21; -- 失败

注:21处于(19,21] 的后闭范围,因此age=21这条索引是被锁住的,无论修改还是新增都失败

» 测试第二把锁范围:age间隙锁(21-24)

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_java_07

  示例a:针对间隙锁(21,24)范围内的区间值 ——22、23

insert into demo(id, age, name) VALUES (9,22,'张三'); -- 失败

insert into demo(id, age, name) VALUES (9,23,'张三'); -- 失败

 示例b:针对间隙锁(21,24)的下区间边界值 ——24

update demo set name='梅花update' where age=24; -- 成功

insert into demo(id, age, name) VALUES (9,24,'张三');  -- 失败

insert into demo(id, age, name) VALUES (11,24,'张三'); -- 成功

思考:为什么age相同的情况都是24,其id=9的记录失败了,而id=11的插入成功!

✦ 解析:插入的数据在区间的边界值,则根据主键来判断锁定范围。针对锁范围 (21,24),下区间边界值是24,其对应的id为10,如果新插入的数据,其age值是下区间边界值24,则其插入的主键必须是大于 age=24所对应的id值10。即主键>10的可以插入,<10不可以,否则插入失败。

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_数据库_08

➳ 注意:针对插入的数据在区间的边界值,则根据主键来判断锁定范围。这里需要区分一个点,是上区间还是下区间,如下图,即便插入的是边界值,最终它封锁的还是 (19,24) 这个范围。

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_mysql临键锁和间隙锁详解_09

☛ 等值查询:值不存在

针对非唯一索引等值查询,查询的记录不存在,则加 next-key lock,但会退化为Gap Lock间隙锁,即只会加一把锁。

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_mysql临键锁和间隙锁详解_10


    1. 事务顺序如下

时间顺序

事务A

事务B

1

begin;  -- 开始事务

2

select * from demo where age=17 lock in share mode; 

(查询一个不存在的记录)

3

begin;  -- 开始事务


4

.... 

此处事务B操作,下方展示

5

commit ; -- 提交事务

    ∞ 事务A加锁变化过程

    ① 加锁查询age=17的记录,先会对普通索引 age 加上 next-key lock,范围是(16,19]。

   

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_加锁_11

  ②  由于查询的记录不存在(加锁规则2),所以不会再额外加个间隙锁,但next-key lock 会退化为间隙锁,最终加锁范围是 (16,19)。

 

2. 假设事务B的操作如下

根据加锁规则2:非唯一索引等值查询记录不存在时,只会加next-key lock,并退化为Gap Lock间隙锁,即只会加一把锁。故查询age=17获得临键锁(16,19] ,并退化成间隙锁 (16,19)

» 测试锁范围:age间隙锁(16-19)

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_加锁_11

 示例a:操作上区间边界值——16

update demo set name='梅花十三' where age=16; -- 成功

insert into demo(id, age, name) VALUES (-1,16,'张三'); -- 成功

insert into demo(id, age, name) VALUES (2,16,'张三');  -- 失败

注:插入的数据在区间的边界值,则根据主键来判断锁定范围

✦ 解析:同上述例子,针对上区间边界值16,如果新插入的数据age值也是16,则其插入的主键id必须小于age=16所对应的id值 1,也就是说 主键<1 的可以插入, 否则插入失败。

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_mysql临键锁和间隙锁详解_13

 示例b:操作间隙锁(16,19)区间范围的数据——17、18

-- (16,19)锁区间范围内的值,age=17、18都会被事务A锁住,不管主键是什么,事务B都无法插入
insert into demo(id, age, name) VALUES (-1,17,'张三'); -- 失败

insert into demo(id, age, name) VALUES (2,17,'张三');  -- 失败

insert into demo(id, age, name) VALUES (2,18,'张三');  -- 失败

 示例c:操作间隙锁(16,19)下区间边界值——19

insert into demo(id, age, name) VALUES (4,19,'张三'); -- 失败

insert into demo(id, age, name) VALUES (6,19,'张三'); -- 成功

✦ 解析:同理,针对下区间边界值19,如果新插入的数据age值也是19,则其插入的主键id必须大于age=19所对应的id值 5,也就是说 主键>5 的可以插入, 否则插入失败。

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_mysql临键锁和间隙锁详解_13

 示例d:操作间隙锁(16,19)范围外的数据

-- 针对锁(16,19)范围外的数据可以自由插入
insert into demo(id, age, name) VALUES (2,15,'张三'); -- 成功

insert into demo(id, age, name) VALUES (6,20,'张三'); -- 成功

▎非唯一索引的范围查询

仔细查看下面案例

1. 事务A进行范围查询,如下:

select * from demo where age>=19 and age<22 lock in share mode;

 ∞ 事务A加锁变化过程如下:

  ① 首先最开始要找的记录是 age>=19,因此产生的临键锁 next-key lock 范围为:(16,19]

   

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_加锁_11

  ②  由于是范围查找,则继续往后找存在的记录,查询条件age<22 最终产生2个next-key lock,分别是 (19,21]、(21, 24]

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_数据库_16

 2. 假设事务B的操作如下

根据事务A产生的三把next-key lock,范围分别是:(16,19]、 (19,21]、(21, 24],总范围可以得出:(16,24]

另外,因为是范围锁,因此针对age符合条件的记录,也就是在此锁范围内(16,24]的数据,都是无法修改和插入的,因为被锁定了,具体的操作大家可以试一下,这里就不一一演示了,这里主要讲下上下区间边界的16、24的数据操作。

» 测试上区间边界值——16

update demo set name='梅花十三' where age=16; -- 成功

insert into demo(id, age, name) VALUES (-1,16,'张三'); -- 成功

insert into demo(id, age, name) VALUES (2,16,'张三');  -- 失败

注:插入的数据在区间的边界值,则根据主键来判断锁定范围

➳ 两个注意点

  • 16虽然是上区间边界值,但属于临键锁,前开后闭,16是前开,因此可以修改
  • 新插入的数据,其age是上区间的边界值16,但id=-1的可以插入成功,上述也讲了很多遍了:插入的数据在区间的边界值,则根据主键来判断锁定范围。数据库存在age=16的记录,其id=1,插入的是上区间边界值,只要插入的主键 小于 其数据库age=16对应的id值 1 即可。

» 测试下区间边界值——24

update demo set name='梅花十三' where age=24; -- 失败

insert into demo(id, age, name) VALUES (9,24,'张三'); --失败

insert into demo(id, age, name) VALUES (11,24,'张三'); -- 成功

注:插入的数据在区间的边界值,则根据主键来判断锁定范围

➳ 两个注意点

  • 24属于临键锁(21,24] 的下区间边界值,属于后闭原则,该条记录被锁定了,无法修改
  • 新插入的数据其age是下区间的边界值24,牢记:插入的数据在区间的边界值,则根据主键来判断锁定范围。数据库存在age=24的记录,其id=10,针对插入的是下区间边界值,只要插入的主键 大于 其数据库age=24 对应的id值 10 就能插入。

▎唯一索引的等值查询

同样,唯一索引进行等值查询的时候,查询的记录存不存在,锁的规则也会不同。

  • 查询的记录存在:在用「唯一索引进行等值查询」时,next-key lock 会退化成「记录锁」
  • 查询的记录不存在:在用「唯一索引进行等值查询」时,next-key lock 会退化成「间隙锁」

☛ 等值查询:值存在

针对唯一索引的等值查询,如果查询记录存在,next-key lock 退化成行锁。

1. 假设下面3个事务,进行对数据的操作顺序如下


                      事务A


                         事务B

                     事务C

begin;

select * from demo

where id=8 lock in share mode;

(查询 id=8的记录)



update demo set name='梅花十三' where id=8;

阻塞:修改 id=8 的记录)

insert into demo(id, age, name) values (6,21,'张三');

正常:插入 id =6 的记录)

commit ;

∞ 事务A加锁变化过程

  ① 查询 id=8的数据,加锁的基本单位是 next-key lock,因此事务A的加锁范围是 id (5, 8];  

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_数据库_17

 ②  唯一索引(包括主键索引) 进行等值查询,且查询的记录存在,所以next-key lock 退化成记录锁,最终加锁的范围就是 id = 8 这一行。

  

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_加锁_18

➳ 解析:由于id=8退化为行锁,因此事务B修改 id=8的这条记录失败,而事务C插入id=6能够成功

☛ 等值查询:值不存在

针对唯一索引的等值查询,如果查询记录不存在,next-key lock 退化成间隙锁。

1. 假设下面3个事务,进行对数据的操作顺序如下


                      事务A


                         事务B

                     事务C

begin;

select * from demo

where id=6 lock in share mode;

(查询 id=6 不存在的记录)



insert into demo(id, age, name) values (6,18,'张三');


insert into demo(id, age, name) values (7,18,'张三');

阻塞:插入 id=6、7的记录)

update demo set name='梅花十三' where id=5;

update demo set name='梅花十三' where id=8;

正常:修改 id=5、8 的记录)

commit ;

∞ 事务A加锁变化过程

  ① 查询 id=6不存在的数据,加锁基本单位next-key lock,因此事务A的加锁范围是 id (5, 8];  

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_数据库_17

 ②  唯一索引进行等值查询,由于查询的记录不存在,next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,8)。  

➳ 解析:由于退化为间隙锁,因此事务B插入该间隙锁(5,8) 范围区间,id=6、7的数据都会失败,而事务C修改上下区间边界值id = 5、8的是能够成功的,因为是前开后开原则。

▎唯一索引的范围查询

范围查询和等值查询的加锁规则是不同的,仔细查看下面案例。

1. 事务A进行范围查询,如下:

select * from demo where id>=5 and id<7 lock in share mode;

 ∞ 事务A加锁变化过程如下:

  ① 首先最开始要找的记录是 id>=5,因此产生的临键锁 next-key lock 范围为:(1,5],但是由于 id 是唯一索引,且该记录是存在的,因此会退化成记录锁,也就是只会对 id = 5 这一行加锁;

   

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_java_20

②  由于是范围查找,就会继续往后找存在的记录,也就是会找到 id = 8 这一行停下来,然后加 next-key lock (5, 8],但由于 id = 8 不满足 id < 7,所以会退化成间隙锁,加锁范围变为 (5, 8)。

mysql临键锁和间隙锁详解 mysql 间隙锁和临键锁_mysql_21

  2. 假设事务B的操作如下

根据事务A加锁变化得出,目前有2把锁,行锁:id=5 , 间隙锁:(5,8)。

 » 测试行锁——5

update demo set name='梅花十三' where id=5; -- 失败

注:由于是行锁,锁定了该行,因此是无法修改的

 » 测试(5,8) 间隙锁区间范围——6、7

insert into demo(id, age, name) VALUES (6,18,'张三'); -- 失败

insert into demo(id, age, name) VALUES (7,18,'张三'); -- 失败

注:6、7都在间隙锁范围内,所以无法插入


» 测试下区间边界值——8

update demo set name='梅花十三' where id=8; -- 成功

注:id=8是间隙锁(5,8)范围内的下区间边界值,由于是后开原则,因此是可以修改的

总结

✈ 唯一索引等值查询

  • 当查询的记录是存在的,next-key lock 会退化成「记录锁」。
  • 当查询的记录是不存在的,next-key lock 会退化成「间隙锁」。

  • 当查询的记录存在时,除了会加 next-key lock 外,还额外加间隙锁,也就是会加两把锁。
  • 当查询的记录不存在时,只会加 next-key lock,然后会退化为间隙锁,也就是只会加一把锁。

  • 唯一索引在满足一些条件的时候,next-key lock 退化为间隙锁和记录锁。
  • 非唯一索引范围查询,next-key lock 不会退化为间隙锁和记录锁。