正文
└──────────┴─────────┴─────────┴─────────┴─────────┘
┌──────────┬─────────┬─────────┬─────────┬─────────┐ series B
└──────────┴─────────┴─────────┴─────────┴─────────┘
. . .
┌──────────┬─────────┬─────────┬─────────┬─────────┬─────────┐ series XYZ
└──────────┴─────────┴─────────┴─────────┴─────────┴─────────┘
chunk 1 chunk 2 chunk 3 ...
尽管基于块的实现方案很棒,如何为每个序列维护一个单独的文件却也是V2存储引擎困扰的地方,这里面有几个原因:
-
我们实际上需要维护的文件数量多于我们正在收集数据的时间序列数量。在“序列分流”一节会详解介绍到这点。由于产生了几百万个文件,不久的将来或者迟早有一天,我们的文件系统会出现inode耗尽的情况。在这种情况下我们只能通过重新格式化磁盘来恢复,这样做可能带有侵入性和破坏性。通常我们都希望避免格式化磁盘,特别是需要适配某个单个应用时更是如此。
-
即便做了分块,每秒也会产生数以千计的数据块并且准备好被持久化。这仍然需要每秒完成几千次单独的磁盘写操作。尽管这一点可以通过为每个序列填满的数据块做分批处理来缓解压力,这反过来又会增加等待被持久化的数据总的内存占用。
-
保持打开所有文件来读取和写入是不可行的。特别是因为在24小时后超过99%的数据便不再会被查询。如果它还是被查询到的话,我们就不得不打开数千个文件,查找和读取相关的数据点到内存,然后再重新关闭它们。而这样做会导致很高的查询延迟,数据块被相对积极地缓存的话又会导致一些问题,这一点会在“耗用资源”一节里进一步概述。
-
最终,旧数据必须得被清理掉,而且数据需要从数百万的文件前面被抹除。这意味着删除实际上是写密集型操作。此外,循环地在这数百万的文件里穿梭然后分析它们会让这个过程常常耗费数个小时。在完成时有可能还需要重新开始。呵呵,删除旧文件将会给你的SSD带来进一步的写入放大!
-
当前堆积的数据块只能放在内存里。如果应用崩溃的话,数据将会丢失。为了避免这种情况,它会定期地保存内存状态的检查点(Checkpoint)到磁盘,这可能比我们愿意接受的数据丢失窗口要长得多。从检查点恢复估计也会花上几分钟,造成痛苦而漫长的重启周期。
从现有的设计中脱颖而出的关键在于块的概念,我们当然希望保留这一设计。大多数最近的块被保留在内存里一般来说也是一个不错的做法。毕竟,最大幅度被查询数据里大部分便是这些最近的点。
一个时间序列对应一个文件这一概念是我们想要替换的。
在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版本存在的问题均可以通过一些改进和部分的重新设计来解决,但为了让事情变得更好玩些(当然,我这个决定是经过深思熟虑的),我决定从头开始编写一款全新的时间序列数据库 —— 从零开始,即,将字节数据写到文件系统。
性能和资源使用这样的关键问题会直接引领我们做出存储格式方面的选择。我们必须为我们的数据找到一个正确的算法和磁盘布局以实现一个性能优良的存储层。
这便是我直接迈向成功时走的捷径 —— 忽略之前经历过的头疼,无数失败的想法,数不尽的草图,眼泪,还有绝望。