深入浅出LSM树

3,193 阅读12分钟

LSM树简介

LSM树逻辑架构 image.png

LSM树(Log-Structured Merge-Tree:日志结构合并树)广泛的作为各种NoSql的底层存储引擎,例如Hbase,RocksDB,Cassandra,LevelDB,TiDB等。

LSM树,其实并不是某一种特定的数据结构,更多的是一种思想,它并没有一个固定的实现格式。

我们可以尝试为LSM树下一个大致的,通用的定义:

  1. LSM树横跨了内存和外存,在内存和外存中均有存储结构。我们暂且将这些存储结构均称为“树”。
  2. 通常将LSM树划分为多个层,称为L0-LN。其中L0位于内存,L1-LN位于磁盘。
  3. 内存中的存储结构,一般采用有序的,可高性能查找的数据结构,例如AVL树,红黑树,跳表等。
  4. 外存,即L1-LN 层,每一层的本质是多个文件,文件内是已经排好序的数据。
  5. 每一层的“树”,数据量到达一定阈值之后,都会向下层compact。
  6. 对LSM树的增删改,均在内存中操作。在归并的时候,会将修改落到磁盘。
  7. LSM树的特点是写性能强,但读性能偏弱,所以适用于多写少读的场景。

LSM树的逻辑结构

前面的定义讲到,LSM树是位于内外存的多颗“树,只是为了方便理解。实际上,LSM树由memtable,immutable-memtable和SSTable组成。其中memtable,immutable-memtable位于内存,其本质是有序的数据结构,SSTable位于磁盘,本质就是存储有序数据的file。

image.png SSTable的示意图

接下来,详细的介绍一下,增删改查LSM树的过程。我们假定,内存中使用的数据结构是二叉搜索树。

LSM树的插入

LSM树的插入涉及两步:1.WAL先写日志。2.将待插入的数据定位到内存中待写入的位置之后,写入即可。若该位置已有重复的数据,则将其更新。 可以发现,插入根本不care该数据是否已经存在于磁盘之中,只在内存中进行插入或更新。

我们的增删改操作都是直接作用在memtable上,当memtable的大小到达一定的阈值之后,其会转变成:immutable-memtable,顾名思义,就是不可改变的memtable,可以将它理解为即将刷入磁盘的一个中间态;与此同时,会新建一个memtable以供接下来的写操作。之后,后台线程会将 immutable memtable 写入到磁盘中的L1层形成一个新的 SSTable 文件,并随后销毁 immutable memTable。SSTable是不可修改的,数据的更新和删除都是以顺序写入新记录的形式呈现。而我们读取数据的时候,会按照新->旧的顺序读取,所以先读到的是最新的数据。数据在文件中是按 key 有序组织的,利于高效地查询和后续合并。

这里要注意,当memtable满了之后,会顺序写入磁盘,变成一个SSTable,所以,在L1层中,单个SSTable中不可能存在相同key的数据,但是不同的SSTable中却可能存在相同key的数据。

LSM树的更新

LSM树的更新可以分为两种场景:1.待更新的数据在内存中。2.待更新的数据在磁盘中。针对上述两种场景,LSM树的更新可以统一概括为:1.在内存中寻找待更新的数据。2.找到了,则将其更新。若没找到(数据在外存),则将待更新的数据插入到本应存在的位置。

LSM树的删除

LSM树的删除和更新的流程几乎一样,找到内存中的数据,或者数据本应存在的位置;之后对这个数据打上一个“delete”标签,或者对这个位置插入一个 打上了“delete”标签的数据。

LSM树的查找

LSM树的查找操作,性能偏弱,其查找流程是按照L0,L1 …… LN的顺序进行查找,找到为止。因为LSM树的特性,即使其存在重复数据,最新的数据也一定位于更低的层中,所以最先找到的一定是最新的数据。

查找时,首先查找内存中的L0层,例如内存中的二叉搜索树,在log(n)内即可查找结束。

内存中若未能找到,就该去找外存了。 外存中的每一层里的多个SSTable都可以配有bloom filter和索引,由bloom filter快速判断block内部是否存在待查找的值,若可能存在,则通过索引加速查找过程。 所以针对外存的查找过程是:从L1层依次找到LN层,每层内依次按照新SSTable -> 旧SSTable的顺序,首先通过布隆过滤器判断元素的存在性,若不存在直接跳过,可能存在,则借助索引加速查找。找到,则结束。最坏的情况,需要遍历L1-LN的所有SSTable。

LSM树的compact

前文讲到,增删改操作均是在内存中进行的,而内存中的数据流入磁盘,和针对磁盘上数据的管理,均扔给compact来做了,所以compact是LSM树中最重要,也是最复杂的逻辑。compact,就是将低级别的多个SST文件中的数据通过不同策略放入高一级的SST文件中,并清理掉低级别中的SST文件的过程。

LSM树的compact过程

compact,即针对L1-LN上的SSTable所做的操作。 在了解L1-LN层的compact之前,我们先来看几个概念。

  • 读放大:读取数据时实际读取的数据量大于真正的数据量。例如 LSM 读取数据时可能需要扫描多个 SSTable.
  • 写放大:写入数据时实际写入的数据量大于真正的数据量。例如在 LSM 树中写入时可能触发Compact操作,导致实际写入的数据量远大于该key的数据量。
  • 空间放大:数据实际占用的磁盘空间比数据的真正大小更多。例如上文提到的 SSTable 中存储的旧版数据都是无效的。

与此同时,我们要思考一个问题:为什么需要compact?答案很显而易见:你会发现,LSM树的增删改操作,都是在内存中进行的,在之后的compact过程中被刷到磁盘中,并且随着时间的推移,不断地存入更高的level中,那么当我想要对一个数据进行增删改的时候,磁盘中大概率会存有这个数据的历史版本,而这个历史版本,不会被读取到,没有任何作用,却占着磁盘,所以我们需要通过compact将其清理掉,只保留数据的最新版本。

compact有多种策略,每种策略都有自己的优缺点,且在不同的存储引擎中被应用。目前广泛应用的有下述两种策略。

Size-Tiered-Compaction Strategy

image.png

STCS策略保证每个level内的SSTable大小相近,当某一个level的SSTable数量达到一定阈值的时候,将这些SSTable合成一个更大的SSTable,放入下一个level。且在合并的过程中会清理掉重复的,被删除的数据,与此同时,新生成的大SSTable也是按key排序的。你可以将其理解成是一个多路归并排序。你可以看到,使用这样的策略,会使得更高级的level中存有更大的SSTable。

  • 空间放大问题:使用这种策略,仅仅能保证每个SSTable内不存在重复的数据,但是同一层的多个SSTable之间依然可能存在重复的数据,即空间放大的问题依然存在。
  • 读放大问题:当需要查找某一个数据时,因为我并不知道这个key存在于哪个SSTable内,故需要从新到旧,依次遍历所有SSTable。即读放大也较严重。
  • 写放大问题:当我们写入数据,如果此时恰好触发了compact,则会有一定写放大问题。

Leveled Compaction Strategy

image.png

LCS策略保证磁盘中所有层的SSTable大小都一致。每一层会限制总文件大小,例如L1层总大小为10G,L2层为100G,L3层为1000G等。且磁盘中(L1层和更高层)每一层内均按序排列且层内无重复数据,即:不仅单个SSTable内是按序排列的,SSTable之间也有序。

如果某一层总文件大小达到了阈值,则会触发compact操作,会在这一层中选择一个SSTable和下一层中key范围有交集的SSTable进行合并。例如:L1层现在总文件大小达到了阈值,从L1层中选择一个SSTable,其key的范围为5-10,此时L2层的多个SSTable的key范围为:0-3,4-8,9-12,13-16。那么此时就会将这个5-10的SSTable和L2层的4-8,9-12的SSTable进行compact。与此同时保证compact之后单个SSTable大小不超过阈值,且层内有序。并且多个不相关的compact可以并行。

  • 空间放大问题:由于其层内并无重复数据,且在compact的时候并不会创建临时文件,所以其空间放大问题并不大。
  • 读放大问题:由于层内SSTable是按key有序排列的,如果想在某一层内寻找某个key,可以直接定位到可能存在该key的SSTable,读放大问题很小。
  • 写放大问题:当某一层的总大小超过阈值之后,LCS 会从中选择一个 SSTable 与下一层中所有和它有交集的 SSTable合并,并将合并后的 SSTable 放在下一层。所以其也是存在写放大的问题的。

拓展:MVCC在LSM树中的应用

当你读完上述的compact操作,不知道会不会有一个疑问,既然LSM树是NoSQL的存储引擎,那如果在高频率读写的情况下,触发了compact操作怎么办?比如一个连接正在读取某个SST文件,结果compact把他删了,那肯定不行。怎么办呢?如果采用悲观锁,那显然并发度会大打折扣,所以,就需要采用MVCC(多版本并发控制)来解决上述问题。

接下来介绍一下levelDB的MVCC实现原理。

在levelDB中,和MVCC有关的数据结构有三个:version,versionSet和versionEdit。

Version保存了各个level下每个sstable的FileMetaData。

image.png 其数据结构如下:

class Version {
......
// 属于的 VersionSet
VersionSet* vset_;
// 链表指针
Version* next_;
Version* prev_;
// 引用计数
int refs_;
// 每个 level 的所有 sstable 元信息。
// files_[i]中的 FileMetaData 按照 FileMetaData::smallest 排序,
// 这是在每次更新都保证的。(参见 VersionSet::Builder::Save())
std::vector<FileMetaData*> files_[config::kNumLevels];

VersionSet全局唯一,其中记录了最新版本的version。

VersionSet {
// 实际的 Env
Env* const env_;
// db 的数据路径
const std::string dbname_;
// 正在服务的 Version 链表
Version dummy_versions_;
// 当前最新的的 Version
Version* current_;

VersionEdit 代表一次更新,新增了哪些SSTable file,以及删除了哪些SSTable file。

levelDB采用引用计数法来决定某个版本是否可以被删除。每个version会有一个ref字段,即引用计数,每个FileMetaData也会有一个引用计数。每当新生成一个version时,默认的引用计数为1,表名其被current_所引用。假设有读请求试图读取最新version时,其引用计数+1。所以我们可以总结出如下场景:

  • 一个读请求正在读取最新version(假设名为v1)的数据,其引用计数为2。
  • 读取的过程中,触发了compact,此时会生成新的version(假设名为v2),并被current_所引用,所以释放掉旧v1的一个引用计数,其引用计数变为1。注意此时读请求读取的是v1版本。
  • 读请求读取完毕,v1引用计数变为0。
  • v1进入析构函数。

针对FileMetaData,每当生成一个新的version,未参与compact的SSTable,其引用计数会+1,并被保存到新的version中,参与了compact的SSTable,其引用计数不变,且不会被保存到新version中。

在version的析构函数中,会将该version的所有FileMetaData的引用计数减一,并删除所有引用计数为0的SSTable,所以参与了compact的SSTable的引用计数会降为0,并且被删除。

总结:通过上述介绍,我们发现,levelDB的MVCC,本质上是对版本以及SSTable加上了引用计数,当某个版本的引用计数降为0时,进入析构函数,析构函数会处理版本内的所有SSTable,将引用计数为0的SSTable清理掉。当读请求来临时,即使产生了新的version,该请求也只能看到他所读取的version下的所有SSTable,而只要有读请求作用在version上,其就不会进入析构函数,也就不会触发SSTable的删除操作,实现了多版本并发控制。

LSM树的应用

LSM树广泛的应用于各种NoSQL中,例如LevelDB的底层引擎就使用的是LCS策略+内存跳表的LSM树,RocksDB 默认采用的是 Size Tiered 与 Leveled 混合的压缩策略。

总结

LSM树是一个典型的读慢写快的思想,其写操作的核心是将涉及到磁盘的操作均转换为顺序追加写,故效率很高,但读有可能会遍历整个LSM树,故效率偏低。所以其适合于读少写多的场景。和最常见的B+树进行比较发现,他们都使用了WAL技术,但B+树的增删改均是随机磁盘写,而LSM树只是追加顺序写,但是LSM树的读操作最坏情况下需要遍历所有level,所以读效率一般是不如B+树的。 并且compact策略各有优劣,需要根据实际场景选择不同的策略。