Prometheus 悄悄吃掉了你 40G 内存:一次指标基数爆炸的现场拆解
先讲个真实场景:一个跑了两年的微服务集群,某天早上监控面板全线飘红,不是业务挂了,是 Prometheus 自己 OOM 了。查询接口超时,告警规则不执行,值班同学盯着一个空白的 Grafana 大屏,脑子里只剩一句话——「昨天还好好的」。
这类故障有个特别讨厌的地方:它不是突然发生的,是「慢性中毒」。你每次扩容 Prometheus 内存,它都能撑住;你每次觉得「应该没事」,它就多长一点。直到某天你发现内存曲线是一条完美的直线往上爬,然后垂直坠落。这不是崩溃,这是谋杀。
今天我们把这件事拆开讲:指标基数(cardinality)到底是什么,它怎么在你眼皮底下偷偷涨到千万级,以及遇到之后该怎么救。
一、先搞懂:Prometheus 到底在存什么
Prometheus 的数据模型很简单:一个时间序列(time series)= 指标名(metric name)+ 一组标签(labels)+ 一组采样点。
举栗子:
http_requests_total{method="GET", path="/api/user", status="200"}
这条序列的名字是 http_requests_total,标签组合是 method+path+status 三个。它的时间序列数量,取决于所有出现过的标签取值组合数,而不是请求数。
很多人第一次接触 Prometheus 都会算错这个账。「我这个接口一天一千万次请求,那 Prometheus 不得存一千万条序列?」——不是。除非标签里有随机值,否则同一组标签永远只算一条序列。
真正要命的不是 QPS,而是「本该有限」的标签里,混进了无限的东西。
存储成本的关键公式很简单:
序列总数 ≈ 指标名数量 × 各标签取值组合数的乘积
任何一个标签从「有限集合」变成「无限集合」,整个乘积就会炸掉。听起来像废话,但工程现场里,它是以各种你意想不到的方式发生的。
二、四大经典作案现场
1. user_id / order_id 这类高基数字段被塞进了 label
新人最常见的操作:为了「排查方便」,把用户 ID 打成一个 label。
# 错误示范
http_requests_total{user_id="u_8837261", path="/api/order"} 10.0
http_requests_total{user_id="u_8837262", path="/api/order"} 10.0
# ... 100万用户 = 100万条序列,而且永远不会被回收
这是最经典的自杀方式。label 是维度,不是数据。要查某个用户的请求次数,正确的做法是去日志系统里查,或者在 label 里放一个粗粒度的分桶(比如 user_tier=free/vip)。
判断标准很简单:如果这个标签的取值空间随着业务量线性增长,它就不该是 label。
2. URL 路径里带 ID
# 错误示范
http_requests_total{path="/api/orders/8837261/detail"}
这跟上面本质是同一类错误,只是更隐蔽,因为 path 看起来「像个正经维度」。REST 风格的 URL 里只要有资源 ID,路径基数就等于资源数量。
解法:要么在 SDK 层就把路径模板化(/api/orders/:id/detail),要么用 OpenTelemetry 的 span 名约定做归一化。指标层做归一化,比在查询层写正则便宜一万倍。
3. 异常信息进 label
# 错误示范
db_query_duration_seconds{error="connection refused to 10.0.3.17:5432"}
异常信息里常常藏着 IP、端口、临时 pod 名、动态生成的请求 ID……每个都会变成一个新维度。
异常应该放在日志和 trace 里,指标只负责告诉你「错误率从 0.1% 涨到了 5%」。细节查日志,聚合看指标——这是指标体系的分工,别越界。
4. 标签里的环境变量没收敛
这个最隐蔽:
request_duration_seconds{version="v1.2.3-rc1-abc123", region="cn-east-1a"}
每次发版带一个新 build 号,每次扩容带一个新 pod 标识符。序列数随着发布次数和副本数一起往上飘。
解法:用有限白名单。比如 version 只允许 canary 和 stable,具体版本号放 tag 或 label(tag 是可搜索的标签,不参与序列生成)。
三、怎么发现:三个救命指标
别等 OOM。Prometheus 本身就带着探针:
prometheus_tsdb_head_series # 当前 head block 的活跃序列数
scrape_samples_scraped # 每次抓取采集到的样本数
scrape_series_added # 本次抓取新增了多少序列
核心看两个:
1. prometheus_tsdb_head_series 的增长速度
健康的服务,序列数曲线应该是阶梯状——发版涨一点,稳态不动。如果这条线是持续平滑上扬的,说明有人正在往 label 里灌动态值。
2. scrape_series_added 长期不为 0
稳态服务里,这个值应该是 0 或极小。它持续很大,意味着每次抓取都在创建新序列——这基本可以直接判定为 label 泄漏。
还有一条更狠的查询,直接把最贵的序列揪出来:
topk(20, count by (__name__) ({__name__=~".+"}))
或者用 TSDB status 页面直接看 top cardinality 的 metric 名字。社区里有个现成工具 prometheus-label-proxy 和 cardinality 相关的巡检脚本,做定时扫描也能提前发现。
经验法则:单个 Prometheus 实例的活跃序列数超过 100 万就该紧张,超过 500 万基本是在等 OOM。
四、已经炸了,怎么救
短期止血
用 relabel 规则做丢弃。在 scrape config 里把问题 label 直接扔掉:
scrape_configs:
- job_name: 'app'
static_configs:
- targets: ['app:8080']
metric_relabel_configs:
# 直接丢掉带 user_id 的序列
- source_labels: [user_id]
regex: '.+'
action: drop
# 把动态路径归一化
- source_labels: [path]
regex: '/api/orders/[0-9]+/.*'
target_label: path
replacement: '/api/orders/:id'
action: replace
注意顺序:drop 要放在 replace 之前还是之后,取决于你想匹配原始值还是归一化后的值。这个细节坑过我一次,改完务必 promtool check config 验一遍。
重启并保留 WAL。如果只是想清掉历史内存,重启 Prometheus 就行——head block 会重新从 WAL 恢复,但被 drop 的序列不会回来。
中期整改
1. 换存储。 Thanos / VictoriaMetrics / Mimir 都能做「全局聚合 + 降采样 + 对象存储」。VictoriaMetrics 在高基数场景下的表现尤其猛,因为它对 series 的索引做了激进优化,内存占用比 Prometheus 低一个数量级。
2. 用 recording rule 做预聚合。把那些你只在 Dashboard 上看的原始高基数指标,预先聚合成低基数的规则:
groups:
- name: api-request-rate
interval: 30s
rules:
- record: api:requests:rate5m
expr: sum by (path, status) (rate(http_requests_total[5m]))
查询 api:requests:rate5m 而不是 http_requests_total,查询层的负担会小一大截。
3. 加准入门槛。在 CI 里跑一次「指标契约检查」:禁止 user_id、request_id、trace_id、email、timestamp 这类高危字段出现在 label 中。指标命名一旦放开,回收成本极高,早拦截比晚治理便宜得多。
五、更深一层:这不是 Prometheus 的问题,是「指标设计」的问题
说句实在话,基数爆炸本质上是个设计缺陷,只是爆炸的位置在监控层。
指标(Metric)、日志(Log)、链路(Trace)三者的分工应该是:
- 指标:低基数、可聚合、回答「趋势怎么样」。
- 日志:高基数、免费形态、回答「具体发生了什么」。
- 链路:因果关系、跨服务还原,回答「到底慢在哪一环」。
很多团队的指标里塞满了本该属于日志和链路的东西——request_id、trace_id、error_message。这不是「顺手」,是职责越界。越界的代价就是 Prometheus 的内存曲线。
我见过最离谱的案例,是有人在 label 里放了完整 URL:
http_request_duration{url="https://shop.example.com/search?q=%E8%80%83%E7%BD%95%E8%80%83%E7%BD%95&page=3"}
查询参数、高亮词、SKU 编码全在里面。这条指标一天能造出几百万条序列,而且随着商品上新持续增长。删掉它的那一刻,我听见隔壁工位传来了一声欢呼。
监控系统的自我膨胀,几乎都源自「我想查得更细」这一句朴素而危险的心愿。
六、最后
Prometheus 官方文档里有一句话被引用无数次:「不要把 user_id 这类无界值放进标签。」但没人听,因为文档不会半夜告警你的集群。
真正该记住的是这条链:
label 里多一个动态维度 → 序列数相乘 → head block 内存线性上涨 → 磁盘 IO 飙升 → 查询变慢 → OOM → 监控失明 → 你在故障发生时失去了唯一的眼睛。
监控失明这件事,比监控本身失效严重得多。所以我现在的做法是:把 prometheus_tsdb_head_series 的日环比和 scrape_series_added 做成一张专门的看板,并设一条「每天新增序列数 > 1% 就告警」的规则。
听起来像小题大做?等你半夜被 OOM 叫起来的时候,你会希望自己当初小题大做了。
至于怎么在指标设计阶段就避开这些坑——那又是另一个能写三万字的话题了。但方向是明确的:让指标回答趋势问题,把细节还给日志和链路。指标越少越贵,越少越有用。
祝你的 Prometheus 内存曲线永远是一条水平线,而不是一条通往 502 的抛物线。