MySQL 死锁排查:从事务边界到锁顺序治理

死锁是两个或多个事务形成循环等待。InnoDB 会检测并回滚其中一个事务,因此应用看到的通常是一条 Deadlock found when trying to get lock。正确处理不是关闭检测或无限重试,而是还原涉及的事务、锁对象和访问顺序。

第一时间保留证据

SHOW ENGINE INNODB STATUS\G

LATEST DETECTED DEADLOCK 会展示相关事务、正在执行的 SQL、已持有和正在等待的锁。这个区域只保留最近一次,监控系统应及时采集。MySQL 8 还可以通过 Performance Schema 查看当前锁等待:

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

只看报错 SQL 不够。事务可能在更早的语句已经持锁,因此还要从应用日志关联同一事务中的完整调用序列和参数。

最常见的形成方式

不一致的更新顺序

事务 A 先更新用户 1 再更新用户 2,事务 B 顺序相反,就可能互相等待。解决方式是所有路径按稳定主键顺序加锁:

sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
    // 按统一顺序执行 SELECT ... FOR UPDATE 或 UPDATE
}

缺少合适索引

更新条件没有索引时,数据库可能扫描并锁住远超预期的记录。先用 Explain 确认访问范围,再设计索引。索引越多不一定越好,不同索引路径也可能改变加锁顺序。

事务包含外部 I/O

开启事务后调用 HTTP、MQ 或等待用户输入,会显著延长持锁时间。事务里只保留必要数据库操作;外部通知使用 Outbox 等事务后机制。

批量事务过大

一次更新上万行会扩大锁集合,也更容易和其他业务交叉。按可恢复批次处理,并为每批设置明确上限。

应用层必须允许有限重试

即使设计良好,复杂并发系统仍可能偶发死锁。事务应具备幂等性,并只对明确的死锁或锁等待错误进行有限重试:

for attempt := 0; attempt < 3; attempt++ {
    err := runTransaction(ctx)
    if !isDeadlock(err) {
        return err
    }
    time.Sleep(backoffWithJitter(attempt))
}

每次重试必须重新开始整个事务,不能在已失败事务上继续。退避需要随机抖动,避免竞争双方同步重试再次碰撞。超过次数后返回可观测错误,不要无限循环。

治理清单

  1. 缩短事务,禁止事务内远程调用。
  2. 多行操作按统一主键或业务键顺序执行。
  3. 为更新和锁定条件提供准确索引。
  4. 记录事务 ID、关键参数和完整 SQL 顺序。
  5. 对死锁做有限、幂等、带抖动的整体重试。
  6. 用并发测试复现,而不是只跑串行单测。

死锁不是单纯的数据库参数问题,它通常暴露了业务资源访问顺序不统一。修复后应把顺序规则写进仓储层公共方法,避免不同业务再次各写一套。

参考资料