死锁处理全攻略:从四条件到预防/检测/解除 先问一个问题你在线上有没有遇到过那种看起来一切正常但业务就是不动了的诡异故障我遇到过。某个模拟订单系统的压测环境里所有线程的CPU占用都正常内存也没爆日志却一行都不再输出。查到最后发现是经典的死锁——两个线程各自持有一把锁又在等对方手里的那把互相僵持谁也没法往前挪一步。从那一刻起我就意识到死锁这四个字在教科书里是一个考点在线上就是一个让团队深夜加班的定时炸弹。这篇文章就把死锁这件事讲透。我会从死锁发生的四个必要条件入手然后分别拆解处理死锁的四种策略预防死锁、避免死锁、检测死锁、解除死锁。不只是讲理论我还会带上每一步的推断逻辑、工程实现思路和实际踩坑经验。无论你是刚接触并发的学生还是已经在写多线程/分布式代码的工程师这篇内容都应该能帮你建立一套完整的死锁处理框架。1. 死锁的四个必要条件与一个线上卡死案例很多人对死锁的印象停留在两个线程互相等待但真正遇到问题时你会发现判断是否死锁、找到死锁链、确认死锁根因每一步都比想象中麻烦。想系统性地处理死锁首先要理解死锁到底在什么条件下才会发生。1.1 四个必要条件缺一不可死锁发生需要同时满足四个条件学术上一般叫死锁必要条件。注意必要这个词——它是必要条件不是充分条件。也就是说这四个条件同时满足系统只是可能发生死锁不一定会死锁但想形成死锁这四个条件一个都不能少。第一是互斥条件。资源在同一时刻只能被一个进程或线程独占使用别人想用只能等。典型例子是打印机、文件写锁、数据库行锁。第二是占有并等待。进程已经占据了至少一个资源同时又在等待获取其他被别的进程占据的资源。说白了就是手里抓着不放眼睛还盯着别人的。第三是不可剥夺。进程已经获取的资源只能由它自己主动释放系统不能强行抢走。比如你初始化了一把互斥锁另一个线程不能直接把你手里的锁拿走。第四是循环等待。存在一条闭合的等待链进程A等待进程B占有的资源进程B等待进程C占有的资源顺着链条走一圈最终又回到A等待的资源上。这四个条件组合在一起就是死锁的完整画像。处理死锁的一切策略本质上都是在跟这四个条件博弈。1.2 我遇到的那个卡死案例说回我在模拟订单系统里遇到的那个问题。系统逻辑很简单用户下单后一个线程负责扣减账户余额另一个线程负责更新库存。当时为了保证数据一致我在账户服务和库存服务里都加了分布式锁。故障现象是压测跑到一半整个系统的下单接口全部超时但CPU和内存指标看起来完全正常线程池队列却越堆越长。当时第一反应是数据库连接池满了查了一圈发现连接池也正常。最后把两个服务上锁的获取顺序放在一起比对问题立刻清楚了线程A先获取账户锁再去获取库存锁线程B刚好反着来先获取库存锁再获取账户锁。当线程A拿着账户锁等库存锁时线程B已经拿着库存锁在等账户锁。两个线程就在这一刻完美地满足了互斥、占有并等待、不可剥夺、循环等待这四个条件。死锁就这么形成了。这个案例其实非常典型。你不需要多么复杂的分布式系统两个线程两把锁只要顺序不对死锁就来了。理解了这个最简单的模型后面讲的所有策略就都有了抓手。2. 预防死锁四套打破必要条件的工程思路预防死锁的核心思想是从根上破坏四个必要条件中的至少一个让死锁在系统设计层面就不可能发生。它不关心系统当前处于什么状态靠的是静态规则写代码时就把死锁的可能性掐死。2.1 破坏互斥条件用共享替代独占互斥条件说的是资源同一时刻只能一个人用。那能不能让资源变成大家都能用在某些场景下可以。比如读操作使用读写锁中的读锁多个读者并行访问再比如无锁数据结构用CASCompare And Swap这种原子操作代替互斥锁线程之间不需要阻塞等待。但必须泼一盆冷水互斥条件在绝大多数场景里是无法被破坏的。你让打印机同时打印两份文档试试打出来的东西肯定没法看。数据库的行锁、写锁更不可能因为你想避免死锁就全部改成共享。所以这条路理论上成立实际工程里能用到的场景很少更多是配合其他策略使用。2.2 破坏占有并等待一次性拿到全部资源这条思路比较容易理解既然占着不放还去等新的会出事那就不允许这种状态出现。具体做法是要求每个进程在开始执行前一次性申请它需要的全部资源要么全给要么全不给执行过程中不再申请新资源。我见过一个实际落地案例某个消息消费组件需要同时操作Redis和MySQL最初是先用Redis锁再拿数据库锁结果在高并发下偶发死锁。后来改成在业务入口处先探测两块资源是否都可用全部可用才开始处理不可用就整个任务排队等待。这样就不会出现占着一个等另一个的中间状态。这个方案的代价也很明显资源利用率低。进程可能只需要资源A的少量空间但因为一次性申请的规则不得不把整个流程需要的资源全部占住后面的进程就得干等。而且在资源需求动态变化的场景里很难做到事前全申请所以它更适合需求明确、资源种类固定的批处理任务。2.3 破坏不可剥夺允许系统把资源抢走如果一个进程申请不到新资源那就不让它继续占着老资源强制把它手上的资源释放掉。听起来很霸道但它的成立前提是资源状态必须能保存和恢复。CPU寄存器可以保存内存页可以换出这类资源确实能被剥夺但正在打印的打印机没法保存打印到一半的状态这种资源就不能剥夺。实际工程里这条思路最常见的变体是超时机制和租约机制。比如很多锁工具都提供tryLock(timeout)拿不到锁就放弃等待同时把已持有的锁释放掉过一会儿再重试。分布式锁更常用租约锁持有者只拥有一个有限期限的租约到期后锁自动被系统收回别人就能抢走了。这种机制天然破坏了不可剥夺因为系统层面让锁可以被抢走。2.4 破坏循环等待给资源编号强制按序申请这是工程实践里最常用、最实用的预防手段。做法是给所有资源进行全局编号规定每个进程只能按编号递增的顺序申请资源。比如只能先申请编号小的资源再申请编号大的资源不允许反过来。回到我那个模拟订单系统的例子。账户锁和库存锁如果统一约定了必须先拿账户锁再拿库存锁线程B就不会先拿库存锁再拿账户锁循环等待链直接就断了。数据库场景里也很常见多张表需要同时更新时约定按照表名的字典序去加锁而不是按业务代码里写死的顺序。分布式系统里则可以给每个共享资源分配一个全局锁ID谁拿锁都按锁ID从小到大拿。这套思路的优点是非常简单、完全可控不需要动态判断。缺点是破坏业务灵活性——有些业务天然就是先拿资源B再拿资源A更合理强制排序以后可能性能受影响。而且资源编号一旦定下来后续要新增资源类型时编号体系就得维护扩展。说白了这是一套需要自律和规范的预防机制适合在统一架构规范下执行。3. 避免死锁银行家算法的推断逻辑与安全序列如果说预防死锁靠的是硬性规则那避免死锁靠的就是实时判断。它的核心思想是对每一次资源请求都做安全性检查只有确认分配后系统仍处于安全状态才允许分配否则就让进程等待。3.1 安全状态与不安全状态动态判断的本质先理解两个概念。安全状态指的是系统存在一个安全序列——按照这个顺序依次执行进程每个进程需要的最大资源都能被满足最终所有进程都能完成。不安全状态就是反过来找不到这么一条执行顺序。这里有一个很容易搞混的关键点不安全状态不等于死锁。不安全状态只是将来有进入死锁的风险可能最后也没真死锁而死锁是已经实际发生了。避免死锁的策略做的就是在每一步分配时把系统往安全状态方向引导只要系统始终处于安全状态死锁就永远不可能出现。3.2 银行家算法的数据结构与判断流程避免死锁最经典的实现就是银行家算法它的名字来源于银行放贷的场景——银行不知道每个客户到底什么时候还款但能根据每个客户声明的最大贷款额度预留足够的资金避免自己被拖垮。系统需要维护四张表Available当前各资源的可用数量、Max每个进程对各资源的最大需求矩阵、Allocation当前已分配给各进程的资源矩阵、Need还需要的资源数量。其中Need Max - Allocation。当一个进程发出资源请求时算法做如下步骤检查请求是否小于等于Need超过最大需求就报错。检查请求是否小于等于Available不够就让进程等待。试探性地进行资源分配更新Available、Allocation、Need。执行安全性检查算法以现有的Available为初始Work遍历所有进程找到一个Need ≤ Work且未完成的进程假设它运行完毕回收它的Allocation并入Work继续下一轮。如果所有进程都能被运行完毕说明系统安全正式分配否则撤销试探分配让进程继续等待。3.3 一个具体的数字推演光看流程有点抽象我手算一个例子。假设系统有三类资源总量分别是A10、B5、C7。有5个进程当前时刻的分配情况如下进程Max (A, B, C)Allocation (A, B, C)Need (A, B, C)P07, 5, 30, 1, 07, 4, 3P13, 2, 22, 0, 01, 2, 2P29, 0, 23, 0, 26, 0, 0P32, 2, 22, 1, 10, 1, 1P44, 3, 30, 0, 24, 3, 1此时剩余资源Available 总量 - 各进程已分配的总和 (10, 5, 7) - (7, 2, 5) (3, 3, 2)。从P0开始检查P0的Need是(7,4,3)大于Available(3,3,2)不满足。P1的Need是(1,2,2)每个维度都小于等于(3,3,2)满足。假设P1执行完回收它的AllocationWork变成(32, 30, 20) (5,3,2)。继续找下一个进程。P3的Need(0,1,1) ≤ (5,3,2)满足执行后Work变成(7,4,3)。再看P4Need(4,3,1) ≤ (7,4,3)满足执行后Work变成(7,4,5)。接着P0Need(7,4,3) ≤ (7,4,5)满足执行后Work变成(7,5,5)。最后P2Need(6,0,0) ≤ (7,5,5)满足。得到安全序列P1 → P3 → P4 → P0 → P2。系统当前处于安全状态这次请求允许分配。说实话这个算法在操作系统教材里讲得很细但工程实践中真正用它的场景非常少。原因也很现实进程未来的最大资源需求很难提前精确声明资源数量也在运行中是动态变化的而且每次分配都要做全量的安全性扫描开销不小。它更多是作为一种理想的动态判断模型存在帮助我们理解安全状态这个概念。4. 检测死锁资源分配图的化简与发现时机预防和避免都没拦住死锁已经发生了那就得靠检测。检测策略的思路是平时不设任何限制允许死锁发生但提供一种手段来判断系统当前是否已经进入死锁状态。如果检测到了再进入解除环节。4.1 资源分配图把等待关系画出来检测死锁的核心工具是资源分配图Resource Allocation Graph。图中的节点分两类进程节点用圆圈表示和资源节点用方框表示。从资源节点指向进程节点的边叫分配边表示资源已分配给该进程从进程节点指向资源节点的边叫请求边表示进程正在等待该资源。判断规则是这样的如果资源分配图里没有环一定没有死锁。如果每个资源类型只有单个实例有环就说明死锁了。如果资源类型有多个实例有环不一定死锁需要进一步化简判断。化简的过程是找出一个既不阻塞又不是孤点的进程它所有的请求边都能被当前资源满足那就把它的所有分配边去掉模拟它运行完毕。不断重复直到所有进程都能被化简就说明系统没有死锁如果剩下一些进程化简不掉它们就是死锁进程。4.2 什么时候做检测检测频率的工程取舍死锁检测本身有开销系统不可能每时每刻都在扫描。时机选择一般有三种每次资源请求都检测。实时性最高但频繁扫描资源分配图性能开销大到不现实。定时检测。比如每隔几分钟跑一次适合对实时性要求不高的系统。资源利用率触发。这是最实用的一种当CPU利用率下降到阈值比如40%以下时怀疑系统可能因为死锁而空闲这时候触发检测。因为死锁的直接表现之一就是进程集体阻塞CPU利用率明显下跌。我个人的体会是死锁检测适合用在资源类型复杂、进程间资源竞争激烈但死锁发生频率又不算高的场景。订好合理的触发时机既能兜住突发问题又不至于频繁扫描导致正常业务被拖累。4.3 一个数据库事务死锁的检测实例以数据库场景为例两个事务事务1先更新账户A再更新账户B。 事务2先更新账户B再更新账户A。如果两个事务同时执行事务1握着账户A的行锁等账户B事务2握着账户B的行锁等账户A资源分配图上就出现了一个闭环。数据库的做法通常是内部维护一张等待图Wait-for Graph节点是事务边表示事务1在等待事务2释放资源。死锁检测器会周期性地在等待图里找环一旦发现环立刻选择一个事务作为牺牲者通常是回滚代价最小的那个比如持有资源少、执行时间短的然后把它回滚释放它持有的所有锁让另一个事务继续执行。这跟理论上的资源分配图思路完全一致只是落到了数据库的具体场景里。理解原理之后你会发现数据库报的Deadlock found when trying to get lock并不是什么玄学背后的逻辑就是图上找环。5. 解除死锁从资源剥夺到事务回滚的实操做法检测发现死锁只是第一步接下来的问题更具体怎么让系统从死锁状态恢复过来解除死锁有几种做法每种都有自己的代价得根据场景选。5.1 资源剥夺抢走一部分资源从其他进程中强制剥夺资源分给死锁进程使用从而打破死锁链。之前讲预防的时候说过这种操作的前提是资源可以被抢占状态能保存能恢复。分布式锁里的租约过期机制其实就是一种自动的资源剥夺。不过在使用时要特别小心如果被剥夺的是有状态资源比如数据库写锁贸然抢走可能导致数据不一致必须有完善的补偿机制。5.2 撤销进程杀鸡儆猴释放资源这是最直接的手段。撤销一个或多个死锁进程把它们的资源全部回收死锁自然解除。选择撤销谁时一般按这几个维度评估代价进程优先级优先撤销低优先级的。进程已执行时间优先撤销执行时间短的。进程占用的资源量优先撤销占用资源少的。进程是交互式还是批处理式优先撤销批处理的。可以只撤销死锁环里的一个进程也可以撤销环里所有进程取决于系统对损失的可接受程度。这种方案简单粗暴但代价是业务可能被中断在关键业务场景要慎重。5.3 进程回退回到检查点重新执行从进程的角度来看能不能不死不伤而是回到过去重新走一遍这就是进程回退。系统周期性地给进程做快照保存状态和上下文。死锁发生后将某个进程回退到死锁发生前的一个检查点释放它从那时起占用的所有资源让它重新执行。这个思路在数据库事务里特别常见事务回滚就是回退到事务开始时的状态释放所有行锁让其他事务有机会拿到锁继续推进。分布式事务里的补偿回滚也是同样的道理。回退的频率需要权衡检查点太密快照开销大检查点太稀回退后重新执行的代价大。5.4 实际操作中的一个完整恢复流程还是用那个模拟订单系统的例子来串一遍。检测器在等待图里发现了死锁环涉及线程A和线程B。做代价评估线程A持有一把账户锁线程B持有两把锁账户锁和库存锁线程B还处于事务中间过程。如果撤销线程A业务损失小一点但线程B持有两把锁撤销它能同时释放更多资源。实际操作中我们选择回滚线程B的事务到检查点让它释放账户锁和库存锁。线程A拿到库存锁后继续执行完成后释放所有资源。线程B回滚到检查点后走另一条业务路径重试成功处理了订单。整个恢复过程对用户来说只是个短暂的延迟没有数据损失。这就是检测回退组合拳的典型打法。6. 四种策略选型对比与经验总结把这四种策略放在一起对比你会发现它们的定位完全不同不是互相替代的关系而是应对不同场景的组合选择。策略核心思想典型实现优点缺点适用场景预防死锁破坏四个必要条件之一资源排序加锁、一次性分配、锁超时实现简单零运行时开销资源利用率低规则僵化单机多线程、分布式锁规范避免死锁动态判断安全状态银行家算法理论上不会死锁资源利用率较高需要预知最大需求开销大教学、小型嵌入式系统检测死锁允许死锁发生事后发现资源分配图化简、等待图找环不限制并发自由度检测有时延需要配合解除数据库事务、复杂资源系统解除死锁死锁已发生恢复运行资源剥夺、撤销进程、事务回滚能让系统及时恢复有业务损失需要权衡代价配合检测策略使用6.1 工程上最常见的组合我在实际项目里见过最多、也最推荐的是预防为主 检测兜底的组合。预防方面所有加锁操作严格按约定的资源顺序执行任何新接入的业务代码都要过一遍锁顺序审查。同时所有锁都加上超时机制就算某个环节出了问题锁也会因为超时自动释放不会无限僵持下去。这一步已经把绝大多数死锁挡在门外。检测方面线上系统保留死锁日志监控和等待图分析。哪些线程在等哪些锁、等了多少秒等等。如果发现某个服务的锁等待时间突破阈值立刻告警并抓取线程栈。数据库层面则依赖数据库自带的事务死锁检测配合事务超时时间确保数据库不会因为一个死锁事务把连接池拖垮。6.2 银行家算法在工程上为什么少见很多初学者不理解教材里讲得那么细的银行家算法怎么工程里几乎见不到原因我前面提过最致命的问题就是系统无法提前声明每个进程对各种资源的最大需求。在真实业务里一个线程要拿多少锁、占多少资源往往是运行时动态决定的谁也没法在启动前给出一个准确的最大需求矩阵。还有一点银行家算法假设资源数量是相对固定的但真实系统里的资源数量在动态变化比如线程池大小弹性伸缩、连接池扩容。每变化一次整个矩阵就乱了维护成本极高。所以它更适合作为理解安全状态的理论工具工程上还是用预防和检测的组合更实在。6.3 踩过几次坑之后的个人体会最后多说两句实在话。第一别迷信任何一种单一策略。死锁处理没有银弹真实系统里永远是一套组合拳资源排序来预防、超时机制来兜底、等待图监控来发现、事务回滚来恢复。第二死锁的根因往往不在锁本身而在业务的资源获取顺序没定清楚。我后来复盘那个模拟订单系统发现问题不在分布式锁代码写得不好而是两个团队各自维护自己的服务账户锁和库存锁的获取顺序从未有过统一约定。后来把锁ID规范化所有服务强制按锁ID从小到大获取问题就再没出现过。第三把死锁知识变成排查问题的直觉。看到线程池积压、CPU利用率下跌、日志戛然而止脑子里要立刻浮现出有没有可能是死锁这个选项然后去抓线程栈、画等待链而不是漫无目的地重启服务。死锁处理策略这套东西考试会做选择题只是最低要求能在线上故障里快速定位并恢复才算真正吃透了。