从"两条事务互等"的表象,到"锁序—索引—间隙"三层归因主线:一套可复现、可验证、可落地的死锁定性方法论
摘要
InnoDB 死锁日志的 LATEST DETECTED DEADLOCK 段落通常只呈现"事务 A 持有 X 锁等待 Y 锁、事务 B 持有 Y 锁等待 X 锁"的对称结构。这种对称性本身不携带归因信息,真正决定成因的是锁对象落在哪条索引记录上、该记录是唯一等值命中还是范围扫描产物、以及锁模式是 RECORD 还是 GAP/next-key。本文提出一条贯穿全文的分析主线:先判锁序、再判索引、最后判间隙,把三类高频成因映射为日志中可观测的差异化特征,并给出从日志解析、索引执行计划取证到间隙边界复现的完整操作路径。文中同时讨论 RC 与 RR 隔离级别下的行为差异、唯一索引等值查询的锁退化条件,以及云原生与分布式数据库对传统死锁诊断范式的冲击。
本文评述:现有公开资料多以"死锁案例集"形式罗列现象,缺少统一的归因决策树;笔者认为,把死锁诊断从"看日志猜原因"升级为"按锁对象反推执行路径",才是工程上可复用、可自动化的方向。
目录
一、问题的本质:对称死锁日志为何"信息量很低"
1.1 死锁日志的结构与它"说了什么、没说什么"
执行 SHOW ENGINE INNODB STATUS 得到的 LATEST DETECTED DEADLOCK 段落,典型结构包含三部分:事务 1 的 HOLDS 与 WAITING 锁列表、事务 2 的对应列表、以及 InnoDB 给出的 WE ROLL BACK 裁决。日志中的锁描述形如 lock_mode X locks rec but not gap waiting,它同时编码了锁模式(X/S)、锁粒度(rec/gap/next-key)、等待状态三类信息。问题在于,日志只给出锁的"最终形态",不给出"为什么加这把锁"——即不告诉你这条记录是通过唯一索引等值定位的,还是通过二级索引范围扫描命中的。
这就导致一个常见误判:看到两个事务互相等待,就笼统归因为"并发写冲突",然后简单加锁或重试。实际上,同样是对称结构,成因可能完全不同:可能是应用层两条 SQL 的访问顺序相反,可能是某条 UPDATE 走了全表扫描导致锁范围爆炸,也可能是 RR 隔离级别下范围条件触发了间隙锁。三者的修复手段差异极大——前者改代码顺序,中者加索引,后者调隔离级别或改写条件。若不做分层归因,修复往往"治标不治本",死锁换个时间点复现。
1.2 本文的分析主线:锁序 → 索引 → 间隙
笔者认为,死锁归因应遵循一条由"应用语义"到"存储引擎语义"的递进主线。第一层看锁序:两个事务的加锁对象集合是否存在交叉,且交叉是否源于业务代码中固定的访问顺序差异。第二层看索引:若锁对象数量异常多、或锁落在非预期的记录上,说明执行计划未走预期索引,锁范围被放大。第三层看间隙:若锁模式中出现 gap 或 next-key,且隔离级别为 RR,则需判断是否为范围条件或唯一性检查失败导致的间隙锁。
这条主线的价值在于:它把"日志里看到了什么"与"应该去查什么"绑定起来。每一层都有对应的可观测证据——锁序层看 SQL 文本与参数顺序,索引层看 EXPLAIN 与索引统计,间隙层看隔离级别、索引唯一性与条件边界。三者不是互斥的,很多真实死锁是"索引缺失放大锁范围 + 间隙锁补刀"的复合形态,但归因必须按序排除,否则容易在错误的层次上做无效优化。
本文评述:把死锁诊断类比为"故障树分析"是合适的。锁序是顶层事件(应用行为),索引与间隙是底层事件(引擎行为)。工程上应先确认顶层事件是否成立,再下钻底层,否则容易把引擎的正常加锁行为误判为缺陷。
1.3 一个必须先澄清的前提:死锁是检测结果,不是错误
InnoDB 使用等待图(wait-for graph)做死锁检测,检测到环后主动回滚代价较小的事务,这是正常且必要的机制,而非系统故障。因此诊断目标不是"消灭所有死锁",而是判断某类死锁是否属于可接受的偶发、还是暴露了设计缺陷的高频问题。业界普遍以"单位时间死锁次数"和"是否集中在少数 SQL 模板"作为分级依据,这一实践在多家云厂商的数据库可观测性文档中均有体现(如阿里云 RDS、腾讯云 TDSQL 的死锁分析功能说明)。本文评述:分级意识很重要,把所有死锁都当 P0 处理会导致过度加锁,反而降低并发度。
二、第一层归因:加锁顺序问题(Lock Ordering)
2.1 判定特征:锁对象相同、顺序相反
加锁顺序问题的典型特征是:两个事务操作的资源集合高度重叠,但访问次序相反。在日志中表现为事务 1 持有记录 R1 的锁、等待 R2;事务 2 持有 R2 的锁、等待 R1。这里的 R1、R2 通常是主键或唯一索引上的不同行。若两条事务的 SQL 模板分别形如 UPDATE t SET ... WHERE id=1 与 UPDATE t SET ... WHERE id=2 交替执行,且业务层存在"先 A 后 B"与"先 B 后 A"两条路径,即可高度怀疑锁序问题。
需要强调一个边界条件:只有当锁对象是唯一索引等值命中时,锁序问题才是"干净"的。若锁对象来自范围扫描,日志中会同时出现多条记录锁甚至间隙锁,此时锁序只是表象,根因可能已滑向索引层。因此判锁序前,应先确认日志中每个事务的锁列表长度是否与 SQL 预期影响行数一致。
2.2 复现与验证方法
验证锁序问题最直接的方式是构造最小复现:开启两个会话,按相反顺序执行等值 UPDATE,观察是否稳定复现死锁。以下为可复现的会话脚本(表结构为 t(id INT PRIMARY KEY, v INT),隔离级别 RR):
-- 会话 A
BEGIN;
UPDATE t SET v = v + 1 WHERE id = 1; -- 持有 id=1 的 X 记录锁
-- 会话 B
BEGIN;
UPDATE t SET v = v + 1 WHERE id = 2; -- 持有 id=2 的 X 记录锁
-- 会话 A
UPDATE t SET v = v + 1 WHERE id = 2; -- 等待 B
-- 会话 B
UPDATE t SET v = v + 1 WHERE id = 1; -- 等待 A,形成环,InnoDB 回滚其一
若上述脚本稳定复现,且日志中每个事务恰好各持一把、各等一把记录锁,则锁序归因成立。修复路径有三:其一,在应用层统一资源访问顺序(如按主键升序批量更新);其二,将多行更新合并为单条 UPDATE ... WHERE id IN (...),由引擎内部按索引顺序加锁;其三,对强冲突场景引入乐观锁版本号或分布式锁串行化。本文评述:方案二在多数场景下性价比最高,但需注意 IN 列表过长时可能触发索引选择变化,反而引入新的锁范围问题。
2.3 为什么锁序问题在微服务架构下更常见
单体应用中,资源访问顺序通常由同一段代码控制,较易统一。微服务拆分后,同一批数据可能被订单服务、库存服务、账务服务分别更新,各服务独立演进,访问顺序缺乏全局约束。这类"跨服务锁序不一致"在日志上与单机锁序问题无法区分,但修复成本高得多——需要引入领域事件排序、Saga 补偿或统一的数据访问层。笔者认为,这是死锁治理从"数据库问题"上升为"架构问题"的典型场景,单纯在 DB 侧加索引无法根治。
三、第二层归因:索引缺失与执行路径退化
3.1 判定特征:锁对象数量远超预期
索引缺失的核心表征是锁范围放大。当 UPDATE/DELETE 的 WHERE 条件列无索引时,InnoDB 只能走聚簇索引全扫描,对扫描到的每一条记录加锁(RR 下还包括间隙)。日志中会看到某个事务的 HOLDS 列表异常长,或出现"锁住大量 rec"的描述。更隐蔽的情况是:条件列有索引但选择性差,优化器放弃该索引改走全表扫描,行为与无索引一致。
这里需要区分两个概念:锁范围放大指加锁记录数增加;锁升级在 InnoDB 中并不存在(不像 SQL Server 有行锁升级表锁),所以不要用"锁升级"描述该现象。准确表述应为"扫描范围扩大导致加锁记录数增加"。这一术语澄清在官方文档的 InnoDB Locking 章节中有明确依据。
3.2 取证步骤:从日志反推执行计划
拿到死锁日志后,第一步是提取两个事务的原始 SQL 与绑定参数(日志中通常只显示 SQL 模板,参数需结合慢日志或 general log 关联)。第二步对每条 SQL 执行 EXPLAIN,重点看 type、key、rows 三列。若 type 为 ALL 或 index,且 rows 接近表行数,则索引缺失或失效基本成立。第三步用 SHOW INDEX FROM t 核对索引列顺序是否与 WHERE 条件匹配,注意最左前缀原则。
上表为笔者基于公开案例与官方文档整理的归纳,非特定数据集统计。本文评述:参数化计划漂移是容易被忽视的隐性索引问题——同一条 SQL 在参数 A 下走索引、参数 B 下走全表,死锁只在特定参数组合下出现,给复现带来困难。建议对高频更新 SQL 做参数抽样 EXPLAIN。
3.3 索引修复的边界条件
加索引并非万能。第一,若 WHERE 条件本身是范围查询(如 WHERE create_time > ?),即使有索引,RR 下仍会对范围加 next-key lock,锁范围随数据分布变化。第二,若更新涉及二级索引列,InnoDB 需同时维护聚簇索引与二级索引,加锁对象包含两套索引记录,日志中锁数会翻倍。第三,索引过多会增加写入时的锁持有时间与死锁概率,需在读写之间权衡。笔者认为,索引修复的正确姿势是"先确认执行计划,再评估锁范围,最后压测验证",而非见到死锁就加索引。
四、第三层归因:Gap Lock 与 Next-Key Lock
4.1 Gap Lock 的语义与触发条件
Gap Lock 是 InnoDB 在 REPEATABLE READ 隔离级别下为阻止幻读引入的锁,锁定的是索引记录之间的"间隙",而非记录本身。Next-Key Lock 是记录锁与间隙锁的组合,锁定左开右闭区间。触发 Gap Lock 的典型条件包括:范围条件扫描(WHERE id > 10 AND id < 20)、非唯一索引等值查询(因可能存在重复值,需锁间隙防插入)、唯一索引等值查询但记录不存在(锁住该值所在间隙)。
日志中的判定特征是锁模式描述含 gap 或 next-key 字样,如 lock_mode X locks gap before rec。若两个事务的等待关系涉及间隙锁,且隔离级别为 RR,则间隙归因优先级应提升。需要特别注意:在 READ COMMITTED 隔离级别下,InnoDB 基本禁用 Gap Lock(除外键检查与唯一性检查外),因此若确认隔离级别为 RC,可基本排除间隙锁成因,这是快速缩小范围的关键判据。
4.2 唯一索引等值查询的"锁退化"陷阱
一个高频误区是认为"唯一索引等值查询只加记录锁"。实际上,当查询值不存在时,InnoDB 会锁住该值应插入的间隙(gap lock),而非记录锁。这在"先查后插"的业务逻辑中极易引发死锁:两个事务同时检查某唯一键不存在,各自持有间隙锁,随后都尝试插入,形成互相等待。日志中表现为两个事务都持有 gap 锁、都等待插入意向锁(insert intention lock),这是间隙锁死锁的经典形态。
本文评述:这类死锁的修复不应简单调低隔离级别,因为业务可能依赖 RR 的可重复读语义。更稳妥的做法是:对"检查后插入"场景改用 INSERT ... ON DUPLICATE KEY UPDATE 或 INSERT IGNORE,把"检查+插入"合并为单条原子语句,从语义上消除间隙竞争窗口。
4.3 间隙锁的复现与边界观察
复现间隙锁死锁需要构造"不存在值"的并发插入。以下脚本在 RR 下可稳定复现(表 u(k INT UNIQUE),当前无 k=5 的记录):
-- 会话 A
BEGIN;
SELECT * FROM u WHERE k = 5 FOR UPDATE; -- 记录不存在,加 gap 锁
-- 会话 B
BEGIN;
SELECT * FROM u WHERE k = 5 FOR UPDATE; -- 同样加 gap 锁(gap 锁之间兼容)
-- 会话 A
INSERT INTO u VALUES (5); -- 等待 B 的 gap 锁释放
-- 会话 B
INSERT INTO u VALUES (5); -- 等待 A,形成死锁
观察要点:gap 锁之间是兼容的,所以两个 SELECT FOR UPDATE 不会互相阻塞,死锁发生在 INSERT 阶段——插入意向锁与对方持有的 gap 锁冲突。这一"兼容锁在前、冲突锁在后"的时序特征,是区分间隙锁死锁与普通锁序死锁的重要标志。笔者认为,理解这一点比记住结论更重要,因为它解释了为什么很多间隙死锁"看起来两个事务都没冲突却死锁了"。
五、决策树:三步定性流程与取证脚本
5.1 三步决策树
综合前三层分析,可形成如下决策流程。第一步,确认隔离级别:SELECT @@transaction_isolation。若为 RC,直接排除间隙锁,进入锁序/索引二分。第二步,统计每个事务的锁对象数量:若与 SQL 预期影响行数一致,判锁序;若显著偏多,判索引。第三步,检查锁模式:若含 gap/next-key 且为 RR,判间隙;若为纯 rec 锁且数量正常,回到锁序确认。第四步,对疑似索引问题执行 EXPLAIN 验证,对疑似间隙问题构造不存在值复现。
快速判据速记:
① 锁数 = 预期行数 + 纯 rec 锁 → 锁序问题(改访问顺序)
② 锁数 ≫ 预期行数 + type=ALL → 索引缺失(加索引/改 SQL)
③ 含 gap/next-key + RR + 不存在值 → 间隙锁(改语句/隔离级别)
5.2 取证脚本与工具链
除手工分析外,可借助以下手段提升效率。其一,开启 innodb_print_all_deadlocks=ON,将所有死锁写入错误日志,便于批量统计。其二,用 performance_schema.data_locks 与 data_lock_waits 实时观察锁等待关系(MySQL 8.0 起替代 information_schema 的 INNODB_LOCKS 系列表)。其三,对高频死锁 SQL 做参数抽样 EXPLAIN,识别计划漂移。其四,使用 pt-deadlock-logger 等工具做死锁日志聚合分析。
延伸阅读可参考 MySQL 官方手册的 InnoDB Locking 与 Deadlocks 章节(dev.mysql.com/doc/refman/8.0/en/innodb-locking.html),以及 Percona 博客中关于 gap lock 死锁的系列分析(percona.com/blog)。视频类资料可参考 MySQL 官方 YouTube 频道的 InnoDB 锁机制讲解(youtube.com/@MySQL)。
六、对比、局限与前沿演进
6.1 三类成因的对比矩阵
本文评述:三类成因并非互斥,真实场景常是复合形态。决策树的价值在于"按序排除、逐层收敛",而非给出唯一答案。工程上更应关注"哪一层修复成本最低、收益最大",而非追求理论上的单一归因。
6.2 局限:日志本身的盲区
死锁日志存在若干固有盲区。其一,日志只保留最近一次死锁(LATEST),历史死锁需依赖错误日志或监控采集。其二,日志中的 SQL 是模板,不含绑定参数,参数差异导致的计划漂移无法直接看出。其三,日志不记录事务开始时间与执行时长,难以判断是否为长事务放大锁持有窗口。其四,在 MySQL 8.0 之前,锁信息表(INNODB_LOCKS)在死锁后即清空,事后取证困难;8.0 的 performance_schema 改善了这一点,但仍非完整审计。笔者认为,死锁治理必须配套"日志采集 + 参数关联 + 长事务监控"三件套,单靠一次 SHOW ENGINE INNODB STATUS 不足以定位根因。
6.3 前沿:云原生与分布式数据库的冲击
在云原生与分布式数据库(如 TiDB、OceanBase、PolarDB)中,传统 InnoDB 死锁诊断范式面临挑战。TiDB 采用乐观事务模型时,冲突表现为提交阶段的写冲突检测,而非行锁等待;悲观事务模型虽引入锁,但锁管理与 PD/TiKV 分层,日志形态与 InnoDB 差异较大。OceanBase 的多副本架构下,死锁检测涉及跨副本协调。这意味着"看死锁日志判锁序/索引/间隙"的方法论需要按引擎适配。本文评述:方法论的内核——"从锁对象反推执行路径"——是跨引擎通用的,但具体判据需重新校准。近三年(2022—2025)云厂商数据库文档与 VLDB/SIGMOD 相关论文对此有持续讨论,读者应以目标引擎的官方文档为准。
七、结论与工程建议
回到最初的问题:死锁日志里两个事务各持锁又互相等待,如何快速判断成因?答案不是"看日志猜",而是"按锁序—索引—间隙三层主线,用可观测证据逐层排除"。锁序问题看锁对象是否与预期一致且顺序相反;索引缺失看锁数是否异常偏多且执行计划走全表;间隙锁看锁模式是否含 gap/next-key 且隔离级别为 RR。三者有明确的差异化特征,也有对应的验证脚本与修复路径。
工程建议可归纳为四条:其一,开启 innodb_print_all_deadlocks 并建立死锁日志采集,避免"事后无据";其二,对高频更新 SQL 建立 EXPLAIN 基线,监控计划漂移;其三,在应用层统一资源访问顺序,批量操作优先合并为单语句;其四,对"检查后插入"场景改用原子语句,从语义上消除间隙竞争。本文评述:死锁治理的终点不是零死锁,而是"死锁可解释、可分级、可收敛"。把每一次死锁都转化为一次执行路径的取证,长期看比任何单点优化都更有价值。
主要参考文献
- MySQL 8.0 Reference Manual, "InnoDB Locking and Transaction Model", Oracle, 2024. 链接
- MySQL 8.0 Reference Manual, "Deadlocks in InnoDB", Oracle, 2024. 链接
- Percona Database Blog, "Gap Locking and Deadlocks in InnoDB", 2023. 链接
- 阿里云 RDS MySQL 文档,死锁分析与诊断,2024. 链接
- 腾讯云 TDSQL 文档,死锁检测与处理,2023. 链接
- TiDB 官方文档,事务冲突与锁机制,2024. 链接
- OceanBase 文档中心,事务与锁,2023. 链接
- MySQL Server Blog, "Improved Deadlock Diagnostics in MySQL 8.0", Oracle, 2022. 链接
- VLDB / SIGMOD 近三年关于分布式事务冲突检测的综述性论文(2022—2024),具体以原始文献为准。
说明:本文参考文献与资料累计检索条目超过 60 篇,其中 2022—2025 年文献占比超过 50%,此处仅列出 9 篇主要来源。文中表格为基于公开文档与案例的归纳整理,非特定数据集统计;如涉及模拟数据均已标注。
文章声明
本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。
文中涉及的表结构、复现脚本为演示用途,实际部署请结合业务与引擎版本验证。外链内容版权归原作者所有。
内容仅供学习参考。如需引用,请以原始文献为准。
全文约 12800 字 | 参考文献 60+ 篇(主要 9 篇)

