1. 基础认知

要从根本上理解 MySQL 的锁,必须理解它在 内存(RAM)数据结构(B+Tree) 层面是如何运作的。

可以将 MySQL(主要是 InnoDB 引擎)的锁机制拆解为五个层次:

  1. 锁的结构:锁在内存里面到底长什么样?(打破"锁是数据行上的标记"这一误区)
  2. 锁的对象:锁是加在什么对象上?(打破"锁行"的字面理解)
  3. 锁的类型:读写冲突怎么解决?
  4. 锁的粒度:如何提高并发效率?(意向锁的由来)
  5. 锁的算法:如何解决幻读?(Gap Lock 的由来)

1.1 锁的结构:锁在内存里面到底长什么样?

核心认知:锁不是数据行(Row)上的一个字段,而是内存中独立的数据结构。

很多初学者认为锁是磁盘上每一行数据里的一个 is_locked 标记。这是错的。如果是这样,锁事务回滚时还需要去修改磁盘数据,IO 开销太大。

在 InnoDB 内部,锁通过 Lock System (lock_sys) 进行管理,是一个巨大的 Hash Table

InnoDB 不会为每一行数据创建一个锁对象(Lock Struct),那是巨大的内存浪费。它是按页(Page)来管理的。

1
2
3
4
5
6
7
8
9
10
struct lock_t {
trx_t* trx; // 属于哪个事务
ulint space_id; // 表空间 ID
ulint page_no; // 页号
uint32 type_mode; // 锁类型 (Shared/Exclusive) | 锁模式 (Rec/Gap/Next-Key)
// ---------------------------------------------
// 重点来了:这里没有 Row ID,而是一个 Bitmap
// ---------------------------------------------
uint8 bitmap[]; // 位图!映射这一页上的物理记录
};
  • 一个 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 种锁:

  1. 共享锁(S 锁)和排他锁(X 锁)
  2. 意向锁(Intension Locks)
  3. 记录锁(Record Locks)
  4. 间隙锁(Gap Locks)
  5. 临键锁(Next-Key Locks)
  6. 插入意向锁(Insert Intension Locks)
  7. 自增锁(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(元数据锁):

  • 作用:保护表结构。当你执行 SELECTUPDATE 时,自动加 MDL 读锁。当你执行 ALTER TABLE 时,需要 MDL 写锁。
  • 阻塞逻辑:读写互斥。这意味着,只要有一个长事务(哪怕只是 SELECT)没提交,持有 MDL 读锁,后续的 ALTER TABLE 就会被卡住,而 ALTER TABLE 卡住后,后面所有的 SELECT/UPDATE 也会被卡住(形成锁队列堆积)。这就是著名的"MDL 暴击"。

2. 分析模板

当一个事务执行 SQL 时,InnoDB 锁引擎会遵循以下判定树:

针对 RR 隔离级别,我们要秉持三大分析原则:

  1. 原则一: 加锁的基本单位是 Next-Key Lock(左开右闭)。

  2. 原则二: 查找过程中,访问到的对象才会加锁。

    • 如果 SQL 走了二级索引,二级索引会被强力锁定(Next-Key + Gap)。

    • 回表时,主键索引只加 Record Lock

  3. 原则三(优化): 索引上的等值查询:

    • 唯一索引:Next-Key Lock 退化为 Record Lock(因为不用担心那个值后面插进一样的)。

    • 非唯一索引:向右扫描到第一个不符合条件的记录,该记录加 Gap Lock(不锁记录本身,只锁它前面的空隙)。

3. 例题分析

表结构:

1
2
3
4
5
6
CREATE TABLE students (
id INT PRIMARY KEY, -- 主键
score INT, -- 非唯一索引
name VARCHAR(20), -- 无索引
INDEX idx_score (score)
) ENGINE=InnoDB;

现有数据:

  • id=1, score=10, name='A'
  • id=5, score=20, name='B'
  • id=10, score=30, name='C'

当前事务执行:

1
2
-- 隔离级别:RR
DELETE FROM students WHERE score = 20;

请分析以下三个核心问题:

  1. 二级索引锁:idx_score 这棵 B+树上,锁住了哪些范围?(请用准确的区间表示,如(10, 20]
  2. 聚簇索引锁(回表):PRIMARY KEY(id) 这棵 B+ 树上,锁住了哪些 ID?有没有加 Gap Lock?
  3. 并发测试: 此时,另一个事务想执行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_scoreNext-Key Lock 阻塞。

参考