InfluxDB-内存索引和时间结构合并树 (TSM)

InfluxDB 存储引擎和时间结构合并树 (TSM)

InfluxDB 存储引擎与 LSM 树非常相似。它有一个预写日志和一组只读数据文件,这些文件在概念上与 LSM 树中的 SSTable 类似。TSM 文件包含已排序的压缩系列数据。

InfluxDB 将为每个时间段创建一个分片。例如,如果您有一个无限期的保留策略,则将为每个 7 天的时间段创建分片。每个分片都映射到底层存储引擎数据库。每个数据库都有自己的WAL和 TSM 文件。

我们将深入研究存储引擎的每个部分。

存储引擎

存储引擎将多个组件绑定在一起,并提供用于存储和查询系列数据的外部接口。它由多个组件组成,每个组件都发挥特定的作用:

  • 内存索引 - 内存索引是跨分片的共享索引,可快速访问测量标签系列。该索引由引擎使用,但并不特定于存储引擎本身。
  • WAL - WAL 是一种写入优化的存储格式,允许写入持久,但不易查询。写入 WAL 的内容将附加到固定大小的段中。
  • 缓存 - 缓存是 WAL 中存储的数据在内存中的表示形式。它在运行时被查询并与存储在 TSM 文件中的数据合并。
  • TSM 文件 - TSM 文件以列式格式存储压缩系列数据。
  • FileStore - FileStore 负责协调对磁盘上所有 TSM 文件的访问。它确保在替换现有文件时自动安装 TSM 文件,并删除不再使用的 TSM 文件。
  • 压缩器 - 压缩器负责将优化程度较低的缓存和 TSM 数据转换为更适合读取的格式。它通过压缩系列、删除已删除的数据、优化索引以及将较小的文件合并为较大的文件来实现此目的。
  • 压缩规划器 - 压缩规划器确定哪些 TSM 文件已准备好进行压缩,并确保多个并发压缩不会互相干扰。
  • 压缩 - 压缩由各种编码器和解码器针对特定数据类型进行处理。有些编码器相当静态,总是以相同的方式对同一类型进行编码;其他编码器则根据数据的形状切换压缩策略。
  • 写入器/读取器 - 每种文件类型(WAL 段、TSM 文件、墓碑等)都有用于处理格式的写入器和读取器。

预写日志(WAL)

WAL 被组织成一堆文件,看起来像_000001.wal。文件编号单调递增,称为 WAL 段。当一个段的大小达到 10MB 时,它将被关闭并打开一个新的段。每个 WAL 段存储多个压缩的写入和删除块。

当写入时,新的点会被序列化,使用 Snappy 进行压缩,然后写入 WAL 文件。文件会被fsync读取,数据会添加到内存索引中,然后才会返回成功。这意味着需要将点分批处理才能实现高吞吐量性能。(对于许多用例来说,最佳批处理大小似乎是每批 5,000-10,000 个点。)

WAL 中的每个条目都遵循TLV 标准,其中单个字节表示条目的类型(写入或删除)、4 个字节uint32表示压缩块的长度,然后是压缩块。

缓存

Cache 是 WAL 中当前存储的所有数据点的内存副本。这些点按键(即测量值、标签集和唯一字段)进行组织。每个字段都按其自己的时间顺序范围保存。Cache 数据在内存中时不进行压缩。

对存储引擎的查询将合并来自缓存的数据和来自 TSM 文件的数据。查询在查询处理时对缓存中的数据副本执行。这样,查询运行时的写入不会影响结果。

发送到缓存的删除将清除给定的键或给定键的特定时间范围。

Cache 公开了一些快照行为控件。两个最重要的控件是内存限制。有一个下限,cache-snapshot-memory-size当超过该下限时,将触发 TSM 文件的快照并删除相应的 WAL 段。还有一个上限,cache-max-memory-size当超过该上限时,将导致 Cache 拒绝新的写入。这些配置有助于防止内存不足的情况,并对写入数据速度快于实例持久保存数据的客户端施加背压。每次写入时都会检查内存阈值。

其他快照控制是基于时间的。空闲阈值,cache-snapshot-write-cold-duration如果在指定的时间间隔内没有收到写入,则强制缓存将快照保存到 TSM 文件。

通过重新读取磁盘上的 WAL 文件,在重启时重新创建内存缓存。

TSM 文件

TSM 文件是内存映射的只读文件集合。这些文件的结构与 LevelDB 或其他 LSM Tree 变体中的 SSTable 非常相似。

TSM 文件由四个部分组成:头、块、索引和页脚。

+--------+------------------------------------+-------------+--------------+
| Header |               Blocks               |    Index    |    Footer    |
|5 bytes |              N bytes               |   N bytes   |   4 bytes    |
+--------+------------------------------------+-------------+--------------+

标头是一个用于识别文件类型和版本号的神奇数字。

+-------------------+
|      Header       |
+-------------------+
|  Magic  │ Version |
| 4 bytes │ 1 byte  |
+-------------------+

块是 CRC32 校验和与数据对的序列。块数据对文件来说是不透明的。CRC32 用于块级错误检测。块的长度存储在索引中。

+--------------------------------------------------------------------+
│                           Blocks                                   │
+---------------------+-----------------------+----------------------+
|       Block 1       |        Block 2        |       Block N        |
+---------------------+-----------------------+----------------------+
|   CRC    |  Data    |    CRC    |   Data    |   CRC    |   Data    |
| 4 bytes  | N bytes  |  4 bytes  | N bytes   | 4 bytes  |  N bytes  |
+---------------------+-----------------------+----------------------+

块后面是文件中块的索引。索引由按键然后按时间字典顺序排列的索引条目序列组成。键包括测量名称、标签集和一个字段。每个点的多个字段会在 TSM 文件中创建多个索引条目。每个索引条目都以键长度和键开头,后跟块类型(浮点型、整数型、布尔型、字符串)以及该键后面的索引块条目数。每个索引块条目由块的最小时间和最大时间、块所在文件的偏移量以及块的大小组成。TSM 文件中包含键的每个块都有一个索引块条目。

索引结构可以提供对所有块的有效访问,以及确定与访问给定键相关的成本的能力。给定一个键和时间戳,我们可以确定文件是否包含该时间戳的块。我们还可以确定该块所在的位置以及必须读取多少数据才能检索该块。知道块的大小后,我们可以高效地配置我们的 IO 语句。

+-----------------------------------------------------------------------------+
│                                   Index                                     │
+-----------------------------------------------------------------------------+
│ Key Len │   Key   │ Type │ Count │Min Time │Max Time │ Offset │  Size  │...│
│ 2 bytes │ N bytes │1 byte│2 bytes│ 8 bytes │ 8 bytes │8 bytes │4 bytes │   │
+-----------------------------------------------------------------------------+

最后部分是页脚,用于存储索引开始的偏移量。

+---------+
│ Footer  │
+---------+
│Index Ofs│
│ 8 bytes │
+---------+

压缩

每个块都经过压缩,以减少查询时的存储空间和磁盘 IO。一个块包含给定系列和字段的时间戳和值。每个块都有一个字节标头,后跟压缩的时间戳,然后是压缩的值。

+--------------------------------------------------+
| Type  |  Len  |   Timestamps    |      Values    |
|1 Byte | VByte |     N Bytes     |    N Bytes     │
+--------------------------------------------------+

时间戳和值使用依赖于数据类型及其形状的编码进行压缩和单独存储。单独存储它们允许对所有时间戳使用时间戳编码,同时允许对不同字段类型使用不同的编码。例如,某些点可能能够使用游程编码,而其他点则不能。

每种值类型还包含一个 1 字节的标头,指示剩余字节的压缩类型。高 4 位存储压缩类型,低 4 位供编码器在需要时使用。

时间戳

时间戳编码是自适应的,基于编码的时间戳结构。它结合使用增量编码、缩放和使用 simple8b 游程编码的压缩,并在需要时恢复为无压缩。

时间戳的分辨率是可变的,但可以细化到纳秒,需要最多 8 个字节来存储未压缩的数据。在编码过程中,首先对值进行增量编码。第一个值是起始时间戳,后续值是与前一个值的差值。这通常会将值转换为更容易压缩的更小的整数。许多时间戳也是单调递增的,并且落在时间的偶数边界上,例如每 10 秒。当时间戳具有这种结构时,它们会按最大公约数(也是 10 的倍数)缩放。这会将非常大的整数增量转换为更小的增量,从而实现更好的压缩。

使用这些调整后的值,如果所有增量都相同,则使用游程编码存储时间范围。如果无法进行游程编码且所有值都小于 (1 « 60) - 1(纳秒分辨率下约为 36.5 年),则使用simple8b 编码对时间戳进行编码。Simple8b 编码是一种 64 位字对齐整数编码,可将多个整数打包成一个 64 位字。如果任何值超过最大值,则增量将以未压缩的形式存储,每个块使用 8 个字节。未来的编码可能会使用修补方案(如修补参考框架 (PFOR))来更有效地处理异常值。

浮点数

浮点数使用Facebook Gorilla 论文中的实现进行编码。编码将连续的值异或在一起,当值接近时产生较小的结果。然后使用控制位存储增量以指示异或值中有多少个前导零和尾随零。我们的实现删除了论文中描述的时间戳编码,仅对浮点值进行编码。

整数

整数编码使用两种不同的策略,具体取决于未压缩数据中的值范围。编码值首先使用ZigZag 编码进行编码。这会在正整数范围内交错正整数和负整数。

例如,[-2,-1,0,1] 变为 [3,1,0,2]。有关详细信息,请参阅 Google 的Protocol Buffers 文档。

如果所有 ZigZag 编码值都小于 (1 « 60) - 1,则使用 simple8b 编码进行压缩。如果任何值大于最大值,则所有值都以未压缩的形式存储在块中。如果所有值都相同,则使用游程编码。这对于经常保持不变的值非常有效。

布尔值

布尔值使用简单的位打包策略进行编码,其中每个布尔值使用 1 位。编码的布尔值数量使用可变字节编码存储在块的开头。

字符串

字符串使用Snappy压缩进行编码。每个字符串都连续打包,然后压缩为一个较大的块。

压缩

压缩是将以写入优化格式存储的数据迁移到读取优化格式的重复过程。当分片处于写入热状态时,会执行多个压缩阶段:

  • 快照- 必须将缓存和 WAL 中的值转换为 TSM 文件,以释放 WAL 段使用的内存和磁盘空间。这些压缩基于缓存内存和时间阈值进行。
  • 级别压缩- 随着 TSM 文件的增长,级别压缩(级别 1-4)会发生。TSM 文件从快照压缩为级别 1 文件。多个级别 1 文件被压缩以生成级别 2 文件。该过程持续进行,直到文件达到级别 4(完全压缩)和 TSM 文件的最大大小。除非需要运行删除、索引优化压缩或完全压缩,否则不会进一步压缩它们。较低级别的压缩使用避免 CPU 密集型活动(如解压缩和组合块)的策略。较高级别(因此频率较低)的压缩将重新组合块以完全压缩它们并提高压缩率。
  • 索引优化- 当许多 4 级 TSM 文件累积起来时,内部索引会变得更大,访问成本也会更高。索引优化压缩会将系列和索引拆分到一组新的 TSM 文件中,将给定系列的所有点排序到一个 TSM 文件中。在索引优化之前,每个 TSM 文件都包含大多数或所有系列的点,因此每个文件都包含相同的系列索引。索引优化之后,每个 TSM 文件都包含来自最少系列的点,并且文件之间的系列重叠很少。因此,每个 TSM 文件都有一个较小的唯一系列索引,而不是完整系列列表的副本。此外,特定系列的所有点在 TSM 文件中都是连续的,而不是分散在多个 TSM 文件中。
  • 完全压缩- 当分片长时间处于写入冷状态或分片上发生删除时,将运行完全压缩(4 级压缩)。完全压缩会生成一组最佳的 TSM 文件,并包括来自级别和索引优化压缩的所有优化。一旦分片完全压缩,除非存储了新的写入或删除,否则不会在其上运行任何其他压缩。

写入

写入将附加到当前 WAL 段,并添加到缓存中。每个 WAL 段都有最大大小。当前文件填满后,写入将转移到新文件。缓存也有大小限制;当缓存太满时,将拍摄快照并启动 WAL 压缩。如果入站写入速率持续超过 WAL 压缩速率,缓存可能会变得太满,在这种情况下,新的写入将失败,直到快照过程赶上来。

当 WAL 段写满并关闭时,Compactor 会快照 Cache,并将数据写入新的 TSM 文件。当 TSM 文件成功写入并fsync保存后,它将被 FileStore 加载和引用。

更新

更新(为已存在的点写入较新的值)以正常写入方式发生。由于缓存值会覆盖现有值,因此较新的写入优先。如果写入会覆盖先前 TSM 文件中的点,则这些点会在查询运行时合并,并且较新的写入优先。

删除

删除通过将删除条目写入测量或系列的 WAL,然后更新缓存和文件存储来实现。缓存会逐出所有相关条目。文件存储会为每个包含相关数据的 TSM 文件写入墓碑文件。这些墓碑文件用于在启动时忽略块以及在压缩期间删除已删除的条目。

针对部分删除的系列的查询在查询时处理,直到压缩从 TSM 文件完全删除数据。

查询

当存储引擎执行查询时,它本质上是查找与特定系列键和字段相关联的给定时间。首先,我们对数据文件进行搜索,以查找包含与查询匹配的时间范围以及包含匹配系列的文件。

一旦我们选择了数据文件,接下来我们需要找到系列键索引条目在文件中的位置。我们对每个 TSM 索引运行二分搜索,以找到其索引块的位置。

在常见情况下,多个 TSM 文件中的块不会重叠,我们可以线性搜索索引条目以找到要读取的起始块。如果存在重叠的时间块,则对索引条目进行排序,以确保较新的写入优先,并且在查询执行期间可以按顺序处理块。

在迭代索引条目时,会从块部分按顺序读取块。解压缩块并寻找特定点。

新的 InfluxDB 存储引擎:从 LSM Tree 到 B+Tree 再返回以创建时间结构化合并树

编写新的存储格式应该是最后的手段。那么 InfluxData 最终是如何编写自己的引擎的呢?InfluxData 尝试了许多存储格式,发现每种格式都存在一些基本缺陷。InfluxDB 的性能要求很高,最终会压倒其他存储系统。InfluxDB 的 0.8 版允许使用多个存储引擎,包括 LevelDB、RocksDB、HyperLevelDB 和 LMDB。InfluxDB 的 0.9 版使用 BoltDB 作为底层存储引擎。本文介绍了 0.9.5 版中发布的时间结构化合并树存储引擎,它是 InfluxDB 0.11+ 版(包括整个 1.x 系列)中唯一支持的存储引擎。

时间序列数据用例的特性对许多现有存储引擎来说都具有挑战性。在 InfluxDB 开发过程中,InfluxData 尝试了一些比较受欢迎的选项。我们从 LevelDB 开始,这是一个基于 LSM 树的引擎,针对写入吞吐量进行了优化。之后,我们尝试了 BoltDB,这是一个基于内存映射 B+ 树的引擎,针对读取进行了优化。最后,我们最终构建了自己的存储引擎,它在很多方面与 LSM 树相似。

使用我们的新存储引擎,我们能够将 B+Tree 设置的磁盘空间使用量减少高达 45 倍,并且写入吞吐量和压缩率甚至高于 LevelDB 及其变体。这篇文章将介绍这一演变的细节,最后深入介绍我们的新存储引擎及其内部工作原理。

时间序列数据的属性

时间序列数据的工作负载与普通数据库工作负载有很大不同。有许多因素共同导致扩展和保持性能非常困难:

  • 数十亿个独立数据点
  • 高写入吞吐量
  • 高读取吞吐量
  • 大量删除(数据过期)
  • 主要是插入/附加工作负载,很少更新

第一个也是最明显的问题是规模问题。在 DevOps、IoT 或 APM 中,每天很容易收集数亿或数十亿个唯一数据点。

例如,假设我们有 200 台虚拟机或服务器在运行,每台服务器平均每 10 秒收集 100 次测量。假设一天有 86,400 秒,那么每台服务器一天的单次测量将产生 8,640 个点。这样每天就有总共 172,800,000 ( 200 * 100 * 8,640) 个单独的数据点。我们在传感器数据用例中发现了类似或更大的数字。

数据量意味着写入吞吐量可能非常高。我们经常收到要求设置每秒可处理数十万次写入的设置。一些较大的公司只会考虑每秒可处理数百万次写入的系统。

同时,时间序列数据可以成为高读取吞吐量用例。确实,如果您要跟踪 700,000 个唯一指标或时间序列,您无法指望将它们全部可视化。这导致许多人认为您实际上并没有读取进入数据库的大部分数据。但是,除了人们在屏幕上显示的仪表板外,还有用于监控或将大量时间序列数据与其他类型数据相结合的自动化系统。

在 InfluxDB 中,动态计算的聚合函数可能会将数万个不同的时间序列组合成一个视图。这些查询中的每一个都必须读取每个聚合数据点,因此对于 InfluxDB 来说,读取吞吐量通常比写入吞吐量高出许多倍。

鉴于时间序列主要是仅附加的工作负载,您可能会认为在 B+Tree 上可以获得出色的性能。键空间中的附加非常高效,您可以实现每秒超过 100,000 次。但是,我们在单个时间序列中发生了这些附加。因此,插入最终看起来更像是随机插入,而不是仅附加插入。

我们发现时间序列数据的最大问题之一是,在数据超过一定时间后,删除所有数据的情况非常普遍。这种情况的常见模式是,用户拥有的高精度数据会保留很短的时间,比如几天或几个月。然后,用户会降低采样率,并将这些数据聚合成精度较低的汇总数据,这些数据会保留更长时间。

最简单的实现方式是,一旦每条记录过了有效期,就直接删除。然而,这意味着一旦写入的第一批点到了有效期,系统处理的删除次数和写入次数一样多,而大多数存储引擎的设计并不是为了实现这一点。

让我们深入了解我们尝试的两种存储引擎的细节,以及这些属性如何对我们的性能产生重大影响。

LevelDB 和日志结构合并树

当 InfluxDB 项目开始时,我们选择 LevelDB 作为存储引擎,因为我们在 InfluxDB 的前身产品中曾将其用于时间序列数据存储。我们知道它具有出色的写入吞吐量特性,一切似乎“正常运转”。

LevelDB 是日志结构化合并树 (LSM 树) 的实现,是 Google 的一个开源项目。它公开了一个用于键值存储的 API,其中键空间是排序的。最后一部分对于时间序列数据很重要,因为它允许我们快速扫描时间范围,只要时间戳在键中即可。

LSM 树基于接受写入的日志和两个称为 Mem Tables 和 SSTables 的结构。这些表表示已排序的键空间。SSTables 是只读文件,它们不断被其他 SSTables 替换,这些 SSTables 将插入和更新合并到键空间中。

LevelDB 给我们带来的两个最大优势是高写入吞吐量和内置压缩。然而,随着我们越来越了解人们对时间序列数据的需求,我们遇到了一些难以克服的挑战。

我们遇到的第一个问题是 LevelDB 不支持热备份。如果要对数据库进行安全备份,必须先关闭数据库,然后再进行复制。LevelDB 变体 RocksDB 和 HyperLevelDB 解决了这个问题,但还有另一个更紧迫的问题,我们认为它们无法解决。

我们的用户需要一种自动管理数据保留的方法。这意味着我们需要大规模删除。在 LSM 树中,删除的成本与写入一样高,甚至更高。删除会写入一条新记录,称为墓碑。之后,查询将结果集与任何墓碑合并,以从查询返回中清除已删除的数据。之后,将运行压缩,删除 SSTable 文件中的墓碑记录和底层已删除记录。

为了避免删除,我们将数据拆分到所谓的“分片”中,即连续的时间段。分片通常保存一天或七天的数据。每个分片都映射到底层 LevelDB。这意味着我们只需关闭数据库并删除底层文件,就可以删除一整天的数据。

此时,RocksDB 的用户可能会想到一项名为 ColumnFamilies 的功能。将时间序列数据放入 Rocks 时,通常会将时间块拆分为列族,然后在时间用完时删除这些列族。这是相同的一般想法:创建一个单独的区域,您可以在其中删除大量数据时直接删除文件,而不是更新索引。删除列族是一种非常有效的操作。但是,列族是一个相当新的功能,我们还有另一个分片用例。

将数据组织到分片中意味着可以在集群内移动数据,而无需检查数十亿个键。在撰写本文时,无法将一个 RocksDB 中的列族移动到另一个 RocksDB。旧分片通常对于写入来说是冷的,因此移动它们会很便宜且容易。我们还有一个额外的好处,就是在键空间中有一个对于写入来说是冷的位置,这样以后进行一致性检查会更容易。

将数据组织成碎片一段时间后效果很好,直到大量数据进入 InfluxDB。LevelDB 将数据拆分成许多小文件。在一个进程中打开数十或数百个这样的数据库最终会造成一个大问题。拥有六个月或一年数据的用户会用完文件句柄。我们发现大多数用户都不会遇到这种情况,但任何将数据库推到极限的人都会遇到这个问题,而我们对此没有解决办法。打开的文件句柄实在是太多了。

BoltDB 和 mmap B+Trees

在与 LevelDB 及其变体斗争了一年之后,我们决定转向 BoltDB,这是一个纯 Golang 数据库,深受 LMDB 的启发,LMDB 是一个用 C 编写的 mmap B+Tree 数据库。它具有与 LevelDB 相同的 API 语义:键值存储,其中键空间是有序的。我们的许多用户都感到惊讶。我们自己发布的 LevelDB 变体与 LMDB(mmap B+Tree)的测试表明,RocksDB 表现最佳。

然而,除了纯粹的写入性能之外,还有其他考虑因素。此时,我们最重要的目标是获得可以在生产中运行和备份的稳定版本。BoltDB 还具有纯 Go 编写的优势,这极大地简化了我们的构建链,并使其易于为其他操作系统和平台构建。

对我们来说,最大的好处是 BoltDB 使用单个文件作为数据库。此时,我们最常见的错误报告来源是文件句柄用完的人。Bolt 同时解决了热备份问题和文件限制问题。

如果这意味着我们可以拥有一个更可靠、更稳定的系统,我们愿意牺牲写入吞吐量。我们的理由是,对于任何推动真正大写入负载的人来说,他们无论如何都会运行一个集群。

我们发布了基于 BoltDB 的 0.9.0 到 0.9.2 版本。从开发角度来看,这是令人欣喜的。API 简洁,在我们的 Go 项目中构建起来快速且简单,而且可靠。然而,运行一段时间后,我们发现写入吞吐量存在很大问题。数据库超过几 GB 后,写入将开始激增 IOPS。

一些用户可以通过将 InfluxDB 放在具有近乎无限 IOPS 的大型硬件上来解决这个问题。但是,大多数用户都在使用云中资源有限的虚拟机。我们必须想办法减少一次性将一堆点写入数十万个系列的影响。

在 0.9.3 和 0.9.4 版本中,我们的计划是在 Bolt 前面放置一个预写日志 (WAL)。这样我们就可以减少随机插入到键空间的次数。相反,我们会缓冲相邻的多个写入,然后一次刷新它们。然而,这只能延迟问题的出现。高 IOPS 仍然是一个问题,即使是在中等工作负载下运行,它也会很快出现。

然而,我们在 Bolt 之前构建第一个 WAL 实现的经验给了我们所需的信心,相信写入问题可以得到解决。WAL 本身的性能非常棒,索引根本跟不上。此时,我们开始再次思考如何创建类似于 LSM 树的东西来跟上我们的写入负载。

于是时间结构合并树诞生了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值