解析与执行 · PromQL 引擎怎么跑
从一行文本到一组结果,中间发生了什么?这一章读 promql/parser 的 yacc 语法与 AST、engine.go 的求值模型,以及那个保护你不被自己的查询打死的 MaxSamples 闸门。
解析与执行 · 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 ← 入口PromQL 用的是正经的编译器技术**:goyacc(Go 版的 yacc)+ 手写词法分析器。**
generated_parser.y—— 形式化的语法定义。PromQL 的语法规则(优先级、结合性、什么能接什么)全在这个文件里声明,由 goyacc 生成实际的解析器代码。lex.go是手写的 —— 词法分析器没用生成器。为什么?因为 PromQL 有些上下文相关的词法(比如{}里面的规则和外面不同,[5m]里的时长格式特殊),手写更好控制。
这告诉你 PromQL 是门“真正的语言”,不是靠正则拼凑的 DSL。它有明确的语法定义、优先级规则、AST——这也是为什么它的报错信息通常挺准(能指出具体位置,靠 posrange 包)。
对比第 1 部分那个 /metrics 文本格式也用了 golex 生成词法分析器——Prometheus 在“解析”这件事上一贯正规。性能敏感 + 语法明确的地方,就上正经的编译器工具链。
AST:查询变成一棵树
解析的产物是 AST(抽象语法树,parser/ast.go)。sum by (job) (rate(http_requests_total[5m])) 大致变成:
AST 的每种节点对应语言里的一个概念:
| 节点 | 是什么 |
|---|---|
VectorSelector | http_requests_total{...} —— 选 series |
MatrixSelector | xxx[5m] —— 加区间 |
Call | rate(...)、sum_over_time(...) —— 函数调用 |
AggregateExpr | sum by (job) (...) —— 聚合 |
BinaryExpr | a / b、a > 0.01 —— 二元运算 |
注意 VectorSelector 和 MatrixSelector 是两种不同的节点——这就是上一章那个“两种向量”在语法树层面的体现。类型检查在这里发生:rate() 的参数必须是 MatrixSelector(或返回矩阵的东西),否则解析期就报错。
这解释了为什么 rate(http_requests_total) 的报错来得那么早——不是跑起来才发现,而是语法树类型检查就拦下了。
求值:两种查询,两种跑法
引擎(promql/engine.go)执行 AST 时,分两种模式:
① 即时查询(instant query) —— “在时刻 T,这个表达式等于多少”。
GET /api/v1/query?query=up&time=2026-07-15T10:00:00Z求值一次,返回一个即时向量。告警规则用的就是这个(第 5 部分)。
② 区间查询(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 分钟的数据,而相邻的求值窗口大量重叠。
MaxSamples:防止你被自己的查询打死
EngineOpts.MaxSamples(engine.go:303)是一道保命闸门**:一个查询在求值过程中,同时加载进内存的采样点总数如果超过这个限制,查询直接被杀掉。**
为什么需要它?因为一个手滑的查询能打死整个 Prometheus:
# 灾难查询:没有任何标签筛选,时间范围巨大
rate({__name__=~".+"}[1h])这会尝试加载所有 series 的一小时数据——几十 GB 内存,Prometheus 直接 OOM,所有人的监控全瞎。
MaxSamples 把“一个人写错查询”的后果,从“全员监控挂掉”降级成“这个查询失败”。 这和第 1 部分的 sample_limit 是同一个哲学:不让单个失控的东西拖垮全局,而且宁可明确失败也不要含糊成功。
配套的还有 Timeout(engine.go:304)和 ErrQueryTimeout(engine.go:99)——查询跑太久也会被杀。
运维提示:如果用户抱怨“查询报 query processing would load too many samples”,这不是 bug,是护栏在工作。正确的回应不是调大 MaxSamples(那只是把炸弹变大),而是让他把查询写好——加标签筛选、缩小时间范围、或者用 recording rule 预聚合。
引擎跑在迭代器上
上一部分讲的 SeriesSet 火山模型,在这里兑现:引擎不会“先把数据全读进来再算”,而是边迭代边算**。**
AggregateExpr(sum) ← 从下面要数据,来一条加一条
↑
Call(rate) ← 从下面要一条 series 的一个窗口,算出一个速率
↑
MatrixSelector[5m] ← 从下面要数据,攒够一个窗口
↑
VectorSelector ← storage.Querier.Select(),返回 SeriesSet 迭代器
↑
TSDB(Head + blocks) ← 按需解压 chunk数据自底向上流,每一层只处理“当前这一条”。 这就是为什么 sum() 一亿个点不会 OOM——它只需要一个累加器,不需要把一亿个点都装进内存。
但注意:不是所有操作都能流式。 sort()、topk() 这类必须看到全部数据才能出结果的操作,会在内存里攒东西——这也是 MaxSamples 存在的另一个理由。
小结与下一章
一行 PromQL 的旅程:解析(promql/parser/:goyacc 的 generated_parser.y 语法 + 手写 lex.go 词法——PromQL 是门“真正的语言”,不是正则拼的 DSL;和 /metrics 用 golex 一样,Prometheus 在解析上一贯正规)→ AST(ast.go:VectorSelector/MatrixSelector/Call/AggregateExpr——两种向量在语法树层面就是两种节点,类型检查在此发生,所以 rate(x) 解析期就报错)→ 求值。求值分两种:即时查询(告警用)和区间查询(Grafana 画图用,本质是 N 次即时查询——查 24h/step=15s 就是 5760 次求值,这解释了为什么拉大时间范围会变慢)。MaxSamples(engine.go:303)是保命闸门:把“一个人写错查询”的后果从“全员监控挂掉”降级成“这个查询失败”——和 sample_limit 同一哲学;报这个错不是 bug 是护栏,正解是把查询写好或用 recording rule。引擎跑在火山模型的迭代器上(边迭代边算,sum() 一亿个点不 OOM;但 sort()/topk() 必须攒数据)。
引擎会跑了。但最常用的那个函数 rate(),藏着一个几乎所有人都不知道的行为。👉 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] 关联两种向量、火山模型、sample_limit。