兄弟们,今天聊个能让每个SRE半夜惊醒的话题——Prometheus 高基数(High Cardinality)。
上周我们线上集群直接炸了,TSDB 写入延迟飙升到 5 秒,查询动不动就 timeout,Grafana 面板全红。我第一反应是“哪个孙子又上了新 metric 没 review”。结果一查,发现是业务方在 label 里塞了 user_id 和 request_id——典型的“基尼系数”爆炸。
这东西不是 bug,是设计失误。但更可怕的是,它爆发前毫无征兆,等你发现的时候,Prometheus 已经在内存里撑了几百万条时间序列,OOM 只差临门一脚。
现象:高基数下的 Prometheus 到底有多惨?
先说说你可能会看到的症状,别等到被 on-call 电话吵醒才后知后觉:
- Prometheus 进程内存持续上涨——head block 在内存里膨胀,GC 频繁触发,CPU 也跟着飙。
/api/v1/query响应时间从毫秒级掉到秒级——甚至直接返回context deadline exceeded。- WAL 写入变慢——因为每个新 label 组合都要写一条新 series,磁盘 IO 直接爆炸。
- Grafana 面板加载超时——不是 Grafana 的问题,是 Prometheus 查不动了。
- 最直观的告警:
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 万条时间序列。再加上 path、status、method 的组合,轻松破千万。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 的 Pod | 用 job 和 instance 替代 |
| 环境/区域 | 🟢 低 | 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 | 高基数友好,内存效率高 | 社区版有功能限制 | 低 |
| Mimir | Grafana 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_series 和 prometheus_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项目的作者,在红迪上分享了一个非常实用的开源工具。
