运维笔记

Prometheus 高基数问题排查与修复:从发现爆炸到彻底治理的实战指南

SRE & Observability 技术可视化

兄弟们,今天聊个能让每个SRE半夜惊醒的话题——Prometheus 高基数(High Cardinality)。

上周我们线上集群直接炸了,TSDB 写入延迟飙升到 5 秒,查询动不动就 timeout,Grafana 面板全红。我第一反应是“哪个孙子又上了新 metric 没 review”。结果一查,发现是业务方在 label 里塞了 user_idrequest_id——典型的“基尼系数”爆炸。

这东西不是 bug,是设计失误。但更可怕的是,它爆发前毫无征兆,等你发现的时候,Prometheus 已经在内存里撑了几百万条时间序列,OOM 只差临门一脚。

现象:高基数下的 Prometheus 到底有多惨?

先说说你可能会看到的症状,别等到被 on-call 电话吵醒才后知后觉:

  1. Prometheus 进程内存持续上涨——head block 在内存里膨胀,GC 频繁触发,CPU 也跟着飙。
  2. /api/v1/query 响应时间从毫秒级掉到秒级——甚至直接返回 context deadline exceeded
  3. WAL 写入变慢——因为每个新 label 组合都要写一条新 series,磁盘 IO 直接爆炸。
  4. Grafana 面板加载超时——不是 Grafana 的问题,是 Prometheus 查不动了。
  5. 最直观的告警prometheus_tsdb_head_series 指标数值远超你的预期。比如你预期 10 万,结果看到 200 万。

红迪上有个老哥说得挺实在:“Timeseries data with high cardinality is always a pain point.” 对,永远是痛点。

根因分析:谁引爆了基数炸弹?

高基数本质上就一个原因——label 值的组合数失控

来看个典型例子:

http_requests_total{path="/api/v1/users", user_id="12345", status="200", method="GET"}

如果 user_id 有 100 万用户,那光是这个 metric 就能产生 100 万条时间序列。再加上 pathstatusmethod 的组合,轻松破千万。Prometheus 的 TSDB 是为“大量时间序列但每个序列数据点少”的场景设计的,不是为“序列数无限膨胀”准备的。

具体来说,哪些 label 最容易引爆?

Label 类型爆炸风险典型例子建议
用户/会话 ID🔴 极高user_id, session_id, request_id绝对不要用作 label
请求路径🟡 高/api/v1/users/{id} 这种带参数的__param__ 或聚合后上报
IP 地址🔴 极高client_ip, source_ip可以用作日志,别做 metric label
容器/Pod 名🟡 中如果是 ephemeral 的 Podjobinstance 替代
环境/区域🟢 低env, region, az有限集合,可控

有个开源项目叫 promcap,MIT 协议,直接在源码层限制 cardinality。它的思路挺聪明——在 metric 注册时就 cap 住 label 组合数,超过就丢弃或聚合。红迪上有人推荐,说“capped Prometheus metric cardinality at the source”。我觉得这思路比事后清理靠谱。

第一步:确认你炸了——查总序列数

别靠猜,直接上指标。

# 查看当前总时间序列数
curl -s http://localhost:9090/api/v1/query?query=prometheus_tsdb_head_series | jq '.data.result[0].value[1]'

# 如果超过你预期的 2-3 倍,基本可以确认有问题

我一般会设一个 Grafana 面板,每分钟抓一次这个值,做成时间序列图。如果曲线是线性增长而不是水平波动,那多半是某个 label 在无限膨胀。

第二步:找出罪魁祸首——谁在制造最多序列?

Prometheus 自带一个调试端点,能告诉你每个 metric 的序列数。

# 查询各 metric 的时间序列数(Top 10)
curl -s http://localhost:9090/api/v1/status/tsdb | jq '.data.seriesCountByMetricName | to_entries | sort_by(.value) | reverse | .[0:10]'

输出大概长这样:

[
  {"key": "http_requests_total", "value": 523400},
  {"key": "api_latency_seconds", "value": 312000},
  {"key": "db_queries_total", "value": 89000}
]

看到 http_requests_total 有 52 万条序列,基本可以断定是 label 爆炸了。

第三步:定位爆炸的 label

光知道 metric 不够,还得知道是哪个 label 在作妖。

# 查看某 metric 的 label 组合分布
curl -s http://localhost:9090/api/v1/status/tsdb | jq '.data.labelValueCountByLabelName | to_entries | sort_by(.value) | reverse | .[0:10]'

如果看到 user_id 有 50 万个不同值,而 status 只有 5 个——凶手找到了。

第四步:紧急止血——用 relabel 干掉问题 label

在找到根因之前,先让 Prometheus 活过来。用 relabel_config 过滤掉爆炸 label。

# prometheus.yml
scrape_configs:
  - job_name: 'my-app'
    static_configs:
      - targets: ['localhost:8080']
    metric_relabel_configs:
      # 方案 A:直接删除 user_id label
      - source_labels: [user_id]
        action: labeldrop
        regex: .*
      
      # 方案 B:保留但聚合(更温和)
      # - source_labels: [user_id]
      #   action: replace
      #   target_label: user_id
      #   replacement: 'redacted'

注意,metric_relabel_configs 是在 scrape 之后、存储之前执行的。所以 metric 本身还在,只是 label 被干掉了。这会导致 user_id 维度的查询失效,但总比 Prometheus 挂了强。

第五步:长期治理——从源头堵住

临时止血之后,得让业务方改代码。这是最难的一步,因为涉及跨团队沟通。

方案 1:应用层限制(推荐)

在代码里直接限制 label 组合数。比如用 Go 的话,可以用 promcap 库:

import "github.com/your-org/promcap"

counter := promcap.NewCappedCounter(
    prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total HTTP requests.",
    },
    []string{"path", "status", "method"},
    1000, // 最多 1000 种 label 组合
)

超过 1000 种组合后,新的组合会被丢弃或归入 __overflow__ 标签。

方案 2:聚合后上报

把高基数的维度聚合掉再上报。比如按 path 前缀聚合:

# Python 示例
def report_request(path, status, duration):
    # 把 /api/v1/users/12345 聚合成 /api/v1/users/{id}
    aggregated_path = re.sub(r'/users/\d+', '/users/{id}', path)
    METRIC.labels(
        path=aggregated_path,
        status=status
    ).observe(duration)

方案 3:用 Recording Rules 预聚合

如果历史数据已经炸了,可以用 recording rule 在查询时聚合,减少存储压力。

# rules.yml
groups:
  - name: cardinality_control
    rules:
      - record: job:http_requests_total:sum
        expr: sum(http_requests_total) by (job, status)

但这只是治标,原始高基数数据依然在消耗内存。

第六步:预防——设 Limits

Prometheus 2.30+ 支持 scrape 级别的样本数限制:

scrape_configs:
  - job_name: 'my-app'
    sample_limit: 5000  # 每次 scrape 最多 5000 个样本

超过的部分会被丢弃,并触发 prometheus_target_scrapes_sample_out_of_order_total 指标。

但注意,sample_limit 只限制样本数,不限制序列数。更狠的是 label_limit

scrape_configs:
  - job_name: 'my-app'
    label_limit: 50  # 每个 metric 最多 50 个不同 label

这两个参数配合使用,基本能防住大部分意外爆炸。

架构层面的反思:单机 Prometheus 撑不住了怎么办?

如果你的业务场景就是高基数(比如 SaaS 平台按 tenant 隔离),单机 Prometheus 怎么调都救不了。这时候得考虑架构升级:

方案适用场景缺点成本
Prometheus + Thanos多集群、长期存储复杂度高,需要对象存储
Prometheus + Cortex多租户、高基数原生支持运维复杂,依赖 Consul/etcd
VictoriaMetrics高基数友好,内存效率高社区版有功能限制
MimirGrafana Labs 出品,S3 原生资源消耗大

红迪上有人提到“shard your data, so every shard will contain only a subset of the labels”。对,分片是解决高基数的终极方案——把不同租户的数据打到不同的 Prometheus 实例,每个实例只处理自己那部分 label 组合。

FAQ

Q: Prometheus 高基数一定会导致 OOM 吗? A: 不一定,但概率极高。Prometheus 的 TSDB 会把所有活跃序列的 metadata 放在内存里,序列数越多,内存占用越大。当内存超过物理内存限制时,OOM Killer 就会出手。通常 100 万条序列需要 2-4GB 内存,具体取决于 label 数量。

Q: 如何在不重启 Prometheus 的情况下清理高基数数据? A: 用 relabel_config 是动态生效的,不需要重启。也可以使用 promtool tsdb delete-series 清理历史数据,但建议先备份 WAL。

Q: 高基数对查询性能的影响有多大? A: 非常大。一个高基数 metric 的查询可能会遍历数百万条序列,导致查询延迟从毫秒级飙升到秒级甚至分钟级。特别是使用 rate()increase() 等函数时,计算量呈指数级增长。

Q: 可以用 Grafana 面板监控 cardinality 吗? A: 可以。用 prometheus_tsdb_head_seriesprometheus_tsdb_head_chunks 这两个指标做面板。当 head_series 出现陡峭上升时,立即告警。

Q: 为什么说 user_id 是 cardinality 炸弹? A: 因为 user_id 的值是无限的。每新增一个用户,就多一条时间序列。100 万用户就是 100 万条序列。加上其他 label 的组合,轻松破千万。Prometheus 不是为这种场景设计的。

References & Community Insights

本文的技术观点综合自多个社区讨论:

  • Reddit r/opensource: promcap 库的作者分享了如何在源码层限制 cardinality,思路值得借鉴。
  • Hacker News: 关于 Prometheus 高基数的讨论中,多位 SRE 分享了实战经验,特别是 relabel 技巧和架构升级方案。
  • X/Twitter: 多位可观测性专家对 VictoriaMetrics 和 Thanos 在高基数场景下的表现进行了对比,结论是 VictoriaMetrics 的内存效率更高。
  • Google 搜索结果: 多篇博客详细分析了 Prometheus TSDB 的内存模型和高基数的影响机制。

特别感谢 promcap 项目的作者,在红迪上分享了一个非常实用的开源工具。

Elvin Hui

关于作者:Elvin Hui

Elvin 拥有 10+ 年企业级数据中心、云原生架构和网络安全经验。持有 CCNA、AWS 解决方案架构师认证。我致力于将一线的“踩坑”经验沉淀为真实、硬核的技术指南,拒绝空洞理论。