Learn
Prometheus/18-operational-best-practices

运维最佳实践

前面十七章讲的都是「怎么用 Prometheus」。这一章讲的是**「怎么让 Prometheus 活下去」**。

这两件事的区别,在小规模时几乎感觉不到。50 个目标、20 万条序列,一台 4 核 8G 的机器能稳稳跑上一年,你不需要懂任何调优知识。

但当规模翻十倍——或者更常见的情况:规模没翻十倍,但有人给某个指标加了一个 user_id 标签——一切都会在几个小时内崩塌:

  • Prometheus 内存从 6G 涨到 60G,被 OOM Killer 杀掉。
  • 重启后进入 WAL 重放,花了 40 分钟才恢复服务,期间监控全瞎。
  • 好不容易起来了,因为要重新加载所有序列,内存又爆了,进入无限重启循环。
  • 看板全部超时,告警规则大面积跳过求值,prometheus_rule_group_iterations_missed_total 一路飙升。
  • 磁盘在两天内被写满。

这不是危言耸听,这是每个跑过大规模 Prometheus 的团队都经历过的剧本。这一章要讲的,就是如何避免它,以及它发生时如何抢救。

读完本章你会掌握:

  • 基数(cardinality)的确切含义、爆炸的传导路径,以及用 /status/tsdb 和 PromQL 做诊断的完整流程
  • 哪些标签是「高基数陷阱」,以及用 metric_relabel_configs、sample_limit、label_limit 建立防线
  • 磁盘容量与内存的估算公式,--storage.tsdb.retention.time 与 retention.size 的取舍
  • 抓取间隔、--query.max-samples、GOMEMLIMIT 等性能相关参数的实际影响
  • 记录规则的减负原理、命名规范、以及「什么该做什么不该做」
  • 联邦(federation)的能力边界,什么时候必须换成远程写入 + 长期存储
  • 告警疲劳的量化诊断与分诊体系设计

一、基数:Prometheus 的第一杀手

什么是基数

基数 = 时间序列的条数。

在 Prometheus 里,指标名 + 一组标签键值对唯一确定一条时间序列。所以:

某个指标的序列数 = 各标签取值数的乘积

举例,http_requests_total 有这些标签:

job      : 1 个值(api)
instance : 20 个值
method   : 4 个值(GET/POST/PUT/DELETE)
status   : 8 个值
path     : 50 个值

序列数 = 1 × 20 × 4 × 8 × 50 = 32000。

这个数字还算健康。现在假设有人加了一个 user_id 标签,日活 10 万:

序列数 = 32000 × 100000 = 32 亿。

这就是基数爆炸。 注意它的关键特征:是乘法,不是加法。加一个标签不是「多一点开销」,而是「乘以这个标签的取值数」。

⚠️Histogram 是基数的隐形放大器

一个 Histogram 指标实际上会产生 桶数 + 2 条序列:每个桶一条 _bucket,加上 _sum 和 _count。

默认的 Go 客户端 Histogram 有 11 个桶(外加 +Inf),也就是 14 条序列。

所以「给 Histogram 加一个有 50 个取值的 path 标签」,实际增加的是 50 × 14 = 700 条序列,而不是 50 条。再乘以 20 个实例,就是 14000 条序列——一个指标就吃掉了整个系统基数预算的一大块。

Histogram 上加标签要按 14 倍来估算代价。 这也是原生直方图(native histogram)最大的价值:它把一个直方图压缩成一条序列。

基数爆炸的传导路径

理解「为什么高基数这么致命」,需要知道 Prometheus 的内存结构。

Prometheus 的 TSDB 分成 head block(内存中的最近 2 小时数据) 和 persistent blocks(磁盘上已压缩的历史块)。head block 里,每一条活跃序列都需要常驻内存:

  1. 一个正在写入的 chunk(约 1 到 2 KB,压缩后存放最近的样本)
  2. 倒排索引的一部分(标签名到序列 ID 的映射、标签值到序列 ID 的 posting list)
  3. 序列的元数据(标签集本身的字符串,虽然有 interning 去重,但仍占空间)

经验值是每条活跃序列 3 到 8 KB 内存,取决于标签数量和标签值长度。取中间值 5 KB 算:

  • 100 万序列 ≈ 5 GB
  • 500 万序列 ≈ 25 GB
  • 3200 万序列 ≈ 160 GB

而且这只是 head block 的基础开销。查询时还要额外分配内存:一次 sum(rate(x[5m])) 扫描 100 万条序列,中间结果会再占用几个 GB。

更糟的是序列流失(churn)。如果标签值本身在不断变化(比如 pod 名字每次发布都变、request_id 每次请求都变),那么「活跃序列」和「历史序列」都在暴涨:

  • 活跃序列:当前正在写入的
  • 历史序列:head block 里 2 小时内出现过、但已经不再写入的——它们的索引仍然占着内存,直到 head block 被压缩落盘

每次发布导致所有 pod 名字变化,就意味着基数在 2 小时内翻倍。 一个发布频繁的环境,churn 造成的内存压力可能比稳态基数还大。

诊断第一站:/status/tsdb

Prometheus Web UI 的 Status → TSDB Status 页面(URL 是 /tsdb-status 或 /status/tsdb,取决于版本)是排查基数问题最快的入口。它直接给出:

  • Head Stats:head block 里的序列总数、样本总数、时间范围
  • Top 10 series count by metric names:哪些指标的序列最多——九成的问题在这一栏就能看出来
  • Top 10 label names with value count:哪些标签的取值最多
  • Top 10 series count by label value pairs:哪个具体的标签值组合关联了最多序列
  • Top 10 label names with high memory usage:哪些标签名占内存最多(长字符串标签会在这里暴露)

这个页面是本地计算的,不消耗查询资源,应该成为你排查的第一反应。

诊断第二站:PromQL

页面看不出全貌时,用查询继续深挖。

# 【最重要的一条】按指标名统计序列数,取 Top 20
topk(20, count by (__name__) ({__name__=~".+"}))
⚠️这条查询本身很贵,别在看板上放

{__name__=~".+"} 匹配所有序列。在一个 500 万序列的 Prometheus 上执行,会瞬间加载全部序列的索引,可能直接触发 --query.max-samples 限制或者把内存打高一截。

排查时手动执行一次没问题,绝对不要放进 Grafana 看板或者记录规则。 更安全的做法是优先看 /status/tsdb 页面,它给的是同样的信息但零查询成本。

如果一定要查询,可以先缩小范围:

topk(10, count by (__name__) ({job="suspect-job"}))

其他诊断查询:

# 按 job 看序列分布
topk(20, count by (job) ({__name__=~".+"}))
 
# 某个指标的哪个标签基数最高(逐个标签试)
count(count by (path) (http_requests_total))
count(count by (user_id) (http_requests_total))
count(count by (instance) (http_requests_total))
 
# 每次抓取拉回来多少样本(不需要扫索引,很便宜,可以放看板)
topk(20, scrape_samples_scraped)
 
# 经过 metric_relabel_configs 处理后实际入库的样本数
topk(20, scrape_samples_post_metric_relabeling)
 
# 这个差值就是被 drop 掉的量,可以验证 relabel 规则有没有生效
scrape_samples_scraped - scrape_samples_post_metric_relabeling
 
# head block 当前的序列总数(这是最该长期监控的一个指标)
prometheus_tsdb_head_series
 
# 序列流失率:每秒新建了多少序列
rate(prometheus_tsdb_head_series_created_total[5m])
rate(prometheus_tsdb_head_series_removed_total[5m])
 
# 样本摄入速率(samples/s)
rate(prometheus_tsdb_head_samples_appended_total[5m])
 
# 有没有目标因为超过 sample_limit 被拒绝
increase(prometheus_target_scrapes_exceeded_sample_limit_total[1h]) > 0
💡churn 比稳态基数更值得警惕
# 流失率 / 稳态序列数。这个比值越高,内存压力越大
rate(prometheus_tsdb_head_series_created_total[1h]) * 3600
  /
prometheus_tsdb_head_series

这个值等于「一小时内新建的序列数占总序列数的比例」。

  • 小于 0.1 → 健康,序列基本稳定。
  • 大于 1 → 每小时都在把整个序列集换一遍,说明有标签在高频变化。典型元凶:pod 名字(每次发布变)、container_id、随机端口、时间戳、批次号。

治理方向不是「减少标签」,而是「让标签值变稳定」——比如用 deployment 名代替 pod 名,用 statefulset 序号代替随机后缀。

高基数标签黑名单

这些标签,永远不要放进 Prometheus 指标:

标签基数量级为什么危险
user_id / account_id用户总数直接乘以日活,几乎必然爆炸
email / phone用户总数同上,外加 PII 合规风险
request_id / trace_id请求总数每条序列只有一个样本,纯粹的浪费
session_id会话总数同上
timestamp / 日期字符串无上限时间本来就是 TSDB 的维度,放标签里是双重记账
完整 URL(含 query string)无上限?page=1、?page=2 各算一条序列
完整错误消息无上限错误消息里常带变量(ID、时间、路径)
SQL 语句原文无上限同上,且字符串很长,索引内存翻倍
IP 地址(客户端)很大服务端 IP 可以,客户端 IP 绝对不行
container_id高流失每次重启就变,churn 灾难
版本号(每次构建变)中等但持续增长用 build_info 单独一条指标承载,别贴到业务指标上
ℹ️判断标准:这个标签的取值集合会随时间无限增长吗?

好标签的取值集合是有界且稳定的:环境名、集群名、服务名、状态码、HTTP 方法、路由模板、错误类别、数据中心。这些东西的取值数量是有限的,而且不会天天变。

坏标签的取值集合随业务量增长:用户、请求、会话、订单、消息。

遇到「我就是想知道某个用户的情况」这类需求,答案是:那不是 Prometheus 该干的事。 用日志(Loki / ELK)或链路追踪(Jaeger / Tempo)。Prometheus 是聚合系统,不是明细查询系统。这个边界一旦模糊,系统必然崩溃。

有一个折中方案:把高基数维度降维成有界维度。比如不存 user_id,而是存 user_tier(free / pro / enterprise,3 个值);不存完整 URL,而是存路由模板 /api/users/:id(有界);不存错误消息原文,而是存 error_code(有界枚举)。

建立防线

第一道防线:metric_relabel_configs——在入库前丢掉。

scrape_configs:
  - job_name: 'app'
    static_configs:
      - targets: ['app:8080']
 
    metric_relabel_configs:
      # 丢掉整个高基数指标
      - source_labels: [__name__]
        regex: 'app_request_detail_.*'
        action: drop
 
      # 丢掉某个指标的某个高基数标签(保留指标本身)
      - source_labels: [__name__]
        regex: 'http_requests_total'
        action: labeldrop
        # 注意:labeldrop 的 regex 匹配的是「标签名」,不看 source_labels
        # 上面这条写法是错的,正确写法见下
 
      # 正确的 labeldrop:直接用 regex 匹配标签名
      - regex: 'user_id|session_id|request_id'
        action: labeldrop
 
      # 只保留白名单内的指标(最激进也最有效)
      - source_labels: [__name__]
        regex: 'http_requests_total|http_request_duration_seconds_bucket|up|process_.*|go_goroutines'
        action: keep
 
      # 截断超长的标签值(避免索引内存爆炸)
      - source_labels: [error_message]
        regex: '(.{0,64}).*'
        target_label: 'error_message'
        replacement: '${1}'
⚠️labeldrop / labelkeep 不看 source_labels

labeldrop 和 labelkeep 这两个 action 有一个和其他 action 完全不同的语义:它们的 regex 匹配的是标签名本身,而且忽略 source_labels 字段。

# ✅ 正确:丢掉所有名字匹配 user_id 或 session_id 的标签
- regex: 'user_id|session_id'
  action: labeldrop
 
# ❌ 错误:source_labels 会被忽略,这条实际上会丢掉所有名字匹配
#    'http_requests_total' 的标签(当然一个也匹配不上),等于什么都没做
- source_labels: [__name__]
  regex: 'http_requests_total'
  action: labeldrop

想「只对某个指标丢标签」是做不到的——labeldrop 作用于该 job 的所有指标。如果只想处理一个指标,得用 replace 把标签置空(Prometheus 会自动移除空值标签):

- source_labels: [__name__]
  regex: 'http_requests_total'
  target_label: 'user_id'
  replacement: ''

注意这条只在 __name__ 匹配时才执行替换,不匹配时规则整体跳过,其他指标的 user_id 得以保留。

第二道防线:抓取层的硬限制。

global:
  scrape_interval: 30s
  scrape_timeout: 10s
 
  # 全局兜底:单次抓取超过这么多样本就整个丢弃(0 表示不限制)
  sample_limit: 20000
  # 单个指标的标签数上限
  label_limit: 30
  # 标签名长度上限
  label_name_length_limit: 100
  # 标签值长度上限
  label_value_length_limit: 512
  # 单个 job 的目标数上限
  target_limit: 500
 
scrape_configs:
  # 对不受信任的目标收紧限制
  - job_name: 'third-party-app'
    sample_limit: 2000
    label_limit: 15
    static_configs:
      - targets: ['vendor-app:9090']

sample_limit 的行为需要特别理解:超限时整次抓取全部丢弃,up 会变成 0,并且 prometheus_target_scrapes_exceeded_sample_limit_total 递增。这是故意设计成很痛的——它逼着业务方立刻处理,而不是悄悄地污染 TSDB。

💡sample_limit 的落地策略

直接给所有 job 加 sample_limit 会误伤(有些 job 本来就合理地暴露很多样本)。推荐的落地顺序:

  1. 先观察,不限制。 用 topk(20, scrape_samples_scraped) 看清楚现状。
  2. 按 job 分别设置,取「当前值的 2 到 3 倍」作为初始阈值,给正常增长留空间。
  3. 加一条预警告警,在接近限制时就通知,而不是等到超限被丢:
- alert: ScrapeSampleCountHigh
  expr: 'scrape_samples_scraped > 15000'
  for: 30m
  labels:
    severity: 'warning'
  annotations:
    summary: '{{ $labels.job }} / {{ $labels.instance }} 单次抓取 {{ $value }} 个样本,接近上限'
 
- alert: ScrapeSampleLimitExceeded
  expr: 'increase(prometheus_target_scrapes_exceeded_sample_limit_total[10m]) > 0'
  for: 0m
  labels:
    severity: 'critical'
  annotations:
    summary: '有目标因超过 sample_limit 被整体丢弃,该目标的监控已完全失效'
  1. 新接入的 job 一律先加严格限制,这是最有效的一条——防患于未然比事后治理便宜一百倍。

第三道防线:长期监控基数趋势。

groups:
  - name: prometheus-cardinality
    interval: 1m
    rules:
      - record: prometheus:head_series:current
        expr: prometheus_tsdb_head_series
 
      - alert: HeadSeriesGrowingFast
        expr: |
          prometheus_tsdb_head_series
            /
          (prometheus_tsdb_head_series offset 1d) > 1.5
        for: 1h
        labels:
          severity: 'warning'
        annotations:
          summary: 'Prometheus 序列数 24 小时内增长超过 50%(当前 {{ $value }} 倍)'
 
      - alert: HeadSeriesTooHigh
        expr: 'prometheus_tsdb_head_series > 4000000'
        for: 30m
        labels:
          severity: 'warning'
 
      - alert: HighSeriesChurn
        expr: |
          rate(prometheus_tsdb_head_series_created_total[1h]) * 3600
            /
          prometheus_tsdb_head_series > 1
        for: 2h
        labels:
          severity: 'warning'
        annotations:
          summary: '序列流失率过高,每小时新建的序列数超过了总序列数'
🎯练习 1:诊断一次基数爆炸

周四下午 3 点,Prometheus 开始频繁 OOM 重启。运维查到:

  • prometheus_tsdb_head_series 从 80 万涨到 620 万,涨幅发生在下午 2 点到 2 点 40 分之间
  • 内存从 8G 涨到 48G(机器只有 48G)
  • 同一时间窗口内有三个团队分别做了发布

请给出:

  1. 一套按顺序执行的诊断步骤(说明每一步用什么工具/查询、能得出什么结论)。注意 Prometheus 正在反复 OOM,你的查询本身也可能把它打挂。
  2. 假设定位到是 payment 服务的 payment_transaction_duration_seconds 这个 Histogram 加了 merchant_id 标签(约 4 万个商户,12 个桶),请计算它贡献了多少序列,并验证是否能解释 540 万的增量。
  3. 给出紧急止血方案和长期治理方案。
  4. 设计一套能在下次「涨到 620 万之前」就发现问题的告警。

二、保留期与存储容量估算

计算公式

Prometheus 的磁盘用量有一个相当准的估算公式:

所需磁盘 = 保留时间(秒) × 每秒摄入样本数 × 每样本字节数

三个因子分别怎么来:

每秒摄入样本数:

samples/s = 活跃序列数 / 抓取间隔(秒)

或者直接查:

rate(prometheus_tsdb_head_samples_appended_total[5m])

每样本字节数: Prometheus 用 Gorilla 风格的双 delta 编码,压缩率极高。官方给出的经验值是 1 到 2 字节/样本。想知道你自己环境的准确值:

rate(prometheus_tsdb_compaction_chunk_size_bytes_sum[1h])
  /
rate(prometheus_tsdb_compaction_chunk_samples_sum[1h])

变化平缓的 Gauge(比如 node_memory_MemTotal_bytes)能压到 0.5 字节以下;抖动剧烈的值可能到 2 到 3 字节。规划时用 2 字节比较稳妥。

一个完整的算例

假设:

  • 活跃序列:100 万
  • 抓取间隔:30 秒
  • 保留期:30 天
  • 每样本:2 字节
samples/s = 1,000,000 / 30 ≈ 33,333
 
30 天秒数 = 30 × 86400 = 2,592,000
 
磁盘 = 2,592,000 × 33,333 × 2 字节
     ≈ 172,800,000,000 字节
     ≈ 161 GiB

再乘以 1.3 到 1.5 的安全系数(索引开销、压缩过程中的临时空间、WAL、以及块压缩时需要同时存在新旧两份数据),实际应该准备 220 到 250 GB。

⚠️压缩期间需要额外的磁盘峰值

TSDB 的块压缩会把多个小块合并成一个大块。合并过程中,新块和旧块会同时存在于磁盘上,直到新块写完、旧块才被删除。

最大的一次压缩会涉及「保留期 10% 或 31 天中较小者」大小的块。对 30 天保留期,最大块约 3 天的数据。压缩这个块时的磁盘峰值大约是稳态用量 + 一个大块的大小。

磁盘使用率超过 80% 就应该告警,不要等到 95%——压缩失败会导致数据无法落盘,最终 head block 撑爆内存。

内存估算

内存 ≈ 活跃序列数 × (3 到 8 KB) + 查询峰值开销

更靠谱的做法是直接测量你自己的环境:

# 每条序列实际占多少字节
process_resident_memory_bytes{job="prometheus"}
  /
prometheus_tsdb_head_series
 
# Go 堆的实际使用
go_memstats_heap_inuse_bytes{job="prometheus"}
 
# 内存使用率
process_resident_memory_bytes{job="prometheus"}
  /
node_memory_MemTotal_bytes

查询峰值开销是最容易被低估的部分。一次扫描 100 万序列的查询,中间结果可能占用好几 GB。所以:

  • 稳态内存不要超过机器内存的 50%,剩下的留给查询和 GC。
  • 一定要设 GOMEMLIMIT(下一节详述)。

两个保留期参数

prometheus \
  --storage.tsdb.path=/var/lib/prometheus \
  --storage.tsdb.retention.time=30d \
  --storage.tsdb.retention.size=200GB
  • --storage.tsdb.retention.time:按时间保留,默认 15d。
  • --storage.tsdb.retention.size:按大小保留。支持 B、KB、MB、GB、TB、PB 后缀(也接受 KiB 这类二进制单位)。

两者同时设置时,谁先触发就按谁清理。 这是最推荐的配置方式:

  • retention.time 保证「不会保留超过业务需要的时长」
  • retention.size 保证「不会写爆磁盘」
ℹ️retention.size 包含 WAL 和 m-mapped chunks

一个容易踩的细节:--storage.tsdb.retention.size 统计的不只是持久化的块,它还包括 WAL 和 m-mapped 的 head chunks。

这意味着如果你把 retention.size 设成和磁盘容量一样大,仍然可能写满——因为 Prometheus 是在超过限制之后才删除最老的块,而删除是以「整块」为单位的。

建议 retention.size 设成磁盘容量的 70% 到 80%。 剩下的空间留给压缩峰值和意外增长。

删除是以块为单位的,不是按样本精确裁剪。所以实际保留的数据量总是略多于配置值,最多多出一个块的时长。

降采样与分层保留

Prometheus 本身不支持降采样(downsampling),也不支持「按指标设置不同保留期」。这是它的设计取舍——保持简单。

需要这些能力时,选择是:

  • Thanos:sidecar 上传块到对象存储,Compactor 做降采样(5m 和 1h 两级),Store Gateway 提供查询。
  • Cortex / Mimir:多租户,水平扩展,通过 remote write 接收数据。
  • VictoriaMetrics:单体或集群模式,压缩率更高,支持按租户设置保留期。

用两套 Prometheus 分层是一个不需要引入新组件的土办法:

# prometheus-short.yml —— 高精度短期
global:
  scrape_interval: 15s
# --storage.tsdb.retention.time=7d
 
# prometheus-long.yml —— 低精度长期,只联邦记录规则的结果
global:
  scrape_interval: 60s
  evaluation_interval: 60s
# --storage.tsdb.retention.time=400d
scrape_configs:
  - job_name: 'federate-recording-rules'
    scrape_interval: 60s
    honor_labels: true
    metrics_path: '/federate'
    params:
      'match[]':
        - '{__name__=~"job:.*"}'
        - '{__name__=~"instance:.*"}'
    static_configs:
      - targets: ['prometheus-short:9090']

这个方案的关键是:长期实例只联邦记录规则的输出(用命名前缀 job: 和 instance: 筛选),序列数只有原始数据的百分之一量级,400 天保留期完全扛得住。

🎯练习 2:为一个中型环境做容量规划

你要为一个新集群规划 Prometheus,已知:

  • 300 台机器,每台跑 node_exporter,每个 exporter 约 1200 条序列
  • 80 个应用实例,每个应用暴露约 3000 条序列(含 Histogram)
  • 12 个中间件 exporter(Redis/MySQL/Kafka),每个约 800 条序列
  • 计划抓取间隔:node_exporter 30s,应用 15s,中间件 30s
  • 需要保留 45 天
  • 预计一年内机器数增长 50%,应用实例数翻倍

请计算:

  1. 总序列数与每秒样本摄入量。
  2. 45 天所需磁盘(给出计算过程和推荐配置值)。
  3. 推荐的内存规格。
  4. 一年后的规模,以及现在应该怎么规划才不用推倒重来。
  5. 如果磁盘预算只有 500 GB,有哪些调整手段?分别能省多少?

三、性能调优

抓取间隔:最大的那个旋钮

抓取间隔和摄入量、磁盘、CPU 全部成反比。把间隔从 15s 改成 30s,这些指标全部减半。

但它也直接决定了监控的时间分辨率:

抓取间隔最快发现异常适用场景
5s约 15 秒极少数关键链路,代价很高,慎用
15s约 45 秒核心业务服务的默认值
30s约 90 秒大多数场景的最佳平衡点
60s约 3 分钟基础设施、变化缓慢的指标
5m约 15 分钟批处理、外部 API 配额、业务日报类指标
⚠️抓取间隔不能超过 2 分钟

Prometheus 有一个 --query.lookback-delta 参数,默认 5 分钟。查询某个时刻的瞬时值时,Prometheus 会往前找最近 5 分钟内的样本,找不到就认为该序列在这个时刻「不存在」。

如果抓取间隔超过 5 分钟,会出现图表断断续续的诡异现象。

更实际的约束来自 rate():rate(x[5m]) 需要区间内至少有 2 个样本才能计算。抓取间隔 3 分钟时,5 分钟窗口里可能只有 1 个点,rate 直接返回空。

经验法则:rate 的区间至少是抓取间隔的 4 倍。 30s 间隔配 [2m] 及以上,60s 间隔配 [4m] 及以上。日常统一用 [5m] 是最省心的选择。

抓取间隔的硬上限建议是 2 分钟。 需要更长的(比如每小时才更新一次的业务指标),应该用 Pushgateway 或者把值做成 Gauge 由应用维护,而不是拉长抓取间隔。

按 job 差异化配置,而不是全局一个值:

global:
  scrape_interval: 30s
  scrape_timeout: 10s
  evaluation_interval: 30s
 
scrape_configs:
  # 核心交易链路:高频
  - job_name: 'payment-api'
    scrape_interval: 15s
    scrape_timeout: 10s
 
  # 普通应用:默认
  - job_name: 'internal-services'
 
  # 基础设施:低频
  - job_name: 'node'
    scrape_interval: 60s
 
  # 变化极慢的:更低频
  - job_name: 'ssl-cert-checker'
    scrape_interval: 5m
    scrape_timeout: 30s

关键启动参数

prometheus \
  --config.file=/etc/prometheus/prometheus.yml \
  --storage.tsdb.path=/var/lib/prometheus \
  --storage.tsdb.retention.time=30d \
  --storage.tsdb.retention.size=200GB \
  --query.max-samples=50000000 \
  --query.max-concurrency=20 \
  --query.timeout=2m \
  --query.lookback-delta=5m \
  --web.enable-lifecycle \
  --web.enable-admin-api \
  --log.level=info
参数默认值说明
--query.max-samples50000000单次查询能加载到内存的最大样本数。这是防止一条查询打挂整个实例的最后防线
--query.max-concurrency20并发查询数上限。超过后排队。Grafana 看板多时可能需要调大,但调大会增加内存峰值
--query.timeout2m查询超时
--query.lookback-delta5m瞬时查询往前找样本的窗口
--web.enable-lifecycle关闭开启后支持 POST /-/reload 热加载配置
--web.enable-admin-api关闭开启后支持删除序列、快照等管理 API
--storage.tsdb.head-chunks-write-queue-size0head chunk 异步写队列长度。高摄入场景设为 10000 左右能降低写延迟毛刺

环境变量层面,GOMEMLIMIT 是最重要的一个:

# systemd unit 里
Environment="GOMEMLIMIT=26GiB"
Environment="GOGC=75"
  • GOMEMLIMIT(Go 1.19+)给 Go 运行时设一个软内存上限。接近时 GC 会变得非常激进,宁可多花 CPU 也要把内存压下去。设成机器物理内存的 80% 左右。 效果是把「被 OOM Killer 杀掉」变成「变慢但活着」——对监控系统来说这个取舍毫无疑问是对的。
  • GOGC 默认 100(堆增长一倍才 GC)。降到 75 或 50 能让内存曲线更平缓,代价是 CPU 上升 10% 到 20%。设了 GOMEMLIMIT 之后通常不需要再调 GOGC。

找出慢查询

# prometheus.yml
global:
  query_log_file: /var/log/prometheus/query.log

开启后,每一条查询都会以 JSON 格式记录,包含完整的查询语句、各阶段耗时、以及调用来源:

# 找出最慢的 20 条查询
cat /var/log/prometheus/query.log \
  | jq -r '[.stats.timings.evalTotalTime, .params.query] | @tsv' \
  | sort -rn | head -20

这个日志文件会长得很快,建议只在排查期间开启,或者配好 logrotate。

配套的自监控指标:

# 规则组求值耗时 vs 求值间隔,接近 1 就危险
prometheus_rule_group_last_duration_seconds
  /
prometheus_rule_group_interval_seconds
 
# 有多少次求值因为上一轮没跑完被跳过(不为 0 就是严重问题)
increase(prometheus_rule_group_iterations_missed_total[1h])
 
# 规则求值失败次数
increase(prometheus_rule_evaluation_failures_total[1h])
 
# 抓取耗时接近超时的目标
topk(10, scrape_duration_seconds / on(job) group_left() (
  max by (job) (scrape_duration_seconds)
))
 
# 抓取超时的目标
scrape_duration_seconds > 8
 
# TSDB 压缩耗时
histogram_quantile(0.99, rate(prometheus_tsdb_compaction_duration_seconds_bucket[1h]))
 
# WAL 截断耗时(重启慢通常和这个有关)
prometheus_tsdb_wal_truncate_duration_seconds_sum
💡Prometheus 必须监控自己

一套最小可用的 Prometheus 自监控告警:

groups:
  - name: prometheus-self
    rules:
      - alert: PrometheusRuleEvaluationsMissed
        expr: 'increase(prometheus_rule_group_iterations_missed_total[10m]) > 0'
        for: 10m
        labels:
          severity: 'critical'
        annotations:
          summary: '规则组 {{ $labels.rule_group }} 求值被跳过,告警可能已失效'
 
      - alert: PrometheusRuleEvaluationFailures
        expr: 'increase(prometheus_rule_evaluation_failures_total[10m]) > 0'
        for: 5m
        labels:
          severity: 'warning'
 
      - alert: PrometheusRuleGroupSlow
        expr: |
          prometheus_rule_group_last_duration_seconds
            /
          prometheus_rule_group_interval_seconds > 0.8
        for: 15m
        labels:
          severity: 'warning'
        annotations:
          summary: '规则组 {{ $labels.rule_group }} 求值耗时占间隔的 {{ $value }},即将开始跳过'
 
      - alert: PrometheusTargetScrapeTimeout
        expr: 'scrape_duration_seconds > 0.9 * 10'
        for: 15m
        labels:
          severity: 'warning'
 
      - alert: PrometheusTSDBCompactionFailing
        expr: 'increase(prometheus_tsdb_compactions_failed_total[1h]) > 0'
        labels:
          severity: 'critical'
 
      - alert: PrometheusTSDBWALCorruption
        expr: 'increase(prometheus_tsdb_wal_corruptions_total[1h]) > 0'
        labels:
          severity: 'critical'
 
      - alert: PrometheusNotIngestingSamples
        expr: 'rate(prometheus_tsdb_head_samples_appended_total[10m]) <= 0'
        for: 10m
        labels:
          severity: 'critical'
        annotations:
          summary: 'Prometheus 已 10 分钟没有摄入任何样本'
 
      - alert: PrometheusConfigReloadFailed
        expr: 'prometheus_config_last_reload_successful == 0'
        for: 5m
        labels:
          severity: 'critical'
          summary: '配置重载失败,当前运行的是旧配置'

PrometheusRuleEvaluationsMissed 是这里面最重要的一条。 规则求值被跳过意味着告警规则没有执行——你的整套告警在那段时间里是瞎的,而且不会有任何提示。这是监控系统最危险的失效模式。

当然,这些告警由 Prometheus 自己评估,如果它彻底挂了就没人报了。完整方案需要一个「监控监控系统的监控系统」:Alertmanager 的 Watchdog 心跳(Prometheus 持续发一条永远 firing 的告警,外部系统检测到心跳中断就报警),或者两个 Prometheus 互相抓取对方的 up。

四、记录规则减负

减负的原理

记录规则的价值在于把「查询时计算」变成「写入时计算」:

  • 一个复杂查询如果被 10 个看板面板引用,每个面板 30 秒刷新一次,那就是每分钟执行 20 次。
  • 做成记录规则后,它每 30 秒执行 1 次,看板直接读结果。
  • 计算量降低了 20 倍,而且是把不可预测的突发负载变成了平稳的固定负载。

第二个好处同样重要:记录规则的结果序列数极少。一个扫描 50 万序列的查询,结果可能只有 10 条序列。看板读这 10 条序列几乎零成本。

命名规范

官方推荐的格式:

level:metric:operations
  • level:聚合层级,也就是结果保留了哪些标签(job、instance、cluster、namespace)
  • metric:原始指标名
  • operations:做了什么操作,从右往左读(rate5m、sum_rate5m、ratio_rate5m)
groups:
  - name: http-recording
    interval: 30s
    rules:
      # 按 job 聚合的 QPS
      - record: job:http_requests:rate5m
        expr: sum by (job) (rate(http_requests_total[5m]))
 
      # 按 job + status 聚合
      - record: job_status:http_requests:rate5m
        expr: sum by (job, status) (rate(http_requests_total[5m]))
 
      # 每个实例的 QPS
      - record: instance:http_requests:rate5m
        expr: sum by (instance) (rate(http_requests_total[5m]))
 
      # 错误率
      - record: job:http_errors:ratio_rate5m
        expr: |
          sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
            /
          sum by (job) (rate(http_requests_total[5m]))
 
      # P99 延迟
      - record: job:http_duration_seconds:p99_rate5m
        expr: |
          histogram_quantile(0.99,
            sum by (job, le) (rate(http_request_duration_seconds_bucket[5m]))
          )

这个命名规范的好处是从名字就能知道能不能再聚合。看到 job:http_requests:rate5m,你知道它已经按 job 聚合过了,不能再 sum by (instance);看到 instance:...,你知道它保留了实例维度。

⚠️记录规则的三条铁律

1. 不要对已经聚合的结果再算 rate。

# ❌ 错误
- record: job:http_requests:rate5m
  expr: rate(sum by (job) (http_requests_total)[5m:])

sum 会掩盖 Counter 重置——某个实例重启时它的 Counter 归零,但求和的结果只是「下降了一点」,rate 会把这个下降当成重置来处理,产生完全错误的尖峰。

永远是 sum(rate(...)),不是 rate(sum(...))。

2. rate 的区间不要小于规则的 interval。

groups:
  - name: bad
    interval: 5m
    rules:
      - record: job:x:rate1m
        expr: sum by (job) (rate(x_total[1m]))   # ❌

每 5 分钟求值一次,每次只看最近 1 分钟——中间 4 分钟的数据被完全跳过,算出来的速率会系统性失真(如果流量有波动,可能偏高也可能偏低)。

规则:rate 区间 ≥ 规则 interval,且 ≥ 4 倍抓取间隔。

3. 同一 group 内顺序执行,跨 group 并行。

同一个 group 里的规则严格按书写顺序串行执行,所以后面的规则可以引用前面规则的结果,拿到的是本轮的新值。

不同 group 之间并行执行,跨 group 引用会拿到上一轮的旧值(甚至可能拿不到)。

所以:有依赖关系的规则必须放在同一个 group,并按依赖顺序书写。

groups:
  - name: slo-base       # 基础层
    interval: 30s
    rules:
      - record: job:requests:rate5m
        expr: sum by (job) (rate(http_requests_total[5m]))
      - record: job:errors:rate5m
        expr: sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
      - record: job:errors:ratio_rate5m           # 依赖上面两条,必须在同一 group 且在其后
        expr: job:errors:rate5m / job:requests:rate5m

什么该做、什么不该做

该做:

  • 被多个看板面板引用的表达式
  • 告警规则里的复杂表达式(尤其是多窗口 SLO 那一套)
  • 长窗口聚合([1d]、[7d]、[30d])
  • 跨指标的除法(错误率、命中率、饱和度)
  • 联邦要暴露给上层的数据
  • 需要长期保留的核心业务指标

不该做:

  • 只用一次的查询——记录规则本身也有成本(占 CPU、占序列、占磁盘),一次性的探索用即席查询就好。
  • 产生高基数结果的规则——一条 sum by (instance, path, method, status) 的规则可能产生几万条新序列。记录规则是用来降维的,不是用来复制数据的。
  • 简单到不值得的表达式——rate(x[5m]) 这种直接查就行,做成规则反而多了一层间接。
  • topk 类查询——结果集每轮都在变,序列会疯狂流失。
💡怎么判断某条记录规则值不值
# 这条规则每轮产生多少条序列
count(job:http_requests:rate5m)
 
# 这条规则求值花多久(需要按 group 看)
prometheus_rule_group_last_duration_seconds{rule_group="http-recording"}
 
# 整个规则体系产生了多少条序列
count({__name__=~"^[a-z_]+:[a-z_]+:.*"})

判断标准: 如果一条记录规则产生的序列数超过原始序列数的 10%,说明它没有起到降维作用,应该重新设计聚合维度。

另外记得:记录规则产生的序列同样计入基数,同样占内存和磁盘。见过团队为了「方便」,把所有指标都按各种维度组合预聚合一遍,结果记录规则的输出比原始数据还多——这属于用记录规则制造问题而不是解决问题。

五、联邦与长期存储的边界

联邦能做什么、不能做什么

联邦(federation)的机制是:上层 Prometheus 抓取下层 Prometheus 的 /federate 端点,用 match[] 参数指定要拉哪些序列。

scrape_configs:
  - job_name: 'federate'
    scrape_interval: 60s
    scrape_timeout: 30s
    honor_labels: true
    metrics_path: '/federate'
    params:
      'match[]':
        - '{__name__=~"job:.*"}'
        - '{__name__=~"instance:.*"}'
        - 'up'
    static_configs:
      - targets:
          - 'prometheus-bj:9090'
          - 'prometheus-sh:9090'
          - 'prometheus-gz:9090'

honor_labels: true 是必需的——它让下层 Prometheus 上报的 job 和 instance 标签保持原样,而不是被上层的 job 配置覆盖。

⚠️联邦不是用来「复制全部数据」的

最常见的误用是这样:

    params:
      'match[]':
        - '{__name__=~".+"}'      # ❌ 拉取全部序列

这行不通,原因有三:

1. 一次抓取要在 scrape_timeout 内完成。 下层有 100 万条序列,/federate 要在一次 HTTP 响应里把 100 万个样本序列化并传输完。这通常需要几十秒甚至几分钟,远超 scrape_timeout,抓取永远失败。

2. 分辨率丢失。 上层以 60s 间隔抓取,下层的 15s 精度数据被压缩成 60s——中间的点直接丢了。你在上层看到的图和下层看到的图不一样,排障时会非常困惑。

3. 上层成为新的瓶颈。 三个下层各 100 万序列,上层就是 300 万序列,你只是把问题搬了个家,还多了一层故障点。

联邦的正确用法是「只拉聚合结果」:下层用记录规则把数据聚合成几百到几千条序列,上层只拉这些。序列数从 100 万降到 1000,抓取在 1 秒内完成,上层可以轻松承载几十个下层。

什么时候必须换成远程写入

联邦解决的是「跨集群的全局视图」,它解决不了:

  • 长期存储(联邦上来的数据仍然存在上层 Prometheus 的本地盘,仍然受保留期限制)
  • 高可用(下层挂了,那段数据就永久丢了,没有补偿机制)
  • 水平扩展(上层仍然是单点)
  • 降采样(Prometheus 不支持)
  • 原始精度的历史查询(联邦本身就降低了分辨率)

需要这些能力时,就该上远程写入 + 长期存储方案了:

remote_write:
  - url: 'http://mimir:8080/api/v1/push'
    queue_config:
      capacity: 10000          # 每个分片的内存队列容量
      max_shards: 50           # 最大并行分片数
      min_shards: 1
      max_samples_per_send: 2000
      batch_send_deadline: 5s
      min_backoff: 30ms
      max_backoff: 5s
    write_relabel_configs:
      # 只把需要长期保存的送出去,能省一大笔存储和带宽费用
      - source_labels: [__name__]
        regex: 'job:.*|instance:.*|up|slo:.*'
        action: keep

远程写入的健康度必须监控:

# 积压:待发送的样本数
prometheus_remote_storage_samples_pending
 
# 发送失败率
rate(prometheus_remote_storage_samples_failed_total[5m])
 
# 【最重要】写入延迟:远端已确认的时间戳落后本地多少秒
(
  prometheus_remote_storage_highest_timestamp_in_seconds
  -
  ignoring(remote_name, url) group_right
  prometheus_remote_storage_queue_highest_sent_timestamp_seconds
)
 
# 当前分片数(持续贴着 max_shards 说明需要调大)
prometheus_remote_storage_shards

方案选型速查

方案适合规模核心优势主要代价
单 Prometheus100 万序列以内零额外组件,运维极简无 HA、无长期存储
Prometheus 分片 + 联邦几百万序列不引入新组件,故障域隔离跨分片查询能力弱,只能看聚合值
Thanos千万级对象存储成本低、降采样、去重、全局查询组件多(sidecar/store/query/compact/ruler),查询链路长
Cortex / Mimir亿级多租户、水平扩展、写入侧高可用架构复杂,运维门槛最高
VictoriaMetrics千万到亿级压缩率高、资源占用低、单体模式部署简单与上游 Prometheus 生态有细微行为差异
ℹ️不要过早引入长期存储方案

Thanos、Mimir 这些方案的运维复杂度是 Prometheus 的好几倍。它们引入了对象存储依赖、多个需要独立扩缩容的组件、以及一套全新的故障模式(compactor 卡住、store gateway 内存爆炸、查询扇出超时)。

在单个 Prometheus 还能扛住(低于 500 万序列、保留期需求低于 90 天)的时候,绝对不要引入它们。

按优先级排的演进路径是:

  1. 先治理基数——大多数「Prometheus 扛不住了」的问题,本质是基数问题,不是架构问题。删掉几个高基数指标能省下的资源,往往比引入 Thanos 还多。
  2. 再加记录规则——把查询压力降下来。
  3. 再按功能分片——prometheus-infra / prometheus-app,故障域隔离,各自独立扩容。
  4. 最后才考虑长期存储方案——而且要先想清楚:真的需要 400 天原始精度数据吗?还是「400 天的聚合值 + 30 天的原始值」就够了?后者用两层 Prometheus 就能做到。

六、告警疲劳与分诊

量化你的告警质量

告警疲劳不是一个感觉问题,它是可以测量的。ALERTS 这个内置指标记录了所有告警的状态,用它做一次「告警体检」:

# 过去 7 天里,哪些告警触发得最频繁(找出最吵的)
topk(20, count_over_time(ALERTS{alertstate="firing"}[7d]))
 
# 过去 7 天每天平均触发多少条告警
count_over_time(ALERTS{alertstate="firing"}[7d]) / 7
 
# 哪些告警从来没触发过(可能是阈值设得太宽,形同虚设)
count_over_time(ALERTS[30d]) == 0
 
# 「闪烁」的告警:触发次数多但每次持续时间短 —— 典型的噪音源
count_over_time(ALERTS{alertstate="firing"}[7d]) > 50
 
# 长期挂着不恢复的告警 —— 说明它不是「事件」而是「状态」,
# 要么阈值不对,要么这事根本没人管
ALERTS{alertstate="firing"} and (ALERTS{alertstate="firing"} offset 7d)
 
# 按严重级别看告警量分布
sum by (severity) (count_over_time(ALERTS{alertstate="firing"}[7d]))
⚠️三个危险信号

信号一:某条告警占了总告警量的 30% 以上。 这条告警一定有问题——要么阈值不对,要么它监控的是一个没人打算修的问题。值班人员早就学会了忽略它,而且会顺带忽略它旁边的其他告警。

信号二:有告警连续 firing 超过一周。 告警的定义是「需要人介入的异常事件」。挂了一周还在的,说明它不是事件,是现状。要么修掉根因,要么调整阈值,要么降级成看板指标——留着不管是最坏的选择,因为它会永久性地污染告警列表。

信号三:每天 critical 告警超过 5 条。 人的注意力有限。超过这个量,值班人员会进入「批量已读」模式,真正的故障就会被淹没。Google SRE 的经验数据是:一次值班班次(12 小时)内的 page 数量应该控制在 2 条以内。

分诊体系

一套可持续的告警体系应该是三层的,而不是「所有告警一视同仁」:

层级判断标准通知方式响应时间数量目标
P1 / Page用户正在受影响,且需要立刻人工介入电话、短信、值班 App5 分钟内每周不超过 2 条
P2 / Ticket会演变成 P1,或已影响部分用户,但可以等工单、企业微信/钉钉群工作时间内每天不超过 10 条
P3 / Info需要知道但不需要行动看板、周报无不进告警通道

判断一条告警该放哪一层,只需要问一个问题:

「如果这条告警在凌晨 3 点响,我会起床处理吗?」

  • 会 → P1
  • 不会,但明天上班第一件事要处理 → P2
  • 明天也不一定看 → P3,不该发通知
💡用 SLO 告警替代阈值告警,是治疗告警疲劳最有效的一招

传统方式下,一个服务可能有几十条告警:CPU、内存、磁盘、连接数、队列长度、GC、线程数、慢查询、5xx 率、延迟……每一条都可能在半夜响,但其中大部分用户根本没感觉。

改成 SLO 告警之后,面向用户的告警只有 4 条(可用性和延迟各两档燃烧率)。它们只在「用户真的在受影响,且预算真的在快速消耗」时才响。

那些 CPU、内存、GC 的指标怎么办?降级成 P2 或 P3。它们的价值是排障时的诊断信息,而不是叫醒人的理由。CPU 100% 但用户毫无感知,那说明系统设计得很好(资源利用充分),不是故障。

这个转变的心理阻力通常很大——「万一 CPU 满了不报警,出事了怎么办?」答案是:出事了 SLO 告警会响。SLO 告警是「症状告警」,它天然覆盖了所有会影响用户的原因,包括那些你没想到的原因。而阈值告警是「原因告警」,你永远只能覆盖到你想得到的那些原因。

保留少量的「容量类」原因告警是合理的(比如磁盘 7 天内会写满、证书 15 天内过期),因为这些问题发生时已经来不及了,必须提前预警。但它们应该是 P2 工单,不是 P1 电话。

工程手段

Alertmanager 侧的四件套:

route:
  # 1. 分组:把同一批告警收成一条通知
  group_by: ['alertname', 'cluster', 'service']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
 
  receiver: 'default-ticket'
 
  routes:
    # 2. 分级路由
    - matchers:
        - severity = "critical"
        - page = "true"
      receiver: 'oncall-phone'
      group_wait: 10s
      repeat_interval: 1h
      continue: false
 
    - matchers:
        - severity = "warning"
      receiver: 'team-ticket'
      repeat_interval: 12h
 
    # 3. 按团队路由
    - matchers:
        - team = "payment"
      receiver: 'payment-group'
 
# 4. 抑制:根因压掉衍生告警
inhibit_rules:
  # 实例宕机时,压掉该实例上的所有其他告警
  - source_matchers:
      - alertname = "InstanceDown"
    target_matchers:
      - severity =~ "warning|critical"
    equal: ['instance']
 
  # 整个集群不可用时,压掉单个服务的告警
  - source_matchers:
      - alertname = "ClusterDown"
    target_matchers:
      - severity =~ "warning|critical"
    equal: ['cluster']
 
  # critical 压掉同一告警的 warning 版本
  - source_matchers:
      - severity = "critical"
    target_matchers:
      - severity = "warning"
    equal: ['alertname', 'instance']

规则侧的三件套:

      # 1. 合适的 for:过滤瞬时抖动
      - alert: HighErrorRate
        expr: 'job:http_errors:ratio_rate5m > 0.05'
        for: 10m          # 不是 0m,也不是 1h
 
      # 2. keep_firing_for:防止告警在恢复边缘反复抖动
      - alert: HighErrorRate
        expr: 'job:http_errors:ratio_rate5m > 0.05'
        for: 10m
        keep_firing_for: 15m
 
      # 3. 有意义的 annotation:让人不用查看板就知道该干什么
        annotations:
          summary: '{{ $labels.job }} 错误率 {{ humanizePercentage $value }}'
          description: |
            服务 {{ $labels.job }} 在过去 10 分钟内 5xx 错误率持续超过 5%。
            影响范围:{{ $labels.cluster }} 集群
          runbook_url: 'https://wiki.internal/runbook/{{ $labels.alertname }}'
          dashboard_url: 'https://grafana.internal/d/api-overview?var-job={{ $labels.job }}'
🎯练习 3:一次 Prometheus 性能急救

生产 Prometheus 出现以下症状:

  • Grafana 上一半的面板显示 Query timeout
  • prometheus_rule_group_iterations_missed_total 在过去 6 小时增长了 340
  • CPU 稳定在 95%,内存 22G / 32G(没有 OOM)
  • prometheus_tsdb_head_series 是 180 万,过去一周基本没变
  • 抓取正常,up 全是 1,scrape_duration_seconds 都在 2 秒以内

请:

  1. 判断瓶颈在哪一环(摄入?存储?查询?规则求值?),说明依据。
  2. 给出定位问题的具体查询和命令。
  3. 给出至少四条优化措施,按「见效速度」排序。
  4. 写一套能防止同类问题复发的告警。
🎯练习 4:设计一次告警体系治理

你接手一个已经运行两年的监控系统,现状:

  • 共 287 条告警规则
  • 上周触发了 4300 次告警(平均每天 614 次)
  • 其中一条 DiskUsageHigh 占了 1800 次
  • 有 12 条告警连续 firing 超过 30 天
  • 值班群里,工程师对告警的平均响应时间是 2 小时(对 critical 也是)
  • 上个月有一次真实故障,告警在 3 分钟内触发,但没人看到,最后是客户投诉才发现的

请设计一次系统性治理,包括:

  1. 用哪些查询做「告警体检」,各能发现什么问题。
  2. 一套分类整改的决策流程(每条告警该怎么处置)。
  3. 针对 DiskUsageHigh 这类「最吵告警」的具体改造。
  4. 针对「12 条长期 firing」的处置原则。
  5. 整改后如何衡量效果,以及如何防止半年后又退化。

小结

  • 基数是 Prometheus 的第一杀手。 序列数 = 各标签取值数的乘积,Histogram 还要再乘以「桶数 + 2」。诊断从 /status/tsdb 页面开始(零成本),再用 topk(20, count by (__name__)(...)) 深挖(很贵,只在排障时用)。
  • 高基数标签黑名单:用户/请求/会话 ID、邮箱手机号、完整 URL、错误消息原文、客户端 IP、容器 ID。判断标准是「取值集合会随业务量无限增长吗」。降维(user_tier 代替 user_id、路由模板代替完整 URL)是标准解法。
  • 三道防线:metric_relabel_configs 在入库前丢、sample_limit 等硬限制兜底、基数趋势告警提前预警。注意 labeldrop 的 regex 匹配的是标签名且忽略 source_labels。
  • 容量公式:磁盘 = 保留秒数 × 摄入样本/秒 × 每样本字节数(1 到 2 字节),再乘 1.4 的安全系数。内存 = 序列数 × 3 到 8 KB,再留 3 到 4 倍给查询峰值。GOMEMLIMIT 是投入产出比最高的一个配置,它把硬 OOM 变成软降级。
  • 抓取间隔是最大的性能旋钮,改一倍所有资源消耗都改一倍。30s 是大多数场景的最佳平衡点。间隔不要超过 2 分钟(lookback-delta 是 5 分钟),rate 的区间至少是抓取间隔的 4 倍。
  • 记录规则把「查询时计算」变成「写入时计算」。命名用 level:metric:operations。三条铁律:永远 sum(rate(...)) 不是 rate(sum(...));rate 区间不小于规则 interval;有依赖的规则放同一 group 并按序书写。大 group 要拆,同组串行、跨组并行。
  • 联邦只能拉聚合结果,用 match[] 配合记录规则的命名前缀筛选。它解决不了长期存储、HA 和水平扩展。演进路径:治基数 → 加记录规则 → 按功能分片 → 最后才上 Thanos/Mimir。
  • prometheus_rule_group_iterations_missed_total 是最该盯的自监控指标——规则求值被跳过意味着告警已经失效,而且是无声的。查询和规则求值共享并发配额,必要时降低 --query.max-concurrency 来保护规则。
  • 告警疲劳可以量化:日均告警量、critical 日均量、单条告警占比、长期 firing 条数。判断一条告警级别只需要问「凌晨 3 点响了我会起床吗」。用 SLO 告警替代阈值告警是最有效的减噪手段。
  • 告警是「事件」不是「状态」。 长期 firing 的告警一定是设计错误。「磁盘 80% 满」是状态,「24 小时内会写满」才是事件。
  • 治理成果必须靠机制维持:用告警监控告警(AlertIsTooNoisy、AlertFiringTooLong)、CI 检查 runbook、每月 15 分钟评审。靠自觉的治理,三个月必然退化。

这一章的所有内容可以浓缩成一句话:Prometheus 的运维,本质上是在管理三种预算——基数预算、资源预算、和人的注意力预算。 三者都是有限的,都会被「多加一点点」的心态慢慢耗尽,也都需要明确的机制来守住。