🪣 直方图水桶与 topk:分位数怎么算、毛线团怎么治

延迟不存单值,存一叠累积水桶(_bucket + le 标签);histogram_quantile 在桶间插值算分位数,topk 只画前 N 名治毛线团。覆盖 c19 / c15 / c23 / b4 / m109 / m123 等全部「分位数面板」的机制课。

直方图水桶与 topk:分位数怎么算、毛线团怎么治 延迟不存单值,存一叠累积水桶 · 分位数从桶里插值 · topk 只画前 N 名 一叠累积水桶:_bucket + le 标签 每只桶是一个 Counter:「耗时 ≤ le」的累计个数 le="0.01" 800 个 le="0.05" 950 个 le="1" 990 个 le="+Inf" 1000 个 注意:桶是累积的——le 大的桶包含 le 小的 「第 950 名在哪」就看相邻两只桶之间的空隙 histogram_quantile(0.95, …) 插值机 问:第 950 名排在哪? ≤0.05s 恰有 950 个 → P95 就在 0.05s 桶沿附近 50ms 1s P95 插值落点 桶越密插值越准;桶越粗越只能估个大概(估算值) histogram_quantile(0.95, sum(rate(…_bucket[5m])) by (le, handler)) le 必须留在 by 里——它是每只桶的天花板标签 🚨 第一大坑:忘了 by (le) sum(rate(…_bucket[5m])) 没写 by (le) → 一叠桶被加成一条线,le 标签消失 → histogram_quantile 拿不到桶,输出鬼数字 一叠桶 一条线 分位数从此失明 ✅ topk(10, …):毛线团急救剪 几十条线糊成毛线团 只画最慢的前 N 条 c19 HTTP P99 Top10 = topk(10, histogram_quantile(0.99, …)) 组合拳:c19 全站最慢 10 接口 · b4 biz 最慢 5 接口 · c15 LLM 延迟 by model · m109/m123 字节分布——全是桶 + 分位数 P95 是插值估算值:m123 用 p50/p95 对照看,桶越粗越只能看趋势

💡 一句话理解

系统不记录每个请求的耗时(太贵),只维护一叠累积水桶:「≤10ms 的有多少个、≤50ms 的有多少个、≤1s 的有多少个……」每只桶是一个 Counter,标签 le(less-or-equal)就是桶的天花板。histogram_quantile 拿着这叠桶回答「第 950 名排在哪」——在相邻两只桶之间插值估出 P95。

代价是 P95 只是估算值(桶越粗越不准);收益是十几个计数器就能算任意分位数。而当你把几十个接口的分位数曲线全部画出来,图会糊成毛线团——topk(10, …) 就是急救剪:只画最慢的前 10 条(c19 口径),排行榜立刻清爽。另外记住第一大坑:算分位数时 by (le) 万万不能丢,丢了桶就被加成一条线,分位数直接失明。

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

学校发成绩单从不公布每个人的分数,只公布分数段人数表:「60 分以下 5 人、80 分以下 38 人、100 分以下 40 人」。注意这张表不用存 40 个人的分数,只存 3 个计数——却足以回答「前 95% 的分界线大概在哪」:第 38 名在 80 分档里,第 36~38 名之间的分界线一插值就估出来了。直方图(Histogram)就是这张分数段人数表,每档是一只桶,桶里是累计人数。

为什么监控要这么省?因为「把每个请求的耗时都存下来」代价巨大——一秒钟几千个请求,存一周就是天文数字。分桶只要十几只计数器,就能随时算 P50/P95/P99 任意分位数。代价你现在已经知道:桶之间的细节丢了,P95 是插值估出来的,桶越粗越只能看大概。四两的字节分布面板(m123)干脆 p50/p95 一起看,就是为了对冲估算误差。

实操中有两个必杀技。第一是公式骨架:histogram_quantile(0.95, sum(rate(…_bucket[5m])) by (le, 维度))——先 rate(桶是 Counter)、再 sum by le(保留天花板标签)、最后插值;le 忘了 by 就全毁。第二是 topk:几十个 handler 各来几条线,图糊得没法看——topk(10, …) 每个时刻只留最慢的前 10 名,c19「P99 Top 10 慢接口」就是 topk 和 histogram_quantile 的合体技。

类比里的东西系统里对应的东西
分数段人数表(60 分以下 5 人…)_bucket 指标:le="0.05" 的桶里有 950 个(累积计数)
每一档的上沿(「80 分以下」)le 标签(less-or-equal):每只桶的天花板
用分段人数推算分界线histogram_quantile(0.95, …) 在相邻桶之间插值
档越细推算越准桶越密 P95 越准;桶粗只能估大概(m123 用 p50/p95 对照)
热搜榜只看前 10topk(10, …):毛线团里只画最慢的前 10 条(c19/b4 口径)

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

  1. 看水桶本桶——查询 siliang_http_request_duration_seconds_bucket{job="siliang_backend_worker"}:同一 handler 冒出一叠 le 不同的序列(le="0.005"、le="0.01"…le="+Inf"),这就是水桶堆。
  2. 亲手算一次 P95——套上 histogram_quantile(0.95, sum(rate(...[5m])) by (le)):一叠桶变成一条 P95 曲线。
  3. 故意踩一次第一大坑——把 by (le) 删掉再跑:图形变成毫无意义的鬼数字(在 Explore 里踩不疼,记牢这个手感)。
  4. 打开 c19 看合体技——面板口径是 topk(10, histogram_quantile(0.99, … by (le, handler))):把 10 改成 3 更清爽,改回 10 恢复榜单。

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

Histogram = 一叠累积桶
延迟、字节数这类「分布值」不存单值,存一叠桶;每只桶是一个 Counter:耗时/字节数 ≤ le 的累计个数。后缀 _bucket 就是它的身份证。
# 一只桶的样子:le 是天花板,值是累计个数
siliang_llm_call_duration_seconds_bucket{le="0.5"}
# 含义:耗时 ≤ 0.5 秒的调用累计发生了多少个
桶是累积的(cumulative)
le 大的桶包含 le 小的:≤1s 的 990 个里已经包含 ≤50ms 的 950 个。最后一档永远是 le="+Inf"(全部样本)。
# 累积关系:只增不减的套娃
le="0.05" → 950
le="1"    → 990   # 包含上面那 950 个
le="+Inf" → 1000  # 全部
标准公式骨架(背下来)
histogram_quantile(分位, sum(rate(…_bucket[窗口])) by (le, 维度))——rate 因为桶是 Counter;sum by (le, …) 因为要跨 worker 汇总但必须保留 le;最后插值。
# c15 口径:按 model 拆的 LLM 延迟分位数(单位:秒)
histogram_quantile(0.95, sum(rate(siliang_llm_call_duration_seconds_bucket[5m]))
  by (le, model))
忘了 by (le) = 第一大坑
没有 by (le),一叠桶被 sum 加成一条线,histogram_quantile 拿不到桶就无法插值,输出的数字毫无意义。看延迟图出现「鬼数字」,先查 le。
# 错: histogram_quantile(0.95, sum(rate(…_bucket[5m])))          # → 鬼数字
# 对: histogram_quantile(0.95, sum(rate(…_bucket[5m])) by (le, handler))
桶必须先 rate 再插值
桶是「累计个数」的 Counter,直接拿开机以来的累计值算分位数,得到的是「历史总平均的估算」,不是「现在的状态」。先 rate 让它变成「窗口内每秒进桶速度」。
# 错: histogram_quantile(0.95, sum(…_bucket) by (le))   # → 开机以来
# 对: histogram_quantile(0.95, sum(rate(…_bucket[5m])) by (le))
分位数是插值估算
P95 落在哪两只桶之间是估出来的,桶越粗越不准。四两字节分布面板用 p50/p95 对照(m123)就是为了看「分布形状」而不迷信单点。
# m123 口径:xAI 生图响应字节 p50/p95 对照
histogram_quantile(0.95, sum(rate(siliang_xai_image_response_bytes_bucket[5m])))
histogram_quantile(0.50, sum(rate(siliang_xai_image_response_bytes_bucket[5m])))
topk 治毛线团
topk(N, 表达式) 每个时刻只保留值最大的前 N 条序列。放在最外层就是排行榜:c19 取最慢 10、b4 取最慢 5。
# c19 完整口径:全站最慢 10 个接口的 P99
topk(10, histogram_quantile(0.99,
  sum(rate(siliang_http_request_duration_seconds_bucket[5m])) by (le, handler)))
别忘了 job 过滤
桶指标同样受 server job 双计影响——漏了过滤,每只桶的个数翻倍,分位数跟着失真。所有 _bucket 查询都要带 job。
# 标准姿势:job 过滤放花括号里
sum(rate(siliang_http_request_duration_seconds_bucket{job="siliang_backend_worker"}[5m])) by (le, handler)

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

① HTTP P99 延迟(Top 10 慢接口)(c19)——topk 与 histogram_quantile 的合体样板,你的性能优化待办清单:🟢 HTTP P99 延迟(Top 10 慢接口)↗

② LLM 调用延迟 P50/P95/P99(按 model)(c15)——by (le, model) 的教科书:gpt-5 长输出 60s+ 属正常:🟡 LLM 调用延迟 P50/P95/P99 ↗

③ WS 连接持续时长 P50/P95/P99(c23)——by (le) 全站一条线,P99 异常长 = 僵尸连接线索:🔵 WS 连接持续时长 ↗

④ biz HTTP 延迟 P95(Top 5)(b4)——topk(5, …) 聚焦版;要 P99 就把 0.95 改成 0.99:🟢 HTTP 延迟 P95(Top 5 慢接口)↗

⑤ 文件/媒体字节分布 p95(m109)——直方图不只量时间:ZIP 产物/视频转存/TTS 音频的「典型体重」也是桶,解释 RSS 为什么冲高:📏 文件/媒体处理字节分布 p95 ↗

🏭 生产实战 real world

场景 1 · 复刻「最慢接口排行榜」

c19 的完整口径,可直接抄进 Grafana;换 b4 的指标名就是 biz 盘版本。

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

# 判读:榜首长期霸榜 = 最优先优化;名次变化 = 优化有效/退化

场景 2 · 单接口三线深查(P50/P95/P99)

排行榜锁定嫌疑人后,把这一个接口的三条分位数拉出来看「队伍形状」。

# 固定 handler,三条分位数一起看(把 P95 榜首的 handler 填进来)
histogram_quantile(0.50, sum(rate(siliang_http_request_duration_seconds_bucket{handler="/me"}[5m])) by (le))
histogram_quantile(0.95, sum(rate(siliang_http_request_duration_seconds_bucket{handler="/me"}[5m])) by (le))
histogram_quantile(0.99, sum(rate(siliang_http_request_duration_seconds_bucket{handler="/me"}[5m])) by (le))

# 判读:三线贴得近 = 均匀慢(容量问题);P99 独秀 = 长尾(锁/缓存/大参数)

场景 3 · LLM 延迟按 model 对比(c15 口径)

不同模型的耗时分布天差地别,混在一起算分位数就是自欺欺人。

# 按 model 拆的 P95(单位:秒)
histogram_quantile(0.95,
  sum(rate(siliang_llm_call_duration_seconds_bucket{job="siliang_backend_worker"}[5m]))
    by (le, model))

# 判读:gpt-5 长输出 60s+ 属正常;P50 整体抬升才是 provider 变慢/限流

场景 4 · 字节数也有桶:大对象体重秤(m109)

RSS 无缘无故冲高?来字节分布面板对时间戳——谁在什么时候搬了大文件一对照便知。

# ZIP 产物的典型体重 P95(m109 口径之一)
histogram_quantile(0.95,
  sum(rate(siliang_file_batch_archive_bytes_bucket{job="siliang_backend_worker"}[5m]))
    by (le))

# 用法:RSS 冲高时刻 × 字节分布对时间戳 = 找到搬运工;
# 处理 200MB 视频时内存必先涨 200MB+

场景 5 · 毛线团分级治理

同一个分位数表达式,三个场景三种 N:巡检看前 3,排查看前 10,全量导出才不加 topk。

# 巡检大屏:只看前 3,一眼扫完
topk(3, histogram_quantile(0.99, sum(rate(siliang_http_request_duration_seconds_bucket[5m])) by (le, handler)))

# 排查:前 10(c19 口径);全量导出给脚本分析:去掉 topk
# 注意:topk 每个时刻独立选人,线条中途换 handler 属正常,看图例认名字

⚠️ 常见坑 pitfalls

坑 1 · 忘了 by (le)(第一大坑) — 症状:延迟图输出一条诡异的、忽高忽低毫无规律的线。原因:le 被 sum 加没了,一叠桶塌成一条线,插值机失去原料。正解:by 里永远带 le:by (le, handler)。
# 错: histogram_quantile(0.95, sum(rate(…_bucket[5m])))            # → 鬼数字
# 对: histogram_quantile(0.95, sum(rate(…_bucket[5m])) by (le, handler))
坑 2 · 桶没先 rate 就插值 — 症状:P95 曲线像被冻住,半天不动一下。原因:直接用开机以来的累计个数,分位数反映的是「历史总和」而不是「当前」。正解:桶是 Counter,必须 rate 之后再插值。
# 错: histogram_quantile(0.95, sum(…_bucket) by (le))    # → 开机以来
# 对: histogram_quantile(0.95, sum(rate(…_bucket[5m])) by (le))
坑 3 · 对 le 求 avg — 症状:跨 worker 汇总桶时写了 avg by (le),P95 低得离谱。原因:桶里是「个数」,多个 worker 的个数应该相加(sum),取平均会把人数人为缩水。正解:对桶聚合只允许 sum(按 le 保留桶沿)。
# 错: avg by (le) (rate(…_bucket[5m]))        # → 人数缩水
# 对: sum by (le) (rate(…_bucket[5m]))        # → 人数合并
坑 4 · 把两个 handler 的分位数再平均 — 症状:「接口 A 的 P95 加接口 B 的 P95 除以 2」当全站 P95 用。原因:两个队伍各自的第 95 名,平均出来谁也不是。正解:要全站分位数就回桶上重算:sum by (le) 不拆 handler。
# 错: (P95_A + P95_B) / 2                              # → 谁都不是
# 对: histogram_quantile(0.95, sum(rate(…_bucket[5m])) by (le))  # → 全站
坑 5 · topk 的线中途「换人」 — 症状:排行榜上某条线的名字看图例对不上,曲线看似断续。原因:topk 每个时刻独立选前 N 名——上一分钟是 A 接口垫底、下一分钟换 B 接口。正解:正常现象;认线看图例,固定对比请把 handler 写死在花括号里。
# 错: 认死一条线的颜色当「同一个接口」    # → 名字会换
# 对: 看图例名;固定对象用 {handler="/me"} 过滤
坑 6 · 分位数第一个参数写成 95 — 症状:histogram_quantile(95, …) 报错或输出为空。原因:参数是 0~1 的分数,0.95 才是 P95。正解:0.50 / 0.95 / 0.99;b4 卡注明的「把 0.95 改 0.99」说的就是这个位置。
# 错: histogram_quantile(95, …)           # → 不是合法分位
# 对: histogram_quantile(0.95, …)         # → P95
坑 7 · 桶太粗还指望精确值 — 症状:P95 永远停在某只桶的边界上不动,怀疑面板坏了。原因:桶粗(档位稀)时插值只能落在桶沿附近,精度天然有限。正解:把 P95 当趋势看;关键对比用 p50/p95 双线(m123 口径);要精确值只能加密桶(改埋点)。
# 错: P95 不动 = 面板坏了?              # → 桶粗,精度如此
# 对: p50/p95 双线看趋势(m123 口径),粗桶看形状足够

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

Q1 · 用成绩单类比,解释延迟为什么存成一叠桶而不是一个数?

参考答案:学校不公布每个人的分数,只公布分数段人数表(60 分以下 5 人、80 分以下 38 人……)——3 个计数就能回答「前 95% 的分界线在哪」。延迟同理:存每个请求的耗时太贵,分桶只要十几只计数器就能算任意分位数。每档就是一只桶(le 是档位上沿),桶里存的是「≤ 该值的累计个数」。

Q2 · 写出算延迟 P95 的标准公式,并解释每一部分为什么在那儿。

参考答案:histogram_quantile(0.95, sum(rate(…_bucket[5m])) by (le, handler))。rate:桶是累计计数器(Counter),必须先算每秒进桶速度才反映当前;sum:把 4 个 worker 的桶合并,但 by 里必须保留 le——它是桶的天花板标签,丢了插值机就没原料(第一大坑);histogram_quantile:在相邻桶之间插值估出第 95% 位置。

Q3 · topk 解决什么问题?它的曲线为什么会「中途换人」?

参考答案:几十个 handler 的分位数曲线画在一张图上会糊成毛线团,topk(N, …) 每个时刻只保留值最大的前 N 条,把图变成排行榜(c19 前 10、b4 前 5)。换人是因为 topk 每个时刻独立评选:上一分钟 A 接口最慢、下一分钟 B 反超,线条的主人就换了——看图例认名字,固定对比用 handler 过滤。

Q4 · 为什么说「P95 是估算值」?四两怎么对冲这个误差?

参考答案:桶只记「≤ 上沿的有多少个」,桶内的分布细节全丢了,P95 只能在相邻两只桶之间插值估个大概——桶越粗越不准。四两的对冲方式:m123 用 p50/p95 双线对照看分布形状而不是迷信单点;趋势判断足够用,要精确值只能加密埋点桶。

← 上一课:P50/P95/P99:把所有请求拉出来排队 📚 课程目录 下一课:max_over_time 与 deriv:抹抖动与算斜率 →