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
- 读数据时,需要同时读取 WAL 与 DB 中的数据,并将两者合并生成最新的记录
由于追加 WAL 是顺序的,可以将随机的磁盘IO转换为顺序的磁盘IO,减少磁盘巡道时间,从而能够更有效地提升了磁盘的吞吐量。
数据库重启过程中会检查 WAL 日志,任何尚未附加到 DB 数据页的记录都将从日志记录中重放,每次提交事务时不再需要(为了保证数据安全)把数据页冲刷到磁盘,有效地提升了事务吞吐量。
WAL 只允许在尾部追加数据 Append-Only
,不允许修改日志记录。这种不变性 Immutability
有利于并发控制:删改数据只能通过追加新的日志实现,因此修改前无需对数据加锁,直接在日志末尾追加新的记录即可。
然而 WAL 的体积也不可能无限增长,系统需要周期性周期性的清理无用的日志记录,减少文件碎片,释放磁盘空间。
索引
索引是一种附加的数据结构,以牺牲空间和写入速度为代价,换取更快的检索速度。最常用的索引结构莫过于 Hash 与 Tree:
Hash
- 维护方便,单个 key 的随机查找速度极快,一般都是常量级的
O(1)
- 无法支持范围查找,随着记录的增长,哈希冲突率上升,导致查找速度下降
- 整个索引需要保证能够放入内存,否则就无法发挥其速度优势
Tree
- 支持范围查找,查找速度稳定,二叉平衡树可以保证
O(log2n)
- 维护成本较高,插入数据时需要重新平衡树,每个节点的需要额外的指针存储空间
- 大数据量的情况下查找性能比较稳定,具有多种变种算法可以适配各种应用场景
由于数据库需要管理海量的数据,因此 Tree 便成为外存索引的不二之选。
下面介绍其中最具代表性两类索引结构:B-Tree 与 LSM-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-Tree | LSM-Tree |
---|---|---|
最优 |
|
|
最坏 |
|
|
LST-Tree 平均只需要写一次磁盘,即写 WAL, 在少数情况下,一次写入也有可能造成多次写磁盘操作。
读放大
Read | B-Tree | LSM-Tree |
---|---|---|
最优 |
|
|
最坏 |
|
|
LST-Tree 由于引入了 SSTable 格式,最坏情况下读取次数不可控。
对比
LSM-Tree 有着更小的写放大效应,B-Tree 有着更小的读放大效应。
LSM-Tree 能够承载更高的写入吞吐量,B-Tree 在随机读的情况下能够提供更稳定的性能保障。
LSM-Tree 本身就是一种对读写的 trade-off,用更大的读放大效应换取更小的写放大效应。
更进一步的,LSM-Tree 可以通过调整合并策略Merge Policy
在读写放大之间进行权衡。
总结
优点 | 缺点 | |
---|---|---|
B-Tree |
|
|
LSM-Tree |
|
|