Prometheus

prometheus 学习调研

时序数据库的根本问题

Log Structured Merge (LSM) Tree & Usages in KV Stores 中,我曾提到过一切数据库的奇技淫巧都是在解决内存与磁盘的读写模式、性能的不匹配。时序数据库也是数据库的一种,自然不能除外。但时序数据库存储的数据有更特殊的读写特征,Björn Rabenstein 将称其为:

Vertical writes, horizontal(-ish) reads

翻译过来就是:

垂直写,水平读

如下图所示:

The Fundamental Problem of TSDBs

每个时序数据库都需要记录成千上万组时序数据,图中每条横线就表示写入数据库的一组时序,在云原生环境下,新的时序可能随时出现,老的时序可能随时消失,一些时序也可能出现中断后恢复的情况,这也是为什么图中的横线时断时续。

"垂直写" 指的是新的数据总是写在每组时序数据的末端,且每个写入周期,每组活跃的时序数据都会有写入。因此写入数据的形式可以表示为图中非绿色的长方形。

"水平读" 指的是用户在查询数据时,总是查询在某个时间范围内的一组或少数几组时序数据,并非每组活跃的时序数据都会被读取,但只要读取,一组时序在一段时间内的数据都将被连续访问。因此读取数据的形式可以表示为图中绿色的长方形。

"垂直写,水平度" 的不同解决方案,也成就了当今市面上相互竞争的时序数据库。

时序数据的格式

每条时序样本数据通常由 4 部分构成:

组成部分

用途

传统数据库中对应概念

时序名称 (time series name)

标识一组时序数据

数据表

标签 (labels/dimensions)

标识时序样本的特征

字段

时间戳 (timestamp)

标识采样的时间点

时间戳字段

数据值 (value)

标识采样的数据值

数值字段

可以看出,关系型数据是时序数据的超集,因此我们也可以将时序数据存储在关系型数据库中。但关系型数据库是通用数据库,它的设计并没有针对时序数据的读写模式进行特别优化。"垂直写" ,即同时向成千上万张表中写入少量数据并非其强项。

许多场景需要通过标签对时序数据做过滤、聚合,且这些标签还可能动态地增减。放在关系型数据库背景下,就意味着需要随时创建、删除数据表,还要建立多个索引。此外,标签之间通常没有稳定的查询顺序,意味着可能需要重复建立多种索引,而数量众多的索引又提高了数据写的成本。

存储层

设计迭代

考虑到 Prometheus 通常应用于云原生环境下的服务监控,它本身最好能够足够稳定,自给自足,不依赖外部服务。因此它在设计之初做的一个重要决定就是将自己定位为单机数据库。

1st Generation

由于时序数据天然地有一个重要索引 --- 时间戳,最简单的解决方案就是使用市面上成熟的键值数据库。google 为想要使用 bigtable 存储海量时序数据的用户提供了标准的 schema 设计方案,如下图所示:

将每条样本数据的时序名称、标签以及时间戳按顺序拼接成 key,再将样本数据值作为 value。如此一来,只要数据库中的所有数据按顺序存放,那么时序数据就会依次按照时序名称、标签、时间戳排列在一起。满足时序数据库的查询要求,且键值数据库通常基于 LSM 树设计,拥有很好的读写性能平衡,能基本解决时序数据库的根本问题。

在 prometheus 的原型版本中,所有时序数据,及元数据索引都存储在 LevelDB 中。但从数据中我们也看到了大量冗余:

  • 时序名、标签名在每条样本中都被存储一次 (实际上 LevelDB 会对其做一定的压缩)

  • 时间戳、数据值都是直接存储,没利用差值压缩

2nd Generation

基于 prometheus 一代,二代只将元数据索引存放在 LevelDB 中,但针对时序数据则构建了自己的存储层。二代将时序数据分块存放在多个文件中。为了迎合文件系统,每组时序数据被划分为固定大小的分块,按照 2 小时的区间存放在不同文件夹中。其文件结构如下所示:(详情参见 prometheus docs: storage)

./data
├── 01BKGV7JBM69T2G1BGBGM6KB12
│ └── meta.json
├── 01BKGTZQ1SYQJTR4PB43C8PD98
│ ├── chunks
│ │ └── 000001
│ ├── tombstones
│ ├── index
│ └── meta.json
├── 01BKGTZQ1HHWHV8FBJXW1Y3W0K
│ └── meta.json
├── 01BKGV7JC0RY8A6MACW02A2PJD
│ ├── chunks
│ │ └── 000001
│ ├── tombstones
│ ├── index
│ └── meta.json
└── wal
├── 00000002
└── checkpoint.000001

也可以看出,二代对文件系统的依赖比较重。

除此之外,为了能够在单机环境下存储更多组时序数据,prometheus 二代在内存和磁盘中的时序数据都提出了相应的压缩方案。详情可参见 Björn Rabenstein 在 PromCon 2016 上的分享。(TODO)

3rd Generation

Prometheus 三代完全定制化设计了自己的时序数据库,增加复杂的定制化索引,不再重度依赖文件系统,而是利用 mmap 将少数的文件映射到内存中。详情可参见三代引擎作者 Fabian Reinartz 的这篇博客。(TODO)

查询语言

Prometheus 设计了自己的查询语言。其开发团队认为强行使用类似 SQL 的查询语句来描述时序数据的查询显得很蹩脚。

查询示例

示例1:QPS

# prometheus
rate(incoming_http_requests_total[5m])
# sql-like
SELECT job, instance, method, status, path, client, version, [...] rate(value, 5m)
FROM incoming_http_requests_total

示例2:Aggregation

# prometheus
avg by(city) (temperature_celsius{country="germany"})
# sql-like
SELECT city, AVG(value)
FROM temperature_celsius
WHERE country = "germany"
GROUP BY city

示例3:Error Rate

# prometheus
errors{job="foo"}/total{job="foo"}
# sql-like
SELECT errors.job, errors.instance, […more labels…], errors.value / total.value
FROM errors, total
WHERE errors.job="foo" AND total.job="foo"
JOIN […some more complicated stuff here…]

示例4:计算

# prometheus
some_metric + 1
log10(some_metric)
my_a - my_b
# graphite
offset(some_metric.*, 1)
logarithm(some_metric.*)
reduceSeries(my.*, "diffSeries", 1, "a", "b")
# sql-like
SELECT 1 + "value" FROM "some_metric"
n/a
SELECT "a" - "b" FROM "table"

示例5:复杂计算

# 找到所有超过平均值 2 个标准差的数据
# prometheus
temperature_celsius > without(instance) group_left
2 * stddev ignoring (instance) (temperature_celsius)
+ avg ignoring (instance) (temperature_celsius)
# graphite
n/a
# sql-like
n/a

查询引擎

一个查询的执行过程如下图所示:

用户通过 HTTP 接口访问 Prometheus 服务,PromQL Engine 解析查询语句后通过 Storage Layer 从磁盘中读取所需的时序数据,然后在内存中完成相应的计算,返回给用户。

在 PromQL Engine 中,系统会将用户的查询语句解析后转化成两种查询:

type Querier interface {
QueryRange(
ctx context.Context, from, through model.Time,
matchers ...*metric.LabelMatcher,
) ([]SeriesIterator, error)
QueryInstant(
ctx context.Context, ts model.Time, stalenessDelta time.Duration,
matchers ...*metric.LabelMatcher,
) ([]SeriesIterator, error)
}
  • QueryRange:查询一段时间内的数据

  • QueryInstant:查询一个时间点上的数据

PromQL 在解析查询语句中的选择器后,会将其转化成一组 LabelMatcher,后者负责决定最终需要读取的时序数据。

之前提到过,每组时序数据都有其元数据的索引,后者至少包括:

  • Label Name => Label Values:某个标签对应的所有取值

  • Label Pair => Time Series:某组标签 (Label Name, Label Value) 对应的包含它的所有时序

QueryRange/QueryInstant 的执行过程可以概括如下:

  1. 利用 Label Name => Label Values 的映射关系,决定 LabelMatchers 匹配到的 Label Pair

  2. 利用 Label Pair => Time Series 的映射关系,得到所需查询的时序列表

  3. 从磁盘中读取所有命中的时序数据在给定查询时间范围内的所有数据块,载入内存并锁定

  4. 返回所有时序的 iterators

  5. 计算查询结果

  6. 关闭 iterators,并解除相应数据块的锁定,允许它们被系统回收

一个具体的例子如下图所示:

References