给慢请求再发一份,系统反而被压垮了
一个查询接口的 p99 是 300 毫秒,p50 只有 10 毫秒:绝大多数请求很快,偶尔有一个碰上垃圾回收、磁盘抖动或一次缓存未命中。于是加了一个优化:30 毫秒没有返回,就向另一个实例再发一份,谁先回来用谁。平时 p99 降到了 40 毫秒。到了流量高峰,这个接口的 p50 变成了十几秒。
这个做法叫备份请求(hedged request)。它针对的是「偶发的、与请求本身无关的慢」:同一个请求换一个实例、换一个时刻再发一次,大概率是快的。代价是多发出去的那些请求要由服务端多执行一遍。平时这个代价很小,负载高时它会自己放大。
本文用一个固定种子的模拟把两种情况都量出来。实验见文末。
一、模拟的设置
- 10 个实例,每个同时处理 8 个请求,多出来的在实例上排队。
- 98% 的请求处理 10 毫秒,2% 的请求处理 300 毫秒。慢是随机落在请求上的,与实例无关。平均处理时间 15.8 毫秒,总容量约每秒 5060 个请求。
- 请求随机到达,按「两次随机选择」分给实例,模拟 30 秒。
- 备份请求发给重新选出的一个实例。先回来的那一份算作响应,另一份不取消,照常执行完。
比较四种做法:不发备份;30 毫秒后发备份;30 毫秒后发备份,但备份请求不超过全部请求的 5%;一开始就发两份。
二、负载是容量的一半时
到达速率每秒 2500 个:
| 做法 | p50 | p99 | p99.9 | 发了备份的请求 | 服务端执行次数 ÷ 请求数 |
|---|---|---|---|---|---|
| 不发备份 | 10 ms | 300 ms | 300 ms | 0 | 1.00 |
| 30 ms 后发备份 | 10 ms | 40 ms | 40 ms | 1.9% | 1.02 |
| 同上,预算 5% | 10 ms | 40 ms | 40 ms | 1.9% | 1.02 |
| 一开始就发两份 | 33 ms | 131 ms | 186 ms | 100% | 2.00 |
不发备份时,p99 就是那 2% 慢请求的处理时间。
等 30 毫秒再发的效果很干净:只有慢的那 2% 触发了备份,备份请求有 98% 的概率是快的,于是这些请求在 30 + 10 = 40 毫秒时拿到了结果。服务端多执行了 2%。
「一开始就发两份」看起来更激进,结果反而差:负载翻倍,每秒 5000 次执行已经贴着 5060 的容量,排队让所有请求都变慢了,p50 从 10 毫秒涨到 33 毫秒。备份请求的收益来自「只为少数慢请求多付一次」,等待时间就是用来筛出这少数的。
等待时间一般取正常情况下的 p95 左右:再短,被筛出来的请求太多;再长,尾部降不了多少。这里 98% 的请求在 10 毫秒内返回,30 毫秒筛出来的正好是慢的那一批。
三、负载接近容量时
到达速率每秒 4500 个,是容量的 89%:
| 做法 | p50 | p99 | p99.9 | 发了备份的请求 | 服务端执行次数 ÷ 请求数 |
|---|---|---|---|---|---|
| 不发备份 | 10 ms | 300 ms | 306 ms | 0 | 1.00 |
| 30 ms 后发备份 | 10894 ms | 22430 ms | 22928 ms | 97.9% | 1.98 |
| 同上,预算 5% | 12 ms | 162 ms | 386 ms | 4.9% | 1.05 |
| 一开始就发两份 | 11391 ms | 22958 ms | 23312 ms | 100% | 2.00 |
不发备份时系统还撑得住:p50 仍是 10 毫秒,p99.9 略有排队。
加上备份请求就是另一回事。过程是这样的:
- 负载高,一部分请求在实例上排了一会儿队,超过 30 毫秒,触发备份。
- 备份请求是额外的负载,排队变得更长。
- 更多的请求等过了 30 毫秒,触发更多备份。
这个循环没有刹车。稳定下来的状态是 98% 的请求都发了备份,服务端的执行次数接近两倍,相当于每秒 9000 次执行压在 5060 的容量上,队列一直增长到模拟结束。「30 毫秒后发备份」在过载时与「一开始就发两份」没有区别。
这个循环和下一节的预算画在一起:
这里的关键是触发条件分不清两种慢:个别请求偶然慢了,与所有请求因为排队都慢了,表现都是「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% 两种负载下的延迟分位、备份比例与服务端执行次数(验证记录)
参考资料
- Dean、Barroso:The Tail at Scale(备份请求与延迟到 p95 再发的做法)
- gRPC 文档:Request Hedging
- gRPC A6:Client Retries(重试与备份请求共用的限流令牌桶、服务端的 pushback)
- Google SRE Book:Addressing Cascading Failures
- Envoy 文档:Request Hedging