从头开始写一个时序数据库 - Writing a Time Series Database from Scratch

从头开始写一个时序数据库

本文译自Fabian Reinartz的 Writing a Time Series Database from Scratch 。

文章目录

  • 从头开始写一个时序数据库
  • 1. Problems, Problems, Problem Space
    • 1.1 Time series data
    • 1.2 Vertical and Horzontal
      • 1.2.1 Current solution
    • 1.3 Series Churn
      • 1.3.1 Current solution
    • 1.4 Resource consumption
  • 2. Starting Over
    • 2.1 Macro Design
      • 2.1.1 Many Little Databases
      • 2.1.2 mmap
      • 2.1.3 Compaction
      • 2.1.4 Retention
    • 2.2 Index
      • 2.2.1 Combining Labels

我一直在从事监控相关工作,特别致力于Prometheus的研究上。Prometheus是一个包含自定义时序数据库(Time Series Database)的监控系统,并且它易于与Kubernetes进行集成。

Kubernetes在很多方面都满足Prometheus的设计需求,它使得如持续集成(Continuous Deployments)、自动伸缩(Auto Scaling)以及其它在高动态环境(Highly Dynamic Environments)所需的功能特性都非常易于实现。而Prometheus本身的如查询语言(PromQL)、操作模型以及其它许多概念性的设计,也使它特别适应这种高动态环境。与此同时,Prometheus所监控的这些工作负载(Workload)现在也变得越来越动态,这给监控系统本身带来了新的压力。正是考虑到这一点,我并不打算在Prometheus已经处理的很好的问题上深究,而是旨在提高其对具备高动态或瞬时服务的环境中的性能。

Prometheus的存储层已经通过历史表现证明了自己出色的性能,单机服务器就有能力为数百万的时间序列(Time Series)提供每秒百万级的样本处理能力,并且仅占用非常少量的磁盘空间。目前的存储系统已经能为我们提供很好的服务,但我仍然设计了一个新的存储子系统,它补齐了现有存储系统的短板,并让Prometheus具备处理下一级数据规模的能力。

注意:我并没有数据库相关的背景,我所说的如果是错误的或是有误导性的,请在Freenode上的 #promethues 话题中向我提出建议。

1. Problems, Problems, Problem Space

首先,概述我们要实现的东西以及其关键性问题,对于每个问题,我们会先看一下目前Prometheus是如何处理的,有什么值得借鉴的,以及在新的设计中我们想解决什么问题。

1.1 Time series data

我们有一个持续采集数据点的系统。

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

如上,所有数据点都是Timestamp和Value的元组。对于监控而言,Timestamp时间戳肯定是一个整数,但Value则可以是任意类型的数值。对于Counter或者Guage类型的Value,64位的浮点数都可以很好的进行表示,所以我们采用它。一组严格的按时间单调递增的数据点(Time Series Data),我们称之为时间序列(Time Series),一般序列都会有一个Identifier来引用它。此处,我们的Identifier就是指标名(Metric Name)以及一组标签(Label)。Label这个维度我们可以认为是指标的下沉的测量空间,一个指标名加上一组Label构成一个独立的时间序列,拥有自己的数据流。

如下是一个典型的序列标识符(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}

我们可以简化这个表示:指标名也可以被视为一个Label__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}

当我们进行查询时,我们希望通过Label来进行Time Series的筛选。在最简单的情况下,通过{__name__="requests_total"}可以查询到属于request_total指标的Time Series。对于所有被选中的Time Seires,我们可以获取到在特定时间窗口中的所有的数据点。
在更复杂的查询中,我们可能还希望能在一次查询中通过多个Label进行Time Series的筛选,以及除“=”之外的更复杂的条件表达式。比如,不等(method!="GET"),或正则表达式(method="PUT|POST")

这在很大程度上决定了如何存储数据,以及如果获取数据。

1.2 Vertical and Horzontal

在简化的视图中,所有的数据点都可以分布在二维平面上。水平维度代表时间,垂直维度代表Time Series。

series
  ^. . . . . . . . . . . . . . . . .   . . . . .   {__name__="request_total", method="GET"}. . . . . . . . . . . . . . . . . . . . . .   {__name__="request_total", method="POST"}. . . . . . .. . .     . . . . . . . . . . . . . . . .                  .... . . . . . . . . . . . . . . . .   . . . .. . . . . . . . . .   . . . . . . . . . . .   {__name__="errors_total", method="POST"}. . .   . . . . . . . . .   . . . . .   {__name__="errors_total", method="GET"}. . . . . . . . .       . . . . .. . .     . . . . . . . . . . . . . . . .                  .... . . . . . . . . . . . . . . .   . . . . 
  v
    <-------------------- time --------------------->

Prometheus定期的从一系列的Time Series中抓取其瞬时值来得到数据点,我们从中获取这些值的实体,称为Target。因此,我们可以看到写入模式是完全垂直并且高度并发的,因为来自每个Target的样本是独立抓取的。
这里提供一些测量的规模:单实例的Prometheus从数万个Target中获取数据点,每个Target都暴露成百上千个不同的Time Series。

在每秒收集数百万个数据点的规模下,支持批量写入是一个毋容置疑的性能要求。在磁盘上分散的写入单个数据点会相当的缓慢,因此,我们想要顺序的写入更大的数据块。
对于旋转磁盘来说,这是一个不足为奇的事实,因为它的磁头需要不停的在扇区间移动。对于SSD,虽然它以致辞快速随机写入而闻名,但他们实际上不能修改单个字节,而只能以4KiB或更大的页面来写入。这意味着写入一个16字节的样本等同于写满一个4KiB的页。这种行为是所谓的写入放大(Write Amplification)的一部分,这将“有助于”你SSD的磨损(因为它不仅会变慢,而且会在几天或几周内摧毁你的硬盘)。
这个问题更深层次的信息,可以参考 Coding For SSDs series 系列资源,此处我们不深入探讨,仅关注最主要的内容:顺序/批量写入对旋转磁盘和SSD都是理想的写入模式,我们遵循这个简单的规则即可。

查询模式跟写入模式有明显的差异,我们可以查询单个Time Series的单个数据点,也可以查询10000个Time Series的单个数据点,或者单个Time Series几周内的数据点,亦或是10000个Time Series几周内的数据点。所以对于我们上面的二维数据平面来说,查询并非完全水平或垂直的,而是一个矩形的区域。
Recoding Rules 有助于减缓已知的查询问题,但对于临时查询而言,它并不是通用的方案。

现在我们知道了我们想批量的写入,但是我们得到的数据集仅仅是跨Time Series的数据点。当从某个时间窗口中查询数据点时,我们不仅很难明确知道从哪里找到这些数据点,而且我们不得不从磁盘上大量随机的地方读取这些数据点。考虑到我们提及的数百万级的样本规模,即使是在最好的SSD上,查询也将会很缓慢。而且读取也会从我们的磁盘中检索到比要求的16字节样本更多的数据,比如SSD会加载一整页,HDD则至少会读取一整个扇区。无论采取何种方式,我们都在浪费宝贵的读取吞吐量。
因此,理想情况下,同一Time Series的样本最好顺序存储,这样我们就能通过尽可能少的读操作来获取它们。最重要的是,我们仅需要知道Time Series的起始位置,我们就能访问所有的数据点。

显然,将收集到的数据写入到磁盘的理想模式,与能够显著提高查询效率的布局之间存在明显的矛盾,这也是我们TSDB要解决的一个基本问题。

1.2.1 Current solution

是时候看看Prometheus当前的存储引擎(我们称之为V2)是如何解决这个问题的。
我们为每个Time Series创建一个文件,里面包含按时间顺序排列的所有样本。由于每隔几秒就向这些文件附加单个样本的成本很高,因此我们将为每个Time Series提供1KiB的内存的Chunk,当这1KiB的内存的Chunk满了之后,再附加到单个文件中。这个方法解决了一大部分的问题,因为写入变成批量的了,并且样本也是按照顺序来读取的。此外,它还支持非常高效的压缩格式,因为同一个Time Series的相邻的样本一般差异很小。Facebook发表的关于Gorilla TSDB的论文(Gorilla: A Fast, Scalable, In-Memory Time Series Database)描述了一个Chunk-Based方法,并且介绍了一种压缩方式,可以将16字节的样本减少到平均1.37字节。V2的存储引擎支持多种压缩格式,其中就包含Gorilla的变体。

   ┌──────────┬─────────┬─────────┬─────────┬─────────┐           series A
   └──────────┴─────────┴─────────┴─────────┴─────────┘
          ┌──────────┬─────────┬─────────┬─────────┬─────────┐    series B
          └──────────┴─────────┴─────────┴─────────┴─────────┘ 
                              . . .
 ┌──────────┬─────────┬─────────┬─────────┬─────────┬─────────┐   series XYZ
 └──────────┴─────────┴─────────┴─────────┴─────────┴─────────┘ 
   chunk 1    chunk 2   chunk 3     ...
  • 虽然Chunk-Based的方法很棒,但由于如下的原因,为每个Time Series保留一个独立的文件会带来一些问题:
    实际上,我们需要比当前采集的Time Series数量多得多的文件来存储数据,在后面的序列分流(Series
    Churn)的章节会有更详尽的描述。对于几百万个文件,我们迟早会耗尽文件系统上的inode。这是我们必须通过格式化磁盘来恢复的情况,这可能具有侵入性和破坏性,我们通常希望避免出现因为某个应用程序而格式化磁盘的局面。
  • 即使是使用Chunk-Based的方法,每秒也会有数千个Chunk准备完成并准备被持久化,这仍然导致每秒进行数以千计的磁盘写入。这个问题虽然可以通过合并Chunk再批量写入来进行缓解,但这反过来会增加等待持久化的数据的内存占用。
  • 保持所有的文件打开来保证读写是不可行的,特别是因为大约99%的数据在24小时后不会再被查询到。如果它被查询,我们必须打开多达数千个文件,查找相关数据点并将其读入内存,然后再关闭它们。这将会导致很高的查询延迟,数据块缓存的剧增会导致新的问题,这将在资源消耗(Resource Consumption)章节作进一步讨论。
  • 最终,我们都必须删除旧的数据,并且需要从数百万个文件的头部去删除,这意味着删除动作其实是一个写入密集型操作。此外,循环浏览数百万个文件并对其进行分析可能会导致数小时的消耗,当它执行完成时,它可能又需要再重新开始了。并且,删除旧文件也会导致SSD的写入放大。
  • 当前正在积累数据的Chunk仅存在于内存中,如果应用程序崩溃,数据就会丢失。为了避免这种情况,内存状态必须定时的同步到到磁盘上,这比我们能接受的数据丢失的时间窗口要长的多。同时,恢复检查点可能也需要几分钟时间,导致更长的重启周期。

现有设计的关键,是Chunk的概念,基于其优点,我们当然希望保留它。最近的Chunk总是保留在内存中,这通常也是好事,因为最近的数据总是最常被查询。
为每个Time Series维持一个文件的概念,是我们希望替换掉的。

1.3 Series Churn

在Prometheus的场景中,我们使用术语序列分流“Series Churn”来描述一种情况:一组Time Series变得不再活跃,即不再接收新的数据点,而取而代之的是一组新的活跃的Time Series。
例如,指定的微服务实例暴露的所有Time Series都包含了“instance”标签,用于标识其来源。如果我们对微服务执行滚动更新,并将每个实例换成新的版本,这时候就会发生序列分流。在更动态的环境中,这些时间可能每小时就会发生一次。像Kubernetes这样的集群编排系统,允许应用进行连续的自动扩展和频繁的滚动更新,每天可能创建数以万计的应用程序实例,也就导致数以万计的全新的Time Series。

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

因此,即使我们这个系统的基础设施规模基本保持不变,随着时间的退役,我们数据库中的Time Seires也会线性增长。虽然Prometheus很乐意收集千万级别的Time Seires,但是如果要在10亿个Time Series中查找数据,其性能就会收到很大的影响。

1.3.1 Current solution

Prometheus当前的V2的存储引擎对当前存储的所有的Time Seires都有一个基于LevelDB的索引,它允许查询包含给定Label的Time Series,但是缺乏一种弹性的方法来从不同的Label选集中组合查询结果。
比如,查询__name__"request_total"会非常高效,但是查询instance="A" and __name__"request_total"就有了可伸缩性问题。我们稍后会重新讨论这个问题,并且审视哪些调整可以改善查询延迟。

这个问题实际上是促使我去寻找一个更好的存储引擎的初因,Prometheus需要一种改进的索引方法来快速搜索亿级的Time Series。

1.4 Resource consumption

在尝试扩展Prometheus时,资源消耗(Resource consumption)是永恒的主题之一。但真正上困扰用户的,并不是绝对的资源匮乏,事实上,根据既定的要求,Prometheus管理着令人难以置信的吞吐,真正影响用户的问题,是Prometheus在面对变化时的不可预测性与不稳定性。通过之前描述的架构,V2存储缓慢地构建大量样本数据的Chunk,这会导致内存消耗随时间不断增加。当Chunk填满时,它们被写入磁盘并可以从内存中驱逐,最终,Prometheus的内存使用量逐渐趋于稳定。这种稳定状态将一直持续,直到被监控的环境发生变化时(每次我们扩容应用程序或进行滚动更新时),序列分流都会增加内存、CPU和磁盘IO的使用。如果变化正在进行中,它最终将再次达到稳定状态,但会明显高于更静态的环境的资源消耗。这个过渡时期通常长达数小时,很难确定最大资源使用量是多少。

为每个Time Series对应一个文件存储的做法,也使得一个查询动作很容易就能崩溃Prometheus的进程。当查询的数据点未在内存中命中时,会打开相关Time Series的文件,对应的数据点将会被读入内存,如果数据量超过可用内存,则Prometheus会因为OOM-killed退出,这种方式并不优雅。
查询完成后,加载的数据就可以再次释放,但通常会被缓存多一段时间,以便为后续的查询提供更快的服务,后者显然是一件好事。

最后,我们研究了SSD写入放大的问题,以及Prometheus如何通过批量写入来缓解这个问题。尽管如此,在某些情况下仍然会出现写入放大,因为批次的量太小,没有在页边界对齐数据。对于大型的Prometheus服务器,我们可以真实的观察到其对硬件寿命的影响。对于高写入量的数据库应用而言,这可能是相当正常的,但我们还是应该留意是否可以缓解它。

2. Starting Over

到目前为止,我们知道了我们要解决的问题域、Prometheus的V2存储引擎如何解决它,以及V2的设计有何问题。从中,我们还看到了一些很棒的概念,我们希望或多或少的继承它们。相当多的V2的问题,可以通过改进和部分重构来解决,但为了让事情变得更有趣(当然,在仔细评估过我的选择后),我觉得尝试从头开始编写整个时序数据库(即,从向文件系统写入字节开始)。

对存储格式的选择,直接影响着性能和资源使用这种关键性问题。我们必须找到一组正确的算法和磁盘布局,以实现性能良好的存储层。

2.1 Macro Design

我们存储的宏观设计是什么?简而言之,在我们的数据目录上运行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

在顶层,我们有一系列编号的Block,前缀为b-。每个Block显然包含一个索引文件,以及一个包含更多编号文件的“chunks”目录。“chunks”目录只包含各个Time Series的原始数据点。与V2一样,这使得我们在一个时间窗口内读取Time Series时开销很小,并且也允许我们使用同样高效的压缩算法,这个概念已经被证明行之有效,因此我们将继承下去。显然,每个Time Seires不再有一个独立的文件,取而代之的是屈指可数的几个Chunk文件。
索引文件应该不足为奇,我们先假设它包含很多的黑魔法,让我们能够找到Label、他们的可能值、全部的Time Seires以及保存其数据点的Chunk。

但是为什么有几个目录包含索引和Chunks文件?为什么最后一个包含“wal”目录?如果能正确理解这两个问题,那我们90%的问题都会自然而然的有答案。

2.1.1 Many Little Databases

我们将水平维度(也就是时间维度),划分为不重叠的Block。每个Block充当一个完全独立的数据库,包含其时间窗口内的所有的Time Seires的数据。因此,它有一组自己的索引文件和Chunk文件。

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

每个Block都是不可变的,当然,我们必须在收集新数据的时,将新的Time Seires和样本添加到最近的Block中。对于这个Block,所有的数据都将写入内存数据库,该数据库提供与持久化的数据库相同的查找特性。内存数据库可以高效的进行更新,为了防止数据丢失,所有输入的数据都会写入临时的write ahead log,这是我们“wal”目录中的一组文件,这使得我们可以在重启Prometheus的时候从中还原我们的内存数据库。
所有的这些文件都带有自己的序列化格式,包含我们期望的所有的特性:大量的标识符、偏移量、变量和CRC32校验值。

这种布局允许我们从时间维度切入去查询时间范围内的所有的Block,每个Block查询出的部分的结果又将合并在一起,形成最终的整体的结果。

这种水平维度的切分方式带来一些很棒的能力:

  • 在查询某个时间范围时,我们可以轻松高端忽略该范围之外的所有Block。它通过减少查询的数据集的方式来解决序列分流的问题。
  • 当一个Block完成后,我们可以将内存数据库持久化到磁盘,通过顺序写入的方式,只需写入少数的几个较大的文件即可。我们避免了任何写入放大,并为SSD和HDD提供同样好的服务。
  • 我们保持了V2中的良好的特性,即最近的Chunk,也就是查询的最多的Chunk,总是在内存中缓存的。
  • 很好,我们也不再受限于固定的1KiB的大小来更好的对齐磁盘上的数据,我们可以选择对单个数据点或压缩格式而言更合理的大小。
  • 删除数据的开销变得非常的小,我们仅仅只需要删除一个目录。请回忆一下,在旧的存储引擎中,我们必须分析和重写数亿规模的文件,这可能需要几个小时才能完成。

每个Block还包含一个meta.json文件,它保存了关于Block的人类可读的信息,以便我们了解存储状态和它包含的数据。

2.1.2 mmap

从数以百万级的小文件变为少数几个较大的文件,使得我们可以以很小的开销就保持所有文件的打开。这使得我们可以解锁对mmap的应用,这是一个系统调用,允许我们通过文件内容透明的回传虚拟内存区域。简单来讲,你可能想把它看做是swap空间,只是我们所有的数据已经在磁盘上,当把数据从内存中交换出来时,不会产生写操作。

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

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

据我的理解,这是当今许多数据库的工作方式,并且如果磁盘格式允许,这是一种理想的工作方式(除非有人有信心在这个过程中超越操作系统)。我们当然可以用很少的工作来得到更多的能力。

2.1.3 Compaction

存储引擎必须定期的分配新的Block,而前一个已经完成的Block,则会写入磁盘。只有在Block已经持久化完成的前提下,用于恢复内存Block的write ahead log才会被删除。
我们通常希望每个Block的区间尽量合理一点(典型的设置是两小时左右),以避免在内存中积累太多的数据。当查询多个Block时,我们必须将它们各自的结果汇总为整体的结果。这个合并的动作显然是有额外的开销的,一个为期一周的查询,不应该需要合并80多个Block。

为了实现这两点,我们引入了压缩机制。压缩的作用是将一个或多个Block写入一个更大的Block的过程。在这个过程中还可以对现有的数据进行修改,如删除已标识为删除状态的数据,或者重构我们的样本Chunk以提高查询性能。

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)
 +--------------------------+  +--------------------------+  +-----------+

在这个例子中,我们有顺序的Block[1, 2, 3, 4],Block 1、2和3可以进行合并,并形成新的Block布局[1, 4]。或者,可以将它们成对压缩为[1, 3]。压缩后,所有的Time Seires的数据依然完整存在,但整体处在更少的Block中。这显著的减少了查询时的合并成本,因为需要合并的“子结果”更少了。

2.1.4 Retention

我们看到在V2存储中删除旧数据是一个相当缓慢的过程,并且对CPU、内存和磁盘都造成影响,那以现有的设计,我们如何在Block中删除旧数据呢?非常简单,只要删除在我们配置的保留时间窗口中没有数据的Block的目录即可。在下面的示例中,Block1可以安全的删除,而Block2必须暂时保留,直到它完全的处在保留时间窗口之外(图中的retention boundary)。

|
 +------------+  +----+-----+  +-----------+  +-----------+  +-----------+
 | 1          |  | 2  |     |  | 3         |  | 4         |  | 5         |   . . .
 +------------+  +----+-----+  +-----------+  +-----------+  +-----------+
                      |
                      |
             retention boundary

随着我们不断的压缩之前的Block,那势必会出现旧Block越来越大的情况,因此,我们必须为其设置一个上限,以防止所有旧的Block被压缩为接近一整个数据库的规模,这将失去我们设计的最初优势。
碰巧的是,这也恰好限制了位于保留时间窗口边界的Block的磁盘开销。如上面示例的Block2,当我们将Block的上限大小设置为保留时间窗口总大小的10%后,则Block2的总开销也就有了10%的上限。

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

2.2 Index

对存储引擎改进的想法最初是想解决因序列分流带来的问题,基于Block的结构减少了服务在查询时必须考虑的Time Seires的总数。因此,假设我们的原本的索引查找的时间复杂度是O(n2),那我们现在已经设法将n减少了很多的数量,现在的复杂度提升到了O(n2)。嗯,等等…糟糕。

快速回忆一下“Algorithms 101”课上提醒我们的,从理论上 它并未带给我们任何好处,如果事情以前很糟糕,那么现在也一样。

在实践中,我们大多数的查询速度已经相当的快。然后,跨越整个时间范围的查询仍然很慢,即使他们只需要找到少数的几个Time Seires。我最初的想法是:我们需要一个更大容量的倒排索引。
倒排索引提供了基于数据内容的子集的快速查找数据项的方法,简单的说,我可以查找所有Label中包括app="nginx"的Time Seires,而无需遍历每个Time Seires并检查它是否包含该标签。

为此,每个序列都会被分配一个唯一的ID,通过该ID可以在恒定的时间内检索它(时间复杂度O(1))。在这个例子中,ID就是我们的正向索引。

示例:如果 ID 为 10、29、9 的Time Seires包含Label app="nginx",那么 “nginx”的倒排索引就是简单的列表 [10, 29, 9],它就能用来快速地获取所有包含Label的序列,即使我们有200多亿个Time Series也不会影响查找速度。

简单来讲,如果n使我们的Time Seires的总数,m是查询操作的结果的大小,我们使用索引的查询的复杂度现在是O(m)。查询的规模现在是取决于m而不是n,这是一个很好的特性,因为m通常会小很多。
为简洁起见,我们假设可以在恒定时间内查找到倒排索引对应的列表。

实际上,这几乎就是V2的倒排索引,也是为百万级Time Seires提供查询服务的最低要求。敏锐的人会注意到,在最坏的情况下,一个Label会存在于所有的Time Seires中,因此,m也可能等于n。但这一点是在预料中的,如果查询全部的数据,那它自然会花费更多时间。一旦我们牵扯上更复杂的查询语句,就会有问题。

2.2.1 Combining Labels

与数百万个Time Seires相关的Label是很常见。假设我们有一个微服务“foo”,其横向扩展着数百个实例,每个实例拥有数千个Time Series,每个Time Seires都会带有Label app="foo"。当然,用户通常不会查询所有的Time Seires,而是会通过更多的Label来限制查询。例如,我想知道服务实例接收到了多少请求,那么查询语句便是__name__="requests_total" AND app="foo"

为了找到满足两个标签选择器的所有Time Seires,我们得到每一个Label的倒排索引的列表并取其交集。结果集通常会比任何一个输入列表小一个数量级。因为每个输入列表最坏情况下的大小为 O(n),所以如果通过嵌套循环地方式为每个列表进行暴力求解的情况下下,期时间复杂度为 O(n2)。相同的成本也适用于其他的集合操作,例如取并集(app="foo" OR app="bar")。当在查询语句上添加更多标签选择器,时间复杂度就会指数增长到 O(n3)、O(n4)、O(n5)……O(nk)。通过改变执行顺序,可以使用很多技巧来优化运行效率,越复杂,越是需要关于数据特征和标签之间相关性的知识。这引入了大量的复杂度,但是并没有减少算法的最坏运行时间。

如上便是 V2 存储系统使用的基本方法,幸运的是,一些很小的改动就能获得显著的提升。如果我们假设倒排索引中的 ID 都是排序好的会怎么样?

假设这个例子的列表用于我们最初的查询:

# 译者注:此处第一行的倒排索引中,原文为"[ 9999, 1000, 1001, 2000000...",根据前后文的描述及分析,笔者认为是原作者笔误,故将9999修改为999
__name__="requests_total"   ->   [ 999, 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(nk)。

我在这里所描述的是几乎所有全文搜索引擎使用的标准搜索索引的简化版本。每个序列描述符都视作一个简短的“document”,每个Label(名称 + 固定值)作为其中的“word”。我们可以忽略搜索引擎索引中通常遇到的很多附加数据,例如单词位置和和频率。

关于改进实际运行时间的方法似乎存在无穷无尽的研究,它们通常都是对输入数据做一些假设。不出意料的是,还有大量技术来压缩倒排索引,其中各有利弊。因为我们的“document”比较小,而且“word”在所有的序列里大量重复,压缩变得几乎无关紧要。例如,一个真实的数据集约有440万个Time Series与大约12个Label,每个Label拥有少于5000个单独的Label。对于最初的存储版本,我们坚持使用基本的方法而不压缩,仅做微小的调整来跳过大范围非交叉的ID。

尽管维持排序好的ID听起来很简单,但实践过程中不是总能完成的。例如,V2存储系统为新的Time Seires赋上一个哈希值来当作ID,我们就不能轻易地排序倒排索引。

另一个艰巨的任务是当磁盘上的数据被更新或删除掉后修改其索引。通常,最简单的方法是重新计算并写入,但是要保证数据库在此期间可查询且具有一致性。V3 存储系统通过每个Block上具有的独立不可变索引来解决这一问题,该索引仅通过压缩时的重写来进行修改。只有可变块上的索引需要被更新,它完全保存在内存中。

你可能感兴趣的:(prometheus,数据库,时序数据库,database)