结论先行:Prometheus 频繁 OOM 且 WAL 截断失败,99% 的根因是高基数(High Cardinality)标签打穿了 Head Block 的倒排索引。底层 Gorilla 压缩算法只能极大地优化时序“值”的存储(16字节压缩至约1.37字节),但救不了无限膨胀的 Label 组合。解决方案:通过 promtool tsdb analyze 定位基数元凶,用 metric_relabel_configs 在抓取阶段实行防御性清洗,并合理配置 TSDB 的 Block 压缩与落盘周期。
某次排查过程中,我们线上一套监控几十个 K8S 集群的核心 Prometheus(v2.45.0)节点陷入了 CrashLoopBackOff 的死亡循环。告警静默,监控大屏一片空白。
查看系统内核日志,死因极其明确——被 OOM Killer 制裁:
$ dmesg -T | grep -i oom
[xxx] prometheus invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
[xxx] Memory cgroup out of memory: Killed process 12345 (prometheus) total-vm:85493200kB, anon-rss:67108864kB
物理机分配了 64GB 内存给这个容器,居然在几分钟内被吃干抹净。这绝不是正常的指标写入量增长,而是典型的“高基数雪崩”。
寻找雪崩元凶:拨开 WAL 与 Head Block 的迷雾
Prometheus 的 TSDB 设计基于内存(Head Block)与磁盘(Persistent Block)的组合。最新采集的 2-3 小时数据全部驻留在内存中,并依靠 WAL(Write-Ahead Log)保证不丢数据。每次 Prometheus OOM 重启后,第一件事就是 Replaying WAL。如果导致 OOM 的高基数数据还在 WAL 里,重启过程必将再次吃满内存,形成死循环。
为了强行中断这个循环,我们先将该节点的内存限制临时放大到 128GB 让其启动,随后立即使用官方神器 promtool 对本地数据目录进行离线解剖:
$ promtool tsdb analyze /prometheus/data
...
# Top 10 label names with high memory usage:
1: trace_id
2: client_ip
3: pod_ip
# Top 10 series count by metric names:
1: 4501230 http_request_duration_seconds_bucket
2: 2100450 http_requests_total
...
破案了。某业务研发在 http_requests_total 和耗时直方图里,顺手加上了 trace_id 和 client_ip 作为 Label。
为什么仅仅是多加了一个 Label,就会耗尽上百 GB 的内存?
很多开发对时序数据库有误解,认为“Prometheus 压缩率很高,多加个字段无所谓”。这就必须深入 TSDB 的底层数据结构来解释。
在 Prometheus 中,一条时间线(Series)由 Metric Name 和一组 Label 键值对唯一确定。
http_requests_total{method="GET", status="200", client_ip="192.168.1.10", trace_id="abc123xxx"}
TSDB 处理数据分为两大核心路径:数据块(Chunks)与倒排索引(Inverted Index)。
-
Chunks 的极致压缩(Gorilla 算法) 对于属于同一条时间线的连续样本数据
(Timestamp, Value),Prometheus 采用了类似 Facebook Gorilla 论文中的 XOR 增量压缩算法。因为时间戳通常是规律递增的(如 15s 一次),Value 往往变化极小。通过计算差值的差值(Delta-of-Delta),一对原本需要 16 Bytes(8 Byte int64 时间戳 + 8 Byte float64 值)的样本,能被压缩到平均 1.37 Bytes。这就是大家常说的“高压缩率”。 -
倒排索引的内存黑洞 Gorilla 压缩对 Label 完全无效。为了能让 PromQL 飞速查询,Prometheus 必须为每一个 Label 的 Name 和 Value 建立倒排索引映射:
Label (client_ip="192.168.1.10") -> [Series ID 1, Series ID 205...]当引入trace_id这种几乎每次请求都不同的 Label 时,Series 的数量等于所有 Label 基数的笛卡尔积。 百万级别的trace_id瞬间生成了数百万条独立的 Series。每条全新 Series 的诞生,都会在 Head Block 中分配新的字符串内存(Symbols table)、新的倒排索引指针(Postings list),以及独立的数据 Chunk。内存消耗呈现指数级爆炸,且完全无法被压缩。
当这些海量的内存结构积压在 Head Block(默认驻留时间最多达 3 小时),内存自然会瞬间被打穿。
落地实战:防御性清洗与架构调优
对于这种毒瘤级的指标,我们绝不妥协,必须在网关侧/采集侧直接干掉,实施“防御性运维”。
1. 采集端防御配置(metric_relabel_configs)
在 Prometheus 的 scrape_configs 中,利用 metric_relabel_configs 在指标进入 TSDB 引擎前将其截杀。注意,不要用 relabel_configs(作用于 target 发现阶段),必须用 metric_relabel_configs:
scrape_configs:
- job_name: 'business-api'
...
metric_relabel_configs:
# 方案一:直接丢弃整个包含了违规 Label 的指标序列(下手最狠)
- source_labels: [trace_id]
regex: '.+'
action: drop
# 方案二:保留指标,但抹除高基数 Label(推荐,保证监控不丢失总并发量)
- regex: '(trace_id|client_ip)'
action: labeldrop
加载配置 (curl -X POST http://localhost:9090/-/reload) 后,新的数据洪流被清洗干净。
2. TSDB 块生命周期(Compaction)调优
为了让 Prometheus 尽快清理掉历史遗留的庞大 Head Block,我们需要理解 TSDB 的落盘(Compaction)机制。
Head Block 中的数据达到特定条件会切分成持久化的 Block 目录(包含 meta.json, index, chunks/, tombstones)。
如果服务器内存吃紧,我们可以适当干预落盘周期(启动参数配置):
# 默认 min-block-duration 为 2h,决定了 Head 块多久切片一次落盘。
# 强制保持一致,避免块过大难以合并
--storage.tsdb.min-block-duration=2h
--storage.tsdb.max-block-duration=24h
持久化到磁盘后的 Block 会被通过 mmap 的方式映射到虚拟内存空间(VIRT),此时只要不进行全量范围的 PromQL 查询,这部分数据对物理内存(RES)的占用将大幅度降低,由 Linux 内核的 PageCache 全权接管。
3. 清理已存在的毒瘤数据(Tombstones 机制) 对于历史的脏数据,可以使用 Admin API 软删除:
curl -X POST -g 'http://localhost:9090/api/v1/admin/tsdb/delete_series?match[]={trace_id=~".+"}'
执行后,数据不会立刻从磁盘消失,而是写入到 Block 目录下的 tombstones 文件中。后续的 Compaction 过程会读取该文件并真正剔除无用数据。如果需要强制立即清理磁盘,可调用:
curl -X POST http://localhost:9090/api/v1/admin/tsdb/clean_tombstones
常见问题 (FAQ)
Q1:为什么通过 metric_relabel_configs 删除了高基数 Label,Prometheus 的物理内存(RES)并没有立刻下降?
A:这是符合预期的。由于 Prometheus TSDB Head Block 的机制,数据通常要在内存中攒满 2 小时(加上最长允许的 1 小时 overlap)才会执行落盘并释放内存。即使新抓取的数据不再含有高基数标签,旧的庞大倒排索引依然存活在内存里。你需要耐心等待下一次 Head 切片,或者干脆重启进程,配合之前放宽的内存上限让 WAL 重放完成后,内存自然回落。
Q2:Prometheus 发生 OOM 重启后,启动特别慢,日志一直卡在 Replaying WAL 是什么情况?
A:Prometheus 只有正常退出时,才会将 Head Block 里的内容做 Checkpoint 或者全量 Flush 落盘。OOM 属于非正常崩溃,内存数据丢失。重启后,它必须逐行读取 data/wal/ 目录下的日志以在内存中重建倒排索引和 Chunks。如果 WAL 高达几十个 GB,这个过程将极其漫长(且高度依赖磁盘 IOPS)。建议将 TSDB 部署在企业级 NVMe SSD 上,这是监控系统的底线。
Q3:我可以通过降低抓取频率(将 scrape_interval 从 15s 调整为 60s)来缓解高基数导致的 OOM 吗?
A:不能,这是经典误区。scrape_interval 影响的是同一条 Series 每分钟追加的样本点数量。这部分数据被 Gorilla XOR 算法高效压缩,占用极小。导致 OOM 的是 Series 的总数(基数规模)膨胀,进而撑爆了不可压缩的倒排索引表。无论是 15s 抓取一次还是 1 分钟抓取一次,只要这几百万个带唯一 trace_id 的 Label 依然存在,生成的索引内存消耗是一模一样的。