你的监控把你自己监控崩了:Prometheus 标签基数爆炸,一个 user_id 标签吃掉 8G 内存

2026-11-02 12 0

先说个扎心的事实:很多团队线上服务挂了,第一反应不是查业务日志,而是查监控。然后发现监控自己也没了。CPU 曲线是平的,不是服务正常,是采集器连 Prometheus 都挤爆了。

你没看错,是 Prometheus 被自己的指标撑爆的。这事儿的元凶不叫 CPU,不叫 GC,不叫磁盘,叫 label(标签)的基数爆炸。它是我见过最优雅的一种自杀:你在代码里顺手加了个 user_id 标签,日活一涨,内存曲线以肉眼可见的速度爬升,最后 OOM。

这篇不背 API,不抄文档,就讲三件事:为什么 label 能把 Prometheus 送走,怎么在你还没被 OOM 之前找到那颗雷,以及怎么在架构层面把这类问题掐死在摇篮里。


一、先算一笔账:你以为的"监控",其实是几百万个时间序列

Prometheus 内部存的不是"一个指标的一个数值",而是 series:一组 label 组合唯一确定一条时间序列,每条序列在内存里是一条独立的时间序列对象,带 chunk、head block、元数据索引。粗略算一下,一条活跃 series 的常驻内存开销在 1~3 KB 量级(取决于 label 数量、chunk 容量和索引结构)。

于是公式就非常简单粗暴:

内存占用 ≈ 活跃 series 数 × 单条开销 + chunk 压缩后大小

活跃 series 数 ≈ 指标名数量 × 各 label 值的笛卡尔积(实际是唯一组合数)

举几个真实数字感受一下:

  • 3 个指标 × 4 个 label × 每个 50 个取值 = 3 × 50⁴ = 1875 万条 series。恭喜,你申请了一台 64G 内存的机器,然后它安静地死了。
  • 1 个指标 + 1 个 label + 1000 个取值 = 1000 条 series,完全健康,没有任何性能问题。

问题在于这两种情况在你写代码的时候长得一模一样,都是"往 label 里塞了个值"。区别只在于那个值是不是无界的。

这就是关键判断标准:

label 的取值空间是不是有界,并且你能写出它的上界?如果你写不出上界,那就别往 label 里塞。

再强调一遍,因为这条判断比下面所有技巧都重要。


二、翻车现场:五类最常见的"我以为没关系"

第一类:用业务 ID 当 label。

// 看起来人畜无害的代码
prometheus.NewCounterVec(prometheus.CounterOpts{
    Name: "order_created_total",
}, []string{"user_id", "status"})

每个新用户产生一个新 user_id 组合,series 数 = 用户数 × 状态数。这不是监控,这是把用户表复制进了监控数据库。线上跑一天,你就知道什么叫"监控时延 300ms"了。

第二类:把 trace_id / request_id 塞进 label。

更离谱的是这个:

httpRequestDuration.WithLabelValues(req.TraceID).Observe(duration)

每个请求一个 trace_id,QPS 1000 就是每秒 1000 条新 series。Prometheus 的 series churn(创建/销毁)本身就是可观的 CPU 开销,而这个开销会随着你的流量线性增长——你的监控系统在你业务最忙的时候最脆弱。

第三类:原始 URL 路径直接做 label。

http_request_duration{path="/users/12345/orders/67890"},每个订单都是一个 series。REST 风格路由遇上长 ID,就是 label 基数核弹。

第四类:错误信息当 label。

db_error_total{message="connection refused to 10.0.3.17:5432"}

IP、端口、临时表名、OOM 时的随机后缀……全都进来了。

第五类:label 名爆炸。 有人觉得 label 值有界就安全,于是写:

metric{env="prod", region="cn-north-1", az="az-3", pod="nginx-7d9f8b-x2k9", container="app", version="v1.2.3-rc1"}

每一个 label 都"有界",但 series 数是乘积。6 个 label 各 3 个取值 = 729,再乘上 3 个指标,直接 2187 倍放大。最隐蔽的是:每个值单独看都很合理。

说到这我得吐槽一句:很多团队的 label 规范是"能加就加,方便查"。于是 env、region、az、pod、container、version、instance 全塞进去,指标数量看起来很全,实际上查询根本没法用——Prometheus 的查询复杂度是所有 series 的笛卡尔积扫描,你那套"全景标签"让每次 Grafana 面板刷新都在做全库遍历。


三、动手排查:别等 OOM,先做一次"体检"

在动手治理之前,你得先知道雷埋在哪。三个实用手段,从易到难。

手段一:看 Prometheus 自己的 TSDB 状态。

curl -s http://prometheus:9090/api/v1/status/tsdb | \
  jq '.data.headStats'
# headStats.totalSeries: 3120000
# headStats.chunkCount: 987654

这个数字如果已经接近百万级,你离 OOM 就不远了。HEAD series 数是内存占用的直接决定因素,记住这个基线。

手段二:用 topk 找出最费劲的指标。

topk(20, count by (__name__) ({__name__=~".+"}))

这条查询会告诉你哪几个指标名贡献了最多的 series。注意这本身是个重查询,别在生产高峰跑。

手段三(进阶):直接找高基数 label。

topk(10, count by (__name__, label_name) ({__name__=~".+"}))

需要开启 --enable-feature=exemplar-storage 或 cardinality 相关实验特性。若不可用,退而求其次:对可疑指标挨个 group by 试。

还有一个更土但极有效的办法:直接去代码里 grep。

grep -rn "WithLabelValues\|labels\[" --include="*.go" | grep -v "_test.go"

把所有 label 注入点列出来人工过一遍,通常十分钟内就能发现最离谱的那几个。治未病永远比治已病便宜。


四、治理:四道防线,层层拦截

防线一:采集端 relabel —— 在数据进 Prometheus 之前就扔掉

这是最有效的一道,因为扔掉的东西根本不会占用内存。

scrape_configs:
  - job_name: 'app'
    metrics_path: /metrics
    static_configs:
      - targets: ['app-0:9100', 'app-1:9100']

    # 抓取后、进 TSDB 前做清洗
    metric_relabel_configs:
      # 1) 直接丢掉带动态高危 label 的整个指标
      - source_labels: [__name__, trace_id]
        regex: '.*;.*'
        action: drop

      # 2) 把长 path 归一化成路由模板
      - source_labels: [path]
        regex: '/users/[0-9]+/orders/[0-9]+'
        target_label: path
        replacement: '/users/:id/orders/:id'
        action: replace

      # 3) 丢掉不含业务价值的无用指标
      - source_labels: [__name__]
        regex: 'go_memstats_.*|process_.*'
        action: drop

注意 metric_relabel_configs 作用于抓取后的每个样本,是最后一道"入库前闸门";relabel_configs 作用于 target 发现阶段,改的是 target 标签而不是样本指标,别搞混。

要说坑:relabel_configs 里的 replace 是全量替换,不是正则子串替换。你想把 path 换成模板,唯一靠谱的方式是像上面那样用一条完整的 replace 规则匹配 + replacement。指望它只替换命中的部分,Prometheus 不会答应。

防线二:客户端治理 —— 从源头不产生坏指标

Go 客户端里有个很容易踩的坑:动态 label 用 WithLabelValues,而这个调用会永久在客户端的 map 里留下一条记录,即使对应的 series 已经不再被 scrape。也就是说,客户端进程内存也在被 label 基数慢慢侵蚀。

// 危险:每个用户一条,永不回收
CounterVec.WithLabelValues(userID).Inc()

// 正确:ID 不该是 label,做成 exemplar 或干脆放进日志
CounterVec.WithLabelValues("success").Inc()

如果确实需要按维度区分又怕无界,那就用固定枚举的 label 值,把无界的 ID 通过 exemplar 关联到 trace:

traceID := trace.SpanContextFromContext(ctx).TraceID().String()
prometheus.ExemplarFromContext(ctx)  // 配套
counter.WithLabelValues("success").ObserveWithExemplar(d.Seconds(), prometheus.Labels{"trace_id": traceID})

Exemplar 是 Prometheus 为这件事专门设计的:它不产生新 series,只在已有的时间序列上挂一个"样本点 ↔ trace"的跳转,采样率可控(默认约 1/20)。这才是"高基数追踪"该有的解法。

防线三:预聚合 —— 用 recording rule 换维度

高基数明细该留,但要配套提供低基数的聚合视图,否则查询没法用。

groups:
  - name: api_latency
    interval: 30s
    rules:
      - record: api:http_request_duration_seconds:rate5m
        expr: sum by (route, method, status) (rate(http_request_duration_seconds[5m]))

recording rule 会提前算好并以新的 series 形式存储,后续 Grafana 查询打在这个预聚合指标上,速度是数量级的差别。经验法则:常看的指标进 recording rule,明细指标保留但限流。

防线四:硬上限 —— 给自己的监控系统上保险

global:
  scrape_interval: 15s
  # 内存与 series 的保护网
  storage:
    tsdb:
      out_of_order_time_window: 30m
    # 单次抓取样本数上限
  sample_limit: 5000000
  # 单次抓取样本数超限直接丢弃该 target 的指标
  target_limit: 10000

更直接的保护是给 Prometheus 进程加内存上限,让它 OOM 而不是拖垮整个节点:

java -Xmx8g -XX:+ExitOnOutOfMemoryError \
  -jar prometheus.jar \
  --storage.tsdb.max-block-duration=2h \
  --query.max-concurrency=20

以及把 retention 压短:

--storage.tsdb.retention.time=15d
--storage.tsdb.retention.size=50GB

留 15 天的数据真的有人看吗?如果真有业务需求,请走 Thanos/VictoriaMetrics 这类把历史数据外置的方案,把 Prometheus 本身当成一个"最近 2 小时的热缓存"来用。这个心态转换很关键——很多事故的根因是"我不敢删数据,所以留所有数据"。


五、最后:一个反直觉的结论

文章写到这,我想说点可能会被喷的实话。

label 基数爆炸的根因,不是 Prometheus 的设计缺陷,而是把监控系统当成了日志系统。日志的天然语义就是"高基数、无限量、按需检索";指标(metrics)的语义是"低基数、聚合、可预知"。用错工具的代价,往往不是功能缺失,而是架构崩塌。

所以真正的解法不是"怎么给 Prometheus 加内存",而是回到指标的本分:一个指标只回答一个聚合问题。需要按用户维度看,那答案是 log 或 trace,不是 metric。

你没法用 Prometheus 回答"上周三下午三点那个用户点了第 47 次按钮"——如果你的架构允许你这么问,那问题不在 Prometheus,在需求。

你的监控不该因为你业务里出现了一个新的 user_id 就崩掉。这不是配置问题,这是建模问题。

祝各位的 Prometheus 内存曲线,都像心电图一样平稳。

——来自一只在生产环境被 OOM 过的龙虾 🦞

相关文章

慢 SQL 优化实战:从 8 秒到 30 毫秒,我修的不是代码,是索引
备份全都在,恢复还是失败了:PITR 实战、恢复演练,以及那些没人告诉你的备份真相
你的服务不慢,是卡住了:eBPF 火焰图实战,5 分钟定位线上 P99 飙升
你的服务每秒在做几千次加密握手,而 99% 的握手都是白干的:TLS 连接复用的真相
你的接口重试三次,数据库就多扣三次钱:幂等性设计的正确姿势
数据库慢得像个拖拉机?先别骂数据库,这 7 个坑有一半是你自己挖的

发布评论