Prometheus作为一个独立地开源监控系统和告警工具,是继Kubernetes之后加入CNCF的第二个项目,社区拥有非常活跃的开发者用户,也被越来越多的公司和组织采用。Pormetheus通常单点方式部署,每个周期可以从上万个Target抓取并处理数百万个时序数据,支持PromQL高效(聚合)查询历史数据,其核心在于时序数据库TSDB设计与实现

时序数据

数据点都是时间戳和值的元组。出于监控的目的,时间戳是一个整数,值是任意数字--64位浮点数对于Counter和Gauge类型来说都是一个很好的表示方式。按时间戳严格单调递增的数据点序列是一个由标识符寻址的Series标识符是带有标签维度的监控项名称,标签维度划分单个监控项的度量空间,每个监控项名称加上一组唯一标签是其自己的时间序列,其具有与之关联的值流



## series格式为 identifier -> (t0, v0), (t1, v1), (t2, v2), (t3, v3), ....
## 一组典型的系列标识符,是请求技术监控度量的一部分
requests_total{path="/status", method="GET", instance=”10.0.0.1:80”}
requests_total{path="/status", method="POST", instance=”10.0.0.3:80”}
requests_total{path="/", method="GET", instance=”10.0.0.2:80”}
## 简化表示,监控项名称可以是另一个标签维度__name__,在查询是它可能会被特殊处理,但与我们存储它的方式无关
{__name__="requests_total", path="/status", method="GET", instance=”10.0.0.1:80”}
{__name__="requests_total", path="/status", method="POST", instance=”10.0.0.3:80”}
{__name__="requests_total", path="/", method="GET", instance=”10.0.0.2:80”}
因此,identifier 形如 metricName{label1=value1,label2=value2,...} 或 
{__name__="metricName",label1=value1,label2=value2,...}
## 在简化的视图中,所有数据点可以在二维平面上展开。水平维度表示时间,垂直维度按series展开
series
 ^ 
 │ . . . . . . . . . . . . . . . . . . . . . . {__name__="request_total", method="GET"}
 │ . . . . . . . . . . . . . . . . . . . . . . {__name__="request_total", method="POST"}
 │ . . . . . . .
 │ . . . . . . . . . . . . . . . . . . . ... 
 │ . . . . . . . . . . . . . . . . . . . . . 
 │ . . . . . . . . . . . . . . . . . . . . . {__name__="errors_total", method="POST"}
 │ . . . . . . . . . . . . . . . . . {__name__="errors_total", method="GET"}
 │ . . . . . . . . . . . . . .
 │ . . . . . . . . . . . . . . . . . . . ... 
 │ . . . . . . . . . . . . . . . . . . . . 
 v
 <-------------------- time --------------------->



实体称为Target。单个Prometheus实例从数万个Target中收集数据,每个Target提供数百到数千个不同的时间序列,来自每个Target的样本(series)被独立地抓取,那么每个周期(分钟/半分钟)大约采集数百万个时序数据,在此规模上,按时序顺序批量地写入是一个不可协商的性能要求(顺序和批量写入也是旋转磁盘和SSD的理想写入模式)

查询模式不同于写入模式,我们可以查询单个数据点的单个时序、单个数据点的10000个时序、单个时序数周的数据点、10000个时序数周的数据点。因此,在二维平面上,查询既不是完全垂直的也不是水平,而是两者的矩形组合。配置查询规则可以缓解已知查询的问题,但不是实时查询的常规解决方案,后者仍需相当好地执行

我们想要批量写数据,但我们获取的是批量跨series的垂直数据点集。在一个时间窗口查询时序数据点时,必须从磁盘上的许多随机位置读取且难以找出目标数据点的位置,即使是在速度最快的SSD上每个查询也可能会触及数百万个样本。读取还将从磁盘中检索比请求16字节样本更多的数据,SSD将加载整页,HDD将至少读取整个扇区...无论哪种方式都在浪费宝贵的读取吞吐量

按理想模式将收集到的数据写入磁盘与显著提高服务效率的布局之间存在明显的紧张关系,这是TSDB需要解决的根本问题

宏观布局

在TSDB根目录运行`tree`命令,展示如下结构





目录树

在顶层,有一系列带编号的块,编号以"b-"为前缀。每个块包含一个可能有多个文件的chunks目录、一个index文件和一个meta.json文件。chunks目录只包含大量的原始序列数据点。这使得在一个时间窗口上读取序列数据非常便利,并且能够应用高效的压缩算法

但为什么有几个目录是包含chunks和index的布局,而最后一个包含wal目录?理解这两个问题,也就弄清楚TSDB的设计精髓

数据库构成

将横向维度(即时间空间)划分为非重叠的块(默认跨度两小时),每个块充当一个完全独立的数据库(区分于chunks,以下简称数据块),包含其时间窗口内的所有时序数据。因此它有自己的index和chunks文件


沿时间轴划分数据库


每一个数据块都是不可变的,我们必须能够在收集新数据时将新序列和样本添加到最新的数据块中。对于这个块,所有新数据都写入内存数据库,该数据库提供与持久块相同的查找属性,可以有效地更新内存中的数据结构。为防止数据丢失,所有传入数据会写入临时预写日志,即wal目录中的文件集,这些文件都用自己的序列化格式(flags、offsets、varints、CRC32 checksums),当发生重启时可以依次恢复内存数据库

tips: 在计算机科学中,WAL(Write-Ahead Logging,预写式日志),是关系数据库中用于提供原子性和持久性的一系列技术。在使用WAL的系统中,所有的修改在提交之前都要先写入log文件中

这种布局允许我们将查询fan out到与查询相关的所有数据块,将各个数据块返回的部分结果合并形成整体查询结果

水平分区的优点

  • 查询时间范围时,可以很容易忽略时间窗口不在此区间的数据块
  • 完成一个数据块后,我们可以通过顺序写入一些较大的文件来持久化内存数据块,避免任何写入放大,同样很好地为SSD和HDD提供服务
  • 保持V2的优良特性,最近被频繁查询的数据块热加载于内存中
  • 为更好地对齐磁盘数据,chunk文件大小不再限制为1KB,我们可以将其指定为对各个数据点和所选压缩格式最有意义的任意大小
  • 处理数据删除只需删除一个目录即可,变得非常即时和便利。在之前的存储方式中,必须分析并重新编写多大数亿个文件,可能需要数小时才能收敛

每个数据块还包含一个meta.json文件,它以人类可读的形式保存数据块的信息,以便了解存储状态及其包含的数据

压缩和保留数据块

存储必须定期"剪切"一个新数据块并将之前完成的数据块写入磁盘,只有在数据块成功持久化后,才会删除用于恢复内存数据块的预写日志文件。数据块应保持合理的大小(默认两小时),以避免内存中积累太多的数据;查询多个数据块时,我们必须将其结果合并为一个整体结果,这个合并过程显然需要成本,一个周期的查询不应该合并超过80个自结果

为实现以上两点,引入压缩。压缩描述了获取一个或多个数据块并将其写入可能更大的数据块的过程,还可以顺便修改现有数据,例如:删除已经删除的数据、重构样本块以提高查询性能


压缩数据块

上述例子中有[1,2,3,4]四个数据块,块1,2,3可以压缩在一起最终形成[1,4];1,2和3,4分别压缩在一起最终形成[1,3]; 所有的时序数据依然存在,只是现在总的数据块变少了,因此查询返回的部分结果数量变少,减少合并开销并且有利于减少查询时间


时间窗口及保留数据

时间窗口之外的就数据会被自动删除,上例中数据块1在保留时间窗口之外,通过删除数据块1的目录即可删除旧数据;数据块2部分部分数据在有效时间窗口之内,部分数据在时间窗口之外,暂时保留待时间窗口完全没有落在数据块2的跨度时,它所在的目录也将被删除

数据库设计

前向索引; 反向索引(倒排索引)基于其内容的子集提供数据项的快速查找,例如:我们可以快速查找具有标签`app="nginx"`的所有时序,而无需浏览所有时序检查它是否包含该标签


## Example
ID号为9,10,29的时序数据包含标签`app="nginx"`,"nginx"标签的
反向索引列表简单表示为[9,10,29],根据反向索引列表,即使是在200亿个的
样本中也可以快速找到包含标签`app="nginx"`的所有时序,而不会影响查询速度


简而言之,如果n是时序总数,m是给定查询的结果集大小(m通常比n小的多),那么使用索引的查询时间复杂度是O(m)。假设我们可以在恒定时间内检索反向索引本身,在最坏的情况下,一个标签存在于所有的时序中,m=n,时间复杂度是O(n)

索引磁盘格式

下面描述每个数据块中索引文件(index)的格式,它由一个目录(TOC, Table Of Contents)终止,该目录用作索引的入口点

下面描述的大多数段都以len开头,它表示尾部CRC32校验和之前的字节数,校验和也是基于len个字节计算


Index Format

Symbol Table

符号表包含已存储过的时序标签对中出现的经过重复数据删除的字符串的排序列表,它们可以从后续段中引用,并能显著减少总索引大小。该段包含一系列字符串条目,每个字符串以字符串原始byte长度为前缀,所有字符串以utf-8编码,按照字典顺序升序排序,并由顺序索引引用


Symbol Table

Series

这个段包含一系列(按标签字典顺序排序的)时序,用于保持时序的标签集及其数据块内的chunk文件。每一个时序段按16字节对齐,因此,Series ID = offset / 16(在后续段中会引用到),Series ID的排序列表就意味着按字典顺序排序的时序标签组列表


Series

delta编码格式存储 存储第一个chunk文件的mint,它的maxt存储为delta,mint和maxt被编码为前一个chunk文件的时间增量,类似地,存储第一个chunk的引用,并将下一个ref存储为前一个的delta


Series Detail

Label Index

#name` 字段确定索引标签名称的数量,后跟`#entries`字段表示条目总数。正文包含Symbol Table引用的`#entries / #name`元组,每个元组的长度为`#name`,值元组按字典递增的顺序排序


Label Index

Example: 四个不同值的单标签名称将被编码为


Label Index Example

标签索引段的序列将由包含标签偏移条目的标签偏移表最终确定,该标签偏移条目指向给定标签名称每个标签索引段的开头

Postings

该段存储单调增加的序列引用列表,其中包含与列表关联的给定标签对,由Posting偏移表最终确定,Posting偏移表包含指向给定标签对每个posting段开头部分的posting偏移条目


Postings

Label Offset Table

标签偏移表存储一系列标签偏移条目,每一个标签偏移条目都将标签偏移名称和偏移量保存到标签索引段中的值


Label Offset Table

Postings Offset Table

它存储一系列posting偏移条目,每个条目都包含标签名称/值对以及postings段其序列列表的偏移量,它用于追踪Postings段,加载索引文件是会将它们读入内存


Postings Offset Table

TOC

TOC作为整个索引的入口点,并指向索引文件的各个部分,如果引用为零,表示相应的段不存在,在查找时应返回空结果


Table Of Content

块磁盘格式

下面描述chunk文件格式,它被创建于`chunks/`目录下,每个段文件最大大小为512MB。文件中的chunk由索引引用,uint64由文件内偏移量(低4字节)和段序列号(高4字节)组成


Chunks

Single Chunk

Tombstones磁盘格式

tombstones文件(待删除)位于数据块block的顶级目录中,文件的最后8字节指定了Stones段开头的偏移量,对于快速扫描,stone段为零填充为4的倍数


Tombstones

Single Tombstone

WAL磁盘格式

[2]LevelDB/RocksDB的预写式日志

记录片段被编码为如下格式


WAL

type标志有以下几种状态

  • 0 页面的其余部分将为空
  • 1 一个完整的记录被编码在单个片段中
  • 2 记录的第一个片段
  • 3 记录的中间片段
  • 4 记录的最后片段

记录编码

Series Records


Series Records

Sample Records 将样本编码为三元组(series_id,timestamp,value)列表,时序引用和时间戳在第一个样本中编码为增量值(delta编码)。第一行存储起始ID和起始时间戳,从第二行开始记录第一个样本


Sample Records

Tombstone Records 将tombstone编码为三元组列表(series_id,min_time,max_time)并且指定删除时序样本的时间间隔


Tombstone Records

小结

了解Prometheus TSDB存储结构设计,有助于理解时序数据处理逻辑。将时间轴划分为不重叠的时间空间,构成小的独立数据库(数据块)。每个数据库包含index,meta.json,chunks及tombstone文件及文件夹,时序按标签对字典顺序排序引用Symbol Table(重复数据删除)及时间戳以Delta-Of-Delta编码格式压缩数据节省存储资源。根据标签建立时序的倒排索引表,忽略查询时间范围之外的数据块,再根据Series ID合并查询结果,实现高效检索历史数据