Prometheus TSDB storage principle

Prometheus includes a time series database stored on the local disk, and also supports integration with remote storage systems, such as grafana cloudthe free cloud storage API provided by , just remote_writefill in the interface information in the Prometheus configuration file.

image-20220412141006992

This article does not involve the content of the remote storage interface, but mainly introduces the implementation principle of local storage of Prometheus time series data.

What is time series data?


Before learning the storage principle of Prometheus TSDB, let's first understand what the time series data of time series databases such as Prometheus TSDB and InfluxDB refer to?

Time series data usually appears in the form of (key, value), a set of corresponding values ​​at the time series collection points, that is, each data point is a tuple consisting of a timestamp and a value.

identifier->(t0,v0),(t1,v1),(t2,v2)...
复制代码

Data Model for Prometheus TSDB

<metric name>{<label name>=<label value>, ...} 
复制代码

specific to an instance

requests_total{method="POST", handler="/messages"}  
复制代码

When storing, it can be marked by name label metric name, and then marked by the identifier @, which constitutes a complete time series data sample.

 ----------------------------------------key-----------------------------------------------value---------
{__name__="requests_total",method="POST", handler="/messages"}   @1649483597.197             52
复制代码

A time series is a sequence of strictly monotonically increasing data points in time, which can be addressed by a metric. Abstracted into a two-dimensional plane, the horizontal axis of the two-dimensional plane represents the monotonically increasing time, metricscovering the entire vertical axis. When extracting sample data, the value can be obtained as long as the time window and metric are given

series

How is time series data stored in Prometheus TSDB?


Above we have a brief understanding of time series data, then we expand Prometheus TSDB storage (V3 engine)

Prometheus TSDB overview

image-20220413104124771

In the above figure, the Head block is the memory block of TSDB, and the gray block Block is the persistent block on disk.

首先传入的样本(t,v)进入 Head 块,为了防止内存数据丢失先做一次预写日志 (WAL) ,并在内存中停留一段时间,然后刷新到磁盘并进行内存映射(M-map) 。当这些内存映射的块或内存中的块老化到某个时间点时,会作为持久块Block存储到磁盘。接下来多个Block在它们变旧时被合并,并在超过保留期限后被清理。

Head中样本的生命周期

image-20220413120050962

当一个样本传入时,它会被加载到Head中的active chunk(红色块),这是唯一一个可以主动写入数据的单元,为了防止内存数据丢失还会做一次预写日志 (WAL)

image-20220413120803681

一旦active chunk被填满时(超过2小时或120样本),将旧的数据截断为head_chunk1。

image-20220413121223066

head_chunk1被刷新到磁盘然后进行内存映射。active chunk继续写入数据、截断数据、写入到内存映射,如此反复。

image-20220413121732282

内存映射应该只加载最新的、最被频繁使用的数据,所以Prometheus TSDB将就是旧数据刷新到磁盘持久化存储Block,如上1-4为旧数据被写入到下图的Block中。

image-20220413113035412

此时我们再来看一下Prometheus TSDB数据目录基本结构,好像更清晰了一些。

./data
├── 01BKGV7JBM69T2G1BGBGM6KB12    
│   └── meta.json
├── 01BKGTZQ1SYQJTR4PB43C8PD98   # block ID
│   ├── chunks         # Block中的chunk文件
│   │   └── 000001     
│   ├── tombstones     # 数据删除记录文件
│   ├── index          # 索引
│   └── meta.json      # bolck元信息
├── chunks_head        # head内存映射
│   └── 000001        
└── wal                # 预写日志
    ├── 000000002     
    └── checkpoint.00000001
        └── 00000000
复制代码
WAL 中checkpoint的作用

我们需要定期删除旧的 wal 数据,否则磁盘最终会被填满,并且在TSDB重启时 replay wal 事件时会占用大量时间,所以wal中任何不再需要的数据,都需要被清理。而checkpoint会将wal 清理过后的数据做过滤写成新的段。

如下有6个wal数据段

data
└── wal
    ├── 000000
    ├── 000001
    ├── 000002
    ├── 000003
    ├── 000004
    └── 000005
复制代码

现在我们要清理时间点T之前的样本数据,假设为前4个数据段:

检查点操作将按000000 000001 000002 000003顺序遍历所有记录,并且:

  1. 删除不再在 Head 中的所有序列记录。
  2. 丢弃所有 time 在T之前的样本。
  3. 删除T之前的所有 tombstone 记录。
  4. 重写剩余的序列、样本和tombstone记录(与它们在 WAL 中出现的顺序相同)。

checkpoint被命名为创建checkpoint的最后一个段号checkpoint.X

这样我们得到了新的wal数据,当wal在replay时先找checkpoint,先从checkpoint中的数据段回放,然后是checkpoint.000003的下一个数据段000004

data
└── wal
    ├── checkpoint.000003
    |   ├── 000000
    |   └── 000001
    ├── 000004
    └── 000005
复制代码
Block的持久化存储

上面我们认识了wal和chunks_head的存储构造,接下来是Block,什么是持久化Block?在什么时候创建?为啥要合并Block?

Block的目录结构

├── 01BKGTZQ1SYQJTR4PB43C8PD98   # block ID
│   ├── chunks         # Block中的chunk文件
│   │   └── 000001     
│   ├── tombstones     # 数据删除记录文件
│   ├── index          # 索引
│   └── meta.json      # bolck元信息
复制代码

磁盘上的Block是固定时间范围内的chunk的集合,由它自己的索引组成。其中包含多个文件的目录。每个Block都有一个唯一的 ID(ULID),他这个ID是可排序的。当我们需要更新、修改Block中的一些样本时,Prometheus TSDB只能重写整个Block,并且新块具有新的 ID(为了实现后面提到的索引)。如果需要删除的话Prometheus TSDB通过tombstones 实现了在不触及原始样本的情况下进行清理。

tombstones 可以认为是一个删除标记,它记载了我们在读取序列期间要忽略哪些时间范围。tombstones 是Block中唯一在写入数据后用于存储删除请求所创建和修改的文件。

tombstones中的记录数据结构如下,分别对应需要忽略的序列、开始和结束时间。

┌────────────────────────┬─────────────────┬─────────────────┐
│ series ref <uvarint64> │ mint <varint64> │ maxt <varint64> │
└────────────────────────┴─────────────────┴─────────────────┘
复制代码

meta.json

meta.json包含了整个Block的所有元数据

{
    "ulid": "01EM6Q6A1YPX4G9TEB20J22B2R",
    "minTime": 1602237600000,
    "maxTime": 1602244800000,
    "stats": {
        "numSamples": 553673232,
        "numSeries": 1346066,
        "numChunks": 4440437
    },
    "compaction": {
        "level": 1,
        "sources": [
            "01EM65SHSX4VARXBBHBF0M0FDS",
            "01EM6GAJSYWSQQRDY782EA5ZPN"
        ]
    },
    "version": 1
}
复制代码

记录了人类可读的chunks的开始和结束时间,样本、序列、chunks数量以及合并信息。version告诉Prometheus如何解析metadata

Block合并

image-20220413113035412

我们可以从之前的图中看到当内存映射中chunk跨越2小时(默认)后第一个Block就被创建了,当 Prometheus 创建了一堆Block时,我们需要定期对这些块进行维护,以有效利用磁盘并保持查询的性能。

Block合并的主要工作是将一个或多个现有块(source blocks or parent blocks)写入一个新块,最后,删除源块并使用新的合并后的Block代替这些源块。

为什么需要对Block进行合并?

  1. 上面对tombstones介绍我们知道Prometheus在对数据的删除操作会记录在单独文件stombstone中,而数据仍保留在磁盘上。因此,当stombstone序列超过某些百分比时,需要从磁盘中删除该数据。
  2. 如果样本数据值波动非常小,相邻两个Block中的大部分数据是相同的。对这些Block做合并的话可以减少重复数据,从而节省磁盘空间。
  3. 当查询命中大于1个Block时,必须合并每个块的结果,这可能会产生一些额外的开销。
  4. 如果有重叠的Block(在时间上重叠),查询它们还要对Block之间的样本进行重复数据删除,合并这些重叠块避免了重复数据删除的需要。
  5. image-20220414120529698

如上图示例所示,我们有一组顺序的Block[1, 2, 3, 4]。数据块1,2,和3可以被合并形成的新的块是[1, 4]。或者成对压缩为[1,3]。 所有的时间序列数据仍然存在,但是现在总体的数据块更少。 这显著降低了查询成本。

Block是如何删除的?

对于源数据的删除Prometheus TSDB采用了一种简单的方式:即删除该目录下不在我们保留时间窗口的块。

如下图所示,块1可以安全地被删除,而2必须保留到完全落在边界之后

image-20220413202322093

因为Block合并的存在,意味着获取越旧的数据,数据块可能就变得越大。 因此必须得有一个合并的上限,,这样块就不会增长到跨越整个数据库。通常我们可以根据保留窗口设置百分比。

如何从大量的series中检索出数据?


在Prometheus TSDB V3引擎中使用了倒排索引,倒排索引基于它们内容的子集提供对数据项的快速查找,例如我们要找出所有带有标签app ="nginx"的序列,而无需遍历每一个序列然后再检查它是否包含该标签。

首先我们给每个序列分配一个唯一ID,查询ID的复杂度是O(1),然后给每个标签建一个倒排ID表。比如包含app ="nginx"标签的ID为1,11,111那么标签"nginx"的倒排序索引为[1,11,111],这样一来如果n是我们的序列总数,m是查询的结果大小,那么使用倒排索引的查询复杂度是O(m),也就是说查询的复杂度由m的数量决定。但是在最坏的情况下,比如我们每个序列都有一个“nginx”的标签,显然此时的复杂度变为O(n)了,如果是个别标签的话无可厚非,只能稍加等待了,但是现实并非如此。

标签被关联到数百万序列是很常见的,并且往往每次查询会检索多个标签,比如我们要查询这样一个序列app =“dev”AND app =“ops” 在最坏情况下复杂度是O(n^2),接着更多标签复杂度指数增长到O(n^3)、O(n^4)、O(n^5)... 这是不可接受的。那咋办呢?

如果我们将倒排表进行排序会怎么样?

"app=dev" -> [100,1500,20000,51166]
"app=ops" -> [2,4,8,10,50,100,20000]
复制代码

他们的交集为[100,20000],要快速实现这一点,我们可以通过2个游标从列表值较小的一端率先推进,当值相等时就是可以加入到结果集合当中。这样的搜索成本显然更低,在k个倒排表搜索的复杂度为O(k*n)而非最坏情况下O(n^k)

剩下就是维护这个索引,通过维护时间线与ID、标签与倒排表的映射关系,可以保证查询的高效率。


Above, let's take a look at the content related to Prometheus TSDB storage from a shallower level. There are still many details not mentioned in this article, such as how wal does compression and playback, the principle of mmap, the data structure of TSDB storage files, etc. If you need further Learn portable reference articles. Read through the blog: iqsing.github.io


This article refers to:

Prometheus maintainer Ganesh Vernekar's series of blogs Prometheus TSDB

Prometheus maintainer Fabian's blog post Writing a Time Series Database from Scratch (original deprecated)

PromCon 2017: Storing 16 Bytes at Scale - Fabian Reinartz

\

Guess you like

Origin juejin.im/post/7086396881420582919