# 原理

  • 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 。