天天看点

Prometheus 作为大厂监控标配,一定要做到知根知底!(存储篇)

作者:每日编程

引言

目前各大厂监控方案首选(或参考)均是 Prometheus+Grafana ,作为时序数据库的基准参考,一定要对Prometheus 知根知底。

虽然大厂把时序做到分布式,也是基于Prometheus的存储引擎做的外层路由封装。其中京东的baudtime就是基于Prometheus的存储引擎作为分布式shard,上层添加的meta route(两层时间路由)和proxy层做的封装,一切都是围绕Prometheus生态的附加而已。掌握了Prometheus也就熟悉了各司的监控系统,架构上大同小异。本篇将详细介绍Prometheus的存储原理。

Prometheus 作为大厂监控标配,一定要做到知根知底!(存储篇)

存储原理

接组件篇~

最近的数据保存在内存中

Prometheus将最近的数据保存在内存中,这样查询最近的数据会变得非常快,然后通过一个compactor定时将数据打包到磁盘。数据在内存中最少保留2个小时(storage.tsdb.min-block-duration)。

由于在Prometheus中会频繁的对map[hash/refId]memSeries进行操作,例如检查这个labelSet对应的memSeries是否存在,不存在则创建等。由于golang的map非线程安全,所以其采用了分段锁去拆分锁。

memChunk在内存中保存的正是采用XOR算法压缩过的数据。

引入以Label为key的倒排索引,倒排索引存储的refId必须是有序的。

底层存储

Prometheus 作为大厂监控标配,一定要做到知根知底!(存储篇)

a.prometheus按照block块的方式来存储数据,每2小时为一个时间单位,首先会存储到内存中,当到达2小时后,Prometheus会进行一次数据压缩,将内存中的数据落盘。

最近的Block一般是存储了2小时的数据,而较为久远的Block则会通过compactor进行合并,一个Block可能存储了若干小时的信息。值得注意的是,合并操作只是减少了索引的大小(尤其是符号表的合并),而本身数据(chunks)的大小并没有任何改变。

所有的Chunk文件在磁盘上都不会大于512M,当写入磁盘单个文件超过512M的时候,就会自动切分一个新的文件。

block:随着数据量的增长,tsdb会将小的block合并成大的block,这样不仅可以减少数据存储,还可以减少内存中的block个数,便于对数据进行检索。

每个block都有一个全局唯一的名称。block的命名规则为:名称总长度16字节。前6个字节为时间戳,后10个字节为随机数;通过block的文件名确定这个block的创建时间,方便按照时间对block进行排序。

block组成:

chunk:用于保存压缩后的时序数据

index:用于对监控数据进行快读的检索,记录chunk的偏移位置,由5张表构成

tombstone:tsdb在删除block数据块时会将整个目录删除,但如果只删除一部分数据块的内容,就可以通过tombstone进行软删除

meta.json:记录block的元数据信息,主要包括一个数据块记录样本的起始时间,截止时间,样本数等信息。

入口是插入倒排时序数据:

如果lset已经在series中了,则直接返回;

否则获取一个seriesId:

将label key/value插入到h.values;

将label key/value和seriesId插入到h.postings中(大map);

b.为防止程序异常而导致数据丢失,采用了WAL机制,即2小时内记录的数据存储在内存中的同时,还会记录一份日志,存储在block下的wal目录中。当程序再次启动时,会将wal目录中的数据写入对应的block中,从而达到恢复数据的效果.

checkpoint就是用来清理wal日志的。

当2hour的内存数据被压缩成block存储至硬盘时:

该时间之前的wal日志就可以删除了;因为已经持久化到硬盘了,即使prometheus实例宕掉,也不会丢数据;

此时,prometheus生成一个checkpoint,进行wal日志的清理;

而关于持久化存储的问题,prometheus实际上并没有试图解决。它的做法是定义出标准的读写接口,从而可以将数据存储到任意一个第三方存储上。

prometheus-data

./data

├── 01BKGV7JBM69T2G1BGBGM6KB12 # block ID

│ └── meta.json #包含了整个Block的所有元数据

├── 01BKGTZQ1SYQJTR4PB43C8PD98 # block ID

│ ├── chunks # Block中的chunk文件

│ │ └── 000001

│ ├── tombstones # 数据删除记录文件

│ ├── index # 索引

│ └── meta.json # bolck元信息

├── chunks_head # head内存映射

│ └── 000001

└── wal # 预写日志

├── 000000002

└── checkpoint.00000001

└── 00000000

查找

queryEngine组件用于rules查询和计算,通过方法promql.NewEngine完成初始化

一个表达式或子表达式可以总结为以下四种类型:

瞬时向量(Instant vector ):一组时间序列,包含每个时间序列的单一样本,所有共享相同的时间戳;

范围向量(Range vector):一组时间序列,包含每个时间序列在一段时间内的数据点范围;

标量(Scalar):一个简单的数字浮点值;

字符串(String):一个简单的字符串值(目前未使用);

先从选择Block开始,遍历所有Block的meta.json,找到具体的Block,通过Labels找数据是通过倒排索引。我们的倒排索引是保存在index文件里面的。

倒排索引:

prometheus tsdb中的index以倒排索引的方式组织:

给每个series分配1个id:

用seriesId查询series,这是前向索引,查询时间复杂度=O(1);

构造label的索引:

若seriesId={2,5,10,29}都含有label: app='nginx';

那么,对于app='nginx", {2,5,10,29}就是它的倒排索引;

给每个序列分配一个唯一ID,查询ID的复杂度是O(1),然后给每个标签建一个倒排ID表。比如包含app ="nginx"标签的ID为1,11,111那么标签"nginx"的倒排序索引为[1,11,111];

首先我们访问的是Posting offset table。由于倒排索引按照不同的LabelPair(key/value)会有非常多的条目。所以Posing offset table就是决定到底访问哪一条Posting索引。offset就是指的这一Posting条目在文件中的偏移。

Posting中的Ref(Series2)和Ref(Series3)即为这两Series在index文件中的偏移。

Series以Delta的形式记录了chunkId以及该chunk包含的时间范围。这样就可以很容易过滤出我们需要的chunk,然后再按照chunk文件的访问,即可找到最终的原始数据。

blockQuerier根据不同的block,构造不同的indexReader来读取Label索引;blockQuerier使用Postings()得到[]seriesId后,再使用chunkReader最终读取到时序数据(t/v)。

通过LableNames()查询所有的lableName;

通过LabelValues(name)查询labelName对应的labelValues;

通过postings查询到key、value对应的[]seriesId,最终使用seriesId+chunkReader查询最终的时序数据(t/v)

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

查询流程简述:

查询入口:加载内存block和磁盘block,构造出blockQuerier,根据不同的block构造出不同的indexReader,BlockQuerier使用indexReader查询postings信息;

首先根据时间范围加载block,定位到所有的block后查找index的TOC表,在根据标签查找标签表,最后根据seriesId定位到chunk中的时序。

远程存储介绍

目前Prometheus支持OpenTsdb、InfluxDB、Elasticsearch等后端存储,通过适配器实现Prometheus存储的remote write和remote read接口,便可以接入Prometheus作为远程存储使用。

面试题: 为什么需要对Block进行合并?

a.对tombstones介绍我们知道Prometheus在对数据的删除操作会记录在单独文件stombstone中,而数据仍保留在磁盘上。因此,当stombstone序列超过某些百分比时,需要从磁盘中删除该数据。

b.如果样本数据值波动非常小,相邻两个Block中的大部分数据是相同的。对这些Block做合并的话可以减少重复数据,从而节省磁盘空间。

c.当查询命中大于1个Block时,必须合并每个块的结果,这可能会产生一些额外的开销。

d.如果有重叠的Block(在时间上重叠),查询它们还要对Block之间的样本进行重复数据删除,合并这些重叠块避免了重复数据删除的需要。

面试题2:查找详细文件记录(过程说明)

摄入的采样每两个小时被分成一个block。每个block由一个包含一个或多个chunk文件的目录组成,其中,这些chunk文件包含该时间窗口的所有时间序列采样,以及一个元数据文件和索引文件(对块文件中的时间序列指标名称和标签进行索引)

当前输入采样的block保存在内存中,尚未完全持久化。

Prometheus将保留至少3个write-ahead-log文件,然而高流量的服务器可能会看到超过3个WAL文件,因为它需要保留至少两个小时的原始数据。

prometheus按照block块的方式来存储数据,每2小时为一个时间单位,首先会存储到内存中,当到达2小时后,会自动写入磁盘中。

为防止程序异常而导致数据丢失,采用了WAL机制,即2小时内记录的数据存储在内存中的同时,还会记录一份日志,存储在block下的wal目录中。当程序再次启动时,会将wal目录中的数据写入对应的block中,从而达到恢复数据的效果。

当删除数据时,删除条目会记录在tombstones 中,而不是立刻删除。

prometheus采用的存储方式称为“时间分片”,每个block都是一个独立的数据库。优势是可以提高查询效率,查哪个时间段的数据,只需要打开对应的block即可,无需打开多余数据。

TSDB 的设计有两个核心:block 和 WAL,而 block 又包含 chunk、index、meta.json、tombstones。

默认最小的 block 保存 2h 监控数据。如果步数为 3、步长为 3,则 block 的大小依次为:2h、6h、18h。随着数据量的不断增长,TSDB 会将小的 block 合并成大的 block,例如将 3 个 2h 的 block 合并成一个 6h 的 block,这样不仅可以减少数据存储,还可以减少内存中的 block 个数,便于对数据进行检索。

每个 block 都有全局唯一的名称,通过 ULID原理生成,可以通过 block 的文件名确定这个 block 的创建时间,从而很方便地按照时间对 block 排序。

chunks 用于保存压缩后的时序数据。每个 chunk 的大小为 512MB,如果超过,则会别截断成多个 chunk 保存,且以数字编号命名。

 index 是为了对监控数据进行快速检索和查询而设计的,主要用来记录 chunk 中时序的偏移位置。

TOC 表。TOC 表是 index 的入口,记录 index 文件中其他表的位置。在写入其他表的数据之前都会先将当前的偏移量(8 字节)作为该表的地址记录,在读取 index 时首先读取的是 TOC 表。

符号表(Symbol Table)。TSDB 对磁盘的利用发挥到了极致,为了避免标签重复存储,对每个标签只存储一次,在使用该标签时直接使用符号表中的索引。

时序列表(Series)。记录该 block 中每个时序的标签及这些时序在该 block 中关联的 chunk 块。

标签索引表(Label Index Table)。将具有相同标签名称(key)的标签组合到一起,从而形成标签索引(Label Index),然后通过标签索引表去查找这些索引。

Postings 表。每个 Posting 都代表一个标签和时序的关联关系,Postings 表则是 Posting 的索引表。

举个例子:

假如要查找某个时间段内某种指标的监控数据,TSDB 就会首先根据该时间段找到所有的 block,并加载每个 block 的 index 文件,之后要先读取 index 的 TOC 表才能找到其他表。对 TOC 表的读取很简单,直接读取 index 文件的最后 52 字节(6 张表 * 每张表 8 字节偏移量 + 4 字节的 CRC 校验和)即可。之后找到符号表,就可以确定这个指标标签的名称和值在符号表中的索引 ID,后续的查找都是基于这个 ID 的查找。

继续阅读