ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

操作系统死锁全解析:四个必要条件、资源分配图与线上排查

操作系统死锁全解析:四个必要条件、资源分配图与线上排查 搞操作系统的人迟早会碰到程序跑着跑着就不动了这种局面。不是崩溃、不是报错、CPU 占用率还很低日志停在某个位置不再往下走重启一下又能跑一阵——这种时候八成是撞上操作系统层面的死锁了。死锁这个东西在教科书里属于必考、但很多人考完就忘的知识点可真到了线上排查线程卡死、数据库事务僵持的时候你会发现它无处不在。我这次的整理面向几类人正在啃操作系统课程、准备期末或者复试的同学写多线程程序、被jstack输出绕晕的开发者以及需要处理数据库事务冲突的后端工程师。对应用层的人来说死锁看不见摸不着但只要你的系统里存在多个执行体竞争多个资源它就有出现的土壤。这篇文章不背定义重点聊死锁为什么必然发生、怎么判定、怎么在生产环境里把它揪出来。1. 死锁卡住的到底是什么四个必要条件从来不是用来背的1.1 两台打印机和三个进程的真实画面先把最经典的场景摆出来。系统里有两台打印机 P1、P2三个进程 A、B、C。A 已经拿到了 P1正等着 P2 打印封面B 拿到了 P2等着 P1 打印正文两边都不肯放手也都不肯继续于是整个链路静止。这就是最朴素的死锁一组进程中的每一个都在等待一个只能由组内其他进程释放的资源。注意组内这个词非常关键——如果 A 等的资源可以被组外的 D 释放那它只是在等不算死锁。拿日常类比更容易理解。四个人围坐一桌吃饭每个人左手都抓着一根筷子右手都在等右边的人把自己左手那根递过来。菜在桌上谁也吃不到但每个人手里的筷子都没浪费也都不是自愿放弃的。这个状态和死锁几乎一模一样资源被持有、没人释放、大家都在等。死锁和普通的等待的核心区别在于普通等待存在一个外部事件能打破僵局比如 D 进程打印完主动释放而死锁是闭环的环内的等待互相依赖没有任何一个环节能自己松动。这里要强调一个很多初学者会混的点死锁不是资源不够。如果只有一台打印机三个进程排队等那是正常的资源竞争谁先拿到谁先用用完就释放队列会往前走。死锁的可怕之处是资源配置看起来是够用的——两台打印机够两个人用——但因为分配顺序错乱导致谁都走不下去。资源总量足够但仍然卡死这才是死锁真正反直觉的地方。1.2 互斥、占有并等待、不可剥夺、循环等待的相互咬合教科书里死锁的四个必要条件互斥、请求并保持占有并等待、不可剥夺、循环等待。很多人背下来了却不会用因为它们不是四个并列的复选框而是一条因果链——必须四个同时成立才会形成死锁缺一个就构不成。互斥说的是资源在同一时刻只能被一个进程使用。这个条件天然存在打印机、临界区、数据库的行锁都满足互斥你没法破只能绕开。占有并等待说的是进程已经握着至少一个资源同时又在申请新的资源。不可剥夺说的是资源不能被强行抢走只能由持有者主动释放。循环等待则是前面这些条件的必然结果进程集合 {P0, P1, …, Pn} 里P0 等 P1 持有的资源P1 等 P2 持有的……Pn 等 P0 持有的首尾相接形成闭环。这四个条件的咬合关系值得细品。互斥和不可剥夺是资源本身的物理属性决定的短期内改不了占有并等待和循环等待则和进程的申请策略强相关是可以被程序设计约束掉的。所以后面讲预防时你会发现几乎所有可行的预防手段都集中在破坏后两个条件上这不是巧合而是因为它们才是人为可控的变量。理解了这个取舍逻辑你在设计并发模块的时候就知道该往哪儿使劲了能一次性把要用的资源都圈出来的场景就别分批申请。1.3 饥饿、活锁和死锁三张长得像但本质不同的脸工程上最容易被误判的就是把饥饿或者活锁当成死锁。我们先区分清楚。饥饿是指某个进程长期拿不到资源但系统整体在往前走。典型例子是优先级调度里低优先级进程被高优先级进程反复插队永远轮不到它。它的特征是系统内有进展只是这个特定进程没进展。活锁则更微妙进程们没有阻塞都在努力但努力的方向互相抵消导致谁也没法推进。两个人走廊里相遇都往左让同步让开又同步撞上来回几次谁也没过去——这就是活锁。它的特征是状态在变但系统整体没有有效进展。死锁的特征最干脆相关进程全部阻塞状态不再变化。三者的排查方式也不同死锁看线程栈能直接看到互相持有的锁饥饿要看调度策略和队列的排队情况活锁往往体现为 CPU 空转、日志疯狂打转但没有实质推进。把这三个分清楚了你排查线上问题时就不会一看到卡住就往死锁上套。我见过有人花了两小时查死锁最后发现是某个线程池的队列上限设置得太小任务在排队等待属于资源饥饿而不是死锁。2. 资源分配图把抽象的卡顿画成可判定的图形2.1 顶点和边的含义以及图是怎么画出来的死锁的判定在理论上靠资源分配图。这张图两种顶点进程集合 P 和资源集合 R。进程用圆圈表示资源用方块表示方块里的圆点数量代表该资源的实例数比如 3 台打印机就画 3 个点。边分两种申请边从进程指向资源表示进程在申请该资源分配边从资源指向进程表示该资源已被该进程占用。画图的规则是死的但正是这些规则让死锁判定从感觉卡住了变成了可计算的问题。申请边表示我还在等还没拿到分配边表示我已经拿到了别人别想动。当某个资源的实例被全部占用时所有指向它的申请边就都没法满足这些边就成了图中堵住的标志。理解这张图的价值在于它把分布式的、动态的等待关系压缩成了一张静态的有向图。你在图上做一次化简就能判断当前系统是否存在死锁而不需要去追踪每个进程的执行轨迹——这在理论上是一个巨大的简化。当然现实系统的资源成百上千真画图不现实但判定算法背后的思想找环、找不可化简的子图是通用的面试里问怎么判断死锁答出这套思路就很到位了。2.2 化简规则从终点倒推找安全序列资源分配图的化简过程本质上是模拟如果某些进程能跑完并释放资源剩下的进程能不能接着跑。具体步骤是这样的在图中找一个既不阻塞又不是孤立的进程所谓不阻塞就是它现有的资源加上可分配的空闲资源够它继续执行并最终释放把这个进程的所有分配边和申请边都去掉相当于它运行完毕归还了资源重复这个过程如果最后能把所有边都消掉说明系统没有死锁如果剩下一个不可化简的子图那这个子图里的进程就是死锁进程。这里有个极易考也极易用错的结论单实例资源下资源分配图存在环就等价于死锁多实例资源下有环不一定死锁。为什么因为多实例资源里环上的某个进程可能从别的空闲实例拿到资源从而脱身。举个具体的例子资源 R 有两个实例进程 A 拿到了一个进程 B 在等 R表面上看起来 B 等 R、A 拿着 R但 R 还剩一个空闲实例B 完全可以直接拿空闲那个这个环就自动解开了。所以多实例情形必须用化简算法来判断不能只看有没有环。2.3 手画一个三人抢两类资源的例子走一遍设资源 A 有 2 个实例资源 B 有 1 个实例。进程 P1 持有 A 的一个实例同时申请 B进程 P2 持有 B唯一那个同时申请 A 的另一个实例。我们来画图P1 到 B 有申请边A 到 P1 有分配边P2 到 A 有申请边B 到 P2 有分配边。化简过程先看谁能跑完。P1 需要 B但 B 的唯一实例被 P2 拿着P1 阻塞。P2 需要 A 的一个实例A 还剩一个空闲的所以 P2 可以拿到那个空闲 A 实例跑完释放 B。B 一释放P1 就能拿到 B 跑完。整个图可化简说明没有死锁。但如果把 A 的实例数改成 1情况就变了P1 拿着唯一的 A等 BP2 拿着唯一的 B等 A。A 没有空闲实例B 也没有空闲实例两个进程都阻塞图不可化简死锁成立。就改了资源数量这一个参数结论完全反转。这个例子非常值得在脑子里过几遍因为它直观地告诉你多实例 和 单实例 在死锁判定上是两套逻辑别混用。考试里那种判断图中是否有死锁的题目十有八九在考这个陷阱。3. 四条破坏路子死锁预防的工程代价账3.1 互斥这条线动不了只能选择绕开先从最没得商量的条件说起。互斥是资源物理属性决定的你不可能让两台打印机同时打印同一份任务也不可能让两个线程同时写同一个临界区变量那叫数据竞争更糟。所以在死锁预防的四种思路里破坏互斥几乎不被采用——现实中能做到的是把互斥的粒度尽量缩小比如用读写锁代替互斥锁让多个读操作可以并行从而减少资源争抢的概率。注意这只是降低概率不是消除死锁。有一类特殊资源倒是可以虚拟化来避开互斥比如把独占式的打印机做成假脱机spooling多个进程把打印任务写进队列由一个后台进程串行输出这样进程之间就不存在对打印机的直接互斥了。这个思路的价值在于当资源可以被中间层代理时互斥就可以被转化为排队死锁的可能性随之消失。日志系统、消息队列基本都是这个套路。3.2 一次性申请全部资源破坏占有并等待的代价破坏占有并等待有两条路。第一条是要求进程在开始执行前一次性申请它整个生命周期里需要的所有资源全部拿到才开工第二条是允许它先释放已经持有的资源再去申请新的。第一条实现简单但资源利用率极差——你想想一个进程可能只在最后一秒才用到打印机却要在开头就把打印机锁住中间这段时间别人全用不了。第二条则要求程序能保存和恢复中间状态否则一释放资源上下文就丢了重启成本高。第二条思路在数据库事务里其实有现实对应事务在拿不到锁的时候可以选择回滚释放已持有的锁然后重试。这也是为什么很多框架会写tryLock(timeout)加退避重试的模板——超时拿不到就放弃已持有的锁、释放、等一会儿再整体重来。这种策略牺牲的是吞吐量换来的是不会长期僵持。我在写多线程代码时基本都遵循这个模式要么一次拿全要么拿不全就全放掉重试绝不允许拿着一个等另一个这种结构出现。3.3 允许抢占破坏不可剥夺的现实版本不可剥夺意味着资源只能被持有者主动释放。要破坏它就得引入某种强制回收机制进程 A 申请的资源被 B 占着时可以强行把 B 挂起、把资源转给 A。听起来暴力但它的变种在真实系统里非常常见。最典型的就是超时释放。数据库的行锁等待有超时时间超时后事务被回滚锁被释放等待方拿到锁继续。Java 的ReentrantLock提供tryLock(long timeout, TimeUnit unit)本质上就是给不可剥夺加了个时间上界。这种策略的代价是被抢占的一方必须能安全回滚否则会留下数据不一致。所以它只适用于那些可以重试、可以回滚的操作对于不可撤销的副作用比如已经发出的转账请求就要格外小心。提示超时时间不是越短越好。设得太短会导致大量事务频繁回滚重试吞吐量不升反降设得太长则僵持时间变长用户侧的感知就是卡顿。一般根据业务里事务的平均执行时间来定取 3 到 5 倍作为起点再压测调整。3.4 资源有序编号破坏循环等待最实用的一招四个条件里最容易人为破坏的是循环等待方法就是给所有资源统一编号规定进程必须按编号递增的顺序申请资源。为什么有效因为如果所有人都按 1、2、3 的顺序申请就不可能存在我拿着 3 我等 1这种情况——要申请 1 就必须在拿 3 之前申请可一旦你已经拿了 3就不可能回头申请 1。循环等待的形成条件被彻底掐断。这一招在工程里用得极多。数据库层面如果两个事务都要更新 A、B 两张表约定都按先 A 后 B的顺序更新就不会死锁分布式锁场景里如果一次要锁多个 key把所有 key 排序后按序加锁也是同一个思路。它的成本几乎为零只需要一次简单的排序或者一个全局约定。真正的难点不在于实现而在于坚持。项目里一旦有人图省事在某个分支里反过来加锁这个约定就废了。所以我见过比较靠谱的做法是把加锁顺序封装到一个统一的工具方法里业务代码不允许直接调用底层加锁 API从代码层面强制约束顺序。约定靠文档是守不住的靠接口约束才守得住。3.5 四种预防策略的代价对照破坏目标具体手段实现难度主要代价适用场景互斥虚拟化资源、缩小锁粒度中引入中间层开销打印、日志、消息投递占有并等待一次性申请全部 / 拿不全就全放低资源利用率低、可能饥饿单次任务明确资源清单不可剥夺超时释放、强制回滚中需要可回滚、重试成本数据库事务、多线程锁循环等待资源全局编号、按序申请低需要全项目约定一致关键路径上的并发热点这张表建议对照自己手上的系统过一遍。我个人经验是循环等待这一栏的低难度、低成本往往被低估很多人一上来就去搞超时重试其实先给资源定个序问题可能就消失一大半。4. 银行家算法安全序列怎么手算以及它为什么进不了生产4.1 安全状态、不安全状态与死锁的三层关系银行家算法的核心概念是安全状态。所谓系统处于安全状态是指存在一个进程执行顺序叫安全序列使得按这个顺序每个进程都能拿到它还需要的那部分资源并顺利跑完。只要存在至少一个这样的序列系统就是安全的如果不存在系统就处于不安全状态。这里必须把三层关系说清楚因为它们经常被搞混安全状态一定不会死锁不安全状态不一定死锁但有可能死锁死锁一定是不安全状态。换句话说不安全状态是死锁的必要不充分条件——它意味着如果运气不好、大家恰好同时申请资源就可能僵住但只要现在就有一个进程序能跑完当前就不会卡死。这就是银行家算法的思路在每次分配资源之前先假装分配出去然后判断分配之后系统是否还处于安全状态安全就真分不安全就把这次申请挂起。它属于避免而不是预防——预防是提前破坏条件避免是动态判断、该拒就拒。4.2 一个 5 进程 3 类资源的完整手算过程光看定义容易飘我们拿一组具体数据走一遍。假设系统有 A、B、C 三类资源总量分别是 10、5、7。当前时刻各进程的已分配量Allocation和最大需求量Max如下进程Allocation (A B C)Max (A B C)Need Max - AllocationP00 1 07 5 37 4 3P12 0 03 2 21 2 2P23 0 29 0 26 0 0P32 1 12 2 20 1 1P40 0 24 3 34 3 1先算可用资源 Available。总量减去已分配总量A 已分 023207剩 3B 已分 100102剩 3C 已分 002125剩 2。所以 Available (3, 3, 2)。现在找安全序列。遍历每个进程的 Need看谁能被当前 Available 满足P0 需要 (7,4,3)Available 是 (3,3,2)A 类不够跳过。P1 需要 (1,2,2)(3,3,2) 完全够P1 可以先跑。跑完释放它占的 (2,0,0)Available 变成 (5,3,2)。P3 需要 (0,1,1)够跑完释放 (2,1,1)Available 变成 (7,4,3)。P4 需要 (4,3,1)够跑完释放 (0,0,2)Available 变成 (7,4,5)。P0 需要 (7,4,3)够跑完释放 (0,1,0)Available 变成 (7,5,5)。P2 需要 (6,0,0)够跑完释放 (3,0,2)Available 变成 (10,5,7)回到初始总量。找到一个安全序列P1 → P3 → P4 → P0 → P2。既然存在安全序列当前系统是安全的。接着做一次请求判定。假设此刻 P1 发出请求 Request (1, 0, 2)。按银行家算法的检查顺序先看请求是否超过它声明的 Need——P1 的 Need 是 (1,2,2)请求 (1,0,2) 没超再看是否超过当前 Available——(1,0,2) 小于等于 (3,3,2)可以然后试探性分配Available 变成 (2,3,0)P1 的 Allocation 变成 (3,0,2)Need 变成 (0,2,0)。现在检查新状态是否安全P1 需要 (0,2,0)Available (2,3,0) 满足跑完释放 (3,0,2)Available 变成 (5,3,2)。P3 需要 (0,1,1)满足释放 (2,1,1)Available 变成 (7,4,3)。P4 需要 (4,3,1)满足释放 (0,0,2)Available 变成 (7,4,5)。P0 需要 (7,4,3)满足释放 (0,1,0)Available 变成 (7,5,5)。P2 需要 (6,0,0)满足跑完。存在安全序列 P1 → P3 → P4 → P0 → P2所以这次分配是安全的可以真正执行。整个过程的关键动作就是先假设分配、再检查安全性、安全才落实、不安全就回退并让申请方等待。算法本身不复杂难的是把它写成检查和回滚的代码一般考试考手算实践里几乎不手写。4.3 银行家算法进不了生产的三个理由第一个理由是必须预知最大需求。算法要求每个进程在开始前就声明它对每类资源的最大需求量这个假设在操作系统内核里勉强成立但在业务系统里根本不现实——一个请求进来会碰哪些数据、锁多少行往往要到执行过程中才知道。需求声明不准安全判定就是空谈。第二个理由是进程之间必须相互独立、申请数量固定。现实中进程之间是有依赖的、资源需求是动态变化的算法的前提假设就被打破了。第三个理由是开销。每次分配都要跑一遍安全性检查检查本身是 O(n²m) 量级的运算在高并发场景下为了保证一次分配正确而付出的计算代价可能比死锁本身造成的损失还大。所以银行家算法的正确打开方式是理解安全状态这个思想用它来判断某个时刻的分配是否冒险而不是把它照搬进代码。你在设计一个限流或者资源池方案时先预留、再分配、留出足够的余量保证剩余请求都能跑完这种思路本质上就是安全状态的直觉版本。5. 检测与恢复既然预防不好做那就等出事再救5.1 检测的时机与开销怎么选都不轻松死锁检测不像预防那样提前约束而是运行时周期性或者按需地检查系统里有没有出现死锁。检测的手段主要靠维护资源分配图并用化简算法判断或者对每类资源分别建等待图找环。开销主要来自检测频率检测太勤CPU 被白白占用检测太懒死锁发生到被发现之间隔很久用户侧的体感就是系统没响应。工程上的折中做法一般是设定阈值再触发检测正常情况下不检测一旦发现某个资源长时间没有被推进、或者 CPU 利用率异常低而请求队列很长就触发一次检测。数据库里的死锁检测器就是这个思路——InnoDB 内部维护一个锁等待图发现有环就立即选一个事务作为牺牲者回滚这个检测是由具体加锁动作触发的不需要周期性扫描开销被摊薄到了加锁操作本身。这种事件驱动 局部检测的模型比全局周期性扫描实用得多。原因也简单死锁的形成必然伴随着某个加锁请求无法满足从加锁这个动作切入检测天然只覆盖了可能出问题的局部复杂度大大降低。这个思路值得迁移到自己的系统设计中——不要做全量扫描做增量触发。5.2 恢复手段回滚、抢占、终止怎么选牺牲者检测出死锁之后就要恢复常见手段有几种。最简单粗暴的是终止所有死锁进程代价是前面的计算全白做温和一些的是每次终止一个进程然后重新检测直到死锁解除但重新检测本身有开销而且终止哪个进程是个决策难题。还有一种是资源抢占把某个进程持有的资源强行拿走交给别人但受伤的进程可能需要回滚到某个检查点才能继续这就要求系统支持回滚。选牺牲者的标准一般综合考虑几项进程已经运行了多久跑得越久的越不该动、已经算了多少产出越多的越不该动、还需要多少资源才能完成需求越多的越可以牺牲、以及它是不是批处理进程交互式进程的恢复成本更高。这其实和数据库死锁检测里的选择代价最小的事务回滚是完全一致的思路。从工程角度看恢复策略的核心约束是被牺牲的操作必须是可以撤销或者重试的。如果你的业务里有一个操作叫扣款那它就不能被随意回滚否则数据就错了。所以现实系统里的死锁恢复基本都是回滚事务 让应用层重试而不是让底层系统去抢占资源。这也解释了为什么很多中间件都在强调事务要幂等、要能安全重试——不是为了好看而是为了让死锁恢复有退路。6. 线上真卡住了怎么查从 jstack 到数据库死锁日志6.1 用 jstack 抓 Java 线程死锁输出到底怎么看理论讲了这么多落到最常用的一步Java 应用卡住了怎么确认是不是死锁。最直接的工具是jstack把目标进程的线程栈 dump 出来# 先拿到 Java 进程号 jps -l # 抓取线程栈输出到文件方便慢慢看 jstack -l pid thread_dump.log打开文件如果存在典型的死锁jstack会在末尾直接给出结论段形如Found one Java-level deadlock: Thread-1: waiting to lock monitor 0x000000076ad8f0 (object 0x000000076b2a30, a java.lang.Object), which is held by Thread-2 Thread-2: waiting to lock monitor 0x000000076b2a50 (object 0x000000076b2a40, a java.lang.Object), which is held by Thread-1 Java stack information for the threads listed above: Thread-1: at com.example.DeadlockDemo.methodB(DeadlockDemo.java:30) - waiting to lock 0x000000076b2a30 (a java.lang.Object) - locked 0x000000076b2a40 (a java.lang.Object)读这段输出的关键是两条信息waiting to lock说明它在等哪把锁locked说明它已经握着哪把锁。Thread-1 握着 0x...a40等 0x...a30Thread-2 握着 0x...a30等 0x...a40。两者互相等对方的锁闭环成立死锁确凿。-l参数会额外打印出Locked ownable synchronizers用于看清每个线程持有的重入锁和同步器排查复杂场景时这个信息非常有用。不过要提醒一点jstack报出来的死锁是对象监视器层面的也就是synchronized关键字或者Object.wait相关的锁。如果你用的是ReentrantLockjstack未必会主动帮你判定成Java-level deadlock它可能只是把parking to wait for之类的状态打印出来需要你人工去比对哪个线程在等哪个锁。这种情况有个额外的抓手是ThreadMXBean.findDeadlockedThreads()可以在代码里主动调用拿到死锁线程的 id 数组做监控告警。用jstack的时候顺手多抓几次 dump比如间隔 5 秒抓三份对比下来看哪些线程一直卡在同一个地方比单次 dump 的结论更可靠。6.2 数据库那条路死锁日志里的 LATEST DETECTED DEADLOCK数据库的死锁排查是另一套逻辑。以常用的 InnoDB 为例执行下面这条命令能看到最近一次死锁的详细信息SHOW ENGINE INNODB STATUS\G输出里找LATEST DETECTED DEADLOCK这一段它通常会包含三个部分死锁发生的时间、两个或多个事务各自的 SQL 语句、以及每个事务当前持有的锁和正在等待的锁。典型的输出片段大致是*** (1) TRANSACTION: TRANSACTION 123456, ACTIVE 5 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) UPDATE t_order SET status 2 WHERE id 100; *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table shop.t_order *** (2) TRANSACTION: TRANSACTION 123457, ACTIVE 3 sec starting index read UPDATE t_order SET status 1 WHERE id 200; *** (2) HOLDS THE LOCK(S):读这段日志的重点是找到WAITING FOR THIS LOCK TO BE GRANTED和HOLDS THE LOCK(S)的对应关系。事务 1 在等某条记录上的锁而这个锁刚好被事务 2 持有反过来事务 2 又在等事务 1 持有的锁两者交织于是数据库选择回滚其中一个一般回滚代价小的那个比如改动行数少的让另一个继续。看日志的时候不要只看单条 SQL要把它放回事务的整个执行顺序里看——很多数据库死锁的根因不是 SQL 本身写得不对而是两个事务访问多张表、多个行的顺序不一致正好形成了交叉等待。6.3 一套可复用的排查顺序把上面的工具串起来我一般按这个顺序走先确认是卡住还是死了。CPU 很低、线程状态长期停在 WAITING/BLOCKED、日志不再增长满足这些才往死锁方向想。如果是 CPU 打满那是别的问题。拿到现场别急着重启。Java 抓jstack数据库导SHOW ENGINE INNODB STATUS把现场留存下来。重启虽然能立刻恢复服务但现场丢了下次同样的死锁还会再来一遍。找闭环。Java 里看哪个线程持锁等锁形成环数据库里看哪个事务持锁等锁形成环。找到环就找到了死锁。定位根因。环上的两段代码/两条 SQL回头看它们进入锁的顺序。绝大多数情况是顺序不一致少数是锁太大比如没走索引导致锁了整张表。修掉顺序而不是加长超时。加超时只是让死锁更快暴露出来不解决根本问题。正确的修法是把相关资源的访问顺序统一或者缩小事务范围、让事务尽快提交。这里有个特别容易被忽略的坑数据库死锁很多时候源于没有命中索引。如果UPDATE ... WHERE的条件字段没有索引InnoDB 可能会扫描并锁定大量行甚至间隙锁导致两个本来互不相干的更新产生了交叉等待。排查时如果发现两条 SQL 看起来访问的行根本不一样那就要去EXPLAIN一下看看是不是因为索引缺失导致锁的范围被放大了。这个坑在测试环境很难复现往往一上量就冒出来。7. 几个从来没人明说但一定会踩的细节先说一个反直觉的结论很多事情被叫做死锁其实根本不是死锁。比如线程池队列溢出后任务被拒绝、连接池耗尽导致请求排队、某个下游接口响应极慢导致上游线程全被挂住——这些现象在外人看来都是系统卡死但它们的机制完全不同修法也完全不同。连接池耗尽要做的是扩容或者缩短超时跟死锁的四个必要条件一点关系都没有。所以排查的第一步永远是确认机制而不是套用死锁的解决方案。我见过不止一次有人拿着死锁的排查思路去查连接池问题白白绕了好几个小时的弯路。第二点是加锁顺序的约定必须写进代码结构里不能只写在文档里。前面提过一次这里再强调因为它是最常见、也最容易复发的一类死锁。我自己的做法是把所有需要成对加锁的场景都收口到一个统一的方法里方法内部先把资源标识排序再依次加锁业务层只调用这个方法。这样即使有人想乱序也做不到因为压根没有暴露底层加锁的口子。文档会过时、人会忘记但接口一旦定型就很难被绕开。第三点是排查死锁时不要只抓一次现场。单次jstack有可能刚好抓在锁正在传递的瞬间看起来像是有人持锁在等其实下一秒就解开了。多抓几次、间隔着抓对比下来看哪些线程一直停在原地结论才站得住。数据库那边同理SHOW ENGINE INNODB STATUS只保留最近一次死锁如果死锁发生得频繁最好开一个定时任务把这段输出定期落盘形成一份可以回溯的死锁历史。最后分享一个我在自己代码里坚持的小习惯任何涉及多个锁或者多次数据库写操作的逻辑我都会先在心里问一句如果另一个线程同时在做这件事会不会和我交叉等待。如果答案是有可能那就要么调整顺序、要么把操作合并成一个原子的、要么加超时和退避。这个思考习惯花不了几秒钟但能省下后面几小时的排查时间。死锁这东西理论看起来离日常很远可真踩上去的时候那套四个必要条件、找环、定序的思路就是最快让你从系统卡住了走到知道该改哪一行的那条路。
返回列表