LSM Tree解析
引言
众所周知传统磁盘I/O是比较耗性能的,优化系统性能往往需要和磁盘I/O打交道,而磁盘I/O产生的时延主要由下面3个因素决定:
- 寻道时间(将磁盘臂移动到适当的柱面上所需要的时间,寻道时移动到相邻柱面移动所需时间1ms,而随机移动所需时间位5~10ms)
- 旋转时间(等待适当的扇区旋转到磁头下所需要的时间)
- 实际数据传输时间(低端硬盘的传输速率为5MB/ms,而高速硬盘的速率是10MB/ms)
近20年平均寻道时间改进了7倍,传输速率改进了1300倍,而容量的改进则高达50000倍,这一格局主要是因为磁盘中运动部件的改进相对缓慢和渐进,而记录表面则达到了相当高的密度。对于一个块的访问完全由寻道时间和旋转延迟所决定,所以花费相同时间访问一个盘块,那么取的数据越多越好。
带来的问题
由于操作磁盘的速度远远低于CPU和内存,并且差距越来越大,磁盘I/O已经成为很多系统的瓶颈;与此同时磁盘高速缓存也迅速增加,进而很大一部分读请求是直接来自文件系统高速缓存的,并不需要磁盘访问操作,I/O的优化很大程度上着手于对写操作的优化。
解决问题思路
I/O类型
磁盘I/O瓶颈可能出现在seek(寻道)和transfer(数据传输)上面。
根据磁盘I/O类型,关系型存储引擎中广泛使用的B树及B+树,而Bigtable的存储架构基础的会使用Log-Structured Merge Tree。
B树
B树是与红黑树类似的一颗平衡查找树,但在降低磁盘I/O操作次数方面更好一些,B树具有较低的深度,查找一个元素只需将很少节点从磁盘Load到内存,很快便能访问到要查找的数据。
- 每个节点x有以下域:
- 每个内节点x包含x.n+1个指向其子女的指针,叶节点没有子女,故他们子女的指针域无定义。
- 如果ki为存储在节点x子节点内的关键字则:
- 每个叶节点具有相同的深度,即树的高度h
- 每个节点所包含的关键字个数x.n包含一个上界和下界,用一个固定的整数t>=2来表式;
- 每个非根的节点至少包含t-1个关键字。每个非根的内节点至少有t个子女,如果树是非空的,则根节点至少包含一个关键字。
- 每个节点至多包含2t-1个关键字,所以说一个内节点至少包含2t个子女,我们说一个节点是满的,如果这个节点恰好包含2t-1个关键字。
B+树是B树的一个变种,B+树比B树更适合实现外存储索引结构,MySQL存储引擎普遍使用B+Tree实现其索引结构。内节点只包含键值以及指向子节点的指针,数据存储在叶子节点,所有记录节点都是按照键值的大小顺序存放在同一层的叶节点中,各节点指针进行连接(双向链表)。
如图:所有记录都在叶节点中,井且是顺序存放的,如果我们从最左边的
叶节点开始顺序遍历,可以得到所有镗值的顺序排序15、10、15、20、25、30、50、55、60、 65、 75、 80、 85、 90
B+树索引在数据库中有一个特点就是其高扇出性,因此数据库中B+树的高度一般都在2~3层*,也就是说对于查找某一键值的行记录,最多只需要2到3次IO。
性能分析
如果没有太多的写操作,B+树可以工作的很好,它会进行比较繁重的优化来保证较低的访问时间。而写操作往往是随机的,随机写到磁盘的不同位置上,更新和删除都是以磁盘seek的速率级别进行的。RDBMS通常都是Seek型的,主要是由用于存储数据的B树或者是B+树结构引起的,在磁盘seek的速率级别上实现各种操作,通常每个访问需要log(N)个seek操作
而LSM-tree工作在磁盘传输速率的级别上,可以更好地扩展到更大的数据规模上,保证一个比较一致的插入速率,因为它会使用日志文件和一个内存存储结构1,把随机写操作转化为顺序写。2,读操作与写操作是独立的,这样这两种操作之间就不会产生竞争。
在传输等量数据场景下,随机写I/O的时延大部分花费在了seek操作上,数据库对磁盘进行零碎的随机写会产生多次seek操作;而顺序存取只需一次seek操作,便可以传输大量数据,针对批量写入大量数据的场景,顺序写比随机写具有明显的优势。
The Log-Structured Merge-Tree(LSM-Tree)的一个重要思想就是通过使用某种算法,该算法会对索引变更进行延迟及批量处理,并通过一种类似于归并排序的方式高效地将更新迁移到磁盘适,进行批量写入,利用磁盘顺序写性能远好于随机写这一特点,将随机写转变为顺序写,从而保证对磁盘的操作是顺序的,以提升写性能,同时建立索引,以获取较快的读性能,在读和写性能之间做一个平衡。
LSM-Tree适用于那些写入频率远大于读取频率的场景,很多Nosql的比如HBase、LevelDB等系统都借鉴了LSM-Tree的思想实现的.
LSM-Tree Algorithm
算法介绍
LSM-Tree可以由多个组件组成,下面以2个组件的情形为例做简单介绍:
如上图所示:一个两组件的LSM-Tree包含一个内存组件C0和一个较大的被持久化在磁盘上面的组件C1。写入或者更新某条记录时,首先会预写日志,用于数据写入失败时进行数据恢复。之后该条记录会被插入到驻留在内存中的C0树,在符合某个条件的时候从被移到磁盘上的C1树中。
记录从C0移到C1中间肯定存在一定时间的延迟,由于这段时间内更新未做持久化,为了防止意外情况下内存中的数据丢失,所以记录恢复该插入操作的日志是很有必要的。
C0树不一定要具有一个类B-树的结构。首先,它的节点可以具有任意大小:没有必要让它与磁盘页面大小保持一致,因为C0树永不会位于磁盘上,因此我们就没有必要为了最小化树的深度而牺牲CPU的效率,一个2-3树或者是AVL树就可以作为C0树使用的一个数据结构。备注:HBase中采用了线程安全的ConcurrentSkipListMap数据结构。
向内存中的C0树插入一个条目速度是非常快的,因为操作不会产生磁盘I/O开销。然而用于C0的内存成本要远高于磁盘,通常做法是限制它的大小。采用一种有效的方式来将记录迁移到驻留在更低成本的存储设备上的C1树中。为了实现这个目的,在当C0树因插入操作而达到接近某个上限的阈值大小时,就会启动一个rolling merge过程,来将某些连续的记录段从C0树中删除,并merge到磁盘上的C1树中。
合并过程如上图所示:Rolling merge实际上由一系列的merge步骤组成。首先会读取一个包含了C1树中叶节点的multi-page block,这将会使C1中的一系列记录进入缓存。之后,每次merge将会直接从缓存中以磁盘页的大小读取C1的叶节点,将那些来自于叶节点的记录与从C0树中拿到的叶节点级的记录进行merge,这样就减少了C0的大小,同时在C1树中创建了一个新的merge好的叶节点。
磁盘上的C1树是一个类似于B-树的数据结构,但是它是为顺序性的磁盘访问优化过的,所有的节点都是满的,为了有效利用磁盘,在根节点之下的所有的单页面节点都会被打包放到连续的多页面磁盘块(multi-page block)上,将对一个页面的寻道时间分摊到多个页面上面。
merge后新的blocks会被写入到磁盘新的位置上,这样老的blocks就不会被覆盖,在crash发生后的恢复中就是可用的。C1中的父目录节点也会被缓存在内存中,此时也会被更新以反映出叶节点的变动,同时父节点还会在内存中停留一段时间以最小化IO;当merge步骤完成后,C1中的老的叶节点就会变为无效状态,之后会被从C1目录结构中删除。通常,每次都是C1中的最左边的叶节点记录参与merge,因为如果老的叶节点都是空的那么merge步骤也就不会产生新的节点,这样也就没有必要进行。除了更新后的目录节点信息外,这些最左边的记录在被写入到磁盘之前也会在内存中缓存一段时间,用于提供在merge阶段的并发访问和从crash后的内存丢失中进行恢复,为了减少恢复时的重构时间,merge过程需要进行周期性的checkpoints,强制将缓存信息写入磁盘。
随着时间的推进将会有更多的flush操作发生,会产生很多存储文件,一个后台进程负责将这些文件聚合成更大的文件,这样磁盘seek操作就限制在一定数目的存储文件上。存储在磁盘上的树结构也可以被分割成多个存储文件。因为所有的存储数据都是按照key排序的,因此在现有节点中插入新的keys时不需要重新进行排序。
查找通过merging的方式完成,首先会搜索内存存储结构,接下来是磁盘存储文件。通过这种方式,从客户端的角度看到的就是一个关于所有已存储数据的一致性视图,而不管数据当前是否驻留在内存中。删除是一种特殊的更新操作,它会存储一个删除标记,该标记会在查找期间用来跳过那些已删除的keys。当数据通过merging被重新写回时,删除标记和被该标记所遮蔽的key都会被丢弃掉。
用于管理数据的后台进程有一个额外的特性,它可以支持断言式的删除。也就是说删除操作可以通过在那些想丢弃的记录上设定一个TTL(time-to-live)值来触发。比如,设定TTL值为20天,那么20天后记录就变成无效的了。Merge进程会检查该断言,当断言为true时,它就会在写回的blocks中丢弃该记录。
不足之处
存在写放大问题,同一份数据随着合并的过程,可能会被写入磁盘多次。
HBase的实现
在HBase中,HFile要求写入的数据是按照KeyValue排好序的,而HDFS本身被设计为顺序读写(sequential reads/writes),写完之后便不允许修改。为了解决这个问题,HBase将最近接收到的数据缓存在内存中(in Memstore),在持久化到HDFS之前完成排序,然后再批量快速的顺序写入HDFS。
MemStore
MemStore是HBase中C0的实现,向HBase中写数据的时候,首先会写到内存中的MemStore,当达到一定阀值之后,这个MemStore会被冻结,不再响应写请求,产生一个新的MemStore来响应写请求,之前的MemStore会flush(顺序写)到磁盘,形成新的StoreFile,StoreFile又会进行merge.
用到Memstore最主要的原因是:HFile要求写入的数据是按照KeyValue排好序的,而HDFS本身被设计为顺序读写(sequential reads/writes),不允许修改。为了解决这个问题,HBase将最近接收到的数据缓存在内存中(in Memstore),在持久化到HDFS之前完成排序,然后再批量快速的顺序写入HDFS,相比传统数据库随机写来说,实际上只要一次寻址便可以将大量数据写到磁盘,加快了数据持久化的速度。
memstore内部维护了一个数据结构:ConcurrentSkipListMap<keyvalue, keyvalue=""> 实现了SortedMap接口,数据存储是按照Key排好序的跳跃列表是在很多应用中有可能替代平衡树而作为实现方法的一种数据结构。跳跃列表的算法有同平衡树一样的渐进的预期时间边界,并且更简单、更快速和使用更少的空间。
Skip list的性质:
- 由很多层结构组成,level是通过一定的概率随机产生的。
- 每一层都是一个有序的链表,默认是升序,也可以根据创建映射时所提供的Comparator进行排序,具体取决于使用的构造方法。
- 最底层(Level 1)的链表包含所有元素。
- 如果一个元素出现在Level i 的链表中,则它在Level i 之下的链表也都会出现。
- 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素
除(O(logn))完成写入、读取操作之外,还可以根据提供的Comparator进行顺序遍历,进而实现顺序写磁盘生成StoreFile。
HFile
HFlile是lsm tree中C1的实现,为了分析方便:逻辑上将HFile分成Index节点和Data节点;
索引记录和数据记录都是按照key(row,family,coqualifier,ts)字典序排好序的。
其中Index节点的每条记录,分别指向到不同的Block,包含以下信息:
- 如果是最底层的索引则指向数据块的第一个key,否则指向Index块的第一个key
- 数据块在HFile中的偏移(指针)
- 磁盘中数据块的大小,如果经过压缩,则是压缩后的大小
下面是索引记录样例。
1
2
3
4
5
6
7
8
9
10
11
|
key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaZI/info:city/LATEST_TIMESTAMP/Put
offset=2155038, dataSize=38135
key=aaaaaaaaaaaaaaaaabbbabbaaaabbbbaTU/info:city/LATEST_TIMESTAMP/Put
offset=4343199, dataSize=38358
key=aaaaaaaaaaaaaaaabbbababbbbababaaxe1T70L5R6F2/info:city/LATEST_TIMESTAMP/Put
offset=6539008, dataSize=38081
|
HFile逻辑上面是一棵满的B+树,发现如果root_index的size达到一定阀值(128K),便会加一级索引;
用pi来表式每条索引记录,则大体可以划出如下逻辑图:
较小的数据量,索引的深度会只有一层,随着表的不断变大,root_index的size达到一定阀值(128K),便会加一层索引。
对于查找过程,HFile中的KeyValue是排好序的,sorted意为数据的存储顺序是按照key的字符串的字典排列顺序升序组织的,并且对key构建了索引。检索时,把index block加载到内存中,通过二分查找找到对应的datablock,即在data index中查找当前查询的key可能在哪个data block中,之后将datablock装载到内存(为了提升性能会有缓存策略),接着顺序遍历这个data block,找到key及value。
通过对这些索引以树状结构进行组织,只让顶层索引常驻内存,其他索引按需读取并通过LRU cache进行缓存,这样就不需要将全部索引加载到内存。
性能思考
- 如果建表时,一个row对应多个列,或者多个版本,那么一个HFileBlock所能存放的keyvalue就会越少,尤其是寻找最新数据时,可能跨越多个块。
- 扫描操作会不停地将块加到缓存,如果多个进行扫描缓存块,而只有一个进程来淘汰块,那么很可能会发生内存溢出,所以在线下计算需要大量扫描的情况下,最好能关闭cache.
- StoreFile过多会影响读性能,因为一个读操作很可能会需要打开多个HFile,io次数太多,会影响性能
- HFile block越大越适合顺序扫描(大的快解压速度会变慢)但是随机读写性能降低,小的HFile block更加适合随机读写,但是需要更多地内存来存放index 默认(128KB)
- HFileV2的索引分成多层,虽然可以减少内存的使用量,加快启动速度,但是多了1-2次IO,尤其是HFile文件较大时,随机读很可能比V1要多2次IO操作,进而读取速度变慢。
合并操作
参考:
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.44.2782&rep=rep1&type=pdf
http://duanple.blog.163.com/blog/static/7097176720120391321283/