在使用 MySQL(InnoDB 引擎)时,你是否遇到过这样的灵异事件:
明明只是删除了一行不存在的数据,却导致另一个事务的插入操作被阻塞?
或者明明两个事务操作的是不同的行,却报了 Deadlock found when trying to get lock?
这背后的“幽灵”,往往就是——间隙锁(Gap Lock)。
今天我们就来扒一扒间隙锁的“前世今生”,以及在生产环境中可能遇到的那些“坑”。
📅 第一部分:前世——它是为了解决什么而生?
要理解间隙锁,必须先回到数据库的隔离级别。
在 SQL 标准中,可重复读(Repeatable Read, RR) 隔离级别通常只需要保证“在同一个事务内,多次读取同一行数据,结果是一样的”。它并不要求解决**幻读(Phantom Read)**问题。
什么是幻读?
假设表中只有 id=1 和 id=5 两条记录。
- 事务 A 查询
select * from table where id > 1,看到了id=5。 - 事务 B 此时插入了一条
id=3的数据并提交。 - 事务 A 再次查询
select * from table where id > 1,结果看到了id=3和id=5。
凭空“变”出了一条数据,这就是幻读。
InnoDB 的野心
MySQL 的 InnoDB 引擎比较“卷”,它决定在 RR 隔离级别下,就把幻读问题解决掉。
怎么解决?
如果只锁住 id=5 这一行(行锁),是挡不住别人插入 id=3 的。
于是,间隙锁(Gap Lock) 诞生了。它的使命非常纯粹:锁住两条记录之间的空隙,防止其他事务在这个空隙中“加塞”(Insert)数据。
🛠 第二部分:今生——它到底锁住了哪里?
在 InnoDB 的 RR 级别下,间隙锁通常和行锁结合使用,合称为 Next-Key Lock(临键锁)。
锁的范围图解
假设我们有一个表 t,id 是主键,现有数据:5, 10, 15, 20。
这些数据将数轴切分成了以下几个区间:
- (-∞, 5]
- (5, 10]
- (10, 15]
- (15, 20]
- (20, +∞)
触发间隙锁的三种典型场景
1. 唯一索引的“等值查询”,但记录不存在(纯粹的间隙锁)
执行 DELETE FROM t WHERE id = 7;
- 现状:
id=7不存在,它落在(5, 10)这个区间。 - 结果:InnoDB 会加上一个 Gap Lock,锁住
(5, 10)。 - 影响:此时,任何试图插入
id=6,id=8,id=9的操作都会被阻塞,直到 delete 事务提交。
2. 普通索引(非唯一)的“等值查询”(Next-Key Lock)
假设 col 是普通索引,且有一行 col=10。
执行 SELECT * FROM t WHERE col = 10 FOR UPDATE;
- 机制:为了防止幻读(比如别人再插一个 col=10),InnoDB 必须锁住
col=10及其前后的间隙。 - 结果:它会锁住
(5, 10](Next-Key Lock)以及(10, 15)(Gap Lock)。
3. 范围查询
执行 SELECT * FROM t WHERE id > 15 FOR UPDATE;
- 结果:锁住
(15, 20]和(20, +∞)。
💣 第三部分:那些年踩过的坑(避坑指南)
间隙锁虽然保证了数据一致性,但它也是生产环境中**死锁(Deadlock)**的频发地带。
坑位一:间隙锁是“共享”的,但插入意向锁是“独占”的
这是最违反直觉的一点:两个事务可以同时持有同一个间隙锁。
-
场景:
- 事务 A 执行
DELETE FROM t WHERE id = 7(记录不存在)。A 获得了(5, 10)的间隙锁。 - 事务 B 执行
SELECT * FROM t WHERE id = 8 FOR UPDATE(记录也不存在)。B 也能获得(5, 10)的间隙锁。 - 结果:A 和 B 相安无事,互不阻塞。
- 事务 A 执行
-
真正的危机:
- 此时,事务 A 想执行
INSERT INTO t (id) VALUES (7)。它发现 B 持有间隙锁,于是 A 被阻塞,等待 B 释放。 - 接着,事务 B 想执行
INSERT INTO t (id) VALUES (8)。它发现 A 持有间隙锁,于是 B 被阻塞,等待 A 释放。 - BOOM!死锁发生。 MySQL 回滚其中一个事务。
- 此时,事务 A 想执行
启示:不要认为
SELECT ... FOR UPDATE或DELETE不存在的记录是无害的,它们埋下了死锁的种子。
坑位二:全表被锁(无索引或索引失效)
如果你的 DELETE 或 UPDATE 语句没有走索引:DELETE FROM t WHERE non_indexed_col = 'abc';
- 后果:InnoDB 无法判断哪些行需要锁定,为了安全起见,它会扫描全表,并锁住所有的记录和所有的间隙。
- 现象:整张表基本无法写入,相当于被锁死了。生产环境发生这种情况通常就是 P0 级事故。
坑位三:LIMIT 语句的误导
你可能以为加上 LIMIT 就能减小锁范围:DELETE FROM t WHERE col = 10 LIMIT 1;
虽然 LIMIT 减少了被删除的行数,但在加锁阶段,如果索引利用不当,MySQL 依然可能申请比你想象中更大的间隙锁范围。
🛡️ 总结与建议
间隙锁是 InnoDB 在 RR 级别下为了数据准确性而付出的代价(牺牲并发换取一致性)。要避免被它“坑”到,建议遵循以下原则:
- 尽量使用主键或唯一索引进行更新和删除操作。如果是精确匹配且记录存在,间隙锁就会退化为行锁,并发性能最好。
- 监控死锁日志:使用
SHOW ENGINE INNODB STATUS查看死锁详情,如果发现大量lock_mode X locks gap before rec字样,基本就是间隙锁惹的祸。 - 考虑隔离级别:如果业务允许(不需要解决幻读),可以将隔离级别调整为 Read Committed (RC)。在 RC 级别下,默认没有间隙锁(外键检查等特殊情况除外),这能极大地减少死锁概率。
- 避免长事务:事务越长,持有锁的时间越长,发生冲突的概率呈指数级上升。
希望这篇文章能帮你彻底看清“间隙锁”的真面目。下次再遇到奇怪的阻塞问题,不妨问问自己:“是不是掉进间隙里了?”