0. 概述
因为在工作中用的是 prometheus,所以总会遇到一些无法直接从 ticket 中解决的坑,于是乎就需要自行阅读源码,其中一块就是 prometheus 是如何存储数据的,本文是理论篇,从理论上介绍 prometheus 的存储实现。
我们是从 Prometheus 0.X 版本开始使用 Prometheus,当然,中间的开发阶段也是经历了一次 Prometheus 的数据存储版本变更,但是,因为我们没有上生产,所以对于我们影响比较小。虽然对于我们影响比较小,但是,这里还是提一句,到目前 prometheus 稳定使用的 V3 版本,Promtheus 已经改了两次存储了。
其中,以前有一个单独的项目叫做 TSDB,但是,在 2.1x 的某个版本,已经不单独维护这个项目了,直接将这个项目合并到了 Prometheus 的主干上了。所以,如果你想看最新的存储代码,那么得去 Prometheus 的 Master 分支上阅读了。本文不涉及源码查看,而是总结和介绍一下 Prometheus 的开发人员的一篇介绍存储的文章:Writing a Time Series Database from Scratch,希望对你有所帮助。同时,也有人翻译了中文版,我已经做了快照:从头编写一款时间序列数据库,原地址是:从头编写一款时间序列数据库。
1. 重要概念
在理解 Prometheus 的存储之前,我希望你是知道 Prometheus 是如何使用的,例如理解 Label 的概念,如果这个概念都无法理解的话,我想,后面的理论应该是看不下去的。在 Prometheus 的存储中,有一些概念非常重要,分别是:
- 标签(Label):一个 <string, string> 的 KV
- 系列(Series):好多个 Label 的集合,{k1: v1, k2: v2, …}
- 数据块(Chunk):TSDB 在磁盘中存储的 <T, V> 数据
- Index:TSDB 用于定位 Label 和 Series 以及对应的 <T, V> 时间系列的位置
- meta.json:人类可读的用于了解一个 Chunk 包含的数据范围的描述文件
2. TSDB 概览
在开始 TSDB 的内部介绍之前,先了解一下 TSDB 的整体概览,首先,Prometheus 抓取的数据可以抽象成:
- < Sereis, T, V >
也就是说,每次抓取,Prometheus 都会有这么一个数据对,对应到我们能够看明白的方式就是:
requests_total{path=”/status”, method=”GET”, instance=”10.0.0.1:80”} 1578407862 101
意思就是在 1578407862 这个时间点,10.0.0.1:80 这个实例上,GET /status
这个请求的次数累计是 101 次。
但是,requests_total{path=”/status”, method=”GET”, instance=”10.0.0.1:80”} 这个格式还不是 Series,因为前面的概念说了 Sereis 是 Label 的集合,所以 TSDB 会将这个格式转换为:
{__name__=”requests_total”, path=”/status”, method=”GET”, instance=”10.0.0.1:80”}
这样就是 Label 的集合了,实际上,在 TSDB 内部实现中也是这么存储的。
3. TSDB 存储
3.1 朴素的存储
现在,我们已经有了很多类似于这样的数据了:
{__name__=”requests_total”, path=”/status”, method=”GET”, instance=”10.0.0.1:80”} 1578407862 101
那么,很显然,对于这一条数据来说,Series 是固定的,也就是说这个东西可能是一直都存在的,而每次采集后变化的部分就是后面这两个 <T, V>,于是早期的 TSDB 存储的操作就是每个 Series 一个文件,每个文件里面存储 T 和 V 的数据,类似于:
[[email protected]]# cat {__name__="requests_total", path="/status", method="GET", instance=”10.0.0.1:80”}.data
1578407862 101
1578407892 105
... ...
但是这个方案有很大的缺陷,这里列举几个:
- 文件会非常多,早晚 too many open file,尤其是 K8S 这种动态变化的环境,会有非常多的 Series;
- 即使使用缓存,对于每个 Series,超过 1K 数据才持久化,但是,也会导致每秒的持久化文件非常多;
- 对于大多数(99%)数据来说,可能超过 24 小时之后就不用了,因为监控,很多时候在乎的是实时值;
- 删除旧数据非常麻烦,例如删除两周前的数据,需要每个文件都操作;
- 做 checkpoint 恢复会很麻烦,很耗时;
基于这个方案的不好,于是 TSDB 采用了一个全新的方案。
3.2 分块存储
在现在的 TSDB 存储方案中,TSDB 的数据被根据时间段分割成一个个目录,每个目录内部都放着完整的一段时间的数据,每个目录的数据时间是不重叠的。
- 默认情况下,每个目录的最小时间是 2 小时
- 如果 2 小时的目录多了之后,会合并一下,每 5 个合并成一个 10 小时的目录
- 10 小时的目录多了之后,也会每 5 个合并成一个 50 小时的目录,之后就不合并了
这样的好处就是存取,删除都很方便。如果要查询数据,那么可以根据查询的时间按需读取部分数据,而不用每次都读取全部目录的数据;并且,如果要删除多久以前的数据,那么直接删除对应时间段的目录就可以了,而不需要删除的目录根本不需要改变,就是这么方便。
这里有一个隐含的缺陷,那就是这里说了 50 小时的目录之后就不压缩了,如果你存放的数据太久的话,迟早也是 too many open file 的,Prometheus 官方对此的解释是,你不要用我原生的存储存放太久的数据(默认的存放周期是 15 天,超过 15 天的数据会被定期清除)。如果你想存放长期的数据,那么请使用通过 Remote Storage 使用第三方存储软件来存放。
因为每个目录都是独立的目录,数据可以独立拿来使用,所以这里有必要了解一下目录的内部情况是怎样的,这里尝试看一个目录的结构看看:
[[email protected]]# tree ./data
./data
├── b-000001
│ ├── chunks
│ │ ├── 000001
│ │ ├── 000002
│ │ └── 000003
│ ├── index
│ └── meta.json
可以看到,在每个目录中,都存在这么三部分,分别是:
- chunks:这是一个目录,里面保存着的只是 T 和 V 的数据
- index:这里保存了 Label 和 Series 的数据,以及如何查找他们的 <T, V> 数据
- meta.json:里面保存的是人类可读的关于这个目录的数据,例如保存的时间段是哪到哪的
这里最复杂的就数 index 了,至于里面有什么,这里先不深究,后面马上都会介绍到的。
3.3 Label 查询
既然每个目录都是完整的数据,那么如果我要查询一个数据,TSDB 是如何操作的?假设我的查询条件就是一个 Label:__name__=“requests_total”
,这个请求在 Prometheus 中很常见。
对于 TSDB 来说,因为在一个目录中保存了很多 Series,如果想要根据一个 Label 来查询对应的所有 Series,那么就是一个比较麻烦的事情,但是,并不是说不可能,这里采用了一种 倒排索引 的方法,TSDB 为每个 Series 中的所有 Label 都建立了一个倒排索引,这样,例如这个 Series:{__name__="requests_total", path="/status", method="GET", instance=”10.0.0.1:80”}
就会有这么多个倒排索引:
Label | Series |
---|---|
__name__="requests_total" |
{__name__="requests_total", path="/status", method="GET", instance=”10.0.0.1:80”} |
path="/status" |
{__name__="requests_total", path="/status", method="GET", instance=”10.0.0.1:80”} |
method="GET" |
{__name__="requests_total", path="/status", method="GET", instance=”10.0.0.1:80”} |
instance=”10.0.0.1:80” |
{__name__="requests_total", path="/status", method="GET", instance=”10.0.0.1:80”} |
但是,可以发现,每个倒排索引后面都是一串的 Label 很不方便,而且还有一个问题,如果我要的是一个组合查询呢,例如我的查询是:__name__=“requests_total” AND app =“nginx”
,这得分别根据这两个 Label 的倒排索引查询出对应的所有Series,然后再求并集,这就很不方便了。这里 TSDB 做了另外一个优化就是 正向索引,也就是给每个 Series 分配了一个 ID,这样,倒排索引就可以很简单了:
Label | SeriesID |
---|---|
__name__="requests_total" |
1001 |
path="/status" |
1001 |
method="GET" |
1001 |
instance=”10.0.0.1:80” |
1001 |
不用直接引用 Series 了,直接用 Series 的 ID 代替。需要注意的是,倒排索引的值是一个数组,因为一个 Label 可能对应多个 Sereis,例如下面这两个 Series 都携带了 Label:__name__="requests_total"
:
{__name__="requests_total", path="/status", method="GET", instance=”10.0.0.1:80”}
{__name__="requests_total", path="/status", method="GET", instance=”10.0.0.3:80”}
所以,真实存储在 Index 文件的倒排索引是这样的:
Label | SeriesID |
---|---|
__name__="requests_total" |
[1001, 1005] |
3.4 组合查询
这里问题又来了,回到 2.3 中的 Label 查询,如果查询的语句是:__name __ =“requests_total” AND app =“nginx”
,那么分别找出对应的倒排索引:
Label | SeriesID |
---|---|
__name__="requests_total" |
[1001, 1005] |
app =“nginx” |
[1001, 1005, 2000] |
那么再求交集,这个复杂度不是 O(N2)了吗?这里 TSDB 又做了一个优化,就是保证在 Index 中,倒排索引中的 SeriesID 是有序的,如果双方的 SereisID 都是有序的,那么采取 ZigZag 的查找方式,可以保证在 O(N) 的时间复杂来找到最终的结果。
这里有个难点就是保证 Label 的倒排索引中的 SeriesID 是有序的,TSDB 对此的处理方式是只有在 Index 被完全读入内存的情况下,才会写出这个 Index 文件,而这通常在两种情况下出现,分别是 写出内存WAL 和 压缩持久数据 的时候。
4. WAL
虽然 TSDB 的目录结构很好,但是,TSDB 对目录结构的定义是只读,不能修改,只有两种情况下才有写操作:
- 创建这个目录的时候,有创建写操作
- 压缩多个目录的时候,有压缩写操作
在平时,是不可能修改这个目录的,那么问题就来了,Prometheus 实时采集的数据要怎么办?TSDB 的处理方式是,将一段时间内的数据放置在内存中,当内存中的数据达到一定时间(默认 2 小时)的时候,就写出到一个新的目录中。但是,作为开发人员,我们都清楚,内存是不可靠的,毕竟这是两个小时的数据,万一崩了再重启,数据不就丢了,而且很多时候都是系统有一些需要保留的状态的时候才会导致 Prometheus 崩溃,例如系统负载额外高的时候,特别需要监控数据,结果你丢失了,这肯定是不行的。
TSDB 的处理方式就是通过 mmap,同时在内存和 WAL 中保存数据,这样可以保证数据的持久不丢失,而且因为时间也短,崩溃之后从故障中恢复时间也可以很快。
5. 小结
本文根据我自身的理解解读了一下 TSDB 的设计文章,除了前面提到的那篇文章,我觉得对我有比较大帮助的可能就是 TSDB 的文档了:TSDB Format。然后,在文中我提到了三个比较重要的处理方式,分别是:
- 正向索引
- 倒排索引
- ZigZag 遍历
可能前面两个大家比较熟悉,第三个不怎么听过,其实这个和合并排序的原理是一个意思,稍微考虑一下即可理解。