业务日志里每隔几天冒出一条 Deadlock found when trying to get lock,重试一次就过去了。也有人半夜被 Lock wait timeout exceeded 叫醒,接口整整卡了五十秒。
这两个报错经常被混为一谈,处理方式却完全不同。前者是 InnoDB 主动挑了一个事务回滚,后者是某个事务一直等不到锁。先把它们分开,再谈修复。
现象:两个错误码,两套逻辑#
| 错误码 | 报错文本 | 含义 | 处理方向 |
|---|---|---|---|
| 1213 | Deadlock found when trying to get lock |
检测到循环等待,InnoDB 回滚代价较小的一方 | 分析两个事务的加锁顺序 |
| 1205 | Lock wait timeout exceeded |
等锁超过 innodb_lock_wait_timeout(默认 50 秒) |
找长事务或未提交事务 |
| — | Trx ... has been waiting for ... sec |
只是等待记录,未必成环 | 看持锁者是谁 |
1213 通常能靠重试消化,但如果频率上升,说明并发路径的加锁顺序已经乱了。1205 更危险,它会把连接和线程一起占住,拖垮整个连接池。
被回滚的那个事务叫牺牲者(victim),InnoDB 一般选择修改行数较少、代价较小的一个。这意味着:重试必须安全。如果业务在事务里已经做了不可回滚的副作用(发消息、调第三方),死锁回滚之后数据就可能不一致。看到 1213,先确认这段逻辑是否幂等,再谈重试次数。
定位:把死锁现场读出来#
InnoDB 会把最近一次死锁留在内存里,直接翻出来;想把历史死锁都留下,再打开全局开关(会增加错误日志量,排障期临时开):
SHOW ENGINE INNODB STATUS\G -- 看 LATEST DETECTED DEADLOCK 段
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 实时看谁在等谁(MySQL 8.0,需开启 performance_schema)
SELECT * FROM performance_schema.data_lock_waits\G
SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_query
FROM information_schema.innodb_trx ORDER BY trx_started;
这一段里有两个事务块,各自列出 HOLDS THE LOCK(S) 和 WAITING FOR THIS LOCK TO BE GRANTED,以及最后执行的 SQL。关键是看两边的 index 和 lock_mode:如果都在等同一张表的不同行,但加锁顺序相反,那就是典型的顺序死锁。
lock_mode 值得单独认一下:
X locks rec but not gap:只锁记录本身,没有间隙锁,通常是唯一索引等值命中。X:记录锁加间隙锁,REPEATABLE READ 下范围条件常见。X locks gap before rec:只锁间隙,多出现在插入或范围扫描时。
两边都是 X 且 index 不同,说明同一批数据走了两条不同的索引路径,加锁顺序自然不一致。
trx_rows_locked 是个好指标:一个只更新几行的语句却锁了几万行,说明索引没走对,锁的范围被放大了。trx_started 很早而 trx_query 为空,通常是应用开了事务却没提交。
根因:死锁的四个必要条件加上三个常见触发#
死锁需要互斥、持有并等待、不可剥夺、循环等待同时成立。数据库能做的只是检测并回滚,真正的解法是打断循环等待。生产上常见三种触发方式。
加锁顺序不一致。 两个事务分别先锁 A 再锁 B、先锁 B 再锁 A。批量更新时尤其隐蔽:WHERE id IN (1,2) 看起来顺序固定,但优化器可能走不同索引,实际加锁顺序并不保证。
锁范围被放大。 更新条件没有索引,或者索引选择性太差,InnoDB 只能扫大量行并逐行加锁。在 REPEATABLE READ 下,范围扫描还会加 next-key lock 和间隙锁,连「不存在的行」都被锁住,插入操作也跟着排队。
唯一键与二级索引的顺序差异。 INSERT ... ON DUPLICATE KEY UPDATE 先加唯一索引的锁,再改聚簇索引;普通 UPDATE 若修改了二级索引列,加锁顺序又不一样。两条路径交叉时就会成环。
还有一种不算死锁但症状相似的:长事务不提交,锁一直不释放,别人等满 innodb_lock_wait_timeout 后报 1205。它和死锁的区别是没有人成环,只是持锁方太慢。
最小复现,两个会话交叉更新两行:
-- 会话 A
BEGIN; UPDATE account SET balance = balance - 10 WHERE id = 1;
-- 会话 B
BEGIN; UPDATE account SET balance = balance + 10 WHERE id = 2;
-- 会话 A(此时等待 B 持有的 id=2)
UPDATE account SET balance = balance + 10 WHERE id = 2;
-- 会话 B(触发死锁,一方被回滚)
UPDATE account SET balance = balance - 10 WHERE id = 1;
修复:先统一顺序,再缩短事务#
顺序问题用顺序解决。批量操作先按主键排序,把锁一次性按固定次序拿全:
BEGIN;
-- 先按主键升序锁定目标行,所有事务走同一顺序
SELECT id, balance FROM account
WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
UPDATE account SET balance = balance - 10 WHERE id = 1;
UPDATE account SET balance = balance + 10 WHERE id = 2;
COMMIT;
配套动作:
- 用
EXPLAIN确认更新条件走了索引,rows估算不应远超实际更新行数。 - 事务里禁止 HTTP、RPC、消息发送,把外部调用挪到事务外。
- 大事务拆小,单事务控制在百行级别,提交越早锁持有越短。
- 死锁回滚是安全的,业务层对 1213 做有限重试;1205 不要盲目重试,先查持锁者。
重试要带退避和上限,避免雪崩:
import random, time
def run_with_retry(fn, attempts=3):
for i in range(attempts):
try:
return fn()
except DeadlockError: # 仅重试 1213
if i == attempts - 1:
raise
time.sleep(0.05 * 2 ** i + random.random() * 0.01)
把隔离级别降到 READ COMMITTED 能减少间隙锁,从而降低一部分死锁概率,但会改变一致性语义,还要确认 binlog 格式为 ROW。这是一项需要评估的取舍,不是默认答案。
重试上限要和接口超时放在一起算:50ms、100ms、200ms 三次退避加起来不到 400ms,才不会把一次请求拖成几秒。退避时间写死成大常数,等于把死锁放大成超时。
防复发#
- 常开或按需开启
innodb_print_all_deadlocks,把死锁写进日志采集,别等用户投诉。 - 监控
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks'的增速,突增就是加锁顺序变了的信号。 - 所有批量更新收敛到一个 DAO 方法,顺序由这一处保证,而不是每个调用方各自拼 SQL。
- 代码规范里写明「事务内不做外部调用」,用中间件或静态检查兜底。
- 压测时构造并发更新同一批行的场景,比读代码更容易复现死锁。
- 给死锁日志配一条检索规则,按表名和 SQL 指纹聚合,重复出现的模式优先治理。
结论#
1213 是检测到循环等待后的主动回滚,1205 是等待超时,两者要分开处理。定位的核心是 SHOW ENGINE INNODB STATUS 里的两个事务块,看谁持有什么锁、又在等什么锁。
修复优先级:统一加锁顺序,缩小锁范围(补索引、避免无索引更新),缩短事务,最后才是重试和降隔离级别。重试只对 1213 有意义,且必须限次数、带退避。
还有一条容易被忽略的前提:重试安全依赖幂等。事务里如果已经产生外部副作用,先把它挪出去,再加循环。
死锁不会消失,只会变少。目标不是零死锁,而是让每一次死锁都能在日志里被解释清楚。
完