查询性能与陷阱 · 别写垮 Prometheus
一个查询的成本由什么决定?这一章给出成本模型(选中的 series 数 × 每条的点数 × 求值次数)、几个高成本反模式(子查询、正则、大窗口),以及一套排查慢查询的实战工具与方法。
查询性能与陷阱 · 别写垮 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、基数。