大家好,我是十三!欢迎来到十三Tech。

前几年一次诡异的死锁——两个事务分别处理订单号 1234 和 5678,明明完全不冲突却死锁了。DBA 看了 SHOW ENGINE INNODB STATUS 半天找不到原因,因为死锁图里没有"等对方的记录",只有奇怪的"lock structs"。最后是 MySQL 内核专家朋友帮忙看的——根因是其中一个事务的 SELECT 没命中任何记录,但 RR 隔离级别下还是加了间隙锁,把另一个事务的 INSERT 堵死了。

这件事让我彻底重新理解间隙锁——它"防幻读"的功能听着美好,但实际行为很反直觉:表里没数据也能锁住范围、两个事务插入完全不同的 ID 也能死锁、范围查询的间隙比你想的大得多。这一篇就聊聊间隙锁的"诡异"行为、死锁原理,以及生产中怎么减轻它的影响。

间隙锁的反直觉行为

一、间隙锁的四个反直觉行为

讲完原理(A11),这一篇专门讲"为什么 Gap Lock 这么坑"——因为它的实际行为违反工程师直觉。

反直觉一:表里没数据也能锁住一个范围

-- 空表
CREATE TABLE t (id INT PRIMARY KEY);
SELECT * FROM t WHERE id = 100 FOR UPDATE;
-- 锁住 "supremum pseudo-record" —— 整个 (-∞, +∞) 间隙

这种"查不到也加锁"的行为,让很多开发困惑——为什么 SELECT WHERE id=100 影响了 INSERT id=200?因为空表的间隙是整个范围。

反直觉二:等值查询不命中也加间隙锁

-- 数据:id=10, 20, 30
SELECT * FROM t WHERE id = 15 FOR UPDATE;
-- 不命中,但加 Gap Lock 锁住 (10, 20) 间隙
-- 其他事务插入 id=11, 12, ..., 19 都被阻塞

"我没查到也没改数据,为什么锁住别人"——开发最常见的吐槽。原因是 InnoDB 要防止其他事务在你之后插入 id=15——你下次同样查询会幻读。

反直觉三:范围查询的间隙比预期大

-- 数据:id=10, 20, 30, 40, 50
SELECT * FROM t WHERE id BETWEEN 20 AND 40 FOR UPDATE;
-- 实际锁住:(10, 20], (20, 30], (30, 40], (40, 50)
-- 即从 id=11 到 id=49 全部插入都被阻塞

"我只查 20-40,为什么锁到 11 和 49"——因为 Next-Key Lock 的"前开后闭"特性,加上边界外的 Gap Lock。

反直觉四:唯一索引和普通索引行为完全不同

-- 假设 id 是主键,age 是普通索引
-- 数据:id=10 (age=20), id=20 (age=20), id=30 (age=30)

-- 主键等值命中:只锁 id=10
SELECT * FROM t WHERE id = 10 FOR UPDATE;

-- 普通索引等值命中:锁住 age=20 的两条记录 + 相邻间隙
SELECT * FROM t WHERE age = 20 FOR UPDATE;
-- 锁住 (-∞, 10], (10, 20], (20, 30) 三个区间

同样的等值查询,因为索引类型不同,加锁范围天差地别。

二、间隙锁导致的"诡异"死锁

讲完反直觉行为,再说最让人崩溃的——间隙锁引发的"诡异"死锁

经典死锁场景一:两个事务插入不同的 ID

-- 表 t 有 id=10 和 id=20,事务 A 和 B 分别想插入 id=15

事务 A:                                事务 B:
SELECT * FROM t                          SELECT * FROM t
  WHERE id = 15 FOR UPDATE;              (锁住 (10, 20) Gap)  WHERE id = 15 FOR UPDATE;
                                                           (锁住 (10, 20) Gap,不冲突!)
INSERT INTO t VALUES (15);             INSERT INTO t VALUES (15);
(被自己的 Gap 阻塞?不,被对方的 Gap)  (被对方的 Gap 阻塞!)
                                        ↑
              互相等待 → 死锁!

关键点:SELECT FOR UPDATE 在等值未命中时只加 Gap Lock,Gap Lock 之间是兼容的(多个事务可以同时持有同一 Gap)。但 INSERT 需要 Insert Intention Lock——它在 Gap Lock 上是不兼容的。所以 A 的 Gap 不阻塞 B 的 Gap,但 A 的 INSERT 被 B 的 Gap 阻塞,反之亦然——死锁。

经典死锁场景二:范围更新的"边界交错"

事务 A:UPDATE t SET ... WHERE id BETWEEN 10 AND 20;
事务 B:UPDATE t SET ... WHERE id BETWEEN 15 AND 25;

事务 A 先锁 (10, 20] + (20, 30) Gap
事务 B 同时锁 (10, 20] + (20, 30] + (30, 40) Gap
如果顺序不对 → 死锁

避免间隙锁死锁的方法

  1. 缩短事务——锁持有时间越短,冲突机会越少
  2. 降低隔离级别——RC 没有 Gap Lock,大量"诡异"死锁消失
  3. 用唯一索引——唯一索引等值命中只加 Record Lock,不加 Gap
  4. 顺序加锁——业务里约定"先低后高"等顺序
  5. catch deadlocks 并重试——这是兜底,生产必须做

间隙锁导致的死锁场景

三、怎么减轻间隙锁影响

讲完坑,再说生产中怎么应对——这是工程师真正要做的决定。

方案一:改 RC 隔离级别。RC 没有 Gap Lock——这是最彻底的解决方案。代价是失去"事务内可重复读"和"间隙防幻读"——但 80% 的业务实际不需要这些。很多大厂(如阿里巴巴、美团)MySQL 默认就是 RC

方案二:用唯一索引代替普通索引。唯一索引等值命中只加 Record Lock——锁范围小很多。生产中"防重表"用唯一索引就是这个道理。

方案三:缩小范围查询的边界

-- 坏:范围太大,锁住很多间隙
SELECT * FROM t WHERE create_time > '2026-01-01' FOR UPDATE;

-- 好:精确到小时,缩小锁范围
SELECT * FROM t
  WHERE create_time >= '2026-06-16 10:00:00'
  AND create_time < '2026-06-16 11:00:00' FOR UPDATE;

方案四:缩短事务时间。这是通用建议——任何锁问题都能从"短事务"中受益。生产中:

  • 不要在事务里调用外部服务(HTTP、RPC)
  • 不要在事务里做复杂计算
  • 业务设计上把"读很多"和"改一点"拆开

方案五:catch + retry。死锁是 MySQL 的"正常"机制——它会回滚代价小的事务。应用层应该捕获 Deadlock found when trying to get lock; try restarting transaction(错误码 1213),重试整个事务。这是兜底,不能省

监控指标

  • Innodb_row_lock_waits:行锁等待次数
  • Innodb_row_lock_time_avg:平均等待时间
  • SHOW ENGINE INNODB STATUS 里的 LATEST DETECTED DEADLOCK

减轻间隙锁影响的方案对比

回头看那个诡异死锁——根因是 RR 隔离级别 + 等值未命中查询 + 并发 INSERT。修复方法:业务里改用"先查后判"的方式替代 SELECT FOR UPDATE,并降低隔离级别到 RC,问题彻底消失。

间隙锁真正教会我的,不是"防幻读"这个概念,而是所有"高级特性"都有代价——间隙锁让 RR 防幻读,代价是死锁概率升高、并发性降低、行为反直觉。InnoDB 把选择权交给业务——RR 安全但慢、RC 快但需要业务能容忍——这个权衡每个项目都要做一次。

下一篇讲死锁排查——SHOW ENGINE INNODB STATUS 怎么读、innodb_lock_monitor 怎么开、生产中怎么定位"为什么我的事务被回滚了"。


关于十三Tech 资深服务端研发,AI 实践者,专注分享真实可落地的技术经验。 相信 AI 是程序员的最佳搭档。

联系方式:569893882@qq.com GitHub:@TriTechAI