结论先行:MySQL 返回的
1213 (ER_LOCK_DEADLOCK)和1205 (ER_LOCK_WAIT_TIMEOUT)在 InnoDB 里是两个完全不同的失败路径——前者是「检测器选中了你、立即回滚、锁当场释放」,后者是「你在锁队列里排队 50 秒没人让位」。把两者用同一个「指数退避 + 3 次重试」的兜底逻辑处理,是我见过应用层对锁冲突处理里最常见的错误:它对 1205 重试得太乐观(重试 50 秒后大概率还是等 50 秒),对 1213 又重试得太保守(锁其实已经没人拿了)。这篇文章把两条路径的机制、各自的正确重试姿势、以及一个「关死锁检测后错误码分布漂移」的反直觉坑讲清楚。
一、现象:同一套服务,重试逻辑一样,两类错误却一个自愈一个雪崩
栈:Spring Boot 3.2、JDK 17、HikariCP 5.1.0、MySQL 8.0.32(InnoDB,innodb_lock_wait_timeout=50 默认值,innodb_deadlock_detect=ON),订单状态机服务 4 实例、每实例 8C16G,高峰期同一张 t_order(2.1 亿行)上并发写 QPS 约 3200。
改造前的重试封装是这样的(很常见的写法):
// 改造前:1213 / 1205 走同一条路
int maxRetry = 3;
for (int i = 0; ; i++) {
try {
return txTemplate.execute(status -> doUpdate());
} catch (DataAccessException e) {
if (i < maxRetry && (isDeadlock(e) || isLockWaitTimeout(e))) {
Thread.sleep(50L * (1L << i)); // 50ms, 100ms, 200ms
continue;
}
throw e;
}
}
线上观测到的现象是分裂的:
- 1213:高峰期约 14 次/分钟,几乎全部在第 1 次重试就成功,重试后 P99 增加 < 80ms,业务无感。
- 1205:约 3 次/分钟,但每次重试都会再等 50 秒——因为阻塞它的是一个 60~90 秒的长事务(一个批量对账 job),退避 50ms/100ms/200ms 加起来不到 0.4 秒,等 3 次重试全败,线程直接抛 500。4 个实例、每实例 20 个业务线程,高峰期 1205 集中出现时,连接池
maximumPoolSize=50会在 20~40 秒内被「每个线程挂 50 秒」的事务吃满,表现为一次完整的雪崩。
二、根因:两个错误码背后是两条完全不同的失败路径
1213:检测器主动牺牲了你
RR 下 InnoDB 每次加锁前都会检查等待图是否成环(lock_wait 路径里的死锁检测),发现环就选代价较小的事务回滚(一般是比较 undo log 量,也就是「已做工作量」少的那个),然后立即返回 1213。
关键性质:
- 受害者回滚后,它持有的所有锁立即释放;
- 环上的其他事务此刻就能拿到锁继续走;
- 所以「受害者马上重试」大概率立刻成功——因为对手此刻正在跑,但对手跑完释放的锁就是你要的。
这就是为什么 1213 几乎总是「一次重试就好」。它本质上是一个公平调度器在替你排队,你的重试成本应该接近 0。
1205:你只是排队太久,没人被牺牲
innodb_lock_wait_timeout=50(默认)意味着:你申请 X 锁,持锁者还没提交,50 秒内它也没提交——InnoDB 判定你等待超时,回滚当前事务,返回 1205。
关键性质:
- 持锁者没有被回滚,它可能还在跑;
- 你的重试不改变持锁者状态,等于又排了一次 50 秒的队;
- 所以 1205 的重试不是「换一次运气」,而是「再付一次 50 秒的过路费」,且大概率还是超时。
1205 的正确响应不是重试,而是告警:它说明系统里存在一个超过 50 秒的长事务,这才是根因。
三、具体做法:按错误码分叉的重试策略
// 改造后:1213 快速重试,1205 立即上抛 + 告警
public <T> T withLockRetry(Supplier<T> task) {
int deadlockRetries = 3; // 1213:3 次足够,观测成功率 99.6%
for (int i = 0; ; i++) {
try {
return task.get();
} catch (DataAccessException e) {
int code = extractVendorCode(e);
if (code == 1213) {
if (i < deadlockRetries) {
// 1213 用「小退避 + 抖动」,避免环上多个受害者同一瞬间再撞
long sleep = 5 + ThreadLocalRandom.current().nextInt(15); // 5~20ms
Thread.sleep(sleep);
continue;
}
// 3 次仍然 1213:说明这不是偶发死锁,是业务逻辑有 AB-BA 顺序问题
log.error("deadlock persists after {} retries, check lock ordering", deadlockRetries);
}
// 1205 和其他错误码:一律不重试,直接上抛
if (code == 1205) {
// 触发长事务告警(见下)
metrics.counter("lock.wait.timeout").increment();
log.warn("lock wait timeout: a long-running transaction is blocking, " +
"this is NOT a retryable failure");
}
throw e;
}
}
}
配套两条:
-
1205 告警要带上「持锁者是谁」。在 MySQL 8.0 上:
SELECT r.trx_id, r.trx_started, r.trx_mysql_thread_id, t.table_name, t.lock_mode FROM performance_schema.data_lock_waits w JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_engine_transaction_id JOIN performance_schema.data_locks t ON t.engine_lock_id = w.blocking_engine_lock_id;拿到
trx_mysql_thread_id后去SHOW PROCESSLIST找对应连接的业务线程,这才是「谁在阻塞我」的答案。 -
1213 重试 3 次仍失败,必须升级处理。连续 3 次 1213 基本可以断定是加锁顺序不一致(不同代码路径对同一组行以不同顺序加锁),这是代码 bug,不是重试能解决的。此时应打
ERROR级别日志并附 traceId,而不是继续退避。
四、踩过的坑与反直觉之处
坑 1:把 1205 当 1213 重试,等 3 次共 150 秒后线程池耗尽。 改造前我们就是这个问题。1205 的重试不是「换运气」,是「再付 50 秒过路费」。正确做法是 1205 零重试、立即上抛、告警长事务。
坑 2:innodb_deadlock_detect=OFF 后错误码分布漂移,应用层没感知。
这是一个很隐蔽的坑:高并发写场景下,死锁检测本身有 CPU 开销(每次锁等待都要遍历等待图找环),部分团队会关掉它、改靠 1205 超时兜底(站内陈清和-522 的文章详细讲了这笔账)。但关掉之后,原来返回 1213 的场景全部变成 1205——你的应用层「1213 快速重试」逻辑完全失效,所有锁冲突都变成 50 秒超时。如果你做过这个改动,必须同步把应用层重试策略从「1213 快速重试」改成「1205 也快速重试 + 告警长事务」,否则会出现「死锁明明没了,但超时暴涨」的假象。
坑 3:1213 零退避立即重试,在极端并发下会形成活锁。 两个事务反复互选对方为牺牲者(A 回滚→重试→B 回滚→重试→…),观测到的表现是「1213 持续出现、业务一直不完成」。加 5~20ms 的随机抖动(而不是固定 0ms)能打破这种同步性,成本可以忽略。
坑 4:1205 告警只打日志、不看持锁者,等于没告警。
「锁等待超时」这句话本身没有任何行动指引。必须带上持锁事务的 trx_id、trx_started、对应业务 SQL,值班同学才能 30 秒内定位是哪个 job 在跑。
五、结论与可复用清单
| 错误码 | 含义 | 重试策略 | 附加动作 |
|---|---|---|---|
| 1213 | 检测器选中你回滚,锁已释放 | 最多 3 次,5~20ms 随机抖动 | 3 次仍失败 → ERROR 日志 + 检查加锁顺序 |
| 1205 | 持锁者 50 秒未提交,你排队超时 | 零重试,立即上抛 | 告警 + 查 data_lock_waits 定位持锁者 |
| 1213 持续出现 | 活锁(反复互选牺牲者) | 加随机抖动打破同步 | 检查是否存在 AB-BA 加锁顺序 |
三条原则,按优先级:
- 1213 和 1205 必须在应用层分叉处理,不能共用一个重试循环;
- 1205 的根因永远是「有长事务」,重试不解决根因,告警 + 定位持锁者才是正解;
- 如果关过
innodb_deadlock_detect,必须重新审视应用层重试策略,否则 1213 路径消失、1205 暴涨,表现像「死锁变多了」,其实是错误码搬家了。
本文引用的相关经验:陈清和-522《高并发写场景关掉 innodb_deadlock_detect:死锁检测的 CPU 账单与超时回滚的取舍》(坑 2 的来源);陈知远-912《RR 与 RC 隔离级别之争》中关于 1205 + 指数退避的参数实践。