死锁原理与排查实战:从资源分配图到数据库事务
1. 死锁不是课本上的判断题,它是凌晨三点的那通电话
死锁这个词,第一次看到是在教材里那张画着圆圈和方块的图上,当时觉得挺抽象;真正让我记住它,是某次线上事故——一个核心接口的响应时间从 80ms 涨到 30 秒,监控上线程数一路爬升,日志里没有任何异常堆栈,服务既没崩也没报错,就是不动了。最后拉线程栈出来,两个线程各自持着一把锁、互相等对方那把,一行Found one Java-level deadlock把问题钉死。从那一刻起,我对“死锁”的理解就从“考试会考的四个条件”变成了“必须能在五分钟内定位的故障类型”。
这一章要聊的东西,本质上是一套资源争用的失控模型:多个执行单元(进程、线程、事务、协程都算)各自占着一部分资源不放,同时又在等别人手里的资源,形成一个谁也动不了的闭环。它解决的问题很具体——当你写并发程序、设计数据库事务、拆分微服务、甚至做分布式任务调度时,只要存在“多个参与者 + 有限资源 + 独占使用”这三件事,死锁就有机会冒头。而它带来的影响往往是静默的:CPU 不高、内存不涨、日志不报错,但业务全线卡住,比崩溃更难查。
这篇内容适合三类人看。第一类是正在啃操作系统课的,教材里那些“安全序列”“资源分配图化简”的推导过程,我会用能手工演算的完整例子补上,让你不光会背定义。第二类是写业务代码的,Java、Go、SQL 里那些真实会踩的死锁场景,我会给出可以直接对照的代码和修复方案。第三类是负责线上稳定的,排查命令、等待视图、超时参数怎么配,后面有专门的实录。不管你现在基础在哪一层,看完至少能做到:看到一段并发代码,能判断它有没有死锁风险;线上卡住了,知道从哪儿下手捞证据。
1.1 一句定义里藏着的三个关键点
教材对死锁的定义通常是这么说的:一组进程中的每一个都在等待一个事件,而这个事件只能由这组进程中的另一个进程触发。这句话短,但每一段都有信息量,拆开看才有用。
第一个关键点是“一组”。死锁从来不是单个执行单元的问题,一个人拿着锁自己等自己,那叫递归锁或者自旋,不叫死锁。必须是两个或以上的参与者互相咬住。这个特征直接决定了排查思路:你要找的是“关系”,不是“状态”。光看某一个线程在干什么没用,得看它等的是谁,那个“谁”又在等谁。
第二个关键点是“等待”。等待意味着有资源被占着没释放,同时有需求没被满足。这里要区分两个概念:占有的资源和想要的资源。死锁的现场一定是“我手里有 A,我想要 B,而 B 在你手里,你手里的 A 我又需要”。排查的时候,本质就是把这张“持有—等待”的关系表画出来,看能不能连成一个环。
第三个关键点是“只能由这组进程触发”。这句限定了范围:如果等待的事件来自组外,比如等一个网络响应、等用户输入,那属于普通的阻塞,不是死锁。这个区分在工程上很重要,因为它们的处理方式完全不同——阻塞等超时就行,死锁等再久也不会自己好。
顺便把三个容易混的概念摆在一起,很多人面试栽在这上面:
| 概念 | 核心特征 | 会不会自己恢复 | 典型现象 |
|---|---|---|---|
| 死锁 | 循环等待,互相持有 | 不会,必须外部干预 | 线程全部挂起,接口全超时 |
| 饥饿 | 某个执行单元长期抢不到资源 | 可能,取决于调度策略 | 低优先级任务一直排队 |
| 活锁 | 都在动,但一直在让,没有进展 | 可能,需要引入随机性 | 两个事务不停回滚重试 |
饥饿和活锁的共同点是系统还在运转,死锁是彻底停摆。这个差别决定了它们的排查手段不一样:死锁看等待环,饥饿看调度公平性,活锁看重试策略里有没有退避和随机。
1.2 四个必要条件,别背,要理解它们为什么是“必要”的
互斥、占有并等待、非抢占、循环等待——这四个条件背起来很顺,但真正用得上的是理解它们的逻辑关系。它们是必要条件,意思是四个同时成立才可能死锁,缺一个就绝无可能。所以所有的预防手段,本质上都是想办法破坏其中至少一个。
互斥说的是资源一次只能被一个执行单元用。这个条件最难破坏,因为很多资源天然就是独占的:写文件、改同一行数据、操作同一个硬件。教材里举的打印机假脱机(spooling)例子其实是在说:把独占资源改造成共享的中间层,就能绕开互斥。工程上的对应做法是“串行化改并行化”,比如把对同一份数据的写操作收敛到一个队列里由单线程处理,也就是常说的单写者模型。
占有并等待说的是拿着一个资源的同时去申请另一个。这个条件是最容易在代码里被发现、也最容易破坏的。你只要规定“要么一次申请全部,要么一个都不申请”,它就没了。代价是资源利用率下降,因为申请的时候可能有些资源还闲着。
非抢占说的是别人占着的资源你不能硬拿。破坏它的办法是允许抢占:抢不到就把自己手里的也放了,等会儿重来。数据库事务的死锁检测后回滚一个事务,本质就是抢占式恢复。
循环等待说的是存在一个等待闭环。破坏它最经典的手段是资源有序分配:给所有资源编号,规定只能按递增顺序申请。这样等待关系就变成了单向的,环不可能形成。这一招在工程里用得最多,后面会详细讲它在代码里长什么样。
注意:这四个条件是“同时成立才可能死锁”,不是“同时成立就一定死锁”。很多初学者看到代码里存在循环等待的可能就断言会死锁,其实还要看实际执行时序。反过来说,只要有一个条件被破坏,就可以放心了。
2. 把僵局画出来:资源分配图怎么读、怎么化简
理论部分最实用的工具就是资源分配图。它的价值不在于画得好看,而在于给你一个可执行的判断流程:拿到现场信息,能不能机械地推出“有没有死锁、卡在谁身上”。
图的元素很少。圆圈表示进程(或者线程、事务),方框表示资源类型,方框里的圆点表示这个资源有几个实例。箭头有两种:从资源指向进程的,叫分配边,意思是“这个实例已经分给它了”;从进程指向资源的,叫请求边,意思是“它正在等这个资源”。
举个具体的:系统里有资源 R1 两个实例、R2 一个实例。进程 P1 占着 R1 的一个实例,同时在等 R2;P2 占着 R1 的另一个实例和 R2,同时在等……这时候画出来,箭头就会绕成一个圈。看到圈,就要警惕了。
2.1 化简法:一步一步把能跑完的进程划掉
图的判断有个机械流程,叫化简。思路特别朴素:如果一个进程当前请求的资源,加上系统剩余的资源,足够满足它,那它就能跑完,跑完就会把手里的资源全还回来。把这个进程的边全擦掉,资源加回池子里,重复这个过程。最后如果所有进程都能被擦掉,说明无死锁;如果还剩下擦不掉的,剩下的就是死锁参与者。
手工做一遍。假设资源 A 有 2 个实例,B 有 1 个,C 有 1 个。当前分配情况是:
- P1 持有 A 的一个实例,请求 B
- P2 持有 B,请求 C
- P3 持有 C 和 A 的另一个实例,请求 A(A 已经分完了)
剩余可用资源是 A:0、B:0、C:0。第一步看谁能满足——P1 要 B,没有;P2 要 C,没有;P3 要 A,也没有。一个都推不动,化简卡死,全部都是死锁参与者。这个例子就是典型的环形等待。
换个例子:同样的资源,P1 持有 A 请求 B,P2 持有 B 请求 C,P3 持有 C 请求 A,但是 A 还有一个实例闲着。那么 P3 请求的 A 能被满足,P3 能跑完,跑完释放 C 和 A,这时候 P2 要的 C 有了,P2 跑完释放 B,P1 也能跑完。整个图能化简干净,无死锁。你看,区别只在于“有没有多余的实例能打破链条”。
这个流程听着简单,但它给了排查一个非常重要的启发:死锁的关键往往不是环本身,而是环上有没有一个“额外的出口”。工程上对应的就是“留冗余”——连接池别配满、锁别设计成必须全部持有、队列留出缓冲。多出来的那一点余量,就是化简时打破环的那一步。
2.2 单实例和多实例,判断结论完全不同
这里有个坑,很多资料讲得不清楚:有环不等于死锁,要分情况。
如果每种资源只有一个实例,那么图里有环就是死锁,有死锁就一定有环,两者等价。这个结论很干脆。
但如果资源有多个实例,环只是死锁的必要条件,不是充分条件。一个环上的进程可能通过环外的资源被满足,从而打破僵局。上面第二个例子就是这种情况。所以多实例场景下,光看有没有环不够,必须做化简,或者用后面讲的检测算法跑一遍。
这个区别在实际系统里很常见。数据库的锁资源就是多实例的——同一张表的不同行可以同时被不同事务锁住。所以数据库死锁检测不能简单找环,是要维护一张等待图然后跑环检测,但环上如果有事务能被其他资源满足……实际上数据库的做法更粗暴:等图里出现环就判定死锁,直接回滚一个。因为事务持锁是持续性的,靠“环外资源”自然解除的概率太低,不如快速失败重试。
3. 预防:把四个条件逐个拆掉,代价要提前算清
预防的思路是在設計阶段就让死锁不可能发生,代价通常是资源利用率或者灵活性。这一节按四个条件分别讲手段,重点讲工程里真正用得上的那些。
3.1 互斥与占有等待:最容易下刀的两个地方
破坏互斥,思路是把独占资源变成共享的。典型的做法有三类:一是加中间层做串行化,比如所有写请求进一个队列,由单线程消费;二是把资源做成分片,不同分片互不干扰,冲突概率降到很低;三是用读写分离,读操作不参与互斥。这三类做法的共同点是“用架构手段减少必须互斥的范围”,而不是真的消灭互斥。
破坏占有等待,有两个方向。第一个方向是一次性申请全部资源。进程在开始前就把需要的所有资源一次申请到位,中途不再申请。这个方案在理论上完美,在实际里很难,因为你往往不知道后面需要什么,而且资源全被占着不用,利用率会崩。第二个方向是申请新资源前先释放手里的。这个方案听着合理,但会带来一个新问题:释放之后再重新申请,可能拿到的是被改过的数据,需要保证可重入或者有版本校验,否则会出现数据一致性问题。
实操心得:破坏占有等待最现实的用法是“批量操作前先排序、一次性申请”。比如批量更新一千条记录,不要一条一条加锁更新,而是按主键排序后在一个事务里一次做完。这样加锁次数少了,持有时间短了,冲突窗口也小了。
3.2 抢占与有序分配:工程落地最狠的两招
破坏非抢占,意思是允许把资源从持有者手里拿走。这在操作系统层面叫抢占式恢复,在应用层的表现是:抢不到锁就放弃已持有的锁,等一段时间重试。数据库死锁检测后回滚事务,就是标准的抢占式处理。用代码实现的话,就是所有加锁操作都带超时,超时后释放已持有的锁,整体重试。
破坏循环等待,最经典的手段是资源有序分配:给所有资源一个全局唯一的编号,规定只能按编号从小到大的顺序申请。这条规则一立,等待关系就变成了单向链条,环永远形不成。
这个规则在代码里长什么样?假设有两个账户对象,转账时要锁两个账户。如果按“先锁转出方、再锁转入方”,A 转 B 和 B 转 A 并发时就会形成环。如果改成“按账户 ID 大小排序,先锁小的”,那不管谁转谁,加锁顺序都一致,环不可能出现。
// 有风险的写法:加锁顺序依赖参数顺序 public void transfer(Account from, Account to, long amount) { synchronized (from) { synchronized (to) { // 扣款加款 } } } // 安全写法:按 ID 排序后加锁 public void transfer(Account from, Account to, long amount) { Account first = from.getId() < to.getId() ? from : to; Account second = from.getId() < to.getId() ? to : from; synchronized (first) { synchronized (second) { // 扣款加款 } } }这个改动只多了一行三元表达式,但把一整类死锁彻底消除了。代价是“加锁顺序不再直观”,新人接手时容易写回原来的样子,所以一定要在注释里写清楚为什么这么排。
同样的思路在批量更新里也成立。比如两条 SQL 分别更新 id 为 1 和 2 的记录,如果两个业务并发执行,一个先更新 1 再更新 2,另一个先更新 2 再更新 1,数据库里就会形成行锁的环。统一按 id 升序执行,问题消失。这条规则在数据访问层做成强制约束,收益非常大。
4. 避免:银行家算法的完整推演,以及它为什么进不了生产
预防是静态的,避免是动态的:每次分配资源前先算一下,分配之后系统还安不安全。安全的意思是,存在一个执行顺序,能让所有进程都顺利完成。这个顺序叫安全序列。
4.1 数据结构与两道检查
银行家算法的数据结构有四个:Available(系统剩余)、Max(每个进程的最大需求)、Allocation(已分配)、Need(还需要的,等于 Max 减 Allocation)。
每次进程提出请求 Request,要做三道检查:
- Request 是否超过自己的 Need。超了就是非法请求,直接报错。
- Request 是否超过系统当前的 Available。超了就得等,因为资源不够。
- 试探性地把资源分配给它,然后跑一遍安全性检查。如果检查通过,真分配;不通过,撤销试探,让进程等待。
第三步是核心,也是很多人只看结论不懂过程的地方。下面用一个经典例子完整推演一遍,你跟着算一遍就明白了。
系统有三类资源 A、B、C,Available = (3, 3, 2),五个进程的分配情况如下:
| 进程 | Max (A B C) | Allocation (A B C) | Need (A B C) |
|---|---|---|---|
| P0 | 7 5 3 | 0 1 0 | 7 4 3 |
| P1 | 3 2 2 | 2 0 0 | 1 2 2 |
| P2 | 9 0 2 | 3 0 2 | 6 0 0 |
| P3 | 2 2 2 | 2 1 1 | 0 1 1 |
| P4 | 4 3 3 | 0 0 2 | 4 3 1 |
现在跑安全性检查。设工作向量 Work = Available = (3, 3, 2),再准备一个已完成标记表。
第一轮找 Need 小于等于 Work 的进程。P1 的 Need 是 (1,2,2),逐项比较:1≤3、2≤3、2≤2,都满足,选它。假设 P1 跑完,归还全部资源,Work 变成 (3,3,2) + (2,0,0) = (5,3,2)。
第二轮,在剩下的里面找。P3 的 Need 是 (0,1,1),0≤5、1≤3、1≤2,满足。P3 跑完,Work = (5,3,2) + (2,1,1) = (7,4,3)。
第三轮,P4 的 Need 是 (4,3,1),4≤7、3≤4、1≤3,满足。Work = (7,4,3) + (0,0,2) = (7,4,5)。
第四轮,P2 的 Need 是 (6,0,0),满足。Work = (7,4,5) + (3,0,2) = (10,4,7)。
第五轮,P0 的 Need 是 (7,4,3),7≤10、4≤4、3≤7,满足。全部完成。
所以安全序列是 <P1, P3, P4, P2, P0>,系统处于安全状态。注意安全序列可能不止一个,找到一个就够了。
4.2 一次真实请求的试探过程
接着上面的状态,P1 提出请求 Request = (1, 0, 2)。走三道检查。
第一道,Request ≤ Need?(1,0,2) ≤ (1,2,2),成立。第二道,Request ≤ Available?(1,0,2) ≤ (3,3,2),成立。第三道,试探分配:
Available 变成 (3,3,2) - (1,0,2) = (2,3,0);P1 的 Allocation 变成 (3,0,2),Need 变成 (0,2,0)。
现在跑安全性检查。Work = (2,3,0)。P1 的 Need (0,2,0) 满足,跑完后 Work = (5,3,2);P3 的 (0,1,1) 满足,Work = (7,4,3);P4 的 (4,3,1) 满足,Work = (7,4,5);P0 的 (7,4,3) 满足,Work = (7,5,5);P2 的 (6,0,0) 满足。全部能跑完,安全,所以真正分配。
再看一个会被拒绝的例子。假设 P4 提出 Request = (3, 3, 0)。第一道检查:(3,3,0) ≤ Need (4,3,1),通过。第二道:≤ Available (3,3,2),通过。第三道试探:Available 变成 (0,0,2),P4 的 Allocation 变成 (3,3,2),Need 变成 (1,0,1)。
跑安全性检查,Work = (0,0,2)。看谁能满足:P1 的 Need (1,2,2),第二项 2 > 0,不行;P3 的 (0,1,1),第二项 1 > 0,不行;P4 的 (1,0,1),第一项 1 > 0,不行;P0、P2 更不行。一个都推不动,说明试探后的状态不安全。于是撤销试探,让 P4 等待。
这个过程体现了避免策略的本质:不是等死锁发生了再处理,而是在资源分配的那一刻就拒绝掉通向死锁的路径。理论上很优雅。
4.3 它为什么进不了生产环境
银行家算法在教材里是重点,在生产里基本见不到,原因很实在。
第一,它要求提前知道每个进程的最大资源需求。这个在操作系统调度里还能勉强假设,在业务系统里完全做不到——你没法预知一个请求会锁多少行、占多少内存。
第二,它假设进程之间相互独立、资源数量固定、进程不会中途退出。现实里进程会崩、会超时、会无限重试,任何一个假设破了,算出来的安全性就没有意义。
第三,计算开销。每来一次资源请求都要跑一遍 O(m×n) 的检查,高频场景下这个开销直接吃掉性能预算。而且检查过程中要保持全局状态的一致性,本身又要加锁,反而制造新的争用。
第四,也是最关键的,它太保守。为了理论上的绝对安全,会拒绝大量实际无害的请求,导致资源利用率低下、请求排队。工程上宁可用“允许死锁发生 + 快速检测 + 回滚重试”的乐观策略,也不愿意承担这种保守带来的吞吐损失。
所以你在实际系统里看到的,几乎都是“检测 + 恢复”路线,而不是“避免”路线。银行家算法的价值在于帮你理解“安全状态”这个概念,以及在做容量规划时留足冗余——这其实就是一种朴素的避免思想。
5. 检测与恢复:已经卡死了,怎么收拾残局
现实系统大多走这条路:先让死锁有可能发生,靠检测机制发现,然后用最小的代价恢复。这个策略的前提是死锁概率低、检测成本可控、恢复代价可接受。
5.1 检测手段:等待图、超时、心跳
等待图是最正统的做法。维护一张“谁在等谁”的有向图,定期跑环检测。单实例场景下,图里一个环就是一次死锁;多实例场景下,要跑更复杂的算法,实操中往往简化成只看锁的等待关系。
超时检测是最简单粗暴的。任何一次加锁都设一个超时时间,超了就认为可能死锁,主动放弃并重试。这个方案的问题是会误判——可能只是对方慢,不是死锁。所以超时时间要设得合理,太短误判率高,太长恢复慢。数据库里的innodb_lock_wait_timeout就是这类参数,默认 50 秒,很多团队会调到 5 到 10 秒,就是为了让卡住的事务快速失败。
心跳与健康检查属于应用层的兜底。定期探测关键路径的响应时间,超过阈值就触发线程栈采集或者事务回滚。这个方案不精确,但覆盖面广,能兜住那些检测机制没覆盖到的场景。
检测频率是个权衡。检测太频繁,开销大;太稀疏,死锁持续时间长。多数系统的做法是“按需检测 + 定期兜底”组合:加锁超时立刻触发一次检测,同时每隔一段时间跑一次全量扫描。
5.2 恢复策略:杀进程还是抢资源
发现死锁之后,两条路。
终止进程。可以全部终止,简单但代价大;也可以逐个终止,每杀一个重新检测一次,直到死锁解除。逐个终止的关键是“选谁杀”,常见的判据是:优先级低的先杀、已经运行时间短的回滚代价小、持有资源少的先杀、还剩多少工作量的预估。实际系统里往往用综合评分。
资源抢占。把资源从某个进程手里硬拿过来给另一个。这要做三件事:选一个牺牲者、回滚它的状态到某个安全点、防止它被饿死(同样的进程不能总被选中)。回滚在数据库里很自然,事务本来就有 undo log;在普通程序里就很难,你没法把一个函数执行到一半的状态回退。
注意:抢占式恢复最大的隐患是饥饿。如果选择算法总是挑同一个进程开刀,它可能永远完不成。解决办法是把这个进程的“被抢占次数”计入代价函数,被抢得越多,下次越不容易被选中。
6. 真实代码里的死锁现场,三种最典型的形态
理论说完了,进入最有价值的部分。下面这三种形态,覆盖了我见过的大部分死锁。
6.1 加锁顺序不一致:最经典也最容易犯
两个方法,各自持有两把锁,顺序相反,就是死锁的教科书现场。
public class OrderService { private final Object lockA = new Object(); private final Object lockB = new Object(); public void methodOne() { synchronized (lockA) { try { Thread.sleep(50); } catch (InterruptedException e) { } synchronized (lockB) { System.out.println("one done"); } } } public void methodTwo() { synchronized (lockB) { try { Thread.sleep(50); } catch (InterruptedException e) { } synchronized (lockA) { System.out.println("two done"); } } } }两个线程分别调这两个方法,各抢到第一把锁,然后互相等第二把,卡死。用jstack抓线程栈,会直接打印出Found one Java-level deadlock,并且把两个线程各自持有什么、等什么列得清清楚楚。
修复方式有四种,按推荐程度排:
- 统一加锁顺序。给锁对象编号,永远按编号递增加锁。这是最彻底的做法。
- 合并成一把锁。如果两个临界区总是要一起操作,不如用一把大锁,简单可靠,代价是并发度下降。
- 用带超时的尝试锁。
ReentrantLock.tryLock(timeout),超时就放弃已持有的锁并重试。适合无法统一顺序的场景。 - 缩小锁范围。如果两个操作其实不需要同时持有两把锁,把它们拆开,先算完再分别更新。
// 方案三的实现示意 public boolean transfer(Lock first, Lock second) throws InterruptedException { while (true) { if (first.tryLock(100, TimeUnit.MILLISECONDS)) { try { if (second.tryLock(100, TimeUnit.MILLISECONDS)) { try { // 业务逻辑 return true; } finally { second.unlock(); } } } finally { first.unlock(); } } // 随机退避,避免活锁 Thread.sleep(ThreadLocalRandom.current().nextInt(10, 50)); } }这段代码有个细节要注意:tryLock失败后必须释放第一把锁再重试,否则第一把锁一直被占着,别人也进不来,虽然不死锁但会造成长时间阻塞。另外重试要加随机退避,不然两个线程可能同步重试、同步失败,形成活锁。
6.2 数据库事务的交叉更新:行锁形成的环
数据库死锁比线程死锁更常见,因为它藏得更深。两个事务按相反顺序更新同一批行,就会形成行锁的等待环。
-- 会话一 BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT; -- 会话二 BEGIN; UPDATE account SET balance = balance + 100 WHERE id = 2; UPDATE account SET balance = balance - 100 WHERE id = 1; COMMIT;两个事务各拿到一行锁,然后互相等另一行,数据库的死锁检测器发现环之后,会主动回滚其中一个事务,报出死锁错误。这个报错是正常的、预期的,业务代码必须能处理,也就是捕获后重试。
修复重点不是“避免报错”,而是“降低发生概率 + 保证重试安全”:
- 所有涉及多行的写操作,统一按主键升序执行。这是最简单也最有效的一条。
- 缩短事务。事务里不要放远程调用、不要等用户输入、不要做大批量计算。事务越长,持锁时间越长,冲突窗口越大。
- 合理使用索引。没有索引的更新会锁全表或者大量行,冲突概率飙升。另外要注意间隙锁,范围更新在可重复读隔离级别下会锁住间隙,两个范围更新即使不重叠也可能互相阻塞。
- 降低隔离级别。读已提交相比可重复读少了间隙锁,死锁概率明显下降,但要确认业务能接受。
- 调整锁等待超时。
innodb_lock_wait_timeout调小,让卡住的事务快点失败,避免雪崩。
6.3 隐式等待与线程池:不明显的环
第三种形态最隐蔽:死锁不是显式加锁造成的,而是资源池耗尽造成的。
典型场景:一个线程池,任务 A 在执行过程中把子任务提交给同一个线程池,然后等子任务的结果。如果线程池满了,所有线程都在等子任务,而子任务在队列里排不上,就卡死了。这时没有任何“锁”,但等待关系构成了环。
// 危险写法:父任务等子任务,共用一个池 ExecutorService pool = Executors.newFixedThreadPool(4); pool.submit(() -> { Future<String> f = pool.submit(() -> "sub result"); return f.get(); // 如果池里 4 个线程都在这么干,直接卡死 });修复思路是隔离资源池:父子任务用不同的池,或者给这种“任务内部再提交”的场景单独配一个池。另一种思路是改用 ForkJoinPool,它支持工作窃取,能在等待时让出线程去执行别的任务,但这个机制有前提,不能滥用。
同类问题还有:连接池耗尽、信号量耗尽、读写锁的写者饥饿。它们的共同特征是“持有资源的同时等待资源”,只要这个模式存在,就有可能形成环。
7. 排查实录:从告警到定位,五分钟内做完
排查死锁有一套固定流程,熟练之后很快。核心原则是:先取证,再动手,别急着重启。重启会丢掉现场,同样的死锁下次还会来。
7.1 Java 应用:线程栈是第一手证据
第一步,找到进程号,导出线程栈。
# 找到 Java 进程 jps -l # 导出线程栈,-l 会额外打印锁信息 jstack -l 12345 > /tmp/thread_dump.txt第二步,搜关键字。
grep -n -A 40 "Found one Java-level deadlock" /tmp/thread_dump.txt grep -n -B 2 -A 20 "BLOCKED" /tmp/thread_dump.txtjstack的输出里,死锁会明确标出“哪个线程持有哪个锁、等待哪个锁”,直接就能看到环。如果没有找到Found one,但有大量线程处于BLOCKED状态,那就看它们等的是同一个监视器,顺着waiting to lock找到持有者。
第三步,结合业务代码定位。线程栈里会打印类名、方法名、行号,直接对到代码里,看那段加锁逻辑有没有顺序问题。
工具方面,jconsole和jvisualvm都有“检测死锁”按钮,点点就能看,适合本地复现。生产环境还是用命令行,别指望图形界面。
7.2 数据库:等待视图与死锁日志
MySQL 的排查路径是这样的:
-- 查看最近一次死锁的详细信息 SHOW ENGINE INNODB STATUS\G输出的LATEST DETECTED DEADLOCK段落会列出两个事务分别执行了什么 SQL、持有哪些锁、在等什么锁,非常详细。这一段是整个排查里最有价值的信息,建议每次出现死锁都存下来。
-- 实时看锁等待关系 SELECT * FROM performance_schema.data_lock_waits; SELECT * FROM performance_schema.data_locks;第一张表告诉你“谁在等谁”,第二张表告诉你“当前持有哪些锁”。两张表 join 一下,等待链就出来了。
PostgreSQL 的写法不一样:
SELECT pid, wait_event_type, wait_event, pg_blocking_pids(pid) AS blocked_by, query FROM pg_stat_activity WHERE wait_event_type = 'Lock';pg_blocking_pids直接给出阻塞它的进程号,顺着查就能找到源头。配上pg_terminate_backend(pid)可以快速解围,但记得先看清楚在杀谁。
提示:终止会话是应急手段,不是解决方案。杀之前先看看那个事务在干什么,有没有可能已经写了一半,杀掉会不会造成数据不一致或者长事务回滚引发的连锁问题。
7.3 通用排查流程,跨语言跨中间件都能套
不管什么技术栈,流程可以固定成五步:
| 步骤 | 动作 | 目的 |
|---|---|---|
| 一 | 确认现象:是全部卡住还是部分慢 | 区分死锁和普通性能问题 |
| 二 | 取样:线程栈、事务列表、锁视图 | 拿到等待关系的原始数据 |
| 三 | 找环:顺着“持有—等待”连起来 | 确认死锁并找出参与者 |
| 四 | 定位代码:对到具体方法和 SQL | 找出加锁顺序问题 |
| 五 | 最小代价解围:回滚事务或杀线程 | 恢复服务,保留现场 |
第五步要克制。能回滚一个事务解决的,不要重启整个服务;能通过超时自动失败的,不要手动介入。每次处理完,把现场信息归档,后面做根因分析用。
8. 常见问题速查与踩过的坑
把高频问题和处理方式整理成表,出问题的时候直接对号入座。
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 应用无响应,CPU 不高 | 线程死锁 | 抓线程栈,找 BLOCKED 环 |
| 接口偶发超时,重试后正常 | 锁等待超时 | 查慢 SQL、看事务持锁时长 |
| 数据库报死锁错误 | 事务交叉更新 | 统一更新顺序、缩短事务、加重试 |
| 线程池任务堆积但不报错 | 池内任务互相等待 | 拆分线程池、改异步编排 |
| 消息消费卡住 | 消费线程持有锁等下游 | 加超时、改补偿机制 |
| 服务重启后恢复,几天后再现 | 未找到根因 | 保留现场,做等待图分析 |
几个实战里总结出来的坑,书上不会写:
第一个坑:以为加了超时就万事大吉。超时只能让失败变快,不能防止数据不一致。如果一段逻辑在两个资源上操作,超时后只释放了其中一部分,可能留下中间状态。所以超时重试必须配合幂等设计。
第二个坑:死锁日志被日志框架吞了。数据库返回的死锁错误是正常响应,不是异常崩溃,很多框架会把它包装成业务异常往上抛,最后被全局异常处理器转成 500。结果就是只知道失败,不知道是死锁。建议在数据访问层统一捕获这类错误,打上明确的标记和重试次数。
第三个坑:只修了一个方向,另一个方向还在。修复死锁往往需要改多处加锁顺序,改了一处以为完事,另一处还留着旧逻辑。建议在代码评审里把“多资源加锁必须有序”作为检查项,甚至写成静态检查规则。
第四个坑:把死锁和慢查询搞混。两者表现都是慢,但慢查询会随数据量变化,死锁是稳定卡住。区分方法是看等待时长分布:如果是固定值附近(比如都卡在超时时间),多半是死锁或者锁等待;如果是长尾分布,多半是慢查询。
第五个坑:忽略分布式场景下的“死锁”。跨服务的调用链里也可能形成环:A 调 B 的时候占着资源,B 反过来调 A,而 A 的资源没释放。这类问题不会出现在任何本地死锁检测里,只能靠调用链追踪和超时控制。设计上的原则是避免双向依赖,如果实在避免不了,至少在调用链路上设置超时和熔断。
最后分享一个我自己的习惯:每次设计涉及多个资源的操作时,先在纸上把“资源、加锁顺序、释放时机”三列写出来,画一遍箭头。如果箭头能连成环,立刻改设计,不要指望测试能测出来——并发问题在测试环境复现的概率极低,等到线上出现,排查成本是设计阶段改的几十倍。这个习惯帮我省掉了很多次半夜被叫起来的机会,比任何检测工具都管用。