Prometheus storage model analysis

Prometheus is nowadays the most popular open source monitoring solution, we can easily to Prometheus as the core monitoring system to quickly build a complete set of monitoring indicators include capture, storage, query and alarms. Prometheus will be able to achieve a single instance of millions of samples per second, while support for fast query data collection, and for Kubernetes grab objects such frequent changes in the environment, Prometheus is the best choice. Obviously, to achieve these excellent properties are inseparable from the support of a good database design timing. This article will design and implement a database tsdb timing of Prometheus built to analyze, understand why it supports reading and writing Prometheus strong performance from architecture design, and code level.

1. Overview of the time series data

Prometheus is a timing to read and write data, compared with the general data object, the data series has its own peculiarities, this TSDb of a lot targeted design and optimization. So understanding is the first step to understand Prometheus series data storage model. It typically consists of two sample data and the identification of the following composition:

标识 -> {(t0, v0), (t1, v1), (t2, v2), (t3, v3)...}
  1. Identifier is used to distinguish the different monitoring index, commonly used in the index name Prometheus + label uniquely identifies a series of a time series. Prometheus is captured as a time series, which http_request_totalis the index name, represents the total number of HTTP requests, it pathand methodtwo label, the path and the method for indicating various requests.
http_request_total{path="/", method="GET"} -> {(t0, v1), (t1, v1)...}

In fact the last index name as a unique label is stored, it is key __name__, as shown below. Prometheus final time sequence identity stored in the database is a bunch of label. We see this heap label called series.

{__name__="http_request_total", path="/", method="GET"}
  1. Many sample data by sampling points (Prometheus called sample) configuration, t0, t1, t2 ... represent sample collection time, v0, v1, v2 ... collected at time index value is represented. Sampling time is generally monotonically increasing and adjacent the same sample interval often, Prometheus default is 15s. And generally adjacent sample index value v does not differ too much. Based on the characteristic sample data, it stores it to efficiently perform compression is entirely possible. Prometheus for sampling data compression algorithm, the reference database Facebook timing of Gorilla in practice by this method, 16 bytes of sample average of only 1.37 bytes of storage space.

2. Architectural Design

Monitoring data is a very strong time-sensitive data types, heat it can be queried over time and continue to lower, but also for monitoring indicators of access usually specify a time period, for example, the last fifteen minutes, and recently a hours, past day and so on. In general, the last hour of the collected data is to be accessed most frequently in the past day's data will be accessed often used to understand the overall volatility of a situation indicator, while the previous year and even month data is accessed the meaning is not very big.

Based on the above characteristics of the monitoring data, tsdb design is very easy to understand, its overall structure is as follows:

arch

For the latest data collected, Prometheus will directly store them in memory, thus speeding up the read and write data. But the memory space is limited, but as time goes by, the probability of memory in the older portion of the data being accessed is gradually reduced. Therefore, by default, every two hours, Prometheus will be part of the "old" data is persisted to disk, every persistent data stored in a separate Block disk. For example figure above block0 to store the [t0, t1] Prometheus all monitoring data acquisition period. The advantage of this is obvious, it can, so that greatly reduced if we want to find access to a data index in the range [t0, t2], then you only need to load data block0 and block1 and looking in range, thereby increasing the speed of queries.

虽然最近采集的数据存放在内存中能够提高读写效率,但是由于内存的易失性,一旦Prometheus崩溃(如果系统内存不足,Prometheus被OOM的概率并不算低)那么这部分数据就彻底丢失了。因此Prometheus在将采集到的数据真正写入内存之前,会首先存入WAL(Write Ahead Log)中。因为WAL是存放在磁盘中的,相当于对内存中的监控数据做了一个完全的备份,即使Prometheus崩溃这部分的数据也不至于丢失。当Prometheus重启之后,它首先会将WAL的内容加载到内存中,从而完美恢复到崩溃之前的状态,接着再开始新数据的抓取。

3. 内存存储结构

在Prometheus的内存中使用如下所示的memSeries结构存储时间序列,一条时间序列对应一个memSeries结构:

memseries

可以看到,一个memSeries主要由三部分组成:

  1. lset:用以识别这个series的label集合
  2. ref:每接收到一个新的时间序列(即它的label集合与已有的时间序列都不同)Prometheus就会用一个唯一的整数标识它,如果有ref,我们就能轻易找到相应的series
  3. memChunks:每一个memChunk是一个时间段内该时间序列所有sample的集合。如果我们想要读取[tx, ty](t1 < tx < t2, t2 < ty < t3 )时间范围内该时间序列的数据,只需要对[t1, t3]范围内的两个memChunksample数据进行裁剪即可,从而提高了查询的效率。每当采集到新的sample,Prometheus就会用Gorilla中类似的算法将它压缩至最新的memChunk

但是ref仅仅是供Prometheus内部使用的,如果用户要查询某个具体的时间序列,通常会利用一堆的label用以唯一指定一个时间序列。那么如何通过一堆label最快地找到对应的series呢?哈希表显然是最佳的方案。基于label计算一个哈希值,维护一张哈希值与memSeries的映射表,如果产生哈希碰撞的话,则直接用label进行匹配。因此,Prometheus有必要在内存中维护如下所示的两张哈希表,从而无论利用ref还是label都能很快找到对应的memSeries

{
	series map[uint64]*memSeries // ref到memSeries的映射
	hashes map[uint64][]*memSeries // labels的哈希值到memSeries的映射 } 

然而我们知道Golang中的map并不是并发安全的,而Prometheus中又有大量对于memSeries的增删操作,如果在读写上述结构时简单地用一把大锁锁住,显然无法满足性能要求。所以Prometheus用了如下数据结构将锁的控制精细化:

const stripSize = 1 << 14

// 为表达直观,已将Prometheus原生数据结构简化
type stripeSeries struct { series [stripeSize]map[uint64]*memSeries hashes [stripeSize]map[uint64][]*memSeries locks [stripeSize]sync.RWMutex }

Prometheus将一整个大的哈希表进行了切片,切割成了16k个小的哈希表。如果想要利用ref找到对应的series,首先要将ref对16K取模,假设得到的值为x,找到对应的小哈希表series[x]。至于对小哈希表的操作,只需要锁住模对应的locks[x],从而大大减小了读写memSeries时对锁的抢占造成的损耗,提高了并发性能。对于基于label哈希值的读写,操作类似。

然而上述数据结构仅仅只能支持对于时间序列的精确查询,必须严格指定每一个label的值从而能够唯一地确定一条时间序列。但很多时候,模糊查询才是更为常用的。例如,我们想知道访问路径为/的各类HTTP请求的数目(请求的方法可以为GETPOST等等),此时提交给Prometheus的查询条件如下:

http_request_total{path="/"}

如果路径/曾经接收了GET,POST以及DELETE三种方法的HTTP请求,那么此次查询应该返回如下三条时间序列:

http_request_total{path="/", method="GET"} ....
http_request_total{path="/", method="POST"} ....
http_request_total{path="/", method="DELETE"} ....

Prometheus甚至支持在指定label时使用正则表达式:

http_request_total{method="GET|POST"}

上述查询将返回所有包含label名为method,且值为GET或者POST的指标名为http_request_total的时间序列。

针对如此复杂的查询需求,暴力地遍历所有series进行匹配是行不通的。一个指标往往会包含诸多的label,每个label又可以有很多的取值。因此Prometheus中会存在大量的series,为了能快速匹配到符合要求的series,Prometheus引入了倒排索引,结构如下:

struct MemPostings struct {
	mtx	sync.Mutex
	m	map[string]map[string][]uint64 ordered bool }

当Prometheus抓取到一个新的series,假设它的ref为x,包含如下的label pair:

{__name__="http_request_total", path="/", method="GET"}

在初始化相应的memSeries并且更新了哈希表之后,还需要对倒排索引进行刷新:

MemPostings.m["__name__"]["http_request_total"]{..., x ,...} MemPostings.m["path"]["/"]{..., x ,...} MemPostings.m["method"]["GET"]{..., x, ...}

可以看到,倒排索引能够将所有包含某个label pair的series都聚合在一起。如果要得到匹配多个label pair的series,只要将每个label pair包含的series做交集即可。对于查询请求

http_request_total{path="/"}

的匹配过程如下:

MemPostings.["__name__"]["http_request_total"]{3, 4, 2, 1}
MemPostings.["path"]["/"]{5, 4, 1, 3}
{3, 4, 2, 1} x {5, 4, 1, 3} -> {1, 3, 4}

但是如果每个label pair包含的series足够多,那么对多个label pair的series做交集也将是非常耗时的操作。那么能不能进一步优化呢?事实上,只要保持每个label pair里包含的series有序就可以了,这样就能将复杂度从指数级瞬间下降到线性级。

MemPostings.["__name__"]["http_request_total"]{1, 2, 3, 4}
MemPostings.["path"]["/"]{1, 3, 4, 5}
{1, 2, 3, 4} x {1, 3, 4, 5} -> {1, 3, 4}

Prometheus内存中的存储结构大致如上,Gorilla的压缩算法提高了samples的存储效率,而哈希表以及倒排索引的使用,则对Prometheus复杂的时序数据查询提供了高效的支持。

WAL

Prometheus启动时,往往需要在参数中指定存储目录,该目录包含WAL以及用于持久化的Block,结构如下:

# ls
01DJQ428PCD7Z06M6GKHES65P2  01DJQAXZY8MPVWMD2M4YWQFD9T  01DJQAY7F9WT8EHT0M8540F0AJ  lock  wal

形如01DJQ428PCD7Z06M6GKHES65P2都是Block目录,用于存放持久化之后的时序数据,这部分内容后文会有详细的叙述,本节重点关注WAL目录,它的内部结构如下:

[wal]# ls -lht
total 596M
-rw-r--r-- 1 65534 65534  86M Aug 20 19:32 00000012
drwxr-xr-x 2 65534 65534 4.0K Aug 20 19:00 checkpoint.000006
-rw-r--r-- 1 65534 65534 128M Aug 20 19:00 00000011
-rw-r--r-- 1 65534 65534 128M Aug 20 18:37 00000010
-rw-r--r-- 1 65534 65534 128M Aug 20 17:47 00000009
-rw-r--r-- 1 65534 65534 128M Aug 20 17:00 00000008
-rw-r--r-- 1 65534 65534 128M Aug 20 16:38 00000007

WAL目录中包含了多个连续编号的且大小为128M的文件,Prometheus称这样的文件为Segment,其中存放的就是对内存中series以及sample数据的备份。另外还包含一个以checkpoint为前缀的子目录,由于内存中的时序数据经常会做持久化处理,WAL中的数据也将因此出现冗余。所以每次在对内存数据进行持久化之后,Prometheus都会对部分编号靠后的Segment进行清理。但是我们并没有办法做到恰好将已经持久化的数据从Segment中剔除,也就是说被删除的Segment中部分的数据依然可能是有用的。所以在清理Segment时,我们会将肯定无效的数据删除,剩下的数据就存放在checkpoint中。而在Prometheus重启时,应该首先加载checkpoint中的内容,再按序加载各个Segment的内容。

那么seriessampleSegment中是如何组织的呢?在将时序数据备份到WAL的过程中,由于涉及到磁盘文件Segment的写入,批量操作显然是最经济的。对于批量写入,Prometheus提供了一个名为Appender的接口如下:

type Appender interface {
	Add(l labels.Labels, t int64, v float64) (uint64, error) AddFast(ref uint64, t int64, v float64) error Commit() error Rollback() error }

每当需要写入数据时,就要创建一个AppenderAppender是一个临时结构,仅供一次批量操作使用。一个Appender类似于其他数据库中事务的概念,通过Add()或者AddFast()添加的时序数据会临时在Appender中进行缓存,只有在最后调用Commit()之后,这批数据才正式提交给Prometheus,同时写入WAL。而如果最后调用的Rollback(),则这批数据的samples会被全部丢弃,但是通过Add()方法新增的series结构则依然会被保留。

seriessampleAppender中是分开存储的,它们在Appender中的结构如下:

// headAppender是Appender的一种实现
type headAppender struct {
	...
	series	[]RefSeries
	samples	[]RefSample } type RefSeries struct { Ref uint64 Labels labels.Labels } type RefSample struct { Ref uint64 T int64 V float64 series *memSeries }

当调用AppenderCommit()方法提交这些时序数据时,seriessamples这两个切片会分别编码,形成两条Record,如下所示:

|RecordType|RecordContent|

RecordType可以取“RecordSample”或者“RecordSeries”,表示这条Record的类型

RecordContent则根据RecordType可以series或者samples编码后的内容

最后,seriessamplesRecord的形式被批量写入Segment文件中,默认当Segment超过128M时,会创建新的Segment文件。若Prometheus因为各种原因崩溃了,WAL里的各个Segment以及checkpoint里的内容就是在崩溃时刻Prometheus内存的映像。Prometheus在重启时只要加载WAL中的内容就能完全"恢复现场"。

Block

虽然将时序数据存储在内存中能够最大化读写效率,但是时序数据的写入是稳定而持续的,随着时间的流逝,数据量会线性增长,而且相对较老的数据被访问的概率也将逐渐下降。因此,定期将内存中的数据持久化到磁盘是合理的。每一个Block存储了对应时间窗口内的所有数据,包括所有的seriessamples以及相关的索引结构。Block目录的详细内容如下:

[01DJNTVX7GZ2M1EKB4TM76APV8]# ls
chunks  index  meta.json  tombstones
[01DJNTVX7GZ2M1EKB4TM76APV8]# ls chunks/
000001

meta.json包含了当前Block的元数据信息,其内容如下:

# cat meta.json
{
    "ulid": "01DJNTVX7GZ2M1EKB4TM76APV8",
    "minTime": 1566237600000,
    "maxTime": 1566244800000,
    "stats": {
        "numSamples": 30432619,
        "numSeries": 65064,
        "numChunks": 255203
    },
    "compaction": {
        "level": 1,
        "sources": [
            "01DJNTVX7GZ2M1EKB4TM76APV8"
        ]
    },
    "version": 1
}

各字段的含义如下:

ulid:用于识别这个Block的编号,它与Block的目录名一致

minTimemaxTime:表示这个Block存储的数据的时间窗口

stats:表示这个Block包含的sampleseries以及chunks数目

compaction:这个Block的压缩信息,因为随着时间的流逝,多个Block也会压缩合并形成更大的Block。level字段表示了压缩的次数,刚从内存持久化的Block的level为1,每被联合压缩一次,子Block的level就会在父Block的基础上加一,而sources字段则包含了构成当前这个Block的所有祖先Block的ulid。事实上,对于level >= 2的Block,还会有一个parent字段,包含了它的父Block的ulid以及时间窗口信息。

chunks是一个子目录,包含了若干个从000001开始编号的文件,一般每个文件大小的上限为512M。文件中存储的就是在时间窗口[minTime,maxTime]以内的所有samples,本质上就是对于内存中符合要求的memChunk的持久化。

tombstones用于存储对于series的删除记录。如果删除了某个时间序列,Prometheus并不会立即对它进行清理,而是会在tombstones做一次记录,等到下一次Block压缩合并的时候统一清理。

index文件存储了索引相关的内容,虽然持久化后的数据被读取的概率是比较低的,但是依然存在被读取的可能。这样一来,如何尽快地从Block中读取时序数据就显得尤为重要了,而快速读取索引并且基于索引查找时序数据则是加快整体查询效率的关键。为了达到这一目标,存储索引信息的index文件在设计上就显得比较复杂了。

┌────────────────────────────┬─────────────────────┐
│ magic(0xBAAAD700) <4b>     │ version(1) <1 byte> │
├────────────────────────────┴─────────────────────┤
│ ┌──────────────────────────────────────────────┐ │
│ │                 Symbol Table                 │ │
│ ├──────────────────────────────────────────────┤ │
│ │                    Series                    │ │
│ ├──────────────────────────────────────────────┤ │
│ │                 Label Index 1                │ │
│ ├──────────────────────────────────────────────┤ │
│ │                      ...                     │ │
│ ├──────────────────────────────────────────────┤ │
│ │                 Label Index N                │ │
│ ├──────────────────────────────────────────────┤ │
│ │                   Postings 1                 │ │
│ ├──────────────────────────────────────────────┤ │
│ │                      ...                     │ │
│ ├──────────────────────────────────────────────┤ │
│ │                   Postings N                 │ │
│ ├──────────────────────────────────────────────┤ │
│ │               Label Index Table              │ │
│ ├──────────────────────────────────────────────┤ │
│ │                 Postings Table               │ │
│ ├──────────────────────────────────────────────┤ │
│ │                      TOC                     │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘

除了文件开头的Magic Number以及版本信息,index文件可以分为7个部分,各部分的内容及作用如下:

TOC:虽然位于文件的末尾,但是TOC包含了整个index文件的全局信息,它存储的内容是其余六部分的位置信息,即它们的起始位置在index文件中的偏移量。

Symbol Table:一个symbol既可以是一个label的key,也可以是它的value,事实上Symbol Table存储的就是在[minTime, maxTime]范围内的samples所属的series的所有label的key和value集合,并且为每个symbol进行了编号。之所以要这样做,是因为后面在存储series以及Label Index等信息的时候,就不需要完整存储所有的label了,只需将label的key和value用对应的字符串在Symbol Table中的编号表示即可,从而大大减小了index文件的体积。

Series:存储的自然是series的相关信息,首先存储series的各个label,正如上文所述,存储的是对应key和value在Symbol Table中的编号。紧接着存储series相关的chunks信息,包含每个chunk的时间窗口,以及该chunk在chunks子目录下具体的位置信息。

Label Index:存储了各个label的key和它所有可能的value的关联关系。例如,对于一个有着四个不同的value的key,它在这部分存储的条目如下所示:

┌────┬───┬───┬──────────────┬──────────────┬──────────────┬──────────────┬───────┐
│ 24 │ 1 │ 4 │ ref(value_0) | ref(value_1) | ref(value_2) | ref(value_3) | CRC32 |
└────┴───┴───┴──────────────┴──────────────┴──────────────┴──────────────┴───────┘
各段含义如下:
24 --> 存储的内容包含24个字节
1 --> 本条目仅仅包含一个key
4 --> 与keys相关的有4个value
ref -> 各个value在Symbol Table中的编号

事实上这样一个条目可以存储多个key和它们的value的映射关系,但一般key的个数都为1。这部分的内容乍一看非常让人疑惑,key的信息呢?为什么只记录key的数目,而没有保存具体的key,哪怕是它在Symbol Table中的编号?其实,我们应该将这部分的内容和Label Index Table中的内容联合起来看。

Label Index Table:存储了所有label的key,以及它们在Label Index中对应的位置信息。那么为什么要将这两部分的内容分开存储呢?Prometheus在读取Block中的数据时会加载index文件,但是只会首先加载Label Index Table获取所有label的key,只有在需要对key相关的value进行匹配时,才会加载Label Index相应的部分以及对应的Symbol。通过Label Index TableLabel Index的分离,使得我们能够只对必要数据进行加载,从而加快了index文件的加载速度。

Postings: 这部分存储的显然是倒排索引的信息,每一个条目存储的都是包含某个label pair的所有series的ID。但是与Label Index相似,条目中并没有指定具体的key和value。

Postings Offset Table:这部分直接对每个label的key和value以及相关索引在Postings中的位置进行存储。同样,它会首先被加载到内存中,如果需要知道包含某个label的所有series,再通过相关索引的偏移位置从Postings中依次获取。

可以看到,index文件结构颇为复杂,但其实设计得相当巧妙,环环相扣。不仅高效地对索引信息进行了存储,而且也最大限度保证了对它进行加载的速度。

总结

Prometheus compact design makes it possible to efficiently mass-series data read and write. However, throughout the analysis down, Prometheus and no so-called "black technology" type of innovation, but there is "every mountain open, water bypass" type of optimization. And that itself is Prometheus design philosophy, "Do one thing and do it well". In fact, Prometheus default only supports local storage for 15 days, over this period the Block will be cleared. Of course, this community has provided a solution, Thanos and Cortex are based Prometheus was extended to provide persistent storage, high availability and other characteristics, which can truly "Prometheus As A Service".

references

Guess you like

Origin www.cnblogs.com/YaoDD/p/11391335.html