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 的存储中,有一些概念非常重要,分别是:

2. TSDB 概览

在开始 TSDB 的内部介绍之前,先了解一下 TSDB 的整体概览,首先,Prometheus 抓取的数据可以抽象成:

也就是说,每次抓取,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 的数据,类似于:

[root@liqiang.io]# cat {__name__="requests&#95;total", path="/status", method="GET", instance=”10.0.0.1:80”}.data
1578407862 101
1578407892 105
... ...

但是这个方案有很大的缺陷,这里列举几个:

基于这个方案的不好,于是 TSDB 采用了一个全新的方案。

3.2 分块存储

在现在的 TSDB 存储方案中,TSDB 的数据被根据时间段分割成一个个目录,每个目录内部都放着完整的一段时间的数据,每个目录的数据时间是不重叠的。

这样的好处就是存取,删除都很方便。如果要查询数据,那么可以根据查询的时间按需读取部分数据,而不用每次都读取全部目录的数据;并且,如果要删除多久以前的数据,那么直接删除对应时间段的目录就可以了,而不需要删除的目录根本不需要改变,就是这么方便。

这里有一个隐含的缺陷,那就是这里说了 50 小时的目录之后就不压缩了,如果你存放的数据太久的话,迟早也是 too many open file 的,Prometheus 官方对此的解释是,你不要用我原生的存储存放太久的数据(默认的存放周期是 15 天,超过 15 天的数据会被定期清除)。如果你想存放长期的数据,那么请使用通过 Remote Storage 使用第三方存储软件来存放。

因为每个目录都是独立的目录,数据可以独立拿来使用,所以这里有必要了解一下目录的内部情况是怎样的,这里尝试看一个目录的结构看看:

[root@liqiang.io]# tree ./data
./data
├── b-000001
│   ├── chunks
│   │   ├── 000001
│   │   ├── 000002
│   │   └── 000003
│   ├── index
│   └── 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"

所以,真实存储在 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。然后,在文中我提到了三个比较重要的处理方式,分别是:

可能前面两个大家比较熟悉,第三个不怎么听过,其实这个和合并排序的原理是一个意思,稍微考虑一下即可理解。

6. Ref