我的转账接口在高峰期偶尔会报 Deadlock found、事务莫名其妙被回滚,我对着这个时有时无的数据库死锁排查了大半天才搞懂加锁顺序的复盘
这是一个让我对数据库死锁刻骨铭心的故事。我有一个转账接口,逻辑很常规:在一个事务里,扣减 A 账户的余额、增加 B 账户的余额。它平时跑得好好的。可一到流量高峰、并发量大的时候,就会偶尔、随机地冒出一个错误:Deadlock found when trying to get lock; try restarting transaction(发现死锁,请尝试重启事务)——我的事务,莫名其妙地被数据库强行回滚了。这个错误时有时无,并发低时几乎不出现,并发一高就冒头,让我抓耳挠腮。
我一开始以为是数据库配置或网络问题,折腾半天没用。直到我把并发执行的几个事务,它们各自的 SQL 加锁顺序,画在纸上一一对照,才终于揭开真相,补上了我对数据库并发一个最核心的认知漏洞:问题的核心,是"死锁(deadlock)",而它的根源,是不同的事务,以不一致的顺序,去获取同样的几把锁。具体来说:我的转账逻辑里,事务的加锁顺序,取决于"转账方向"——当 A 转给 B 时,事务会先锁住 A 这行、再锁住 B 这行;而当同时,另一笔 B 转给 A 的交易也在执行时,它会先锁住 B 这行、再锁住 A 这行。于是,致命的交叉就发生了:第一个事务,锁住了 A,正等着去锁 B;而第二个事务,恰好锁住了 B,正等着去锁 A——它们俩,各自持有着对方正想要的那把锁,又都在死等对方先释放,谁也不肯、也无法退让,就这样无限地、互相等待下去,形成了一个"循环等待"的死结——这,就是死锁。而数据库本身,有死锁检测机制:当它发现这种"谁也走不了"的循环等待时,会主动出手"断结"——挑一个事务(通常是代价较小的那个)作为"牺牲品",强行将它回滚,从而让另一个事务能继续下去;那个被回滚的事务,就收到了 Deadlock found 这个错误。我这才痛彻地明白:数据库死锁,不是数据库的 bug,而是我的代码"用错了加锁的姿势"主动制造出来的;它的最常见成因,就是"并发事务以不同的顺序,锁定相同的资源"。要避免它,关键在于从代码层面,保证所有事务,都以一个全局一致的、固定的顺序,去获取它们需要的锁。死锁不可怕,可怕的是不理解它"循环等待"的本质,而在代码里到处埋下"加锁顺序不一致"的地雷。
故障现场:两个事务以相反顺序加锁,互相死等
我把这个"死锁"的现场,用代码摊开给你看:
-- ✗ 灾难: 两个并发事务以"相反的顺序"锁定同样两行 → 死锁
-- 事务1: A 转给 B (先锁 A, 再锁 B)
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 'A'; -- ① 锁住 A
UPDATE account SET balance = balance + 100 WHERE id = 'B'; -- ② 等着锁 B
COMMIT;
-- 事务2: B 转给 A (先锁 B, 再锁 A) —— 几乎同时执行
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 'B'; -- ① 锁住 B
UPDATE account SET balance = balance + 100 WHERE id = 'A'; -- ② 等着锁 A
COMMIT;
-- 死锁怎么形成(循环等待):
-- 时刻1: 事务1 锁住 A; 事务2 锁住 B。
-- 时刻2: 事务1 想锁 B → 但 B 被事务2 占着 → 等待。
-- 事务2 想锁 A → 但 A 被事务1 占着 → 等待。
-- → 互相持有对方想要的锁, 互相死等 → 循环等待 → 死锁!
-- 数据库的处理:
-- - InnoDB 有死锁检测, 发现循环等待 → 选一个事务回滚(牺牲品)。
-- - 被回滚的事务报: Deadlock found; try restarting transaction。
-- - 另一个事务得以继续。
-- 为什么"时有时无"?
-- - 必须两个相反方向的事务"恰好并发、且交错到那个时间点"才触发。
-- - 并发越高, 撞上的概率越大 → 高峰期频发, 低峰期几乎不见。
-- 根因: 并发事务以不一致的顺序锁定相同资源, 形成循环等待 → 死锁。
看着这两个交叉的事务,我才算彻底想明白了这场"死锁"的根源。问题的核心,是两个并发事务,以"相反的顺序",锁定了同样的两行。死锁是这么形成的(循环等待):时刻 1,事务 1 锁住了 A、事务 2 锁住了 B;时刻 2,事务 1 想锁 B(但 B 被事务 2 占着,只能等)、事务 2 想锁 A(但 A 被事务 1 占着,也只能等)——两者各自持有对方想要的锁、互相死等,形成循环等待,死锁。数据库会出手处理:InnoDB 有死锁检测,发现循环等待后,选一个事务回滚(牺牲品),让另一个继续;被回滚的那个,就报 Deadlock found。那为什么"时有时无"?因为必须两个相反方向的事务,"恰好并发、且交错到那个时间点"才会触发;并发越高,撞上的概率越大——所以高峰期频发,低峰期几乎不见。归根结底:并发事务以不一致的顺序锁定相同资源,形成循环等待,就产生死锁——这,就是根源。
第一件事:搞懂死锁的四个必要条件
定位到根源,我必须把"死锁是怎么形成的"从原理上彻底搞清楚:
死锁: 多个事务"循环等待"对方持有的锁, 谁也走不了
# 死锁的四个必要条件(经典理论, 同时满足才会死锁):
# 1. 互斥: 锁是排他的, 一把锁同时只能被一个事务持有。
# 2. 持有并等待: 事务持有一把锁的同时, 又去请求另一把(且等待)。
# 3. 不可剥夺: 锁不能被强行抢走, 只能持有者主动释放。
# 4. 循环等待: 事务们形成一个"环": A等B, B等C, ..., 最后又等回A。
# → 破坏任何一个条件, 就能避免死锁(实践中最易破坏的是"循环等待")。
# 数据库锁的常识:
# - UPDATE/DELETE/SELECT...FOR UPDATE 会对涉及的行加"排他锁(X锁)"。
# - 锁在"事务提交/回滚"时才释放 → 事务越长, 持锁越久, 越易冲突。
# - 行锁: 锁具体的行; 若没走索引可能退化成锁更多行甚至表 → 更易死锁。
# 数据库怎么应对死锁?
# - 死锁检测: InnoDB 检测到循环等待, 主动回滚一个"代价小"的事务。
# - 锁等待超时: innodb_lock_wait_timeout, 等太久直接超时报错(另一种保护)。
# - 注意: 被回滚的事务报 Deadlock, 应用层要能"捕获并重试"。
# 怎么破坏"循环等待"(最实用的解法)?
# - 让所有事务"以同一个固定顺序"获取锁(比如永远按 id 升序锁) → 不可能成环!
# 关键认知: 死锁多是"加锁顺序不一致"造的; 统一顺序就能根除大部分死锁。
# 核心: 死锁=循环等待对方的锁; 破坏循环等待(统一加锁顺序)最实用;
# 数据库会回滚牺牲品, 应用层要捕获并重试。
原理终于清晰了。死锁,本质是多个事务"循环等待"对方持有的锁,谁也走不了;它有四个必要条件(同时满足才死锁):互斥(锁排他)、持有并等待(持有一把锁还去请求另一把)、不可剥夺(锁不能被强抢)、循环等待(形成环:A 等 B、B 等 A)——破坏任何一个就能避免死锁,而实践中最易破坏的,是"循环等待"。几个数据库锁的常识:UPDATE/DELETE/SELECT...FOR UPDATE 会给涉及的行加排他锁;锁在事务提交/回滚时才释放(事务越长、持锁越久、越易冲突);没走索引可能锁更多行甚至锁表、更易死锁。数据库怎么应对?死锁检测(回滚代价小的事务)、锁等待超时(innodb_lock_wait_timeout);而被回滚的事务报 Deadlock,应用层要能捕获并重试。那怎么破坏"循环等待"(最实用的解法)?让所有事务"以同一个固定顺序"获取锁(比如永远按 id 升序锁)——这样就不可能成环了!由此,我刻下一个关键认知:死锁多是"加锁顺序不一致"造成的;统一加锁顺序,就能根除大部分死锁。归根结底:死锁 = 循环等待对方的锁;破坏循环等待(统一加锁顺序)最实用;数据库会回滚牺牲品,应用层要捕获并重试。
第二件事:正解——统一加锁顺序 + 死锁重试
搞懂了原理,正解就清晰了:让所有事务都以"同一个固定顺序"获取锁(打破循环等待),再给应用层加上死锁重试(兜底)。
// ✓ 正解一: 统一加锁顺序 —— 永远"按 id 排序"后再依次锁定(根治!)
public void transfer(String from, String to, BigDecimal amount) {
// ✓ 关键: 不管转账方向, 都按"id 较小的先锁", 保证全局加锁顺序一致
String first = from.compareTo(to) < 0 ? from : to;
String second = from.compareTo(to) < 0 ? to : from;
txTemplate.execute(status -> {
// ✓ 永远先锁 first(id小), 再锁 second(id大) → 任何并发都不会成环!
lockAndLoad(first);
lockAndLoad(second);
// 然后再做真正的扣减/增加(谁转给谁的逻辑不变)
doTransfer(from, to, amount);
return null;
});
}
// A转B 和 B转A 现在都会"先锁 A 再锁 B"(假设 A
修复的方向,是"根治 + 兜底"双管齐下。正解一,统一加锁顺序(根治!):不管转账方向是 A→B 还是 B→A,都先按 id 排序,永远"先锁 id 小的、再锁 id 大的"。这样,所有并发事务的加锁顺序就完全一致了——A 转 B 和 B 转 A,现在都会"先锁 A、再锁 B"(假设 A<B),再也不会出现交叉,死锁从根上被消除。正解二,应用层死锁重试(兜底,配合用):捕获死锁异常(如 DeadlockLoserDataAccessException),退避一下、自动重试几次——因为死锁被回滚的事务,重试一次往往就成功了(此时冲突的另一方多半已完成)。为什么统一顺序能根治?因为死锁的核心是"循环等待(环)";若所有事务都按同一顺序加锁(如都按 id 升序),它们的加锁请求就永远排成"一条线",无法成环;没有环,就不可能死锁——这是从根上消除,比单纯重试彻底得多。归根结底:统一加锁顺序(如按 id 排序)打破循环等待、从根上消除死锁;再叠加应用层死锁重试,兜底那些偶发的情况。
第三件事:其他容易诱发死锁的写法
这次踩坑后,我把项目里其他容易诱发死锁的写法,系统排查了一遍——它们大多比"反向转账"更隐蔽:
容易诱发死锁的常见写法
# 1. 加锁顺序不一致(本文最典型)
# - 不同代码路径以不同顺序锁定相同的行/表。
# → 统一顺序(按主键/某固定规则排序后加锁)。
# 2. 事务太大太长
# - 一个事务里干太多事、持锁时间长 → 撞锁窗口变大。
# → 事务尽量小而快; 别在事务里做 RPC/IO/慢计算/sleep。
# 3. 没走索引 → 锁范围扩大
# - UPDATE ... WHERE 没走索引 → 可能锁很多行甚至全表 → 极易冲突。
# → 确保 WHERE 走索引, 把锁限制在最小行集。
# 4. 间隙锁(RR 隔离级别)
# - MySQL 可重复读下, 范围更新/插入可能加"间隙锁", 并发插入易死锁。
# → 了解隔离级别的锁行为; 必要时调整隔离级别或加锁策略。
# 5. 先查后改的竞争(丢失更新 + 锁升级)
# - 多个事务 SELECT 同一行(共享锁), 再都想 UPDATE(升级排他锁) → 互等。
# → 一开始就用 SELECT ... FOR UPDATE 拿排他锁, 别"先共享再升级"。
# 6. 外键 / 触发器引发的隐式加锁
# - 外键约束、触发器会隐式锁定关联行, 容易制造意外的循环等待。
# → 注意这些"看不见的锁"。
# 排查死锁: SHOW ENGINE INNODB STATUS \G
# - 里面的 LATEST DETECTED DEADLOCK 段, 会告诉你两个事务各持/各等什么锁。
# 核心: 死锁诱因有加锁顺序乱、大事务、没走索引锁范围大、间隙锁、锁升级等;
# 用 SHOW ENGINE INNODB STATUS 看死锁详情定位。
原来诱发死锁的"地雷",远不止"反向转账"一种。第一,加锁顺序不一致(本文最典型,统一顺序解决);第二,事务太大太长(持锁久、撞锁窗口大,要事务小而快、别在事务里做 RPC/IO/慢计算/sleep);第三,没走索引导致锁范围扩大(UPDATE 没走索引可能锁很多行甚至全表,要确保 WHERE 走索引);第四,间隙锁(MySQL 可重复读下范围更新/插入可能加间隙锁、并发插入易死锁);第五,先查后改的锁升级(多事务先 SELECT 共享锁、再都想 UPDATE 升级排他锁而互等,应一开始就 SELECT ... FOR UPDATE 拿排他锁);第六,外键/触发器的隐式加锁。而排查死锁的利器是:SHOW ENGINE INNODB STATUS \G 里的 LATEST DETECTED DEADLOCK 段,会清楚告诉你两个事务各自持有/等待什么锁。归根结底:死锁诱因有加锁顺序乱、大事务、没走索引锁范围大、间隙锁、锁升级等;用 SHOW ENGINE INNODB STATUS 看死锁详情来定位。
下面这张图,是这次死锁的成因与解法:
第四件事:避免死锁的几种手段对比
这次踩坑后,我把避免/缓解死锁的几种手段,按"治本程度、适用场景"比了一遍。
| 手段 | 原理 | 治本程度 | 适用 |
|---|---|---|---|
| 统一加锁顺序 | 打破循环等待, 无法成环 | ★★★ 根治 | 多行/多资源加锁的核心场景 |
| 缩短事务 | 减少持锁时间, 缩小撞锁窗口 | ★★ 大幅降低概率 | 所有事务都该做 |
| 走索引精确锁行 | 缩小锁范围, 减少冲突 | ★★ 降低概率 | UPDATE/DELETE 都该确保 |
| 应用层重试 | 死锁回滚后自动重试 | ★ 兜底, 不根治 | 偶发死锁的兜底 |
| 降低隔离级别 | 减少间隙锁等 | ★ 视情况 | 谨慎, 可能引入其他问题 |
| 一次锁定所有/悲观锁排队 | 避免持有再请求 | ★★ 视场景 | 能一次拿全锁的场景 |
把它们排在一起,策略就清楚了。最该优先做的、也是真正"根治"的,是"统一加锁顺序"——它直接打破了死锁四条件里的"循环等待",让死锁从原理上不可能发生。其余手段,大多是降低概率或兜底:缩短事务(减少持锁时间)和走索引精确锁行(缩小锁范围),是所有事务都该做的好习惯,能大幅降低撞锁概率;应用层重试是必要的兜底(因为死锁很难 100% 杜绝),但它不根治;降低隔离级别要谨慎(可能引入脏读等其他问题)。它给我的最大启发是:对付死锁,要"根治为主、兜底为辅":先用"统一加锁顺序"从设计上消除它,再用"小事务 + 走索引"降低残余概率,最后用"重试"兜住那些实在无法避免的偶发情况——这三层叠加,才是一个健壮的、能扛住高并发的防死锁体系。
第五件事:死锁背后,是"并发资源竞争"的通用难题
这次死锁,让我意识到它只是"并发资源竞争"这个大主题下的一个具体表现。我把相关的、同源的并发问题,一并梳理了。
| 并发问题 | 本质 | 应对 |
|---|---|---|
| 死锁(数据库/代码锁) | 循环等待对方持有的资源 | 统一获取顺序, 打破环(本文) |
| 丢失更新 | 两事务并发改同一行, 后者覆盖前者 | 乐观锁(版本号)/ 悲观锁 FOR UPDATE |
| 脏读/不可重复读/幻读 | 读到未提交/中途变化的数据 | 选合适的事务隔离级别 |
| 锁等待超时 | 等锁太久(没死锁但被长期阻塞) | 缩短事务 + 调超时 + 减少热点行 |
| 热点行竞争 | 大量事务抢同一行(如全局计数) | 分段/分桶, 或用 Redis 等扛热点 |
| 活锁 | 都重试又都退让, 反复冲突不前进 | 随机退避, 避免同步重试 |
这张表,让我看清了死锁所属的更大版图。它们本质上都是同一类问题:当多个并发的执行者,去竞争同一份有限的资源时,如果缺乏良好的协调机制,就会产生各种各样的"竞争乱象"——死锁(互相等死)、丢失更新(互相覆盖)、脏读幻读(读到不该读的中间态)、热点行竞争(挤在一处抢)、活锁(反复冲突却谁也不前进)。而它们的应对之道,也都指向同一个核心——"协调":死锁靠"统一顺序"协调获取、丢失更新靠"乐观/悲观锁"协调修改、脏读幻读靠"隔离级别"协调可见性、热点行靠"分散"协调压力、活锁靠"随机退避"协调重试节奏。它给我的最大启发是:并发编程的核心难题,从来不是"如何让一个事情做得快",而是"如何让多个事情井然有序、互不踩踏地共享有限的资源";而死锁,正是这道难题在没协调好时,给你的一记响亮的警钟。理解了"并发即协调"这个本质,你才能在面对各种并发乱象时,不头痛医头,而是从"资源是怎么被竞争的、又该如何协调"的高度去解决它。
第六件事:写一个要锁多行的事务时,我现在会怎么决策
现在,每当我准备写一个"会锁定多行/多个资源"的事务,脑子里都会过一遍这张决策图——核心就一问:所有路径,加锁顺序一致吗?
这张图的灵魂,是把"加锁顺序"提升为多行事务设计的第一考量。第一问:会有并发竞争同样的资源吗?——几乎不并发的,风险低(但仍建议小事务);会高并发竞争的,就有死锁风险,必须重点设计。然后层层加固:统一加锁顺序(所有路径按同一规则、如 id 升序加锁,这是根治)、缩短事务(别在事务里做 RPC/IO/慢计算)、确保 UPDATE/DELETE 走索引精确锁行、应用层加死锁重试兜底。最后,也是我以前最缺的一步:用压测验证高并发下确实不再死锁,再上线;并配好 SHOW ENGINE INNODB STATUS 以备排查残留。
我立下的几条规矩
这场"高峰期偶发死锁"的事故,换来了我写数据库事务时,刻进骨子里的几条铁律:
- 多行加锁,顺序必须全局一致。所有事务都按同一规则(如 id 升序)加锁,打破循环等待,从根上消除死锁。
- 事务要小而快。别在事务里做 RPC、IO、慢计算、sleep;持锁时间越短,撞锁概率越低。
- UPDATE/DELETE 务必走索引。没走索引会扩大锁范围(甚至锁表),极易冲突;让锁限制在最小行集。
- 应用层一定要有死锁重试。捕获 Deadlock 异常,退避后自动重试——死锁难 100% 杜绝,重试是必备兜底。
- 先查后改用 SELECT ... FOR UPDATE。别"先共享锁再升级排他锁",一开始就拿排他锁,避免锁升级死锁。
- 死锁了用 SHOW ENGINE INNODB STATUS 查。LATEST DETECTED DEADLOCK 段会告诉你两个事务各持/各等什么锁。
- 把并发当"协调"问题看。死锁、丢失更新、热点行都是资源竞争,核心是设计好"如何有序共享有限资源"。
附:读懂 SHOW ENGINE INNODB STATUS 的死锁报告
死锁排查的第一步,是看懂数据库给出的死锁报告。下面是 SHOW ENGINE INNODB STATUS 里 LATEST DETECTED DEADLOCK 段的典型样子和读法:
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION: ← 事务1
UPDATE account SET balance=... WHERE id='B' ← 事务1 当前卡在这条(想锁 B)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: ← 事务1 在等这把锁
RECORD LOCKS ... index PRIMARY of table account ... (id='B') ← 等 B 的锁
*** (2) TRANSACTION: ← 事务2
UPDATE account SET balance=... WHERE id='A' ← 事务2 当前卡在这条(想锁 A)
*** (2) HOLDS THE LOCK(S): ← 事务2 持有的锁
RECORD LOCKS ... (id='B') ← 事务2 持有 B 的锁(事务1正等它)
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: ← 事务2 在等这把锁
RECORD LOCKS ... (id='A') ← 等 A 的锁(事务1 持有)
*** WE ROLL BACK TRANSACTION (1) ← 数据库回滚了事务1(牺牲品)
# 怎么读这份报告(三步):
# 1. 看两个事务"各自卡在哪条 SQL"(WAITING FOR 上面那条)。
# 2. 看"谁持有谁等待": 事务2 HOLDS B, 事务1 WAITING B;
# 事务1 HOLDS A(隐含), 事务2 WAITING A → 循环等待坐实!
# 3. 看 WE ROLL BACK 哪个 → 那就是被牺牲、报 Deadlock 的事务。
# 从报告反推代码:
# - 看两条卡住的 SQL 锁的是哪些行/索引 → 定位到代码里对应的两处加锁。
# - 对比它们的加锁顺序 → 多半就是"顺序不一致" → 改成统一顺序。
# 核心: 死锁报告告诉你"两个事务各持/各等什么锁", 据此还原"循环等待环",
# 再反推代码里加锁顺序不一致之处, 统一顺序即可根治。
这份报告,是死锁排查的"案发现场记录"。读它有三步:第一,看两个事务"各自卡在哪条 SQL"(WAITING FOR 上面那条,就是它当前想执行、却被锁挡住的语句);第二,看"谁持有、谁等待"——报告里 HOLDS THE LOCK(S) 是持有的锁、WAITING FOR 是在等的锁;把两个事务的持有与等待对上(事务 2 持有 B、事务 1 等 B;事务 1 持有 A、事务 2 等 A),"循环等待"就坐实了;第三,看 WE ROLL BACK 哪个,那就是被牺牲、报 Deadlock 的事务。而最关键的,是从报告反推代码:看那两条卡住的 SQL 锁的是哪些行/索引,定位到代码里对应的两处加锁,对比它们的加锁顺序——多半就是"顺序不一致",改成统一顺序即可根治。这,正是我想用这份报告解读,留给每一个后端开发者的最后一课:数据库其实已经把死锁的"作案全过程"清清楚楚地记录下来了;你要做的,不是对着"Deadlock found"这五个字干着急,而是俯下身去,读懂它留下的这份详尽的现场报告——真相,从来都写在日志里,只看你愿不愿意、会不会去读它。
写在最后
回头看,这场由"加锁顺序不一致"引发的、高峰期偶发死锁的事故,真正教给我的,是一个比"统一加锁顺序"本身更深的道理:在并发的世界里,每一个"单独看完全正确"的操作,放到一起,却可能酿成灾难;因为并发的 bug,常常不在任何一个个体身上,而在它们"相遇时的那个交点"上。我那两个转账事务,单独拎出任何一个来看,逻辑都无懈可击、天经地义:"A 转 B,自然先动 A 再动 B"——这有什么错呢?没错。可正是这两个"各自都没错"的事务,在并发的那个特定时刻相遇时,它们"各自合理的加锁顺序",却拼成了一个谁也无法挣脱的死结。这让我深刻地意识到:写并发代码,不能只盯着"单个执行流是否正确",更要有一种"上帝视角"——去想象多个执行流同时运行、并在各种可能的时间点交错时,会碰撞出什么。这种"从单线程的线性思维,跃迁到多线程的交错思维"的能力,正是区分"会写功能"和"会写并发安全的系统"的分水岭。跳出单个执行流,用上帝视角想象它们相遇时的碰撞——这,是我用一次"偶发死锁"的事故,换来的、关于数据库、也关于一切并发编程的、最朴素也最深刻的领悟。如果这篇复盘,能让你在下一次写一个锁多行的事务时,多问一句"如果有人反着来,会撞上吗",那我对着那个 Deadlock found 熬的这大半天,就值了。
—— 别看了 · 2026