Learn
Prometheus/06-promql-basics

PromQL 基础

PromQL(Prometheus Query Language)是 Prometheus 的查询语言。它和 SQL 完全不同——SQL 查的是「表里的行」,PromQL 查的是「时间轴上的序列」。

这一章只讲最基础的一层:怎么把想要的序列挑出来。运算符和函数留到后两章。听起来很简单,但恰恰是这一层的两个概念——即时向量 vs 区间向量——是 90% 新手困惑的根源。

1. PromQL 的四种数据类型

先建立全局认知。PromQL 的每个表达式都会求值成以下四种类型之一:

类型英文是什么例子
即时向量instant vector一组序列,每条序列在同一时刻有一个样本up
区间向量range vector一组序列,每条序列有一段时间窗口内的多个样本up[5m]
标量scalar一个单独的浮点数3.14
字符串string一个字符串(几乎用不到)"foo"
⚠️这是整个 PromQL 最重要的类型系统

每个函数都对输入类型有严格要求,弄错就报错:

rate(http_requests_total)        # ❌ rate 要区间向量,给了即时向量
sum(http_requests_total[5m])     # ❌ sum 要即时向量,给了区间向量

绝大多数「表达式解析错误」都源于此。养成习惯:写每个函数前先问自己「它吃什么类型?」

2. 即时向量:某一刻的快照

2.1 最简单的查询

在 Prometheus Web UI 的查询框里输入一个指标名,就是一个即时向量查询:

up

结果类似:

up{job="prometheus", instance="localhost:9090"}   1
up{job="node",       instance="10.0.1.5:9100"}    1
up{job="node",       instance="10.0.1.6:9100"}    0
up{job="api",        instance="10.0.2.11:8080"}   1

这就是即时向量:N 条序列,每条序列在「查询时刻」有一个值。

up 是 Prometheus 自动生成的指标——每次抓取一个目标,它就写一个 up 样本:抓取成功为 1,失败为 0。这是最常用的健康检查指标:

# 找出所有挂掉的目标
up == 0

2.2 「查询时刻」到底取哪个样本

这里有个细节值得讲清楚。假设你在 10:00:07 执行 up,但抓取间隔是 15 秒,样本落在 10:00:00 和 10:00:15。这时返回什么?

Prometheus 的规则是:向前回溯,取查询时刻之前最近的一个样本。

样本:      10:00:00 (1)          10:00:15 (1)
                │                     │
查询时刻:       │      10:00:07       │
                └──────────┘
                返回 10:00:00 的样本,但时间戳标记为 10:00:07

回溯有个上限,叫 lookback delta,默认 5 分钟:

如果查询时刻往前 5 分钟内都没有样本 → 这条序列不出现在结果里
ℹ️5 分钟回溯窗口的两个实际影响

影响一:目标下线后,图表还会「拖」5 分钟。 一个实例挂了,抓取失败,不再有新样本。但接下来 5 分钟内查询它仍会返回最后一个值(一条水平直线),5 分钟后才彻底消失。

(例外:如果是「序列在抓取结果中消失」而非「抓取失败」,Prometheus 会写入 staleness marker,线会立刻断掉。详见第 4 章。)

影响二:抓取间隔不能超过 5 分钟。 如果 scrape_interval: 10m,那么每次抓取后的第 5 分钟到第 10 分钟之间,查询会返回空——你的监控图会变成虚线。抓取间隔请保持在 1 分钟以内,除非你同时调大了 --query.lookback-delta。

3. 区间向量:一段时间的样本序列

3.1 语法与含义

在选择器后面加 [时长],就得到区间向量:

http_requests_total[5m]

它返回的是每条序列在最近 5 分钟内的所有样本。假设抓取间隔 15 秒,5 分钟就是约 20 个样本:

http_requests_total{job="api", instance="10.0.2.11:8080"} =>
    1203441 @1714540800
    1203987 @1714540815
    1204512 @1714540830
    ... (共约 20 个样本)

对比一下,即时向量只有一个值:

http_requests_total{job="api", instance="10.0.2.11:8080"}  1204512

3.2 时长单位

ms  毫秒
s   秒
m   分钟
h   小时
d   天    (= 24h,不考虑夏令时)
w   周    (= 7d)
y   年    (= 365d,不考虑闰年)

可以组合书写,但必须从大到小、不能重复:

http_requests_total[1h30m]    # ✅ 1 小时 30 分
http_requests_total[1d12h]    # ✅ 1 天 12 小时
http_requests_total[30m1h]    # ❌ 顺序错了
http_requests_total[1h1h]     # ❌ 单位重复

3.3 区间向量不能直接画图

这是新手最容易撞的墙:

http_requests_total[5m]

在 Web UI 的 Table 标签页能看到一堆样本,但切到 Graph 标签页会报错:

Error executing query: invalid expression type "range vector" for range query,
must be Scalar or instant Vector

为什么? 因为画图需要「每个时间点一个值」,而区间向量每个时间点是「一堆值」。你必须先用函数把它压缩成一个值:

rate(http_requests_total[5m])           # 区间 → 速率(一个值)
avg_over_time(node_load1[5m])           # 区间 → 平均值(一个值)
max_over_time(go_goroutines[1h])        # 区间 → 最大值(一个值)
count_over_time(up[1h])                 # 区间 → 样本个数
💡区间向量的唯一用途:喂给函数

记住这条规则就不会错:

区间向量永远不会单独出现在最终结果里,它一定被某个函数包裹着。

反过来说,看到 [5m] 就应该去找它外面的那个函数。找不到?那这个查询画不出图。

3.4 区间长度怎么选

这是个需要判断力的问题。核心约束:

区间长度必须至少能容纳 2 个样本,否则函数返回空。

scrape_interval = 15s
rate(x[15s])  → 区间内可能只有 1 个样本 → 返回空 ❌
rate(x[1m])   → 约 4 个样本            → 可以,但抖动大
rate(x[5m])   → 约 20 个样本           → ✅ 推荐

经验法则:区间长度 ≥ 抓取间隔 × 4。

抓取间隔建议最小区间常用值
15s1m[5m]
30s2m[5m]
1m4m[10m]

区间越长越平滑(抹平毛刺),越短越灵敏(能看到突刺)。告警一般用 [5m],看趋势大盘可以用 [30m] 甚至 [1h]。

⚠️Grafana 里更好的做法是用变量

面板里硬编码 [5m] 有个问题:当你把时间范围拉到「最近 30 天」时,Grafana 的步长会变成几十分钟,而 [5m] 的窗口小于步长,会跳过大量数据点,图表出现锯齿甚至空洞。

Grafana 提供了两个内置变量:

rate(http_requests_total[$__rate_interval])   ← 推荐,专为 rate 设计
rate(http_requests_total[$__interval])        ← 等于当前步长,可能太小

$__rate_interval 会自动取 max(4 × 抓取间隔, 步长 + 抓取间隔),保证任何时间范围下都有足够样本。在 Grafana 里写 rate,优先用它。

4. 选择器与匹配器

4.1 基本形式

一个完整的即时向量选择器长这样:

http_requests_total{job="api", status="200"}

拆开看:

http_requests_total  {  job="api"  ,  status="200"  }
       │                   │                │
    指标名             匹配器 1          匹配器 2

多个匹配器之间是 AND 关系——必须全部满足才会被选中。

4.2 四种匹配器

匹配器名称含义
=相等标签值精确等于给定字符串
!=不等标签值不等于给定字符串
=~正则匹配标签值匹配给定正则
!~正则不匹配标签值不匹配给定正则

逐个看例子:

# = 精确匹配
http_requests_total{status="200"}
 
# != 排除
http_requests_total{status!="200"}
 
# =~ 正则:所有 5xx
http_requests_total{status=~"5.."}
 
# !~ 正则排除:排除所有健康检查路径
http_requests_total{path!~"/health.*"}
 
# 多条件组合(AND)
http_requests_total{job="api", method="POST", status=~"5..", region!="test"}

4.3 正则匹配的三个坑

坑一:正则是「完全匹配」的。

Prometheus 使用 RE2 正则,并且会自动在两端加上 ^ 和 $:

# ❌ 匹配不到 500、503
http_requests_total{status=~"5"}
# 因为它等价于 status=~"^5$",要求整个值就是 "5"
 
# ✅ 正确写法
http_requests_total{status=~"5.."}      # 5 开头的三位数
http_requests_total{status=~"5.*"}      # 5 开头的任意长度

这是新手最常见的困惑,没有之一。

坑二:| 表示「或」,用来枚举多个值。

# 匹配 GET 或 POST
http_requests_total{method=~"GET|POST"}
 
# 匹配多个 job
up{job=~"api|web|worker"}
 
# 等价的排除写法
up{job!~"test|staging"}

坑三:空值匹配有特殊语义。

# 匹配「没有 env 标签」或「env 标签为空」的序列
http_requests_total{env=""}
 
# 匹配「有 env 标签且非空」的序列
http_requests_total{env!=""}

在 Prometheus 的模型里,「标签不存在」和「标签值为空字符串」是等价的。这个特性很有用——比如筛出那些还没打上 team 标签的服务:

count by (job) (up{team=""})
ℹ️正则的性能考虑

=~ 需要对所有候选序列逐个跑正则,比 = 慢得多。在大规模环境里:

# 🐢 慢:要扫描所有指标的所有序列
{__name__=~"node_.*"}
 
# 🚀 快:先用精确匹配缩小范围,再用正则
node_cpu_seconds_total{mode=~"user|system"}

原则:至少有一个精确匹配器(通常是指标名)来缩小候选集,再用正则做细筛。

另外,status=~"200|404|500" 这类纯枚举的正则,Prometheus 会自动优化成集合查找,性能接近精确匹配,不用担心。

4.4 指标名也是标签

第 4 章提过:指标名存储为特殊标签 __name__。所以这两个查询完全等价:

http_requests_total{status="200"}
 
{__name__="http_requests_total", status="200"}

这带来一个实用能力——对指标名做正则匹配:

# 所有 node_exporter 的 CPU 相关指标
{__name__=~"node_cpu.*"}
 
# 所有以 _total 结尾的指标(找出所有 Counter)
{__name__=~".*_total"}
⚠️选择器至少要有一个非空匹配器
{}                    # ❌ 报错:至少需要一个匹配器
{job!=""}             # ✅ 合法
{__name__=~".*"}      # ⚠️ 语法合法,但会尝试返回全部序列 —— 别在生产环境执行

Prometheus 要求选择器必须至少有一个能选中非空值的匹配器,防止你不小心把整个数据库拉出来。

即便如此,{__name__=~".+"} 这样的查询依然是合法的,在有几百万条序列的实例上执行会直接把 Prometheus 打挂。探索数据请用 /api/v1/label/__name__/values 接口或 Web UI 的指标下拉框,不要用通配查询。

5. offset:查询过去的数据

5.1 基本用法

offset 修饰符把查询的「时间基准」往前挪:

# 当前的请求总数
http_requests_total
 
# 1 小时前的请求总数
http_requests_total offset 1h
 
# 1 天前的 QPS
rate(http_requests_total[5m] offset 1d)

注意区间向量的写法:offset 放在 [5m] 后面:

rate(http_requests_total[5m] offset 1d)    # ✅
rate(http_requests_total offset 1d [5m])   # ❌ 语法错误

5.2 offset 的经典用途:同比 / 环比

offset 最大的价值是做对比。因为它返回的还是即时向量,可以直接和当前值做运算:

# 环比:现在的 QPS 相对 1 小时前的变化率
(
  sum(rate(http_requests_total[5m]))
  -
  sum(rate(http_requests_total[5m] offset 1h))
)
/
sum(rate(http_requests_total[5m] offset 1h))
# 同比:现在 vs 上周同一时刻(避开工作日/周末的差异)
sum(rate(http_requests_total[5m]))
/
sum(rate(http_requests_total[5m] offset 7d))

同比对比在告警里特别有用:

- alert: TrafficDropAnomaly
  # 当前流量不到上周同期的 50% → 可能是上游故障或 DNS 问题
  expr: |
    sum(rate(http_requests_total[10m]))
    /
    sum(rate(http_requests_total[10m] offset 7d))
    < 0.5
  for: 15m
  labels:
    severity: warning
  annotations:
    summary: "流量相比上周同期下跌超过 50%"

为什么用「上周同期」而不是「1 小时前」? 因为业务流量有明显的日周期和周周期。凌晨 3 点的流量本来就只有下午的 10%,用「1 小时前」做基线会在每天早高峰和晚低谷时疯狂误报。用 offset 7d 比较的是「上周同一个星期几的同一时刻」,基线最稳。

5.3 offset 的限制

限制一:offset 必须是正数常量。

http_requests_total offset 1h      # ✅
http_requests_total offset -1h     # ❌ 老版本报错

(Prometheus 2.26+ 开启 --enable-feature=promql-negative-offset 后支持负 offset,用于查询「未来」的数据——只在有远程写回填数据时才有意义。)

限制二:offset 只能修饰选择器,不能修饰表达式。

# ❌ 错误:offset 不能加在函数结果上
rate(http_requests_total[5m]) offset 1h
 
# ✅ 正确:offset 加在选择器上
rate(http_requests_total[5m] offset 1h)
 
# ❌ 错误
sum(http_requests_total) offset 1h
 
# ✅ 正确
sum(http_requests_total offset 1h)

限制三:offset 受数据保留期限制。

offset 30d 在一个 --storage.tsdb.retention.time=15d 的实例上永远返回空。做长周期同比要先确认保留期够长,或者接了 Thanos / VictoriaMetrics 这类长期存储。

5.4 @ 修饰符:锚定到绝对时间

Prometheus 2.25+ 引入了 @,可以把求值时刻锚定到一个绝对的 Unix 时间戳:

# 查询 2024-05-01 00:00:00 UTC 那一刻的值
http_requests_total @ 1714521600
 
# 配合 offset 使用(先 @ 后 offset)
http_requests_total @ 1714521600 offset 5m

两个特别有用的内置写法:

# 查询范围的起点 / 终点
http_requests_total @ start()
http_requests_total @ end()

一个非常实用的场景——在 Grafana 面板上算「相比时间范围起点增长了多少」:

sum(http_requests_total) - sum(http_requests_total @ start())
ℹ️offset 和 @ 的区别
基准典型用途
offset 1h相对当前求值时刻往前推同比环比,随图表时间轴滑动
@ 1714521600绝对时间点,固定不动定位某次事故时刻,做基线对照

在 Grafana 画图时,offset 的效果是「整条曲线整体左移」,@ 的效果是「一条水平直线」(因为每个点取的都是同一时刻的值)。

6. 在 Web UI 里试跑

理论讲完了,最快的学习方式是打开 Prometheus 自带的 Web UI 亲手试。

6.1 打开 Graph 页面

# 假设 Prometheus 跑在本机
open http://localhost:9090/graph

界面上有几个关键元素:

┌──────────────────────────────────────────────────┐
│ [ 查询输入框                          ] [Execute]│
│  ▸ Metrics explorer(指标下拉,可搜索)           │
├──────────────────────────────────────────────────┤
│  [ Table ]  [ Graph ]      ← 两个标签页          │
│  时间范围: [1h ▾]  分辨率: [auto]  Stacked □     │
└──────────────────────────────────────────────────┘
  • Table 标签页:执行的是即时查询(instant query),显示某一时刻的值。区间向量只能在这里看。
  • Graph 标签页:执行的是区间查询(range query),在时间轴上每隔一个步长求值一次,画成曲线。

6.2 循序渐进的试跑路径

# ① 最简单:看所有目标的健康状态
up
 
# ② 加匹配器:只看某个 job
up{job="node"}
 
# ③ 找异常:哪些目标挂了
up == 0
 
# ④ 数一数:一共有多少个目标
count(up)
 
# ⑤ 换个 Counter,先看原始值(Table 页)
prometheus_http_requests_total
 
# ⑥ 切到 Graph 页,你会看到一堆上升的斜线 —— 这就是「Counter 不套 rate」的样子
 
# ⑦ 套上 rate,再看 Graph —— 这才是有意义的图
rate(prometheus_http_requests_total[5m])
 
# ⑧ 加个 offset 做对比
rate(prometheus_http_requests_total[5m] offset 1h)
 
# ⑨ 试试区间向量直接查(Table 页可以,Graph 页会报错)
prometheus_http_requests_total[2m]

6.3 用 API 做同样的事

Web UI 背后就是 HTTP API,用 curl 也可以:

# 即时查询
curl -s 'http://localhost:9090/api/v1/query?query=up' | jq
 
# 带 URL 编码的复杂查询
curl -s --data-urlencode 'query=rate(prometheus_http_requests_total[5m])' \
  http://localhost:9090/api/v1/query | jq '.data.result[0]'
 
# 区间查询(画图用的那种)
curl -s 'http://localhost:9090/api/v1/query_range' \
  --data-urlencode 'query=up' \
  --data-urlencode 'start=2024-05-01T00:00:00Z' \
  --data-urlencode 'end=2024-05-01T01:00:00Z' \
  --data-urlencode 'step=60s' | jq
 
# 列出所有指标名(探索数据的正确姿势)
curl -s http://localhost:9090/api/v1/label/__name__/values | jq -r '.data[]' | head -30
 
# 查看某个指标有哪些标签值
curl -s 'http://localhost:9090/api/v1/label/job/values' | jq
💡三个提高效率的 Web UI 技巧

技巧一:Ctrl + Enter 执行查询,不用去点 Execute 按钮。

技巧二:Metrics explorer(输入框旁的图标) 可以模糊搜索所有指标名,比自己猜名字快得多。输入 cpu 就能看到所有含 cpu 的指标。

技巧三:出错时看 /targets 和 /tsdb-status。

  • /targets:所有抓取目标的状态和最后一次抓取的错误信息。查不到数据先看这里。
  • /tsdb-status:序列数 Top10 的指标和标签,排查基数问题的第一站。

🎯练习 1:判断类型与合法性

判断下列每个表达式的结果类型(即时向量 / 区间向量 / 标量 / 报错),如果报错说明原因:

1.  node_load1
2.  node_load1[10m]
3.  rate(node_load1[10m])
4.  rate(node_load1)
5.  sum(node_load1[10m])
6.  avg_over_time(node_load1[10m])
7.  up{job="api"} offset 30m
8.  rate(http_requests_total offset 1h [5m])
9.  http_requests_total[5m] offset 1h
10. 42
🎯练习 2:写出正确的选择器

根据描述写出对应的 PromQL 选择器:

  1. 选出 job 为 api 或 web 的所有 up 序列
  2. 选出所有 4xx 和 5xx 的 HTTP 请求(指标 http_requests_total,状态码在 status 标签)
  3. 选出 api job 中,实例 IP 以 10.0.2. 开头的请求指标
  4. 选出所有没有打 team 标签的目标
  5. 选出所有文件系统指标,但排除 tmpfs 和 overlay 类型(标签 fstype)
  6. 选出指标名以 go_gc 开头的所有指标
  7. 选出 api job 中,路径不是健康检查(/health、/healthz、/ready)的请求
🎯练习 3:用 offset 做同比分析

你负责一个电商网站的监控。需求如下:

  1. 写一个查询,显示当前 QPS 和 1 天前同一时刻的 QPS 的比值。
  2. 解释为什么用 offset 1d 比 offset 1h 更适合做流量异常检测。
  3. 写一条告警规则:当前订单创建速率(orders_created_total)不到上周同期的 60% 时告警。
  4. 上面的告警在什么情况下会误报?如何改进?
🎯练习 4:在 Web UI 中排查一个查不到数据的问题

你在 Grafana 里写了这个查询,但面板一片空白:

rate(myapp_http_requests_total{job="myapp", env="prod", status=~"5"}[30s])

已知:应用确实在运行,Prometheus 抓取间隔是 15s,应用确实有 5xx 错误。

请列出排查步骤,找出所有可能的问题并给出修正后的查询。

小结

  • PromQL 有四种类型:即时向量(每序列一个值)、区间向量(每序列一段样本)、标量、字符串。类型不匹配是最常见的报错来源。
  • 即时向量按「向前回溯最近一个样本」取值,回溯上限默认 5 分钟(lookback delta)——这解释了目标下线后图表为何还会拖 5 分钟。
  • 区间向量不能直接画图,它的唯一用途是喂给 rate / increase / *_over_time 这类函数。区间长度建议 ≥ 抓取间隔 × 4,Grafana 里优先用 $__rate_interval。
  • 四种匹配器 = / != / =~ / !~,多个匹配器之间是 AND 关系。正则是完全匹配的(自动加 ^$),status=~"5" 匹配不到 500。
  • 标签值为空 label="" 同时匹配「标签不存在」,可用于找出缺失元数据的目标。指标名等价于 __name__ 标签,可对它做正则。
  • offset 把求值时刻相对往前推,是同比环比的基础;@ 锚定到绝对时间点。两者都只能修饰选择器,且要写在 [区间] 之后。
  • 所有「比值型」告警都应配一个绝对量下限条件,防止低流量时段的除法误报。
  • 遇到查不到数据,去 Web UI 把查询拆到最简,再逐个条件加回去。