LSM-Tree 与 B-Tree

外部存储

数据库管理系统DBMS是现代应用中不可或缺的一部分,其中一个重要原因是其隐藏了外存管理的细节,并为应用层提供了高效、易用的数据检索Retrieval与持久化Persistence功能。

外存具有容量大、成本低、断电非易失等优点,但同时也存在寻址慢、访问粒度粗的问题:

  • 内存寻址速度快(ns 级),寻址单位小(byte)
  • 外存寻址速度慢(ms 级),寻址单位大(≥4kb)

数据库的读写性能取决于外存访问效率,而优化外存访问的手段有:

  • 减少外存访问次数:借助写缓冲Buffer、读缓存Cache的方式,将热点数据临时存储在内存,避免频繁的外存访问
  • 避免随机寻址:使用预写日志WAL对写操作进行优化,将随机写操作简化为顺序的追加操作
  • 单次读取尽可能多的数据:使用高密度的外存索引Index来组织数据,通过有序性提高检索效率

预写日志

预写日志系统 WALWrite-Ahead Logging是一种用于提高数据库写性能的常见手段,被广泛应用于持久化数据库中。

数据库中的状态可以分为两部分:

  • WAL 日志:所有对数据库的变更都先写入这个日志,并在事务提交时进行持久化,防止已提交数据丢失,已提交的日志数据会被定期清理
  • DB 文件:包含所有已经交的数据、索引信息,数据长期存在不会消失

WAL 的核心思想是 日志先行

  • 写数据时,变更操作首先追加到 WAL 日志末尾,WAL 会将数据顺序刷到磁盘(提交成功)。异步线程会消费 WAL 中的变更消息(类似于队列),将应用变更到 DB 文件中并重置 WAL
  • 读数据时,需要同时读取 WALDB 中的数据,并将两者合并生成最新的记录

由于追加 WAL 是顺序的,可以将随机的磁盘IO转换为顺序的磁盘IO,减少磁盘巡道时间,从而能够更有效地提升了磁盘的吞吐量。

数据库重启过程中会检查 WAL 日志,任何尚未附加到 DB 数据页的记录都将从日志记录中重放,每次提交事务时不再需要(为了保证数据安全)把数据页冲刷到磁盘,有效地提升了事务吞吐量。

WAL 只允许在尾部追加数据 Append-Only ,不允许修改日志记录。这种不变性 Immutability 有利于并发控制:删改数据只能通过追加新的日志实现,因此修改前无需对数据加锁,直接在日志末尾追加新的记录即可。

然而 WAL 的体积也不可能无限增长,系统需要周期性周期性的清理无用的日志记录,减少文件碎片,释放磁盘空间。

索引

索引是一种附加的数据结构,以牺牲空间和写入速度为代价,换取更快的检索速度。最常用的索引结构莫过于 HashTree

Hash

  • 维护方便,单个 key 的随机查找速度极快,一般都是常量级的 O(1)
  • 无法支持范围查找,随着记录的增长,哈希冲突率上升,导致查找速度下降
  • 整个索引需要保证能够放入内存,否则就无法发挥其速度优势

Tree

  • 支持范围查找,查找速度稳定,二叉平衡树可以保证 O(log2n)
  • 维护成本较高,插入数据时需要重新平衡树,每个节点的需要额外的指针存储空间
  • 大数据量的情况下查找性能比较稳定,具有多种变种算法可以适配各种应用场景

由于数据库需要管理海量的数据,因此 Tree 便成为外存索引的不二之选。
下面介绍其中最具代表性两类索引结构:B-TreeLSM-Tree

B-Tree

最基础的 Tree 莫过于二叉查找树。其查找数据的方式,就是从根节点开始逐层向下遍历,直到找到目标节点。但是当数据量比较大的时候,会有以下问题:

  • 节点之间的地址不连续,每次在节点之间的跳转访问时,都要进行寻址,访问效率不高
  • 最坏情况下的访问效率取决于树的高度,当数据量大时,即便是平衡树,其高度也很可观

B-Tree 是一种用于处理海量数据的平衡多路查找树,其主要改进是对二叉树中间节点进行了合并,通过平衡算法和分叉因子 b,可以将树高度控制在logbn 的级别,对外存访问更为友好:

  • 每个节点包含尽可能多的数据,可以一次读出大量的数据,减少对外存的访问次数
  • 有效地降低了整棵树的高度,在大数据量的情况下能够保证较少的访问次数

这意味着:只需要很少的磁盘 IO,就能够对大量的数据进行高效的查找操作。

B-Tree 在作为外存索引使用时:

  • 根节点会常驻内存,其余节点存储在磁盘上,从而能够减少一次磁盘 IO
  • 按照页来组织数据,每个节点大小需对应一个完整的页(磁盘IO的基本单位是物理块block,操作系统使用逻辑页page管理应用程序的地址映射)
  • 为了保证数据的安全性,在对索引数据进行修改前要先写 WAL,因此每次写操作会造成至少两次磁盘写(忽略写缓存)
  • 写入的 Key 如果是随机或不连续的,可能会造成索引节点的多次分裂,影响写入的效率(写放大效应)
  • 在多次修改、删除操作之后,索引文件中会产生比较多的空洞,造成磁盘空间的浪费,并且会影响读性能(需要定期重建索引)

B+Tree 是对 B-Tree 的进一步改进:将 Key 与 Value 进行分离,非叶节点只保存 Key,所有 Value 下沉到叶子节点。
每个中间节点可以容纳更多的 Key,进一步提高了中间节点的密度,在相同的数据量下,树的高度要比 B-Tree 更低。

LSM-Tree

LSM-Tree 的全称是 Log-Structured Merge Tree,相较于一种索引结构,其本质更接近于一整套完整的索引维护机制:

LSM-Tree 大致可以分为两部分:

  • Memtable: 常驻内存的 KV 查找树(可用 SkipList 替代) + 无序的 WAL 文件
  • SSTable (Sorted String Table): 一组存储在磁盘的不可变文件(稀疏索引部分可选),存储有序的键值对

写入流程

1. 同步写 Memtable

先将数据写入 WAL 文件,然后修改内存中的 AVL,因此最优情况下,每次写操作只有一次磁盘 I/O。

删除操作并不会直接删除磁盘中的内容,而是将删除标记(tombstone)写入 Memtable。当 Memtable 增大到一定程度后,则会转换为 Immutable Memtable 并产生一个新的 Memtable 接受写操作。

2. 异步写 SSTable

后台会启动一个合并线程,当 Immutable Memtable 达到一定数量,合并线程会将其写入磁盘(Flush),生成 Level 0 的 SSTable 文件。

Level N 的 SSTable 文件数量到达阈值之后,会进行合并压缩(Compaction)操作,在 Level N+1 生成新的 SSTable 文件。

SSTable 分为多层,单个文件的大小通常是上一层的 10 倍,每层可以同时包含多个 sst 文件,每个文件由多个 block 组成,其大小约为 32K,是磁盘 IO 的基本单位。

Level i (i > 0) 层的 SSTable 满足:

  • 第 i 层所有文件均由 i - 1 层的 SSTable 合并排序而来,可以通过设定阈值(文件个数...)来控制合并的行为
  • 文件之间是有序的,且每个文件的 key 集合不会与其他文件有交集(Level 0 的 SSTable 除外)

读取流程

首先中 Memtable 中进行查找,如果找不到,则按 Level 0、Level 1、... 的方式逐层向下遍历.

一个 Key 可能同时存在于多层 SSTable 中,这种情况下以层数最小的记录为准
为了提高热点数据的读取效率,提供了 sstable block cache 的功能,用于缓存读取数据。

某些不存在的 Key 可能会导致较深的无用查找,通过使用 BloomFIlter 对 Key 进行过滤可以规避这一问题。

放大效应

  • 写放大效应:一次写操作,实际所需的磁盘 IO 次数不止一次
  • 读放大效应:一次读操作,实际所需的磁盘 IO 次数不止一次

对于读写负载较高的数据库,性能瓶颈很有可能是磁盘的读写频率。在这种情况下,读写放大会显著影响性能:

在磁盘带宽一定的情况下,放大效应越明显,每次对数据库的读写操作造成磁盘IO越多,每秒钟能处理的数据库操作次数越小

写放大

Write B-TreeLSM-Tree
最优
  1. 将变更附加到 WAL 末尾(写)
  2. 读出 Key 所在的 page 到内存(读)
  3. 在内存中对 page 进行修改
  4. 将修改后的 page 写回磁盘(异步写)
  1. 将变更附加到 WAL 末尾(写)
  2. 修改内存中的 Memtable
最坏
  1. 将变更附加到 WAL 末尾(写)
  2. 读出 Key 所在的及其相关的 page 到内存(异步读 x N)
  3. 在内存中对 page 进行合并、分裂、修改
  4. 将修改后的所有 page 写回磁盘 (异步写 x M)
  1. 将变更附加到 WAL 末尾(写)
  2. 修改内存中的 Memtable
  3. 将 Memtable 序列化到磁盘中(异步写 x 1)
  4. 对 SSTable 进行合并操作(异步写 x K)
B-tree 平均需要写两次磁盘,一次是 WAL,另一次是写树节点对应的 page,并且是以整页的方式进行存取的。

LST-Tree 平均只需要写一次磁盘,即写 WAL, 在少数情况下,一次写入也有可能造成多次写磁盘操作。

读放大

Read B-TreeLSM-Tree
最优
  1. 命中缓存,Key 对应的 page 在内存中
  2. 返回 page 中对应的记录
  1. 命中缓存,Key 对应的记录在 Memtable 或者在 SSTable 的 block cache 中
  2. 返回 Memtable 或 cache 中对应的记录
最坏
  1. 未命中缓存,根据 Key 加载出对应的 page(读)
  2. 返回 page 中对应的记录
  1. 未命中缓存,根据 Key 在 SSTable 中逐层向下查找对应的 block,并加载到 cache 中 (读xM)
  2. 返回 cache 中对应的记录
B-tree 最坏情况下需读取的次数等于树高,通常情况下为 2 - 3 次。

LST-Tree 由于引入了 SSTable 格式,最坏情况下读取次数不可控。

对比

LSM-Tree 有着更小的写放大效应,B-Tree 有着更小的读放大效应。

LSM-Tree 能够承载更高的写入吞吐量,B-Tree 在随机读的情况下能够提供更稳定的性能保障。

LSM-Tree 本身就是一种对读写的 trade-off,用更大的读放大效应换取更小的写放大效应。

更进一步的,LSM-Tree 可以通过调整合并策略Merge Policy在读写放大之间进行权衡。

总结

优点缺点
B-Tree
  • 普适性强,适合更广泛的应用场景,广泛应用于各种 RMDB 中
  • 便于实现强事务,每个 Key 会严格对应一个 Value,不会出现相同 Key 同时存在多个的 Value 的现象
  • 需要配合 WAL 使用,需要额外的磁盘写操作
  • 随着删改操作的增多,文件会包含越来越多的空间碎片,会降低磁盘利用率
LSM-Tree
  • 写操作优化,将随机写转换为顺序写,适合顺序写负载重的应用场景,特别是时序日志型应用
  • 文件碎片少,对 SSTable 进行合并时,同时也完成了压缩操作,生成的文件更为紧凑
  • 应用场景有限,不适合随机读写或非热点数据的读取
  • 需要使用独立的后台线程来维护 SSTable,对 SSTable 的维护操作会与用户操作争抢磁盘带宽
posted @ 2020-08-10 00:10  buttercup  阅读(1194)  评论(1编辑  收藏  举报