LSM-Tree使用BeansDB的BitCask使用的Log-Structred结构(其实应该说是BitCask仿照LSM-Tree,因为后者更早),更多的关于LSM-Tree的资料可以参考:
http://duanple.blog.163.com/blog/static/7097176720120391321283/ 对LSM-Tree论文的翻译http://www.cnblogs.com/hibernate6/archive/2012/05/01/2522242.html 对LSM-Tree用法的一些总结
个人LSM-Tree本质上来说并不能说是一种树,它只是一种数据的写入策略——顺序写,延迟写和批处理。LSM-Tree有多个组件,最底层的组件为C0,驻留在内存中,上层组件可能有C1,C2...,Ck,层数越高,变动的频率就越小。这种数据结构对插入友好,但是对查询则没有招架之力——只有自己进行优化,比如缓存数据,进行适当的索引结构选取等。下面就看看nessDB1.0是如何实现的吧!
首先说一下它的util文件:
buffer.c 类似stringstream的东西,可以把它作为一个string,具体细节不看。
skiplist.c 跳表,实现比较简单,可以看看,如果不看也不会影响啥,把它看做一个set好了。
Level_0:old lru lists
Level_1:new lru lists
a) insert entry into old-lru list ahead
b) if old-lru size >OMAX,remove last entry from list;then if hits >HIT,move it to new-lru list ahead
c) if new-lru size >NMAX,remove all old-lru entries,move last N entries to old-lru
根据代码,确实是看不懂这个思想的到底是啥,如果一个元素频繁被命中,按照常理它应该一直驻留在内存中才对,没想到还会因为hits太高了而被打入“冷宫”——new_level中,new_level跟普通的lru就没有什么区别了。而且在阅读llru.c的时候总是感觉代码不够直接,读起来有点别扭。代码里的实现是跟c描述的不符呀,c的描述的意思应该就是在new_level中被移除的元素不应该被直接删除掉,而是放回到old_level中——毕竟它的hits曾经辉煌过呀,但是代码中却给它直接删除了。我觉得这个思想可能是这样的:既然一个元素的hits已经到达了如此之高的地步,那么再增加它也是无济于事了,于是把它们放入到“奥班”——new_level,old_level其实是一个考场,专门筛选那些符合条件的元素。当new_level班容量满了的时候,就把它们放入old_level,继续跟后来者进行切磋较量,最后生存下来的还是hits最高的主。这样就会保持htable中的元素一直是那些被频繁使用的元素。所以源码中_llru_set_node的编码是不对的。
nessDB的优化文件为:
bloom.c 布鲁姆过滤器。可以在网上查询相应的资料。其核心思想是使用bitset建立元素的痕迹,查找时如果bloom中没有,那么这个元素一定不存在于数据库中;但是即使是bloom中有,也不能保证数据库中有。它其实是数据的一种“过滤器”,是“数据在数据库中”的必要但不充分条件。而且,布鲁姆的过滤器越多,它的帅选能力就越强,但是这也会带来性能上的负担。有时候会觉得虽然计算上有负担,但总是比直接查询要好的吧?所以布鲁姆的筛选器应该动态地根据错误率来调整筛选策略和筛选器的个数。
nessDB的逻辑文件为:
meta.c 元数据文件。其实就是保存每个文件的end_key的一个vector,每次插入都要保持这个vector的有序,每次查询时,返回一个upper_bound——用vector加上algorithm就可以做到了。
* .SST's LAYOUT:
* +--------+--------+--------+--------+
* | sst block 1 |
* +--------+--------+--------+--------+
* | sst block 2 |
* +--------+--------+--------+--------+
* | ... all the other blocks .. |
* +--------+--------+--------+--------+
* | sst block N |
* +--------+--------+--------+--------+
* | footer |
* +--------+--------+--------+--------+
*
* BLOCK's LAYOUT:
* +--------+--------+--------+--------+
* | key(定长) | offset
* +--------+--------+--------+--------+
*
* FOOTER's LAYOUT:
* +--------+--------+--------+--------+
* | last key |
* +--------+--------+--------+--------+
* | block count |
* +--------+--------+--------+--------+
* | crc |
* +--------+--------+--------+--------+
sst.c主要做了以下的工作:
1.当nessDB启动时,首先需要启动一个新的sst结构,遍历目录中的所有.sst文件,建立meta的vector,初始化布鲁姆过滤器。
2.查询一个key对应的value在.db文件中的位置。这需要先从meta中查找该key可能存在的sst文件,然后将整个文件进行map组成一个skiplist,在skiplist中进行查找。
3.merge。merge也正是LSM-Tree中“M”的意义所在。具体merge的代码在_flush_list中,对该函数的注释为
//例如1.sst的end_key是4,2.sst是8,3.sst是12
//那么当skiplist中的值是1,2,3,6,9,13,16,19时,应当如下插入
//meta_get是查找end_key比key要大(或者等于)的第一个sst文件
//对于1,它将被插入到1.sst的merge中,如果merge不存在,则首先读取1.sst来构建
//对于2,3也是一样,它发现1.sst已经放入到merge中了,所以直接插入merge中
//当key为6时,发现它需要插入到2.sst中,所以先把merge进行flush,然后打开2.sst构建merge,插入key6,对于key9也是同样的情况
//对于key13,会发现meta_info为NULL,也就是if的情况,那么13以后的键都不会存在于现有的.sst文件中了
//所以直接把13跟它以后的键加入新的sst文件中,但是3.sst的merge此时还存在,所以先要把merge写入然后return。
整体的思想就是将skiplist按照顺序插入到每个sst文件中。但是一个一个插入的话太浪费时间,有可能相邻的两个node需要被插入到同一个sst文件中,这样会先查找已经打开的sst文件所能包容的skiplist的node,然后将这些node一次性插入到sst文件的skiplis中。最后根据skiplist的大小决定是否需要将这个sst文件进行分裂。
有时merge使用的sst文件会和查询的sst文件是同一个文件,也就是说sst的文件编号同mutexer的lsn相同,这时候需要使用锁来保证读写的互斥性。
log.c 用于进行上一次内存中数据的恢复。内存中的数据主要存储在两种skiplist中——一种保存正在进行merge的数据,我们称它为后端skiplist;一种用来保存插入删除的数据,我们称它为后端skiplist。对应地,nessDB中最多有两个log文件,一个是同正在merge的skiplist中的数据相同的后端log,一个是同进行操作的skiplist的数据相同的前端log。
这样log.c就需要有以下的功能:
1.首先就需要有log的前后端切换,记录写入和记录读取的功能。注意这里的记录包括key值和value值。
2.对skiplist的内容进行记录,避免异常关机时内存中数据丢失。当重新启动时,扫描这个前端log文件,恢复内存中的数据
3.上次使用db时,有可能merge没有正常退出,则遍历后端log文件,继续进行上次未竟的merge。nessDB是在启动时将两种log的内容放入到一个skiplist中,然后进行merge
4.将完成任务的log删除。
5.db文件的写入也是通过log来实现的。
注意恢复的顺序应该是先将merge log放入skiplist中,然后才是active log。log在恢复时假定文件的遍历是按照文件编号来进行的(实际上应该也是这样的吧?),先给编号小的赋值为log_new,第二次扫描到的是log_old,然后应该先将log_new中的放入skiplist,然后是log_old,这里的变量名字可能颠倒了。另外ret后面的=应该改为+=比较合理。
index.c 是nessDB中内存索引的整个控制者。它主要有以下的功能:1.在db启动时,启动sst和log,首先需要先进行sst的恢复,因为log的恢复需要sst,然后恢复log。如果内存有记录(没有在上一次db运行时被flush),则进行sst的merge。
2.当内存中的skiplist满了之后,将此skiplist和它对应的log放入到后台线程中进行merge,主线程则新建一个skiplist和一个新的log对外提供服务。
3.提供内存索引的插入、查询接口。插入的步骤是:写入log文件->写入skiplist->写入布鲁姆。如果skiplist满了则触发merge操作。查询的步骤是:向布鲁姆查询->向active skiplist查询->向merging skiplist查询->向sst查询。db.c 加入了llru的index的封装。
由以上我们可以看到LSM-Tree之所以插入快速,是因为它把数据直接插入内存了,但是并没有直接进行索引文件的调整,它把调整放到一个适当的时机进行——其实最终也没有摆脱索引调整的命运。但是批处理确实给它带来了性能上的提升。不过这点提升,却很容易被查询操作的代价所掩埋,所以它适合的场景是频繁插入,少量查询,人们常说用空间换时间,显然,LSM-Tree则是使用了“内存堆积”来提高插入的吞吐量,是使用了内存的空间,来获得更好的用户体验。如果没有更好的优化方法,摘下它频繁插入的面纱,我们就会发现,其实这跟批处理日志根本没有差别。
nessDB是LSM-Tree的两组件实现。对于我们理解LSM-Tree很具有启发性。但是两组件的话,很明显是“lazy”不到位的。它的log的recovery值得借鉴,但还是有很多异常没有能处理。