KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

07 · 死锁的工程化处理 — keel 龙骨

这一章回答:死锁日志拿到了,怎么反推该改哪一行代码,以及应用层到底要不要重试。

这一章回答:死锁日志拿到了,怎么反推该改哪一行代码,以及应用层到底要不要重试。

先修课讲了怎么看 SHOW ENGINE INNODB STATUS。这一章往前走两步:把日志翻译成代码改动,以及建立一套死锁不会演变成事故的防线。

一、死锁是怎么产生的(以及 InnoDB 怎么处理)

死锁的四个必要条件:互斥、持有并等待、不可抢占、循环等待。打破任意一个即可,工程上最可行的是打破循环等待(统一加锁顺序)和持有并等待(缩短事务、一次性取锁)。

InnoDB 的处理:

⚠️ 锁等待超时(50s 默认)与死锁是不同的东西:死锁是"互相等"被检测出来,锁等待超时是"等太久"被动放弃。前者要改加锁顺序,后者要先查谁持锁不放(长事务)。

二、读懂死锁日志

SHOW ENGINE INNODB STATUS\G

看 LATEST DETECTED DEADLOCK 段,关键字段:

------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
UPDATE orders SET status='PAID' WHERE id=10        ← 事务1在执行的 SQL

*** (1) HOLDS THE LOCK(S):                          ← 事务1已持有的锁
RECORD LOCKS space id 57 page no 4 index PRIMARY of table `app`.`orders`
  trx id 12345 lock_mode X locks rec but not gap

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:        ← 事务1在等的锁
RECORD LOCKS ... index PRIMARY ... trx id 12345 lock_mode X ...

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 2 sec starting index read
UPDATE orders SET status='PAID' WHERE id=20        ← 事务2的 SQL
*** (2) HOLDS THE LOCK(S): ...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: ...

*** WE ROLL BACK TRANSACTION (2)                    ← 谁被回滚了

四步翻译法:

① 找到两个事务各自的 SQL(两行 UPDATE/DELETE/SELECT FOR UPDATE)
② 看各自 HOLDS 什么锁、WAITING 什么锁 → 画出等待环
③ 看加锁的 index 名:是 PRIMARY 还是二级索引?是否是 gap lock?
④ 回代码:这两条 SQL 在哪个函数里?它们的执行顺序为什么会交叉?

第 ③ 步常被忽略但很关键:index PRIMARY 说明是主键行锁;出现 locks gap before rec 说明是 RR 下的间隙锁(范围更新/不存在行的更新容易产生)。

三、四类高发场景与对应改法

场景 1:加锁顺序相反(最常见)

# 事务 A:先 id=10 再 id=20;事务 B:先 id=20 再 id=10 → 必死锁
async def transfer_bad(from_id, to_id):
    await execute("UPDATE accounts SET balance=balance-100 WHERE id=?", from_id)
    await execute("UPDATE accounts SET balance=balance+100 WHERE id=?", to_id)

✅ 修法:按固定顺序加锁(如按 id 排序):

async def transfer(from_id, to_id):
    first, second = sorted([from_id, to_id])     # 统一从小到大
    await execute("UPDATE accounts SET balance=balance-100 WHERE id=?", first)
    await execute("UPDATE accounts SET balance=balance+100 WHERE id=?", second)

这是消除死锁最有效的一招,成本几乎为零。

场景 2:批量更新乱序

UPDATE orders SET status='CLOSED' WHERE id IN (30, 10, 20);   -- 优化器不保证顺序

✅ 修法:在应用层排序后逐条或分批按序更新,或至少保证所有地方用同一种顺序。

for oid in sorted(order_ids):
    await execute("UPDATE orders SET status='CLOSED' WHERE id=?", oid)

场景 3:间隙锁(RR 下的范围更新 / 更新不存在的行)

-- RR 下,这条会锁住 (100, 200] 区间(临键锁),即使这些行不存在
UPDATE orders SET amount=0 WHERE id > 100 AND id < 200;

并发插入 id=150 的事务会与之死锁。

✅ 修法(按优先级):

  1. 把范围更新改成按主键精确更新(先查 id 列表,再按序更新);
  2. 业务允许时,把该会话/该表的隔离级别降到 RC(RC 下基本没有间隙锁);
  3. 缩短事务,减少间隙锁的持有时间。

场景 4:二级索引与主键交叉加锁

UPDATE orders SET status='PAID' WHERE tenant_id=42 AND status='PENDING';
-- 会同时锁二级索引记录与对应的主键记录

如果另一条 SQL 先更新主键再更新二级索引,就可能形成环。

✅ 修法:让所有更新走同一条路径(要么都先查主键再更新,要么都用同一索引条件)。

四、预防四招(按性价比排序)

招式 做法 性价比
① 统一加锁顺序 多行/多表更新前按 id 或业务键排序 ⭐⭐⭐⭐⭐
② 缩短事务 事务里只放数据库操作;外部调用、计算、等待一律移到事务外 ⭐⭐⭐⭐⭐
③ 缩小锁范围 WHERE 必须走索引;避免范围更新;避免大事务 ⭐⭐⭐⭐
④ 降低隔离级别 业务允许时用 RC(减少间隙锁) ⭐⭐⭐(要评估一致性影响)

第 ② 招值得单独强调:事务里调用一个 200ms 的 HTTP 接口,等于把锁持有时间增加 200ms。并发下锁冲突概率大约是并发度 × 持锁时间的函数,所以持锁时间翻倍,冲突也翻倍。这是"我只是加了个远程调用"引发大面积死锁的原因。

五、应用层该不该重试:要,但要有纪律

死锁被检测后,被回滚的事务是可以安全重试的(它的修改已全部回滚,没有副作用)。但重试必须满足:

async def with_deadlock_retry(fn, retries=3):
    for attempt in range(retries):
        try:
            async with session.begin() as tx:      # 事务边界在这里
                result = await fn(tx)
            return result                          # 提交成功才返回
        except DeadlockError:                      # 1213
            if attempt == retries - 1:
                raise
            await asyncio.sleep(random.uniform(0.01, 0.05) * (2 ** attempt))  # 退避在事务外

四条纪律:

  1. 重试整个事务,不能只重试最后一条 SQL(前面的状态已经回滚了);
  2. 退避必须指数 + 随机抖动(避免同时重试造成二次冲突);
  3. 次数上限(通常 2~3 次),超过就明确失败;
  4. 只对死锁重试(1213),不要对锁等待超时(1205)盲目重试——后者说明系统已经过载,重试会加剧问题。

六、让死锁可见:监控与告警

-- 死锁计数(8.0)
SELECT count FROM information_schema.innodb_metrics WHERE name = 'lock_deadlocks';
-- 或用 status
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';

要做的三件事:

  1. 采集死锁次数指标并告警(突然升高通常伴随一次发布);
  2. 保留死锁日志:innodb_print_all_deadlocks = ON,把死锁写进 error log,否则 SHOW ENGINE INNODB STATUS 只保留最近一次,排查时已经被覆盖;
  3. 在应用日志里记录死锁的 SQL 与堆栈,这样能直接定位到代码位置,而不是只知道"数据库发生了死锁"。
SET GLOBAL innodb_print_all_deadlocks = ON;   -- 建议生产开启(日志量可控)

动手:可观察结果

产出 判断标准
一次死锁复现与日志解读 能指出两个事务各自持有什么、等什么,并画出等待环
一次"日志 → 代码"的反推 从死锁日志中的两条 SQL 定位到具体函数,说明顺序为何交叉
统一加锁顺序的修复验证 修复前死锁 N 次/分钟 → 修复后 0 次,附压测数据
间隙锁场景验证 RR 下复现间隙锁死锁,降到 RC 后对比
重试策略实现 整事务重试 + 指数退避 + 上限;只对 1213 重试

完成标志:给你一段真实的 LATEST DETECTED DEADLOCK 日志,你能说出改哪一行代码、为什么,并预估修复后的效果。

故障注入

注入方式 观察
两个事务以相反顺序更新两行 死锁是否被检测,谁被回滚,日志内容
在事务里调用一个慢 HTTP 接口后压测 死锁/锁等待次数随持锁时间的变化
RR 下范围更新 + 并发插入区间内数据 是否出现间隙锁死锁;改成 RC 后是否消失
修复加锁顺序后重跑相同压测 死锁次数是否归零,吞吐变化
对锁等待超时(1205)做盲目重试 是否加剧过载(错误率与延迟)
关闭 innodb_print_all_deadlocks 后连续产生多次死锁 排查时是否只剩最后一次(说明开启的必要性)

自测题

  1. 死锁与锁等待超时有什么本质区别?两者的处理思路分别是什么?
  2. 死锁日志里你最关心哪四个信息?如何从中反推代码位置?
  3. 统一加锁顺序为什么能消除死锁?它对业务逻辑有什么要求?
  4. 为什么"事务里调用外部接口"会导致死锁激增?用并发度与持锁时间的关系解释。
  5. 应用层的死锁重试有哪些纪律?为什么不能对锁等待超时无脑重试?

进入 keel 阅读