日常业务开发中,UPDATE
语句是频繁使用的操作之一。然而一个令人困惑的问题是:一条 UPDATE 语句在执行时,底层到底加锁了数据库中的哪些范围?
这个问题在网上讨论多年,众说纷纭:
- 有人说是行锁(Record Lock)
- 有人说是间隙锁(Gap Lock)
- 有人说是Next-Key Lock(前开后闭,记录锁 + 间隙锁的组合)
各有说法,公说公有理,婆说婆有理,争论不休。
其实这个问题并不复杂,比起抽象晦涩的名词解释,不如我们通过一次清晰的实验,以“看得见、摸得着”的方式去亲身体验 InnoDB 锁定的真正范围。数据不会说谎,结果最有说服力。
一、实验目标:弄清楚 UPDATE 的加锁范围
我们将通过构造一张简单的数据表、执行不同类型的 UPDATE
操作,并配合验证性 INSERT
语句,来逐个分析:
- 精准命中行是否加锁?
- 更新不存在记录是否加锁?加哪一段?
- 主键之间的“间隙”是否被锁?
- 范围更新加锁范围是大是小?
- InnoDB 加锁策略到底依据什么?
二、实验环境配置
为确保大家可以复现本实验,这里提供完整环境参数:
- 数据库版本:MySQL 8.0.27
- 存储引擎:InnoDB(支持多种行级锁)
- 事务隔离级别:RR(Repeatable Read,可重复读,InnoDB 默认)
- 数据表结构:仅包含两个字段(主键+name)
CREATE TABLE test_lock (
id INT PRIMARY KEY,
name VARCHAR(100)
);
初始化数据:
INSERT INTO test_lock (id, name) VALUES (10, 'a'), (50, 'b');
当前表中主键 ID 仅包含两个值:10
与 50
。
三、实验设计与 SQL 概览
我们设计了四组基础 UPDATE
语句,对应四种典型场景,分别命名为 M1 ~ M4
。
编号 | SQL 语句 | 场景说明 |
---|---|---|
M1 | UPDATE test_lock SET name = 'x' WHERE id = 7 | 非精准匹配,目标记录不存在 |
M2 | UPDATE test_lock SET name = 'x' WHERE id = 10 | 精准匹配,目标记录存在 |
M3 | UPDATE test_lock SET name = 'x' WHERE id = 12 | 非精准匹配,夹在现有主键之间 |
M4 | UPDATE test_lock SET name = 'x' WHERE id >= 8 AND id <= 11 | 范围更新,部分存在,部分不存在 |
四、实验验证方式:双窗口并发测试锁行为
我们使用两条并发事务:
- 事务A(主事务):执行
UPDATE
语句,不提交也不回滚,保持锁定 - 事务B(验证事务):执行不同的
INSERT
语句,尝试插入不同主键值,观察是否被阻塞
核心判断标准:
- 如果插入被阻塞,说明该 ID 所在位置被锁住;
- 如果插入立刻成功,说明该位置未被锁定。
五、四组 UPDATE 实验解析
M1:UPDATE test_lock SET name = 'x' WHERE id = 7
- 当前表中并无 ID = 7 的记录;
- 该值小于现有的 10,因此锁定了 (负无穷, 10) 区间的间隙。
实验现象:
插入 ID | 是否阻塞 | 原因 |
---|---|---|
1 ~ 9 | 阻塞 | 位于 (−∞,10) 区间内,被间隙锁锁住 |
10 | 报主键冲突 | 行已存在 |
>10 | 正常插入 | 不在锁定范围 |
加锁类型:
- Gap Lock(间隙锁)
- 锁定范围:(−∞,10),不包含10本身
M2:UPDATE test_lock SET name = 'x' WHERE id = 10
- 命中现有记录;
- 仅对
id = 10
这行加锁。
实验现象:
插入 ID | 是否阻塞 | 原因 |
---|---|---|
10 | 主键冲突 | 已存在 |
其他任何值 | 正常插入 | 无锁冲突 |
加锁类型:
- Record Lock(记录锁)
- 锁定范围:仅限于
id=10
本身
M3:UPDATE test_lock SET name = 'x' WHERE id = 12
- 表中没有 ID = 12,但该值位于两个已存在主键(10 与 50)之间;
- 加锁策略是锁定该值所在的索引“间隙”。
实验现象:
插入 ID | 是否阻塞 |
---|---|
11、13、14、…、49 | 阻塞(间隙锁) |
10、50 | 不阻塞(节点存在,不在间隙中) |
≥51 | 正常插入 |
加锁类型:
- Gap Lock
- 锁定范围:(10,50)
M4:UPDATE test_lock SET name = 'x' WHERE id >= 8 AND id <= 11
-
范围包含
id=10
,其余未命中; -
会加:
- 记录锁:锁住
id=10
- 间隙锁:锁住范围内不存在记录的间隙,如
(8,10)
、(10,11)
- 记录锁:锁住
实验现象:
插入 ID | 是否阻塞 | 原因 |
---|---|---|
7 | 正常 | 不在查询范围内 |
8 | 阻塞 | 在区间起始间隙内 |
9、11 | 阻塞 | 在间隙中 |
10 | 主键冲突 | 行已存在 |
12+ | 正常 | 不在锁定区间 |
加锁类型:
- Next-Key Lock(记录锁 + 间隙锁)
- 锁定范围:(−∞,10)、
id=10
、(10,11)
六、InnoDB 加锁机制总结
场景 | SQL 示例 | 加锁类型 | 锁定范围 |
---|---|---|---|
精准匹配命中 | UPDATE ... WHERE id=10 | 记录锁 | 只锁命中行 |
精准匹配未命中 | UPDATE ... WHERE id=7 | 间隙锁 | 锁住目标值所处间隙 |
中间值未命中 | UPDATE ... WHERE id=12 | 间隙锁 | 锁住 12 所在索引间隙 (10,50) |
范围更新 | UPDATE ... WHERE id>=8 AND id<=11 | Next-Key Lock | 记录锁 + 所有相关间隙 |
七、加锁原理:B+ 树索引结构的影响
InnoDB 中的主键索引是 B+ 树结构:
- 每个节点按主键顺序排列
- 叶子节点之间是双向链表
- 间隙锁(Gap Lock)实际上锁的是 相邻叶子节点之间的区间
- Next-Key Lock 是对记录本身和左右间隙一起加锁
图示:
现有主键为:10
和 50
B+ 树索引结构:
---|---(10)---|---(50)---|---
id = 7
锁的是(−∞,10)
id = 12
锁的是(10,50)
id = 10
锁的是[10]
- 范围
id >= 8 and id <= 11
锁的是(−∞,10) + [10] + (10,11)
八、实际开发中的建议
-
尽量使用精准的主键更新
- 锁粒度最小,性能最好
-
避免非命中更新
- 无谓加锁间隙,影响插入并发性
-
范围更新慎用
- 特别是热点表上,Next-Key Lock 会锁较大范围
-
合理设计主键值
- 稀疏、均匀分布的主键可以减少间隙锁冲突
-
并发插入场景中避免同时进行范围更新
九、实验脚本与复现建议
你可以使用以下步骤手动验证上述实验:
步骤一:开启事务窗口A
START TRANSACTION;
UPDATE test_lock SET name = 'x' WHERE id = 7; -- 不提交
步骤二:在窗口B执行 INSERT 验证是否阻塞
INSERT INTO test_lock (id, name) VALUES (9, 'test'); -- 会阻塞
通过这种方式,你可以自行验证各种 UPDATE 的加锁范围,理解最清晰!
十、结语
通过一组清晰的实验,我们最终得出了结论:
- 不同 UPDATE 写法对加锁范围影响巨大
- InnoDB 加锁策略与 B+ 树结构密切相关
- 合理使用索引与写法,是保证高并发性能的关键
记住这三句话:
- 命中即行锁,精准高效;
- 不命中即间隙锁,小心误伤;
- 范围查询慎用,锁范围可能远超想象。
希望本文实验+原理的方式能帮助你彻底搞懂 InnoDB 的锁机制。
如果你已经看懂,不妨照着实验动手试一遍。数据不会骗人,实践最能加深记忆。