适用场景
业务接口偶发返回 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_trx、innodb_locks 和 innodb_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 回到事务设计
- 用错误日志中的时间、连接 ID 或 trace ID 找到两条完整 SQL 和业务入口;不要只看最终失败的 SQL。
- 为涉及的 SQL 执行
EXPLAIN。更新条件未命中合适索引时,扫描范围扩大,锁定的二级索引记录和间隙也会增加。 - 检查同一业务对象的锁定顺序。例如批量更新必须按
sku_id、account_id等稳定键排序。 - 检查事务内是否夹带 RPC、文件上传、消息发送、长循环或人工确认;这些操作会无谓延长持锁时间。
- 确认隔离级别。
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 应接近本次业务实际处理行数。若 key 为 NULL 或 rows 很大,先补齐或调整联合索引,再观察死锁变化;不要只在应用层无限重试。
修复方案
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 与索引,统一访问顺序并缩短事务,最后才为幂等操作增加有限重试。这样既能降低死锁发生率,也能避免用无限重试掩盖数据模型或事务边界问题。
Discussion
评论