PQL这个词,第一次听到的人十有八九会把它和SQL搞混,或者干脆以为是某个数据库厂商的私有方言。我在带新人的时候被问得最多的一个问题就是:“PQL到底是个啥,跟SQL有啥区别,我学它干嘛?”其实PQL全称是Prometheus Query Language,是Prometheus这套监控体系里用来查询时序数据的语言。它跟SQL最大的不同在于,SQL面向的是关系型表结构,而PQL面向的是时间序列——也就是带时间戳的一串数值。你写PQL的时候,脑子里想的不是“从哪张表里select什么字段”,而是“我要在哪个指标上、按什么维度、取哪个时间窗口的数据”。
这篇内容我打算写给两类人看:一类是刚接触监控体系、需要快速上手写查询的初学者;另一类是用了一段时间PQL但总觉得写出来的查询又慢又乱、想系统梳理一下写法的人。我会从最基础的查询结构讲起,把指标选择、标签过滤、时间窗口、聚合运算这几个核心环节拆开揉碎,再补上几个我实际踩过的坑和优化技巧。五分钟上手不是夸张,但五分钟之后你得知道往哪个方向继续练。
1. 先搞清楚PQL在查什么:时序数据的结构决定了写法
1.1 一条时间序列长什么样
要写好PQL,第一步不是背语法,而是理解它操作的对象。Prometheus里的每一条时间序列,由两部分构成:一个指标名称(metric name),加上一组标签(label)。指标名称描述“这是什么”,标签描述“这个数据的维度特征”。比如一个典型的指标长这样:
http_requests_total{method="POST", handler="/api/orders", status="200"}这条序列的含义是:HTTP请求总数,限定方法为POST、路径为/api/orders、状态码为200。注意这里的http_requests_total是一个累加型指标(counter),它的值只会增长不会减少。这一点非常关键,因为PQL里大量的查询都是围绕counter的增长率来做的,而不是直接读它的绝对值。
你可以把一条时间序列想象成一条心电图:横轴是时间,纵轴是数值,每个采样点间隔固定(默认15秒)。PQL做的事情,就是在这条曲线上做切片、做运算、做聚合。理解了这一点,你就能明白为什么PQL里到处是rate()、increase()、[5m]这类东西——它们都是在处理“一段时间内的变化”。
1.2 指标类型决定了你该用什么函数
Prometheus有四类指标:Counter(计数器)、Gauge(仪表盘)、Histogram(直方图)、Summary(摘要)。初学者最容易犯的错,就是拿处理Counter的方式去处理Gauge,或者反过来。我见过有人对一个表示当前内存占用的Gauge指标套rate(),结果查出来一堆莫名其妙的负数和小数。
记住一个简单的判断原则:只增不减的用Counter,可增可减的用Gauge。Counter适合统计请求数、错误数、处理字节数这类累计量,查询时几乎都要配rate()或increase();Gauge适合统计CPU使用率、内存占用、队列长度这类瞬时值,直接查或者配avg_over_time()就行。Histogram和Summary稍微复杂一些,它们会自动生成带_bucket、_sum、_count后缀的衍生序列,后面讲分位数的时候会细说。
提示:如果你不确定某个指标是什么类型,可以在查询框里直接输入指标名回车,看返回的数值是不是单调递增的。单调递增基本就是Counter。
2. 从零写第一条查询:选择器与标签匹配的实操细节
2.1 指标选择器的基本写法
最简单的PQL查询就是一个指标名:
node_cpu_seconds_total回车之后你会看到一堆序列,因为每台机器、每个CPU核心、每种模式都会生成一条。这时候就需要标签过滤来缩小范围。标签匹配支持四种运算符:
| 运算符 | 含义 | 示例 |
|---|---|---|
= | 精确等于 | mode="idle" |
!= | 不等于 | mode!="idle" |
=~ | 正则匹配 | mode=~"idle|user" |
!~ | 正则不匹配 | mode!~"idle|user" |
组合起来就是:
node_cpu_seconds_total{mode="idle", instance="10.0.0.1:9100"}这里有个初学者常忽略的点:标签匹配是精确到序列级别的。你写的每一个标签条件,都会参与序列的筛选,条件越多筛得越细。但反过来,条件写太多也可能一条都匹配不到,因为标签名或标签值拼错一个字符就全落空。我的习惯是先用宽条件查出来,看清楚实际有哪些标签和值,再逐步收紧。
2.2 正则匹配里的坑:锚定与转义
=~用的是RE2正则语法,默认是部分匹配,不是全匹配。也就是说mode=~"idle"会匹配到idle,但如果有个值是non-idle,它也会被匹配上,因为包含子串。要精确匹配必须加锚定:mode=~"^idle$"。这个坑我在早期项目中踩过,当时查CPU空闲率,结果把iowait相关的某些值也带进来了,数据对不上排查了半天。
另外正则里的特殊字符要转义,比如路径标签里带.或+,得写成\.和\+。还有一点,正则匹配的性能开销比精确匹配大,如果标签值是有限枚举,能用=就别用=~。在大规模集群上,一个写得随意的正则可能让查询慢上好几秒。
2.3 时间窗口:方括号里的数字不是随便填的
PQL里凡是涉及“一段时间”的运算,都要用方括号指定范围,比如[5m]表示最近5分钟。这个窗口的选择直接决定了查询结果的平滑程度和灵敏度。窗口太短,曲线抖动剧烈,看不出趋势;窗口太长,反应迟钝,故障已经恢复了曲线还没降下来。
我的经验是:窗口至少要是采集间隔的4倍。默认采集间隔15秒,那窗口最小填1m,实际用5m比较稳妥。对于变化缓慢的指标(比如磁盘使用率),可以用1h甚至更长;对于需要快速告警的指标(比如错误率突增),1m到5m之间比较合适。还有一点,rate()要求窗口内至少有两个采样点,如果窗口设得比采集间隔还短,会直接返回空值。
3. 让查询真正有用:rate、聚合与分组的三板斧
3.1 rate()为什么是Counter查询的灵魂
前面说过Counter只增不减,直接读它的值没有意义——你看到的是从进程启动到现在的累计量。真正有用的是“单位时间内的增长速率”,这就是rate()干的事。它的计算逻辑是:取窗口内第一个和最后一个采样点,算差值,再除以窗口秒数。
rate(http_requests_total{status="500"}[5m])这条查询返回的是“最近5分钟内,每秒产生多少个500错误”。注意单位是每秒,不是每分钟。如果你想要每分钟的数量,用increase(),它等价于rate()乘以窗口秒数。这两个函数经常被混用,记住:看速率用rate,看总量用increase。
还有一个细节,rate()会自动处理counter重置的情况(比如进程重启导致计数归零),它会把重置点之前和之后的数据分段计算再拼接。这个特性是PQL内置的,不需要你手动处理,但你要知道它存在,否则看到重启后的曲线会觉得奇怪。
3.2 聚合运算:sum、avg、max该怎么选
单条序列的查询往往不够用,实际场景里我们关心的是“所有实例加起来是多少”或者“平均值是多少”。PQL提供了一组聚合函数,最常用的几个:
sum():求和,适合统计总请求量、总错误数avg():求平均,适合看整体水位max()/min():取极值,适合找最忙或最闲的实例count():计数,适合统计实例数量
聚合的时候通常要配合by或without来指定分组维度。比如:
sum(rate(http_requests_total[5m])) by (handler)这条查询的意思是:先算每个序列的请求速率,再按handler标签分组求和。结果就是每个接口路径的总QPS。by后面跟的是你想保留的标签,其余标签在聚合时被丢弃。without则相反,指定要丢弃的标签,保留其余所有。
这里有个容易搞混的地方:by和without不能同时用,选一个就行。我的习惯是用by,因为显式列出保留维度更清晰,不容易漏掉关键标签。但如果你有大量动态标签(比如pod名),用without把高基数标签排除掉会更省事。
3.3 分组维度选错,结果全废
聚合的分组维度直接决定了你看到的数据形态。举个真实场景:你想看每个服务的错误率,如果按instance分组,你会得到几十上百条曲线,根本没法看;如果按service分组,就能得到每个服务一条线,一目了然。但如果你漏掉了service标签,只按status分组,那所有服务的错误就混在一起了。
选分组维度的原则是:按你决策的粒度来分。你要定位到哪个服务出问题,就按服务分;你要定位到哪台机器,就按实例分。维度太细信息过载,维度太粗定位不到根因。我一般会先按粗粒度看全局,发现异常后再下钻到细粒度。
4. 进阶写法:分位数、子查询与避免常见性能陷阱
4.1 histogram_quantile:算P99的正确姿势
延迟类指标通常用Histogram类型记录,它会自动生成_bucket序列,每个bucket是一个数值上限(le标签),记录的是小于等于该上限的请求数。要算P99延迟,用histogram_quantile():
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, handler))这里有几个关键点。第一,rate()要作用在_bucket上,不是_sum或_count。第二,sum聚合时必须保留le标签,否则分位数算不出来。第三,histogram_quantile的第二个参数是聚合后的bucket序列,不是原始序列。这三条任何一条写错,结果要么是NaN要么完全失真。
还有一个常见误区:直接把多个实例的P99再求平均,这是错的。分位数不能做算术平均,必须先聚合bucket再算分位数。我见过有团队用avg(histogram_quantile(...)),算出来的值比真实P99低一大截,导致告警阈值形同虚设。
4.2 子查询:当你要对“速率”再做一次运算
有时候你需要对一段时间内的rate结果再做聚合,比如“最近一小时内,每5分钟的QPS峰值是多少”。这时候就要用子查询:
max_over_time(rate(http_requests_total[5m])[1h:5m])方括号里的1h:5m表示:在1小时范围内,以5分钟为步长取值。子查询的语法是[range:step],range是总时间范围,step是采样步长。step不写的话默认用全局的evaluation interval。
子查询的性能开销比普通查询大,因为它要在内存里展开多个时间点的计算。能不用尽量不用,如果非用不可,把range和step控制得尽量小。另外子查询嵌套层级别太深,两层以上基本就该考虑换个思路了,比如用Recording Rule预计算。
4.3 高基数标签是查询变慢的头号元凶
PQL查询慢,十有八九是因为标签基数太高。什么叫高基数?就是一个标签有成千上万个不同的值。典型的高基数标签包括:pod_name、request_id、user_id、url(带参数的完整路径)。这些标签会让序列数量爆炸,查询时扫描的数据量成倍增长。
我的处理原则是:查询时尽量不碰高基数标签,或者在聚合阶段把它们丢掉。比如查接口延迟,不要按完整URL分组,按路由模板(如/api/orders/:id)分组就够了。如果指标本身就把高基数信息打进了标签,那在采集端就该做处理,而不是等到查询时再补救。
注意:
{__name__=~".+"}这种全量匹配的查询在生产环境基本等于自杀,它会扫描所有序列。除非你在做调试,否则永远不要这么写。
5. 我踩过的几个坑和对应的排查思路
5.1 查询返回空值:从标签拼写开始查
新手最常遇到的问题是“我明明写了查询,为什么结果是空的”。排查顺序我总结成三步:第一,去掉所有标签条件,只留指标名,看有没有数据;第二,逐个加回标签条件,看加到哪个的时候变空;第三,检查那个标签的值是不是拼错了,或者用了正则但没锚定导致匹配不上。
还有一种情况是指标名本身带后缀。比如Counter类型在暴露时可能自动加了_total后缀,你查http_requests查不到,得查http_requests_total。这个后缀规则不同exporter可能不一样,最稳妥的办法是在查询框里输入指标名前几个字符,让自动补全告诉你完整名称。
5.2 曲线出现断点:采集间隔与窗口不匹配
有时候曲线中间会断一截,或者rate算出来忽高忽低。这通常是因为时间窗口和采集间隔没对齐。如果采集间隔是30秒,你窗口设了1m,那窗口内可能只有两个点,rate的计算误差会很大;如果某个时刻采集失败丢了一个点,窗口内只剩一个点,rate直接返回空。
解决办法是把窗口放大到采集间隔的4到6倍。另外可以检查一下target的健康状态,看是不是有采集超时或失败。如果是跨集群查询,还要考虑时钟偏差的问题,不同节点的时钟不同步会导致时间戳对不齐,rate计算出现锯齿。
5.3 聚合后数值翻倍:重复序列没去重
这个坑比较隐蔽。当你用sum()聚合时,如果底层有重复的序列(比如两个target采集了同一个指标),结果会翻倍。常见于联邦部署或者relabel配置不当的场景。排查方法是先不加聚合,直接看原始序列,数一数同一个标签组合出现了几次。
还有一种情况是by里漏了某个区分维度,导致本该分开的序列被合并求和。比如两个机房的数据,你按service分组但没带region,两个机房的量就加在一起了。这种错误不会报错,只会让数字看起来“偏高”,很容易被忽略。
6. 把查询写得更稳:命名习惯与预计算
6.1 给查询起个能看懂的名字
PQL本身不支持给查询命名,但你在面板或告警规则里是要写标题的。我的习惯是标题里带上“指标+运算+维度”,比如“API P99延迟(按路由)”、“订单服务5xx速率(按实例)”。这样别人看面板不用点开查询详情就知道在算什么。
另外查询本身也建议加注释。虽然PQL的注释语法(#)在部分界面里支持有限,但在Recording Rule的配置文件里是可以写的。给每条规则写一行说明它算什么、为什么这么算,三个月后你自己回来看会感谢当时的自己。
6.2 Recording Rule:把重查询变成轻查询
如果一个查询被多个面板或告警反复使用,而且它本身比较重(涉及大窗口rate、多层聚合、子查询),那就该用Recording Rule把它预计算成一个新指标。配置大概长这样:
groups: - name: api_rules rules: - record: api:request_rate5m expr: sum(rate(http_requests_total[5m])) by (service, handler)预计算之后,面板直接查api:request_rate5m就行,速度快一个数量级。命名上我习惯用level:metric:operation的格式,冒号分隔,一眼能看出这是聚合后的衍生指标。Recording Rule的评估间隔要和原始采集间隔协调好,一般设成采集间隔的整数倍,避免出现空洞。
6.3 告警查询要留缓冲,别卡在临界值上
写告警用的PQL和写面板用的思路不太一样。面板可以实时反映瞬时值,告警要考虑抖动。我的做法是:告警条件里加一个for持续时间,比如“错误率持续5分钟超过1%才触发”,同时在查询里用稍长的窗口(比如rate(...[5m])而不是[1m]),让曲线平滑一些。
阈值也不要卡得太死。如果SLO是99.9%可用性,错误率阈值设1%看着合理,但实际会因为正常波动频繁触发。留10%到20%的缓冲,比如设1.2%,配合for持续时间,误报能少一大半。这些都是调出来的经验,没有标准答案,得根据自己系统的基线来定。
写PQL这件事,语法本身一两个小时就能过一遍,真正花时间的是理解你的指标体系和业务语义。我建议初学者的练习路径是:先对着现成的面板把每条查询拆开看懂,然后试着自己改写——换个聚合维度、调个时间窗口、加个过滤条件,看结果怎么变。改着改着,你对“这条查询在算什么”的直觉就建立起来了。等到你能看着一个业务问题,脑子里直接蹦出该用哪个指标、套哪个函数、按什么分组的时候,PQL就算真正上手了。