MySQL 是怎么加锁的?

不背结论,逐个场景推演加锁过程:一条 SQL 进来,InnoDB 先定位到哪、扫到哪、每一步给哪个区间加什么锁。看完你会得到一条万能加锁规则,任何查询都能自己推出来。

与《MySQL 锁全景速查手册》互补:那篇按「维度」分类盘点,本篇按「场景」逐步推演(提纲:哪些 SQL 加锁 → 锁的种类 → 五种索引场景逐一分析)。
加锁基本单位:Next-Key Lock 两种退化优化 五个场景逐一推演 附 data_locks 实测方法
1
问题拆解:「怎么加锁」其实是三个问题
就像你给的提纲那样,把大问题拆成三步,就一点都不乱了。

问题一:什么 SQL 会加锁?

搞清「谁有资格加锁」。核心是区分快照读与当前读——大部分 SELECT 根本不加锁。

问题二:锁有哪些种类?

搞清「锁长什么样」。InnoDB 行锁就三种:Record Lock(锁记录)、Gap Lock(锁间隙)、Next-Key Lock(前两者合体)。

问题三:具体怎么加?

搞清「什么场景加哪些」。按索引类型 × 查询方式分五种场景逐一推演,这是本篇的主体。

先给结论(后面逐一验证)——InnoDB 加锁只有一条万能规则:
① 加锁的基本单位是 Next-Key Lock(左开右闭区间);② 接着按两条规则退化:唯一索引等值命中 → 退化为 Record Lock;等值未命中 / 扫描到不满足条件的记录 → 退化为 Gap Lock。所有场景都是这条规则的展开。
2
问题一:什么 SQL 语句会加行级锁?
先筛掉 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 隔离级别。
3
问题二:行级锁有哪些种类?
只有三种,且后两种只在 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 根本不防幻读,自然不需要它。
4
推演准备:一张样例表
后面五个场景都用它,先看清索引上有哪些值。
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。就这三句。
5
场景一:唯一索引(主键)等值查询
两种结果:命中 → 只锁一行;未命中 → 只锁一个间隙。

命中:锁一条记录 最理想

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 可改
6
场景二:唯一索引(主键)范围查询
逐步推演:定位 → 扫描 → 边界退化。
select * from t_user where id >= 15 and id < 20 for update;

推演过程(按扫描顺序一步步来):

  1. 定位起点:找到 id=15。条件是 >= 15,15 满足。它是范围起点(不是等值查询),所以不退化,加 Next-Key Lock (10, 15]。
  2. 继续右扫:扫到 id=20。条件 < 20,20 不满足。这是「第一条不满足条件的记录」→ 退化为 Gap Lock (15, 20)。
  3. 停止扫描。最终锁 = (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 实测。
7
场景三:非唯一索引等值查询
最复杂也最经典的场景——三把锁,缺一不可。
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,否则别人可以直接改主键行绕过限制。
8
场景四:非唯一索引范围查询
把「右扫多一格」体现得最明显的场景。
select * from t_user where age >= 15 and age <= 20 for update;

推演过程:

  1. 定位 15,满足(>=)→ 加 Next-Key (10,15]。
  2. 扫到 20,满足(<=20)→ 它是普通索引不退化,加 Next-Key (15,20]。
  3. 继续右扫到 25,不满足(>20)→ 退化为 Gap Lock (20,25),防止插入 age=21 之类的新行造成幻读。
  4. 同时对命中的主键记录加 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 字段建合适的索引,本质上是在给锁「减负」。
9
场景五:没有索引的查询
最危险的一种:行锁「升级」为全表锁。
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 字段建索引。
10
总结:一条万能加锁规则 + 速查表

万能规则(三句话)

  • ① 默认形态:InnoDB 对扫描到的每条记录加 Next-Key Lock(记录 + 它前面的间隙,左开右闭)。
  • ② 退化一:索引等值查询命中,且索引唯一 → 退化为 Record Lock(唯一性保证不可能再插相同的值,无需防插入)。
  • ③ 退化二:等值查询未命中,或范围扫描遇到第一条不满足条件的记录 → 退化为 Gap Lock(记录本身不该锁,但空隙必须封死防插入)。
场景锁备注
唯一索引等值 · 命中Record Lock只锁一行,最优
唯一索引等值 · 未命中Gap Lock锁本该落入的间隙
唯一索引范围(含起点)Next-Key + Gap起点满足不退化;终点不满足退化
非唯一索引等值 · 命中Next-Key + Gap + 主键 Record右侧多锁一个间隙防重复值;回表锁主键
非唯一索引范围多个 Next-Key + Gap多扫到第一个不满足的记录
无索引(任意)全表 Next-KeyRC 下会释放不满足行的锁;治本靠建索引
普通 SELECT(RC/RR)不加锁快照读走 MVCC

理解一:锁的本质是「防幻读」

记录锁防「改」,间隙锁防「插」。所有加锁范围的扩张,都可以回答一个问题:不这么锁,会产生什么幻读?想通这一点,五个场景不用背也能推出来。

理解二:锁的开销与索引质量成正比

唯一索引 < 普通索引 < 无索引。建索引不只是为了查询快,更是在缩小加锁范围——这是「为什么 DBA 强调 WHERE 必须走索引」的锁视角。

理解三:RR 是锁冲突之源

Gap / Next-Key 只在 RR 存在。高并发写入场景把隔离级别换成 RC(接受幻读),锁范围立刻缩小到「只锁命中的行」——这是一致性与并发性的直接交换。

11
亲手验证:别信任何文章,包括这篇
两个会话 + 一张系统表,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 是唯一不会骗人的答案。把本文五个场景各跑一遍,加锁逻辑就彻底内化了。