聚合查询慢,几乎是每个玩过 Prometheus 的人都会遇到的事。你辛辛苦苦写了一条查询,数据量一大,它就在那转圈,甚至超时。别急着怪机器,问题的根源在底层存储和读取的那套流程上。今天咱们就聊清楚这件事,再看看从哪些方向动手,能让查询快起来。

一、先说问题:为什么聚合查询这么慢

平时我们看监控,总喜欢看“总共”多少。比如整个集群的CPU用量,一张图就是一条线。为了得到这条线,你会在 PromQL 里写一个求和,或者求平均。这些操作叫做聚合。聚合的意思是:从很多条时间序列里,把同一时刻的值合在一起,变成一个结果。

问题出在,Prometheus 的底层存储 TSDB 并不像关系型数据库那样按列存,而是按“时间序列”一条一条地存。每个指标名和标签的不同组合,都是独立的一条序列。当你想对所有序列求和时,TSDB 只能一条一条地把序列读出来,再在内存里做加法。这就像你有一堆流水账本,每本按日期记录每天的支出。你要统计这个月所有本子上买水果的总花费,就只能一本一本地翻,翻到水果那一项再记下来。没法跳过,也没法让本子自己帮你说“统计好了”。

数据量小的时候,翻账本也就几十页;数据量一大,比如几千台机器、几万个实例、几十个核,那需要翻的账本可能是几十万本。慢就慢在这里。

二、TSDB 是怎么存数据的,又是怎么读的

2.1 一个时间序列就是一本流水账

在 TSDB 里,每一个时间序列都有一个固定的标签组合。比如 CPU 这个指标,就会拆成无数条序列,因为每个实例、每个 CPU 核、每种模式,都是一条独立的线。采样数据会按时序不断追加,为了节省空间,TSDB 会把连续一段时间的样本打包成一个数据块,这个块就叫做 chunk。你可以把它想象成账本里的一页纸。一页纸上写满了按时间排好序的样本值。

2.2 查询时的“翻账本”过程

当你执行一条聚合查询时,TSDB 先找到所有匹配的序列,然后对每条序列,把查询时间范围内的相关 chunk 全部找出来。每一个 chunk 都是压缩过的,要先解压,然后按时间戳对齐,再逐条参与聚合计算。如果查询语句里还带了速率计算函数,那么还需要把速率计算窗口内所有样本都读进来,才能算出变化率。所以,即使你只需要结果中的一分钟,也可能读进来前后好几小时的原始样本。

这个“先读取、再计算”的模式,决定了聚合的耗时会随着样本总量的增加而线性增长。而样本总量又是随序列数量和存储时长快速膨胀的。于是,慢变成了常态。

三、聚合效率低下的真正原因

归纳一下,主要有四个原因。

第一,读的数据量实在太大。一个聚合可能涉及到几万个 chunk,每个 chunk 都要经过磁盘读取和内存复制。第二,重复解压。同一个 chunk 可能被不同查询反复读取,但 Prometheus 没有查询缓存,每次都是重新走一遍解压。第三,串行读取。很多版本里,单个查询对序列的处理还是逐个进行,没有充分利用电脑的多核。第四,磁盘访问不连续。chunk 在磁盘文件里并不是紧密排列的,读取时不均匀,特别是机械硬盘上,光找位置就要花不少时间。

这里要提一个关联技术:chunk 压缩用的是 Gorilla 算法,它对浮点数做了非常聪明的编码,能省不少空间,但解压也需要额外消耗 CPU。所以当你在磁盘和 CPU 两边都吃紧时,聚合查询的延迟自然就上来了。

四、优化方向一:减少要读的数据量

最直接的思路,就是让查询别去读那么多。

减少读取量可以从几个方面入手。第一,标签匹配精准点。能用精确匹配就别用正则,能匹配一个具体实例就别匹配一堆。第二,时间范围短一点。如果只是看最近几分钟的监控,不要查一个月的数据。第三,查询步长调大。如果不关心秒级抖动,把 step 调成 1 分钟甚至 5 分钟,内部可以跳过一些中间点。

下面用一条 bash 命令演示精准查询,只查某台机器的数据,避免全集群扫描。

# 查询 web-1 这台机器的 CPU 用户态使用率求和
# 这里用精确的 instance 匹配,避免扫描整个集群的所有实例
# 时间范围也缩短到 15 分钟,减少读取的 chunk 数量
curl -G 'http://localhost:9090/api/v1/query_range' \
  --data-urlencode 'query=sum(rate(node_cpu_seconds_total{mode="user", instance="web-1"}[5m]))' \
  --data-urlencode 'start='"$(date -d '15 min ago' +%s)" \
  --data-urlencode 'end='"$(date +%s)" \
  --data-urlencode 'step=60s'

这个命令里,我们用 instance 这个标签做了精确过滤。这样一来,TSDB 只需要找到 instance 等于 web-1 的那几条序列,读取量比全集群少了很多倍。

这里补充一个容易踩的坑:很多人喜欢用正则表达式做批量匹配,比如把实例名写成一个模式。正则匹配需要扫描所有可能匹配的序列名,一旦序列数量大,性能会急剧下降。能精确到具体实例,就不要用正则。

五、优化方向二:让 chunk 本身更“好读”

数据量有时候没法减少,那就在读取效率上做文章。首先是存储介质。如果还在用机械硬盘,换一块固态硬盘是最明显的提升。因为 chunk 的随机读取在固态硬盘上速度快很多。其次是 chunk 的大小。Prometheus 允许你通过启动参数设置每个 chunk 里的样本个数。样本数多一点,chunk 数量就少一点,顺序读更容易;但单个 chunk 解压时间也会变长。这个参数需要权衡。

下面用 bash 演示如何在启动服务时调整这个参数。

# 启动 Prometheus 服务,并调整每个 chunk 的最大样本数
# 默认值大约是 120,这里改成 240,让 chunk 稍微大一点
prometheus \
  --config.file=prometheus.yml \
  --storage.tsdb.path=/data/prometheus \
  --storage.tsdb.samples-per-chunk=240

# 注意:这个参数只影响新写入的数据,对旧数据不生效

还有一个技巧是调整 block 的时间窗口。TSDB 把数据按时间分成多个 block,默认两个小时一个。如果查询跨了很多 block,就要打开很多个文件。你可以通过参数把 block 时间拉长,比如设成 6 小时,减少文件数量。但这会带来内存占用和压缩频率的变化,需要测试再上线。

六、优化方向三:提前算好,别等查询

前面说的都是“被动优化”,真正主动的办法是使用录制规则。这个功能允许你预先定义一些聚合表达式,Prometheus 在后台定时跑这些表达式,把结果存成新的指标。以后查询直接查这个新指标,就像查普通指标一样,速度非常快。

下面是一个完整的录制规则示例,用 bash 写配置文件并检查。

# 创建一个录制规则文件,把常用的 CPU 汇总提前算好
cat <<'EOF' > /etc/prometheus/rules/cpu_rules.yml
groups:
  - name: cpu_sum.rules
    interval: 60s
    rules:
      # 先计算每个实例的用户态CPU使用率(5分钟平均)
      - record: node:cpu_user:rate5m
        expr: sum(rate(node_cpu_seconds_total{mode="user"}[5m])) by (instance)
      # 再对所有实例求和
      - record: node:sum_cpu_user:rate5m
        expr: sum(node:cpu_user:rate5m)
EOF

# 校验规则文件语法是否正确
promtool check rules /etc/prometheus/rules/cpu_rules.yml

# 让 Prometheus 热加载规则配置
curl -X POST http://localhost:9090/-/reload

这就是一个很典型的优化。原来查询要扫描所有机器、所有 CPU 核的所有 chunk,现在只需要读一个结果指标,也就是很少的几个 chunk。而且因为它是提前算好的,查询时不会占用实时查询的 CPU 资源,响应时间也稳定。

关联技术:除了录制规则,还可以用联邦集群。把不同区域的聚合结果拉到一个中心 Prometheus 里,避免中心直接访问所有原始数据。这适合多机房、多集群的场景,但会有点延迟,不适合对实时性要求极高的场景。

七、应用场景和优缺点

这种优化思路最适合下面这些场景:监控规模大,比如几千个节点;仪表盘上有很多重复的聚合查询;多人共用同一个 Prometheus,查询并发高;以及需要快速响应的告警,比如每 30 秒就要判断一次是否超过阈值。

优点也很明显。查询速度大幅度提升,用户看板不转圈,告警也能及时触发。副作用是,额外占用了磁盘和内存,因为你要多存一些预计算结果。再者,录制规则本身有执行间隔,所以结果的时间分辨率会被限制在间隔级别。如果你每 5 分钟算一次,那查询只能得到 5 分钟粒度的结果。要是你想看秒级突刺,预聚合帮不上忙。

另一个缺点是规则过多后维护成本高,需要经常清理不再使用的指标,否则反而会拖累 TSDB。

八、注意事项

这里给几条实际踩坑后的建议。第一,录制规则的执行频率不要太高,尤其是涉及大数据量时,比如每 10 秒执行一次全集群求和,那 Prometheus 自己就会先累垮。设置 60 秒比较稳妥。第二,查询时能不用正则就不用正则,正则匹配会让 TSDB 扫描更多的序列名。第三,避免使用过大的偏移量,因为它会让查询跳过正常的时间块,造成额外的扫描范围扩大。

还有,监控一下 TSDB 自身。Prometheus 自带一些内部指标,比如表示加载块数量的指标,可以看看加载了多少个 block。如果你想定位是哪些指标占用了大量 chunk,可以使用官方分析工具。下面演示一下。

# 分析Prometheus数据目录,找出chunk数量最多的指标
# 这会帮你知道该对哪个指标做优化
promtool tsdb analyze --open-truncate=2h /data/prometheus

# 运行后,会输出一个列表,包含每个指标的chunk数、样本数、磁盘占用等
# 你就可以针对排名靠前的指标,调整采集频率或做预聚合

这些细节看起来小,但在关键时刻很管用。

九、总结

聚合并不会天生就慢,慢的根源是 TSDB 的读取模式:它把数据按时间序列组织好,而聚合偏偏要跨序列归并。所以在查询时,大量 chunk 被翻出来、解压、再扔掉,大部分功夫都花在“找”和“读”上。优化方向也由此展开:要么减少读取量,比如精确匹配、缩小时间范围;要么提升读取效率,比如用固态硬盘、调整 chunk 大小;要么把计算提前,比如用录制规则把热门的聚合结果存下来。

没有一种方案是万能的。生产环境里,我建议你先用官方分析工具分析一下当前占资源最多的指标,再定位慢查询,然后针对热点应用录制规则,最后再调整存储参数。这套组合拳下来,聚合查询基本不会再拖后腿。