Jimmy's blog
返回文章列表
2862 字15 分钟

数据库死锁故障分析复盘

数据库#死锁 / 复盘

1. 故障现象

系统在执行数据库写入时出现:

Deadlock found when trying to get lock; try restarting transaction

数据库检测到多个事务之间存在循环等待,主动回滚其中一个事务,并将死锁异常返回给应用程序。

需要注意:异常堆栈中显示的 SQL,只代表数据库发现死锁的位置,不一定是最先持有锁、导致死锁的 SQL。

2. 死锁原因分析

2.1 死锁形成的必要条件

数据库死锁通常需要同时满足以下条件:

  1. 存在两个或以上并发事务;
  2. 事务访问了相同的记录、唯一索引、普通索引或间隙;
  3. 每个事务都持有部分锁,同时等待其他事务持有的锁;
  4. 锁的获取顺序不一致,形成循环等待。

如果只有一个事务,或者多个事务只访问互不重叠的数据,通常只会出现锁等待,不会形成死锁。

2.2 典型死锁过程

假设两个事务需要处理相同的两条记录:

事务 T1:记录 A -> 记录 B
事务 T2:记录 B -> 记录 A

可能的执行顺序为:

T1 获取记录 A 的锁
T2 获取记录 B 的锁
T1 申请记录 B 的锁,等待 T2
T2 申请记录 A 的锁,等待 T1

等待关系为:

T1 -> T2 -> T1

这就是完整的死锁环。数据库会选择其中一个事务作为牺牲者回滚,另一个事务继续执行。

2.3 批量写入为什么容易产生死锁

批量写入通常会在一个事务中处理多条记录。事务在执行过程中会逐步获取多条记录或索引锁,并且这些锁要等事务提交后才释放。

当两个批次存在交集,且数据顺序不一致时,就可能出现:

批次 1:A、B、C
批次 2:C、B、A

批量 Upsert 也不能天然避免死锁。即使 SQL 使用了“重复键更新”,数据库仍然需要:

  • 检查唯一索引;
  • 判断记录是否已存在;
  • 对重复记录执行更新;
  • 同时维护主键索引和其他辅助索引。

这些过程都可能获取排他锁。

2.4 事务范围过大是重要放大因素

以下操作会增加死锁概率:

  • 一个事务处理过多记录;
  • 一个事务中混合执行多种写操作;
  • 事务中包含远程调用、文件操作或其他耗时逻辑;
  • 事务提交延迟,导致已获取的锁长时间不释放;
  • 多个入口以不同顺序访问相同数据。

事务持锁时间越长,其他事务越容易在等待过程中形成反向依赖。

2.5 重试机制可能放大故障

死锁事务回滚后,如果消息系统或应用立即重试,并且重试仍然使用相同的数据顺序,可能出现:

死锁 -> 事务回滚 -> 立即重试 -> 再次竞争 -> 再次死锁

因此,重试必须配合退避、随机等待和最大次数限制。

3. 定位过程

3.1 第一步:确认异常类型

区分以下几类异常:

异常 含义
Deadlock / MySQL 1213 已经形成循环等待,数据库主动回滚事务
Lock wait timeout / MySQL 1205 等待锁超时,不一定已经形成死锁
Duplicate key 唯一键冲突,不等同于死锁
Connection timeout 连接或网络问题,不等同于数据库锁问题

只有确认是死锁,才能按死锁路径定位和处理。

3.2 第二步:从异常 SQL 追踪完整事务

不能只分析报错的单条 SQL,需要继续向上追踪:

  1. 该 SQL 属于哪个事务;
  2. 事务此前执行过哪些 SQL;
  3. 事务一次处理了多少记录;
  4. 事务是否还执行了更新、删除或状态变更;
  5. 事务何时开始、何时提交;
  6. 是否存在异常捕获后继续使用当前事务的情况。

重点是还原“一个事务先拿了哪些锁,之后又申请了哪些锁”。

3.3 第三步:确认并发来源

检查所有可能写入同一数据资源的入口:

  • Web 请求;
  • 消息消费者;
  • 定时任务;
  • 异步线程;
  • 批处理程序;
  • 补偿任务;
  • 运维脚本或人工 SQL。

单个进程或单个线程没有并发,不代表整个系统没有并发。还需要检查实例数量、消费者数量、重试任务和其他服务。

3.4 第四步:获取数据库死锁图

死锁发生后应立即执行:

SHOW ENGINE INNODB STATUS\G;

重点查看:

  • 死锁涉及的事务 ID;
  • 每个事务最近执行的 SQL;
  • 每个事务持有的锁;
  • 每个事务等待的锁;
  • 被回滚的事务;
  • 锁对应的索引名称和记录范围。

如果数据库支持 Performance Schema,还应查看锁等待信息:

SELECT * FROM performance_schema.data_locks\G;
SELECT * FROM performance_schema.data_lock_waits\G;

3.5 第五步:确认表结构和索引

检查实际表结构,而不是只依据 ORM 或 Mapper 定义:

SHOW CREATE TABLE <table_name>\G;
SHOW INDEX FROM <table_name>;

重点确认:

  • 是否存在多个唯一索引;
  • 唯一索引的字段顺序;
  • SQL 是否能够使用正确索引;
  • 是否发生全表扫描;
  • 是否存在较大的间隙锁范围;
  • 主键和辅助索引是否都参与了锁竞争。

3.6 第六步:形成死锁证据链

一个完整的定位结论应包含:

事务 T1 执行了 SQL 顺序:A -> B
事务 T2 执行了 SQL 顺序:B -> A
T1 持有 A,等待 B
T2 持有 B,等待 A
数据库回滚 T1

如果只能看到应用异常,不能看到另一个事务和锁等待关系,应将结论标记为“高概率原因”,不能表述为已经完全确认。

4. 问题修复方案

4.1 统一锁获取顺序

所有并发写入入口都应按照同一规则处理数据,例如按照唯一键的字段顺序排序:

先按字段 1 排序
再按字段 2 排序
最后按字段 3 排序

只要所有事务访问相同数据时遵守一致顺序,就能显著降低锁顺序反转导致的死锁。

4.2 批内去重并控制批次大小

写入前应:

  1. 使用与数据库唯一键一致的规则去重;
  2. 按统一顺序排序;
  3. 控制单批记录数量;
  4. 避免同一事务处理过多数据。

需要注意:仅在一个外层事务中把大列表拆成多个 SQL,并不能完全释放前面 SQL 获取的锁。若要真正缩短锁持有时间,应让每个小批次独立提交。

4.3 使用原子写入

应优先使用数据库提供的原子插入或 Upsert,避免:

先查询是否存在
再根据查询结果决定插入或更新

查询和写入之间存在竞态窗口。多个事务可能同时判断“记录不存在”,然后同时尝试插入。

原子写入可以解决竞态和重复数据问题,但不能单独保证绝对没有死锁,仍需要统一顺序和事务重试。

4.4 缩短事务范围

建议将长流程拆分为:

事务 1:写入或锁定必要状态,立即提交
事务外:执行远程调用或耗时操作
事务 2:保存最终结果,立即提交

不要在数据库事务中执行:

  • 远程 HTTP/RPC 调用;
  • 文件读写;
  • 长时间等待;
  • 大量计算;
  • 不必要的查询。

4.5 优化索引

为查询和更新条件建立合理索引,使数据库尽量锁定精确记录,而不是扫描大范围索引区间。

索引优化的目标是:

  • 减少扫描范围;
  • 减少间隙锁;
  • 缩短 SQL 执行时间;
  • 缩短事务持锁时间。

索引不能替代事务设计,也不能替代死锁重试。

5. 代码层死锁防御

5.1 死锁发生后的正确处理流程

代码应遵循:

识别死锁异常
-> 当前事务完整回滚
-> 退避并随机等待
-> 重新开启事务
-> 重试完整操作

5.2 必须重试整个事务

错误做法:

try {
writeData();
} catch (DeadlockException e) {
writeData(); // 仍然在原事务中重试
}

正确做法是让原事务结束,再从事务入口重新执行:

外层重试逻辑
-> 新事务
-> 完整数据库操作
-> 成功提交

死锁发生后,当前事务可能已经被数据库回滚,或者已经被事务管理器标记为只能回滚。此时不能继续复用当前事务。

5.3 重试策略

建议:

最大重试次数:2~3 次
等待时间:指数退避
等待时间:增加随机抖动
超过次数:记录错误并进入补偿或死信流程

只对确定的瞬时异常重试,例如:

  • MySQL 1213;
  • SQLState 40001;
  • 数据访问层 Deadlock 异常。

业务参数错误、数据格式错误、权限错误和 SQL 语法错误不应无限重试。

5.4 消息确认策略

如果数据库写入由消息消费触发,应遵循:

事务成功提交 -> 确认消息消费成功
事务死锁 -> 重试事务
重试仍失败 -> 返回稍后重试或进入死信队列

不能在事务失败后直接确认消息,否则可能造成数据丢失。

5.5 保证操作幂等

事务重试意味着同一请求可能执行多次,因此写入操作必须具备幂等性:

  • 使用稳定的业务唯一键;
  • 使用原子 Upsert;
  • 避免重复生成不可恢复的副作用;
  • 对外部操作使用幂等号或操作 ID;
  • 将数据库提交结果作为最终成功标准。

5.6 增加日志和监控

每次任务批处理至少记录:

  • 请求或消息 ID;
  • 事务批次大小;
  • 数据范围或唯一键范围;
  • 重试次数;
  • 异常码和 SQLState;
  • 最终提交结果;
  • 执行耗时。

建议监控:

deadlock_total
deadlock_retry_total
deadlock_final_failure_total
transaction_latency
lock_wait_latency
backlog_size

并临时开启数据库死锁日志,保留完整死锁图,便于判断死锁参与者是否发生变化。

6. 复盘结论

本次故障应归因于并发事务之间的锁顺序反转,而不是某条业务数据本身异常:

并发事务
+ 访问重叠数据
+ 批量持有多条锁
+ 锁获取顺序不一致
+ 事务提交较晚
=> 循环等待
=> 数据库回滚一个事务

最终防御方案为:

统一锁顺序
+ 批内去重
+ 原子写入
+ 小批次独立事务
+ 缩短事务范围
+ 完整事务级重试
+ 幂等和消息补偿

本复盘文档只描述数据库死锁相关的通用原因、定位方法和代码防御原则,不绑定具体业务、服务、表名或实现类。具体修复效果仍需结合实际死锁图、表索引和并发压测验证。

文章信息

许可协议CC BY-NC-SA 4.0