专栏名称: 分布式实验室
最专业的Docker文章,最权威的Docker新闻。关注容器生态圈的发展。
目录
相关文章推荐
51好读  ›  专栏  ›  分布式实验室

从头编写一款时间序列数据库

分布式实验室  · 公众号  · 后端  · 2017-05-20 08:09

正文

我从事监控方面的工作。尤其是专注在Prometheus,一款内置了自己定制的时间序列数据库的监控系统,以及它和Kubernetes的集成工作。


从很多方面来说,Prometheus似乎是专门为Kubernetes设计的。它使得持续部署,自动扩缩,以及高度动态环境的其他功能更易于实现。它的查询语言和操作模型,还有许多其他概念方面的决策使得Prometheus尤其适合这样的环境。然而,如果被监控的工作负载变得更加显著动态的话,这也会给监控系统本身带来新的压力。基于这一点的考虑,与其再次回顾Prometheus已经很好解决的问题,还不如专注于在这样一个高度动态或短生命周期服务的环境里提高它的性能。


Prometheus的存储层在历史上有着惊人的性能表现,一个单台服务器每秒可以摄取多达100万个采样,数百万个时间序列,同时仅占用令人惊叹的少量磁盘空间。尽管当前的存储已经给我们提供了不错的服务,笔者构思了一个新设计的存储子系统用来纠正现有解决方案的一些短板,并且可以用来配备支撑下一代的集群规模。


注意:笔者并没有数据库方面的背景。我在这里所说的话可能是错误的或是带有误导性的。你可以在Freenode上的#prometheus频道里将你的批评指正反馈到我(fabxc)。


问题,难题,问题域


首先,快速概括一下我们试图完成的任务以及这里面暴露出的关键问题。针对每一点,我们会先看一看Prometheus目前的做法,它在哪些地方做的出色,以及我们旨在通过新的设计想解决哪些问题。


时间序列数据


我们有一个根据时间采集数据点的系统。


identifier -> (t0, v0), (t1, v1), (t2, v2), (t3, v3), ....


每个数据点都是一个由时间戳和值组成的元组。为了达成监控的目的,时间戳是一个整数,值则可以是任意数字。经验来看,一个64位的浮点数往往能够很好地展现计数器(counter)和测量(gauge)的值,因此我们也不例外。一个时间序列是一组时间上严格单调递增的数据点序列,它可以通过一个标识符来寻址。我们的标识符便是一个度量(metric)名带上一个多维标签的字典。多维标签会将单个度量的测量空间分区。每个度量名加上一串唯一的标签便组成了它自己的时间序列(time series),它会有一个与之关联的值序列流。下面是一组典型的序列标识符,它是度量请求计数的一部分:


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

   


Prometheus通过定期抓取一组时间序列的当前值来检索得到数据点。这样一个我们检索批次的实体被称作一个目标(target)。由于每个目标的样本数据都是单独抓取的,因此写入模式是完全垂直并且高度并发的。这里提供一些衡量尺度:一个单个的Prometheus实例会从成千上万的目标采集数据点,每个目标可以暴露出数百上千个不同的时间序列。


就每秒采集数百万个数据点的规模而言,批量写入是一个不可调和的性能需求。分散地写入单个数据点到磁盘的话又会是一个非常缓慢的过程。因此,我们想要实现的是按顺序写入更大的数据块。对于机械的旋转磁盘而言这样做并不出奇,因为它们的头会一直物理地移动到不同的区块。虽然SSD以快速地随机写入性能而闻名,但是实际上它们却不能修改单个字节,而只能写入4KiB或更大的的页。这意味着写一个16字节的样本同写一个完整的4KiB页没什么两样。这种行为即是所谓的写入放大的一部分,作为一个“额外红利”,它会耗损你的SSD —— 因此它不仅仅只是会变慢而已,还会在几天或者几周内完全毁掉你的硬件。关于这个问题的更详细信息,系列博客"针对SSD编程"系列(http://suo.im/22oKo4)会是一个不错的资源。我们不妨考虑一下这里面的主要关键点:顺序和批量写入是旋转磁盘和SSD的理想写入模式。 这是一个应该遵循的简单规则。


时间序列数据库的查询模型跟写模型相比,更是有明显不同的区别。我们可以对一个单个序列查询一个单个的数据点,在10000个序列里查询一个单个的数据点,在一个单个序列里查询几周的数据点,甚至在10000个序列里查询几周的数据点,等等。因此在我们的二维平面上,查询既不是完全垂直的,也不是水平的,而是二者的矩形组合。记录规则可以减轻已知的一些查询方面的问题,但是仍然不是临时查询(ad-hoc queries)的一个通用解决方案,这些查询也必须能很好的进行下去。


须知我们想要的是批量写入,但是我们得到的批次只是序列之间一个纵向的数据点集合。当在一个时间窗口上针对某个序列查询数据点时,不仅难以确定各个数据点可以被找到的位置,我们还不得不从磁盘上大量的随机位置进行读取。每次查询操作可能涉及到数以百万的样例数据,即使在最快的SSD上这样的操作也会变慢。读操作还将从磁盘上检索更多的数据,而不仅仅只是所请求的16字节大小的样本。 SSD将加载一整页,HDD将至少读取整个扇区。 无论哪种方式,我们都会浪费宝贵的读吞吐量。


因此,在理想情况下,相同序列的样本数据将会被顺序存储,这样一来我们便可以用尽可能少的读来扫描得到它们。 在上层,我们只需要知道这个序列可以访问的所有数据点的开始位置。


在将收集的数据写入磁盘的理想模式和为服务的查询操作提供更显著有效的存储格式之间显然存在着强烈的冲突。这是我们的时间序列数据库要解决的根本问题。


当前的解决方案


是时候来看看Prometheus当前的存储是如何实现的,我们不妨叫它“V2”,它致力于解决这个问题。我们会为每个时间序列创建一个文件,它会按照时间顺序包含所有的样本数据。由于每隔几秒就把单个的样本数据添加到所有这些文件的成本不小,我们针对每个序列在内存里批量存放了1KiB的数据块,一旦它们填满了再把这些块添加到一个个的文件里。这一方案解决了很大一部分问题。写操作如今是分批次的,样本数据也是顺序存储的。它还能为我们提供一个令人难以置信的高效压缩格式,这是基于一个给定的样本相对于相同序列里前面的那些样本数据只有非常少量的变化这一属性而设计。Facebook在它们的Gorilla TSDB的论文里描述了一种类似的基于块(Chunk)的存储方法,并且引入了一个压缩格式(http://www.vldb.org/pvldb/vol8/p1816-teller.pdf),将16个字节的样本减少到平均1.37字节。V2存储使用了各种压缩格式,包括Gorilla的一个变种。


┌──────────┬─────────┬─────────┬─────────┬─────────┐           series A

  └──────────┴─────────┴─────────┴─────────┴─────────┘

         ┌──────────┬─────────┬─────────┬─────────┬─────────┐    series B

         └──────────┴─────────┴─────────┴─────────┴─────────┘

                             . . .

┌──────────┬─────────┬─────────┬─────────┬─────────┬─────────┐   series XYZ

└──────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

  chunk 1    chunk 2   chunk 3     ...


尽管基于块的实现方案很棒,如何为每个序列维护一个单独的文件却也是V2存储引擎困扰的地方,这里面有几个原因:


  • 我们实际上需要维护的文件数量多于我们正在收集数据的时间序列数量。在“序列分流”一节会详解介绍到这点。由于产生了几百万个文件,不久的将来或者迟早有一天,我们的文件系统会出现inode耗尽的情况。在这种情况下我们只能通过重新格式化磁盘来恢复,这样做可能带有侵入性和破坏性。通常我们都希望避免格式化磁盘,特别是需要适配某个单个应用时更是如此。


  • 即便做了分块,每秒也会产生数以千计的数据块并且准备好被持久化。这仍然需要每秒完成几千次单独的磁盘写操作。尽管这一点可以通过为每个序列填满的数据块做分批处理来缓解压力,这反过来又会增加等待被持久化的数据总的内存占用。


  • 保持打开所有文件来读取和写入是不可行的。特别是因为在24小时后超过99%的数据便不再会被查询。如果它还是被查询到的话,我们就不得不打开数千个文件,查找和读取相关的数据点到内存,然后再重新关闭它们。而这样做会导致很高的查询延迟,数据块被相对积极地缓存的话又会导致一些问题,这一点会在“耗用资源”一节里进一步概述。


  • 最终,旧数据必须得被清理掉,而且数据需要从数百万的文件前面被抹除。这意味着删除实际上是写密集型操作。此外,循环地在这数百万的文件里穿梭然后分析它们会让这个过程常常耗费数个小时。在完成时有可能还需要重新开始。呵呵,删除旧文件将会给你的SSD带来进一步的写入放大!


  • 当前堆积的数据块只能放在内存里。如果应用崩溃的话,数据将会丢失。为了避免这种情况,它会定期地保存内存状态的检查点(Checkpoint)到磁盘,这可能比我们愿意接受的数据丢失窗口要长得多。从检查点恢复估计也会花上几分钟,造成痛苦而漫长的重启周期。


从现有的设计中脱颖而出的关键在于块的概念,我们当然希望保留这一设计。大多数最近的块被保留在内存里一般来说也是一个不错的做法。毕竟,最大幅度被查询数据里大部分便是这些最近的点。


一个时间序列对应一个文件这一概念是我们想要替换的。


序列分流(Series Churn)



在Prometheus的上下文里,我们使用术语“序列分流”来描述一组时间序列变得不活跃,即不再接收数据点,取而代之的是有一组新的活跃的序列出现。


举个例子,由一个给定的微服务实例产出的所有序列各自都有一个标识它起源的“instance”标签。如果我们对该微服务完成了一次滚动更新然后将每个实例切换到了一个更新的版本的话,序列分流就产生了。在一个更加动态的环境里,这些事件可能会以小时的频率出现。像Kubernetes这样的集群编排系统允许应用程序不断地自动伸缩和频繁的滚动更新,它可能会创建出数万个新的应用程序实例,并且每天都会使用全新的时间序列。


series

 ^

 │   . . . . . .

 │   . . . . . .

 │   . . . . . .

 │               . . . . . . .

 │               . . . . . . .

 │               . . . . . . .

 │                             . . . . . .

 │                             . . . . . .

 │                                         . . . . .

 │                                         . . . . .

 │                                         . . . . .

 v

   


因此,即便整个基础设施大体上保持不变,随着时间的推移,我们数据库里的时间序列数据量也会呈线性增长。 尽管Prometheus服务器很愿意去采集1000万个时间序列的数据,但是如果不得不在十亿个序列中查找数据的话,很明显查询性能会受到影响。


当前解决方案


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 - 宏观设计


我们新版存储引擎的宏观设计是怎样的?简略来讲,只要到我们的data目录下运行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”目录?搞清楚这两个问题的话可以解决我们90%的问题。


众多的小型数据库


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


t0            t1             t2             t3             now

┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐

│           │  │           │  │           │  │           │                 ┌────────────┐

│           │  │           │  │           │  │  mutable  │

│           │  │           │  │           │  │           │                 └────────────┘

└───────────┘  └───────────┘  └───────────┘  └───────────┘                        ^

      └──────────────┴───────┬──────┴──────────────┘                              │

                             │                                                  query

                             │                                                    │

                           merge ─────────────────────────────────────────────────┘


每个块的数据均是无法更改的。当然,在我们采集到新数据时我们必须能够将新序列和样本数据添加到最近的数据块里。对于这个数据块,所有新数据都将写入到内存数据库里,跟我们持久化的数据块一样,它也会提供相同的查找属性。内存里的数据结构也可以被有效地更新。为了防止数据丢失,所有传入的数据还会被写入预写日志(write ahead log),即我们的“wal”目录中的一组文件,我们可以在重新启动时基于这些文件将之前内存里的数据重新填充到内存数据库。


所有这些文件都带有自己的序列化格式,它附带了许多标志,偏移量,变体和CRC32校验和。比起无聊地读着介绍,读者朋友自己去发现它们也许会更有乐趣些。


这种布局允许我们查出所有和被查询的时间范围相关的数据块。每个块的部分结果被合并到一起形成最终的完整结果。


这种水平分区解锁了一些很棒的功能:


  • 当查询一个时间范围时,我们可以轻松地忽略该范围外的所有数据块。 通过减少一系列开始时需要检查的数据,它可以初步解决序列分流的问题。


  • 当完成一个数据块的填充时,我们可以通过顺序写入数据到一些较大的文件来保存内存数据库中的数据。 这样就避免了任何写入放大的问题,并且同样适用于SSD和HDD。


  • 我们继承了V2优秀的地方,最近最多被查询的数据块总是作为热点保存在内存里。


  • 棒棒哒,我们再也不需要通过固定的1KiB块大小设定来更好地对齐磁盘上的数据。 我们可以选择任何对于个别数据点和选定的压缩格式最有意义的大小。


  • 删除旧数据变得非常低成本和及时。我们只需要删除一个目录。 请记住,在旧存储中,我们不得不分析并重新编写高达数亿个文件,这一操作可能需要几个小时才能收敛。


每个块还包含一个meta.json文件。 它简单地保存该数据块的人类可读信息,便于用户轻松了解数据块的存储状态及其包含的数据。


mmap


从数以百万的小文件改成几个更大的文件使得我们能够以很小的成本保持所有文件的打开句柄。这也解锁了使用mmap(2)的玩法,它是一个系统调用,允许我们通过文件内容透明地回传到一个虚拟内存区域。为了简单起见,你可以联想它类似于交换(swap)空间,只是我们所有的数据已经在磁盘上,并且在将数据交换出内存后不会发生写入。这意味着我们可以将数据库里的所有内容均视为内存而不占用任何物理RAM。只有我们访问我们的数据库文件中的某些字节范围时,操作系统才会从磁盘惰性地加载页面。这就把和我们持久化数据相关的所有内存管理都交给了操作系统负责。 一般来说,操作系统更有资格做出这样的决定,因为它对整个机器及其所有过程有更全面的看法。查询数据可以相当积极地被缓存在内存里,而一旦面临内存压力,页面便会被逐出(evicted)。如果机器有未使用的内存,Prometheus将会很高兴去缓存整个数据库,而一旦另一个应用程序需要它,它将立即返回。


这样一来,比起受到RAM的大小限制,即便查询更多的持久化数据,查询操作也不会再轻易造成进程的OOM。内存的缓存大小变得完全自适应,只有在查询实际需要的数据时才会加载数据。


就我个人的理解,这是今天的很多数据库的工作方式,如果磁盘格式允许的话,这是一个理想的方法 - 除非你有信心在进程里做的工作能够超越操作系统。我们自己做了很少一部分工作而确实从外部系统收获了大量功能。


压缩(compaction)


存储引擎必须定期地“切出”一个新的块,并将之前完成的块写入到磁盘。只有块被成功持久化后,用于恢复内存块的预写日志文件(wal)才会被删除。


我们有兴趣将每个块的保存时间设置的相对短一些(一般设置大约两个小时),以避免在内存中堆积太多的数据。当查询多个块时,我们必须将其结果合并为一个完整结果。 这个合并过程显然会有一个成本,一个一周长的查询不应该需要合并超过80个的部分结果。


为了实现两者共同的需求,我们引入数据压缩(compaction)。它描述了采集一个或多个数据块并将其写入一个可能会更大的块的过程。它还可以沿途修改现有的数据,例如,清理已删除的数据,或重组我们的样本数据块以提高查询性能。


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]”。 所有的时间序列数据仍然存在,但是现在总体的数据块更少。 这显著降低了查询时的合并成本,因为现在需要被合并的部分查询结果会更少。


保留(Retention)


我们看到,删除旧数据在V2存储引擎里是一个缓慢的过程,而且会消耗CPU,内存和磁盘。那么,我们该如何在基于块的设计中删除旧数据呢?简单来讲,只需删除该目录下在我们配置的保留窗口里没有数据的块。 在下面的示例中,块1可以安全地被删除,而2必须保留到完全落在边界之后才行。


|

┌────────────┐  ┌────┼─────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐

│ 1          │  │ 2  |     │  │ 3         │  │ 4         │  │ 5         │   . . .

└────────────┘  └────┼─────┘  └───────────┘  └───────────┘  └───────────┘

                 |

                 |

        retention boundary


获取越旧的数据,数据块可能就变得越大,这是因为我们会不断地压缩以前压缩的块。 因此必须得有一个压缩的上限,这样一来块就不会扩展到跨越整个数据库从而影响到我们设计的最初优势。


另一个方便之处在于,这样也可以限制部分在保留窗口里部分在外面的数据块的总磁盘开销,即上面示例中的块2.当用户将最大块的大小设置为总保留窗口的10%时,保留块2的总开销也有10%的上限。


总而言之,保留删除的实现从非常高的成本变成了几乎零成本。


如果看到这里,而且读者朋友本人有一些数据库的背景的话,你可能会问一件事:这是一个新玩法吗? —— 其实不是;而且大概还可以做得更好


在内存里批量处理数据,在预写日志(wal)里跟踪,并定期刷新到磁盘,这种模式在今天是被广泛采纳的。


无论数据特指的问题域是什么,我们所看到的好处几乎都是普遍适用的。 遵循这一方法的突出开源案例是LevelDB,Cassandra,InfluxDB或HBase。而这里面的关键是要避免重复发明劣质轮子,研究经过生产验证的方法,并采取正确的姿势应用它们。


这里仍然留有余地可以添加用户自己的黑科技。


索引(index)


调研存储的改进方案的源动力便是为了解决序列分流引发的问题。基于块的布局设计减少了为查询提供服务所涉及的时间序列的总数。因此,假设我们索引查找的时间复杂度是O(n ^ 2),我们设法减少n个相等的数量,那么现在就有一个改进的复杂度O(n ^ 2) - uhm,等一下... 哇靠。


这时候脑海里迅速回忆起“算法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)。 这是预料中的事情,而且也完全合理。 如果用户要查询所有的数据,自然就需要更长的时间。 一旦涉及到更复杂的查询这里可能就有问题了。


组合标签(Combining Labels)


标签被关联到数百万序列是很常见的。 假设有一个拥有数百个实例的横向可扩缩的“foo”微服务,每个实例有数千个系列。 每个系列都会有“app =”foo“的标签。当然,用户一般不会去查询所有的系列,而是通过进一步的过滤标签来限制查询,例如,我想知道我的服务实例收到多少个请求,那查询语句便是__name __ =“requests_total” AND app =“foo”。


为了找出满足两个标签选择器的所有系列,我们取每个标签选择器的倒排索引列表然后取交集。 所得到的集合通常比每个输入列表小一个数量级。由于每个输入列表具有最差情况的复杂度是O(n),所以在两个列表上嵌套迭代的暴力解都具有O(n ^ 2)的运行时间。 其他集合操作也是相同的成本,例如union(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 ]


它们的交集相当小。我们可以通过在每个列表的开始处设置一个光标,并且始终从较小的数字那端依次推进。 当两个数字相等时,我们将数字添加到我们的结果中并推进两个游标。总的来说,我们以这种之字形模式(zig-zag pattern)扫描这两个列表,这样一来我们总的成本会是O(2n)= O(n),因为我们只是在任意一个列表中向前移动。


两个以上列表的不同集合操作的过程也是类似的效果。因此,k个集合操作的数量仅仅只会将时间复杂度修改为(O(k * n))而不是我们最坏情况的查找运行时的指数级(O(n ^ k))。真是一个大进步。


我在这里描述的内容几乎就是任意一款全文搜索引擎 所使用的规范搜索索引的简化版本。每个序列的描述符被视为一个简短的“文档”,每个标签(名称+固定值)作为其中的“单词”。我们可以忽略通常在搜索引擎索引中遇到的大量附加数据,例如字位置和出现频率等数据。


业内似乎都在无休止的研究探索改进实际运行时的方法,他们也常常对输入数据做出一些假设。不出所料的是,许多可以压缩倒排索引的技术均是有利有弊的。而由于我们的“文档”很小,“文字”在所有序列里都是非常重复的,所以压缩变得几乎无关紧要。 例如,一个约440万系列的现实世界数据集,每个标签约有12个,拥有少于5,000个唯一的标签。在我们最开始的存储版本里,我们坚持使用基本方法而不进行压缩,只添加了一些简单的调整来跳过大范围的非相交ID。


维持排序好的ID听上去可能很简单,但是实际坚持下来却是不太容易办到的。比如,V2存储引擎将一个哈希值作为ID赋给新的序列,我们无法有效地基于此建立一个排序好的倒排索引。另一个艰巨的任务是在数据被删除或更新时修改磁盘上的索引。通常,最简单的方法是简单地重新计算和重写它们,但是得在保证数据库可查询和一致性的同时执行这一操作。V3版本的存储引擎通过在每个块中分配一个单独的不可变索引来彻底解决这一问题,只能通过压缩时的重写来进行修改。而且,只有整个保存在内存里的可变块的索引才需要被更新。


基准测试(Benchmark)


我发起了一个最初开发版本V3存储的基准测试,它是基于从现实世界数据集中提取的大约440万个序列描述符,并生成合成的数据点到对应的序列。这种遍历测试了单独的存储模块,而且对于快速识别性能瓶颈和触发仅在高并发负载下才会遇到的死锁尤为重要。


在完成概念性的实施之后,基准测试可以在我的Macbook Pro上保持每秒2000万个数据点的写吞吐量 —— 而所有的Chrome Tab和Slack都在持续运行。所以尽管这听上去很棒,但也表明推动这一基准测试没有进一步的价值(或者在这个问题里的随机环境下运行是这样的)。毕竟,这是合成的,这就决定了第一印象不会太好。对比最初的设计目标放大到近20倍的数据量,那么是时候将它嵌入到真正的Prometheus服务器里了,我们可以在上面添加所有只会在更贴近现实的环境里才会遇到的一切实际开销和情景。


实际上,我们没有可重复的Prometheus基准测试配置,特别是没有允许不同版本的A / B测试。 亡羊补牢为时不晚,现在我们有一个了(https://github.com/prometheus/prombench)!


我们的工具允许我们声明式地定义一个基准测试场景,然后将其部署到AWS上的Kubernetes集群。 虽然这不是全面的基准测试的最佳环境,但它肯定能反映出我们的用户基本上会比64内核和128GB内存的专用裸机服务器跑的更好。我们部署了两台Prometheus 1.5.2的服务器(V2存储引擎)以及两台基于2.0开发分支(V3存储引擎)部署的两台Prometheus服务器。每台Prometheus服务都是运行在一台配备有一块SSD的专用服务器上。我们将一个横向可扩展的应用程序部署到了工作节点上并让它对外暴露典型的微服务度量。此外,Kubernetes集群和节点本身也正在被监控。全部配置均由另一个Meta-Prometheus监督,它会监控每台Prometheus服务器的健康性和性能。为了模拟序列分流,微服务会定期地向上扩容和向下缩容,以去除旧的pod,并产生新的pod,从而生成新的序列。 查询负载以“典型”地选择查询来模拟,对每个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倍。 大约六个小时后,Prometheus 1.5版本就有一个明显的尖峰,与六个小时的保留边界一致。 由于删除操作成本很高,资源消耗也随之增加。这将在下面的各种其他图表中体现。


CPU使用率,核心/秒


CPU使用率的展示也是类似的模式,但是这里面查询服务器与非查询服务器之间的增量差异更为明显。以约0.5个核心/秒的平均值摄取大约110,000个样本/秒,与查询计算所花费的时间周期相比,我们的新存储消耗成本几乎可以忽略不计。 总的来说,新存储需要的CPU资源减少了3-10倍。


磁盘写入MB/秒


我们磁盘的写入利用率方面展示出了最突出和意想不到的改进。 这清晰地表明了为什么Prometheus 1.5容易造成SSD的耗损。 一旦第一个块被持久化到序列文件里,我们就能看到最开始会有一个飙升的过程,一旦删除然后开始重写,就会出现第二次飙升。令人诧异的是,被查询和非查询的服务器显示出完全不同的资源消耗。


另一方面,Prometheus 2.0只是以大约每秒一兆字节的写入速度写入到wal文件。 当块被压缩到磁盘时,写入周期性地出现一个尖峰。 这总体上节省了:惊人的97-99%。


磁盘大小(GB)


与磁盘写入量密切相关的是磁盘空间的总占用量。 由于我们对样本,即我们数据中的大部分组成,使用几乎相同的压缩算法,因此它们也应该是大致相同的。 在一个更稳定的环境中,这样做在很大程度上是合理的,但是因为我们要处理的是高度的序列分流,我们还得考虑每个序列的开销。


可以看到,Prometheus 1.5在两个版本都抵达稳定状态之前,消耗的存储空间因为保留策略的执行而迅速飙升。而Prometheus 2.0似乎在每个序列的开销都有一个明显的降幅。我们可以很高兴地看到磁盘空间是由预写日志文件线性填充的,并随着其压缩会瞬间下降。 事实上,Prometheus 2.0服务器不完全匹配线性增长的情况也是需要进一步调查的。


一切看上去都是充满希望的。 剩下的重要部分便是查询延迟。 新的索引应该提高了我们的查找复杂度。 没有实质改变的是这些数据的处理,例如 rate()函数或聚合。 这些是查询引擎的一部分。


99百分位数查询延迟(以秒为单位)


数据完全符合预期。 在Prometheus 1.5中,随着存储更多的序列,查询延迟会随时间而增加。 一旦保留策略开始执行,旧的系列被删除,它才会平息。 相比之下,Prometheus 2.0从一开始就停留在合理的位置。


这个数据怎样被收集则需要用户花些心思。对服务器发出的查询请求取决于一个时间范围值和即时查询估计的最佳搭档,压缩或轻或重,以及涉及的序列或多或少等等。它不一定代表查询的真实分布。它也不能代表冷数据的查询性能,我们可以假设所有样本数据实际上总是存储在内存中的热点数据。


尽管如此,我们仍然可以非常有信心地说,新版存储引擎在序列分流方面整体查询的性能变得非常有弹性,并且在我们高压的基准场景中存储的性能提高了4倍。在一个更加静态的环境里,我们可以假定查询时间主要用于查询引擎本身,而且延迟明显可以被改进到更低值。


采样/秒


最后,快速过一下我们对不同Prometheus服务器的采样率。 我们可以看到,配备V3存储的两台服务器是相同的采样率。几个小时后,它变得不稳定,这是由于基准集群的各个节点高负载造成的失去响应而跟Prometheus实例本身无关。 (这两行2.0的数据完全匹配的事实希望能让人信服)


即便还有更多可用的CPU和内存资源,Prometheus 1.5.2服务器的采样速率也在大大降低。 序列分流的高压导致它无法收集更大量的数据。


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


我不知道——而且也故意不关注这一点。


影响Prometheus数据流量的因素众多,而这里面没有哪个单个数字能够衡量捕获质量。最大采样率历来是导致基准偏倚的一个指标,它忽略了更重要的方面,如查询性能以及对序列分流的抵御能力。 一些基本测试证实了资源使用线性增长的粗略假设。而这很容易推断出存在什么可能的结果。


我们的基准测试设置模拟了一个高度动态的环境,它给Prometheus施加的压力比今天大多数现实世界的设定要更大。 结果表明,我们在最优设计目标的基础上运行,而在不是最棒的云服务器上跑着。当然,最终衡量是否成功还是得取决于用户的反馈而不是基准数字。


注意:在撰写本文时,Prometheus 1.6正在开发中,它将允许更可靠地配置最大内存使用量,并且可能会显著降低总体的消耗,略微有利于提高CPU利用率。我并没有进行重复的测试,因为整体的结果仍然变化不大,特别是当面对高度的序列分流时更是如此。


结论


Prometheus开始准备应对独立样本的高基数序列及吞吐的处理。 这仍然是一项很具有挑战的任务,但是新的存储引擎似乎使得我们对于超大规模,超收敛的GIFEE基础设施的未来感到满意。恩,它似乎跑的不错。


配备新版V3存储引擎的第一个Alpha版本的Prometheus 2.0(http://suo.im/3dGiQ6)已经可用于测试。在这个早期阶段,预计会发生崩溃,死锁和其他错误。


存储引擎本身的代码可以在单独的项目 中找到。对于Prometheus本身而言,这是非常不可知论的,而且它也可以广泛用于一大波正在苦苦寻觅一个有效的本地时间序列数据库存储的应用。


这里得感谢很多人对这项工作的贡献。以下名单不分前后:


Bojoern Rabenstein和Julius Volz在V2存储引擎上的打磨工作以及他们对于V3的反馈为这新一代设计里所能看到的一切事物奠定了基础。


Wilhelm Bierbaum持续不断地意见和见解为新一代的设计做出了重大贡献。Brian Brazil源源不断的反馈也确保我们最终采用语义上合理的方案。与Peter Bourgon的精辟讨论验证了新的设计,并且造就了这篇文章。


当然也别忘了我所在的CoreOS整个团队和公司本身对这项工作的支持和赞助。感谢那些能够耐心听我一次又一次地扯着SSD,浮点数和序列化格式的每一位同学。


本文为翻译文章,点击阅读原文链接可查看原文。