MySQL 是怎么加锁的?
不背结论,逐个场景推演加锁过程 :一条 SQL 进来,InnoDB 先定位到哪、扫到哪、每一步给哪个区间 加什么锁。看完你会得到一条万能加锁规则 ,任何查询都能自己推出来。
与《MySQL 锁全景速查手册》互补:那篇按「维度」分类盘点,本篇按「场景」逐步推演(提纲:哪些 SQL 加锁 → 锁的种类 → 五种索引场景逐一分析)。
加锁基本单位:Next-Key Lock
两种退化优化
五个场景逐一推演
附 data_locks 实测方法
就像你给的提纲那样,把大问题拆成三步,就一点都不乱了。
问题一:什么 SQL 会加锁?
搞清「谁有资格加锁」 。核心是区分快照读 与当前读 ——大部分 SELECT 根本不加锁。
问题二:锁有哪些种类?
搞清「锁长什么样」 。InnoDB 行锁就三种:Record Lock (锁记录)、Gap Lock (锁间隙)、Next-Key Lock (前两者合体)。
问题三:具体怎么加?
搞清「什么场景加哪些」 。按索引类型 × 查询方式 分五种场景逐一推演,这是本篇的主体。
先给结论(后面逐一验证)——InnoDB 加锁只有一条万能规则:
① 加锁的基本单位是 Next-Key Lock(左开右闭区间);② 接着按两条规则退化: 唯一索引等值命中 → 退化为 Record Lock;等值未命中 / 扫描到不满足条件的记录 → 退化为 Gap Lock。所有场景都是这条规则的展开。
先筛掉 90% 的误会——绝大多数 SELECT 根本不加锁。
图 1 · 快照读 vs 当前读:只有当前读才加锁
同一个表,两种读法,两种命运
快照读(不加锁)
SELECT * FROM t WHERE ...(普通 SELECT)
读的是 MVCC 版本链里的历史快照
不加锁、不被写操作阻塞
例外:SERIALIZABLE 隔离级别下会加共享锁
这就是「读很并发」的原因
当前读(加锁)
SELECT ... FOR UPDATE → 加 X 锁
SELECT ... LOCK IN SHARE MODE → 加 S 锁
UPDATE / DELETE → 自动加 X 锁
INSERT → 加 X 锁 + 插入意向锁
读最新已提交版本,必须加锁保证正确性
语句 加锁吗 加什么
SELECT * FROM t WHERE ...不加锁 快照读,走 MVCC
SELECT ... FOR UPDATE加 排他锁 X(当前读)
SELECT ... LOCK IN SHARE MODE加 共享锁 S(当前读)
UPDATE ... / DELETE ...加 排他锁 X(隐式当前读)
INSERT ...加 新记录 X 锁 + 插入意向锁
记住分水岭: 凡是「读最新版本并打算改」的语句(当前读)都要加锁;凡是「读个大概就行」的语句(快照读)都不加锁。后面五个场景的推演,全部基于当前读 + 默认 RR 隔离级别 。
只有三种,且后两种只在 RR 隔离级别下出现。
图 2 · 三种行锁的锁定范围(索引上依次有 10、15 两条记录)
左开右闭区间是看懂一切加锁图的钥匙
10
15
……
……
① Record Lock 记录锁
只锁 15 这一条记录
② Gap Lock 间隙锁
锁 (10, 15) 空隙,11~14 插不进来,但 10、15 本身不受限
③ Next-Key Lock 临键锁
锁 (10, 15]:空隙 + 15 记录一起锁(左开右闭)
Next-Key Lock = Gap Lock + Record Lock,是 InnoDB 在 RR 下的默认加锁单位
锁 锁什么 防什么 何时登场
Record Lock 单条索引记录 这行被改 / 删 唯一索引等值命中 ;RC 隔离级别几乎全是它
Gap Lock 记录之间的空隙(不含记录) 空隙里插入 新记录 → 防幻读 等值未命中 ;扫描到不满足条件的记录时
Next-Key Lock 间隙 + 记录(左开右闭) 既防改又防插 RR 下默认的加锁单位 ,之后按规则退化
我的理解——为什么需要间隙锁? 记录锁只能锁「已经存在的行」,但幻读的本质是「多出了原本不存在的行 」。要防幻读就必须把「还不存在、未来可能插入的位置 」也锁住——那就是间隙。所以:记录锁防修改,间隙锁防插入,合体(Next-Key)才能同时防住两种破坏 。这也是为什么 Gap Lock 只在需要防幻读的 RR 下存在——RC 根本不防幻读,自然不需要它。
后面五个场景都用它,先看清索引上有哪些值。
CREATE TABLE t_user (
id INT PRIMARY KEY , -- 主键索引(唯一)
age INT ,
KEY index_age (age) -- 普通索引(非唯一)
) ENGINE =InnoDB ;
INSERT INTO t_user VALUES
(1 ,1 ),(5 ,5 ),(10 ,10 ),(15 ,15 ),(20 ,20 ),(25 ,25 );
于是 id 主键索引 和 age 普通索引 上的值都是:1, 5, 10, 15, 20, 25,索引记录之间形成这样一条轴:
图 3 · 索引值轴与 Next-Key 区间:每个 Next-Key 区间 =(前一个值, 本值]
1
5
10
15
20
25
(-∞,1]
(1,5]
(5,10]
(10,15]
(15,20]
(20,25]
(25,+∞]
InnoDB 会把「最大值之后」虚拟成一条 supremum 记录,所以最后一个区间是 (25, +∞]
推演前把万能规则再念一遍: ① 默认给扫到的记录加 Next-Key Lock (即上图的区间);② 唯一索引等值命中 → 退化 Record Lock;③ 等值未命中 或 扫到第一条不满足条件的记录 → 退化 Gap Lock。就这三句。
两种结果:命中 → 只锁一行;未命中 → 只锁一个间隙。
命中:锁一条记录 最理想
select * from t_user where id = 15 for update;
主键唯一 ,命中 15 后不可能再有第二个 15,不需要防插入 → Next-Key 退化成 Record Lock ,只锁 [15,15] 一行。
其它事务:改 15 不行 ;插 14、16 都行。
未命中:锁一个间隙 防幻读
select * from t_user where id = 13 for update;
13 不存在。若不锁,别的会话此刻插入 id=13,下次再查 13 就查到了 → 幻读 。所以退化成 Gap Lock ,锁住 (10, 15)。
其它事务:插 11~14 不行 ;改 10、15 都行。
图 4 · 场景一对比:命中锁「点」,未命中锁「隙」
1
5
10
15
20
25
id=15 命中 → Record Lock
只锁 15 本身
id=13 未命中 → Gap Lock (10,15)
11~14 插不进来,10 与 15 可改
逐步推演:定位 → 扫描 → 边界退化。
select * from t_user where id >= 15 and id < 20 for update;
推演过程(按扫描顺序一步步来):
定位起点 :找到 id=15。条件是 >= 15,15 满足 。它是范围起点(不是等值查询),所以不退化 ,加 Next-Key Lock (10, 15] 。
继续右扫 :扫到 id=20。条件 < 20,20 不满足 。这是「第一条不满足条件的记录」→ 退化为 Gap Lock (15, 20) 。
停止扫描 。最终锁 = (10,15] + (15,20) 。
图 5 · 场景二加锁结果:一个 Next-Key + 一个 Gap
5
10
15
20
25
Next-Key Lock (10,15]:改 15 不行,插 11~14 不行
Gap Lock (15,20):插 16~19 不行,改 20 可以
边界方向决定了锁的形状:
条件是 >= 15(含 起点)→ 起点 15 满足 → 加 Next-Key (10,15],连同记录一起锁 ;
条件若是 > 15(不含 )→ 15 不满足 → 对 15 退化为 Gap,锁的是 (10,15);
终点方向的记录(如 < 20 里的 20)永远退化为 Gap ,因为它本身不满足条件、不该被锁记录,但要把「能插进 16~19 的空隙」封死。
具体版本行为可能有细微差异——
别背结论,用第 ⑪ 节的 SQL 实测 。
最复杂也最经典的场景——三把锁,缺一不可。
select * from t_user where age = 15 for update; -- age 是普通索引
关键区别:age 是普通索引,可能有多个 age=15 的行 。所以命中的 15 不能退化 ,还必须继续向右扫 ,确认后面没有别的 15 才罢休。
select * from t_user where age = 15 for update;
下一步 ▶
自动播放
重置
共 5 步 · 点击「下一步」看加锁全过程
我的理解——为什么非唯一索引要「多锁一个右间隙」? 因为存在第二个 age=15 的可能性 。如果只锁 (10,15],另一个事务此刻插入一条 age=15 的新行——虽然落在 (15,20) 区间里——你下次等值查询 age=15 就会多出一行,幻读照样发生 。所以必须把右侧间隙 (15,20) 也封死,直到扫到第一个不相等的值(20)才能确认「15 的都找完了」。
这也解释了主键为什么要一起锁: 普通索引的记录锁只锁了 age 索引条目,但 UPDATE 要改的是行数据 (在主键上),所以回表后还得给对应的主键记录加 Record Lock,否则别人可以直接改主键行绕过限制。
把「右扫多一格」体现得最明显的场景。
select * from t_user where age >= 15 and age <= 20 for update;
推演过程:
定位 15 ,满足(>=)→ 加 Next-Key (10,15] 。
扫到 20 ,满足(<=20)→ 它是普通索引不退化 ,加 Next-Key (15,20] 。
继续右扫到 25 ,不满足(>20)→ 退化为 Gap Lock (20,25) ,防止插入 age=21 之类的新行造成幻读。
同时对命中的主键记录加 Record Lock。
图 6 · 场景四加锁结果:两个 Next-Key + 一个 Gap
5
10
15
20
25
Next-Key (10,15]
Next-Key (15,20]
Gap (20,25):防插入 21~24
对比场景二,看出规律了吗? 同样是「15 到 20 的范围」,唯一索引只锁了一个 Next-Key + 一个 Gap ;非唯一索引锁了两个 Next-Key + 一个 Gap 。差异的根源只有一句话:唯一性让「不可能再出现相同值」,于是少了一把防插入的锁 。锁开销:唯一索引 < 普通索引 < 无索引——给 WHERE 字段建合适的索引,本质上是在给锁「减负」 。
最危险的一种:行锁「升级」为全表锁。
select * from t_user where name = '张三' for update; -- name 没有任何索引
推演过程: 行锁加在索引记录 上,而 name 没有索引 → InnoDB 只能全表扫描 (走主键索引逐行扫描)→ 扫描经过的每一条记录 都会被加锁,且由于不是等值命中,全部以 Next-Key Lock 形式锁住——包括 supremum(最大值之后的虚拟记录)。效果等同锁全表 。
图 7 · 无索引:整条索引轴全部被 Next-Key 锁住
1
5
10
15
20
25
(-∞,1] (1,5] (5,10] (10,15] (15,20] (20,25] (25,+∞] 全部 Next-Key Lock → 其它事务任何写都阻塞
RC 隔离级别下会好一点: RC 没有间隙锁 ,无索引扫描时虽然也会先全表加锁,但事务对「不满足 WHERE 条件的行」会立即释放锁 (server 层过滤后通知 InnoDB 解锁,即 semi-consistent read 的一种体现),最终只留下满足条件行的锁。这也是 RC 并发更好的又一个原因。但治本的方法永远只有一个:给 WHERE 字段建索引 。
万能规则(三句话)
① 默认形态 :InnoDB 对扫描到的每条记录加 Next-Key Lock (记录 + 它前面的间隙,左开右闭)。
② 退化一 :索引等值查询命中 ,且索引唯一 → 退化为 Record Lock (唯一性保证不可能再插相同的值,无需防插入)。
③ 退化二 :等值查询未命中 ,或范围扫描遇到第一条不满足条件的记录 → 退化为 Gap Lock (记录本身不该锁,但空隙必须封死防插入)。
场景 锁 备注
唯一索引等值 · 命中 Record Lock 只锁一行,最优
唯一索引等值 · 未命中 Gap Lock 锁本该落入的间隙
唯一索引范围(含起点) Next-Key + Gap 起点满足不退化;终点不满足退化
非唯一索引等值 · 命中 Next-Key + Gap + 主键 Record 右侧多锁一个间隙防重复值;回表锁主键
非唯一索引范围 多个 Next-Key + Gap 多扫到第一个不满足的记录
无索引(任意) 全表 Next-Key RC 下会释放不满足行的锁;治本靠建索引
普通 SELECT(RC/RR) 不加锁 快照读走 MVCC
理解一:锁的本质是「防幻读」
记录锁防「改」,间隙锁防「插」。所有加锁范围的扩张,都可以回答一个问题:不这么锁,会产生什么幻读? 想通这一点,五个场景不用背也能推出来。
理解二:锁的开销与索引质量成正比
唯一索引 < 普通索引 < 无索引。建索引不只是为了查询快,更是在缩小加锁范围 ——这是「为什么 DBA 强调 WHERE 必须走索引」的锁视角。
理解三:RR 是锁冲突之源
Gap / Next-Key 只在 RR 存在。高并发写入场景把隔离级别换成 RC (接受幻读),锁范围立刻缩小到「只锁命中的行」——这是一致性与并发性的直接交换。
两个会话 + 一张系统表,5 分钟亲眼看到锁。
-- 会话 A:制造一把锁
BEGIN ;
SELECT * FROM t_user WHERE age = 15 FOR UPDATE ;
-- 会话 A:看自己拿了哪些锁(MySQL 8.0)
SELECT index_name, lock_type, lock_mode, lock_data
FROM performance_schema.data_locks
WHERE object_name = 't_user' ;
-- 预期看到(-age 普通索引等值命中):
-- index_age RECORD X supremum pseudo-record / 各区间
-- index_age RECORD X 10, 15 ← next-key (10,15]
-- index_age RECORD X 15, 20 ← gap (15,20)
-- PRIMARY RECORD X 15 ← 主键记录锁
-- 会话 B:试试插入,会被阻塞
INSERT INTO t_user VALUES (16 , 16 ); -- age=16 落在 (15,20) 间隙 → 等待
-- 会话 A:回滚收尾
ROLLBACK ;
为什么建议实测? 不同 MySQL 版本对边界场景 (如 > 起点、IN 多值、覆盖索引下不加主键锁等)的实现有细微差异,网上文章(包括本文)都可能过时。performance_schema.data_locks 是唯一不会骗人的答案 。把本文五个场景各跑一遍,加锁逻辑就彻底内化了。