在MySQL的InnoDB引擎中,除了我们熟悉的行锁,还有一种经常被忽视却极易引发问题的锁机制——间隙锁(Gap Lock)。不少线上死锁问题、莫名其妙的插入阻塞,追根溯源都和它有关。理解间隙锁的工作原理,是掌握InnoDB并发控制的关键一步。本文将从加锁原理、不同场景下的加锁表现以及实际使用中的注意事项三个层面,系统讲清楚这个知识点。

一、间隙锁的核心原理:它到底锁住了什么
首先要明确一点:间隙锁锁的不是行记录本身,而是索引记录之间的“区间”。举个例子,假设表中有一个索引字段,当前存在值为10和20两条索引记录,那么这两个值之间就构成一个间隙,即区间(10, 20)。当一个事务持有这个间隙的锁时,其他事务就无法向这个区间内插入任何记录,比如插入值为15的行会被阻塞,但插入值为25的行不受影响。
InnoDB引入间隙锁的根本目的是解决幻读问题。在可重复读(REPEATABLE READ)隔离级别下,单纯给已存在的行加行锁无法阻止其他事务插入新记录——因为新记录根本还不存在,没有行可锁。MySQL的解决方案是把“可能出现新记录的区间”整体锁住,这种行锁加间隙锁的组合就构成了所谓的临键锁(Next-Key Lock),它是InnoDB在RR级别下的默认加锁单位,锁定范围是左开右闭区间,例如(10, 20]。
间隙锁有几个非常特殊的性质需要牢记。第一,间隙锁之间不互斥,两个事务可以同时持有同一个间隙的锁,这与行锁的排他性完全不同;第二,间隙锁只在REPEATABLE READ及以上隔离级别生效,如果数据库运行在READ COMMITTED级别,间隙锁会被直接禁用;第三,间隙锁只阻止插入操作,对普通的SELECT、UPDATE已存在记录等操作没有直接约束。理解了这三点,很多看似诡异的锁冲突现象就能解释通了。
二、不同场景下的加锁行为分析
间隙锁的加锁规则比较复杂,需要结合索引类型和SQL语句形式来分析。下面通过几个典型场景逐一说明。
1. 唯一索引的等值查询
当使用唯一索引进行等值查询且记录存在时,InnoDB会将临键锁退化为纯行锁,不会额外锁定间隙。比如SELECT * FROM t WHERE id = 15 FOR UPDATE,如果id为主键且记录存在,就只锁这一行。但如果查询的记录不存在,情况完全不同:假设表中存在id为10和20的记录,查询id=15时,会锁定间隙(10, 20),此时其他事务插入id为11到19之间的任何值都会被阻塞。
2. 普通索引的等值查询
如果索引不是唯一的,即使记录存在,锁也不会退化为行锁。InnoDB会锁定匹配记录本身及其前后的间隙。例如普通索引列上存在值10、10、20,执行等值查询命中10时,加锁范围是临键锁(负无穷, 10]加上间隙锁(10, 20),这是因为普通索引无法保证唯一性,引擎需要锁住相邻区间来防止幻读。这也是普通索引比唯一索引更容易产生锁冲突的原因。
3. 范围查询的加锁
范围查询会锁定扫描到的所有区间。以SELECT * FROM t WHERE id > 10 AND id < 30 FOR UPDATE为例,假设表中存在id为10、15、20、30的记录,加锁范围为(10, 15]、(15, 20]、(20, 30)三个区间。注意最后一个区间是开区间,因为id=30本身不满足条件。任何向这些区间插入数据的操作都会被挡住。
4. 无索引字段查询
这是最危险的情况。如果查询条件字段上没有任何索引,InnoDB只能全表扫描,效果等同于给整张表的所有间隙和所有行加锁,等于锁全表。许多生产事故正是源于此:一条看似普通的UPDATE语句,因为条件字段没建索引,把整张表的插入操作全部阻塞。来看一段演示代码:
-- 会话1:条件字段 status 没有索引,导致全表加锁
BEGIN;
UPDATE orders SET remark = 'processed' WHERE status = 'pending';
-- 此时整张表所有间隙被锁定
-- 会话2:任何插入操作都会被阻塞
INSERT INTO orders (order_no, status) VALUES ('ORD1001', 'new');
-- 阻塞,直到会话1提交或超时
三、实战使用场景与避坑指南
1. 利用间隙锁实现业务防重
间隙锁并非只会带来麻烦,它在特定场景下非常有用。典型应用是业务防重:比如电商系统中,要求同一用户的秒杀请求只能成功一次。可以在事务中先执行一条命中间隙的锁定查询,再执行插入:
-- 利用间隙锁防止并发插入重复数据 BEGIN; -- 先锁住 user_id=1001 前后的间隙(假设该用户还没有记录) SELECT * FROM seckill_record WHERE user_id = 1001 FOR UPDATE; -- 并发请求会阻塞在这里,避免重复插入 INSERT INTO seckill_record (user_id, sku_id) VALUES (1001, 2001); COMMIT;
这段代码中,第一个会话持有间隙锁后,其他事务执行相同的SELECT会被阻塞,从而保证只有一个插入成功。当然,更简洁的方案是给user_id加唯一索引,利用唯一性约束让重复插入直接报错,性能也更好。
2. 排查和规避锁冲突
当遇到插入被阻塞的问题时,可以通过以下步骤排查。首先执行SHOW ENGINE INNODB STATUS查看最近的死锁信息;再通过information_schema.INNODB_TRX和performance_schema.data_locks(MySQL 8.0)查看当前持锁情况,重点看lock_mode列是否出现GAP字样。
规避间隙锁问题有几个实用建议。第一,确保UPDATE和DELETE语句的条件字段有索引,避免全表加锁;第二,尽量使用唯一索引做等值查询,让锁退化为行锁,缩小锁定范围;第三,如果业务确认不需要防幻读,可以考虑将隔离级别调整为READ COMMITTED,间隙锁自然失效,并发度显著提升,这也是不少互联网公司的实际做法;第四,减少事务中锁的持有时间,把耗时的远程调用移到事务外部,降低死锁概率。
最后要注意,间隙锁和插入意向锁(Insert Intention Lock)是配套关系。事务执行INSERT时会在目标间隙申请插入意向锁,如果该间隙已被其他事务的间隙锁占用,插入就会等待。理解这个机制,再结合SHOW ENGINE INNODB STATUS输出的锁等待信息,绝大多数锁冲突问题都可以定位清楚。掌握间隙锁,本质上就是掌握InnoDB在并发与一致性之间的权衡设计。