# 原理
- 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 。
- 例如 Prometheus 本身也提供了 exporter 端口,执行
# 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 接口的响应中。
- exporter
Prometheus scrape 的工作流程:
- Prometheus 发出 HTTP 请求。
- HTTP 请求经过网络传输,到达 exporter 。
- exporter 开始统计 metrics ,生成 HTTP 响应。
- HTTP 响应经过网络传输,到达 Prometheus 。
- 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 。
- 例如 scrape_interval 为 30s 时,
- Prometheus 会记录从发出 HTTP 请求,到存入 memSeries 的耗时,称为 duration 。受网络延迟、exporter 运行速度的影响,duration 可能经常变化。
- Prometheus 记录的 point.timestamp 是发出 HTTP 请求的时刻即 scrapeTime ,并不是 exporter 生成 metrics 的时刻。因此 Prometheus 不能准确测量 metrics 的时刻。
- scrapeTime 会以 scrape_interval 为单位递增,不受 duration 影响。即使某次 scrape 超时,下一次 scrape 依然会按时进行。
- Prometheus 刚启动时,不会同时采集所有 target ,否则会造成很大的瞬时负载。相反,为了错峰,Prometheus 会为每个 target 配置一个随机的 offset ,等待 offset 时长之后才开始 scrapeLoop 。
# 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 类型。
- metric_name 必须匹配正则表达式
- 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 等信息,便于链路追踪。
- 该功能默认禁用。
- Counter
# 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 的编号。
- 每隔 2 小时,Prometheus 会执行一次持久化:创建一个随机编号的 block 目录,将内存中 2 小时范围内的数据压缩之后保存到
# 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 占用 10KB 内存,可用
每个 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 ,却依然占用内存。
- 将
- 每个文件可存储多个 chunk 数据,每个 chunk 的内容分为以下几部分:
# 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 标记的数据。
- 以下情况会创建新的 block ,删除旧的 block :
# 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 等。
- 例如记录:含有标签
- 正排索引:记录该 block 存储的所有 time series ,每个 time series 记录以下信息:
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>" # 反正则匹配
- 当用户在 Promtheus 的 Web 页面上键入查询表达式时,会自动基于
例如查询
delta(go_goroutines{instance="10.0.0.1:9090", job="prometheus"}[1m])
时,Prometheus 的处理流程如下:- 查询的时间范围为过去 1m ,因此在 TSDB 中找到在该时间范围内的所有 block 。
- 遍历上述 block ,读取 index 文件并进行以下查询:
- 查询含有标签
__name__="go_goroutines"
的所有 seriesId 。 - 查询含有标签
instance="10.0.0.1:9090"
的所有 seriesId 。 - 查询含有标签
job="prometheus"
的所有 seriesId 。 - 对上述几组 seriesId 取交集,筛选出当前 block 中同时满足上述标签的所有 seriesId ,暂存在内存中。
- 查询下一个 block 。
- 查询含有标签
- 遍历上述 seriesId ,找到它们的 point 数据存储在
${block}/chunks/
目录下的哪些文件中,从磁盘读取这些 point 的 timestamp、value ,载入内存。 - 用 delta() 函数计算上述 point 。