在第一和第二部分中,我们讨论了操作系统的底层机制,这些机制有助于在磁盘上执行写操作。现在是时候开始转向更高层次的概念了。
今天我们将探讨一种数据库中经常使用的存储类型。每种类型都有其优点和缺点,因此构建数据库系统总是涉及权衡取舍,我们也会尝试解决其中的一些问题。
可变 vs 不可变数据结构
我们将在接下来的章节中讨论的数据结构之间的一个区别是可变性(或缺乏可变性)。这里,我们将讨论磁盘上的可变性,因此构造和函数式编程相关的概念在我们的讨论中不相关。
不可变数据结构的明显优点是可以最小化存储开销:我们不必为将来插入的数据或更新记录所需的额外空间保留任何额外空间。
保持数据结构不可变有利于顺序写入:数据在磁盘上一次性写入,仅附加操作。可变数据结构将在一次性预分配,但后续写入将是随机的。一些结构需要节点分裂,这将重新定位已经写入的部分。经过一段时间后,随机写入的文件可能需要进行碎片整理。
一些数据库,不进行就地更新,而是将过时的记录标记为“已删除”(以便最终进行垃圾回收),并将新记录附加到文件的专门更新区域。虽然这是一个不错的折衷方案,但有时写入会填满所有指定空间,并且需要创建溢出区域。所有这些可能会减慢后续的读取和写入。
不可变文件的另一个优点是数据可以在没有操作间段锁的情况下从磁盘读取,这显著简化了并发访问。相比之下,可变数据结构使用分层锁和闩锁,以确保磁盘数据结构的完整性,允许多个读取者同时访问,但给写入者提供对树部分的独占所有权。
可变和不可变数据结构都需要一些维护工作以优化性能,但原因不同。由于分配的文件数量不断增长,不可变数据结构必须合并和重写文件,以确保查询时命中的文件数量最少,因为请求的记录可能分散在多个文件中。另一方面,可变文件可能需要部分或完全重写,以减少碎片,合并溢出区域,并回收更新或删除记录占用的空间(因为它们的新内容已写入其他地方)。当然,维护过程的具体工作范围在很大程度上取决于具体实现。
我们将在讨论LSM树(日志结构合并树)和B树变体时讨论可变和不可变存储的示例。
LSM树
让我们从日志结构合并树开始,因为这个概念非常直观。开创性的LSM论文提出了一种类似于B树的磁盘常驻树的实现,不同之处在于它针对顺序磁盘访问进行了优化,并且节点可以完全占用(我们将在讨论B树时详细讨论占用情况)。在这一背景下,值得一提的是,尽管LSM树经常与B树进行对比,但这种比较并不完全准确,这里我的重点是LSM树允许不可变的、可合并的文件,表的主索引的性质是实现的一个问题(因此,即使是B树也可以用作索引数据结构)。
声明某些东西是以LSM树实现的并不一定说明查找复杂性,甚至不说明内部文件布局,只是关于概念结构。然而,值得指出的是,许多现代数据库领域的LSM实现有一些共同点:排序字符串表(SSTables)。
排序字符串表
排序字符串表的优点在于其简单性:它们易于写入、搜索和读取。SSTables是从键到值的持久化有序不可变映射,其中键和值都是任意的字节串。它们具有一些不错的特性,例如,可以通过查找主索引快速完成随机点查询(即通过键查找值),可以通过按顺序读取记录高效地完成顺序扫描(即在指定键范围内迭代所有键/值对)。
通常,SSTable有两部分:索引和数据块。数据块由一个接一个的键/值对组成。索引块包含主键和偏移量,指向数据块中实际记录的位置。主索引可以使用一种优化的格式实现,例如B树,以实现快速搜索。
由于SSTable是不可变的,插入、更新或删除操作将需要重写整个文件,因为它针对读取进行了优化,按顺序写入,并且没有预留的空白空间以允许任何就地修改。这就是LSM树发挥作用的地方。
结构
在LSM树中,所有的写操作都针对可变的内存数据结构进行(再一次,通常使用允许对数时间查找的数据结构来实现,例如B树或跳跃表)。每当树的大小达到某个阈值(或在经过一些预定时间段后,以先到者为准),我们将数据写入磁盘,创建一个新的SSTable。这个过程有时称为“刷新”。检索数据可能需要搜索磁盘上的所有SSTable,检查内存表并将其内容合并在一起后再返回结果。
读取过程中需要进行合并步骤,因为数据可能被分成几部分(例如,插入操作后接着删除操作,删除操作会覆盖最初插入的记录;或者插入后接着更新操作,记录中添加了新字段)。
每个SSTable中的数据项都有一个与之关联的时间戳。对于插入操作,它指定写入时间;对于更新操作,它指定更新时间;对于删除操作,它指定删除时间。
压缩
由于磁盘驻留表的数量不断增加,键的数据分布在多个文件中,同一记录的多个版本,被删除覆盖的冗余记录,使得读取操作会变得越来越昂贵。为了避免这种情况,LSM树需要一个过程来从磁盘读取完整的SSTable并进行合并,类似于我们在读取过程中必须做的操作。这个过程有时称为压缩。由于SSTable的布局,这个操作非常高效:记录以顺序方式从多个来源读取,并可以立即附加到输出文件中,因为所有输入都是排序和合并的,生成的文件也将具有相同的属性。构建索引文件可能是一个更复杂的操作(就复杂性而言)。
合并
在讨论合并时,有两件重要的事情需要讨论:复杂性保证和覆盖逻辑。
在复杂性方面,合并SSTable与合并排序集合相同。它有O(M)的内存开销,其中M是正在合并的SSTable的数量。维护每个SSTable的迭代器头的排序集合(log(n))。在每一步,从排序集合中取出最小项,并从相应的迭代器中重新填充。
相同的合并过程用于读取操作和压缩过程。在压缩过程中,顺序读取源SSTable和顺序写入目标SSTable有助于保持良好的性能保证。
覆盖是为了确保更新和删除操作能够正常工作:LSM树中的删除操作插入占位符,指定哪个键被标记为删除。类似地,更新只是一个具有更大时间戳的记录。在读取期间,被删除操作覆盖的记录不会返回给客户端。同样的情况也适用于更新:对于两个具有相同键的记录,返回具有较晚时间戳的记录。
总结
使用不可变数据结构通常可以简化程序员的工作。使用不可变的磁盘结构时,你需要偶尔合并表格,以换取更好的空间管理(通过避免溢出页面并将空间利用率提高到100%)、并发性(因为读取者和写入者从不竞争同一个文件,因此不需要互斥)以及潜在的更简单的实现。LSM树数据库通常针对写操作进行优化,因为所有写操作都针对预写日志(以确保持久性和故障转移)和内存驻留表进行。读取通常较慢,因为需要进行合并过程并检查磁盘上的多个文件。
由于维护,LSM树可能导致更差的延迟,因为CPU和IO带宽被用于重新读取和合并表格,而不是仅仅用于读取和写入。在写操作繁重的工作负载下,可能会由于写入和刷新操作而饱和IO,导致压缩过程停滞。压缩滞后会导致读取变慢,增加CPU和IO压力,使情况变得更糟。这是需要注意的地方。
LSM树会引起一些写放大:数据必须写入预写日志,然后刷新到磁盘,最终在压缩过程中重新读取和写入。也就是说,可变的B树结构也存在写放大问题,所以我更愿意在讨论B树和一个有助于理解我们只是将读取性能与写入性能和内存开销进行权衡的猜想之后,再进行成本分析。
许多数据库使用SSTable:例如RocksDB和Cassandra,但还有很多其他例子。Cassandra从3.4版本开始,采用了SSTable附加二级索引,这一概念建立在SSTable和LSM树之上,通过将索引构建与内存驻留表刷新和SSTable合并过程结合起来,简化了二级索引的维护。
在下一篇文章中,我们将讨论几种B树变体,然后转向访问模式以及之前承诺的部分。