从头编写一个时序数据库
本文介绍如何去设计一个时序数据库,可以学习一下文章中提及的一些技术点。需要注意的是,本文编写的时间为2017年4月,因此文中需要改善的也是老版本的Prometheus存储存在的问题。
译自:Writing a Time Series Database from Scratch
在很多方面,Kubernetes 已经成为了 Prometheus 的设计目标,它实现了持续部署、自动扩容以及其他方便访问的高度动态环境特性。请求语言、操作模型和其他概念设计使得Prometheus非常适合这类环境。然而,当监控的负载变得更动态的同时,也给监控系统本身带来了新的压力,相比于质疑Prometheus已经解决的问题,我们更倾向于提升高度动态或临时服务环境下的性能。
在过去,Prometheus的存储层展现了卓越的性能,单个服务每秒能够从百万级的时间序列中提取多达100万个样本,同时仅占用极少的磁盘空间。当前的存储运行非常好,在此我提出了一个新的存储子系统方案,它可以纠正现有解决方案的缺点,并能够处理更大规模的场景。
现有问题
下面快速看一下我们尝试达成的目的以及面临的主要问题。对于每个问题,首先看下现有Prometheus的处理方式,看看哪些地方做的好,以及在新的方案中应该着重解决哪些问题。
时序数据
系统会随时间采集数据点:
identifier -> (t0, v0), (t1, v1), (t2, v2), (t3, v3), ....
每个数据都是时间戳和值的元组。为了监控,时间戳是一个整型,值为任意数值。其中64位的浮点数非常适合表示counter和gauge类型的值。一系列按照时间戳严格递增的数据点组成一个序列,并通过一个标识符进行定位。我们的标识符为带有标签维度的指标名称。标签维护划分了单个指标的测量空间。每个指标名称加上一个唯一的标签集就组成了与该指标有关的时间序列(并携带与之相关的值)。
下面是一组典型的序列标识符,为用于计算请求总数的指标的一部分:
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通过周期性地抓取一组时间序列的当前值来检索数据点,数据来源称为目标(target)。因此,写模式是完全垂直且高度并行的(对每个目标的数据提取都是各自独立的)。
这里提供一些测量规模:单个Prometheus示例可以从上千个目标中采集数据点,以此暴露成百上千个时间序列。
为了支持每秒采集百万级别的数据点,批量写入是一个不可忽视的性能需求。跨磁盘写入单个数据点会非常慢,因此我们希望顺序写入更大块的数据。
对于旋转磁盘来说不足为奇,它的磁头需要不停地在不同的section之间移动。而SSD支持快速随机写入,但无法修改单独的字节,只能以4KiB或更大的页为单位执行写操作。这意味着,写入16字节的样本和写入一个完整的4KiB的页是相等的。这种行为属于写放大的一部分,它会导致SSD磨损--不仅会降低写入速度,还有可能在一段时间之后损坏硬盘,更多信息可以参见“Coding for SSDs” series。做个总结:对于旋转磁盘和SSD来说,顺序和批量写入都是理想的写模式。
请求模式和写模式有很大区别,我们可以查询单个序列的单个数据点,也可以查询10000个序列的单个数据点,或单个序列的一周的数据点,以及10000个序列的一周的数据点等等。因此,在外面的二维平面中,请求既不是完全垂直的也不是完全水平的,而是二者的矩形组合。
Recording rules 可以缓解已知查询中的问题,但不能作为临时查询的通用解决方案。
我们期望批量执行写入,但批量的内容只是多个序列的数据点的集合。在一个时间窗口内查询一个序列的数据点时,不仅需要指出这些数据点的位置,还需要从磁盘的各个地方读取数据。由于每次查询涉及的样本可能有百万级别,因此即使在高速SSD上也很慢。相比于请求16字节的样本,读操作还会从磁盘上检索更多的数据。SSD会加载一个完整的页,而HDD则至少会读取一整个section。不管哪种方式,都会浪费宝贵的读吞吐量。
因此理想上,当顺序存储相同序列的样本时,就可以通过尽可能少的读操作对其进行扫描。在此之上,我们只需要了解采集数据点的起始位置即可。
很显然,理想的写入模式和能够显著提升查询的布局之间关系密切。这也是我们的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的写放大。
- 当前积累的块会被保存到内存中。如果应用奔溃,数据就会丢失。为了避免这种情况,需要定期将内存状态保存(checkpoint)到磁盘,这个过程花费的时间窗口可能要大于数据丢失的时间窗口。执行恢复时也可能会花费好几分钟,导致较长的重启周期。
现有设计的关键概念是块,这也是我们会保留的内容。将最新的块保存在内存中通常也是合理的,且最近的数据被查询的概率也相对大。
下面我们将寻求一种方式来替代为每个时间序列保留一个文件的方案。
Series Churn
在Prometheus的上下文中,我们使用术语series churn来描述一组非激活的时间序列,即这些序列不再接收数据点,并使用了一组新的激活的序列。
例如,一个特定的微服务实例暴露的所有序列标识符都包含一个"instance"标签。当我们执行滚动更新该微服务时,会替换成新版本的实例,此时就发生了series churn。在更动态的环境中,可能会每小时发生一次。集群编排系统,如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存储中的大部分问题。
选择的存储格式会直接影响到性能和资源的使用。我们需要找到合适的算法以及磁盘布局来实现一个高性能存储层。
V3--宏观设计
注意:由于此处使用了block,为避免与chunk混淆,后续将直接使用block。
宏观布局如下:
$ 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中都有一个包含索引的文件,以及一个"chunk"目录,其中包含更多的以数字命名的文件。"chunk"目录中包含了各种序列的原始数据块。与V2一样,这种布局可以很轻易地读取一个时间窗口内的序列数据,并允许采用相同的(高效)压缩算法。由于这种方式运行地很好,因此我们将保留这种方式。显然,不会为每个序列保持一个文件,转而使用好几个文件来保存多个序列的数据。
"index"文件的存在应该不足为奇,我们假设它包含了很多黑魔法,允许我们查找标签、可能的值、整个时间序列以及持有的数据点的块。
但为什么使用多个包含索引和块文件的目录?为什么最后一个包含一个"wal"目录?理解了这两个问题,就解决了我们90%的难题。
多个小数据库
我们将水平维度(即时间空间)分割成了不重叠的block,每个block作为一个完全独立的包含该时间窗口内的所有时间序列的数据库,这样,每个块都有其各自的索引和块文件。
t0 t1 t2 t3 now ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ │ │ │ │ │ ┌────────────┐ │ │ │ │ │ │ │ mutable │ <─── write ──── ┤ Prometheus │ │ │ │ │ │ │ │ │ └────────────┘ └───────────┘ └───────────┘ └───────────┘ └───────────┘ ^ └──────────────┴───────┬──────┴──────────────┘ │ │ query │ │ merge ─────────────────────────────────────────────────┘
每个block中的数据都是不可变的,当然,必须能够在采集到新数据时,在最近的block中添加新的序列和样本。对于这类block,需要将所有新数据写入内存数据库,并能够提供与已经持久化的block相同的查找功能。可以有效地更新内存数据结构。为了防止数据丢失,所有进入的数据都会被写入一个临时的预写式日志中,即"wal"目录,通过wal可以在重启时重新填充内存数据库。
所有这些文件都有自己的序列化格式,以及人们期望的内容:大量标志、偏移量、变量和 CRC32 校验和等。
这种布局允许我们将查询散布到与查询时间范围有关的所有块。来自每个块的局部结果最终会合并成整体结果。
这种水平分割增加了几大功能:
- 当请求一个时间范围时,可以很容易地忽略不在该范围内的所有数据块(block)。它通过减少检查的数据集来解决series churn问题。
- 当完成一个block后,可以通过顺序写入一些较大的文件来保存内存数据库中的数据。以此避免写放大,并能够在SSD和HDD上很好地运作。
- 我们保留了V2中最新块的属性,这部分数据有可能被经常访问,将其保存在内存中。
- 此外,不再限制1KiB的块大小来将数据对齐到磁盘。我们可以为单个数据点选择最有意义的块大小,并选择压缩格式。
- 删除旧数据变得非常快速便捷。只需要删除单独的目录即可。在老的存储中,我们需要进行分析并重新写入数亿个文件,这个过程可能需要数小时。
每个block同时包含一个meta.json
文件。包含用户可读的关于block的信息,可以方便了解存储的状态和包含的数据。
mmap
从百万个小文件变为相对较大的文件可以在较小的开销下打开所有的文件。通过mmap(2)系统调用,可以给文件内容创建一个透明的虚拟内存域。
这意味着我们认为数据库中的所有内容都位于内存中,而无需占用任何物理RAM。只有在访问数据库文件的特定字节段时,操作系统才会被动地从磁盘加载页。这种方式使得操作系统负责与我们的持久化数据有关的所有内存管理。由于操作系统可以看到整个机器和进程的完整视图,因此通常可以由操作系统来执行内存管理。查询的数据可能被缓存到内存中,在内存有压力时可以通过驱逐页来释放内存,如果机器存在未使用的内存,则Prometheus可以缓存整个数据库,并在其他应用需要时立即返回相关的数据。因此,相比适应RAM,请求更多的持久化数据更容易OOM我们的进程。这样,内存缓存大小完全是自适应的,且只有在真正需要时才会加载数据。
在我看来,上述方式也是如今很多数据库所采用的方式,也是在磁盘格式允许(除非有人有信心在进程层面能够打败OS)下的一种理想方式。从我们的角度看,只需很少的工作就能获得很多功能。
压缩
存储需要周期性地"切出"一个新的block,然后写入前一个block,这就是如何完成将block持久化到磁盘的。只有在block成功持久化之后,才能删除预写式日志文件(用于恢复内存block)。
我们需要将每个block的大小维持在一个合理的范围(通常设置为2小时),以此避免在内存中积累过多的数据。当请求多个blocks时,需要将多个结果合并成一个完整的结果。这个合并过程显然是有代价的,例如一个一周长度的查询不应该合并 80 多个局部结果。
为了达成上述两个目的,我们引入了压缩
。压缩描述了将一个使用一个或多个block的数据写入到一个可能更大的block的过程。压缩还可以在处理过程中修改现有的数据,如丢掉已删除的数据,或重新构建样本块(用于提升查询性能)。
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) └──────────────────────────┘ └──────────────────────────┘ └───────────┘
本例中由四个联系的blocks [1, 2, 3, 4]
。block 1,2,3可以一起压缩,新的布局为[1, 4]
,此外,还可以将它们压缩为 [1, 3]
。所有时间序列数据仍然存在,但整体的blocks数变少了。这种方式大大降低了查询时的合并开销,即减少了合并的局部查询结果的数目。
保留(Retention)
在V2存储中可以看到删除旧数据是一个比较慢的过程,并对CPU、内存和磁盘造成一定的负担。那么在基于block的设计中如何丢弃老的数据?非常简单,如果一个block中的数据不在保留窗口内,只需要删除该block的目录即可。在下例中,可以安全删除block 1,而block 2则不能删除,需要等到它完全不在保留边界内才能删掉。
| ┌────────────┐ ┌────┼─────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ 1 │ │ 2 | │ │ 3 │ │ 4 │ │ 5 │ . . . └────────────┘ └────┼─────┘ └───────────┘ └───────────┘ └───────────┘ | | retention boundary
获取的数据越旧,block有可能会变得越大(压缩过程会不断压缩先前已压缩的块)。因此需要设置一个上限来防止block增长到整个数据库,进而违背我们设计的初衷。
同时也限制了部分在、部分不在保留窗口的block造成的磁盘开销,如上例中的block 2。当block的最大大小设置为总的保留窗口的10% 时,保留block 2造成的总的开销也限制在10%以内。
索引
调查存储改进的最初动机是改善series churn带来的问题。基于block的布局减少了一个处理请求考虑到的总序列数。假设我们查找索引的复杂度为O(n2)*,我们将n降低到了一个合理的数目,但复杂度则增加到了*O(n2)...
实际中,大部分情况下都可以很快地响应请求。然而当请求跨整个范围时会很慢,即使只需要查询少量序列。我最初的想法(可以追溯到所有这些工作开始之前)中,有一个解决该问题的方案:一个更强大的倒排索引。
倒排索引提供一种基于内容子集快速查找数据项的方法。简单地说,我可以查找所有包含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使用的倒排索引类型,也是在数百万个序列中提供高性能查询的最低要求。敏锐的观察者可能会注意到,在最坏条件下,如果所有序列都包含一个标签,则复杂度又变成了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存储的基本方式,幸运的是,只需要进行很小的修改就可以获得显著的提升。如果倒排索引是有序的会发生什么?
假设我们这是初始的查询:
__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)),提升相当大。
这里我使用的是范围搜索索引(通常用于全文搜索引擎)的一个简化版。每个序列描述符都被认为是一个短"document",每个标签(名称+固定值)被认为是"document"内的一个"word"。通常在使用搜索引擎进行索引时,可以忽略很多额外的数据,如"word"的位置以及频率数据。
关于改进实际运行时方法的研究似乎无穷无尽,需要经常对输入数据做一些假设。对倒排索引进行压缩的很多技术都有其优缺点。由于我们的"document"很小,且"word"在很多序列中高度重复,因此压缩并不那么重要。例如,在实际中,在包含12个标签的约4.4百万个序列的数据集中,具有唯一标签的序列不超过5000个(即大部分时重复的) 。 在我们的初始版本中没有使用压缩,仅使用一些简单的技巧来跳过大范围的不感兴趣的ID。
虽然保持ID有序听起来很简单,但实际并没有那么容易。例如,V2存储使用哈希作为新序列的ID,此时就无法有效构建倒排索引。
另一项艰巨的任务是在数据删除或更新时修改磁盘上的索引。通常,最简单的办法是重新计算并重写索引,但同时需要保证数据库时可查询且一致的。V3存储通过为每个block分配一个独立的不可变(只能通过在压缩时重写进行修改)索引来实现删除和更新。只有完全在内存中的可变block的索引才需要被更新。
性能测试
作者分别使用 Prometheus 1.5.2 servers (V2 storage) 和 Prometheus 2.0 servers (V3 storage) 进行了性能验证。细节请参考原文。
总结
- 使用批量写入来降低对磁盘IO的压力。批量写入磁盘的数据块大小为4K,防止因为在SSD上发生写放大而导致降低效率并损坏磁盘。
- 顺序写入数据,这样在读数据时就无需扫描磁盘的多个地方,增加读吞吐量。
- 以上是解决一般读写性能问题的常用方式,与时序数据库有关的改进点如下:
- 存储时为每个时间序列分配一个ID,使用倒排索引来通过标签检索到时间序列对应的ID,以此来加快查找速度
- 一个block的目录结构大体如下,chunks中记录了原始指标数据,index中记录了该block的倒排索引以及相关偏移量,可以使用index文件快速查找需要的内容。
data ├── 01EM6Q6A1YPX4G9TEB20J22B2R | ├── chunks | | ├── 000001 | | └── 000002 | ├── index | ├── meta.json | └── tombstones ├── chunks_head | ├── 000001 | └── 000002 └── wal ├── checkpoint.000003 | ├── 000000 | └── 000001 ├── 000004 └── 000005
- 使用mmap将文件内容映射到内存,让操作系统去做内存管理。由于OS有整个系统的内存视图,在内存不足是可以执行如内存页替换等动作来防止OOM。
- 使用时间窗口来创建数据块文件(block文件),而非为每个时间序列创建一个文件。
- 内存中的block用于接收新的采集的数据,在block填充满之后才会被写入文件,为了防止内存数据丢失,需要用到wal,以便在系统重启之后恢复数据。只有当内存中的block持久化完成之后才能删除wal。
- 为了降低查询需要合并的局部结果的数目,采用了压缩,可以将多个block压缩到一个block中。但需要对block的大小做一个限制,防止因为压缩而生成一个非常大的block,导致因为部分在、部分不在内存保留区间的block占用过多内存。
- 为了降低组合多个标签的查询的复杂度,可以对标标签的倒排索引ID做排序,这样可以降低组合的复杂度
更多Prometheus TSDB有关的内容请参见Prometheus TSDB。