我的两个事务互相等着对方手里的锁、谁也不肯松手,最后数据库直接报了死锁、强行回滚了一个,我盯着这个高并发下偶发的报错查了好几天的深度复盘
这是一个让我对"数据库死锁"刻骨铭心的故事。我有一个业务,需要在一个事务里,更新两条记录(比如一笔转账:从账户 A 扣钱、给账户 B 加钱)。逻辑很直接:开启事务,先 UPDATE 一条、再 UPDATE 另一条,然后提交。本地测试、低并发下,一切正常。
可一上生产、并发一高,数据库就时不时地、抛出一个让我陌生又恐慌的错误:Deadlock found when trying to get lock; try restarting transaction(发现死锁,请尝试重启事务)。然后,我的一个事务,被数据库强行回滚了——它做了一半的操作,全没了。这个错误,偶发、且只在高并发下出现,本地怎么都复现不出来。我一开始完全不知所措:"死锁"是什么?我明明只是在更新数据啊,怎么就死锁了?为什么数据库要回滚我的事务?直到我沉下心,去研究数据库的锁机制、并复盘那些死锁发生时并发执行的事务,才恍然大悟,补上了关于数据库并发最重要的一课:问题的核心,是两个并发的事务,陷入了"循环等待(circular wait)"——它们互相持有着对方想要的锁,又都在等对方先释放,于是谁也走不下去,僵在了那里。具体来说:我的转账业务,有的请求是"A 转给 B"(先锁 A、再锁 B),有的请求是"B 转给 A"(先锁 B、再锁 A)。当这两种请求恰好并发时:事务 T1(A→B)先 UPDATE 了 A,拿到了 A 的行锁,然后去 UPDATE B,要等 B 的锁;而与此同时,事务 T2(B→A)先 UPDATE 了 B,拿到了 B 的行锁,然后去 UPDATE A,要等 A 的锁。于是,致命的环就形成了:T1 持有 A 的锁、在等 B 的锁;而 T2 持有 B 的锁、在等 A 的锁——T1 等 T2 释放 B,T2 等 T1 释放 A,两个事务,互相死等,谁也动不了。这,就是"死锁"。而数据库(如 MySQL InnoDB),有一个死锁检测机制:它会主动发现这种循环等待,然后,为了打破僵局,它会强制回滚其中一个事务(通常是"代价较小"的那个),让另一个能继续下去——这,就是我那个事务被"强行回滚"的原因。我一直以为"更新两条数据"是件再简单不过的事;殊不知,在高并发下,当多个事务,以不一致的顺序,去争抢同一批资源(行锁)时,就可能形成循环等待、酿成死锁。
故障现场:两个事务,以相反的顺序争抢两把锁
我把这个"死锁"的现场,用两个并发事务的时序摊开给你看:
-- ✗ 灾难: 两个并发事务, 以"相反的顺序"更新同样的两行 → 死锁
-- 事务 T1(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;
-- 事务 T2(B 转给 A): 先锁 B, 再锁 A(顺序和 T1 相反!)
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 'B'; -- ② 拿到 B 的行锁
UPDATE account SET balance = balance + 100 WHERE id = 'A'; -- ④ 想拿 A 的锁 → 等!
COMMIT;
-- 并发交错执行的时序(死锁形成):
-- ① T1 锁住 A
-- ② T2 锁住 B
-- ③ T1 想锁 B → B 被 T2 占着 → T1 等待 T2
-- ④ T2 想锁 A → A 被 T1 占着 → T2 等待 T1
-- → 循环等待: T1 等 T2, T2 等 T1, 谁也不放手 → 死锁!
-- 数据库怎么处理?
-- - InnoDB 有"死锁检测": 发现循环等待, 立刻打破僵局。
-- - 它会选一个"代价较小"的事务, 强制回滚(抛 Deadlock found 错误)。
-- - 另一个事务得以继续。被回滚的那个, 需要"重试"。
-- 根因: 多个事务"以不一致的顺序"争抢同一批锁(资源) → 循环等待 → 死锁。
-- (锁的是 InnoDB 的"行锁"; 更新同一行/相关行时会加锁、互斥)
-- 偶发性: 要两个相反顺序的事务"恰好交错"才触发 → 高并发才频繁, 本地难复现。
看着这两个交错执行的事务,我才算真正理解了这个"死锁"的根源。问题的核心,是两个并发的事务,陷入了"循环等待(circular wait)":它们互相持有着对方想要的锁,又都在等对方先释放。具体到我的转账业务:有的请求是"A 转给 B"(代码里先 UPDATE A、再 UPDATE B,于是先锁 A、再锁 B),有的请求是"B 转给 A"(于是先锁 B、再锁 A)——这两种请求的加锁顺序,是相反的。当它们恰好并发交错时:① T1 锁住了 A;② T2 锁住了 B;③ T1 想去锁 B,可 B 被 T2 占着,只能等 T2;④ T2 想去锁 A,可 A 被 T1 占着,只能等 T1。于是,那个致命的环就形成了:T1 持有 A、等 B;T2 持有 B、等 A——T1 等 T2 释放 B,T2 等 T1 释放 A,两个事务,互相死等,谁也动不了。这,就是"死锁"。而数据库是怎么处理的?InnoDB 有一个死锁检测机制:它会主动发现这种循环等待,然后,为了打破僵局,它会选择一个"代价较小"的事务、强制回滚它(并向应用抛出 Deadlock found 错误),让另一个事务得以继续下去——这,就是我那个事务被"强行回滚"的原因。被回滚的那个事务,需要由应用重试。这也解释了它的偶发性:要两个"相反顺序"的事务,恰好交错到那个时序,才会触发死锁;所以,只有在高并发下才会频繁出现,而低并发的本地环境,几乎撞不上。归根结底:我一直以为"在一个事务里更新两条数据",是件再简单不过的事;殊不知,在高并发下,当多个事务,以"不一致的顺序",去争抢同一批资源(这里是行锁)时,就可能形成"循环等待",从而酿成死锁。死锁,不是某条 SQL 写错了,而是多个事务并发时,加锁顺序的不一致,导致的一种相互僵持。
第一件事:搞懂死锁是"循环等待"
定位到根源,我必须把"死锁是怎么形成的、数据库怎么处理"彻底搞清楚:
数据库死锁 = 多个事务"循环等待"对方的锁
# 死锁是怎么形成的?(以两个事务为例)
# - 事务都会"持有"一些锁(更新某行 → 锁住那行)。
# - T1 持有锁 a, 想要锁 b; T2 持有锁 b, 想要锁 a。
# - T1 等 T2 放 b, T2 等 T1 放 a → 互相等, 谁也不放 → 循环等待 → 死锁。
# 死锁的四个必要条件(经典理论, 缺一不可):
# 1. 互斥: 锁是独占的(一个资源同时只能被一个事务持有)。
# 2. 持有并等待: 持有着一些锁的同时, 又去等待别的锁。
# 3. 不可剥夺: 锁不能被强行抢走(只能持有者主动释放)。
# 4. 循环等待: 存在一个"等待环"(T1→T2→...→T1)。
# → 破坏其中任何一个, 就能避免死锁。最常用: 破坏"循环等待"(统一顺序)。
# 数据库怎么处理死锁?
# - 死锁检测: InnoDB 主动检测"等待环", 发现就处理(默认开启)。
# - 处理: 选一个"代价小"的事务回滚(抛 Deadlock found), 让其它的继续。
# - 或锁等待超时: 等太久(innodb_lock_wait_timeout)也会放弃。
# → 所以应用层会收到"死锁/锁超时"错误, 需要能"重试"。
# 关键认知:
# - 死锁是"并发 + 加锁顺序不一致"的产物, 不是单条 SQL 的错。
# - 它偶发(要恰好交错)、高并发才频繁、本地难复现。
# - 数据库会"牺牲一个事务"来打破死锁 → 你的事务可能被回滚, 要重试。
# 核心: 死锁 = 循环等待。多个事务以不一致顺序抢锁, 就可能成环。
# 避免它: 统一加锁顺序(破坏循环等待)+ 短事务 + 死锁重试。
原理终于清晰了。数据库的死锁,本质就是多个事务"循环等待"对方的锁:事务都会持有一些锁(更新某行,就锁住那行);当 T1 持有锁 a、想要锁 b,而 T2 持有锁 b、想要锁 a 时,T1 等 T2 放 b、T2 等 T1 放 a,互相等、谁也不放,就形成了循环等待,即死锁。而死锁的形成,有四个经典的必要条件,缺一不可:互斥(锁是独占的)、持有并等待(持有着锁的同时又去等别的锁)、不可剥夺(锁不能被强行抢走)、循环等待(存在一个等待环)——破坏其中任何一个,就能避免死锁;而最常用、最实际的,是破坏"循环等待"(让所有事务以一致的顺序加锁)。而数据库是怎么处理死锁的?InnoDB 有死锁检测(主动检测等待环,默认开启),发现后,会选一个"代价小"的事务回滚(抛 Deadlock found),让其它的继续;此外还有锁等待超时(等太久也会放弃)。所以,应用层会收到"死锁/锁超时"的错误,需要能够"重试"。由此,我建立起几个关键认知:第一,死锁是"并发 + 加锁顺序不一致"的产物,而不是某条单独的 SQL 写错了;第二,它偶发(要恰好交错)、高并发才频繁、本地难复现;第三,数据库会"牺牲一个事务"来打破死锁,所以你的事务可能被回滚,要做好重试。归根结底:死锁,等于循环等待;多个事务,以不一致的顺序去抢锁,就可能成环。而避免它的办法,核心是三条:统一加锁顺序(破坏循环等待)+ 保持事务短小 + 对死锁做重试——这,是我用一次"高并发下偶发的死锁回滚",补上的、关于数据库并发最关键的一课。
第二件事:正解——统一加锁顺序 + 短事务 + 死锁重试
搞懂了根因——"加锁顺序不一致导致循环等待"——正解就清晰了:最根本的,是让所有事务,以"一致的顺序"去加锁(比如永远按 id 从小到大),这就破坏了"循环等待",死锁从根上不会形成;此外,要保持事务短小(减少持锁时间、降低冲突概率),并对"死锁/锁超时"错误,做自动重试(因为死锁难以 100% 杜绝)。
-- 正解1(最根本): 统一加锁顺序——所有事务都按"同一个顺序"锁
-- 比如: 永远按 id 的字典序/大小, 从小到大地更新
-- T1(A转B) 和 T2(B转A), 都改成"先锁 id 小的, 再锁 id 大的":
BEGIN;
-- 不管业务是 A→B 还是 B→A, 都先处理 min(A,B), 再处理 max(A,B)
UPDATE account SET balance = ... WHERE id = LEAST('A','B'); -- 先锁小的
UPDATE account SET balance = ... WHERE id = GREATEST('A','B');-- 再锁大的
COMMIT;
-- → 所有事务加锁顺序一致 → 不可能形成"环" → 死锁消失!
// 正解2: 死锁/锁超时, 应用层自动重试(死锁难 100% 杜绝, 要兜底)
public void transferWithRetry() {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
doTransferInTransaction(); // 一个事务
return; // 成功
} catch (DeadlockException e) { // 捕获死锁错误
log.warn("死锁, 第 {} 次重试", i + 1);
sleep(randomBackoff()); // 退避一下再重试(错开时序)
}
}
throw new RuntimeException("重试多次仍失败");
}
// 死锁被回滚的事务, 重试时往往就能成功(冲突的另一方已结束)。
// 正解3: 保持事务"短小精悍"
// - 事务里只放"必须在一个原子单元里的操作", 别夹杂耗时操作(调接口/复杂计算)。
// - 事务越短, 持锁时间越短, 和别人冲突、形成死锁的概率就越低。
// - 别开了事务就去"干很多别的事", 早提交早释放锁。
// 正解4: 减少锁的范围/粒度
// - 加合适的索引: 让 UPDATE/DELETE 走索引(精确锁少数行), 别全表扫(锁很多行)。
// - 别用过强的锁(如不必要的 SELECT ... FOR UPDATE 锁大范围)。
// 核心: 统一加锁顺序(破坏循环等待, 治本) + 短事务(少冲突)
// + 减小锁范围 + 死锁重试(兜底)。多管齐下, 死锁基本绝迹。
这套正解,核心是从"预防"和"兜底"两方面,系统地对付死锁。正解1(统一加锁顺序,最根本):这是治本之策——让所有事务,都以"同一个一致的顺序"去加锁(比如,永远按 id 从小到大地更新)。回到我的转账例子:不管业务上是"A 转 B"还是"B 转 A",代码里都改成"先锁 id 较小的那个,再锁 id 较大的那个"(如用 LEAST/GREATEST);这样,所有事务的加锁顺序就一致了,也就不可能再形成那个"环"——死锁,从根上消失了。这正是"破坏循环等待"这个必要条件。正解2(死锁重试,兜底):死锁难以 100% 杜绝,所以要在应用层,捕获"死锁/锁超时"的错误,并自动重试(重试时,最好退避一小段随机时间,错开时序);被回滚的事务,重试时往往就能成功(因为冲突的另一方,大概率已经结束了)。正解3(保持事务短小):事务里,只放"必须在一个原子单元里"的操作,别夹杂调接口、复杂计算这类耗时操作;事务越短,持锁的时间就越短,和别人冲突、形成死锁的概率就越低;别开了事务,就去"干很多别的事",要早提交、早释放锁。正解4(减小锁范围):加合适的索引,让 UPDATE/DELETE 走索引、精确地锁少数几行,别因为没索引而全表扫、锁住一大片;也别用不必要的、范围过大的强锁。归根结底:统一加锁顺序(破坏循环等待,治本)+ 短事务(减少冲突)+ 减小锁范围 + 死锁重试(兜底)——这几招多管齐下,死锁基本就绝迹了。我那次的错误,正是放任了加锁顺序的不一致;而正解的核心,就是把这个顺序,统一起来。
下面这张图,对比了"加锁顺序不一致"和"统一加锁顺序"两条路径:
这张图的对比很清楚:左边红色那条,加锁顺序不一致(有的 A→B、有的 B→A),T1 锁 A 等 B、T2 锁 B 等 A,互相等待、形成循环等待环,死锁、数据库回滚一个事务;右边绿色那条,统一加锁顺序(都按 id 从小到大),所有事务先锁小的再锁大的,不可能形成环,顶多排队等待,无死锁、顺序拿到锁完成。两条路的根本分野,在于多个事务争抢锁的顺序,是不是一致的。
第三件事:死锁的其它诱因和排查方法
填平了"顺序不一致"这个核心,我系统排查了死锁的其它诱因,以及怎么排查死锁:
死锁的其它诱因 & 怎么排查
# 死锁的其它常见诱因:
# 1. 加锁顺序不一致(本文核心): 不同事务以相反顺序锁同几行。
# 2. 不同 SQL 锁了"相关的行/范围"而顺序冲突(不只是更新同一行)。
# 3. 索引缺失 → UPDATE/DELETE 锁了大范围/很多行 → 更容易和别人冲突。
# 4. 间隙锁(gap lock): 可重复读隔离级别下, 范围条件会锁"间隙",
# 两个事务的间隙锁互相冲突, 也可能死锁(insert 时常见)。
# 5. 长事务: 持锁太久, 大大增加和别人冲突成环的概率。
# 6. 外键 / 唯一索引检查也会加锁, 可能卷入死锁。
# 怎么排查死锁?
# 1. 看死锁日志: MySQL: SHOW ENGINE INNODB STATUS;
# → "LATEST DETECTED DEADLOCK" 段, 会显示死锁时"两个事务各持有/等待什么锁"。
# → 从中能看出"谁锁了哪行、在等哪行", 推出加锁顺序的冲突。
# 2. 开 innodb_print_all_deadlocks = ON: 把每次死锁都打到错误日志。
# 3. 分析涉及的 SQL: 它们锁了哪些行? 顺序是否一致? 是否走了索引?
# 4. 复现: 用两个会话, 手动按那个时序执行, 复现死锁来验证。
# 处理 + 预防的组合:
# - 预防: 统一加锁顺序、短事务、加索引减小锁范围。
# - 兜底: 应用层捕获死锁、自动重试。
# - 观测: 监控死锁发生频率, 高了就排查。
# 核心: 死锁诱因虽多, 但根子都在"并发事务争抢锁、顺序/范围冲突"。
# 用死锁日志定位冲突, 用"统一顺序+短事务+重试"系统地预防和兜底。
这一排查,让我对死锁有了全面的认识。除了加锁顺序不一致(本文核心),死锁还有其它诱因:不同 SQL 锁了"相关的行/范围"而顺序冲突(不只是更新同一行);索引缺失(UPDATE/DELETE 没走索引、锁了一大片行,更容易和别人冲突);间隙锁(gap lock)(可重复读隔离级别下,范围条件会锁住"间隙",两个事务的间隙锁互相冲突,在并发 insert 时常引发死锁);长事务(持锁太久,大大增加成环概率);外键/唯一索引检查(也会加锁,可能卷入死锁)。而怎么排查死锁?第一,看死锁日志(MySQL 用 SHOW ENGINE INNODB STATUS,它的 "LATEST DETECTED DEADLOCK" 段,会显示死锁时两个事务各持有/等待什么锁,从中能看出"谁锁了哪行、在等哪行",从而推出加锁顺序的冲突);第二,开 innodb_print_all_deadlocks(把每次死锁都打到错误日志);第三,分析涉及的 SQL(它们锁了哪些行?顺序一致吗?走索引了吗?);第四,复现(用两个会话,手动按那个时序执行,复现验证)。归根结底:死锁的诱因虽多,但根子,都在"并发事务争抢锁、而顺序或范围冲突"上;用死锁日志去定位具体的冲突,再用"统一加锁顺序 + 短事务 + 加索引减小锁范围 + 应用层重试"这套组合,去系统地预防和兜底——死锁,就能被牢牢地控制住。
第四件事:补上锁与事务的基础
这次踩坑,逼我把数据库"锁与事务"的一些基础,系统地补了一遍——这是理解死锁、以及一切并发数据库问题的前提:
数据库锁与事务的基础(理解死锁的前提)
# 锁的类型(InnoDB):
# - 行锁: 锁住具体的行(走索引时)。死锁多发生在行锁。
# - 表锁: 锁整张表(粒度大, 并发差)。
# - 共享锁(S, 读锁) vs 排他锁(X, 写锁):
# S 锁之间不互斥(都能读); X 锁与任何锁互斥(写要独占)。
# - 间隙锁/Next-Key 锁: 锁"范围/间隙", 防幻读(可重复读级别)。
# 锁是怎么加的?
# - UPDATE/DELETE/SELECT...FOR UPDATE: 对涉及的行加"排他锁(X)"。
# - 普通 SELECT: 一般不加锁(MVCC 快照读)。
# - 锁在"事务提交/回滚"时释放(所以事务越长, 持锁越久)。
# 事务隔离级别(影响加锁行为):
# - 读未提交 / 读已提交 / 可重复读(MySQL 默认) / 串行化。
# - 级别越高, 加的锁越多/越强(如可重复读有间隙锁) → 死锁也更易出现。
# 锁等待 vs 死锁:
# - 锁等待: A 等 B 释放锁(单向等待), 等到了就继续, 是正常的。
# - 死锁: 互相等待(成环), 永远等不到 → 数据库必须介入打破。
# 几个关键参数:
# - innodb_lock_wait_timeout: 锁等待超时(等太久就放弃)。
# - innodb_deadlock_detect: 死锁检测开关(默认 ON)。
# 理解了这些, 就能明白:
# - 为什么"走索引"重要(行锁 vs 锁一大片)。
# - 为什么"短事务"重要(早释放锁)。
# - 为什么"隔离级别"影响死锁(锁的强度/范围不同)。
# 核心: 锁是并发控制的基础; 事务持有锁、提交时释放。
# 理解行锁、隔离级别、锁等待, 才能真正驾驭死锁这类并发问题。
这一补课,让我对数据库的并发控制,有了体系化的理解。首先是锁的类型:行锁(锁住具体的行,走索引时——死锁多发生在行锁上)、表锁(锁整张表,粒度大、并发差);以及共享锁(S,读锁)和排他锁(X,写锁)(S 锁之间不互斥、都能读,X 锁则与任何锁互斥、写要独占);还有间隙锁/Next-Key 锁(锁住"范围/间隙",用于防幻读)。其次是锁是怎么加的:UPDATE/DELETE/SELECT...FOR UPDATE 会对涉及的行加排他锁;普通 SELECT 一般不加锁(走 MVCC 快照读);而锁,是在事务提交/回滚时才释放的(所以事务越长、持锁越久)。然后是事务隔离级别:从读未提交、读已提交、可重复读(MySQL 默认)、到串行化——级别越高,加的锁越多、越强(比如可重复读有间隙锁),所以死锁也更容易出现。还要分清锁等待和死锁:锁等待是 A 等 B 释放锁(单向等待),等到了就继续,是正常的;而死锁是互相等待、成了环,永远等不到,数据库必须介入打破。以及几个关键参数:innodb_lock_wait_timeout(锁等待超时)、innodb_deadlock_detect(死锁检测开关,默认 ON)。理解了这些,我才真正明白了那几条建议背后的道理:为什么"走索引"重要(决定是精确锁几行、还是锁一大片)、为什么"短事务"重要(早释放锁、减少冲突)、为什么"隔离级别"会影响死锁(锁的强度和范围不同)。归根结底:锁,是并发控制的基础;事务持有锁、提交时释放。理解了行锁、隔离级别、锁等待这些基础,才能真正驾驭死锁这一类并发问题。我之前,正是因为对这些基础一知半解,才被死锁打得措手不及。把锁等待和死锁,以及几个概念,整理成一张表:
| 概念 | 含义 | 是否正常/怎么办 |
|---|---|---|
| 锁等待 | 单向等对方释放锁 | 正常,等到就继续 |
| 死锁 | 互相等待成环 | 异常,DB 回滚一个,要重试 |
| 行锁(走索引) | 只锁涉及的几行 | 好,并发高 |
| 锁一大片(没索引) | UPDATE 全表扫锁很多行 | 差,易冲突,加索引 |
| 长事务 | 持锁时间长 | 差,易成死锁,改短 |
第五件事:并发争抢资源,"顺序一致"是避免死锁的通用法则
这次踩坑,在认知层面给了我最大的纠偏——它让我领悟到一个超越数据库的、关于并发的通用法则。我把这层反思,沉淀了下来:
认知纠偏: 并发争抢多个资源时, "统一的获取顺序"能避免死锁
# 我的误解(错误的):
# 我按"业务的自然顺序"去加锁(A转B就先A后B), 没意识到不同业务的
# 顺序不一致, 会在并发时形成环。
# → 我没有"全局统一的资源获取顺序"这个意识。
# 真相: 死锁是个"通用"的并发问题, 不只数据库有
# - 任何"多个并发参与者, 争抢多个共享资源(锁)"的场景, 都可能死锁。
# - 数据库行锁、多线程的互斥锁、分布式锁……机制不同, 但本质一样。
# - 经典例子: "哲学家就餐问题"——每人左手拿一根筷子等右手, 全饿死(死锁)。
# 避免死锁的通用法则: "破坏循环等待"——统一资源获取顺序!
# - 给所有资源, 定一个"全局的、固定的顺序"(如按 id、按地址、按名字)。
# - 规定: 任何参与者, 都必须"按这个顺序"去获取多个资源。
# - 这样, 就不可能出现"我等你、你等我"的环 → 死锁无从形成。
# (多线程加多把锁时, 同理: 永远按固定顺序加锁。)
# 其它破坏死锁条件的办法:
# - 一次性获取所有锁(破坏"持有并等待")。
# - 超时放弃 + 重试(破坏"不可剥夺", 兜底)。
# - 减少共享/锁(从源头减少争抢)。
# 普遍智慧: 多方争抢多个资源时, "约定一个统一的获取顺序", 是最简单、
# 最有效的避免死锁的办法——无论在数据库、多线程、还是分布式里。
# 核心: 死锁是通用的并发问题; "统一资源获取顺序"是避免它的通用法则。
# 不只数据库, 任何加多把锁的地方, 都该按固定顺序加。
这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是:我按"业务的自然顺序"去加锁(A 转 B 就先 A 后 B、B 转 A 就先 B 后 A),却没意识到,不同业务的顺序不一致,会在并发时形成环;我缺少"全局统一的资源获取顺序"这个意识。可真相是:死锁,是一个"通用"的并发问题,绝不只数据库才有。任何"多个并发的参与者,争抢多个共享资源(锁)"的场景,都可能死锁:数据库的行锁、多线程的互斥锁、分布式锁……机制不同,但本质完全一样。最经典的例子,就是"哲学家就餐问题"——每个哲学家,都先拿起左手的筷子、再等右手的筷子,结果所有人都拿着左手、等着右手,谁也吃不上,全饿死(这就是死锁)。而避免死锁的通用法则,正是"破坏循环等待"——给所有资源,定一个"全局的、固定的顺序"(比如按 id、按地址、按名字),并规定:任何参与者,都必须"按这个统一的顺序"去获取多个资源;这样,就不可能再出现"我等你、你等我"的环,死锁也就无从形成。(回到哲学家问题:只要规定"所有人都先拿编号小的筷子",死锁就消失了。多线程加多把锁,也是同理:永远按固定顺序加锁。)当然,还有其它破坏死锁条件的办法:一次性获取所有锁(破坏"持有并等待")、超时放弃 + 重试(破坏"不可剥夺",作兜底)、减少共享/锁(从源头减少争抢)。归根结底,我领悟到一条普遍的智慧:多方争抢多个资源时,"约定一个统一的获取顺序",是最简单、最有效的避免死锁的办法——无论是在数据库、多线程、还是分布式系统里。死锁是通用的并发问题,而"统一资源获取顺序",是避免它的通用法则;不只数据库,任何要加多把锁的地方,都该按一个固定的顺序去加。我那次的转账死锁,正是缺了这份"顺序一致"的约定。把"顺序随意"和"顺序统一"对比成一张表:
| 维度 | 顺序随意(踩坑) | 顺序统一(智慧) |
|---|---|---|
| 加锁顺序 | 按各自业务的自然顺序 | 全局固定顺序(如按 id) |
| 并发时 | 可能形成循环等待 | 不可能成环 |
| 死锁 | 偶发,高并发频繁 | 从根上避免 |
| 适用范围 | — | 数据库/多线程/分布式通用 |
| 哲学家就餐 | 都先拿左手→饿死 | 都先拿小编号→不死锁 |
一套"怎么预防数据库死锁"的决策流程
把这次踩坑的全部教训,我浓缩成了一张"设计涉及多行更新的事务、怎么预防死锁"的决策图,贴在了团队的数据库规范里:
这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:一个事务要更新多行/加多把锁,第一就统一加锁顺序(都按 id 等固定顺序);再看事务里有没有耗时操作,有就移出去、保持事务短小;确认 UPDATE 走索引、只锁少数行;应用层捕获死锁/锁超时、自动重试+退避作兜底;如果还频繁死锁,就看 INNODB STATUS 死锁日志、定位冲突的加锁顺序、回去修正。这条"统一顺序治本、短事务+索引减冲突、重试兜底、日志定位"的决策链,现在是我们团队设计每一个多行更新事务时的准则。
我立下的几条数据库死锁规矩
这次"转账死锁"的踩坑,让我把数据库死锁的注意事项,认真地立成了几条规矩:
- 多行更新统一加锁顺序。所有事务都按固定顺序(如 id 从小到大)加锁,破坏循环等待,从根上避免死锁。
- 保持事务短小。别在事务里夹耗时操作(调接口/复杂计算),早提交早释放锁。
- UPDATE/DELETE 走索引。精确锁少数行,别全表扫锁一大片、徒增冲突。
- 应用层对死锁自动重试。死锁难 100% 杜绝,捕获 Deadlock 错误、退避后重试兜底。
- 用死锁日志定位。
SHOW ENGINE INNODB STATUS看冲突的加锁,推出顺序问题。 - 注意隔离级别和间隙锁。可重复读下范围条件/insert 易触发间隙锁死锁。
- 记住顺序一致是通用法则。多线程加多把锁、分布式锁,都按固定顺序获取,避免死锁。
写在最后
这次"我的两个事务互相等锁、死锁、被回滚一个"的经历,是我在数据库并发路上,一次很经典、也很受用的成长。它教给我的,远不止"统一加锁顺序"这一条具体的技术经验,更是一个超越数据库的、关于并发的通用法则——当多方争抢多个资源时,约定一个统一的获取顺序,就能从根上避免死锁。我那个偶发的转账死锁,根源就在于,我按"业务的自然顺序"去加锁,A 转 B 和 B 转 A 的顺序恰好相反,在高并发下交错执行,就形成了"我等你、你等我"的循环等待。
所以,当你的代码,需要在并发中,获取多个资源(数据库的多行锁、多线程的多把锁、多个分布式锁)时,请务必想一想:所有的参与者,是不是都以"同一个固定的顺序"在获取它们?只要这个顺序统一了,那个致命的"循环等待"环,就永远无法形成。就像那个转账业务,你只要规定"永远先锁 id 小的、再锁 id 大的",就再也不会经历那种"高并发下偶发死锁、还被回滚一个事务"的困扰。从"按业务自然顺序随意加锁"到"全局统一资源获取顺序",从被死锁打懵到理解并主动破坏"循环等待",是从一个"会写事务"的开发,走向一个"懂并发、能驾驭锁与竞争"的工程师,必经的修炼。愿你设计的每一个并发事务,都井然有序、永无死锁;也愿你我,在任何要争抢多个资源的地方,都记得为它们,约定一个统一的、不会成环的获取顺序。共勉。
—— 别看了 · 2026