50.4 度量(四黄金指标 / 直方图 / 基数爆炸)
一句话
指标是「写入前就聚合好的数值序列」,它便宜到可以 7×24 告警,但只能回答「多少、多快、多满」;三条纪律决定它可不可信——四黄金指标选对信号、分位数只在桶合并后算、标签基数必须受控。
数据模型:一条序列 = 名字 + 一组标签
http_requests_total{service="order", route="/checkout", code="500", instance="10.0.3.12:8080"} 1294
└──── 指标名 ────┘ └──────────────── 标签集(series 的身份)─────────────────┘ └─ 值 ─┘
- 序列(series) = 指标名 + 标签集的唯一组合。
- 样本(sample) = 一个时间戳 + 一个值;抓取间隔决定点密度(15 s → 每天 5760 点/序列)。
- 没有「行」的概念,也没有 schema:多一个标签值 = 多一整条序列,这就是后面所有成本问题的根源。
这种形状和关系库正好相反,五条特征解释了为什么必须用 TSDB(Prometheus、VictoriaMetrics、InfluxDB 这类)而不是 MySQL:
| 特征 | 含义 | 对存储的要求 |
|---|---|---|
| 写多读少 | 1000 QPS 的服务,指标写入是每 15 s 全量一次,查询是人在看的时候才发生 | 写入路径要极轻,不为事务/二级索引付出代价 |
| 只追加、不乱序 | 样本永远按时间往后追加 | 顺序写 + 时间分区,天然适合 LSM / 列式压缩 |
| 按时间范围整块过期 | 30 天前的数据整体没价值 | 按时间分块删除,不需要逐行 DELETE |
| 允许降采样 | 一年前的曲线用 1 分钟粒度就够 | 支持 rollup / 预聚合,把点密度降下来 |
| 查询是聚合 | 几乎每次查询都是 sum by (route) (rate(...)) | 列式存储 + 标签倒排索引,而不是 B+ 树回表 |
反过来说:把指标存进 MySQL,第一周能跑,第一个月会因为「每 15 秒几十万行写入 + 全表扫描聚合」而崩。时序库的设计取舍与产品对比见 44.17 时序数据库。
四种类型,决定「能不能聚合」
| 类型 | 语义 | 跨实例能合并吗 | 分位数怎么来 | 典型用途 | 坑 |
|---|---|---|---|---|---|
| Counter | 只增不减的累计值 | 能(求和) | 不可用 | 请求数、错误数、字节数 | 直接看总值无意义,要用 rate();进程重启归零由 rate() 容忍 |
| Gauge | 可增可减的瞬时值 | 能(相加或取 max) | 不可用 | 队列长度、内存占用、连接数 | 不同实例相加可能无意义(如「平均 CPU」) |
| Histogram | 累积桶 + _sum + _count | 能(逐桶相加) | 查询时用 histogram_quantile | 延迟、请求体大小 | 精度由桶边界决定;桶太粗则分位数失真 |
| Summary | 客户端算好的分位数 | 不能 | 客户端写入时已固定 | 单实例内部观测 | 多实例时无法合并出全局 P99 |
🔑 记法:Histogram 存的是「分布的形状」,Summary 存的是「分布的几个数」。形状可以合并,数不能。
原文把指标分为「计数类、瞬态、吞吐率、直方图、采样点分布」五类——那是按业务语义分的,不是 Prometheus 的类型体系。写代码时只有上面四种类型可用;按语义分类在落地时会直接撞墙(比如「吞吐率」不是一个类型,它是 Counter 的 rate())。
四黄金指标
| 指标 | 定义 | 用哪个类型 | 常见错误 |
|---|---|---|---|
| Latency 延迟 | 成功请求的耗时分位(P50/P95/P99) | Histogram | 把成功与失败混在一起:失败请求往往 1 ms 就返回,会把平均值拉低得「很好看」 |
| Traffic 流量 | 每秒请求数 / 每路由请求数 / 消费速率 | Counter + rate() | 只看总 QPS,看不到某个路由被刷 |
| Errors 错误 | 显式错误(5xx、超时)+ 隐式错误(200 但业务失败) | Counter,按 code/reason 分组 | 只统计 5xx,漏掉「HTTP 200 + 业务错误码」——这是最容易被骗的一类 |
| Saturation 饱和度 | 最可能成为瓶颈的那个资源的利用率 | Gauge | 只监控 CPU,忽略连接池、队列长度、磁盘 IO、文件句柄 |
四黄金指标来自 Google SRE 对「面向用户的服务」的抽象。落到工程上分成两套视角:
| 看什么对象 | 用哪套 | 具体信号 |
|---|---|---|
| 服务(请求/响应) | RED:Rate、Errors、Duration | 每路由 QPS、错误率、P50/P99 |
| 资源(机器/池/队列) | USE:Utilization、Saturation、Errors | CPU 利用率、就绪队列长度、连接池占用、OOM 次数 |
🔑 饱和度是最容易漏、也最容易解释「为什么突然雪崩」的一个:CPU 80% 可能毫无影响,而连接池 95% 会让 P99 从 50 ms 直接变成 5 s(排队)。先找那个「接近 100% 就排队」的资源。
直方图与分位数:为什么 P99 不能取平均
直方图把观测值落进累积桶。求分位数时,Prometheus 在命中的桶内做线性插值(假设桶内均匀分布)——所以精度完全由桶边界决定。下面这段程序同时演示两件事:桶合并才正确、桶太粗会错:
package main
import (
"fmt"
"math"
"math/rand"
"sort"
)
// —— Prometheus 直方图:累积桶计数 ——
type hist struct {
bounds []float64
counts []float64 // len(bounds)+1,最后一个是 +Inf
}
func newHist(ratio, start, max float64) *hist {
h := &hist{}
for b := start; b <= max; b *= ratio {
h.bounds = append(h.bounds, b)
}
h.counts = make([]float64, len(h.bounds)+1)
return h
}
func (h *hist) observe(v float64) {
h.counts[sort.SearchFloat64s(h.bounds, v)]++ // 第一个 >= v 的桶
}
// 与 Prometheus histogram_quantile 同样的算法:桶内线性插值
func (h *hist) quantile(q float64) float64 {
total := 0.0
for _, c := range h.counts {
total += c
}
rank, acc := q*total, 0.0
for i, c := range h.counts {
acc += c
if acc < rank {
continue
}
if i == len(h.bounds) {
return h.bounds[i-1] // +Inf 桶只能报下界
}
lo := 0.0
if i > 0 {
lo = h.bounds[i-1]
}
hi := h.bounds[i]
return hi - ((acc-rank)/c)*(hi-lo) // 假设桶内均匀分布
}
return math.Inf(1)
}
func exact(p []float64, q float64) float64 {
s := append([]float64(nil), p...)
sort.Float64s(s)
return s[int(q*float64(len(s)))-1]
}
func main() {
r := rand.New(rand.NewSource(42))
// A:99% 命中缓存 8ms,1% 回源 120ms
// B:98% 命中缓存 8ms,2% 回源 300ms
var a, b []float64
for i := 0; i < 100000; i++ {
va, vb := 0.008+r.Float64()*0.0002, 0.008+r.Float64()*0.0002
if r.Float64() < 0.01 {
va = 0.120
}
if r.Float64() < 0.02 {
vb = 0.300
}
a, b = append(a, va), append(b, vb)
}
all := append(append([]float64(nil), a...), b...)
truth := exact(all, 0.99) * 1000
fmt.Printf("两个实例各 10 万请求;合并后真实 P99 = %.0fms\n\n", truth)
fmt.Println("| 桶粒度 | A 的 P99 | B 的 P99 | 两个 P99 取平均 | 桶合并后 P99 | 误差 |")
fmt.Println("|---|---|---|---|---|---|")
for _, c := range []struct {
name string
ratio float64
}{{"×1.2", 1.2}, {"×2.5", 2.5}} {
ha, hb := newHist(c.ratio, 0.005, 10), newHist(c.ratio, 0.005, 10)
for i := range a {
ha.observe(a[i])
hb.observe(b[i])
}
merged := &hist{bounds: ha.bounds, counts: make([]float64, len(ha.counts))}
for i := range merged.counts {
merged.counts[i] = ha.counts[i] + hb.counts[i]
}
avg := (ha.quantile(0.99) + hb.quantile(0.99)) / 2 * 1000
got := merged.quantile(0.99) * 1000
fmt.Printf("| %s(%d 桶) | %.0fms | %.0fms | %.0fms | %.0fms | %+.0f%% |\n",
c.name, len(ha.bounds), ha.quantile(0.99)*1000, hb.quantile(0.99)*1000, avg, got,
(got-truth)/truth*100)
}
// 平均值如何掩盖长尾
mean := 0.999*5 + 0.001*2000
fmt.Printf("\n99.9%% 请求 5ms、0.1%% 请求 2000ms:平均值 %.1fms(看起来健康),P99.9 = 2000ms\n", mean)
}
输出(本机实测):
两个实例各 10 万请求;合并后真实 P99 = 120ms
| 桶粒度 | A 的 P99 | B 的 P99 | 两个 P99 取平均 | 桶合并后 P99 | 误差 |
|---|---|---|---|---|---|
| ×1.2(42 桶) | 111ms | 303ms | 207ms | 132ms | +10% |
| ×2.5(9 桶) | 78ms | 338ms | 208ms | 189ms | +58% |
99.9% 请求 5ms、0.1% 请求 2000ms:平均值 7.0ms(看起来健康),P99.9 = 2000ms
三个结论:
- 两个 P99 取平均 = 207 ms,真实值 120 ms,错 72%。 分位数不是可加的统计量,只有「先合并桶、再算分位」才对。
- 桶太粗会二次犯错:×1.2 的桶误差 10%,×2.5 的桶误差 58%。桶边界要按 SLO 判定点设计——SLO 卡在 P99,就在 P99 附近加密。
- 平均值能掩盖长尾:99.9% 的请求 5 ms、0.1% 的请求 2000 ms,平均只有 7 ms,看起来一切正常;但每 1000 个用户就有 1 个在等 2 秒。SLO 必须用分位数,不能用平均值。
桶宽与误差的经验关系:桶宽比为 r 时,桶内插值的相对误差在 (r−1)/2 量级——上面的实测里 ×1.2 得到 10%、×2.5 得到 58%,与这个估算吻合。要做「5% 精度」的分位数,r 取 1.05 左右;但桶越多,序列的时间序列点数也越多(每个桶都是一条 _bucket 序列),精度和基数是同一笔账。
基数爆炸:指标唯一的结构性成本
序列数 = 各标签取值数的乘积(忽略空值)。算一下:
route(100)× status(6)× instance(50) = 3 万序列
再乘 user_id(100 万) = 3000 亿序列
每天点数:3e11 × 5760 ≈ 1.7e15 点
按 1.5 B/样本: ≈ 2600 TB/天
这不是「贵」,是不可用:Prometheus 的倒排索引与内存里的序列元数据会先撑爆,抓取循环跟不上,整个实例 OOM。真实事故里通常发生在 user_id、request_id、原始 URL、错误消息这类标签上。
| 危险标签 | 为什么危险 | 替代方案 |
|---|---|---|
user_id / session_id | 用户量级,每个值一条序列 | 降级为 user_tier(≤ 5 个值);个体排查用日志/链路 |
request_id / trace_id | 每请求唯一,序列数 = 请求量 | exemplar:指标保持低基数,样本上挂一个 trace_id |
| 原始 URL / 完整 path | 带 id 的 URL 基数等于对象数 | 路由模板 /orders/{id} |
| 错误消息 / 堆栈 | 天然高基数且无限增长 | 归一化成 error.type 枚举 |
| 客户端 IP | 数量大且敏感 | 网段、地域、ASN |
| 毫秒时间戳、浮点数值 | 每个值都是新组合 | 分桶成 label(le 桶、size=1k-2k) |
量级参考(经验值,随实现差异大):单实例活跃序列在 100 万量级时,抓取与内存开销开始明显吃紧;200 万序列按 15 s 抓取、1.5 B/样本约 17 GB/天(本机算式见 50.1 三支柱与成本模型)。
治理手段(按优先级):
- 在埋点处不放高基数标签——最有效,成本最低。
- relabel 白名单:只允许预定义的标签进入存储,其余丢弃(Prometheus 的
metric_relabel_configs)。 - 预聚合降维:
recording rule把高基数的明细聚成低基数的比率/分位数(如「按路由的 P99」),明细只保留短周期。 - exemplar:需要「从指标跳到个体」时,这是唯一不破坏基数的通道。
- 按租户/命名空间限额:远端写入侧限流,防止一个团队把整个集群打爆。
推还是拉
| 维度 | Pull(Prometheus 默认) | Push(Pushgateway / remote write) |
|---|---|---|
| 谁发起 | 服务端定时抓取 | 客户端主动上报 |
| 目标发现 | 必须知道抓谁(服务发现、静态配置) | 不需要,谁知道地址谁推 |
| 健康信号 | 抓不到就是 up=0,天然有「目标活着吗」 | 推失败可能静默,需要额外监控 |
| 短生命周期任务 | 抓不到(任务已退出) | 必须用(批处理、CronJob、Serverless) |
| 客户端堆积 | 无(客户端只响应) | 服务端不可用时会堆积/丢数据 |
原文「先推后拉」的描述要修正一处:拉(pull)是服务端主动去客户端取数据,不是「服务端上报给客户端」。Pushgateway 的正确用途只有两类:短生命周期任务,以及 Prometheus 无法主动访问的目标(NAT 后、内网、跨网段)。它不该被当成「默认推送通道」——Pushgateway 不会自动清理过期指标,长期用它会让「已死的任务」一直显示旧值。
Exporter 是「把第三方系统的指标翻译成 Prometheus 格式」的组件:只要集成了 client library 就是一个 exporter;没有代码的系统(MySQL、Redis、Kafka、主机)用现成的 exporter 抓取。
业务指标和技术指标不是两种指标
「聚合度量是业务监控、链路追踪是技术监控」这个分法不成立:Prometheus 既能采 CPU 使用率(技术),也能采订单数(业务),两者是同一种数据形状,同样受基数约束。
真正有意义的区分是**「离用户多远」**,它决定排障顺序:
| 层 | 例子 | 用来回答 |
|---|---|---|
| 业务漏斗 | 下单成功率、支付成功率、跳出率 | 用户有没有受影响 |
| 入口 RED | 每路由 QPS、错误率、P99 | 受影响的入口是哪个 |
| 依赖 RED | DB/缓存/MQ 的调用错误率与耗时 | 是哪个依赖 |
| 资源 USE | CPU、连接池、队列、磁盘 | 依赖为什么慢 |
「网页跳出率 40%」和「CPU 85%」是同一种数据,区别只在它离用户几步:跳出率异常 → 看入口 RED → 看依赖 → 看资源。这正是 50.1 三支柱与成本模型 里那条排障顺序在指标内部的展开。
反模式
| 反模式 | 后果 |
|---|---|
| 用平均值做 SLO | 长尾完全不可见,用户投诉与看板长期矛盾 |
| 用 Summary 算全局 P99 | 多实例无法合并,结果取决于「哪个实例恰好被抓到」 |
| 桶边界离 SLO 判定点很远 | 99.9% 的 SLO 用 10 个粗桶判,误差比预算还大 |
告警用瞬时值 rate(...[1m]) | 抖动即报警,误报泛滥(正确做法在 50.5 告警) |
| 高基数标签进了指标 | 序列爆炸,抓取变慢、内存暴涨、账单翻倍 |
| 抓取间隔 > 判定窗口 | 15 s 抓取无法发现持续 8 s 的尖刺(大促时尤其致命) |
| 只采 CPU/内存,不采饱和度 | 「为什么突然排队」永远解释不清 |
| 只采集不治理 | 序列数每年翻几倍,半年后没人敢重启 Prometheus |
自检
Q1 两个实例的 P99 分别是 100 ms 和 200 ms,合并后的 P99 是多少?
▸ 答案:不知道——这个信息不足以算出合并 P99。分位数不可加,必须回到桶:把两个实例的 _bucket 逐桶相加,再用 histogram_quantile 求。若两实例流量不同,还要按流量加权(桶计数天然带权重)。取平均(150 ms)是错的,可能在 120 ms 也可能在 190 ms,取决于分布形状,实测里平均法错了 72%。
Q2 Summary 和 Histogram 都能给 P99,什么时候必须用 Histogram?
▸ 答案:只要需要跨实例/跨维度聚合,就必须用 Histogram。Summary 的分位数在客户端就算死了,服务端拿到的只是「数」,无法合并(两个 P99 不能求平均);Histogram 存的是桶计数,可加、可分组、可在查询时按任意维度聚合。Summary 只适合单实例内部、且确定永不聚合的场景,实践中基本不用。
Q3 加一个 user_id 标签,存储会放大多少?为什么?
▸ 答案:放大的倍数约等于该标签的去重值个数(乘进原有的维度组合)。100 万用户就是约 100 万倍——从 3 万序列变成 3000 亿,直接不可用。原因是序列身份 = 标签组合,每个新取值都新开一条序列,而每个序列每天都要写满 5760 个点。这跟 QPS 无关,流量不变账单照样爆炸。
Q4 四黄金指标里最容易被忽略的是哪个?漏了会怎样?
▸ 答案:饱和度。延迟、流量、错误都是「已经发生」的结果,饱和是「即将发生」的原因。只监控 CPU 时,连接池占用 95%、队列长度暴涨、文件句柄耗尽都看不见,于是只看到「P99 突然从 50 ms 变成 5 s」,却解释不了为什么——这是典型的排队雪崩。
Q5 抓取间隔 15 s,能发现持续 8 s 的延迟尖刺吗?
▸ 答案:大概率不能。8 s 的尖刺落在两次抓取之间就完全不可见;即使被采到,也只有 1 个点,用 rate(...[1m]) 会被平滑掉。要发现它有三条路:缩短抓取间隔(成本上升)、在客户端用 Summary/Histogram 记录真实分布(桶计数不受抓取间隔影响)、或者靠链路抽到那条慢请求。指标的时间分辨率由抓取间隔决定,链路由采样率决定,两者都不是「实时」。