LSM 树(Log-Structured Merge-Tree) 原理

1. LSM 树介绍

参考资料

1.1 背景

传统关系型数据库通常在读性能上有较高的要求,通过以下方式组织数据能够在复杂的读场景下(比如按关键字或者范围)高效地进行查找

  1. 二分查找: 将数据按一定规则顺序保存,使用二分查找可完成特定 key 的快速查找
  2. 哈希:使用哈希将数据分割存储在特定位置
  3. B+树:使用 B+树 或者 ISAM 索引顺序存取等方法组织数据,可以减少外部文件的读取
  4. 外部文件:将数据保存为日志,并使用一个 hash 表或者查找树映射对应的文件

但这些方式都将总体的结构信息强加在了数据上,数据必须按照特定的方式存储,当需要保存数据到磁盘时就有一个明显的缺陷,逻辑上相距很近的数据在物理上却可能相隔很远,这就可能造成大量的磁盘随机写,严重影响写操作性能。也就是说,传统关系型数据库实现高性能读操作的代价是相对低效的写操作

以 B-Tree 的一个随机写操作为例,整个过程分两步进行,对于随机 key 的写操作平均需要两次 IO

  1. 从磁盘查找目标 key 应该存储的块节点并加载到内存
  2. 修改目标节点内容再将其写回磁盘

基于磁盘随机操作慢,顺序读写快的基本情况,如果要提高写操作性能,最好避免随机写,设计成顺序写。一个简单的方法是将数据直接追加到文件而不对存储位置做特殊的要求,这个策略经常被使用在日志或者堆文件,因为它们完全是顺序的,所以有非常好的写操作性能。但是与要求较高的读操作性能时面临的问题类似,这种做法会导致读一些数据花费的时间比写操作更多,因为查找时需要扫描所有数据,直到找到所需的内容

在高性能读操作与高性能写操作不可兼得的情况下,需要面对不同的场景做出取舍,而 Log-Structured Merge-Tree 就是一个权衡的产物

1.2 核心思想

日志化结构合并树(Log-Structured Merge-Tree)是一种分层、有序、面向磁盘的数据结构,其核心思想是充分利用磁盘批量的顺序写远比随机写高效的特性,放弃部分读效率换取最大化的写操作效率

我们知道最大化发挥磁盘特性的使用方式是一次性地读取或写入固定大小的一块数据,并尽可能地减少随机寻道操作。LSM 树的设计思想就是依据磁盘这个特性,大致思路如下:

假定内存足够,不需要每次有数据更新就将其写入磁盘,而是先将最新的数据驻留在内存中,等数据量积累到足够多之后,再使用归并排序的方式将内存中的数据与磁盘中的数据合并,批量追加到磁盘。合并过程因为所有待排序的数据都是有序的,所以可以通过归并排序快速合并

与B-tree相比,LSM 树能在较长的时间提供对文件的高速增删,但是在需要高效查询时性能相对较低,所以 LSM 树通常适用于索引插入比检索更频繁的系统。不过 LSM-Tree 结构除了利用磁盘顺序写实现高性能写,也通过划分内存+磁盘的多层合并结构及各种优化实现尽量保证读性能

LSM 树结构的文件结构策略目前已经应用在多种 NoSQL 数据库,如 Hbase,Cassandra,Leveldb,RocksDB,MongoDB,TiDB,SQLite等

2. 基本原理

最基本的 LSM 很简单,就是将传统关系型数据库组织数据使用的单个的整体查找结构变换为多个相似的将数据顺序保存的有序文件。LSM 树通过这些有序文件实现了管理一组索引文件而不是单一的索引文件,并且推迟和批量地进行数据更新,充分利用内存来存储近期或常用数据以提高读效率,利用硬盘存储不常用数据以减少存储代价

下图是 LSM-tree 的组成,是一个和树一样上小下大的多层结构。最上层是位于内存中的 C0 层,保存了所有最近写入的数据,这个内存结构是有序的,可以随时原地更新并支持随时查询。剩下的 C1 到 Ci 层都在磁盘上,每一层都是一个 key 有序的结构

在这里插入图片描述

2.1 写数据流程

  1. 写数据操作触发,首先将数据记录在写前日志(Write Ahead Log),以便故障时恢复数据
  2. 把数据追加到内存中的 C0 层
  3. 当 C0 层的数据量达到一定大小,就把 C0 层 和 C1 层以归并排序的方式合并,这个过程被称为 Compaction(合并)。合并出来的新的文件会顺序写磁盘,替换掉旧的文件。当 C1 层达到一定大小,会继续和下层合并,合并之后所有旧文件都可以删掉,留下新的
  4. 需注意数据的写入可能重复,新版本需要覆盖老版本。比如先写(a=10),再写(a=666),666 就是新版本数据。假如 a 老版本已经到 Ci 层了, C0 层来了个新版本,这时不会去更新下层文件中的老版本记录,而只在 C0 层写一个新的数据

以上写入过程基本只用到了内存,Compaction 可以后台异步完成,不阻塞写入。因为比较旧的文件不会被更新,重复的纪录只会通过创建新的纪录来覆盖,这就产生了一些冗余的数据,所以可以周期地执行 Compaction 合并有序文件,合并过程会移除重复的更新或者删除纪录,同时也会删除上述的冗余

2.2 读数据流程

在写入流程中可以看到,最新的数据在 C0 层,最老的数据在 Ci 层,所以查询也是先查 C0 层,如果没有查找到数据则逐层向下查询。因为每层中数据都是有序的,所以可以用二分查找,效率较高。但是随着层级的增加,读操作会变的越来越慢,因为需要检索的索引文件变多了

为了解决这个问题,可以引入一些优化手段

  1. 页缓存
    因为有序文件写入磁盘后除了 Compaction 之外是不会变化的,所以可以缓存在内存中,减少二分查找的消耗
  2. Bloom filter :
    一个带概率的 bitmap,可以高效来判断一个 key 是否包含包含在某个有序文件中。这使读效率得到了提升,但付出了空间代价,典型的空间换时间
  3. 周期执行Compaction 合并
    Compaction 合并会将小的有序文件合并为大的文件,这样多个有序文件的索引合并形成了一个大的索引文件,其实就是一个索引碎片消除的过程,可以提高查找效率

3. LevelDB 中的 LSM

LevelDB 的架构中 LSM-tree 的文件被分成两种:

  1. memtable
    位于内存中,又分为了两种,一种是正常的接收写入请求的 memtable,另一种是不可修改的 immutable memtable
  2. SStable(Sorted String Table)
    位于磁盘上的有序字符串表,这个有序的字符串就是数据的 key。SStable 一共有七层(L0 到 L6),下一层的总大小限制是上一层的 10 倍

在这里插入图片描述
LevelDB 的架构中 LSM-tree 写数据的流程主要为以下几步:

  1. 当收到一个写请求时,先把该条数据记录在写前日志(write-ahead log) 里面,用作故障恢复
  2. 写完 Log 后,把该条数据写入内存的 memtable 里面(删除是墓碑标记,更新是新记录一条数据),为了维持有序性在内存里面可以采用红黑树或者跳跃表相关的数据结构
  3. 当 memtable 满了,会切换为不可更改的 immutable memtable,同时为了不阻塞写操作,需要新生成一个 memtable 继续接收写入请求
  4. 把内存里面不可变的 immutable memtable 刷成硬盘上的 L0 层的SSTable 文件,此步骤也称为Minor Compaction。需注意在磁盘上第一层的 SSTable 是没有进行合并的,所以这里的 key 在多个SSTable中可能会出现重叠
  5. 每一层的所有文件总大小有限制,每下一层大10倍。一旦某一层的总大小超过阈值了,就选择一个文件和下一层的文件合并,这个周期的合并步骤也称为Major Compaction。这个阶段会真正地清除掉被标记为删除的数据,并进行多版本数据的合并,避免浪费空间,实际执行时由于 SSTable 都是有序的,可以直接采用归并排序进行高效合并

猜你喜欢

转载自blog.csdn.net/weixin_45505313/article/details/107556438
LSM