HFile索引结构根据索引层级不同分为2种:Single-Level 和 Multi-Level。前者表示单级索引,后者表示多级索引,一般分为2级或者3级。之所以HFile v2引入多级索引是因为,随着HFile文件越来越大,Data Block越来越多,索引数据也越来越多,大到已经无法全部加载到内存,而多级索引可以只是加载部分索引,降低内存的使用空间。
Index Block分为2种:Root Index Block和 Non-Root Index Block.
Non-Root Index Block又分为Intermediate Index Block和Leaf Index
Block2种。HFile中索引结构类似于一棵树,Root Index Block表示索引数据根节点,Intermediate Index Block表示中间节点,Leaf Index Block表示叶子节点,叶子结点直接指向实际数据块。
对于Data Block,由于HFile刚开始数据量比较小,采用single-level结构,只有root index 一级索引,直接指向数据块。当数据量慢慢变大时,root index block满了之后,索引就会变成multi-level结构,由一级索引变成两级索引,根节点指向叶子节点,叶子结点指向实际数据块。如果数据量在变大,索引就会变成三级索引。
我们来着重分析Index Block结构:
一 Root Index Block
Root Index Block表示索引树根节点索引块,可以作为Bloom Block的直接索引,也可以作为Data Block索引的根索引。在Single-Level和Multi-Level中两种索引结构对应的Root Index Block稍有差异,如图示:
Index Entry: 表示具体的索引对象,每一个索引由BlockOffset,BlockDataSize,BlockKey三个字段组成。
# BlockOffset: 指的是当前索引指向数据块的偏移量
# BlockDataSize: 指的是索引指向的数据块在磁盘上的大小
# BlockKey: 指的是索引指向的数据块中的第一个key
如果在Multi-Level还有MidKey相关信息:
MidKey表示HFile中所有Data Block中中间的一个Data Block,用于对HFile进行split操作,快速定位HFile中间的位置。
Root Index Block会在HFile解析的时候,直接加载进内存,我们知道Trailer Block中有一个字段为dataIndexCount,就表示此处Index Entry个数。因为Index Entry并不定长,只有知道Entry的个数才能正确将所有Index Entry加载进内存。
二 Non-Root Index Block
当HFile越来越大的时候,Single-Level结构的索引,已经不足以支撑所有数据都加载到内存,需要分化为Multi-Level结构。Multi-Level结构中,Non-Root Index Block可作为中间节点,也可以直接作为叶子节点,他们都拥有相同的结构:
和Root Index Block相比,Non-Root Index Block也有Index Entry,用于指向叶子节点或者实际的数据块。对于Intermediate Index Block就是指向叶子节点,对于Leaf Index Block就是指向实际的数据块。但是Non-Root Index Block多了EntryOffset 和 numEntires
numEntires: 记录entry的数量
EntryOffset: 是Non-Root Index Block内部索引字段,表示Index Entry在该block中的相对偏移量,相对于第一个Index Entry,用于实现Block内部的二分查找,故针对Non-Root Index Block,在其内部定位一个key的具体索引,不是通过遍历而是通过二分查找实现,可以更加高效快速定位到待查找的key
三 索引的分裂
我们知道,数据量少的时候,文件小的时候,只需要一个Root Index
Block 就可以完成索引,即索引树只有一级。但是当数据不断写入的的时候,文件变大之后,索引数据也会相应变大,索引结构就会由Single-Level转化为Multi-Level,这期间会涉及到索引块写入和分裂,我们就分析一下数据写入是如何引起索引分裂的?
我们知道MemStore的flush主要分为三个阶段:
第一阶段:将MemStore的KeyValue数据进行snapshot
第二阶段: 再将这部分数据flush到HFile,并生成临时目录
第三阶段: 将临时文件移动到指定的Column Family目录下
在flush阶段又可以具体分为两个阶段:
Append阶段:MemStore中的KeyValue首先会写入到HFile中的数据块
# 预检查: 检查key的大小是否大于前一个key,如果大于则不符合HBase顺序排列,抛出异常;检查value是否为null,如果为null也会抛出异常
# block是否写满:检查当前Data Block是否已经写满,如果没有则直接写入KeyValue;否则需要执行数据块写入磁盘以及索引块修改操作
# 数据块落盘并修改索引: 如果Data Block已满,首先将block写入流;再生成一个Leaf Index Entry,写入Leaf Index Block;再检查该Leaf Index Block是否已经写满需要落盘,如果已经写满,就将该Leaf Index Block写入输出流,并且为索引树根节点Root Index Block新增一个索引,指向叶子节点
# 生成一个新的block:reset 输出流,初始化startOffset = -1
# 写入KeyValue: 将KeyVlaue以流的方式写入输出流,同时需要写入memstoreTS,除此之外,如果该key是第一个key,还要赋值给变量firstKeyInBlock
Finalized阶段: 修改HFile中meta元数据,索引块数据以及Trailer数据块
当数据落盘并修改索引会使得Root Index 不断增多,当增大到一定程度之后就需要分裂。
根节点索引指向叶子节点索引块。finalize阶段系统会对Root Index Block进行大小检查,如果大小大于规定的大小就需要进行分裂,图中分裂过程实际上就是将原来的Root Index Block块分割成4块,每块独立形成中间节点InterMediate Index Block,系统再重新生成一个Root Index Block(图中红色部分),分别指向分割形成的4个interMediate Index Block。此时索引结构就变成了third-level结构。
四HFile数据完整索引流程
了解了HFile中数据索引块的两种结构之后,就来看看如何使用这些索引数据块进行数据的高效检索。整个索引体系类似于MySQL的B+树结构,但是又有所不同,比B+树简单,并没有复杂的分裂操作。具体见下图所示:
图中上面三层为索引层,在数据量不大的时候只有最上面一层,数据量大了之后开始分裂为多层,最多三层,如图所示。最下面一层为数据层,存储用户的实际keyvalue数据。这个索引树结构类似于InnoSQL的聚集索引,只是HBase并没有辅助索引的概念。
图中红线表示一次查询的索引过程(HBase中相关类为HFileBlockIndex和HFileReaderV2),基本流程可以表示为:
1. 用户输入rowkey为fb,在root index block中通过二分查找定位到fb在’a’和’m’之间,因此需要访问索引’a’指向的中间节点。因为root index block常驻内存,所以这个过程很快。
2. 将索引’a’指向的中间节点索引块加载到内存,然后通过二分查找定位到fb在index ‘d’和’h’之间,接下来访问索引’d’指向的叶子节点。
3. 同理,将索引’d’指向的中间节点索引块加载到内存,一样通过二分查找定位找到fb在index ‘f’和’g’之间,最后需要访问索引’f’指向的数据块节点。
4. 将索引’f’指向的数据块加载到内存,通过遍历的方式找到对应的keyvalue。
上述流程中因为中间节点、叶子节点和数据块都需要加载到内存,所以io次数正常为3次。但是实际上HBase为block提供了缓存机制,可以将频繁使用的block缓存在内存中,可以进一步加快实际读取过程。所以,在HBase中,通常一次随机读请求最多会产生3次io,如果数据量小(只有一层索引),数据已经缓存到了内存,就不会产生io。