详解磁盘 IO: 第 3 部分LSM 树

34 阅读11分钟

了解 I/O 的工作原理以及理解算法和存储系统的用例和权衡可以让开发人员生活变得更好:他们将能够提前做出更好的选择(基于他们正在评估的数据库的底层内容),当数据库行为异常时排除性能问题(通过将他们的工作负载与他们选择的数据库的工作负载进行比较)并调整他们的堆栈(通过平衡负载、切换到不同的介质、文件系统、操作系统或选择不同的索引类型)。

该系列由 5 件组成:

在第一部分和第二部分中,我们讨论了有助于执行磁盘写入的底层操作系统机制。现在是时候开始了解更高层次的概念了。

今天,我们将探讨数据库中经常使用的一种存储类型。每种存储类型都有各自的优点和缺点,因此构建数据库系统总是需要权衡利弊,我们也会尝试解决其中的一些问题。

可变与不可变数据结构

我们将在下一章中讨论的数据结构之间的差异之一是可变性。在这里,我们将讨论磁盘上的可变性,因此构造语义和函数式编程相关概念与我们的讨论目的无关。

不可变数据结构的明显优势是可以最小化存储开销:我们不必为稍后插入的数据或更新记录比最初写入的记录需要更多空间的情况保留任何额外的空间。

保持数据结构不变有利于顺序写入:数据一次性写入磁盘,仅追加。可变数据结构将在一次性中预先分配,但后续写入将是随机的。某些结构需要节点拆分,这将重新定位已写入的部分。一段时间后,随机写入的文件可能需要碎片整理

有些数据库不进行就地更新,而是将过期的记录标记为“已删除”(以便最终将其作为垃圾回收),并将新记录附加到文件专门指定的更新区域。虽然这是一个很好的折衷方案,但有时写入会填满所有指定空间,因此必须创建溢出区域。所有这些都可能会减慢后续的读取和写入速度。

不可变文件的另一个优点是可以从磁盘读取数据,而无需操作之间进行任何段锁定,从而大大简化了并发访问。

相比之下,可变数据结构采用分层锁和闩锁来确保磁盘数据结构的完整性,允许多个读取器同时读取,但将树的部分独占所有权交给写入器。

可变和不可变数据结构都需要进行一些内部管理,以优化性能,但原因不同。由于分配的文件数量不断增长,不可变数据结构必须合并和重写文件,以确保在查询期间命中的文件数量最少,因为请求的记录可能分布在多个文件中。另一方面,可变文件可能必须部分或全部重写以减少碎片、合并溢出区域并回收更新或删除的记录占用的空间(因为它们的新内容已写入其他地方)。当然,内部管理过程所做的确切工作范围在很大程度上取决于具体实施。

在查看 LSM 树(日志结构合并树)和 B 树变体时,我们将讨论可变和不可变存储的示例。

LSM 树

让我们从LSM树开始,因为这个概念非常简单。开创性的LSM 论文提出了一种类似于 B 树的磁盘驻留树的实现,不同之处在于它针对顺序磁盘访问进行了优化,并且节点可以完全占用(我们将在讨论 B 树时更详细地讨论占用情况)。从这个角度来看,值得一提的是,尽管 LSM 树经常与 B 树形成对比,但这种比较并不完全准确,我在这里强调的是 LSM 树允许不可变、可合并的文件,而表的主索引的性质是一个实现问题(因此,即使是 B 树也可以在这里用作索引数据结构)。

说某个东西是作为 LSM 树实现的并不一定说明查找复杂性,甚至内部文件布局,只说明概念结构。不过,值得指出的是,数据库领域的许多现代 LSM 实现都有一个共同点:排序字符串表

排序字符串表

排序字符串表的优点在于其简单性:易于编写、搜索和读取。SSTable 是从键到值的持久有序不可变映射,其中键和值都是任意字节字符串。它们具有一些不错的特性,例如,可以通过查找主索引快速完成随机点查询(即按键查找值),只需逐个读取记录即可高效地完成顺序扫描(即在指定键范围内迭代所有键/​​值对)。

通常,SSTable 包含两个部分:索引和数据块。数据块由一个接一个连接的键/值对组成。索引块包含主键和偏移量,指向数据块中可以找到实际记录的偏移量。主索引可以使用针对快速搜索优化的格式来实现,例如 B 树。

image.png

SSTable 是一种持久的、有序的、不可变的数据结构。它通常由索引块和数据块组成,其中索引块可以用快速查找数据结构表示,保存数据块中值的偏移量;数据块保存连接的键/值对,从而实现快速的顺序范围扫描。

由于 SSTable 是不可变的,因此插入、更新或删除操作需要重写整个文件,因为它针对读取进行了优化,按顺序写入,并且没有允许任何就地修改的保留空白空间。这就是 LSM 树发挥作用的地方。

SSTable 细节

在 LSM 树中,所有写入都是针对可变的内存数据结构执行的(同样,通常使用允许对数时间查找的数据结构来实现,例如 B-Tree 或SkipList)。每当树的大小达到某个阈值时(或者在某个预定义的时间段过去之后,以先到者为准),我们将数据写入磁盘,创建一个新的 SSTable。此过程有时称为“刷新”。检索数据可能需要搜索磁盘上的所有 SSTable,检查内存表并将它们的内容合并在一起,然后返回结果。

LSM 树的结构:内存驻留表,用于写入。只要内存表足够大,其排序内容就会写入磁盘,成为 SSTable。读取操作会命中所有 SSTable 和内存驻留表,需要合并过程来协调数据。

读取过程中的合并步骤是必需的,因为数据可以分成几个部分(例如,插入后跟着删除操作,其中删除会遮蔽最初插入的记录;或者插入后跟着更新操作,其中新字段被添加到记录中)。

SSTable 中的每个数据项都有一个与之关联的时间戳。对于插入,它指定写入时间;对于更新,它指定更新时间;对于删除,它指定移除时间。

压缩

由于磁盘驻留表的数量不断增长、键的数据位于多个文件中、同一记录的多个版本、被删除所掩盖的冗余记录)并且读取会随着时间的推移变得越来越昂贵。为了避免这种情况,LSM 树需要一个从磁盘读取完整的 SSTable 并执行合并的过程,类似于我们在读取期间必须执行的过程。这个过程有时称为压缩。由于 SSTable 布局,此操作非常高效:记录以顺序方式从多个源读取并可以立即附加到输出文件,因为所有输入都经过排序和合并,所以生成的文件将具有相同的属性。构建索引文件可能是一个更昂贵的操作(就复杂性而言)。

压缩会将多个 SSTable 合并为一个。一些数据库系统会将大小相同的表逻辑地分组到同一“级别”,并且只要某个级别上有足够的表,就会启动合并过程。

合并

在合并方面,有两件重要的事情需要讨论:复杂性保证阴影逻辑

就复杂性而言,合并 SSTables 与合并已排序集合相同。它具有O(M)内存开销,其中M是要合并的 SSTables 数量。维护每个 SSTable 上迭代器头的已排序集合 ( log(n) )。在每个步骤中,从已排序集合中取出最小项,并从相应的迭代器中重新填充。

读取操作和压缩过程中使用相同的合并过程。在压缩过程中,顺序源 SSTable 读取和顺序目标 SSTable 写入有助于保持良好的性能保证。

Shadowing对于确保更新和删除操作有效必不可少:LSM Tree 中的删除操作会插入占位符,指定哪个键被标记为删除。同样,更新操作只是具有更大时间戳的记录。在读取过程中,被删除操作影子的记录不会返回给客户端。更新操作也会发生同样的事情:在具有相同键的两个记录中,将返回具有较晚时间戳的记录。

合并步骤协调了存储在不同表中的同一键的数据:此处 Alex 的记录使用时间戳 100 写入,并使用新手机和时间戳 200 进行更新;John 的记录已被删除。其他两个条目按原样处理,因为它们未被隐藏。

总结

使用不可变数据结构通常可以简化程序员的工作。使用不可变的磁盘结构时,您需要偶尔合并表,以获得更好的空间管理(通过避免页面溢出并将空间占用率提高到 100%)、并发性(因为读取器和写入器永远不会争夺同一个文件,因此不需要相互排斥)和可能更简单的实现。LSM Tree 数据库通常是写入优化的,因为所有写入都是针对预写日志(用于持久性和故障转移)和内存驻留表执行的。由于合并过程和需要检查磁盘上的多个文件,读取速度通常较慢。

由于需要维护,LSM-Trees 可能会导致更严重的延迟,因为 CPU 和 IO 带宽都用于重新读取和合并表,而不仅仅是提供读取和写入服务。在写入繁重的工作负载下,还可能仅通过写入和刷新就使 IO 饱和,从而拖延压缩过程。滞后的压缩会导致读取速度变慢,增加 CPU 和 IO 压力,使情况变得更糟。这是需要注意的事情。

LSM-Trees 会导致一些写入放大:数据必须写入预写日志,然后刷新到磁盘,最终将在压缩过程中重新读取和写入。话虽如此,可变的 B-Tree 结构也会受到写入放大的影响,因此我更愿意在我们讨论完 B-Trees 和一个有助于理解我们只是在用读取性能换取写入性能和内存开销的猜想之后再进行成本分析。

许多数据库都使用 SSTables:RocksDBCassandra,仅举几个例子,但还有很多其他例子。从 3.4 版开始,Cassandra 引入了SSTable 附加二级索引,这是建立在 SSTables 和 LSM 树之上的概念,它通过将索引构建与驻留在内存中的表刷新和 SSTable 合并过程结合起来,简化了二级索引的维护。

在下一篇文章中,我们将讨论几种 B 树变体,然后转到访问模式和之前承诺的部分。