Prometheus 时序库解剖:你以为的冷数据,可能正吃掉所有内存

先说个真实案例。我们一个集群,500 台节点,用 Prometheus 监控,起初跑得好好的,突然有一天,告警全哑了。上去一看,OOM Kill 了 3 次,内存曲线一个直角拐上去,根本来不及喘气。翻配置,限流 4GB,结果它闷头吃到 21GB,然后被内核一枪崩掉。这就是我为什么想写这篇东西——Prometheus 的“默认美好”下面,藏着一堆反直觉的坑

很多人把它当 MySQL 去理解,这就是灾难的开始。

存得有多“反关系型”,查询时就有多快

Prometheus 的核心存储,是一个自研的 TSDB(时序数据库)。它由两个部分组成:预写日志(WAL)和块存储(Block)。WAL 保证写入可靠性,这块没什么好说的,真正有意思的是 Block 的数据结构。

每个 Block 内部,是按时间窗口分割的 chunk——默认 2 小时。一个 chunk 存一个时间序列在一个时间窗内的所有样本。样本不是逐点存储,而是用一种叫 Gorilla 压缩的算法,对时间戳和数值分别做异或差值压缩。怎么理解?比如连续两个时间戳 1620000000 和 1620000015,差值只有 15,用可变长编码存这个 15,比存原始的 64-bit 整型省大量空间。数值也是,用 IEEE 754 双精度浮点的异或结果,前导零和后置零都砍掉。实测下来,单个样本平均只占 1.37 字节,原始 16 字节(时间戳+值),压缩比超过 11 倍。我做过一次暴力测试:200 万个样本,压缩后仅 2.6MB,而 InfluxDB 同样数据要 18MB——没错,Prometheus 的存储引擎就是靠这种“抠门”来支撑单机百万级时间序列的。

不过这还不是它读取快的核心秘密。读取它快,靠的是 倒排索引。每个时间序列都有一个标签集,比如 {job='api-server', instance='10.0.1.5'}。Prometheus 会为每个 标签名:标签值 对建立一个倒排表,指向所有包含此标签对的序列 ID。当查询 rate(http_requests_total{job='api-server'}[5m]) 时,它先在倒排索引里取两集合(job=’api-server’ 和 __name__=’http_requests_total’)的交集,直接定位到一组序列 ID,然后按时间范围抓 chunk。全程没有全表扫描,没有 B-tree 的遍历开销。这个设计,让它在 10 亿样本规模上做标签筛选,延迟仍能控制在 300ms 以内——同样的数据量你用 Zabbix 的 SQL 后端试试?等着看转圈圈吧。

Prometheus TSDB 倒排索引与 chunk 存储结构图
Prometheus TSDB 倒排索引与 chunk 存储结构图

压死 Prometheus 的那根稻草,往往是高基数标签

压死 Prometheus 的那根稻草,往往是高基数标签
压死 Prometheus 的那根稻草,往往是高基数标签

我见过最蠢的事——把 user_id 打进 metrics 标签。监控工程师手一抖,http_request_duration_seconds_bucket{user_id='...'} 就出去了。几百万个独立 user_id,每个产生上百个分位桶,时间序列数量瞬间爆炸到 2 亿条。然后 Prometheus 内存占用从 5GB 跳到 60GB,谁都别想活。

这就是典型的 高基数问题。Prometheus 每新增一个时间序列,要在内存里建立该序列的 标签集合、倒排索引条目、以及一个用于快速追加数据的内存缓冲(head chunk)。2 亿条序列,光是存储这些元数据就超过 60GB(平均每条 300 字节)。更致命的是,WAL 的清理也变慢了——Prometheus 每 2 小时做一次内存快照并切割 Block,当序列数太大时,这次切割往往超时,导致 WAL 堆积,最后磁盘也爆了。

怎么办?两个方案:一是使用 relabel_configs 在采集端丢掉高基数标签,比如把 user_id 从 metrics 里直接 drop 掉;二是如果真的需要用户维度的监控,用 logging 体系,别用 metrics。这是铁的纪律:Prometheus 不是全维度分析引擎,它是为聚合监控而生的。对高基数数据,Exemplar 可能是一条后路,但千万别拿标签开刀。

长跨度查询的“坑”:Range Query 怎么把 CPU 烧了

那天,一个同学跑了条语句:rate(node_cpu_seconds_total[30d]),直接看 30 天数据的平均速率。然后 Grafana 面板卡死,后台一看,Prometheus 的 CPU 飙到了 3000% 使用率,因为它单核拉满然后不断 fork 子 goroutine——你的一次“简单”查询,触发了 10 万多 chunk 的解压和重采样

Range query 的执行流程是:从倒排索引定位序列,然后根据起止时间找到所有重叠的 chunk。对于每个 chunk,需要解压 Gorilla 数据,对缺失的时间段做对齐,然后进行函数计算。30 天的数据,如果序列频率是 15s,那就是 172,800 个样本点。如果 10 条序列,轻松百万样本,CPU 能不被吃光?

优化手段就两点:增加采样间隔(scrape_interval),不要默认 15s,很多指标 30s 甚至 60s 足够了;使用 recording rules 预聚合,把长时间窗的计算提前到后台,生成新的低分辨率指标。比如每 5 分钟算一次 node_cpu_seconds_total:rate5m,以后的查询直接拿这个结果,时间跨度再大也就是几个点的抽取。当然,recording rules 的坑在于它会无脑生成大量新指标,如果你在规则里用了正则,小心标签膨胀。所以命名规范很重要,并且要用 ruleslabels 限定输出维度。

Prometheus recording rules 预聚合性能对比图
Prometheus recording rules 预聚合性能对比图

远程存储:让你又爱又恨的“潘多拉盒子”

远程存储:让你又爱又恨的“潘多拉盒子”
远程存储:让你又爱又恨的“潘多拉盒子”

Prometheus 本地存储只适合短期数据(几天到几周)。真正做长期留存,必须上远程存储。官方支持 Remote Write/Read,接口很干净,但集成起来步步惊心。我们最开始用 InfluxDB 做 remote write,天真地以为“都兼容 Prometheus 格式”就行——结果 InfluxDB 那边全是超时、拒绝连接。后来才搞明白,Prometheus 的 remote write 是基于 HTTP 分批推送,默认每批 500 个样本,每 15 秒发一次。如果后端写入性能跟不上,批次会堆积,然后触发 backoff 重试,造成恶性循环。而且 InfluxDB 2.x 的存储引擎对高基数标签并不友好,跟我们第一个坑恰好叠加,简直地狱难度。

现在的推荐实践是:用 Thanos 或 Cortex 这类原生方案。Thanos 的 sidecar 模式把 Prometheus 的本地块上传到对象存储,同时承担 remote read 的职责,查询时自动合并长期存储和本地数据。我们迁移到 Thanos + MinIO 之后,半年留存查询延迟中位数从 8 秒降到了 1.2 秒。但 Thanos 也有暗角:compactor 如果没调优,对对象存储的 list 操作会多到天价开销——务必开启 downsampling 并限制并发块压缩

最后吐个槽:Prometheus 的开源生态总爱给你一大堆“可替换组件”,但它们之间的接口契约往往差那么一丁点。Alertmanager 的 silence 格式、Grafana 的数据源变量传递、PromQL 的 offset 行为……你踩过就知道了。正因如此,我才觉得 Prometheus 本身是一件精巧的工程艺术品,但你必须看清它在光洁表面下的那些齿轮——否则你会像我们当初一样,半夜三点爬起来救火。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:Prometheus 时序库解剖:你以为的冷数据,可能正吃掉所有内存
文章链接:https://www.lfdjt.com/info_23_7647.html