前言
LSM-Tree全称是Log Structured Merge Tree(日志结构合并树),最早听过这个存储引擎概念还是在《Bigtable: A Distributed Storage System for Structured Data》这篇paper中,后续在接触很多如Hbase,LevelDB,RocksDB这些数据库组件的时候发现他们都是在使用的LSM-Tree结构来组织的。我们可能最多接触的还是Mysql的B+Tree这样的数据结构,但是在大数据领域内的存储引擎基本上都是基于LSM-Tree来实现的。
我们一定好奇,为什么不能和MySQL一样用B+Tree来实现?B-Tree或者B+Tree其实就是一个多叉树的结构,每个叶子节点上存储了一个page的数据。B-tree这类存储结构多用于OLTP型的数据库,因为这类数据库主要以事务,或是行级别的读取和存储为主的。这种类型的数据库更多的操作是小批量或单行级别的更新或读取,并且可能还有事务方面的需求。B+Tree毋庸置疑,其结构赋予了很好的读性能,但是在大数据场景下,经常性的会有大量的数据的写入和更新。这样大量的随机数据的插入操作,就会让B+Tree不断的对页进行分裂,当有大量分裂时,会导致大量的磁盘随机寻道,从而降低性能。
上述B+Tree的问题,就是它在大量的更新插入操作的时候,无法解决随机IO暴增的问题。我们知道磁盘的顺序写的速度是远远大于随机写的速度的,所以便设想能不能有一种方式将所有的随机写都能转为顺序写?即使牺牲部分读性能。LSM-Tree就是这样,在B+树这些存储引擎中,如果有新插入的或者更新的数据,我们是需要去寻找到对应位置再将数据写入的,而LSM不是这样,他会将所有数据的操作都顺序写入日志和内存中,然后通过后面不断的合并来将数据更新。这样子,针对写入操作就会很快。
LSM-Tree
如上所说:LSM树的核心是**放弃部分读性能,换取写入能力的最大化**。而让写能力最大化的方式就是 将所有的随机IO全部转为顺序IO(包括那些更新的操作)。而对于读性能的缺失,通过一些其他的机制来补救,比如:布隆过滤器,树合并。而且要注意一点,LSM和B+Tree不一样,它不算是一种索引结构,而是一种文件存储方式。LSM-Tree里面Tree的实现是没有固定标准的。
核心概念
LSM的基本思想:假定内存足够大,因此不需要每次有数据更新就必须将数据写入到磁盘中,因此可以先将最新的数据驻留在内存中,等到积累到最后多之后,再使用归并排序的方式将内存内的数据合并追加到磁盘队尾。
如图,LSM-Tree的存储分为了2个部分:内存和磁盘。其中C0树存在于内存缓存,C1树存在于硬盘上。我们将新的数据的插入更新都在C0树进行操作,并直接在内存中对数据进行排序。当C0树达到我们设置的一定阈值的时候,就需要将C0树落盘到C1树上去。磁盘中一开始只有一个C1-Tree,经过多次的合并会逐步递增C2-Tree...Cn-Tree,树的大小也会越来越大。
上面介绍的比较粗糙,但是这就是LSM的核心思想,下面对其细化说明下。LSM-Tree在实现的时候,有下面重要的部分:
- WAL(write ahead log)
- 写前日志是所有数据库基本都有的一个功能,对数据的所有的更新操作都会不断的append到该日志文件,以备服务器宕机情况下数据的恢复。
- MemTable
- 这个就是C0-Tree在内存中对应的数据结构,这个树是什么结构,其实LSM并没有强约束,我们可以是红黑树,也可以是跳表结构。
- Immutable MemTable
- 当C0-Tree达到内存设置的阈值的时候,我们需要将数据进行落盘,但是在写的时候,总不能阻塞外面的写请求。所以我们会先将MemTable转变为一个不可变的Table,用来数据的持久化。同时新创建一个MemTable来处理新的读写请求
- SSTable(Sorted String Table)
- 这个是LSM树组在磁盘中的数据结构,是一个有序键值对集合。
LSM tree的写入流程
- 先将写入操作append到wal日志里面。
- 将该数据存储到内存的memtable中,该表的实现可以是红黑树或者跳跃表。
- 当memtable达到内存阈值的时候,会将该部分数据形成快照,并新生产一个memtable用来处理后续的请求。
- 将immutable memtable持久化到磁盘的sstable中,该步骤也叫Minor Compaction,数据会先会合并到L0层,然后每层的sstable达到阈值的时候也会不断的向下合并(Major Compaction),这个阶段清除掉被标记删除掉的数据以及多版本数据的合并,避免浪费空间,因为SSTable都是有序的,我们一般采用merge sort进行合并。
LSM tree 的读取流程
- 先查询memtable以及immutable memtable,找到就返回内存中的(毕竟内存中是最新的)。
- 如果没有找到,则向下到sstable level 0 层开始查询,如果还是找不到,就继续往下面更高层次的sstable中查找,知道找到或者找不到进行返回。
LSM-Tree通过一些方式来优化其缺陷的读性能,因为我们不能像B+Tree那样找一次就能确定,很有可能要不断的下推查找。
- Bloom filter:就是个带随即概率的bitmap,可以直接确定元素不存在。
- compact:小树合并为大树,太多的小树,就会对应查太多的文件,所以定期将小树合并大树上可以有效减少查询次数。
LSM-Tree缺点
LSM-Tree存在的一个问题就是读写放大的问题:
读写放大 = 磁盘上实际读写的数据量 / 用户需要的数据量。
比如用户本来要写1KB数据,最后往磁盘写了10KB的数据,那么写放大就是 10,读也类似。
写放大
以RocksDB的Level Style Compaction 机制为例,这种合并机制每次拿上一层的所有文件和下一层合并,下一层大小是上一层的 r 倍。这样单次合并的写放大就是 r 倍,这里是 r 倍还是 r+1 倍跟具体实现有关,我们举个例子:
假如现在有三层:文件的大小分别为9k,90k,900k,r=10。此时需要写入1k,这时候就会不断合并,1+9=10,10+90=100,100+900=1000。总共写了 10+100+1000。按理来说写放大应该为 1110/1,但是各种论文里不是这么说的,论文里说的是等号右边的比上加号左边的和,也就是10/1 + 100/10 + 1000/100 = 30 = r * level。这只是最坏情况。
即因为达到阈值合并的原因,可能在某些条件下发生多米诺骨牌的效应,明明写很少的数据,实际底层却写了很多。
读放大
因为LSM-Tree的数据存在多级文件上,所以查询一个 1KB 的数据。比如sstable的L0层有8个文件(L0的key是不能保证唯一的)。然后实际数据可能在L6,那么最坏情况下需要读 L0 层的 8 个文件,再读 L1 到 L6 的每一个文件,一共 14 个文件。而每一个文件内部需要读 16KB 的索引,4KB的布隆过滤器,4KB的数据块(看不懂不重要,只要知道从一个SSTable里查一个key,需要读这么多东西就可以了)。一共 24*14/1=336倍。
总结
- LSM-Tree通过将所有的随机IO转换为顺序IO,放弃部分读性能,换取写入能力的最大化。
- LSM-Tree将数据的操作会先放在内存中直接执行,然后后面不断的进行compact操作来落盘,以及盘中数据的不断整合。所以内存越大,其实对LSM-Tree的性能越友好。
- LSM-Tree还有很多知识点,包括优化,实现等等,后面有机会详解。