☰
Prometheus 高基数内存优化实战:从 relabel 到 recording rule 的调优指南
2026/10/28 8:25:13 网站建设 项目流程

简介:基于FPGA的多功能视频处理平台为2024年大赛国一获奖作品,面向FPGA开发者和视频处理学习者,提供支持五路视频输入、三路输出并集成24种图像处理算法的完整工程方案,适合研究复杂视频架构与多接口协同设计。包内共216个文件,以115个v源码为核心,辅以vhd、sv等硬件描述代码,ipc和adc为IP核及引脚配置,tcl与sdc用于工程约束和自动化流程,mif存放初始化数据,bit文件可直接烧写验证,docx与txt则提供详细使用说明,整体仅5.88MB,结构紧凑且类型覆盖完整。已有158人学习下载。通过该资源可快速掌握双目摄像头、SD卡、HDMI、以太网、SFP及MIP接口的综合运用,理解从视频采集、算法处理到多路输出的完整链路,并可直接参考国一作品的模块划分与代码实现,对备赛、课程设计和项目移植都有很高的参考价值。

1. 高基数监控的终局:Prometheus 在千万级时间序列下的内存生存指南

没有哪个监控系统的内存是线性增长的。你的 Prometheus 从单机几十万序列起步,到托管 500 万 series、1000 万条活跃监控指标时,内存会被三个东西吃光:head block 里缓存的 chunk 数据、整个 TSDB 的内存索引、以及查询时的瞬时聚合结果。如果还在用默认配置,翻车就是几天之内的事。这文章不聊概念,直接讲清楚在高基数下怎么压内存、怎么留余量、怎么在查询时不让 OOM 找你麻烦。

2. 内存都去哪了:存储引擎的缓存机制与新引擎的边界

2.1 head block 与 chunk 缓存的真实消耗

Prometheus 的内存占用大头从来不在磁盘上的历史 block,而在内存里那块名为 head 的区域。所有最近写入的样本先进 head,以 chunk 为单位组织,每 chunk 默认最多 120 个 sample。当 head 里的 chunk 达到 2 小时窗口后才会被刷到磁盘,但注意:刷盘不等于释放内存。磁盘上的 chunk 并不会全部被换出,而是通过 mmap 映射方式保留对文件的引用,查询历史数据时仍然需要将这些 chunk 载入内存进行解压。

在 500 万 series 的规模下,每个 series 在 head 中至少要保留一个 chunk 结构体,这个结构体包含 meta 信息、样本缓冲区和索引项。仅此一项,500 万个 chunk 头部的结构体开销,就足以消耗掉数 GB 内存。实际压测时你会发现 RSS 超过 20GB 并不罕见,而服务端默认只提供了GOGC这个隐式调节手段,等到 GC 压力上来,性能已经开始断崖式下跌了。这台机器的内存不是被“慢慢吃掉”的,是被结构体数量堆死的。

2.2 内存索引的膨胀速度

第二个吃内存大户是索引。每个 series 的 label 组合都会在内存中建立倒排索引,用于支撑 label 查询。在 metrics 数量少时,这个索引的开销可以忽略不计。但一旦 label 组合爆炸,比如把user_id、request_path这种高基数 label 直接放进了 metric 里,索引的膨胀速度会远超你的预期。

我见过最典型的一个 case:某个业务团队把pod_ip和client_ip全部塞进http_request_duration_seconds的 label,仅仅这一个 metric 就产生了几十万个 series。倒排索引需要为每个 label 值维护一个 postings list,这些 list 在内存里以 slice 形式存储,更新和查询都要遍历,内存和 CPU 双重爆炸。在优化之前先做一次基数画像比什么都重要,sum by (job) (count by (job, __name__) ({__name__=~".+"}))这种查询虽然也消耗资源,但能让你一眼看出哪些 metric 是内存杀手。

2.3 新存储引擎的适用边界

v2.39 之后引入了新存储引擎,默认启用后,它将部分标签索引从内存移到磁盘,整体内存占用确实下降了 30% 到 40%。但别高兴太早,新引擎对查询模式有更严格的约束:高基数 label 的检索性能反而可能下降,因为它把部分 postings 放到了磁盘上进行读取。如果你的环境是 500 万 series 加上大量按 label 过滤的查询,新引擎带来的省内存收益,很可能被查询延迟的上升抵消。

新引擎适合“写入大、查询相对固定”的场景,不适合需要频繁做多维聚合分析的场景。我在做压测对比后发现,老引擎配合合理的内存配额,在新引擎默认配置下表现可能更稳定。这不是说哪个更好,而是说你要先评估自己的查询模式,再决定开不开这个特性。

3. 采集侧优化:从源头上砍掉一半 series

3.1 用 relabel 干掉高基数 label

很多人不知道metric_relabel_configs和relabel_configs的区别。前者在抓取之后生效,直接修改 target 暴露的样本;而后者是在服务发现阶段生效,决定 target 是否被抓。在高基数场景下,我会优先在metric_relabel_configs里做 label 裁剪,因为服务发现阶段的 relabel 无法感知每个样本的具体 label 值。

来看一段我在生产上用的配置:

scrape_configs: - job_name: "my-service" metrics_path: /metrics scrape_interval: 15s metric_relabel_configs: - source_labels: [client_ip] regex: "(.*)" replacement: "$1" action: drop

这段配置会直接丢弃client_ip这个 label。像这种高基数 label,日志系统或许需要你保留,但监控系统里基本用不上。每个被丢弃的 label 背后就是成百上千个 series 的消失。

注意action: drop后面没有target_label,它匹配的是源 label 的值。source_labels的定位顺序很重要,如果你要保留一个 label 并改写它的值,要用action: replace。用labeldrop动作可以更简洁地按 label 名过滤:

metric_relabel_configs: - action: labeldrop regex: "client_ip|request_id|trace_id"

这个配置会一次性删掉所有样本上的三个高基数 label。regex用的是 RE2 语法,|表示或。我个人建议在采集侧的处理顺序是:先labeldrop删掉已知无用的高基数 label,再用drop按值过滤掉完全不关心的样本,最后再考虑record方式做预聚合。

3.2 降低采样频率与对齐问题

scrape_interval的调整是立竿见影的。默认 15 秒采集一次,如果业务指标本身变化不快,放到 30 秒甚至 60 秒都是安全的。但这里有个隐藏的坑:scrape_interval必须与evaluation_interval(规则计算周期)保持合理的倍数关系。你以为改了抓取间隔就能降内存,但如果规则计算还是 15 秒一次,两次计算之间可能拿不到新数据,查询结果全是插值或者旧值。

更系统的做法是直接在抓取层面把指标拆分到不同的 job 里,不同 job 用不同采集频率:

scrape_configs: - job_name: "fast-metrics" scrape_interval: 15s static_configs: - targets: ["localhost:9100"] - job_name: "slow-metrics" scrape_interval: 60s static_configs: - targets: ["localhost:9101"]

这样把实时性要求高的指标和低频业务指标分开了。fast-metrics保持 15 秒,slow-metrics用 60 秒,单位时间内的样本量直接降到原来的四分之一。如果你是做长期趋势观察,采样间隔 60 秒完全够用,内存压力瞬间小一半。

3.3 抓取超时与 keep 策略

抓取超时时间scrape_timeout默认只有 10 秒,高基数 target 往往在拉取时就需要更长的时间来编码响应。很多人在这个环节翻过车:context deadline exceeded频繁出现,不得不调大超时时间,但超时时间调大后,Prometheus 会在内存中同时维护多个尚未完成的抓取请求,内存进一步膨胀。

另一个容易被忽略的参数是每个 target 的样本数上限。在抓取配置里加上sample_limit是最保险的做法:

scrape_configs: - job_name: "risky-endpoint" sample_limit: 100000 scrape_timeout: 20s static_configs: - targets: ["192.168.1.50:9100"]

sample_limit一旦触发,该 target 的这次抓取会被标记为失败,数据不会写入 TSDB。这比在应用端限制指标数量要硬得多,它是最后一道防线。10 万这个阈值我一般会根据目标暴露的指标量来调,宁可在抓取时报错,也不能让垃圾数据进库。

4. 查询侧降内存:把聚合压力分摊到规则里

4.1 用 Recording Rule 预先聚合

高基数监控里最蠢的查询方式,就是每次告警或看板都实时对几百万 series 做一次sum。一次sum by (job)如果跑在几百万 series 上,查询引擎需要把所有匹配的 series 拉进内存做归并,这会直接导致 OOM。与其每次现算,不如提前把高频查询固化掉。

Recording rule 的用法和告警规则类似,但它的结果是写入一个新的时间序列。举个例子,你每天都要看每个服务的 QPS 和平均延迟,那就在规则文件里定义好:

groups: - name: aggregation.rules interval: 30s rules: - record: job:http_request_duration_seconds:sum_rate expr: sum by (job) (rate(http_request_duration_seconds_sum[5m])) - record: job:http_request_duration_seconds:avg expr: sum by (job) (rate(http_request_duration_seconds_sum[5m])) / sum by (job) (rate(http_request_duration_seconds_count[5m]))

定义之后,查询job:http_request_duration_seconds:sum_rate就是直接读取一个已经聚合好的 series,查询引擎不再需要扫描底层几百万条时间序列了。record名称的命名规范我建议直接用job:metric:operation这种结构,可读性最强,也方便在 Grafana 里搜到。

这里的关键点在于interval和expr里的时间窗口必须匹配。规则每 30 秒算一次,如果expr里的窗口是 5 分钟,那这次计算内部需要读 5 分钟的数据做聚合,读出来的结果写入一个新序列,这个新序列每隔 30 秒一个点。你后续告警用这个新序列做rate()时,它的数据密度已经足够,不需要再回到原始序列上扫。

4.2 查询时的内存配额控制

即便有 recording rule,无法完全避免直接查询原始高基数序列的场景。在配置层面可以限制查询引擎的并发数和单查询的最大样本数:

./prometheus \ --query.max-concurrency=8 \ --query.max-samples=1000000 \ --storage.tsdb.retention.time=15d

--query.max-concurrency=8表示同一时刻最多 8 个查询在跑,超出的一律排队。这个值设得太小影响看板体验,设得太大内存照样爆。8 这个数字适合 16 核 64GB 的机器,如果机器更大可以稍微往上调。

--query.max-samples=1000000是单个查询可以处理的样本数上限。一旦查询涉及的样本数超过 100 万,Prometheus 会直接报错,而不是硬扛着算完触发 OOM。这个参数非常有用,它会以“查询失败”的方式让你知道哪些查询是重量级的,而不是等到进程挂掉才排查。

4.3 合理利用 lookback delta

--query.lookback-delta控制了查询时间范围外溢的宽度,默认是 5 分钟。它的作用是:当你想查询当前时刻的指标值,但最后一个采样点是 3 分钟前的,引擎会认为该序列在这个时刻仍然有效。这意味着,在规则计算时,所有在过去 5 分钟内出现过数据的系列都会被拉出来参与计算。

高基数场景下,这个默认值会让规则计算时扫描的 series 数量远大于实际写入的 series。把它调小到 1 分钟或 40 秒,可以明显缩小查询范围,代价是样本点对不齐时会更快被标记为过期。如果你对时序的连续性要求没那么高,我会直接把lookback-delta降到 1 分钟,这是最简单粗暴又不会影响大多数业务的调优。

5. 参数级调优与验证:跑通一套不会 OOM 的部署

5.1 关键启动参数配置总览

我们把前面讨论过的参数汇总成一张表,这部分可以直接对照你的启动脚本:

参数推荐值作用
--storage.tsdb.retention.time15d控制历史数据保留时长,直接决定磁盘占用和查询范围
--storage.tsdb.max-block-duration2hhead block 刷盘周期,默认即可,别调大
--storage.tsdb.min-block-duration2h强制最小块周期,防止大量小 block 刷盘导致内存抖动
--query.max-concurrency8限制同时执行的查询数
--query.max-samples1000000单查询最大样本数硬上限
--query.lookback-delta1m缩短规则计算时扫描的 series 数量
--storage.tsdb.retention.size20GB按容量而不是按时间保留数据,超限自动删旧块
GOGC30降低垃圾回收触发阈值,减少峰值内存

GOGC这个参数比较特殊。Go 运行时的垃圾回收默认在堆内存增长 100% 时触发,如果内存本来就紧张,GC 频率太低会导致内存峰值过高。设成 30,意味着堆内存每增长 30% 就触发一次 GC,内存峰值得以下降,代价是 CPU 占用率上升。我一般只在 500 万 series 规模且 CPU 有富余的情况下才会动这个参数。

如果你用的是 systemd 托管 Prometheus,启动参数可以直接写在ExecStart那行里。如果是容器部署,注意要把这些参数写进args数组,而不是command。很多人在这上面踩坑,以为改了 command 就能生效,实际 command 会被 Dockerfile 里的入口脚本覆盖掉。

5.2 内存调优实战:一次 OOM 的处理过程

有一次线上 Prometheus 突然 OOM 重启,查了日志发现没有任何超出预期的查询在跑,排除了查询峰值导致的爆内存。后来把内存 dump 拉出来分析,发现大量内存都花在 head block 的 chunk 上。原因是某个业务上线了一个新版本,把原本低频的日志指标改成按请求维度记录,series 数量一夜之间翻了四倍。

当晚的处理流程大致是这样:先把该业务的 scraping 暂停,封掉那几个新增的高基数指标,然后通过labeldrop把可保留但不需要监控的 label 过滤掉。等 series 数量降下来了,再逐步恢复抓取。事后我把所有能拆的 label 都拆干净了,并且在服务发现层加了一层独占的 job 配置,这样任何新增实例在进入 TSDB 前都会经过 relabel 过滤。

这期间有个细节很关键:Prometheus 并没有直接提供清理内存的命令,唯一可靠的方式是重启进程让 head block 重新加载。但重启会导致所有历史 series 重新建立索引,这段时间内存会先降后升。针对这个问题,我当时做的是在重启前先把磁盘上最近两小时的 wal 目录备份,重启后如果出现数据回放问题,可以通过替换 wal 目录来还原。这是一个实用但很少有人提前准备的后悔药。

5.3 从 Grafana 侧压查询内存

有时候问题不在 Prometheus 而在于 Grafana 的查询方式。很多看板写了类似于{instance=~".+"}这种不加 label 过滤的表达式,前端一刷新,后端就得全量扫数据,内存和 CPU 全被打满。Grafana 侧可用的优化手段是:

  • 在 dashboard 变量里设置合理的时间范围,避免默认拉取 30 天数据
  • 在 panel 查询里加上$__interval变量,让时序聚合的步长自动适应时间范围
  • 多使用rate()而不是delta(),因为delta()会拉取原始样本

具体到查询写法,推荐把sum by (...)前加一个内层聚合:

sum by (job) ( sum(rate(http_request_duration_seconds_sum[5m])) by (job, instance) )

先用内层把instance维度聚合掉,再对job求和。这样的好处是,inner 查询的结果集已经远小于原始 series 数量,外层聚合的压力就小了很多。你不需要对所有查询都这样写,但凡是看板里出现instance这个高基数 label 的查询,最好都包一层内聚合。

5.4 用 PromQL 提前发现高基数隐患

定期巡检是防止内存爆炸的最后防线。我每周都会跑一次下面的查询,找出 series 增长最快的 top 20 个指标:

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

这个查询会统计每个指标名下的 series 总数,并按降序取前 20 个。看到哪个指标的量级不对,就登录对应的 exporter 检查它的 label 设计。把这种检查做成 cron,每周自动跑一遍并把结果推送到企业微信或者 Slack,比你盯着 Grafana 等告警更靠谱。

6. 验证手段:压测脚本与内存观测技巧

6.1 用 tsdb 工具做离线占用分析

Prometheus 自带一个隐藏调试工具,在二进制包里叫tsdb。你可以用它来分析任意一个数据目录的存储结构:

./tsdb bench --storage.tsdb.path=/data/prometheus \ --bench=5000000 \ --samples=10

这条命令会模拟 500 万个 series、每个 series 写入 10 个 sample 的场景,输出内存分配和耗时情况。通过它的结果可以预估你的机器在高基数写入场景下还能撑多久。--bench=5000000表示 series 数量,--samples=10表示每个 series 写多少个样本点。这个值和线上规模越接近,参考意义越大。

除了 bench,更常用的是直接解析 head 块的统计信息:

./promtool tsdb analyze /data/prometheus

它会列出每个 metric 的 series 数量、总样本数和 chunk 数。输出的表格里,你会一目了然地看到哪些 metric 占据了绝大多数存储空间。我用这个命令抓出过一个诡异的container_memory_working_set_bytes,它在每个 pod 上都会按 container 维度拆分,看起来正常,但结合高基数的 namespace 维度后,series 总数直接翻了三倍。

6.2 观测内存趋势时盯住三个指标

Prometheus 自身暴露的指标中,和内存相关的关键项有:

  • process_resident_memory_bytes:常驻内存,代表实际物理内存占用。
  • prometheus_tsdb_head_series:head 中活跃的 series 数量。这个值比 RSS 更能反映内存压力。
  • prometheus_tsdb_head_chunks:head 中 chunk 总数,每个 chunk 都对应一份内存里的结构体。

这三个指标配合起来看,可以定位内存增长到底来自 series 数量增长还是 chunk 缓存增长。如果head_series不变但head_chunks持续增长,说明有大量不活跃的 series 在 chunk 里被保留了太久,可以考虑调小 block 时长或缩短保留时间。

6.3 一个务实的验收指标

最终衡量优化是否成功的标准只有一个,就是稳定运行一周不重启、不 OOM,同时查询 P99 延迟保持在 1 秒以内。我会做一次完整的淘汰过程:先按第 3 章的 relabel 方案削减 series,再按第 4 章加 recording rule 压查询,最后观察连续 24 小时的内存曲线是否处于平缓状态。内存曲线里如果有周期性的大坑,那是因为 GC,不是问题;如果有持续上扬,那说明还有新的 label 组合在源头产生。

从那以后我每次上线新业务之前,都强制走一遍这套检查流程:先量 series 基数,再定 relabel 策略,最后加一条 recording rule 打底。这套流程帮我挡掉过好几次潜在的内存事故,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询