3.2.0 本地存储

每种监控系统都会涉及对监控数据的存储,Prometheus 也不例外。从借助第三方数据库 LevelDB 到借助自己研发的时序数据库,Prometheus 的本地存储经历了多次迭代。下面将从 Prometheus 时序数据库的发展、核心概念和实现原理等方面,详细讲解 Prometheus 的本地存储。

3.2.1 历史

Prometheus 是于 2012 年由 SoundCloud 公司发起的开源项目,在起初的两年里,Prometheus 实现了 V1 版本的时序数据库,监控数据在这个版本中被分为数据和元数据(元数据主要指标签)。其中,数据和元数据都被保存在 LevelDB 中,并且每隔 15min 刷新一次,这就产生了一个严重的问题:如果 Prometheus 宕机,则可能会丢失 15min 的监控数据。并且,V1 版本性能不高,受限于 LevelDB 的存储性能,每秒只能接收 50000 个样本,这就意味着:如果按照每台机器 1000 个指标、采集周期 10s 计算,单台 Prometheus 最多采集 500 台机器的数据。
针对 V1 版本存在的问题,由 Beorn 为主导开发的 V2 版本在 2015 年发布。在该版本中将每个时序数据以单个文件的方式保存,并且充分借鉴了 Facebook Gorilla 的压缩算法,将性能提高到了每秒 800000 个样本。但因为每个时序都对应一个文件并且每个文件有 10MB,所以产生了严重的时序流失和写放大等问题,使对内存的使用率不断增加,时常发生 OOM;同时,Prometheus 需要维护大量的时序文件,消耗过多 Inode 会引发「打开文件数过多」的内核错误。所以,Prometheus 从 2.0 版本开始便引入了时序数据库的 V3 版本,并成立一个独立的时序数据库项目(https://github.com/Prometheus/tsdb)。
该 V3 版本主要由 Fabian 主导开发,将监控数据以时间段拆分成不同的 block,并且会压缩、合并历史数据块;还引入了 WAL,避免因为 Prometheus 重启或者宕机导致数据丢失等问题;并且,数据存储可接近每秒 10000000 个样本,与 V2 版本相比,对 CPU 的使用率降低 3 倍,磁盘 I/O 降低 10 倍。

3.2.2 核心概念

Prometheus 的本地存储被称为 Prometheus TSDB,本文之后所有的 TSDB 都代指 Prometheus 的本地存储。TSDB 的设计有两个核心:block 和 WAL,而 block 又包含 chunk、index、meta.json、tombstones,下面进行详细介绍。
1.block
TSBD 将存储的监控数据按照时间分隔成 block,block 的大小并不固定,按照设定的步长倍数递增。默认最小的 block 保存 2h 监控数据。如果步数为 3、步长为 3,则 block 的大小依次为:2h、6h、18h。随着数据量的不断增长,TSDB 会将小的 block 合并成大的 block,例如将 3 个 2h 的 block 合并成一个 6h 的 block,这样不仅可以减少数据存储,还可以减少内存中的 block 个数,便于对数据进行检索。
每个 block 都有全局唯一的名称,通过 ULID(Universally Unique Lexicographically Sortable Identifier,全局字典可排序 ID)原理生成,可以通过 block 的文件名确定这个 block 的创建时间,从而很方便地按照时间对 block 排序。对时序数据的查询通常都会涉及连续的很多块,这种通过命名便可以排序的设计非常简便。
ULID 的数据格式如图 3-2 所示。

图 3-2

它的总长度是 128 位(16 字节),其中,前 48 位(6 字节)为时间戳,后 80 位(10 字节)为随机数。为了生成可排序的字符串,Prometheus 将 16 字节的 ULID 通过 Base32 算法转化为 26 字节的可排序字符串,例如「01CZC2BAFKWQQB3AXM4FDDWQKE」,其中前 10 字节由 ULID 的前 6 字节转化而来。
block 包含 4 个主要部分:chunks、index、meta.json、tombstones,如图 3-3 所示。

图 3-3

1)chunks
chunks 用于保存压缩后的时序数据。每个 chunk 的大小为 512MB,如果超过,则会被截断成多个 chunk 保存,且以数字编号命名。
2)index
index 是为了对监控数据进行快速检索和查询而设计的,主要用来记录 chunk 中时序的偏移位置。index 的数据格式如下所述,如图 3-4 所示。
(1)TOC 表。TOC 表是 index 的入口,记录 index 文件中其他表的位置。在写入其他表的数据之前都会先将当前的偏移量(8 字节)作为该表的地址记录,在读取 index 时首先读取的是 TOC 表。
(2)符号表(Symbol Table)。TSDB 对磁盘的利用发挥到极致,为了避免标签重复存储,对每个标签只存储一次,在使用标签时直接使用符号表中的索引。
(3)时序列表(Series)。记录该 block 中每个时序的标签及这些时序在该 block 中关联的 chunk 块。
(4)标签索引表(Label Index Table)。将具有相同标签名称(key)的标签组合到一起,从而形成标签索引(Label Index),然后通过标签索引表去查找这些索引。
(5)Postings 表。每个 Posting 都代表一个标签和时序的关联关系,Postings 表则是 Posting 的索引表。

图 3-4

index 是由上面所说的 5 张表组成的,读者可能还不是很清楚上面 5 张表的具体工作原理,下面通过一个数据查询的例子来解释这些表的具体作用。
假如要查找某个时间段内某种指标的监控数据,TSDB 就会首先根据该时间段找到所有的 block,并加载每个 block 的 index 文件,之后要先读取 index 的 TOC 表才能找到其他表。对 TOC 表的读取很简单,直接读取 index 文件的最后 52 字节(6 张表 × 每张表 8 字节的偏移量 +4 字节的 CRC 校验和)即可。之后找到符号表,就可以确定这个指标标签的名称和值在符号表中的索引 ID,后续的查找都是基于这个 ID 的查找。
那么如上所述的指标标签在哪里呢?当然是通过如上所述的标签索引表,找到这个标签在 Postings Table 中的位置,从而找到具体的 Posting,这样就能找到这个标签对应的时序了。在找到对应的时序后根据时序表查找其在 block 中的具体位置,就可以读取磁盘及加载监控数据了。
3)tombstone
tombstone 用于对数据进行软删除。TSDB 在删除 block 数据块时会将整个目录删除,但如果只删除一部分数据块的内容,则可以通过 tombstone 进行软删除。
4)meta.json
meta.json 记录 block 的元数据信息,主要包括一个数据块记录样本的起始时间(minTime)、截止时间(maxTime)、样本数、时序数和数据源等信息,这些元数据信息在后期对 block 进行维护(删除过期 block、合并历史 block 等)时会用到。
下面是一个 meta 文件的样例:

上面的 compaction 记录这个块被压缩的次数。之前介绍 block 大小时提到:小块会被 TSDB 压缩成大块,每压缩一次,level 的值就会加一,并且 source 部分会记录这个大块是由哪些小块压缩而成的。
这些 block 按照时间顺序被分割成一个个 block,其中,第 1 个 block 被称为 head block(头块),它被存储在内存中并且允许修改,而后面的 block 以只读方式存储在硬盘中。整体布局如图 3-5 所示。

图 3-5

head block 和后面的 block 都被初始设定为保存 2h 数据,当 head block 超过 1.5 倍大小(3h)的时候,它将被重新划分成 2h 和 1h 两部分,前面一部分将会变成只读块并被保存到硬盘中。细心的读者可能会发现,在上面的 head block 中多了一个 wal 属性,下面将详细介绍 WAL 的作用和原理。
2.WAL
WAL(Write-ahead logging,预写日志)是关系型数据库中利用日志来实现事务性和持久性的一种技术,即在进行某个操作之前先将这件事情记录下来,以便之后对数据进行回滚、重试等操作并保证数据的可靠性。
Prometheus 为了防止丢失暂存在内存中的还未被写入磁盘的监控数据,引入了 WAL 机制。WAL 被分割成默认大小为 128MB 的文件段(Segment),文件段以数字命名,例如 00000000、00000001、00000002 等,以此类推。WAL 的写入单位是页(page),每页的大小为 32KB,所以每个段文件的大小必须是页的大小的整数倍。每个文件段都有一个「已使用的页」属性来标识在该段中已经分配的页数目,如果 WAL 一次性写入的页数超过一个段的空闲页数,就会创建一个新的文件段来保存这些页,从而确保一次性写入的页不会跨段存储。
按照每种对象设定的采集周期,Prometheus 会将周期性采集的监控数据通过 Add 接口添加到 head block 中,但这些数据没有被持久化,TSDB 通过 WAL 将提交的数据先保存到磁盘中,在 TSDB 宕机重启后,会首先启动多协程读取 WAL,从而恢复之前的状态。

3.2.3 相关参数

1.保存时间
Prometheus 的监控数据在本地存储中默认保存 15d,可以通过参数—storage.tsdb.retention 调整保存时间,需要注意:如果将保存时间调整为 4h,则此时通过 Prometheus 接口最多可以查询 11h 的监控数据,这是因为 Prometheus 在计算时排除了 head block 和最新生成 block,是从 t-5h 开始计算的。所以,如果将保存时间调整为 4h,那么 TSDB 只会删除样本采集时间小于 t-5h-4h = t-9h(t 代表当前时间)的 block,所以无论是调整为保存 4h 还是保存 5h,都最多可以查询 11h 的监控数据。block 的数据分布如图 3-6 所示。

图 3-6

那么,为什么这里每个 block 保存数据的范围都是 2h 呢,为什么没有发生上文所说的 block 压缩呢?这是因为在默认情况下 TSDB 的最大 block 是—storage.tsdb.retention 值的十分之一,在上面的例子中设定的保存时间是 4h,所以最大 block 和最小 block 保存的数据范围都是 2h,因此并没有发生 block 压缩。
在实际生产环境中可以通过下面的公式粗略计算存储空间的大小:
存储空间 = 每个指标的大小(1~2 字节)× 采集的周期 ×storage.tsdb.retention
2.存储路径
Prometheus 的时序数据被保存在本地硬盘(磁盘或者 SSD,为了提高读写速度,推荐使用 SSD)中,可以通过参数—storage.tsdb.path 调整时序数据的存储路径,WAL 日志也会被保存到这个路径下。

3.2.4 本地存储接口

Prometheus 的本地存储自然要实现 Prometheus 的 Appender 存储接口。其中,AddFast 方法主要在 head block 中实现,将给定时序的索引 ID、采样时间和样本值保存在 head block(内存)中,在 head block 中定义了一个数组,用于保存所有插入的样本数据;Commit 接口则将这次写入的样本数据保存到 WAL 中;Rollback 则用于回滚提交的数据。