07 InnoDB 锁机制——从记录锁到间隙锁的底层实现

摘要: 上一篇进阶使用文章从”开发者怎么用锁”的视角讲了死锁的预防,本文从”InnoDB 如何实现锁”的视角深入底层。锁在 InnoDB 内部是如何存储的?为什么加锁的对象是索引而不是行数据?为什么在 REPEATABLE READ 级别下,等值查询对”不存在的行”也会加间隙锁?间隙锁之间为什么互相兼容而不冲突?本文从锁的物理存储(Lock Bitmap)出发,解析记录锁、间隙锁、临键锁的内部结构,推导不同 SQL 在不同场景下的加锁过程,以及 InnoDB 死锁检测的等待图(Wait-for Graph)算法的工作原理。


第 1 章 锁的物理存储:Lock Bitmap

1.1 锁不是”附着在行上”的

一个普遍的误解是:行锁”附着”在被锁定的行上,修改行时顺便给它加上一把锁的标记。这种直觉虽然方便理解,但并不符合 InnoDB 的实际实现。

InnoDB 的锁信息存储在独立的内存区域中,而不是直接嵌入数据页或行记录中。具体来说,InnoDB 使用一种叫做 Lock Bitmap(锁位图) 的数据结构来记录锁信息。

1.2 Lock Bitmap 的结构

每个事务在对某个页上的行加锁时,InnoDB 会为该事务在该页上创建一个 Lock 结构,包含:

  • 持有该锁的事务(trx 指针)
  • 被锁定的页(space_id + page_no
  • 锁位图(Bitmap):一个 bit 数组,每个 bit 对应页中的一个行槽(heap_no)。bit 为 1 表示该槽上的行被加锁,bit 为 0 表示未加锁。

这种设计的优势是:同一个事务对同一页上多行加锁,只需要一个 Lock 结构,通过位图标记多行——内存开销远小于”每行独立一个锁对象”的方案。

Lock 结构示意:
+------------------+
| trx: T100        |  ← 持有锁的事务
| space: 5         |  ← 表空间 ID
| page_no: 3       |  ← 页号
| lock_mode: X     |  ← 锁模式(S/X)
| lock_type: REC   |  ← 锁类型(记录锁/间隙锁/临键锁)
| lock_bitmap:     |
|  bit 0: 0        |  ← 虚拟最小记录(Infimum),未加锁
|  bit 1: 0        |  ← 行 1,未加锁
|  bit 2: 1        |  ← 行 2,已加锁 ✓
|  bit 3: 1        |  ← 行 3,已加锁 ✓
|  bit 4: 0        |  ← 行 4,未加锁
|  ...             |
+------------------+

当一行被删除(Insert 新行)时,页的 heap_no 可能变化,但 InnoDB 保证在有锁的情况下,行的 heap_no 不会被回收重用——否则锁位图中的 bit 就会错误地指向新行。

1.3 为什么锁加在索引上

InnoDB 的行锁,本质上是对 B+Tree 索引节点上特定记录的加锁,而不是对行数据本身的加锁。原因是:

  1. 行数据通过聚簇索引组织:行数据就是主键 B+Tree 的叶子节点,“对行加锁”在物理上等同于”对主键索引的某个叶子记录加锁”
  2. 二级索引查询也需要锁:通过二级索引查询时,先在二级索引上加锁,再对对应的主键索引上的行加锁——两个索引都需要锁定,以防止其他事务通过不同索引路径修改同一行
  3. 间隙锁依赖索引的有序性:间隙是”两个相邻索引记录之间的空间”,只有在有序的 B+Tree 索引上才有”间隙”的语义。没有索引就没有间隙锁的概念

重要推论:如果 WHERE 条件没有走任何索引,InnoDB 会扫描聚簇索引的所有叶子节点,并对扫描到的每一条记录都加临键锁——相当于锁住了全表所有记录和所有间隙。这就是”没有索引导致全表锁”的底层原因。


第 2 章 三种行锁的物理语义

2.1 记录锁(Record Lock):锁住索引中的一条具体记录

记录锁锁定的是 B+Tree 中某个特定的索引记录。在 Lock 结构中,lock_type = LOCK_REC,位图中对应 heap_no 的 bit 被设为 1。

记录锁只锁记录本身,不锁记录前面的间隙。在等值查询精确命中唯一索引(或主键)时,临键锁会退化为记录锁——因为唯一性已经保证了不会有其他行插入到”这条记录的位置”,不需要锁间隙。

记录锁的语义扩展:Delete 操作除了对目标行加排他记录锁,还会对目标行的”Purge 操作”加锁——确保在事务提交之前,Purge 线程不会清理掉该行对应的 Undo Log(MVCC 可能还需要这些旧版本)。

2.2 间隙锁(Gap Lock):锁住两条记录之间的空间

间隙锁锁定的是 B+Tree 中某条记录之前的间隙(即”在这条记录之前插入新记录”的权利)。

间隙锁的物理存储方式与记录锁相同(也是 Lock 位图的某个 bit),但 lock_type 标志位不同(LOCK_GAP)。间隙锁锁的是某条记录”之前”的间隙,而不是某条记录本身。

间隙锁的关键特性:间隙锁之间相互兼容

这是一个反直觉的设计,值得深入理解。两个事务可以同时持有同一个间隙的间隙锁(都是 Gap Lock),它们不会互相冲突。

为什么这样设计? 间隙锁的目的是防止其他事务向间隙内插入(INSERT) 新记录。INSERT 操作需要加 Insertion Intention Gap Lock(插入意向间隙锁)。插入意向间隙锁与普通间隙锁不兼容——也就是说,如果一个间隙上有 Gap Lock,任何试图在这个间隙内 INSERT 的操作都会被阻塞。

但两个持有同一个间隙 Gap Lock 的事务,它们只是都在”声明我要防止这个间隙内的插入”,彼此之间没有冲突。如果 Gap Lock 之间互相排斥,两个只是想”读取范围”的事务就会互相阻塞,这显然没有必要。

锁类型Record LockGap LockNext-Key LockInsertion Intention Gap Lock
Record Lock冲突(S/X不兼容)兼容冲突兼容
Gap Lock兼容兼容兼容冲突
Next-Key Lock冲突兼容冲突冲突
Insertion Intention兼容冲突冲突兼容

2.3 临键锁(Next-Key Lock):记录锁 + 间隙锁的组合

临键锁是 InnoDB 在 RR 级别下的默认加锁单元,语义是:锁住某条记录本身 + 这条记录之前的间隙

形式上,一个临键锁可以分解为:

  • 对这条记录的记录锁(防止修改)
  • 对这条记录之前间隙的间隙锁(防止插入)

临键锁锁定的区间是左开右闭的:(上一条记录的键, 本条记录的键]

对于一个有 id = 10, 20, 30 的索引,临键锁的区间划分为:

  • (-∞, 10]:id=10 的临键锁
  • (10, 20]:id=20 的临键锁
  • (20, 30]:id=30 的临键锁
  • (30, +∞):针对最大记录(Supremum 虚记录)的间隙锁(实际上没有临键锁,只有间隙锁,因为 Supremum 是虚拟记录)

第 3 章 加锁过程的完整推导

3.1 主键等值查询的加锁推导

场景SELECT * FROM t WHERE id = 10 FOR UPDATE,表中 id=10 存在。

推导步骤

  1. 查询走主键(聚簇)索引,等值查询精确命中 id=10 的记录
  2. InnoDB 默认加临键锁:(上一条记录的 id, 10]
  3. 等值查询命中唯一索引时,临键锁退化为记录锁:只锁 id=10 这条记录,不锁间隙
  4. 最终加锁:id=10 上的排他记录锁

退化的理由:id 是唯一索引,永远不会有另一条 id=10 的记录被插入(唯一约束保证了这一点)。间隙锁的目的是防止幻读,而等值查询 + 唯一索引的场景下,幻读(出现另一条 id=10 的记录)是不可能的,所以间隙锁没有存在的意义。

场景SELECT * FROM t WHERE id = 10 FOR UPDATE,表中 id=10 不存在(id 的已有值为 5 和 15)。

  1. 查询走主键索引,等值查询未命中任何记录
  2. InnoDB 找到 id=15(第一个大于 10 的记录)
  3. 间隙锁(5, 15) 这个间隙(注意:是开区间,不包含 5 和 15)
  4. 阻止任何事务在 5 和 15 之间插入新记录(防止幻读:事务再次查询 id=10 时不会出现新行)

3.2 主键范围查询的加锁推导

场景SELECT * FROM t WHERE id > 10 FOR UPDATE,表中有 id = 10, 20, 30。

推导步骤

  1. 查询走主键索引,范围查询
  2. 从 id > 10 的第一条记录开始(id=20)向后扫描
  3. 对 id=20 加临键锁:(10, 20]
  4. 继续扫描 id=30,加临键锁:(20, 30]
  5. 继续扫描到 Supremum(虚拟最大记录),加间隙锁:(30, +∞)

最终加锁范围:(10, +∞) 的所有记录和间隙都被锁定,任何在这个范围内的 INSERT 都会被阻塞。

注意:id=10 本身是否被锁取决于 MySQL 版本和具体场景。在某些版本中,id > 10 的查询中,优化器会先访问 id=10 的记录再决定不包含它,此时可能对 id=10 加记录锁但不加间隙锁(即不会阻塞 id < 10 范围内的插入)。

3.3 二级索引查询的双重加锁

场景SELECT * FROM t WHERE age = 25 FOR UPDATEage 列有普通索引(非唯一)。

表中 age=25 的行有两条:id=10, age=25 和 id=30, age=25。age 索引中 25 的前后值分别为 20 和 30。

推导步骤

  1. 查询走二级索引 idx_age,等值查询但 age 不是唯一索引(可能多行 age=25)
  2. idx_age 上,对 age=25 对应的所有记录加临键锁:(20, 25] 范围的临键锁
  3. 由于不是唯一索引,还需锁住 age=25 之后的第一个间隙,防止插入 age=25 的新行:加 (25, 30) 的间隙锁
  4. 对二级索引匹配的每条记录,在主键索引上加排他记录锁(id=10 和 id=30 各一把记录锁)

最终加锁范围:

  • idx_age 上:(20, 25] 临键锁 + (25, 30) 间隙锁(合并即锁住 (20, 30) 内 age 不存在的间隙以及 age=25 的记录)
  • 主键索引上:id=10 和 id=30 的记录锁

第 4 章 意向锁的实现细节

4.1 意向锁解决的问题

假设事务 A 持有表 orders 上某行的排他锁(行级 X 锁)。这时,事务 B 想对 orders 表加表级共享锁(如 LOCK TABLE orders READ)。

如果没有意向锁,InnoDB 要判断”表上是否已有行级锁”,就必须扫描整张表的每一行,检查是否有行被锁定——这是 O(n) 的操作,n 是表的行数。

意向锁(Intention Lock)是表级锁,在事务对某行加行级锁之前,先对表加对应的意向锁:

  • 加行级共享锁(S)前,先加表级意向共享锁(IS)
  • 加行级排他锁(X)前,先加表级意向排他锁(IX)

有了意向锁,事务 B 只需要检查 orders 表上是否有 IX 或 X 的意向锁,就能知道是否有其他事务持有行级排他锁——O(1) 的判断。

4.2 意向锁的兼容性矩阵

已持有 \ 申请ISIXS(表锁)X(表锁)
IS兼容兼容兼容冲突
IX兼容兼容冲突冲突
S兼容冲突兼容冲突
X冲突冲突冲突冲突

意向锁之间(IS-IS, IS-IX, IX-IX)都兼容——这意味着多个事务可以同时对同一张表的不同行加行级锁,不会因为意向锁互相阻塞。只有当一个事务要加表级锁时,才会与意向锁产生冲突。


第 5 章 死锁检测的内部算法

5.1 等待图(Wait-for Graph)

InnoDB 使用等待图(Wait-for Graph) 来检测死锁。这是一个有向图:

  • 每个节点代表一个事务
  • 如果事务 A 正在等待事务 B 持有的锁,就有一条从 A 指向 B 的有向边

当图中出现环(Cycle) 时,说明存在死锁。

5.2 死锁检测的触发时机

死锁检测不是持续进行的,而是在每次有新的锁等待时触发

  1. 事务 T 尝试申请一个锁,发现该锁被其他事务持有
  2. T 进入等待队列,同时触发死锁检测
  3. InnoDB 以 T 为起点,在等待图中做深度优先搜索(DFS),查找是否能从 T 到达 T 自身(即是否存在包含 T 的环)
  4. 如果发现环,选择”代价最小”的事务(innodb_deadlock_detect 算法:undo log 数量少的事务优先被牺牲)作为 Victim,回滚该事务
  5. 回滚 Victim 释放其持有的锁,其他等待该锁的事务可以继续

5.3 死锁检测的性能影响

死锁检测的时间复杂度是 O(T × L),其中 T 是当前活跃事务数,L 是每个事务持有的锁数量。在高并发场景下(几百个并发事务),每次锁等待都需要遍历整个等待图,可能成为性能瓶颈。

参数 innodb_deadlock_detect(默认 ON)控制是否启用死锁检测。如果确信应用代码不会产生死锁(如严格控制了加锁顺序),可以将其设为 OFF,改为让锁等待超时(innodb_lock_wait_timeout,默认 50 秒)来处理潜在的死锁。但这会导致死锁时事务等待 50 秒后才超时,响应很慢——不推荐在生产环境关闭死锁检测,除非有充分的理由和测试验证。


第 6 章 锁监控与诊断

6.1 查看当前锁信息

-- 查看 InnoDB 当前的锁等待(MySQL 8.0+)
SELECT * FROM performance_schema.data_lock_waits;
 
-- 查看所有持有的锁
SELECT 
    ENGINE_TRANSACTION_ID AS trx_id,
    OBJECT_SCHEMA AS db,
    OBJECT_NAME AS tbl,
    INDEX_NAME AS idx,
    LOCK_TYPE,
    LOCK_MODE,
    LOCK_STATUS,
    LOCK_DATA
FROM performance_schema.data_locks
ORDER BY ENGINE_TRANSACTION_ID;

LOCK_DATA 列特别有用,它显示了被锁定的记录的键值(对于记录锁)或间隙的边界(对于间隙锁),帮助你理解”锁住了哪个范围”。

6.2 LOCK_MODE 字段解读

LOCK_MODE 值含义
S共享临键锁(S Next-Key Lock)
S,REC_NOT_GAP共享记录锁(不含间隙)
S,GAP共享间隙锁
X排他临键锁(X Next-Key Lock)
X,REC_NOT_GAP排他记录锁(不含间隙)
X,GAP排他间隙锁
X,INSERT_INTENTION插入意向间隙锁

第 7 章 小结

本文从底层实现角度构建的锁知识体系:

  1. Lock Bitmap:锁信息存储在独立内存中,每事务每页一个 Lock 结构,通过 bit 数组标记被锁定的行。比”每行一个锁对象”节省大量内存
  2. 锁加在索引上:行锁是对 B+Tree 索引记录的锁定,没有索引时扫描全表所有记录并加临键锁(等同于全表锁)
  3. 三种行锁的精确语义:记录锁(单行)、间隙锁(区间内禁止插入)、临键锁(记录+间隙,RR 级别默认)
  4. 间隙锁之间兼容:Gap Lock 之间不冲突,只与插入意向锁冲突——这是防止写操作互相因间隙锁死锁的关键设计
  5. 加锁退化规则:等值查询命中唯一索引 → 记录锁;等值查询未命中 → 间隙锁;范围查询 → 临键锁;非唯一索引等值查询 → 临键锁 + 后续间隙锁
  6. 意向锁:表级锁,O(1) 判断表上是否有行级锁,避免 O(n) 全表扫描
  7. 死锁检测算法:等待图 DFS 查环,代价最小的事务被回滚。高并发时检测代价随活跃事务数增长

思考题

  1. Binlog 记录了所有修改数据的 SQL 语句(Statement 格式)或行变更(Row 格式)。Row 格式记录了每行数据的前后值——精确但体积大。Statement 格式记录 SQL 语句——体积小但某些语句在从库执行结果可能不同(如 NOW()UUID())。Mixed 格式自动选择——在什么场景下 Mixed 仍然可能导致主从不一致?
  2. MySQL 的主从复制延迟是常见问题——从库的 SQL 线程单线程回放 Binlog,而主库是多线程并发写入。MySQL 5.7+ 支持多线程复制(slave_parallel_workers)——按库(DATABASE)或按事务组(LOGICAL_CLOCK)并行回放。在什么场景下 LOGICAL_CLOCK 比 DATABASE 并行更高效?
  3. GTID(Global Transaction Identifier)为每个事务分配全局唯一 ID。在主从切换时,GTID 使得新从库可以自动找到复制的起始位置——无需手动指定 Binlog 位点。GTID 复制与传统位点复制相比,在运维便利性和限制(如不支持 CREATE TABLE ... SELECT)方面有什么权衡?