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 索引节点上特定记录的加锁,而不是对行数据本身的加锁。原因是:
- 行数据通过聚簇索引组织:行数据就是主键 B+Tree 的叶子节点,“对行加锁”在物理上等同于”对主键索引的某个叶子记录加锁”
- 二级索引查询也需要锁:通过二级索引查询时,先在二级索引上加锁,再对对应的主键索引上的行加锁——两个索引都需要锁定,以防止其他事务通过不同索引路径修改同一行
- 间隙锁依赖索引的有序性:间隙是”两个相邻索引记录之间的空间”,只有在有序的 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 Lock | Gap Lock | Next-Key Lock | Insertion 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 存在。
推导步骤:
- 查询走主键(聚簇)索引,等值查询精确命中 id=10 的记录
- InnoDB 默认加临键锁:
(上一条记录的 id, 10] - 等值查询命中唯一索引时,临键锁退化为记录锁:只锁 id=10 这条记录,不锁间隙
- 最终加锁:id=10 上的排他记录锁
退化的理由:id 是唯一索引,永远不会有另一条 id=10 的记录被插入(唯一约束保证了这一点)。间隙锁的目的是防止幻读,而等值查询 + 唯一索引的场景下,幻读(出现另一条 id=10 的记录)是不可能的,所以间隙锁没有存在的意义。
场景:SELECT * FROM t WHERE id = 10 FOR UPDATE,表中 id=10 不存在(id 的已有值为 5 和 15)。
- 查询走主键索引,等值查询未命中任何记录
- InnoDB 找到 id=15(第一个大于 10 的记录)
- 加间隙锁:
(5, 15)这个间隙(注意:是开区间,不包含 5 和 15) - 阻止任何事务在 5 和 15 之间插入新记录(防止幻读:事务再次查询 id=10 时不会出现新行)
3.2 主键范围查询的加锁推导
场景:SELECT * FROM t WHERE id > 10 FOR UPDATE,表中有 id = 10, 20, 30。
推导步骤:
- 查询走主键索引,范围查询
- 从 id > 10 的第一条记录开始(id=20)向后扫描
- 对 id=20 加临键锁:
(10, 20] - 继续扫描 id=30,加临键锁:
(20, 30] - 继续扫描到 Supremum(虚拟最大记录),加间隙锁:
(30, +∞)
最终加锁范围:(10, +∞) 的所有记录和间隙都被锁定,任何在这个范围内的 INSERT 都会被阻塞。
注意:id=10 本身是否被锁取决于 MySQL 版本和具体场景。在某些版本中,id > 10 的查询中,优化器会先访问 id=10 的记录再决定不包含它,此时可能对 id=10 加记录锁但不加间隙锁(即不会阻塞 id < 10 范围内的插入)。
3.3 二级索引查询的双重加锁
场景:SELECT * FROM t WHERE age = 25 FOR UPDATE,age 列有普通索引(非唯一)。
表中 age=25 的行有两条:id=10, age=25 和 id=30, age=25。age 索引中 25 的前后值分别为 20 和 30。
推导步骤:
- 查询走二级索引
idx_age,等值查询但 age 不是唯一索引(可能多行 age=25) - 在
idx_age上,对 age=25 对应的所有记录加临键锁:(20, 25]范围的临键锁 - 由于不是唯一索引,还需锁住 age=25 之后的第一个间隙,防止插入 age=25 的新行:加
(25, 30)的间隙锁 - 对二级索引匹配的每条记录,在主键索引上加排他记录锁(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 意向锁的兼容性矩阵
| 已持有 \ 申请 | IS | IX | S(表锁) | 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 死锁检测的触发时机
死锁检测不是持续进行的,而是在每次有新的锁等待时触发:
- 事务 T 尝试申请一个锁,发现该锁被其他事务持有
- T 进入等待队列,同时触发死锁检测
- InnoDB 以 T 为起点,在等待图中做深度优先搜索(DFS),查找是否能从 T 到达 T 自身(即是否存在包含 T 的环)
- 如果发现环,选择”代价最小”的事务(
innodb_deadlock_detect算法:undo log 数量少的事务优先被牺牲)作为 Victim,回滚该事务 - 回滚 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 章 小结
本文从底层实现角度构建的锁知识体系:
- Lock Bitmap:锁信息存储在独立内存中,每事务每页一个 Lock 结构,通过 bit 数组标记被锁定的行。比”每行一个锁对象”节省大量内存
- 锁加在索引上:行锁是对 B+Tree 索引记录的锁定,没有索引时扫描全表所有记录并加临键锁(等同于全表锁)
- 三种行锁的精确语义:记录锁(单行)、间隙锁(区间内禁止插入)、临键锁(记录+间隙,RR 级别默认)
- 间隙锁之间兼容:Gap Lock 之间不冲突,只与插入意向锁冲突——这是防止写操作互相因间隙锁死锁的关键设计
- 加锁退化规则:等值查询命中唯一索引 → 记录锁;等值查询未命中 → 间隙锁;范围查询 → 临键锁;非唯一索引等值查询 → 临键锁 + 后续间隙锁
- 意向锁:表级锁,O(1) 判断表上是否有行级锁,避免 O(n) 全表扫描
- 死锁检测算法:等待图 DFS 查环,代价最小的事务被回滚。高并发时检测代价随活跃事务数增长
思考题
- Binlog 记录了所有修改数据的 SQL 语句(Statement 格式)或行变更(Row 格式)。Row 格式记录了每行数据的前后值——精确但体积大。Statement 格式记录 SQL 语句——体积小但某些语句在从库执行结果可能不同(如
NOW()、UUID())。Mixed 格式自动选择——在什么场景下 Mixed 仍然可能导致主从不一致?- MySQL 的主从复制延迟是常见问题——从库的 SQL 线程单线程回放 Binlog,而主库是多线程并发写入。MySQL 5.7+ 支持多线程复制(
slave_parallel_workers)——按库(DATABASE)或按事务组(LOGICAL_CLOCK)并行回放。在什么场景下 LOGICAL_CLOCK 比 DATABASE 并行更高效?- GTID(Global Transaction Identifier)为每个事务分配全局唯一 ID。在主从切换时,GTID 使得新从库可以自动找到复制的起始位置——无需手动指定 Binlog 位点。GTID 复制与传统位点复制相比,在运维便利性和限制(如不支持
CREATE TABLE ... SELECT)方面有什么权衡?