http://blog.sina.com.cn/s/blog_693f08470101njc7.html
http://blog.xiuwz.com/2012/04/09/large-web-algorithms-2/
总结:lsm-tree就是在内存中用多个树缓存数据的更新,当内存满时,在将多个树进行合并,写入磁盘。
如果是一个运行时间很长的b树,那么几乎所有的请求,都是随机io。因为磁盘块本身已经不再连续,很难保证可以顺序读取。磁盘本身是一个顺序读写快,随机读写慢的系统,
当写读比例很大的时候(写比读多),LSM树相比于B树有更好的性能。因为随着insert操作,为了维护B树结构,节点分裂。读磁盘的随机读写概率会变大,性能会逐渐减弱。而LSM,具有批量特性,存储延迟,C0为内存组件,Cn为磁盘组件(Cn可以有多个,并且是从小到大,逐个合并),当C0 size达到阀值时,才会把insert批量刷新到磁盘。多次单页随机写,变成一次多页随机写。复用了磁盘寻道时间,极大提升效率。(C0和Cn都是有序树,查询效率为logN)
平衡树,可以看到基本上一个元素下只有两个子叶节点
抽象的来看,树想要达成有效查找,势必需要维持如下一种结构:
树的子叶节点中,左子树一定小于等于当前节点,而当前节点的右子树则一定大于当前节点。只有这样,才能够维持全局有序,才能够进行查询。
这也就决定了只有取得某一个子叶节点后,才能够根据这个节点知道他的子树的具体的值情况。这点非常之重要,因为二叉平衡树,只有两个子叶节点,所以如果想找到某个数据,他必须重复更多次“拿到一个节点的两个子节点,判断大小,再从其中一个子节点取出他的两个子节点,判断大小。”这一过程。
这个过程重复的次数,就是树的高度。那么既然每个子树只有两个节点,那么N个数据的树的高度也就很容易可以算出了。
平衡二叉树这种结构的好处是,没有空间浪费,不会存在空余的空间,但坏处是需要取出多个节点,且无法预测下一个节点的位置。这种取出的操作,在内存内进行的时候,速度很快,但如果到磁盘,那么就意味着大量随机寻道。基本磁盘就被查死了。
而b树,因为其构建过程中引入了有序数组,从而有效的降低了树的高度,一次取出一个连续的数组,这个操作在磁盘上比取出与数组相同数量的离散数据,要便宜的多。因此磁盘上基本都是b树结构。
不过,b树结构也不是完美的,与二叉树相比,他会耗费更多的空间。在最恶劣的情况下,要有几乎是元数据两倍的格子才能装得下整个数据集(当树的所有节点都进行了分裂后)。
(索引通常是非常大的,不能全部放入内存,对于二叉树而言,最极端的情况是,节点分布的很散,所以每次读取节点都要进行一次随机的磁盘读取。而对于B树而言,每个节点都存放一个有序数组,这个数组往往比较大,这样的话,树的高度就大大降低,通常可以看做高度为2,这样在查询的时候只需要磁盘读取2次,一次读取一个数组)
B树的缺点是:
1. 会占用更多的空间
2.
但如果有更新插入删除等综合写入,最后因为需要循环利用磁盘块,所以会出现较多的随机io.大量时间消耗在磁盘寻道时间上。
如果是一个运行时间很长的b树,那么几乎所有的请求,都是随机io。因为磁盘块本身已经不再连续,很难保证可以顺序读取。
首先来分析一下为什么b+树会慢。
从原理来说,b+树在查询过程中应该是不会慢的,但如果数据插入比较无序的时候,比如先插入5 然后10000然后3然后800 这样跨度很大的数据的时候,就需要先“找到这个数据应该被插入的位置”,然后插入数据。这个查找到位置的过程,如果非常离散,那么就意味着每次查找的时候,他的子叶节点都不在内存中,这时候就必须使用磁盘寻道时间来进行查找了。更新基本与插入是相同的
那么,LSM Tree采取了什么样的方式来优化这个问题呢?
简单来说,就是放弃磁盘读性能来换取写的顺序性。
乍一看,似乎会认为读应该是大部分系统最应该保证的特性,所以用读换写似乎不是个好买卖。但别急,听我分析之。
1. 内存的速度远超磁盘,1000倍以上。而读取的性能提升,主要还是依靠内存命中率而非磁盘读的次数
2. 写入不占用磁盘的io,读取就能获取更长时间的磁盘io使用权,从而也可以提升读取效率。
因此,虽然SSTable降低了了读的性能,但如果数据的读取命中率有保障的前提下,因为读取能够获得更多的磁盘io机会,因此读取性能基本没有降低,甚至还会有提升。
而写入的性能则会获得较大幅度的提升,基本上是5~10倍左右。
下面来看一下细节
其实从本质来说,k-v存储要解决的问题就是这么一个:尽可能快得写入,以及尽可能快的读取。
让我从写入最快的极端开始说起,阐述一下k-v存储的核心之一—树这个组件吧。
我们假设要写入一个1000个节点的key是随机数的数据。
对磁盘来说,最快的写入方式一定是顺序的将每一次写入都直接写入到磁盘中即可。
但这样带来的问题是,我没办法查询,因为每次查询一个值都需要遍历整个数据才能找到,这个读性能就太悲剧了。。
那么如果我想获取磁盘读性能最高,应该怎么做呢?把数据全部排序就行了,b树就是这样的结构。
那么,b树的写太烂了,我需要提升写,可以放弃部分磁盘读性能,怎么办呢?
简单,那就弄很多个小的有序结构,比如每m个数据,在内存里排序一次,下面100个数据,再排序一次……这样依次做下去,我就可以获得N/m个有序的小的有序结构。
在查询的时候,因为不知道这个数据到底是在哪里,所以就从最新的一个小的有序结构里做二分查找,找得到就返回,找不到就继续找下一个小有序结构,一直到找到为止。
很容易可以看出,这样的模式,读取的时间复杂度是(N/m)*log2N 。读取效率是会下降的。
这就是最本来意义上的LSM tree的思路。
那么这样做,性能还是比较慢的,于是需要再做些事情来提升,怎么做才好呢?
于是引入了以下的几个东西来改进它
1. Bloom filter : 就是个带随即概率的bitmap,可以快速的告诉你,某一个小的有序结构里有没有指定的那个数据的。于是我就可以不用二分查找,而只需简单的计算几次就能知道数据是否在某个小集合里啦。效率得到了提升,但付出的是空间代价。
2. 小树合并为大树: 也就是大家经常看到的compact的过程,因为小树他性能有问题,所以要有个进程不断地将小树合并到大树上,这样大部分的老数据查询也可以直接使用log2N的方式找到,不需要再进行(N/m)*log2n的查询了。
这就是LSMTree的核心思路和优化方式。
今天来聊聊lsm tree,它的全称是log structured merge tree ,简单来说,lsm tree可以认为是针对传统b树在磁盘写入上低劣表现的一种优化,其核心思想的核心就是放弃部分读能力,换取写入的最大化能力。所以你可以看到几乎所有的nosql都在跟b树拼写入速度和延迟。这是为什么呢? 看了今天的文章大家就应该能够有个比较清晰的认识了:)
要了解lsm面临并解决的问题,那么就需要先随我一起回顾两个问题:
1、磁盘的技术特性。对磁盘来说,能够最大化的发挥磁盘技术特性的使用方式是:一次性的读取或写入固定大小的一块数据,并尽可能的减少随机寻道这个操作的次数。
2、b树的写入过程。对b树的写入过程是一次原位写入的过程,主要分为两个部分,首先是查找到对应的块的位置,然后将新数据写入到刚才查找到的数据块中,然后再查找到块所对应的磁盘物理位置,将数据写入回去。当然,在内存比较充足的时候,因为b树的一部分可以被缓存在内存中,所以查找块的过程有一定概率可以在内存内完成,不过为了表述清晰,我们就假定内存很小,只够存一个b树块大小的数据吧。可以看到,在上面的模式中,需要两次随机寻道(一次查找,一次原位写),才能够完成一次数据的写入,代价还是很高的。
这就是下面了解LSM所需的必要前置知识了,可以很容易的看出,传统b树在写入的时候需要两次的随机寻道,这两次寻道会导致写入性能存在比较大的瓶颈,导致传统B树的写入效率比较低。
那么,有没有办法能够对这个bad case进行调优呢?自然而然的就会想到,如果能够减少或不进行随机寻道,不就能够自然而然的提升写入效率了么?
这就需要我们来进行一下分析,看看这些寻道主要做了什么事儿,以及如果省略了会有什么不良的影响。
第一次随机寻道的主要作用是找到那个块的数据需要被替换。
如果我们省去第一次随机寻道这个操作,那么会有以下的两个影响:
1,无法完成sql中insert类的语意,该语义要求如果待插入的数据存在就抛异常说主键冲突,如果不存在就插入。 这个操作要求先找到当前数据的最新值并进行存在性判断后才能完成插入。而如果不进行第一次查找,则这个操作是无法进行的。
2,无法保证数据块内的有序性,原因是一个数据块其实是包含了一组个数有限的有序数据的(包括待写入的数据本身),如果没有取出待写入的目标块,那么自然就不知道要待写数据的前后都是哪些数据了。
举个例子: 开始的时候有9个数据,0,1,2|3,4,5|6,7,9。 每三个组织成一个块,也就是0,1,2组成一个块;3,4,5组成一个块;6,7,9组成一个块。 那么,对一个b树而言,如果他要写入一个数据8,他需要先取出第三个block(6,7,9),然后判断这个block是否有足够的空间写入,发现最大只有仨位置,于是就需要进行分裂。变成俩block,然后将新的数据写入。最后写出来的样子可能类似0,1,2|3,4,5|6,7,空|8,9,空
然而,如果没有查找这个步骤,那么我们自然的就不知道8这个数的周围是哪几个数了,也就不可能进行分裂等操作了。
第二次随机寻道的作用是在内存满了的情况下,将数据刷写回到磁盘的原有位置。
当内存写满之后,我们面临的最主要问题就是如何解决刷磁盘的时候有哪些策略。
第一种策略可以叫做原位写入,也就是把数据尽可能的写回到原来的位置。 这种方式的最大好处是不会有空间的过多浪费,原来占用多大的空间,那么只要不分裂,还是占用那么多空间,没有过多的空间浪费。同时,查询也能保证是O(log2n)不会过多劣化,这个我们在队尾写的时候再进行比较。 这也是目前innodb等常见数据库在使用的B-Tree结构。
第二种模式叫做队尾写入,也就是不做原位替代,只将新写入的数据追加到整个数据的尾部。使用这种方式的好处是可以减少一次写入时的随机寻道时间,加快写入的速度,不过,这样做会有个最大的问题在于,数据的老值还存在于数据块中,这些数据也会占用额外的空间。。
例如,开始的时候有9个数据,0,1,2|3,4,5|6,7,9。 每三个组织成一个块,也就是0,1,2组成一个块;3,4,5组成一个块;6,7,9组成一个块。那么这时候要写一个数据8 ,再写个数据9',再写个数据比如5',在队尾写的时候,他就是很简单的直接将数据按照3个一组的方式放在队尾,那么最后数据可能类似这样: 树1:0,1,2|3,4,5|6,7,9; 树2:5',8,9' 。
这样我们就必须处理两个问题,一个是如何读取到数据的最新值,另外一个问题在于如何解决空间额外浪费比较严重的问题。
对于数据最新值的问题,
第一种能想到的做法是每次都将内存中的树和硬盘中的树做合并后写到新的位置,并更新父节点的指针。 这种方的好处是查询可以得到很好的性能,劣势在于磁盘增删次数多了以后,会存在磁盘空洞比较严重的问题,需要在compaction的时候做好磁盘整理,同时对范围查询不友好,因为数据在多次增删之后是随机跳跃存在的,所以读取时候的随机io会变的更多。
第二种能想到的做法是分开大的有序树和小的有序树,对于上面的例子而言,我们可以认为,“0,1,2|3,4,5|6,7,9” 是一棵大的有序树,而5',8,,9' 是一颗小的有序树。内存内也可以有一个或多个有序树的结构。这样,在每颗树的时间复杂度都是O(log2n) ,而如果写入比较重,那么存在了N个小的有序树没有合并,那么读取一个数据的时候,可以根据时间顺序倒着去多个树中依次查找,那么只要能找到数据,就一定是该数据的最新值了。
而对于空间额外浪费比较严重的问题,一般的解决方式就是再开一个线程, 利用合并排序的方式将多个小的有序数树进行合并后的追加写。 因为这个操作也是后台异步并行进行的,并且写出来的数据也是顺序有序的,所以也可以尽可能的降低磁盘的随机寻道次数。 如使用上面的例子,那么这次归并就是把 “0,1,2|3,4,5|6,7,9” 这个有序树与"5',8,,9' " 这个有序树做归并排序,最后得到的磁盘结构类似 “树1:0,1,2|3,4,5|6,7,9;树2:5',8,9' ;树3:0,1,2|3,4,5'|6,7,8|9'" ,然后因为树3已经合并完成并包含了树1和树2 的最新值,于是树1和树2就可以被标记为删除了,最后磁盘中留下的就是”树3:0,1,2|3,4,5'|6,7,8|9'" ,一般我们会有一个专门的英语单词来代表这个过程:compation。使用队尾追加的数据结构的人就太多了, cassandra , levelDB , hbase 等等都是使用这种结构来写入数据的,因此你会发现使用这样的数据结构的存储引擎,在写入效率上都明显的好于原位写,而读取则因为需要查找更多棵在磁盘的树而会使用更多的磁盘io.
然后把这两两次随机寻道做一次结合。就能发现几个合理的解决思路:
解1. 如果需要sql中的insert操作,那么就必须进行第一次随机寻道。
解2. 如果使用队尾写入的时候,是不需要进行第二次随机寻道的。结合解1,就可以完全的避免在写入时的随机寻道了,代价是读效率的降低或者更多的碎片问题。
解3. 针对原位写,可以进行将原来单独写回的操作变成批量写回操作。这样就可以提升顺序写的概率,从而提升写入效率。
这基本上就是目前我们能够为磁盘做的一切了:)
了解了核心思路之后,下面我们来看看我们经常看到的三个词汇的具体含义,就比较容易的可以理解了:
第一个是LSM Tree ,这个概念就是结构化合并树的意思,它的核心思路其实非常简单,就是假定内存足够大,因此不需要每次有数据更新就必须将数据写入到磁盘中,而可以先将最新的数据驻留在磁盘中,等到积累到最后多之后,再使用归并排序的方式将内存内的数据合并追加到磁盘队尾(因为所有待排序的树都是有序的,可以通过合并排序的方式快速合并到一起)。这就是LSM Tree的核心定义。
对应到上面的就是解2的所有可能性实现。
第二个概念是Merge-Dump 模型的LSM Tree, 就我目前理解而言,这个概念表述的是一种特殊的LSM tree实现,对应在LSM Tree论文里应该是 Multi-Component LSM Tree 。LSM Tree有个最大的问题就是如果合并速度比较慢,写入速度比较快,那么可能一次合并周期内用户所写入的数据会远远大于内存的大小,这样就不得不需要一个非常大的内存空间才能满足lsm tree的写入要求,这明显是不可能的。为了解决这一矛盾,能够想到的方案就是定期将内存数据直接刷写到磁盘尾部并将内存清空,这样就可以保证数据以磁盘最大吞吐量写入而不会出现内存溢出的情况。 并且可以让合并操作异步化,从而让写入更平滑。代价则是在查询时会消耗更多的随机io查询。
对应到上面就是解2的一个特例。
第三个概念就是SSTable. 就我目前理解而言,这个概念表述的是一种特殊的Merge-Dump模型的LSM Tree , 关键特征是key是按照字典序排好的”字符串“的merge-dump树:)。
也对应上面的解2特例的一个特例。
第四个概念是传统b树,
一般来说传统b树的优化策略是解3,也就是原位更新,使用分裂合并的策略来完成数据数据在磁盘上的扩展。
对应上面策略中的解3.
了解了核心概念,我们再分别针对几个标志性的场景做一下扩展性分析。
首先回答的一个问题是为什么原位的存储结构,例如innodb,会在写入删除多次之后性能下降呢?
在大家经常能够看到的性能指标中,都会看到innodb这类原位写的磁盘存储结构会在使用一段时间以后出现效率下降的问题,而hbase/cassandra 等所使用的sstable则不会出现这类的下降,那么自然就会有个疑问,这是为什么呢? 其实原因不复杂,核心的问题在于磁盘空洞,在多次写入之后,数据在磁盘上可能是类似这样的 ”0,1,2|空洞|8,9,11|空洞|4,5,6|",虽然在逻辑上,B树是个有序的树状结构,但是在实际磁盘上数据却是按照块组织的, 在进行分裂的时候,会从空池中找出一个能放下数据的块,然后就进行写入,这样如果经过多次写入擦除轮回之后,可进行连续写入的空间就会变的很少,于是就会导致一些看起来是顺序写的过程,在磁盘上变成了随机写。从而导致了写入性能的下降。需要做后台数据整理,把空闲块变得更连续,才有可能再次提升写入性能。
第二个问题是针对Merge-Dump结构的存储结构,是不是读取都是烂的没法要了?
这个问题的答案是否定的,因为虽然因为是队尾追加小树,所以要找到数据的最新值的时候,读取的时间复杂度从原来的O(log2n)变成了O(N*log2n) ,效率直线下降了很多,不过我们仍然有很多方式来对读取进行一些优化,让这件事变得没有那么的夸张。
那么为了能够提升读取效率,可以考虑去做的事情有以下几个:
第一件事就是一个设计良好的数据缓存系统,在大部分情况下,如果你的查询能够命中缓存,那么将在内存以O(log2n)即可命中并返回数据。在目前内存越来越大的机器上而言,这是一个最直接而简单的提升Merge-Dump结构的读取效率的方式。
第二件事是一个设计良好的compaction实现,这其实才是Merge-Dump数据结构写入效率的关键,因为在大部分情况下,compaction过程会与写入线程争抢磁盘的io资源,如果两个事情同时进行,甚至还会额外的增加随机io次数。因此,如何能够平衡和高效的完成磁盘compaction的过程,是一个存储引擎实现好坏的关键。另外的一个关键的问题是在什么时候触发compaction. 触发的时间早了,会造成资源争抢,而触发的时间晚了。会造成大量磁盘空间的浪费,目前主流的实现中,基本上都会以sstable的个数或者时间点作为触发条件。各位可以按照自己的实际需要进行配置,以达到最佳的处理效果。
第三件事就是可以考虑增加bloom-filter来提升查找效率,我在这里只说bloom-filter能做到的事情和具体举一个例子,不展开具体实现方式,各位感兴趣可以自行查询。bloom-filter在这里的作用在于能够以O(1)的效率判断一个待查的数据是否在一个小树中。 那么在一大堆小树中,我们要查找某个数据是否在某个小树中,只需要以O(N)的效率遍历这些小树就可以快速的找到这个数据具体在哪个树中,比使用O(N*log2n)的查找效率就快了不少,代价是额外的写入时cpu消耗,以及额外的一些空间。
第三个问题是,是不是用了Merge-Dump模型的LSM Tree实现的存储结构实现的数据库,效率就一定会有很大的提升了?
是,也不是
首先,假定没有任何其他优化,那么使用Merge-Dump模型的LSM Tree实现的数据库的在实现"insert"这个语义的时候,也是需要一次随机寻道来判断“数据是否存在”的, 这额外增加了成本。所以你会发现NoSQL里面默认提供的语义是put , 也就是无论数据是否存在,都将数据以覆盖写入的方式写入到存储中,这种方式是针对merge-dump而言消耗最小的写入模式。
其次,数据库中还有一块比较重的逻辑在事务关系,也就是以MVCC的方式保证针对一条或多条数据的多次更新能够保持一个统一而一致的用户状态。 这会额外的增加一些开销,从而降低了系统的整体性能。
最后一个问题是:我应该选择哪个?
答案还是选合适的。没有一种数据结构能够包打天下,Merge-Dump类的数据结构(如sstable),优势在写入速度快,劣势在读取消耗资源大。而原位写的数据结构,如innodb类的结构,优势在于读写比较平衡,尤其对于读取有比较大的优化。
就阿里集团的实际使用经验来说,如果你日常的写入与读取操作的比率小于1:2,那么选择写优化的存储结构是非常合适的。而如果超过这个阈值,那么选择面向读取优化的数据结构是最为合适的。
做一下简单的小结,LSM Tree和传统B树最大的区别就在于数据合并的策略,是采用原位替换的模式,还是采用合并追加到队尾的方式来进行数据合并。采用追加队尾的方式,对于磁盘写入来说会有极大的性能提升,代价一般是读取效率的降低。而对于传统B树来说,读取和写入则更为平衡,适合一般性场景,不适合写入读取比率比较高的场景。
最后,还是老规矩,让我们以综合评价作为本篇的结束。
1.是否支持范围查找
因为是有序结构,所以能够很好的支持范围查找。
2.集合是否能够随着数据的增长而自动扩展
可以,与Btree的方式一致
3.读写性能如何
对于写入来说,Merge-Dump类的数据结构能够尽可能的减少写入时的磁盘寻道次数,所以写入能够到达磁盘吞吐量的上限,写入比较平稳,而原位写的数据结构则会存在磁盘空洞问题,导致写入性能有一定下降。
对于读取来说,Merge-Dump不占优势,当然因为有cache和compaction ,所以效率不会下降很多,但是因为每次读取需要与写入线程争用磁盘随机寻道次数,所以他们之间也会相互影响,从而降低性能。
对于原位写的数据结构来说,查询的效率能够做到固定的随机寻道次数,比较理想。
4.是否面向磁盘结构
一般来说,在有内存的情况下,root层和branch里面的一部分都会被缓存在内存中,所以如果树的高度是三层,那么前两层一般都会被缓存在内存中,所以查询基本上只需要一次随机寻道时间,是一个面向磁盘的结构
5.并行指标
对于LSM类数据结构来说,并行指标主要由两个部分组成,第一个部分是内存中树的并行指标,第二个是在磁盘中树的并行指标。
以目前主流的实现而言,都可以做到比较好的并行。
6.内存占用
这个主要看在内存中选择了哪个数据结构,以及在内存中维持了几个小树,小树越多,那么内存额外消耗的就多,而如果内存中小树比较少,那么内存中的消耗就少。
The Log-Structured Merge-Tree
在说LSM-Tree之前,首先说下该算法的来由。在上一章节中,提到了非常经典的B+树数据模型。B+树非常适合磁盘存储,也很适合顺序写入,但对于随机写则是其致命伤。业界针对B+树随机写的优化,通常有两种方式:
- 采用SSD等硬件设备。
- 软件解决方案,比如说针对特定应用开发特定模块实现内存的Merge Dump(在目前部分公司都有采用这种内存dump+mysql的方式来提升更新能力),还有就是这个经典的LSM-Tree了。
LSM-Tree软件解决方案的基本原理都非常简单,就是在内存中对最近的更新操作进行缓存,当更新积累到一定程度,然后进行批量的merge dump,从而把随机写变成批量顺序更新。典型的架构如下图:
在上图中有几个关键点:
1、memtable。内存结构映像,更新都是首先更新到memtable。
2、tablet log,也称为redo log。主要用于在异常情况下,对memtable的重建。
3、读操作需要从sstable和memtable中读取,并进行merge返回。
LSM-Tree最早是由Patrick O’Neil 等在1996年提出来的,论文在这里。中文版本可以参考这里:上、中、下。下面是一个LSM-Tree的merge过程示意图。
在实际应用中,都会对LSM-Tree进行一些优化,比如说leveldb中lsm-tree和skip-list的结合、bigtable在merge过程中并发控制等等。同时也基于此产生了大量相关的算法,比如说cache-oblivious B-tree、buffer tree等等。推荐看看Tokutek的这几篇文章:Detailed review of Tokutek storage engine、Performance of Fractal-TreeDatabases。