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

三个结论:

  1. 两个 P99 取平均 = 207 ms,真实值 120 ms,错 72%。 分位数不是可加的统计量,只有「先合并桶、再算分位」才对。
  2. 桶太粗会二次犯错:×1.2 的桶误差 10%,×2.5 的桶误差 58%。桶边界要按 SLO 判定点设计——SLO 卡在 P99,就在 P99 附近加密。
  3. 平均值能掩盖长尾: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 三支柱与成本模型)。

治理手段(按优先级):

  1. 在埋点处不放高基数标签——最有效,成本最低。
  2. relabel 白名单:只允许预定义的标签进入存储,其余丢弃(Prometheus 的 metric_relabel_configs)。
  3. 预聚合降维:recording rule 把高基数的明细聚成低基数的比率/分位数(如「按路由的 P99」),明细只保留短周期。
  4. exemplar:需要「从指标跳到个体」时,这是唯一不破坏基数的通道。
  5. 按租户/命名空间限额:远端写入侧限流,防止一个团队把整个集群打爆。

推还是拉

维度 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 记录真实分布(桶计数不受抓取间隔影响)、或者靠链路抽到那条慢请求。指标的时间分辨率由抓取间隔决定,链路由采样率决定,两者都不是「实时」。

关联