- Published on
设计能回答问题的 Prometheus 指标 —— 类型选择、基数预算,以及 rate 和分位数的陷阱
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 —— 40 个面板,却没有答案的仪表盘
- 指标能回答的问题是什么形状的
- Counter、Gauge、Histogram —— 选错了,某些计算会在原理上变得不可能
- 标签基数预算
- rate 在什么条件下会悄悄给出错误答案
- histogram_quantile 在什么条件下会悄悄给出错误答案
- Recording rule —— 什么时候建、起什么名字
- 放上仪表盘之前,自问的五个问题
- 结语 —— 指标的价值来自能回答问题,而不是数量
引言 —— 40 个面板,却没有答案的仪表盘
处理故障的时候打开仪表盘,上面有 40 个面板:CPU、内存、线程数、GC 次数、连接池大小、堆内存使用量、每秒请求数,全都画着图。可这时候真正想知道的只有一件事:「用户是不是正在经历失败,如果是,占比多少。」
这个答案,那 40 个面板里哪一个都给不出来。
指标设计的问题不在于数据不够,而在于收集到的数据能回答的问题,和真正需要回答的问题之间存在错位。本文讲的就是怎么缩小这个错位。内容以 Prometheus 3.13.0 LTS 为准验证;原生直方图在 3.8.0 中已经稳定,但抓取仍然需要显式开启选项。
指标能回答的问题是什么形状的
指标是随时间变化的数字的聚合。这个定义决定了它能回答的问题是什么形状的。
| 问题 | 指标能不能回答 | 原因 |
|---|---|---|
| 从什么时候开始变差的 | 能 | 时间轴是连续的,历史被保留下来 |
| 百分之多少的请求受到了影响 | 能 | 聚合值本身就是比率 |
| 和昨天同一时段相比怎么样 | 能 | 存储成本低,能做长期保留 |
| 这个用户的请求为什么失败了 | 不能 | 单个事件已经在聚合中消失了 |
| 慢请求的时间花在哪个服务上了 | 不能 | 按服务统计的数据无法保证属于同一个请求 |
| 是哪个输入值触发了这个分支 | 不能 | 如果值没进标签,就没法还原 |
最后三行很关键。一旦为了回答这些问题而增加标签,指标就会变成日志或 trace 的劣质替代品。基数决定了指标的边界。 一旦开始把每个请求都不一样的东西塞进标签,那东西就已经不是指标了,而是一个压缩率很差的事件存储。
出发点是一份问题清单。先写下值班在凌晨三点会问的五个问题,只做能回答这五个问题的指标。
- 用户是否正在经历失败,占比多少
- 是否变慢了,哪条路由变慢了
- 从什么时候开始的,是否和某次发布的时间重合
- 是否触及了容量上限(队列长度、连接池、磁盘)
- 依赖的外部服务里有没有出问题的
Counter、Gauge、Histogram —— 选错了,某些计算会在原理上变得不可能
选类型不是口味问题。一旦选错,后面想做的某个计算就会在原理上变得不可能。
Counter 只会单调递增,进程重启后会归零。数值本身没有意义,只有用 rate 看每秒变化率才有意义。请求数、错误数、处理的字节数、重试次数都属于这一类。
Gauge 会上下波动,表示当前时刻的状态。队列长度、活跃连接数、内存使用量、温度都属于这一类。
Histogram 把观测值的分布记录成一组桶计数器,用在延迟、响应大小这类「想知道分位数」的值上。
最常见的错误是把延迟记录成 Gauge。
# 差 —— 只留下最后一次请求的延迟,既算不出分位数也算不出平均值
from prometheus_client import Gauge
last_latency = Gauge("http_request_duration_seconds", "请求处理时间")
def handle(req):
t0 = time.monotonic()
resp = process(req)
last_latency.set(time.monotonic() - t0) # 之前的值全部消失
return resp
如果抓取间隔是 15 秒、每秒请求数是 500,那 7500 次请求里只有 1 次的值会被存下来,其余的就像从没发生过一样。这条时间序列既算不出 p99,事后重新处理数据也没法把它找回来。
# 好 —— histogram 会把每一次观测都累加进桶里
from prometheus_client import Counter, Histogram
REQUESTS = Counter(
"http_requests_total", "请求总数",
["method", "route", "status_class"],
)
LATENCY = Histogram(
"http_request_duration_seconds", "请求处理时间",
["method", "route"],
buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
)
def handle(req):
route = req.route_template # "/v1/orders/:id" —— 不是真实的 ID
with LATENCY.labels(req.method, route).time():
resp = process(req)
REQUESTS.labels(req.method, route, f"{resp.status // 100}xx").inc()
return resp
注意 status_class。如果直接把状态码放进去,取值会有几十种,而大多数问题只需要知道是 2xx 还是 5xx。需要具体状态码的时刻是排查问题的时候,那时候看日志就够了。
| 想知道的东西 | 类型 | 常见的错误答案 | 错误答案的后果 |
|---|---|---|---|
| 每秒请求数 | Counter | 用 Gauge 记上一秒的请求数 | 抓取间隙之间的请求会消失 |
| 延迟分位数 | Histogram | 用 Gauge 记最后一个值 | 无法计算分位数 |
| 延迟分位数 | Histogram | 应用自己算出来的 p99 Gauge | 没法跨实例聚合 |
| 当前队列长度 | Gauge | 用 Counter 累计入队数 | 不知道当前积压了多少 |
| 总吞吐量 | Counter | 用 Gauge 记每秒吞吐量 | 一旦漏抓,那段时间的数据就没了 |
| 批处理任务是否成功 | Counter + 时间戳 Gauge | 只在成功时给 Counter 加一 | 分不清「跑了但失败」和「根本没跑」 |
最后一行是批处理任务里反复出现的问题。失败的时候也要给 Counter 加一,才能区分「跑过但失败了」和「压根没跑」。同时也要用 Gauge 记下最后一次成功的时间。
JOB_RUNS = Counter("batch_job_runs_total", "批处理运行次数", ["job", "result"])
JOB_LAST_SUCCESS = Gauge("batch_job_last_success_timestamp_seconds", "最后一次成功的时间", ["job"])
def run_job(name, fn):
try:
fn()
JOB_RUNS.labels(name, "success").inc()
JOB_LAST_SUCCESS.labels(name).set(time.time())
except Exception:
JOB_RUNS.labels(name, "failure").inc()
raise
标签基数预算
基数不是靠感觉,而是乘法。一个指标的时间序列数,是各标签取值个数的乘积,再乘以实例数。
http_request_duration_seconds_bucket
route 120 个 (路由模板)
method 5 个
le 11 个 (桶边界 + Inf)
instance 40 个 (Pod 数)
------------------------------------
= 120 * 5 * 11 * 40 = 264,000 条时间序列
再加上 _sum 和 _count
120 * 5 * 40 * 2 = 48,000
------------------------------------
合计约 312,000 条时间序列 —— 仅来自一个指标
单个 Prometheus 能承载的活跃时间序列,大致在几百万这个量级时,内存和查询响应速度就会急剧恶化。精确的上限因硬件和查询模式而异,但重要的是:如果一个指标就用掉了 30 万条时间序列,光这一个就已经吃掉了预算的相当一部分。
绝对不能放进标签的,是取值集合接近无限的东西。
- 用户 ID、会话 ID、请求 ID、trace ID
- 未归一化的 URL 路径 ——
/v1/orders/A-99183 - 邮箱、电话号码、订单号
- 时间戳,或者包含时间戳的字符串
- 错误信息原文 —— 一旦混入堆栈跟踪片段,取值就近乎无限
- 自由格式的查询字符串
也有一些处于边界地带。pod 或 instance 会按 Pod 数量成倍增加,而且自动伸缩和滚动发布会让这些值不断变化,随着时间推移不断累积。现实的策略是只在真正需要按实例区分的指标上保留它,其余的用 recording rule 聚合之后,原始数据只做短期保留。
下面这些查询可以用来诊断当前状态。
# 时间序列数量排名前 10 的指标
topk(10, count by (__name__)({__name__=~".+"}))
# 某个指标的基数是由哪个标签造成的
count(count by (route) (http_requests_total))
count(count by (instance) (http_requests_total))
count(count by (status) (http_requests_total))
# 全部活跃时间序列数量的走势 —— 如果出现阶梯式跳变,说明有新标签被引入了
prometheus_tsdb_head_series
# 每个抓取目标发来多少样本 (超过上限抓取会被拒绝)
topk(20, scrape_samples_scraped)
prometheus_tsdb_head_series 值得挂一条告警。基数事故大多在发布之后呈阶梯式出现,把时间和发布对上,就能立刻找到罪魁祸首的那次提交。
在采集环节也有办法拦住它。在抓取配置里设一个样本数上限,就能防止某个爆炸的目标把整个 Prometheus 拖垮。
# prometheus.yml
scrape_configs:
- job_name: checkout-api
sample_limit: 20000 # 该目标发来的样本超过这个数,本次抓取就按失败处理
label_limit: 24
label_value_length_limit: 256
scrape_interval: 15s
metric_relabel_configs:
# 用于事故应对 —— 在采集这一步就把出问题的标签清掉。
# 之所以不用 labeldrop、而是用 replace 写入空值:
# labeldrop 只看标签的「名字」来匹配,会把这个 job 下所有指标里的
# user_id 都清掉;像下面这样写,就只会从 http_requests_total 里清掉它。
# 在 Prometheus 的数据模型里,空值的标签等同于不存在这个标签。
- source_labels: [__name__]
regex: 'http_requests_total'
target_label: user_id
replacement: ''
action: replace
# 完全不需要的指标直接丢弃
- source_labels: [__name__]
regex: 'go_gc_duration_seconds.*|python_gc_.*'
action: drop
一旦触发 sample_limit,该目标的这次抓取会整个失败,所以这个值要留得宽松一些,但必须设置。没有上限的话,一个服务的失误就会让整个监控停摆。
rate 在什么条件下会悄悄给出错误答案
rate 用 range vector 里第一个样本和最后一个样本算出每秒增长率,并对 Counter 重置做修正。这里有三个陷阱。
陷阱 1 —— 窗口相对抓取间隔太窄
rate 要求窗口内至少有两个样本。如果抓取间隔是 15 秒,窗口却只有 20 秒,就会出现只落进一个样本的瞬间,结果直接变空。表现为图表上的空洞,在告警里则表现为「条件始终不成立」。
# 危险 —— 抓取间隔 15s 时,[20s] 会出现只有 1 个样本的瞬间
rate(http_requests_total[20s])
# 安全 —— 至少是抓取间隔的 4 倍。告警建议用 [5m] 以上
rate(http_requests_total[1m])
rate(http_requests_total[5m])
经验法则很简单:告警用 5 分钟以上,仪表盘用 Grafana 的 rate 窗口变量。 4 倍规则能保证哪怕漏抓一两次,结果依然存活。
陷阱 2 —— 在 sum 之后再用 rate
这个错误因为结果看起来很合理,所以能存活很久。
# 错误 —— 先把 Counter 加起来,实例重启(也就是重置)就检测不到了
rate(sum(http_requests_total) by (route)[5m:])
# 正确 —— 先 rate,再聚合
sum(rate(http_requests_total[5m])) by (route)
Counter 重置的修正只在单条时间序列的层面上才准确。某个 Pod 一旦重启,那条序列会掉到 0,但一旦已经被合并了,看到的只是「总数略微下降」,根本不会被识别成重置。结果就是算出来的请求率比实际偏低,看起来每次发布之后流量都掉了一截。
陷阱 3 —— 直接画 Counter
# 只会画出一条不断右上扬的直线,没有任何信息
http_requests_total
# 每秒增长率 —— 这才是真正想看的值
sum(rate(http_requests_total[5m])) by (route)
# 特定区间内的总增量 —— 很适合写进告警文案
sum(increase(http_requests_total{status_class="5xx"}[1h])) by (route)
increase 是 rate 乘以窗口长度得到的,所以算出来的不是整数。明明只发生了 3 次错误,increase 却返回 3.4,这不是 bug,而是外推的结果。需要「精确 N 次」的计算不要用它。
irate 只看最后两个样本。用在仪表盘上看瞬时反应还不错,但绝对不要用在告警里,一次噪声就能把它触发。
histogram_quantile 在什么条件下会悄悄给出错误答案
分位数的计算陷阱更多。
陷阱 1 —— 聚合时漏掉了 le
# 错误 —— 丢掉 le,各个桶会被混在一起,算出一个毫无意义的数字
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (route))
# 正确 —— le 必须保留
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)
)
这个错误不会报错,只会返回一个数字,所以经常在仪表盘上挂好几个月都没人发现。
陷阱 2 —— 在桶边界之外做外推
histogram_quantile 是在桶边界之间做线性插值的。如果 p99 落在最后一个有限桶之上,函数就会返回那个最后的边界值。真实的 p99 是 8 秒,但最后一个桶是 2.5 秒,那结果就永远是 2.5 秒。
# 诊断 —— 看 Inf 桶和最后一个有限桶之间的差值
sum(rate(http_request_duration_seconds_bucket{le="+Inf"}[5m])) by (route)
-
sum(rate(http_request_duration_seconds_bucket{le="10.0"}[5m])) by (route)
如果这个值大于 0,说明有请求超出了最后一个桶,p99 可能已经不可信了。
反过来,桶设得太稀疏,插值误差就会很大。如果桶只有 0.1 和 1.0 两档,而大多数请求都集中在 0.15 秒附近,p99 的估计值就会在 0.1 和 1.0 之间线性切分,和真实值差得很远。桶要在 SLO 阈值附近设得密一些。 如果目标是 300ms,桶里就应该有 0.2、0.25、0.3、0.4、0.5 这些值。
陷阱 3 —— 对分位数求平均
# 错误 —— 分位数不支持算术平均
avg(histogram_quantile(0.99, ...))
# 正确 —— 先把桶计数器加起来,再算分位数
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))
各实例 p99 的平均值不等于整体的 p99。这正是 histogram 有价值的原因:桶计数器可以相加,加完之后再求分位数,得到的才是对整体分布的估计。如果让应用自己算出 p99 再暴露成 Gauge,就会失去这个性质。
原生直方图
从 Prometheus 3.8.0 开始,原生直方图是一个稳定功能。它用指数化的方式自动管理桶边界,不需要自己去挑边界,而且用单条时间序列来表示,能大幅降低基数。不过抓取依然需要显式开启。
# prometheus.yml
global:
scrape_interval: 15s
scrape_configs:
- job_name: checkout-api
# 原生直方图的抓取是显式 opt-in
scrape_native_histograms: true
static_configs:
- targets: ['checkout-api:8000']
# 固定桶: _bucket 时间序列上带 le 标签
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route))
# 原生直方图: 不带 le,直接把指标本身传进去
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m])) by (route))
| 项目 | 固定桶直方图 | 原生直方图 |
|---|---|---|
| 时间序列数 | 桶数乘以标签组合数 | 每种标签组合 1 条 |
| 桶设计 | 必须提前选好,改动会和历史数据不连续 | 自动,只需指定分辨率 |
| 超出范围的值 | 在最后一个边界处被截断 | 靠指数化的方案覆盖更广的范围 |
| 工具兼容性 | 到处都能用 | 需要在抓取时 opt-in,部分工具不支持 |
| 迁移难度 | 基准 | 需要修改查询和仪表盘 |
从新指标开始转换会更安全。一旦改动已有指标,那一刻起历史数据和查询就会出现不连续。
Recording rule —— 什么时候建、起什么名字
Recording rule 把计算从查询时刻挪到评估时刻。建它的判断标准有四条。
- 同一个表达式在三个以上地方重复出现时
- 查询执行超过 2 秒时
- 告警规则在每次评估时都要跑一个很重的表达式时
- 想把高基数的原始数据聚合起来、做长期保留时
命名遵循 层级:指标:运算 这个约定。冒号只用在 recording rule 里,绝不能出现在原始指标名里。遵守这个约定,光看名字就能知道保留了哪个维度。
# rules/http.yml
groups:
- name: http_sli
interval: 30s
rules:
# 第一层 —— 只从原始数据聚合一次
- record: route:http_requests:rate5m
expr: sum(rate(http_requests_total[5m])) by (route)
- record: route:http_requests_errors:rate5m
expr: sum(rate(http_requests_total{status_class="5xx"}[5m])) by (route)
# 第二层 —— 引用第一层,不重新扫描原始数据
- record: route:http_error_ratio:rate5m
expr: |
route:http_requests_errors:rate5m
/
route:http_requests:rate5m
- record: route:http_request_duration_seconds:p99_rate5m
expr: |
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)
)
# 整个服务级别 —— 去掉了 route 维度的汇总
- record: service:http_error_ratio:rate5m
expr: |
sum(rate(http_requests_total{status_class="5xx"}[5m]))
/
sum(rate(http_requests_total[5m]))
分层结构是核心。如果多条规则复用第一层,对原始时间序列的扫描就只需要一次。但规则之间绝不能形成循环引用,而 promtool 又抓不出这个问题,所以要靠评审来把关。
recording rule 会新增多少时间序列,要在部署前算清楚。如果有 120 条路由,又分别针对 5 个窗口建了规则,那就是 600 条新的时间序列;规则有 50 条的话,就是 3 万条。
验证要放进 CI 里跑。
# 语法检查
promtool check rules rules/http.yml
# 单元测试 —— 输入一组时间序列,验证期望值
promtool test rules tests/http_test.yml
# 检查完整配置
promtool check config prometheus.yml
# tests/http_test.yml
rule_files:
- ../rules/http.yml
evaluation_interval: 30s
tests:
- interval: 15s
input_series:
- series: 'http_requests_total{route="/v1/orders", status_class="2xx"}'
values: '0+150x40'
- series: 'http_requests_total{route="/v1/orders", status_class="5xx"}'
values: '0+3x40'
promql_expr_test:
- expr: route:http_error_ratio:rate5m
eval_time: 8m
exp_samples:
- labels: 'route:http_error_ratio:rate5m{route="/v1/orders"}'
value: 0.0196078431372549
提前自己把期望值算出来,以后要是有人「优化」规则时不小心改变了含义,CI 就能把它抓出来。
放上仪表盘之前,自问的五个问题
每做一个面板,都要过一遍下面这些检查。过不了就不做这个面板。
- 这个面板回答的问题,能不能用一句话写出来。「CPU 使用率」不是一个问题,「这个服务是不是撞上了 CPU 上限、导致了延迟」才是一个问题。
- 值变差了之后要做什么,是不是已经定好了。 能看、但不会引发任何行动的指标,应该留作探索性查询,而不是放进仪表盘。
- 这个值和用户体验是怎么联系起来的。 GC 次数本身毫无意义,只有和延迟放在一起才有意义。
- 知不知道正常范围是多少。 不知道什么是正常,就不可能知道什么是异常。坐标轴范围和阈值线要一起定。
- 这条查询是不是在直接画 Counter,或者在对分位数求平均。 再确认一遍前面两节讲过的那些陷阱。
面板的顺序也要跟着问题的顺序走。最上面是用户视角的指标,往下是候选原因,最下面是基础设施资源。从上往下读,就是「是否有影响、来自哪里、是不是资源问题」这样一条线索。
结语 —— 指标的价值来自能回答问题,而不是数量
在一个有 300 个指标的系统里,值班找不到答案是常有的事。反过来,也有团队靠精心挑选的 20 个指标就能把大多数故障缩小范围。差别不在采集了多少,而在于每个指标是否明确写清楚了它是为了回答哪个问题而存在的。
现在就能做的、成本最低的两项检查是:第一,把时间序列数排名前 10 的指标拉出来,给每一个写下「它能回答什么问题」——答不上来的那些,往往占了成本的一半。第二,把仪表盘里所有含 histogram_quantile 的查询搜一遍,确认是不是都带着 by (le——总会漏掉一个。
值得继续深入的资料。