LSM-Tree的读写放大和空间放大

前言

本文主要是针对阅读 A-Comparison-of-Fractal-Trees-to-Log-Structured-Merge-LSM-Trees的一个记录,该文章通过对分形树和LSM-Tree在数据写入过程中的读写放大做一个比较,了解到不同场景下读写的放大情况。因为分形树是B树在读写放大的场景下的一个优化,而且主要是学习LSM-Tree,本文主要选取LSM-Tree和B+树 部分的内容。

基本概念

写放大:

  • 程序打算写入的数据量 / 实际写入的数量量

读放大:

  • 查找数据所需的IO次数

空间放大:

  • 需要使用的空间 / 实际数据量的大小

场景,在64G内存的机器上写入10Tb的数据量,其中,Block 大小为B,树的分支树为K,内存大小为M,数据库总大小为N

写放大

B 树的写放大

B树在写入过程中,每次都是按照页的大小写入,在一般场景下,如果需要写入的某行数据中的100bytes的数据量,则仍然需要修改整个页的数据,写放大就是 页大小/实际写入的数据量。如页大小是16Kb,那么100bytes的写放大就是160。

LSM-Tree的写放大

接下来是按照层级合并和大小合并做不同的分析,先简单的讨论下两者的区别。首先就是为什么需要合并?在LSM-Tree中,数据都是先写入内存在写入磁盘,如果不进行合并,那么数据重叠部分会特别的多,比如写入数据都是key都是a-z 开始的数据,那么每个文件都有一个a-z的数据,也就是说查询过程可能会在每个文件中查找。所以如何有效的将数据的重叠部分减少就能够更加高效的进行查询。

分层合并:

  • 数据会按层级进行管理,合并的过程是从小到大进行层级合并,如开始数据在0 层,合并和就会进入到1层,依次合并,每一层的数据量都是递增的,比如leveldb中的数据量在第2层以后都是10倍递增,到第7层可以管理10TB的数据
  • 分层管理可以很简单和高效的表现出数据的新旧,越低层级数据越新,被查询的概率越高。每一层的合并都会减少数据之间的重叠部分,增加查询效率

分大小合并:

  • 数据按照相同大小进行合并,每一份数据量是一定的,比如分4个级别的大小,如果是10Tb,那么第4级别就是10TB,第3层级可能是4个250GB到2.5TB的数据,如果出现当前大小接近的文件数量超过4个,则进行一次合并,一次类推。
  • 数据大小合并的吞吐会更高一些,因为在文件组织中不要求每个文件之间的数据重叠部分少,因为都是达到Size 就进行合并,也就是可以在一个层级持续写入,如上面的例子,如果是a-z轮询写入,那么同一层级的4个文件中都有可能包含当前查询的数据,所以查的性能相对要差一点。

按层进行合并的LSM-Tree

合并策略为按层合并,将数据分为N层,下一层是上一层的S倍递增(如LevelDB是10倍递增)。当层级的文件数或者大小达到阈值,则向上合并。

在一个文件作为一层的场景下,需要将上层文件读取,然后和当前需要合并的层进行一个Merge操作。也就是同一时间可能需要2倍的数据量大小的磁盘空间。

设当前递增的倍数为k,最小的层数的大小为B,那么一共会有 <math xmlns="http://www.w3.org/1998/Math/MathML"> Θ ( log ⁡ k N / B ) Θ(\log_kN/B) </math>Θ(logkN/B)层。 每个层级的数据至少被移出一次,但是该层级的数据会反复和上一层的数据进行合并,平均而言,首次写入到某个层级后,每个数据会与相当初层级的数据合并k/2次。因为当前上升到上一层级的数据中,下一层的数据每次合并上来,当前层级的数据被合并的次数为0次到K次,也就是可能每次合并都需要一起参与合并,也可能直到当前层级上升也没有合并,所以是0-k的区间,取平均数就是k/2 (个人理解,原文:Data must be moved out of each level once, but data from a given level is merged repeatedly with data from the previous level. On average, after being first written into a level, each data item is remerged back into the same level about ​ times. So the total write amplification is ​. )
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> k / 2 ∗ O ( l o g k N / B ) = O ( k l o g k N / B ) k/2*O(log_kN/B)=O(klog_kN/B) </math>k/2∗O(logkN/B)=O(klogkN/B)

按大小合并的LSM-Tree

如果大小的层级的分支数是4(相同大小的文件数据量达到阈值,默认是4),那么一共会有​个层级,数据会被写入到每一层级,所以写放大就是​,这个写放大被分层合并小的原因是分层合并的数据不仅仅会往上层写入,而且在本层还可能涉及到当前文件分裂或者合并的写入情况,大小分层则不存在这种情况。

读放大

B树的读放大

在B树中获取某个key的值的IO最多是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( l o g B N / B ) O(log_BN/B) </math>O(logBN/B) 也就是树的深度,而且因为B树只是在叶子结点存储数据,如果当前查询的叶子结点已经被缓存的话,那么读放大就是1。

LSM-Tree的读放大

按层进行合并的LSM-Tree

因为需要在每层进行二分查找,那么瑞国在最上层进行二分查询,那么他的读取次数为 <math xmlns="http://www.w3.org/1998/Math/MathML"> l o g N / B logN/B </math>logN/B第二大的层级中包含的数据量就是N/K,比如第7层是10TB,因子是10,第6层的数据量就是1TB,所以读取次数就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( l o g ( N / ( k B ) ) ) = O ( ( l o g N / B ) − l o g k ) O(log(N/(kB))) = O((logN/B)−logk) </math>O(log(N/(kB)))=O((logN/B)−logk) ​依此类推就是下面的公式。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> R = ( l o g N / B ) + ( l o g N / B − l o g k ) + ( l o g N / B − 2 l o g k ) + ⋅ ⋅ ⋅ + 1 = = O ( ( l o g 2 N / B ) / l o g k ) . R = (logN/B) + (logN/B−logk) + (logN/B−2logk) +···+1 == O((log^2N/B)/log_k). </math>R=(logN/B)+(logN/B−logk)+(logN/B−2logk)+⋅⋅⋅+1==O((log2N/B)/logk).

如果当前是10TB的数据,那么可能需要93次读IO。

PS:这里的值其实有一定的问题,因为在真正的实现过程中,并不会将所有的数据都在磁盘中查询,按层进行合并,可以记录每层中每个文件最大最小值,个人觉得写放大应该是K+size of(Level0),即最大的层数,也就是在内存中获取最大最小判断可能存在的位置,然后在每层的符合区间的文件中查询,因为level0 可能没有进行区间的分隔,所以可能需要每个文件都查询。当然还有BloomFilter的因素在里面也没有考虑

如果说数据被缓存到内存中,在64G的内存条件下,最多缓存前三层的数据,所以读放大是3

按大小合并的LSM-Tree

上文提到过,如果是按大小进行合并,则每层级之间的文件重叠区会变多,所以他的读放大比按层进行合并大,如果当前的文件数是:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> O ( k log ⁡ k N / B ) O(k\log_kN/B) </math>O(klogkN/B)

他的读取性能就会是按层查询的k倍,因为可能需要在每层的K个文件中查询:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> O ( k ( l o g 2 N / B ) / l o g k ) O(k(log^2N/B)/logk) </math>O(k(log2N/B)/logk)

原文说这种方式在100TB的数据量64GB的内存条件下需要17 次IO。个人觉得这个数据在大小合并的场景下如果去掉布隆过滤器的干扰是相对合理的,因为重叠区间太多。100TB的数据,那么可能就分为4个5T,4个10T,4个500G,4个250G,4个65G。那么在最后可能就是17次IO。

如果是有缓存的,那么他的读放大可能就是文件数,而且是在有bloom过滤器的加持下,这个值个人不是很理解,可以说如果某个Key没在文件中,那么可能就是所有文件数都查询一遍(查询过滤器)。当然,如果Levedb不在内存中记录文件对应的最大最小值,那么这个写放大也就等于最大的文件数了。

空间放大

B树的空间放大

虽然B数在写入过程中可能涉及到重平衡,比如当前的Page 是写满了数据的,但是部分的Page写的数据量只有1/2,那么平均下来填充就是3/4,也就是说空间需要总的数据量的4/3,分裂过程中对空间放大不是很明显,只是在节点分裂为多个节点的情况下可能出现空间放大。

LSM-Tree的空间放大

按层进行合并的LSM-Tree

个人觉得这个是按照文件的切割来的,比如每层中的数据量是2MB,在往上层进行合并的过程中,需要去掉合并的区间,也就是说本层的10%(因子是10)的文件都需要重新合并,那么需要的空间比所有的空间多出10%的合并区间,所以空间放大就是1.1。

按大小进行合并的LSM-Tree

大小合并的情况下,最坏的情况应该是当前层级数量-1,比如如果是4,那么空间放大的可能值是3,比如批量的写入某一段的数据,比如当前一直刷入a-z的数据,每次写入的key相同,只是value的值进行字节的变化,假设是在25Gb的层级上进行合并,第一次合并需要额外的25Gb存储更新后的文件,第二次使用新的25Gb文件和原来的第二个文件合并,最后留下的数据是25GB最后的文件,但是中间使用了75GB的数据。放大系数就是3.

最后的比较图

总结

在合并的过程中,应该是有很多的tradoff的。比如读多写少的文件,比较适合使用层级分区,因为他的空间放大比较小,而且相对于大小合并的情况下,读的性能相对较高,毕竟每个level中的文件其实重叠区间相对较少。

在LSM-tree中,文件合并的策略其实决定了读取的性能和使用的空间,因为他的顺序写入已经将写入的吞吐拉到了极致,但是因为需要后台处理,读取的过程中涉及到的文件,合并文件的选择都会给文件读取性能带来影响。

我能想到的优化的点:

  • 将合并的过程不携带Value,而是只做key值的合并,最后移动key的offset。集中处理Value的垃圾回收,相当于每次都可以在移动的同时记录当前Value的ref。
  • 在写入磁盘前尽量保证文件之间的区间重叠
  • 层级之间添加索引,这样可以直接通过本层确定下层的文件位置,但是感觉这个只是减少了二分查找的速度,不见得十分有效

LSM-Tree的合并很多人都在研究和写论文,本文只是做一个写放大,读放大和空间放大的概述。如果读者知道更好的方式可以告知下。

相关推荐
LunarCod7 个月前
LevelDB源码阅读笔记(1、整体架构)
linux·c++·后端·架构·存储·leveldb·源码剖析
码灵1 年前
java LevelDB工具类
java·leveldb
明悠小猪1 年前
LevelDB之Compaction
leveldb
明悠小猪1 年前
LevelDB之Version
后端·leveldb
明悠小猪1 年前
LevelDB之SSTable读写
后端·leveldb
明悠小猪1 年前
LevelDB之SSTable 数据结构
后端·leveldb
明悠小猪1 年前
LevelDB 之MemTable
后端·leveldb
明悠小猪1 年前
LevelDB之Log
后端·leveldb
明悠小猪1 年前
LevelDB的SkipList实现
后端·leveldb