# 原理

  • Prometheus 的工作原理:
    • 运行一些 exporter 程序,能通过 HTTP API 输出文本格式的监控指标,称为 metrics 。
    • Prometheus 每隔 scrape_interval 时长向各个 exporter 发送一个 HTTP GET 请求,从 HTTP 响应中读取 metrics 文本,然后存储到内置的时序数据库 TSDB 中。该过程称为采集(scrape)。
    • 用户可向 Prometheus 发出查询请求,查询 TSDB 中存储的监控指标。
  • 一个应用程序,只要能通过 HTTP API 输出正确格式的 metrics 文本,就可担任 exporter 。
    • 例如 Prometheus 本身也提供了 exporter 端口,执行 curl 127.0.0.1:9090/metrics 即可获得 metrics 。
    • Prometheus 可采集大量 exporter 。每个 exporter 通过不同的 IP:PORT 地址来区分,每个地址称为一个 target、instance 。

# scrape

  • Prometheus scrape 的方式有多种:

    • exporter
      • :最基础的方式。让程序监听一个符合 exporter 格式的 HTTP API ,由 Prometheus 定期发送 HTTP GET 请求到该 API ,采集 metrics 。
      • exporter 平时一般无事可做,收到 HTTP 请求时才动态生成一次当前时刻的 metrics ,例如统计当前的线程数。
      • 减少 scrape_interval 能提高采集频率,减小离散采样的误差,但会增加 Prometheus 和 exporter 的 CPU、带宽开销。
    • pushgateway
      • :程序定期将 metrics 文本通过 POST 请求发送到 pushgateway ,然后 Prometheus 以 exporter 方式从 pushgateway 采集 metrics 。
    • 第三方媒介
      • :程序定期将 metrics 文本写入文件、数据库等媒介,然后由 node_exporter 等工具收集这些 metrics ,加入自己 exporter 接口的响应中。
  • Prometheus scrape 的工作流程:

    1. Prometheus 发出 HTTP 请求。
    2. HTTP 请求经过网络传输,到达 exporter 。
    3. exporter 开始统计 metrics ,生成 HTTP 响应。
    4. HTTP 响应经过网络传输,到达 Prometheus 。
    5. Prometheus 将 HTTP 响应中的 metrics 文本解析成 points ,存入 memSeries 。
  • Prometheus 会为每个 target 运行一个 scrapeLoop 循环,源代码如下:

    func (sl *scrapeLoop) run(errc chan<- error) {
        select {
        case <-time.After(sl.scraper.offset(sl.interval, sl.offsetSeed)): // 等待 offset 时长,才开始 scrapeLoop
        case <-sl.ctx.Done():
            close(sl.stopped)
            return
        }
    
        var last time.Time                    // 记录上一次执行 scrapeAndReport() 的时刻
        ticker := time.NewTicker(sl.interval) // 每隔 scrape_interval 时长采集一次
        defer ticker.Stop()
    
    mainLoop:
        for {
            scrapeTime := time.Now().Round(0)   // 记录当前时刻,之后会赋值给 point.timestamp
            last = sl.scrapeAndReport(last, scrapeTime, errc) // 采集指标,并存入 memSerie
            ...
        }
        ...
    }
    
    • Prometheus 刚启动时,不会同时采集所有 target ,否则会造成很大的瞬时负载。相反,为了错峰,Prometheus 会为每个 target 配置一个随机的 offset ,等待 offset 时长之后才开始 scrapeLoop 。
      • 例如 scrape_interval 为 30s 时,go_goroutines{instance="10.0.0.1:9090", job="prometheus"} 的 scrapeTime 分别是第 2、32、62 秒,go_goroutines{instance="10.0.0.2:9090", job="prometheus"} 的 scrapeTime 分别是第 5、35、65 秒。
      • offset 由 target、Prometheus 的哈希值决定。这样能避免一个 Prometheus 同时采集所有 target 、多个 Prometheus 同时采集同一个 target 。
    • Prometheus 会记录从发出 HTTP 请求,到存入 memSeries 的耗时,称为 duration 。受网络延迟、exporter 运行速度的影响,duration 可能经常变化。
    • Prometheus 记录的 point.timestamp 是发出 HTTP 请求的时刻即 scrapeTime ,并不是 exporter 生成 metrics 的时刻。因此 Prometheus 不能准确测量 metrics 的时刻。
    • scrapeTime 会以 scrape_interval 为单位递增,不受 duration 影响。即使某次 scrape 超时,下一次 scrape 依然会按时进行。

# metrics

  • exporter 输出的 metrics 文本示例:

    # HELP go_goroutines Number of goroutines that currently exist.
    # TYPE go_goroutines gauge
    go_goroutines{instance="10.0.0.1:9090", job="prometheus"}   80
    go_goroutines{instance="10.0.0.2:9090", job="prometheus"}   90
    
    • # HELP <metric_name> <comment> 行用于声明该指标的注释,可以省略。
    • # TYPE <metric_name> <type> 行用于声明该指标的类型。
    • 每行指标的格式为 <metric_name>{<label_name>=<label_value>, ...} metric_value ,先是指标名称、标签,然后在任意个空格之后声明指标值。
      • metric_name 必须匹配正则表达式 [a-zA-Z_:][0-9A-Za-z_:]* ,一般通过 Recording Rules 定义的指标名称才包含冒号 :
      • exporter 生成多条用途相同的监控指标时,建议采用同一个 metric_name 、不同的 label_value ,然后通过 labels 来筛选。
      • label_value 只能用双引号 " 作为定界符,不能用单引号 ' 。
      • label_value 可包含 Unicode 字符。
      • metric_value 只能是数值,不支持 string 类型。
    • exporter 输出的 metrics 中可包含多行指标。
      • 相同 metric_name 的指标放在一起,放在一行 TYPE 之下。
      • 每行末尾不能存在空格,否则会被解析成最后一个字段。
      • 每行末尾要有换行符,最后一行也需要换行。
  • metrics 有多种用途:

    • Counter
      • :计数器,取值单调递增。
    • Gauge
      • :仪表,取值没有单调性,可以自由增减。
    • Histogram
      • :直方图。将时间平均分成一段段区间,将每段时间内的多个 points 取平均值再返回(这会增加 Prometheus 的 CPU 开销),相当于从散点图变成直方图。
      • 例如 prometheus_http_request_duration_seconds_count{} 10 表示 HTTP 请求的样本总数有 10 个。
      • 例如 prometheus_http_request_duration_seconds_sum{} 0.1 表示 HTTP 请求的耗时总和为 0.1s 。
      • 例如 prometheus_http_request_duration_seconds_bucket{le="60"} 10 表示 HTTP 请求中,耗时低于 60s 的有 10 个。
    • Summary
      • :汇总。将所有 points 按取值从小到大排列,然后返回其中几个关键位置的 points 的值(由 exporter 计算),相当于正态分布图。
      • 后缀通常为 ..._count..._sum
      • 例如 go_gc_duration_seconds{quantile="0.5"} 0.01 表示排在第 50% 位置的值,即中位数。说明 50% 的 gc 耗时不超过 0.01 。
      • 例如 go_gc_duration_seconds{quantile="0.99"} 0.02 表示排在第 99% 位置的值,该指标称为 p99 。
      • 例如 go_gc_duration_seconds{quantile="1"} 0.03 表示排在第 100% 位置的值,即最大值。
      • 例如对于网站的 HTTP 响应耗时,建议监控 p99 指标。
        • p100 最大值可能因为网络抖动而异常大,难以降低。
        • p99 更容易降低,而且代表了大部分用户的体验。
    • exemplar
      • :在 metrics 之后附加 traceID 等信息,便于链路追踪。
      • 该功能默认禁用。

# Series

  • Prometheus 会将采集到的每行 metric 文本,转换成一种数据结构:采样点(sample),又称为数据点(point)。这样便于写入 TSDB 时序数据库。

    • 例如 go_goroutines{instance="10.0.0.1:9090", job="prometheus"} 80 这行 metric ,会转换成一个 point ,表示成 JSON 格式如下:
      {
          "metric": {                       // 该 metric 的 label-value set
              "__name__": "go_goroutines",  // 将 metric_name 记录为内置标签 __name__
              "instance": "10.0.0.1:9090",
              "job": "prometheus",
          },
          "value": [
              1656109032.131,               // timestamp ,单位为秒,保留三位小数到毫秒
              "80"                          // value
          ]
      }
      
    • 假设在多个时刻采样一次 go_goroutines{instance="10.0.0.1:9090", job="prometheus"} 的值,得到多个 point 。
      • 这些 point 的 label-value set 相同,属于同一条指标。
      • 将这些 point 按时间顺序组成一个数组,就得到了一个时间序列(time series),可用于监控指标取值随时间变化的趋势。
  • 一个 Series 包含多个 point ,它们具有相同的 label-value set 。为了节省存储空间,只记录一份 label-value set ,再给每个 point 记录一份时间戳、取值。数据结构如下:

    type Series struct {
        Metric     labels.Labels  // 该 Series 的 label-value set
        Floats     []FPoint       // 该 Series 的 float 类型的 point 数组
        Histograms []HPoint
    }
    type Labels []Label     // Labels 是数组类型,包含一组 Label 对象
    type Label struct {
        Name, Value string
    }
    type FPoint struct {
        T int64             // timestamp 时间戳
        F float64           // float 类型的 Value
    }
    type HPoint struct {
        T int64
        H *histogram.FloatHistogram
    }
    
    • 目前 point 的取值有两种数据类型:float、histogram 。一个 Series 包含的所有 point 必须属于同一数据类型。
    • 一组相邻 point 的 timestamp 通常前缀相同,因此存储到磁盘时可以压缩。

# Vector

metrics 有多种数据结构:

  • string

  • 标量(scalar)

    • :一个数值,属于 float 数据类型。
    • 例如数值 1 属于 scalar 。
    • scalar 只有 value ,没有 timestamp、labels 信息,因此不能进行 delta() 等函数运算,常用于与 vector 进行算术运算。
  • 瞬时向量(instant vector)

    • :包含一组 time series 在单个时刻的所有 point 。
    • instant vector 简称为 vector ,数据结构如下:
      type Vector []Sample
      
    • 例:在 Prometheus 的 Graph 页面,查询 go_goroutines{job="prometheus"} 会显示多条曲线,对应多个 time series 。
      • 在图中任选一个时刻,该时刻包含多个 point ,属于不同的 time series 。
      • 同一时刻的多个 point 组成了一个 instant vector 。
      • 每个时刻分别有一个 instant vector 。
    • 例:go_goroutines{job="prometheus"} 查询结果表示成 JSON 格式如下:
      {
          "metric": {
              "__name__": "go_goroutines",
              "instance": "10.0.0.1:9090",
              "job": "prometheus",
          },
          "value": [
              1656109032.131,
              "80"
          ]
      },
      {
          "metric": {
              "__name__": "go_goroutines",
              "instance": "10.0.0.2:9090",
              "job": "prometheus",
          },
          "value": [
              1656109032.131,
              "85"
          ]
      }
      ,
      ...
      
  • 范围向量(range vector)

    • :包含一组 time series 在某段时间范围内的所有 point 。
    • range vector 简称为 matrix 。数据结构如下:
      type Matrix []Series
      
    • 例:go_goroutines{job="prometheus"}[1m] 属于 range vector ,它先从 TSDB 中读取 instant vector 格式的数据,然后转换成 range vector 格式的数据。
    • 例:go_goroutines{instance="10.0.0.1:9090", job="prometheus"}[1m] 也属于 range vector ,虽然只包含一个 time series ,表示成 JSON 格式如下:
      {
          "metric": {
              "__name__": "go_goroutines",
              "instance": "10.0.0.1:9090",
              "job": "prometheus",
          },
          "values": [
              [1656109032.131, "80"],
              [1656109062.503, "82"],
          ]
      }
      
    • instant vector 主要用于绘制曲线图。而 range vector 不能直接绘制曲线图,主要用于 delta()、rate() 等函数的计算。

# TSDB

# 目录结构

  • Prometheus 内置的时序数据库 TSDB 默认存储在 ${prometheus}/data/ 目录下,目录结构如下:
    data/
    ├── 01E728KFZWGDM7HMY6M2D26QJD/   # 一个 block 目录
    │   ├── chunks
    │   │   ├── 000001                # 持久化保存的数据文件,已压缩
    │   │   └── 000002
    │   ├── index                     # 索引
    │   ├── meta.json                 # 元数据,记录该 block 的 minTime、maxTime、numSeries 等信息
    │   └── tombstones                # 墓碑
    ├── 01BKGTZQ1HHWHV8FBJXW1Y3W0K/
    ├── chunks_head/
    ├── lock                          # Prometheus 启动时会在 data 目录下创建一个 lock 文件,禁止其它 Prometheus 进程同时读写 TSDB
    ├── queries.active
    └── wal/
        ├── 00000003                  # 预写日志文件,未压缩
        ├── 00000004
        └── checkpoint.000002/        # 检查点
    

# wal

  • Prometheus 采集到一批 metrics 数据时,首先将它们暂存在内存中。同时备份到磁盘的 wal 目录下,从而避免 Prometheus 终止时丢失内存中的数据。

    • wal 目录用于保存预写日志(Write Ahead Log ,WAL),分为多个文件(称为 segment ),文件名是从 0 开始递增的编号。
    • 每个文件的最大体积为 128MB ,当一个文件写满数据时就会创建一个新文件。
    • Prometheus 重启时,会读取 wal 目录,从而恢复数据到内存中。该过程通常耗时几十秒,Prometheus 会打印一条日志:Replaying WAL, this may take a while
  • wal 目录下的每个 segment 文件会保存 2 小时以上。

    • 每隔 2 小时,Prometheus 会执行一次持久化:创建一个随机编号的 block 目录,将内存中 2 小时范围内的数据压缩之后保存到 ${block}/chunks/ 目录下。
    • 如果持久化成功,则会删除 wal 目录下开头 2 小时范围内的一连串 segment ,并创建一个新的 checkpoint ,用于记录最后一个删除的 segment 的编号。

# memSeries

  • 对于采集到的每个 time series ,Prometheus 会在内存中分别创建一个数据结构 memSeries 来保存数据。如下:

    type memSeries struct {
        ref  chunks.HeadSeriesRef     // 当前的 seriesId
        lset labels.Labels            // 当前 time series 的 label-value set
        mmappedChunks []*mmappedChunk // 指向磁盘 data/chunks_head/ 目录下的所有 chunks ,以 MMAP 方式读取
        headChunk     *memChunk       // 指向内存中的 headChunk
        ...
    }
    
    • 一个 time series 包含多个 point ,保存在同一个 memSeries 中。该 memSeries 会保存一份 label-value set ,被这些 point 共享,然后在 chunks 空间中保存各个 point 的 timestamp、value 。
    • 新采集一个 point 时,
      • 如果它的 seriesId 匹配已有的某个 memSeries ,则以 append 方式写入该 memSeries 。否则,创建一个新的 memSeries 。
      • 将 point 写入 memSeries 的同时,也会写入 wal 预写日志文件,防止丢失。
  • 对于每个 time series ,计算其 label-value set 的哈希值,记作 seriesId 。

    • seriesId 是 time series 在 TSDB 数据库的主键、唯一索引,又称为 series ref 。
    • Prometheus 采集、查询时涉及的 seriesId 基数越大,在内存中创建的 memSeries 越多,导致 CPU、内存开销越多。
      • 采集时,平均每个 time series 占用 10KB 内存,可用 process_resident_memory_bytes / prometheus_tsdb_head_series 估算。
      • 因此,exporter 应该避免在 label-value set 中包含一些经常变化的值,比如 url、time 。
      • 查询时,除了减少 seriesId 基数,用户还应该减小时间范围。查询的时间范围越大,从磁盘读取的 chunks 越多,导致耗时、内存开销越多。
  • 每个 time series 通常每隔 scrape_interval 时长采集一个 point ,因此每个 memSeries 中保存的 point 逐渐增多,占用内存也增多,那么什么时候将内存中数据保存到磁盘呢?

    • 每个 memSeries 会创建多个 chunk 空间,每个 chunk 用于保存大约 120 个 point (此时压缩效率最好)。
    • 每个 memSeries 只有最新的一个 chunk 可供写入新数据,称为 headChunk 。
    • 当 headChunk 写入大约 120 个 point 时,就会以 MMAP 方式 flush 到磁盘的 data/chunks_head/ 目录下,然后在内存中创建一个新的 headChunk 。
      • 采用 MMAP 方式,既可以减少大量内存开销,又可以在之后转存为 block 时快速读取这些文件。
  • data/chunks_head/ 目录下保存了多个数据文件,每个文件的最大体积为 128MB 。

    • 每个文件可存储多个 chunk 数据,每个 chunk 的内容分为以下几部分:
      series ref <8 byte>     # 该 chunk 所属的 seriesId
      mint <8 byte, uint64>   # 最小时间戳。表示该 chunk 保存的这些 point 所处的时间范围
      maxt <8 byte, uint64>   # 最大时间戳
      encoding <1 byte>       # data 部分的压缩格式
      len <uvarint>           # data 部分的长度
      data <bytes>            # 将一些 point 的 timestamp、value 数据压缩之后保存在此
      CRC32 <4 byte>          # 对上述数据的校验码
      
    • 这些文件,加上内存中每个 memSeries 的 headChunk ,组成了 head_block 。
      • head_block 是 TSDB 中最新的一个 block ,保存在内存中。只有 head_block 可供写入新数据,其它 block 都持久化保存在磁盘中,不可写入数据。
    • 这些文件会保存 2 小时以上。每隔 2 小时,Prometheus 会执行一次持久化:
      • data/chunks_head/ 目录下开头 2 小时范围内的一连串文件,压缩为一个新的 block 目录。
      • 更新 wal 目录的 checkpoint 。
      • 在内存中执行垃圾收集,删除没有 chunk 数据的 memSeries 。这样能避免某些 time series 长时间没有新增 point ,却依然占用内存。

# block

  • 每个 block 目录最初保存 2 小时范围的数据、索引,但之后可能经过合并,保存更长时间范围的数据、索引。

    • ${block}/chunks/ 目录用于保存压缩之后的 point 数据,分为多个文件,每个文件包含多个 chunk 。文件名是从 0 开始递增的编号,每个文件的最大体积为 512MB 。
  • 因为顺序读写磁盘的速度比随机读写快多倍,Prometheus 一般不会修改 block ,只允许读取。

    • 以下情况会创建新的 block ,删除旧的 block :
      • 每个 block 存在一段时间后可能被进一步压缩。
      • 时间相邻的几个 block 可能被合并为一个 block 。
    • 当用户请求删除某些 points 数据时,Prometheus 不会立即修改 block 的数据文件,而是在 tombstones 文件中标记哪些数据待删除。等下一次压缩、合并 block 时,才从磁盘删除这些数据。
      • tombstones 用于标记删除某些 seriesId 在某些时间范围内的 point 。
      • 查询 block 时,会忽略 tombstones 标记的数据。

# index

  • 每个 block 目录下分别建立了一个 index 索引文件,记录以下信息:

    • 正排索引:记录该 block 存储的所有 time series ,每个 time series 记录以下信息:
      • seriesId
      • label-value set :该 time serie 包含哪些标签、值。
      • chunks :该 time series 的 point 数据存储在哪些 chunks 中,每个 chunk 定位到 ${block}/chunks/<id> 文件中的某个 offset 处。
    • 倒排索引:从每个 label-value 向 seriesId 建立倒排索引。
      • 例如记录:含有标签 __name__="go_goroutines" 的 seriesId 有 11、12、13 等,含有标签 job="prometheus" 的 seriesId 有 11、21、31 等。
  • index 文件可进行三种基本查询:

    LabelNames()        // 返回 block 中所有不同的 label_name
    LabelValues(name)   // 返回某个 label_name 所有不同的 label_value
    Select([]matcher)   // 返回 block 中匹配 matcher 的所有 point 数据
    
    • 当用户在 Promtheus 的 Web 页面上键入查询表达式时,会自动基于 LabelNames()LabelValues(name) 显示 autocomplete 提示。
    • 当用户执行查询表达式时,Prometheus 会在底层转换成多个 matcher ,基于 Select([]matcher) 查询数据。
    • 一个 matcher 同时只能查询一个 label_name ,有四种匹配条件:
      labelName="<value>"     # 字符串匹配
      labelName!="<value>"    # 反字符串匹配
      labelName=~"<regex>"    # 正则匹配。这里要求正则表达式匹配 label_value 整个字符串,相当于 ^<regex>$
      labelName!~"<regex>"    # 反正则匹配
      
  • 例如查询 delta(go_goroutines{instance="10.0.0.1:9090", job="prometheus"}[1m]) 时,Prometheus 的处理流程如下:

    1. 查询的时间范围为过去 1m ,因此在 TSDB 中找到在该时间范围内的所有 block 。
    2. 遍历上述 block ,读取 index 文件并进行以下查询:
      1. 查询含有标签 __name__="go_goroutines" 的所有 seriesId 。
      2. 查询含有标签 instance="10.0.0.1:9090" 的所有 seriesId 。
      3. 查询含有标签 job="prometheus" 的所有 seriesId 。
      4. 对上述几组 seriesId 取交集,筛选出当前 block 中同时满足上述标签的所有 seriesId ,暂存在内存中。
      5. 查询下一个 block 。
    3. 遍历上述 seriesId ,找到它们的 point 数据存储在 ${block}/chunks/ 目录下的哪些文件中,从磁盘读取这些 point 的 timestamp、value ,载入内存。
    4. 用 delta() 函数计算上述 point 。

# 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 ,开销大,可能导致网页卡顿。

# 查询表达式

  • 编写 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() 计算导数。
  • 关于 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 标记。
  • 关于 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 ,不包含在查询结果中。
  • 相关源代码 (opens new window)

    // 该函数用于读取单个 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 的误差。
  • 问题:用户查询 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 ,指标 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)
  • 相关源代码 (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() 函数需要遍历所有数据点的值,开销更大。