欢迎光临
我们一直在努力

13 面试官:MySQL 行锁、间隙锁、临键锁,到底怎么区分的?90% 的人背了三种锁的名字,却说不清谁防了幻读

摘要:本文深入剖析 MySQL InnoDB 存储引擎的三种核心锁机制——行锁、间隙锁和临键锁。从源码层面解读 lock0lock.cc 中锁的实现原理,揭示锁的本质是加在索引记录而非数据行上。通过 bitmask 设计、lock_rec_lock() 快速路径与慢路径、锁冲突矩阵等细节,阐明临键锁如何成为 RR 隔离级别下防幻读的默认机制。结合实战场景对比不同索引类型下的锁范围差异,并给出面试标准回答模板,帮助读者彻底理解 InnoDB 锁的工作机制与性能影响。

面到 MySQL 锁这块的时候,我一定从三种锁开始问。不是因为它们难——是因为它们太容易被背混。

候选人分两种。"背书型"说:行锁锁一行、间隙锁锁区间、临键锁是行锁加间隙锁——背得一字不差,但追问一句"写一条 SELECT … FOR UPDATE,InnoDB 到底加哪种锁?什么时候加行锁,什么时候升级成临键锁?"多半答不上来。"真干过型"直接从源码讲 lockreclock 的 mode 参数是怎么拼出来的。

今天我们不背八股文,打开 InnoDB 源码从 lock0lock.cc 一行行看三种锁到底怎么生出来的,谁防了幻读,谁没防住。


前置认知:锁的是索引记录,不是数据行

很多人以为"行锁"就是锁住磁盘上那一行数据——错了。InnoDB 数据存在聚簇索引的 B+ 树叶子节点里,锁的本质是在索引记录上加锁。这意味着:没有走索引全表扫描,InnoDB 会锁所有扫描到的聚簇索引记录等同于表锁;走辅助索引时会锁辅助索引同时回表锁聚簇索引对应记录;间隙锁更是直接依赖索引记录的相邻关系——没索引间隙根本不存在。

补充一个反直觉的细节:即使你 SELECT … WHERE name = 'abc' FOR UPDATE 并且 name 列有索引,InnoDB 不光锁辅助索引上的记录,还会回表锁聚簇索引上对应的记录。因为更新数据最终发生在聚簇索引上,不锁聚簇索引等于没锁。所以如果你的查询走了覆盖索引(不用回表),锁的范围会小很多——这是一个被大多数开发忽视的性能优化点:加锁查询尽量走覆盖索引。

面试官追问"为什么辅助索引上的锁还不够"——InnoDB 的 MVCC 依赖聚簇索引上的 undo 指针链,修改任何一行最终写入聚簇索引的 trxid 和 rollptr。如果只锁辅助索引不锁聚簇索引,另一个事务可以通过主键直接修改同一行数据绕过你的锁。这就是为什么回表锁是必须的。


三种锁的"身份证号"

在 include/lock0types.h 里用 bitmask 区分:

#define LOCK_GAP 512 // 间隙锁:锁记录前的间隙
#define LOCK_REC_NOT_GAP 1024 // 行锁:只锁这一条记录
#define LOCK_INSERT_INTENTION 2048 // 插入意向锁
// LOCK_ORDINARY = 0 → 临键锁(默认!)

这个设计极其精妙——不主动指定 LOCKGAP 或 LOCKRECNOTGAP,默认加的就是临键锁。一种缺省值覆盖了 InnoDB RR 级别下最常用的行为。

为什么用 bitmask 不用 enum? 因为这三种锁不是互斥的——临键锁本质上是"间隙锁 + 行锁"的组合。用 bitmask 可以通过按位或直接组合:LOCKGAP | LOCKRECNOTGAP 理论上是临键锁,但 InnoDB 的设计更聪明——临键锁就是 mode=0,两个位都不设置意味着同时具备 GAP 和 NOTGAP 语义。这种方案下所有锁模式判断都是位运算:if (lock->typemode & LOCKGAP) 判断有没有间隙锁成分,if (lock->typemode & LOCKRECNOT_GAP) 判断有没有行锁成分。用 enum 做互斥值得到处写 if-else,bitmask 让 InnoDB 的锁系统在高并发下每次冲突检查都是 O(1) 位运算——这就是为什么 InnoDB 能在几千并发下撑住。


lockreclock() 源码逐行拆解

lockreclock 中先快速检查:当前事务是不是已经持有这个位置更高级别的锁了?如果手里已经有 X 锁再请求 S 锁就直接返回成功。这段逻辑在源码里大概长这样:

// lock0lock.cc 简化版
if (trx->lock.wait_lock != NULL) {
// 当前事务已经在等锁,不能重复请求
return DB_LOCK_WAIT;
}
if (lock_rec_has_expl(lock_rec_t* lock, ulint heap_no, trx)) {
// 事务已经持有同位置足够强的锁,快速返回
return DB_SUCCESS;
}
// 否则走慢路径
err = lock_rec_lock_slow(impl, mode, block, heap_no, index, thr);

快速路径的价值被很多人低估。 在 OLTP 场景里,同一个事务反复操作同一个范围内的记录(比如批量更新同一批订单状态),每次 lockreclock 都会命中快速路径——这意味着后续加锁操作几乎没有额外开销。这就是为什么 InnoDB 在高并发下表现优异的原因之一:锁复用,不用每次重新创建锁结构体。面试的时候如果能说出来"快速路径用的是 bitmap 判断,O(1) 复杂度",面试官知道你看过源码。

慢路径里 lockreclockslow 创建锁对象后由 lockrechasto_wait 做冲突判断。冲突判断的核心是一个位运算查表——锁模式按位运算后查冲突矩阵:

注意这个矩阵里有一个很容易搞混的地方:S 锁和 S+GAP 锁在矩阵里都兼容——两个事务可以在同一条记录上同时持有 S 间隙锁。但 X+GAP 和 S 之间不兼容——因为有 X 成分所以锁强度更高。这意味着一个 SELECT … LOCK IN SHARE MODE 事务可以顺利地在已被间隙锁保护的区间里读,只会在遇到 X 锁时被阻塞。这个矩阵一旦搞反了排查死锁就懵了。

这里有一个反直觉的设计:间隙锁和间隙锁之间不互斥。 两个事务可以同时在 (10,20) 这个区间上持有间隙锁——因为间隙锁的目的是防止别人往里面插记录,不是防止别人也拿间隙锁。真正被挡住的是带了 LOCKINSERTINTENTION 的插入操作。这种设计避免了两个并发 SELECT … FOR UPDATE 在同一个区间互相阻塞——只有真正要插入数据的那个事务才需要等间隙锁释放。

这就解释了为什么 RR 级别下不会发生死锁在间隙锁上——两个读事务可以共存,只有某个事务想插入的时候才会卡住。但如果你在大事务里先 SELECT … FOR UPDATE 了范围然后又 INSERT,可能会被另一个人也在等插入意向锁给堵死。这是 RR 级别下最常见的死锁场景。


插入意向锁:被误解最多的锁类型

很多人以为"插入意向锁"是一种独立的锁类型——其实它是间隙锁的一种特殊变体。它加了 LOCKGAP | LOCKINSERT_INTENTION 两个位。它不阻塞任何其他插入意向锁(多个事务可以同时在同间隙上持有插入意向锁等待插入),但它会被间隙锁阻塞。

这个设计解决了什么?假设三个事务同时插入 id=25,如果没有插入意向锁的"互相不阻塞"机制,三个插入会在间隙锁上排队,并发性能一塌糊涂。有了插入意向锁后,三个插入可以同时在同间隙上等待——一旦间隙锁释放,第一个醒来的插入成功,后面的会看到记录已存在、重新定位间隙再尝试。这就是"插入不互斥、间隙锁释放后争抢"的并发模型。

面试的时候说清楚这个,比你背"三种锁的区别"加分多得多。线上遇到 INSERT 卡住但 SHOW ENGINE INNODB STATUS 看不到锁等待,大概率是插入意向锁在等间隙锁——查 performanceschema.datalocks 的 LOCKMODE 字段里有 INSERTINTENTION 就是。


三种锁实战

表数据(主键索引):id=10, 20, 30

纯行锁:SELECT * FROM t WHERE id = 20 FOR UPDATE——等值查询命中唯一索引,InnoDB 不需要锁间隙,因为唯一索引保证了不可能有第二行 id=20 的记录。mode=LOCKX|LOCKRECNOTGAP,只锁 id=20 这一行。

临键锁:SELECT * FROM t WHERE id = 25 FOR UPDATE——25 不存在。InnoDB 在 B+ 树上找 25,定位到 id=20 和 id=30 之间,知道"这条记录不存在但我必须保证在我提交之前它不会突然出现"——这就是幻读的威胁。于是在下一条实际存在的记录 id=30 上加临键锁,锁住 (20,30) 区间加 id=30 本身。别人的 INSERT 25 被挡(间隙锁)、UPDATE 30 被挡(行锁)。

间隙锁:SELECT * FROM t WHERE id > 10 AND id < 20 FOR UPDATE——范围查询没命中任何记录。只在边界 id=20 上加 LOCK_GAP,锁住 (10,20) 区间不锁 20 本身。

再加一个容易搞混的场景:等值查询走普通索引(非唯一索引),比如 SELECT * FROM t WHERE name = 'abc' FOR UPDATE,name 是普通索引。InnoDB 不光在 name='abc' 上加临键锁,还锁住了辅助索引上下一条记录之间的间隙。因为普通索引可以重复,必须防止别人在 name='abc' 的间隙里插入新的 'abc' 记录。这就是为什么非唯一索引的等值查询锁范围比唯一索引大得多的原因。我见过有人线上把唯一索引删了改普通索引——结果锁范围暴增,吞吐量直接从 5000QPS 掉到 200。原因很简单:唯一索引下一条等值查询只锁一行,普通索引下同一条查询锁了一页。索引设计直接影响锁粒度,这不是八股文,是血的教训。


谁防了幻读?

锁类型防幻读?原理
行锁 只锁一条记录,别人可以在旁边插入新行
间隙锁 插入需要先申请意向锁,被间隙锁挡住
临键锁 间隙部分防插入,记录部分防更新

临键锁是 RR 级别下的默认行为,两个降级条件:等值查询命中唯一索引→降为行锁(唯一约束杜绝了幻读可能);等值查询未命中任何记录→降为纯间隙锁(不存在的记录没必要锁记录部分)。

注意一个细节:幻读的定义是"同一个事务内两次相同查询返回的结果集不同"。临键锁通过锁住"记录 + 记录前的间隙"来保证在你提交之前不会有新记录出现在你查询过的范围里。但如果你查询的是 WHERE id > 100,最后一条记录之后还有一个正无穷的间隙——InnoDB 会在"上确界伪记录"(supremum pseudo-record)上加锁来保护这个尾间隙。这个 supremem 记录是 B+ 树里的一个特殊哨兵,你通过 SELECT * FROM performanceschema.datalocks 可以看到 LOCK_DATA = 'supremum pseudo-record',这就是 InnoDB 在告诉你"我锁住了这个区间后面的所有空间"。面试偶尔被问到"范围查询的上界怎么防",能说出 supremem pseudo-record 说明你真的看过锁监控数据。


🎯 面试标准回答

"InnoDB 的锁是加在 B+ 树索引记录上的,由 lockreclock 的 mode 位掩码决定类型——LOCK_ORDINARY(临键锁)是缺省值。RR 级别下任何加锁查询默认使用临键锁,锁记录本身及其紧前间隙。有两个降级场景:等值命中唯一索引降为行锁,等值未命中降为纯间隙锁。间隙锁之间不互斥——两个事务可以在同一个间隙上同时持有间隙锁,但任何一个间隙锁都会阻止后续的插入操作,因为插入需要申请插入意向锁,插入意向锁与间隙锁冲突但插入意向锁之间不互斥。这保证了'读-读不互斥,插入被排队'的并发模型。等值查询走普通索引时临键锁会额外锁住辅助索引上相邻记录间的间隙,锁范围比唯一索引大很多——这也是索引设计直接影响锁粒度的典型例子。行锁不防幻读,临键锁和间隙锁防。RC 级别不加间隙锁自然不存在幻读保护,但并发性能更高。"


聊到这儿三种锁的本质应该门清了。但你可能已经注意到——RC 隔离级别为什么没幻读风险反而小?因为 RC 根本不加间隙锁,每次语句都会重建 ReadView,读到的是最新已提交数据。代价是可能出现不可重复读和幻读,但并发性能远高于 RR。如果你的业务不需要严格的一致性保证,RC + 行锁的组合在性能上甩 RR + 临键锁一条街。

我线上把一个报表查询从 RR 切到 RC,SELECT … FOR UPDATE 在 RR 下锁了三页数据,RC 下只锁了 5 行——锁等待从 3 秒降到 50 毫秒。不是 RC 比 RR 好,是你的索引和场景决定用哪个级别。

下一篇拆 MVCC——ReadView 怎么生成的,undo 日志链怎么遍历,"快照读 vs 当前读"到底差在哪。

私信回复「666」,一次性领走:

面试宝典:Java 高频考点速查表、HashMap/ConcurrentHashMap 源码笔记、JVM 调优案例、Spring Boot 面试 50 问

AI 编程工具箱:Cursor/Copilot/Codex 六工具对比表、10 个 Prompt 模板、Debug 万能公式、Cursor 速查手册、AI 图片生成入门、30+ 效率工具包

一份资料包,两个专栏都能用。「唠点键盘之外的」,只讲干货。

赞(0)
未经允许不得转载:171主机测评 » 13 面试官:MySQL 行锁、间隙锁、临键锁,到底怎么区分的?90% 的人背了三种锁的名字,却说不清谁防了幻读
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址