🏁 P50 / P95 / P99:把所有请求拉出来排队

P95 = 95% 的请求比它快。平均值会被海量快请求稀释,P99 会诚实暴露最慢的那撮人。覆盖 c15 LLM 延迟 / c19 P99 Top10 / c23 WS 时长 / c28 / b4 等全部延迟面板的原理课。

P50 / P95 / P99:把所有请求拉出来排队 分位数 = 队伍里某个位置的人跑了多久 · 平均值会被快请求稀释 把一段时间内所有请求按耗时从快到慢排队(示意 20 个座位 = 1000 个请求) 分位数不关心每个人的确切耗时,只关心「队伍里那个位置的人等了多久」 P50 · 典型体验 P95 · 95% 比它快 P99 · 最倒霉 1% ← 快(100ms 级) 慢(60s 级 LLM 长输出)→ 第 500 名 第 950 名 第 990 名 🚨 平均值的陷阱(事故现场) 999 个请求 100ms + 1 个请求 60s 平均 = (999×0.1s + 60s) ÷ 1000 ≈ 160ms 看起来一切正常,没人报警——平均值被快请求稀释了 但那 1 个用户的体验是 60 秒的灾难 💥 类比:马云进酒吧,人均身价上百亿 平均数对吧里任何一个真实顾客都没有意义 所以 Grafana 延迟面板全用分位数,不用 mean ✅ 分位数的诚实:长尾无处可藏 同样 1000 个请求:P99 ≈ 60s —— 灾难一眼暴露 P50 正常 + P99 爆炸 = 长尾问题 常见病因:缓存未命中 / 锁冲突 / 大参数请求 P50 = 典型体验 · P95 = 最慢 5% 的代表 · P99 = 最惨 1% 📌 四两实况:c15 LLM 延迟 P99 跑到 60s+ 属正常 gpt-5 长输出本来就慢——先分 model 再下结论 所有延迟面板都是分位数:c15 / c19 / c23 / c28 / b4

💡 一句话理解

把一段时间内到达的所有请求按耗时从快到慢拉出来排队:P50 是队伍正中间那位(典型体验),P95 是站在 95% 位置的那位——95% 的请求都比它快,只有最慢的 5% 排它后面;P99 就是最倒霉的 1% 的水平。分位数不关心平均,只关心「队伍里某个位置的人等了多久」。

为什么不看平均值?因为平均值会被海量快请求稀释:999 个 100ms 的请求混进 1 个 60s 的 LLM 慢请求,平均只有约 160ms,看起来一切正常,其实有人等了整整一分钟。P99 会诚实地把这 60s 举过头顶。四两所有延迟面板(c15/c19/c23/c28/b4)全用分位数,没有一个用平均值。

🧩 费曼拆解 讲给完全没接触过的小白

想象一场 1000 人的马拉松。完赛后把所有人按成绩从快到慢排队:站在第 500 位的是「中位数选手」,他的成绩就是 P50——一半人比他快、一半人比他慢,是「典型选手」的水平;站在第 950 位的是 P95 选手——95% 的人都比他快,他是「最慢的那一批人」的代表;站在第 990 位的是 P99——垫底那 1% 的水平。赛事报告写「平均成绩 2 小时」毫无诊断价值,写「P99 选手跑了 4 小时」立刻引出问题:那 10 个人经历了什么?

服务器请求就是这场马拉松。大多数请求几十毫秒完赛,但 LLM 调用天生有长尾:gpt-5 长输出会跑到 60 秒以上。如果只报平均值,海量快请求把这 60s 稀释得无影无踪(999 个 100ms 加 1 个 60s,平均才 160ms)——报表绿色、用户爆碎。P99 不做平均,它直接指着队伍末尾问:最惨的这 1% 等了多久?

于是分位数成了「长尾照妖镜」:P50 正常而 P99 爆炸,说明大多数人没事、但有一小撮请求遇到了特殊情况——缓存没命中、撞上数据库锁冲突、或者带了个超大参数。四两的面板把三个分位数画在一起(c15 LLM 延迟、c23 WS 连接时长),就是让你一眼看出「队伍有多宽」:P50 和 P99 之间的距离,就是你的长尾宽度。

类比里的东西系统里对应的东西
第 500 名选手的成绩P50(中位数):典型用户体验,一半请求比它快
第 950 名选手的成绩P95:95% 的请求比它快,最慢 5% 的「代表」
垫底 1% 的成绩P99:最倒霉的 1%,长尾问题的照妖镜
全员平均成绩平均值:被海量快请求稀释,60s 慢请求根本拉不平它
P50 正常而 P99 爆炸长尾问题:一小撮请求撞上缓存未命中/锁冲突/大参数

🛠 动手验证(4 步,在 Grafana 里亲手做一遍)

  1. 看三条线的「队形」——打开 c15「LLM 调用延迟」:P50/P95/P99 三条线,P50 几秒、P99 跑到 60s+ 属正常(gpt-5 长输出,单位秒),先建立体感。
  2. 看排行榜怎么来的——打开 c19「HTTP P99 延迟 Top 10」:这就是按 P99 排序的「性能优化待办清单」,每条线一个慢接口。
  3. 量一次长尾宽度——在 Explore 里跑 histogram_quantile(0.50, …) 和 histogram_quantile(0.99, …)(c15 口径 by (le, model)):两条线的间距 = 长尾宽度。
  4. 用 P99 找一次僵尸连接——看 c23「WS 连接持续时长」:P99 异常长 = 该死的没死(teardown 清理没触发),去 c20 活跃连接数互证。

🧠 必知必会 看懂本课全部面板的地基

分位数 = 排队位置
P95 不是「95% 的延迟值」,是「队伍 95% 位置上那位选手的成绩」:95% 的请求比它快,5% 比它慢。方向记牢:百分数越大,代表越慢的那批人。
# 分位数在 PromQL 里的算式(下一课拆解内部机制)
histogram_quantile(0.95, sum(rate(…_bucket[5m])) by (le, handler))
# 0.95 = 队伍 95% 位置
P50 = 中位数 = 典型体验
一半请求比它快、一半比它慢。用户「一般体感」看 P50;它突变说明问题已波及大众,是更严重的信号。
# P50 突然整体抬升 = 大多数人都变慢了(c15 判读口径)
# → provider 变慢/限流,对照错误率面板互证
平均值的稀释陷阱
999 个 100ms + 1 个 60s,平均 ≈160ms:报表一切正常,灾难被稀释到看不见。这就是「马云进酒吧人均百亿」——平均数对任何真实个体都没有意义。
# 算给你看:
# (999 × 0.1s + 60s) ÷ 1000 ≈ 0.16s   ← 平均,岁月静好
# P99 ≈ 60s                            ← 真相,有人等了一分钟
P50 正常 + P99 爆炸 = 长尾
大多数人没事、一小撮人很惨。病因通常是三类:缓存未命中、锁冲突、大参数。处置不是调阈值,而是把这一撮人捞出来看它们撞上了什么。
# 下钻套路:全站 P99 抬升 → 按 handler 拆开找那撮人
histogram_quantile(0.99, sum(rate(siliang_http_request_duration_seconds_bucket[5m])) by (le, handler))
P99 高 ≠ 一定是故障
c15 的口径:gpt-5 长输出跑到 60s+ 属正常,别当成故障。先按 model 拆开,看抬升的是「天生慢的模型」还是「所有模型一起变慢」——后者才是 provider 出事。
# 按 model 拆(c15 口径),单位:秒
histogram_quantile(0.99, sum(rate(siliang_llm_call_duration_seconds_bucket[5m])) by (le, model))
拆维度才有行动意义
「全站 P95 多少」无法行动,「哪个 handler 的 P95 最该打」才能行动。分位数必须配 by 维度拆开,c19 干的就是这件事。
# c19 口径:按 handler 拆 P99,再取前 10 名
topk(10, histogram_quantile(0.99, sum(rate(…_bucket[5m])) by (le, handler)))
分位数是插值估算
Prometheus 不存每个请求的耗时(太贵),存的是一叠「累积水桶」,P95 是从桶之间插值估算出来的——桶越粗越不准。这正是下一课的主角。
# 分位数的原料是 _bucket(一叠累积计数器)
siliang_llm_call_duration_seconds_bucket{le="0.5"}
# 没有 _bucket 就排不了队(第 7 条坑)

🔗 在四两监控里哪里用到 理论落回你的 89 张图

① LLM 调用延迟 P50/P95/P99(按 model)(c15)——典型体验看 P50,最惨的 1% 看 P99;gpt-5 长输出 60s+ 属正常:🟡 LLM 调用延迟 P50/P95/P99 ↗

② HTTP P99 延迟(Top 10 慢接口)(c19)——按 P99 排序的「性能优化待办清单」,优化前后变没变快看它:🟢 HTTP P99 延迟(Top 10 慢接口)↗

③ WS 连接持续时长 P50/P95/P99(c23)——一条对讲机平均开多久;P99 异常长 = 僵尸连接线索:🔵 WS 连接持续时长 ↗

④ 消耗统计查询耗时 P95(c28)——查账单要等多久,聚合 SQL 随数据量变慢会在 P95 先显形:🟠 消耗统计查询耗时 P95 ↗

⑤ biz HTTP 延迟 P95(Top 5)(b4)——biz 盘的优化清单,比核心盘 top10 更聚焦:🟢 HTTP 延迟 P95(Top 5 慢接口)↗

🏭 生产实战 real world

场景 1 · 产出本周的性能优化待办清单

c19 就是干这个的:全站接口按 P99 排队,取最慢前 10。优化做完再看这张图验收。

# c19 完整口径:最慢 10 个接口的 P99(单位:秒)
topk(10, histogram_quantile(0.99,
  sum(rate(siliang_http_request_duration_seconds_bucket{job="siliang_backend_worker"}[5m]))
    by (le, handler)))

# 判读:榜首长期不动 = 最值得投入的优化点;
#       优化后名次下滑 = 改出了效果

场景 2 · 判断 LLM 变慢是常态还是事故

P99 高不一定是故障:先分 model,再看是「整体抬升」还是「只有长输出正常地长」。

# P50 与 P99 都按 model 拆(c15 口径,单位秒)
histogram_quantile(0.50, sum(rate(siliang_llm_call_duration_seconds_bucket[5m])) by (le, model))
histogram_quantile(0.99, sum(rate(siliang_llm_call_duration_seconds_bucket[5m])) by (le, model))

# 判读:P99 60s+ 但 P50 稳定 = gpt-5 长输出,正常;
#       P50 突然整体抬升 = provider 变慢/限流,对照 c13 错误率互证

场景 3 · 给核心接口的 P95 写告警

延迟告警盯分位数而不是平均值。阈值是业务承诺(SLA),示例值按你的业务定,四两图谱未定统一延迟阈值。

# P95 延迟告警模板(示例阈值,按业务 SLA 填)
groups:
- name: siliang-latency
  rules:
  - alert: HandlerP95Slow
    expr: |
      histogram_quantile(0.95,
        sum(rate(siliang_http_request_duration_seconds_bucket{job="siliang_backend_worker"}[5m]))
          by (le, handler)) > 2   # 示例:P95 超 2 秒
    for: 10m
    labels:
      severity: warning
    annotations:
      summary: "{{ $labels.handler }} P95 超标,进 c19 榜单核实"

场景 4 · 长尾下钻:P99 抬升后找「那一撮人」

全站 P99 抬升时,第一步是按 handler 拆开——通常是某一个接口在拉仇恨。

# 第一步:拆 handler 看谁在拉高全站(c19 不带 topk 的版本)
histogram_quantile(0.99, sum(rate(siliang_http_request_duration_seconds_bucket[5m])) by (le, handler))

# 第二步:锁定 handler 后缩小时间窗对比抬升前后(对齐时间戳)
# 第三步:按病因三连排查——缓存命中(m127)/锁冲突(c34/c35)/大参数

场景 5 · 优化验收:发布前后 P95 对比

「感觉快了」不算数,分位数说话:同一查询、两个时间窗,P95 降了多少清清楚楚。

# 优化前(把时间选择器拨到发布前 1h)
histogram_quantile(0.95, sum(rate(siliang_http_request_duration_seconds_bucket[5m])) by (le, handler))

# 优化后(发布后 1h,同一查询原样再跑一遍)
# 注意流量要可比:挑流量量级相近的两个时段,否则又是平均值的把戏

⚠️ 常见坑 pitfalls

坑 1 · 用平均值汇报延迟 — 症状:周报写「平均响应 160ms,一切正常」,客服却在收「页面转圈一分钟」的投诉。原因:平均值被海量快请求稀释,慢请求被藏起来了。正解:延迟只报分位数,P50 讲典型、P99 讲最惨。
# 错: 平均 160ms,一切正常            # → 稀释陷阱
# 对: P50 100ms / P99 60s,长尾待查    # → 诚实
坑 2 · 只看 P50 不看 P99 — 症状:P50 永远优雅,问题却反复发生。原因:P50 只代表「幸运的一半」,坏消息全在尾巴上。正解:三线连看:P50 管大众、P95 管少数、P99 管倒霉蛋。
# 错: 面板只画 P50                        # → 长尾全盲
# 对: P50/P95/P99 三线同图(c15/c23 口径)
坑 3 · 看到 P99 高就当故障 — 症状:LLM 延迟 P99 跑到 60s+ 就开始翻代码找 bug,白忙一场。原因:gpt-5 长输出天生就慢(c15 口径:60s+ 属正常)。正解:先按 model 拆开,P50 稳定而 P99 高 = 长输出的常态。
# 错: P99 60s = 出事了,全组排查        # → 可能是常态
# 对: by (model) 拆开,P50 抬升才立案   # → c15 判读口径
坑 4 · 把两段时间的 P95 相加平均 — 症状:「上午 P95 0.5s + 下午 P95 1.5s = 全天 P95 1.0s」——错了。原因:分位数不能跨集合平均,两个队伍的「第 95 名」平均出来谁也不是。正解:要全天分位数,回桶上重算(把窗口拉长让 histogram_quantile 自己算)。
# 错: (P95上午 + P95下午) / 2            # → 谁都不是
# 对: histogram_quantile(0.95, sum(rate(...[1d])) by (le, handler))
坑 5 · 把 P95 理解反了 — 症状:以为「P95 = 95% 的延迟值」或「最快的 95%」,定级全乱。原因:方向没搞清。正解:P95 = 队伍 95% 位置:95% 的请求比它快,它代表最慢的那 5%。
# 错: P95 是「95% 请求的延迟值」        # → 概念错
# 对: P95 = 95% 的请求比它快            # → 排队位置
坑 6 · 忘了 by 维度,全站一条线 — 症状:延迟曲线只有一条,出事不知道哪个接口背锅。原因:sum 时把 handler 维度加没了。正解:histogram_quantile 里必须 by (le, handler)——le 是命根子,下一课细讲。
# 错: sum(rate(…_bucket[5m]))                     # → 维度没了
# 对: sum(rate(…_bucket[5m])) by (le, handler)    # → 每接口一条
坑 7 · 没有桶却想算分位数 — 症状:对着一个单值 Gauge(如池借出数)问「它的 P95 呢」。原因:分位数需要「一堆样本排队」,单值时间序列在每个时刻只有一个点,排不了队。正解:分位数只能从直方图桶(_bucket)算——马上进入下一课。
# 错: histogram_quantile(0.95, siliang_db_pool_connections)  # → 排不了队
# 对: histogram_quantile(0.95, sum(rate(…_duration_seconds_bucket[5m])) by (le))

🎓 费曼自测 合上书,能讲出来才算懂

Q1 · 用马拉松,向运营同学解释 P95 是什么?

参考答案:把所有请求想象成 1000 个跑完的选手,按成绩从快到慢排队。P95 就是站在第 950 位那位选手的成绩——95% 的人都比他快,他代表「最慢的那一批」。所以「P95 延迟 2 秒」的意思是:95% 的请求 2 秒内就返回了,只有 5% 比它更慢。

Q2 · 为什么平均 160ms,却有用户等了 60 秒?

参考答案:999 个请求只花了 100ms,1 个请求等了 60s。平均把 60s 摊到 1000 个人头上:(999×0.1 + 60) ÷ 1000 ≈ 160ms——快请求把灾难稀释了。平均值对「个体体验」没有意义,就像马云进酒吧人均身价上百亿。要看见那 1 个用户,只能看 P99。

Q3 · P50 正常而 P99 爆炸,说明什么?按什么顺序查?

参考答案:这是典型的长尾问题——大多数人没事,一小撮请求很惨。下钻顺序:① 按 handler 拆开(c19 口径 by le, handler)找出拉高 P99 的接口;② 按三大病因排查:缓存命中(m127)、数据库锁冲突(c34/c35)、大参数/大响应;③ 修复后回到 c19 榜单看名次变化验收。

Q4 · c15 的 LLM 延迟 P99 跑到 60s+,是故障吗?怎么判断?

参考答案:不一定是。c15 的口径写明:gpt-5 长输出跑到 60s+ 属正常(单位秒)。判断方法:按 model 拆开看——只有长输出的模型 P99 高、P50 稳定,是常态;如果 P50 突然整体抬升,说明大家都变慢了,才是 provider 变慢或限流,此时对照 c13 错误率互证后再动手。

← 上一课:increase() 与 changes() 📚 课程目录 下一课:直方图水桶与 topk →