死锁是加锁顺序不一致的必然结果,需统一按主键升序加锁、避免范围查询无索引、禁用事务内非数据库操作,并以日志+重试为兜底而非解决方案。

死锁不是“运气差”,是加锁顺序不一致的必然结果
MySQL在转账类业务中频繁死锁,根本原因不是并发高,而是多个事务对同一组账户(比如 A 和 B)以不同顺序执行 UPDATE。InnoDB 的行锁是按 SQL 执行顺序逐行加的,A 先锁 id=1 再锁 id=2,B 反过来先锁 id=2 再锁 id=1 —— 循环等待立刻成立。只要满足互斥、持有并等待、不可剥夺、循环等待这四个条件,死锁就一定会被 InnoDB 检测到并回滚其中一个事务。
必须统一按主键升序加锁,而不是按业务逻辑顺序
很多人误以为“先扣 A 再打 B”是业务语义,就该写成两步 UPDATE。但数据库只认锁顺序,不认业务意图。正确做法是:不管转账方向(A→B 还是 B→A),所有事务都按 id 升序锁定涉及的账户行。
- 错误写法(死锁温床):
UPDATE account SET balance = balance - 100 WHERE id = ?(传入 A 的 id),再UPDATE account SET balance = balance + 100 WHERE id = ?(传入 B 的 id),参数顺序由业务决定 - 正确写法(强制升序):先算出
MIN(id_A, id_B)和MAX(id_A, id_B),然后固定执行:UPDATE account SET balance = balance - 100 WHERE id = ?(小 id),再UPDATE account SET balance = balance + 100 WHERE id = ?(大 id) - 更稳妥的写法:用一条
SELECT ... FOR UPDATE ORDER BY id一次性锁定,例如:SELECT id, balance FROM account WHERE id IN (?, ?) ORDER BY id FOR UPDATE,之后再做余额校验和更新
间隙锁(Gap Lock)在范围查询中悄悄埋雷
如果你的转账逻辑里混入了类似 WHERE status = 'pending' AND created_at > '2026-09-01' 这种非主键条件,而 status 列又没索引,InnoDB 就会退化为全表扫描+间隙锁,极大增加死锁概率。尤其在 RR 隔离级别下,间隙锁会锁住索引区间,两个事务同时执行类似操作极易交叉等待。
- 确认所有
WHERE条件字段都有对应索引,用EXPLAIN验证是否走了索引 - 高并发扣减场景禁用范围条件,改用主键 or 唯一键精确查询
- 若业务能容忍幻读,可考虑将隔离级别降为
READ COMMITTED,InnoDB 会自动关闭间隙锁 - 避免在事务内做非数据库操作(如 HTTP 调用、复杂计算),否则锁持有时间拉长,放大冲突窗口
应用层重试不能替代锁顺序治理
捕获 Deadlock found when trying to get lock 后自动重试,只是兜底手段,不是解决方案。它掩盖了设计缺陷,且无法保证重试后不继续死锁——如果加锁顺序没改,重试只是把失败概率均摊到更多请求上。
- 重试最多 2–3 次,超时应快速失败并告警,而不是无限循环
- 日志中必须记录被回滚事务的
thread_id和关联业务单号,方便与SHOW ENGINE INNODB STATUS中的TRANSACTION块对齐 - ORM(如 MyBatis)动态 SQL 容易导致
IN子句内 id 顺序不一致,建议对关键转账事务禁用<if></if>拼接,改用预编译语句 + 显式排序
真正难的不是写重试逻辑,而是让所有服务、所有模块、所有开发人员都遵守同一套加锁顺序规则。一旦有例外,死锁就会在某个凌晨三点准时出现。











