资讯动态

死锁预防与检测恢复实战:从循环等待到线程栈排查

发布时间:2026/10/27 11:08:26 来源:尧图企业网站定制
1. 死锁不是玄学先把它的形状摸清楚我到现在还记得第一次真正撞上死锁的场景压测跑到一半服务不报错、不打日志、CPU 不高、内存也正常就是接口全部超时。重启之后一切恢复再压又挂。那天我们花了六个小时才把问题钉死在两个方法的加锁顺序上——一个先锁库存再锁账户另一个反过来。从那次之后我对死锁的预防和处理这件事的理解从教科书上的四个必要条件变成了每次写锁之前都要在脑子里过一遍的那张图。这篇东西聊的就是死锁。它是什么、为什么会出现、怎么在设计阶段预防掉大部分、剩下那一小部分漏网的怎么在线上快速定位并恢复以及不同场景下线程、数据库、线程池、分布式锁、协程那些长得完全不一样但本质同一个毛病的案例。适合已经会写并发代码但被卡死问题折磨过的人也适合还没踩过坑、想提前把地基打牢的同学。我不打算按教材的顺序讲而是按我实际排查的顺序来先看现象再看结构最后看怎么防和怎么救。死锁这件事最坑的地方在于它往往不是写错了而是写得都对只是组合起来错了。单个方法看逻辑无懈可击加锁点也都合理但两个请求交错跑起来就变成一个环。所以后面每讲一个方案我都会尽量说清它到底打断了四条件里的哪一条而不是丢给你一句注意加锁顺序就完事。2. 死锁的四个条件与三种卡住的区分2.1 四个必要条件缺一条就构不成死锁教科书上讲死锁有四个必要条件互斥、请求与保持占有并等待、不可剥夺、循环等待。这四个词很干我用转账的例子把它翻译成人话。互斥就是这笔资源同一时刻只能一个人用比如那把锁、那行数据、那个独占设备。请求与保持是线程甲已经拿着 A 锁又去申请 B 锁在等 B 的时候 A 不撒手。不可剥夺是甲手里的 A 锁别人抢不走只能等甲自己放。循环等待是甲等乙、乙等甲绕成一个圈。关键在于这四条是必要条件的合取不是充分条件——四条都满足不一定会死锁可能永远不出现那个交叉时刻但只要有一条不成立就一定死不了。这个性质决定了所有预防方案的思路不奢望去消灭互斥有共享写就必然有互斥而是想办法打断后三条中的任意一条。这也是我后面判断一个方案到底有没有用的唯一标准。注意很多团队写了禁止嵌套加锁的规范却依然出问题因为嵌套只是表现形式真正要禁的是两个线程以相反顺序嵌套同一组锁。规范写错了方向等于没写。2.2 死锁、活锁、饥饿三种病症三套药线上说卡住了其实可能是三种完全不同的病。分不清就会用错药——我见过把活锁当死锁、直接重启导致问题反复出现的。现象线程状态资源是否被占用典型原因处理方向死锁阻塞/等待永久不动相互占用谁都不放循环等待打断环、回滚牺牲者活锁一直在跑状态反复变化通常不占用重试策略无退避、冲突后同步重来加随机退避、打破对称饥饿一直就绪但拿不到资源被高优先级长期占着优先级不均、队列不公平公平锁、限流、分级队列活锁最典型的例子是两个事务冲突后都立刻重试又同时冲突如此往复CPU 烧得挺高但业务一笔都没成。解决办法特别朴素重试前加一个随机抖动或者指数退避。饥饿则是另一个方向比如读多写少场景下读写锁不加公平参数写线程可能被源源不断的读线程饿死。我更愿意把这三者理解成同一个问题的三个刻度死锁是彻底静止活锁是原地打转饥饿是有人永远排不上队。排查时的第一个动作就应该是把线程栈抓下来看状态这一动作的价值比后面所有猜测加起来都高。2.3 一次典型的线上卡死从现象倒推结构举个我自己经历过的模拟案例。某订单服务两个接口下单要扣库存、扣余额取消订单要退余额、退库存。代码写得很对称下单那边先锁库存再锁账户取消那边先锁账户再锁库存。单测全过因为单线程永远碰不到交错。压测到 200 并发时开始偶发超时比例不高大概千分之几。第一反应是数据库慢查询看了下慢日志没有第二反应是连接池不够加大之后照样偶发第三反应才是线程 dump一抓就看到两个线程互相 waiting to lock锁对象正好是库存和账户那两个。从结构上倒推就非常清楚了线程甲持有库存锁等账户锁线程乙持有账户锁等库存锁两条等待边首尾相接成环。解决办法也不复杂不是给某个方法加 synchronized而是把两个业务的加锁顺序统一成按资源 ID 升序加锁。改完之后同样的压测跑了两小时一次都没复现。这个案例我想强调一点死锁的复现概率通常极低但一旦复现成因几乎是确定性的。所以不要指望多压几次看看而是要在线程栈里把那个环抓出来。抓环这件事后面第 3 节会专门讲工具。3. 预防从设计上把循环等待掐死3.1 资源有序分配最便宜也最有效的一招如果只能保留一条死锁预防手段我会毫不犹豫选全局锁顺序。它的逻辑简单到粗暴给所有可能被同时持有的锁编一个全局唯一的序号任何线程都必须按序号从小到大申请。这样一来任何一个线程的持有序列都是单调递增的等待边永远指向更大的序号形成一个严格的偏序关系——有向图里不可能出现环。落到代码上最实用的做法是用业务主键做序而不是引入额外的锁编号。比如转账、扣库存这类操作资源天然有 ID那就比较两个 ID 的大小先锁小的再锁大的public void transfer(long fromId, long toId, long amount) { long first Math.min(fromId, toId); long second Math.max(fromId, toId); synchronized (lockOf(first)) { synchronized (lockOf(second)) { doTransfer(fromId, toId, amount); } } }synchronized的嵌套顺序由Math.min/max决定无论调用方传参顺序如何实际加锁顺序永远一致。这段代码的价值不在于它多优雅而在于它把顺序一致这件事从人的记忆转移到了代码结构里——只要所有人都走这个入口就不可能写错。对于无法用主键排序的场景比如锁的是一个查询结果集可以退一步用锁对象自身的稳定哈希或者显式的层级编号。我给团队里的规范是任何类如果可能被嵌套持有就必须声明一个lockLevel常量代码扫描工具检查嵌套时 level 必须递增。这条规则执行起来有点烦但它把死锁从偶发的线上事故变成了编译期就能发现的错误这个交换非常划算。注意有序分配的前提是所有加锁路径都遵守包括那些看起来无关紧要的内部工具方法。破窗效应在这里非常明显——只要有一个人图省事绕过了顺序整条防线就形同虚设。3.2 一次性申请与银行家算法为什么落地很少理论上更彻底的方案是一次性申请全部资源线程在开始干活之前把所有需要的锁一口气拿下来拿不全就一个都不拿。这直接消灭了请求与保持自然也就没有环。这在代码里的形态通常是批量锁工具传入一组 ID内部排序后依次加锁任何一个失败就回滚已获取的部分。注意这个回滚已获取的部分是必须的否则超时退出时还攥着一半锁等于人为制造了请求与保持。那银行家算法呢它的思路是每次分配前先做一次安全性检查确认分配之后系统仍存在一个安全序列所有进程都能按某个顺序跑完才真正分配。听起来很美实际工程里几乎见不到原因很实在需要预先知道每个线程的最大资源需求这个数字在业务代码里基本无法准确给出每次分配都要做一次 O(进程数 × 资源种类) 的安全性检查高并发下这个开销不可接受判断过于保守明明实际不会死锁的组合也会被拒绝资源利用率掉得厉害。我的看法是银行家算法更适合作为理解安全状态的教学模型工程上真正用的是有序分配加超时回退这两招的组合。知道它为什么不用比知道它怎么算更有价值。3.3 用超时和可剥夺打断不可剥夺第三条可打断的是不可剥夺。只要允许线程在等不到锁的时候放弃并释放已持有的资源环就被强行拆开了。这一条在工程上落到两个具体做法带超时的加锁和可中断等待。boolean ok1 lock1.tryLock(200, TimeUnit.MILLISECONDS); if (!ok1) { return Retry.LATER; } boolean ok2 false; try { ok2 lock2.tryLock(200, TimeUnit.MILLISECONDS); if (!ok2) { return Retry.LATER; } // 关键这里会走到 finally 释放 lock1 return doWork(); } finally { if (ok2) lock2.unlock(); lock1.unlock(); }这段代码里最容易写错的地方是finally的释放顺序和条件判断——必须先放后拿的、再放先拿的并且只在确实拿到的情况下才放。我见过不止一次因为unlock写在try内部、异常路径没覆盖导致锁泄漏最后表现出的症状和死锁几乎一模一样。超时方案还有一个必须警惕的副作用超时后的重试风暴。如果 200 毫秒拿不到就立刻重试几百个线程会同时醒来再次争抢反而把冲突概率推到最高。所以重试一定要带退避和抖动比如base * 2^retry random(0, base)并且设置重试上限超限之后走失败降级而不是无限重试。另外有个细节容易被忽略tryLock拿到锁的顺序如果是乱的比如先抢到了第二把第一把超时一定要把第二把放掉再返回不能拿一半先干活。这一条如果没做超时机制不但没预防死锁反而增加了死锁概率。3.4 减少锁的持有范围把死锁概率压到接近零上面三招都是在锁的结构上做文章还有一类更日常的手段让锁根本不需要被同时持有。具体有几种做法我在不同项目里都用过效果比想象中好。第一种是缩小临界区。锁里只做内存操作把 IO、RPC、日志、序列化全部挪到锁外。临界区越短两个线程同时在锁内交错的机会越少即便顺序有问题也可能因为时间窗口太窄而永远撞不上——当然这只是降低概率不是根治不能当成懒的借口。第二种是用不可变对象和无锁结构替换锁。计数器用原子类缓存用并发容器状态机用 CAS 循环。凡是能用 CAS 解决的就别上互斥锁因为 CAS 失败只是重试不会进入阻塞等待压根构不成等待这条边。第三种是串行化把本来需要并发修改的资源丢到一个单线程的执行器里按顺序处理。这是最土也最省心的办法代价是吞吐受限于单线程但对大多数业务来说完全够用。我用它处理过对账这类任务一天几十万条单线程跑得轻轻松松还顺带消灭了所有并发问题。第四种是分段锁。把一把大锁拆成 N 把按 key 哈希的小锁不同 key 走不同锁冲突概率降到 1/N。这里要注意的是一旦业务需要同时操作多个分段比如跨段求和又回到了持有多把锁的老问题上就必须回到有序分配。技术方案之间是会互相抵消的这点必须提前想清楚。4. 检测与恢复已经卡住了怎么救4.1 等待图把谁等谁画成一张有向图死锁检测的通用模型是资源分配图简化之后就是等待图wait-for graph。节点是线程边 A→B 表示 A 正在等 B 持有的东西。系统定期扫描这张图一旦发现环就说明死锁成立。用代码表达大概是这样维护一个MapThreadId, ThreadId每次线程申请资源超时或被阻塞时记录它等的是谁线程释放资源或结束时删掉对应的边。检测就是在这个图上做环检测深度优先即可复杂度 O(VE)节点数量通常不大几毫秒就能跑完。def find_cycle(wait_for: dict) - list: color {} # 0 未访问 1 在栈上 2 已完成 stack, path [], [] def dfs(node): color[node] 1 path.append(node) nxt wait_for.get(node) if nxt is not None: if color.get(nxt, 0) 1: i path.index(nxt) return path[i:] [nxt] # 找到环返回闭环路径 if color.get(nxt, 0) 0: r dfs(nxt) if r: return r path.pop() color[node] 2 return None for node in list(wait_for): if color.get(node, 0) 0: r dfs(node) if r: return r return []这段代码本身不难难的是数据从哪里来。业务线程的等待关系通常拿不到只能靠框架埋点或者 JVM/内核层面的信息。所以实践中等待图检测更多是数据库、分布式锁这类中心化组件在做的事应用层的死锁排查基本靠线程栈快照也就是下面这一节。4.2 抓现场线程栈是最快的入口应用层最实用的手段就是抓线程栈。Java 用jstack一条命令的事# 先拿 pid jps -l # 抓栈-l 会额外打印锁对象的地址信息找死锁必须加 jstack -l 12345 /tmp/thread-dump-$(date %s).txt # 直接过滤死锁结论 grep -A 40 Found one Java-level deadlock /tmp/thread-dump-*.txt如果虚拟机的版本比较新jstack通常会自动做一次死锁检测并在末尾打印结论包含每个线程持有的锁和等待的锁。看到waiting to lock 0x000000076ab...和locked 0x000000076ab...这两类地址刚好在两个线程之间互换基本就板上钉钉了。我个人的习惯是至少抓三份间隔五秒。原因是要区分死锁和慢操作真死锁的三份栈完全一样如果栈在变那更可能是某个慢查询或者外部调用卡住方向完全不同。这个动作成本极低但能省掉大量误判。非 Java 的场景同理。Linux 上用gdb -p PID -batch -ex thread apply all bt或者装个轻量工具批量导出Go 更方便直接给进程发SIGQUIT就能打出所有 goroutine 栈看到sync.runtime_SemacquireMutex之类的调用链基本就能定位。内核态问题可以看/proc/locks调试场景还可以借助锁依赖检查器在启动阶段就报出潜在的反向加锁。注意抓栈之前先确认进程还活着且没有在做 GC。我遇到过把一次长 GC 当死锁重启的重启完发现根本没这回事白折腾一轮。抓栈时顺手看一眼 GC 日志和 CPU 曲线能避免一大半误判。4.3 数据库的死锁检测等待图 回滚牺牲者数据库是死锁检测做得最成熟的地方思路还是等待图只是恢复策略是自动的找一个代价最小的牺牲者回滚掉让环断开。以主流关系型数据库的 InnoDB 引擎为例开启死锁检测后事务在等待锁时会遍历等待图找环一旦发现环就选择回滚代价最小的那个事务粗略说就是持有和修改行数最少的让它抛错退出另一个事务继续。业务侧往往会收到一个类似 Deadlock found when trying to get lock; try restarting transaction 的错误这不是 bug是数据库在告诉你它已经帮你选了牺牲者。想看到具体现场有几个配置和命令很有用-- 查看最近一次死锁的完整信息包括两个事务各自执行的 SQL、持有的锁、等待的锁 SHOW ENGINE INNODB STATUS\G -- 把每次死锁都写进错误日志长期留存默认只保留最近一次 SET GLOBAL innodb_print_all_deadlocks ON; -- 死锁检测是有成本的超高并发下可以关掉改用超时 SET GLOBAL innodb_deadlock_detect OFF; SET GLOBAL innodb_lock_wait_timeout 5;关掉检测这个操作要慎重。它的收益是省掉了每次等待时的图遍历开销代价是死锁不再被立刻发现而是变成等到超时才报错响应时间会明显变长。我一般只在明确观察到检测开销成为瓶颈、且业务能容忍超时时间的情况下才会这么干并且一定会同时把超时时间调小。4.4 恢复策略从回滚到降级检测出死锁之后恢复无非几种回滚、重启、降级。选哪个取决于代价。事务级别的回滚最优雅前提是业务必须支持重试且保证幂等。如果回滚后重试又立刻撞上同样的死锁那就变成了活锁所以重试要限量并退避。我一般会设置最多三次每次间隔带随机。线程级别的恢复如果是单进程内的死锁没有通用的杀掉某个线程的办法最干脆的手段是重启进程。听起来很粗暴但配合上抓栈 → 保留现场 → 重启 → 复盘这个流程其实是可接受的。关键是重启之前一定要把栈抓下来否则第二天什么都查不到同样的坑还会再踩一次。业务级别的降级是当死锁集中发生在某个非核心功能上时先把它关掉保住主链路。比如某个统计接口老是和别人抢同一批行那就先停止统计把资源让出来。这个决定需要一点魄力但线上救火的时候保核心比保完整重要得多。5. 不同场景下的死锁实战5.1 数据库间隙锁、唯一索引与批量更新数据库死锁和线程死锁本质相同但表现形式差别很大因为锁的粒度是行甚至间隙而不是代码里看得见的那把锁。第一个高频场景是范围条件加间隙锁。在可重复读隔离级别下UPDATE ... WHERE id BETWEEN 10 AND 20不仅锁住命中的行还会锁住范围之间的间隙。两个事务如果范围有重叠且有交叉顺序就可能形成死锁。这类问题的解法通常是把范围操作改成按主键排序的单行操作或者评估把隔离级别降到读已提交很多业务其实不需要可重复读。第二个场景是唯一索引冲突。两个事务同时插入同一个唯一键都会先申请共享锁做重复性检查然后都想升级为排他锁来插入互相等着对方释放共享锁经典死锁。解决办法很土但有效插入前先查一下或者用INSERT ... ON DUPLICATE KEY UPDATE把检查后插入的两步合成一步。第三个场景是批量更新顺序不一致。两个批处理任务同时跑一个按id升序更新一个按name排序更新命中的行集合相同但加锁顺序不同必然出问题。规范就是一句话批量写操作一律按主键升序。-- 不推荐顺序依赖执行计划容易和别的任务交叉加锁 UPDATE t_order SET status 1 WHERE user_id 100 AND status 0; -- 推荐先按主键取出待更新行再按主键顺序逐条更新加锁顺序完全可预期 SELECT id FROM t_order WHERE user_id 100 AND status 0 ORDER BY id; UPDATE t_order SET status 1 WHERE id IN (...) ORDER BY id;注意死锁重试必须幂等。我见过重试逻辑把一笔订单重复扣款的原因就是回滚之后重试时没有校验业务状态。死锁错误是可以重试的信号但不是盲重试的许可。5.2 线程池父任务等子任务池子被自己撑死线程池死锁是最隐蔽的一类因为代码里一个synchronized都没有看起来完全无锁但资源变成了线程本身。典型形态是一个有界线程池父任务提交若干子任务到同一个池然后调用future.get()阻塞等待结果。并发一上来池里所有线程都被父任务占满每个都在等子任务而子任务在队列里排队永远轮不到执行。整个池子就这么静止了。这个问题的本质是线程作为资源被持有父任务占着同时又在申请新资源子任务需要线程并且线程不可被剥夺形成了一个等待环。四条件全中。解法有几种按推荐程度排序用CompletableFuture做异步编排全程不阻塞靠回调串联或者给父子任务用不同的线程池物理隔离父池的线程不会被自己提交的任务堵死再或者是用支持工作窃取的池允许等待中的线程去执行队列里的任务。最不推荐的是把池子调大那只是把触发阈值往后推问题还在。5.3 分布式锁租约到期与被误删的锁分布式锁的死锁不长成环而长成锁永远不释放。持有者进程崩溃、网络分区、GC 停顿锁留在那里谁也拿不到。标准解法是租约加锁时带过期时间到期自动释放。但加了过期时间就引入一个新问题——业务执行时间超过租约锁自动释放了另一个请求拿到锁两个请求同时操作同一资源互斥性被破坏。这时候如果第一个请求执行完还去解锁就会把别人的锁删掉问题扩散。所以完整的实现必须做到三点加锁时写入一个唯一标识作为 value解锁时先比对 value 再删除这两步要用脚本保证原子性业务执行时间不确定时启动续期机制在租约过半时自动延长所有被锁保护的写操作保持幂等作为最后一道保险。# 加锁NX 保证互斥PX 给出租约value 放本次请求的唯一标识 SET lock:order:1001 req-8f3a1c NX PX 30000 # 解锁比对 value 后删除必须是原子的脚本或事务 # 如果直接 DEL就可能删掉别人的锁注意不要用先 GET 判断再 DEL的两步做法两个请求之间永远存在窗口。而且续期也不能无脑延长——如果进程假死续期会把锁一直攥在手里反而制造了真正的死锁。续期必须有上限超过上限就主动放弃。5.4 协程与通道无缓冲互相等待和不可重入在有通道channel语义的语言里死锁的表现形式又变了。最常见的两种一种是无缓冲通道互相发送。协程甲向通道 A 发、等通道 B 收协程乙向 B 发、等 A 收。两个发送都要等对方先接收谁都不动。这种代码在纸上画一画立刻能看出来但写的时候很容易漏因为通道通常藏在几个函数调用后面。另一种是锁不可重入导致的自我死锁。某段逻辑在持锁的情况下调用了一个内部方法这个方法又去拿同一把锁如果锁不可重入线程就等自己。这类问题最难查因为线程栈里只有一个线程看不到环只有一行等待自己已经持有的锁。解决办法要么改成可重入锁要么把内部方法改成不加锁版本由调用方统一负责加锁边界。// 反例无缓冲通道上的相互等待两个 goroutine 永远停住 a : make(chan int) b : make(chan int) go func() { a - 1; -b }() // 卡在 a - 1 go func() { b - 1; -a }() // 卡在 b - 1 // 修复至少一侧改用带缓冲通道或调整收发顺序排查这类问题有个很爽的办法给进程发退出信号打出全量协程栈一眼就能看到所有协程都卡在 channel 收发上且数量正好对得上。这也是我特别推崇这个语言生态的原因——检测成本几乎为零。6. 常见问题与排查速查表6.1 排查动作清单按顺序做别跳步我把这些年排查死锁的动作固化成了一个顺序遵循它能省掉大量来回步骤动作目的判断依据1看 CPU、内存、GC排除是死锁还是慢/GCCPU 极低且业务无响应偏向死锁2隔 5 秒抓两次以上线程栈区分静止与慢操作两次栈完全一致才怀疑死锁3搜栈里的死锁结论快速定性有明确结论就不用再猜4找持有 A 等 B的互换对定位到具体代码行锁地址在两个线程间互换5回看这两个入口的加锁顺序找结构性问题顺序不一致即根因6保留栈、日志、SQL为复盘留证据重启前必须完成这套动作里最容易被跳过的是第 6 步。重启一把梭确实爽但如果没有留下现场第二天开会就只能靠猜。6.2 常见误判与踩坑记录下面这几条全都是我自己或者身边人真实踩过的误判真实原因怎么避免判定为死锁重启后复发其实是慢查询/外部调用超时抓两份栈对比看状态是否变化加大连接池后好了只是把触发时间推后根因还在修复后要压测更长时间验证只加了超时没回滚已持有的锁超时反而制造了新的等待环超时必须配合 finally 释放死锁重试没做幂等数据被重复处理重试前查业务状态锁里打日志/发消息临界区变长死锁概率飙升锁内只做内存操作以为加了分布式锁就安全租约到期后互斥被破坏唯一 value 原子解锁 幂等还有一条不在表里但我想单独说不要在没有证据的情况下改锁粒度。我见过一上来就把大锁拆成细锁的结果本来一个死锁变成了三个因为细锁之间有更多组合可能。改之前一定要先确认当前的死锁到底发生在哪几把锁上。6.3 几条我认为最值钱的经验第一条锁的顺序问题靠 code review 是防不住的因为 review 的时候你只能看到单个方法。能防住的是代码结构——把所有需要同时持有多个锁的逻辑收敛到极少数几个入口最好是一个工具方法其余业务代码根本不允许嵌套加锁。约束入口数量比约束顺序有效得多。第二条死锁的复现测试一定要写。写一个多线程循环跑 1000 次、失败就断言的用例大多数顺序类死锁都能用这种方式稳定复现。这种东西平时跑得很慢可以放到夜间任务里一旦有人改了加锁顺序第二天就能报警。我待过的团队里凡是坚持写这类测试的模块死锁事故明显少。第三条抓栈的工具和命令要提前准备好别等出事再查。把命令写进运维手册把脚本放到跳板机上把抓三份写进值班流程。事故现场最贵的就是时间而这几条命令的准备成本几乎为零。第四条接受死锁无法彻底消灭这个事实。只要存在共享资源的并发写就有理论上的可能。工程上的目标不是零死锁而是死锁可检测、可恢复、影响可控。把检测和恢复做扎实比追求彻底预防更现实——毕竟后者往往意味着牺牲性能或者大幅降低并发度代价未必划算。最后再分享一个我自己一直用的思维习惯每次准备嵌套加锁之前先在纸上或者注释里把我拿着什么、我要等什么写出来。如果写不出一个明确的顺序或者发现这个顺序取决于入参那这里就是隐患。这个动作花不了十秒钟但它拦下的问题比我后来所有排查工具加起来都多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑