背景
Prometheus是目前在时序数据监控领域中广泛使用的开源项目,也是CNCF(云原生计算基金会)中非常受欢迎的项目之一。它能够高效地收集和存储指标(Metrics),并提供了一种灵活的查询语言——PromQL,用于计算和分析这些时序数据。然而,由于PromQL在语法设计上与传统SQL有着显著的差异,开发人员常常对其计算结果感到困惑。本文将通过图文并茂的方式详细介绍PromQL的计算原理,并结合具体的查询语句逐步拆解其计算步骤。
PromQL vs SQL
在介绍前,我们需要达成一个共识:PromQL与SQL不同,它是一种非精确模式的QL,该语法设计中有“回溯补点、边界外推、窗口计算”等特性,下表简要介绍了二者的主要差异点。
对比维度 |
PromQL |
SQL |
数据模型 |
基于时间序列(Time Series):指标名 + 标签(Labels) + 时间戳 + 值。 |
基于表格(Table):行(Row)+ 列(Column)。 |
索引结构 |
依赖标签(Labels)的高效索引,无需手动管理。 |
依赖数据库索引,需手动优化索引策略。 |
基本语法结构 |
无SELECT语句,直接操作指标(如http_requests_total{job="api"})。 |
以SELECT ... FROM ... WHERE ...为核心结构。 |
时间/过滤条件 |
读取符合时间范围、过滤条件的所有数据点。 |
读取符合时间范围、过滤条件的所有数据点。 |
预选点 |
执行计算前需基于lookback、窗口参数等预先选出待计算的数据点。 |
无。 |
聚合/分组 |
支持多种聚合算子并配合 by 或 without 运算符做分组,例如 sum by (job)。 |
聚合函数(如SUM()、AVG())并配合GROUP BY实现分组效果。 |
JOIN/关联操作 |
通过 on/ignoring 和 group_left/ group_right 等运算符实现标签匹配(类似多对一关联)。 |
通过JOIN操作符(如INNER JOIN、LEFT JOIN)显式关联两张表的数据。 |
子查询 |
支持子查询,例如 avg_over_time(rate(http_requests[5m])[1h:1m]) |
支持子查询,例如 SELECT * FROM (SELECT ... FROM ... WHERE ...) WHERE ... |
结果类型 |
返回 Vector 或者 Matrix。 |
返回表格数据(行和列)。 |
图文解析PromQL原理
PromQL中所有的计算行为都是围绕“时间线”这个概念进行,此处先详细了解下何为“时间线”?时序场景下的观测对象是 “Metric”,例如指标process_resident_memory_bytes表示“进程常驻内存使用量”。在Prometheus的数据模型中,使用一组Key-Value来表示一条独立的时间线,该时间线由“指标(即Metric)、标签列表(即Labels)”组成:
- Metric:指标对应的
Key固定为"__name__",Value为对应的"MetricName",表示指标名。 - Labels:标签列表是由多个键值对组成的附属维度属性,例如,
job="demo"、instance="demo.promlabs.com:10000"代表了“进程名为demo,机器实例IP为demo.promlabs.com:10000”。
除Metric、Labels外,单个监控数据点还由时间戳、数值构成,分别表示该监控点的采集时刻、数值。以下图为例(其PromQL语句为 process_resident_memory_bytes{}/1024/1024 ),图中存在三条时间线,分别为:
__name__="process_resident_memory_bytes", instance="demo.promlabs.com:10000", job="demo"__name__="process_resident_memory_bytes", instance="demo.promlabs.com:10001", job="demo"__name__="process_resident_memory_bytes", instance="demo.promlabs.com:10002", job="demo"
每条时间线中的监控数值随时间增长而随之变化,能很好的表示一个监控对象在时间维度上的变化情况。
前面的示例中仅对指标process_resident_memory_bytes做了简单的查询操作,还未涉及到PromQL中的多种AGG算子(avg/sum/count/max/min等)、窗口函数(rate/increase/avg_over_time/sum_over_time等)、非窗口函数(abs/clamp/round/sort/label_replace等)、VectorMatching(group_left/group_right)、二元表达式、subquery等特性,其完整支持的语法特性参见Prometheus Querying。
Prometheus所支持的PromQL查询接口中,主要以下四项入参:
- start/end:查询的时间区间
- query:PromQL查询语句
- step:步长,此参数表示每轮计算的执行间隔
特有的step概念
前文提到“PromQL是一种非精确的查询QL”,这很大程度上与PromQL计算引擎中的参数step有关,步长step参数表示在指定的时间范围 [start, end] 中每轮计算的执行间隔。下图给出了step参数的工作原理,其中start参数为 "10:00:00"、end参数为"10:11:30"、step参数为"120s",其计算流程可总结为:从start时间点至end时间点,每间隔step即执行一轮计算。以前述参数为例,第1轮计算的时间点为"10:00:00",第6轮计算的时间点为"10:10:00",在预期中第7轮时间点会超出给定的end时间点,因此共计仅会执行6轮计算。
此处强调一点,在Prometheus计算引擎中,所有类型PromQL语句的计算操作(涵盖所有AGG算子、函数、二元等)都遵循“按step间隔执行多轮计算”这个大前提。从计算流程来看,step参数通常可视作一种“降查询精度”的特殊设计,尤其是对长时间段的查询,其step参数通常高达数小时(例如 1h/1d),此种场景下,其计算过程通常会跳过较多时间区间的数据,致使计算结果仅能观察出指标的大致趋势。
特殊的选点设计
在常规的SQL计算中,所有符合输入时间段[start, end]以及 WHERE 条件的数据点都会纳入到计算流程中;而PromQL的计算与其有非常大的区别,在间隔"step"的每轮次计算中,纳入到真正计算的数值点都需要执行一种特殊的“选点”逻辑,主要有以下两种“选点”方式:基于lookback-delta参数的前溯选点、基于窗口函数入参[xx]的范围选点。
lookback回溯选点
时序的采集侧通常是按固定间隔做采集操作并上报监控数据,而PromQL查询的入参通常是任意执行的,大部分场景下不会与上报的时间点刚好重合。PromQL计算流程是从入参的startTime开始至endTime结束,如果每轮次对应的时间点T不存在原始数据点的话,则会往前回溯找一个最新的数据点作为的T时刻数据,其中T2时刻未选取到有效数据点。
下图给出了一个针对单条时间线执行“选点”的全流程示例,仍以参数 start "10:00:00"、end "10:11:30"、step "120s" 为例,假设原始监控数据的上报间隔是"30s"上报一次。整个流程中会执行6轮次的选点操作,最终被选取出来的时刻点分别是: "09:59:40"、"10:01:40"、"10:03:40"、"10:05:40"、"10:07:40"、"10:09:40" 这6个时刻,即这个6个时刻的数据点会被PromQL-Engine视作"10:00:00"、"10:02:00"、"10:04:00"、"10:06:00"、"10:08:00"、"10:10:00"这6个时刻的数据点并进入到后续的计算流程中。
上述示例中往前回溯选点的最大时间区间由参数lookback-delta控制,该参数在Prometheus中默认为5分钟, 在SLS时序库中默认为3分钟。例如,在"10:00:00"时刻点往前最大回溯3分钟,如果在"09:57:00" ~ "10:00:00"区间内都未找到数据点则表示 "10:00:00"时刻无数据进入后续阶段的计算。此种选点模式对应了Prometheus中的 InstantVectorSelector,在Prometheus源码中定义为VectorSelector。
窗口函数选点
顾名思义,“窗口函数选点”表示此种“选点”逻辑仅存在于窗口函数的计算流程中。同样以前述的参数 start "10:00:00"、end "10:11:30"、step "120s"为例,此外,假设窗口函数的窗口参数为[5m],此参数表示5分钟。
如上图所示,每轮次的选点操作中都会往前取5分钟窗口内的所有数据点,例如第1轮的选点中,执行时刻为"10:00:00",此轮次中会将 "09:55:00" ~ "10:00:00" 窗口内的10个数据点("09:55:10"、"09:55:40"、"09:56:10"、"09:56:40"、"09:57:10"、"09:57:40"、"09:58:10"、"09:58:40"、"09:59:10"、"09:59:40")都纳入到后续的计算操作中。此种选点模式对应了Prometheus中的 RangeVectorSelector,在Prometheus源码中定义为MatrixSelector。
计算引擎
在介绍各类具体的PromQL计算操作前,再强调一遍前面提到的大前提:所有类型PromQL语句的计算操作(涵盖所有AGG算子、函数、二元等)都遵循“按step间隔执行多轮计算”。本节将以图文形式介绍PromQL语法中几类常用的计算操作。
过滤型Query
此种基础的Query语句不涉及任何的算子、函数、运算符,查询语句中支持对任意label增加过滤条件,仅支持=、!=、=~、!~条件匹配符。过滤条件通常会被下推到存储层中,即进入到计算过程中的数据已经执行过各过滤条件。
后续针对指标数据的运算非常简单,直接“基于lookback-delta机制选点”即可。此处给出几个Query示例供参考。
http_requests_total{replica="rep-a"} http_requests_total{replica!~".*a"} http_requests_total{environment=~"staging|testing|development",method!="GET"}
如果不希望指定某个指标,并将指标名也纳入到模糊匹配的条件中,可以参考下述的示例。
- 注意:指标名模糊匹配的执行效率很低,一般不建议生产使用
{__name__="http_requests_total", replica="rep-a"} {__name__=~"http.*", replica="rep-a"} {__name__=~".+", instance=~"127.*"}
AGG聚合算子类
- 注意:所有AGG聚合操作的计算流程都可以总结为“先基于lookback-delta机制选点,再执行跨时间线的AGG聚合计算”。
目前PromQL已支持的AGG聚合算子有:sum、avg、min、max、bottomk、topk、group、count、count_values、stddev、stdvar、quantile、limitk、limit_ratio。本节以max和count两个算子与以下三条时间线为例来介绍AGG算子的计算过程。
timeseries 1: __name__="request_total_count", instance="127.0.0.1:10000", job="prometheus"
timeseries 2: __name__="request_total_count", instance="127.0.0.1:10001", job="vm-agent"
timeseries 3: __name__="request_total_count", instance="127.0.0.1:10002", job="vm-agent"
查询入参设定为:
start: 10:00:00
end : 10:11:30
step: 120s
max算子
此处以查询语句 query: max ( request_total_count ) by ( job ) 为例,介绍max算子的计算流程。如下图所示,每一轮的计算都分成“选点”和“聚合计算”两阶段的操作,其中“选点”遵循前述的“lookback回溯选点”机制,之后再按 by 的分类做max的取值操作即可,最终的计算结果中会有两条时间线,即对应job标签的两种分类值。
count算子
查询语句修改为query: count ( request_total_count ) by ( job ) ,count算子用于统计每种分类下数值点的个数,详细计算流程见下图。
阶段一中的三条时间线按照job分类仅有 job="prometheus" 和 job="vm-agent" 两种分组,即对应了阶段2中的group-1和group-2,之后按照分组场景执行聚合计算即可。
Function函数类
PromQL中的函数可分成“窗口函数”和“非窗口函数”两类,其支持的详细函数列表请参见Prometheus Query functions,其中入参类型为(v instant-vector)的函数为“非窗口函数”、入参类型为(v range-vector)的函数为“窗口函数”。请注意,“Function函数”与“AGG算子”计算中的最大差异在:“AGG算子”是跨时间线对数值执行聚合运算,而“Function函数”仅聚焦在单时间线上对“数值/标签”做运算。
本节仍使用下述三条时间线为例来介绍多种Function函数的计算过程。
timeseries 1: __name__="request_total_count", instance="127.0.0.1:10000", job="prometheus"
timeseries 2: __name__="request_total_count", instance="127.0.0.1:10001", job="vm-agent"
timeseries 3: __name__="request_total_count", instance="127.0.0.1:10002", job="vm-agent"
查询入参设定为:
start: 10:00:00
end : 10:11:30
step: 120s
非窗口函数
非窗口函数的计算流程与“AGG聚合算子”的计算比较类似,在“选点”阶段都遵循“lookback回溯选点”机制,整体计算流程可以总结为“先基于lookback-delta机制选点,再针对该数据点执行相应的函数运算”。此处用函数log2为例来展示非窗口函数的计算原理,该函数用于计算以 2 为底的对数,对应的PromQL查询语句为query: log2 ( request_total_count )。
除了对数值做计算的函数外,PromQL中也有对Labels做运算的函数,例如label_join、label_replace,此类函数在计算过程中仍然会先基于“lookback-delta”机制执行选点处理,之后则是对时间线的Label信息执行拼接或替换操作。
label_replace(prometheus_build_info{}, "branch", "$1", "version", "(.)@.")
上述的Query表示对每条时间线中Key为"version"的Label执行正则提取,提取出"@"符号前的字符串数据作为一个Key为"branch"的新Label并加入到结果时间线的Labels列表中。
窗口函数
“窗口函数”与“非窗口函数”的区别仅在“选点”逻辑不同,在每轮的选点操作中,窗口函数都会将一个时间窗口内的所有点全部纳入到后续的运算流程中,整体计算流程可以总结为“先基于时间窗口选出n个数据点,再对窗口内的所有数据点执行相应的函数运算”。“时间窗口”的大小取决于函数的range入参大小,参数格式参考duration。
此处以函数max_over_time为例来介绍窗口函数的计算流程,对应的PromQL查询语句为query: max_over_time ( request_total_count[5m] ),其中5m表示时间窗口为5分钟。
上图中以timeseries 1为例展示了函数max_over_time的计算流程,在每轮次的计算中,首先会选取最近5分钟内的所有数据点,再对所有数据点执行一次取max的运算即可。之后再对其它时间线上的数据重复执行上述操作即可完成PromQL的完整运算。
PromQL支持的部分窗口函数的计算行为较特殊,若使用不当/理解不对,往往会出现一些非预期的计算结果,比如 delta、rate、increase三个函数。
- delta函数
delta函数用于计算时间窗口中首尾两个数据点的差值,此函数要求时间窗口内至少有两个点参与计算,否则直接返回空数据。此函数有一项较为特殊的设计,即会基于窗口内的数据点与“时间窗口”参数做“边界外推”处理,进而可能导致某个全为整数值的指标(例如,request_total_count)在使用该函数做计算后出现带小数点的数值结果。
- rate / increase 函数
此两项函数除了计算时间窗口中首尾两个数据点的差值之外,还会遍历余下的数值点,若出现数值下降时则会将前一个数值累加到最终结果值上,这可能导致最终结果值变得异常巨大,详细代码参见源码。另外,rate函数相较于increase函数会多执行一次时间窗口内的变化率。
二元表达式
PromQL中支持三种模式的二元表达式计算,分别是 “Scalar Scalar”、“Vector Scalar”、“Vector Vector”。
- Scalar Scalar
第一类的二元表达式很容易理解,即直接对两个Scalar数值做二元运算,例如下面的Query示例:
1024 * 1024 9 / 3 3 ^ 2 3 == 1
- Vector Scalar
此种模式的二元表达式计算行为与“非窗口函数”的计算非常类似,可总结为“先基于lookback-delta机制选点,再对该数据点执行对应的二元运算”。图文解析可参考 [非窗口函数]一节,这里给出几项符合此模式的示例Query:
request_total_count_min / 60 process_resident_memory_bytes / 1024 / 1024 query_latency_seconds * 1000
- Vector Vector
此类模式的二元表达式是对两个指标向量执行计算,整体计算流程可总结为“先基于lookback-delta机制对左右两边的表达式执行选点,然后对左右两边完全匹配的时间线执行数值计算,不匹配的时间线则跳过计算”。
指标:request_total_latency_ms
timeseries 1: __name__="request_total_latency_ms", instance="127.0.0.1:10000", job="prometheus"
timeseries 2: __name__="request_total_latency_ms", instance="127.0.0.1:10007", job="vm-agent"
timeseries 3: __name__="request_total_latency_ms", instance="127.0.0.1:10002", job="vm-agent"
指标:request_total_count
timeseries 4: __name__="request_total_count", instance="127.0.0.1:10000", job="prometheus"
timeseries 5: __name__="request_total_count", instance="127.0.0.1:10001", job="vm-agent"
timeseries 6: __name__="request_total_count", instance="127.0.0.1:10002", job="vm-agent"
查询入参设定为:
start: 10:00:00
end : 10:11:30
step: 120s
以上面的2项指标数据、6条时间线数据为例来介绍此类二元表达式的计算流程,PromQL语句为 query: request_total_latency_ms / request_total_count
此Query的真实含义可解释为计算某个时间下所有请求的平均耗时。首先对左右两边的表达式基于“lookback-delta”机制执行选点,此阶段的操作与 [lookback回溯选点] 的行为完全一致,然后针对Labels信息完全匹配的两侧时间线数据做数据运算,下图以timeseries 1 和 timeseries 4为例介绍完整计算流程。
上述例子中只有两组时间线的Labels是完全匹配的,分别是timeseries 1 与 timeseries 4以及 timeseries 3 与 timeseries 6,而timeseries 3和timeseries 5不存在匹配时间线会跳过后续计算操作,因此最终的结果集中只会存在2条时间线。
VectorMatching操作
Vector matching是PromQL语法的核心特性之一,其语法结构与二元表达式类似,但它允许在具有不同Labels的两个或多个数据向量之间进行二元运算。在基础的二元表达式基础上,VectorMatching扩展支持了on和ignore运算符以支持两侧Labels不匹配时的二元运算,其中on表示仅匹配某些Label、ignore则表示忽略某些Label;但同时二元表达式两侧Labels也可能会出现“一对多”、“多对一”甚至“多对多”的情况,所以又引入了group_left与group_right两项运算符来应对此场景。
从左右两侧Labels的匹配结果来看,主要分为One-to-One、One-to-Many、Many-to-One三种场景(Many-to-Many不支持)。本节以下述6条时间线为例来解释此三种场景的计算流程。
指标:request_total_latency_ms
timeseries-1: __name__="request_total_latency_ms", instance="127.0.0.1:10000", job="prometheus", code="200" 90
timeseries-2: __name__="request_total_latency_ms", instance="127.0.0.1:10002", job="vm-agent", code="200" 20
timeseries-3: __name__="request_total_latency_ms", instance="127.0.0.1:10007", job="vm-agent", code="200" 60
指标:request_total_count
timeseries-4: __name__="request_total_count", instance="127.0.0.1:10000", job="prometheus" 10
timeseries-5: __name__="request_total_count", instance="127.0.0.1:10002", job="vm-agent" 20
timeseries-6: __name__="request_total_count", instance="127.0.0.1:10007", job="sls-ilogtail" 30
One-to-One
此类匹配运算要求左右表达式结果集中的Labels需满足一一对应。
request_total_latency_ms / on(instance) request_total_count --> 计算过程: timeseries-1 / timeseries-4 --结果--> instance="127.0.0.1:10000", 90/10 timeseries-2 / timeseries-5 --结果--> instance="127.0.0.1:10002", 20/20 timeseries-3 / timeseries-6 --结果--> instance="127.0.0.1:10007", 60/30
上面给出了一个使用on运算符的示例Query,on(instance)表示在匹配两侧Labels时仅考虑“instance”这一个Lable,因此上述Query实际也等价于request_total_latency_ms / ignore(job,code) request_total_count。
如果不使用on运算符,上述示例数据中仅有“timeseries 1 <--> timeseries 4”、“timeseries 2 <--> timeseries 5”两组时间线能够match上并且执行下一步的二元运算。
- Many-to-One/ One-to-Many
在使用on和ignore运算符后,两侧Labels可能会出现“一对多”、“多对一”的匹配情况。默认情况下,此类匹配场景会直接报错,需使用group_left或group_right运算符来兼容此种匹配场景,其中group_left表示“允许左向量的多个时间序列匹配右向量的一个序列”,而group_right则表示“允许右向量的多个时间序列匹配左向量的一个序列”。
request_total_latency_ms / on(job) group_left(instance, code) request_total_count --> 计算过程: timeseries-1 / timeseries-4 --结果--> instance="127.0.0.1:10001",job="prometheus",code="200", 90/10 timeseries-2 / timeseries-5 --结果--> instance="127.0.0.1:10002",job="vm-agent",code="200", 20/20 timeseries-3 / timeseries-5 --结果--> instance="127.0.0.1:10007",job="vm-agent",code="200", 60/20
此处给了一个使用on结合group_left运算符的示例Query:使用on(job)时会左右两侧会出现“Many-to-One”的情况,即左侧的“timeseries-2”和“timeseries-3”都能与右侧的“timeseries-5”匹配上,此时需使用group_left放行此类计算。此外,group_left(instance, code)语法表示在结果集中保留“instance”和“code”这两项Label。
其它基础运算
subquery
PromQL语法要求窗口函数的入参必须是一个裸指标而不接受某项计算的中间结果作为入参。如果希望基于某个表达式的结果之上再执行窗口函数,则需要使用subquery这项特性,语法为 [ : [] ],其中表示某个子表达式、表示外层窗口函数的窗口大小参数、表示内部子表达式使用的step参数。如果不传入参数,计算引擎内部会根据自动计算一个step作为子表达式的step。此处调整执行参数的详细逻辑可见:源码。
subquery的执行流程与普通的窗口函数类似,但会调整内部子表达式真正生效的start、end、step等关键参数。这里提供两个Query示例,“step”设置为2m:
max_over_time(sum(process_resident_memory_bytes) by (instance)[10m:1m]) 查询表达式拆解: sum(process_resident_memory_bytes) by (instance) --> step调整为"1m" max_over_time(xxxx[10m:1m]) --> 窗口参数range为"10m", step仍为"2m"
rate(sum(process_resident_memory_bytes) by (instance)[30m:30s]) 查询表达式拆解: sum(process_resident_memory_bytes) by (instance) --> step调整为"30s" rate(xxxx[30m:30s]) --> 窗口参数range为"30m", step仍为"2m"
offset 修饰符
offset用于对查询的时间范围做偏移计算,以PromQL语句query: request_total_count offset 5m以及下述查询参数为例介绍offset的计算过程,其中offset 5m表示将查询时间段往前偏移5分钟。
查询参数:
start: 10:06:00
end : 10:11:30
step: 120s
第一轮次的计算在"10:06:00"时刻,此时会将查询时间往前偏移到"10:01:00"时刻,此时基于lookback-delta机制会选取到"10:00:40"时刻的数据点;后续的两轮次计算则分别选取到"10:02:40"和"10:04:40"时刻的数据点。
此外,offset参数也支持负值,例如offset -5m则表示往后偏移5分钟。
@ 修饰符
@运算修饰符支持“@start()、@end()、@”三种方式,其中@start()表示取传入的start参数、@end()表示取传入的end参数、@则是直接取Query语句中的时间戳。
此修饰符的运算逻辑比较简单,即将每轮次的执行时刻点固定偏移到 start、end或者,以PromQL语句query: request_total_count @ start()以及下述查询参数为例介绍@修饰符的计算原理。
查询参数:
start: 10:06:00
end : 10:11:30
step: 120s
进阶用法示例
前文对PromQL中的基础算子、函数、表达式等运算行为的原理做了详细介绍,而在真实的业务监控场景中,往往还需要嵌套各种运算符才能完整表示预期的计算行为。PromQL执行流程与SQL Volcano Model类似,由叶子结点执行读数据操作,并一层层往上传导执行各阶段的计算操作。下面以2个较为复杂的PromQL Query语句来介绍详细的表达式结构与计算流程。
Query1
max ( max_over_time( process_memory_bytes [10m] ) / 1024 / 1024 ) by ( instance )
此Query的语法树结构可参考上图,整体的执行流程可拆分为对应的四个阶段,分别是:
- 阶段一
expr_1: process_resident_memory_bytes{}[10m]
此阶段是基于窗口函数的选点机制选取每轮次中的一个时间窗口内的所有数据点。
- 阶段二
expr_2: max_over_time(expr_1)
此阶段是计算单条时间线、单轮次计算中对应时间窗口内所有数据点的最大值。
- 阶段三
expr_3: expr_2/1024/1024
在完成上述两阶段计算后,此阶段将对结果集中每条时间线中的每个数值点执行 "/1024/1024/1024"的数学运算。
- 阶段四
expr_4: max(expr_3) by (instance)
对阶段三的结果集执行跨时间线的分类聚合计算,每轮次都会计算出每个"instance"分组中的最大值。
Query2
(sum(delta(container_network_receive_packets_dropped_total{namespace=~"kube-system"}[1m] offset 1h)) by (namespace,pod) / sum(delta(container_network_receive_packets_total{namespace=~"kube-system"}[1m] offset 1h)) by (namespace,pod)) > 0.02
Query语句的语法树参见上图,整体按递归模式逐层调用,针对二元表达式则是先执行左子树、后执行右子树。所有的Query语句都可按照此种方式划分各执行层级,由VectorSelector或MatrixSelector节点执行读数据操作,然后自底向上执行各层级表达式即可。
常见“非预期”场景
前文详细介绍了PromQL计算引擎中各种算子的计算原理,相信大家对PromQL语法的“特殊”已经有些体会了。本节将介绍几种因“lookback-delta”这种特殊选点设计引起的一些“非预期”的结果,虽然其计算结果令开发人员费解,但实际又是完全符合PromQL设计规范的“正常结果”。
- 已写入时序数据,但使用PromQL无法查出数据
此类场景通常出现在数据上报间隔较大的场景,例如某项每小时执行一次的聚合任务,其计算结果表示指标 "request_total_count_1h"。在使用PromQL对此指标执行查询时,如果输入的step参数较大,有较大概率会无法查出任何数据。
假设原始指标"request_total_count_1h"在每个整小时点都写入一条数据,下图以计算参数 start "10:30:00"、end "15:30:00"、step "3600s"为例介绍无法查出数据的原理。
上图中,每轮次地计算都会往前回溯3分钟选取最近一个时刻的数据点,以"10:30:00"为例,该轮次选点中不会选取到任何数据点;后续所有轮次同样也没有选取到任何数据点,导致最终的结果中也不存在任何数据。
- 某时刻后已不再写入数据,但该时刻后长达数分钟仍能使用PromQL查出数据
此种现象同样很常见,通常发生在采集Agent停止指标采集/上报后,后续数分钟内仍能查出指标数据。假设采集Agent每30s做一次采集并上报指标"request_total_count",于"10:02:00"时刻后停止数据采集/上报,下图以计算参数 start "10:00:00"、end "10:06:00"、step "60s"、lookback-delta "3m"为例介绍前述非预期想象的原理,其中lookback-delta "3m"表示最大回溯区间为3分钟。
上图中,"10:02:00"、"10:03:00"、"10:04:00" 三个时刻往前回溯3分钟后都能选取到"10:01:40"时刻点的数据,从而导致了前述的非预期现象。针对此场景,建议自定义设置"lookback-delta"参数缩小最大回溯区间来减小此种补点行为的影响。
- 在存在时间线汰换的场景中,PromQL使用AGG算子出现数倍高的结果
时间线汰换通常出现在Kubernetes集群场景下,其中Pod、节点、服务等组件会频繁地被创建、更新和销毁,这种动态性会导致 Prometheus监控数据中出现大量短暂活跃的时间序列数据。
下面给出一个时间线汰换较频繁的指标resource_count的计算示例,如果使用sum(resource_count)语句,其计算结果与通过SQL计算出来的结果相差2倍之多,这显然是不符合真实情况的;而使用sum(last_over_time(resource_count[59s]))语句的计算结果则基本与SQL接近。
- sum(resource_count) 结果
- sum(last_over_time(resource_count[59s])) 结果
这种非预期结果同样是由“lookback-delta”机制造成的,下面用两条汰换时间线为例来解释下出现上述结果的原因,其中timeseries 1在"10:01:40"时刻后消失,timeseries 2则是在"10:02:10"时刻新增。
示例中的step参数设置为“1m”,若使用sum(resource_count)语句,在"10:02:00"时刻的选点过程中,仅会选取到timeseries 1中"10:01:40"时刻的数据点;而"10:03:00"时刻的选点流程不仅会重复选取到timeseries 1中"10:01:40"时刻的历史数据点,还会选到timeseries 2中"10:02:40"时刻的数据点,致使出现了非预期的数据结果。sum(last_over_time(resource_count[59s]))语句则可以用来应对此种场景,即每轮次的选点阶段使用last_over_time函数选取最近59s内的最后一个数值点并执行sum计算。
特殊设计项
StableNan标识
PrometheusEngine中定义了一个名为StableNan特殊的float64数值,用它来标识一个非法数值。该标识的生效逻辑是:若基于“lookback-delta”机制选取到该数值的数据点,则视作本轮次未选取到有效点。顺便说明一下,常规的Math.Nan是正常的数值点。
SLS时序库支持写入StableNan数值,下面给出一个基于SLS GoSDK的数据示例:
var log = &sls.Log{ Time: proto.Uint32(uint32(time.Now().Unix())), Contents: [ ]*sls.LogContent{ {Key: proto.String("__name__"), Value: proto.String("test_metric")}, {Key: proto.String("__labels__"), Value: proto.String("A#$#a|B#$#b")}, {Key: proto.String("__time_nano__"), Value: proto.String("1687943952000000000")}, // 此处的 "__STALE_NAN__" 是固定字符串, 用来表示StableNan {Key: proto.String("__value__"), Value: proto.String("__STALE_NAN__")}, }, }
其它数据类型
- Examplar
Exemplar是一个用于增强监控系统可观测性的特性,它允许在时间序列数据点上附加具体事件的引用或元数据,例如链路追踪ID或日志条目ID。这种机制使用户能够在监控指标中快速关联异常行为与特定的事件,从而显著简化故障排查和性能分析。Exemplar通常由支持该特性的客户端库在应用程序中生成和附加,并在Prometheus抓取数据时一起收集。
在分布式系统中,Exemplar可以与链路追踪系统(如Jaeger或Zipkin)集成,提供从指标异常到具体追踪的直接参考,使得问题诊断过程更加高效和准确。Grafana已支持这一特性,使用户能够在指标曲线上直观地查看和分析这些事件关联,增强整个监控体系的功能和用户体验。总体而言,Exemplar为复杂系统中的监控和故障排查提供了重要的可视化和分析能力。 - NativeHistogram
在Prometheus中,传统的Histogram模型依赖用户预定义的固定bucket边界,通过多个指标(如_bucket,_sum,_count)来记录数据分布。这种方法可能导致不准确的分布表示,尤其当用户不清楚实际数据分布时,难以定义合适的bucket。此外,多个指标的方式增加了数据存储和查询的复杂性,用户需要处理这些分散的指标来获得完整的分布信息。
相比之下,NativeHistogram模型采用自适应的bucket划分算法,动态调整bucket以适应实际数据分布,不再需要用户手动定义bucket边界。它将原先多个指标合并为一个数据结构,显著减少了监控指标的数据量以及查询复杂度。数据不再以简单的浮点数表示,而是以更复杂的字节数组存储,因此可直接保存一个时间点上整个分布的快照。NativeHistogram提供了更高的准确性和效率,简化了监控系统的使用和管理,特别适用于处理复杂和变化多端的数据分布场景。
为什么SLS时序库暂不支持上述数据类型?
- Examplar
从本质上来说,由于Prometheus不具备通用的存储能力,才额外设计了Examplar这种特殊数据类型。其查询接口与PromQL查询接口类似,同样是根据指标名、Label条件读出整个Examplar数据。SLS的Logstore的通用存储能力完全可以适配此场景,且具备更高的存储/查询效率。
- NativeHistogram
此特性自 Prometheus v2.40.0 引入以来一直是实验性功能(需通过 --enable-feature=native-histograms 启用),各发布版本可能会break change导致API和存储格式不稳定。此外,其相关的工具生态支持不足(例如Exporter、AlertManager等组件不支持)、衍生存储产品(Mimir、VictoriaMetrics等)同样不支持此特性。在其API相对稳定、性能优化到位且工具链成熟时后,SLS时序库会考虑逐步适配此特性。
API接口简介
query类接口
Prometheus支持了/query和/query_range两个用于查询和分析时间序列数据的HTTP API接口。
/query_range接口用于执行范围查询,它接受PromQL语句以及指定的开始时间、结束时间和步长参数,接口会返回在这段时间范围内的数据序列。前文介绍的所有执行过程都可以归属于/query_range接口。此接口适合于历史数据分析和趋势观察,帮助用户了解系统性能随时间的变化情况,如生成图表来观察一段时间内的资源使用情况。
/query接口主要用于执行瞬时查询,返回在特定时间点上的度量值。简单来说,/query执行流程可视作/query_range执行流程的一个子集,即/query有且仅执行指定时间点的一轮计算,此接口通常应用于实时告警场景。
meta类接口
Prometheus还支持了元数据查询接口,该接口支持获取与时间序列相关的标签和指标信息,而不涉及具体的时间戳和数值数据。/labels接口提供当前存储中所有标签名称的列表;/label//values接口提供给定标签名称的所有可能值;/series接口支持返回匹配特定标签条件的时间序列元数据。这些接口支持检索特定时间段内的所有指标、标签和标签值信息,提供了一种方便的方式来探索和理解Prometheus数据的结构和组成,而无需检索具体的时间戳和数值信息。
meta接口主要供 Grafana 等前端工具实现PromQL自动补全、动态配置等功能。如果调用接口时未指定合理的match[]参数来限制查询范围,存储层可能会命中海量时间序列(高达数百万)并构建结果,这可能导致查询耗时从几百毫秒暴增到几十秒,内存消耗也飙升到 GB 级别。在大规模生产环境中,不合理的match[]甚至可能引发查询超时或服务不可用,请谨记务必使用match[]=参数将查询限制在具体的时间序列上。
当前SLS时序库已支持的HTTP API接口、错误码详情可参考:MetricStore HTTP API详情、MetricStore HTTP API返回值说明。
总结
相信大家看完后能对PromQL语法原理有一个更清晰的理解,此处便不再赘述其它了。本文由阿里云SLS可观测团队发布,若文中对PromQL语法的表述存在错误,敬请指正。