做系统架构这行,死锁是个绕不开的话题。我印象最深的一次线上事故,是数据库里两条业务链路互相持有对方的行锁,两边都在等对方释放,最后直接拖垮了整个核心链路。当时DBA kill进程都kill了两轮,第一次kill错了,新请求又把锁堵上了,整个排查过程持续到凌晨。后来翻操作系统书的时候看到银行家算法,我脑子里第一反应是:如果资源分配之前能像这样“先算一笔账”,很多死锁问题本来就不该发生。
银行家算法(Banker's Algorithm)是经典的死锁避免机制,它解决的问题非常具体:当多个进程同时向系统申请有限资源时,系统如何判断这次分配会不会把全局拖入死锁。它不是“死锁发生了怎么恢复”,而是“在分配之前就预判这条路走不走得通”。这篇内容适合系统架构设计师、后端开发、中间件开发,以及所有需要跟并发、资源池、锁打交道的人。我会从算法原理讲到手工推演,再给一套能直接改改就用的代码骨架,最后聊聊它在真实架构里的落地边界和选型思路。
1. 死锁问题为什么在系统架构中如此棘手
1.1 一次线上事故:死锁不是理论问题
先说我遇到的那次事故。两个微服务A和B,A服务的某个任务先更新订单表、再更新库存表;B服务的另一个任务恰好反过来,先更新库存表、再更新订单表。当两个任务同时跑且命中同一批数据时,A握着订单锁等库存锁,B握着库存锁等订单锁,谁也不退让。
这种场景在高并发下不是小概率事件。数据库里两条update语句互相等待,MySQL默认的锁等待时间一过,事务回滚,业务层重试,流量一上来就会放大成雪崩。更麻烦的是,死锁现场很难复现,因为它是多个请求在时间轴上恰好交错的结果,日志里只能看到“Deadlock found when trying to get lock; try restarting transaction”,看不到完整的资源申请轨迹。
死锁真正让人头疼的地方在于:它不是单点故障,而是多个节点之间的资源循环依赖。系统架构越微服务化,锁的粒度越小、嵌套越深,死锁出现的可能反而越大。处理死锁不能靠运气,必须在设计阶段就有一套机制去规避或者缓解。
1.2 死锁的四要件与架构中的真实场景
教科书里把死锁产生的条件归纳为四个,我在实际排查时发现这四个条件在分布式系统里几乎处处成立:
互斥条件:资源同一时刻只能被一个进程占用。数据库的行锁、分布式锁、连接池里的连接,都是典型互斥资源。
持有并等待:进程占用了部分资源,又在等待别的资源。比如事务A更新了订单表还没提交,又去更新库存表,这时候它手里的订单锁就是“持有”,对库存锁就是“等待”。
不可剥夺:进程占用的资源不能被系统强行抢走。除非事务回滚,否则数据库不会把你已经拿到的行锁释放给别人。
循环等待:多个进程形成一条闭环等待链。A等B、B等C、C等A,闭环一旦形成,谁也跑不掉。
四个条件只需同时满足,死锁就成立。架构设计里最难破的是“持有并等待”和“循环等待”,因为这两个往往是业务逻辑天然带来的——代码写出来就是先做一件事再做另一件事,你很难为了规避死锁去重排所有业务操作的顺序。
1.3 为什么“事后处理”总让人头疼
死锁发生之后,最常见的处理方式是超时和回滚。数据库本身会检测锁等待图,发现环就选一个牺牲者回滚;应用层则设置lock wait timeout,超时后整个事务重来。
这套“事后处理”的思路有几个绕不开的痛点。
第一,回滚成本可能很高。长事务里可能已经执行了几十条SQL,一旦回滚,前面所有的CPU、I/O、网络开销全部白费,而且回滚期间它持有的锁还在,反而拖慢后面的所有请求。
第二,重试可能引发“惊群”。事务回滚后客户端立刻重试,多个事务在同一时间点重新发起一模一样的申请顺序,死锁会再次出现,甚至每次都命中同一批资源,形成锁死循环。
第三,超时时间很难定。设短了,正常的锁等待也被误伤;设长了,死锁期间业务延迟会无限放大。我在项目里见过把innodb_lock_wait_timeout调到50秒的,死锁一来,整个接口的P99直接奔着分钟级去了。
所以核心思路应该转变:不去等死锁发生再处理,而是在每次分配资源之前,先判断“这次给了之后,系统还能不能找到一个办法让所有进程都走完”,如果找不到,宁可让这个请求等一等,也不要冒险发资源。这就是银行家算法的出发点。
2. 银行家算法的设计思想与核心数据结构
2.1 银行家为什么不破产:一个贷款类比
理解银行家算法最好的方式,是把它当成银行在审核贷款申请。银行手里有一笔钱,多个企业家来借钱,每个企业家知道自己最多需要借多少钱(最大需求),会分期来借(已分配),未来还要继续借(剩余需求)。
银行放贷的原则很简单:借出去这笔钱之后,银行手里剩下的钱,加上未来所有企业家还回来的钱,必须能把每个企业家最终需要的钱都给齐,否则就存在有人中途资金链断裂、钱收不回来的风险。
放到操作系统里,资源就是钱,进程就是企业家,银行家就是资源分配器。每次收到一个资源请求,系统不是先给再说,而是先做一次“纸上演算”:假装把资源分出去,然后检查系统里是否存在某个执行顺序,让每个进程都能依次拿到自己还需要的资源并最终运行结束。如果存在,这次分配就是安全的;如果不存在,系统就拒绝这次分配,让进程先等着,资源留给真正能走通的路径。
这个“拒绝”的瞬间,其实就是架构里的熔断思想:与其让一个请求把系统拖进泥潭,不如现在让它等一等。
2.2 四张表:Available、Max、Allocation、Need
银行家算法用几组数据结构来描述整个系统,假设系统里有n个进程、m类资源。
Available(可用资源向量):长度为m,表示当前系统中各类资源还剩多少可用。注意是“还能自由分配的”,不包含已经被进程占用的部分。
Max(最大需求矩阵):n行m列,表示每个进程在整个生命周期内最多需要多少资源。这个值是进程启动时声明的,相当于企业家的商业计划书里写的资金上限。
Allocation(已分配矩阵):n行m列,表示每个进程当前已经占用的各类资源数。
Need(剩余需求矩阵):n行m列,表示每个进程还差多少资源才能达到它的最大需求。计算公式是 Need[i][j] = Max[i][j] - Allocation[i][j],这是个恒等式,任何时候都成立。
系统还要保证两个全局约束:对每一类资源,所有进程最大需求的总和不能超过资源总数,否则从一开始这系统就不可能让所有人都跑完,算法直接进入“不可调度”状态;同时每个进程的已分配资源数不能超过它的最大需求。
这几张表之间的关系,我在推演案例时会反复用到。实际写代码的时候我习惯把矩阵存成二维数组,向量存成一维数组,初始化和更新都按行列访问,避免踩到行列颠倒的坑。
2.3 安全状态和安全序列的定义
银行家算法引入了两个容易混淆的概念:安全状态和安全序列。
安全序列是指存在一个进程执行顺序,比如 P1、P3、P0、P2、P4,按这个顺序,每个进程在当前可用资源下都能满足剩余需求,运行完再释放自己的资源,让下一个进程接着跑。只要存在这样的顺序,系统当前就处于安全状态。
安全状态的反面是不安全状态,指的是无论如何都找不到一个执行顺序能让所有进程都走完。注意,不安全状态不一定马上死锁,因为进程并不一定会同时申请资源,可能运气好大家交错着完成了;但从数学期望上讲,只要进入不安全状态,后续任何一次请求都可能触发死锁,风险已经不可控。
这里我想强调一个很多初学者会忽略的点:银行家算法防止的是“当前系统进入不安全状态”,而不是直接杜绝死锁。它的哲学是“我不做那个把牌局搞僵的人”,每次分配决策都保证牌局还存在一条让所有人出完牌的通路。至于后续进程到底按不按那条路走,算法不关心,因为只要路径存在,系统早晚能收敛到安全状态去。
理解了这四个条件、四张表和安全状态的概念,就可以开始推演一个真实案例了。
3. 手工推演一个完整的资源分配案例
3.1 初始状态:5个进程、3类资源
我直接用操作系统教材里最经典的例子来推演,这个例子也在系统架构设计师考试的案例分析里出现过。系统有5个进程P0到P4,3类资源A、B、C,总数分别是10、5、7。T0时刻的分配情况如下。
| 进程 | 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) |
Available = (3,3,2),算法校验一下:所有进程已分配的资源总和是 (7,2,5),用总量 (10,5,7) 减掉,正好得到 (3,3,2),表是正确的。
先验证当前状态是否安全。用Work表示当前可用资源的动态集合,初始为Available=(3,3,2)。
P0的Need (7,4,3)大于Work,不满足;P1的Need (1,2,2)完全小于等于Work,可以执行。让P1运行完,它释放手里的(2,0,0),Work变成(5,3,2)。P3的Need (0,1,1)满足,执行后释放(2,1,1),Work变成(7,4,3)。P4的Need (4,3,1)满足,执行后释放(0,0,2),Work变成(7,4,5)。P0的Need (7,4,3)满足,执行后释放(0,1,0),Work变成(7,5,5)。最后P2的Need (6,0,0)满足,执行完整个系统回到(10,5,7)。
所以初始状态存在安全序列:P1、P3、P4、P0、P2,系统当前处于安全状态。
3.2 第一次请求:P1申请(1,0,2),成功
现在P1发出一个资源请求 Request=(1,0,2)。按照算法流程,先做三道检查。
第一,Request必须在P1的最大需求范围内,即 Request <= Need[P1] = (1,2,2),满足。第二,Request必须小于等于系统当前可用资源,即 (1,0,2) <= (3,3,2),满足。第三,假设把资源分给P1,预分配之后系统是否还能处于安全状态。
预分配后的全新状态是:Available变成(2,3,0);P1的Allocation从(2,0,0)变成(3,0,2);P1的Need从(1,2,2)变成(0,2,0);其他进程的表项不变。
对这组新状态重新做安全性检查。Work=(2,3,0)。P0的Need(7,4,3)不满足;P1的Need(0,2,0)满足,运行完释放(3,0,2),Work变成(5,3,2)。接着P3的Need(0,1,1)满足,释放(2,1,1),Work变成(7,4,3)。P4的Need(4,3,1)满足,释放(0,0,2),Work变成(7,4,5)。P0的Need(7,4,3)满足,释放(0,1,0),Work变成(7,5,5)。P2的Need(6,0,0)满足,释放(3,0,2),Work变成(10,5,7)。
安全序列存在:P1、P3、P4、P0、P2。这次分配可以批准,系统把资源(1,0,2)正式分配给P1,同时更新全局状态。
3.3 第二次请求:P0申请(0,2,0),必须拒绝
P1拿到资源后,P0紧接着发出请求 Request=(0,2,0)。
检查第一项:Request(0,2,0) <= Need[P0]=(7,4,3),满足。检查第二项:Request(0,2,0) <= Available=(2,3,0),也满足。注意,这时候Available的B类还有3个,P0只要2个,从表面的资源数量上看是够的。
但第三步的预分配演算马上会暴露问题。假设先把(0,2,0)分给P0,系统的状态会变成:Available=(2,1,0);P0的Allocation从(0,1,0)变成(0,3,0);P0的Need从(7,4,3)变成(7,2,3)。
对这份新状态执行安全性检查。Work=(2,1,0)。P0:Need(7,2,3),A类7大于2,B类2大于1,不满足。P1:Need(0,2,0),B类2大于1,不满足。P2:Need(6,0,0),A类6大于2,不满足。P3:Need(0,1,1),C类1大于0,不满足。P4:Need(4,3,1),都不满足。
扫描完一整轮,找不到一个可以安全执行的进程,更凑不出安全序列。系统一旦真的把资源给了P0,就进入不安全状态,后续任何一次常规请求都可能引爆死锁。所以算法给出结论:拒绝P0的这次请求,所有预分配的表项全部回滚,P0继续等待。
这个例子展示了银行家算法最有价值的一点:从表面看资源是够的,但分配之后整个系统失去了“可收敛性”。普通直觉在这里会犯错,算法不会。
3.4 整个判定流程的伪代码视角
把上面两次请求的处理流程抽出来,就得到一套完整的判定逻辑。
1. 收到进程Pi的资源请求向量Request 2. 如果 Request[j] > Need[i][j],说明进程申请量超过了它声明的最大需求 ,属于非法请求,直接报错 3. 如果 Request[j] > Available[j],说明当前资源不够,进程必须等待 4. 进入预分配: Available = Available - Request Allocation[i] = Allocation[i] + Request Need[i] = Need[i] - Request 5. 调用安全性检查算法: Work = Available Finish = [False, False, ...] 循环扫描所有进程: 如果能找到一个未完成、且Need[i] <= Work的进程: 假设它执行完毕释放资源:Work = Work + Allocation[i] 标记Finish[i] = True 重新从头扫描 如果一整轮扫描找不到任何可执行进程,跳出循环 如果所有Finish都为True,存在安全序列,预分配生效 否则系统进入不安全状态,回滚预分配,拒绝请求安全性检查本身是个两层循环:外层最多扫描n轮,每轮要遍历n个进程、比较m类资源,最坏复杂度是O(n²m),n和m不大的时候很快,但是资源类型变多、进程数上千之后,这个开销不能忽略,后面讲落地时会提到。
4. 可运行代码骨架:安全检查和请求处理
4.1 安全性检查函数的实现
先写安全性检查函数,它是银行家算法的核心。我给出一份可以直接跑的Python实现,便于理解也便于改写成其他语言。
def is_safe(available, allocation, need): """ 判断当前系统是否存在安全序列 :param available: 一维列表,当前可用资源 :param allocation: 二维列表,各进程已分配资源 :param need: 二维列表,各进程剩余需求 :return: (是否安全, 安全序列) """ n = len(allocation) # 进程数 m = len(available) # 资源类型数 work = available[:] # 工作向量,动态变化 finish = [False] * n # 标记进程是否已执行完 sequence = [] # 收集安全序列 while len(sequence) < n: found = False for i in range(n): if not finish[i] and all(need[i][j] <= work[j] for j in range(m)): # 模拟进程i运行完成,释放其占用的全部资源 for j in range(m): work[j] += allocation[i][j] finish[i] = True sequence.append(i) found = True break if not found: # 一整轮扫描找不到可执行的进程,系统不安全 return False, [] return True, sequence函数里有个细节值得注意:每轮扫描找到第一个可执行进程后要 break 出来重新从头扫。因为前面进程释放资源后,后面的进程可能就能满足了,必须在新的一轮里重新检查所有未完成的进程,而不是继续往后扫。这个写错的话,安全序列判断会出错。
4.2 资源请求判定与回滚逻辑
在安全性检查之上,资源请求逻辑就顺理成章了。
def request_resources(pid, request, available, allocation, need): """ 处理进程pid的资源请求 :param pid: 请求资源的进程编号 :param request: 一维列表,本次申请的各类资源数 :return: 字符串提示(granted/wait/error/denied) """ n = len(allocation) m = len(available) # 第一步:检查是否超过最大需求 for j in range(m): if request[j] > need[pid][j]: return "error: request exceeds declared max need" # 第二步:检查当前可用资源是否足够 for j in range(m): if request[j] > available[j]: return "wait: system has insufficient resources, try again later" # 第三步:预分配 for j in range(m): available[j] -= request[j] allocation[pid][j] += request[j] need[pid][j] -= request[j] # 第四步:安全性检查 safe, seq = is_safe(available, allocation, need) if safe: return f"granted: safe sequence = {seq}" # 第五步:不安全则回滚预分配 for j in range(m): available[j] += request[j] allocation[pid][j] -= request[j] need[pid][j] += request[j] return "denied: would lead to unsafe state, request postponed"这个函数的顺序绝对不能乱。先判最大需求,再判可用资源,最后预分配和回滚。回滚时要把三张表available、allocation、need全部还原,缺一个都会让系统状态错乱。
我还建议把安全序列打印出来,方便做测试验证。经典的推演数据可以直接喂进函数里跑,看输出是不是我们刚才推演的结果。
4.3 实现时容易踩的坑
第一处是副本和引用的混淆。is_safe函数里我用了 work = available[:],这是复制出一份新的列表,避免修改原始available。Python里如果直接写 work = available,函数内部对work的任何修改都会污染原始数据。其他语言同理,传引用前先想清楚。
第二处是整数溢出和负数。预分配之后出现负数,说明某处逻辑漏了判断,比如Request比Available还大就混进了预分配。我在代码里把返回类型设计成字符串而不是布尔值,就是为了把wait、error、denied区分开,排查问题的时候一眼能看出来走到哪一步。
第三处是性能退化。is_safe的时间复杂度是O(n²m),进程数几千、资源类型几十时,一次请求就要做几百万次比较。如果系统里请求频率很高,这个算法本身就会变成瓶颈。后面章节会说怎么在架构上规避这个问题。
5. 银行家算法在真实系统架构中的实践边界
5.1 数据库系统为什么没有直接用它
很多人会问:数据库锁管理器为什么不用银行家算法?这是个好问题,答案在于“最大需求不可知”。银行家算法要求每个进程在开始前就声明自己的Max,但SQL事务里,一个事务到底要更新多少行、持有哪些锁,只有执行到那一刻才知道,不可能预先声明。PostgreSQL、MySQL这类数据库的死锁处理普遍走的是“锁等待图检测+超时回滚”路线,就是在死锁已经发生或即将发生时把它打破。
但这不代表银行家算法对数据库设计没有启发。数据库连接池、线程池这类资源池,恰恰是“最大需求”可以量化的场景——每个任务在提交时知道自己要占用多少连接、多少线程,资源池管理者可以在发放资源前做准入控制。
5.2 分布式架构下的天然限制
把银行家算法直接搬到分布式系统里,会遇到几个硬约束。
最大需求难以预知。微服务之间的调用链跨节点、跨数据库,一个请求最终会触碰哪些资源,连发起方都不完全清楚。让每个服务预先声明“我可能用到多少CPU、多少内存、多少连接”,这在业务层面几乎不可能。
资源模型不是一维向量。银行家算法把资源抽象成若干类别,每类独立计数,但真实系统的CPU配额、内存、带宽、磁盘I/O、数据库连接是相互纠缠的,内存不够可能换页导致CPU飙升,连接池打满可能拖垮数据库,多类资源之间不是简单的“各自满足”关系,而是存在复杂的转换和联动效应。
分布式系统里还有协调开销。算法要求所有资源分配决策集中在一个中心节点上,所有进程的请求和释放都要同步过来,这个中心节点本身就变成单点和性能瓶颈。即便用分布式锁或共识协议来协调,通信延迟也会让算法的信息同步远远跟不上真实系统状态的变化速度。
所以现实中,银行家算法在分布式领域很难整体落地,但它的核心思想——资源分配前做一次安全预判——可以局部化、单机化地应用在各类资源池组件里。
5.3 最像银行家算法的现代系统:连接池与任务调度
我实际在工程里用过类似思路的地方,是数据库连接池的准入控制。
曾经有个服务突发流量,线程池被打满,所有线程都在等待数据库连接,而连接池的连接又被超时未清理的事务占着,整个服务进入假死。后来我给连接池管理加了一层“最小可用连接保护”:连接分配前,判断如果本次分配后,剩余空闲连接是否还能覆盖当前所有已挂起任务的已知需求,如果不能,就让新的请求排队。这其实就是银行家算法里“分配前检查安全状态”的变体,只是把进程换成了请求,把资源换成了连接。
任务调度器里也能看到类似设计。一个调度器收到一批任务,每个任务声明自己需要多少内存、多少临时磁盘,调度器批处理时不是来一个分一个,而是先做一次全量校验——这批任务并行之后,系统里有没有哪个任务会因为资源不足而永久挂起?如果存在这种任务,调度器就把它延后,先跑更有把握的任务组合。
Kubernetes里的requests和limits设计也有点这个味道。Pod申报资源请求,调度器根据节点剩余可分配量判断能否调度,并且要求节点上所有Pod的资源请求总和不能超过节点容量。这保证了节点上的Pod都有一个“名义上的可满足上限”,虽然它不能完全避免资源竞争,但从架构理念上说,和银行家算法的“预占式资源检查”是一脉相承的。
6. 死锁避免、预防、检测的选型建议
6.1 三种主流策略的对比
死锁处理策略整体上可以分三类:预防、避免、检测与恢复。我习惯把它们的核心手段、成本和适用场景放到一张表里对比。
| 策略 | 核心手段 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| 死锁预防 | 破坏死锁四个必要条件之一,比如资源按序分配、一次性分配所有资源 | 简单直接,实现成本低,不需要运行时判断 | 资源利用率低;限制进程行为,业务改造成本高 | 单机嵌入式系统、强约束的批处理任务 |
| 死锁避免 | 分配前检查是否存在安全序列(银行家算法) | 资源利用率高,能在一定程度上容忍动态请求 | 需要提前知道最大需求;运行时计算有开销 | 操作系统内存管理、单节点资源池、有限状态的调度器 |
| 死锁检测与恢复 | 允许死锁发生,用等待图或超时发现,再回滚或杀死进程 | 通用性强,不需要预知最大需求 | 死锁期间有性能损耗;回滚成本不可控 | 数据库锁管理、分布式锁、微服务调用链 |
没有哪个策略是绝对最优的,关键在于系统的资源特征。如果业务操作顺序可以强制统一,资源排序的预防策略最省心;如果系统规模小、资源类型少、最大需求明确,银行家算法的避免策略最优雅;如果是动态性很强的复杂调用链,那就老老实实做检测加超时兜底。
6.2 我在工程中的组合拳打法
我在系统架构里很少单独依赖某一种策略,常规组合是“预防为主、检测兜底、关键节点避免”。
对业务代码里那些可以标准化的资源申请顺序,比如必须先查用户后写订单,我会在架构规范层面强制排序,这是成本最低的预防。对数据库这类无法预知最大需求的场景,依赖事务锁等待超时和死锁重试机制,这是检测与恢复。而对连接池、线程池、任务队列这类“资源类型少、最大需求可声明”的组件,我会用类似银行家算法的准入判断做避免,宁可让请求在前台排队,也不让它进入资源池后相互卡死。
这套组合的本质,是根据不同资源的特点选择不同策略,而不是试图用一套算法通吃。
6.3 最后的个人体会
我自己折腾银行家算法最大的收获,并不是学会了那套矩阵演算,而是养成了一个分配资源的习惯:给资源之前,先想想如果现在把东西给出去了,有没有办法收场。连接池、线程池、限流器、调度器,凡是涉及“把稀缺资源交给多个竞争方”的系统,都可以先问一句——当前这批请求全部进场之后,有没有可能谁也走不完?如果有可能,就得在入场的源头设一道闸。
系统架构里的很多问题都是这样,真正值钱的不是某个算法本身,而是它逼你养成的那个思考框架。银行家算法就是这样一把扳手,它未必每个场景都能直接拧螺丝,但会让你在面对资源分配时,多一个从“能不能给”到“给了之后还能不能收场”的判断维度。