深入解析 MySQL 间隙锁:从加锁规则到死锁案例
1. 引言:为什么你要理解间隙锁
作为后端与数据库开发者,你大概率在业务高峰期遭遇过锁等待超时或死锁的报错。InnoDB 在可重复读(RR)隔离级别下默认使用 next-key lock,其中间隙锁(Gap Lock)是导致并发性能下降和死锁的主要来源。很多开发者对锁的理解停留在"行锁"层面,遇到死锁只会盲目重试或升级隔离级别,却无法根治问题。
本文将从锁的类型与结构出发,逐步拆解 InnoDB 的加锁规则,重点区分唯一索引与普通索引下的间隙锁行为,解释 RR 隔离级别下隐式加锁的细节,并演示如何利用 performance_schema 监控锁等待。随后通过真实死锁案例复盘,提炼规避策略和最佳实践。
无论你是正在排查线上问题的应用开发者,还是负责数据库优化的 DBA,本文都能为你提供一套系统化的分析与决策框架。读完本文,你将能准确预测常见 SQL 的加锁范围,快速定位锁等待原因,并设计出不易触发死锁的事务逻辑。
2. InnoDB 锁体系:从行锁到间隙锁
2.1 锁的类型
InnoDB 实现了多粒度锁:共享锁(S 锁)、排他锁(X 锁)、意向锁(IS、IX)。其中行级锁包括记录锁(Record Lock)、间隙锁(Gap Lock)和临键锁(Next-Key Lock)。记录锁锁住索引记录本身;间隙锁锁住两条记录之间的开区间,以防止幻读;next-key lock 是记录锁与间隙锁的组合,锁住一个左开右闭的区间。
2.2 为什么需要间隙锁
在 RR 隔离级别下,InnoDB 需要保证同一事务内两次查询结果一致,即避免幻读。仅靠记录锁无法阻止其他事务在范围内插入新记录,因此间隙锁被引入。当事务锁定一个范围时,它同时对范围内的间隙加锁,其他事务无法在该间隙内插入记录。
2.3 加锁的基本单位
InnoDB 的加锁基本单位是 next-key lock,但在某些情况下会退化为记录锁或间隙锁,这与索引的唯-性和查询方式密切相关。理解退化逻辑是分析锁范围的关键,后续章节将详细展开。
3. 加锁规则核心:区间与 next-key lock
3.1 区间定义
对于索引,记录按顺序排列。假设索引中有记录值 10、20、30,那么存在四个间隙:负无穷到 10、10 到 20、20 到 30、30 到正无穷。对于等值查询且记录存在时,next-key lock 退化为记录锁;等值查询记录不存在时,锁住的是该值所在区间的间隙;范围查询则会锁住扫描到的全部区间。
3.2 加锁规则简化模型
核心规则可概括为两条:等值查询时,唯一索引命中则不产生间隙锁;普通索引命中时,会额外锁住索引记录前后的间隙。范围查询(> 或 <)时,会锁住所有扫描到的索引范围,包括不存在的边界间隙。此外,加锁顺序与查询优化器的执行计划相关。
3.3 隔离级别与锁的关联
在提交读(RC)隔离级别下,InnoDB 默认使用记录锁,间隙锁仅在外键检查和复制等特殊场景使用。而在 RR 下,间隙锁是默认策略,因此并发插入更容易被阻塞。如果业务对幻读不敏感,可考虑将隔离级别降为 RC,但需要权衡主从复制格式等影响。
4. 唯一索引与普通索引:间隙锁的差异
4.1 唯一索引等值查询
当查询使用唯一索引且记录存在时,由于唯一性保证不会出现幻读,InnoDB 会主动将 next-key lock 退化为记录锁,只锁目标行。例如,SELECT * FROM t WHERE id = 5 FOR UPDATE 且 id 是主键,只锁 id=5 的记录。
4.2 唯一索引查询记录不存在
若查询记录不存在,比如 SELECT ... WHERE id = 7 FOR UPDATE,而 id 范围在 5 和 10 之间,此时会锁住间隙(5,10),防止其他事务插入 id=7 的记录。
4.3 普通索引等值查询
对于普通索引,即使查询命中记录,由于可能存在重复键值,仍需要间隙锁锁定该值前后的间隙,防止幻读。例如 SELECT ... WHERE col = 'abc' FOR UPDATE,如果 col 上有普通索引且存在多条 col='abc' 的记录,会锁住所有命中的记录以及它们之间的间隙;如果不存在,则锁定该值所在间隙。
4.4 对比表格
| 场景 | 索引类型 | 查询记录 | 加锁范围 | 是否包含间隙锁 |
|---|---|---|---|---|
| 等值唯一索引命中 | 唯一索引 | 存在 | 该记录 | 否 |
| 等值唯一索引未命中 | 唯一索引 | 不存在 | 前后间隙 | 是 |
| 等值普通索引命中 | 普通索引 | 存在 | 记录及其间隙 | 是 |
| 范围查询 | 任何索引 | - | 扫描范围内所有记录与间隙 | 是 |
5. RR 隔离级别下的隐式加锁规则
5.1 普通 SELECT 不加锁
在 RR 下,普通 SELECT 通过 MVCC(多版本并发控制)实现快照读,不加任何锁。只有显式加锁的 SELECT(如 FOR UPDATE)和 DML 语句才会申请锁。这常常被误解,许多人以为 SELECT 也会加锁。
5.2 加锁语句的分类
- SELECT ... FOR SHARE:加共享 next-key lock。
- SELECT ... FOR UPDATE:加排他 next-key lock。
- UPDATE/DELETE:首先执行查询定位记录,然后对涉及的行和间隙加排他锁。
- INSERT:需要检查插入间隙是否被其他事务锁定,并可能加上插入意向锁。
5.3 隐式加锁的连锁反应
当一条 UPDATE 的 WHERE 条件涉及索引时,InnoDB 会对扫描到的每个索引区间加 next-key lock,即使行最终没有被修改。这意味着即使更新条件只匹配少数行,如果范围较大,可能锁住大量间隙。例如,UPDATE t SET a=1 WHERE b>100,b 无索引,则全表扫描并对所有间隙加锁,导致并发插入完全阻塞。
5.4 主键与二级索引的锁交互
在 InnoDB 中,二级索引的记录锁最终会回表查找主键索引,并对主键索引记录加锁。因此,更新一个二级索引列,会同时锁住二级索引记录和对应主键记录。若多个事务以不同顺序访问二级索引和主键,可能造成死锁。
6. 通过 performance_schema 分析锁等待
6.1 启用锁监控
从 MySQL 5.7 开始,performance_schema 提供了 data_locks 和 data_lock_waits 表,可以查看当前锁信息。默认可能未开启,需要确认 performance_schema 变量为 ON。可通过 SHOW VARIABLES LIKE 'performance_schema'; 检查。
6.2 核心表结构
data_locks 表展示了所有持锁和等待锁的锁记录,字段包括 ENGINE_TRANSACTION_ID、LOCK_TYPE(RECORD/TABLE)、LOCK_MODE(X/GAP/等)、LOCK_STATUS(GRANTED/WAITING)等。data_lock_waits 表展示了锁等待关系。
6.3 实战查询锁等待链
假设会话 A 持有锁,会话 B 等待。执行如下 SQL 可查看谁在等待谁:
mysql
-- 查询当前锁等待
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread
FROM performance_schema.data_lock_waits w
INNER JOIN information_schema.innodb_trx r
ON w.REQUESTING_ENGINE_TRANSACTION_ID = r.trx_id
INNER JOIN information_schema.innodb_trx b
ON w.BLOCKING_ENGINE_TRANSACTION_ID = b.trx_id;
6.4 定位间隙锁引起的阻塞
通过查询 data_locks 中 LOCK_MODE 包含 GAP 或 XGAP 的记录,可以判断是否因为间隙锁导致插入阻塞。如果等待线程执行的 SQL 是 INSERT,且被锁的是间隙,即可确认。
7. 死锁案例复盘与规避策略
7.1 案例一:两个事务做范围更新
事务 A:UPDATE orders SET status=1 WHERE id IN (101,102);事务 B:UPDATE orders SET status=2 WHERE id IN (102,101)。由于两者加锁顺序可能不同,导致死锁。原因:A 先锁 101,B 先锁 102,然后互相等待。
7.2 案例二:先查后插导致间隙锁死锁
事务 A:SELECT...WHERE id=5 FOR UPDATE(锁间隙);事务 B:同样执行该语句(等待);若 B 再插入 id=7,则 A 可能执行插入,与 B 形成循环等待。此类死锁在防止唯一键冲突的 check-then-insert 场景常见。
7.3 案例三:普通索引范围删除与另一事务插入
事务 A:DELETE FROM t WHERE age BETWEEN 20 AND 30,锁住年龄 20-30 的间隙;事务 B:插入年龄 25 的记录,被阻塞。若 B 同时持有其他锁,可能死锁。
7.4 规避策略总结
| 策略 | 适用场景 | 说明 |
|---|---|---|
| 固定访问顺序 | 多个事务同时更新多个资源 | 例如按主键升序处理,避免死锁 |
| 减少锁范围 | 范围查询或批量更新 | 使用精确条件,避免锁太多间隙 |
| 使用读已提交 | 业务能容忍不可重复读 | 关闭间隙锁,但需评估复制安全 |
| 缩短事务时间 | 所有事务 | 快速提交,减少锁持有时长 |
| 捕获死锁重试 | 高并发 | 使用 innodb_deadlock_detect,捕获后重试 |
8. 常见误区与澄清
误区一:只有显式 SELECT FOR UPDATE 才加锁。实际上 UPDATE/DELETE 也会隐式加锁。
误区二:唯一索引不会产生间隙锁。等值查询记录不存在时,仍会锁间隙。
误区三:间隙锁会影响所有插入操作,实际上间隙锁只阻止其他事务在间隙内插入,不影响更新已有行。
误区四:死锁只是由于行锁相互冲突,间隙锁同样能导致死锁。
9. 生产实践建议
- 尽量使用唯一索引或主键作为 WHERE 条件进行精确更新,避免范围过大。
- 对批量数据操作,分段提交,控制每次事务影响行数。
- 监控
information_schema.INNODB_TRX和performance_schema,及时发现长事务。 - 在业务代码中捕获死锁异常,做有限次重试(如 3 次)。
- 合理设置
innodb_lock_wait_timeout,防止无限等待。 - 若应用允许,考虑将隔离级别降为 READ-COMMITTED。
10. 排障清单:锁等待与死锁快速定位
- 确认隔离级别:
SELECT @@transaction_isolation;。 - 确认是否发生阻塞:查询
SHOW ENGINE INNODB STATUS或 performance_schema。 - 找到锁等待事务:使用前文 SQL 查询。
- 分析锁模式:在
data_locks中看到GAP权限。 - 检查事务执行时间,定位慢 SQL。
- 检查索引使用:通过 EXPLAIN 确认是否走索引。
- 查看死锁日志:
SHOW ENGINE INNODB STATUS中的 LATEST DETECTED DEADLOCK 段。
11. 面试/复盘问题
- RR 下 InnoDB 如何用间隙锁解决幻读?
- 唯一索引和普通索引在等值查询时的加锁差异是什么?
- 解释 next-key lock 的作用和退化条件。
- 如何通过 performance_schema 观察锁等待链?
- 设计一个可能因间隙锁产生死锁的场景,并给出规避方案。
12. 总结
间隙锁是 MySQL 保证 RR 隔离级别下数据一致性的核心机制,但对于不熟悉其规则的开发者,它也是不少并发问题的来源。理解加锁范围、索引类型差异以及隐式加锁规则,是提升数据库开发与排障能力的关键一步。通过本文的案例和工具方法,你应当能更自信地面对锁等待与死锁。
13. 参考资料
- MySQL 官方文档:InnoDB Locking 章節,https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html
- MySQL 官方文档:InnoDB Deadlock Detection,https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlock-detection.html
- performance_schema 官方文档,https://dev.mysql.com/doc/refman/8.0/en/performance-schema.html
- MySQL 实战 45 讲(作者:丁奇,极客时间),部分章节涉及间隙锁与死锁案例
注意:本文示例基于 MySQL 8.0,若使用更早版本,部分 performance_schema 表名或字段可能略有差异,以实际版本为准。