1. 基础认知
要从根本上理解 MySQL 的锁,必须理解它在 内存(RAM) 和 数据结构(B+Tree) 层面是如何运作的。
可以将 MySQL(主要是 InnoDB 引擎)的锁机制拆解为五个层次:
- 锁的结构:锁在内存里面到底长什么样?(打破"锁是数据行上的标记"这一误区)
- 锁的对象:锁是加在什么对象上?(打破"锁行"的字面理解)
- 锁的类型:读写冲突怎么解决?
- 锁的粒度:如何提高并发效率?(意向锁的由来)
- 锁的算法:如何解决幻读?(Gap Lock 的由来)
1.1 锁的结构:锁在内存里面到底长什么样?
核心认知:锁不是数据行(Row)上的一个字段,而是内存中独立的数据结构。
很多初学者认为锁是磁盘上每一行数据里的一个 is_locked
标记。这是错的。如果是这样,锁事务回滚时还需要去修改磁盘数据,IO
开销太大。
在 InnoDB 内部,锁通过 Lock System (lock_sys) 进行管理,是一个巨大的 Hash Table。

InnoDB 不会为每一行数据创建一个锁对象(Lock Struct),那是巨大的内存浪费。它是按页(Page)来管理的。
1 | struct lock_t { |
- 一个
lock_t结构体对应"一个事务"在"一个数据页"上的锁信息。 - 在这个结构体后面,跟着一个 Bitmap(位图)。
- 如果一个页面有 100 条记录,位图里就有对应的一堆 Bit。如果你执行
UPDATE... WHERE id < 1000,锁住了这页的 50 行记录,InnoDB 不需要创建 50 个锁对象,只需要在一个锁对象的位图中把这 50 个 bit 置为 1。这就是为什么 MySQL 宣称它支持行级锁且开销极小,即便你锁住了 100 万行,只要它们集中在几千个 Page 里,内存消耗依然很小。
1.2 锁的对象:锁是加在什么对象上?
核心认知:InnoDB 的行锁,永远是加在索引(Index)上的。
这是理解所有复杂锁问题的总钥匙。
- 如果你通过主键更新:锁加在聚簇索引(Clustered Index)的记录上。
- 如果你通过二级索引更新:锁先加在二级索引(Secondary Index)的记录上,然后回表,去锁聚簇索引上的记录。
- 如果你不走索引:因为 MySQL 必须扫描全表才能找到你要更新的行。它会扫描一条、锁一条(在聚簇索引上)。虽然在 RC 隔离级别下 MySQL 有优化(不匹配的行会释放锁),但在 RR 级别下,它会把所有扫描过的记录以及记录之间的间隙全锁上。这在效果上等同于锁表。
1.3 锁的类型:读写冲突怎么解决?
MySQL InnoDB 有 7 种锁:
- 共享锁(S 锁)和排他锁(X 锁)
- 意向锁(Intension Locks)
- 记录锁(Record Locks)
- 间隙锁(Gap Locks)
- 临键锁(Next-Key Locks)
- 插入意向锁(Insert Intension Locks)
- 自增锁(Auto-Inc Locks)
MDL 是 MySQL Server 层面的锁。
可以从 2 个维度进行理解:
- 锁的粒度:锁表、锁行、锁区间
- 锁的模式:共享、排他、意向

1.4 锁的粒度:如何提高并发效率?
如果表里有 100 万行数据,事务 A 锁住了其中第 999 行(行锁)。此时事务 B 想申请整张表的写锁(表锁),B 怎么知道能不能加锁?
- 笨办法:B 遍历这 100 万行,看有没有人加了行锁。-> 效率极低,不可接受。
- 第一性原理优化:在层级结构中,下层有锁,上层必须有标记。
意向锁(Intention Lock, IS/IX) 这实际上是表级锁,它的作用只是"信号灯":
- 规则:事务 A 在给第 999 行加 行级排他锁 (X) 之前,必须先给这张表挂一个 意向排他锁 (IX)。
- 效果:事务 B 来看一眼表门头,发现有 IX 标记,就知道表里有人在干活,于是 B 阻塞等待。B 不需要遍历全表,效率提升为 O(1)。
1.5 锁的算法:如何解决幻读?
为了防止幻读(Phantom Read),InnoDB 发明了复杂的锁算法,我们需要把数据想象成 B+ 树叶子节点上的一条有序链表。
假设表中现在的 ID 有:10, 20, 30。
Record Lock(记录锁):
- 定义:仅仅锁住索引记录本身。
- 场景:精准命中。例如
SELECT * FROM t WHERE id = 10 FOR UPDATE;。 - 内存表现:Bitmap 中对应
id=10的那个 bit 被置位。

Gap Lock(间隙锁):
- 定义:锁住两个索引记录之间的空隙,不包含记录本身。
- 目的:纯粹是为了通过禁止插入(insert)来防止幻读。
- 场景:
SELECT * FROM t WHERE id = 15 FOR UPDATE;。 - 范围:例如
(10, 20)。这意味着你不能插入11,12... 19。 - 重要特性:Gap Lock 之间是兼容的!事务 A 可以对
(10, 20)加 Gap Lock,事务 B 也可以对(10, 20)加 Gap Lock。为什么?因为它们的目的都是阻止别人插入,不冲突。真正冲突的是 Insert Intention Lock(插入意向锁)。

Next Key Lock(临键锁):
- 定义:Record Lock + Gap Lock。即锁住记录本身,也锁住它前面的空隙。
- 默认行为:在 RR(Repeatable Read)隔离级别下,InnoDB 对于范围查询或非唯一索引的等值查询,默认加 Next-Key Lock。
- 场景:
SELECT * FROM t WHERE id > 10 FOR UPDATE; - 左开又闭:在
10, 20, 30的例子中,扫描到 20 时,加 Next-Key Lock(10, 20];扫描到 30 时,加 Next-Key Lock(20, 30]。扫描完 30 后,指针继续向后,发现没有真实记录了,碰到了 B+ 树页面的 Supremum 伪记录(代表无穷大)。加 Next-Key Lock(30, ∞)。综合起来是锁住了(10, +∞)。它不仅仅是锁间隙,也会锁住记录。这意味着:你不能修改 20,也不能在 10 到 20 之间插入数据 。

Insert Intension Lock(插入意向锁):
- 定义:这其实是一种特殊的 Gap Lock,但在代码里它叫"插入意向"。
- 触发:当执行
INSERT时,如果目标位置已经被别的事务加了 Gap Lock,插入操作就会进入等待,并在这个间隙上生成一个"插入意向锁"。 - 死锁之源:很多死锁都是因为两个事务持有 Gap Lock,然后又都想在这个 Gap 里插入数据(申请插入意向锁),结果互相等待。
Metadata Lock(元数据锁):
- 作用:保护表结构。当你执行
SELECT或UPDATE时,自动加 MDL 读锁。当你执行ALTER TABLE时,需要 MDL 写锁。 - 阻塞逻辑:读写互斥。这意味着,只要有一个长事务(哪怕只是
SELECT)没提交,持有 MDL 读锁,后续的ALTER TABLE就会被卡住,而ALTER TABLE卡住后,后面所有的SELECT/UPDATE也会被卡住(形成锁队列堆积)。这就是著名的"MDL 暴击"。
2. 分析模板
当一个事务执行 SQL 时,InnoDB 锁引擎会遵循以下判定树:

针对 RR 隔离级别,我们要秉持三大分析原则:
原则一: 加锁的基本单位是 Next-Key Lock(左开右闭)。
原则二: 查找过程中,访问到的对象才会加锁。
如果 SQL 走了二级索引,二级索引会被强力锁定(Next-Key + Gap)。
回表时,主键索引只加 Record Lock。
原则三(优化): 索引上的等值查询:
唯一索引:Next-Key Lock 退化为 Record Lock(因为不用担心那个值后面插进一样的)。
非唯一索引:向右扫描到第一个不符合条件的记录,该记录加 Gap Lock(不锁记录本身,只锁它前面的空隙)。
3. 例题分析
表结构:
1 | CREATE TABLE students ( |
现有数据:
id=1, score=10, name='A'id=5, score=20, name='B'id=10, score=30, name='C'
当前事务执行:
1 | -- 隔离级别:RR |
请分析以下三个核心问题:
- 二级索引锁: 在
idx_score这棵 B+树上,锁住了哪些范围?(请用准确的区间表示,如(10, 20]) - 聚簇索引锁(回表): 在
PRIMARY KEY(id) 这棵 B+ 树上,锁住了哪些 ID?有没有加 Gap Lock? - 并发测试: 此时,另一个事务想执行
INSERT INTO students (id, score, name) VALUES (2, 15, 'D');,能否成功?为什么?

问题一:idx_score 到底锁了什么?
数据 (id, score):
(1, 10),(5, 20),(10, 30)SQL:
DELETE FROM students WHERE score = 20;(隔离级别 RR)
InnoDB 的逻辑是:我要锁住 score=20,并且防止别人在
score=20 的前后插入数据。
- 命中记录: 首先找到
(5, 20)这条记录。加上 Next-Key Lock。- 范围一:
(10, 20]。 - 注意: 这里锁的是
score的范围,配合主键id。确切地说,它锁的是(score=10, id=1)到(score=20, id=5)之间的间隙,加上(20, 5)这条记录本身。
- 范围一:
- 向右探测:
- 为了防止幻读(比如别人插入一个
score=20的新行),InnoDB 必须继续向右扫描,直到遇到第一条不满足条件的记录为止。 - 它向右看到了
(10, 30)。 - 因为
30!= 20,扫描结束。但是,为了封锁20之后到30之前的空隙,它必须在(10, 30)这条记录上加一个 Gap Lock。 - 范围二:
(20, 30)。
- 为了防止幻读(比如别人插入一个
结论: 在 idx_score
索引上,实际锁住的范围是 (10, 30)。
score在(10, 20]的不能插。score在(20, 30)的也不能插。
问题二:聚簇索引 (PRIMARY KEY)
锁了什么?
二级索引回表锁主键时,只加 Record Lock,不加 Gap Lock。
原因: Gap Lock
是为了防止"在范围内插入"。二级索引上的 Gap Lock 已经足够阻止
score=20 的插入了(因为插入必须维护所有索引)。主键上再加
Gap Lock 是多余的,且会极大地降低并发(会误伤 id 邻近但
score 无关的行)。
所以主键 id=5 上只有 Record Lock (X锁)。
问题三:并发测试结果分析
SQL:
INSERT INTO students (id, score, name) VALUES (2, 15, 'D');
- 我们要插入
score=15。 - 检查
idx_score锁范围:15落在(10, 20]这个区间内。 - 结果: 被
idx_score的 Next-Key Lock 阻塞。