时,多页块的读写优势将荡然无存,B树的插入性能将优于LSM-tree。
为了避免M小于1,就又不得不增加C0的规模。
一个解决方法就是在日益增大的C1和有空间上限的C0之间加入一个C做为C0和C1的缓冲。
这时LSM-tree就由三个部分(C1、C和C0)组成。
这种LSM-tree称为多部件的LSM-tree。
4.多部件的LSM-tree
在数据不断增加的情况下,即使在C1和C0之间增加上一个C,C的规模也会不断增长。
当C像过去C1那么大时,就需要在C和C0之间再增加一个C。
以此类推,硬盘中的C树将会越来越多。
如下图所示:
图5:
多部件的LSM-tree
通常,一个多部件的LSM-tree由大小依次递增的C0,C1,C2,...,CK-1和CK组成,C0常驻内存之中,可以是键值索引的数据结构。
而C1~CK则存储于硬盘之中,但其经常访问的页会被缓存于内存之中,它们的结构都是索引树。
在数据不断插入的过程中,当较小的Ci-1的规模超过某一阈值时,相邻的两个部件Ci-1和Ci会进行滚动合并,从较小的Ci-1转移条目至较大的Ci中。
各个相邻部件Ci-1和Ci的滚动合并是异步的。
也就是说,一个条目会插入到C0中,之后经过不断的异步滚动合并过程,最终合并至CK中。
由于各个相邻部件Ci-1和Ci的滚动合并是异步的,但当对其中的数据进行访问时,常常发生一些并发性问题。
例如当进行精确检索或者加载多页块至内存时,Ci里的一个节点会被读入内存;当进行范围检索或滚动合并时,Ci里的多页块会被读入内存中。
这些情况下,查找数据时Ci里的所有未被锁住的结点都可以被访问,并会定位被读入内存的结点。
即使结点正在进行滚动合并,结点也可以被访问。
显然,这时结点上的数据可能是不完整的。
基于这些考虑,访问LSM-tree必须遵循下列规则:
1.当硬盘中的相邻部件进行滚动合并的时候,当前参与合并的结点不能被查找;
2.当C0和C1进行滚动合并的时候,当前参与合并的C0的结点周边不能被查找和插入;
3.在Ci-1和Ci与Ci和Ci+1同时进行滚动合并时,Ci-1与Ci滚动合并的游标有时会超过Ci和Ci+1滚动合并的游标。
为了避免对硬盘里的部件进行存取时产生的物理冲突,LSM-tree设置了以结点为单位的锁。
在进行滚动合并时,正在合并的结点会在写模式下被锁住,直到有来自较大部件的结点被合并才被释放。
在进行查找时,正在被读取的结点会在读模式下被锁住,读取完毕后,锁即被释放。
与C0和C1的滚动合并相比,硬盘内的相邻部件Ci-1和Ci之间的滚动合并多了一个清空块和填充块。
这是因为Ci-1和Ci都存储在硬盘之中,合并时需要先将Ci-1和Ci的清空块和填充块存入内存,合并的过程与C0和C1的相同,但Ci-1不会将所有的条目都拿去合并,而是会保留一部分条目(例如新插入的那部分条目)到Ci-1的填充块。
如下图所示:
图6:
硬盘中相邻两部件的滚动合并
当前正在进行滚动合并的结点被加锁(红圈圈住的点),写保护。
蓝色的点表示游标,绿色的点表示游标未到达的结点,树上折线表示从树根到游标的路径
5.查找、删除和修改
在LSM-tree树进行查找时,为了保证LSM-tree上的所有条目都被检查。
首先要搜索C0,再搜索C1,进而搜索C2,...,CK-1和CK。
即使硬盘中的部件C1,C2,...,CK-1和CK的结构都是B-tree,这也将耗费一些时间。
但在实际的应用中,总可以将搜索限制在前几个的部件的搜索上。
试想新条目插入时首先插入至C0中,然后通过各相邻部件Ci,和Ci+1之间的滚动合并,逐步转移到更大的部件中。
在滚动合并时,如果将最近τ时间内被访问的条目保留下来,而将其它条目用于合并,那么经常被访问的那些数据就会被依次保存在C0,C1,...,CK-1和CK中。
也就是说,我们可以简单的认为,C0保存的是最近τ时间内被访问的条目,C1保存的是除了C0保存的数据外,最近2τ时间内被访问的条目,C2保存的是除了C0和C1保存的数据外,最近3τ时间内被访问的条目。
依此可推广到CK-1。
而最后的部件CK保存的是最近Kτ之前的条目。
这样凡是在最近τ时间内被执行的事务都不需要与硬盘进行I/O交换,可以直接在内存中找到数据。
图7:
查找与删除
LSM-tree的优势在于其能推迟写回硬盘的时间,进而达到批量地插入数据的目的。
为了更高效地利用LSM-tree的插入优势,删除操作被设计为通过插入操作来执行。
当C0所索引的一个条目被删除时,首先在C0上查找该条目所对应的索引是否存在,若不存在,就建立一个索引。
然后在该索引键值的位置上设置删除条目(deletenodeentry)。
删除条目的意义仅在于通知所有访问该索引的操作,“此索引键值所索引的条目已经被删除了”。
在后续的滚动合并中,凡是在较大的部件中碰到的与该索引键值相同的条目都将被删除。
此外,当在LSM-tree中查找删除的条目时,如果碰到这种删除条目,就会直接返回未找到。
对于条目的修改,依仗LSM-tree的插入优势,可以先插入一个对应的删除条目,待删除条目经滚动合并离开C0后,再在上插入该条目的新值。
崩溃恢复
在新条目插入到C0后,当C0与C1进行滚动合并时,某些条目将从C0转移到更大的部件中。
由于滚动合并发生在内存缓存的多页块中,所以只有当条目真正写入硬盘时,滚动合并的成果才会真正生效。
然而滚动合并时可能就会发生系统故障,进而使得内存数据丢失。
为了能有效地进行系统恢复,在LSM-tree的日常使用中,需要记录一些用以恢复数据的日志。
然而与以往数据库中的日志不同的是,日志中只需要要记录数据插入的事务。
简单地说,这些日志只包含了被插入数据的行的号码及插入的域和值。
LSM-tree在记日志时设置检查点(checkpoint)以恢复某一时刻的LSM-tree。
当需要在时刻T0设置检查点时:
1.完成所有部件的当前合并,这样结点上的锁就会被释放;
2.将所有新条目的插入操作以及滚动合并推迟至检查点设置完成之后;
3.将C0写入硬盘中的一个已知的位置;此后对C0的插入操作可以开始,但是合并操作还要继续等待;
4.将硬盘中的所有部件(C1~CK)在内存中缓存的结点写入硬盘;
5.向日志中写入一条特殊的检查点日志。
检查点日志的内容包括:
1.T0时刻最后一个插入的已索引的行的日志序列号(LogSequenceNumber,LSN0);
2.硬盘中的所有部件的根在硬盘中的地址;
3.各个部件的合并游标;
4.新多页块动态分配的当前信息。
在以后的恢复中,硬盘存储的动态分配算法将使用此信息判别哪些多页块是可用的。
图8:
Checkpoint
一旦检查点的信息设置完毕,就可以开始执行被推迟的新条目的插入操作了。
由于后续合并操作中向硬盘写入多页块时,会将信息写入硬盘中的新位置,所以检查点的信息不会被消除。
只有当后续检查点使得过期的多页块作废时,检查点的信息才会被废弃。
6.恢复
当系统崩溃后重启进行恢复时,需要进行如下操作:
1.在日志中定位一个检查点;
2.将之前写入硬盘的C0和其它部件在内存中缓存的多页块加载到内存中;
3.将日志中在LSN0之后的部分读入内存,执行其中索引条目的插入操作;
4.读取检查点日志中硬盘部件(C1~CK)的根的位置和合并游标,启动滚动合并,覆盖检查点之后的多页块;
5.当检查点之后的所有新索引条目都已插入至LSM-tree且被索引后,恢复即完成。
这一恢复措施的唯一的一个缺点就是恢复的时间可能会比较长,但通常这并不严重。
因为内存中的数据可以很快地写入硬盘。
当两个相邻的部件进行滚动合并时,新产生的结点将会写入到硬盘中的新位置。
这样在将合并产生的结点写入硬盘时,上层结点中指向该结点的指针需要更新为结点的新位置。
当正在进行滚动合并,却临时需要设置检查点时,加载进内存的多页块和目录结点都会写入到硬盘中新的位置。
这样,在高层的目录结点中指向这些结点的指针同样需要立即更新为硬盘中的新地址。
在恢复的过程中需要注意的是目录结点的更新。
图9:
高层结点引用下层结点的新位置
更进一步,当使用检查点进行恢复时,滚动合并所需的所有的多页块都会从硬盘重新读回内存,由于所有的多页块的新位置较之设置检查点时的旧位置都发生了改变,这样所有目录结点的指针都需要更新。
这听起来似乎是一大笔性能开销,但这些多页块其实都已加载到内存里了,所以没有I/O开销。
若要使得恢复的时间不超过几分钟,那么可以每隔几分钟的I/O操作就设置一次检查点。
7.小结
日志结构的合并树(LSM-tree)是一种基于硬盘的数据结构,与B-tree相比,能显著地减少硬盘磁盘臂的开销,并能在较长的时间提供对文件的高速插入(删除)。
然而LSM-tree在某些情况下,特别是在查询需要快速响应时性能不佳。
通常LSM-tree适用于索引插入比检索更频繁的应用系统。
Bigtable在提供Tablet服务时,使用GFS来存储日志和SSTable,而GFS的设计初衷就是希望通过添加新数据的方式而不是通过重写旧数据的方式来修改文件。
而LSM-tree通过滚动合并和多页块的方法推迟和批量进行索引更新,充分利用内存来存储近期或常用数据以降低查找代价,利用硬盘来存储不常用数据以减少存储代价。