数据存储和检索:B-tree 和 LSM-tree

发布时间 2023-11-12 22:04:29作者: Changry

   本文主要介绍数据库的核心数据结构索引的实现方式:B+tree 和 LSM-tree。实际上,数据库是可以不存在索引结构的,遍历数据库总归可以实现数据库的查询,但是,如果数据量很大,这种低效的做法是不可接受的,那么自然而然,牺牲部分空间换取时间被提出和接受,即保留额外的元数据,实现数据快速查询。

  索引是基于原始数据派生而来的额外数据结构。很多数据库允许单独添加和删除索引,而不影响数据库的内容,它只会影响查询性能。维护额外的数据结构势必会引入开销,特别是在新数据写入时。对于写入,它很难超过简单地追加文件方式的性能,因为那已经是最简单的写操作了。由于每次写数据时,需要更新索引,因为任何类型的索引通常都会降低写数据的速度。因此,默认情况下,数据库通常不会对所有内容进行索引,它需要应用开发人员或者数据库管理员,基于对应用程序典型查询模式的了解,手动选择索引。

哈希索引


  哈希索引并非本文的重点,之所以这里先介绍哈希索引,是因为:一,哈希索引的确是某些数据库的索引实现方式,二,哈希索引比起其他索引实现方式,更加简单直观,是其他更复杂索引的基础构造模块。

  假设数据存储全部采用追加式文件组成,最简单的索引策略就是:保存内存中的 hash map,把每个键一一映射到数据文件中特定的字节偏移量,这样就可以找到每个值的位置。每当在文件中追加新的 key-value 对时,还要更新 hash map 来反映刚刚写入数据的偏移量(包括插入新的键和更新已有的键)。当查找某个值时,使用 hash map 来找到目标数据在文件中的偏移量,即存储位置,然后读取其内容。hash map 是 Bitcask(Riak中的默认存储引擎)所采用的核心做法。只要所有的 key 可以放入内存,Bitcask 可以提供高性能的读和写。value 值取决于是否命中文件系统缓存,不命中的话只需一次磁盘寻址便可将 value 加载到内存。像 Bitcask 这样的存储引擎非常适合每个键值频繁更新的场景。

  至此,存在两个问题需要解决:在海量数据场景下,所有的 key 都加载到内存是困难的,另外,数据存储以追加写的方式,如何管理磁盘空间?解决第一个问题需要层级缓存机制,第二个问题以分段存储和后台压缩合并的方式解决。在数据存储中,设定固定大小的文件,一旦写满,就将后续写入到新的段文件中。如何知道合并哪些段?合并策略是什么?段文件存储格式?并发和部分写问题?哈希冲突怎么解决?等等。这些问题都是在开发中实际要解决的问题,也是核心问题。

  正如在平时使用哈希表时遇到的问题一样,哈希索引的另外一个重要局限就是区间查询效率不高,因为经过散列函数以后,键值并非有序存储的,对于在数据库查询中常用的区间查询,它只能一个一个查找。

SSTables和LSM-tree


SSTable

  上面说到哈希索引的重要缺点:区间查询效率太低,如果每个数据存储段以键有序的方式来存储呢?这不是就能弥补这个缺点了吗?哈希索引在实现中还会遇到段文件过大或者内存有限情况下,多个段文件压缩合并时很难将其全部加载到内存,导致内存页频繁换进换出,效率很低,而如果每个段文件内 key-value 对是有序的,那么压缩合并就简单多了,即使段文件大于内存,类似归并排序中的方法,很容易从多个段文件中找到最小键。段文件中 key 有序带来的另外好处就是,可以不在内存中保存所有键的索引,通过稀疏存储的 key,可以快速锁定查找范围,然后扫描范围内的数据即可。这种段文件按键有序排列的k ey-value 对构成的字符串表,称为 SSTable。

  既然 SSTable 能带来很多好处,如何构建和维护 SSTable 呢?在内存中维护有序结构的常见做法就是利用树状数据结构,如红黑树,AVL树。使用这些结构,可以以任意顺序插入键并以排序后的顺序读取它们。

  存储引擎的基本工作流程如下:

  • 当写入时,将其添加到内存中的平衡树数据结构中(例如红黑树)。这个内存中的树有时被称为内存表。
  • 当内存表大于某个阈值(通常为几兆字节)时,将其作为 SSTable 文件写入磁盘。由于树已经维护了有序的 key-value 对,写磁盘就比较高效。新的 SSTable 文件成为数据库的最新部分。当 SSTable 写磁盘同时,新到的写入数据写入新的内存表。
  • 当有读请求时,首先尝试在当前内存表中查找,如果没有,然后根据新旧时间顺序依次遍历磁盘段文件,直到找到目标(或为空)。
  • 后台进程周期性地执行段合并与压缩过程,得到新的合并段,丢弃或者覆盖已有的段。

  上述流程可以顺利的工作,但仍存在一个问题:如果数据库崩溃,最近的写入(在内存表中尚未写入磁盘)将会丢失。为了避免该问题,可以在磁盘上保留单独的日志,每个写入都会立即追加到该日志。日志是否排序并不重要,它存在的唯一目的就是为了崩溃后恢复内存表。每当内存表写入 SSTable 后,对应的日志就可以丢弃。

LSM-tree

  上述描述的算法本质上正是 LevelDB 和 RocksDB 所使用的,主要用于嵌入到其他应用程序的 key-value 存储引擎库。在 Riak 中的 LevelDB 可以用于 Bitcask 的替代品。类似的存储引擎还被用于 Cassandra 和 HBase,这两个引擎都受到 Google Bigtable 论文的启发(它引入了 SSTable 和内存表这两个术语)。

  最初这个索引结构由 Patrick O'Neil 等人以日志结构的合并树(Log-Structued Merge-Tree,或 LSM-Tree)命名,它建立在更早期的日志结构文件系统之上。因此,基于合并和压缩排序文件原理的存储引擎通常都被称为 LSM-tree 存储引擎

性能优化

  上面描述的算法是可以工作的,但在实际使用中还有很多需要优化的地方。当在内存表中无法找到目标时,会从新到旧遍历数据段,这样会非常耗时。为了优化这种访问,存储引擎通常使用额外的布隆过滤器(布隆过滤器是内存高效的数据结构,用于近似计算集合的内容。如果数据库中不存在某个健,它能够很快告诉结果,从而节省了很多对于不存在的健的不必要的磁盘读取)。

  还有不同的策略会影响甚至决定 SSTable 压缩和合并时的具体顺序和时机。最常见的方式是大小分级分层压缩。LevelDB 和 RocksDB 使用分层压缩,HBase 使用大小分级,Cassandra 则同时支持这两种压缩。在大小分级的压缩中,较新的和较小的 SSTable 被连续合并到较旧和较大的 SSTable。在分层压缩中,键的范围分裂成多个更小的 SSTables,旧数据被移动到单独的“层级”,这样压缩可以逐步进行并节省磁盘。

  即使有许多细微的差异,但 LSM-Tree 的基本思想(保存在后台合并的一系列 SSTable)却足够简单有效。即使数据集远远大于内存,它仍然能够正常工作。由于数据按排序存储,因此可以有效地执行区间查询(从最小值到最大值扫描所有的键),并且由于磁盘是顺序写入的,所以 LSM-tree 可以支持非常高的写入吞储量。

B-tree


  B-tree 始见于 1970 年,不到十年便被冠以“普遍存在”,B-tree经受了长久的时间考验。时至今日,它仍然是几乎所有关系数据库中的标准索引实现,许多非关系数据库也经常使用。

  像 SSTable 一样,B-tree 保留按键值排序的 key-value 对,这样可以实现高效的 key-value 查找和区间查询。但相似仅此而已:B-tree 本质上具有非常不同的设计理念。

  B-tree 将数据库分解成固定大小的块或者页,传统上大小为 4KB(有时更大),页是内部读/写的最小单元。这种设计更接近底层硬件,因为磁盘也是以固定大小的块排列。每个页面都可以使用地址或者位置进行标识,这样可以让一个页面引用另一个页面,类似指针,不过它指向磁盘地址,而不是内存。可以使用这些页面引用构造一个树状页面,如下图所示。

  某一页被指定为 B-tree 的根;每当查找索引中的一个键时,总是从这里开始,该页面包含若干个键和对子叶的引用。每个孩子都负责一个连续范围内的键,相邻引用之间的键可以指示这些范围的边界。

  在图示的例子中,假定正在查找键 251,因此需要沿着 200-300间的页引用,到达类似的页,进一步将 200-300 范围分解成子范围。最终,到达一个包含单个键的页,该页包含每个内联键的值或者包含可以找到值的页的引用。

  B-tree 中一个页所包含的子页引用数量称为分支因子。在图示的例子中,分支因子为 6。在实际中,分支因素取决于存储页面引用和范围边界所需的空间总量,通常为几百个。

  B-tree 是一个多叉平衡树,为了保持树的平衡,在一个叶子节点满了之后,需要将叶子节点分裂。分裂子结点可能会影响多个父子节点。

  该算法确保树保持平衡:具有 n 个键的 B-tree 总是具有 O(log n) 的深度。大多数数据库可以适合 3-4 层的 B-tree,因此不需要遍历非常深的页面层次即可找到所需的页(分支因子为 500 的 4KB 页的四级树可以存储高达 256TB)。

使 B-tree 可靠

  B-tree 底层的基本写操作是使用新数据覆盖磁盘上的旧页。它假设覆盖不会改变页的磁盘存储位置,也就是说,当页被覆盖时,对该页的所有引用保持不变。这与日志结构索引形成鲜明对比,LSM-tree 仅追加更新文件(并最终删除过时文件),但不会修改文件。既然是覆盖写,就可能出现覆盖写失败的问题,如果失败,原数据又被破坏,就会出现严重的数据可靠性问题。

  为了使数据库能从崩溃中恢复,常见 B-tree 的实现需要支持磁盘上的额外数据结构:预写日志(write-ahead log,WAL),也称为重做日志(re-do log)。这是一个仅支持追加修改的文件,每个 B-tree 的修改必须先更新 WAL 然后再修改树本身的页。当数据库在崩溃后需要恢复时,该日志用于将 B-tree 恢复到最近一致的状态。

  原地更新页的另一个复杂因素是,如果多个线程要同时访问 B-tree,则需要注意并发控制,否则线程可能会看到树处于不一致的状态。通常使用锁存器(轻量级的锁)保护树的数据结构来完成。在这方面,日志结构化的方法显得更简单,因为它们在后台执行所有合并,而不会干扰前端的查询,并且会不时地用新段原子地替换旧段。

性能优化

  B-tree 经过几十年的发展,有很多优化措施,下面仅列举一些:

  • 一些数据库(如 LMDB)不使用覆盖页和维护 WAL 来进行崩溃恢复,而是使用写时复制(COW)方案。修改的页被写入不同的位置,树中父页的新版本被创建,并指向新的位置。这种做法对于并发控制也很有帮助。
  • 保存键的缩略信息,而不是完整的键,这样可以节省页空间。特别是在树中间的页中,只需要提供足够的信息来描述键的起止范围。这样可以将更多的键压入到页中,让树具有更高的分支因子,从而减少层数。
  • 一般来说,页可以放在磁盘上的任何位置;没有要求相邻的页需要放在磁盘的相邻位置。如果相邻,可能提高范围查找的效率。但是,数据量不断变化,树的规模也在变大,这种方法实现起来并不容易。

B-tree 和 LSM-tree


  根据经验,LSM-tree 通常对于写入更快,而 B-tree 被认为对于读取更快。读取通常在 LSM-tree 上较慢,因为它们必须在不同的压缩段检查目标数据。然而,性能表现的具体情况依赖于工作负载的细节,经验数据也并不一定适用所有情况。

写放大

  B-tree 索引必须至少写两次数据:一次写预写日志(重做日志),一次写树本身(可能发生页分裂或合并)。即使该页中只有几个字节的更改,也必须承受写整个页的开销。一些存储引擎设置覆盖相同的页两次,以避免在电源故障的情况下最终出现部分更新的页。

  LSM-tree 由于 SSTable 反复压缩和合并,日志结构索引也会重写数据多次,产生写放大。

  对于大量写密集的应用程序,性能瓶颈很可能在于数据库写入磁盘的速率。在这种情况下,写放大具有直接的性能成本。

  LSM-tree通常能够承受比 B-tree 更高的写入吞吐量,部分是因为它们有时具有较低的写放大(尽管这取决于存储引擎的配置和工作负载),部分是因为它们以顺序的方式写入紧凑的 SSTable 文件,而不必重写树中的多个页。 

碎片管理

  B-tree 中页分裂或者当一行的内容不能适合现有页时,页中的某些空间无法使用,出现碎片化。

  LSM-tree 不是面向页的,并且不断执行压缩和合并 SSTables,因此具有较低的存储开销,特别是使用分层压缩时。

时延

  B-tree 在查找数据时,因为树的高度固定,层数较低,时延响应则更具确定性。

  LSM-tree 在数据压缩过程中,可能占用较高的底层带宽,从而影响正在执行的读写操作,这会影响时延的百分位数。而且,目标数据可能并不在当前内存表,遍历数据段也会导致查询响应时间相当高。

事务语义

  B-tree 的优点是每个键都恰好唯一对应于索引中的某个位置,而日志结构的存储引擎可能在不同的段中具有相同键的多个副本。如果数据库希望提供强大的事务语义,这方面 B-tree 显得更具吸引力:在许多关系数据库中,事务隔离是通过键范围上的锁来实现的,并且在 B-tree 索引中,这些锁可以定义到树中。

结语


  B-tree 经过几十年的发展和演进,在数据库领域已经根深蒂固,LSM-tree 作为新贵,在实际使用中也有非常不错的表现,二者在设计上存在着本质差异,如何选择,除了了解二者的优缺点之外,实际工作负载下的测试是必要的。

参考


1. 数据密集型应用系统设计,Martin Kleppmann