长尾请求的根治:从 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 降下来,都是实打实的用户体验提升。