插入记录导致唯一索引冲突,on duplicate key update 更新主键字段值的加锁情况分析。
作者:操盛春,爱可生技术专家,公众号『一树一溪』作者,专注于研究 MySQL 和 OceanBase 源码。
爱可生开源社区出品,原创内容未经授权不得随意使用,转载请联系小编并注明来源。
本文基于 MySQL 8.0.32 源码,存储引擎为 InnoDB。
1. 准备工作
创建测试表:
CREATE TABLE `t4` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`i1` int DEFAULT '0',
`i2` int DEFAULT '0',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uniq_i1` (`i1`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
插入测试数据:
INSERT INTO `t4` (`id`, `i1`, `i2`) VALUES
(1, 11, 21), (2, 12, 22), (3, 13, 23),
(4, 14, 24), (5, 15, 25), (6, 16, 26);
2. 可重复读
把事务隔离级别设置为 REPEATABLE-READ(如已设置,忽略此步骤):
SET transaction_isolation = 'REPEATABLE-READ';
-- 确认设置成功
SHOW VARIABLES like 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
执行以下 insert 语句(唯一索引冲突,更新主键字段值):
begin;
insert into t4 (id, i1, i2) values (7, 12, 220)
on duplicate key update id = values(id), i2 = values(i2);
查看加锁情况:
select
engine_transaction_id, object_name, index_name,
lock_type, lock_mode, lock_status, lock_data
from performance_schema.data_locks
where object_name = 't4'
and lock_type = 'RECORD'\G
***************************[ 1. row ]***************************
engine_transaction_id | 250933
object_name | t4
index_name | uniq_i1
lock_type | RECORD
lock_mode | X
lock_status | GRANTED
lock_data | 12, 2
***************************[ 2. row ]***************************
engine_transaction_id | 250933
object_name | t4
index_name | uniq_i1
lock_type | RECORD
lock_mode | X
lock_status | GRANTED
lock_data | 13, 3
***************************[ 3. row ]***************************
engine_transaction_id | 250933
object_name | t4
index_name | PRIMARY
lock_type | RECORD
lock_mode | X,REC_NOT_GAP
lock_status | GRANTED
lock_data | 2
***************************[ 4. row ]***************************
engine_transaction_id | 250933
object_name | t4
index_name | PRIMARY
lock_type | RECORD
lock_mode | X
lock_status | GRANTED
lock_data | supremum pseudo-record
***************************[ 5. row ]***************************
engine_transaction_id | 250933
object_name | t4
index_name | PRIMARY
lock_type | RECORD
lock_mode | X,GAP
lock_status | GRANTED
lock_data | 7
***************************[ 6. row ]***************************
engine_transaction_id | 250933
object_name | t4
index_name | uniq_i1
lock_type | RECORD
lock_mode | X,GAP
lock_status | GRANTED
lock_data | 12, 7
insert 语句插入记录,导致唯一索引冲突,更新主键字段值,这个过程加锁有点多,加锁逻辑也比较复杂。
上面查询出来的加锁情况,不是按照加锁先后面顺序展示的。接下来,我们按照加锁的先后顺序,来看看这些记录的加锁逻辑。
1. row、4. row、3. row 这三条记录的的加锁逻辑,和上一期唯一索引冲突,不更新主键字段值的加锁逻辑相同。为了方便阅读,这里把上一期的内容拷贝过来了。
1. row,lock_data = 12,2、lock_mode = X 表示对唯一索引 uniq_i1 中 <i1 = 12, id = 2>
的记录加了排他 Next-Key 锁。
insert 语句执行过程中,先插入记录到主键索引,再逐个插入记录到二级索引。
对于示例 SQL,先插入 <id = 7, i1 = 12, i2 = 220>
的记录到主键索引,因为主键字段值(id = 7)和主键索引中已有记录不冲突,插入成功。
再插入 <i1 = 12, id = 7>
的记录到唯一索引 uniq_i1,插入记录之前,需要先找到插入记录的目标位置。
找到的目标位置是 uniq_i1 中 <i1 = 12, id = 2>
的记录后面。insert 语句发现即将插入的新记录和这条记录的 i1 字段值都是 12。
对于唯一索引,需要进行两项检查,以确认新记录是否和已有记录冲突:
- 第 1 项,检查新记录中是否有哪个字段值为 NULL。
- 第 2 项,检查已发现可能冲突的记录是否已经被标记删除。
进行以上两项检查之前,insert 语句需要对表中可能冲突的记录加锁。
因为唯一索引允许插入唯一字段值为 NULL 的多条记录。为了防止其它事务往可能冲突的记录间隙插入记录,检查过程中,会对可能冲突的记录加 Next-Key 锁。
这和对主键索引中可能冲突的记录的加锁逻辑不同。
示例 SQL 包含 on duplicate key update 子句,如果确认新记录和已有记录冲突,会用这个子句中各字段值更新冲突记录,检查过程中没有加共享锁,而是直接加了排他锁。
以上就是对 uniq_i1 中 <i1 = 12, id = 2>
的记录加排他 Next-Key 锁的原因。
4. row,lock_data = supremum pseudo-record、lock_mode = X 表示对主键索引某个数据页中 supremum 记录加了排他 Next-Key 锁。
supremum 记录和 insert 语句想要插入的记录,似乎隔着十万八千里,怎么还给它加了锁呢?
这个过程有点曲折,我们一步步来看。
insert 语句插入 <id = 7, i1 = 12, i2 = 220>
的记录到主键索引成功之后,接着插入 <i1 = 12, id = 7>
的记录到唯一索引 uniq_i1 中,发现并确认新记录和 uniq_i1 中已有 <i1 = 12, id = 2>
的记录冲突。
新记录和 uniq_i1 中已有记录冲突,插入操作无法继续进行下去了,刚刚插入到主键索引的记录会被删除。
InnoDB 执行 insert 语句之前,会创建一个保存点,删除刚刚插入到主键索引的记录,就是通过回滚到这个保存点实现的。
回滚过程中,删除刚刚插入到主键索引中 <id = 7>
的记录之前,会把这条记录上的隐式锁转换为显式锁,锁模式为 LOCK_X
,精确模式为 LOCK_REC_NOT_GAP
。
按照 data_locks 表中 lock_mode 字段的显示格式是 X,REC_NOT_GAP
。
隐式锁转换为显式锁之后,继续删除 <id = 7>
的记录。
但是,问题又来了,这条记录上现在有显式锁,删除这条记录之后,它上面的锁怎么办呢?
那就是只能顺延了,让它的下一条记录,也就是 supremum 记录继承它的锁。
下一条记录继承锁的时候,只会继承锁模式(LOCK_X
),不会继承精确模式(LOCK_REC_NOT_GAP
),然后加上自己的精确模式(LOCK_GAP
)。
按照 data_lock 表中 lock_mode 字段的显示格式是 X,GAP
。
然而,我们从 data_locks 表中查询出来的加锁情况,supremum 记录的 lock_mode 是 X
,这又是为什么呢?
这是因为 InnoDB 对 supremum 记录做了特殊处理,所有事务对 supremum 记录加锁,不管原来的精确模式是什么,都会被改为 LOCK_ORDINARY
(值为 0),也就是对 supreum 记录加的锁都会变成 Next-Key 锁。
3. row,lock_data = 2、lock_mode = X,REC_NOT_GAP 表示对主键索引中 <id = 2>
的记录加了排他普通记录锁。
回滚操作删除刚刚插入到主键索引中 <id = 7>
的记录之后,insert 语句接下来执行 on duplicate key update 子句的操作,用这个子句中指定的各字段值更新 uniq_i1 的冲突记录对应的表中记录,也就是 <id = 2>
的记录。
更新之前,需要先根据 uniq_i1 的冲突记录中保存的主键字段值,回表读取完整的主键索引记录,也就是读取主键索引中 <id = 2>
的完整记录。
读取记录过程中,需要对主键索引中 <id = 2>
的记录加锁。
按照主键字段值回表查询一条记录,加普通记录锁就可以满足要求。读取记录之后,接下来要更新记录,所以直接加了排他锁。
5. row,lock_data = 7、lock_mode = X,GAP 表示对主键索引中 <id = 7>
的记录加了排他间隙锁。
前面插入 <i1 = 12, id = 7>
的记录到唯一索引 uniq_i1,发现和 uniq_i1 中已有 <i1 = 12, id = 2>
的记录冲突,通过回滚到保存点的操作删除了刚刚插入到主键索引中 <id = 7, i1 = 12, i2 = 220>
的记录。
然后又回表查询了 <id = 2>
的完整记录,准备执行 on duplicate key update 子句的更新操作。
执行更新操作过程中,因为主键字段(id)的值发生了变化(从 2 变为 7),需要先删除主键索引中 <id = 2>
的记录,再插入 <id = 7>
的记录。
插入记录,本来不会显式加锁,示例 SQL 却对新插入的 <id = 7>
的记录加了排他间隙锁。
这是因为 <id = 7>
的记录,插入到主键索引中 <id = 6>
的记录后面,supremum 记录前面,而 supremum 记录被加了排他 Next-Key 锁。
<id = 7>
的记录插入到 supremum 记录前面的间隙,就把这个间隙一分为二了。
为了避免其它事务插入记录到刚刚分裂出来的 <id = 7>
的记录前面的间隙,也需要锁住这个间隙,所以会对 <id = 7>
的记录加间隙锁。
因为是插入操作,所以加了排他锁,综合起来,insert 语句就对新插入的 <id = 7>
的记录加了排他间隙锁。
2. row,lock_data = 13,3、lock_mode = X 表示对唯一索引 uniq_i1 中 <i1 = 13, id = 3>
的记录加了排他 Next-Key 锁。
第二次插入 <id = 7, i1 = 12, i2 = 220>
的记录到主键索引成功之后,接下来插入 <i1 = 12, id = 7>
的记录到唯一索引 uniq_i1 中。
找到插入记录的目标位置(<i1 = 12, id = 2>
的记录后面),insert 语句还是发现这条记录可能和即将插入的新记录冲突。
会再一次进行两项检查,以确认是否真的存在冲突,这个过程中依然要对唯一索引 uniq_i1 中 <i1 = 12, id = 2>
的记录加排他 Next-Key 锁。
加锁过程中,发现之前已经加过同样的锁了,不会重复加锁。然后,发现唯一索引中 <i1 = 12, id = 2>
的记录已经被标记删除,和即将插入的 <i1 = 12, id = 7>
的记录不冲突。
因为唯一索引中允许存在唯一字段值为 NULL 的多条记录,所以,这条记录和新插入记录不冲突,还需要继续检查下一条记录是否和即将插入的记录冲突。
唯一索引 uniq_i1 中 <i1 = 12, id = 2>
的记录的下一条记录,就是 <i1 = 13, id = 3>
的记录。
insert 语句也会对这条记录进行两项检查,检查过程中,会对这条记录加排他 Next-Key 锁。
这就是 insert 语句对唯一索引 uniq_i1 中 <i1 = 13, id = 3>
的记录加排他 Next-Key 锁的原因。
6. row,lock_data = 12,7、lock_mode = X,GAP 表示对唯一索引 uniq_i1 中 <i1 = 12, id = 7>
的记录加了排他间隙锁。
前面对唯一索引 uniq_i1 中 <i1 = 13, id = 3>
的记录进行两项检查之后,发现和即将插入的 <i1 = 12, id = 7>
的记录不冲突,可以继续插入这条记录到唯一索引 uniq_i1 中。
插入记录之前,依然要找到插入记录的目标位置:位于 <i1 = 11, id = 1>
的记录后面,<i1 = 13, id = 3>
的记录前面。
因为 <i1 = 13, id = 3>
的记录被加了排他 Next-Key 锁。插入 <i1 = 12, id = 7>
的记录到这条记录前面,就把它前面的间隙一分为二了。
为了避免其它事务插入记录到刚刚分裂出来的 <i1 = 12, id = 7>
的记录前面的间隙,也需要锁住这个间隙,所以会对 <id = 12, id = 7>
的记录加间隙锁。
因为是插入操作,所以加了排他锁,综合起来,insert 语句就对新插入的 <i1 = 12, id = 7>
的记录加了排他间隙锁。
3. 读已提交
把事务隔离级别设置为 READ-COMMITTED(如已设置,忽略此步骤):
SET transaction_isolation = 'READ-COMMITTED';
-- 确认设置成功
SHOW VARIABLES like 'transaction_isolation';
+-----------------------+----------------+
| Variable_name | Value |
+-----------------------+----------------+
| transaction_isolation | READ-COMMITTED |
+-----------------------+----------------+
执行以下 insert 语句(唯一索引冲突,更新主键字段值):
begin;
insert into t4 (id, i1, i2) values (7, 12, 220)
on duplicate key update id = values(id), i2 = values(i2);
查看加锁情况:
select
engine_transaction_id, object_name, index_name,
lock_type, lock_mode, lock_status, lock_data
from performance_schema.data_locks
where object_name = 't4'
and lock_type = 'RECORD'\G
***************************[ 1. row ]***************************
engine_transaction_id | 250935
object_name | t4
index_name | uniq_i1
lock_type | RECORD
lock_mode | X
lock_status | GRANTED
lock_data | 12, 2
***************************[ 2. row ]***************************
engine_transaction_id | 250935
object_name | t4
index_name | uniq_i1
lock_type | RECORD
lock_mode | X
lock_status | GRANTED
lock_data | 13, 3
***************************[ 3. row ]***************************
engine_transaction_id | 250935
object_name | t4
index_name | PRIMARY
lock_type | RECORD
lock_mode | X,REC_NOT_GAP
lock_status | GRANTED
lock_data | 2
***************************[ 4. row ]***************************
engine_transaction_id | 250935
object_name | t4
index_name | uniq_i1
lock_type | RECORD
lock_mode | X,GAP
lock_status | GRANTED
lock_data | 12, 7
和可重复读隔离级别一样,上面查询出来的加锁情况,不是按照加锁先后面顺序展示的。接下来,我们按照加锁的先后顺序,来看看这些记录的加锁逻辑。
1. row,lock_data = 12,2、lock_mode = X,表示对唯一索引 uniq_i1 中 <i1 = 12, id = 2>
的记录加了排他 Next-Key 锁。
这条记录的加锁逻辑和可重复读隔离级别下一样,这里就不赘述了。
有一点需要说明的是,读已提交隔离级别本来不应该加 Next-Key 锁,这里却加了 Next-Key 锁。
这是因为唯一索引中允许存在唯一字段值为 NULL 的多条记录,确认新记录和表中已有记录是否冲突的过程中,为了避免其它事务插入唯一字段值为 NULL 的记录,所以这里加了 Next-Key 锁。
对于示例 SQL,虽然可能存在冲突的是唯一字段(i1)值等于 12 的记录,但是 InnoDB 没有针对唯一索引字段值是 NULL 或不是 NULL 做不同处理,而是简单粗暴的在这个场景下都加 Next-Key 锁。
以下三条记录的加锁逻辑,和可重复读隔离级别下一样,不赘述了。
3. row,lock_data = 2、lock_mode = X,REC_NOT_GAP 表示对主键索引中 <id = 2>
的记录加了排他普通记录锁。
2. row,lock_data = 13,3、lock_mode = X 表示对唯一索引 uniq_i1 中 <i1 = 13, id = 3>
的记录加了排他 Next-Key 锁。
4. row,lock_data = 12,7、lock_mode = X,GAP 表示对唯一索引 uni1_i1 中 <i1 = 12, id = 7>
的记录加了排他间隙锁。
4. 总结
没有需要总结的内容了。
有一个问题,可重复读隔离级别下,insert 语句对刚刚插入到主键索引中 <id = 7>
的记录加了排他间隙锁,为什么不是加排他 Next-Key 锁?
换句话说,就是为什么只锁定这条记录前面的间隙,而不锁定这条记录本身。
欢迎评论区留言交流~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。