# PromQL
- Prometheus 提供了一种查询语言 PromQL ,用于查询 TSDB 中的 points ,还可进行加工计算。
- 用户可在 Prometheus 的 Web 页面执行 PromQL 查询表达式,可显示成两种图表:
- Table
- :查询某个时刻的 points ,然后显示成列表。
- 此时前端会向 Prometheus 发送
/api/v1/query
请求,返回 instant vector 格式的 points 。
- Graph
- :查询某段时间范围内的所有 points ,然后以时间为 x 轴显示成曲线图。
- 此时前端会向 Prometheus 发送
/api/v1/query_range
请求,返回 range vector 格式的 points 。 - 显示曲线图需要加载很多 points ,开销大,可能导致网页卡顿。
- Table
# 查询表达式
编写 PromQL 查询表达式时,至少要包含一个 metric_name 或 label-value ,用于筛选 points 。语法示例如下:
go_goroutines # 查询具有该名称的指标 {job="prometheus"} # 查询具有指定标签值的指标 {job!~'_.*', job!~'prometheus'} # 支持查询重复的指标名 {__name__="go_goroutines", job='prometheus'} # 通过内置标签 __name__ 可匹配指标名 go_goroutines{job="prometheus"} # 查询具有该名称、该标签值的指标 go_goroutines{job!="prometheus"} # 要求具有 job 标签,且值不等于 prometheus go_goroutines{job=""} # 要求 job 标签的值为空字符串(这等价于不具有 job 标签) go_goroutines{job!=""} # 要求具有 job 标签且值不为空 go_goroutines{job=~`prometheu\w`} # 正则匹配 go_goroutines{job!~`prometheu\w`} # 反正则匹配 go_goroutines{job="prometheus"} offset 1m # 默认是查询当前时刻的 points ,使用 offset 则是查询一段时间之前的 points ,相当于在一段时间之前执行该查询表达式 sum(go_goroutines{job="prometheus"} offset 1m) # 使用函数时,offset 符号要放在函数括号内
- 用 # 声明单行注释。
- 将字符串用反引号包住时,不会让反斜杠转义。
- 查询表达式不能为空的
{}
,同理也不能使用{__name__=~".*"}
选中所有指标。
以上查询表达式得到的是 instant vector ,以下查询表达式得到的是 range vector :
go_goroutines{job="prometheus"}[1m] # 查询以当前时刻为基准,过去 1m 时间范围内的所有 points go_goroutines{job="prometheus"}[10m:1m] # 在过去 10m 时间范围内,每隔 1m 时长取一个 point 返回
PromQL 中时间的取值必须是正整数,支持以下时间单位:
s # 秒 m # 分钟 h # 小时 d # 天 w # 周 y # 年
# 运算符
运算符的优先级从高到低如下,同一优先级的采用左结合性:
^ * / % + - == != <= < >= > and unless or
可以进行如下算术运算:
go_goroutines + 1 # 加 1 - 2 # 减 1 * 2 # 乘 1 / 3 # 除法(小数点后会保留十多位) 1 % 3 # 取模 2 ^ 3 # 取幂
- 只能对指标的值进行运算,不能对标签的值进行运算。
- 关于 0 的除法运算:
0 / 任意正数 # 结果为 0 0 / 任意负数 # 结果为 -0 0 / 0 # 结果为 NaN 任意正数 / 0 # 结果为 +Inf 任意负数 / 0 # 结果为 -Inf
- 对于特殊值,可以用 expression > 0 等方式过滤掉。
可以进行如下比较运算:
go_goroutines == 2 go_goroutines != 2 go_goroutines > 2 # 返回大于 2 的部分曲线 go_goroutines < 2 go_goroutines >= 2 go_goroutines <= 2
- 比较运算默认是过滤掉不符合条件的数据。
- 如果在比较运算符之后加上关键字 bool ,比如
1 == bool 2
,就会返回比较运算的结果,用 1、0 分别表示 true、flase 。
向量之间可以进行如下集合运算:
go_goroutines{job='prometheus'} and go_goroutines # 交集(返回两个向量中标签列表相同的时间序列,取第一个向量中的值) go_goroutines{job='prometheus'} or go_goroutines{job='prometheus'} # 并集(将两个向量中的所有时间序列合并,如果存在标签列表重复的时间序列,则取第一个向量中的值) go_goroutines{job='prometheus'} unless go_goroutines{job!='prometheus'} # 补集(返回在第一个向量中存在、但在第二个向量中不存在的时间序列)
向量之间进行运算时,默认只会对两个向量中标签列表相同的时间序列(即标签名、标签值完全相同)进行运算。如下:
go_goroutines - go_goroutines go_goroutines{instance="10.0.0.1:9100"} - go_goroutines # 两个向量中存在匹配的时间序列,可以进行运算 go_goroutines{instance="10.0.0.1:9100"} - go_goroutines{instance="10.0.0.2:9100"} # 两个向量中不存在匹配的时间序列,因此运算结果为空 go_goroutines{instance="10.0.0.1:9100"} - go_gc_duration_seconds_sum{instance="10.0.0.1:9100"} # 指标名不同,但标签列表相同,依然可以运算
可以按以下格式,将两个只有部分标签匹配的时间序列进行运算:
go_goroutines{instance="10.0.0.1:9100"} - on(job) go_goroutines{instance="10.0.0.2:9100"} # 只考虑 job 标签,则能找到匹配的时间序列 go_goroutines{instance="10.0.0.1:9100"} - ignoring(instance) go_goroutines{instance="10.0.0.2:9100"} # 忽略 instance 标签,则能找到匹配的时间序列 go_goroutines{instance="10.0.0.1:9100"} and on() hour() == 8 # 只获取 8 点时的时间序列
以上只是对时间序列进行一对一匹配,可以按下格式进行一对多的匹配:
go_goroutines - on() group_left vector(1) # 不考虑任何标签,用右边的一个时间序列匹配左边的多个时间序列,分别进行运算,相当于 go_goroutines - 1 vector(1) + on() group_right go_goroutines # group_right 表示用左边的一个时间序列匹配右边的多个时间序列,group_left 则相反
# 函数
向量与标量的转换:
vector(1) # 输入标量,返回一个向量 scalar(vector(1)) # 输入一个 time series ,返回每个采样时刻处的标量值
关于时间:
time() # 返回当前的 Unix 时间戳(标量),单位为秒 timestamp(vector(1)) # 返回向量中每个数据点的时间戳(向量) # 以下函数用于获取某个时间信息(注意为 UTC 时区)。可以输入一个时间向量,不输入时默认采用当前时间,比如 hour( timestamp(vector(1)) ) minute([vector]) # 分钟,取值为 0~59 hour ([vector]) # 小时,取值为 0~23 month ([vector]) # 月份,取值为 1~31 year ([vector]) # 年份 day_of_month([vector]) # 该月中的日期,取值为 1~31 day_of_week ([vector]) # 周几,取值为 0~6 ,其中 0 表示周日
例:
hour() == 16 and minute() < 5 # 仅在 UTC+8 时区每天的前 5 分钟,表达式结果不为空,采取第一段的值,即 16
关于排序:
sort(go_goroutines) # 按 metrics 取值,升序排列 sort_desc(go_goroutines) # 按 metrics 取值,降序排列 sort_by_label(<instant-vector>, <label>) # 按 label 的取值,升序排列。这是 Prometheus v2.50.0 新增的功能 sort_by_label_desc(<instant-vector>, <label>) # 按 label 的取值,降序排列
- 在 Prometheus 的 Table 页面,显示的指标默认是无序的,只能通过 sort() 函数按指标值排序。不支持按 label 进行排序。
- 在 Graph 页面,显示的图例是按第一个标签的值进行排序的,且不受 sort() 函数影响。
修改向量的标签:
# 给向量 go_goroutines 添加一个标签,其名为 new_label ,其值为 instance、job 标签的值的组合,用 , 分隔 label_join(go_goroutines, "new_label", ",", "instance", "job") # 正则匹配。给向量 go_goroutines 添加一个标签,其名为 new_label ,其值为 instance 标签的值的正则匹配的结果 label_replace(go_goroutines, "new_label", "$1-$2", "instance", "(.*):(.*)")
- 如果 new_label 与已有标签同名,则会覆盖它。
# 算术函数
向量可以使用以下算术函数:
abs(go_goroutines) # 返回每个采样时刻处,数据点的绝对值 round(go_goroutines) # 返回每个采样时刻处,数据点四舍五入之后的整数值 absent(go_goroutines) # 在每个采样时刻处,如果向量为空(不存在任何数据点),则返回 1 ,否则返回空值 absent_over_time(go_goroutines[1m]) # 在每个采样时刻处,如果过去 1m 内向量一直为空,则返回 1 ,否则返回空值 changes(go_goroutines[1m]) # 返回每个采样时刻处,过去 1m 内数值变化的次数 resets(go_goroutines[1m]) # 返回每个采样时刻处,过去 1m 内数值减少的次数 delta(go_goroutines[1m]) # 返回每个采样时刻处,过去 1m 内最新的值减去最旧的值之差 idelta(go_goroutines[1m]) # 返回每个采样时刻处,过去 1m 内最新两个数据点的差值 deriv(go_goroutines[1m]) # 通过简单线性回归,计算每秒的导数 # 以下函数专用于处理 Counter 类型的向量,即单调递增的向量。计算结果不会为负 increase(http_requests_count[1m]) # 返回每个采样时刻处,过去 1m 内的增量 rate(http_requests_count[1m]) # 返回每个采样时刻处,过去 1m 内的增长率,即平均每秒增量 irate(http_requests_count[1m]) # 返回每个采样时刻处,过去 1m 内最新两个数据点之间的增长率
关于 delta()、increase() :
- delta() 函数用于计算向量的差值、变化量。
- 例如
delta(http_requests_count[1m])
的计算结果接近于http_requests_count - http_requests_count offset 1m
,但不完全相同,因为受到 extrapolation 的影响。
- 例如
- increase() 函数用于计算向量的增量,因此希望向量是单调递增的。如果向量的值在 range 时长内一会增大一会减小,则累计每一段增量,视作总增量。
- 假设向量的值从 30 变为 50 ,则差值、增量都为 20 。
- 假设向量的值从 50 变为 40 ,则差值为 -10 ,增量为 40 。这里 increase() 认为 Counter 计数器先从 50 重置到 0 ,然后从 0 增长到 40 ,只是因为离散采样没有采样到 0 值,因此增量为 40-0=40 。
- rate() 的计算结果,相当于 increase() 除以时间范围 range 。
- 如果向量为 Counter 类型,即单调递增,则 delta() 与 increase() 的计算结果通常相同。
- 但 Counter 类型的向量不能保证总是单调递增,比如 exporter 重启时会重新计数,称为计数器重置(Counter Reset),此时 delta() 与 increase() 的计算结果不同。
- 如果向量的值减小,则 delta() 的计算结果可能为负,可以只取
delta(...) >= 0
部分的值。而 increase() 的计算结果不会为负,反而会计算出比 delta() 更大的增量,因为每次 Counter Reset 之后会从 0 开始重新计算增量。
- 综上,选用函数的一般逻辑如下:
- 如果向量为 Counter 类型,则用 increase() 计算增量,用 rate() 计算增长率。
- 如果向量为 Gauge 类型,则用 delta() 计算差值,用 deriv() 计算导数。
- delta() 函数用于计算向量的差值、变化量。
关于 idelta()、irate() :
- 时间范围 range 越大,rate() 函数的曲线越平缓。而 idelta()、irate() 的曲线总是很尖锐,不受 range 影响,接近瞬时值。
- range 取值应该尽量大些,因为 range 过大时不影响 idelta()、irate() 的计算精度,但是 range 小于 scrape_interval 的两倍时会缺少数据点。
- Prometheus 可能因为超时而漏采了一些点,此时如果用 idelta()、irate() 计算向量每隔 scrape_interval 时长的变化量,则误差较大。
例:假设 scrape_interval 为 30s ,指标 http_requests_count 在 2m 时间内采样了 4 个数据点,取值依次为 3、6、9、12 ,进行以下函数计算:
delta(http_requests_count[1m])
计算结果中,每个数据点的值都为 6 。idelta(http_requests_count[1m])
计算结果中,每个数据点的值都为 3 。increase(http_requests_count[1m])
计算结果中,每个数据点的值都为 6 。rate(http_requests_count[1m])
计算结果中,每个数据点的值都为 0.1 。irate(http_requests_count[1m])
计算结果中,每个数据点的值都为 0.1 。
例:假设 scrape_interval 为 30s ,指标 http_requests_count 在 2m 时间内采样了 4 个数据点,取值依次为 3、1、2、5 ,进行以下函数计算:
delta(http_requests_count[30s])
计算结果为空,因为 30s 时间内包含的数据点少于 2 个,不能计算差值。delta(http_requests_count[50s])
计算结果显示的图像不连续。因为 50s 时间内,有时包含 2 个数据点,就有图像;有时包含 1 个数据点,就没有图像。delta(http_requests_count[1m])
计算结果中,第 4 个数据点的值为 (5-2)/30*60=6 ,该值受 extrapolation 影响。delta(http_requests_count[90s])
计算结果中,第 4 个数据点的值为 (5-1)/60*90=6 ,该值受 extrapolation 影响。
# 聚合函数
- 如果向量包含多个时间序列,用算术函数会分别对这些时间序列进行运算,而用聚合函数会将它们合并成一个或多个时间序列。
- 向量可以使用以下聚合函数:
# 基本统计 count(go_goroutines) # 返回每个采样时刻处,该向量的数据点的数量(即包含几个时间序列) count_values("value", go_goroutines) # 返回每个采样时刻处,各种值的数据点的数量,并按 {value="x"} 的命名格式生成多个时间序列 sum(go_goroutines) # 返回每个采样时刻处,所有数据点的总和(即将曲线图中所有曲线叠加为一条曲线) min(go_goroutines) # 返回每个采样时刻处,数据点的最小值 max(go_goroutines) # 返回每个采样时刻处,数据点的最大值 avg(go_goroutines) # 返回每个采样时刻处,数据点的平均值 # 高级统计 stddev(go_goroutines) # 返回每个采样时刻处,数据点之间的标准差 stdvar(go_goroutines) # 返回每个采样时刻处,数据点之间的方差 topk(3, go_goroutines) # 返回每个采样时刻处,最大的 3 个数据点 bottomk(3, go_goroutines) # 返回每个采样时刻处,最小的 3 个数据点 quantile(0.5, go_goroutines) # 返回每个采样时刻处,大小排在 50% 位置处的数据点 # 修改数据点的值 last_over_time(go_goroutines[1m]) # 返回每个采样时刻处,过去 1m 内最新数据点的值 group(go_goroutines) # 将每个数据点的取值置为 1 sgn(go_goroutines) # 判断每个数据点取值的正负。如果为正数、负数、0 ,则分别置为 1、-1、0 clamp(go_goroutines, 0, 10) # 限制每个数据点取值的最小值、最大值,语法为 clamp(vector, min, max) clamp_min(go_goroutines, 0) # 限制最小值 clamp_max(go_goroutines, 10) # 限制最大值
- 聚合函数默认不支持输入有限时间范围内的向量,需要使用带
_over_time
后缀的函数,如下:sum_over_time(go_goroutines[1m]) # 返回每个采样时刻处,过去 1m 内数据点的总和(分别计算每个时间序列) avg_over_time(go_goroutines[1m]) # 返回每个采样时刻处,过去 1m 内的平均值
- 聚合函数可以与关键字 by、without 组合使用,如下:
sum(go_goroutines) by(job) # 将所有曲线按 job 标签的值分组,分别执行 sum() 函数 sum(go_goroutines) without(job) # 将所有曲线按除了 job 以外的标签分组,分别执行 sum() 函数 sum(go_goroutines) by(Time) # Time 是隐式 label ,这里相当于 sum(go_goroutines)
- 聚合函数默认不支持输入有限时间范围内的向量,需要使用带
# evaluator
- 下面分析 Prometheus 源代码 (opens new window) 中,执行 PromQL 查询表达式的主要逻辑:
// 每次执行一个查询表达式时,就创建一个 evaluator 对象,用于记录该查询的元数据 type evaluator struct { ctx context.Context // 记录 HTTP 查询请求中的 start、end、step 参数 // 如果 startTimestamp 等于 endTimestamp ,则查询的是 instant vector ,否则是 range vector startTimestamp int64 endTimestamp int64 interval int64 maxSamples int // Prometheus 默认配置了 --query.max-samples=50000000 ,限制每次查询最多读取的 points 数量,避免内存开销过大 currentSamples int // 记录当前查询读取的 points 数量 logger log.Logger lookbackDelta time.Duration // Prometheus 默认配置了 --query.lookback-delta=5m samplesStats *stats.QuerySamples noStepSubqueryIntervalFn func(rangeMillis int64) int64 } // evaluator.eval() 函数用于执行一个查询表达式,返回其查询结果 func (ev *evaluator) eval(expr parser.Expr) (parser.Value, storage.Warnings) { // 如果查询超时,或者被取消,则终止函数 if err := contextDone(ev.ctx, "expression evaluation"); err != nil { ev.error(err) } // 计算在 [start, end] 时间范围内的 step 数量 numSteps := int((ev.endTimestamp-ev.startTimestamp)/ev.interval) + 1 // 根据查询表达式的类型,分别处理 switch e := expr.(type) { // 处理各种函数 case *parser.Call: call := FunctionCalls[e.Func.Name] if e.Func.Name == "timestamp" { ... } ... // 处理二进制类型的查询,比如运算符 and、or case *parser.BinaryExpr: ... // 读取 instant vector 类型的 points case *parser.VectorSelector: ... // 创建一个 Matrix 对象,它是一个长度为 len(e.Series) 的数组,用于暂存接下来读取到的 Series mat := make(Matrix, 0, len(e.Series)) // 创建一个 it 迭代器,用于从 TSDB 读取 points 。迭代时会按 timestamp 递增的顺序获取 point it := storage.NewMemoizedEmptyIterator(durationMilliseconds(ev.lookbackDelta)) var chkIter chunkenc.Iterator // 一个 vector 可能包含多个 time series ,因此遍历这些 time series ,分别处理 for i, s := range e.Series { // 移动迭代器的指针,指向当前 time series 的 chunks ,然后便可从中读取 points chkIter = s.Iterator(chkIter) it.Reset(chkIter) // 创建一个 Series 对象,用于暂存接下来读取到的 points ss := Series{ Metric: e.Series[i].Labels(), } // 从 start 时刻开始,每隔 interval 即 step 时长读取一次当前 time series 的 point ,直到 end 时刻 for ts, step := ev.startTimestamp, -1; ts <= ev.endTimestamp; ts += ev.interval { step++ // 读取单个 time series 在 ts 时刻的 point _, f, h, ok := ev.vectorSelectorSingle(it, e, ts) // 如果成功读取到 ts 时刻的 point ,则暂存起来。如果没读取到,则继续下一轮循环,因此可能漏点 if ok { // 如果当前读取的 points 数量超过 maxSamples 限制,则报错 if ev.currentSamples < ev.maxSamples { // 如果 floats 数组为空,则需要初始化,创建一个长度为 numSteps 的数组 if ss.Floats == nil { ss.Floats = getFPointSlice(numSteps) } // 将读取到的 point 追加到 floats 数组中 ss.Floats = append(ss.Floats, FPoint{F: f, T: ts}) ev.currentSamples++ } else { ev.error(ErrTooManySamples(env)) } } } // 如果该 Series 读取到至少一个 points ,则追加到 mat 数组中 if len(ss.Floats) > 0 { mat = append(mat, ss) } } ev.samplesStats.UpdatePeak(ev.currentSamples) // 返回 mat 数组,作为查询结果 return mat, ws // 读取 range vector 类型的 points case *parser.MatrixSelector: if ev.startTimestamp != ev.endTimestamp { panic(errors.New("cannot do range evaluation of matrix selector")) } return ev.matrixSelector(e) // 处理子查询 case *parser.SubqueryExpr: ... ... }
# 误差
- Prometheus 是离散采样,比如每隔 30s 采集一次监控指标,这与实时状态之间存在误差。而且 Prometheus 在执行查询表达式时,还可能增加误差。
- 如果用户想实现零误差的监控系统,比如电商计费,则建议记录所有事件的日志,然后搭建基于日志的监控系统,比如 ELK 。
# lookback
用户查询 Prometheus 时,通常是发送以下 HTTP 请求:
GET /api/v1/query?query=??&time=??
- 理论上:用户查询 time 时刻处的监控指标,于是 Prometheus 将查询表达式转换成 label-value set ,从 TSDB 读取 points 。
- 实际上:用户查询的 time 时刻处,不一定刚好采集了 point 。为了方便用户查询,Prometheus 采用 lookback 机制:取过去
--query.lookback-delta=5m
分钟,即时间范围[time-5m, time]
内的最后一个 point ,当作 time 时刻的 point 。如果该时间范围内连一个 point 都不存在,则查询结果为空,此时称该 time series 已过时(stale),在最近没有采集 point 。 - 以下是一个查询示例:
GET /api/v1/query?query=go_goroutines{instance='10.0.0.1:9090'}&time=1589241600 { "status": "success", "data": { "resultType": "vector", "result": [ { "metric": { "__name__": "go_goroutines", "instance": "10.0.0.1:9090", "job": "prometheus", }, "value": [ # TSDB 中存储的 point.timestamp 不一定等于 query time ,但 Prometheus 返回查询结果时,会自动修改 point.timestamp ,对齐 query time 1589241600, "7" ] } ] } }
- 如果用户查询的是 range vector ,则会发送
/api/v1/query_range
请求,不采用 lookback 机制。
评价 lookback 机制:
- 优点:
- 用户不必准确指定 time ,就能查询到 point 。
- 缺点:
- 增加了时间误差,用户查询到的 point 可能是几分钟之前的值。降低 scrape_interval 和 eval_interval 可减小该误差。
- 当 Prometheus 停止采集一个 time series 之后(比如 target 下线),用户以最新时刻去查询,依然能查询到 point ,造成该 time series 依然被采集的假象。
- 例如在 k8s 中删除一批 pod 又重建,然后查询
count(kube_pod_info)
统计 pod 总数,会将 5m 内已删除的 pod 也统计进来,导致总数虚高。 - 为了解决该问题,Prometheus 2.0 引入了 StaleNaN 标记。
- 例如在 k8s 中删除一批 pod 又重建,然后查询
- 优点:
关于 StaleNaN 标记。
- Prometheus 定义了以下常量:
NormalNaN uint64 = 0x7ff8000000000001 // 表示算术运算中的 NaN StaleNaN uint64 = 0x7ff0000000000002 // 表示 time series 已过时
- 如果 Prometheus 上一次 scrape 采集到了某个 time series 的 point ,下一次 scrape 却没采集到该 time series ,则给该 time series 追加一个取值为 StaleNaN 的 point ,表示该 time series 已过时。
- 当用户查询 instant vector 时,如果过去 5 分钟内的最后一个 point 的值为 StaleNaN ,则不采用 lookback 机制,返回的查询结果为空。
- 当用户查询 range vector 时,会忽略取值为 StaleNaN 的 point ,不包含在查询结果中。
- Prometheus 定义了以下常量:
-
// 该函数用于读取单个 time series 在 ts 时刻的 point func (ev *evaluator) vectorSelectorSingle(it *storage.MemoizedSeriesIterator, node *parser.VectorSelector, ts int64) ( int64, float64, *histogram.FloatHistogram, bool, ) { // 表面上查询时刻是 ts ,但查询表达式中可能有 offset ,因此需要计算实际的查询时刻 refTime refTime := ts - durationMilliseconds(node.Offset) var t int64 var v float64 var h *histogram.FloatHistogram // Seek() 函数会移动迭代器的指针,指向 timestamp 大于等于 refTime 的第一个 point ,并返回当前 point 的 ValueType 。如果未找到 point ,则返回 ValNone valueType := it.Seek(refTime) switch valueType { case chunkenc.ValNone: // 如果未找到 point ,则检查:可能是因为该时刻不存在 point ,也可能时因为读取 TSDB 失败而报错 if it.Err() != nil { ev.error(it.Err()) } case chunkenc.ValFloat: // 如果找到 float 类型的 point ,则获取其 timestamp、value t, v = it.At() case chunkenc.ValFloatHistogram: t, h = it.AtFloatHistogram() default: panic(fmt.Errorf("unknown value type %v", valueType)) } // 如果 it.Seek(refTime) 未找到 point ,则通过 it.PeekPrev() 读取最后一个 point // 如果 it.Seek(refTime) 找到了 point ,但其 timestamp 大于 refTime ,则通过 it.PeekPrev() 读取前一个 point (时间戳更小) if valueType == chunkenc.ValNone || t > refTime { var ok bool t, v, h, ok = it.PeekPrev() // 如果 it.PeekPrev() 未找到 point ,则查询结果为空 // 如果 it.PeekPrev() 找到了 point ,但其 timestamp 与 refTime 的差值超过 lookbackDelta ,则不采用 lookback 机制,查询结果为空 if !ok || t < refTime-durationMilliseconds(ev.lookbackDelta) { return 0, 0, nil, false } } // 如果 it.Seek(refTime) 找到了 point ,但取值为 StaleNaN ,则查询结果为空 if value.IsStaleNaN(v) || (h != nil && value.IsStaleNaN(h.Sum)) { return 0, 0, nil, false } return t, v, h, true }
# extrapolation
delta()、increase()、rate() 是数学里常见的函数,但 Prometheus 是离散采样,实现它们存在一定难度、误差。
问题:用户查询 range vector 时,时间范围 range 过小
- 假设 scrape_interval 为 30s ,查询
delta(http_requests_count[20s])
。考虑到查询时刻 t0 的不同,在[t0-20s, t0]
时间范围内,有时不存在 point ,有时只存在一个 point ,因此 delta() 计算结果总是为空。 - 假设 scrape_interval 为 30s ,查询
delta(http_requests_count[40s])
。考虑到查询时刻 t0 的不同,在[t0-40s, t0]
时间范围内,有时存在一个 point ,因此 delta() 计算结果为空;有时存在两个 point ,因此 delta() 计算结果非空。最终绘制的时间曲线不连续,某些时刻不存在图像,某些时刻存在图像。 - 为了解决这种问题,建议用户用 delta()、rate() 等函数计算 range vector 时,将时间范围
[range]
至少配置为 scrape_interval 的两倍,否则在过去 range 时长内的 point 可能少于 2 个,导致函数计算结果为空。 - 另外,range vector 的时间范围
[range]
可改为[range:step]
的格式,例如delta(http_requests_count[40s:1s])
。- 这会先从 TSDB 读取原始采样的 points ,然后根据它们的值,在内存中以 1s 的时间间隔创建一组虚拟 points ,最后用这些虚拟 points 进行
delta(http_requests_count[40s])
运算。 - 优点:可将相邻 point 的时间间隔从 scrape_interval 改为自定义间隔,避免在过去 range 时长内的 point 少于 2 个。
- 缺点:可能增加 extrapolation 的误差。
- 这会先从 TSDB 读取原始采样的 points ,然后根据它们的值,在内存中以 1s 的时间间隔创建一组虚拟 points ,最后用这些虚拟 points 进行
- 假设 scrape_interval 为 30s ,查询
问题:用户查询 range vector 时,时间范围 range 过大
- 假设 scrape_interval 为 30s ,查询
delta(http_requests_count[70s])
,此时不能找到相距 70s 的两个 points ,如何计算 70s 时长内的差值? - 为了解决这种问题,Prometheus 采用外推(extrapolation)机制:先计算向量在 30s 时长内的差值,然后按时间比例放大,得到 70s 时长内的差值。
- 实际上,用户查询 range vector 时,不管时间范围 range 是否为 scrape_interval 的整数倍,Prometheus 总是会采用 extrapolation 机制。
- 假设 scrape_interval 为 30s ,查询
例:假设 scrape_interval 为 30s ,指标 http_requests_count 采样的一组 points 取值为 20、30、50、40 。
- 如果查询
delta(http_requests_count[1m])
,则最后一个 scrape_interval 的差值为 40-50=-10 。然后 extrapolate ,得到过去 1m 时间内的差值为 -10/30*60=-20 。 - 如果查询
increase(http_requests_count[1m])
,则最后一个 scrape_interval 的差值为 40-50 ,叠加 Counter Reset 之前的值 50 ,得到增量为 40-50+50=40 。然后 extrapolate ,得到过去 1m 时间内的增量为 40/30*60=80 。 - 如果查询
rate(http_requests_count[2m])
,则过去 90s 时间内的增长率为 (40-20+50)/90≈0.78 。然后 extrapolate ,将它视作过去 2m 时间内的增长率。
- 如果查询
评价 extrapolation 机制:
- 优点:
- 用户使用 delta()、increase()、rate() 函数时,可自由调整 range 的大小,方便统计分析。
- 即使 Prometheus 漏采了少量数据点,也能使用 delta()、increase()、rate() 函数绘制图像。即使未来几分钟的监控指标尚未采集,也能绘制图像,实现预测。
- 缺点:
- 如果原指标的值不是匀速变化的,则 range/scrape_interval 比例越小,函数计算结果的误差越大。相关 Issue (opens new window)
- 即使原指标的值为整数,但受到 extrapolation 的影响,函数计算结果也可能包含小数。
- 如果用户担心 extrapolation 的误差,可采取以下措施:
- 增加 range 或减小 scrape_interval ,从而增加 range/scrape_interval 比例,能减小 extrapolation 误差。
- 改用 idelta()、irate() 函数,能避免 extrapolation 误差,但只能计算每隔 scrape_interval 时长的变化量,不能通过修改
[range]
来监控 1m、5m 等时间间隔。
- 相关博客 (opens new window)
- 优点:
-
func extrapolatedRate(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper, isCounter, isRate bool) Vector { ms := args[0].(*parser.MatrixSelector) // 当前 matrix 的查询条件 vs := ms.VectorSelector.(*parser.VectorSelector) var ( points = vals[0].(Matrix)[0] // 读取 range 时长内采样的全部数据点,保存为一个数组 rangeStart = enh.Ts - durationMilliseconds(ms.Range+vs.Offset) // range 的开始时刻,等于查询时刻减去 Range、Offset rangeEnd = enh.Ts - durationMilliseconds(vs.Offset) // range 的结束时刻 resultFloat float64 // 记录该函数的计算结果,可能是差值、增长率、增量三种类型 firstT, lastT int64 numSamplesMinusOne int ) // 如果采样的数据点少于 2 个,则不能计算差值,结束执行该函数 if len(samples.Floats) < 2 { return enh.Out } // 计算指标差值 numSamplesMinusOne = len(samples) - 1 first_sample = samples[0] // 采样的第一个数据点 last_sample = samples[numSamplesMinusOne] // 采样的最后一个数据点 resultFloat = last_sample.Value - first_sample.Value // 以上的 resultFloat 只是计算首尾两个数据点的差值,满足了 delta() 函数的需求。但 increase()、rate() 函数用于处理 Counter 类型的指标,需要考虑 Counter Reset 的情况 // 如果指标为 Counter 类型,则遍历所有数据点的 value 。如果当前 value 小于 prevValue ,则认为发生了一次 Counter Reset ,为了补偿 resultFloat ,给它叠加 prevValue if isCounter { prevValue := samples[0].Value for _, currPoint := range samples[1:] { if currPoint .Value < prevValue { resultFloat += prevValue } prevValue = currPoint.F } } // 计算 extrapolation 阈值 sampledInterval := last_sample.Time - first_sample.Time // 采样的时长 averageDurationBetweenSamples := sampledInterval / numSamplesMinusOne // 数据点之间的平均采样间隔,通常等于 scrape_interval 。如果漏采了一些点,则会大于 scrape_interval extrapolationThreshold := averageDurationBetweenSamples * 1.1 // 触发 extrapolation 的阈值 extrapolateToInterval := sampledInterval // 如果数据点距离 rangeStart、rangeEnd 较近,则将 sampledInterval 时长内的指标差值,按时间比例放大,视作 extrapolateToInterval 时长内的指标差值 // 如果距离超过 extrapolationThreshold 阈值(大概为 1.1 倍 scrape_interval ),则只放大少量时间(大概为 0.5 倍 scrape_interval )。否则只采集了 1m 时间的指标,却可以计算 1h 时间的增量,误差很大,不可信 durationToStart := first_sample.Time - rangeStart // 第一个数据点距离 rangeStart 的时长 durationToEnd := last_sample.Time - rangeEnd // 最后一个数据点距离 rangeEnd 的时长 if durationToStart < extrapolationThreshold { extrapolateToInterval += durationToStart } else { extrapolateToInterval += averageDurationBetweenSamples / 2 } if durationToEnd < extrapolationThreshold { extrapolateToInterval += durationToEnd } else { extrapolateToInterval += averageDurationBetweenSamples / 2 } // 按时间比例放大 resultFloat factor := extrapolateToInterval / sampledInterval resultFloat *= factor // 如果正在执行 rate() 函数,则将差值除以时长,得到增长率 if isRate { resultFloat /= sampledInterval } ... } // delta() 函数调用 extrapolatedRate() 时会声明 isCounter=false, isRate=false func funcDelta(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) Vector { return extrapolatedRate(vals, args, enh, false, false) } func funcRate(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) Vector { return extrapolatedRate(vals, args, enh, true, true) } func funcIncrease(vals []parser.Value, args parser.Expressions, enh *EvalNodeHelper) Vector { return extrapolatedRate(vals, args, enh, true, false) }
- delta()、increase()、rate() 函数在底层都是调用 extrapolatedRate() 函数,从而计算差值、增量、增长率。
- delta() 函数只需要计算首尾两个数据点的差值,而 increase()、rate() 函数需要遍历所有数据点的值,开销更大。