不久之前,有位读者问了一个关于 insert intention waiting 的问题,回答过程中,我还把意向锁(intention lock)和插入意向锁(insert intention lock)搞混了,实际上这是 2 种不同类型的锁。
为此,我研究了下插入意向锁,于是有了这篇文章。
本文基于 MySQL 8.0.32 源码,存储引擎为 InnoDB,事务隔离级别为可重复读。如需转载,请联系『一树一溪』公众号作者,转载后请标明来源。
正文
1、什么是插入意向锁?
我们先来看看官方定义:插入意向锁是由 INSERT 操作在插入记录之前加的一种间隙锁。
官方文档原文如下:
An insert intention lock
is a type of gap lock
set by INSERT operations
prior to row insertion.
我们再来看看插入意向锁的加锁代码:
// 为了方便阅读,代码格式做了调整。
// storage/innobase/lock/lock0lock.cc
dberr_t lock_rec_insert_check_and_lock(...)
{
...
const ulint type_mode =
LOCK_X |
LOCK_GAP |
LOCK_INSERT_INTENTION;
const auto conflicting =
lock_rec_other_has_conflicting(type_mode, block, heap_no, trx);
...
if (conflicting.wait_for != nullptr) {
RecLock rec_lock(thr, index, block, heap_no, type_mode);
trx_mutex_enter(trx);
err = rec_lock.add_to_waitq(conflicting.wait_for);
trx_mutex_exit(trx);
}
...
}
type_mode 包含了 3 个标志位:
- LOCK_X,表示这是个排他锁。
- LOCK_GAP,表示这是个间隙锁。
- LOCK_INSERT_INTENTION,表示这是插入意向锁。
代码和官方文档可以相互印证:插入意向锁是一种排他(LOCK_X)间隙锁(LOCK_GAP)。
2、为什么需要插入意向锁?
通过前面的介绍,我们知道了:插入意向锁本质上是间隙锁。
那么,问题来了:既然有了间隙锁,那还弄个插入意向锁干啥?
答案当然是有用了。
有啥用?
说来话长。
那我们就长话长说,先从间隙锁说起。
我们先来看一下间隙锁的特点:
- 间隙锁的唯一用途是阻止其它事务插入记录到间隙中,以实现可重复读。
- 共享间隙锁、排他间隙锁的功能完全一样。
- 间隙锁可以共存,一个事务持有某个间隙的锁,该间隙锁释放之前,其它事务也可以申请并获得该间隙的锁,并且不区分共享锁还是排他锁。
由于多个间隙锁可以共存,插入记录需要加锁时,如果直接使用间隙锁,一个事务锁住了某个间隙,其它事务执行 INSERT 语句还可以插入记录到该间隙中,也就违背了间隙锁用于实现可重复读这一特点了。
为了解决这个问题,InnoDB 引入了插入意向锁。
上一小节,我们从 lock_rec_insert_check_and_lock() 代码看到了插入间隙锁的 type_mode:
const ulint type_mode =
LOCK_X |
LOCK_GAP |
LOCK_INSERT_INTENTION;
实际上,插入意向锁就是在排他间隙锁的基础上打了个 LOCK_INSERT_INTENTION 标志。
我们通过具体的应用场景来看一下 LOCK_INSERT_INTENTION 标志的作用机制:
图片
事务 T 执行 INSERT 语句,插入记录 R 到某个表的记录 R1 之前。
如果其它事务对 R1 前面的间隙加了(共享或排他)间隙锁,事务 T 会申请对该间隙加插入意向锁。
因为插入意向锁有 LOCK_INSERT_INTENTION 标志,识别到这个标志,InnoDB 就会让 INSERT 语句进入等待状态。
直到 R1 前面间隙的锁被释放,INSERT 语句才能获得插入意向锁,插入记录 R 到 R1 前面的间隙中。
通过 LOCK_INSERT_INTENTION 标志的介绍可以看到,插入记录时,只有使用插入意向锁,其它事务持有的间隙锁才能阻止插入操作插入记录到间隙中。
也就是说,间隙锁需要插入意向锁的配合,才能实现可重复读,这就是为什么需要插入意向锁的原因了。
3、插入意向锁和其它锁的关系
为了介绍这一小节的内容,我们需要先做点准备工作。
创建测试表:
USE `test`;
CREATE TABLE `t1` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`i1` int DEFAULT '0',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
插入测试数据:
INSERT INTO `t1`(`id`, `i1`)
VALUES (10, 101), (20, 201), (30, 301);
数据如下:
通过以下命令创建 3 个数据库连接备用:
mysql -h127.0.0.1 -uroot -D test
3 个连接分别命名为 session 1、session 2、session 3。
(1)间隙锁会阻塞插入意向锁
我们可以按以下步骤,验证间隙锁会阻塞插入意向锁。
第 1 步,在 session 1 中执行以下 SQL,对 id = 20 的记录加间隙锁:
BEGIN;
SELECT * FROM `t1`
WHERE `id` > 10 AND `id` < 20
FOR SHARE;
SELECT 语句会锁住 id = 20 的记录前面的间隙。
第 2 步,在 session 2 中执行以下 SQL,插入一条记录到 id = 20 的记录之前:
BEGIN;
INSERT INTO `t1`(`id`, `i1`)
VALUES (12, 121);
INSERT 语句发现 id = 20 的记录前面的间隙被锁住了,会申请对该间隙加插入意向锁,并进入等待状态。
第 3 步,在 session 3 中执行以下 SQL,查看加锁情况:
SELECT
`engine_transaction_id` as `trx_id`,
`object_name` as `table`,
`index_name` as `index`,
`lock_type`, `lock_mode`,
`lock_status`, `lock_data`
FROM `performance_schema`.`data_locks`
WHERE `lock_type` = 'RECORD'
AND object_schema = 'test';
图片
通过加锁情况,我们可以确认间隙锁会阻塞插入意向锁:
- session 1 的 SELECT 语句持有间隙锁(lock_mode 包含 GAP)。
- session 2 的 INSERT 语句正在等待插入意向锁(lock_mode 包含 INSERT_INTENTION)。
最后,在 session 1、session 2 中执行 ROLLBACK 语句,回滚事务,为后面的验证工作做准备。
(2)插入意向锁不会阻塞间隙锁
我们可以按以下步骤,验证插入意向锁不会阻塞间隙锁。
第 1 步,在 session 1 中执行以下 SQL,对 id = 20 的记录加间隙锁:
BEGIN;
SELECT * FROM `t1`
WHERE `id` > 10 AND `id` < 20
FOR SHARE;
SELECT 语句会锁住 id = 20 的记录前面的间隙。
第 1 步加间隙锁,是为了引发第 2 步的 INSERT 语句加插入意向锁。
第 2 步,在 session 2 中执行以下 SQL,插入一条记录到 id = 20 的记录之前:
BEGIN;
INSERT INTO `t1`(`id`, `i1`)
VALUES (12, 121);
INSERT 语句发现 id = 20 的记录前面的间隙被锁住了,会申请对该间隙加插入意向锁,并进入等待状态。
第 3 步,在 session 1 中执行以下 SQL,回滚事务:
ROLLBACK;
SELECT 语句释放间隙锁之后,第 2 步 session 2 中的 INSERT 语句成功获得插入意向锁。
第 4 步,在 session 1 中执行以下 SQL,对 id = 20 的记录加间隙锁:
BEGIN;
SELECT * FROM `t1`
-- `id` > xx 中的 xx 取值为 15
WHERE `id` > 15 AND `id` < 20
FOR SHARE;
注意:因为第 2 步的 INSERT 语句在 id = 10 ~ 20 之间插入了 id = 12 的记录,第 4 步 WHERE 条件 id > xx 中的 xx 必须大于 12,否则会触发 id = 12 的记录上的隐式锁逻辑,导致 SELECT 语句等待 id = 20 的记录上的 next-key 锁。
第 5 步,在 session 3 中执行以下 SQL,查看加锁情况:
SELECT
`engine_transaction_id` as `trx_id`,
`object_name` as `table`,
`index_name` as `index`,
`lock_type`, `lock_mode`,
`lock_status`, `lock_data`
FROM `performance_schema`.`data_locks`
WHERE `lock_type` = 'RECORD'
AND object_schema = 'test';
通过加锁情况,我们可以确认插入意向锁不会阻塞间隙锁:
- session 2 中,第 2 步的 INSERT 语句持有插入意向锁(lock_mode 包含 INSERT_INTENTION)。
- session 1 中,第 4 步的 SELECT 语句成功获得了间隙锁(lock_mode 包含 GAP)。
最后,在 session 1、session 2 中执行 ROLLBACK 语句,回滚事务,为后面的验证工作做准备。
(3)插入意向锁相互之间不会阻塞
我们可以按以下步骤,验证插入意向锁相互之间不会阻塞。
第 1 步,在 session 1 中执行以下 SQL,对 id = 20 的记录加间隙锁:
BEGIN;
SELECT * FROM `t1`
WHERE `id` > 10 AND `id` < 20
FOR SHARE;
SELECT 语句会锁住 id = 20 的记录前面的间隙。
执行这一步是为了让第 2、3 步的 INSERT 语句都申请对 id = 20 的记录前面的间隙加插入意向锁,并进入等待状态。
第 2 步,在 session 2 中执行以下 SQL,插入一条记录到 id = 20 的记录之前:
BEGIN;
INSERT INTO `t1`(`id`, `i1`)
VALUES (12, 121);
INSERT 语句发现 id = 20 的记录前面的间隙被锁住了,会申请对该间隙加插入意向锁,并进入等待状态。
第 3 步,在 session 3 中执行以下 SQL,插入一条记录到 id = 20 的记录之前:
BEGIN;
INSERT INTO `t1`(`id`, `i1`)
VALUES (15, 151);
INSERT 语句发现 id = 20 的记录前面的间隙被锁住了,会申请对该间隙加插入意向锁,并进入等待状态。
第 4 步,在 session 1 中执行以下 SQL,查看锁等待情况:
SELECT
`engine_transaction_id` as `trx_id`,
`object_name` as `table`,
`index_name` as `index`,
`lock_type`, `lock_mode`,
`lock_status`, `lock_data`
FROM `performance_schema`.`data_locks`
WHERE `lock_type` = 'RECORD'
AND object_schema = 'test';
图片
由于 id = 20 的记录前面的间隙被第 1 步的 SELECT 语句锁住了,第 2、3 步的 INSERT 语句正在等待该间隙的插入意向锁。
第 5 步,在 session 1 中执行回滚语句,释放 id = 20 的记录上的间隙锁:
ROLLBACK;
第 6 步,在 session 1 中执行以下 SQL,查看加锁情况:
SELECT
`engine_transaction_id` as `trx_id`,
`object_name` as `table`,
`index_name` as `index`,
`lock_type`, `lock_mode`,
`lock_status`, `lock_data`
FROM `performance_schema`.`data_locks`
WHERE `lock_type` = 'RECORD'
AND object_schema = 'test';
第 2、3 步的 INSERT 语句同时获得了 id = 20 的记录前面间隙的插入意向锁。
通过加锁情况,我们可以确认插入意向锁相互之间不会阻塞。
3.4 next-key 锁和插入意向锁会相互阻塞吗?
对于 next-key 锁和插入意向锁是否会相互阻塞,这里只给出结论:
- next-key 锁会阻塞插入意向锁。
- 插入意向锁不会阻塞 next-key 锁。
感兴趣的读者可以按照 3.1、3.2 小节的步骤自行测试,毕竟自己动手获得的知识才会记得更牢。
测试时,需要把 SELECT 语句 WHERE 条件中的 id < 20 替换为 id <= 20,确保 SELECT 语句加的是 next-key 锁而不是普通的间隙锁。
4. 怎么知道加了插入意向锁?
我们通过查询 performance_schema.data_locks,可以知道某个事务是否申请了对某个间隙加插入间隙锁,这种方式我们在上一小节中已经使用过多次。
如果查询结果中某条记录的 lock_mode 字段包含 INSERT_INTENTION,说明对应的事务申请了加插入意向锁。
lock_status = WAITING 说明正在等待插入意向锁。
lock_status = GRANTED 说明已经获得了插入意向锁。
还有一种方式,只能看到正在等待的插入意向锁,无法看到已经获得的插入意向锁。
执行 SHOW ENGINE InnoDB STATUS 语句,部分结果如下:
-- 为了方便阅读,对以下结果的格式做了调整
-- TRX HAS BEEN WAITING 2 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 47
n bits 72
index PRIMARY of table `test`.`t1`
trx id 133955
lock_mode X
locks gap before rec
insert intention waiting
通过以上结果,我们可以得到以下信息:
事务 133955 正在等待(waiting)获取插入意向锁(insert intention):
- lock_mode X 对应 type_mode 中的 LOCK_X。
- locks gap before rec 对应 type_mode 中的 LOCK_GAP。
- insert intention 对应 type_mode 中的 LOCK_INSERT_INTENTION。
5、总结
在排他(LOCK_X)间隙锁(LOCK_GAP)的基础上增加 LOCK_INSERT_INTENTION 标志,就得到了插入意向锁,所以,从本质上来说,插入意向锁是个特殊的间隙锁。
间隙锁需要插入意向锁的配合,才能阻塞其它事务插入记录到某个间隙中,从而实现可重复读,这就是需要插入意向锁的原因了。