背景知识
磁盘存储格式
总结
Prometheus 作为云原生时代的时序数据库,是当下最流行的监控平台之一。它的整体架构虽然一直保持不变,但是它底层的存储引擎却经历了几个版本的演进。在本文中,我们将主要介绍如下内容:Prometheus V2(即现在使用的)版本的存储格式细节、查询是如何定位到符合条件的数据以及如何通过本文的分析来更深入了解 Prometheus 的存储引擎。
本文中的代码分析基于 v2.25.2 版本,如有差异,请以最新版本为准。
背景知识
时序特点
时序数据的特点可以用一话概括:垂直写(最新数据),水平查。
对于云原生场景来说,还有一个重要特点,那就是应用和数据的生命周期相对较短。这是由于容器的扩缩容会导致时间线膨胀一倍,因此需要更加灵活的数据存储和管理策略来适应这种短暂的生命周期。在这种背景下,Prometheus 作为一种开源的监控和警报系统,正是因为其能够灵活地存储、查询、分析和展示经过多次扩缩容的数据而备受关注。通过使用 Prometheus,用户可以更加精细地监测和管理其云原生应用和服务,从而提高其可靠性和可用性:
可以看到,数据目录主要有以下几部分:
Prometheus 支持的模式比较简单,只支持单值模式,如下:
数据模式
-
block,一个时间段内(默认 2 小时)的所有数据,只读,用 ULID 命名。每一个 block 内主要包括:
chunks 固定大小(最大 128M)的 chunks 文件
index 索引文件,主要包含倒排索引的信息
meta.json 元信息,主要包括 block 的 minTime/maxTime,方便查询时过滤
-
chunks_head,当前在写入的 block 对应的 chunks 文件,只读,最多 120 个数据点,时间跨度最大 2 小时。
-
wal,Prometheus 采用攒批的方式来异步刷盘,因此需要 WAL 来保证数据可靠性
-
通过上面的目录结构,不难看出 Prometheus 的设计思路:
-
通过数据按时间分片的方式来解决数据生命周期短的问题
-
通过内存攒批的方式来对应只写最新数据的场景
倒排索引
索引是支持多维搜索的主要手段,时序中的索引结构和搜索引擎的类似,是个倒排索引,可参考下图
在一次查询中,会对涉及到的 label 分别求对应的 postings lists(即时间线集合),然后根据 filter 类型进行集合运算,最后根据运算结果得出的时间线,去查相应数据即可。
磁盘存储格式
数据格式
chunk 为数据在磁盘中的最小组织单元,需要明确以下两点:
-
单个 chunk 的时间跨度默认是 2 小时,Prometheus 后台会有合并操作,把时间相邻的 block 合到一起
-
series ref 为时间线的唯一标示,由 8 个字节组成,前 4 个表示文件 id,后 4 个表示在文件内的 offset,需配合后文的索引结构来实现数据的定位
索引格式
在一个索引文件中,最主要的是以下几部分(从下往上):
-
TOC 存储的是其他部分的 offset
-
Postings Offset Table,用来存储倒排索引,Key 为 label name/value 序对,Value 为 Postings 在文件中的 offset。
-
Postings N,存储的是具体的时间线序列
-
Series,存储的是当前时间线,对应的 chunk 文件信息
-
Label Offset Table 与 Label Index 目前在查询时没有使用到,这里不再讲述
每个部分的具体编码格式,可参考官方文档 Index Disk Format,这里重点讲述一次查询是如何找到符合条件的数据的:
在第一步主要在 PostingsForMatchers 函数中完成,主要有下面几个优化点:
-
首先在 Posting Offset Table 中,找到对应 label 的 Postings 位置
-
然后再根据 Postings 中的 series 信息,找到对应的 chunk 位置,即上文中的 series ref。
使用方式
Prometheus 在启动时,会去加载数据元信息到内存中。主要有下面两部分:
-
block 的元信息,最主要的是 mint/maxt,用来确定一次查询是否需要查看当前 block 文件,之后把 chunks 文件以 mmap 方式打开
-
block 对应的索引信息,主要是倒排索引。由于单个 label 对应的 Postings 可能会非常大,Prometheus 不是全量加载,而是每隔 32 个加载,来减轻内存压力。并且保证第一个与最后一个一定被加载,查询时采用类似跳表的方式进行 posting 定位。
-
下面代码为 DB 启动时,读入 postings 的逻辑:
下面代码为根据 label 查询 postings 的逻辑,完整可见 index 的 Postings 方法:
内存结构
Block 在 Prometheus 实现中,主要分为两类:
-
当前正在写入的,称为 head。当超过 2 小时或超过 120 个点时,head 会将 chunk 写入到本地磁盘中,并使用 mmap 映射到内存中,保存在下文的 mmappedChunk 中。
-
历史只读的,存放在一数组中
在上文磁盘结构中介绍过,postingOffset 不是全量加载,而是每隔 32 个。
Head
MemPostings 是 Head 中的索引结构,与 Block 的 postingOffset 不同,posting 是全量加载的,毕竟 Head 保存的数据较小,对内存压力也小。
stripeSeries 是比较的核心结构,series 字段的 key 为时间线,采用自增方式生成;value 为 memSeries,内部有存储具体数据的 chunk,采用分段锁思路来减少锁竞争。
使用方式
对于一个查询,大概涉及的步骤:
-
根据 label 查出所涉及到的时间线,然后根据 filter 类型,进行集合运算,找出符合要求的时间线
-
根据时间线信息与时间范围信息,去 block 内查询符合条件的数据
-
对于取反的 filter( != !~ ),转化为等于的形式,这样因为等于形式对应的时间线往往会少于取反的效果,最后在合并时,减去这些取反的时间线即可。可参考:Be smarter in how we look at matchers. #572
-
不同 label 的时间线合并时,利用了时间线有序的特点,采用类似 mergesort 的方式来惰性合并,大致过程如下:
在第一步查出符合条件的 chunk 所在文件以及 offset 信息之后,第二步的取数据则相对简单,直接使用 mmap 读数据即可,这间接利用操作系统的 page cache 来做缓存,自身不需要再去实现 Buffer Pool 之类的数据结构。
总结
通过上文的分析,我们可以了解到 Prometheus 的存储结构和查询流程。 然而,还有很多细节值得我们深入探讨。例如,为了节约内存使用,label 使用了字典压缩。这种技术可以大大减少存储空间的使用,但是对于不了解此技术的读者来说,可能需要更多的解释才能真正理解其原理。
此外,Prometheus 默认 2 小时一个 Block 对大时间范围查询不友好。为了解决这个问题,它的后台会对定期 chunk 文件进行 compaction。这个过程可以将多个文件合并成一个文件,以便更有效地查询。合并后的文件大小为 min(31d, retention_time * 0.1)。这里值得一提的是,这个过程的细节非常复杂,需要更深入地探讨。
总的来说,我们希望通过这篇文章,更深入地了解 Prometheus 的内部工作原理,以及它如何处理数据和查询。如果您对这些内容有更深入的了解,相信您将能更好地利用 Prometheus 来监控您的应用程序。
-
-
-
-