两个并发事务因为以不同的顺序去更新两条记录,互相等着对方手里的锁,撞成了死锁、被 MySQL 强行回滚了一个:一次数据库死锁的深度复盘

转账接口高并发时偶发 Deadlock found,部分请求失败。根因是一个事务要更新两条记录,而不同请求加锁顺序不同:A→B 的请求先锁 A 再锁 B、B→A 的先锁 B 再锁 A,并发时事务1持有A等B、事务2持有B等A,互相等待形成循环等待的环、谁也走不了,MySQL 检测到死锁就回滚其中一个。本文讲透死锁怎么形成和它的四个必要条件,给出固定加锁顺序(破坏循环等待,最有效)、缩小事务/缩短持锁、走索引减小锁范围、应用层捕获重试的正解,梳理事务与锁常见坑,最后落到'破坏必要条件之一即可、死锁是交互型问题、用统一约定协调竞争共享资源的多方'的认知。

两个并发事务因为以不同的顺序去更新两条记录,互相等着对方手里的锁,撞成了死锁、被 MySQL 强行回滚了一个:一次数据库死锁的深度复盘

那个错误偶发又扎眼:我们一个转账类的接口,在高并发时时不时抛出 Deadlock found when trying to get lock; try restarting transaction(发现死锁,请重试事务),导致部分请求失败。它不是每次都出,但稳定地、低频地出现。我盯着"死锁"这两个字排查了大半天,才终于看清两个事务是怎么"抱死"的,后背发凉:我有一个操作,要在一个事务里同时更新两条记录(比如账户 A 和账户 B 的余额)。问题在于,不同的请求,更新这两条记录的顺序不一样:转账"A 给 B"的请求,先锁 A 再锁 B;而转账"B 给 A"的请求,先锁 B 再锁 A。当这两个请求恰好并发时:事务 1 锁住了 A、正要去锁 B;事务 2 锁住了 B、正要去锁 A;于是事务 1 等着事务 2 释放 B、事务 2 等着事务 1 释放 A——两个事务互相等着对方手里的锁,谁也不让、谁也走不了,这就是死锁(deadlock)。MySQL 有死锁检测,发现这种"互相等待的环"后,会强行回滚其中一个事务(牺牲一个、让另一个走),被回滚的那个就抛出了 Deadlock 错误。问题的根,是多个事务以不一致的顺序去获取多把锁,形成了"循环等待"。这篇就把这次"数据库死锁"的坑,从头到尾复盘一遍。

故障现场:两个事务以相反顺序加锁

问题代码,是一个更新两条记录、但加锁顺序不固定的事务:

-- ✗ 出问题的场景: 两个并发事务以【相反的顺序】锁两条记录

-- 事务1(转账 A→B): 先扣A, 再加B
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 'A';  -- 锁住A
-- ... 此时事务2也在跑 ...
UPDATE account SET balance = balance + 100 WHERE id = 'B';  -- 想锁B, 但B被事务2锁着 → 等待
COMMIT;

-- 事务2(转账 B→A): 先扣B, 再加A
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 'B';   -- 锁住B
UPDATE account SET balance = balance + 50 WHERE id = 'A';   -- 想锁A, 但A被事务1锁着 → 等待
COMMIT;

-- 死锁形成:
-- - 事务1: 持有A的锁, 等待B的锁(B被事务2持有);
-- - 事务2: 持有B的锁, 等待A的锁(A被事务1持有);
-- - → 互相等待对方释放, 形成"循环等待"的环, 谁也走不了 → 死锁;
-- - MySQL检测到死锁, 回滚其中一个事务 → 那个事务报 "Deadlock found..."。

-- 为什么偶发: 要两个事务【恰好以相反顺序、并发地】锁同样的两条记录, 才会撞上;
--   低并发时概率低, 高并发时才频繁出现。

-- 死锁的四个必要条件(凑齐才死锁):
-- 1. 互斥(锁是独占的);  2. 持有并等待(拿着一个还要另一个);
-- 3. 不可剥夺(不能强抢别人的锁);  4. 循环等待(A等B、B等A...成环)。
-- → 破坏任意一个条件, 就不会死锁; 最实用的是破坏【循环等待】(固定加锁顺序)。

-- 关键: 多个事务以【不一致的顺序】获取多把锁, 会形成"循环等待"→死锁;
--       MySQL会检测并回滚一个; 这是并发事务里一类经典问题。

第一次画出这个"互相等待的环"时,我恍然大悟:"原来不是某个事务卡住了,而是两个事务''在一起、互相把对方堵死了。"这个坑最典型的特征是它的"偶发性"和"对称性":它要两个事务恰好以相反的顺序、且并发地去锁同样的资源才会发生——低并发时概率极低、几乎遇不到,只在高并发时才频繁撞上;而且它是"双方共同造成"的(不是某一个事务的错,是它们俩加锁顺序冲突),所以单看任何一个事务都"没毛病"下面就来拆解,死锁的成因和怎么避免。

第一件事:搞懂死锁怎么形成,以及四个必要条件

我认真梳理了死锁的原理,才彻底理解这个坑和正解。

死锁(deadlock)怎么形成? 四个必要条件

【核心: 多个事务/线程互相持有对方需要的锁、互相等待, 形成"循环等待"的环, 谁也走不了】

1. 死锁是什么:
   - 两个(或多个)事务/线程, 各自持有一部分锁, 又都在等待对方持有的锁;
   - → 形成一个"等待环": A等B、B等A(或A等B、B等C、C等A...);
   - → 环里的每个都在等下一个释放, 但没人能释放(因为都卡着) → 全部卡死。

2. 死锁的四个必要条件(必须【同时满足】才会死锁, 缺一不可):
   - ① 互斥: 锁是独占的, 一个资源同一时刻只能被一个持有;
   - ② 持有并等待: 已经持有一些锁, 还在请求/等待另一些锁(不放手已有的);
   - ③ 不可剥夺: 不能强行抢走别人已持有的锁(只能等它自己释放);
   - ④ 循环等待: 存在一个"等待环"(A等B、B等A...)。
   - → 这四个条件凑齐, 才死锁; 破坏任意一个, 就不会死锁。

3. 破坏哪个条件最实用 → 破坏"循环等待"(④):
   - 让所有事务都【以相同、固定的顺序】获取锁(比如总是按id从小到大锁);
   - → 就不可能形成"A先锁X再锁Y, B先锁Y再锁X"的环 → 没有循环等待 → 不死锁;
   - 这是最常用、最有效的防死锁手段。

4. MySQL的死锁处理:
   - InnoDB有死锁检测, 发现循环等待会【立即回滚其中一个事务】(代价最小的那个);
   - 被回滚的事务报 "Deadlock found...", 应用层应【捕获并重试】这个事务;
   - 所以死锁在MySQL里不会"永久卡死"(它会打破), 但会导致事务失败、需要重试。

类比: 两个人过独木桥, 一个从这头走一个从那头走, 在桥中间顶上了——
   都想过去、都不肯退, 就僵住了(死锁); 解法: 约定"总是让某一方先过"(固定顺序)。

一句话: 死锁是多事务互相持有对方需要的锁、循环等待、谁也走不了; 死锁需四个条件同时满足;
   最实用的破解是"以固定顺序获取锁"(破坏循环等待); MySQL会回滚一个事务、应用应捕获重试。

这套原理,是整个坑的根。死锁是什么:多个事务各自持有一部分锁、又都在等对方持有的锁,形成"等待环"(A 等 B、B 等 A),环里每个都等下一个释放、但没人能释放,全部卡死。死锁的四个必要条件(同时满足才死锁):①互斥(锁独占)②持有并等待(拿着一个还要另一个)③不可剥夺(不能强抢)④循环等待(有环)——破坏任意一个就不死锁破坏哪个最实用?破坏"循环等待":让所有事务都以相同、固定的顺序获取锁(总按 id 从小到大锁),就不可能形成"A 先锁 X 再锁 Y、B 先锁 Y 再锁 X"的环MySQL 的处理:InnoDB 有死锁检测、发现循环等待立即回滚代价最小的一个事务,被回滚的报 Deadlock、应用层应捕获并重试。就像两人过独木桥从两头走在中间顶上了(死锁),解法是约定"总让某一方先过"(固定顺序)一句话:死锁是多事务互相持有对方需要的锁、循环等待、谁也走不了;死锁需四个条件同时满足;最实用的破解是"以固定顺序获取锁"(破坏循环等待);MySQL 会回滚一个事务、应用应捕获重试。

第二件事:正解——固定加锁顺序、缩小事务、捕获重试

搞懂了原理,正解就清晰了:让所有事务以固定顺序访问/加锁多条记录(破坏循环等待)、缩小事务粒度和持锁时间、合理用索引减少锁范围、捕获死锁错误并重试

-- ====== 正解一(最有效): 固定加锁顺序, 破坏循环等待 ======
-- 不管是 A→B 还是 B→A 的转账, 都【按id排序后】依次锁(比如总是先锁id小的)
-- 转账(from, to):
BEGIN;
-- ★ 按id排序, 总是先锁id较小的那个, 再锁较大的——所有事务顺序一致
-- (伪代码: ids = sort([from, to]); for id in ids: 锁/更新)
UPDATE account SET balance = ... WHERE id = LEAST(from, to);   -- 先锁id小的
UPDATE account SET balance = ... WHERE id = GREATEST(from, to);-- 再锁id大的
COMMIT;
-- → 所有事务都"先小后大"地锁, 不会出现"A先锁X再锁Y, B先锁Y再锁X"的环 → 不死锁。
# ====== 防死锁/降低死锁概率的其他手段 ======

# 1. 固定加锁顺序(最有效): 多个事务对多条记录, 都按同一顺序(如按主键id)加锁 → 无循环等待。

# 2. 缩小事务、缩短持锁时间:
#    - 事务越大、持锁越久, 越容易和别的事务冲突; 把事务拆小、只把"必须在事务里的"放进去;
#    - 别在事务里做慢操作(调外部接口、复杂计算)——会长时间占着锁。

# 3. 合理用索引, 减小锁范围:
#    - WHERE条件走索引 → 锁的是精确的行(行锁); 没索引可能锁更大范围甚至表 → 更易冲突;
#    - 锁的范围越小, 和别人冲突的概率越低。

# 4. 降低隔离级别(谨慎): 较高隔离级别(如可串行化)加锁更多更易死锁; 按需用合适的隔离级别。

# 5. 一次性获取所需的锁 / 用更粗粒度的单把锁: 避免"持有一个再去要另一个"(破坏持有并等待)。

# ====== 应用层: 捕获死锁错误并重试 ======
# - 死锁被MySQL回滚后报错(如MySQL错误码1213); 应用应【捕获它, 然后重试整个事务】;
# - 死锁是"偶发、可重试"的(重试时通常不会再撞上); 重试要有限次数+退避。

# ====== 排查死锁 ======
# - SHOW ENGINE INNODB STATUS; 里有 "LATEST DETECTED DEADLOCK" 段, 显示死锁的两个事务和它们的锁;
# - 据此分析"它们各自持有/等待什么锁、加锁顺序", 找出循环等待。

# 核心: 防死锁——固定加锁顺序(最有效)、缩小事务和持锁时间、走索引减小锁范围、合理隔离级别;
#   应用层捕获死锁错误并有限重试(退避); 用INNODB STATUS分析死锁现场。

修复的核心,是"让加锁顺序一致(破坏循环等待),并对偶发死锁重试"正解一(最有效):固定加锁顺序——不管 A→B 还是 B→A 的转账,都按 id 排序后依次锁(总是先锁 id 小的、再锁大的),所有事务顺序一致,就不会形成"A 先锁 X 再锁 Y、B 先锁 Y 再锁 X"的环其他手段:缩小事务和持锁时间(别在事务里做慢操作)、走索引减小锁范围(锁精确的行而非更大范围)、合理隔离级别、一次性获取所需锁应用层:捕获死锁错误(MySQL 1213)并重试整个事务(死锁偶发可重试、重试要有限次+退避)排查:SHOW ENGINE INNODB STATUS 看 LATEST DETECTED DEADLOCK 分析两事务的锁和加锁顺序归根结底:防死锁——固定加锁顺序(最有效)、缩小事务和持锁时间、走索引减小锁范围、合理隔离级别;应用层捕获死锁错误并有限重试(退避);用 INNODB STATUS 分析死锁现场。

第三件事:数据库事务与锁相关的其他常见坑

排查后我把事务/锁相关的其他常见坑也系统梳理了一遍。

数据库事务 / 锁的其他常见坑

# 1. 死锁(本文): 不一致的加锁顺序致循环等待。→ 固定加锁顺序+重试。

# 2. 大事务: 事务里干太多/太久, 长时间持锁, 阻塞别人、易死锁、回滚慢。→ 拆小事务。

# 3. 事务里调外部接口: 在事务里调RPC/HTTP, 锁被占着等网络, 极易超时和阻塞。→ 别在事务里做IO。

# 4. 没走索引导致锁范围大: 更新/删除条件没索引, 可能锁很多行甚至表锁。→ 走索引锁精确行。

# 5. 隔离级别理解不清: 不可重复读、幻读、脏读各隔离级别表现不同。→ 理解并选合适的隔离级别。

# 6. 长事务不提交: 忘了commit/rollback, 锁一直占着、连接不释放。→ 确保事务及时结束。

# 7. 乐观锁冲突没处理: 用version乐观锁, 更新影响0行(被人改过)却没检查。→ 检查影响行数并重试。

# 8. 间隙锁(gap lock)的意外: 可重复读下范围更新会加间隙锁, 可能锁住"不存在的间隙"致意外阻塞/死锁。

# 共同根源: 事务和锁是为了"并发下的数据一致性", 但锁带来了"等待、阻塞、死锁"的代价;
#   不理解锁的粒度、范围、获取顺序、持有时长, 就会在并发事务里踩各种锁的坑。

# 核心: 事务尽量小、持锁尽量短、加锁顺序固定、走索引减小锁范围、别在事务里做IO; 理解隔离级别;
#   死锁/乐观锁冲突要捕获重试; 用INNODB STATUS分析锁问题——并发事务的核心是管好锁。

排查让我把事务/锁的其他坑也梳理清了。一、死锁(本文)。二、大事务(长时间持锁,拆小)。三、事务里调外部接口(锁被占着等网络)。四、没走索引锁范围大五、隔离级别理解不清六、长事务不提交(锁一直占)。七、乐观锁冲突没处理八、间隙锁的意外它们的共同根源是:事务和锁是为了"并发下的数据一致性",但锁带来了"等待、阻塞、死锁"的代价;不理解锁的粒度、范围、获取顺序、持有时长,就会在并发事务里踩锁的坑核心是:事务尽量小、持锁尽量短、加锁顺序固定、走索引减小锁范围、别在事务里做 IO;理解隔离级别;死锁/乐观锁冲突要捕获重试;用 INNODB STATUS 分析锁问题下面这张图,是这次死锁坑的成因与解法:

第四件事:死锁四个必要条件与破解手段对照表

这次踩坑后,我把死锁的四个必要条件和对应的破解手段整理成一张表。

必要条件 含义 怎么破坏(防死锁)
互斥 锁是独占的 难破坏(锁本质就是互斥)
持有并等待 拿着一个还要另一个 一次性获取所有锁
不可剥夺 不能抢别人的锁 超时放弃(等不到就退)
循环等待 A等B、B等A成环 固定加锁顺序(最常用)

这张表把死锁的破解之道钉清了。核心是:死锁需要四个条件同时满足,所以破坏任意一个就能防死锁;而其中"固定加锁顺序"(破坏循环等待)是最实用、代价最小的——只要所有人都按同一顺序拿锁,就不可能成环它给我的最大启发是:面对一个"由多个条件共同导致"的问题,一个强大的解决思路是"找出它的'必要条件',然后打破其中最容易打破的那一个"——你不需要消除所有条件,只要瓦解掉一个,整个问题就不成立了;死锁需要四个条件凑齐,我破坏掉"循环等待"这一个(最易破坏),死锁就没了这其实是一种普适的问题分析方法:"分解一个问题的成因(它需要哪些条件才会发生),再针对性地打破最薄弱的那个环节"——很多复杂问题(死锁、竞态、雪崩、安全漏洞)都是"多个因素凑齐才爆发"的,把这些因素列清楚,往往能发现"原来只要堵住其中一个口子就行";"找必要条件、破最弱的一环",比"试图正面硬刚整个复杂问题"更聪明、更高效用"破坏必要条件之一"的思路防死锁、分解成因打破最弱环节——是这个坑带给我的问题分析方法。

第五件事:死锁是"双方共同造成"的反思

这次让我意识到,死锁不是某一个事务的""。我把这种"交互型问题"的特征整理成表。

维度 单方问题 交互型问题(如死锁)
成因 某一段代码自己的bug 多方交互/时序的冲突
单看一方 能看出问题 每一方都"没毛病"
复现 较稳定 偶发, 需特定并发时序
例子 空指针、越界 死锁、竞态、活锁
解决 改那段代码 调整多方的协作规则

这张表道出了一类特殊问题的本质。核心是:死锁是一类"交互型/涌现型"的问题——它不是某一个事务自身的 bug(单看事务 1 或事务 2,它们各自的逻辑都没问题),而是两个事务"交互"时、它们的加锁顺序"冲突"才涌现出来的;这类问题偶发、需要特定的并发时序、且单看任何一方都找不出错它给我的深刻启发是:有一大类问题(死锁、竞态条件、活锁、分布式不一致),是"多个独立正确的部分,在交互时产生的"——它们的根源不在"某个部分错了",而在"部分之间的协作/时序/约定有冲突";用"找出那个写错的代码"的思路去查这类问题,往往会徒劳(因为每个部分单独看都对)——必须跳出单个部分,去审视"它们是怎么交互的、交互时哪里冲突了"这给了我一种排查"交互型问题"的视角:当一个问题"偶发、和并发/时序相关、单看每个部分都正常"时,要意识到它很可能是"交互型"的,从而把视角从"检查单个组件"切换到"分析多个组件之间的交互、时序、和共享的约定"——"它们是怎么互相影响的?加锁/访问的顺序一致吗?有没有竞争同一资源?";"看交互而非看个体",是定位死锁、竞态这类涌现型问题的关键思维认清死锁是交互型问题、排查时从看个体切换到看交互——是这个坑带给我的排查认知。

第六件事:写一个要锁多条记录的事务时,我现在的检查习惯

现在每当我写一个会在事务里锁多条记录的操作,我都会按这张图先想一想:

这张图的精髓,是"锁多条记录就固定加锁顺序、缩小事务、应用层重试"会有并发锁同样记录就固定加锁顺序(按 id 排序)、缩小事务缩短持锁、走索引锁精确行,应用层捕获死锁错误有限重试这套习惯,让我从"事务里随手按业务顺序锁多行"变成了"锁多行先想加锁顺序一致不一致"——核心始终是:多事务锁多条记录要固定加锁顺序破坏循环等待,配小事务和重试。

我立下的几条规矩

这场"数据库死锁"的事故,换来了我做并发事务时,刻进骨子里的几条铁律:

  1. 死锁是多事务循环等待、互相抱死。需要四个必要条件同时满足。
  2. 固定加锁顺序破坏循环等待。所有事务都按同一顺序(如 id)加锁,最有效。
  3. 事务尽量小、持锁尽量短。别在事务里做慢操作/调外部接口。
  4. 走索引减小锁范围。锁精确的行,降低和别人冲突的概率。
  5. 应用层捕获死锁错误并有限重试。死锁偶发可重试,重试要退避。
  6. 用 SHOW ENGINE INNODB STATUS 分析死锁现场。看两事务的锁和顺序。
  7. 死锁是交互型问题,看交互而非看个体。单看一个事务都没毛病。

写在最后

回头看,这场由"两个事务加锁顺序相反"引发的死锁,真正教给我的,远不止"固定加锁顺序、捕获重试"这一个技巧。它让我对"有些问题,不是任何一方'做错了',而是大家'没商量好一个共同的规矩'",有了一次刻骨的体会。我排查时一度很困惑,因为无论单独看哪个事务,它的逻辑都是对的——"转账要扣一方加一方",天经地义。可问题恰恰在于:两个都"各自正确"的事务,因为对"先锁谁后锁谁"没有一个统一的约定,各按各的来(一个先 A 后 B、一个先 B 后 A),在并发相遇时就撞死了死锁的根,不是"谁的代码错了",而是"大家缺少一个共同遵守的、关于'获取资源的顺序'的约定";而我的解法(固定加锁顺序),本质就是给所有参与者立了一个"共同的规矩":都按 id 从小到大锁——有了这个统一的规矩,冲突就消失了这让我领悟到一个关于"协作系统"的深刻认知:在多个独立的参与者(事务、线程、服务、节点)需要竞争共享资源的系统里,"让大家遵守一个统一的、关于'如何访问共享资源'的约定/协议",往往比"让每个参与者自己都正确"更重要——因为"个体正确"不能保证"交互正确",而一个好的共同约定,能让原本会冲突的个体和谐协作;固定加锁顺序、统一的通信协议、一致的命名规范、约定好的数据格式——这些"共同的规矩",正是让多方协作不出乱子的关键这给了我一种设计协作系统的自觉:当多个参与者要共享、竞争资源时,要主动地为它们设计一个"统一的、所有参与者都遵守的协作约定"(谁先谁后、怎么访问、什么顺序)——而不是让每个参与者"各行其是、各自为政",指望它们自发地不冲突;"用一个共同的规矩,把'各自正确但可能冲突'的个体,协调成'整体和谐'"——这是构建一切并发/分布式/多方协作系统的核心智慧认清死锁源于缺少共同约定、用统一的协作规矩协调竞争共享资源的多方——这,是我用一次数据库死锁的事故,换来的、关于数据库、也关于如何设计多方协作系统的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写一个要锁多条记录的事务时,先想一句"所有事务的加锁顺序一致吗",转而按 id 排序加锁,那我对着那个偶发的 Deadlock 排查的这大半天,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一行 int count = map.get(key) 的赋值,在 key 不存在时悄悄触发了自动拆箱、抛出莫名其妙的空指针:一次 Java 自动装箱拆箱的深度复盘

2026-6-2 18:50:37

技术教程

一个调用下游接口后忘了关闭连接的服务,在下游主动断开后留下了一大堆 CLOSE_WAIT,把文件描述符耗尽、再也建不了新连接:一次 CLOSE_WAIT 堆积的深度复盘

2026-6-2 19:00:58

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索