1 问题背景与核心结论

InnoDB 通过多粒度锁协调并发读写:表级锁管理开销低但冲突面大;行级锁把锁定范围收缩到索引记录及其间隙,是在 OLTP 场景下支撑高并发的关键。意向锁则在表级与行级之间建立 O(1) 的冲突检测通道,避免每次申请表锁时遍历全表行锁。

本文聚焦 InnoDB,讨论表级锁、意向锁、行级锁(记录锁、间隙锁、临键锁)的触发条件与协同规则,不同 SQL 在当前读场景下的加锁行为,以及死锁的检测、观测与规避。普通 SELECT 默认走 MVCC 快照读,不进入下文行锁规则;记录锁、间隙锁、临键锁主要作用于当前读(SELECT ... FOR UPDATE/SHAREUPDATEDELETE 等)。

核心结论可以概括为四点:

  • 多粒度分工:表级锁锁定整表;行级锁落在索引结构上,形态包括记录锁、间隙锁、临键锁;意向锁(IS/IX)是表级辅助锁,声明事务后续将在部分行上加 S/X 锁。
  • RR 下的范围锁:默认隔离级别为 REPEATABLE READ 时,当前读对范围扫描倾向于使用临键锁(记录锁 + 前间隙),并在唯一索引等值命中等场景下降级为记录锁或间隙锁,以缩小锁范围。
  • 幻读与 MVCC:快照读依靠 Read View 解决一致性问题;当前读在 RR 下仍可能看到别的事务新插入的行,因此需要间隙锁/临键锁阻止间隙内插入。
  • 死锁处理:InnoDB 用等待图主动检测循环等待,回滚代价较小的事务;工程上应优先缩短持锁时间、缩小锁范围、统一访问顺序,而不是依赖数据库事后回滚。

2 基础认知

2.1 锁要解决什么问题

数据库锁是事务访问共享资源时的互斥机制,目标是在隔离性与并发度之间取得平衡:粒度越细,冲突概率越低,但锁管理成本越高。InnoDB 默认走行级锁;MyISAM、MEMORY 等引擎仅支持表级锁,且不支持事务。

特性InnoDBMyISAM/MEMORY
锁粒度行级 + 表级仅表级
事务支持不支持
典型场景OLTP、高并发写入历史只读遗留系统

2.2 快照读与当前读

两种读法决定是否会触发行锁:

类型典型语句机制是否加行锁
快照读普通 SELECTMVCC + Read View通常不加
当前读SELECT ... FOR UPDATE/SHAREUPDATEDELETE读最新版本并加锁会加

后文加锁规则均指当前读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 UPDATEUPDATE 等会显示。
  • 排查阻塞时,通常配合 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_IDInnoDB 内部事务 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 / XLOCK_TYPE=RECORD临键锁,锁记录同时锁间隙
X,GAP,INSERT_INTENTION插入意向锁,INSERT 在目标间隙上的插入意图
IX / IS / S / XLOCK_TYPE=TABLE表级意向锁或表级 S/X 锁

LOCK_DATA 的理解要点:

  • 聚簇索引上的 LOCK_DATA 一般是主键值,例如 10 表示与主键 10 相关的锁。
  • 二级索引上的 LOCK_DATA 通常是二级索引列 + 主键的组合展示,说明锁在哪个索引项上。
  • 间隙锁的 LOCK_DATA 往往表现为某个索引边界值,表示"在该值附近的间隙";具体展示格式与索引类型有关,需结合 INDEX_NAME 和当时 SQL 的扫描范围理解。
  • 索引最左端、最右端的开区间,分别由 infimum / supremum 伪记录 作为边界;在 data_locksSHOW ENGINE INNODB STATUS 中可能直接出现 supremum pseudo-record 字样(详见 2.4.4 节)。
  • LOCK_DATANULL 时,多见于部分表级锁,不代表"没有加锁"。

2.4.4 infimum / supremum 伪记录

InnoDB 把每个索引组织成按键值排序的记录链表。链表两端各有一个哨兵伪记录(pseudo-record),它们不是用户插入的业务行,而是引擎内部用来表示索引边界的占位节点:

伪记录作用
infimum索引逻辑下界,键值小于任何合法索引值
supremum索引逻辑上界,键值大于任何合法索引值

和间隙锁的关系

间隙锁锁的是两条索引记录之间的逻辑区间。对 LOCK_DATA 有一个常用读法:它通常标识该间隙的右边界记录——锁住的 gap 在这条记录的左侧。

以主键 10, 11, 13, 20 为例:

逻辑间隙右边界LOCK_DATA 通常指向
(13, 20)主键 2020
(20, +∞)supremumsupremum 伪记录(而非用户可见的 20)
(-∞, 10)主键 1010(左边界 infimum 一般不在 LOCK_DATA 中单独展示)

当范围扫描或 INSERT 涉及索引最右端时,就容易在锁信息里看到 supremum pseudo-record。它表示"最后一条用户记录之后、直到索引末尾"的整个开区间,而不是某条真实业务数据。

在不同视图中的呈现

  • performance_schema.data_locksLOCK_MODEX,GAPX 时,LOCK_DATA 多数情况下是右边界键值;最右端间隙则关联 supremum。
  • SHOW ENGINE INNODB STATUSLATEST 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_IDOBJECT_NAMEINDEX_NAMELOCK_TYPELOCK_MODELOCK_STATUSLOCK_DATA
4231ordersNULLTABLEIXGRANTEDNULL
4231ordersPRIMARYRECORDX,REC_NOT_GAPGRANTED10

读法:

  1. 第一行:TABLE + IX 表示事务在表级持有意向排他锁,声明后续会在部分行上加 X 锁。
  2. 第二行:RECORD + X,REC_NOT_GAP + PRIMARY + 10 表示在聚簇索引主键 10 上持有排他记录锁
  3. LOCK_STATUS = WAITING,说明该锁尚未拿到,需要到 data_lock_waits 查是谁阻塞了它。

若改为范围扫描:

SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;

则可能看到多行 RECORD 锁,LOCK_MODE 出现 X 或同时存在 X,REC_NOT_GAPX,对应临键锁 / 记录锁组合;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 TABLEDROP 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 行锁的区分

MDLInnoDB 行锁
层级MySQL ServerInnoDB 存储引擎
保护对象表结构元数据索引记录 / 间隙
普通 SELECT会持有(语句或事务期间)快照读通常不加行锁
典型阻塞长事务 + DDL并发更新、间隙锁、死锁
排查视图performance_schema.metadata_locksdata_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表 XISIX
表 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 在索引链表两端还有 infimumsupremum 两个伪记录作为哨兵,用户数据实际夹在二者之间。逻辑间隙为:

(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 当前读可先把策略理解为"先按临键锁扫描,再尽可能降级":

  1. 唯一索引等值命中已有记录 → 记录锁(不锁间隙)。
  2. 唯一索引等值未命中 → 间隙锁(锁住访问到的最后一个索引对象所在的间隙)。
  3. 非唯一索引等值或任意范围扫描 → 通常保持临键锁;普通索引等值扫描向右遍历时,边界处可能退化为间隙锁。
  4. 唯一索引范围扫描 → 可能扫描到第一个不满足条件的记录;该边界行为是 InnoDB 实现细节,线上应通过 EXPLAINdata_locks 验证,而非死记口诀。

下面是《MySQL 实战 45 讲》中总结的加锁规则:

  • 原则 1:加锁的基本单位是 next-key lock。是一个前开后闭区间。
  • 原则 2:查找过程中访问到的对象才会加锁。
  • 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
  • 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
  • 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

5.4 隐式锁与显式锁

UPDATEDELETESELECT ... FOR UPDATE 会在锁系统中登记显式锁,这是可以被等待和检测的真实锁结构。

INSERT 在未发生冲突时,新记录通过事务 ID 表达占有关系,形成隐式锁;当其他事务尝试修改该未提交记录时,隐式锁才转换为显式锁参与等待与死锁检测。

这个设计的核心价值是减少锁系统开销。大量插入场景下,如果每插入一条记录都立即创建显式锁,锁系统需要维护的结构会急剧膨胀;而隐式锁采用的是一种 “延迟显式化” 的策略:没有事务冲突时,只依赖记录上的事务信息表达占有关系;真正发生冲突时,再转换为显式锁,参与等待和死锁检测。

5.5 二级索引的双层加锁

如果语句通过主键索引定位记录,InnoDB 可以直接在聚簇索引记录上加锁;而如果语句通过二级索引定位记录,InnoDB 通常会先在二级索引记录上加锁,再根据二级索引记录中保存的主键值,回到聚簇索引上锁住对应的主键记录。

这意味着,二级索引查询可能同时涉及两类锁对象:

  • 二级索引上的记录、间隙或临键锁:用于保护二级索引扫描范围,防止其他事务修改或插入影响当前扫描结果的索引项。
  • 聚簇索引上的记录锁:用于保护真正的数据行,防止其他事务并发修改或删除对应主键记录。

很多线上死锁并不是简单发生在 “同一条主键记录” 上,而是发生在二级索引锁与主键记录锁的交叉获取过程中。例如,事务 A 先通过二级索引锁住一批索引项,再回表申请主键记录锁;事务 B 先通过主键更新记录,再维护二级索引项。只要两个事务访问资源的顺序形成交叉,就可能构成循环等待。

5.6 插入意向锁

插入意向锁(Insert Intention Lock)INSERT 在真正写入索引记录之前,在目标间隙上申请的一种特殊 gap 锁。它的 LOCK_MODEdata_locks 中通常显示为 X,GAP,INSERT_INTENTION

需要先和表级 IX(意向排他锁) 区分开:

表级 IX插入意向锁
粒度整张表某个索引上的某个间隙
触发语句INSERT / UPDATE / DELETE 等写操作INSERT(及 INSERT-类写入)
作用声明"后续会在部分行上加 X 锁"声明"我准备在这个间隙的某个位置插入新记录"
data_locksLOCK_TYPE=TABLELOCK_MODE=IXLOCK_TYPE=RECORDLOCK_MODE=X,GAP,INSERT_INTENTION

5.6.1 为什么需要插入意向锁

如果只有普通间隙锁,无法精细表达"多个事务要在同一间隙的不同位置各插一条"这类并发插入需求。插入意向锁的设计目标是:

  • 普通间隙锁 / 临键锁冲突:当前读已经锁住了某个间隙时,新的 INSERT 必须等待,避免幻读或被范围锁排斥。
  • 彼此兼容:多个事务在同一间隙插入不同位置的新记录时,不必互相等待。

因此,插入意向锁不是"锁住行",而是在插入发生前,对目标间隙中的插入意图做冲突检测。

5.6.2 兼容关系

已持有 \ 新请求间隙锁插入意向锁临键锁
间隙锁兼容互斥
插入意向锁互斥兼容
临键锁互斥按 X/S 模式判断

要点:

  • 间隙锁 vs 间隙锁:兼容。多个事务可以同时持有同一间隙上的 gap lock。
  • 插入意向锁 vs 插入意向锁:兼容。同一间隙内插入 id=12id=18 通常可并行。
  • 间隙锁 / 临键锁 vs 插入意向锁:互斥。这是 INSERTSELECT ... 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 锁并同时等待插入意向锁,或 INSERTUPDATE 路径交叉,可能形成等待环(需用 LATEST DETECTED DEADLOCK 还原)。

排查 INSERT 阻塞时,不要只盯新记录上的隐式锁,应先检查是否有前置的范围查询、FOR UPDATEUPDATEDELETE 占住了相关间隙。

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 节):

  1. 自动获取表级 IX
  2. 定位新记录落入的间隙,申请 插入意向锁X,GAP,INSERT_INTENTION);若该间隙已被 gap / next-key 锁占用,则等待。
  3. 插入成功后以隐式锁占有新记录;与其他事务冲突时转为显式 X 记录锁。
  4. 唯一键、外键、自增列检查可能引入额外锁。

同一间隙内不同插入位置的插入意向锁通常可并存;阻塞多来自已有的间隙锁 / 临键锁,而非插入之间互斥。线上 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_NAMELOCK_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 STATUSLATEST DETECTED DEADLOCK 保留最近一次死锁现场
performance_schema.metadata_locksMDL 等待,与 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 规避策略

数据库自动回滚只能止损,工程上应主动降低死锁率:

  1. 统一资源访问顺序(多行、多表场景最有效)。
  2. 缩短事务:外部 RPC、人工交互移出事务;写操作尽量后置并尽快提交。
  3. 缩小锁范围:保证 WHERE/JOIN 走合适索引;能等值就不用范围;业务允许时可评估 RC(减少间隙锁,但需接受语义变化)。
  4. 合理索引:高频过滤列建索引;等值查询优先唯一索引,便于临键锁降级为记录锁。
  5. 应用层死锁重试:捕获死锁错误后有限次随机退避重试(通常 3–5 次)。
  6. 避免 LOCK TABLES 与无计划 DDL:结构变更优先在线 schema 变更工具,控制长事务对 MDL 的影响。

典型场景速查:

场景推荐做法关键风险
库存扣减、转账唯一索引等值 + FOR UPDATE + 短事务索引失效导致大范围锁
报表一致性读FOR SHARE 或快照读长事务持 S 锁阻塞写
只读查询普通 SELECT(MVCC)
批量导入低峰期操作;慎用表锁阻塞全表业务

8 总结

InnoDB 加锁体系是多粒度协同:意向锁解决表级与行级冲突检测的效率问题;记录锁支撑细粒度并发;间隙锁与临键锁在 RR 当前读下约束索引间隙,配合 MVCC 共同处理一致性与幻读风险。

实际工作中,判断一条 SQL 会锁什么,可按"当前读 → 隔离级别 → 执行计划与索引 → 临键锁及降级 → S/X 模式"逐层推导。死锁方面,InnoDB 能检测并打破循环等待,但回滚意味着业务失败;更稳妥的路径是控制锁范围、缩短持锁时间、统一访问顺序,并在应用层对偶发死锁做重试。