你有没有遇到过这种情况:监控面板上一片绿油油,P50 二十毫秒,P99 五十毫秒,健康检查全过,容量水位也只有三成。然后用户一个接一个来说"页面卡死了"。
你去看日志,接口确实是快的。你甚至压测了一遍,结论是"性能没问题"。
这种情况我遇到过好几次了。后来我意识到,我们大部分人监控延迟的方式,本身就是有问题的——不是监控数据骗人,是我们问错了问题。
平均延迟是给报表看的,尾延迟才是给用户感受的。而这两者之间的差距,往往不是百分之几,是几十倍。
一、平均值把九个正常人和一个白菜平均了
先说个被讲烂了但依然没被内服的道理。假设 100 次请求里,99 次花了 10ms,1 次花了 5000ms。
算术平均:(99 × 10 + 5000) / 100 = 59.9ms。看起来"59.9ms 也不慢啊"。但真实情况是:99% 的用户 experience 极好,1% 的用户直接被卡死——而这 1% 往往是那个正在下单、正在支付、正在等一个关键结果的倒霉蛋。
更要命的是很多告警阈值是这么配的:平均值超过 100ms 报警。你永远等不到这一天,因为平均值会被那 99 个 10ms 牢牢按在地上。
所以第一个原则:延迟永远看分位数,而且要看多个分位数。P50、P95、P99、P99.9 一起看。少一个都可能骗你——只盯 P99,会被采样率极低的毛刺折磨;只看 P50,那基本等于没监控。
二、Little's Law:你的延迟是排队排出来的,不是算出来的
这是排队论里最漂亮的一个定理,也是很多人做性能优化时最大的盲点。
L = λ × W
L = 系统中的平均请求数(并发数)
λ = 平均到达率(QPS)
W = 平均停留时间(延迟)
翻译成人话:并发数 = QPS × 延迟。反过来推更刺激:
假设你的 QPS 是 1000,平均延迟 50ms,那么系统里的平均并发数是 50。对一台 64 核的机器来说,这听起来轻松得不像话。
但这只是 Little's Law 揭示的一半。另一半是:利用率越接近 100%,等待时间会呈非线性爆炸。M/M/1 排队模型里有个著名的结论——利用率 ρ 趋近 1 时,平均等待时间趋于无穷。工程上的经验值是:
- 利用率 50%:延迟基本线性
- 利用率 80%:延迟开始明显抬头
- 利用率 90%:延迟涨到几倍
- 利用率 95%+:排队延迟可能是理论服务时间的十倍以上
这就解释了一个经典的反直觉现象:把下游扩容一倍,延迟可能直接腰斩,因为你把它从 90% 拉回了 45%,排队那部分延迟整个消失了。反过来,如果下游利用率只有 20%,你扩容十倍,延迟纹丝不动——瓶颈根本不在那。
这件事的实战意义是:优化延迟的第一优先级不是"让代码跑得更快",而是"别让请求排队"。一条 SQL 从 5ms 优化到 4ms,收益可能只有 4%;而把一个 95% 利用率的队列拆开,收益可能是 300%。
三、协同遗漏:压测工具亲手给你造了个假象
接下来这个坑,我踩得心服口服。
标准的压测模型是:一个线程按固定速率发请求,发一个等一个。问题是,假设目标 QPS 是 1000,但服务端某次卡了 3 秒——这期间压测客户端是串行的,它发完第 1 个请求就在那儿干等,3 秒内它一个请求都没发出去。等它发完,客户端再继续按"每秒 1000 个"的节奏发。
结果服务端实际收到的请求间隔是 3 秒,而不是 1ms。也就是说:当系统最慢的时候,客户端的请求速率自动降低了,服务器因此没有被压垮,于是压测报告上 P99 依然漂亮。
这个现象叫 Coordinated Omission(协同遗漏),由 Gil Tene 提出。它的隐蔽之处在于:你以为你在测一个恒定速率的系统,实际上你测的是一个"会自己变慢"的系统。
正确的修法是:记录预期发送时间点,而不是记录实际开始时间点。
// 错误:只测了服务端的快慢,服务器的慢被藏起来了
start := time.Now()
resp, err := client.Do(req)
latency := time.Since(start) // 客户端阻塞的这 3 秒完全没被计入
recorder.RecordValue(latency.Milliseconds())
// 正确:把"本该发出的时间"作为基准
scheduled := expectedSendTime // 闭环压测器维护的下一个预定发送时刻
start := time.Now()
resp, err := client.Do(req)
elapsed := time.Since(start)
// 关键修正:如果这次请求因为等待而迟到,那么"排队延迟"也必须计入
corrected := elapsed + start.Sub(scheduled)
recorder.RecordValue(corrected.Milliseconds())
用 HDR Histogram 这类支持分位数统计的库(Go 里是 hdrhistogram,Java 里是 HdrHistogram)记录,并且记得把精度设得够细——它用压位计数存储,能在很小的内存开销下给出误差有保证的 P99.9。
改完之后你会发现,那个漂亮的 P99 可能变成了 P99 的两三倍。这才是真相。
四、Deadline 传播:把超时写进请求里,别写在文档里
还有一个我见过无数次的场景:网关超时 3 秒,于是用户看到 502 或者网关自定义错误;而实际上,下游的数据库查询还在跑,第 4 秒才失败;再往下,某个 RPC 客户端的默认超时是 10 秒。
这叫超时不一致。上游放弃了下游还在傻跑,纯属浪费;更阴的是它让尾延迟被系统性放大——一个五层链路,每层都可能多等几秒,尾延迟是乘出来的,不是加出来的。
解法是 Deadline Propagation(截止时间传播):在请求入口处算出一个绝对截止时间,一路往下传,每一层用"剩余时间"而不是"自己配置的超时"。
// 入口层:算一次绝对截止时间
func WithTimeout(timeout time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
deadline := time.Now().Add(timeout)
ctx := context.WithValue(r.Context(), deadlineKey, deadline)
r = r.WithContext(ctx)
// 同时写进 header,方便跨语言/跨服务的下游读取
r.Header.Set("X-Deadline-Ms",
strconv.FormatInt(deadline.UnixMilli(), 10))
next.ServeHTTP(w, r)
})
}
}
// 中间层:不再用自己的配置,直接吃剩余时间
func DeadlineFromContext(ctx context.Context, fallback time.Duration) time.Time {
if d, ok := ctx.Value(deadlineKey).(time.Time); ok {
return d
}
return time.Now().Add(fallback)
}
然后每一层实际执行的超时,变成 min(自己配置的, 剩余时间)。原则很简单:下游必须比上游更早放弃,否则上游的取消就永远传不下去,资源会被白白占住。
顺带说一句:光有超时不够,超时之后你还得真的取消。如果你的数据库驱动不支持 context,或者你的 HTTP 客户端没把取消信号传给下游,那所谓的超时只是"我不等了",不是"对方别干了"。这两者的资源浪费差距是一个数量级。
五、重试:尾延迟最常见的放大器
现在来看尾延迟的头号杀手:重试。
设想一个典型的三层调用:网关 → 服务 A → 服务 B。服务 B 的 P99 是 500ms,某一时刻因为 GC 或者一个慢查询,B 的 P99 涨到了 3 秒。这时如果 A 配置了"超时 1 秒就重试一次",会发生什么?
- 正常请求:10ms
- B 变慢时:每个请求要等 1 秒超时 → 重试 → 再等 1 秒 → 总共 2 秒+
- 更糟的是,重试请求又涌向已经过载的 B,直接把 B 彻底压垮
这就是重试风暴的雏形:一个瞬时的局部抖动,被重试放大成了系统级的雪崩。尾延迟不是被修复了,而是被翻倍再翻倍。
几条我实践下来的规矩:
第一,重试必须带退避和抖动。固定间隔的重试就是同步放大器——所有失败请求在同一毫秒一起重试。必须加随机抖动(full jitter 效果最好:delay = random(0, base * 2^attempt))。
func backoff(attempt int, base time.Duration) time.Duration {
if attempt > 6 {
attempt = 6 // 封顶,避免溢出成天文数字
}
cap := base * time.Duration(1<<attempt)
return time.Duration(rand.Int63n(int64(cap)))
}
第二,重试只在幂等的操作上做,或者带幂等键。写操作重试如果没有去重,等于制造重复订单。幂等键的正确做法是让调用方生成并在重试间保持不变,而不是服务端在收到请求时生成。
第三,重试预算要占掉整个超时预算的一部分,而不是叠加在上面。总预算 2 秒的话,重试预算最多给 500ms——留够时间给后面的链路,不然重试成功的请求也白干。
第四,只在明确的错误上重试。连接失败、503、429 值得重试;参数错误、404 重试一万次结果一样,纯属浪费预算。
六、别只盯着服务,让重试"敢"发生
有人会问:既然重试这么危险,干脆关掉?也不至于。真正的问题在于很多系统不敢重试,因为重试会放大故障。
解法是给重试"上保险",这叫 Hedged Request(对冲请求):不等第一个请求超时,而是过一段时间(通常是 P95 延迟的某个倍数)主动再发一个,谁先回来用谁,另一个丢弃。
// 简化的对冲请求
func hedged(ctx context.Context, c *Client, req *Request, hedgeDelay time.Duration) (*Response, error) {
ch := make(chan *Response, 2)
errCh := make(chan error, 2)
go func() {
r, err := c.Do(ctx, req)
if err != nil { errCh <- err } else { ch <- r }
}()
timer := time.NewTimer(hedgeDelay)
defer timer.Stop()
select {
case r := <-ch:
return r, nil
case err := <-errCh:
return nil, err
case <-timer.C:
// 第一个还没回来,发个对冲请求
go func() {
r, err := c.Do(ctx, req)
if err != nil { errCh <- err } else { ch <- r }
}()
select {
case r := <-ch:
return r, nil
case err := <-errCh:
return nil, err
}
}
}
这个招的精髓在于:它把尾延迟的时间成本换成了额外的资源消耗。平时不触发,只有慢的时候才多花一点资源。对那些"99% 的用户不在乎快 100ms,但 1% 的用户完全无法忍受 3 秒"的场景,这笔买卖非常划算。
但用对冲请求有三个前提,缺一不可:
- 操作必须幂等,或者能安全丢弃多余的响应
- 下游有足够余量——你多打了一倍流量,下游得扛得住
- 要有舱壁隔离(bulkhead):对冲请求走独立的连接池/线程池,别把正常流量一起拖下水
最后一条最容易被忽略。想象一下:你的对冲逻辑让正常流量和补偿流量共用一个连接池,结果对冲请求一多,正常请求也开始排队。你本来是想降低尾延迟,结果把中位数也搞坏了。
七、监控该长什么样
最后说说监控怎么改,不然前面全是空谈。
看分位数,且要能横向切。按接口、按机房、按下游依赖、按客户端版本分别看 P99。整体 P99 正常不代表每个角落都正常——通常就是某一个下游、一个特定机房的某台机器在拖后腿。
用 SLI 而不是技术指标。"成功率"和"延迟达标率"(比如 P99 < 200ms 的请求占比)才是用户真正感知的东西。CPU 使用率、GC 次数、连接池水位都是诊断指标,是拿来追查的,不是拿来定 KPI 的。
把排队时间和执行时间分开。这是最有价值的一个改动。在你的 RPC 框架里埋一个埋点:记录请求到达时间戳和真正开始执行的时刻,差值就是排队时间。排队时间的长尾通常比执行时间的长尾更夸张,而且它明确指向了"该扩容了"或者"该限流了",而不是"该优化代码了"。这条指标能砍掉大量无效的代码优化。
告警用"坏请求的比例"而不是"平均延迟"。比如"过去 5 分钟内 P99 超过 200ms 的请求占比超过 1%"。这种告警的好处是它不会因为个别毛刺就半夜把你叫起来,也不会在用户体验已经崩了的时候还让你安心睡觉。
结语
回过头看,尾延迟这件事的核心其实只有一句话:延迟是你排队的结果,而不只是你执行的结果。
而绝大多数性能优化的直觉("这段代码太慢,优化一下")恰恰用错了地方——因为代码那几毫秒通常不是瓶颈,队列才是。真正有效的动作往往是:减少排队(扩容、限流、削峰)、对齐超时(deadline 传播)、控制放大(重试退避与预算)、在关键路径上主动对冲。
所以下次再看到监控全绿而用户骂娘,别急着去改那段"感觉有点慢"的代码。先做两件事:把压测工具改成不会协同遗漏的版本,把 P99 真正算出来。
你会发现真正该改的东西,和你以为的完全不是一回事。
顺便说一句,凌晨三点叫醒你的从来不是平均延迟,而是那 1%。它一直在那儿,只是你一直没看它。