T
traeai
登录
返回首页
InfoQ

慢请求,而非失败:自适应对冲请求如何将p99延迟降低74%

9.2Score
慢请求,而非失败:自适应对冲请求如何将p99延迟降低74%

TL;DR · AI 摘要

自适应对冲请求可将p99延迟降低74%,其核心是用实时学习的延迟分布动态触发对冲,而非静态阈值或重试;DDSketch实现O(1)内存量化估算,配合令牌桶限流防止负载雪崩。

核心要点

  • 在100个下游服务、各1%慢请求率的扇出架构中,63%的顶层请求会被至少一个慢请求拖累,导致单服务健康指标失真。
  • DDSketch支持±1%相对误差的实时分位数估算,每请求仅35纳秒开销,适合每主机粒度的延迟追踪。
  • 令牌桶机制将对冲请求上限设为总流量的可配置百分比(如10%),避免真实故障时负载翻倍恶化系统。

结构提纲

按章节快速跳转。

  1. 慢请求在扇出架构中累积导致尾部延迟恶化,而重试会加重后端负载,使问题更严重。

  2. 静态阈值无法适应负载、部署和时间变化引起的延迟分布偏移,需持续人工调优但实践中极少执行。

  3. ·DDSketch实现低开销实时分位数估计

    DDSketch以O(1)内存和约35纳秒/请求开销提供±1%相对误差的分位数估算,适用于实时主机级延迟监控。

  4. 对冲请求由实时延迟分布动态触发,并通过令牌桶限制对冲比例,确保故障时系统优雅降级而非雪崩。

  5. 在50,000请求、5%慢请求概率的lognormal模型基准测试中,该方案将p99延迟显著降低74%。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 自适应对冲请求降低p99延迟
    • 问题本质
      • 慢请求(Stragglers)主导尾延迟
      • 重试加剧后端负载,恶化问题
      • 单服务监控掩盖系统级尾部问题
    • 关键技术组件
      • DDSketch:O(1)内存分位数估算
      • 实时延迟分布学习
      • 令牌桶限流:防负载雪崩
    • 效果与验证
      • p99延迟降低74%
      • 50k请求仿真:lognormal+5%慢请求
      • 零配置、可复用Go库开源

金句 / Highlights

值得收藏与分享的关键句。

  • 在100个下游服务、每个服务1%慢请求率的扇出架构中,63%的顶层请求会被至少一个慢请求延迟,使单服务健康指标对系统级尾延迟诊断失效。

    Key Takeaways 第2条

    ⬇︎ 下载 PNG𝕏 分享到 X
  • DDSketch提供O(1)常量内存、±1%相对误差的分位数估算能力,每请求仅35纳秒开销,适合实时每主机粒度延迟追踪。

    Key Takeaways 第4条

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 令牌桶预算机制将对冲请求上限设为总流量的可配置百分比(如10%),当所有请求变慢时自动停止对冲,避免负载翻倍引发雪崩。

    Key Takeaways 第5条

    ⬇︎ 下载 PNG𝕏 分享到 X
  • 该方案在50,000请求、lognormal基线延迟+5%慢请求概率的仿真中,将p99延迟降低74%,且无需任何配置即可运行。

    正文末段

    ⬇︎ 下载 PNG𝕏 分享到 X
#分布式系统#延迟优化#对冲请求#DDSketch#微服务
打开原文

核心要点

  • 滞后者(stragglers),即完成缓慢而非失败的请求,是扇出架构中 p99 延迟的主要驱动因素,而重试通过向已经承压的后端增加负载,使问题更加恶化。
  • 在一个具有百个下游服务的扇出架构中,如果每个服务的滞后率为 1%,那么 63% 的顶层请求将至少被一个滞后者延迟,这使得单个服务的健康指标在诊断系统级尾部延迟时具有误导性。
  • 静态对冲阈值在基准测试中看似有效,但在生产环境中,由于延迟分布会随着负载、部署和一天中的时间而变化,因此会失效,需要持续的手动调整,而这在实际操作中很少发生。
  • DDSketch 提供 O(1) 的常量内存分位数估算,并具有相对误差保证(正负 1%),适合用于实时的每主机延迟跟踪,每个请求的开销约为 35 纳秒。
  • 使用令牌桶预算将对冲率限制为总流量的可配置百分比,可以防止在真实故障期间出现负载倍增的螺旋效应。当每个请求都变慢时,对冲会自动停止,从而使服务优雅降级,而不是放大问题。

本文源于对大规模微服务架构中一种模式的十年观察:在单个服务仪表盘显示正常的情况下,扇出架构中的滞后者累积会导致 p99 性能下降。本文描述了一种始终有效的干预措施,并以此为基础提供了一个可重用且无需配置的实现。

大多数云服务在仪表盘中看起来都很健康,p50 快速,p90 可接受。但当你查看 p99 时,就会发现问题。直觉是使用重试。这看起来很合理:如果一个请求变慢了,就重试它。但这种直觉是误导性的,因为慢请求与失败请求不同,将两者混淆会导致解决方案反而使问题恶化。

本文将探讨滞后者与失败之间的区别,为什么滞后者在规模扩展时会以服务监控无法察觉的方式累积,以及如何构建一种自适应对冲机制,该机制能够实时学习服务的延迟分布,绕过慢请求而不是重试它们,同时防止在真实故障期间放大负载。

#### 相关赞助商

该方法基于论文 "The Tail at Scale"(Dean 和 Barroso,2013 年),并使用 DDSketch (Masson, Rim, and Lee 2019) 进行实时分位数估算。参考实现作为开源 Go 库提供在 GitHub 上。

本文中的结果来自一个可重现的基准模拟:五万个请求针对一个具有对数正态基础延迟和 5% 滞后概率的后端模型,参数选择反映了中等负载下微服务的真实行为。该库尚未部署在生产系统中,但基准测试是对过去十年中反复观察到的尾部延迟模式的受控重现。完整的模拟可在 GitHub ([_go run ._]) 上获取,供任何希望验证或根据自身参数调整的人使用。

滞后者与失败

失败是指未完成的请求。滞后者是指完成但速度很慢的请求:后端的垃圾回收(GC)暂停、热点分区或内核调度抖动。从调用者的角度来看,两者都会损害 p99。但它们需要根本不同的解决方案。

重试通过发送另一个请求来解决失败,因为第一个请求未完成。但如果第一个请求只是比正常情况慢十倍,重试会向已经承压的后端添加第二个请求。后端现在有两个针对同一逻辑操作的在飞请求,而重试本身也可能成为滞后者,因此 p99 变得更糟,而不是更好。

处理掉队者的正确工具是带对冲的请求:在主请求仍在处理时发送备份请求,并使用首先响应的那个。输掉的那个会被取消。你不是在等待失败,而是在绕过慢的那个。

关键的区别在于,重试是反应式的(它们等待失败,然后重新发送),而对冲是主动的(它们检测到缓慢并竞速备份)。对于由掉队者而非失败引起的尾部延迟,对冲是正确的干预措施。

Image 1/filters:no_upscale()/articles/adaptive-hedged-requests-p99-latency/en/resources/216figure-1-1779785815295.jpg)

图1. 掉队者与失败:两个不同的问题。 (图片来源:作者创作)

为什么掉队者会在规模上累积

单个掉队者的比率通常看起来微不足道:每项服务中百分之一的慢响应。百分之一完全是可以接受的,直到你考虑到扇出效应。在扇出架构中,单个用户请求会触及多个下游服务。系统的 p99 并不由任何单一服务决定。它由所有服务中最慢的那个决定:

P(至少一个掉队者) = 1 - (1 - p)^n

如果有十个下游调用,掉队者率为百分之一,大约百分之九点六的顶级请求会遇到掉队者。当进行一百次下游调用时,百分之六十三的顶级请求会遇到掉队者。

即使每个单独的服务看起来都很健康,大多数顶级请求仍会被至少一个掉队者延迟。这就是为什么优化单个服务通常无法在系统级别移动 p99。问题不在于任何单一服务,而在于累积效应。这一见解来自 Dean 和 Barroso 2013 年在 Google 发表的论文,这篇论文仍然是关于分布式系统延迟最实用的文献之一。

Image 2/filters:no_upscale()/articles/adaptive-hedged-requests-p99-latency/en/resources/162figure-2-1779785815295.jpg)

图2. 扇出放大效应。为什么单个健康指标会误导。 (图片来源:作者创作)

难点:何时对冲

太早会浪费容量,太晚会几乎没有任何收益。

静态阈值,比如五十毫秒,在具有固定延迟分布的基准测试中看起来很棒。但生产环境不同。延迟会随着负载、部署、GC 调优和一天中的时间而变化。一个在凌晨三点完美的五十毫秒阈值,在高峰流量时会变得过于保守。分布上移,你会接受那些本可以对冲的慢响应。一个在高峰时有效的十毫秒阈值,在凌晨三点会变得过于激进。你正在对正常请求进行对冲并增加不必要的负载。

这就是“在配置之前就知道答案”的问题。静态阈值要求你持续监控每个服务的延迟,并在客户端与之通信的每个目标条件变化时重新配置。实际上,这个问题很少发生。阈值通常在初始部署时设置一次,然后逐渐变得过时。

需要的是一种机制,它可以从实时流量中学习延迟分布,并在分布中的正确点触发对冲,无论该分布当前处于何处。

防止负载放大

对冲的明显问题是,在真正的故障期间(即后端确实变慢,而不仅仅是偶尔产生掉队者时),每个请求都会超过对冲阈值。如果没有安全阀,你将对所有请求进行对冲,并在最糟糕的时刻使后端负载加倍。

解决方案是使用令牌桶预算。将对冲率限制为总流量的可配置百分比,例如百分之十。以下公式捕获了桶的填充速率:

填充速率 = 估计RPS x 预算百分比 / 100

在正常运行时,掉队者率为百分之五,预算永远不会耗尽。对冲会在需要时触发。在故障期间,每个请求都变慢。在每秒一千次请求(RPS)和百分之十的预算下,桶中有一百个令牌,并在全故障条件下大约一秒内耗尽,自动停止对冲,从而防止后端负载加倍。服务会优雅降级,而不是陷入螺旋式下降。

在百分之十的预算下,你最多只会向后端增加百分之十的额外请求,而不是将其加倍。只有当请求已经变慢时,这百分之十才会触发。正常请求没有任何成本。

自适应机制:DDSketch

为了在实际当前分布的 p90 处进行对冲,你需要每个目标主机的实时分位数估计,该估计在每次完成请求时更新,具有有限的内存和 O(1) 成本。

DDSketch(Masson, Rim, 和 Lee 2019)正好提供了这种对冲。它是一种具有相对误差保证的流分位数草图:返回的分位数始终在真实值的正负百分之一范围内。这一点很重要,因为像 t-digest 构造算法这样的替代方案使用的是排名误差界限,这在高分位数时可能会任意不准确,而这正是我们关心尾部延迟的范围。

DDSketch 将值映射到对数桶中:

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

其中 gamma = (1 + alpha) / (1 - alpha),alpha 是所需的相对精度。添加操作为 O(1)。内存是固定的。关键路径开销约为每请求三十五纳秒,对于任何网络调用来说都可忽略不计。DDSketch 作用于正值;零延迟响应(在实际网络调用中不应发生)被排除在草图之外。

适应变化的条件

单个 DDSketch 会无限期地累积观察值。如果后端变慢十分钟然后恢复,旧的慢观察值仍然保留在草图中。对冲阈值会保持人为的高位,并继续在不需要对冲的请求上触发对冲。

修复方案采用的是翻滚窗口机制,两个草图以固定的时间间隔(例如三十秒)交替旋转。分位数查询会合并这两个草图。由于每次查询都会合并两个草图,因此有效的观察窗口跨度为一个到两个旋转间隔,按照默认设置为三十到六十秒,这种方法提供了比硬截止窗口更稳定的估计值,同时仍然能够淘汰陈旧数据。当条件发生变化时,无论是部署、流量激增还是慢速 GC 阶段的结束,阈值都会随着实际分布的变化而变化。无需手动干预,也无需更新配置。

窗口持续时间是一个可调的权衡。较短的窗口(十到十五秒)可以更快地适应突然的分布变化,例如部署或 GC 激增,但每个窗口的观察次数较少,这可能会在低请求率下使分位数估计更加嘈杂。较长的窗口(六十秒或更长)会产生更稳定的估计值,但会滞后于分布变化,可能会对不再需要的请求触发对冲。默认的三十秒旋转对于每秒大约五十个请求以上的服务是一个合理的起点;对于低流量服务,较长的窗口可以确保草图在老化之前有足够的观察值。

Image 3/filters:no_upscale()/articles/adaptive-hedged-requests-p99-latency/en/resources/137figure-3-1779785815295.jpg)

图 3. DDSketch 窗口旋转。 (图片来源:作者创作)

综合应用

完整的请求流程如下:

  • 请求到达并被分发到目标主机。
  • 查询该主机的 DDSketch 以获取当前的 p90 延迟估计值。
  • 设置该时长的计时器。
  • 如果主响应在计时器触发之前到达,则直接返回。草图会更新为观察到的延迟值。无需对冲。
  • 如果计时器在主响应到达之前触发,则检查令牌桶。如果有可用令牌,则使用从调用者上下文中派生的子上下文向同一目标发送对冲请求。
  • 无论主响应还是对冲响应,先到达的那个将返回给调用者。另一个将被取消,并且其响应体将被清空以释放连接回到连接池。

主请求和对冲请求都派生自调用者的上下文。无论哪个请求到达第二,都会立即取消,并释放其连接,从而防止资源耗尽,无论对冲率如何。

Image 4/filters:no_upscale()/articles/adaptive-hedged-requests-p99-latency/en/resources/96figure-4-1779785815295.jpg)

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

参考实现将此流程编码为 HTTP RoundTripper,使其成为任何现有传输的即插即用替代品,无需更改调用站点。

零配置。传输自动学习延迟

code
import "github.com/bhope/hedge"

client := &http.Client{
    Transport: hedge.New(http.DefaultTransport),
}
resp, err := client.Get("https://api.example.com/data")

使用显式选项和可观察性进行调整

code
var stats *hedge.Stats

client := &http.Client{
    Transport: hedge.New(http.DefaultTransport,
        hedge.WithPercentile(0.90),
        hedge.WithBudgetPercent(10),
        hedge.WithEstimatedRPS(1000),
        hedge.WithMinDelay(time.Millisecond),
        hedge.WithStats(&stats),
    ),
}

// 请求后:
fmt.Printf("hedged=%d total=%d budget_exhausted=%d\n",
    stats.HedgedRequests.Load(),
    stats.TotalRequests.Load(),
    stats.BudgetExhausted.Load(),
)

当一个响应获胜时,失败者的响应体将在后台 goroutine 中最多读取一兆字节,并将连接释放回连接池,从而在大规模取消时确保安全,即使在高对冲率下也不会耗尽连接。传输包装了任何现有的 http.RoundTripper。对于 gRPC,一个 UnaryClientInterceptor 提供了相同的自适应对冲功能,并具有相同的选项。

对冲 LLM 推理:TTFT 与 TTFB

自适应对冲自然适用于 LLM 推理,但与标准 HTTP 服务有一个重要的区别。什么是慢响应的定义是不同的。

对于典型的微服务,延迟是从请求到响应测量的。对于使用分块传输或 SSE 的流式 LLM 端点,HTTP 响应头几乎立即到达,通常在一到两毫秒内,因为服务器在开始处理时会发送 200 OK 状态码。真正的成本是首次生成令牌的时间(TTFT),即实际生成内容的第一个字节到达所需的时间。这个延迟由预填充计算、KV 缓存状态和队列深度驱动,这就是慢节点所在的地方。

基于响应头到达时间校准的对冲传输会得到完全错误的信号。在基准测试中,草图学习到大约 1.6 毫秒的阈值,并在几乎每次调用时触发备份请求,产生百分之百的开销,因为几乎每个请求相对于几乎即时的响应头都“看起来很慢”。对冲正在错误的指标上竞争。

修复方法很简单,但需要挂钩到响应体读取路径而不是响应头接收。对冲在第一个响应体字节处测量延迟,而不是在响应头接收时测量,从而为预填充分离架构中的 DDSketch 提供正确的信号,其中响应头和第一个令牌之间的时间差为数十到数百毫秒。

实际效果显著。在模拟的流式后端(例如,50,000 个请求,并发 20,日志正态缓存命中 TTFT,均值 = 15 毫秒,标准差 = 3 毫秒)中,20% 的缓存未命中率建模为一个单独的日志正态分布(例如,均值 = 200 毫秒,标准差 = 25 毫秒),表示在冷 KV 缓存上的预填充重新计算,如下表所示:

端到端延迟 - 网关服务器 (TTFH ~ TTFT,阻塞于头部时触发对冲):

配置p50p90p99开销 无对冲 5.1 毫秒 26.3 毫秒 28.1 毫秒 0% 基于 TTFB 校准(错误信号)2.6 毫秒 13.1 毫秒 14.2 毫秒 ~100% 基于 TTFT 校准(对冲)4.9 毫秒 12.3 毫秒 14.0 毫秒 17-19.8%

基于 Time to First Byte (TTFB) 校准的对冲策略将 p90 减半,但使后端负载加倍,并且对 p99 几乎没有影响。基于 TTFT 校准的对冲策略在大约 19.8% 的开销下实现了类似的尾部改进,并且仅在最慢的 20% 的请求(实际缓存未命中)上触发,而不会影响正常请求。

这种方法还使对冲成为 LLM 服务基础设施中与延迟预测器并行使用的补充观测信号。预测器根据请求特征提供前瞻性估计;对冲则通过 DDSketch 提供基于最近每主机 TTFT 观测的回溯性经验信号。两者结合最有价值:预测器处理预期的负载模式,而对冲捕捉现实与预测偏离的情况,例如缓存驱逐、GC 暂停和嘈杂邻居。基本上,预测器处理比任何模型都能更快应对的负载。TTFT 校准的传输以相同的零配置即插即用方式提供。

相关工作

对冲请求最早在论文 "The Tail at Scale"(Dean 和 Barroso 2013)中被描述,该论文提出在短暂延迟后发送重复请求,并使用第一个响应的请求。原始论文建议使用基于预期 p95 或 p99 延迟的静态延迟。

远程过程调用框架 gRPC 通过服务配置中的 hedgingPolicy 原生支持请求对冲已有数年。它在纯 gRPC 环境中表现良好,但需要预先配置并手动更新静态的 hedgingDelay。此外,它缺乏防止负载放大的预算机制。

Netflix 的 Zuul 代理在其边缘网关中实现了带有退避的自适应重试,但更关注失败驱动的重试而非慢请求驱动的对冲。重试逻辑不维护每主机的延迟分布。Envoy 代理在其重试策略中支持请求对冲,但同样使用静态超时配置而非自适应阈值。

这里描述的方法通过结合三种机制有所不同:通过 DDSketch 实现每主机自适应阈值(消除静态配置)、用于跟踪分布变化的窗口轮换,以及用于安全降级的令牌桶预算。这种组合使其在无需持续操作调整的情况下适用于生产环境。

何时不使用对冲

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

#### 非幂等请求

对冲会发送重复请求。如果操作具有副作用(例如写入、收费或状态变更),你将执行两次操作。仅对幂等操作进行对冲,或确保后端处理去重。

#### 单后端服务

对冲会在主请求之外竞争一个备份请求。如果两者都指向同一个单实例,对冲会增加已经缓慢的机器的负载。对冲在负载均衡、多实例部署中最为有效。

#### CPU 密集型后端

如果后端变慢是因为计算资源饱和,添加对冲请求会使饱和情况更糟。对冲在由瞬时因素(GC 暂停、网络抖动、热点分区)而非持续资源耗尽导致的慢请求时效果最佳。

#### 非常低流量的服务

DDSketch 需要观测数据以生成准确的分位数估计。在极低请求率(每秒少于一个请求)的情况下,草图可能没有足够的数据来区分慢请求和正常波动。

#### 共享速率限制后的服务

如果后端强制执行全局速率限制,例如第三方 API 的每账户请求上限或每分钟令牌配额,对冲请求会消耗该配额。一个对冲调用在主请求和备份之间竞争可能导致两个计费或计数请求,而原本只需一个。这一点对于许多提供商强制执行每账户每分钟令牌限制的 LLM 推理 API 尤为相关;针对限流端点的 20% 对冲率可能会触发原本不会发生的限流。

基准测试结果

针对模拟后端的五万次请求,其基准延迟为对数正态分布(即平均值为五毫秒,标准差为两毫秒),并且有 5% 的慢请求概率(延迟倍增十倍),这是一个在中等负载下云微服务的现实模型。

基准测试结果:对冲策略与无对冲在延迟百分位数上的对比:

配置p50p90p95p99p999开销 无对冲 5.1 毫秒 9.0 毫秒 18.8 毫秒 65.0 毫秒 103.8 毫秒 0.0% 静态 10 毫秒 5.0 毫秒 9.0 毫秒 13.3 毫秒 17.5 毫秒 61.2 毫秒 7.7% 静态 50 毫秒 5.0 毫秒 9.0 毫秒 16.5 毫秒 54.9 毫秒 59.7 毫秒 2.1% 自适应(对冲)5.0 毫秒 8.9 毫秒 12.3 毫秒 17.3 毫秒 63.5 毫秒 8.9%

从 65 毫秒降至 17.3 毫秒,p99 减少了 74%,并且在零手动配置的情况下匹配了最佳手动调优的静态阈值。本质上,p50 保持不变,正常请求无需付出成本。

静态 50 毫秒阈值几乎没有帮助(p99 仍为 54.9 毫秒),因为该分布中的慢请求远高于 50 毫秒,无法及时捕获。静态 10 毫秒阈值在此基准测试中与自适应性能匹配,但仅因为基准测试的基线延迟接近 10 毫秒。如果分布发生变化(生产环境中不可避免),静态阈值要么过于激进,要么过于保守。

结论

尾部延迟在分布式系统中是一个统计问题,而不是代码问题。在扇出架构中,掉队者(stragglers)以对每个服务监控不可见的方式累积,而标准的应对方法——重试,反而会使问题恶化。

自适应对冲请求提供了一种不同的方法,包括使用 DDSketch 从实时流量中学习延迟分布,为真正慢的请求发起备份竞争,并通过令牌桶预算防止在故障期间负载放大。结果是一种机制,它无需任何手动配置即可匹配手工调优的静态阈值,并且关键在于,随着分布的变化,它仍然保持准确。

同样的机制可以自然地扩展到 LLM 推理工作负载中,通过测量真正的 TTFT(而非头部接收时间),使其适用于标准延迟信号具有误导性的日益增长的流式 AI 后端类别。

参考实现 bhope/hedge 在 GitHub 上为 Go 服务提供了即插即用的 HTTP 和 gRPC 支持,完整的基准模拟和贡献指南可在仓库中找到。同样的机制可以自然扩展到 LLM 推理工作负载,但前提是传输在正确的点测量延迟。通过包装响应体并在第一次成功读取时记录草图样本(而非在头部接收时),对冲计时器会在第一个令牌交付时竞争,而不是在连接建立时。延迟信号必须与实际工作发生的地方相匹配。

参考文献

  1. Jeffrey Dean 和 Luiz Andre Barroso. "大规模尾部延迟". Communications of the ACM, 56(2):74-80, 2013.
  2. Charles Masson, Jee E. Rim 和 Homin K. Lee. "DDSketch:一种快速且完全可合并的相对误差保证分位数草图". PVLDB, 12(12):2195-2205, 2019.

关于作者

Image 5

#### Prathamesh Bhope

Prathamesh Bhope 是沃尔玛全球科技公司的高级工程经理,他领导着支持沃尔玛市场卖家的全球市场支付和金融基础设施平台,覆盖美国和国际市场。在此之前,他领导了沃尔玛云原生平台的架构设计和扩展工作,该平台跨越 Azure、GCP 和私有数据中心,扩展到五百多个生产 Kubernetes 集群,服务于五千多个应用程序。他是 CNCF 生态系统的活跃贡献者,kube-state-metrics 的评审员,以及 hedge 的作者,hedge 是一个用于自适应对冲请求的开源 Go 库。

显示更多 显示更少

AI 可能会生成不准确的信息,请核实重要内容