P99 降 74% 不等于问题解决:hedging 是症状治疗不是病因治疗

hedging 降 P99 是症状治疗不是病因治疗。基于 Go 实测:hedging 让 P99 降 51% 但服务端 mutex 等待暴增 12 倍。尾延迟治理三层框架——先治病因,再消放大,最后才用 hedging 兜底。

封面

P99 降了 74%。

这是 Tony Bai 文章的标题,也是 Reddit r/golang 的高赞经验贴。bhope/hedge 库的基准测试给出了同样的数字:50000 个请求,自适应 hedging 把 P99 从 64.3ms 压到 17.0ms。

数字是真的。降法也是对的。

但我想问的是:然后呢?

P99 降了 74%,你的锁竞争解决了吗?你的慢查询解决了吗?你的 GC 停顿解决了吗?

如果都没解决,那 P99 降的这 74%,到底意味着什么?

这篇文章不讲 hedging 怎么用。Tony Bai 写过教程,dev.to 写过决策指南,bhope/hedge 库写过自适应方案。我要讲的是另一个维度:hedging 在尾延迟治理体系里,到底站在哪个位置。

(我跑的实测,锁竞争场景下降 51%,修复病因后再上 hedging 降 78.5%。本文要讨论的是"降了之后发生了什么",不是"74% 是不是真的"。)

1. 尾延迟为什么会放大

先说清楚 P99 为什么会高。

单节点 1% 的超时概率,看起来不高。但如果你的一次请求要并行调用 100 个下游节点(任一节点慢,整体就慢),那么整体超时概率是 1 - 0.99^100 = 63.4%。

近三分之二的请求会慢。这就是 Fan-out 放大效应。

这个数学来自 Google 2013 年的论文《The Tail at Scale》,作者 Jeffrey Dean 和 Luiz André Barroso。我第一次读到这个数字时是信的,但还是想自己验证一下。

我跑了一组实测。服务端模拟 1% 长尾(1-2 秒),99% 正常(10-100ms),客户端并行调用 N 个节点等所有返回,每场景采样 200 次。

Fan-out P99 延迟 理论超时概率
1 100.7ms 1.00%
10 1.975s 9.56%
50 1.971s 39.50%
100 (理论推算) 63.40%

[实测 Go 1.26.4 darwin/arm64] Fan-out=100 因本地资源限制(200 次 × 100 并发 × 63% 命中率 × 1-2s 长尾,内存和 goroutine 调度压力过大)未完成采样,但前 3 个数据点已与理论值高度吻合。

1% 的单节点长尾,被 Fan-out 结构放大成 63% 的整体灾难。这就是放大层的威力。

注意放大不是线性的。Fan-out 从 1 到 10(10 倍),超时概率从 1% 涨到 9.56%(约 10 倍);但 Fan-out 从 10 到 100(10 倍),超时概率从 9.56% 涨到 63.4%(约 6.6 倍)。放大效应递减,但绝对值在攀升。到 Fan-out=50 时,近 40% 的请求会命中长尾,每 2-3 次请求就有 1 次慢,SLO(比如 P99 < 500ms)根本守不住。

Fan-out 放大效应柱状图

这就是为什么"加机器"解决不了尾延迟。你把单节点延迟从 50ms 优化到 40ms,Fan-out=100 的整体超时概率还是 63%,因为 1% 的长尾率没变。治理放大层比治疗单节点更要紧。

换个角度:如果你的系统 Fan-out=100,单节点 1% 长尾,你要么把长尾率压到 0.1% 以下(病因层),要么把 Fan-out 拆成 10 个 10(放大层),要么用 hedging 兜底(症状层)。三层都能降 P99,但代价和持久性完全不同。

论文给出的解法是 hedging:客户端发第一个请求,P95 延迟内未返回就发第二个,取先返回的,取消其余。论文说 hedging 的触发率约 5%,相应增加约 5% 的系统负载,P99 削减大半。

加机器 vs 治理放大

听起来很美。但论文有一个前提:单节点本身是健康的。

如果单节点有锁竞争、有慢查询、有 GC 停顿呢?hedging 还能解决问题吗?

2. hedging 在做什么

先说清楚 hedging 和 Retry 的区别。

Retry 通常用于失败后重发。请求超时或出错后,再发一次。代价是延迟翻倍:你等了一个超时周期,再等一次。

Hedging 用于慢请求并发兜底。第一个请求发出后,等一个 hedgeDelay(通常设在 P95 附近),如果没返回就发第二个。两个请求取先到的,取消另一个。

本质区别是:Retry 是串行(等失败再发),Hedging 是并行(不等失败,并发发)。Hedging 用"多发一个请求"的代价,换"不等的延迟"。

用时间轴看更直观。假设一个请求正常 50ms,长尾 500ms:

  • Retry:发请求 → 等 500ms 超时 → 再发 → 50ms 返回。总延迟 550ms
  • Hedging(hedgeDelay=50ms):发请求 → 50ms 没回 → 并发发第二个 → 第二个 50ms 返回。总延迟 100ms

Hedging 的代价是发了两个请求,但省了 450ms。这就是它有效的场景:长尾是偶发的、随机的,第二个请求大概率不会也命中长尾。

Retry vs Hedging 时间轴

但如果长尾是结构性的呢?如果是因为锁竞争导致的呢?第二个请求会撞上同一把锁。这就是下一章要讲的问题。

Go 中实现 hedging 很简洁:自定义一个 http.RoundTripper,在 RoundTrip 方法里克隆请求,用 context.WithCancel 控制取消,channel 竞争取先到的:

type hedgedTransport struct {
    base       http.RoundTripper
    hedgeDelay time.Duration
}

func (t *hedgedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    ctx, cancel := context.WithCancel(req.Context())
    defer cancel()

    ch := make(chan result, 2)
    // cleanup 负责关闭未中选的 response
    var wg sync.WaitGroup
    collect := func() {
        if r, ok := <-ch; ok && r.resp != nil {
            r.resp.Body.Close()
        }
    }

    wg.Add(1)
    go func() {
        defer wg.Done()
        resp, err := t.base.RoundTrip(req.Clone(ctx))
        ch <- result{resp, err}
    }()

    timer := time.NewTimer(t.hedgeDelay)
    defer timer.Stop()

    select {
    case r := <-ch:
        return r.resp, r.err
    case <-timer.C:
        wg.Add(1)
        go func() {
            defer wg.Done()
            resp, err := t.base.RoundTrip(req.Clone(ctx))
            if ctx.Err() != nil && resp != nil {
                resp.Body.Close()
                return
            }
            ch <- result{resp, err}
        }()
        r := <-ch
        cancel()              // 触发另一个 goroutine 的清理
        go collect()          // 异步关闭未中选的 response
        return r.resp, r.err
    }
}

这段代码有几个生产环境必须注意的细节。(精简版见原文博客,这里保留完整生产可用实现。)

第一,hedging 要求请求 Body 可重放。req.Clone 依赖 http.Request.GetBody:对 bytes.Readerstrings.Reader 等标准库会自动设置 GetBody,Clone 可重发;但若 Body 是自定义 io.Reader 或已被 middleware 消费,Clone 后无法重发。POST 请求上 hedging 前必须确认 Body 可重放。

第二,未中选请求的 response 必须关闭,否则连接池耗尽。上面的代码在 ctx.Err() != nil 分支显式关闭,并在拿到结果后立即 cancel() 触发另一个 goroutine 进入清理。

第三,取消传播。cancel() 触发后,信号经 context 链传到 transport 层,未中选请求的 RoundTrip 返回 context.Canceled。但服务端呢?如果 handler 不检查 r.Context().Done(),取消信号对服务端无效,客户端取消了,服务端还在跑完整计算。hedging 的"取消另一个"形同虚设,资源放大效应无法消除。服务端必须在 handler 中响应取消:

func handler(w http.ResponseWriter, r *http.Request) {
    select {
    case <-r.Context().Done():
        return  // 客户端已取消,停止计算
    default:
    }
    // 正常处理
}

gRPC 原生支持 hedging,Service Config 里配 hedgingPolicy 就行,不用改业务代码。不过这里有个坑:gRPC 的 hedging 目前只在 Java 和 C++ 版本可用,Go 版本尚未支持(截至 2026 年 6 月,gRPC 官方文档明确标注 “Go: Not yet supported”)。所以 Go 开发者要上 hedging,目前得自己实现 RoundTripper,或者用 bhope/hedge 这样的第三方库。

到这里,Tony Bai 的教程和 dev.to 的决策指南都讲清楚了。我没有补充。

我要补充的是下一章。

3. 证伪实验:hedging 降了 P99,但病因还在

我的核心假设是:hedging 能降低 P99,但不解决病因。病因依然存在,只是被"绕过"了。

如果这个假设错了呢?如果 hedging 降 P99 的同时也改善了病因指标,那我这篇文章的论点就不成立。

所以我先跑了一个证伪实验。

实验设计

服务端有一个全局 mutex 保护 1-3ms 的短临界区,临界区外有慢操作(90% 概率 10-50ms,10% 概率 200-500ms 长尾)。锁竞争是"病因",长尾是"症状"。

为什么 mutex 只保护 1-3ms,而不是整个慢操作?因为这更贴近真实生产场景。生产环境中,锁保护的是热点资源的短访问(计数器更新、缓存写入、状态变更),长尾来自锁之外的下游调用或 IO。如果锁保护整个慢操作,50 个并发请求会全部排队,P99 被锁排队主导,那是另一个问题,不是 hedging 要治的"个别请求慢"。

客户端分两个场景:

  • 场景 A:无 hedging,发 2000 请求
  • 场景 B:有 hedging(hedgeDelay=50ms),发 2000 请求

采集客户端 P99 和服务端 mutex 等待时间。mutex 等待时间通过 runtime.SetMutexProfileFraction(100) 采样(mutex profiling 默认关闭,需设置非零值才会采样),用 go tool pprof 解析。

结果

指标 场景 A(无 hedging) 场景 B(有 hedging) 变化
客户端 P99 486ms 237ms ↓ 51.2%
服务端 mutex 总等待时间 37.29s 465.15s ↑ 1147%
服务端处理请求量 2000 3991 ↑ 99.5%

[实测 Go 1.26.4 darwin/arm64] mutex 采样率 1/100,两个场景的对比比例有效。

结论

P99 降了 51%。但 mutex 等待时间暴增 12 倍(其中约 2 倍来自请求量翻倍,6 倍来自每请求锁竞争加剧)。

hedging 在客户端"看起来"降了 P99,但在服务端"实际上"加剧了病因。因为 hedging 把请求量翻倍灌进同一把锁里,锁竞争恶化了,但客户端 P99 监控完全看不见这个,因为 hedging 已经把慢请求"绕过"了。

P99 vs mutex 双 Y 轴图

如果你只看客户端 P99 监控,你会以为问题解决了。

但服务端的 mutex profile 会告诉你真相:病因还在,而且更严重了。

这个实验构造的是锁竞争为主因的场景,hedging 翻倍请求量直接放大锁竞争。其他病因类型下 hedging 的副作用程度不同:网络抖动场景下第二个请求大概率走不同网络路径,副作用较轻;GC 停顿场景下 hedging 的内存压力会叠加。本文的结论是"hedging 不解决病因,且在锁竞争场景下会加剧病因",不是"hedging 在所有场景下都加剧所有病因"。但有一点是确定的:hedging 永远不修复病因。

三层对比

这还不够。我又跑了一个三层对比实验,验证"修复病因"和"不修复病因"的差距。

三种方案:

  • A 无优化:全局单锁 + 无 hedging
  • B 仅 hedging(症状治疗):全局单锁 + hedging
  • C 修复锁竞争 + hedging(病因治疗 + 症状兜底):16 分片锁 + hedging

修复方式是把全局锁改成 16 分片锁(同一把锁拆成 16 把,按 key 取模分散竞争,16 是常见分片数,兼顾分散度和管理复杂度)。

方案 P50 P95 P99 服务端请求量 hedging 触发
A 无优化 55.2ms 364.9ms 482.9ms 2000 0
B 仅 hedging 156.0ms 206.3ms 387.2ms 3992 1993
C 修复+hedging 34.4ms 82.3ms 103.6ms 2265 294

[实测 Go 1.26.4 darwin/arm64]

方案 B 请求数 3992 与 2000+1993=3993 差 1,方案 C 请求数 2265 与 2000+294=2294 差 29,均来自并发竞争和 context 取消:首个请求先返回触发 cancel 时,已进入 channel 但未真正抵达服务端的 hedging 请求被丢弃。两个实验独立运行,基线数据略有差异(±1.6% 内)属正常波动。

几个发现:

方案 B(仅 hedging)P99 只降了 20%,远低于方案 C 的 78.5%。而且 P50 变差了 3 倍(55ms → 156ms):hedging 翻倍请求量后锁等待时间增加,所有请求(含未被 hedging 触发的正常请求)的排队时间都增加,普通请求为 P99 付出了排队代价。

方案 B 的 hedging 触发 1993 次(99.6%),几乎是主力。方案 C 只触发 294 次(14.7%),回归兜底角色。

三方案对比

方案 C 的服务端请求量只增 13%,方案 B 翻倍。这意味着方案 B 给下游服务施加了近一倍的额外负载。

hedging 本身是兜底工具。当病因层未修复时,它被迫成为主力,代价是 P50 变差和服务端负载翻倍。病因层修复后,hedging 回归"保险"角色,只在真正的长尾时触发。

4. 三层决策框架:病因 / 放大 / 症状

三层对比表

基于上面的实验,我推导出一个尾延迟治理的三层框架。

P99 高,说白了是"个别请求慢"。为什么个别请求会慢?三类原因,按"距离根因的远近"分层:

第一层是病因层(根因)。请求处理过程本身有问题:GC 停顿、锁竞争、慢查询、资源耗尽。工具是 profiling + 直接修复。

病因层的判断方法:看服务端的 profile,不是看客户端的 P99。go tool pprof 看 CPU 和 mutex profile,GODEBUG=gctrace=1 看 GC 停顿,慢查询日志看数据库。如果这些指标有异常,P99 高的根因就在病因层,直接修,不要跳层。

第二层是放大层(结构)。系统结构把"个别慢"放大成"整体慢":Fan-out 放大、缓存击穿、批处理等待。工具是结构改造 + 超时控制。

放大层的判断方法:单节点 P99 不高,但整体 P99 高。比如单节点 P99 只有 50ms,Fan-out=50 的整体 P99 飙到 2 秒,这就是放大层的问题。治理手段:降低 Fan-out(拆分调用链)、加单节点超时(切断慢节点传播)、缓存预热(避免击穿)。

第三层是症状层(表现)。客户端感知到的"慢":P99 数字、超时率、用户卡顿。工具是 hedging + fallback + 熔断。

三层金字塔框架

三层有顺序,不能跳层。

如果 P99 高是因为锁竞争(病因层),直接上 hedging(症状层)会发生什么?第一章的实验已经回答了:hedging 降了 P99(症状缓解),但锁竞争还在(病因未除),而且 hedging 带来的额外请求量让锁竞争恶化了 12 倍(跳层治理的副作用)。

这就像发烧吃退烧药。体温计数字好看了(症状缓解),但感染还在(病因未除)。如果你因为体温正常就停止治疗感染,病情会恶化。

一次生产环境的印证(经验复盘,非实测数据)

有一次,我接手一个 P99 报警不断的服务。是个订单查询接口,下游依赖五六个内部服务,Fan-out 在 20-30 之间。监控显示 P99 在 800ms-1.2s 之间抖动,单节点 P99 其实只有 50-80ms。

我的第一反应和多数人一样:上 hedging。加了 HedgedTransport,hedgeDelay 设在 P95 附近(约 60ms),压测显示 P99 从 1s 降到 300ms 左右。降了 70%。

监控变好看了。报警停了。我当时觉得这事儿解决了。

两周后问题回来。不是 P99,是下游服务开始报 CPU 过载。一开始我以为是下游服务的 bug,花了两天定位才发现是 hedging 的副作用:触发率接近 80%,每个原始请求平均发 1.8 个下游请求。下游的连接池开始打满,GC 压力上升,反而出现新的长尾。

有意思的是,客户端 P99 监控依然是绿的:hedging 在客户端就把慢请求绕过了,监控根本看不见服务端发生了什么。如果不是下游团队主动来找,我可能还在庆祝 P99 降了 70%。

我翻了服务端的 mutex profile,发现某个热点资源的锁等待时间比上 hedging 之前涨了 3 倍。hedging 把请求量翻倍灌进同一把锁里,锁竞争恶化了,但客户端 P99 监控完全看不见这个。病因在服务端恶化,症状在客户端消失。这比"没治好"更危险,你连"没治好"都看不见了。

生产场景复盘

最后做了两件事:把那个热点资源的锁改成分片结构,锁等待时间直接降一个量级;hedging 保留,但 hedgeDelay 调到略低于 P99(约 200ms),让 hedging 只兜底真正的长尾,不再当主力。

调完之后,hedging 触发率从 80% 降到 15% 左右,下游 CPU 回到正常,P99 稳定在 150ms 上下。

那次之后我有个判断:hedging 触发率超过 50%,大概率说明你在用 hedging 当主力,病因层有问题。 50% 不是精确阈值,只是量级判断,触发率到了一半以上就该警觉。15%-50% 是灰度区,需结合服务端 profile 判断。

5. hedging 的正确用法与成本

如果你已经修好了病因层,hedging 应该怎么用?

hedgeDelay 设在 P99 附近或略低于 P99,不是 P95。 论文建议 P95,那是基于单节点健康的前提。生产环境中 P95 太激进,5% 的触发率在病因层未完全修复时会放大负载。我的经验是设在 P97-P99 区间,让 hedging 只兜底真正的长尾(1-3% 触发率),而不是常规波动。

还有一个必须项是熔断器。hedging 在故障期间会放大负载:下游已经扛不住了,你还发双倍请求,加速雪崩。dev.to 把这个叫"outage gotcha",我认为这是 hedging 最危险的副作用。熔断器在错误率超过阈值(经验值 10% 左右)时强制停发 hedging 请求,这是保命的一层。Go 生态可以用 sony/gobreaker 等熔断库。

最后是幂等性。hedging 会发两个请求,如果操作非幂等,两个请求都到达服务端时即使客户端只接收一个响应,服务端可能已执行两次非幂等操作(比如两次扣款)。tonybai 和 dev.to 都提了这一点,不展开。

hedging 使用决策树

怎么判断病因层已修复

一个实操问题:我怎么知道病因层修好了,可以上 hedging 了?

看两个指标:

hedging 触发率。先上 hedging,设一个保守的 hedgeDelay(P99 附近),观察触发率。如果触发率 > 50%,说明病因层有问题,回去修。如果触发率 < 15%,病因层基本健康,hedging 可以保留作为兜底。

服务端 profile。直接看服务端的 mutex profile、GC trace、慢查询日志。如果这些指标在 hedging 开启后恶化,说明 hedging 在放大病因,即使客户端 P99 看起来降了,也要回去修病因层。

这两个指标是"病因层健康度"的代理指标。客户端 P99 不是:P99 降了不代表病因好了,反而可能掩盖了病因恶化。

hedging 的隐藏成本

dev.to 提到 hedging 的成本是"5% 额外请求"。这是论文的乐观估计(dev.to 转述了这个估计),前提是触发率 5%。

我测了一下高触发率场景的成本:

指标 无 hedging 有 hedging(触发率 99.4%) 变化
P50 57.5ms 156.2ms ↑ 172%(变差)
P99 478.3ms 236.6ms ↓ 50.5%
内存分配 13.99 MB 77.67 MB ↑ 455%
GC 次数 8 18 ↑ 125%

[实测 Go 1.26.4 darwin/arm64] E6 与 E2 独立运行,无 hedging 基线数据略有差异(P50 57.5ms vs 55.2ms,±2ms 内)属正常波动。

hedging 隐藏成本表

几个被忽视的成本:

内存分配增加 4.5 倍。每个 hedging 请求都是完整的 HTTP 请求,内存成本翻倍。生产环境中这意味着 GC 压力上升,而 GC 停顿本身就是长尾的来源之一。你用 hedging 治长尾,hedging 产生的新长尾比它治的还多。(内存数据通过 runtime.ReadMemStats 采集 HeapAlloc 差值。)

P50 变差 2.7 倍。为了降 P99,你牺牲了普通请求的体验。如果 SLO 同时考核 P50 和 P99,hedging 可能让 P50 SLO 破线。

可观测性污染。服务端日志量翻倍,metrics 失真(处理量虚高、P99 失真),链路追踪出现重复 span:同一逻辑请求对应两条服务端 trace,排障时容易误判。

你看到一个慢请求的 trace,发现服务端处理了 800ms,你会以为是服务端慢。但实际上这是 hedging 的第二个请求,第一个请求在客户端被取消了,但服务端还在处理。你看到的是"被取消的请求的服务端视角",不是"真正影响用户的请求"。如果没有特别标记 hedging 请求,排障时会误判病因。

告警阈值也要重调。hedging 开启后服务端 QPS 翻倍,原有的 QPS 告警会频繁误报。SLO 计算需要区分原始请求和 hedging 请求,否则你的 SLO 达成率会被 hedging 请求的延迟分布污染。

合理的 hedging 触发率应该在 5-15%。方案 C 的实测是 14.7%。如果你的触发率超过 50%,不是 hedging 的问题,是病因层的问题,回去修病因。

最后说一句关于自适应 hedging。bhope/hedge 库用 DDSketch 跟踪延迟分位数,动态调整 hedgeDelay,比固定阈值更适应负载变化。这解决了"hedgeDelay 设多少"的运维难题,但不改变本文的论点:自适应只是让症状层工具更精准,不能替代病因层的修复。不管你的 hedging 多智能,锁竞争还是锁竞争,慢查询还是慢查询。

结尾:别把止痛药当抗生素

P99 降了 74%。这是真的。

治不了病也是真的。

hedging 是症状治疗。它降的是客户端感知的 P99,不是服务端的病因。锁竞争还在,慢查询还在,GC 停顿还在。你只是看不见了。

尾延迟治理的三层框架:先治病因(profiling + 直接修复),再消放大(结构改造 + 超时控制),最后兜底症状(hedging + 熔断)。三层递进,不能跳层。

如果你正在用 hedging,看一下触发率。超过 50%,说明你在用止痛药当抗生素,体温计数字好看,但感染在恶化。

先把病因层解决了,再让 hedging 做它该做的事:兜底真正的长尾,而不是掩盖结构性的问题。

别把止痛药当抗生素

P99 降了 74%。然后呢?

然后你要去看服务端的 profile,不是看客户端的监控。


附录:实验代码和原始数据

本文 4 组实验的代码和 pprof 原始文件已开源:

GitHub:zhiyulab-evidence/go-request-hedging

实验 内容 路径
E1 证伪实验:hedging 降 P99 51% 但 mutex 等待时间 +1147% code/e1-falsification/(含 mutex profile 原始文件)
E2 三层对比:仅 hedging vs 修复+hedging code/e2-three-layer-compare/(含三组 mutex profile)
E3 Fan-out 放大实测:1% → 9.56% → 39.50% → 理论 63.4% code/e3-fanout-amplification/
E6 hedging 成本:内存 +455% / GC +125% / P50 变差 2.7 倍 code/e6-hedging-cost/

另有两组场景类论据:scenarios/e4-production-recap.md(生产场景复盘)和 scenarios/e5-three-layer-framework.md(三层框架推演)。

每个实验子目录都有独立 README,说明如何复现。二进制编译产物不入库,跑实验前自己 go build


原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →