KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
07 · 死锁的工程化处理 — keel 龙骨
这一章回答:死锁日志拿到了,怎么反推该改哪一行代码,以及应用层到底要不要重试。
这一章回答:死锁日志拿到了,怎么反推该改哪一行代码,以及应用层到底要不要重试。
先修课讲了怎么看 SHOW ENGINE INNODB STATUS。这一章往前走两步:把日志翻译成代码改动,以及建立一套死锁不会演变成事故的防线。
一、死锁是怎么产生的(以及 InnoDB 怎么处理)
死锁的四个必要条件:互斥、持有并等待、不可抢占、循环等待。打破任意一个即可,工程上最可行的是打破循环等待(统一加锁顺序)和持有并等待(缩短事务、一次性取锁)。
InnoDB 的处理:
- 检测到循环等待后,回滚代价较小的事务(通常是修改行数少的那个),另一个继续执行;
- 被回滚的事务会收到
ERROR 1213 (40001): Deadlock found; - 检测有开销,但 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 的事务会与之死锁。
✅ 修法(按优先级):
- 把范围更新改成按主键精确更新(先查 id 列表,再按序更新);
- 业务允许时,把该会话/该表的隔离级别降到 RC(RC 下基本没有间隙锁);
- 缩短事务,减少间隙锁的持有时间。
场景 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)) # 退避在事务外
四条纪律:
- 重试整个事务,不能只重试最后一条 SQL(前面的状态已经回滚了);
- 退避必须指数 + 随机抖动(避免同时重试造成二次冲突);
- 次数上限(通常 2~3 次),超过就明确失败;
- 只对死锁重试(1213),不要对锁等待超时(1205)盲目重试——后者说明系统已经过载,重试会加剧问题。
六、让死锁可见:监控与告警
-- 死锁计数(8.0)
SELECT count FROM information_schema.innodb_metrics WHERE name = 'lock_deadlocks';
-- 或用 status
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
要做的三件事:
- 采集死锁次数指标并告警(突然升高通常伴随一次发布);
- 保留死锁日志:
innodb_print_all_deadlocks = ON,把死锁写进 error log,否则SHOW ENGINE INNODB STATUS只保留最近一次,排查时已经被覆盖; - 在应用日志里记录死锁的 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 后连续产生多次死锁 |
排查时是否只剩最后一次(说明开启的必要性) |
自测题
- 死锁与锁等待超时有什么本质区别?两者的处理思路分别是什么?
- 死锁日志里你最关心哪四个信息?如何从中反推代码位置?
- 统一加锁顺序为什么能消除死锁?它对业务逻辑有什么要求?
- 为什么"事务里调用外部接口"会导致死锁激增?用并发度与持锁时间的关系解释。
- 应用层的死锁重试有哪些纪律?为什么不能对锁等待超时无脑重试?