Prometheus 悄悄吃掉了你 40G 内存:一次指标基数爆炸的现场拆解

2026-11-13 12 0

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 的抛物线。

相关文章

分布式系统里没有「现在」:时钟、超时与租约,一次讲透
我们给支付接口加了幂等键,结果它照样扣了三次钱
你的 Go 服务正在偷偷漏 goroutine:context 取消、channel 关闭与那些没人告诉你的并发真相
你的定时任务跑了三遍,还把数据库打挂了:分布式调度里六个没人愿意告诉你的真相
为什么你的 Rust 团队,三个月后又写回了 Go?—— 所有权、生命周期与「编译期错误」的谎言
你的 SQL 为什么这么慢:一次把「索引自杀现场」挖了个底朝天

发布评论