深度解析MySQL 锁机制:间隙锁、Next-Key Lock 与幻读防御
作者:知识即是力量ol
导语: 当你在 MySQL 中执行一条
UPDATE、DELETE或SELECT ... FOR UPDATE时,数据库锁住的远不止你"看到"的那几行记录——它还会把记录之间的"空地"一并封锁。这片空地就叫间隙(Gap),而锁住它的机制,叫做 Gap Lock(间隙锁) 与 Next-Key Lock(临键锁)。本文将从"为什么要锁间隙"出发,一路追到"为什么是左开右闭",把这套机制彻底讲透。
一、从一个问题出发:为什么不只锁行?
在正式讲间隙锁之前,先理解一个核心问题:MySQL 为什么不只锁住被操作的那几行记录,而要额外锁住行与行之间的空隙?
答案只有一个词:防止幻读(Phantom Read)。
什么是幻读?
幻读是指在同一个事务内,两次执行相同的范围查询,第二次的结果比第一次多出了几行——就好像凭空出现了"幽灵记录"一样。
来看一个具体的例子。假设有一张账户表,事务 A 执行了如下更新:
UPDATE accounts SET status = 'frozen' WHERE balance < 100;
如果 MySQL 只锁住当前满足 balance < 100 的那几行,而没有锁住间隙,那么:
- 事务 A 正在扫描并更新符合条件的记录。
- 事务 B 趁机插入了一条新记录:
balance = 50。 - 事务 A 更新完成并提交。
结果: 事务 A 明明说要把所有余额小于 100 的账户都冻结,却偏偏漏掉了事务 B 刚插进来的那一行。这条漏网之鱼,就是幻读的典型表现。
为了在可重复读(Repeatable Read) 隔离级别下彻底消灭幻读,InnoDB 不得不把整个 balance < 100 的搜索空间全部封锁,不允许任何新记录插入其中。这便是间隙锁存在的根本原因。
二、三种锁的概念厘清
在深入讲解之前,我们先把三个容易混淆的概念理清楚:
| 锁类型 | 英文名 | 锁的对象 | 作用 |
|---|---|---|---|
| 记录锁 | Record Lock | 索引上的某一条具体记录 | 防止其他事务修改或删除这条记录 |
| 间隙锁 | Gap Lock | 两条记录之间的空白间隙 | 防止其他事务在这个间隙内插入新记录 |
| 临键锁 | Next-Key Lock | 间隙 + 右侧那条记录 | 记录锁 + 间隙锁的组合,是 InnoDB 默认的行锁形式 |
Next-Key Lock = Gap Lock + Record Lock,这是理解本文的核心公式。
三、唯一索引的特殊待遇:为什么它可以只用记录锁?
在讲间隙锁的全貌之前,有必要先说明一个重要的例外情况。
当且仅当同时满足以下两个条件时,InnoDB 会将 Next-Key Lock 降级为纯粹的 Record Lock,不加间隙锁:
- 搜索条件命中的是唯一索引或主键。
- 搜索方式是等值查询(使用
=),而不是范围查询。
例如:
-- 这是唯一搜索,只加记录锁,不加间隙锁 SELECT * FROM user WHERE id = 10 FOR UPDATE;
为什么唯一索引可以享受这种特殊待遇?
因为唯一索引从定义上保证了 id = 10 这个值在整张表里永远只能存在一行。只要把这一行锁住,别人就不可能再插入另一个 id = 10 的记录进来——幻读在这里根本就不可能发生,自然也就不需要锁间隙了。
反之,以下所有情况都无法享受这种降级,必须使用 Next-Key Lock:
| 搜索类型 | 示例语句 | 索引类型 | 锁的行为 |
|---|---|---|---|
| 唯一搜索(例外) | WHERE id = 10 | 主键/唯一索引 | 仅记录锁(Record Lock) |
| 普通索引搜索 | WHERE age = 25 | 普通索引 | 临键锁(Next-Key Lock) |
| 范围搜索 | WHERE id > 10 | 任何索引 | 临键锁(Next-Key Lock) |
| 未命中记录 | WHERE id = 11(11不存在) | 唯一索引 | 间隙锁(Gap Lock) |
四、UPDATE 和 DELETE 也会触发范围锁
很多人以为只有显式加锁的 SELECT ... FOR UPDATE 才会触发间隙锁,其实 UPDATE 和 DELETE 同样会触发,而且行为完全一致。
这是因为 UPDATE 和 DELETE 在底层隐含了一个 SELECT ... FOR UPDATE 的过程——它们必须先通过当前读拿到最新的行数据,才能在此基础上进行修改或删除。
来看一个直观的例子:
-- 这条语句会锁住 balance < 100 范围内所有记录和间隙 UPDATE accounts SET status = 'frozen' WHERE balance < 100;
执行这条语句时,InnoDB 会同时完成两件事:
- 行锁(Record Lock): 锁住所有当前
balance < 100的记录,防止其他事务修改或删除它们。 - 间隙锁(Gap Lock): 锁住这些记录之间以及边界外的空隙,防止其他事务往这个范围内插入新记录。
两者合在一起,就是 Next-Key Lock,对整个搜索范围实施了"全面封锁"。
大范围锁的代价
理解了这一点,也就理解了为什么大范围的 UPDATE 或 DELETE 是高并发场景下的危险操作:
死锁风险: 两个事务若尝试以不同顺序锁定重叠的间隙,极易发生死锁。
并发下降: 大范围锁会让其他想往这个范围内插入数据的业务线程全部进入 Lock Wait 状态,系统响应急剧变慢。
最佳实践: 在进行 UPDATE 或 DELETE 时,尽量通过主键 ID 进行操作。通过唯一主键只会触发记录锁,不会阻塞整个范围的插入,性能最优。
五、Next-Key Lock 如何划分区间?
现在我们来看 InnoDB 是如何将整条索引轴线划分为一段段"封锁区域"的。
区间的形成规则
每一条索引记录都是一个"锚点",记录与记录之间自然形成间隙。InnoDB 会像切香肠一样,沿着索引轴线把整个空间划分为若干个左开右闭的区间。
假设索引中存在以下值:10, 11, 13, 20,InnoDB 会划出如下区间:
(−∞, 10] (10, 11] (11, 13] (13, 20] (20, +∞)
每个区间的含义如下:
| 区间 | 锁定内容 |
|---|---|
(−∞, 10] | 锁住所有小于 10 的插入位置,以及 10 这条记录本身 |
(10, 11] | 锁住 10 到 11 之间的空隙(禁止插入 10.x),以及 11 这条记录本身 |
(11, 13] | 锁住 11 到 13 之间的空隙(禁止插入 12),以及 13 这条记录本身 |
(13, 20] | 锁住 13 到 20 之间的空隙(禁止插入如 15、18),以及 20 这条记录本身 |
(20, +∞) | 锁住所有大于 20 的插入位置(无上界) |
整条索引轴线被无缝、无重叠地分割成了这五段,任何一个插入位置都必然且唯一地落在某一段中。
特殊的最右侧区间
最右侧的区间 (20, +∞) 看起来是一个"开区间",与其他区间的左开右闭形式似乎不一致。其实在 InnoDB 内部,它同样是左开右闭的:(20, Supremum]。
InnoDB 在每一棵 B+ 树索引的内部都维护着两个虚拟的边界伪记录:
- Infimum:比所有记录都小的虚拟最小值。
- Supremum:比所有记录都大的虚拟最大值。
由于 Supremum 是用户无法感知的虚拟概念,所以这个区间在表现上就像一个向右无限延伸的开区间 (20, +∞)。这意味着,如果你执行了 WHERE id > 20 FOR UPDATE,那么所有大于 20 的新数据(如 21、100、1000……)全部无法插入。
一个实战案例
假设表中只有 id = 11 和 id = 13 两条记录,你执行了:
-- id = 12 在表中不存在 SELECT * FROM user WHERE id = 12 FOR UPDATE;
由于 id = 12 这条记录根本不存在,InnoDB 无法加记录锁。于是它转而锁定包含 12 的那个 Next-Key 区间,即 (11, 13]。
这意味着:
id = 12无法被插入(落在被锁的间隙内)。id = 13也无法被修改或删除(被记录锁锁住)。id = 11不受影响(不在这个区间内)。
六、为什么是左开右闭,而不是左闭右开?
理解了区间的形态之后,一个更深层的问题浮出水面:InnoDB 为什么选择 (a, b] 这种左开右闭的形式,而不是 [a, b) 左闭右开?
这背后有三个底层逻辑。
原因一:符合 B+ 树从左向右的扫描顺序
在 B+ 树索引中,记录是按升序从小到大排列的,MySQL 扫描时也是从左向右逐条推进的。
当扫描到某条记录 b 时,InnoDB 需要立刻决定如何加锁:
- 锁住记录
b本身(记录锁)。 - 锁住
b左侧的间隙,即(a, b)这段空地(间隙锁)。
这两个动作合并在一起,自然就形成了 (a, b] 的左开右闭区间。
如果采用左闭右开 [a, b),则意味着扫描到记录 a 时,需要去锁住 a 右侧的间隙——你还没"走到"右边,却要预先声称锁住右边的空地,这与 B+ 树从左到右扫描的顺序是背离的,逻辑上非常别扭。
原因二:与 Supremum 伪记录天然契合
使用左开右闭时,最后一个区间可以完美地表示为 (最大记录值, Supremum],用统一的闭区间逻辑封死了索引右侧的所有插入可能。
如果采用左闭右开,则最后一个区间将是 [Supremum, …)——但 Supremum 已经是最大边界,它右边根本没有任何间隙,这个表达式在逻辑上完全行不通。
原因三:每条记录都明确成为一个区间的唯一锚点
左开右闭的设计让每一条索引记录都清晰地成为且仅成为一个区间的右终点。
在 (a, b] 中,记录 b 是整个 Next-Key Lock 的"锚点"——当你申请锁定记录 b 时,InnoDB 顺带就把 b 左边的间隙一并管辖起来。这种设计保证了一个至关重要的特性:整条索引轴线上,每一个可能插入新数据的位置,都属于且仅属于一个 Next-Key Lock 区间,没有任何遗漏,也没有任何重叠。
设计对比总结
| 维度 | 左开右闭 (a, b](MySQL 选择) | 左闭右开 [a, b) |
|---|---|---|
| 锚点关系 | 记录 b 负责它左侧的间隙 | 记录 a 负责它右侧的间隙 |
| 扫描逻辑 | 符合 B+ 树从左到右的扫描顺序 | 逻辑滞后,扫到 a 却要锁右边 |
| 边界处理 | 完美兼容 Supremum 伪记录 | 无法自然处理无穷大边界 |
| 锁定本质 | 间隙锁 + 记录锁的自然结合 | 记录锁 + 后置间隙锁,逻辑拆散 |
一句话总结: 左开右闭是为了让记录锁(Record Lock)能顺理成章地作为它左侧间隙(Gap Lock)的守护者,从而在 B+ 树升序扫描的过程中,实现对索引轴线的无死角、无重叠覆盖。
七、完整总结
| 概念 | 核心含义 |
|---|---|
| 间隙锁(Gap Lock) | 锁住两条记录之间的空地,禁止插入新记录,是防幻读的核心武器 |
| 记录锁(Record Lock) | 锁住某条具体的索引记录,防止修改或删除 |
| 临键锁(Next-Key Lock) | Gap Lock + Record Lock 的组合,是 InnoDB 在 RR 级别下的默认锁形式 |
| 唯一索引等值查询 | 唯一例外,降级为纯记录锁,不加间隙锁,因为唯一性保证不会有幻读 |
| UPDATE / DELETE | 隐含当前读,同样会触发 Next-Key Lock,大范围操作需特别谨慎 |
| 左开右闭区间 | InnoDB 划分锁区间的统一形式,符合 B+ 树扫描顺序,无死角覆盖整条索引轴线 |
| Supremum 伪记录 | B+ 树内部的虚拟最大边界,使最右侧区间 (x, +∞) 在内部同样以闭区间表示 |
间隙锁机制是 InnoDB 在不牺牲并发性能的前提下,消灭幻读这一顽固问题的精巧解法。理解它的区间划分方式,不仅能帮你看懂那些"莫名其妙"的插入等待,更能让你在设计高并发业务时主动规避范围锁陷阱,写出真正高效的数据库操作。
到此这篇关于MySQL 锁机制深度解析:间隙锁、Next-Key Lock 与幻读防御的文章就介绍到这了,更多相关mysql间隙锁、Next-Key Lock 与幻读防御内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
