Skip to content

给慢请求再发一份,系统反而被压垮了 ​

一个查询接口的 p99 是 300 毫秒,p50 只有 10 毫秒:绝大多数请求很快,偶尔有一个碰上垃圾回收、磁盘抖动或一次缓存未命中。于是加了一个优化:30 毫秒没有返回,就向另一个实例再发一份,谁先回来用谁。平时 p99 降到了 40 毫秒。到了流量高峰,这个接口的 p50 变成了十几秒。

这个做法叫备份请求(hedged request)。它针对的是「偶发的、与请求本身无关的慢」:同一个请求换一个实例、换一个时刻再发一次,大概率是快的。代价是多发出去的那些请求要由服务端多执行一遍。平时这个代价很小,负载高时它会自己放大。

本文用一个固定种子的模拟把两种情况都量出来。实验见文末。

到达速率 2500/s(容量的一半):p99不发备份300 ms30 ms 后发备份40 ms,执行次数 1.02 倍同上,预算 5%40 ms,执行次数 1.02 倍一开始就发两份131 ms,执行次数 2.00 倍到达速率 4500/s(容量的 89%):p99不发备份300 ms30 ms 后发备份22.4 s,执行次数 1.98 倍同上,预算 5%162 ms,执行次数 1.05 倍一开始就发两份23.0 s,执行次数 2.00 倍备份请求用多出来的容量换尾部延迟;容量不够时,它把「个别请求慢」变成「所有请求慢」
图 1 · 实测(固定种子的模拟):2% 的请求偶发地慢到 300 毫秒。负载是容量的一半时,等 30 毫秒再发备份把 p99 从 300 毫秒降到 40 毫秒,服务端只多执行 2%。负载到容量的 89% 时,排队让多数请求都等过了 30 毫秒,于是多数请求都发了备份,负载翻倍,p99 到了 22 秒。给备份请求加一个 5% 的预算,p99 是 162 毫秒。

一、模拟的设置 ​

  • 10 个实例,每个同时处理 8 个请求,多出来的在实例上排队。
  • 98% 的请求处理 10 毫秒,2% 的请求处理 300 毫秒。慢是随机落在请求上的,与实例无关。平均处理时间 15.8 毫秒,总容量约每秒 5060 个请求。
  • 请求随机到达,按「两次随机选择」分给实例,模拟 30 秒。
  • 备份请求发给重新选出的一个实例。先回来的那一份算作响应,另一份不取消,照常执行完。

比较四种做法:不发备份;30 毫秒后发备份;30 毫秒后发备份,但备份请求不超过全部请求的 5%;一开始就发两份。

二、负载是容量的一半时 ​

到达速率每秒 2500 个:

做法p50p99p99.9发了备份的请求服务端执行次数 ÷ 请求数
不发备份10 ms300 ms300 ms01.00
30 ms 后发备份10 ms40 ms40 ms1.9%1.02
同上,预算 5%10 ms40 ms40 ms1.9%1.02
一开始就发两份33 ms131 ms186 ms100%2.00

不发备份时,p99 就是那 2% 慢请求的处理时间。

等 30 毫秒再发的效果很干净:只有慢的那 2% 触发了备份,备份请求有 98% 的概率是快的,于是这些请求在 30 + 10 = 40 毫秒时拿到了结果。服务端多执行了 2%。

「一开始就发两份」看起来更激进,结果反而差:负载翻倍,每秒 5000 次执行已经贴着 5060 的容量,排队让所有请求都变慢了,p50 从 10 毫秒涨到 33 毫秒。备份请求的收益来自「只为少数慢请求多付一次」,等待时间就是用来筛出这少数的。

等待时间一般取正常情况下的 p95 左右:再短,被筛出来的请求太多;再长,尾部降不了多少。这里 98% 的请求在 10 毫秒内返回,30 毫秒筛出来的正好是慢的那一批。

三、负载接近容量时 ​

到达速率每秒 4500 个,是容量的 89%:

做法p50p99p99.9发了备份的请求服务端执行次数 ÷ 请求数
不发备份10 ms300 ms306 ms01.00
30 ms 后发备份10894 ms22430 ms22928 ms97.9%1.98
同上,预算 5%12 ms162 ms386 ms4.9%1.05
一开始就发两份11391 ms22958 ms23312 ms100%2.00

不发备份时系统还撑得住:p50 仍是 10 毫秒,p99.9 略有排队。

加上备份请求就是另一回事。过程是这样的:

  1. 负载高,一部分请求在实例上排了一会儿队,超过 30 毫秒,触发备份。
  2. 备份请求是额外的负载,排队变得更长。
  3. 更多的请求等过了 30 毫秒,触发更多备份。

这个循环没有刹车。稳定下来的状态是 98% 的请求都发了备份,服务端的执行次数接近两倍,相当于每秒 9000 次执行压在 5060 的容量上,队列一直增长到模拟结束。「30 毫秒后发备份」在过载时与「一开始就发两份」没有区别。

这个循环和下一节的预算画在一起:

负载高请求在实例上排队等过 30 毫秒分不清偶发的慢触发备份多一份执行队列更长额外负载没有刹车预算 5%超了就不发,等原来那份结果(89% 负载)无预算:备份 97.9%,执行 1.98 倍p50 10.9 秒有预算:备份 4.9%,执行 1.05 倍p99 162 毫秒负载一半时只有慢的 2% 触发备份,预算用不到;问题只在接近容量时出现,压测要压到这个区间
图 2 · 实测(固定种子的模拟,负载为容量的 89%):「30 毫秒没返回就发备份」分不清偶发的慢和排队的慢,排队越长、触发越多,最终 97.9% 的请求都发了备份,服务端执行 1.98 倍。备份不超过总请求 5% 的预算把额外负载封顶:只发出 4.9%,执行 1.05 倍,p99 162 毫秒

这里的关键是触发条件分不清两种慢:个别请求偶然慢了,与所有请求因为排队都慢了,表现都是「30 毫秒没有返回」。前一种换个实例重发有用,后一种重发只会让队列更长。这与重试放大是同一件事,见每层都重试三次,最底下的服务收到二十七倍请求。

四、给备份请求加预算 ​

第三行与第二行只差一个限制:备份请求的数量不超过请求总数的 5%,超了就不发,继续等原来的那一份。

  • 负载一半时,只有 1.9% 的请求需要备份,预算根本用不到,结果与不限制完全一样。
  • 负载 89% 时,预算挡下了 29090 次本来要发的备份,实际发出去的是 4.9%。服务端多执行 5%,仍在容量之内,p50 是 12 毫秒,p99 是 162 毫秒,比不发备份的 300 毫秒还低。

预算把备份请求能带来的额外负载封了顶。上限定在多少,取决于系统平时留了多少余量:余量 30% 的系统,备份请求最多占 5%~10% 是安全的;按 90% 利用率运行的系统,任何比例的额外负载都要小心。

gRPC 的备份请求策略里,这个机制是内置的:重试与备份请求共用一个令牌桶,失败消耗令牌,成功补充令牌,令牌低于阈值时不再发送备份。服务端还可以在响应里带一个「不要再发了」的信号,客户端收到后停止备份。自己实现时至少要有前者。

五、发出去的两份都会被执行 ​

模拟里落后的那一份不取消,所以「发了备份的请求」里几乎每一个都被服务端完整执行了两遍。这带来两件事。

取消能省下多少。 拿到第一个响应后取消另一份,能省掉它剩余的工作,过载时的表现会好一些。但取消只对还在排队、或者处理过程中会检查取消信号的请求有效;一条已经发给数据库的查询不会因为上游断开而停下。取消的传播见超时了、取消了,任务其实还在跑。

只能用在可以重复执行的请求上。 查询天然满足。写操作发两份,就是同一个操作执行两次,必须带幂等键并且服务端按它去重;做不到就不要发备份。「先回来的那个成功了,另一个可能也成功了」这一点,与超时之后不知道对方执行了没有是同一个问题,见一次 HTTP 调用超时了,对方到底执行了没有。

六、慢是实例造成的时候 ​

模拟里的慢与实例无关,所以备份发给哪个实例都一样。真实系统里另一种常见情况是某个实例整体变慢。这时备份请求仍然有用,但前提是第二份不会又落到那个慢实例上;同时所有经过慢实例的请求都会触发备份,备份的比例会明显升高,更容易撞上预算。

实例级的慢更适合在选择实例的环节解决:按在途请求数选择,或者把持续异常的实例摘除,见十个实例里一个变慢,十分之一的请求都超时了。备份请求留给剩下的那部分随机的慢。

七、检查清单 ​

  • 只对可以重复执行的请求发备份;写操作要有幂等键。
  • 等待时间取正常情况下的 p95 上下,不要一开始就发两份。
  • 备份请求有预算:占总请求的比例有上限,超了就不发。
  • 备份的比例是一个要看的指标。它平时应该接近慢请求的比例;持续顶着预算上限,说明系统在排队,备份已经不起作用了。
  • 调用链上只在一层发备份。每一层都发,与每一层都重试一样会逐层相乘。
  • 压测要压到接近容量。备份请求在低负载下的表现总是好的,问题只在高负载时出现。

配套实验

  • codesphere-labs/distributed/hedged-requests:固定种子的离散事件模拟(JDK 25.0.4.1):2% 的请求偶发变慢时,不发备份、30 毫秒后发备份、带 5% 预算、立即发两份,在容量的一半与 89% 两种负载下的延迟分位、备份比例与服务端执行次数(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权