youyichannel

志于道,据于德,依于仁,游于艺!

0%

谈谈 MySQL InnoDB 是如何加行级锁的

哪些 SQL 会加行级锁?

除去串行化隔离级别情况,普通的 SELECT 语句是不会对记录加锁的,因为它属于快照读,是通过 MVCC 实现的。

如果要在查询时对记录加行级锁,可以使用锁定读

SELECT ... LOCK IN SHARE MODE; # 对读取的记录加共享锁

SELECT ... FOR UPDATE; # 对读取的记录加独占锁

上面这两条语句必须在处于事务环境中,因为当事务提交了,锁就会被释放,所以在使用这两条语句的时候,需要开启事务,否则和普通读没有什么区别。

除了锁定读语句会加行级锁之外,UPDATE 和 DELETE 操作都会加行级锁,且锁的类型都是独占锁。

共享锁(S锁)满足读读共享,读写互斥。独占锁(X锁)满足写写互斥、读写互斥。

MySQL 是如何加行级锁的?

首先需要明确加锁的对象是索引,加锁的基本单位是 Next-Key Lock,它是由记录锁和间隙锁组合而成的,Next-Key Lock 是前开后闭区间,间隙锁是前开后开区间

在一些场景下,Next-Key Lock 会退化成记录锁或者间隙锁。这些场景都有一些共同特点:能使用记录锁或者间隙锁就能够避免幻读。

实验环境:MySQL 8.0.31,隔离级别是 RR;

表结构:

CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL,
`age` int NOT NULL,
PRIMARY KEY (`id`),
KEY `index_age` (`age`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

其中 id 为主键索引,age 为普通索引(非唯一索引),name 是普通的列。

表中记录:

mysql> select * from user;
+----+---------+-----+
| id | name | age |
+----+---------+-----+
| 1 | John | 25 |
| 5 | Michael | 35 |
| 10 | Alice | 30 |
| 15 | Bob | 22 |
| 20 | Emily | 28 |
+----+---------+-----+
5 rows in set (0.00 sec)

唯一索引等值查询

当使用唯一索引进行等值查询时,查询的记录存不存在,加锁的规则也会不同:

  1. 当查询的记录是「存在」的,在索引树上定位到这一条记录后,该索引的记录中的 Next-Key Lock 会退化成「记录锁」
  2. 当查询的记录是「不存在」的,在索引树上定位到这一条记录后,该索引的记录中的 Next-Key Lock 会退化成「间隙锁」

📢注意:

此处「唯一索引」是用「主键索引」作为案例说明的,加锁只加在主键索引项上。

如果是用二级索引(不论是非唯一索引,还是唯一索引)进行锁定读查询的时候,除了会对二级索引项加行级锁(如果是唯一索引的二级索引,加锁规则和主键索引的案例相同),而且还会对查询到的记录的主键索引项上加「记录锁」。

记录存在

假设事务 A 执行了这条等值语句,查询的记录是「存在」于表中的。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id = 1 for update;
+----+------+-----+
| id | name | age |
+----+------+-----+
| 1 | John | 25 |
+----+------+-----+
1 row in set (0.00 sec)

那么,事务 A 就会为 id 为 1 的这条记录加上 X 型的记录锁

接下来,如果有其他事务对 id 为 1 的记录进行更新或者删除操作的话,这些操作都会被阻塞,因为更新或者删除操作也会对记录加 X 型的记录锁,而 X 锁和 X 锁之间是互斥关系。

在 MySQL 中,我们是可以通过 SELECT * FROM performance_schema.data_locks\G; 语句查看事务执行 SQL 过程中加了什么锁。

以上述的事务 A 为例:

从上图可以看到,共加了两个锁,分别是:

  • 表锁:X 类型的意向锁;
  • 行锁:X 类型的记录锁;

图中 LOCK_TYPE 中的 RECORD 表示行级锁,而不是记录锁的意思。

通过 LOCK_MODE 可以确认是 Next-Key 锁,还是间隙锁,还是记录锁:

  • 如果 LOCK_MODE 为 X,说明是 Next-Key锁;
  • 如果 LOCK_MODE 为 X, REC_NOT_GAP,说明是记录锁;
  • 如果 LOCK_MODE 为 X, GAP,说明是间隙锁。

因此,此时事务 A 在 id=1 记录的主键索引上加的是记录锁,锁住的范围是 id=1 这条记录。这样一来,其他事务就无法对 id=1 这条记录进行更新和删除操作了。

由此也可以看出,加锁的对象是索引,因为这里查询语句扫描的 B+ 树是聚簇索引树,即主键索引树,所以是对主键索引加锁。将对应记录的主键索引加 记录锁后,就意味着其他事务无法对该记录进行更新和删除操作了。

问题:为什么唯一索引等值查询并且查询记录存在的场景下,该记录索引中的 Next-Key Lock 会退化成记录锁?

原因就是在唯一索引等值查询并且查询记录存在的场景下,仅靠记录锁也能避免幻读的问题。

幻读的定义就是,当一个事务前后两次查询的结果集,不相同时,就认为发生幻读。所以,要避免幻读就是避免结果集某一条记录被其他事务删除,或者有其他事务插入了一条新记录,这样前后两次查询的结果集就不会出现不相同的情况。

  • 由于主键具有唯一性,所以其他事务插入 id = 1 的时候,会因为主键冲突,导致无法插入 id = 1 的新记录。这样事务 A 在多次查询 id = 1 的记录的时候,不会出现前后两次查询的结果集不同,也就避免了幻读的问题。
  • 由于对 id = 1 加了记录锁,其他事务无法删除该记录,这样事务 A 在多次查询 id = 1 的记录的时候,不会出现前后两次查询的结果集不同,也就避免了幻读的问题。

记录不存在

假设事务 A 执行了这条等值查询语句,查询的记录是「不存在」于表中的。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id = 2 for update;
Empty set (0.00 sec)

通过 select * from performance_schema.data_locks\G; 这条语句,查看事务执行 SQL 过程中加了什么锁。

从上图可以看到,共加了两个锁,分别是:

  • 表锁:X 类型的意向锁;
  • 行锁:X 类型的间隙锁;

因此,此时事务 A 在 id = 5 记录的主键索引上加的是间隙锁,锁住的范围是 (1, 5)。

接下来,如果有其他事务插入 id 值为 2、3、4 这一些记录的话,这些插入语句都会发生阻塞。

📢注意,如果其他事务插入的 id = 1 或者 id = 5 的记录话,并不会发生阻塞,而是报主键冲突的错误,因为表中已经存在 id = 1 和 id = 5 的记录了。

间隙锁的范围 (1, 5) 是如何确定的?

如果 LOCK_MODE 是 Next-Key 锁或者间隙锁,那么 LOCK_DATA 就表示锁的范围「右边界」,此次的事务 A 的 LOCK_DATA 是 5。

然后锁范围的「左边界」是表中 id 为 5 的上一条记录的 id 值,即 1。

因此,间隙锁的范围(1, 5)

问题:为什么唯一索引等值查询并且查询记录「不存在」的场景下,在索引树找到第一条大于该查询记录的记录后,要将该记录的索引中的 Next-Key Lock 退化成「间隙锁」?

原因就是在唯一索引等值查询并且查询记录不存在的场景下,仅靠间隙锁就能避免幻读的问题。

  • 首先第一个问题,为什么 id = 5 记录上的主键索引的锁不可以是 Next-Key Lock?如果是 Next-Key Lock,就意味着其他事务无法删除 id = 5 这条记录,但是这次的案例是查询 id = 2 的记录,只要保证前后两次查询 id = 2 的结果集相同,就能避免幻读的问题了,所以即使 id =5 被删除,也不会有什么影响,那就没必须加 Next-Key Lock,因此只需要在 id = 5 加间隙锁,避免其他事务插入 id = 2 的新记录就行了。
  • 为什么不可以针对不存在的记录加记录锁?锁是加在索引上的,而这个场景下查询的记录是不存在的,自然就没办法锁住这条不存在的记录。

唯一索引范围查询

当唯一索引进行范围查询时,会对每一个扫描到的索引加 Next-Key 锁,然后如果遇到下面这些情况,会退化成记录锁或者间隙锁

  • 情况一:针对「大于等于」的范围查询,因为存在等值查询的条件,那么如果等值查询的记录是存在于表中,那么该记录的索引中的 Next-Key 锁会退化成记录锁
  • 情况二:针对「小于或者小于等于」的范围查询,要看条件值的记录是否存在于表中:
    • 当条件值的记录不在表中,那么不管是「小于」还是「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的索引的 Next-Key 锁会退化成间隙锁,其他扫描到的记录,都是在这些记录的索引上加 Next-Key 锁。
    • 当条件值的记录在表中,如果是「小于」条件的范围查询,扫描到终止范围查询的记录时,该记录的索引的 Next-Key 锁会退化成间隙锁,其他扫描到的记录,都是在这些记录的索引上加 Next-Key 锁;如果「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的索引 Next-Key 锁不会退化成间隙锁。其他扫描到的记录,都是在这些记录的索引上加 Next-Key 锁。

针对「大于或者大于等于」范围查询

1)针对于「大于」的范围查询的情况

假设事务 A 执行了这条范围查询语句:

mysql> begin;
Query OK, 0 rows affected (0.01 sec)

mysql> select * from user where id > 15 for update;
+----+-------+-----+
| id | name | age |
+----+-------+-----+
| 20 | Emily | 28 |
+----+-------+-----+
1 row in set (0.00 sec)

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

  1. 最开始要找的第一行是 id = 20,由于查询该记录不是一个等值查询(不是大于等于条件查询),所以对该主键索引加的是范围为 (15, 20] 的 Next-Key 锁;
  2. 由于是范围查找,就会继续往后找存在的记录,在 Innodb 存储引擎中,会用一个特殊的记录来标识最后一条记录,该特殊的记录的名字叫 supremum pseudo-record ,所以扫描第二行的时候,也就扫描到了这个特殊记录的时候,会对该主键索引加的是范围为 (20, +∞] 的 Next-Key 锁;
  3. 停止扫描。

由此可以得知,事务 A 在主键索引上加了两个 X 型 的 Next-Key 锁:

  • 在 id = 20 这条记录的主键索引上,加了范围为 (15, 20] 的 Next-Key 锁,意味着其他事务即无法更新或者删除 id = 20 的记录,同时无法插入 id 值为 16、17、18、19 的这一些新记录。
  • 在特殊记录( supremum pseudo-record)的主键索引上,加了范围为 (20, +∞] 的 Next-Key 锁,意味着其他事务无法插入 id 值大于 20 的这一些新记录。

2)针对于「大于等于」的范围查询的情况

假设事务 A 执行了这条范围查询语句:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id >= 15 for update;
+----+-------+-----+
| id | name | age |
+----+-------+-----+
| 15 | Bob | 22 |
| 20 | Emily | 28 |
+----+-------+-----+
2 rows in set (0.00 sec)

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

  1. 最开始要找的第一行是 id = 15,由于查询该记录是一个等值查询(等于 15),所以该主键索引的 Next-Key 锁会退化成记录锁,也就是仅锁住 id = 15 这一行记录。
  2. 由于是范围查找,就会继续往后找存在的记录,扫描到的第二行是 id = 20,于是对该主键索引加的是范围为 (15, 20] 的 Next-Key 锁;
  3. 接着扫描到第三行的时候,扫描到了特殊记录( supremum pseudo-record),于是对该主键索引加的是范围为 (20, +∞] 的 Next-Key 锁;
  4. 停止扫描。

由此可以得知,事务 A 在主键索引上加了三个 X 型 的锁,分别是:

  • 在 id = 15 这条记录的主键索引上,加了记录锁,范围是 id = 15 这一行记录;意味着其他事务无法更新或者删除 id = 15 的这一条记录;
  • 在 id = 20 这条记录的主键索引上,加了 Next-Key 锁,范围是 (15, 20] 。意味着其他事务即无法更新或者删除 id = 20 的记录,同时无法插入 id 值为 16、17、18、19 的这一些新记录。
  • 在特殊记录( supremum pseudo-record)的主键索引上,加了 Next-Key 锁,范围是 (20, +∞] 。意味着其他事务无法插入 id 值大于 20 的这一些新记录。

针对「小于或者小于等于」的范围查询

1)针对「小于」的范围查询时,查询条件值的记录「不存在」表中的情况

假设事务 A 执行了这条范围查询语句:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id < 6 for update;
+----+---------+-----+
| id | name | age |
+----+---------+-----+
| 1 | John | 25 |
| 5 | Michael | 35 |
+----+---------+-----+
2 rows in set (0.01 sec)

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

  1. 最开始要找的第一行是 id = 1,于是对该主键索引加的是范围为 (-∞, 1] 的 Next-Key 锁;
  2. 由于是范围查找,就会继续往后找存在的记录,扫描到的第二行是 id = 5,所以对该主键索引加的是范围为 (1, 5] 的 Next-Key 锁;
  3. 由于扫描到的第二行记录(id = 5),满足 id < 6 条件,而且也没有达到终止扫描的条件,接着会继续扫描;
  4. 扫描到的第三行是 id = 10,该记录不满足 id < 6 条件的记录,所以 id = 10 这一行记录的锁会退化成间隙锁,于是对该主键索引加的是范围为 (5, 10) 的间隙锁;
  5. 由于扫描到的第三行记录(id = 10),不满足 id < 6 条件,达到了终止扫描的条件,于是停止扫描。

由此可以得知事务 A 在主键索引上加了三个 X 型的锁:

  • 在 id = 1 这条记录的主键索引上,加了范围为 (-∞, 1] 的 Next-Key 锁,意味着其他事务即无法更新或者删除 id = 1 的这一条记录,同时也无法插入 id 小于 1 的这一些新记录。
  • 在 id = 5 这条记录的主键索引上,加了范围为 (1, 5] 的 Next-Key 锁,意味着其他事务即无法更新或者删除 id = 5 的这一条记录,同时也无法插入 id 值为 2、3、4 的这一些新记录。
  • 在 id = 10 这条记录的主键索引上,加了范围为 (5, 10) 的间隙锁,意味着其他事务无法插入 id 值为 6、7、8、9 的这一些新记录。

虽然这次范围查询的条件是「小于」,但是查询条件值的记录不存在于表中( id 为 6 的记录不在表中),所以如果事务 A 的范围查询的条件改成 <= 6 的话,加的锁还是和范围查询条件为 < 6 是一样的。

因此,针对「小于或者小于等于」的唯一索引范围查询,如果条件值的记录不在表中,那么不管是「小于」还是「小于等于」的范围查询,扫描到终止范围查询的记录时,该记录中索引的 Next-Key 锁会退化成间隙锁,其他扫描的记录,则是在这些记录的索引上加 Next-Key 锁

2)针对「小于等于」的范围查询时,查询条件值的记录「存在」表中的情况

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id <= 5 for update;
+----+---------+-----+
| id | name | age |
+----+---------+-----+
| 1 | John | 25 |
| 5 | Michael | 35 |
+----+---------+-----+
2 rows in set (0.00 sec)

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

  1. 最开始要找的第一行是 id = 1,于是对该记录加的是范围为 (-∞, 1] 的 Next-Key 锁;
  2. 由于是范围查找,就会继续往后找存在的记录,扫描到的第二行是 id = 5,于是对该记录加的是范围为 (1, 5] 的 Next-Key 锁;
  3. 由于主键索引具有唯一性,不会存在两个 id = 5 的记录,所以不会再继续扫描,于是停止扫描。

由此可以得到事务 A 在主键索引上加了 2 个 X 型的锁

  • 在 id = 1 这条记录的主键索引上,加了范围为 (-∞, 1] 的 Next-Key 锁。意味着其他事务即无法更新或者删除 id = 1 的这一条记录,同时也无法插入 id 小于 1 的这一些新记录。
  • 在 id = 5 这条记录的主键索引上,加了范围为 (1, 5] 的 Next-Key 锁。意味着其他事务即无法更新或者删除 id = 5 的这一条记录,同时也无法插入 id 值为 2、3、4 的这一些新记录。

3)针对「小于」的范围查询时,查询条件值的记录「存在」表中的情况

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where id < 5 for update;
+----+------+-----+
| id | name | age |
+----+------+-----+
| 1 | John | 25 |
+----+------+-----+
1 row in set (0.00 sec)

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

  1. 最开始要找的第一行是 id = 1,于是对该记录加的是范围为 (-∞, 1] 的 Next-Key 锁;
  2. 由于是范围查找,就会继续往后找存在的记录,扫描到的第二行是 id = 5,该记录是第一条不满足 id < 5 条件的记录,于是该记录的锁会退化为间隙锁,锁范围是 (1,5)
  3. 由于找到了第一条不满足 id < 5 条件的记录,于是停止扫描。

由此可以得知,此时事务 A 在主键索引上加了两种 X 型锁:

  • 在 id = 1 这条记录的主键索引上,加了范围为 (-∞, 1] 的 Next-Key 锁,意味着其他事务即无法更新或者删除 id = 1 的这一条记录,同时也无法插入 id 小于 1 的这一些新记录;
  • 在 id = 5 这条记录的主键索引上,加了范围为 (1,5) 的间隙锁,意味着其他事务无法插入 id 值为 2、3、4 的这一些新记录。

小结

在针对「小于或者小于等于」的唯一索引(主键索引)范围查询时,存在如下两种情况会将索引的 Next-Key 锁会退化成间隙锁的:

  • 当条件值的记录「不在」表中时,那么不论是「小于」还是「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的主键索引中的 Next-Key 锁会退化成间隙锁,其他扫描到的记录,都是在这些记录的主键索引上加 Next-Key 锁;
  • 当条件值的记录「存在」表中时:
    • 如果是「小于」条件的范围查询,扫描到终止范围查询的记录时,该记录的主键索引中的 Next-Key 锁会退化成间隙锁,其他扫描到的记录,都是在这些记录的主键索引上,加 Next-Key 锁;
    • 如果是「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的主键索引中的 Next-Key 锁不会退化成间隙锁,其他扫描到的记录,都是在这些记录的主键索引上加 Next-Key 锁。

非唯一索引等值查询

当使用非唯一索引(二级索引)进行等值查询的时候,因为存在两个索引,一个是主键索引,一个是非唯一索引(二级索引),所以在加锁时,同时会对这两个索引都加锁,但是对主键索引加锁的时候,只有满足查询条件的记录才会对它们的主键索引加锁

针对于非唯一索引等值查询时,查询的记录存在与否,加锁的规则也会不同:

  • 当查询的记录「存在」时,由于不是唯一索引,所以会存在索引值相同的记录,于是非唯一索引等值查询的过程是一个扫描的过程,直到扫描到第一个不符合条件的二级索引记录就停止扫描;在扫描的过程中,对扫描到的二级索引记录加的是 Next-Key 锁,而对于第一个不符合条件的二级索引记录,该二级索引的 Next-Key 锁会退化成间隙锁。同时,在符合查询条件的记录的主键索引上加记录锁。
  • 当查询的记录「不存在」时,扫描到第一条不符合条件的二级索引记录,该二级索引的 Next-Key 锁会退化成间隙锁,因为不存在满足查询条件的记录,所以不会对主键索引加锁。

记录不存在

假设事务 A 对非唯一索引(age)进行了等值查询,且表中不存在 age = 29 的记录。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where age = 29 for update;
Empty set (0.01 sec)

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

  • 定位到第一条不符合查询条件的二级索引记录,即扫描到 age = 30,于是该二级索引的 Next-Key 锁会退化成间隙锁,范围是 (22, 30)
  • 停止查询。

事务 A 在 age = 30 记录的二级索引上,加了 X 型的间隙锁,范围是 (28, 30)。意味着其他事务无法插入 age 值为 29 的新记录。不过对于插入 age = 28 和 age = 30 记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入,具体情况要看是否有锁。

从上图的分析,可以看到,事务 A 在 age = 30 记录的二级索引上(INDEX_NAME: index_age ),加了范围为 (28, 30) 的 X 型间隙锁。此时,如果有其他事务插入了 age 值为 29 的新记录,那么这些插入语句都会发生阻塞。

问题:当有一个事务持有二级索引的间隙锁 (28, 30) 时,什么情况下,可以让其他事务的插入 age = 28 或者 age = 30 记录的语句成功?又是什么情况下,插入 age = 28 或者 age = 30 记录时的语句会被阻塞?

首先需要明确,什么情况下插入语句会发生阻塞?

插入语句在插入一条记录之前,需要先定位到该记录在 B+ 树的位置,如果插入的位置的下一条记录的索引上有间隙锁,才会发生阻塞。

在分析二级索引的间隙锁是否可以成功插入记录时,我们要先要知道二级索引树是如何存放记录的?

二级索引树是按照二级索引值(age列)按顺序存放的,在相同的二级索引值情况下, 再按主键 id 的顺序存放。

基于前面的例子,事务 A 是在 age = 30 记录的二级索引上加了 X 型的间隙锁,范围是 (28, 30)。

插入 age = 28 记录的成功和失败的情况分别如下:

  • 当其他事务插入一条 age = 28,id = 19 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条是 id = 20、age = 28 的记录,该记录的二级索引上没有间隙锁,所以这条插入语句可以执行成功
  • 当其他事务插入一条 age = 28,id = 21 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条是 id = 10、age = 30 的记录,正好该记录的二级索引上有间隙锁,所以这条插入语句会被阻塞,无法插入成功

插入 age = 30 记录的成功和失败的情况分别如下:

  • 当其他事务插入一条 age = 30,id = 9 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条是 id = 10、age = 30 的记录,正好该记录的二级索引上有间隙锁,所以这条插入语句会被阻塞,无法插入成功
  • 当其他事务插入一条 age = 30,id = 11 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条记录不存在,也就没有间隙锁了,所以这条插入语句可以插入成功

所以,当有一个事务持有二级索引的间隙锁 (28, 30) 时,插入 age = 28 或者 age = 30 记录的语句是否可以执行成功,关键还要考虑插入记录的主键值,因为「二级索引值(age列)+主键值(id列)」才可以确定插入的位置,确定了插入位置后,就要看插入的位置的下一条记录是否有间隙锁,如果有间隙锁,就会发生阻塞,如果没有间隙锁,则可以插入成功

在回过头看非唯一索引等值查询时,查询的记录不存在时的锁记录信息:

之前的结论中,只考虑了 LOCK_DATA 第一个数值(30),没有考虑 LOCK_DATA 第二个数值(10)。

LOCK_DATA:30,10 是什么意思?

  • LOCK_DATA 第一个数值,也就是 30, 它代表的是 age 值。从前面我们也知道了,LOCK_DATA 第一个数值是 Next-Key 锁和间隙锁锁住的范围的右边界值
  • LOCK_DATA 第二个数值,也就是 10, 它代表的是 id 值。

之所以 LOCK_DATA 要多显示一个数值(ID值),是因为针对「当某个事务持有非唯一索引的 (28, 30) 间隙锁的时候,其他事务是否可以插入 age = 30 新记录」的问题,还需要考虑插入记录的 id 值。而 LOCK_DATA 的第二个数值,就是说明在插入 age = 30 新记录时,哪些范围的 id 值是不可以插入的

因此, LOCK_DATA:30,10 + LOCK_MODE : X, GAP 的意思是,事务 A 在 age = 30 记录的二级索引上(INDEX_NAME: index_age ),加了 age 值范围为 (28, 30) 的 X 型间隙锁,同时针对其他事务插入 age 值为 30 的新记录时,不允许插入的新记录的 id 值小于 10 。如果插入的新记录的 id 值大于 10,则可以插入成功。

但是我们无法从performance_schema.data_locks 的输出的结果分析出「在插入 age =28 新记录时,哪些范围的 id 值是可以插入成功的」,这时候就得画出二级索引的 B+ 树的结构,然后确定插入位置后,看下该位置的下一条记录是否存在间隙锁,如果存在间隙锁,则无法插入成功,如果不存在间隙锁,则可以插入成功

记录存在

假设事务 A 对非唯一索引(age)进行了等值查询,且表中存在 age = 28 的记录。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from user where age = 28 for update;
+----+-------+-----+
| id | name | age |
+----+-------+-----+
| 20 | Emily | 28 |
+----+-------+-----+
1 row in set (0.00 sec)

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

  • 由于不是唯一索引,所以肯定存在值相同的记录,于是非唯一索引等值查询的过程是一个扫描的过程,最开始要找的第一行是 age = 28,于是对该二级索引记录加上范围为 (25, 28] 的 Next-Key 锁。同时,因为 age = 28 符合查询条件,于是对 age = 28 的记录的主键索引加上记录锁,即对 id = 20 这一行加记录锁;
  • 接着继续扫描,扫描到的第二行是 age = 30,该记录是第一个不符合条件的二级索引记录,所以该二级索引的 Next-Key 锁会退化成间隙锁,范围是 (28, 30);
  • 停止查询。

由此可以看出,事务 A 对主键索引和二级索引都加了 X 型的锁:

  • 主键索引:
    • 在 id = 20 这条记录的主键索引上,加了记录锁,意味着其他事务无法更新或者删除 id = 20 的这一行记录。
  • 二级索引(非唯一索引):
    • 在 age = 28 这条记录的二级索引上,加了范围为 (25, 28] 的 Next-Key 锁,意味着其他事务无法更新或者删除 age = 28 的这一些新记录,不过对于插入 age = 25 和 age = 28 新记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入;
    • 在 age = 30 这条记录的二级索引上,加了范围 (28, 30) 的间隙锁。意味着其他事务无法插入 age 值为 29 的新记录。不过对于插入 age = 28 和 age = 30 记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入。

从上图可以看出,事务 A 对二级索引(INDEX_NAME: index_age)加了两个 X 型锁,分别是:

  • 在 age = 28 这条记录的二级索引上,加了范围为 (25, 28] 的 Next-Key 锁,意味着其他事务无法更新或者删除 age = 28 的这一些新记录,针对是否可以插入 age = 25 和 age = 28 的新记录,分析如下:
    • 是否可以插入 age = 25 的新记录,还要看插入的新记录的 id 值,如果插入 age = 25 新记录的 id 值小于 1,那么就可以插入成功,因为此时插入的位置的下一条记录是 id = 1,age = 25 的记录,该记录的二级索引上没有间隙锁。如果插入 age = 25 新记录的 id 值大于 1,那么就无法插入成功,因为此时插入的位置的下一条记录是 id = 20,age = 28 的记录,该记录的二级索引上有间隙锁;
    • 是否可以插入 age = 28 的新记录,还要看插入的新记录的 id 值,从 LOCK_DATA : 28, 20 可以得知,其他事务插入 age 值为 28 的新记录时,如果插入的新记录的 id 值小于 20,那么插入语句会发生阻塞;如果插入的新记录的 id 大于 20,还要看该新记录插入的位置的下一条记录是否有间隙锁,如果没有间隙锁则可以插入成功,如果有间隙锁,则无法插入成功
  • 在 age = 30 这条记录的二级索引上,加了范围 (28, 30) 的间隙锁。意味着其他事务无法插入 age 值为 29 的新记录,针对是否可以插入 age = 28 和 age = 30 的新记录,分析如下:
    • 是否可以插入 age = 28 的新记录,还要看插入的新记录的 id 值,如果插入 age = 28 新记录的 id 值小于 20,那么插入语句会被阻塞,无法插入,因为此时插入的位置的下一条记录是 id = 20,age = 28 的记录,该记录的二级索引上有间隙锁( age = 28 这条记录的二级索引上有 Next-Key 锁)。如果插入 age = 28 新记录的 id 值大于 20,也无法插入,因为此时插入的位置的下一条记录是 id = 10,age = 30 的记录,该记录的二级索引上有间隙锁;
    • 是否可以插入 age = 30 的新记录,还要看插入的新记录的 id 值,从 LOCK_DATA : 30, 10 可以得知,其他事务插入 age 值为 30 的新记录时,如果插入的新记录的 id 值小于 10,那么插入语句会发生阻塞,如果插入的新记录的 id 大于 10,则可以插入成功

同时,事务 A 还对主键索引(INDEX_NAME: PRIMARY )加了 X 型的记录锁:在 id = 20 这条记录的主键索引上,加了记录锁,意味着其他事务无法更新或者删除 id = 20 的这一行记录。

问题:为什么需要在二级索引索引上加范围 (28, 30) 的间隙锁?

是为了避免幻读现象的发生,同时这也是间隙锁的目的。

如果不加这个间隙锁的话,根据上述的分析,没有间隙锁,只有(25, 28]的 Next-Key 锁,是会存在 age = 28 的记录可以被插入的。这个时候就会出现幻读的问题了。

加上间隙锁之后,age = 28 的记录就不会被插入了,因为前后都存在间隙锁。

非唯一索引范围查询

非唯一索引和主键索引的范围查询的加锁也有所不同,不同之处在于非唯一索引范围查询,索引的 Next-Key lock 不会有退化为间隙锁和记录锁的情况,也就是非唯一索引进行范围查询时,对二级索引记录加锁都是加 Next-Key 锁。

mysql> begin;
Query OK, 0 rows affected (0.03 sec)

mysql> select * from user where age >= 30 for update;
+----+---------+-----+
| id | name | age |
+----+---------+-----+
| 10 | Alice | 30 |
| 5 | Michael | 35 |
+----+---------+-----+
2 rows in set (0.05 sec)

事务 A 的加锁变化:

  • 最开始要找的第一行是 age = 30,虽然范围查询语句包含等值查询,但是这里不是唯一索引范围查询,所以是不会发生退化锁的现象,因此对该二级索引记录加 Next-Key 锁,范围是 (28, 30]。同时,对 age = 30 这条记录的主键索引加记录锁,即对 id = 10 这一行记录的主键索引加记录锁;
  • 由于是范围查询,接着继续扫描已经存在的二级索引记录。扫面的第二行是 age = 35 的二级索引记录,于是对该二级索引记录加 Next-Key 锁,范围是 (30, 35],同时,对 age = 35 这条记录的主键索引加记录锁,即对 id = 5 这一行记录的主键索引加记录锁;
  • 在 Innodb 存储引擎中,会用一个特殊的记录,supremum pseudo-record 来标识最后一条记录,所以扫描第二行的时候,也就扫描到了这个特殊记录的时候,会对该二级索引记录加的是范围为 (35, +∞] 的 Next-Key 锁;
  • 停止查询。

  • 主键索引(id 列):
    • 在 id = 5 这条记录的主键索引上,加了记录锁,意味着其他事务无法更新或者删除 id = 10 的这一行记录。
    • 在 id = 10 这条记录的主键索引上,加了记录锁,意味着其他事务无法更新或者删除 id = 10 的这一行记录。
  • 二级索引(age 列):
    • 在 age = 30 这条记录的二级索引上,加了范围为 (28, 30] 的 Next-Key 锁,意味着其他事务无法更新或者删除 age = 30 的这一些新记录,不过对于是否可以插入 age = 28 和 age = 30 的新记录,还需要看新记录的 id 值,有些情况是可以成功插入的,而一些情况则无法插入;
    • 在 age = 35 这条记录的二级索引上,加了范围为 (30, 35] 的 Next-Key 锁,意味着其他事务无法更新或者删除 age = 35 的这一些记录,也无法插入 age 值为 31、32、... 、35 的这一些新记录。不过对于是否可以插入 age = 30 和 age = 35 的新记录,还需要看新记录的 id 值,有些情况是可以成功插入的,而一些情况则无法插入;
    • 在特殊的记录(supremum pseudo-record)的二级索引上,加了范围为 (35, +∞] 的 Next-Key 锁,意味着其他事务无法插入 age 值大于 35 的这些新记录。

问题:在 age >= 30 的范围查询中,明明查询 age = 30 的记录存在并且属于等值查询,为什么不会像唯一索引那样,将 age = 30 记录的二级索引上的 Next-Key 锁退化为记录锁?

因为 age 字段是非唯一索引,不具有唯一性,所以如果只加记录锁(记录锁无法防止插入,只能防止删除或者修改),就会导致其他事务插入一条 age = 30 的记录,这样前后两次查询的结果集就不相同了,出现了幻读现象。

没有加索引的查询

前面的示例中的查询语句都有使用索引查询,也就是查询记录的时候,是通过索引扫描的方式查询的,然后对扫描出来的记录进行加锁。

如果锁定读查询语句,没有使用索引列作为查询条件,或者查询语句没有走索引查询,导致扫描是全表扫描。那么,每一条记录的索引上都会加 Next-Key 锁,这样就相当于锁住的全表,这时如果其他事务对该表进行增、删、改操作的时候,都会被阻塞。

不只是锁定读查询语句不加索引才会导致这种情况,UPDATE 和 DELETE 语句如果查询条件不加索引,那么由于扫描的方式是全表扫描,于是就会对每一条记录的索引上都会加 Next-Key 锁,这样就相当于锁住的全表。

因此,在线上在执行 UPDATE、DELETE、SELECT ... FOR UPDATE 等具有加锁性质的语句,一定要检查语句是否走了索引,如果是全表扫描的话,会对每一个索引加 Next-Key 锁,相当于把整个表锁住了,这是非常严重的。

总结

MySQL InnoDB 行级锁加锁规则:

唯一索引等值查询

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

非唯一索引等值查询

  • 当查询的记录「存在」时,由于不是唯一索引,所以肯定存在索引值相同的记录,于是非唯一索引等值查询的过程是一个扫描的过程,直到扫描到第一个不符合条件的二级索引记录就停止扫描,然后在扫描的过程中,对扫描到的二级索引记录加的是 Next-Key 锁,而对于第一个不符合条件的二级索引记录,该二级索引的 Next-Key 锁会退化成间隙锁。同时,在符合查询条件的记录的主键索引上加记录锁
  • 当查询的记录「不存在」时,扫描到第一条不符合条件的二级索引记录,该二级索引的 Next-Key 锁会退化成间隙锁。因为不存在满足查询条件的记录,所以不会对主键索引加锁

非唯一索引和唯一索引的范围查询的加锁规则区别在于:

  • 唯一索引在满足一些条件的时候,索引的 Next-Key Lock 退化为间隙锁或者记录锁;
  • 非唯一索引范围查询,索引的 Next-Key Lock 不会退化为间隙锁和记录锁。

从避免幻读的角度去理解这些规则。

除此之外,在线上在执行 UPDATE、DELETE、SELECT ... FOR UPDATE 等具有加锁性质的语句,一定要检查语句是否走了索引,如果是全表扫描的话,会对每一个索引加 Next-Key 锁,相当于把整个表锁住了,这是非常严重的。