LSM Tree

来源:互联网 发布:mac phpstorm使用教程 编辑:程序博客网 时间:2024/05/29 17:24
我们在访问数据的时候速度肯定是内存大于磁盘的,这样的话为什么不全用内存呢?原因大家自己考虑下就行了,所以权衡下来还是需要用硬盘的,那么为了实现数据的快速插入和查询,存储应该怎么设计呢?学过oracle的同学都应该知道,要使一个表对查询的响应比较快,那么最主要的手段就是索引,但是索引多了就会影响数据插入的速度,这也是一种平衡,下面我们将分析lsm,看看它是设计了个完美的解决方案吗?

在讨论这个问题之前,让我们看下lsm tree解决了什么问题:
答案就是减少数据频繁的插入、修改、删除操作所需要的磁盘I/O次数

学过数据结构的同学都知道Btree树是很多索引的优先选择结构,b tree树访问的时间复杂度接近Logm(N/2),我们可以计算下,在成百上千的索引节点下,即使索引十几亿的数据,那么树的深度也不会很深的,应该是10以内吧,再加上对于lru算法的支持,可以很明显的减少io,那为什么HBase不用这个结构呢,答案就是本文开头的几句话。
因为hbase中数据插入是比较随机的或者说是无序的,在查询数据的时候回到索引上,也就是对于某个叶子节点的访问是很随机的,这个场景很重要,那么我们根据这个具体场景分析一下b+树,因为查询是随机的,那么也就是说我们上次调入内存的数据可能很久以后都不会被访问,所以lru算法失去了它的价值,主要的系统开销变成了访问B+树的io了,内存的命中率很低,对于插入数据来说道理是一样的。

下面我们再看看lsm tree是怎么做的:
lsm构造许多小的结构,每个结构在内存里排序一下构成内部有序,查询的时候对每个小结构就可以采用二分法高效的查找定位,我们都知道有序的东西查找起来速度肯定比无序的快,如果只是这么设计肯定不能达到快速插入和查询的目的,lsm还引入了Bloom filter和小树到大树的排序。
Bloom Filter是一种空间效率很高的随机数据结构,它利用位数组很简洁地表示一个集合,并能判断一个元素是否属于这个集合。Bloom Filter的这种高效是有一定代价的:在判断一个元素是否属于某个集合时,有可能会把不属于这个集合的元素误认为属于这个集合(false positive)。因此,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter通过极少的错误换取了存储空间的极大节省。

Bloom filter在lsm中的作用就是判断要查询的数据在哪个内存部件中,或者要插入的数据应该插入到哪个内存部件中。
小树到大树的排序是为了节约内存,做开发的同学应该都明白内存中宝贵,同时也是为了恢复,因为我们知道hbase的delete和update其实都是insert,这都是由lsm的特点决定的,新的数据会被写到磁盘新的位置上去,这样就保证了旧记录不会被覆盖,在系统crash后的恢复过程会很有用,只要按日志顺序恢复就ok了。
说了半天没说什么是lsm tree:
LSM-Tree通过使用一个基于内存的组件C0和一至多个基于磁盘的(C1, C2, …, CK)组件算法,对索引变更进行延迟及批量处理,并通过归并的方式高效的将更新迁移到磁盘。

下面我们看一下两组件算法的具体实现:

如下图



在每条历史记录表中的记录生成时,会首先向一个日志文件中写入一个用于恢复该插入操作的日志记录。然后针对该历史记录表的实际索引节点会被插入到驻留在内存中的C0树,之后它将会在某个时间被移到磁盘上的C1树中。对于某条记录的检索,将会首先在C0中查找,然后是C1。在记录从C0移到C1中间肯定存在一定时间的延迟,这就要求能够恢复那些crash之前还未被移出到磁盘的记录。现在我们只是简单地认为那些用于恢复插入的历史记录数据的日志记录可以被看做逻辑上的日志;在恢复期间我们可以重构出那些已经被插入的历史记录,同时可以重建出需要的那些记录并将这些记录进行索引以恢复C0丢失的内容。


向驻留在内存中的C0树插入一个索引条目不会花费任何IO开销。但是,用于保存C0的内存的成本要远高于磁盘,这就限制了它的大小。这就需要一种有效的方式来将记录迁移到驻留在更低成本的存储设备上的C1树中。为了实现这个目的,在当C0树因插入操作而达到接近某个上限的阈值大小时,就会启动一个rolling merge过程,来将某些连续的记录段从C0树中删除,并merge到磁盘上的C1树中。




 C1树具有一个类似于B-树的目录结构,但是它是为顺序性的磁盘访问优化过的,所有的节点都是100%满的,同时为了有效利用磁盘,在根节点之下的所有的单页面节点都会被打包(pack)放到连续的多页面磁盘块(multi-page block)上;类似的优化也被用在SB-树中。对于rolling merge和长的区间检索的情况将会使用multi-page block io,而在匹配性的查找中会使用单页面节点以最小化缓存需求。对于root之外的节点使用256Kbytes的multi-page block大小,对于root节点根据定义通常都只是单个的页面。

 

Rolling merge实际上由一系列的merge步骤组成。首先会读取一个包含了C1树中叶节点的multi-page block,这将会使C1中的一系列记录进入缓存。之后,每次merge将会直接从缓存中以磁盘页的大小读取C1的叶节点,将那些来自于叶节点的记录与从C0树中拿到的叶节点级的记录进行merge,这样就减少了C0的大小,同时在C1树中创建了一个新的merge好的叶节点。

 

merge之前的老的C1树节点被保存在缓存中的称为emptying block{!掏空ing,即该block中的那些节点正在被掏空}的multi-page block中,而新的叶节点会被写入到另一个称为filling block{!填充ing,即该block正在被不断地用新节点填充}的缓存中的multi-page block。当C1中新merge的节点填满filling block后,该block会被写入到磁盘上的新空闲区域中。如果从图2.2中看的话,包含了merge结果的新的multi-page block位于图中老节点的右侧。后续的merge步骤会随着C0和C1的索引值的增加而发生,当达到阈值时,就又会从最小值开始启动rolling merge过程。

 

新的merge后的blocks会被写入到新的磁盘位置上,这样老的blocks就不会被覆盖,这样在crash发生后的恢复中就是可用的。C1中的父目录节点也会被缓存在内存中,此时也会被更新以反映出叶节点的变动,同时父节点还会在内存中停留一段时间以最小化IO;当merge步骤完成后,C1中的老的叶节点就会变为无效状态,之后会被从C1目录结构中删除。通常,每次都是C1中的最左边的叶节点记录参与merge,因为如果老的叶节点都是空的那么merge步骤也就不会产生新的节点,这样也就没有必要进行。除了更新后的目录节点信息外,这些最左边的记录在被写入到磁盘之前也会在内存中缓存一段时间。用于提供在merge阶段的并发访问和从crash后的内存丢失中进行恢复的技术将会在第4节详细介绍。为了减少恢复时的重构时间,merge过程需要进行周期性的checkpoints,强制将缓存信息写入磁盘。


2.1  How a Two Component LSM-Tree Grows

为了追踪LSM-tree从诞生那一刻开始的整个变化过程,我们从针对C0的第一次插入开始。与C1树不同,C0树不一定要具有一个类B-树的结构。首先,它的节点可以具有任意大小:没有必要让它与磁盘页面大小保持一致,因为C0树永不会位于磁盘上,因此我们就没有必要为了最小化树的深度而牺牲CPU的效率{!如果看下B-树,就可以知道实际上它为了降低树的高度,牺牲了CPU效率。在当整个数据结构都是在内存中时,与二分查找相比,B-树在查找时,在节点内部的比较,实际上退化成了顺序查找,这样它查找一个节点所需的比较次数实际上要大于AVL的比较次数}。这样,一个2-3树或者是AVL树就可以作为C0树使用的一个数据结构。当C0首次增长到它的阈值大小时,最左边的一系列记录将会从C0中删除(这应是以批量处理的模式完成,而不是一次一条记录),然后被重新组织成C1中的一个100%满的叶子节点。后续的叶节点会按照从左到右的顺序放到缓存中的一个multi-page block的初始页面中,直到该block填满为止;之后,该block会被写到磁盘中,成为C1树的磁盘上的叶级存储的第一部分。随着后续的叶节点的加入,C1树会创建出一个目录节点结构,具体细节如下。

C1树的叶节点级的后续multi-page block会按照键值递增的顺序被写入到磁盘中,以防止C0树大小超过阈值。C1树的上级目录节点被存放在独立的multi-page block buffers或者是单页面缓存中,无论存在哪里,都是为了更好地利用内存和磁盘;目录节点中的记录包含一些分隔点,通过这些分隔点可以将用户访问导引到单个的singe-page节点中,像B-树那样。通过这种指向叶级节点的single-page索引节点可以提供高效的精确匹配访问,避免了multi-page block的读取,这样就最小化了缓存需求。这样在进行rolling merge或者按range检索时才会读写multi-page block,对于索引化的查询(精确匹配)访问则读写singe-page节点。[22]中提出了一种与之类似但又稍有不同的结构。在一系列叶级节点blocks被写出时,那些由C1的目录节点组成的还未满的multi-page block可以保留在缓存中。在如下情况下,C1的目录节点会被强制写入磁盘:

l  由目录节点组成的某个multi-page block被填满了

l  根节点发生了分裂,增加了C1树的深度(成了一个大于2的深度)

l  执行Checkpoint

 

对于第一种情况,只有被填满的那个block会被写出到磁盘。对于后两个情况,所有的multi-page block buffers和目录节点buffers都会被flush到磁盘。

 

当C0树的最右边的叶节点记录首次被写出到C1树后,整个过程就又会从两个树的最左端开始,只是从现在开始,需要先把C1中的叶子级别的multi-page block读入到buffer,然后与C0树中的记录进行merge,产生出需要写入到磁盘的新的C1的multi-page leaf block。

 

一旦merge过程开始,情况就变地更复杂了。我们可以把整个两组件LSM-tree的rolling merge过程想象成一个具有一定步长的游标循环往复地穿越在C0和C1的键值对上,不断地从C0中取出数据放入到磁盘上才C1中。该rolling merge游标在C1树的叶节点和更上层的目录级都会有一个逻辑上的位置。在每个层级上,所有当前正在参与merge的multi-page blocks将会被分成两个blocks:”emptying block”-它内部的记录正在搬出,但是还有一些信息是merge游标所未到达的,”filling block”-反映了此刻的merge结果。类似地,该游标也会定义出”emptying node”和”filling node”,这两个节点此刻肯定是已在缓存中。为了可以进行并发访问,每个层级上的”emptying block”和”filling block”包含整数个的page-sized C1树节点。(在对执行节点进行重组的merge步骤中,针对这些节点的内部记录的其他类型的并行访问将会被阻塞)。当所有被缓存的节点需要被flush到磁盘时,每个层级的所有被缓存的信息必须被写入到磁盘上的新的位置上(同时这些位置信息需要反映在上层目录信息中,同时为了进行恢复还需要产生一条日志记录)。此后,当C1树某一层级的缓存中的filling block被填满及需要再次flush时,它会被放到新的磁盘位置上。那些可能在恢复过程中需要的老的信息永不会被覆盖,只有当后续的写入提供了足够信息时它们才可以宣告失效。第4节来还会进行一些关于roling merge过程的更细节的解释,在那一节里还会考虑关于并发访问和恢复机制的设计。

 

在C1的某个层级上的rolling merge过程,需要很高的节点传输速率时,所有的读写都是以multi-page blocks为单位进行的,对于LSM-tree来说,这是一个很重要的效率上的优化。通过减少寻道时间和旋转延迟,我们认为与普通的B-树节点插入所产生的随机IO相比,这样做可以得到更大的优势(我们将会在3.2节讨论其中的优势)。总是以multi-page blocks为单位进行写入的想法源自于由Rosenblum和Ousterhout发明的Log-Structured File System,Log-Structured Merge-tree的叫法也源于此。需要注意的是,对于新的multi-page blocks的写入使用连续的新的磁盘空间,这就意味着必须对磁盘区域进行包装管理,旧的被丢弃的blocks必须能被重用。使用记录可以通过一个内存表来管理;旧的multi-page blocks作为单个单元被置为无效和重用,通过checkpoint来进行恢复。在Log-Structured File System中,旧的block的重用会引入显著的IO开销,因为blocks通常是半空的,这样重用就需要针对该block的一次读取和写入。在LSM-tree中,blocks是完全空的,因此不需要额外的IO。