长尾延迟:P99 与 GC 抖动
「平均延迟 30ms、P99 是 2 秒」比「平均延迟 200ms」更糟糕:平均值掩盖了少数用户体验极差的请求,而这些请求恰恰是超时、重试与雪崩的起点。本章讲怎么判断是整体平移还是尾部异常,长尾的常见根因,以及处置顺序。
先判断:整体变慢还是尾部异常
| 分位表现 | 判断 | 方向 |
|---|---|---|
| P50 与 P99 同比例上涨 | 整体平移,容量或全链路问题 | 查下游、CPU、饱和度 |
| P50 稳定但 P99 上涨 | 长尾问题,少数请求极慢 | 查 GC、排队、锁、重传 |
| P99 稳定但 P999 上涨 | 极少数请求受影响 | 查超时重试、慢下游、冷数据 |
| 分位曲线出现明显拐点 | 触及某个资源上限 | 找那个资源 |
| 某分位整段抬高再回落 | 周期性事件 | 对齐时间戳找周期 |
判断工具首选直方图,而不是分位曲线:
同一接口的延迟直方图(单位 ms)
0-10 ████████████████████████ 62%
10-50 ████████████ 31%
50-100 ██ 5%
100-500 █ 1.5%
500-2000 ▏ 0.4%
>2000 ▏ 0.1%
| 直方图形状 | 含义 |
|---|---|
| 单峰且窄 | 健康,延迟集中 |
| 单峰整体右移 | 整体变慢,所有请求都受影响 |
| 单峰加右侧长尾 | 少数请求存在额外等待:排队、GC、重传 |
| 双峰 | 存在两类请求:命中缓存与未命中、或两种机型 |
| 尾部孤立小峰 | 周期性事件,多半是定时任务或后台刷新 |
双峰最容易被忽略:它通常意味着两条路径被混在同一个指标里,比如一部分请求走缓存、一部分回源。此时应该先拆维度(按 cache hit、按机型、按租户),而不是去优化代码。
长尾的常见根因
| 根因 | 特征 | 确认方式 | 处置 |
|---|---|---|---|
| GC 停顿 | 周期性与 GC 日志时间戳对齐 | 对比 GC 暂停时间与慢请求时间戳 | 换收集器、调堆、减少分配 |
| 队列排队 | 高负载时 P99 陡增,低负载消失 | 利用率与 P99 的相关性 | 限流、扩容、削峰 |
| 连接池等待 | 慢请求与获取连接耗时峰值对齐 | 连接池指标的 P99 | 调池大小、缩短语句时间 |
| 网络重传 | 表现为随机分布的长尾 | ss -ti 看 retrans、netstat -s 看重传率 | 修链路、加超时 |
| 磁盘抖动 | 与 IO wait、await 峰值对齐 | iostat -x 1 看 await 与 %util | 换存储、读写分离 |
| 下游长尾 | 慢请求都调用了同一个下游 | 按下游分组统计 P99 | 隔离、降级、并行化 |
| 锁竞争 | 并发上升时 P99 恶化明显 | 线程栈 BLOCKED、mutex profile | 减小临界区、分段锁 |
| CPU 抢占/超卖 | 与宿主机邻居负载相关 | 容器 cpu.throttled、steal time | 调整配额、独占核 |
| 冷启动与冷数据 | 新实例、首次访问慢 | 对齐实例启动时间 | 预热、缓存、提前编译 |
排队论视角:为什么 70% 就够呛
节点到达率接近处理能力时,排队长度与等待时间不是线性上升,而是发散。直觉版本:
利用率 50% → 平均排队时间 ≈ 1 倍服务时间
利用率 70% → 平均排队时间 ≈ 2.3 倍服务时间
利用率 85% → 平均排队时间 ≈ 5.7 倍服务时间
利用率 95% → 平均排队时间 ≈ 19 倍服务时间
所以「CPU 才 80%,为什么这么慢」是个错误的问题。80% 的稳定利用率意味着请求在队列里等待的时间已经是服务时间的数倍,任何小流量波动都会把 P99 推上去。运维上更实用的口径是:核心链路稳态利用率控制在 50%~70%,并以此反推扩容阈值;超过 80% 按临近拐点处理。
重试放大:长尾的加速器
超时重试本意是提高成功率,实际常常是雪崩的放大器:
| 场景 | 结果 |
|---|---|
| 客户端超时 1s、重试 3 次,下游已经变慢 | 下游流量放大 3 倍,进一步变慢 |
| 多层重试叠加(网关 3 次 × 服务 2 次 × SDK 2 次) | 放大 12 倍,直接打死下游 |
| 重试无退避无抖动 | 所有客户端同时重试,形成同步脉冲 |
| 对所有错误都重试 | 参数错误这类不可重试的也重试,纯浪费 |
| 重试请求计入同一超时预算 | 用户等待时间被重试吃光,体验更差 |
正确做法:只对幂等且可能自愈的错误(超时、连接重置、5xx)重试;总次数限制在 1 次;带指数退避与随机抖动;为每个下游设置重试预算(例如该下游总请求的 10%),超出预算直接失败。
处置顺序
1. 止血:给慢下游加超时与熔断,砍掉重试放大
2. 隔离:慢请求走独立线程池/连接池,避免污染核心链路
3. 削峰:限流与排队,把利用率压回 70% 以下
4. 拆解:减少串行调用,改并行或提前预取
5. 预热:新实例先跑热缓存与连接再放流量
6. 优化:GC、锁、序列化等单点问题逐个处理
顺序不能颠倒:在利用率 95% 的系统上做代码优化,收益会被队列吞掉,看起来像「优化无效」。
监控口径
| 指标 | 建议 |
|---|---|
| 分位数 | 至少 P50/P95/P99,核心接口加 P999 |
| 直方图 | 保留延迟直方图,用于判断形状与双峰 |
| 超时率 | 单列一个指标,不要混进错误率 |
| 重试率 | 按下游分别统计,超预算即告警 |
| 饱和度 | CPU、线程池、连接池、队列长度的 P99 与最大值 |
| GC 停顿 | 停顿时间与频率单独上报,并与 P99 共用时间轴 |
预防与巡检项
- 每个接口的延迟目标写成「P99 < X ms」,而不是平均值。
- 每周看一次延迟直方图形状,双峰与右侧长尾的变化比平均值更有信息量。
- 定时任务与后台批量刷新和大促错峰,避免制造周期性长尾。
- 扩容阈值按利用率而不是按 QPS 定,并留出 30% 余量。
- 压测要打分位,
wrk --latency的 P99、P999 与平均值一起看。
wrk -t8 -c200 -d300s --latency --timeout 2s http://10.0.0.12:8080/api/order/detail
小结:长尾问题先看直方图形状判断是整体平移还是尾部异常,再用时间轴对齐 GC、队列、连接池、重传等根因;稳态利用率控制在 70% 以下,重试限制在 1 次并带退避抖动,处置顺序永远是先止血与隔离,再谈优化。