1 问题背景与核心结论
InnoDB 通过多粒度锁协调并发读写:表级锁管理开销低但冲突面大;行级锁把锁定范围收缩到索引记录及其间隙,是在 OLTP 场景下支撑高并发的关键。意向锁则在表级与行级之间建立 O(1) 的冲突检测通道,避免每次申请表锁时遍历全表行锁。
本文聚焦 InnoDB,讨论表级锁、意向锁、行级锁(记录锁、间隙锁、临键锁)的触发条件与协同规则,不同 SQL 在当前读场景下的加锁行为,以及死锁的检测、观测与规避。普通 SELECT 默认走 MVCC 快照读,不进入下文行锁规则;记录锁、间隙锁、临键锁主要作用于当前读(SELECT ... FOR UPDATE/SHARE、UPDATE、DELETE 等)。
核心结论可以概括为四点:
- 多粒度分工:表级锁锁定整表;行级锁落在索引结构上,形态包括记录锁、间隙锁、临键锁;意向锁(IS/IX)是表级辅助锁,声明事务后续将在部分行上加 S/X 锁。
- RR 下的范围锁:默认隔离级别为 REPEATABLE READ 时,当前读对范围扫描倾向于使用临键锁(记录锁 + 前间隙),并在唯一索引等值命中等场景下降级为记录锁或间隙锁,以缩小锁范围。
- 幻读与 MVCC:快照读依靠 Read View 解决一致性问题;当前读在 RR 下仍可能看到别的事务新插入的行,因此需要间隙锁/临键锁阻止间隙内插入。
- 死锁处理:InnoDB 用等待图主动检测循环等待,回滚代价较小的事务;工程上应优先缩短持锁时间、缩小锁范围、统一访问顺序,而不是依赖数据库事后回滚。
2 基础认知
2.1 锁要解决什么问题
数据库锁是事务访问共享资源时的互斥机制,目标是在隔离性与并发度之间取得平衡:粒度越细,冲突概率越低,但锁管理成本越高。InnoDB 默认走行级锁;MyISAM、MEMORY 等引擎仅支持表级锁,且不支持事务。
| 特性 | InnoDB | MyISAM/MEMORY |
|---|---|---|
| 锁粒度 | 行级 + 表级 | 仅表级 |
| 事务 | 支持 | 不支持 |
| 典型场景 | OLTP、高并发写入 | 历史只读遗留系统 |
2.2 快照读与当前读
两种读法决定是否会触发行锁:
| 类型 | 典型语句 | 机制 | 是否加行锁 |
|---|---|---|---|
| 快照读 | 普通 SELECT | MVCC + Read View | 通常不加 |
| 当前读 | SELECT ... FOR UPDATE/SHARE、UPDATE、DELETE | 读最新版本并加锁 | 会加 |
后文加锁规则均指当前读在 RR 隔离级别下的行为,除非单独说明。
2.3 行锁落在索引上
InnoDB 行锁不是锁"物理行",而是锁索引记录或索引间隙。加锁范围由执行计划实际访问的索引与扫描范围决定,而不是 WHERE 字面的直觉。
- 主键/唯一索引等值命中:通常只锁一条索引记录。
- 二级索引定位:先在二级索引上加锁,再回表锁聚簇索引上的主键记录。
- 未走有效索引:不会语法层面"升级为表锁",但会扫描更大范围并对途经对象持续加锁,业务上接近大范围阻塞。
2.4 用 data_locks 观察 InnoDB 锁
performance_schema.data_locks 是 MySQL 8.0 观察 InnoDB 当前持有或等待中的锁 的主要视图。它记录的是存储引擎层的表锁、意向锁、记录锁、间隙锁等,不包含 MDL;普通快照读通常也不会出现在这里。
2.4.1 前置条件
- MySQL 8.0+,且
performance_schema已启用。 - 只有当前读和写入语句才会产生可观测的 InnoDB 行锁;Autocommit 下的单条普通
SELECT一般查不到行锁,但长事务里的FOR UPDATE、UPDATE等会显示。 - 排查阻塞时,通常配合
performance_schema.data_lock_waits一起看:前者看"锁在哪里",后者看"谁在等谁"。
2.4.2 常用查询
SELECT *
FROM performance_schema.data_locks;
线上排查建议带上表名和事务 ID,并关联等待关系:
SELECT
dl.ENGINE_TRANSACTION_ID,
dl.OBJECT_SCHEMA,
dl.OBJECT_NAME,
dl.INDEX_NAME,
dl.LOCK_TYPE,
dl.LOCK_MODE,
dl.LOCK_STATUS,
dl.LOCK_DATA
FROM performance_schema.data_locks AS dl
WHERE dl.OBJECT_SCHEMA = 'your_db'
AND dl.OBJECT_NAME = 'your_table'
ORDER BY dl.ENGINE_TRANSACTION_ID, dl.LOCK_TYPE, dl.LOCK_DATA;
SELECT
dlw.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx,
dlw.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx
FROM performance_schema.data_lock_waits AS dlw;
2.4.3 核心字段解读
| 字段 | 含义 | 读法 |
|---|---|---|
ENGINE_TRANSACTION_ID | InnoDB 内部事务 ID | 同 ID 的多行锁通常属于同一事务;与 information_schema.innodb_trx.trx_id 对应 |
OBJECT_SCHEMA / OBJECT_NAME | 库名 / 表名 | 锁落在哪张表 |
INDEX_NAME | 索引名 | PRIMARY 表示聚簇索引;二级索引名表示锁在二级索引上;NULL 常见于表级锁 |
LOCK_TYPE | 锁对象类型 | TABLE = 表级锁(含 IS/IX/S/X);RECORD = 行级锁(记录 / 间隙 / 临键) |
LOCK_MODE | 锁模式 | 见下表 |
LOCK_STATUS | 锁状态 | GRANTED = 已持有;WAITING = 正在等待 |
LOCK_DATA | 锁定标识 | 对 RECORD 锁,通常是主键值或索引键值;间隙锁可能显示区间边界值 |
LOCK_MODE 常见取值(RECORD 类型):
| LOCK_MODE | 含义 |
|---|---|
X,REC_NOT_GAP | 记录锁,只锁已有索引记录 |
X,GAP | 间隙锁,锁索引间隙,不锁已有记录 |
S / X(LOCK_TYPE=RECORD) | 临键锁,锁记录同时锁间隙 |
X,GAP,INSERT_INTENTION | 插入意向锁,INSERT 在目标间隙上的插入意图 |
IX / IS / S / X(LOCK_TYPE=TABLE) | 表级意向锁或表级 S/X 锁 |
LOCK_DATA 的理解要点:
- 聚簇索引上的
LOCK_DATA一般是主键值,例如10表示与主键 10 相关的锁。 - 二级索引上的
LOCK_DATA通常是二级索引列 + 主键的组合展示,说明锁在哪个索引项上。 - 间隙锁的
LOCK_DATA往往表现为某个索引边界值,表示"在该值附近的间隙";具体展示格式与索引类型有关,需结合INDEX_NAME和当时 SQL 的扫描范围理解。 - 索引最左端、最右端的开区间,分别由 infimum / supremum 伪记录 作为边界;在
data_locks或SHOW ENGINE INNODB STATUS中可能直接出现supremum pseudo-record字样(详见 2.4.4 节)。 LOCK_DATA为NULL时,多见于部分表级锁,不代表"没有加锁"。
2.4.4 infimum / supremum 伪记录
InnoDB 把每个索引组织成按键值排序的记录链表。链表两端各有一个哨兵伪记录(pseudo-record),它们不是用户插入的业务行,而是引擎内部用来表示索引边界的占位节点:
| 伪记录 | 作用 |
|---|---|
| infimum | 索引逻辑下界,键值小于任何合法索引值 |
| supremum | 索引逻辑上界,键值大于任何合法索引值 |
和间隙锁的关系
间隙锁锁的是两条索引记录之间的逻辑区间。对 LOCK_DATA 有一个常用读法:它通常标识该间隙的右边界记录——锁住的 gap 在这条记录的左侧。
以主键 10, 11, 13, 20 为例:
| 逻辑间隙 | 右边界 | LOCK_DATA 通常指向 |
|---|---|---|
(13, 20) | 主键 20 | 20 |
(20, +∞) | supremum | supremum 伪记录(而非用户可见的 20) |
(-∞, 10) | 主键 10 | 10(左边界 infimum 一般不在 LOCK_DATA 中单独展示) |
当范围扫描或 INSERT 涉及索引最右端时,就容易在锁信息里看到 supremum pseudo-record。它表示"最后一条用户记录之后、直到索引末尾"的整个开区间,而不是某条真实业务数据。
在不同视图中的呈现
performance_schema.data_locks:LOCK_MODE为X,GAP或X时,LOCK_DATA多数情况下是右边界键值;最右端间隙则关联 supremum。SHOW ENGINE INNODB STATUS的LATEST DETECTED DEADLOCK段落,往往更直白地写出supremum pseudo-record,便于判断是否在索引尾部发生了间隙锁 / 插入意向锁冲突。
看到 supremum 时,应结合 INDEX_NAME 和当时 SQL 的扫描上界理解:常见场景包括范围条件扫到索引末尾、向最大键值之后插入、或唯一索引等值未命中时锁住了"尾部间隙"。
2.4.5 示例与读法
假设事务 A 执行:
BEGIN;
SELECT * FROM orders WHERE id = 10 FOR UPDATE;
-- 暂不提交
data_locks 中可能出现类似行(数值仅为示意):
| ENGINE_TRANSACTION_ID | OBJECT_NAME | INDEX_NAME | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
|---|---|---|---|---|---|---|
| 4231 | orders | NULL | TABLE | IX | GRANTED | NULL |
| 4231 | orders | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 10 |
读法:
- 第一行:
TABLE+IX表示事务在表级持有意向排他锁,声明后续会在部分行上加 X 锁。 - 第二行:
RECORD+X,REC_NOT_GAP+PRIMARY+10表示在聚簇索引主键10上持有排他记录锁。 - 若
LOCK_STATUS = WAITING,说明该锁尚未拿到,需要到data_lock_waits查是谁阻塞了它。
若改为范围扫描:
SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;
则可能看到多行 RECORD 锁,LOCK_MODE 出现 X 或同时存在 X,REC_NOT_GAP 与 X,对应临键锁 / 记录锁组合;LOCK_DATA 会出现多个键值或间隙边界,锁范围明显大于单条等值查询。
2.4.6 使用边界
- 查不到锁,不等于没有阻塞:可能是 MDL 等待、Server 层锁,或语句仍是快照读。
- 同一事务锁很多行,常见于范围扫描、二级索引回表、未走合适索引,是线上锁冲突放大的直接信号。
data_locks是当前快照,不会保留历史;死锁发生后的完整现场需结合SHOW ENGINE INNODB STATUS中的LATEST DETECTED DEADLOCK(见 7.3 节)。
3 表级锁与 MDL
3.1 InnoDB 表级锁类型
InnoDB 表级锁在业务中较少显式使用,主要出现在 LOCK TABLES 等场景:
- S 锁:
LOCK TABLES ... READ,允许并发读,阻塞写。 - X 锁:
LOCK TABLES ... WRITE,阻塞其他会话对该表的加锁请求。 - IS/IX:意向锁,由 InnoDB 在申请行锁前自动维护,用户不可手动控制。
3.2 MDL 不是 InnoDB 行锁
元数据锁(Metadata Lock,MDL) 属于 MySQL Server 层,保护的是表结构元数据(表是否存在、列定义、索引定义等),而不是 InnoDB 里的数据行。任何会话访问一张表时通常都要先获取 MDL,哪怕只是普通 SELECT;而 ALTER TABLE、DROP TABLE 等 DDL 则需要更强的 MDL,以便在表结构变更期间避免访问语义混乱。
3.2.1 长事务未提交,阻塞 DDL
典型场景如下:
-- 会话 A
BEGIN;
SELECT * FROM orders WHERE id = 1;
-- 长时间不 COMMIT / ROLLBACK
-- 会话 B
ALTER TABLE orders ADD COLUMN remark VARCHAR(255);
-- 等待 MDL
会话 A 执行 SELECT 后会持有该表的 MDL 读锁;ALTER TABLE 需要 MDL 排他锁,二者互斥,因此 B 只能等待 A 释放 MDL。A 不结束,B 就一直卡住。
这里的关键点是:即使只是 SELECT,只要事务未提交,也会持续占用 MDL。线上常见根因包括:事务中夹杂外部 RPC 或人工交互、批处理脚本长时间不提交、连接池泄漏导致悬挂事务等。表面现象是 ALTER TABLE 很慢,根因往往是前面有个长事务占着 MDL。
3.2.2 排队 DDL 反向阻塞后续 DML
更隐蔽、也更容易造成"整表被锁死"的是第二种情况。在 3.2.1 的基础上,再加入会话 C:
-- 会话 C
INSERT INTO orders(id, amount) VALUES (1001, 99);
-- 同样等待 MDL
直觉上,A 持有读锁、C 申请读/写锁,二者本应兼容;但 MySQL 有一条重要规则:一旦某个 DDL 已经在等待 MDL 排他锁,后续新的 MDL 请求也会被阻塞,即使新请求与当前持有者原本兼容。
因此等待链通常是这样的:
A(长事务 SELECT,占 MDL 读锁)
→ 阻塞 B(ALTER TABLE,等待 MDL 排他锁)
→ 反向阻塞 C、D、E...(后续所有 DML / SELECT)
B 虽然还没真正开始改表,但已进入等待队列;从这一刻起,新来的业务 SQL 不能再拿到 MDL。这样设计是为了避免 DDL 执行期间仍有新会话按旧结构访问表,导致元数据状态不一致。结果是:ALTER TABLE 在等,后续对该表的所有读写也在等,业务上像整张表被锁住,但根因是 MDL 等待链,不是 InnoDB 行锁。
sequenceDiagram
participant A as 会话A(长事务)
participant B as 会话B(DDL)
participant C as 会话C(业务DML)
A->>A: BEGIN
A->>A: SELECT 持有 MDL 读锁
B->>B: ALTER TABLE(申请 MDL 排他锁,等待 A)
Note over B: DDL 进入等待队列
C->>C: INSERT / SELECT(申请 MDL,被 B 的等待阻塞)
Note over C: 即使与 A 原本兼容,也不能进入
A->>A: COMMIT
B->>B: 获得 MDL 排他锁,执行 ALTER
C->>C: 随后继续3.2.3 与 InnoDB 行锁的区分
| MDL | InnoDB 行锁 | |
|---|---|---|
| 层级 | MySQL Server | InnoDB 存储引擎 |
| 保护对象 | 表结构元数据 | 索引记录 / 间隙 |
| 普通 SELECT | 会持有(语句或事务期间) | 快照读通常不加行锁 |
| 典型阻塞 | 长事务 + DDL | 并发更新、间隙锁、死锁 |
| 排查视图 | performance_schema.metadata_locks | data_locks / data_lock_waits |
线上"整表卡住"常见两类根因:
- InnoDB 锁:
LOCK TABLES、大范围行锁 / 间隙锁。 - MDL 等待:长事务未提交阻塞 DDL,或排队 DDL 反向阻塞后续 DML。
排查时应先区分二者。MDL 问题常见特征是:ALTER TABLE 处于 Waiting for table metadata lock,后续大量普通 SQL 也报同样等待,而队首往往有一个长时间未提交的事务。
规避上,核心是缩短事务、DDL 选低峰并使用在线 schema 变更工具(如 gh-ost、pt-online-schema-change),以及 DDL 前先检查目标表是否存在长事务占用 MDL。
4 意向锁
4.1 设计动机
多粒度锁并存时,若申请表级 S/X 锁需遍历所有行锁,复杂度为 O(N)。意向锁把检测提升到表级:事务在行上加锁前先获取 IS 或 IX,使冲突判断变为 O(1)。
4.2 兼容矩阵
横向为已持有锁,纵向为当前请求:
| 表 S | 表 X | IS | IX | |
|---|---|---|---|---|
| 表 S | ✓ | ✗ | ✓ | ✗ |
| 表 X | ✗ | ✗ | ✗ | ✗ |
| IS | ✓ | ✗ | ✓ | ✓ |
| IX | ✗ | ✗ | ✓ | ✓ |
要点:IS/IX 彼此兼容;IX 与表 S 互斥;IX 与表 X 互斥。意向锁不阻塞其他事务的行锁请求,只参与表级锁冲突判断。
5 行级锁
5.1 三种形态与 S/X 模式
需要先区分两个维度:
- 锁定范围:记录锁、间隙锁、临键锁。
- 锁模式:S(共享)与 X(排他)。
| 形态 | 锁定对象 | 主要作用 |
|---|---|---|
| 记录锁 | 已有索引记录 | 防止并发修改同一条记录 |
| 间隙锁 | 两条记录之间的间隙 | 阻止间隙内插入 |
| 临键锁 | 左开右闭 (prev, curr] | 同时保护记录与前间隙 |
| 插入意向锁 | 目标插入位置所在的间隙 | INSERT 插入前声明意图;与间隙锁配合控制并发插入 |
S/X 兼容规则:S-S 兼容;S-X、X-X 互斥。
5.2 间隙锁与临键锁示例
假设主键存在 10, 11, 13, 20。InnoDB 在索引链表两端还有 infimum、supremum 两个伪记录作为哨兵,用户数据实际夹在二者之间。逻辑间隙为:
(infimum, 10), (10, 11), (11, 13), (13, 20), (20, supremum)
等价于直觉上的 (-∞, 10) … (20, +∞)。在 data_locks 或死锁日志里,最右端间隙常体现为 supremum pseudo-record(见 2.4.4 节)。
执行 SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE 时,RR 下典型锁范围为:
(infimum, 10], (10, 11], (11, 13], (13, 20], (20, supremum)
实际上这样的范围查询是可以不用锁住 (20, supremum) 这个间隙的(我自己的理解),就算并发事务插入了这个范围的数据,也不会出现幻读问题。
间隙锁之间互相兼容,但会阻塞在该间隙上的插入意向锁(详见 5.6 节);因此其他事务向 (11, 13) 插入会被阻塞,但未必阻塞对已有记录 11 的更新(取决于是否持有记录锁)。
间隙锁与临键锁主要在 RR 生效;RC 下通常只用记录锁(外键/唯一键检查等场景除外)。
5.3 临键锁降级规则
RR 当前读可先把策略理解为"先按临键锁扫描,再尽可能降级":
- 唯一索引等值命中已有记录 → 记录锁(不锁间隙)。
- 唯一索引等值未命中 → 间隙锁(锁住访问到的最后一个索引对象所在的间隙)。
- 非唯一索引等值或任意范围扫描 → 通常保持临键锁;普通索引等值扫描向右遍历时,边界处可能退化为间隙锁。
- 唯一索引范围扫描 → 可能扫描到第一个不满足条件的记录;该边界行为是 InnoDB 实现细节,线上应通过
EXPLAIN与data_locks验证,而非死记口诀。
下面是《MySQL 实战 45 讲》中总结的加锁规则:
- 原则 1:加锁的基本单位是 next-key lock。是一个前开后闭区间。
- 原则 2:查找过程中访问到的对象才会加锁。
- 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
- 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
- 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
5.4 隐式锁与显式锁
UPDATE、DELETE、SELECT ... FOR UPDATE 会在锁系统中登记显式锁,这是可以被等待和检测的真实锁结构。
而 INSERT 在未发生冲突时,新记录通过事务 ID 表达占有关系,形成隐式锁;当其他事务尝试修改该未提交记录时,隐式锁才转换为显式锁参与等待与死锁检测。
这个设计的核心价值是减少锁系统开销。大量插入场景下,如果每插入一条记录都立即创建显式锁,锁系统需要维护的结构会急剧膨胀;而隐式锁采用的是一种 “延迟显式化” 的策略:没有事务冲突时,只依赖记录上的事务信息表达占有关系;真正发生冲突时,再转换为显式锁,参与等待和死锁检测。
5.5 二级索引的双层加锁
如果语句通过主键索引定位记录,InnoDB 可以直接在聚簇索引记录上加锁;而如果语句通过二级索引定位记录,InnoDB 通常会先在二级索引记录上加锁,再根据二级索引记录中保存的主键值,回到聚簇索引上锁住对应的主键记录。
这意味着,二级索引查询可能同时涉及两类锁对象:
- 二级索引上的记录、间隙或临键锁:用于保护二级索引扫描范围,防止其他事务修改或插入影响当前扫描结果的索引项。
- 聚簇索引上的记录锁:用于保护真正的数据行,防止其他事务并发修改或删除对应主键记录。
很多线上死锁并不是简单发生在 “同一条主键记录” 上,而是发生在二级索引锁与主键记录锁的交叉获取过程中。例如,事务 A 先通过二级索引锁住一批索引项,再回表申请主键记录锁;事务 B 先通过主键更新记录,再维护二级索引项。只要两个事务访问资源的顺序形成交叉,就可能构成循环等待。
5.6 插入意向锁
插入意向锁(Insert Intention Lock) 是 INSERT 在真正写入索引记录之前,在目标间隙上申请的一种特殊 gap 锁。它的 LOCK_MODE 在 data_locks 中通常显示为 X,GAP,INSERT_INTENTION。
需要先和表级 IX(意向排他锁) 区分开:
| 表级 IX | 插入意向锁 | |
|---|---|---|
| 粒度 | 整张表 | 某个索引上的某个间隙 |
| 触发语句 | INSERT / UPDATE / DELETE 等写操作 | 仅 INSERT(及 INSERT-类写入) |
| 作用 | 声明"后续会在部分行上加 X 锁" | 声明"我准备在这个间隙的某个位置插入新记录" |
在 data_locks 中 | LOCK_TYPE=TABLE,LOCK_MODE=IX | LOCK_TYPE=RECORD,LOCK_MODE=X,GAP,INSERT_INTENTION |
5.6.1 为什么需要插入意向锁
如果只有普通间隙锁,无法精细表达"多个事务要在同一间隙的不同位置各插一条"这类并发插入需求。插入意向锁的设计目标是:
- 与普通间隙锁 / 临键锁冲突:当前读已经锁住了某个间隙时,新的
INSERT必须等待,避免幻读或被范围锁排斥。 - 彼此兼容:多个事务在同一间隙插入不同位置的新记录时,不必互相等待。
因此,插入意向锁不是"锁住行",而是在插入发生前,对目标间隙中的插入意图做冲突检测。
5.6.2 兼容关系
| 已持有 \ 新请求 | 间隙锁 | 插入意向锁 | 临键锁 |
|---|---|---|---|
| 间隙锁 | 兼容 | 互斥 | — |
| 插入意向锁 | 互斥 | 兼容 | — |
| 临键锁 | — | 互斥 | 按 X/S 模式判断 |
要点:
- 间隙锁 vs 间隙锁:兼容。多个事务可以同时持有同一间隙上的 gap lock。
- 插入意向锁 vs 插入意向锁:兼容。同一间隙内插入
id=12与id=18通常可并行。 - 间隙锁 / 临键锁 vs 插入意向锁:互斥。这是
INSERT被SELECT ... FOR UPDATE范围扫描阻塞的直接原因。
5.6.3 INSERT 的加锁顺序
一次普通 INSERT 在 InnoDB 层大致按以下顺序进行:
sequenceDiagram
participant T as 事务
participant Idx as 索引
T->>T: 获取表级 IX
T->>Idx: 定位新记录应落入的间隙
T->>Idx: 申请插入意向锁(X,GAP,INSERT_INTENTION)
alt 间隙已被 gap/next-key 锁住
Idx-->>T: 等待
else 获取成功
T->>Idx: 写入新记录(隐式锁)
end若表上有多个索引(主键 + 二级唯一索引等),每个需要写入的索引都可能各自申请插入意向锁。
5.6.4 示例
主键现有 10, 20。事务 A 执行:
BEGIN;
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 不提交
此时 A 在 (10, 20) 等间隙上持有 gap / next-key 锁。事务 B 执行:
INSERT INTO t(id) VALUES (15);
B 需要在 (10, 20) 间隙上申请插入意向锁,但与 A 的间隙锁冲突,因此 B 进入等待,直到 A 提交或回滚。
反例:若没有事务持有 (10, 20) 上的间隙锁,则:
-- 事务 B
INSERT INTO t(id) VALUES (12);
-- 事务 C
INSERT INTO t(id) VALUES (18);
B 与 C 的插入意向锁彼此兼容,通常不会互相阻塞。
5.6.5 与死锁、线上排查的关系
插入意向锁本身很少单独构成死锁,更常见的是:
- 事务 A 持大范围 gap / next-key 锁 → 事务 B 的
INSERT等插入意向锁 → 业务表现为"插入突然变慢"。 - 多个事务交叉持有 gap 锁并同时等待插入意向锁,或
INSERT与UPDATE路径交叉,可能形成等待环(需用LATEST DETECTED DEADLOCK还原)。
排查 INSERT 阻塞时,不要只盯新记录上的隐式锁,应先检查是否有前置的范围查询、FOR UPDATE、UPDATE、DELETE 占住了相关间隙。
6 SQL 语句的加锁规则
以下基于 InnoDB + RR + 当前读,且假设语句走了合适索引。
6.1 SELECT … FOR UPDATE / UPDATE / DELETE
三者加锁逻辑一致,扫描到的对象通常加 X 模式锁,并遵循第 5.3 节降级规则。未走有效索引时,锁范围随扫描范围线性放大。
6.2 SELECT … FOR SHARE
与 FOR UPDATE 的锁定范围相同,但使用 S 模式:允许多事务并发持有 S 锁,与 X 锁互斥。
6.3 INSERT
INSERT 不会主动对范围加临键锁,但链路包含(插入意向锁详见 5.6 节):
- 自动获取表级 IX。
- 定位新记录落入的间隙,申请 插入意向锁(
X,GAP,INSERT_INTENTION);若该间隙已被 gap / next-key 锁占用,则等待。 - 插入成功后以隐式锁占有新记录;与其他事务冲突时转为显式 X 记录锁。
- 唯一键、外键、自增列检查可能引入额外锁。
同一间隙内不同插入位置的插入意向锁通常可并存;阻塞多来自已有的间隙锁 / 临键锁,而非插入之间互斥。线上 INSERT 卡住,优先排查同表是否有未提交的范围当前读。
6.4 执行计划决定锁范围
加锁分析的标准流程:
flowchart LR
A["是否当前读?"] -->|否| MVCC["MVCC,通常无行锁"]
A -->|是| B["隔离级别"]
B --> C["实际使用的索引"]
C --> D["等值/范围、唯一/非唯一、是否命中"]
D --> E["临键锁 → 降级为记录锁或间隙锁"]
E --> F["S 或 X 模式"]线上锁冲突排查,应优先 EXPLAIN 确认扫描范围,再对照 performance_schema.data_locks 中的 INDEX_NAME 与 LOCK_DATA。
7 死锁
7.1 成因
死锁本质是锁资源的循环等待,需同时满足互斥、持有且等待、不可剥夺、循环等待四个条件。InnoDB 中常见诱因:
- 交叉加锁:事务 A 先锁 row1 再锁 row2,事务 B 顺序相反。
- 二级索引与主键顺序不一致:回表路径与直接主键更新路径交叉。
- 间隙锁 + 插入:事务持有范围 gap / next-key 锁,另一事务
INSERT等待插入意向锁;若再与其他锁路径交叉,可能形成等待环(见 5.6.5 节,并结合LATEST DETECTED DEADLOCK还原)。 - 长事务放大冲突窗口:持锁久、扫描范围大,并不直接构成死锁,但显著提高碰撞概率。
7.2 检测与处理
innodb_deadlock_detect 默认为 ON。事务因锁阻塞时,InnoDB 用等待图检查是否存在环;一旦发现,回滚 undo 量较小的事务,客户端收到:
Deadlock found when trying to get lock; try restarting transaction
注意:LOCK TABLES、MyISAM 等不在 InnoDB 事务锁管理范围内,InnoDB 无法对其做死锁检测,只能依赖 innodb_lock_wait_timeout(默认 50 秒)超时回滚。
7.3 观测手段
| 来源 | 用途 |
|---|---|
performance_schema.data_locks | 当前持有/等待的锁对象、模式、索引、LOCK_DATA |
performance_schema.data_lock_waits | 等待边:谁在等谁 |
SHOW ENGINE INNODB STATUS | LATEST DETECTED DEADLOCK 保留最近一次死锁现场 |
performance_schema.metadata_locks | MDL 等待,与 InnoDB 行锁分开看 |
排查顺序:先排除 MDL → data_lock_waits 找阻塞链 → data_locks 定位索引与锁类型(字段解读见 2.4 节)→ 若已发生死锁,立即查看 INNODB STATUS。
示例:
SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME, INDEX_NAME,
LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
SELECT REQUESTING_ENGINE_TRANSACTION_ID, BLOCKING_ENGINE_TRANSACTION_ID
FROM performance_schema.data_lock_waits;
7.4 规避策略
数据库自动回滚只能止损,工程上应主动降低死锁率:
- 统一资源访问顺序(多行、多表场景最有效)。
- 缩短事务:外部 RPC、人工交互移出事务;写操作尽量后置并尽快提交。
- 缩小锁范围:保证
WHERE/JOIN走合适索引;能等值就不用范围;业务允许时可评估 RC(减少间隙锁,但需接受语义变化)。 - 合理索引:高频过滤列建索引;等值查询优先唯一索引,便于临键锁降级为记录锁。
- 应用层死锁重试:捕获死锁错误后有限次随机退避重试(通常 3–5 次)。
- 避免
LOCK TABLES与无计划 DDL:结构变更优先在线 schema 变更工具,控制长事务对 MDL 的影响。
典型场景速查:
| 场景 | 推荐做法 | 关键风险 |
|---|---|---|
| 库存扣减、转账 | 唯一索引等值 + FOR UPDATE + 短事务 | 索引失效导致大范围锁 |
| 报表一致性读 | FOR SHARE 或快照读 | 长事务持 S 锁阻塞写 |
| 只读查询 | 普通 SELECT(MVCC) | 无 |
| 批量导入 | 低峰期操作;慎用表锁 | 阻塞全表业务 |
8 总结
InnoDB 加锁体系是多粒度协同:意向锁解决表级与行级冲突检测的效率问题;记录锁支撑细粒度并发;间隙锁与临键锁在 RR 当前读下约束索引间隙,配合 MVCC 共同处理一致性与幻读风险。
实际工作中,判断一条 SQL 会锁什么,可按"当前读 → 隔离级别 → 执行计划与索引 → 临键锁及降级 → S/X 模式"逐层推导。死锁方面,InnoDB 能检测并打破循环等待,但回滚意味着业务失败;更稳妥的路径是控制锁范围、缩短持锁时间、统一访问顺序,并在应用层对偶发死锁做重试。