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

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

- 作者：David（道雾轩）
- 专栏：指标采集与观测（https://daiw.org/manual/observability.md）
- 最后更新：2026-10-01
- 原文：https://daiw.org/manual/observability/promql-performance
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

[MaxSamples 会拦住最离谱的查询](https://daiw.org/manual/observability/promql-engine)，但**“没被拦住”不等于“不慢”**。一个写得糟的看板能让 Prometheus 的 CPU 长期跑满，拖慢所有人。

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

## 成本模型：三个乘数

<Callout type="info">
  **一个查询的成本 ≈ **选中的 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 数** | [加标签筛选](https://daiw.org/manual/observability/index-format)、用 [recording rule 预聚合](https://daiw.org/manual/observability/recording-rules)、[根治基数](https://daiw.org/manual/observability/defining-metrics) |
  | **② 每条的点数** | 缩小窗口（但[别小于 4 倍抓取间隔](https://daiw.org/manual/observability/promql-rate)）、拉长抓取间隔 |
  | **③ 求值次数** | 调大 step、缩小时间范围（Grafana 会自动调 step） |

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

## 反模式一：子查询

<Callout type="warn">
  **子查询（`[5m:1m]` 这种语法）是**最容易写出灾难**的东西。**

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

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

  **成本爆炸在哪？** 内层的 `rate(...[5m])` 会被执行 **60 次**（1h / 1m），而**每次都要读 5 分钟的数据**——相邻的执行之间**窗口大量重叠**，同样的数据被反复读。而这还只是**一次**外层求值；如果这是个[区间查询](https://daiw.org/manual/observability/promql-engine)（Grafana 画图），外层本身还要跑几千次！

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

  **实战规则：**
  - **能用 recording rule 就别用子查询** —— [把内层的 `rate()` 预先算好存成一条新 series](https://daiw.org/manual/observability/recording-rules)，外层直接对它做 `max_over_time`。**成本降几个数量级。**
  - **子查询的 step 别太小** —— `[1h:1s]` 是自杀（3600 次内层求值）。
  - **子查询不写 step 时，默认用全局 evaluation_interval** —— 容易踩坑，**建议显式写**。

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

## 反模式二：正则与高基数

<Callout type="warn">
  **[第 3 部分讲过](https://daiw.org/manual/observability/index-format)：等值匹配走[倒排索引](https://daiw.org/manual/observability/index-format)直接查表，正则要**遍历该标签所有取值逐个测试**。**

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

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

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

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

  **实战：给指标加一个低基数的分类标签，而不是对高基数标签做正则。** 比如与其 `{pod=~"api-.*"}`，不如在[埋点或 relabel 时](https://daiw.org/manual/observability/relabeling)就打上 `{app="api"}`，然后用等值匹配。**把“查询时的正则”变成“采集时的标签”**——[relabel 就是干这个的](https://daiw.org/manual/observability/relabeling)。
</Callout>

## 反模式三：无边界的选择器

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

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

<Callout type="info">
  **好的选择器应该**从最能收窄的标签开始**。** [倒排索引求交集时，先用小的 postings 列表能快很多](https://daiw.org/manual/observability/index-format)（虽然 Prometheus 会自己做一些优化，但给它好的输入总是对的）。

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

## 实战：怎么找到慢查询

<Callout type="info">
  **① 开查询日志**

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

  **② 看内置指标（Prometheus 监控自己）**

  ```text
  # 查询耗时分布
  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` 参数**

  ```bash
  curl 'http://prom:9090/api/v1/query?query=up&stats=all'
  ```
  返回里会带 **`totalQueryableSamples`**（这个查询读了多少个采样点）和各阶段耗时。**这是量化“成本模型三个乘数”的最直接工具**——你能看到到底读了多少点。

  **④ Grafana 面板逐个检查**

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

## 优化的利器：recording rules

<Callout type="info">
  **[下一部分要讲的 recording rules](https://daiw.org/manual/observability/recording-rules) 是查询性能的**根本解法**：把昂贵的查询**预先算好、存成一条新 series**，看板和告警直接读那条。**

  ```yaml
  # 预先算好每个 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]))
  ```

  之后看板只需要：
  ```text
  service:http_errors:rate5m     ← 直接读,几乎零成本
  ```

  **成本从“每次画图都从 5 万条 series 现算”降到“读几条预聚合的 series”**——[这就是聚合消维度的威力](https://daiw.org/manual/observability/promql-aggregation)。代价是**规则本身要周期性求值**（但那是**每个 evaluation_interval 算一次**，而不是每个用户每次刷新面板都算一次）。

  **规律：一个查询如果被多个看板/告警重复使用，它就该是个 recording rule。**
</Callout>

## 小结与下一部分

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

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

## 参考资料

- [1] Prometheus 源码 `promql/query_logger.go`（查询日志）、`promql/engine.go`（并发与超时控制）。
- [2] Prometheus 官方文档：Query API（`stats=all`）、Recording rules、Subquery。
- [3] 关联[倒排索引与正则](https://daiw.org/manual/observability/index-format)、[MaxSamples](https://daiw.org/manual/observability/promql-engine)、[基数](https://daiw.org/manual/observability/defining-metrics)。
