题干与适用场景
转账服务偶发卡死:线程 A 持有账户 42 的锁并等待账户 84,线程 B 持有账户 84 的锁并等待账户 42。两个线程都不释放已有锁,请判断是否死锁,解释四个必要条件,并设计预防、检测、恢复与验证方案。
这是一道通用软件工程与操作系统基础题,适用于后端、系统、基础设施、SRE 以及需要编写并发代码的岗位。2026 年中英文公开面试资料仍单独考察死锁定义、四个必要条件和处理策略。本文不声称特定公司归属,也不使用无法核验的题目频率。
场景中的账户 42 和 84 是虚构标识。核心问题不是背出四个名词,而是区分“等待很久”和“形成无法自行解除的等待环”,再把理论落到锁协议、运行时证据、故障恢复与测试。文章主要讨论单实例互斥锁和数据库行锁;分布式租约、网络分区与共识不在首轮范围。
面试官考察点
第一项是诊断是否基于等待关系。CPU 很低、请求超时或两个线程都处于阻塞态,只能说明系统没有进展,不能单独证明死锁。强回答会画等待图:节点是线程或事务,边表示“正在等待对方持有的资源”,再寻找环。
第二项是能否准确使用四个必要条件:互斥、持有并等待、不可抢占和循环等待。它们解释死锁为什么可能发生;把四项逐字列出却不能对应当前转账路径,仍然只是记忆答案。
第三项是预防策略有没有可证明的全局不变量。最实用的方案通常是为所有锁定义稳定顺序,并要求所有入口一致遵守。只在当前函数交换两行代码不够,批处理、退款、后台修复和未来代码路径若反向取锁,等待环仍会回来。
最后看恢复边界。数据库可以检测环并回滚一个事务;进程内互斥锁通常不能安全地“抢走”后继续执行,因为被中断代码可能只修改了一半状态。好的回答会区分预防、避免、检测和恢复,说明超时的代价,并用可控交错复现问题,而不是依赖压力测试碰运气。
回答前需要澄清的问题
- 资源是否都是单实例互斥锁? 单实例资源的等待图出现环即可证明这组等待无法推进;一种资源有多个实例时,资源分配图中的环只表示可能死锁,还要检查可用实例和剩余需求。
- 锁是可重入还是不可重入? 同一线程再次取得不可重入锁可能形成自我死锁。若转出账户与转入账户相同,必须先去重,不能假设排序自动解决重复加锁。
- 两个账户的修改是否位于同一个可回滚事务? 可回滚事务允许检测后放弃一个受害者并重试;已经发送外部付款或邮件的流程,需要幂等键或提交后发件箱,不能盲目重放。
- 所有取锁入口能否共享同一排序规则? 能统一时优先全局锁顺序;第三方组件或跨服务资源无法纳入同一协议时,应减少同时持有、重构所有权,或选择可检测和回滚的边界。
- 允许等待多久,错误语义是什么? 锁超时可以限制尾延迟,却可能终止只是很慢的合法请求。调用方必须知道这是可重试、不可重试,还是结果未知。
- 运行平台提供什么诊断能力? JVM 线程监控、数据库等待视图和内核锁验证器覆盖的锁类型不同。工具没有报环不能证明所有异步等待、虚拟线程或外部资源都没有死锁。
- 持锁期间是否调用网络、磁盘或用户输入? 不可控等待会延长锁持有时间并放大阻塞。应把这些操作移出临界区,除非一致性协议明确要求并能处理失败。
30 秒回答框架
“这是死锁:A 等 B 释放 84,B 又等 A 释放 42,等待图形成 A → B → A。场景同时具备互斥、持有并等待、不可抢占和循环等待。我会把账户锁按稳定账户 ID 排序、去重后统一升序获取,逆序释放,从协议上破坏循环等待。线上用线程或数据库等待信息确认环;数据库死锁让一个事务回滚并有界重试,进程内死锁先保留诊断证据,再按状态安全边界恢复服务。最后用屏障固定两个线程的交错,证明旧实现必现、排序实现保持余额不变量。”
完整回答应补充:为什么排序能排除环、超时为什么不是证明、检测工具覆盖什么,以及恢复是否会重复外部副作用。
分步骤深入解答
第一步:先证明是等待环,不用症状代替结论
把运行态写成等待图。线程 A 已持有锁 42,申请锁 84,而 84 由 B 持有,因此有边 A → B。线程 B 已持有 84,申请 42,而 42 由 A 持有,因此有边 B → A。两个线程只有取得第二把锁后才会释放第一把锁,环内没有能够先完成的节点,所以它们不会自行推进。
线上诊断需要取得同一时间附近的持有者、等待者和调用栈。一次线程快照可能撞到短暂竞争;连续快照中相同调用栈与相同等待边持续存在,证据更强。普通长等待没有回边:A 可能等 B,但 B 正在运行并最终释放资源。把所有慢锁都标为死锁,会把容量或外部依赖问题误诊为锁协议错误。
图结论还有模型边界。每把互斥锁只有一个持有者时,等待环足以证明相关线程死锁。资源类别有多个实例时,资源分配图出现环只表示风险:环外实例释放后仍可能让某个节点继续。此时需要结合可用数量、已分配数量和最大剩余需求判断,不能套用单实例结论。
第二步:把四个必要条件映射到当前代码
四个条件在转账场景中分别是:
- 互斥: 同一账户同一时刻只能由一个线程持有写锁。
- 持有并等待: A 持有 42 时等待 84;B 持有 84 时等待 42。
- 不可抢占: 运行时不会从线程手中强行夺走账户锁,只能由持有者释放。
- 循环等待: A 等 B,B 又等 A。
死锁发生时四项都存在,破坏其中任何一项可以预防这一类死锁。这里不应把“系统允许互斥”或“代码可能嵌套锁”直接当成已经死锁;必要条件描述可能性,具体运行态还要看是否形成无法推进的环。
互斥往往保护余额不变量,不能为了消除死锁而无锁写共享状态。不可抢占对普通内存临界区也很难安全破坏。工程上更常选择消除循环等待,或者在能回滚的系统中检测后放弃一个事务。
第三步:用全局锁顺序消除循环等待
为可同时持有的锁定义全序,例如“资源类型等级,随后是资源 ID”。转账只涉及账户锁时,按账户 ID 升序。输入先去重,解决同一账户转给自己的情况;然后依序取得所有锁,在临界区只做余额校验和内存或数据库变更,最后逆序释放。
以下是语言无关的伪代码:
transfer(fromid, toid, amount): ids = unique(sortascending([fromid, to_id])) acquired = [] try: for id in ids: acquired.append(lock_account(id)) validateandapplytransfer(fromid, to_id, amount) finally: for lock in reverse(acquired): unlock(lock)
排序正确性的证明很短。假设每个线程持有低等级锁时只能等待更高等级锁,那么等待环会要求等级严格上升:
r1 < r2 < … < rn < r1
严格小于关系不可能回到起点,因此循环等待无法形成。这个证明依赖所有路径遵守同一规则;任何按请求顺序、列表顺序或数据库返回顺序取锁的旁路都会破坏不变量。
释放逆序主要让资源生命周期与嵌套结构清晰,不是排除环的必要条件。更重要的是不要在持锁期间做远程调用、等待用户输入或执行无界 I/O。临界区越长,虽然不一定死锁,锁竞争、超时和故障恢复成本都会上升。
第四步:知道何时选择其他策略
一次申请全部资源可以破坏“持有并等待”,但调用方必须预先知道完整集合;资源很多时还会延长占用、降低并行度。适合小而明确的锁集合,不适合边遍历边发现资源的流程。
死锁避免会在每次分配前判断分配后是否仍处于安全状态。银行家算法要求进程预先声明最大资源需求,并维护可用、最大、已分配和剩余需求。动态 Web 服务通常不知道请求未来会访问哪些对象,因此它更适合解释安全状态,不应机械搬进普通业务代码。安全状态保证存在完成顺序;不安全状态只是可能走向死锁,并不等于当前已经死锁。
减少共享可变状态、单一所有者队列或成熟并发容器能减少手写锁协议,但不能宣称“用了消息就不会死锁”。两个有界队列互相等待空间、任务在同一线程池中互相等待结果,仍可能形成等待环。替代方案也要画出实际等待边。
尝试加锁与超时属于有损退出机制,不是无环证明。若超时路径保证撤销局部状态并释放所有已持有锁,它会在阈值到达后打破持有并等待,解除本次循环;在阈值前请求仍会停顿,阈值过短还会误伤合法慢请求。双方同时超时、同时重试,又可能进入活锁。使用时需要最大尝试次数、随机退避、幂等语义和最终失败出口。
第五步:把检测工具的覆盖范围说清楚
JVM 的 ThreadMXBean 可以查找等待对象监视器或可拥有同步器的平台线程环,并返回线程 ID;Java SE 25 文档明确说明,包含虚拟线程的环不会被这个方法发现,而且该方法用于故障排查,不是同步控制。工具返回空值只代表其覆盖范围内没有发现环。
Linux 内核的 lockdep 记录锁类别之间的获取依赖。例如运行中观察到 L1 → L2 和 L2 → L1,就能报告潜在的锁反转,即使这次执行尚未恰好卡住。它说明开发与测试阶段可以验证锁顺序协议,但应用层不能假设所有运行环境都有相同验证器。
PostgreSQL 会自动检测事务死锁并中止其中一个事务,让其他事务继续。官方文档不保证哪个事务被选中,因此业务不能依赖“较新的请求总会失败”。它同时建议所有应用以一致顺序锁定多个对象;无法完全预防时,再处理死锁错误并重试。
生产观测至少记录锁等待时长、持锁时长、死锁次数、被回滚事务、重试次数和最终失败率。诊断事件应能关联等待者、持有者、锁类别与调用栈,但不能把完整账户、查询参数或敏感载荷写进日志。
第六步:按资源边界设计恢复
数据库事务被选为受害者后,必须回滚完整事务,重新读取状态并重新执行,不能从“已经拿到第一把锁”之后接着跑。重试要有上限和随机退避;若请求可能重复到达,使用业务幂等键。邮件、消息或外部付款应在提交后通过发件箱等幂等链路触发,否则数据库回滚不了已经发生的副作用。
进程内线程卡在互斥锁上时,强行终止一个线程可能把内存不变量留在半完成状态。常见恢复路径是先保存线程转储和关键指标,再由监督系统重启可安全重建状态的进程或实例。若进程内保存不可恢复的唯一状态,重启也不是安全方案,设计阶段就应把持久化与恢复边界补齐。
受害者选择可以考虑已完成工作量、回滚成本、优先级和重试次数,但正确性优先。恢复让服务重新前进,不等于根因解决;若不修复锁顺序,流量恢复后仍会重复死锁。
第七步:用确定性交错验证修复
不要只跑高并发压测等待偶现。为旧实现加入测试屏障:A 取得 42 后到达屏障,B 取得 84 后到达同一屏障;两边同时继续并申请第二把锁。看门狗应在限定时间内捕获两个等待边,证明用例确实制造了 A → B → A,而不是因为测试机慢。
修复后用同样的反向输入运行。A 和 B 都先尝试较小 ID 42,其中一个等待时尚未持有 84;先取得 42 的线程再取得 84、完成并释放,另一个随后继续。验证两笔请求都得到契约允许的结果、总余额不变、单笔转账不重复。
还要覆盖:
- 转出与转入 ID 相同,确认去重后不会重复取得不可重入锁;
- 三个以上账户与不同资源类型,确认排序键在所有路径一致;
- 余额不足、异常、取消和部分加锁失败,确认已取得锁都在 finally 路径释放;
- 批处理、退款和后台修复与在线转账并发,确认没有旁路逆序;
- 数据库检测并回滚一个事务,确认完整重试、幂等副作用和重试上限;
- 随机高竞争长测,持续检查余额、锁等待、死锁与饥饿指标。
确定性用例证明已知反例被修复,长测寻找未建模路径。两者都通过,才有资格声称协议得到验证。
高质量示范回答
“我会先确认这不是普通慢等待。A 持有 42 等待 B 的 84,因此有 A → B;B 持有 84 又等待 A 的 42,因此有 B → A。两把锁都是单实例互斥锁,两个持有者都只有拿到第二把锁才会释放第一把,所以这个等待环就是死锁。
四个必要条件在这里全部成立:账户写锁互斥;两边都持有一把再等另一把;锁不能被安全抢走;等待关系形成环。我不会删除互斥,因为余额需要保护。首选是破坏循环等待:对所有账户锁定义同一全序,先对账户 ID 去重,再统一按升序获取、逆序释放。若所有路径都只从低等级锁等待高等级锁,环就要求等级一路上升后又回到更低起点,这是不可能的。
这个规则必须覆盖在线转账、退款、批任务和后台工具。持锁期间只做校验与状态变更,不调用远程服务。超时可以在完整释放后解除本次等待,但不能证明锁协议无环;两个请求同时超时重试还可能活锁,所以若采用超时,我会配置有界次数、随机退避和幂等键。
线上我会看线程转储或数据库等待信息,建立等待者到持有者的图。JVM 工具和数据库检测各有覆盖边界。PostgreSQL 检测到事务死锁时会中止一个事务,我会回滚并有界重试完整事务,不依赖哪个请求成为受害者;外部消息放在提交后的幂等链路。
验证时用屏障让 A 先拿 42、B 先拿 84,再同时申请第二把锁,稳定复现旧实现。修复版使用相同反向输入,应只出现无环等待并最终完成。我还会覆盖相同账户、三个账户、异常释放、旁路任务和数据库回滚,检查总余额、重复副作用、死锁数与最终失败率。”
这段回答把定义、证明、工程选择、恢复和测试连在一起。若题目只要求四个条件,可以在第二段结束;若面试官继续追问生产处置,再展开工具覆盖与重试边界。
常见错误
- 看到线程阻塞就宣布死锁 → 长锁等待可能仍有持有者在推进 → 画等待者到持有者的边,确认环及资源实例模型。
- 只背四个条件 → 没有说明当前代码怎样满足条件,也没有解决方案 → 逐项映射场景,并明确选择破坏哪一项。
- 每个函数各自排序 → 不同模块可能使用不同键或资源类型顺序 → 定义仓库级锁等级与稳定 ID,并检查全部入口。
- 忽略相同资源 ID → 不可重入锁可能被同一线程取得两次 → 排序前去重,并为自转账定义业务语义。
- 把设置超时当作无环设计 → 反向锁序仍存在,且超时会误杀慢请求 → 完整释放已持有锁,并增加有界退避、幂等与最终失败。
- 检测到环后从中间继续 → 局部状态可能已经失效,外部副作用可能重复 → 回滚并重跑完整事务,副作用使用幂等提交后链路。
- 假设等待图有环总能证明死锁 → 多实例资源可能由环外实例解除等待 → 区分单实例等待图和多实例资源分配模型。
- 强行终止持锁线程 → 内存不变量可能停在半更新状态 → 先保留证据,只在状态可重建边界重启进程。
- 只做随机压力测试 → 没复现不代表没有反向锁序 → 用屏障固定交错,再用长测补充未知路径。
追问及应对
追问一:一次转账需要锁三个账户,排序方案还成立吗?
成立,前提是先得到完整且去重的锁集合,再使用同一稳定键排序。若账户在执行中动态发现,就先做无锁读取建立候选集合,重新验证版本后一次加锁;无法预先确定时,考虑拆分事务或使用可回滚的检测策略,不能边发现边按局部顺序加锁。
追问二:不同资源类型如何排序?
建立组合等级,例如先按资源类型固定“客户 → 账户 → 账本分片”,同类型内再按稳定 ID。规则必须由共享锁协议维护,代码评审和测试验证跨类型边。若业务必须反向进入,重构调用方向或在进入前释放低层资源,不能为单个路径增加例外。
追问三:使用 tryLock 和 100 毫秒超时是否已经解决死锁?
它保证这次等待最多约束在配置窗口内,但没有证明锁协议无环。100 毫秒也可能小于合法临界区尾延迟,导致假失败。还要定义释放已持有锁、回滚部分状态、有界随机退避、幂等和最终错误;否则系统可能从死锁变成持续重试的活锁。
追问四:读锁升级为写锁为什么危险?
两个线程都持有共享读锁,再同时等待独占写锁时,彼此的读锁都会阻止升级,形成等待环。优先使用库提供的明确升级协议;没有协议时释放读锁、竞争写锁,并在取得写锁后重新验证读取条件。重新验证是必要的,因为释放窗口内状态可能变化。
追问五:PostgreSQL 已经自动检测,应用还需要做什么?
需要统一多对象锁顺序来降低死锁发生率,并把死锁错误当作整个事务失败。应用重新读取后有界重试,保证请求幂等,记录重试与最终失败;不能依赖特定事务总被中止,也不能在数据库回滚后重复发送不可撤销的外部动作。
追问六:死锁、活锁和饥饿怎样区分?
死锁中的参与者因等待环都无法推进;活锁中的参与者仍在执行和改变状态,却不断互相退让或重试而没有完成;饥饿表示某个参与者长期拿不到资源,但其他参与者仍可能完成。三者都影响活性,证据不同:等待环、持续状态变化无完成、以及长期不公平等待。