大家好,我是十三!欢迎来到十三Tech。
前几年一次死锁排查——两个事务分别处理订单 id=100 和 id=200,看起来互不相关却死锁了。SHOW ENGINE INNODB STATUS 显示 lock wait,但当时看不懂是哪种锁、锁了什么范围。研究了一周才搞懂——根因是其中一个事务走了间隙锁——它锁的不是记录本身,而是记录之间的"空隙",把另一个事务的插入堵死了。
这件事让我重新看 InnoDB 的行锁——很多人讲"行锁"只讲"锁住一行",太浅。InnoDB 行锁实际有三种格式——Record / Gap / Next-Key——理解这三种格式,才能解释"MySQL 行锁为什么不全是锁一行"。
这一篇就聊聊三种格式的差别、加锁规则,以及为什么 SELECT WHERE id = 10 FOR UPDATE 在 RR 下可能锁住 id=15。
一、Record Lock — 锁单条记录
Record Lock 是最直观的行锁——锁定索引上的一条记录。
-- 假设 id 是主键,已有记录 id=10, 20, 30
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 在主键索引上对 id=10 加 Record Lock
这条 SQL 在 id=10 上加 Record Lock——只锁 id=10 这条记录,不影响 id=11, 12... 的插入和修改。
Record Lock 的触发条件:
- 等值查询命中唯一索引(主键 / 唯一索引)——只加 Record Lock,不加 Gap
- 等值查询命中普通索引的"精确记录"——加 Record Lock + 隐式 Next-Key
关键差别——唯一索引等值命中加 Record Lock;非唯一索引等值命中加 Next-Key。原因是唯一索引能保证"这个值最多一条",不需要防幻读;非唯一索引可能有重复值,需要锁间隙防止"再插入相同值"。
-- 表结构:id 主键,age 普通索引
-- 数据:id=10 (age=20), id=20 (age=20), id=30 (age=30)
-- 唯一索引等值 → Record Lock
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 锁住 id=10
-- 非唯一索引等值 → Next-Key Lock
SELECT * FROM t WHERE age = 20 FOR UPDATE;
-- 锁住 (-∞, 10], (10, 20], (20, 30) 三个区间(RR 隔离级别下)
这个差别解释了为什么"明明查的是 age=20 却锁住了 id=10 和 id=30"——非唯一索引的范围加锁是 RR 防幻读的核心机制。
二、Gap Lock — 锁间隙但不锁记录
Gap Lock 锁定的是记录之间的空隙——目的是防止新数据插入到这个间隙里。
Gap Lock 的特点:
- 只阻塞 INSERT,不阻塞其他 Gap Lock——多个事务可以同时持有同一间隙的 Gap Lock
- 只在 RR 隔离级别下生效——RC 下没有 Gap Lock(这是为什么 RC 并发性好)
- 隐式触发——范围查询、非唯一索引等值查询都会隐式加 Gap
-- 数据:id=10, 20, 30
SELECT * FROM t WHERE id BETWEEN 15 AND 25 FOR UPDATE;
-- 锁住 (10, 20), (20, 30) 两个间隙 + 20 这条记录
-- 即 id=15, 16, ..., 19, 21, 22, ..., 29 的插入都会被阻塞
Gap Lock 的"诡异"行为——这是开发最常踩坑的地方:
- 插入 id=15 被阻塞(落在间隙 (10, 20) 内)
- 插入 id=25 被阻塞(落在间隙 (20, 30) 内)
- 插入 id=10 不受影响(不是间隙范围)
- 修改 id=20 不受影响(如果已有 Record Lock 会被阻塞,但单独 Gap Lock 不阻塞修改)
Gap Lock 的"反向"性质——SELECT WHERE id > 10 AND id < 20 FOR UPDATE(间隙里没记录)依然会加 Gap Lock——锁住 (10, 20) 这个空隙,防止其他事务插入 11-19 的值。这就是为什么"明明表里没数据"也能锁住一个范围。
三、Next-Key Lock — Record + Gap
Next-Key Lock 是 InnoDB 在 RR 隔离级别下的默认行锁格式——它是 Record Lock + Gap Lock 的组合,锁住一个前开后闭区间 (a, b]。
-- 数据:id=10, 20, 30
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 实际加锁:(10, 20] 这一个 Next-Key + (10, 20) 这个 Gap(如果命中边界)
-- 等价于锁住:
-- · 修改 id=20 这条记录
-- · 插入 id=11, 12, ..., 19 的任何值
Next-Key Lock 的精妙之处——它让 InnoDB 在 RR 级别下,通过"锁定查询范围 + 前后相邻间隙"防止幻读。这是 InnoDB 实现的 RR 比 SQL 标准 RR 更严格的关键。
Next-Key Lock 的退化为 Record Lock——当唯一索引命中精确值时,Next-Key 退化为 Record Lock(不需要锁间隙,因为唯一索引保证了不会有重复值)。
-- 主键等值命中:退化 Record Lock
SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- 只锁 id=10 这条记录(无 Gap)
-- 主键等值未命中:仍是 Gap Lock
SELECT * FROM t WHERE id = 15 FOR UPDATE;
-- 锁住 (10, 20) 这个间隙(防止 id=15 被插入)
这个"退化"机制是为什么唯一索引的并发性比普通索引好——命中时不加 Gap,不影响相邻记录的插入。
Insert Intention Lock——这是 Insert 操作特殊的"意图锁"。事务 A 持有 (10, 20) 的 Gap Lock 时,事务 B 想 INSERT id=15,会先加 Insert Intention Lock——它表示"我打算在这个间隙插入"。当 A 释放 Gap 后,B 才能继续。这是 InnoDB 优化并发的机制。
四、加锁规则总结
把三种格式的触发条件整理成规则——这是面试常考、生产必懂的核心。
RR 隔离级别下的加锁规则(默认情况下):
| 查询条件 | 索引类型 | 命中 | 加锁 |
|---|---|---|---|
| 等值 | 唯一索引 | 命中 | Record Lock |
| 等值 | 唯一索引 | 未命中 | Gap Lock |
| 等值 | 普通索引 | 命中 | Next-Key + 同值后续记录的 Gap |
| 等值 | 普通索引 | 未命中 | Gap Lock |
| 范围 | 唯一索引 | - | Next-Key(命中部分 + 边界 Gap) |
| 范围 | 普通索引 | - | 多个 Next-Key |
RC 隔离级别下的加锁规则:
- 几乎所有情况只加 Record Lock
- 没有范围查询的 Gap Lock
- 唯一索引和普通索引差别不大(都只锁记录)
怎么知道实际加了什么锁:
-- 8.0+ 用 performance_schema
SELECT * FROM performance_schema.data_locks WHERE OBJECT_NAME = 't';
-- 看锁的类型:RECORD / TABLE
-- 看 LOCK_MODE:S, X, IS, IX, X,GAP, X,REC_NOT_GAP 等
回头看那个死锁案例——根因是 RR 隔离级别下,事务 A 的 SELECT WHERE id BETWEEN 100 AND 200 FOR UPDATE 锁住了 (100, 200] 这个 Next-Key,事务 B 想插入 id=150 被阻塞;同时事务 B 持有另一个范围的 Next-Key,事务 A 也想插入新值——形成循环等待。
行锁格式真正教会我的,不是"三种锁名词",而是锁是 InnoDB 在"严格隔离"和"高并发"之间的精妙平衡——Record Lock 锁最少但只适用于唯一索引;Gap Lock 防幻读但容易误伤;Next-Key 是组合拳但范围难预测。理解三种格式,才能从 lock wait 里读懂"为什么被锁"。
下一篇讲间隙锁深入——专门讲 Gap Lock 的"诡异"行为和减轻影响的方法。
关于十三Tech 资深服务端研发,AI 实践者,专注分享真实可落地的技术经验。 相信 AI 是程序员的最佳搭档。
联系方式:569893882@qq.com GitHub:@TriTechAI
