你可能遇到过这种场景:平均延迟看起来不错,但用户总在“偶尔几次”卡住。真正把体验拖垮的,往往不是均值,而是尾延迟(比如 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 延迟 | 420ms | 180ms | 200ms |
| 平均延迟 | 48ms | 50ms | 50ms |
| 请求量 | 1.00x | 1.08x | 1.04x |
| CPU | 52% | 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/投机重试,本质是“用可控冗余购买尾延迟稳定性”;先小流量验证收益,再用预算守住成本。