2.3 过载保护与降级实战:优先级、熔断与恢复风暴


文档摘要

2.3 过载保护与降级实战:优先级、熔断与恢复风暴 再好的限流也挡不住极端峰值。当系统真的过载——比如大促流量是平时的 10 倍,或者某个推理集群突然掉了一半节点——这时候能不能守住核心 SLO、避免雪崩,靠的是过载保护。这一节讲优先级队列、加权公平、熔断器,以及一个常被忽视却致命的"恢复风暴"。 我参与过一个事后复盘,系统在大促时过载,限流策略生效,大量请求被 429。运维紧急扩容,加了 30% 的 GPU 节点。按理说容量够了应该恢复,但系统却死活回不来——TTFT 依然高企,错误率依然攀升。折腾了 40 分钟才发现问题:客户端拿到 429 后按固定间隔重试,当系统刚恢复一点容量,所有积压的客户端同时发起重试,瞬间把刚恢复的系统再次压垮。如此反复震荡,扩容完全没用。

2.3 过载保护与降级实战:优先级、熔断与恢复风暴

再好的限流也挡不住极端峰值。当系统真的过载——比如大促流量是平时的 10 倍,或者某个推理集群突然掉了一半节点——这时候能不能守住核心 SLO、避免雪崩,靠的是过载保护。这一节讲优先级队列、加权公平、熔断器,以及一个常被忽视却致命的"恢复风暴"。

我参与过一个事后复盘,系统在大促时过载,限流策略生效,大量请求被 429。运维紧急扩容,加了 30% 的 GPU 节点。按理说容量够了应该恢复,但系统却死活回不来——TTFT 依然高企,错误率依然攀升。折腾了 40 分钟才发现问题:客户端拿到 429 后按固定间隔重试,当系统刚恢复一点容量,所有积压的客户端同时发起重试,瞬间把刚恢复的系统再次压垮。如此反复震荡,扩容完全没用。这就是典型的"恢复风暴"——它不是容量问题,是协调问题,而很多团队压根没意识到它的存在。

过载保护的三大武器

武器一:优先级队列与加权公平

过载时"先服务谁"比"拒绝谁"更重要。按优先级排队是基本思路:付费和关键请求高权重,过载时优先保障;免费和普通请求低权重,过载时先被限流;后台批处理最低优先级,过载时直接暂停。

加权公平(Weighted Fair Queuing)比简单的"先到先服务"更符合商业逻辑。给每类请求分配权重(比如付费:免费 = 3:1),按权重分配算力。这样即使过载,付费用户仍然能拿到 75% 的服务能力,而不会和免费用户一起被均匀地饿死。1.3 反模式二里"付费用户被 429"的根因,就是没有优先级——所有人都一视同仁地抢同一个令牌桶,高价值用户没有任何保障。

实现优先级队列要注意一点:它增加了调度复杂度,且低优先级请求可能被"饿死"(长期拿不到资源)。生产里通常用"加权公平 + 最低保障"的组合——每类请求都有一个最低保障份额,即使高优先级流量再大,低优先级也不会完全饿死,只是变慢。

给一组具体的权重参数。我们用三档优先级:P0(付费/关键,权重 5)、P1(免费/普通,权重 2)、P2(后台批处理,权重 1)。按加权公平,过载时算力分配是 P0:P1:P2 = 5:2:1,即 P0 拿 62.5%、P1 拿 25%、P2 拿 12.5%。最低保障设成"P0 至少 40%、P1 至少 15%、P2 至少 5%"——即使 P0 流量再大,P1 和 P2 也不会被压到 0。P2(后台批处理)有个特殊处理:过载时直接暂停(设个开关,过载阈值触发就停 P2 的所有新任务),把全部算力让给在线请求,等过载缓解再恢复。这套参数跑了一年,过载期付费用户的成功率始终 > 99%,免费用户 > 85%,没有出现过付费用户投诉"过载时用不了"的情况。

武器二:熔断器(Circuit Breaker)

当某个下游(比如某个推理节点)持续失败,继续往它发请求只会雪上加霜——请求排队、超时、占用连接、最终拖垮调用方。熔断器的逻辑是:失败率超过阈值后,自动停止往这个下游发请求,给它恢复的时间。

熔断器有三个状态。闭合(Closed)是正常调用,同时统计失败率。打开(Open)是失败率超阈值后,直接拒绝请求(快速失败,不再拖累),让故障节点有时间恢复。半开(Half-open)是经过冷却时间后,放少量试探请求过去,成功则恢复为闭合,失败则继续打开。

熔断器的价值在于让故障节点被自动摘除,避免一个坏节点拖垮整个链路。没有熔断器的系统,一个故障节点会让所有发向它的请求都超时(每个超时占用 30 秒连接),很快耗尽调用方的连接池,级联故障就这么扩散开来。加了熔断器,故障节点的请求毫秒级失败,调用方立刻 fallback 到其它节点或降级,故障被隔离在一个节点范围内。

熔断器的关键参数是阈值和冷却时间。阈值设得太敏感,正常抖动也会触发熔断(毛刺误判);设得太迟钝,故障扩散了还没熔断。冷却时间太短,节点还没恢复就半开试探,再次失败;太长,节点早恢复了却还在打开状态,浪费容量。这些参数要结合实际负载特征调优,没有通用答案。

展开讲讲具体参数怎么定。我们用的是 Resilience4j,配置大致这样:

circuit_breaker: failure_rate_threshold: 0.5 # 失败率 50% 触发熔断 slow_call_rate_threshold: 0.8 # 慢调用比例 80% 也算"失败" slow_call_duration_threshold: 3s # 超过 3 秒算慢调用 sliding_window_size: 100 # 滑动窗口 100 次请求 sliding_window_type: COUNT_BASED minimum_number_of_calls: 30 # 至少 30 次请求才统计(防小样本误判) wait_duration_in_open_state: 30s # 打开后冷却 30 秒 permitted_number_of_calls_in_half_open: 5 # 半开试探 5 个请求

几个调参经验。failure_rate_threshold 用 0.5 而不是更高,是因为推理节点一旦失败率到 50%,基本是真出问题了(OOM、显存碎片、调度卡死),不值得继续硬撑。slow_call_duration_threshold 设 3 秒是因为我们正常 TTFT p99 在 1.5 秒内,超过 3 秒基本是节点已经卡了。minimum_number_of_calls 设 30 是为了防"刚启动样本少,一两个失败就把失败率顶到 100%"的误判。wait_duration_in_open_state 设 30 秒,是因为推理节点从故障恢复(如 OOM 后重启)通常要 20-60 秒,30 秒是个折中——太短就反复试探打不中,太长浪费容量。这套参数不是一次定准的,是观察了多次真实故障的恢复曲线后逐步调出来的。

武器三:降级(Graceful Degradation)

过载或故障时,不是"直接拒绝",而是降低服务质量但保持可用。这是过载保护里最能保住用户体验的一招。

降级有几种常见形式。模型降级——大模型降为小模型,响应快、成本低,质量略降,但用户至少有东西可看。缓存兜底——用历史相似响应兜底,可能不够精准,但有总比报错强。功能裁剪——关闭高耗资源的非核心功能,比如关闭多轮上下文改为单轮,减少 prefill 负担。

降级的目标是"用户感知到变慢或变笨,但不感知到中断"。一个过载时能降级到小模型继续服务的平台,和一个过载就直接 500 报错的平台,用户感知天差地别——前者是"今天有点慢",后者是"产品挂了"。我在多个项目里坚持把降级作为过载保护的首选策略,因为它能最大化保住可用性这个核心 SLO。

降级要做到"无感",前提是降级路径平时就演练过。不要等到真过载了才第一次尝试降级到小模型——那时你会发现小模型的 prompt 格式不兼容、路由配置没写好、缓存没预热,降级根本切不过去。生产团队应该定期做混沌演练,主动注入过载,验证降级路径是否真的生效。

我展开几个降级案例和坑。案例一:模型降级链。我们配的降级链是"旗舰 → 标准 → 小模型 → 缓存兜底 → 友好报错",过载时按这个顺序逐级降级。有一次降级切到标准模型时,发现标准模型的 prompt 模板和旗舰不一样(旗舰用 system prompt + user,标准只接 user),降级后回答质量崩塌——因为 system prompt 里的角色设定丢了。修这个坑后我们做了"降级前 prompt 归一化"中间件,无论降到哪个模型,先把 prompt 转成目标模型兼容的格式。案例二:缓存兜底的"陈旧数据"问题。语义缓存的兜底响应可能是几天前的,对时效性敏感的查询(如"今天天气""最新新闻")会答错。所以缓存兜底要带"生成时间"标记,时效敏感类查询跳过缓存兜底直接降级或报错,宁可慢不要错。案例三:功能裁剪的告知。关闭多轮上下文时,如果不告诉用户,用户会困惑"AI 怎么忘了前面说的"。所以功能裁剪要在响应里加个提示(如"系统繁忙,暂不支持上下文记忆"),让用户知道是临时降级而不是 AI 变笨,体验上能接受很多。

致命的"恢复风暴"

回到开头那个案例。恢复风暴是过载期最隐蔽的杀手:系统过载,大量请求被拒绝/降级;客户端按固定间隔重试;当系统刚恢复一点容量,所有积压的客户端同时发起重试,瞬间把刚恢复的系统再次压垮;系统在"过载-恢复-再过载"中震荡,无法真正恢复。

恢复风暴的本质不是容量问题,是协调问题——成千上万的客户端彼此不知道对方,它们各自的重试决策叠加起来,形成了对系统的同步冲击。解法要从协调入手。

第一招是客户端退避加抖动。服务端通过 Retry-After 响应头,指导客户端做指数退避(每次重试间隔翻倍)加随机抖动(间隔上加一个随机量)。指数退避让重试间隔越来越长,给系统恢复时间;随机抖动让不同客户端的重试时刻分散开,避免同步冲击。这两个配合,能从根本上消除"所有客户端同时重试"的同步性。

具体的服务端代码思路(网关层给 429 注入 Retry-After):

import random def build_429_response(): base = 1.0 # 基础退避秒数 # 指数退避 + 抖动:这里简化为单次响应给的退避建议 # 实际生产里会用"基于当前负载的自适应"算法 load_factor = current_load / capacity # 当前负载比,如 1.3 backoff = min(base * (2 ** min(retry_count, 6)), 60) # 上限 60 秒 jitter = random.uniform(0, backoff * 0.5) # 抖动量是退避的 0-50% retry_after = int(backoff + jitter) return Response(status=429, headers={"Retry-After": str(retry_after)})

关键点:抖动量要足够大(退避的 50% 甚至 100%),太小了客户端重试时刻还是会聚集。我们的经验是抖动达到退避的 50% 以上,才能把重试洪流摊平。另外 Retry-After 客户端要真的尊重,SDK 文档里要写明"严格遵守 Retry-After,不要自行立即重试",否则服务端的努力白费。

第二招是服务端逐步放量。系统恢复时不要立刻承接 100% 流量,而是逐步放量(比如先放 10%、再 30%、再全量),给系统缓冲。这可以通过网关层的接纳速率控制实现——即使后端已经恢复,网关也主动限制新请求的进入速率,避免被重试洪流二次压垮。

逐步放量的具体实现:网关维护一个"接纳率"参数,过载期降到当前的 30%,恢复时分三档爬升(30% → 50% → 80% → 100%),每档保持 30-60 秒观察错误率,错误率正常才爬下一档,错误率反弹就回退一档。这个"小步快爬 + 失败回退"的恢复曲线比线性恢复稳得多,能把震荡压下去。

第三招是请求接纳控制(Admission Control)。网关在恢复期主动控制新请求的接纳,优先服务"新请求"而非"重试请求"——因为重试请求往往是导致风暴的元凶。可以在请求里标记 is_retry,恢复期对重试请求施加更严格的限流。

恢复风暴是很多"明明扩容了却还是不稳"的根因。我见过团队反复扩容、反复加机器,系统却始终在震荡,最后发现根本不是容量问题——容量早够了,是重试协调没做好。遇到"扩容不解决问题"的情况,第一时间该怀疑的就是恢复风暴。

过载保护的设计原则

把这一节的内容凝练成几条原则。降级优于拒绝——能降级就别 429,保住可用性。快速失败优于长时间挂起——故障下游要快速失败(熔断),别让请求在队列里挂到超时。背压传导——过载信号要从推理层一路传到客户端,全链路协同减速。预留恢复缓冲——别把容量用到 100%,留余量应对突发和恢复期,否则任何风吹草动都会越界。

这四条原则的共同精神是:过载时优先保"可用"和"可控",而不是保"全量服务"。承认过载、主动降级、协调恢复,远比硬撑到全线崩溃要好。

第二章的收尾

至此第二章完成。接入与限流层的三道题都有了答案:连接怎么卸(四层分层治理)、流量怎么限(分层组合限流 + 软限流优先)、过载怎么活(优先级 + 熔断 + 降级 + 防恢复风暴)。这一层守住了"百万长连接不把入口压垮"这道关口。

下一章进入高并发能否打平成本的胜负手——上下文与缓存层。如果说网关层是"守",那缓存层就是"攻",是主动出击降低推理成本的杠杆。


作者与出处
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 会发光的石头的小龙虾 转发
评论区 (0)
U