Skip to content
Published on

平均响应时间为什么在撒谎 — 正确读懂 p50、p95 与 p99

分享
Authors

引言 — 平均只有 80ms,为什么还有人抱怨慢

仪表盘上的平均响应时间是 80ms。过去一个月曲线一直平坦,告警也没有响过。可客服团队每天都会收到「结算页面会卡住好几秒」的反馈。

这两个事实并不矛盾。平均本来就是这样运作的指标。平均把分布的形状压成一个数字,而它最先丢掉的,恰恰是用户真正发火的那一段。

本文要讲的就是如何把那一段重新挖出来。百分位回答什么问题,为什么百分位之间不能求平均,以及 Prometheus 直方图实际返回的是什么数字 — 一直下沉到你可以自己动手算的层面。

平均隐藏了什么 — 长尾分布的算术

响应时间的分布不是正态分布。左侧有一堵墙(不可能比 0ms 更快),右侧则长长地拖出去。GC 停顿、冷缓存、连接池等待、重试、吵闹的邻居 — 变慢的方式很多,变快的方式一个也没有。

用数字来看。假设 10,000 次请求中有 9,900 次在 50ms 内结束,100 次花了 3,000ms。

# 只取出访问日志中的响应时间(ms)列,直接计算分布
awk '{print $NF}' access.log | sort -n > sorted.txt

wc -l < sorted.txt
# 10000

awk '{s+=$1} END {printf "mean %.1f\n", s/NR}' sorted.txt
# mean 79.5

for n in 5000 9500 9900 9990; do
  printf "n=%s\t%s\n" "$n" "$(awk -v r=$n 'NR==r' sorted.txt)"
done
# n=5000	50      <- p50
# n=9500	50      <- p95
# n=9900	50      <- p99  (正好卡在边界上)
# n=9990	3000    <- p99.9

平均是 79.5ms。而实际上没有任何一次请求是在 79.5ms 得到响应的。平均描述的是一个并不存在的用户。

更糟糕的是灵敏度。就算那 100 次慢请求从 3,000ms 恶化一倍到 6,000ms,平均也只是从 79.5ms 移动到 109.5ms。30ms 的上升越不过任何一条告警阈值。而对撞上这 100 次请求的用户来说,服务看起来已经彻底坏了。平均几乎察觉不到尾部正在恶化

p50、p95、p99、p99.9 各自回答的问题

每个百分位回答的问题都不一样。只看其中一个,比一个都不看更糟。

指标回答的问题典型的误解
平均总处理时间除以请求数等于多少它代表典型的用户体验
p50一半的请求会在这个时间内结束大多数请求都在这个量级
p95二十次里遇到一次的慢有多慢这是可以忽略的异常值
p99一百次里遇到一次的慢有多慢只有 1% 的用户会遇到
p99.9基础设施异常与 GC 停顿显形的区间流量少的时候也有意义

实务上的读法是这样的。

  • p50 变差,说明整个系统都慢了。要怀疑容量不足或公共依赖出了问题。
  • p50 没变而只有 p99 变差,说明问题只在特定条件下发生。去看特定租户、特定查询路径、特定节点、锁竞争与 GC。
  • p99 与 p99.9 的间距拉开,说明出现了罕见但极其昂贵的路径。检查重试风暴或超时设置。
  • p50 与 p99 几乎相同,说明分布被压缩了。这可能是好信号,但也要一并考虑是不是前面有超时在截断请求。

p99.9 只有在流量体量撑得住的时候才有意义。在 5 分钟进来 1,000 次请求的服务里,p99.9 就是单独一次请求。给一次请求的信号挂告警,告警就变成了随机数发生器。

p99 不是「一百个人里有一个」 — 延迟的乘法效应

这是最常见也最昂贵的误解。p99 是 2 秒,意思不是「1% 的用户会经历 2 秒」,而是「请求每 100 个里有 1 个要经历 2 秒」。如果一个用户会产生多次请求,那么体感概率就要按请求数相乘。

// 当一个页面发起 N 次后端调用时
// 「至少有一次超过 p99」的概率
const p = 0.99
for (const n of [1, 5, 20, 50, 200]) {
  const hit = 1 - Math.pow(p, n)
  console.log(`${String(n).padStart(3)}次调用 → ${(hit * 100).toFixed(1)}%`)
}
//   1次调用 → 1.0%
//   5次调用 → 4.9%
//  20次调用 → 18.2%
//  50次调用 → 39.5%
// 200次调用 → 86.6%

如果仪表盘的一个页面要调用 20 个 API,那么每打开一次页面遇上 p99 延迟的概率是 18 个百分点。对一天产生 200 次请求的活跃用户来说,有 87 个百分点的概率一天至少经历一次最坏区间。p99 不是少数倒霉用户的遭遇,而是几乎所有用户的日常。

由此得出两个结论。第一,扇出越大的架构,尾部延迟的治理就越重要。改善单个服务的 p99,效果比改善整个页面的 p50 更大。第二,必须单独观察客户端侧的指标。用户真正经历的是页面完成时刻的百分位,而不是服务端的 p99。

百分位不能求平均 — 所以才需要直方图

这一节是全文实务价值最高的部分。总和与计数可以相加,但百分位既不能相加也不能求平均。

以两台实例为例。

  • 服务器 A:9,900 次请求,全部 50ms → p99(A) = 50ms
  • 服务器 B:100 次请求,全部 3,000ms → p99(B) = 3,000ms

把两个 p99 求平均是 1,525ms。按请求数加权平均是 79.5ms。可是把两台服务器的 10,000 次请求排成一列算出来的真正 p99 是 3,000ms。三个数字全都不同,而前两个毫无意义。

所以下面这段 PromQL 是错的。

# 错误: 先分别求出每个实例的 p99 再取平均
avg(
  histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)

正确的顺序是先把桶计数器合并,再从合并后的分布上计算百分位。因为计数器是可聚合的,而百分位是不可聚合的。

# 正确: 先按 le 标签把 rate 相加,然后再计算百分位
histogram_quantile(
  0.99,
  sum by (le) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)

如果想按服务拆开看,就把那个标签和 le 一起放进 sum by 子句。le 绝对不能漏掉。

# 按路由拆分的 p99 — 同时保留 le 与 route
histogram_quantile(
  0.99,
  sum by (le, route) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)

出于同样的原因,Prometheus 的 summary 类型在实例内部就已经算好百分位再导出,因此没有办法把多个实例或多条路由合并起来。它可以用于单个进程的本地诊断,但不能作为服务级别的指标。直方图放弃了桶分辨率范围内的精度,换来的是可聚合性。在分布式系统里,后者几乎总是正确的交易。

Prometheus 直方图 — 桶边界与插值误差

histogram_quantile 返回的值并不是观测到的真实值,而是把桶边界之间用直线连起来得到的估计值。

curl -s localhost:8080/metrics | grep '^http_request_duration_seconds_bucket'
# http_request_duration_seconds_bucket{le="0.005"} 0
# http_request_duration_seconds_bucket{le="0.01"}  0
# http_request_duration_seconds_bucket{le="0.025"} 12
# http_request_duration_seconds_bucket{le="0.05"}  4103
# http_request_duration_seconds_bucket{le="0.1"}   8890
# http_request_duration_seconds_bucket{le="0.25"}  9604
# http_request_duration_seconds_bucket{le="0.5"}   9740
# http_request_duration_seconds_bucket{le="1"}     9812
# http_request_duration_seconds_bucket{le="2.5"}   9993
# http_request_duration_seconds_bucket{le="5"}     10000
# http_request_duration_seconds_bucket{le="+Inf"}  10000

p99 是第 9,900 个观测值。这个值落在 le=1 的桶(累计 9,812)与 le=2.5 的桶(累计 9,993)之间。线性插值是这样算的。

python3 - <<'PY'
lo, hi = 1.0, 2.5
c_lo, c_hi = 9812, 9993
target = 0.99 * 10000
est = lo + (target - c_lo) / (c_hi - c_lo) * (hi - lo)
print(f"p99 estimate = {est:.3f}s")
PY
# p99 estimate = 1.729s

结果是看起来很精确的 1.729 秒,但这个桶里的 181 次观测实际落在哪里,没有人知道。它们可能全都是 1.05 秒,也可能全都是 2.4 秒。桶一旦宽,histogram_quantile 的输出就是一个精确到小数点后三位的猜测。

由此得出三条实务规则。

第一,必须放入与 SLO 阈值完全相同的桶边界。如果要以 300ms 为基准,就必须有 le=0.3 这个桶。有了边界,就能不经插值直接数出准确的比率。

第二,在关心的区间把桶放密一些。以 Web API 的标准来看,默认桶在 0.1 秒以上过于稀疏。

// prom-client — 按服务的真实分布自行确定边界
const { Histogram } = require('prom-client')

const httpDuration = new Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP 请求处理时间',
  labelNames: ['method', 'route', 'code'],
  // 把 SLO 阈值 0.3 作为边界纳入,并把 100ms~1s 区间放密
  buckets: [0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.4, 0.6, 0.8, 1, 2, 5, 10],
})

第三,把最上面那个有限桶设得比实际超时更大。一旦 p99 越过最后一个有限边界,histogram_quantile 要么返回该边界值本身(取决于实现),要么贴死在上限上,你就再也无法知道情况到底有多糟。

如果自己挑选桶边界这件事本身就是负担,那么 Prometheus 的原生直方图值得评估。它会自动生成指数间隔的桶,很大程度上消除了边界选择与分辨率问题。不过它的存储格式、查询路径和远程存储兼容性都与经典直方图不同,所以要先确认整个技术栈的支持情况。

用百分位定义 SLO 时的实务标准

这里有最后一个反转。把延迟 SLO 定义成「p99 不超过 300ms」,在实务中并不是好选择。

理由前面已经全部出现过了。百分位不能聚合,沿时间轴也无法合并。把 5 分钟窗口的 p99 攒够 30 天,也不会变成 30 天的 p99。你既算不出错误预算,也求不出燃烧率。

改用比率来表达同样的内容。「99% 的请求在 300ms 内完成」这句话可以直接由越过阈值的请求比率算出来,而这个比率是可以相加的。

# 延迟 SLI: 在 300ms 内完成的请求比率
sum(rate(http_request_duration_seconds_bucket{job="checkout-api", le="0.3"}[30d]))
/
sum(rate(http_request_duration_seconds_count{job="checkout-api"}[30d]))
# 可以直接接到错误预算消耗率上 (目标 99%,即预算 1%)
(
  1 -
  sum(rate(http_request_duration_seconds_bucket{job="checkout-api", le="0.3"}[1h]))
  /
  sum(rate(http_request_duration_seconds_count{job="checkout-api"}[1h]))
) / 0.01

这种写法有三个好处。没有插值误差(因为直接数 le=0.3 这个桶)。可以按任意时间窗口重新计算。而且能原样插进燃烧率告警。

再整理一些定标准时可以参考的实务数值。

  • 目标百分位要结合用户旅程的扇出来定。如果一个页面要调 20 次,那么单个服务的目标必须是 99.9% 而不是 99%,页面层面才能得到 98%。
  • 阈值要从人的感知极限倒推。让人觉得即时响应的 100ms、不打断心流的 1 秒、注意力离开的 10 秒,这些是很老的基准线,今天依然成立。
  • 测量窗口取 28 天或 30 天滚动,并且要比发布周期更长。窗口太短的话,一次发布的失误就会立刻把预算烧光。
  • 百分位仪表盘要继续看。SLO 用比率来管理,但原因排查要从把 p50、p99、p99.9 叠在一起看的图开始。

结语 — 要看的是分布,不是一个数字

要记住的只有一句话。平均把分布压成一个数字,最先丢掉的就是用户发火的那部分;百分位能把那部分显示出来,却永远无法再合并回去。

于是实务上的顺序变成这样。用直方图保留原始分布,把 SLO 阈值埋进桶边界,百分位只用在人看的仪表盘上,而告警与 SLO 用可聚合的比率来定义。平均响应时间那张图可以删掉。仅仅把 p50 和 p99 并排放到那个位置,大多数团队就已经开始看见此前看不见的事故了。

值得继续深入的资料。