46 KiB
从零写一个时间序列数据库
我从事监控工作。特别是在 Prometheus 上,监控系统包含一个自定义的时间序列数据库,并且集成在 Kubernetes 上。
在许多方面上 Kubernetes 展现出了所有 Prometheus 的设计用途。它使得持续部署,弹性伸缩和其他高动态环境下的功能可以轻易地访问。在众多概念上的决策中,查询语句和操作模型使得 Prometheus 特别适合这种环境。但是,如果监控的工作负载动态程度显著地增加,这就会给监控系统本身带来新的压力。记住了这一点,而不是回过头来看 Prometheus 已经解决的很好的问题,我们就可以明确目标去提升它高动态或瞬态服务环境下的表现。
Prometheus 的存储层在很长一段时间里都展现出卓越的性能,单一服务器就能够以每秒数百多万个时间序列的速度摄入多达一百万个样本,同时只占用了很少的磁盘空间。尽管当前的存储做的很好,但我依旧提出一个新设计的存储子系统,它更正了现存解决方案的缺点,并具备处理更大规模数据的能力。
注释:我没有数据库方面的背景。我说的东西可能是错的并让你误入歧途。你可以在 Freenode 的 #prometheus 频道上提出你的批评(fabxc)
问题,难题,问题域
首先,快速地概览一下我们要完成的东西和它的关键难题。我们可以先看一下 Prometheus 当前的做法 ,它为什么做的这么好,以及我们打算用新设计解决哪些问题。
时间序列数据
我们有一个收集一段时间数据的系统。
identifier -> (t0, v0), (t1, v1), (t2, v2), (t3, v3), ....
每个数据点是一个时间戳和值的元组。在监控中,时间戳是一个整数,值可以是任意数字。64 位浮点数对于计数器和测量值来说是一个好的表示方法,因此我们将会使用它。一系列严格单调递增的时间戳数据点是一个序列,它由标识符所引用。我们的标识符是一个带有标签维度字典的度量名称。标签维度分开了单一指标的测量空间。每一个指标名称加上一个独一无二的标签集就成了它自己的时间序列,它有一个与之关联的数据流。
这是一个典型的序列标识符集,它是统计请求指标的一部分:
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”}
我们想通过标签来查询时间序列数据。在最简单的情况下,使用 {__name__="requests_total"}
选择所有属于 requests_total
指标的数据。对于所有选择的序列,我们在给定的时间窗口内获取数据点。
在更复杂的语句中,我们或许想一次性选择满足多个标签的序列,并且表示比相等条件更复杂的情况。例如,非语句(method!="GET"
)或正则表达式匹配(method=~"PUT|POST"
)。
这些在很大程度上定义了存储的数据和它的获取方式。
纵与横
在简化的视图中,所有的数据点可以分布在二维平面上。水平维度代表着时间,序列标识符域经纵轴展开。
series
^
│ . . . . . . . . . . . . . . . . . . . . . . {__name__="request_total", method="GET"}
│ . . . . . . . . . . . . . . . . . . . . . . {__name__="request_total", method="POST"}
│ . . . . . . .
│ . . . . . . . . . . . . . . . . . . . ...
│ . . . . . . . . . . . . . . . . . . . . .
│ . . . . . . . . . . . . . . . . . . . . . {__name__="errors_total", method="POST"}
│ . . . . . . . . . . . . . . . . . {__name__="errors_total", method="GET"}
│ . . . . . . . . . . . . . .
│ . . . . . . . . . . . . . . . . . . . ...
│ . . . . . . . . . . . . . . . . . . . .
v
<-------------------- time --------------------->
Prometheus 通过定期地抓取一组时间序列的当前值来获取数据点。我们获取到的实体称为目标。因此,写入模式完全地垂直且高度并发,因为来自每个目标的样本是独立摄入的。这里提供一些测量的规模:单一 Prometheus 实例从成千上万的目标中收集数据点,每个数据点都暴露在成百上千个不同的时间序列中。
在每秒采集数百万数据点这种规模下,批量写入是一个不能妥协的性能要求。在磁盘上分散地写入单个数据点会相当地缓慢。因此,我们想要按顺序写入更大的数据块。 对于旋转式磁盘,它的磁头始终得物理上地向不同的扇区上移动,这是一个不足为奇的事实。而我们都知道 SSD 具有快速随机写入的特点,但事实上它不能修改单独的字节,只能写入一页 4KiB 或更多的数据量。这就意味着写入 16 字节的样本相当于写入满满一个 4Kib 的页。这一行为部分上属于写入放大,这种特性会损耗你的 SSD。因此它不仅影响速度,而且还毫不夸张地在几天或几个周内破坏掉你的硬件。 关于此问题更深层次的资料,“Coding for SSDs”系列博客是极好的资源。让我们想想有什么收获:顺序写入和批量写入对于旋转式磁盘和 SSD 来说都是理想的写入模式。大道至简。
查询模式比起写入模式千差万别。我们可以查询单一序列的一个数据点,也可以为 10000 个序列查询一个数据点,还可以查询一个序列几个周的数据点,甚至是 10000 个序列几个周的数据点。因此在我们的二维平面上,查询范围不是完全水平或垂直的,而是二者形成矩形似的组合。 记录规则减轻了已知查询的问题,但对于点对点查询来说并不是一个通用的解决方法。
我们知道自己想要批量地写入,但我们得到的仅仅是一系列垂直数据点的集合。当查询一段时间窗口内的数据点时,我们不仅很难弄清楚在哪才能找到这些单独的点,而且不得不从磁盘上大量随机的地方读取。也许一条查询语句会有数百万的样本,即使在最快的 SSD 上也会很慢。读入也会从磁盘上获取更多的数据而不仅仅是 16 字节的样本。SSD 会加载一整页,HDD 至少会读取整个扇区。不论哪一种,我们都在浪费宝贵的读吞吐量。 因此在理想上,相同序列的样本将按顺序存储,这样我们就能通过尽可能少的读取来扫描它们。在上层,我们仅需要知道序列的起始位置就能访问所有的数据点。
显然,将收集到的数据写入磁盘的理想模式与能够显著提高查询效率的布局之间存在着很强的张力。这是我们 TSDB 需要解决的一个基本问题。
当前的解法
是时候看一下当前 Prometheus 是如何存储数据来解决这一问题的,让我们称它为“V2”。 我们创建一个时间序列的文件,它包含所有样本并按顺序存储。因为每几秒附加一个样本数据到所有文件中非常昂贵,我们打包 1Kib 样本序列的数据块在内存中,一旦打包完成就附加这些数据块到单独的文件中。这一方法解决了大部分问题。写入目前是批量的,样本也是按顺序存储的。它还支持非常高效的压缩格式,基于给定的同一序列的样本相对之前的数据仅发生非常小的改变这一特性。Facebook 在他们 Gorilla TSDB 上的论文中描述了一个相似的基于数据块的方法,并且引入了一种压缩格式,它能够减少 16 字节的样本到平均 1.37 字节。V2 存储使用了包含 Gorilla 等的各种压缩格式。
┌──────────┬─────────┬─────────┬─────────┬─────────┐ series A
└──────────┴─────────┴─────────┴─────────┴─────────┘
┌──────────┬─────────┬─────────┬─────────┬─────────┐ series B
└──────────┴─────────┴─────────┴─────────┴─────────┘
. . .
┌──────────┬─────────┬─────────┬─────────┬─────────┬─────────┐ series XYZ
└──────────┴─────────┴─────────┴─────────┴─────────┴─────────┘
chunk 1 chunk 2 chunk 3 ...
尽管基于块存储的方法非常棒,但为每个序列保存一个独立的文件会给 V2 存储带来麻烦,因为:
- 我们实际上需要比当前收集的时间序列数目使用更多的文件。多出的部分在序列分流上。拥有几百万个文件,迟早会使用光文件系统中的 inodes。这种情况我们只可以通过重新格式化来恢复磁盘,这种方式是最具有破坏性的。我们通常想要避免为了适应一个应用程序而格式化磁盘。
- 即使是分块写入,每秒也会产生几千万块的数据块并且准备持久化。这依然需要每秒数千个次的磁盘写入量。尽管通过为每个序列打包好多个块来缓解,但反过来还是增加了等待持久化数据的总内存占用。
- 要保持所有文件的打开状态进行读写是不可行的。特别是因为 99% 的数据在 24 小时之后不再会被查询到。如果它还是被查询到,我们就得打开数千个文件,找到并读取相关的数据点到内存中,然后再关掉。这样做就会引起很高的查询延迟,数据块缓存加剧会导致新的问题,这一点在“资源消耗”一节另作讲述。
- 最终,旧的数据需要被删除并且数据需要从数百万文件的头部删除。这就意味着删除实际上是高强度的写入操作。此外,循环遍历数百万文件并且进行分析通常会导致这一过程花费数小时。当它完成时,可能又得重新来过。喔天,继续删除旧文件又会进一步导致 SSD 产生写入放大。
- 目前所积累的数据块仅维持在内存中。如果应用崩溃,数据就会丢失。为了避免这种情况,内存状态会定期的保存在磁盘上,这比我们能接受数据丢失的时间要长的多。恢复检查点也会花费数分钟,导致很长的重启周期。
我们能够从现有的设计中学到的关键部分是数据块的概念,这一点会依旧延续。最近一段时间的数据块会保持在内存中也大体上不错。毕竟,最近时间段的数据会大量的查询到。一个时间序列对应一个文件,这种概念是我们想要替换掉的。
序列分流
在 Prometheus 的上下文中,我们使用术语序列分流来描述不活越的时间序列集合,即不再接收数据点,取而代之的是出现一组新的活跃序列。 例如,由给定微服务实例产生的所有序列都有一个相对的“instance”标签来标识它的起源。如果我们为微服务执行了滚动更新,并且为每个实例替换一个新的版本,序列分流便会发生。在更加动态的环境中,这些事情基本上每小时都会发生。像 Kubernetes 这样的集群编排系统允许应用连续性的自动伸缩和频繁的滚动更新,这样也许会创建成千上万个新的应用程序实例,并且伴随着全新的时间序列集合,每天都是如此。
series
^
│ . . . . . .
│ . . . . . .
│ . . . . . .
│ . . . . . . .
│ . . . . . . .
│ . . . . . . .
│ . . . . . .
│ . . . . . .
│ . . . . .
│ . . . . .
│ . . . . .
v
<-------------------- time --------------------->
所以即便整个基础设施的规模基本保持不变,过一段时间后数据库内的时间序列还是会成线性增长。尽管 Prometheus 很愿意采集 1000 万个时间序列数据,但要想在 10 亿的序列中找到数据,查询效果还是会受到严重的影响。
当前解法
当前 Prometheus 的 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 存储系统相当数量的问题都可以通过改进和部分的重新设计来解决,但为了好玩(当然,在我仔细的验证想法之后),我决定试着写一个完整的时间序列数据库——从头开始,即向文件系统写入字节。
性能与资源使用这种最关键的部分直接导致了存储格式的选取。我们需要为数据找到正确的算法和磁盘布局来实现一个高性能的存储层。
这就是我解决问题的捷径——跳过令人头疼,失败的想法,数不尽的草图,泪水与绝望。
V3—宏观设计
我们存储系统的宏观布局是什么?简而言之,是当我们在数据文件夹里运行 tree
命令时显示的一切。看看它能给我们带来怎样一副惊喜的画面。
$ 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
文件夹。chunks
目录只包含不同序列数据点的原始块。与 V2存储系统一样,这使得通过时间窗口读取序列数据非常高效并且允许我们使用相同的有效压缩算法。这一点被证实行之有效,我们也打算沿用。显然,这里并不存在含有单个序列的文件,而是一堆保存着许多序列的数据块。
index
文件的存在应不足为奇。让我们假设它拥有黑魔法,可以让我们找到标签、可能的值、整个时间序列和存放数据点的数据块。
但为什么这里有好几个文件夹都是索引和块文件的布局?并且为什么存在最后一个包含“wal”文件夹?理解这两个疑问便能解决九成的问题 。
许多小型数据库
我们分割横轴,即将时间域分割为不重叠的块。每一块扮演者完全独立的数据库,它包含该时间窗口所有的时间序列数据。因此,它拥有自己的索引和一系列块文件。
t0 t1 t2 t3 now
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ │ │ │ │ │ │ │ ┌────────────┐
│ │ │ │ │ │ │ mutable │ <─── write ──── ┤ Prometheus │
│ │ │ │ │ │ │ │ └────────────┘
└───────────┘ └───────────┘ └───────────┘ └───────────┘ ^
└──────────────┴───────┬──────┴──────────────┘ │
│ query
│ │
merge ─────────────────────────────────────────────────┘
每一块的数据都是不可变的。当然,当我们采集新数据时,我们必须能向最近的块中添加新的序列和样本。对于该数据块,所有新的数据都将写入内存中的数据库中,它与我们的持久化的数据块一样提供了查找属性。内存中的数据结构可以高效地更新。为了防止数据丢失,所有预传入的数据同样被写入临时的预写日志中,这就是 wal
文件夹中的一些列文件,我们可以在重新启动时通过它们加载内存数据库。
所有这些文件都带有序列化格式,有我们所期望的所有东西:许多标志,偏移量,变体和 CRC32 校验。纸上得来终觉浅,绝知此事要躬行。
这种布局允许我们扩展查询范围到所有相关的块上。每个块上的部分结果最终合并成完整的结果。
这种横向分割增加了一些很棒的功能:
- 当查询一个时间范围,我们可以简单地忽略所有范围之外的数据块。通过减少需要检查的一系列数据,它可以初步解决序列分流的问题。
- 当完成一个块,我们可以通过顺序的写入大文件从内存数据库中保存数据。这样可以避免任何的写入放大,并且 SSD 与 HDD 均适用。
- 我们延续了 V2 存储系统的一个好的特性,最近使用而被多次查询的数据块,总是保留在内存中。
- 足够好了,我们也不再限定 1KiB 的数据块尺寸来使数据在磁盘上更好地对齐。我们可以挑选对单个数据点和压缩格式最合理的尺寸。
- 删除旧数据变得极为简单快捷。我们仅仅只需删除一个文件夹。记住,在旧的存储系统中我们不得不花数个小时分析并重写数亿个文件。
每个块还包含了 meta.json
文件。它简单地保存了关于块的存储状态和包含的数据以供人们简单的阅读。
mmap
将数百万个小文件合并为一个大文件使得我们用很小的开销就能保持所有的文件都打开。这就引出了 mmap(2)
的使用,一个允许我们通过文件透明地回传虚拟内存的系统调用。为了简便,你也许想到了交换空间,只是我们所有的数据已经保存在了磁盘上,并且当数据换出内存后不再会发生写入。
这意味着我们可以当作所有数据库的内容都保留在内存中却不占用任何物理内存。仅当我们访问数据库文件确定的字节范围时,操作系统从磁盘上惰性加载页数据。这使得我们将所有数据持久化相关的内存管理都交给了操作系统。大体上,操作系统已足够资格作出决定,因为它拥有整个机器和进程的视图。查询的数据可以相当积极的缓存进内存,但内存压力会使得页被逐出。如果机器拥有未使用的内存,Prometheus 目前将会高兴地缓存整个数据库,但是一旦其他进程需要,它就会立刻返回。 因此,查询不再轻易地使我们的进程 OOM,因为查询的是更多的持久化的数据而不是装入内存中的数据。内存缓存大小变得完全自适应,并且仅当查询真正需要时数据才会被加载。
就个人理解,如果磁盘格式允许,这就是当今大多数数据库的理想工作方式——除非有人自信的在进程中智胜操作系统。我们做了很少的工作但确实从外面获得了很多功能。
压缩
存储系统需要定期的“切”出新块并写入之前完成的块到磁盘中。仅在块成功的持久化之后,写之前用来恢复内存块的日志文件(wal)才会被删除。 我们很乐意将每个块的保存时间设置的相对短一些(通常配置为 2 小时)以避免内存中积累太多的数据。当查询多个块,我们必须合并它们的结果为一个完成的结果。合并过程显然会消耗资源,一个周的查询不应该由 80 多个部分结果所合并。
为了实现两者,我们引入压缩。压缩描述了一个过程:取一个或更多个数据块并将其写入一个可能更大的块中。它也可以在此过程中修改现有的数据。例如,清除已经删除的数据,或为提升查询性能重建样本块。
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 │ . . .
└────────────┘ └────┼─────┘ └───────────┘ └───────────┘ └───────────┘
|
|
retention boundary
得到越旧的数据,保存的块也就越大,因为我们会压缩之前的压缩块。因此必须为其设置一个上限,以防数据块扩展到整个数据库而损失我们设计的最初优势。 方便的是,这一点也限制了部分存在于保留窗口内部分存在于保留窗口外的总磁盘块的消耗。例如上面例子中的块 2。当设置了最大块尺寸为总保留窗口的 10% 后,我们保留块 2 的总开销也有了 10% 的上限。
总结一下,保留与删除从非常昂贵到了几乎没有成本。
如果你读到这里并有一些数据库的背景知识,现在你也许会问:这些都是最新的技术吗?——并不是。而且可能还会做的更好。
在内存中打包数据,定期的写入日志并刷新磁盘的模式在现在相当普遍。 我们看到的好处无论在什么领域的数据里都是适用的。遵循这一方法最著名的开源案例是 LevelDB,Cassandra,InfluxDB 和 HBase。关键是避免重复发明劣质的轮子,采用经得起验证的方法,并正确地运用它们。 这里仍有地方来添加你自己的黑魔法。
索引
研究存储改进的最初想法是解决序列分流的问题。基于块的布局减少了查询所要考虑的序列总数。因此假设我们索引查找的复杂度是 O(n^2)
,我们就要设法减少 n 个相当数量的复杂度,之后就有了改进后 O(n^2)
的复杂度。——恩,等等...糟糕。
快速地想想“算法 101”课上提醒我们的,在理论上它并未带来任何好处。如果之前就很糟糕,那么现在也一样。理论是如此的残酷。
实际上,我们大多数的查询已经可以相当快地被相应。但是,跨越整个时间范围的查询仍然很慢,尽管只需要找到少部分数据。追溯到所有这些工作之前,最初我用来解决这个问题的想法是:我们需要一个更大容量的倒排索引。倒排索引基于数据项内容的子集提供了一种快速的查找方式。简单地说,我可以通过标签 app=”nginx"
查找所有的序列而无需遍历每个文件来看它是否包含该标签。
为此,每个序列被赋上一个唯一的 ID 来在常数时间内获取,例如 O(1)。在这个例子中 ID 就是 我们的正向索引。
示例:如果 ID 为 10,29 ,9 的序列包含标签
app="nginx"
,那么 “nginx”的倒排索引就是简单的列表[10, 29, 9]
,它就能用来快速地获取所有包含标签的序列。即使有 200 多亿个数据也不会影响查找速度。
简而言之,如果 n 是我们序列总数,m 是给定查询结果的大小,使用索引的查询复杂度现在就是 O(m)。查询语句跟随它获取数据的数量 m 而不是被搜索的数据体 n 所扩展是一个很好的特性,因为 m 一般相当小。 为了简单起见,我们假设可以在常数时间内查找到倒排索引对应的列表。
实际上,这几乎就是 V2 存储系统已有的倒排索引,也是提供在数百万序列中查询性能的最低需求。敏锐的人会注意到,在最坏情况下,所有的序列都含有标签,因此 m 又成了 O(n)。这一点在预料之中也相当合理。如果你查询所有的数据,它自然就会花费更多时间。一旦我们牵扯上了更复杂的查询语句就会有问题出现。
标签组合
数百万个带有标签的数据很常见。假设横向扩展着数百个实例的“foo”微服务,并且每个实例拥有数千个序列。每个序列都会带有标签app="foo"
。当然,用户通常不会查询所有的序列而是会通过进一步的标签来限制查询。例如,我想知道服务实例接收到了多少请求,那么查询语句便是 __name__="requests_total" AND app="foo"
。
为了找到适应所有标签选择子的序列,我们得到每一个标签的倒排索引列表并取其交集。结果集通常会比任何一个输入列表小一个数量级。因为每个输入列表最坏情况下的尺寸为 O(n),所以在嵌套地为每个列表进行暴力求解下,运行时间为 O(n^2)。与其他的集合操作耗费相同,例如取并集 (app="foo" OR app="bar"
)。当添加更多标签选择子在查询语句上,耗费就会指数增长到 O(n^3), O(n^4), O(n^5), ... O(n^k)。有很多手段都能通过改变执行顺序优化运行效率。越复杂,越是需要关于数据特征和标签之间相关性的知识。这引入了大量的复杂度,但是并没有减少算法的最坏运行时间。
这便是 V2 存储系统使用的基本方法,幸运的是,似乎稍微的改动就能获得很大的提升。如果我们假设倒排索引中的 ID 都是排序好的会怎么样?
假设这个例子的列表用于我们最初的查询:
__name__="requests_total" -> [ 9999, 1000, 1001, 2000000, 2000001, 2000002, 2000003 ]
app="foo" -> [ 1, 3, 10, 11, 12, 100, 311, 320, 1000, 1001, 10002 ]
intersection => [ 1000, 1001 ]
它的交集相当小。我们可以为每个列表的起始位置设置游标,每次从最小的游标处移动来找到交集。当二者的数字相等,我们就添加它到结果中并移动二者的游标。总体上,我们以锯齿形扫描两个列表,因此总耗费是 O(2n)=O(n),因为我们总是在一个列表上移动。
两个以上列表的不同集合操作也类似。因此 k 个集合操作仅仅改变了因子 O(k*n) 而不是最坏查找运行时间下的指数 O(n^k)。 我在这里所描述的是任意一个全文搜索引擎使用的标准搜索索引的简化版本。每个序列描述符都视作一个简短的“文档”,每个标签(名称 + 固定值)作为其中的“单词”。我们可以忽略搜索引擎索引中很多附加的数据,例如单词位置和和频率。 似乎存在着无止境的研究来提升实际的运行时间,通常都是对输入数据做一些假设。不出意料的是,仍有大量技术来压缩倒排索引,其中各有利弊。因为我们的“文档”比较小,而且“单词”在所有的序列里大量重复,压缩变得几乎无关紧要。例如,一个真实的数据集约有 440 万个序列与大约 12 个标签,每个标签拥有少于 5000 个单独的标签。对于最初的存储版本,我们坚持基本的方法不使用压缩,仅做微小的调整来跳过大范围非交叉的 ID。
尽管维持排序好的 ID 听起来很简单,但实践过程中不是总能完成的。例如,V2 存储系统为新的序列赋上一个哈希值来当作 ID,我们就不能轻易地排序倒排索引。另一个艰巨的任务是当磁盘上的数据被更新或删除掉后修改其索引。通常,最简单的方法是重新计算并写入,但是要保证数据库在此期间可查询且具有一致性。V3 存储系统通过每块上独立的不可变索引来解决这一问题,仅通过压缩时的重写来进行修改。只有可变块上的索引需要被更新,它完全保存在内存中。
基准测试
我发起了一个最初版本的基准测试,它基于现实世界数据集中提取的大约 440 万个序列描述符,并生成合成数据点对应到这些序列中。这个方法仅仅测试单独的存储系统,快速的找到高并发负载场景下的运行瓶颈和触发死锁至关重要。
在概念性的运用完成之后,基准测试能够在我的 Macbook Pro 上维持每秒 2000 万的吞吐量—并且所有 Chrome 的页面和 Slack 都保持着运行。因此,尽管这听起来都很棒,它这也表明推动这项测试没有的进一步价值。(或者是没有在高随机环境下运行)。毕竟,它是合成的数据,因此在除了好的第一印象外没有多大价值。比起最初的设计目标高出 20 倍,是时候将它部署到真正的 Prometheus 服务器上了,为它添加更多现实环境中的开销和场景。
我们实际上没有可重复的 Prometheus 基准测试配置,特别是对于不同版本的 A/B 测试。亡羊补牢为时不晚,现在就有一个了!
工具可以让我们声明性地定义基准测试场景,然后部署到 AWS 的 Kubernetes 集群上。尽管对于全面的基准测试来说不是最好环境,但它肯定比 64 核 128GB 内存的专用裸机服务器更能反映出用户基础。我们部署两个 Prometheus 1.5.2 服务器(V2 存储系统)和两个从 2.0 分支继续开发的 Prometheus (V3 存储系统) 。每个 Prometheus 运行在配备 SSD 的专用服务器上。我们将横向扩展的应用部署在了工作节点上,并且让其暴露典型的微服务量。此外,Kubernetes 集群本身和节点也被监控着。整个配置由另一个 Meta-Prometheus 所监督,它监控每个 Prometheus 的健康状况和性能。为了模拟序列分流,微服务定期的扩展和收缩来移除旧的 pods 并衍生新的 pods,生成新的序列。查询负载通过典型的查询选择来模拟,对每个 Prometheus 版本都执行一次。
总体上,伸缩与查询的负载和采样频率一样极大的超出了 Prometheus 的生产部署。例如,我们每隔 15 分钟换出 60% 的微服务实例去产生序列分流。在现代的基础设施上,一天仅大约会发生 1-5 次。这就保证了我们的 V3 设计足以处理未来几年的工作量。就结果而言,Prometheus 1.5.2 和 2.0 之间的性能差异在不温和的环境下会变得更大。 总而言之,我们每秒从 850 个暴露 50 万数据的目标里收集了大约 11 万份样本。
在此配置运行一段时间之后,我们可以看一下数字。我们评估了两个版本在 12 个小时之后到达稳定时的几个指标。
请注意从 Prometheus 图形界面的截图中轻微截断的 Y 轴
堆内存使用(GB)
内存资源使用对用户来说是最为困扰的问题,因为它相对的不可预测且能够导致进程崩溃。 显然,被查询的服务器正在消耗内存,这极大程度上归咎于查询引擎的开销,这一点可以当作以后优化的主题。总的来说,Prometheus 2.0 的内存消耗减少了 3-4 倍。大约 6 小时之后,在 Prometheus 1.5 上有一个明显的峰值,与我们设置 6 小时的保留边界相对应。因为删除操作成本非常高,所以资源消耗急剧提升。这一点在下面几张图中均有体现。
CPU 使用(核心/秒)
类似的模式展示 CPU 使用,但是查询的服务器与非查询的服务器之间的差异尤为明显。每秒获取大约 11 万个数据需要 0.5 核心/秒的 CPU 资源,比起评估查询所花费的时间,我们新的存储系统 CPU 消耗可忽略不计。
磁盘写入(MB/秒)
图片展示出的磁盘利用率取得了令人意想不到的提升。这就清楚的展示了为什么 Prometheus 1.5 很容易造成 SSD 损耗。我们看到最初的上升发生在第一个块被持久化到序列文件中的时期,然后一旦删除操作引发了重写就会带来第二个上升。令人惊讶的是,查询的服务器与非查询的服务器显示出了非常不同的利用率。 Prometheus 2.0 on the other hand, merely writes about a single Megabyte per second to its write ahead log. Writes periodically spike when blocks are compacted to disk. Overall savings: staggering 97-99%.Prometheus 2.0 在另一方面,每秒仅仅写入大约一兆字节的日志文件。当块压缩到磁盘之时,写入定期地出现峰值。这在总体上节省了:惊人的 97-99%。
磁盘大小(GB)
与磁盘写入密切相关的是总磁盘空间占用量。由于我们对样本几乎使用了相同的压缩算法,因此磁盘占用量应当相同。在更为稳定的配置中,这样做很大程度上是正确地,但是因为我们需要处理高序列分流,所以还要考虑每个序列的开销。 如我们所见,Prometheus 1.5 在两个版本达到稳定状态之前,使用的存储空间因保留操作而急速上升。Prometheus 2.0 似乎在每个序列上具有更少的消耗。我们可以清楚的看到写入日志线性地充满整个存储空间,然后当压缩完成后立刻掉下来。事实上对于两个 Prometheus 2.0 服务器,它们的曲线并不是完全匹配的,这一点需要进一步的调查。
前景大好。剩下最重要的部分是查询延迟。新的索引应当优化了查找的复杂度。没有实质上发生改变的是处理数据的过程,例如 rate()
函数或聚合。这些就是查询引擎要做的东西了。
第 99 个百分位查询延迟(秒)
数据完全符合预期。在 Prometheus 1.5 上,查询延迟随着存储的数据而增加。只有在保留操作开始且旧的序列被删除后才会趋于稳定。作为对比,Prometheus 从一开始就保持在合适的位置。 我们需要花一些心思在数据是如何被采集上,对服务器发出的查询请求通过估计以下方面被选中:查询范围和即时查询的组合,进行或轻或重的计算,访问或多或少的文件。它并不需要代表真实世界里查询的分布。也不能代表冷数据的查询性能,我们可以假设所有的样本数据都是保存在内存中的热数据。 尽管如此,我们可以相当自信地说,整体查询效果对序列分流变得非常有弹性,并且提升了高压基准测试场景下 4 倍的性能。在更为静态的环境下,我们可以假设查询时间大多数花费在了查询引擎上,改善程度明显较低。
摄入的样本/秒
最后,快速地看一下不同 Prometheus 服务器的摄入率。我们可以看到搭载 V3 存储系统的两个服务器具有相同的摄入速率。在几个小时之后变得不稳定,这是因为不同的基准测试集群节点由于高负载变得无响应,与 Prometheus 实例无关。(两点之前的曲线完全匹配这一事实希望足够具有说服力) 尽管还有更多 CPU 和内存资源,两个 Prometheus 1.5.2 服务器的摄入率大大降低。序列分流高压导致了无法采集更多的数据。
那么现在每秒可以摄入的绝对最大样本数是多少?
我不知道——而且故意忽略。
存在的很多因素都会影响 Prometheus 数据流量,而且没有一个单独的数字能够描述捕获质量。最大摄入率在历史上是一个导致基准出现偏差的度量量,并且忽视了更多重要的层面,例如查询性能和对序列分流的弹性。关于资源使用线性增长的大致猜想通过一些基本的测试被证实。很容易推断出其中的原因。
我们的基准测试模拟了高动态环境下 Prometheus 的压力,它比起真实世界中的更大。结果表明,虽然运行在没有优化的云服务器上,但是已经超出了预期的效果。
注意:在撰写本文的同时,Prometheus 1.6 正在开发当中,它允许更可靠地配置最大内存使用量,并且可能会显著地减少整体的消耗,提高 CPU 使用率。我没有重复进行测试,因为整体结果变化不大,尤其是面对高序列分流的情况。
总结
Prometheus 开始应对高基数序列与单独样本的吞吐量。这仍然是一项富有挑战性的任务,但是新的存储系统似乎向我们展示了未来的一些好东西:超大规模,高收敛度,GIFEE 基础设施。好吧,它似乎运行的不错。
第一个配备 V3 存储系统的 alpha 版本 Prometheus 2.0 已经可以用来测试了。在早期阶段预计还会出现崩溃,死锁和其他 bug。
存储系统的代码可以在这个单独的项目中找到。Prometheus 对于寻找高效本地存储时间序列数据库的应用来说可能非常有用,之一点令人非常惊讶。
这里需要感谢很多人作出的贡献,以下排名不分先后:
Bjoern Rabenstein 和 Julius Volz 在 V2 存储引擎上的打磨工作以及 V3 存储系统的反馈,这为新一代的设计奠定了基础。
Wilhelm Bierbaum 对新设计不断的建议与见解作出了很大的贡献。Brian Brazil 不断的反馈确保了我们最终得到的是语义上合理的方法。与 Peter Bourgon 深刻的讨论验证了设计并形成了这篇文章。
别忘了我们整个 CoreOS 团队与公司对于这项工作的赞助与支持。感谢所有那些听我一遍遍唠叨 SSD,浮点数,序列化格式的同学。
via: https://fabxc.org/blog/2017-04-10-writing-a-tsdb/
作者:Fabian Reinartz 译者:译者ID 校对:校对者ID