跳到主要内容

429 错误背后的惊群效应:速率限制是一个分布式系统问题

· 阅读需 13 分钟
Tian Pan
Software Engineer

调出你上次遇到持续 429 错误时的请求日志。你可能会发现一些奇怪的现象:错误并不是以稳定的流形式出现的。它们成波浪式出现——一波 429 爆发,接着是一段安静的间隔,然后是更大的爆发,再接着是另一个间隔。服务商的配额在波动期间并没有改变。你的流量也没有激增。你所看到的其实是你自己的重试逻辑在自我同步。每个在第 0 秒失败的客户端都计算了相同的退避(backoff)延迟,睡眠了相同的时长,并在同一瞬间醒来,然后再次共同失败。

这就是“惊群效应”(thundering herd),而最讽刺的是,标准的修复方案——指数退避(exponential backoff)——并不能阻止它。确定性的指数退避“组织”了惊群。它将一群几乎在同一时刻失败的客户端聚集起来,并让他们步调一致地前进:所有人都在 1 秒后重试,然后所有人都在 2 秒后,再然后是 4 秒。负载峰值的间隔变得更远了,但每个峰值的高度依然和第一个一样。如果正是这个峰值触发了你的速率限制,那么你实际上构建了一个永远重新触发它的节拍器。

大多数团队都是通过惨痛的教训才发现这一点的,因为速率限制处理通常被视为从服务商文档中复制的一段代码片段,而不是它真正的本质:一个分布式系统问题。数十个甚至数百个进程——API worker、批处理作业、agent 循环——在没有协调器的情况下竞争一个共享的、外部强制执行的资源。一旦你认真对待这个框架,三十年来的分布式系统研究成果就直接适用,它们告诉了我们三件事:随机化重试、限制总重试量,以及最终你需要构建一个调度器。让我们按顺序来看。

为什么指数退避会同步你的客户端

这种失效模式通过一个具体的时间线最容易看清楚。假设 100 个 worker 都在同一个 200 毫秒的时间窗内发起请求,而你的服务商对所有请求都进行了限流。每个 worker 都运行相同的重试代码:等待 base × 2^attempt 后重试。如果基数(base)是一秒,那么所有 100 个 worker 都会睡眠一秒,并在同一个 200 毫秒的时间窗内醒来。如果服务商每分钟的预算可以容纳 60 个请求,那么另外 40 个会再次被限流——而现在这 40 个是“完美”同步的,因为它们都在同一个墙上时钟(wall-clock)瞬间失败。到了第三轮,每一波的幸存者会形成越来越紧密的集群。退避计划是确定的,触发因素是共享的,因此重试在构造上就是相关的。

AWS 通过一个模拟实验证明了这一点,该实验后来成为了经典引用:客户端在乐观并发(optimistic concurrency)下竞争共享资源,并使用纯粹的指数退避进行重试。结果显示,竞争几乎没有下降——重试仍然成簇出现,只是间隔得更远了。指数计划在时间上分散了波浪,但它对在波浪“内部”分散客户端毫无作用。

在 LLM 工作负载中,还有一个微妙的加剧因素:失败触发因素比大多数系统更加“共享”。当传统服务降级时,客户端会因为各自的请求超时而在稍有不同的时间失败。而当服务商的每分钟 token 桶(token-per-minute bucket)耗尽时,在那一刻之后到达的所有人都会“同时且立即”收到 429 错误。同步更加剧烈,因此惊群更加紧密。Agent 框架使情况变得更糟——一个在每一步扇出五个并行工具调用的多 agent 流水线,在任何重试逻辑运行之前,本身就已经是一个小型协调爆发。

抖动(Jitter)不是锦上添花 — 它是真正的解决方案

修复方法是随机性。每个客户端不再睡眠精确的 base × 2^attempt,而是从一个指数增长的窗口中抽取一个随机时长进行睡眠。标准的变体,按其去相关(decorrelate)的剧烈程度从小到大排列如下:

  • Equal jitter: 睡眠一半的指数延迟,加上另一半时长内的随机量。保留一个保证的最小等待时间,将剩余部分随机化。
  • Full jitter: 在 0 到完整指数延迟之间选择一个均匀分布的随机时长。整个等待过程都是随机的。
  • Decorrelated jitter: 在基底和“上一次”睡眠时长的三倍之间选择一个随机时长,并设定上限。每个客户端的计划独立于重试次数和所有其他客户端。

在 AWS 的模拟中,无抖动变体的表现远差于其他所有方案,以至于不得不将其从对比图表中移除。在带有抖动的变体中,full jitter 在服务器负载和完成时间(time-to-completion)的综合衡量上胜出,并且它仍然是默认的推荐方案——包括在大多数官方 LLM 服务商的 SDK 中。这一点值得检查而非盲目假设:一些流行的客户端在发布时就启用了抖动退避,而团队随后又在上面叠加了自己简陋的重试循环,从而重新引入了 SDK 已经解决的同步问题。

这种建议的博客版本通常会忽略两个实用的注意事项。首先,抖动也应该应用在“第一次”尝试中,而不仅仅是重试。如果一个定时任务(cron)或队列清理在分钟开始时释放了 500 个作业,那么在任何失败发生之前,它们就是一个同步的惊群;几百毫秒的随机启动延迟就能瓦解它。

其次,当服务商发送 retry-after 响应头时(主要的 LLM API 都会发送),请遵守它,但要在它“周围”加入抖动。如果 80 个客户端在同一时刻都收到了 retry-after: 30,那么精确睡眠 30 秒就是在服务商自己的“祝福”下重新创建了步调一致的波动。将该响应头视为一个底限,并在其之上增加一个随机分布。

退避延缓了惊群效应;预算则彻底终结了它

生产环境事故报告中有一个令人不安的结论:即使是正确带有抖动(jitter)的指数退避(exponential backoff),也仅仅是推迟了持续故障期间的负载放大。它并不能限制负载的上限。

这种计算方式简单而残酷。如果你的客户端对每次失败重试两次,那么在大多数请求都失败的事故期间,每个逻辑请求都会变成三个物理请求——即 3 倍的放大。如果服务 A 重试对 B 的调用,而 B 又重试对 C 的调用,这种放大效应就会倍增:两层各两次重试,会导致栈底承受 9 倍的负载。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates