查询性能与陷阱 · 别写垮 Prometheus

一个查询的成本由什么决定?这一章给出成本模型(选中的 series 数 × 每条的点数 × 求值次数)、几个高成本反模式(子查询、正则、大窗口),以及一套排查慢查询的实战工具与方法。

作者 David更新于 第 30 篇(共 52 篇)

查询性能与陷阱 · 别写垮 Prometheus

MaxSamples 会拦住最离谱的查询,但**“没被拦住”不等于“不慢”**。一个写得糟的看板能让 Prometheus 的 CPU 长期跑满,拖慢所有人。

这一章给一个成本模型和一套排查方法,收尾第 4 部分。

成本模型:三个乘数

一个查询的成本 ≈ 选中的 series 数 × 每条 series 要读的点数 × 求值次数。三个乘数,任何一个失控都会出事。

sum(rate(http_requests_total[5m]))   查 24 小时,step=15s

① 选中的 series 数    = 假设 50,000 条
② 每次求值读的点数    = [5m] 窗口 / 15s 抓取间隔 = 20 个点
③ 求值次数            = 24h / 15s = 5,760 次
─────────────────────────────────────────────
总读取 ≈ 50,000 × 20 × 5,760 = 57.6 亿个点   ← 会被 MaxSamples 拦下

看清这个模型,优化方向就清楚了——降低任何一个乘数:

乘数怎么降
① series 数加标签筛选、用 recording rule 预聚合、根治基数
② 每条的点数缩小窗口(但别小于 4 倍抓取间隔)、拉长抓取间隔
③ 求值次数调大 step、缩小时间范围(Grafana 会自动调 step)

① 通常是最大的那个乘数,也是最值得攻的。 一个 {job="api"} 的筛选可能就把 50,000 降到 500——100 倍的提升,一行的事。

反模式一:子查询

子查询([5m:1m] 这种语法)是最容易写出灾难的东西。

# 看起来无害
max_over_time( rate(http_requests_total[5m])[1h:1m] )

它的意思是:“在过去 1 小时里,每隔 1 分钟算一次 rate(...[5m]),然后取最大值”。

成本爆炸在哪? 内层的 rate(...[5m]) 会被执行 60 次(1h / 1m),而每次都要读 5 分钟的数据——相邻的执行之间窗口大量重叠,同样的数据被反复读。而这还只是一次外层求值;如果这是个区间查询(Grafana 画图),外层本身还要跑几千次!

单次外层求值成本 = 60 次内层 × 每次读 20 个点 × series 数
区间查询成本     = 上面的 × 求值次数(几千)

实战规则:

  • 能用 recording rule 就别用子查询 —— 把内层的 rate() 预先算好存成一条新 series,外层直接对它做 max_over_time。成本降几个数量级。
  • 子查询的 step 别太小 —— [1h:1s] 是自杀(3600 次内层求值)。
  • 子查询不写 step 时,默认用全局 evaluation_interval —— 容易踩坑,建议显式写。

子查询是“能力”不是“日常工具”——它适合探索性分析,不适合放进看板和告警。

反模式二:正则与高基数

第 3 部分讲过:等值匹配走倒排索引直接查表,正则要遍历该标签所有取值逐个测试**。**

# ❌ 慢:两头开放的正则,几乎必然全扫
{pod=~".*api.*"}

# ✅ 快:等值
{job="api"}

# ⚠️ 中间:锚定的前缀正则,Prometheus 能优化(提取字面量前缀)
{pod=~"api-.*"}

最糟的组合是“高基数标签 + 正则”:{pod=~".*api.*"} 在有 10 万个 pod 的集群里 = 10 万次正则匹配,每次查询都跑一遍。

实战:给指标加一个低基数的分类标签,而不是对高基数标签做正则。 比如与其 {pod=~"api-.*"},不如在埋点或 relabel 时就打上 {app="api"},然后用等值匹配。把“查询时的正则”变成“采集时的标签”——relabel 就是干这个的。

反模式三:无边界的选择器

# ❌ 灾难:没有任何筛选
{__name__=~".+"}
sum(rate({__name__=~"http_.*"}[5m]))

# ❌ 也很糟:只按一个高基数标签,没有 job 之类的收窄
{instance="10.1.2.3:8080"}

好的选择器应该从最能收窄的标签开始**。** 倒排索引求交集时,先用小的 postings 列表能快很多(虽然 Prometheus 会自己做一些优化,但给它好的输入总是对的)。

经验:每个选择器都该至少有一个 job= 或 __name__ 的等值约束。 裸的 {some_label="x"} 会跨所有指标去找,通常不是你想要的。

实战:怎么找到慢查询

① 开查询日志

Prometheus 有个 --query.log-file 参数(源码 promql/query_logger.go),记录每个查询的耗时。这是找慢查询最直接的手段。

② 看内置指标(Prometheus 监控自己)

# 查询耗时分布
histogram_quantile(0.99, rate(prometheus_engine_query_duration_seconds_bucket[5m]))

# 当前并发查询数(排队说明过载)
prometheus_engine_queries
prometheus_engine_queries_concurrent_max

# 被 MaxSamples 或超时杀掉的查询
rate(prometheus_engine_query_log_failures_total[5m])

③ 用 /api/v1/query 的 stats=all 参数

curl 'http://prom:9090/api/v1/query?query=up&stats=all'

返回里会带 totalQueryableSamples(这个查询读了多少个采样点)和各阶段耗时。这是量化“成本模型三个乘数”的最直接工具——你能看到到底读了多少点。

④ Grafana 面板逐个检查

慢查询往往藏在看板里。重点查:时间范围默认是不是太大、有没有子查询、选择器够不够窄。

优化的利器:recording rules

下一部分要讲的 recording rules 是查询性能的根本解法**:把昂贵的查询预先算好、存成一条新 series,看板和告警直接读那条。**

# 预先算好每个 service 的错误率,存成一条新 series
- record: service:http_errors:rate5m
  expr: |
    sum by (service) (rate(http_requests_total{status=~"5.."}[5m]))
    / sum by (service) (rate(http_requests_total[5m]))

之后看板只需要:

service:http_errors:rate5m     ← 直接读,几乎零成本

成本从“每次画图都从 5 万条 series 现算”降到“读几条预聚合的 series”——这就是聚合消维度的威力。代价是规则本身要周期性求值(但那是每个 evaluation_interval 算一次,而不是每个用户每次刷新面板都算一次)。

规律:一个查询如果被多个看板/告警重复使用,它就该是个 recording rule。

小结与下一部分

成本模型:查询成本 ≈ 选中的 series 数 × 每条要读的点数 × 求值次数——三个乘数任一失控都出事,而 ① 通常最大也最值得攻(一个 job= 筛选可能就是 100 倍提升)。三个反模式:① 子查询([1h:1m] 会让内层 rate 跑 60 次且窗口大量重叠,区间查询下再乘几千次——能用 recording rule 就别用子查询);② 正则与高基数(等值走索引查表、正则要遍历所有取值;最糟是“高基数标签 + 两头开放正则”)——把“查询时的正则”变成“采集时的标签”;③ 无边界选择器(每个选择器至少要有一个 job= 或 __name__ 等值约束)。找慢查询:--query.log-file、prometheus_engine_query_duration_seconds、/api/v1/query?stats=all 看 totalQueryableSamples。正解是 recording rules——把昂贵查询预算好存成新 series,成本从“每次刷新都现算”降到“读几条”;被多处复用的查询就该是 recording rule。

PromQL 讲完了。接下来是它最重要的用途之一,也是这个专栏最实战的部分之一:告警。👉 第 5 部分 · 告警工程。

参考资料

  • [1] Prometheus 源码 promql/query_logger.go(查询日志)、promql/engine.go(并发与超时控制)。
  • [2] Prometheus 官方文档:Query API(stats=all)、Recording rules、Subquery。
  • [3] 关联倒排索引与正则、MaxSamples、基数。

本页目录