从零写一个时间序列数据库

Fabian Reinartz 的头像

·

·

·

8,467 次阅读

编者按:Prometheus 是 CNCF 旗下的开源监控告警解决方案,它已经成为 Kubernetes 生态圈中的核心监控系统。本文作者 Fabian Reinartz 是 Prometheus 的核心开发者,这篇文章是其于 2017 年写的一篇关于 Prometheus 中的时间序列数据库的设计思考,虽然写作时间有点久了,但是其中的考虑和思路非常值得参考。长文预警,请坐下来慢慢品味。

首先,快速地概览一下我们要完成的东西和它的关键难题。我们可以先看一下 Prometheus 当前的做法 ,它为什么做的这么好,以及我们打算用新设计解决哪些问题。

时间序列数据

我们有一个收集一段时间数据的系统。

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

每个数据点是一个时间戳和值的元组。在监控中,时间戳是一个整数,值可以是任意数字。64 位浮点数对于计数器和测量值来说是一个好的表示方法,因此我们将会使用它。一系列严格单调递增的时间戳数据点是一个序列,它由标识符所引用。我们的标识符是一个带有 标签维度 label dimensions 字典的度量名称。标签维度划分了单一指标的测量空间。每一个指标名称加上一个唯一标签集就成了它自己的时间序列,它有一个与之关联的 数据流 value stream

这是一个典型的 序列标识符 series identifier 集,它是统计请求指标的一部分:

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 -+++           series A
   +++-+++    series B
          +++-+++-+++>

所以即便整个基础设施的规模基本保持不变,过一段时间后数据库内的时间序列还是会成线性增长。尽管 Prometheus 很愿意采集 1000 万个时间序列数据,但要想在 10 亿个序列中找到数据,查询效果还是会受到严重的影响。

当前解决方案

当前 Prometheus 的 V2 存储系统对所有当前保存的序列拥有基于 LevelDB 的索引。它允许查询语句含有给定的 标签对 label pair ,但是缺乏可伸缩的方法来从不同的标签选集中组合查询结果。

例如,从所有的序列中选择标签 __name__="requests_total" 非常高效,但是选择 instance="A" AND __name__="requests_total" 就有了可伸缩性的问题。我们稍后会重新考虑导致这一点的原因和能够提升查找延迟的调整方法。

事实上正是这个问题才催生出了对更好的存储系统的最初探索。Prometheus 需要为查找亿万个时间序列改进索引方法。

资源消耗

当试图扩展 Prometheus(或其他任何事情,真的)时,资源消耗是永恒不变的话题之一。但真正困扰用户的并不是对资源的绝对渴求。事实上,由于给定的需求,Prometheus 管理着令人难以置信的吞吐量。问题更在于面对变化时的相对未知性与不稳定性。通过其架构设计,V2 存储系统缓慢地构建了样本数据块,这一点导致内存占用随时间递增。当数据块完成之后,它们可以写到磁盘上并从内存中清除。最终,Prometheus 的内存使用到达稳定状态。直到监测环境发生了改变——每次我们扩展应用或者进行滚动更新,序列分流都会增加内存、CPU、磁盘 I/O 的使用。

如果变更正在进行,那么它最终还是会到达一个稳定的状态,但比起更加静态的环境,它的资源消耗会显著地提高。过渡时间通常为数个小时,而且难以确定最大资源使用量。

为每个时间序列保存一个文件这种方法也使得一个单个查询就很容易崩溃 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- 为前缀编号的 block 。每个块中显然保存了索引文件和含有更多编号文件的 chunk 文件夹。chunks 目录只包含不同序列 数据点的原始块 raw chunks of data points 。与 V2 存储系统一样,这使得通过时间窗口读取序列数据非常高效并且允许我们使用相同的有效压缩算法。这一点被证实行之有效,我们也打算沿用。显然,这里并不存在含有单个序列的文件,而是一堆保存着许多序列的数据块。

index 文件的存在应该不足为奇。让我们假设它拥有黑魔法,可以让我们找到标签、可能的值、整个时间序列和存放数据点的数据块。

但为什么这里有好几个文件夹都是索引和块文件的布局?并且为什么存在最后一个包含 wal 文件夹?理解这两个疑问便能解决九成的问题。

许多小型数据库

我们分割横轴,即将时间域分割为不重叠的块。每一块扮演着完全独立的数据库,它包含该时间窗口所有的时间序列数据。因此,它拥有自己的索引和一系列块文件。

t0            t1             t2             t3             now
 +--+  +--+
 |           |  |           |  |           |  |           |                 ++
 |           |  |           |  |           |  |  mutable  | <- ┤ Prometheus |
 |           |  |           |  |           |  |           |                 ++
 +--+  +--+                        ^
       +--+-++--+                              |
                              |                                                  query
                              |                                                    |
                            merge -+

每一块的数据都是 不可变的 immutable 。当然,当我们采集新数据时,我们必须能向最近的块中添加新的序列和样本。对于该数据块,所有新的数据都将写入内存中的数据库中,它与我们的持久化的数据块一样提供了查找属性。内存中的数据结构可以高效地更新。为了防止数据丢失,所有传入的数据同样被写入临时的 预写日志 write ahead log 中,这就是 wal 文件夹中的一些列文件,我们可以在重新启动时通过它们重新填充内存数据库。

所有这些文件都带有序列化格式,有我们所期望的所有东西:许多标志、偏移量、变体和 CRC32 校验和。纸上得来终觉浅,绝知此事要躬行。

这种布局允许我们扩展查询范围到所有相关的块上。每个块上的部分结果最终合并成完整的结果。

这种横向分割增加了一些很棒的功能:

  • 当查询一个时间范围,我们可以简单地忽略所有范围之外的数据块。通过减少需要检查的数据集,它可以初步解决序列分流的问题。
  • 当完成一个块,我们可以通过顺序的写入大文件从内存数据库中保存数据。这样可以避免任何的写入放大,并且 SSD 与 HDD 均适用。
  • 我们延续了 V2 存储系统的一个好的特性,最近使用而被多次查询的数据块,总是保留在内存中。
  • 很好,我们也不再受限于 1KiB 的数据块尺寸,以使数据在磁盘上更好地对齐。我们可以挑选对单个数据点和压缩格式最合理的尺寸。
  • 删除旧数据变得极为简单快捷。我们仅仅只需删除一个文件夹。记住,在旧的存储系统中我们不得不花数个小时分析并重写数亿个文件。

每个块还包含了 meta.json 文件。它简单地保存了关于块的存储状态和包含的数据,以便轻松了解存储状态及其包含的数据。

mmap

将数百万个小文件合并为少数几个大文件使得我们用很小的开销就能保持所有的文件都打开。这就解除了对 mmap(2) 的使用的阻碍,这是一个允许我们通过文件透明地回传虚拟内存的系统调用。简单起见,你可以将其视为 交换空间 swap space ,只是我们所有的数据已经保存在了磁盘上,并且当数据换出内存后不再会发生写入。

这意味着我们可以当作所有数据库的内容都视为在内存中却不占用任何物理内存。仅当我们访问数据库文件某些字节范围时,操作系统才会从磁盘上 惰性加载 lazy load 页数据。这使得我们将所有数据持久化相关的内存管理都交给了操作系统。通常,操作系统更有资格作出这样的决定,因为它可以全面了解整个机器和进程。查询的数据可以相当积极的缓存进内存,但内存压力会使得页被换出。如果机器拥有未使用的内存,Prometheus 目前将会高兴地缓存整个数据库,但是一旦其他进程需要,它就会立刻返回那些内存。

因此,查询不再轻易地使我们的进程 OOM,因为查询的是更多的持久化的数据而不是装入内存中的数据。内存缓存大小变得完全自适应,并且仅当查询真正需要时数据才会被加载。

就个人理解,这就是当今大多数数据库的工作方式,如果磁盘格式允许,这是一种理想的方式,——除非有人自信能在这个过程中超越操作系统。我们做了很少的工作但确实从外面获得了很多功能。

压缩

存储系统需要定期“切”出新块并将之前完成的块写入到磁盘中。仅在块成功的持久化之后,才会被删除之前用来恢复内存块的日志文件(wal)。

我们希望将每个块的保存时间设置的相对短一些(通常配置为 2 小时),以避免内存中积累太多的数据。当查询多个块,我们必须将它们的结果合并为一个整体的结果。合并过程显然会消耗资源,一个星期的查询不应该由超过 80 个的部分结果所组成。

为了实现两者,我们引入 压缩 compaction 。压缩描述了一个过程:取一个或更多个数据块并将其写入一个可能更大的块中。它也可以在此过程中修改现有的数据。例如,清除已经删除的数据,或重建样本块以提升查询性能。

t0             t1            t2             t3             t4             now
 ++  +--+  +--+
 | 1          |  | 2        |  | 3         |  | 4         |  | 5 mutable |    before
 ++  +--+  +--+
 +--+  +--+  +--+
 +--+  +--+  +--+

在这个例子中我们有顺序块 [1,2,3,4]。块 1、2、3 可以压缩在一起,新的布局将会是 [1,4]。或者,将它们成对压缩为 [1,3]。所有的时间序列数据仍然存在,但现在整体上保存在更少的块中。这极大程度地缩减了查询时间的消耗,因为需要合并的部分查询结果变得更少了。

保留

我们看到了删除旧的数据在 V2 存储系统中是一个缓慢的过程,并且消耗 CPU、内存和磁盘。如何才能在我们基于块的设计上清除旧的数据?相当简单,只要删除我们配置的保留时间窗口里没有数据的块文件夹即可。在下面的例子中,块 1 可以被安全地删除,而块 2 则必须一直保留,直到它落在保留窗口边界之外。

                      |
 ++  +--+  +--+  +-+--+  +--+
                      |
                      |
             retention boundary

随着我们不断压缩先前压缩的块,旧数据越大,块可能变得越大。因此必须为其设置一个上限,以防数据块扩展到整个数据库而损失我们设计的最初优势。

方便的是,这一点也限制了部分存在于保留窗口内部分存在于保留窗口外的块的磁盘消耗总量。例如上面例子中的块 2。当设置了最大块尺寸为总保留窗口的 10% 后,我们保留块 2 的总开销也有了 10% 的上限。

总结一下,保留与删除从非常昂贵到了几乎没有成本。

如果你读到这里并有一些数据库的背景知识,现在你也许会问:这些都是最新的技术吗?——并不是;而且可能还会做的更好。

在内存中批量处理数据,在预写日志中跟踪,并定期写入到磁盘的模式在现在相当普遍。

我们看到的好处无论在什么领域的数据里都是适用的。遵循这一方法最著名的开源案例是 LevelDB、Cassandra、InfluxDB 和 HBase。关键是避免重复发明劣质的轮子,采用经过验证的方法,并正确地运用它们。

脱离场景添加你自己的黑魔法是一种不太可能的情况。

索引

研究存储改进的最初想法是解决序列分流的问题。基于块的布局减少了查询所要考虑的序列总数。因此假设我们索引查找的复杂度是 O(n^2),我们就要设法减少 n 个相当数量的复杂度,之后就相当于改进 O(n^2) 复杂度。——恩,等等……糟糕。

快速回顾一下“算法 101”课上提醒我们的,在理论上它并未带来任何好处。如果之前就很糟糕,那么现在也一样。理论是如此的残酷。

实际上,我们大多数的查询已经可以相当快响应。但是,跨越整个时间范围的查询仍然很慢,尽管只需要找到少部分数据。追溯到所有这些工作之前,最初我用来解决这个问题的想法是:我们需要一个更大容量的倒排索引

倒排索引基于数据项内容的子集提供了一种快速的查找方式。简单地说,我可以通过标签 app="nginx" 查找所有的序列而无需遍历每个文件来看它是否包含该标签。

为此,每个序列被赋上一个唯一的 ID ,通过该 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),所以在嵌套地为每个列表进行 暴力求解 brute force solution 下,运行时间为 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 存储系统通过每块上具有的独立不可变索引来解决这一问题,该索引仅通过压缩时的重写来进行修改。只有可变块上的索引需要被更新,它完全保存在内存中。

基准测试

via: https://fabxc.org/blog/2017-04-10-writing-a-tsdb/

作者:Fabian Reinartz 译者:LuuMing 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注