# 解析与执行 · PromQL 引擎怎么跑

> 从一行文本到一组结果，中间发生了什么？这一章读 promql/parser 的 yacc 语法与 AST、engine.go 的求值模型，以及那个保护你不被自己的查询打死的 MaxSamples 闸门。

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

# 解析与执行 · PromQL 引擎怎么跑

`rate(http_requests_total[5m])` 这行文本，怎么变成一组数字？这一章走完 **解析 → AST → 求值** 这条路。

## 解析：一份真正的 yacc 语法

看 `promql/parser/` 目录，有个显眼的文件：

```
promql/parser/
├── generated_parser.y       ← yacc 语法定义(手写)
├── generated_parser.y.go    ← goyacc 生成的解析器
├── lex.go                   ← 手写的词法分析器
├── ast.go                   ← 抽象语法树的节点定义
└── parse.go                 ← 入口
```

<Callout type="info">
  **PromQL 用的是**正经的编译器技术**：goyacc（Go 版的 yacc）+ 手写词法分析器。**

  - **`generated_parser.y`** —— **形式化的语法定义**。PromQL 的语法规则（优先级、结合性、什么能接什么）全在这个文件里声明，由 goyacc 生成实际的解析器代码。
  - **`lex.go` 是手写的** —— 词法分析器没用生成器。为什么？因为 PromQL 有些**上下文相关**的词法（比如 `{}` 里面的规则和外面不同，`[5m]` 里的时长格式特殊），手写更好控制。

  **这告诉你 PromQL 是门“真正的语言”**，不是靠正则拼凑的 DSL。它有明确的语法定义、优先级规则、AST——[这也是为什么它的报错信息通常挺准](https://daiw.org/manual/observability/promql-engine)（能指出具体位置，靠 `posrange` 包）。

  **对比[第 1 部分那个 `/metrics` 文本格式](https://daiw.org/manual/observability/exposition-format)也用了 golex 生成词法分析器**——Prometheus 在“解析”这件事上一贯正规。**性能敏感 + 语法明确的地方，就上正经的编译器工具链。**
</Callout>

## AST：查询变成一棵树

解析的产物是 **AST**（抽象语法树，`parser/ast.go`）。`sum by (job) (rate(http_requests_total[5m]))` 大致变成：

```mermaid
flowchart TD
    A["AggregateExpr<br/>op=sum, by=[job]"] --> B["Call<br/>func=rate"]
    B --> C["MatrixSelector<br/>range=5m"]
    C --> D["VectorSelector<br/>name=http_requests_total<br/>matchers=[...]"]
```

<Callout type="info">
  **AST 的每种节点对应语言里的一个概念：**

  | 节点 | 是什么 |
  | --- | --- |
  | `VectorSelector` | `http_requests_total{...}` —— [选 series](https://daiw.org/manual/observability/index-format) |
  | `MatrixSelector` | `xxx[5m]` —— [加区间](https://daiw.org/manual/observability/promql-model) |
  | `Call` | `rate(...)`、`sum_over_time(...)` —— 函数调用 |
  | `AggregateExpr` | `sum by (job) (...)` —— 聚合 |
  | `BinaryExpr` | `a / b`、`a > 0.01` —— 二元运算 |

  **注意 `VectorSelector` 和 `MatrixSelector` 是两种不同的节点**——这就是[上一章那个“两种向量”](https://daiw.org/manual/observability/promql-model)在语法树层面的体现。**类型检查在这里发生**：`rate()` 的参数**必须**是 `MatrixSelector`（或返回矩阵的东西），否则解析期就报错。

  **这解释了为什么 `rate(http_requests_total)` 的报错来得那么早**——不是跑起来才发现，而是**语法树类型检查**就拦下了。
</Callout>

## 求值：两种查询，两种跑法

引擎（`promql/engine.go`）执行 AST 时，分两种模式：

<Callout type="info">
  **① 即时查询（instant query）** —— “**在时刻 T，这个表达式等于多少**”。
  ```
  GET /api/v1/query?query=up&time=2026-07-15T10:00:00Z
  ```
  求值一次，返回一个[即时向量](https://daiw.org/manual/observability/promql-model)。**告警规则用的就是这个**（[第 5 部分](https://daiw.org/manual/observability/alerting-rules)）。

  **② 区间查询（range query）** —— “**从 T1 到 T2，每隔 step，这个表达式等于多少**”。
  ```
  GET /api/v1/query_range?query=up&start=...&end=...&step=15s
  ```
  **这就是 Grafana 画图用的**：它本质是**把即时查询在每个 step 上重复执行一遍**，把结果串成曲线。

  **关键洞见：区间查询 = N 次即时查询。** 如果你查 24 小时、step=15s，那就是 **5760 次**求值！这解释了：
  - **为什么 Grafana 上时间范围拉大会变慢** —— step 不变的话，求值次数线性增长。（Grafana 会自动调大 step 来缓解。）
  - **为什么 `[5m]` 的窗口大小会放大成本** —— 每次求值都要读那 5 分钟的数据，而相邻的求值**窗口大量重叠**。
</Callout>

## MaxSamples：防止你被自己的查询打死

<Callout type="warn">
  **`EngineOpts.MaxSamples`（`engine.go:303`）是一道**保命闸门**：一个查询在求值过程中，同时加载进内存的采样点总数如果超过这个限制，**查询直接被杀掉**。**

  为什么需要它？因为**一个手滑的查询能打死整个 Prometheus**:

  ```text
  # 灾难查询:没有任何标签筛选,时间范围巨大
  rate({__name__=~".+"}[1h])
  ```
  这会尝试加载**所有 series 的一小时数据**——几十 GB 内存，**Prometheus 直接 OOM，所有人的监控全瞎**。

  **`MaxSamples` 把“一个人写错查询”的后果，从“全员监控挂掉”降级成“这个查询失败”。** 这和[第 1 部分的 `sample_limit`](https://daiw.org/manual/observability/scrape-loop) 是同一个哲学：**不让单个失控的东西拖垮全局**，而且[宁可明确失败也不要含糊成功](https://daiw.org/manual/observability/scrape-loop)。

  配套的还有 **`Timeout`**（`engine.go:304`）和 **`ErrQueryTimeout`**（`engine.go:99`）——**查询跑太久也会被杀**。

  **运维提示**：如果用户抱怨“查询报 `query processing would load too many samples`”，**这不是 bug，是护栏在工作**。正确的回应不是调大 `MaxSamples`（那只是把炸弹变大），而是**让他把查询写好**——加标签筛选、缩小时间范围、或者用 [recording rule 预聚合](https://daiw.org/manual/observability/recording-rules)。
</Callout>

## 引擎跑在迭代器上

<Callout type="info">
  **[上一部分讲的 `SeriesSet` 火山模型](https://daiw.org/manual/observability/query-path)，在这里兑现：引擎不会“先把数据全读进来再算”，而是**边迭代边算**。**

  ```
  AggregateExpr(sum)          ← 从下面要数据,来一条加一条
      ↑
  Call(rate)                  ← 从下面要一条 series 的一个窗口,算出一个速率
      ↑
  MatrixSelector[5m]          ← 从下面要数据,攒够一个窗口
      ↑
  VectorSelector              ← storage.Querier.Select(),返回 SeriesSet 迭代器
      ↑
  TSDB(Head + blocks)         ← 按需解压 chunk
  ```

  **数据自底向上流，每一层只处理“当前这一条”。** 这就是为什么 [`sum()` 一亿个点不会 OOM](https://daiw.org/manual/observability/query-path)——它只需要一个累加器，不需要把一亿个点都装进内存。

  **但注意：不是所有操作都能流式。** `sort()`、`topk()` 这类**必须看到全部数据**才能出结果的操作，会在内存里攒东西——这也是 [`MaxSamples` 存在的另一个理由](https://daiw.org/manual/observability/promql-engine)。
</Callout>

## 小结与下一章

一行 PromQL 的旅程：**解析**（`promql/parser/`：**goyacc 的 `generated_parser.y` 语法 + 手写 `lex.go` 词法**——[PromQL 是门“真正的语言”，不是正则拼的 DSL](https://daiw.org/manual/observability/promql-engine)；和 [`/metrics` 用 golex 一样，Prometheus 在解析上一贯正规](https://daiw.org/manual/observability/exposition-format)）→ **AST**（`ast.go`：`VectorSelector`/`MatrixSelector`/`Call`/`AggregateExpr`——[两种向量在语法树层面就是两种节点，类型检查在此发生，所以 `rate(x)` 解析期就报错](https://daiw.org/manual/observability/promql-model)）→ **求值**。求值分两种：**即时查询**（[告警用](https://daiw.org/manual/observability/alerting-rules)）和**区间查询**（[Grafana 画图用，本质是 N 次即时查询](https://daiw.org/manual/observability/promql-engine)——**查 24h/step=15s 就是 5760 次求值**，这解释了为什么拉大时间范围会变慢）。**`MaxSamples`**（`engine.go:303`）是**保命闸门**：[把“一个人写错查询”的后果从“全员监控挂掉”降级成“这个查询失败”](https://daiw.org/manual/observability/scrape-loop)——和 `sample_limit` 同一哲学；报这个错[不是 bug 是护栏，正解是把查询写好或用 recording rule](https://daiw.org/manual/observability/recording-rules)。引擎跑在[火山模型的迭代器](https://daiw.org/manual/observability/query-path)上（边迭代边算，`sum()` 一亿个点不 OOM；但 `sort()`/`topk()` 必须攒数据）。

引擎会跑了。但最常用的那个函数 `rate()`，藏着一个几乎所有人都不知道的行为。👉 [rate 的真相](https://daiw.org/manual/observability/promql-rate)。

## 参考资料

- [1] Prometheus 源码 `promql/parser/generated_parser.y`（yacc 语法）、`lex.go`、`ast.go`、`parse.go`。
- [2] Prometheus 源码 `promql/engine.go`（`EngineOpts:300`、`MaxSamples:303`、`Timeout:304`、`ErrQueryTimeout:99`）。
- [3] 关联[两种向量](https://daiw.org/manual/observability/promql-model)、[火山模型](https://daiw.org/manual/observability/query-path)、[sample_limit](https://daiw.org/manual/observability/scrape-loop)。
