用一点冗余换掉最慢 1%:Hedged Request 与投机重试实战指南

5 阅读6分钟

你可能遇到过这种场景:平均延迟看起来不错,但用户总在“偶尔几次”卡住。真正把体验拖垮的,往往不是均值,而是尾延迟(比如 p99)。

这篇文章只解决一个问题:如何用“额外请求量”去换“更低尾延迟”,以及什么时候该用、怎么控成本、怎么避免把后端打爆。

先把问题说人话:什么叫尾延迟

尾延迟可以理解成“最慢那一小撮请求的速度”。

  • 平均延迟像“全班平均分”,看起来挺体面。
  • p99 像“班里最慢的 1% 同学跑步成绩”,它决定你会不会被用户吐槽“怎么又卡了”。

生活类比:你在打车,90% 时候 2 分钟上车,但 10% 时候要等 15 分钟。用户记住的,通常是那 15 分钟。

小案例:一个聚合查询要并行打 12 个分片,只要其中 1 个分片慢,整次请求就慢。分片越多,撞上“慢副本”的概率越高。

图示:尾延迟如何出现(流程)

用户请求 -> 并行请求 12 个分片 -> 11 个很快返回 -> 1 个分片抖动
      -> 聚合器必须等最后一个 -> 整体响应被拖到 p99

这张流程图说明:先定位“谁在等谁”,再决定是否用对冲请求,而不是只盯平均值。

两个手段:Hedged Request 与投机重试

1) Hedged Request(对冲请求)

定义(白话版): 先发主请求,等一个短延迟 (\Delta);如果还没回来,再向另一个副本发“同一份请求”,谁先回来用谁,并取消另一个。

生活类比:你先打第一辆车,3 分钟还没接单,再同时叫第二辆,谁先接就坐谁的。

小案例:读取配置中心数据时,主副本偶发抖动。客户端在 p95 时点发第二份请求,p99 明显下降,但平均延迟几乎不变。

2) 投机重试(Speculative Retry)

定义(白话版): 在“还没完全超时”前,基于高概率慢请求的判断,提前重试到其他副本或通路。

生活类比:快递系统发现路线 A 堵车,不等彻底超时,提前让一单走路线 B。

小案例:跨可用区调用出现偶发网络毛刺,客户端在短窗口内发起投机重试,减少了“卡住 1~2 秒后才重试”的等待。

图示:三种重试策略对比(比较表)

策略触发时机优点风险适合场景
普通超时重试超时后实现简单用户已等很久,尾延迟高低 QPS、对时延不敏感
Hedged Request未超时但超过 (\Delta)直接压 p99放大请求量多副本读请求、偶发长尾
投机重试未超时,依据慢请求信号提前触发比纯超时重试更快止损参数复杂、误判会增压网络/链路偶发抖动

如果你是“多副本 + 读多 + 有偶发长尾”,优先从 Hedged Request 开始,而不是先堆更长超时。

什么时候该用,什么时候别硬上

先记一句话:这不是免费午餐,它是“拿流量买稳定感”。

更适合的场景:

  • 长尾是偶发的,不是系统性持续慢。
  • 服务有多个可替代副本。
  • 请求幂等(多发一次不会产生脏写)。
  • 业务对 p99 比对平均值更敏感(搜索、推荐、配置读取、元数据查询)。

不适合的场景:

  • 本来就过载,CPU/队列已经高压。
  • 写请求或非幂等操作。
  • 单副本或副本间高度相关(一起慢就没救)。

图示:是否启用对冲请求(决策树)

开始
 -> 有 >=2 个可替代副本吗?否 -> 不启用
 -> 长尾是偶发吗?否(持续慢)-> 先治根因,不启用
 -> 请求幂等吗?否 -> 不启用
 -> 后端有 15% 以上余量吗?否 -> 先扩容/限流
 -> 小流量灰度后 p99 明显下降吗?否 -> 回滚
 -> 启用(带预算上限)

你现在可以按这棵树快速自检,避免“还没救延迟,先把系统压垮”。

代价怎么量化:流量放大不是玄学

一个实用估算公式:

[ \text{放大量} \approx 1 + (\text{触发对冲比例}) \times (\text{每次额外副本数}) ]

例子:

  • 基础 QPS = 10000
  • 触发对冲比例 = 8%
  • 每次额外副本数 = 1

则总请求约为: [ 10000 \times (1 + 0.08 \times 1) = 10800 ]

也就是约 8% 的额外流量。别忘了,这 8% 会进一步放大到连接池、线程池、下游依赖和日志量上。

小案例:某读取服务上线对冲后 p99 从 420ms 降到 180ms,但 CPU 从 52% 升到 61%。团队随后加了“每秒对冲预算”,把 CPU 拉回 57%,p99 维持在 200ms 左右。

图示:收益与代价对照(前后表)

指标启用前启用后(无预算)启用后(有预算)
p99 延迟420ms180ms200ms
平均延迟48ms50ms50ms
请求量1.00x1.08x1.04x
CPU52%61%57%

先追 p99,再用预算把放大量压回去,这是更稳的落地节奏。

一套可复现的落地步骤(可直接照做)

Step 1:先测基线

记录一周或至少高峰时段的 p50/p95/p99、超时率、CPU、队列长度。

Step 2:选 (\Delta)(对冲延迟)

第一版建议从下游延迟分布的 p95 附近起步,不要一上来就太激进。

Step 3:只发 1 份额外请求

最多 1 个 hedge,先证明收益,再考虑更复杂策略。

Step 4:谁先成功用谁,立即取消其余

“赢者返回,输家取消”是关键,否则你只会白白放大压力。

Step 5:加三道保险丝

  • 对冲预算:例如每秒最多 N 个对冲请求。
  • 熔断条件:CPU/队列超阈值时自动停用。
  • 采样灰度:先 1%,再 5%,再 20%,全程盯 p99 与错误率。

参考伪代码:

resp1 = send(primary_replica)
wait Delta
if resp1 not ready and hedge_budget_available:
    resp2 = send(secondary_replica)
winner = first_success(resp1, resp2)
cancel(loser)
return winner

验证标准(上线门槛):

  • p99 下降至少 20%
  • 错误率不上升
  • CPU 增幅在可接受预算内(例如 <8%)

最后给你一个“别踩坑清单”

  • 把对冲当万能药:错。持续慢要先修根因(慢 SQL、GC、热点、锁竞争)。
  • 对非幂等写请求开对冲:危险,可能重复扣款/重复写入。
  • 不做取消:等于把“双发”变成“硬扛双倍成本”。
  • 不设预算:高峰时容易从“优化延迟”变成“放大故障”。

如果你只记一件事: Hedged Request/投机重试,本质是“用可控冗余购买尾延迟稳定性”;先小流量验证收益,再用预算守住成本。