6SRE 工作手册

防止过载

优雅降级的工程实践

负载削减熔断器重试风暴背压

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第6章 防止过载

优雅降级的工程实践

章节导读

2012 年,某大型电商在促销活动中遭遇了一个教科书级的故障:流量达到预期的三倍,系统开始变慢,于是用户开始刷新页面,客户端开始自动重试,负载均衡把重试流量分发到已经过载的后端,后端处理更慢,更多请求超时,更多重试产生——三十分钟内,整个系统进入死亡螺旋(death spiral),完全瘫痪,直到工程师手动切断大部分流量才恢复。事后复盘发现一个残酷的事实:系统在流量达到 120% 容量时还是健康的,真正杀死它的是 120% 到 150% 之间的正反馈循环。这就是过载的可怕之处:它不是线性的性能劣化,而是非线性的系统崩溃。蓝皮书第 6 章「防止过载」系统性地讨论了这个主题,它的核心洞察是:过载保护必须在系统设计阶段内建,而不是事故发生后临时加装;一个没有过载保护的系统,其真实容量不是「能处理的最大流量」,而是「开始崩溃的流量」,两者可能相差数倍。理解过载的机理是设计保护的前提。当系统负载接近容量时,会发生几件事:请求排队,队列增长导致延迟上升;延迟上升导致客户端超时,超时触发重试,重试进一步增加负载;资源(线程、连接、内存)被慢请求长期占用,可用资源减少,处理速度进一步下降;最终系统进入「负载增加 → 延迟增加 → 重试增加 → 负载更增加」的正反馈循环。这个循环一旦启动就很难自然停止,因为系统的「自救行为」(重试)恰恰是循环的燃料。Google 把这种状态称为「 serving critical capacity 之下」:系统不是慢慢变差,而是断崖式崩溃,且崩溃后即使流量回落到正常水平,系统也可能无法自动恢复(积压的队列和耗尽的资源需要人工干预)。防止过载的第一道防线是优雅降级(graceful degradation):当负载超过安全阈值时,系统主动放弃一部分功能或质量,保住核心能力。降级有几个层次:拒绝新请求(返回 429 或 503,快速失败比慢慢等死好);降低响应质量(返回缓存的旧数据、低分辨率图片、简化版页面);关闭非核心功能(推荐系统、个性化、非关键的分析上报);限制单用户消耗(每个用户每分钟最多 N 次请求)。降级的设计原则是「事先规划、自动触发、分级实施」:哪些功能可以降级、降到什么程度、什么条件触发,都必须在设计阶段确定并演练过——事故现场临时决定降级方案是最危险的。第二道防线是限流(rate limiting)。限流算法有多种:令牌桶(token bucket)允许突发流量,桶里攒了令牌就可以短时间超发;漏桶(leaky bucket)强制平滑输出,不允许突发;自适应限流根据系统当前状态(延迟、队列深度、CPU)动态调整阈值,比静态阈值更能应对容量变化。限流的位置也有讲究:在入口限流(网关层)保护整个后端,在客户端限流(每个调用方独立配额)防止单个租户拖垮所有人,在服务间限流(每个依赖调用都有配额)防止级联故障。Google 的实践是多层限流:全局入口限流 + 每用户限流 + 每服务依赖限流,层层设防。第三道防线是熔断器(circuit breaker):当下游依赖的失败率超过阈值时,熔断器「跳闸」,后续请求直接快速失败(或返回降级结果)而不再真正调用下游。熔断器给下游喘息恢复的时间,同时防止上游的资源被无望的等待耗尽。熔断器有三个状态:闭合(正常调用)、断开(快速失败)、半开(定期放行少量探测请求,成功则恢复闭合)。与熔断器配套的是重试策略的正确设计。重试是分布式系统的双刃剑:适度重试掩盖瞬时故障提升可用性,过度重试放大负载制造雪崩。防止重试风暴的规则:只对可重试的错误重试(超时、503),不对确定性错误重试(400、404);重试必须有指数退避加随机抖动(jitter),避免大量客户端同步重试形成「重试洪峰」;限制重试次数(通常 2 到 3 次)和重试预算(重试流量不超过原始流量的 10%);下游返回 429/503 时尊重 Retry-After 头,不要立即重试。Google 内部有一条重要经验:重试应该由最上层(离用户最近的层)负责,底层服务尽量不重试——每层都重试会导致重试次数指数放大(三层各重试 3 次 = 最多 27 倍流量)。第四道防线是背压(backpressure)与负载削减(load shedding)。背压是让下游的压力信号传导到上游:队列满时拒绝入队、处理慢时让调用方感知(返回 429 而不是慢慢超时),使整个调用链的流量自动调节到最慢环节能承受的水平。负载削减是更主动的策略:系统持续评估自身负载,超过安全线时按优先级丢弃请求——先丢低优先级(爬虫、批量任务、免费用户),后丢高优先级(付费用户、核心交易),丢弃时快速返回明确错误(429 + Retry-After),让客户端知道该稍后再试。负载削减的关键设计是「削减决策要快、削减对象要准、削减信号要明」:决策慢了就赶不上崩溃速度,对象不准会误伤核心业务,信号不明客户端会立即重试抵消削减效果。本章还会讨论容量预留与过载保护的关系:为什么 Google 建议系统常态运行在 60% 到 70% 容量水位(留出应对突发的余量),以及过载保护与自动扩缩容的配合——扩容需要时间(分钟级),过载保护必须在秒级生效,两者是不同时间尺度的防线。最后的案例部分将展示 Google 服务的过载保护实践:从搜索到 Gmail,这些服务如何在黑五级别的流量冲击下保持优雅。过载保护的设计哲学可以总结为一句话:宁可拒绝 10% 的请求,也不能让 100% 的请求都失败。快速失败、部分降级、保住核心,这是分布式系统在极端条件下的生存法则。

核心概念

  • 负载削减
  • 熔断器
  • 重试风暴
  • 背压

要点精析

过载的表现与机理:从延迟飙升到级联失败。识别过载的早期信号是设计保护的前提,因为过载的发展是非线性的——等错误率飙升时往往已经太晚。过载的演进通常经历四个阶段。阶段一:延迟爬升。负载接近容量时,请求开始排队,P99 延迟首先恶化(长尾比平均值敏感得多),此时 P50 可能还正常。监控要点:盯住 P99/P95 而不是平均延迟,队列深度指标(线程池活跃数、消息队列长度)是最早的预警信号。阶段二:错误率上升。队列满后开始拒绝或超时,客户端超时触发重试,负载进一步增加。此阶段的危险在于重试放大:如果每个失败请求平均重试 2 次,有效负载瞬间变成原来的 3 倍,系统被推入下一阶段。阶段三:资源耗尽。慢请求长期占用线程、连接、内存,可用资源枯竭,健康请求也开始失败。GC 压力增大导致停顿,停顿导致更多超时,更多超时导致更多重试——正反馈循环全面形成。阶段四:级联失败。本服务的失败传导到依赖它的上游(上游线程被等待占满)和它依赖的下游(重试流量冲击),单点过载演变成系统性的雪崩。级联失败的经典模式是「重试风暴 + 线程池耗尽」的组合:下游变慢 → 上游每个请求占用线程更久 → 上游线程池耗尽 → 上游也变慢 → 更上游重复此过程。理解这个机理后,过载保护的设计目标就清晰了:在阶段一就介入(基于队列深度和延迟的早期触发),阻断阶段二的重试放大(限流 + 正确的重试策略),防止阶段三的资源耗尽(超时 + 熔断 + 舱壁隔离),遏制阶段四的级联传导(每层独立保护 + 背压)。Google 的经验数据:从阶段一到阶段四可能只需要几分钟,人工介入基本来不及,所以保护必须自动化、必须事先配置、必须定期演练。

优雅降级的层次:拒绝请求、降低质量、关闭非核心功能。优雅降级的核心思想是「部分可用优于全部不可用」,设计时需要回答三个问题:降级什么、何时降级、如何恢复。降级动作按侵入性分为四个层次。层次一:关闭非核心功能。每个服务都应该在设计时区分核心功能与非核心功能:电商的核心是「浏览、搜索、下单、支付」,非核心是「推荐、个性化、评论、积分」。过载时先关非核心——推荐模块返回固定的热门列表、评论延迟加载、积分异步补记。这层降级用户几乎无感知,应该最先触发。层次二:降低响应质量。返回缓存的旧数据代替实时计算(价格可以晚 5 分钟更新)、低分辨率图片代替原图、简化版页面(纯文本无个性化)、部分结果代替完整结果(搜索只返回前 10 条)。质量降级要事先定义「最低可接受质量」,低于这条线不如直接拒绝。层次三:拒绝新请求(限流与负载削减)。返回 429/503 快速失败,配合 Retry-After 头引导客户端稍后重试。拒绝策略按优先级排序:先拒绝爬虫和批量任务,再拒绝免费用户,最后才是付费用户和核心交易。层次四:服务整体降级模式。极端情况下切换到「生存模式」:只保留最核心的读写路径,关闭一切附加功能,用最小资源维持基本服务。降级触发的设计原则:自动触发为主(基于负载指标的水位线),手动触发为辅(值班有权提前触发);分级渐进(L1 → L2 → L3 随负载上升逐级启用,随负载下降逐级恢复);每级降级都有独立的开关和明确的恢复条件,避免「降级后忘了恢复」或「恢复太早导致二次过载」。恢复条件要带迟滞(hysteresis):触发 L2 的阈值是 120% 负载,恢复 L2 的阈值应该是 100% 而不是 119%——否则系统会在临界点反复震荡。Google 的实践要求所有降级开关定期演练:一个从未被真实切换过的降级开关,大概率在需要它的时候不工作(配置过期、代码路径腐烂、依赖的缓存已失效)。

限流算法:令牌桶、漏桶与自适应限流。限流是过载保护最常用的工具,不同算法适合不同场景。令牌桶(Token Bucket):桶以固定速率 r 生成令牌,容量为 b;请求到达时取走一个令牌,无令牌则拒绝。特性:允许突发——桶里积攒的令牌支持短时间超过 r 的流量(最多 b 个请求的突发),适合用户行为天然突发的场景(页面加载瞬间发出几十个资源请求)。参数设计:r 设为长期平均允许速率,b 设为可接受的最大突发量(通常 r 的 1 到 5 秒流量)。漏桶(Leaky Bucket):请求入队,以固定速率流出处理,队列满则拒绝。特性:强制平滑输出,完全消除突发,适合保护处理能力恒定的下游(如数据库写入)。缺点是对合法突发不友好,用户体验生硬。固定窗口计数:每个时间窗口(如 1 秒)独立计数,超过阈值拒绝。实现最简单但有边界问题:窗口交界处可能通过 2 倍阈值的流量(前一窗口末尾 + 后一窗口开头)。滑动窗口日志/计数可以缓解但成本更高。自适应限流(Adaptive Concurrency Limiting):不预设固定阈值,而是根据系统实时状态动态调整——常用信号包括延迟变化率(延迟开始爬升说明接近容量,收紧限制)、CPU 使用率、队列深度、in-flight 请求数。Netflix 的 concurrency-limits 库实现了 TCP 拥塞控制风格的自适应算法(Vegas、Gradient 等),把「网络拥塞控制」的思想搬到了服务限流。自适应限流的优势是不需要精确知道容量(容量本身会随实例健康度、依赖状态、请求构成而变化),劣势是行为不如静态限流可预测,需要仔细调参和充分测试。Google 的实践建议:入口层用静态令牌桶(行为可预测、便于容量规划),服务内部用自适应限流(适应动态容量),两者叠加。多层限流的配额分配也重要:如果网关限流 1000 QPS 而后端限流 800 QPS,中间层的 200 QPS 差额会堆积在队列里——各层阈值应该协调设计,越靠近资源核心阈值越紧。

熔断器模式:防止级联失败。熔断器借鉴了电路保险丝的思想:当下游依赖持续失败时,主动切断调用,保护上游自身。没有熔断器的系统里,下游变慢会导致上游的每个调用线程长时间阻塞等待,上游线程池被耗尽,上游也随之变慢——故障沿调用链向上传导,这就是级联失败。熔断器把「无望的等待」变成「快速的失败」,让上游线程立即释放,可以用降级逻辑(返回缓存、返回默认值、跳过非关键步骤)继续服务。熔断器的三状态机:闭合(Closed)状态正常放行请求,同时统计失败率;当失败率超过阈值(如 10 秒窗口内 50%)且请求量达到最小样本数(如 20 个,防止低流量误判),切换到断开(Open)状态;断开状态下所有请求立即失败(或走降级路径),不再真正调用下游;经过冷却期(如 30 秒)后进入半开(Half-Open)状态,放行少量探测请求(如 5 个);探测成功率达到阈值则回到闭合,否则重新断开。参数设计的要点:失败的定义要精确——超时、连接错误、5xx 算失败,4xx(客户端错误)通常不算(下游没问题,是请求本身的问题);冷却期长短权衡「给下游恢复时间」和「上游降级体验时长」,通常 15 到 60 秒;半开探测量要小,避免探测流量本身打垮正在恢复的下游。熔断器的降级路径设计同样关键:熔断后返回什么?最佳实践是按调用性质区分——可缓存的数据返回上次成功值(stale-while-error),非关键调用直接跳过(推荐挂了就不显示推荐),关键调用返回明确错误让上层决策。熔断器的常见误区:只熔断不降级(快速失败但没有兜底逻辑,用户体验从「慢」变成「错」);全局熔断粒度太粗(一个下游的一个接口故障熔断了整个下游的所有调用);忘记监控熔断状态(熔断器频繁开合是系统亚健康的重要信号,应该告警并分析)。Google 的建议是每个跨服务调用都配置熔断器,并把熔断事件纳入监控——熔断器不仅是保护机制,也是依赖健康度的探测器。

重试风暴:为什么重试会放大问题,如何避免。重试是分布式系统可用性的基石——网络抖动、瞬时故障、负载均衡摘除节点,这些日常事件都靠重试掩盖。但重试也是过载事故的头号放大器:系统在过载时失败率上升,失败触发重试,重试增加负载,负载加重过载——重试把 20% 的流量超载放大成 100% 的流量海啸。更隐蔽的是「重试层级放大」:客户端重试 3 次,网关重试 2 次,服务 A 调服务 B 重试 2 次,一个原始请求最坏情况产生 3 × 2 × 2 = 12 个请求。Google 内部事故复盘的常见发现是:事故的直接原因往往不是初始故障(那可能只是一次普通的实例重启),而是重试风暴把小故障放大成了大事故。防止重试风暴的规则体系:第一,只对可重试错误重试。超时、连接拒绝、503(服务过载)、429(限流)是可重试的;400、404、401 是确定性错误,重试一万次结果也一样。第二,指数退避加抖动。第一次重试等 100ms,第二次 200ms,第三次 400ms,每次加 ±50% 随机抖动。抖动至关重要:没有抖动,同一时刻失败的一万个客户端会在同一时刻重试,形成同步洪峰;抖动把重试打散到时间轴上。第三,限制重试预算。全局层面,重试流量不应超过原始流量的 10%——当重试预算耗尽,后续失败直接返回错误不再重试。这个机制保证重试放大倍数有硬上限。第四,尊重服务端信号。收到 429/503 时读取 Retry-After 头,按服务端要求的时间等待;服务端明确拒绝说明它已过载,立即重试等于落井下石。第五,重试责任单点化。整个调用链只允许一层负责重试(通常是离用户最近的层),下游各层不重试只快速失败。第六,幂等性前提。只有幂等操作才能安全重试——非幂等的写操作(扣款、下单)重试可能造成重复执行,必须配合幂等键(idempotency key)机制。Google 的额外实践:客户端库层面统一实现重试策略(而不是每个团队自己写),重试行为可被服务端配置动态调整(过载时服务端可以下发「暂停重试」指令),重试指标单独监控(重试率飙升是过载的早期信号,往往早于错误率告警)。

背压(Backpressure):让上游感知下游压力。背压概念源自流体力学:管道下游受阻时,压力会向上游传导,迫使上游减速。在分布式系统中,背压指的是把「处理能力不足」的信号从下游快速、准确地传导到上游,让整条调用链的流量自动调节到最弱环节能承受的水平。没有背压的系统里,下游过载的表现是「默默变慢然后崩溃」:上游感知不到压力,继续以原速率发送请求,请求在下游队列里堆积,延迟无限增长,最终内存耗尽或全部超时。背压的实现机制有多种。同步 RPC 场景:下游返回 429/503 加 Retry-After 头,上游收到后立即减速(配合客户端限流器动态调整发送速率);更精细的做法是下游在响应头里携带「当前负载水位」,上游据此按比例调节。消息队列场景:消费者处理不过来时,队列深度增长——这本身就是一种背压信号,生产者应该监控队列深度并主动降速,或者队列设置容量上限、满时拒绝入队(把压力立即反馈给生产者而不是无限缓冲)。流式处理场景:TCP 的滑动窗口就是天然背压,应用层协议(如 gRPC 的 flow control、Reactive Streams 的 request(n) 语义)把这种机制延伸到应用逻辑。响应式编程场景:RxJava/Reactor 的背压操作符(onBackpressureBuffer/Drop/Latest)让数据流消费者声明自己处理不过来的策略。背压设计的三个原则。第一,快速反馈优于缓慢缓冲:无界队列是背压的敌人——它把「立即失败」变成「延迟很久后失败」,上游在漫长的排队中毫无察觉,等发现时下游已经崩溃。有界队列 + 满时拒绝是更健康的模式。第二,压力信号要可分级:理想的背压不是二元的「行/不行」,而是连续的水位信号,让上游能渐进减速而不是急刹车。第三,背压要与优先级结合:压力传导时,低优先级流量先被压制(批量任务减速),高优先级流量尽量保持(用户请求通行),这就是负载削减与背压的结合点。Google 的实践案例:Borg 集群调度器的准入控制就是大型背压系统——作业提交速率超过集群消化能力时,调度器排队并反馈预计等待时间,客户端据此决定是等待、降低作业规模还是放弃。背压的测试方法:压测时观察「下游过载 → 上游减速」的传导时间和传导比例,健康的背压系统里上游速率应该在下游过载后几秒内自动下降,且整条链路的错误率保持低位(因为流量被主动调节了),而不是大量超时。

负载 Shedding:主动丢弃请求的策略设计。负载削减(load shedding)是过载保护的最后手段:当系统负载超过安全线,主动、有选择地丢弃一部分请求,用局部的牺牲换取整体的存活。它与普通限流的区别在于「选择性」:限流通常按速率一刀切,shedding 按请求的价值和成本做智能取舍。Shedding 的优先级设计是核心。Google 的做法是为每类请求定义优先级,过载时从低到高逐级削减:最低优先级是爬虫流量、后台批量任务、健康检查之外的内部探测;然后是免费用户的非核心操作(浏览历史、收藏同步);再是免费用户的核心操作;然后是付费用户的非核心操作;最后是付费用户的核心操作(几乎不会被削减)。优先级信息通过请求头或令牌传递,shedding 组件在入口处根据当前负载水位和请求优先级决定放行或拒绝。Shedding 的触发机制通常基于「自适应水位」:系统持续评估自身状态(in-flight 请求数、队列深度、CPU),当状态超过安全线时开始按概率丢弃低优先级请求,负载越高丢弃概率越大,直到只保留最高优先级。这种概率式削减比阈值式更平滑,避免「刚好在阈值上下震荡」。被削减请求的响应设计同样重要:返回 429(Too Many Requests)而不是 503——429 语义上告诉客户端「你没问题,是我忙,稍后再来」,配合 Retry-After 头给出建议等待时间;响应体可以包含降级指引(「当前系统繁忙,请稍后重试或使用轻量版页面」)。最坏的做法是削减后让请求慢慢超时——客户端不知道被削减了,会立即重试,抵消削减效果。Shedding 的成本核算:削减多少才够?这需要容量模型支持——已知安全容量 C 和当前负载 L,需要削减 L - C×0.9(留 10% 余量)的流量。如果优先级分布均匀,从最低优先级开始削减直到补足差额。Google 的经验是 shedding 策略必须定期演练:每季度在预发环境模拟 150% 负载,验证削减的优先级顺序正确、触发及时、恢复平滑。一个真实的教训:某服务的 shedding 配置里优先级标签拼写错误,导致过载时削减的是付费用户而爬虫安然无恙——这种错误只有演练能发现。

容量预留与过载保护的关系:为什么系统不能跑满。一个反直觉但重要的工程原则:生产系统的常态负载应该显著低于其最大容量,Google 的典型水位是 60% 到 70%。为什么不跑到 90%?三个理由。第一,突发流量需要缓冲:真实流量不是平稳的,促销、热点事件、爬虫突发都会造成分钟级的流量尖峰,预留的 30% 到 40% 容量是吸收尖峰的缓冲垫。第二,故障冗余需要余量:N+1 或 N+2 冗余意味着任何时刻都有实例可能故障,剩余实例要能接管故障实例的负载——如果常态水位 90%,一台实例故障就会把其余实例推入过载。Google 的数据中心设计标准是「任意一个机架掉电,剩余容量仍能承载全部负载」。第三,过载保护需要工作空间:限流、shedding 的阈值都设定在容量附近,如果常态负载已经贴着阈值运行,保护机制会频繁误触发,系统行为变得不可预测。容量预留的成本与收益需要量化权衡:预留 40% 容量意味着多 40% 的机器成本,但对比一次全站崩溃的业务损失(收入、信誉、用户流失),这个保险费极其便宜。Google 的优化手段让预留成本可控:混合部署(在线服务与批处理任务共享机器,在线负载低谷时批处理填满空闲资源)、自动扩缩容(按负载动态调整实例数,预留变成「快速扩容能力」而非「常备闲置机器」)、全局流量调度(某区域过载时把流量切到有余量的区域)。过载保护与容量规划的配合公式:安全容量 = 实测崩溃点 × 0.7(安全系数);常态水位 = 当前负载 ÷ 安全容量,超过 70% 触发扩容流程;限流阈值 = 安全容量 × 0.9(在崩溃点之前拦截);shedding 阈值 = 安全容量 × 1.0(最后防线)。这套数字的关系要整体设计:限流阈值低于 shedding 阈值,两者都远低于实测崩溃点,形成层层递进的防线。另一个实践要点:容量数字必须定期重测——代码变更、依赖升级、请求构成变化都会改变真实容量,一年前测的崩溃点今天可能已经失效。Google 的做法是把容量测试纳入持续集成:每次重大发布前自动跑基准压测,容量下降超过 10% 阻断发布。

案例:Google 服务的过载保护实践。蓝皮书和黑皮书记录了多个 Google 服务的过载保护设计,其中搜索服务的实践最具代表性。Google 搜索面临的过载场景极其多样:全球性热点事件(突发事件导致搜索量瞬间翻倍)、爬虫风暴(新站点上线被爬虫疯狂抓取索引)、基础设施故障(某数据中心网络抖动导致流量涌向其他中心)、以及最隐蔽的「内部重试放大」(某个内部服务的 bug 导致对搜索接口的重复调用)。搜索的防护体系分四层。第一层:全球流量调度。Google 的 DNS 和 anycast 体系把用户请求路由到最近且健康的数据中心,某中心过载时自动把流量切到其他中心——这是最大粒度的负载削减。第二层:入口配额。每类调用方(用户前端、API 客户、内部服务、爬虫)有独立配额,配额总量略低于集群安全容量,任何单一调用方失控都不会拖垮全局。爬虫流量被特别管理:Googlebot 的抓取速率根据网站承受能力和自身集群负载动态调节。第三层:查询级降级。过载时搜索自动降级:减少每次查询扫描的索引分片数(结果质量略降但速度快)、关闭部分实验性功能、对复杂查询(大量 OR 条件、深度分页)施加更严的限制。第四层:单元级保护。每个搜索单元(index serving 分片)有独立的过载保护,单元过载时快速失败并由上层用其他副本重试(受重试预算约束)。这套体系的效果在多次真实事件中验证:某年超级碗期间搜索流量创历史新高,系统自动降级了非核心功能,用户几乎无感知;某数据中心因冷却故障紧急下线,全球流量调度在几分钟内完成切换。Google 实践的三条可迁移经验:第一,过载保护是体系不是单点——任何单一机制(只靠限流或只靠扩容)都会被绕过,多层防御才能覆盖多样的过载场景;第二,降级要事先设计并演练——搜索的每种降级模式都有明确的开关、触发条件和质量影响评估,全部经过定期演练;第三,把「调用方身份」作为一等公民——知道请求来自谁,才能做精细的配额管理和优先级削减,匿名流量只能一刀切。

代码示例

/* 示例 1:令牌桶限流器实现(TypeScript) */
class TokenBucket {
  private tokens: number;
  private lastRefill: number;
  constructor(
    private rate: number,      /* 每秒生成令牌数 */
    private capacity: number,  /* 桶容量 = 最大突发量 */
  ) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }
  tryAcquire(n = 1): boolean {
    const now = Date.now();
    /* 按流逝时间补充令牌 */
    this.tokens = Math.min(
      this.capacity,
      this.tokens + ((now - this.lastRefill) / 1000) * this.rate,
    );
    this.lastRefill = now;
    if (this.tokens >= n) { this.tokens -= n; return true; }
    return false;
  }
}
/* 用法:rate=1000 表示长期允许 1000 QPS,
   capacity=2000 表示允许最多 2000 个请求的瞬时突发 */
const limiter = new TokenBucket(1000, 2000);
这个 30 行的令牌桶实现覆盖了核心语义:按时间流逝连续补充令牌(而不是每秒批量重置,后者会造成窗口边界的流量尖峰)、桶容量限制最大突发量、无令牌时立即拒绝。参数设计的经验法则:rate 设为「单实例安全 QPS × 0.9」(留 10% 余量给测量误差),capacity 设为 rate 的 1 到 3 秒流量(允许页面加载这类天然突发,但不允许持续超载)。生产环境的注意事项:单机令牌桶只保护单实例,集群限流需要集中式计数(Redis + Lua 脚本保证原子性)或「全局限额 ÷ 实例数」的本地近似(实例数变化时要动态调整);分布式限流器本身可能成为瓶颈和单点,Google 的做法是本地限流为主(快、无依赖)、全局限流为辅(防止整体超载),两层叠加。测试限流器时记得验证突发行为:连续打入 capacity 个请求应该全部通过,第 capacity+1 个被拒绝,等待 1 秒后又能通过约 rate 个——这三个断言覆盖了令牌桶的全部语义。
/* 示例 2:指数退避加抖动的重试策略 */
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  opts = { maxRetries: 3, baseMs: 100, factor: 2, jitter: 0.5 },
): Promise<T> {
  let attempt = 0;
  while (true) {
    try {
      return await fn();
    } catch (err) {
      /* 只对可重试错误继续 */
      if (!isRetryable(err) || attempt >= opts.maxRetries) throw err;
      const backoff = opts.baseMs * Math.pow(opts.factor, attempt);
      /* 抖动: 在 [0.5x, 1.5x] 区间随机化,打散重试洪峰 */
      const delay = backoff * (1 - opts.jitter + Math.random() * 2 * opts.jitter);
      /* 尊重服务端 Retry-After */
      const retryAfter = getRetryAfterHeader(err);
      await sleep(retryAfter ? retryAfter * 1000 : delay);
      attempt++;
    }
  }
}
function isRetryable(err: any): boolean {
  const status = err?.status ?? err?.response?.status;
  return status === 429 || status === 503 || err?.code === "ETIMEDOUT";
}
这段代码实现了防重试风暴的全部关键要素。isRetryable 白名单只放行三类可重试错误:429(服务端限流,明确告知稍后再来)、503(服务过载或实例下线)、超时(网络瞬时故障)——400/404/401 等确定性错误立即抛出,避免无意义重试。指数退避(100ms → 200ms → 400ms)给服务端逐级递增的恢复时间。抖动的实现是「全抖动」变体:在标称退避值的 ±50% 区间随机——没有抖动时,同一秒失败的 1 万个客户端会在同一秒集体重试,形成周期性洪峰;抖动把它们均匀打散。Retry-After 头的优先级高于本地退避计算:服务端最清楚自己什么时候能缓过来,客户端的猜测应该让位。maxRetries: 3 意味着一个请求最多产生 4 次调用(1 原始 + 3 重试),配合「重试责任单点化」原则(调用链只有一层做重试),总放大倍数可控。进阶实践:加入全局重试预算——统计最近 10 秒重试流量占比,超过 10% 时停止新的重试直接失败,这是防止重试风暴的最后一道闸。
/* 示例 3:熔断器状态机实现 */
class CircuitBreaker {
  private state: "CLOSED" | "OPEN" | "HALF_OPEN" = "CLOSED";
  private failures = 0; private successes = 0;
  private openedAt = 0;
  constructor(
    private failureThreshold = 0.5,   /* 失败率阈值 */
    private minRequests = 20,         /* 最小样本数,防低流量误判 */
    private cooldownMs = 30000,       /* 断开冷却期 */
    private probeCount = 5,           /* 半开探测请求数 */
  ) {}
  async call<T>(fn: () => Promise<T>, fallback: () => T): Promise<T> {
    if (this.state === "OPEN") {
      if (Date.now() - this.openedAt < this.cooldownMs) return fallback();
      this.state = "HALF_OPEN"; this.successes = 0; this.failures = 0;
    }
    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (err) {
      this.onFailure();
      if (this.state === "HALF_OPEN") return fallback();
      throw err;
    }
  }
  private onSuccess() {
    if (this.state === "HALF_OPEN") {
      if (++this.successes >= this.probeCount) this.state = "CLOSED";
    } else { this.successes++; this.maybeReset(); }
  }
  private onFailure() {
    this.failures++;
    const total = this.failures + this.successes;
    if (this.state === "HALF_OPEN" || (total >= this.minRequests && this.failures / total > this.failureThreshold)) {
      this.state = "OPEN"; this.openedAt = Date.now();
    }
  }
  private maybeReset() { /* 滑动窗口衰减统计,此处简化 */ }
}
这个熔断器实现了标准三状态机的完整语义。几个容易被简化实现遗漏的细节:minRequests 防止低流量误判——凌晨 3 点只有 2 个请求、恰好都失败,失败率 100% 但没有统计意义,不应该跳闸;probeCount 控制半开状态的探测流量——熔断器恢复时下游还很脆弱,一次放行全部流量会造成二次冲击(这也是「熔断器恢复导致下游再次崩溃」事故的根源),只放 5 个探测请求,全部成功才完全恢复;fallback 参数强制调用方思考降级路径——熔断后返回什么?缓存旧值、默认配置、还是明确错误?没有 fallback 的熔断器只是把「慢失败」变成「快失败」,用户体验依然受损。生产级实现还需要:滑动时间窗口统计(而不是累积计数,否则早期的失败永远影响失败率)、熔断状态变化的事件上报(频繁开合是系统亚健康的信号,应该告警)、按依赖和接口维度的独立熔断器(对下游 A 的 /foo 熔断不应该影响对 A 的 /bar 调用)。测试熔断器的标准用例集:正常流量保持闭合、持续失败触发断开、断开期间走 fallback、冷却后进入半开、探测成功恢复闭合、探测失败重新断开。
/* 示例 4:基于优先级的负载削减中间件(伪代码) */
const PRIORITY = { PAID_CORE: 0, PAID_OTHER: 1, FREE_CORE: 2, FREE_OTHER: 3, BATCH: 4, CRAWLER: 5 };
function sheddingMiddleware(req, res, next) {
  const load = currentLoadRatio();  /* 当前负载 / 安全容量 */
  const priority = req.headers["x-request-priority"] ?? PRIORITY.FREE_OTHER;
  /* 水位 < 1.0 全部放行;1.0-1.2 削减 BATCH 及以下;
     1.2-1.5 再削减 FREE_*;> 1.5 概率削减 PAID_OTHER */
  const shedLevel =
    load < 1.0 ? -1 :
    load < 1.2 ? PRIORITY.BATCH :
    load < 1.5 ? PRIORITY.FREE_CORE : PRIORITY.PAID_OTHER;
  if (priority >= shedLevel && shedLevel >= 0) {
    /* 概率随超载程度递增,避免阈值震荡 */
    const shedProb = Math.min(1, (load - 1.0) * 2);
    if (Math.random() < shedProb) {
      res.status(429)
         .set("Retry-After", String(Math.ceil(5 * load)))
         .json({ error: "overloaded", priority_shed: true });
      metrics.increment("shed_requests", { priority });
      return;
    }
  }
  next();
}
这段中间件展示了生产级负载削减的三个设计要点。第一,优先级来自请求方声明(x-request-priority 头,由认证层根据用户等级注入,不信任客户端自报)——知道「谁在请求」是精细削减的前提,匿名流量只能一刀切。第二,分级水位与渐进概率的结合:水位决定「削减到哪个优先级为止」,概率决定「该优先级内削减多少比例」,概率随超载程度线性增长(超载 10% 时削减概率 20%,超载 50% 时概率 100%)。纯阈值式削减会在水位临界点震荡(削减 → 水位下降 → 停止削减 → 水位回升 → 再削减),概率式让削减力度平滑过渡。第三,被削减请求的响应规范:429 状态码(语义是「你没错,我忙」)+ Retry-After 头(告诉客户端等多久,且等待时间随负载增长)+ 明确的 JSON 错误体 + 按优先级维度的指标上报。最后的 metrics 上报至关重要:削减了多少、削减了谁,这些数据既是事后分析的输入,也是容量规划的信号——如果 PAID_CORE 都开始被削减,说明容量缺口已经到了必须立即扩容的程度。
/* 示例 5:过载保护的 Prometheus 告警配置 */
groups:
  - name: overload-protection
    rules:
      /* 早期预警:P99 延迟爬升(过载阶段一的信号) */
      - alert: LatencyClimbing
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket{job="api"}[5m])) by (le)
          ) > 2 * histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket{job="api"}[1h] offset 1d)) by (le)
          )
        for: 10m
        labels: { severity: ticket }
        annotations:
          summary: "P99 延迟达到昨日同时段 2 倍以上,可能接近容量拐点"
      /* 负载水位告警:接近安全容量 */
      - alert: CapacityWaterLevelHigh
        expr: |
          sum(rate(http_requests_total{job="api"}[5m]))
          / on() group_left
          scalar(api_safe_capacity_qps) > 0.7
        for: 15m
        labels: { severity: ticket }
        annotations:
          summary: "负载超过安全容量 70%,启动扩容评估"
      /* 削减发生告警:shedding 已触发说明保护在工作但容量不足 */
      - alert: LoadSheddingActive
        expr: sum(rate(shed_requests_total[5m])) > 0
        for: 5m
        labels: { severity: page }
        annotations:
          summary: "负载削减已触发,核心用户可能受影响"
这组告警对应过载演进的三个阶段,形成递进的预警体系。LatencyClimbing 是最早的信号:用「当前 P99 与昨日同时段 P99 的比值」做基线对比,比绝对阈值更适应业务的昼夜周期——延迟翻倍往往意味着负载接近容量拐点(排队论:利用率接近 100% 时延迟非线性飙升),此时系统还没出错但已在悬崖边。CapacityWaterLevelHigh 直接监控负载与安全容量的比值,70% 阈值对应 Google 的常态水位红线,触发后启动扩容评估(自动扩容或人工审批),给扩容留出时间窗口。LoadSheddingActive 是 page 级告警——削减触发说明前两道防线都没拦住,核心用户可能已受影响,需要立即人工介入(紧急扩容、切流、或接受降级状态直到负载回落)。三条告警的 severity 递进(ticket → ticket → page)体现了「越早的信号越从容」的原则。注意 api_safe_capacity_qps 这个容量值应该是动态维护的配置(压测更新、扩缩容后重算),硬编码过时的容量值会让水位告警完全失真——容量数字本身也需要「新鲜度」管理。

实验笔记

本实验的目标是为一个服务设计并验证完整的过载保护体系。实验分五个部分,预计 5 到 6 小时,需要一个可以做压力测试的实验环境。第一部分:容量基线测量(约 1.5 小时)。用压测工具(如 k6、wrk 或 Locust)对目标服务做梯度加压:从 10% 预估容量开始,每 2 分钟增加 10%,记录每个负载水平下的 P50/P95/P99 延迟、错误率、CPU/内存使用率。找到三个关键点:拐点(延迟开始非线性上升的负载水平)、崩溃点(错误率超过 5% 的负载水平)、安全水位(建议设为拐点的 70%)。产出容量报告:单实例安全 QPS、推荐副本数计算公式(目标 QPS ÷ 单实例安全 QPS × 1.4 冗余系数)。检查清单:压测是否覆盖了真实请求的分布(读写比例、参数多样性)?是否排除了缓存命中率虚高的影响?第二部分:降级方案设计(约 1 小时)。为服务设计三级降级方案。L1 轻度降级(负载达安全水位 100%):关闭非核心功能(推荐模块、分析上报),返回缓存数据替代实时计算。L2 中度降级(120%):限制单用户请求频率,简化响应内容(只返回核心字段)。L3 重度降级(150%):负载削减,按优先级丢弃请求(爬虫和批量任务先丢,普通用户次之,付费用户最后),返回 429 + Retry-After。为每级降级写明:触发条件(精确的指标和阈值)、降级动作(精确到配置开关名)、恢复条件(负载回落到多少持续多久)、用户影响说明。第三部分:限流与熔断实现(约 1.5 小时)。在实验环境实现:入口层令牌桶限流(阈值设为单实例安全 QPS 的 110%);对下游依赖的调用加熔断器(失败率 50% 持续 10 秒跳闸,30 秒后半开探测);客户端重试策略改为指数退避加抖动(基础 100ms,倍数 2,抖动 ±50%,最多 3 次,尊重 429 的 Retry-After)。用代码或现成组件(如 resilience4j、Envoy 的限流熔断配置)实现均可。第四部分:过载演练(约 1 小时)。这是实验的核心。依次执行三个场景并记录系统行为:场景 A「无保护过载」:关闭所有限流熔断,加压到 200% 容量,观察死亡螺旋——记录从过载到完全崩溃的时间、崩溃后流量回落系统能否自恢复。场景 B「有保护过载」:开启全部保护,同样加压到 200%,验证系统稳定在降级状态——L1/L2/L3 依次触发、核心请求成功率保持在 90% 以上、流量回落后自动恢复。场景 C「重试风暴」:模拟客户端疯狂重试(重试间隔 0),验证限流和 Retry-After 是否有效遏制风暴。对比 A 和 B 的数据,量化保护体系的价值。第五部分:复盘与文档(约 45 分钟)。回答:降级触发的时机是否恰当(太早浪费容量、太晚来不及)?429 响应是否被测试客户端正确处理?熔断器恢复时有没有二次冲击?把演练结果写成过载保护配置文档,包含所有阈值的设定依据。决策树练习:生产环境突发流量达到 180%,自动降级已触发但核心成功率仍在下跌,你作为值班的下一步动作是什么?(预期答案路径:确认降级全部生效 → 检查是否有未受保护的依赖拖垮系统 → 手动触发更激进的削减 → 同时启动紧急扩容 → 若仍无效,考虑部分区域切流。)

延伸阅读

  • The Site Reliability Workbook 第 6 章 "Preventing Overload"——本章原始出处,详细讨论了优雅降级的层次设计、负载削减的优先级策略、Google 内部的过载保护配置实践,以及多个真实过载事故的复盘。原文关于「降级决策应该在系统设计时做出而非事故现场」的论述是全书最有价值的工程原则之一。
  • SRE 黑皮书第 21 章 "Handling Overload"——过载主题的理念篇,深入讲解了服务过载的机理、客户端重试的放大效应、负载削平(load leveling)策略,以及 Google 服务如何应对突发流量。与蓝皮书第 6 章配合阅读,理论加实践完整覆盖。
  • Release It!(Michael Nygard 著)——稳定性模式的百科全书,熔断器(Circuit Breaker)模式正是由这本书推广开来。书中还系统讲解了舱壁(Bulkhead)、稳态(Steady State)、快速失败等模式,以及著名的「反模式」章节(阻塞线程链、重试风暴、级联失败)。每个模式都配有真实事故案例,是设计韧性系统前必读。
  • Google Cloud Architecture Framework: 可靠性设计中的过载保护章节——面向云原生架构的过载保护指南,覆盖 API 网关限流、Serverless 并发控制、托管服务的配额管理。如果你的系统构建在云上,这一章比通用理论更贴近实际选型。`, 《Netflix 技术博客:Hystrix 与韧性工程系列》——Netflix 的 Hystrix 库(现已维护模式,继任者为 resilience4j)文档和博客详细阐述了熔断器、舱壁隔离、请求缓存的实战设计。虽然 Hystrix 已停止活跃开发,其设计思想仍是韧性工程的经典教材。
  • AWS Architecture Blog: Throttling, Shedding, and Backpressure——AWS 官方关于限流、负载削减和背压的工程实践文章,包含大量真实服务的设计案例和参数选择依据。与 Google 的实践对照阅读,可以发现不同公司在同一问题上的思路差异与共识。`, 《The Tail at Scale》(Jeff Dean 与 Luiz Barroso 的论文)——讲解大规模服务中「长尾延迟」的成因与对策,包括对冲请求(hedged requests)、绑定请求(tied requests)等 Google 核心技术。过载往往先表现为长尾恶化,理解延迟的分布特性是设计过载保护的前提。
  • k6 与 Locust 官方文档的过载测试指南——两个主流压测工具的官方文档都包含「如何找到系统崩溃点」的方法论:梯度加压、拐点识别、崩溃后恢复测试。第 6 章的实验部分依赖这些工具,先读文档能让演练事半功倍。
6/12防止过载
0%