日志结构流派存储引擎的演化

本文主要内容来自于《数据密集型应用系统设计》 第三章,内容对我很有启发,所以分享给大家,推荐看原作。

背景

存储引擎存在着两个主要流派:

  1. 日志结构流派,只允许追加式更新/删除文件,不会修改已写入的文件,BitcastSSTablesLSM-TreeLevelDBRocksDBCassandraHBaseLucene 等属于此类
  2. 原地更新流派,将磁盘视为可以覆盖的一组固定大小的页。B-tree 就是这一流派的典型代表,已用于所有主流关系型数据库,以及大量的非关系数据库

大部分人已经对原地更新流派中 B-tree 已经比较熟悉了,但对日志结构流派并不是很了解,本文带领大家了解下其演化过程及 LSM-tree 与 B-tree 的对比

演化过程

1. 一个最简单的数据库

基本原理

由两个 Bash 函数实现:

db_set(){
        echo "$1,$2" >> database
}

db_get(){
        grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}

插入/更新数据: 往 database 文本中追加一行

查询数据: 查找文本中最后一次出现的

例如:

➜  ~ db_set key1 val1
➜  ~ db_set key2 val2
➜  ~ db_set key1 val3
➜  ~ cat database
key1,val1
key2,val2
key1,val3
➜  ~ db_get key1
val3

局限性

db_set: 追加式写,性能高。但db_get: 从头扫描到尾,性能很差

如何解决:

增加一个数据结构:索引

索引:原始数据派生的额外数据结构,适当的索引可以加速,但是加索引会减慢写的速度

2. 哈希索引

基本原理

为了解决上述追加式文件读性能太差的问题,可以在内存中维护一个 的 哈希表,如下图所示。offset 代表此 key 在文件中偏移量,所以 get 不需要遍历整个文件了。

日志结构流派存储引擎的演化_第1张图片

这听上去可能过于简单,但它的确是一个可行的方法。事实上,Bitcask 就是这么做的(Riak 中默认的存储引擎)

这个简单的想法可行,但是要应用到实际中还需要解决其存在的一些问题或者说需要做一些优化

1. 追加式日志存储冗余太多

压缩思路:一个 key 存在多次写,可以只保留最新写入的 value

解决方案:将日志分解成一定大小的段,当文件达到特定大小时关闭当前段文件,写入一个新的段文件。然后在这些段上执行压缩。

可以在一个段内压缩,也可以把多个段合并在一起, 下图是一个把两个段合并在一起的例子。

日志结构流派存储引擎的演化_第2张图片

压缩合并:

  • 如果 key 存在于多个段中,保留最新的段中的
  • 压缩合并可以运行在后台线程
  • 在压缩合并的过程中,可以继续用旧的段读取和写入
  • 合并过程完成后,读和写请求切换到新的段,旧的段可以删除了

现在,每个段在内存中都有自己的哈希表,读取流程为:首先检查最新段的哈希表,如果 key 不存在,检查第二新的段,以此类推。如果 key 不存在,要遍历完所有段的哈希表

2. 文件格式
  • 上面例子使用的 CSV 作为日志格式,为了性能考虑,可以使用二进制格式的日志
3. 如何删除
  • 在数据文件中追加一个特殊的删除记录
4. 崩溃恢复
  1. 内存中哈希表会丢失

    1. 可以重头到尾重新扫描段文件,恢复哈希表,但是恢复速度太慢
    2. 将哈希表的快照存在磁盘中,Bitcast 就是这么做的,恢复时读取
  2. 存在部分写入的记录

    • 加校验值
5. 并发控制
  • 使用单个线程写

优势(其实以下是日志结构流派的优势)

  • 追加和合并都是顺序写,顺序写与随机写性能之间有 2 个数量级以上的差异
  • 段文件是追加和不可修改的,并发与崩溃恢复简单得多。不必担心重写值时发生崩溃,留下一个包含部分旧值与部分新值混杂在一起的文件
  • 合并旧段可以避免数据文件出现碎片化的问题

局限性(哈希索引结构)

  1. 哈希表必须全局放入内存,如果数据量比较多,内存很容易放不下。虽然理论上可以把哈希表放在磁盘维护,但是哈希表放在磁盘的表现并不好,大量随机 IO
  2. 区间查询效率不高,必须遍历所有段的哈希表中每个 key

3. SSTables

基本原理

为了解决上述哈希索引的问题,提出以下要求

  • 段文件中 按 key 排序

这种格式的段文件被称为排序字符表(SSTable),其相对哈希索引的日志段有以下优势

  1. 即使文件大于可用内存,合并段的操作仍然是简单而高效的。使用归并排序算法,如下图所示

日志结构流派存储引擎的演化_第3张图片

  1. 不需要在内存中保存所有 key 的索引了。因为其中段文件中 key 是排序的,索引可以非常稀疏,每几千个字节存一个索引。然后可以在几千个字节中使用遍历/二分查找出特定的 key
  2. 可以将两个索引之间的多个 分组,压缩存储,节省磁盘 IO

局限性

虽然 SSTable 相对哈希索引有这么多好处, 但其要求段文件中 按 key 排序,这个要求本身就违背了日志结构流派的初心-追加顺序写,因为用户的写请求不可能是按 key 从小到大顺序来的

4. LSM-Tree

LSM-Tree 作为目前日志结构流派的最终 boss 出场了,它的作用是构建和维护 SSTables

原理

写入以任意顺序出现,但可以在内存中维护一个有序的数据结构,如红黑树、AVL 树、跳跃表等。

因此现在的写入流程为:

  1. 有新写入时,将其添加到内存中的平衡树数据结构(例如红黑树)。这个内存树有时被称为内存表(memtable)
  2. 内存表大于某个阈值(通常为几 MB)时,将其作为 SSTable 文件写入硬盘。由于已经是有序 对了,写磁盘比较高效。新的 SSTable 文件将成为数据库中最新的段。当该 SSTable 被写入硬盘时,新的写入可以在一个新的内存表实例上继续进行。

读取流程为:

  1. 收到读取请求时,首先尝试在内存表中找到对应的 key
  2. 如果内存表没有就在最近的 SSTable 段中寻找
  3. 如果还没有就在下一个较旧的段中继续寻找,以此类推

后台线程周期性执行段的合并压缩过程,以合并段文件并将已覆盖或已删除的值丢弃掉

以上还存在一个问题,内存表的数据会在崩溃中丢掉,这也很好解决,使用 WAL 记录内存表中的写入。下图很形象的描述了 LSM-tree 的工作原理

日志结构流派存储引擎的演化_第4张图片

以上就是 LSM-tree 的基本思想了,其获得了巨大成功,LevelDB,RocksDB,Cassandra,HBase,Lucene 等都使用到了类似的方法。

性能优化

要满足实际应用的需求,总是由很多细节值得深入

问题 1:查找数据库中不存在的 key 时,要回溯到最旧的段(多次磁盘访问)

解决方法: 布隆过滤器

问题 2:合并与压缩 SSTable 的策略: 什么时候去合并压缩 SSTable? 应该合并压缩 那几个 SSTable?

解决方案 :分层压缩(LevelDB 与 RocksDB)与大小分级(HBase)

我们这里主要介绍下 LevelDB 的实现,其主要思想是把 SSTable 分层,如下图所示:

日志结构流派存储引擎的演化_第5张图片

内存表:

  • 默认 4MB 左右内存块
  • 存放有序 Key-Value
  • 内存结构为跳跃表
  • 数据先写入 Memtable,达到指定大小后,把它变成 ImmuableTable,之后异步 Compaction 落盘到 Level-0

SSTable 文件:

  • SSTable 文件是层次结构,每层按 key range 分区存放在多个 SSTable 中
  • Level-0 层 比较特殊,其上不同 SSTable 的 key range 会存在重叠,其它层 key range 不重叠。level-0 限定总大小 4 MB,单个 SSTable 段 1 MB
  • Level-i(i > 0)的大小呈指数增长,其单个 SSTable 大小限定 2MB,Level-i 层总大小限定大小 10^i MB,第 6 层能容纳 1 T 的数据量
  • Level-i 层中的每个 SSTable 最多与 Level-(i+1) 的 10 个 SSTable 存在交集
  • 数据新鲜度:Memtable > ImmutableTable > Level-0 > Level-1 > Level-2 > ..

合并时机:

level-i 的大小超过其限定大小 10^i MB 时,选择一个 level-i 的 SSTable 与其 level-(i+1) 的 key 存在交集的 SSTable 进行合并。

触发生成新的 SSTable 时机

  • SSTable 文件大小达到 2 MB
  • SSTable 与下一层 key 重叠的 SSTable 数量超过十个

存在的一些问题

  1. 写放大:指一次写入请求造成多次磁盘写,合并压缩引起
  2. 读放大:指实际读取的数据量大于用户需要的数据量,LSM-tree 从内存表开始,若找不到,会一层层查找后面 SSTable
  3. 空间放大: 不是原地更新,过期数据不会马上删除

合并压缩能够减少读放大与空间放大,但会带来写放大,三者的关系有点类似 CAP,需要取舍。

LSM-tree 与 B-tree 的对比

尽管 B-tree 实现通常比 LSM-tree 实现更成熟,但 LSM-tree 由于其性能特点目前很有吸引力。根据经验,通常 LSM-tree 的写入速度更快,而 B-tree 的读取速度更快。 LSM-tree 上的读取通常比较慢,因为它们必须检查几种不同的数据结构和不同压缩(Compaction)层级的SSTables。

LSM-Tree 优势

1. 设计简单高效

基本思路是合并压缩排序文件,写入性能高,在并发与崩溃恢复等问题上也简单得多

2. 较低的写放大

B-tree 写数据时,即使只有几个字节更改,也必须承受整个页的开销(有时还可能发生页分裂)

LSM-tree 在压缩和合并 SSTable 的过程中,也会重写数据多次

结论:LSM-tree 具有较低的写放大

写放大在写入吞吐量和 SSD 磁盘寿命中影响比较大

3. 顺序写

B-tree:随机写

LSM-tree: 顺序写

顺序写性能更高,在使用普通磁盘的机器中非常关键

4. 支持更好地压缩

B-tree 不支持压缩, 面向页的,存在一些碎片无法使用

LSM-tree 支持压缩,磁盘中文件比 B-tree 小,定期合并 SSTable 消除碎片化

LSM-tree 劣势

1. 后台压缩合并过程会影响实时读写操作

磁盘的并发资源有限,而压缩合并过程非常占用磁盘资源,会干扰实时读写请求

LSM-tree 会在更高的百分位查询响应(pct999?)相当高的情况,而 B-tree 的响应延迟更具确定性

2.可能后台压缩速度赶不上写入速度

硬盘的有限写入带宽需要在初始写入(记录日志和刷新内存表到硬盘)和在后台运行的压缩线程之间共享。写入空数据库时,可以使用全硬盘带宽进行初始写入,但数据库越大,压缩所需的硬盘带宽就越多。

如果写入吞吐量很高,并且压缩没有仔细配置好,有可能导致压缩跟不上写入速率。在这种情况下,硬盘上未合并段的数量不断增加,直到硬盘空间用完,读取速度也会减慢,因为它们需要检查更多的段文件。

通常情况下,即使压缩无法跟上,基于SSTable的存储引擎也不会限制传入写入的速率,所以你需要进行明确的监控来检测这种情况

3. 对事务的支持不如 B-tree

B-tree 中一个 key 存在一个确切的位置,而 LSM-tree 可能存在于不同段中

在许多关系数据库中,事务隔离是通过在键范围上使用锁来实现的,在 B-tree 索引中,这些锁可以直接附加到树上。在 LSM-tree 对范围上锁,只能把所有 SSTable 对应位置锁上。

总结

B-tree 在数据库架构中是根深蒂固的,为许多工作负载都提供了始终如一的良好性能,所以它们不可能很快就会消失。而在新的数据存储中,日志结构化索引变得越来越流行,日志结构流派的设计足够简单高效,值得我们学习。

你可能感兴趣的