一粟 · yisu.app
· 13 min read
长尾性能

长尾请求的根治:从 P99 出发的工程方法

P50 优化容易,P99 优化很难。把长尾请求从"玄学"变成可观测、可归因、可优化的工程对象,需要一整套方法论。

我们团队曾有个目标:把详情页 P99 从 800ms 降到 200ms。前两周很快——加索引、加缓存、调连接池,P50 从 100ms 降到 30ms,但 P99 还在 700ms。后面的两个月非常痛苦——每次优化 P50 都不动了,P99 却顽固不动。这件事让我意识到,长尾优化是另一门学问。

长尾不是"平均变差",是"少数变极差"

长尾的本质是"少数请求的延迟远高于大多数"。它们不是平均值的偏移,而是分布里单独的一个包。所以优化长尾不能靠"整体加速",要靠"找到那些慢请求,单独解决"。

优化 P50 是把公路拓宽。优化 P99 是找出每辆抛锚的车,把它们拖走。

第一步:让长尾可观测

平均延迟仪表盘上看不到长尾。你需要:百分位时间序列(P50/P90/P99/P99.9);延迟直方图(看分布形状);慢请求采样(P99 之外的请求记录详情);关联 trace(把慢请求的完整链路留下)。关键动作:开"慢请求日志"。任何超过 P99 阈值的请求,自动记录 URL、参数、trace ID、机器、GC 状态。这是后续所有归因的基础。

第二步:归因——慢在哪里

拿到慢请求样本后,按"慢的原因"聚类。常见的几类:GC 型(周期性出现,全机器同时慢);IO 型(随机出现,单请求慢);竞争型(并发高时出现);下游型(整条链路都慢);调度型(特定 CPU 慢,NIC IRQ 绑定不当)。我们的样本里,60% 是 GC 型,20% 是 IO 型,10% 是下游型,10% 是其他。每一类需要不同的优化方法。

类型特征典型原因
GC 型周期性,全机器同时慢Java Full GC、Go STW
IO 型随机出现,单请求慢缓存未命中、磁盘 IO
竞争型并发高时出现锁竞争、连接池等待
下游型整条链路都慢依赖服务抖动

第三步:分型治理

GC 型长尾是最常见也最容易被忽视的。Java 的 Full GC 可能停顿几百毫秒,期间该机器上所有请求都慢。优化手段:调小 GC 阈值;用 G1GC / ZGC 替代 CMS;减少对象分配;用 jemalloc 替代 glibc malloc。IO 型长尾的优化:提高缓存命中率;未命中时用 singleflight 防止击穿;回源请求加超时 + 降级。竞争型长尾:换成分片锁、无锁结构、增大池、缩短临界区。

第四步:兜底——超时与降级

不是所有长尾都能根治。有些依赖就是会偶尔慢。这时候兜底机制是关键:超时(每个 RPC 必须有超时,且要分级);重试(幂等请求可以重试,但要限制重试次数);降级(超时后返回缓存或默认值);熔断(连续超时则熔断下游)。这套兜底看起来是"妥协",但其实是工程上的"优雅退让"——承认某些长尾无法根治,用机制保护用户体验。

第五步:把 P99 写进 SLO

最后,把 P99 目标写进 SLO,让它成为团队承诺。我们的详情页 SLO 是"P99 < 300ms,月度达标率 > 99%"。这个数字不是拍脑袋,是结合"用户感知阈值"与"系统能力"得出的。SLO 一旦定下,error budget 就有了。预算耗尽时,新功能发布暂停,全团队投入可靠性。这是把长尾治理变成"持续纪律"的机制。

几个反直觉的经验

  • 减少分配比优化算法更能降 P99。GC 是长尾最大来源。
  • 本地小缓存比远程大缓存更能降 P99。网络抖动也是长尾来源。
  • 减少依赖比加速依赖更能降 P99。少一个 RPC 就少一个长尾源。

长尾治理是一场持久战。它没有"一招制敌"的银弹,只有"持续观察、持续归因、持续治理"的纪律。但每一次把 P99 降下来,都是实打实的用户体验提升。