去年 11 月一个周二凌晨两点四十,值班手机响了。Prometheus 主实例 OOMKilled,重启循环。这个实例 32Gi limit,跑了快两年没出过事。
我打开 Grafana 上 Prometheus 监控自己的那个 dash,看 prometheus_tsdb_head_series,240 万。翻两个月前的截图,是 90 万。中间没人加过新业务监控,硬生生涨了 150 万。
当时的现场:WAL replay 一次要 6 分多钟,重启后还没恢复完就开始 OOM,死循环。那天最后是靠临时把 limit 提到 48Gi 抢回来的,这不是办法。
先说结论:90% 的高基数不是 Prometheus 的问题,是指标设计的问题。换 VictoriaMetrics、Mimir、Thanos 只能解决「存不下」,解决不了「存的是垃圾」。
第一步:先看清楚 series 到底长在哪
别急着重启、别急着加内存。Prometheus 自带一个 HTTP 接口就是干这个的,不用装任何额外组件:
curl -s localhost:9090/api/v1/status/tsdb | jq '.data | {seriesCountByMetricName: .seriesCountByMetricName[:10], labelValueCountByLabelName: .labelValueCountByLabelName[:10]}'
返回三组数据,我一般先看 seriesCountByMetricName 找罪魁,再看 labelValueCountByLabelName 找是哪个 label 在作妖。
我们那次 top1 是 http_request_duration_seconds_bucket,87 万条 series。注意 _bucket 的 series 数是要乘 bucket 个数的,我们的 histogram 有 12 个 bucket,所以真正的 label 组合只有 7 万出头。这个坑我第一次排查的时候没反应过来,盯着 87 万懵了半天,还以为是哪里配错了。
第二个接口更细,能看到单个 label 值的分布:
curl -s localhost:9090/api/v1/status/tsdb | jq '.data.seriesCountByLabelValuePair[:10]'
这个只能看前 N 个,量大了会很慢,别在生产高峰期跑。我们那会儿就是下午三点跑的,Prometheus 直接卡了半分钟没响应,被同事问了一句「你是不是在查 TSDB status」。
如果 Prometheus 已经重启不了了,直接从磁盘上跑离线分析更稳:
promtool tsdb analyze /prometheus
它会输出整个 block 里每个 label 的 cardinality 排行,包括 Highest cardinality labels 和 Highest cardinality metric names 两节。这个命令对线上零影响(只要你读的是已经落盘的 block,不是 head),我现在的习惯是每季度跑一次,写进例行巡检。
我们踩到的三类高基数
一、没归一化的 URL path
这是最经典的。我们的 http_request_duration_seconds 带了个 path 标签,41 万个不同值。而整个后端 API 加起来不到 200 个 endpoint。
原因是有个前端把资源 ID 直接拼进了 URL:/api/v1/order/detail/8f3a2c91-...。每个订单一个 series,跑一天就是几十万条,而且这些 series 永远只有一次采样,第二天全变成死的,但还占着索引。
修法有两种。治标的是在 scrape 端用 metric_relabel_configs 挡掉:
- job_name: app
metric_relabel_configs:
- source_labels: [path]
regex: '.*/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*'
action: drop
治本的是改客户端,把 path 换成 route pattern(/api/v1/order/detail/:id)。我们在代码里包了一层 middleware 做这个,推了两个月才全量上线,中间那两个月就靠上面这条 regex 顶着。顺便吐槽一下,让业务团队改指标 label 这件事,比让他们改接口还难,因为指标这东西平时没人看,改起来没成就感。
二、cAdvisor 的 id 标签
kubelet 暴露的 container_* 系列指标带一个 id 标签,值是 /kubepods/burstable/pod<uid>/<container_id> 这么一长串。这个标签基本没人用,但它让每个容器的每条指标都变成独一无二的 series。
- job_name: kubelet
metric_relabel_configs:
- source_labels: [id]
regex: '/kubepods/.*'
action: drop
我们只做这一条,series 掉了 18 万。注意别直接用 action: labeldrop 加 regex: id,有些 job 的 id 是有意义的,得按值匹配,不然你会把业务指标也一起干掉。
顺便说一句,__meta_kubernetes_pod_uid 这类 meta label 默认不会进 series,只有在 relabel_configs 里显式写成 target_label 才会。我见过有人为了追查容器重启,把它保留成 pod_uid,然后 Deployment 一天滚 20 次,series 直接翻 20 倍。这种「为了排查方便」加的标签,代价通常要到半年后内存告警的时候才浮出来。
三、没人看的 recording rule
翻 prometheus_tsdb_head_series 的 label 分布时,我发现有 12 万条 series 来自一个叫 api_latency_p99_5m_by_tenant_route_region 的 recording rule。这个 rule 是三年前做多租户 SLA 报表时加的,报表下架了,rule 还在,每 15 秒算一次,算出来的东西没人查。
我们那次一口气删了 9 条僵尸 recording rule。光看总数是看不出这类问题的,得去 prometheus_tsdb_head_series 的 label 里按 __name__ 分组,或者直接翻 rules 文件跟 Grafana 面板对一遍——面板里引用不到的 rule,八成可以删。
说实话,这一步最费时间,纯人工,没有好工具。有的话请告诉我。
几种处理手段,我按性价比排个序
- 客户端改指标设计。根治,但是要跨团队推,周期以月计。
- metric_relabel_configs 丢标签或丢 series。当天见效,缺点是配置分散在各个 job 里,半年后自己都看不懂谁加的、为什么加。
- recording rule 预聚合 + drop 原始指标。适合那种「只关心汇总值、不需要下钻」的场景,比如容量规划用的资源指标。
- 换 TSDB(VictoriaMetrics / Mimir / Thanos)。这个我要多说两句。
很多团队的第一反应是换存储。我们当时也算过账:240 万 series,30 天保留,本地盘 1.4TB。换到 Mimir + 对象存储,存储成本大概从每月 400 多块(如果是云盘)降到几十块,看着很香。
但这里有个陷阱——Mimir、VM 这些之所以能扛住高基数,是因为它们把索引做得更省、把老的 series 更快地踢进冷存储。它们不改变「每条 series 都是一次写入、一次索引、一次查询扫描」这个本质。你把 240 万条垃圾 series 灌进 Mimir,查询该慢还是慢,rate() 该扫多少还是扫多少,只是账单好看了一点,还多了一堆组件要维护。
我的判断是:series 数在 200 万以下,先把指标设计收拾干净,比换存储划算得多;真到了 500 万以上或者要多集群联邦,再考虑换。这个数字不是拍脑袋,是我们单机 Prometheus(8C32G,SSD)的实测拐点,过了 200 万之后查询 p99 从 1.2s 涨到 4s 以上,涨得很快。
代价:我丢过一次东西,记到现在
砍指标这事,最怕的是砍掉自己以为不重要的东西。
我们当时把一个内部服务的 gRPC method 级指标给 drop 了。理由很充分:2000 多个 method,大部分 QPS 是 0,纯占坑。三个月后出了个 P2,只影响 BatchSubmitV2 这一个冷门 method,QPS 平时 0.02。我们花了 40 分钟从日志里拼出结论。如果那个 method 的指标还在,rate() 一看就出来了,可能 5 分钟。
所以现在我的做法是分层,不做全量 drop。高频的保留原始 label,低频的折叠成 other:
groups:
- name: cardinality-tiering
interval: 30s
rules:
- record: grpc:handled:top_methods
expr: |
sum by (service, method, code) (grpc_server_handled_total)
and on (service, method)
topk(50, sum by (service, method) (rate(grpc_server_handled_total[5m])))
- record: grpc:handled:other
expr: |
sum by (service, code) (grpc_server_handled_total)
- sum by (service, code) (grpc:handled:top_methods)
上面这段我改了三版才跑对,and on (...) 那块特别容易踩 label 对不齐的坑,你们要是照抄,先拿一个小 job 试试,看输出行数对不对再说。别直接上生产,这个我吃过亏。
最后的结果
| 项目 | 优化前 | 优化后 |
|---|---|---|
| active series | 2,410,000 | 510,000 |
| head block 内存 | 18.3 GB | 4.1 GB |
| 30 天本地磁盘 | 1.4 TB | 380 GB |
| WAL replay 时间 | 6 分 10 秒 | 52 秒 |
| scrape p99 | 8.2s | 1.9s |
内存 limit 从 48Gi 降回了 16Gi,稳定跑了 5 个月。
回头看,最有价值的不是那张表,是意识到指标也是一种要还的技术债。加一个 label 的人当天就能看到图,付账的是半年后凌晨被叫醒的人。
我现在给团队定的规矩就三条:任何 _bucket 类型的指标,label 组合数不超过 5000;任何带 id、uid、uuid、trace 字样的 label,必须在 relabel 里处理掉;recording rule 每季度对一次 Grafana 面板,没人查的删。
第三条执行得最差,我承认。