Skip to content

필사 모드: 设计能回答问题的 Prometheus 指标 —— 类型选择、基数预算,以及 rate 和分位数的陷阱

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 —— 40 个面板,却没有答案的仪表盘

处理故障的时候打开仪表盘,上面有 40 个面板:CPU、内存、线程数、GC 次数、连接池大小、堆内存使用量、每秒请求数,全都画着图。可这时候真正想知道的只有一件事:「用户是不是正在经历失败,如果是,占比多少。」

这个答案,那 40 个面板里哪一个都给不出来。

指标设计的问题不在于数据不够,而在于收集到的数据能回答的问题,和真正需要回答的问题之间存在错位。本文讲的就是怎么缩小这个错位。内容以 Prometheus 3.13.0 LTS 为准验证;原生直方图在 3.8.0 中已经稳定,但抓取仍然需要显式开启选项。

指标能回答的问题是什么形状的

指标是随时间变化的数字的聚合。这个定义决定了它能回答的问题是什么形状的。

问题指标能不能回答原因
从什么时候开始变差的时间轴是连续的,历史被保留下来
百分之多少的请求受到了影响聚合值本身就是比率
和昨天同一时段相比怎么样存储成本低,能做长期保留
这个用户的请求为什么失败了不能单个事件已经在聚合中消失了
慢请求的时间花在哪个服务上了不能按服务统计的数据无法保证属于同一个请求
是哪个输入值触发了这个分支不能如果值没进标签,就没法还原

最后三行很关键。一旦为了回答这些问题而增加标签,指标就会变成日志或 trace 的劣质替代品。基数决定了指标的边界。 一旦开始把每个请求都不一样的东西塞进标签,那东西就已经不是指标了,而是一个压缩率很差的事件存储。

出发点是一份问题清单。先写下值班在凌晨三点会问的五个问题,只做能回答这五个问题的指标。

  1. 用户是否正在经历失败,占比多少
  2. 是否变慢了,哪条路由变慢了
  3. 从什么时候开始的,是否和某次发布的时间重合
  4. 是否触及了容量上限(队列长度、连接池、磁盘)
  5. 依赖的外部服务里有没有出问题的

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
  • 邮箱、电话号码、订单号
  • 时间戳,或者包含时间戳的字符串
  • 错误信息原文 —— 一旦混入堆栈跟踪片段,取值就近乎无限
  • 自由格式的查询字符串

也有一些处于边界地带。podinstance 会按 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)

increaserate 乘以窗口长度得到的,所以算出来的不是整数。明明只发生了 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 把计算从查询时刻挪到评估时刻。建它的判断标准有四条。

  1. 同一个表达式在三个以上地方重复出现时
  2. 查询执行超过 2 秒时
  3. 告警规则在每次评估时都要跑一个很重的表达式时
  4. 想把高基数的原始数据聚合起来、做长期保留时

命名遵循 层级:指标:运算 这个约定。冒号只用在 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 就能把它抓出来。

放上仪表盘之前,自问的五个问题

每做一个面板,都要过一遍下面这些检查。过不了就不做这个面板。

  1. 这个面板回答的问题,能不能用一句话写出来。「CPU 使用率」不是一个问题,「这个服务是不是撞上了 CPU 上限、导致了延迟」才是一个问题。
  2. 值变差了之后要做什么,是不是已经定好了。 能看、但不会引发任何行动的指标,应该留作探索性查询,而不是放进仪表盘。
  3. 这个值和用户体验是怎么联系起来的。 GC 次数本身毫无意义,只有和延迟放在一起才有意义。
  4. 知不知道正常范围是多少。 不知道什么是正常,就不可能知道什么是异常。坐标轴范围和阈值线要一起定。
  5. 这条查询是不是在直接画 Counter,或者在对分位数求平均。 再确认一遍前面两节讲过的那些陷阱。

面板的顺序也要跟着问题的顺序走。最上面是用户视角的指标,往下是候选原因,最下面是基础设施资源。从上往下读,就是「是否有影响、来自哪里、是不是资源问题」这样一条线索。

结语 —— 指标的价值来自能回答问题,而不是数量

在一个有 300 个指标的系统里,值班找不到答案是常有的事。反过来,也有团队靠精心挑选的 20 个指标就能把大多数故障缩小范围。差别不在采集了多少,而在于每个指标是否明确写清楚了它是为了回答哪个问题而存在的。

现在就能做的、成本最低的两项检查是:第一,把时间序列数排名前 10 的指标拉出来,给每一个写下「它能回答什么问题」——答不上来的那些,往往占了成本的一半。第二,把仪表盘里所有含 histogram_quantile 的查询搜一遍,确认是不是都带着 by (le——总会漏掉一个。

值得继续深入的资料。

현재 단락 (1/229)

处理故障的时候打开仪表盘,上面有 40 个面板:CPU、内存、线程数、GC 次数、连接池大小、堆内存使用量、每秒请求数,全都画着图。可这时候真正想知道的只有一件事:「用户是不是正在经历失败,如果是,占...

작성 글자: 0원문 글자: 10,275작성 단락: 0/229