翻译原文:https://fabxc.org/tsdb/

这是一篇prometheus storage重新设计(v2->v3)的文章分享。

我致力于监控。特别是在Prometheus上:一个监控系统,包括自定义时间序列数据库,以及它与Kubernetes的集成。

在许多方面,Kubernetes代表了prometheus为之设计的所有东西。它可以轻松访问高度动态环境的持续部署,自动扩展和其他功能。查询语言和操作模型以及许多其他概念决策使Prometheus特别适合此类环境。然而,如果受监控的工作负载变得更加动态,这也会给监控系统本身带来新的压力。考虑到这一点,我们专注于提高其在具有高动态或瞬态服务的环境中的性能,而不是将Prometheus已经解决的问题加倍。

prometheus的存储层历来表现出色,其中单个服务器能够支撑每秒百万级别的时序,同时占用极小的磁盘空间。虽然目前的存储已经很好地为我们服务,但我提出了一个新设计的存储子系统,它可以纠正现有解决方案的缺点,并能够处理下一个规模的订单。

注意:我没有数据库背景。我说的可能是错的或有误导。您可以在Freenode的#prometheus中向我(fabxc)提出批评。

问题

首先,快速概述我们要完成的工作以及它提出的关键问题。对于每一个,我们来看看普罗米修斯当前的方法,它做得好的以及我们希望通过新设计解决哪些问题。

时序数据

我们有一个系统可以随时间收集数据点。

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个时序的数个周数据点等等。因此,在我们的二维平面上,查询不是完全垂直的或水平,而是两者的矩形组合。
recording规则可以缓解已知查询的问题,但不是ad-hoc查询的常规解决方案,后者仍需要相当好地执行。

我们知道我们想要批量写,但我们获得的是跨时序列的垂直数据点集。在一个时间窗口查询时序数据点时,不仅难以找出各个点的位置,我们还必须从磁盘上的许多随机位置读取。每个查询可能有数百万个数据点,即使在最快的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存储:

  • 实际上,我们需要的文件比我们当前收集数据的时间序列数量要多得多。更多信息在“时序丢失”部分讲解。几百万个文件迟早会耗尽我们文件系统上的inode。这是我们只能通过重新格式化磁盘来恢复,这可能是侵入性和破坏性的。我们通常希望避免专门格式化磁盘以适合单个应用程序。
  • 即使在分块时,每秒也会有数千个块完成并准备好继续存在。这仍然需要每秒数千个单独的磁盘写入。虽然通过对一个时序的多个已完成的块进行批处理来缓解它,但这反过来增加了等待持久化的数据的总内存占用量。
  • 将所有文件保持打开以进行读写是不可行的。特别是因为大约99%的数据在24小时后再也没有被查询过。如果要查询它,我们必须打开数千个文件,找到并读取相关数据点到内存中,然后再次关闭它们。由于这会导致高查询延迟,数据块被缓存不至于导致问题,这些将在“资源消耗”章节深入分析。
  • 最终,必须删除旧数据,并且需要从数百万个文件的前面删除数据。这意味着删除实际上是写入密集型操作。此外,循环访问数百万个文件并对其进行分析,这一过程通常需要数小时。到它完成时,它可能不得不重新开始。哦,是的,删除旧文件将导致您的SSD进一步“写入放大”!
  • 当前正在累积的块只存储在内存中。如果应用程序崩溃,数据将丢失。为了避免这种情况,内存状态会定期写到磁盘,这可能比我们愿意接受的数据丢失窗口要长得多。恢复检查点也可能需要几分钟,导致痛苦的长时间重启。

从现有设计中脱颖而出的关键是块的概念,我们当然希望保留这些块。最新的块总是被保存在内存中也很好。毕竟,最新数据的查询率最高。
每个时间序列有一个文件是我们想要找到的替代方案的概念。

时序丢失

在Prometheus上下文中,我们使用术语时序丢失来描述一组时间序列变为非活动状态,即不再接收数据点,而是出现一组新的活跃时序。
例如,由给定微服务实例暴露的所有时序都附加了相应的“实例”标签,用于标识其来源。如果我们执行微服务的滚动更新并使用较新版本交换每个实例,则会发生时序流失。在更动态的环境中,这些事件可能每小时发生一次。像Kubernetes这样的集群编排系统允许连续自动扩展和频繁滚动更新应用程序,可能每天创建数以万计的新应用程序实例,以及全新的时间序列集。

series
^
│ . . . . . .
│ . . . . . .
│ . . . . . .
│ . . . . . . .
│ . . . . . . .
│ . . . . . . .
│ . . . . . .
│ . . . . . .
│ . . . . .
│ . . . . .
│ . . . . .
v
<-------------------- time --------------------->

因此,即使整个基础架构的大小大致保持不变,随着时间的推移,我们的数据库中的时间序列也会呈线性增长。虽然Prometheus服务器将很乐意收集1000万个时间序列的数据,但如果必须在十亿个时序中找到数据,则查询性能会受到严重影响。

当前解决方案

Prometheus的当前V2存储具有基于LevelDB的索引,用于当前存储的所有时序数据。它允许查询包含给定标签对的时序,但缺乏可扩展的方法来组合来自不同标签选择的结果。
例如,选择标签name =“requests_total”的所有时序都可以有效工作,但选择所有具有instance =“A”AND name =“requests_total”的时序具有可伸缩性问题。我们稍后将重新审视导致此问题的原因以及为改进查找延迟所需的调整。

事实上,这个问题催生了对更好的存储系统的初步追捕。 Prometheus需要一种改进的索引方法,以便快速搜索数亿个时间序列。

资源消耗

在尝试扩展prometheus(或其他任何东西)时,资源消耗是永恒的主题之一。但实际上并不是令用户烦恼的绝对资源饥饿。实际上,Prometheus根据其要求管理着令人难以置信的吞吐量。问题在于它面临变化时的相对不可预测性和不稳定性。通过其体系结构,V2存储缓慢地构建了大量的样本数据,这导致内存消耗随着时间的推移而逐渐增加。随着块的完成,它们被写入磁盘并可以从内存中逐出。最终,prometheus的内存使用率达到稳定状态。直到受监控的环境发生变化 - 每次扩展应用程序或进行滚动更新时,时序丢失(series churn)都会增加内存,CPU和磁盘IO的使用。
如果变化正在进行中,它最终将再次达到稳定状态,但它将显着高于更静态的环境。过渡期通常是几个小时,很难确定最大资源使用量。

每个时间序列只有一个文件的方法也使单个查询很容易导致Prometheus进程被杀。查询未缓存在内存中的数据时,将打开查询时序的文件,并将包含相关数据点的块读入内存。如果数据量超过可用内存,Prometheus会因为OOM被杀而强制退出。
查询完成后,可以再次释放已加载的数据,但通常会将其缓存更长时间,以便更快地为相同数据的后续查询提供服务。后者显然是一件好事。

最后,我们研究了SSD背景下的写入放大以及Prometheus如何通过批量写入来缓解它。尽管如此,在一些地方,它仍然会因批量太小而无法在页面边界上精确对齐数据而导致写入放大。对于较大的Prometheus服务器,在现实世界中观察到硬件寿命缩短。对于具有高写入吞吐量的数据库应用程序来说,这仍然是正常的,但是我们应该关注我们是否可以缓解它。

从头开始

到目前为止,我们已经很好地了解了存在的一些问题,V2存储如何解决它,以及V2的设计存在的问题。我们还看到了一些我们想要或多或少地无缝适应的伟大概念。可以通过改进和部分重新设计来解决相当数量的V2问题,但为了保持乐趣(当然,在仔细评估我的选项之后),我决定尝试重新写整个时间序列数据库——从头开始。

性能和资源利用率是所选存储格式的关键因素。我们必须为我们的数据找到合适的算法和磁盘布局,以实现性能良好的存储层。

这就是我采取捷径并直接开始解决问题的方法—— 跳过头痛、失败的想法、无尽的素描,眼泪和绝望。

V3 —— 总体设计

存储的总体设计可以通过数据目录下的树形结构显示出来。只需要打印目录结构就可以对此有清晰的了解。

$ 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-为前缀。每个块显然包含一个包含索引的文件和一个包含更多编号文件的“块”目录。 “chunks”目录只包含各种序列的原始数据点块。就像V2一样,这使得在一段时间内读取时序数据非常便捷,并且允许我们应用相同的高效压缩算法。这个概念已被证明能够很好地发挥作用,我们坚持这一点。显然,每个时序不再有单个文件,而是少数文件对其中的很多文件都保留有块。

index文件的存在不应该令人惊讶。我们假设它包含许多“黑魔法”使我们能够找到labels标签,它们的可能值,整个时间序列以及保存其数据点的块。

但为什么有几个目录包含索引和块文件?而最后一个编号块却包含的是“wal”目录呢?理解这两个问题,就解决了我们90%的问题。

许多小的数据库

我们将水平维度(即时间空间)划分为不重叠的块。每个块都充当一个完全独立的数据库,其中包含其时间窗口的所有时间序列数据。因此,它有自己的索引和一组块文件。

t0            t1             t2             t3             now
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ │ │ │ │ │ │ │ ┌────────────┐
│ │ │ │ │ │ │ mutable │ <─── write ──── ┤ Prometheus │
│ │ │ │ │ │ │ │ └────────────┘
└───────────┘ └───────────┘ └───────────┘ └───────────┘ ^
└──────────────┴───────┬──────┴──────────────┘ │
│ query
│ │
merge ─────────────────────────────────────────────────┘

每块数据都是不可变的。当然,当我们收集新数据时,我们必须能够将新的时序和样本添加到最新的数据块。对于此块,所有新数据都将写入内存数据库,该数据库提供与持久块相同的查找属性。内存中的数据结构可以被有效地更新。为防止数据丢失,所有传入数据也会写入临时预写式日志(WAL),这是我们“wal”目录中的文件,我们可以在重新启动时重新填充内存数据库。

所有这些文件都带有自己的序列化格式,它具有所有预期的功能:大量标志位,偏移量,varints和CRC32校验和。

这种布局使我们能够查询时间范围相关的所有块。将每个块的部分结果合并回来以形成整体结果。

这种水平分区增加了一些很棒的功能:

  • 查询时间范围时,我们可以轻松忽略此范围之外的所有数据块。它通过减少开始检查的数据集来解决时序流失问题。
  • 完成一个块后,我们可以通过顺序写一些较大的文件来保留内存数据库中的数据。我们避免任何写入放大,SSD和HDD都有很好的性能表现。
  • 我们保持V2好的属性:最近被查询的最频繁的块总是在内存中。
  • 很好,我们也不再受限于固定的1KiB块大小,以便更好地对齐磁盘上的数据。我们可以选择最适合单个数据点和所选压缩格式的大小。
  • 删除旧数据变得非常便宜和即时。我们只需要删除一个目录。请记住,在旧存储中,我们必须分析并重新写多达数亿个文件,这可能需要数小时才能收敛。

每个块还包含一个meta.json文件。它只是保存有关块的人类可读信息,以便轻松了解存储状态及其包含的数据。

mmap

从数百万个小文件迁移到少数几个小文件允许我们保持所有文件打开而只需很少的开销。这可以解除mmap(2)的使用,这是一个系统调用,它允许我们按文件内容透明地备份虚拟内存区域。为简单起见,您可能希望将其视为交换空间,只是所有数据都已存在于磁盘上,并且在将数据交换内存时不会发生写入。

这意味着我们可以将数据库中的所有内容视为它们在内存中而不占用任何物理RAM。只有当我们访问数据库文件中的某些字节范围时,操作系统才会从磁盘中延迟加载页面。这使操作系统负责与我们的持久数据相关的所有内存管理。通常,它更有资格做出这样的决定,因为它可以全面了解整个机器及其所有流程。查询的数据可以被积极地缓存在内存中,但是在内存压力下页面将被驱逐。如果机器有未使用的内存,Prometheus现在可以高兴地缓存整个数据库,但是一旦另一个应用程序需要它就会立即返回它。

通过查询更多持久化数据,进程很容易OOM。因此,内存高速缓存大小完全自适应,并且只有在查询实际需要时再加载数据。

根据我的理解,这就是当今许多数据库的工作方式,如果磁盘格式允许的话,这是一种理想的方式——除非有人有信心在这个过程中超越操作系统。从我们这边来看,我们确实获得了很多功能,而且工作量很少。

压缩

存储必须定期“剪切”一个新块,并将之前完成的一个块写入磁盘。只有在块成功保留后,才会删除用于恢复内存块的预写日志文件。
我们有兴趣保持每个块合理短(典型设置大约需要两个小时),以避免在内存中积累太多数据。查询多个块时,我们必须将其结果合并为一个总体结果。这个合并过程显然带有成本,一个为期一周的查询不应该合 并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是我们的前向指数。

例如:如果标识为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)),这是一个很大的改进。
我在这里描述的是几乎任何全文搜索引擎使用的规范搜索索引的简化版本。每个时序描述符都被视为一个简短的“文本”,并且每个标签(名称+固定值)都被视为其中的“单词”。我们可以忽略搜索引擎索引中通常遇到的大量附加数据,例如字位置和频率数据。
看似无休止的研究存在于改进实际运行时的方法上,通常会对输入数据做出一些假设。不出所料,还有大量的技术来压缩倒序索引,这些索引有自己的优点和缺点。由于我们的“文本”很小,而且“文字”在所有时序中都是重复性很强的,所以压缩变得几乎无关紧要。例如,具有约12个标签的〜440万时序的真实世界数据集每个具有少于5,000个唯一标签。对于我们的初始存储版本,我们坚持不压缩的基本方法,只添加一些简单的调整来跳过大范围的非相交ID。

虽然保持ID的有序听起来很简单,但并不总是保持恒定。例如,V2存储将哈希分配为新时序的ID,我们无法有效地构建有序的倒排索引。
另一个令人生畏的任务是在数据被删除或更新时修改磁盘上的索引。通常,最简单的方法是简单地重新计算和重写它们,但这样做同时保持数据库可查询和一致。 V3存储通过每个块具有单独的不可变索引来完成这一任务,该索引仅通过压缩时重写进行修改。只有完全存储在内存中的可变块的索引需要更新。

Benchmarking

我基于从现实世界数据集中提取的~440万个时序描述符的存储基准测试的初始开发,并生成合成数据点以馈入这些时序。这个迭代只是测试了独立存储,对于快速确定性能瓶颈和仅在高度并发负载下遇到的死锁才是至关重要的。

在完成概念实施之后,基准测试可以在我的Macbook Pro上保持每秒2000万个数据点的写入吞吐量 ——所有的Chrome浏览器标签和Slack都在运行。所以虽然这听起来很棒,但它也表明在推动这个基准测试(或者在一个较不随意的环境中运行它)没有更多的意义。毕竟,它是人造的,因此不值得有一个良好的第一印象。比初始设计目标大约高出20倍,现在是时候将其嵌入到实际的prometheus服务器中,增加了所有实际的开销和薄片,只有在更真实的环境中才能体验到。

我们实际上没有为Prometheus提供可重现的基准测试设置,特别是没有允许不同版本的A / B测试。事后看来,现在我们有一个!

我们的工具允许我们声明性地定义基准测试场景,然后将其部署到AWS上的Kubernetes群集。虽然这不是全面的基准测试的最佳环境,但它肯定反映了我们的用户群比拥有64核心和128GB内存的专用裸机服务器更好。
我们部署两台1.5.2版本的prometheus服务器(V2存储)和两台2.x(V3存储)Prometheus服务器。每台prometheus服务器都运行在一台配备SSD的专用机器上。展开典型的微服务度量标准的横向扩展应用程序部署到工作节点。此外,Kubernetes集群本身和节点正在受到监视。整个设置由另一个Meta-Prometheus监督,监视每个Prometheus服务器的健康状况和性能。
为了模拟时序流失,微服务会周期性地放大和缩小,以移除旧的pod并产生新的pod,从而揭开新的系列。查询负载通过选择“典型”查询来模拟,针对每个Prometheus版本的一台服务器运行。

总体而言,缩放和查询负载以及采样频率明显超过了今天prometheus的生产部署。例如,我们每15分钟换出60%的微服务实例以产生时序流失。在现代基础设施中,这可能每天只发生1-5次。这确保了我们的V3设计能够处理未来几年的工作量。因此,prometheus1.5.2和2.0之间的性能差异比在更温和的环境下更大。
总的来说,我们每次从850个目标中收集约110,000个样本点,每次收集50万个时序。

保持此设置运行一段时间后,我们可以看看数字。我们评估了在两个版本的前12小时内达到稳定状态的几项指标。

请注意Prometheus图形用户界面屏幕截图中略有截断的Y轴。

image
image
堆内存使用情况 GB

内存利用率是当今用户最麻烦的资源,因为它相对不可预测,并且可能导致进程崩溃。
显然,查询的服务器消耗更多的内存,这很大程度上可归因于查询引擎的开销,这将在未来得到优化。总的来说,Prometheus 2.0的内存消耗减少了3-4倍。大约六个小时后,prometheus1.5出现明显的峰值,这与我们设置的保留6小时对齐。由于删除成本非常高,资源消耗也在增加。这将在下面的各种其他图表中显示出来。

image
image

CPU利用率 cores/second

类似的模式展示CPU使用率,但查询和非查询服务器之间的增量更为重要。平均约0.5个核心/秒,同时抓取约110,000个样本/秒,与查询评估所花费的周期相比,我们的新存储几乎可以忽略不计。总的来说,新存储需要3到10倍更少的CPU资源。

image
image
磁盘写入 MB/s

迄今为止最引人注目和意想不到的改进表现在我们磁盘的写入利用率上。它清楚地说明了为什么Prometheus 1.5容易磨损SSD。一旦第一个块被持久化到系列文件中,我们就会看到一个初始的上升,一旦删除开始重写它们,我们会看到第二个上升。令人惊讶的是,查询和非查询的服务器显示出非常不同的利用率。另一方面,Prometheus 2.0仅向其预写日志写入大约每秒一兆字节。当块被压缩到磁盘时,会定期写入尖峰。整体节省:惊人的97-99%。

image
image
磁盘大小 GB

与磁盘写入密切相关的是占用磁盘空间的总量。由于我们对样本使用几乎相同的压缩算法(这是我们的大部分数据),它们应该大致相同。在一个更稳定的设置中,这很大程度上是正确的,但是当我们处理高级时序流失时,还需要考虑每个时序的开销。
正如我们所看到的,Prometheus 1.5在两个版本达到稳定状态之前更快地增加存储空间,因为保留启动。prometheus2.0似乎每个时序的开销显着降低。我们可以很好地看到空间由写入提前日志(WAL)线性填充,并在压缩时瞬间下降。 两个Prometheus 2.0的服务器表现不完全匹配这一事实需要进一步调查。

这看起来很有希望。剩下的重要部分是查询延迟。新索引应该提高了我们的查找复杂性。没有实质性改变的是处理这些数据,例如,在rate()函数或聚合中。这些方面是查询引擎的一部分。

image
image
99线查询时延 seconds

数据完全满足了期望。在Prometheus 1.5中,随着更多时序的存储,查询延迟随着时间的推移而增加。它只会在保留开始后关闭,旧时序将被删除。相比之下,Prometheus 2.0从一开始就保持不变。
必须谨慎对待这些数据的收集方式。通过估计范围和即时查询的良好组合,进行更重和更轻量级的计算以及触摸少数或多个系列来选择针对服务器发出的查询。它不一定代表查询的真实分布。对于命中冷数据的查询也没有代表性,我们可以假设所有样本数据在任一存储器中的内存中实际上总是很热。
尽管如此,我们可以充满信心地说,整体查询性能对于时序流失变得非常有弹性,并且在我们压力基准测试场景中提高了4倍。在更静态的环境中,我们可以假设查询时间主要用在查询引擎本身,并且改进的明显更低。

image
image
抓取的样本/秒

最后,快速了解不同Prometheus服务器的抓取速率。我们可以看到具有V3存储的两台服务器具有相同的抓取率。几个小时后,它变得不稳定,这是由于基准测试集群的各个节点由于高负载而不是Prometheus实例而变得无响应。(两条2.0线完全匹配的事实有望令人信服。)
即使有更多的CPU和内存资源可用,Prometheus 1.5.2服务器也开始遭受抓取率的显著下降。时序流失的高压力导致大量数据无法收集。

但是现在你可以抓取的每秒绝对最大样本数是多少?

我不知道 - 故意不在乎。

有许多因素会影响流入prometheus的数据,并且没有一个能够捕获质量的数字。历史上,最大抓取率一直是导致基准测试偏差的指标,而忽略了更重要的方面,例如查询性能和时序流失的弹性。一些基本测试证实了资源使用线性增加的粗略假设,这个很容易推断。

我们的基准测试设置模拟了一个高度动态的环境,比今天的大多数实际设置更强调普罗米修斯。结果表明,在非最佳云服务器上运行时,我们超越了最初的设计目标。最终,成功将取决于用户反馈而不是基准数字。

注意:在写本文时,Prometheus 1.6正在开发中,这个版本将允许更可靠地配置最大内存使用量,并可显着降低总体功耗,有利于略微提高CPU利用率。我没有重复对此的测试,因为整体结果仍然有效,特别是在面临高的时序流失时。

结论

Prometheus设定为处理单机时序吞吐。它仍然是一项具有挑战性的任务,但新的存储似乎使我们能够很好地适应未来的超大规模,超融合,GIFEE基础设施……好吧,它似乎运作良好。

Prometheus 2.0的第一个alpha版本与新的V3存储可用于测试。在这个早期阶段,预计会出现崩溃,死锁和其他错误。

存储本身的代码可以在单独的项目中找到。它对Prometheus本身和更广泛的应用程序来说是不可预期的——找到更高效的本地时间序列数据库。