适用场景

业务接口偶发返回 Deadlock found when trying to get lock(错误码 1213),订单、库存、账户余额或状态流转等事务写入失败;重试后通常成功,但高峰期错误数明显上升。本文以 InnoDB 为例,给出一套可在生产环境执行的取证、定位和治理流程。

死锁不是数据库“故障”:两个或多个事务互相等待对方持有的锁,InnoDB 会主动回滚其中一个事务以打破环路。真正需要解决的是业务访问顺序、事务范围或索引设计导致的高频死锁。

现象与边界

应用日志常见如下异常:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

先区分两类问题:

  • 死锁:通常立即报错 1213,错误日志或 InnoDB 状态中有“LATEST DETECTED DEADLOCK”。
  • 锁等待超时:等待超过 innodb_lock_wait_timeout 后报错 1205,未必存在等待环。

两者的修复手段不同。不能仅通过调大 innodb_lock_wait_timeout 来处理死锁。

第一时间保留现场

在出现告警时,先在主库执行以下只读查询。SHOW ENGINE INNODB STATUS 只保留最近一次死锁,因此建议将结果及时保存到故障工单或日志系统。

SHOW ENGINE INNODB STATUS\G

SELECT
  OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME,
  LOCK_TYPE, LOCK_MODE, LOCK_STATUS,
  LOCK_DATA, ENGINE_TRANSACTION_ID,
  THREAD_ID, PROCESSLIST_INFO
FROM performance_schema.data_locks
ORDER BY OBJECT_SCHEMA, OBJECT_NAME, ENGINE_TRANSACTION_ID;

SELECT
  REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx,
  BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx,
  REQUESTING_THREAD_ID AS waiting_thread,
  BLOCKING_THREAD_ID AS blocking_thread
FROM performance_schema.data_lock_waits;

重点阅读死锁段中的三个字段:

  • TRANSACTION:事务 ID、已持锁数量和等待锁。
  • WAITING FOR THIS LOCK TO BE GRANTED:本事务正在请求的索引及记录。
  • HOLDS THE LOCK(S):另一事务已持有的索引及记录。

如果 data_locks 查询提示表不存在,说明 MySQL 版本较旧;可使用 information_schema.innodb_trxinnodb_locksinnodb_lock_waits,但应规划升级,不要在业务高峰依赖轮询这些旧表。

一个典型定位示例

假设库存扣减接口一次处理多个 SKU。事务 A 按请求顺序更新 101, 205,事务 B 按另一顺序更新 205, 101

-- 事务 A
BEGIN;
UPDATE inventory SET available = available - 1 WHERE sku_id = 101;
UPDATE inventory SET available = available - 1 WHERE sku_id = 205;
COMMIT;

-- 事务 B(并发执行)
BEGIN;
UPDATE inventory SET available = available - 1 WHERE sku_id = 205;
UPDATE inventory SET available = available - 1 WHERE sku_id = 101;
COMMIT;

两边各拿到一把记录锁,再请求对方的记录锁,就形成环路。死锁报告中若出现同一张表、同一个 PRIMARY 或二级索引、且两条记录交叉等待,优先检查调用链是否存在这种不一致的锁定顺序。

排查路径:从 SQL 回到事务设计

  1. 用错误日志中的时间、连接 ID 或 trace ID 找到两条完整 SQL 和业务入口;不要只看最终失败的 SQL。
  2. 为涉及的 SQL 执行 EXPLAIN。更新条件未命中合适索引时,扫描范围扩大,锁定的二级索引记录和间隙也会增加。
  3. 检查同一业务对象的锁定顺序。例如批量更新必须按 sku_idaccount_id 等稳定键排序。
  4. 检查事务内是否夹带 RPC、文件上传、消息发送、长循环或人工确认;这些操作会无谓延长持锁时间。
  5. 确认隔离级别。REPEATABLE READ 下范围条件可能出现 next-key lock;不改变业务语义的前提下,可评估 READ COMMITTED 是否减少间隙锁竞争。

下面的查询可快速检查更新条件是否走到预期索引:

EXPLAIN UPDATE inventory
SET available = available - 1
WHERE sku_id IN (101, 205);

SHOW INDEX FROM inventory;

key 应为用于定位行的索引,rows 应接近本次业务实际处理行数。若 keyNULLrows 很大,先补齐或调整联合索引,再观察死锁变化;不要只在应用层无限重试。

修复方案

1. 统一加锁顺序

批量业务先去重并按主键升序排序,所有写路径采用相同顺序。以 Python 为例:

sku_ids = sorted(set(request.sku_ids))

with connection.cursor() as cursor:
    cursor.execute("START TRANSACTION")
    try:
        for sku_id in sku_ids:
            cursor.execute(
                "UPDATE inventory "
                "SET available = available - %s "
                "WHERE sku_id = %s AND available >= %s",
                [quantity[sku_id], sku_id, quantity[sku_id]],
            )
            if cursor.rowcount != 1:
                raise InsufficientStock(sku_id)
        cursor.execute("COMMIT")
    except Exception:
        cursor.execute("ROLLBACK")
        raise

排序的关键不是“升序更快”,而是让所有并发事务以完全一致的顺序竞争同一组记录。

2. 缩短事务并补齐索引

将外部调用移到提交后执行;将大事务拆成有明确幂等边界的小事务。对 WHERE tenant_id = ? AND status = ? 这类高频条件,建立与筛选顺序匹配的联合索引,并通过 EXPLAIN 验证。索引变更应先在影子库或低峰灰度执行,避免 DDL 本身造成新的写入抖动。

3. 只对可幂等操作做有限重试

InnoDB 选择受害事务后会回滚整个事务,因此客户端应重新开启事务,而不是继续复用原事务。推荐对错误码 1213 或 SQLSTATE 40001 做 2–3 次指数退避重试,并确保请求具备幂等键。

import random
import time

MAX_ATTEMPTS = 3

def run_with_deadlock_retry(work):
    for attempt in range(MAX_ATTEMPTS):
        try:
            return work()  # work 内部必须完整地 begin/commit 或 rollback
        except DatabaseError as exc:
            if getattr(exc, "errno", None) != 1213 or attempt == MAX_ATTEMPTS - 1:
                raise
            time.sleep((0.05 * (2 ** attempt)) + random.uniform(0, 0.03))

支付、发券、消息投递等有外部副作用的流程,必须先以唯一业务键落库,再由事务外的可靠投递机制处理副作用;否则重试可能造成重复扣款或重复发送。

预防与监控

  • 监控 MySQL 错误码 1213 的 QPS、按接口/SQL 指纹聚合,并设置相对基线告警。
  • 在非高峰短时启用 innodb_print_all_deadlocks=ON 将所有死锁写入错误日志;取证完毕后关闭,避免日志暴涨。
  • 为批量写入制定“稳定排序、短事务、可重试、幂等键”四项代码评审清单。
  • 发布涉及索引、隔离级别或批处理并发度的变更后,观察死锁率、P95 延迟和重试成功率,而非只看接口成功率。

总结

处理死锁的顺序应是:保存死锁报告,识别交叉锁定的 SQL 与索引,统一访问顺序并缩短事务,最后才为幂等操作增加有限重试。这样既能降低死锁发生率,也能避免用无限重试掩盖数据模型或事务边界问题。