从零开始写tsdb(翻译)

从很多方面来说k8s代表了所有那些prometheus用来设计的东西,使得持续部署、自动扩缩容以及其他高动态变化的环境更容易实现。不管是查询语言还是可操作的模型以及其他很多概念的设计和决定让prometheus尤其适配这种环境。但是,当前这种监控性的工作量也变得愈加处在不断的变化中,这同样也给监控系统自身带来了很大的变化与挑战。从这一点出发而不是折回去解决prometheus已经处理的很好的哪些问题,我们专注于提升它在高度变化环境中的性能。

prometheus的存储层在历史上已经展现出了卓越性能,比如一台单机server就足以吞吐高达每秒百万级别的数据点为几百万的时间序列,同时占用非常少的磁盘空间。虽然当前存储表现很好,我仍然提议来设计一款新的存储子系统,用来校正当前版本的一些缺点同时为下一代做准备。

作者这里强调自己没有数据库背景因此以下说的不一定全对

问题,问题,问题空间

首先是简单快速看一下当前我们正在尝试解决的大致问题的轮廓以及它所带来的问题。对于每一点,我们会先看一下prometheus当前的方法,他哪里做的好以及我们期望通过新的设计解决哪些问题。

时间序列数据

我们的系统尝试收集以下这种数据

1
2
identifier -> (t0, v0), (t1, v1), (t2, v2), (t3, v3), ....
// 标识符 -> (时间、数值)...

每一个十数据点都是一个时间戳和数值组成的元组,出于监控的考虑,每一个时间戳是一个int类型数值则可能是任意值。使用64位的float类型来表示计数器和仪表数据被证明是一种很好的形式,所以我们继续采用这种形式。一组伴随着严格单调递增时间戳的数据是一个序列,他们被一个标识符索引和定位。我们的标识符是一个指标名以及一系列的标签维度。标签用来区分相同指标。每一个指标加上一个唯一的标签集合唯一标示一个包含数据流的时间序列。

下面是一个很典型的用来计量请求计数的序列

1
2
3
4
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”}
//requests_total是指标metric 括号里面的kv就是label集合

让我们简化一下这个表达形式:一个指标名称可以被当做另一个标签维度”__name__” 。在检索层面可能会被特殊对待,但是不涉及我们如何存储,后面我们会看到

1
2
3
4
{__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”}
//这里可以看到request_total 和其他维度是一样的本质上

当我们检索序列数据的时候,期望使用他的label来进行检索。最简单的情况{__name__ = “request_total”}会将所有属于requests_total这个指标的时间序列选取出来,对于选到的序列,我们使用一个指定时间窗口来获取历史数据。

除此之外还有一些复杂的检索,我们可能希望一次拿到那些满足多个标签并且使用更加复杂的条件而不是严格匹配,比如说,取反(method!=”GET”)或者正则匹配(method=~"PUT|POST").

这在很大程度上定义了他的存储方式和调用形式。

垂直和水平

从一个简单的视角看,所有的数据点都落在一个二维平面上,水平维度表示时间,垂直维度表示遍布的标识符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
series
^
│ . . . . . . . . . . . . . . . . . . . . . . {__name__="request_total", method="GET"}
│ . . . . . . . . . . . . . . . . . . . . . . {__name__="request_total", method="POST"}
│ . . . . . . .
│ . . . . . . . . . . . . . . . . . . . ...
│ . . . . . . . . . . . . . . . . . . . . .
│ . . . . . . . . . . . . . . . . . . . . . {__name__="errors_total", method="POST"}
│ . . . . . . . . . . . . . . . . . {__name__="errors_total", method="GET"}
│ . . . . . . . . . . . . . .
│ . . . . . . . . . . . . . . . . . . . ...
│ . . . . . . . . . . . . . . . . . . . .
v
<-------------------- time --------------------->

prometheus通过周期性的拉取一组时间序列的当前值。通过这种方式获取的一组数据所代表的实体成为目标(target)。因此,写的模式是垂直且高并发的,因为每一个目标的数据都是独立吞吐的。这里提供一些度量标准:每个prometheus实例都会从成千上万的目标手机数据点,每个目标都会暴露成百上千不同的时间序列。

由于每秒收集数百万个数据点的规模,因此批量写入是一个很严苛的性能要求。将数据点分散的写入磁盘会满的令人发指。因此我们想要写入更大一些的顺序数据块。

对于传统的机械硬盘这不是一个让人惊讶的事实,因为他们的读写头需要在物理层面不停地在区块间移动。而SSD类型的硬盘在快速随机写方面的性能众所周知,由于不能直接按照byte最小单位写入磁盘只能按照4KiB一页的最小单位甚至更大来操作写入硬盘,这代表我们不管是写16byte的样本还是写入整4KiB大小的数据是等同的。这个行为属于写扩大的一部分,这将会导致SSD硬盘的损耗-所以他不仅仅会导致慢,并且会在一段时间毁掉你的硬件。

更多关于这个问题的深入信息,可以查看“coding for SSDs“这个很棒的系列博客。这里让我们简单带过只看重点:对于机械硬盘和SSDs类型的硬盘,顺序的批量写是一种很完美的模式。一个很简单需要遵守的规则。

检索与写入是两种明显不同的场景。我们可以检索一个数据点构成的序列,或者10000个由单个点组成的序列,或者检索一个包含一周的数据点的序列,以及10000个包含了一周数据点的序列,等等等。所以在我们这个二维平面上,检索既不是完全的纵向或者横向,而是一个两者组合的矩形。

Prometheus支持的Recording rule可以优化对于已知检索这种情况,但对于临时性的检索仍然需要一个良好的性能表现,因此并不是一个通用的解决方法。

虽然我们想要批量写入,但是我们仅仅可以获取到针对序列来说锤子的数据点(因为拉取数据时是关于N个序列的当前时间数据点)。当检索一段时间的数据点是,不管是搞清楚每个独立的点在哪里去找到,还是从磁盘读取很多的随机地址。由于每个检索可能存在数百万个数据点,因此即便是在最快的SSD上面也会很慢。读也会检索比我们需要的16b更多的数据。SSD也会加载整个页面数据,HDD会至少加载整一个扇区。不管是哪种类型都是对于宝贵的读吞吐极大的浪费。

因此理想情况下应该是同一个序列应该被顺序存放在一起,这样我们仅需要尽可能少的读就可以拿到数据。最重要的是,我们仅仅需要知道序列是从哪里开始的就可以访问所有的数据点。

想而易见的的是在收集数据写入磁盘的写模式与高效的支持检索的设计之间存在很大的鸿沟。这也是对于我们的TSDB来说需要解决的最基本的问题。

当前解决方案

现在让我们看一下prometheus当前的存储是如何解决这个问题的,我们称之为V2。针对每个时间序列我们创建一个单独的文件用来按照顺序存储他全部的样本。由于每秒都将单个样本追加到这些文件成本过于高昂,因此我们针对每个序列会在内存中保留一个1KiB大小的桶,当他们满的时候追加到每个单独的文件。这一点很大程度上解决了大部分问题。现在是批量写,样本也是被顺序存储。基于一个给定样本几乎很少被修改这样一个属性,这种格式同样支持令人惊奇的压缩格式。脸书关于他们的的Gorilla TSDB的论文表述了一种相似的基于桶的实现并且介绍了一种可以将16byte压缩至平均1.37byte的方式。V2存储使用了多种压缩格式,包括了Gorilla的变体实现。

1
2
3
4
5
6
7
8
 ┌──────────┬─────────┬─────────┬─────────┬─────────┐           series A
└──────────┴─────────┴─────────┴─────────┴─────────┘
┌──────────┬─────────┬─────────┬─────────┬─────────┐ series B
└──────────┴─────────┴─────────┴─────────┴─────────┘
. . .
┌──────────┬─────────┬─────────┬─────────┬─────────┬─────────┐ series XYZ
└──────────┴─────────┴─────────┴─────────┴─────────┴─────────┘
chunk 1 chunk 2 chunk 3 ...

虽然基于桶的实现很棒,但是为每一个序列保留一个单独的文件给V2也带来了各种各样的问题:

  • 我们会需要比实际正在收集的时间序列更多的文件。更多细节我们会在“序列搅动”部分提及。由于这些成百上万的文件存在,或早或晚我们都将耗尽系统的inode。如果真的变成这样,我们只能通过格式化磁盘解决这种极具侵入性和破坏性的情况。一般情况下我们也想去避免格式化磁盘,尤其是仅仅为了去适应某个单个程序。
  • 即便被缓存为大块内存,但是成千上万的大块内存都被填满并且需要被持久化。依然需要每秒成千上万的磁盘写入。虽然能够通过批量写入多个某序列完整的大块内存,但是这也反过来增大了持久化数据总的内存占用。
  • 为了读写保持所有文件打开状态也是不可实现的。尤其是当数据过去24h之后大约99%的数据都再也不被检索查询到了。即便是真的被检索到,我们必须首先打开成百上千的文件,找到并且加载相关的数据点到内存并再次关闭它们。由于这会导致很高的查询延迟,数据块会被大量的缓存这进一步导致“资源耗尽”那一节的问题。
  • 最后旧的数据必须要被删掉数据需要被从成千上万的文件头部删掉。这代表着删除实际上是密集型操作。除此之外,循环成百上万的文件并且分析他们需要花费好几个小时的时间。当时间结束的时候他可能又要重新再来一遍,哦对了,删除旧文件还会造成SSD硬盘的放大写的问题。
  • 正在累积当中的内存块仅仅在内存中保有。如果这个时候程序崩溃,数据就会丢失。为了避免这种情况,内存状况需要周期性的写入磁盘,这也很可能要比我们愿意接受的数据丢失窗口要长得多。还原这些数据监测点可能也需要好几分钟,造成漫长的重启周期。

我们从当前设计中需要吸收的是内存块的概念,这是我们最需要保留的概念。最近的内存块被保留在内存中一般情况下也是很好的。毕竟最近的数据也是最可能被检索到的。

每个序列一个文件这个设计我们需要一个替代方案。

序列搅动

在prometheus的上下文,我们使用序列搅动来描述一些时间序列变得不再活跃,比如不再接受更多的数据点,取而代之的是一些新的序列出现。举例来说,一些由携带有instance标签的微服务所暴露出来的时间序列,如果此时执行一个滚动升级并且使用新版本的实例替代老版本,那么序列抖动就发生了。在更加动态的环境中,这些情况基本每时每刻都在发生。像k8s这样的集群编排系统允许持续自动的扩容以及高频的滚动更新应用,这些动作都可能伴随着创建数以千计的应用实例,当这些动作完成的时候也就创建了很多新的时间序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
series
^
│ . . . . . .
│ . . . . . .
│ . . . . . .
│ . . . . . . .
│ . . . . . . .
│ . . . . . . .
│ . . . . . .
│ . . . . . .
│ . . . . .
│ . . . . .
│ . . . . .
v
<-------------------- time --------------------->
理想情况下应该是一个完整的二维平面,但是现在变成了断断续续的数据

即便基础设施的规模保持基本不变,随着时间的发展时间序列在我们的数据库中也会有一个线性的增长。虽然一台prometheus服务实例可以手机1000万的时间序列,但是检索性能也会因为在数亿级别的数据检索而大打折扣。

当前解决方案

当前v2的存储,为每个序列使用了基于levelDB的索引。虽然允许使用给定的键值对进行检索但它缺少一种可拓展的方式来吧不同的标签选择符的结果组合起来。

比如说,如果选取包含{__name__=”requests_total”}标签的所有序列虽然很高效,但是如果选取所有包含{instance=”A” AND __name__=”requests_total”}则会有伸缩性问题。我们稍后会重新确认是什么导致了这种情况,以及为了优化查找延迟必须做哪些必要的改变。

这个问题事实上引出了我们最初寻找一个更好的存储系统的原因。prometheus需要一个改进的索引去实现在成百上万的时间序列里快速查找。

资源消耗

资源消耗是一个衡量prometheus很重要的一个标准(对于其他服务也是)。但困扰用户的并不仅仅是绝对的资源占用。实际上,在可得到的资源配置下,prometheus管理者令人吃惊的吞吐量。他的问题在于当面对变化使得不可预测性和不稳定性。在现有v2版本的存储之下会慢慢的逐渐构建样本数据内存块,这会造成内存消耗随时间逐步上升。当内存块完成之后他们会被写入磁盘并从内存中移除。最终,prometheus的内存使用会达到一个稳定的状态。直到监控环境发生变化-序列搅动增加了内存使用、cpu消耗以及磁盘IO,每当我们扩容应用或者执行一次滚动升级。在变化过程中,最终会再次稳定在一个状态但是会比一个静态环境明显高尚很多。变化期间往往会持续数个小时,难以预测最大的资源消耗会到多少。

使用单文件保存时间序列的方式也很容易使得prometheus被一个查询把进程打卦。当检索的数据没有在内存中,就需要把所有检所涉及到的文件打开并且相关的数据点需要被加载进入内存。如果这些数据超出了可用内存。prometheus会被OOM立刻杀掉。

当检索完成后,加载的数据可以被释放但是往往会被缓存较长的时间使得潜在可能的相似查询可以更快一些。这显然是一件有意义的事情。

最后我们来看一下SSD环境下的写入放大问题,以及prometheus如何通过批量写入减轻这个问题。尽管如此在很多情况下仍然会因为太小的批量写数据或者没有精确地将数据对齐而导致的写放大。对于更大一些规模的prometheus服务器,明显观察到了他们的硬件生命周期更短一些。虽然对于高写入吞吐量的数据库应用来说这些都是正常的,但是我们仍然需要想办法是否能减轻这种情况的发生。

从头开始

到目前为止我们已经明确了问题的范围,包括V2版本的存储是如何解决这些问题的,以及他那里设计的存在问题。我们也看到很多非常棒的想要吸收结合的设计理念。V2的很多问题可以通过改进与部分重构来解决,但是为了是事情更加有趣(当然是在详细比对了我的不同方案之后),我决定首先从零开始实现一个时间序列数据库。将每个byte写入文件系统。

最关注的性能与资源使用是被存储格式直接决定的。我们必须找到合适的算法以及磁盘数据格式进而实现一个高性能的存储层。

从这里开始我们长话短说,直接跳过令人头疼的思考过程、失败的想法以及无穷无尽的草稿,来到解决方案。

V3 —宏观设计

我们存储的宏观设计是如何的?简单概括下就是,所有的东西都能通过在数据目录下执行tree一目了然。仅仅根据这些输出就能一目了然的知道当前的进展。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ tree ./data
./data
├── b-000001
│ ├── chunks
│ │ ├── 000001
│ │ ├── 000002
│ │ └── 000003
│ ├── index
│ └── meta.json
├── b-000004
│ ├── chunks
│ │ └── 000001
│ ├── index
│ └── meta.json
├── b-000005
│ ├── chunks
│ │ └── 000001
│ ├── index
│ └── meta.json
└── b-000006
├── meta.json
└── wal
├── 000001
├── 000002
└── 000003

在最顶层,我们有一个按数字排序的b-开头的块。可以看到,每一个块都包含了一个索引文件以及一个包含更多数字命名的文件内存块目录。“chunk”目录仅包含了不同时间序列的原始chunk数据。就像V2一样,这使得读取一个时间窗口的时间序列资源消耗很低并且使得我们可以应用同样高效的压缩算法。这个模式被证明可以运转的很好所以我们就选定他了。很显然,每个时间序列不再是一个单一的文件,而是一个有少量文件容纳了许多序列的块。

不必对“index”文件的存在吃惊。让我们假定他包含了很多的黑科技,允许我们查找到标签、他们可能的值、整个时间序列以及包含了他们数据点的块。

但是为什么这里会有这么多包含了index和chunk文件结构的目录?为什么最后一个目录会包含一个“wal”目录而不是和其他目录一样?理解了这两个问题,就能解决我们遇到的90%的问题。

很多小的数据库

我们把水平维度进行分割,比如时间区间分割成相互不重叠的块。每个块可以当做一个完整的独立的包含一个时间窗口的学历额数据的数据库系统。因此它拥有自己的索引数据以及一些块文件。

1
2
3
4
5
6
7
8
9
10
t0            t1             t2             t3             now
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ │ │ │ │ │ │ │ ┌────────────┐
│ │ │ │ │ │ │ mutable │ <─── write ──── ┤ Prometheus │
│ │ │ │ │ │ │ │ └────────────┘
└───────────┘ └───────────┘ └───────────┘ └───────────┘ ^
└──────────────┴───────┬──────┴──────────────┘ │
│ query
│ │
merge ─────────────────────────────────────────────────┘

每个块的数据都是不可变的。当然我们在收集数据的同时肯定是需要将新的序列和样本数据写入最新的块文件中。对于这个块文件所有新的写入内存数据库的查找属性会被原样写入我们的持久块中。内存中的数据结构可以被高效的更新。为了防止数据丢失,所有获取到的数据会被也如一个临时的log文件中,就是刚才我们看到的那些wal目录下面的文件,通过这些文件我们可以在服务重启的时候重新装载内存数据库系统的数据。

所有这些文件都包含了他们自己的序列化格式其中包含了应该有的很多东西:很多的标记为、步长、可变数字、以及校验和。虽然想出来很有意思,但是读起来就很枯燥了。

这个设计允许我们将所有的检索请求发送给对应时间相关联的块。所有这些从每个块文件获取的部分数据会被合并还原成原始的结果。

这个水平切分的实现增加了一些很棒的功能:

  • 当检索某个时间段时,我们可以简单的忽略所有不在这个时间范围内的数据块。他就通过减少检索数据的开始位置这种简单的方式解决了序列抖动的问题。
  • 当完成一个块文件时,我们可以将数据从我们的内存数据库中取出,按照顺序持久化到为数不多的相对更大一些的文件中。我们避免了任何可能的写放大的问题,并且在ssd和hdd硬盘上都表现良好。
  • 我们保留了V2就已经有的良好属性,那就是将最新也最可能被检索的块数据热加载保留在内存中。
  • 更好的是,我们不用再为了更好地对齐数据到硬盘而被1KB的块文大小束缚。我们可以为每个独立的数据点选择任何合理的大小和压缩格式。
  • 删除老的数据也变得更加的高效。我们几乎只是简单地删除一个路径。要知道,在老的存储中我们必须分析并且重新写入城北上万的文件,这往往会花费好几个小时。
  • 每个块同样包含了一个meta.json文件。他只是单纯地保留了关于这些块文件的可读信息,以便于我们更加简单的理解我们存储的状态以及它包含的数据。

mmap

从成百上万的小文件改为少数的大文件之后允许我们用很小的开销就能保持所有文件的打开状态。这使得我们可以使用mmap这个系统调用,来把文件内容回传到一个虚拟内存区域。简单来说,你可以把这个当做一个交换空间,我们所有的数据已经在磁盘并且没有任何写入的发生,当从空间中把数据交换出来的时候。

这表示我们可以把数据库包含的所有的信息当做在内存中一样使用,而不用额外去占用任何物理内存。只有当我们访问数据文件中特定的数据时,操作系统才会从硬盘中懒加载页面数据到内存中。这使得操作系统负责所有与我们的持久化数据相关的内存管理。一般来说这种方式更加合理,因为操作系统对整个机器以及他上面的进程拥有更完整的视野。被查询的数据可以被积极地缓存在内存中,而在内存的压力下页面会被换出。如果机器还有未使用的内存,prometheus会很乐于把整个数据库缓存起来,当另一个应用需要内存的时候会立刻释放。

因此查询不会再像以前一样因为查询比RAM更多的持久化数据而使得我们的进程OOM。内存数据变成完全的自适应并且只有当查询实际需要的时候才会进行加载。

从我的理解来看,这就是当前很多的数据库系统的工作方式,并且当磁盘格式允许的情况下是一个很完美的方式—-除非某个人自信自己比操作系统更加的专业。

站在我们的角度来看,我们仅仅是做了一小部分工作却获得了很多的其他功能。

压缩

存储会周期性的切出一个新的块并且完成的前一个块写入硬盘,只有当块文件被持久化成功之后,用来保存内存块数据的写日志的文件才会被删除。

我们很乐意将每个块文件保持合理的比较短的长度(比如两个小时)避免在内存中积累过多的数据。当检索涉及到多个块文件时,我们必须将他们的结果合并成一个总的结果。这个合并的过程很显然需要一定的小号,并且一周那么长的检索不应该合并超过80+的结果。

为了打成以上目标,我们引入了压缩。压缩主要是把一个或者多个数据文件写入一个千载的更大的块文件。它能够沿用我们现在的方式修改数据,比如说丢弃删除数据或者重新构建我们的样本块来提升检索性能。

1
2
3
4
5
6
7
8
9
t0             t1            t2             t3             t4             now
┌────────────┐ ┌──────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ 1 │ │ 2 │ │ 3 │ │ 4 │ │ 5 mutable │ before
└────────────┘ └──────────┘ └───────────┘ └───────────┘ └───────────┘
┌─────────────────────────────────────────┐ ┌───────────┐ ┌───────────┐
│ 1 compacted │ │ 4 │ │ 5 mutable │ after (option A)
└─────────────────────────────────────────┘ └───────────┘ └───────────┘
┌──────────────────────────┐ ┌──────────────────────────┐ ┌───────────┐
│ 1 compacted │ │ 3 compacted │ │ 5 mutable │ after (option B)

在这个例子中,我们有一个成序列的块,[1/2/3/4],块文件1、2、3可以被压缩起来生成新的结构[1/4]。也可以成对压缩他们变成[1/3]。所有的时间序列数据仍然存在,但是块文件的总数变少了。当这种成片的小文件数量变少的时候能显著减少合并数据的代价。

保留

在V2版本的存储当中,删除老数据是很慢的流程并且会给注入cpu、内存、硬盘这种带来性能损耗。那么我们在基于块文件的设计中怎样删除数据呢?很简单,当一个目录包含的块设备都不在我们配置的时间保留窗口了,那么删掉它就可以了。比如下面这个例子,文件块1可以被安全删除,但是文件块2必须比保留,直到它完全脱离边界

1
2
3
4
5
6
┌────────────┐  ┌────┼─────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐
│ 1 │ │ 2 | │ │ 3 │ │ 4 │ │ 5 │ . . .
└────────────┘ └────┼─────┘ └───────────┘ └───────────┘ └───────────┘
|
|
retention boundary

随着数据逐渐变得更老,文件块也变得越来越大,因为我们需要不断保持压缩之前压缩过得文件块。因此需要设定一个上限防止块文件逐渐横跨整个数据库,从而减少我们设计的原始优势。

方便的是,这也限制了部分在保留窗口内和部分在保留窗口外的块的总磁盘开销,以上图块文件2为例。当将最大区块大小设置为总保留窗口的10%时,我们将区块2保留在周围的总开销也受到10%的约束。

总的来说,保留删除这个操作从很昂贵变成了几乎无开销。

1
2
3
4
如果你看到了这里,并且拥有一定的数据库背景知识,你可能会好奇一件事情:这有什么新奇的吗? ---未必,并且可能会更好。
将数据批量缓存在内存中,提前将可追溯数据写入日志,并且定期刷入磁盘的模式在当前是很常见的设计模式。
目前我们所看到的益处几乎普遍使用,而不用管数据部分设计的具体细节。遵循这个实现的开源项目包括LevelDB/Cassandra/influxDb以及HBase。关键的启发是避免重新发明一个劣质的轮子,研究成熟的方法并以正确的方式加以应用。
不用担心找不到机会来自由发挥将自己的想法加入进去,这是不可能发生的情况(因为不同项目的细节一定不同)

索引

转载请注明来源链接 http://just4fun.im/2020/11/22/tsdb-translation/ 尊重知识,谢谢:)