写点什么

拖尾请求不是故障:自适应 Hedged Request 如何降低 74% 的 p99 延迟

作者:Prathamesh Bhope
  • 2026-06-08
    北京
  • 本文字数:7960 字

    阅读完需:约 26 分钟

本文源于过去十年间对大规模微服务架构的持续观察。我们反复看到同样的现象:扇出架构中的拖尾请求不断累积,导致 p99 指标持续恶化,而单个服务的监控面板却依然一片绿色。后来,一个屡次验证有效的干预方案逐渐演变成了本文介绍的可复用、零配置实现。

大多数云服务在监控面板中看起来都很健康:p50 很快,p90 也在可接受范围内。但当你查看 p99 时,就会发现问题。很多人的第一反应是启用重试(retry)。听起来似乎很合理:如果一个请求很慢,那就重试一次。但这种直觉具有误导性。因为“请求缓慢”和“请求失败”并不是同一件事。将两者混为一谈,最终往往会导致问题变得更加严重。

本文将介绍拖尾请求与失败请求之间的区别,解释为什么拖尾请求会在大规模系统中不断累积,以及为什么这种现象对单服务监控来说几乎不可见。随后,我们将展示如何构建一种自适应请求对冲机制,使其能够实时学习服务的延迟分布,通过与慢请求“赛跑”而非简单重试来降低尾部延迟,同时避免在真实故障发生时放大系统负载。

本文的方法建立在 Dean 和 Barroso 于 2013 年发表的论文《The Tail at Scale(规模化系统中的尾部延迟)》基础之上,并使用 DDSketch(Masson、Rim 和 Lee,2019)进行实时分位数估计。相关参考实现已作为开源 Go 库发布在 GitHub 上。

本文展示的结果来自一个可复现的基准测试模拟:共执行 5 万次请求,后端延迟模型采用对数正态分布(lognormal),并设置 5% 的拖尾概率。这些参数旨在模拟中等负载下真实微服务系统的行为。代码库目前尚未部署到生产环境,但该基准测试在受控条件下复现了过去十年间反复观察到的尾部延迟模式。完整模拟程序已在 GitHub 上公开(go run .),任何人都可以基于自己的参数进行验证或调整。

拖尾请求与失败请求

失败请求(Failure)指的是最终未能完成的请求。拖尾请求(Straggler)则不同,这类请求最终可以完成,只是耗时异常长。例如后端发生垃圾回收(GC)暂停、出现热点分区,或内核调度出现短暂异常等情况,都可能导致拖尾请求。从调用方的角度看,两者都会拉高 p99 延迟。但它们需要的是截然不同的解决方案。

重试可以用于处理失败请求。因为第一个请求没有完成,所以系统再发送一个新的请求。但如果第一个请求其实能够完成,只是比正常情况慢了十倍,那么重试就会给已经承受压力的后端再增加一个请求。这样一来,后端需要同时处理两个针对同一逻辑操作的请求,而重试产生的请求本身也可能变成拖尾请求。结果不是改善 p99,而是让 p99 进一步恶化。

对于拖尾请求,更合适的工具是对冲请求(Hedged Request)。当主请求仍在执行时,系统会发送一个备用请求,并采用最先返回的结果。较慢的那个请求会被取消。这里并不是等待请求失败之后再重试,而是在发现请求变慢时,主动发起一个“竞争者”来绕过它。

两者最关键的区别在于:重试是一种被动机制。它必须先等待请求失败,然后才会重新发送请求。而对冲请求是一种主动机制。它会在检测到请求变慢时立即发起备用请求,与原请求竞争返回结果。当尾部延迟是由拖尾请求而非失败请求造成时,对冲机制才是正确的应对手段。

图 1:拖尾请求与失败请求——两类不同的问题。(图片来源:作者绘制)

为什么拖尾请求会在大规模系统中不断累积

单个服务的拖尾请求比例通常看起来微不足道。例如,每个服务只有 1% 的请求响应缓慢。

单独来看,1% 完全不是问题。问题出现在扇出(Fan-out)架构中。在扇出架构下,一个用户请求往往会调用多个下游服务。此时,系统的 p99 不再由某个单独服务决定,而是由所有下游服务中最慢的那个决定。

P(至少出现一个拖尾请求的概率为)= 1 - (1 - p)^n

当存在十个下游调用,且每个服务的拖尾请求率为 1% 时,大约有 9.6% 的顶层请求会遇到拖尾请求。而当下游调用增加到 100 个时,大约有 63% 的顶层请求会受到拖尾请求影响。

换句话说,即使每个服务都表现得十分健康,大多数顶层请求仍然会因为至少一个拖尾请求而被拖慢。这也是为什么优化单个服务往往无法改善系统级 p99 的原因。问题并不在某一个服务身上,真正的问题在于累积效应。这一洞见来自谷歌 Dean 和 Barroso 于 2013 年发表的论文《The Tail at Scale》。时至今日,它仍然是分布式系统延迟领域最具实践价值的研究成果之一。

图 2:扇出放大效应:为什么单个服务的健康指标会产生误导。(图片来源:作者绘制)

最棘手的问题:什么时候发起对冲请求

发起得太早,会浪费系统容量。发起得太晚,则几乎得不到任何收益。

在延迟分布固定的基准测试环境中,使用一个静态阈值(例如 50 毫秒)往往能够取得不错的效果。但生产环境完全不同。请求延迟会随着系统负载、版本发布、GC 参数调整以及一天中不同时段的流量变化而不断波动。一个能在凌晨 3 点表现完美的 50 毫秒阈值,到了业务高峰期可能就会变得过于保守。因为整体延迟分布已经上移,很多原本可以通过对冲机制规避的慢请求,最终仍然被当作正常请求处理。反过来,一个在高峰期表现良好的 10 毫秒阈值,到了凌晨 3 点又可能变得过于激进。此时系统会对大量原本正常的请求发起对冲,从而引入额外且没有必要的负载。

这本质上是一个“配置之前必须先知道答案”的问题。静态阈值要求你持续监控每个服务的延迟情况,并随着运行环境的变化不断调整配置,而且这种工作需要针对客户端所访问的每一个目标服务分别进行。现实中几乎没有团队能够做到这一点。通常的情况是:阈值在系统初次上线时被设定一次,然后随着时间推移逐渐失效。

真正需要的是一种能够从实时流量中自动学习延迟分布的机制。无论延迟分布如何变化,它都能够自动找到合适的触发位置,并在正确的时机发起对冲请求。

防止负载放大

对冲请求最容易引发的担忧是:如果遇到真正的服务故障怎么办?这里所说的故障,是指后端整体变慢,而不是偶尔出现几个拖尾请求。在这种情况下,几乎所有请求都会超过对冲阈值。如果没有保护机制,系统就会为每个请求都发起对冲请求,在最糟糕的时候把后端负载直接翻倍。

解决办法是引入令牌桶(Token Bucket)预算机制。例如,可以将对冲请求的比例限制在总流量的 10%。令牌桶的补充速率可以表示为:

补充速率 = 预估 RPS × 预算比例 ÷ 100

在正常情况下,如果拖尾请求比例只有 5%,预算通常不会被耗尽。需要发起对冲时,对冲请求可以正常触发。但在服务故障期间,所有请求都会变慢。假设系统每秒处理 1000 个请求(RPS),而预算比例为 10%,那么令牌桶中会有 100 个令牌。在完全故障的情况下,这些令牌大约会在 1 秒内消耗完毕,从而自动停止发起新的对冲请求,避免后端负载被进一步放大。

这样一来,服务会逐步降级,而不会进入恶性循环。

在 10% 的预算限制下,额外增加的请求量最多也只有后端流量的 10%,而不是直接翻倍。

而且这部分额外流量只会出现在请求已经变慢的情况下,对正常请求几乎没有额外成本。

自适应机制:DDSketch

如果希望始终在当前实际延迟分布的 p90 位置触发对冲请求,就需要为每个目标主机维护实时分位数估计。这个统计结果必须能够在每次请求完成后立即更新,同时具备有界内存占用和 O(1) 的计算开销。

DDSketch(Masson、Rim 和 Lee,2019)正是为此设计的。这是一种流式分位数统计结构,能够提供相对误差保证:返回的分位数结果与真实值之间的误差始终控制在 ±1% 以内。这一点非常重要,因为像 t-digest 这样的方案采用的是排名误差保证。在高分位区间,它的误差可能变得非常大,而高分位恰恰是尾部延迟分析最关注的区域。

DDSketch 的核心思想是将数值映射到对数桶(logarithmic bucket)中:

bucket_index = ceil(ln(value) / ln(gamma))

其中:γ = (1 + α) / (1 - α),α 表示期望的相对精度。插入一次数据(Add)的时间复杂度为 O(1),内存占用保持恒定。在关键执行路径上,每个请求大约只增加 35 纳秒的开销。对于任何网络调用而言,这样的成本几乎可以忽略不计。DDSketch 只能处理正数值。对于延迟为零的响应,由于网络请求在实际场景下几乎不可能出现这种情况,因此不会被纳入统计。

适应不断变化的运行条件

单个 DDSketch 会无限制地累积历史观测数据。如果后端服务曾经持续变慢十分钟,然后又恢复正常,那么这段时间产生的高延迟样本仍然会保留在统计结构中。结果就是,对冲触发阈值被人为抬高,并持续对一些实际上已经不需要对冲的请求发起对冲。

解决方法是使用滑动窗口机制,但实现方式不是传统意义上的单一窗口,而是两个 DDSketch 以固定时间间隔轮换(例如每 30 秒切换一次)。在查询分位数时,同时合并这两个 sketch 的结果。由于每次查询都会同时读取两份数据,实际观测窗口的有效范围介于一个轮换周期和两个轮换周期之间。在默认配置下,大约对应 30 到 60 秒的时间范围。

这种方式比严格的硬切窗口更稳定,同时也能逐步淘汰过期数据。当系统状态发生变化时——无论是版本发布、流量突增,还是一次已经结束的 GC 抖动——对冲触发阈值都会随着真实延迟分布自然移动,无需任何人工干预,也不需要修改配置。

窗口长度本质上是一个权衡参数。窗口越短(例如 10 到 15 秒),系统对分布变化的响应越快,比如部署或 GC 抖动带来的延迟上升,但由于每个窗口内的样本更少,在低 QPS 场景下分位数估计会更加不稳定。窗口越长(例如 60 秒甚至更长),统计结果会更稳定,但对分布变化的反应更慢,可能会继续对一些已经恢复正常的请求发起对冲。

默认的 30 秒轮换周期,对于 QPS 在 50 以上的服务通常是一个合理起点。而对于低流量服务,则需要适当延长窗口时间,以确保在数据被淘汰之前能够积累足够的观测样本。

图 3:DDSketch 的窗口轮换机制。(图片来源:作者绘制)

组合起来看整体流程

完整的请求处理流程如下:

  • 请求到达后,首先被路由到目标主机。

  • 系统查询该主机对应的 DDSketch,得到当前 p90 延迟估计值。

  • 随后设置一个对应时长的计时器。

  • 如果主请求在计时器触发之前返回,则直接返回结果,并将该次观测延迟写入 DDSketch,此时不需要任何对冲请求。

  • 如果计时器先触发,则检查令牌桶中是否还有可用令牌。如果存在令牌,则基于调用方上下文向同一目标发起一个对冲请求。

  • 无论主请求还是对冲请求,谁先返回就使用谁的结果。另一个请求会被立即取消,其连接会被回收,以避免占用连接池资源。

主请求与对冲请求都继承自同一个调用上下文。无论哪一个后返回,都会被立即取消并释放连接,从而避免在高对冲率情况下出现资源耗尽的问题。

图 4:对冲请求;完整请求决策流程。(图片来源:作者绘制)

参考实现将这一整套流程封装为一个 HTTP 的 RoundTripper,因此可以直接作为现有网络传输层的替换组件使用,而无需修改任何调用方代码。

零配置:传输层自动学习延迟

import "github.com/bhope/hedge"client := &http.Client{    Transport: hedge.New(http.DefaultTransport),}resp, err := client.Get("https://api.example.com/data")
复制代码

支持显式选项与可观测性微调

var stats *hedge.Statsclient := &http.Client{    Transport: hedge.New(http.DefaultTransport,        hedge.WithPercentile(0.90),        hedge.WithBudgetPercent(10),        hedge.WithEstimatedRPS(1000),        hedge.WithMinDelay(time.Millisecond),        hedge.WithStats(&stats),    ),}// After requests:fmt.Printf("hedged=%d total=%d budget_exhausted=%d\n",    stats.HedgedRequests.Load(),    stats.TotalRequests.Load(),    stats.BudgetExhausted.Load(),)
复制代码

当某个响应胜出时,失败的一方(较慢的请求)会在后台协程中读取其响应体数据,最多读取 1MB 内容后释放连接回连接池。这样可以确保即使在较高对冲比例下,也不会因为连接长期占用而导致连接池耗尽。该传输层可以包裹任意现有的 http.RoundTripper 实现。对于 gRPC 场景,则通过 UnaryClientInterceptor 提供等价的自适应对冲能力,并保持一致的配置方式。

LLM 推理中的对冲:TTFT 和 TTFB

自适应对冲同样适用于大模型推理,但与传统 HTTP 服务存在一个关键差异:什么才算“慢响应”。

对于普通微服务而言,延迟通常以“请求到响应完成”的时间来衡量。但对于采用分块传输(chunked transfer)或 SSE 的流式 LLM 接口来说,HTTP 响应头通常会在极短时间内返回,往往只需要 1~2 毫秒,因为服务端在开始处理时就会立即返回 200 OK 状态码。真正决定用户体验的,是首个 token 的生成时间(TTFT)。这个延迟由预填充计算、KV cache 状态以及队列排队深度共同决定,拖尾请求也主要出现在这一阶段。

如果对冲机制以“响应头到达时间”为基准,那么整个信号就会被误导。在这种情况下,sketch 会学习到一个大约 1.6 毫秒的“慢阈值”,然后几乎对每个请求都触发对冲——在实际运行中会带来接近 100% 的额外开销,因为几乎所有请求相对于响应头来说都“很慢”。此时对冲机制实际上是在错误的指标上进行竞速。

修正方法很直接,但关键在于:不能以响应头作为延迟起点,而必须以响应体的第一个字节到达时间作为起点进行测量。也就是说,对冲延迟必须基于“首字节时间”,而不是“响应头时间”。只有这样,DDSketch 才能在预填充与流式输出解耦的架构中获得正确的信号——在这类系统里,响应头与首个 token 之间往往存在几十到数百毫秒的差异。

这一调整带来的实际影响非常显著。在一个模拟的流式后端中(例如:5 万次请求、并发度 20、对数正态分布的缓存命中 TTFT(均值 = 15ms,标准差 = 3ms),以及 20% 的缓存未命中率,用另一组对数正态分布模拟(均值 = 200ms,标准差 = 25ms),用于表示 KV cache 冷启动时的 prefill 重计算),其效果如下表所示:

端到端延迟 —— 网关服务(TTFH ~ TTFT,对冲在响应头阻塞期间触发)

基于 TTFB(首字节时间)的对冲虽然让 p90 减半,但它会把后端负载直接翻倍,而且几乎无法改善 p99。而基于 TTFT(首个 token 时间)校准的对冲,在约 19.8% 的额外开销下实现了相近的尾部延迟优化效果,并且只会在那 20% 的慢请求上触发——也就是实际的缓存未命中请求,从而不会影响正常请求路径。

这种方式还有一个额外价值:它可以作为 LLM 推理系统中的“观测信号补充”,与延迟预测模型形成互补。预测模型通常基于请求特征,提供一个前向的延迟估计;而对冲机制则基于 DDSketch 提供的每个主机近期 TTFT 分布,形成一个后向的经验信号。两者结合效果最好:预测模型负责处理可预期的负载模式;对冲机制负责捕捉现实与预测不一致的情况,例如缓存失效、GC 暂停、以及“邻居噪声”(noisy neighbor)等问题。

本质上,预测模型解决的是“应该发生什么”,而 TTFT 校准的传输层解决的是“实际发生了什么”,并且能够比任何模型更快反映真实系统状态。该 TTFT 校准版本同样以零配置、可直接替换的传输层形式提供。

相关工作

对冲请求(hedge request)最早在论文《The Tail at Scale》(Dean 和 Barroso,2013)中被提出。该论文建议在主请求发出一段延迟之后再发送备用请求,并使用最先返回的结果。原始方案使用的是基于固定延迟的策略,通常依赖 p95 或 p99 延迟作为参考。

远程过程调用框架 gRPC 在服务配置中通过 hedgingPolicy 已经原生支持对冲机制多年。这种方式在纯 gRPC 环境下表现良好,但需要预先配置一个固定的 hedgingDelay,并在运行条件变化时手动调整,同时也缺乏用于防止负载放大的预算机制。

Netflix 的 Zuul 代理实现了带退避策略的自适应重试机制,主要用于故障驱动的重试,而不是基于拖尾请求的对冲,因此不会维护每个主机的延迟分布。Envoy 代理也支持请求对冲作为其重试策略的一部分,但同样依赖静态超时配置,而不是自适应阈值。

本文提出的方法的不同之处在于将三种机制组合在一起:通过 DDSketch 实现按主机的自适应阈值(消除静态配置)、通过窗口轮换追踪分布变化,以及通过令牌桶机制实现安全降级。正是这三者的组合,使其能够在无需持续人工调优的情况下投入生产使用。

什么时候不应该使用对冲

自适应对冲并不适用于所有类型的工作负载。

非幂等请求

对冲本质上会发送重复请求。如果操作具有副作用(例如写入、扣费或状态变更),就可能被执行两次。因此,对冲只适用于幂等操作,或者后端具备去重能力的场景。

单后端服务

对冲的前提是“主请求与备用请求竞争”。如果所有请求都落在同一个实例上,那么对冲只会给已经变慢的机器增加额外压力。对冲通常更适合负载均衡器或多实例部署场景。

CPU 密集型后端

如果后端变慢是由于计算资源耗尽,那么增加对冲请求只会加剧资源饱和。对冲更适用于由瞬时因素引起的拖尾请求,例如 GC 暂停、网络抖动或热点分区,而不是持续性的资源不足。

超低流量服务

DDSketch 需要足够的观测样本来估计分位数。在极低 QPS(例如低于 1 RPS)的情况下,数据不足以区分真实拖尾与正常波动。

受共享限流约束的服务

如果后端存在全局限流机制,例如第三方 API 的账户级请求上限或每分钟 token 配额,那么对冲请求会消耗额外配额。在这种情况下,一个原本只需要一次调用的操作,可能因为对冲变成两次计费请求或两次计数请求。这一点在 LLM 推理 API 场景中尤其重要,因为许多服务商都会对每个账户设置每分钟的 token 数的限制。如果对一个受限接口使用 20% 的对冲比例,可能会触发本不会发生的限流行为,从而反过来影响系统稳定性。

基准测试结果

在一个模拟后端上进行 5 万次请求压测。该后端使用对数正态基础延迟分布(均值约为 5 毫秒,标准差为 2 毫秒),并叠加 5% 的拖尾请求概率,且拖尾放大倍数为 10 倍。这一组合在中等负载下能够较为真实地模拟云微服务的行为特征。

基准测试结果如下:不同对冲策略在各延迟分位点的表现对比。

可以看到,p99 从 65 毫秒下降到 17.3 毫秒,整体降低约 74%。这一结果已经达到了手动调参后的静态阈值方案水平,但完全不需要任何人工配置。与此同时,p50 基本保持不变,普通请求几乎没有额外成本。

固定 50 毫秒阈值几乎没有带来明显改善(p99 仍然是 54.9 毫秒),原因在于该分布中的拖尾请求通常远高于 50 毫秒,因而无法被及时捕捉。而固定 10 毫秒阈值在这个特定基准中表现接近自适应方案,但这是由于基准分布的基础延迟恰好落在该范围附近。一旦延迟分布发生变化(而生产环境必然会变化),这个静态阈值就会变得过于激进或过于保守。

结论

分布式系统中的尾部延迟,本质上是一个统计问题,而不是代码问题。拖尾请求会在扇出架构中不断累积,并且这种累积对单个服务的监控指标是不可见的。而传统的应对方式——重试机制——往往会进一步放大问题。

自适应对冲请求提供了一种不同的路径:通过 DDSketch 从真实流量中学习延迟分布;在真正变慢的请求周围发起“竞速式”备用请求;并通过令牌桶机制在故障期间避免负载失控。最终得到的是一个无需手工调参,却能达到甚至匹配手工调优静态阈值效果的机制,并且在分布发生变化时仍然保持稳定。

同样的机制也可以自然扩展到 LLM 推理场景中,通过测量真实的首 token 时间(TTFT),而不是响应头到达时间,使其适用于流式 AI 后端这一类越来越常见的系统形态,其中传统延迟指标往往会产生误导。

参考实现 bhope/hedge(GitHub)提供了 Go 语言的 HTTP 与 gRPC 即插即用支持,并包含完整的基准测试模拟与贡献指南。需要注意的是,该机制之所以能够扩展到 LLM 推理场景,是因为传输层始终在正确的位置采集延迟信号。通过在响应体读取路径中记录 DDSketch 样本,而不是在响应头返回时采样,对冲计时器实际上是在“首 token 到达”这一真实工作完成点上进行竞速,而不是在连接建立阶段。延迟信号必须与实际计算发生的位置保持一致。

参考文献

Jeffrey Dean and Luiz Andre Barroso. "The Tail at Scale". Communications of the ACM, 56(2):74-80, 2013.

Charles Masson, Jee E. Rim, and Homin K. Lee. "DDSketch: A Fast and Fully-Mergeable Quantile Sketch with Relative-Error Guarantees". PVLDB, 12(12):2195-2205, 2019.