Overview

  • HBase中的一个big table,首先会按行划分成一些region(这些region之间是有序的,由startkey保证),每个region分配到不同的节点进行存储。因此,region是HBase分布式和负载均衡的最小单元。
  • 对每个节点而言,它会对分配到的region是按列族进行存储的。也即,region被分为多个store(对应多个列族)。而store内部,又有一个memStore和多个storeFiles组成
  • 数据首先更新到memStore,memStore会内排序,而storeFile是由memStore flush到磁盘的,所以每个storeFile内部都是有序的。但是,本质上HBase进行的都是append操作(删除并不是真正的删除,而是打上delete的tag),所以是无法保证storeFiles之间有序的
  • storeFiles之间无序,那么当storeFiles个数过多时,必然造成效率下降,因此会对storeFiles做merge操作。也即当 StoreFile 文件数量超过设定值时,会触发合并操作,合并成一个大文件。
  • 同样的,当region的大小达到阈值时,会被切分开,生成一个新的region,HMaster会对其进行管理,分配到合适的 regionserver。region的变化后,系统还需要对hbase:meta 表进行维护

HBase:meta

  • 在Hbase的老版本中,是包含-root-和.meta.表的,每次索引数据都要进行三次定位。在新版本中改成了只有HBase:meta表,它存在于Zookeeper中。
  • 当我们在HBase中查找某rowkey时,首先要定位其所在的RegionServer,这就需要通过HBase:meta表来实现了。
  • HBase:meta表本质上也是一张HBase表。

HBase Read

  • 以上,查找某rowkey时:
    1. 通过Zookeeper中的HBase:meta表定位其所在region(RegionServer);【因为region之间是有序的,因此可以根据startKey来定位】
    2. 在RegionServer中查找:首先是BlockCache,然后是MemStore,再然后是StoreFiles(StoreFiles之间虽是无序的,但每个StoreFile内部是有序的)。

LSM

  • 参考:LSM树及其到HBase的应用
  • LSM全称是基于日志结构的合并树(Log-structured merge-Tree). NoSQL数据库一般采用LSM作为数据结构,HBase也不例外。
  • 众所周知,RDBMS一般采用B+树作为索引的数据结构。RDBMS中的B+树一般是3层n路的平衡树。B+树的节点对应于磁盘数据块。因此对于RDBMS,数据更新操作需要5次磁盘操作(从B+树3次找到记录所在数据块,再加上一次读和一次写)。在RDBMS中,数据随机无序写在磁盘块中,如果没有B+树,读性能会很低。B+树对于数据读操作能很好地提高性能,但对于数据写,效率不高。对于大型分布式数据系统,B+树还无法与LSM树相抗衡。
  • 讲LSM树之前,需要提下三种基本的存储引擎,这样才能清楚LSM树的由来

    • 哈希存储引擎是哈希表的持久化实现,支持增、删、改以及随机读取操作,不支持顺序扫描,对应的存储系统为key-value存储系统。对于key-value的插入以及查询,哈希表的复杂度都是O(1),明显比树的操作O(n)快,如果不需要有序的遍历数据,哈希表就是your Mr.Right。
    • B树存储引擎是B树的持久化实现,不仅支持单条记录的增、删、读、改操作,还支持顺序扫描(B+树的叶子节点之间的指针),对应的存储系统就是关系数据库(Mysql等)。
    • LSM树(Log-Structured Merge Tree)存储引擎和B树存储引擎一样,同样支持增、删、读、改、顺序扫描操作。而且通过批量存储技术规避磁盘随机写入问题。当然凡事有利有弊,LSM树和B+树相比,LSM树牺牲了部分读性能,用来大幅提高写性能
  • LSM树的设计思想非常朴素:将对数据的修改增量保持在内存中,达到指定的大小限制后将这些修改操作批量写入磁盘,不过读取的时候稍微麻烦,需要合并磁盘中历史数据和内存中最近修改操作,所以写入性能大大提升,读取时可能需要先看是否命中内存,否则需要访问较多的磁盘文件
  • LSM树原理把一棵大树拆分成N棵小树,它首先写入内存中,随着小树越来越大,内存中的小树会flush到磁盘中,磁盘中的树定期可以做merge操作,合并成一棵大树,以优化读性能
  • LSM数据更新只在内存中操作,没有磁盘访问,因此比B+树要快。对于数据读来说,如果读取的是最近访问过的数据,LSM树能减少磁盘访问,提高性能。
  • LSM中的数据删除不会去删除磁盘上的数据,而是为数据添加一个删除标记。在随后的major compaction中,被删除的数据和删除标记才会真的被删除。
  • 以上这些大概就是HBase存储的设计主要思想,这里分别对应说明下:

    • 因为小树先写到内存中,为了防止内存数据丢失,写内存的同时需要暂时持久化到磁盘对应了HBase的MemStore和HLog;【数据首先顺序写如hlog (WAL), 然后写到MemStore】
    • MemStore上的树达到一定大小之后,需要flush到HRegion磁盘中(一般是Hadoop DataNode),这样MemStore就变成了DataNode上的磁盘文件StoreFile,定期HRegionServer对DataNode的数据做merge操作,彻底删除无效空间,多棵小树在这个时机合并成大树,来增强读性能。【数据读首先搜索MemStore,如果不在MemStore中,则到storefile中寻找】
  • hbase在实现中,是把整个内存在一定阈值后,flush到disk中,形成一个file,这个file的存储也就是一个小的B+树,因为hbase一般是部署在hdfs上,hdfs不支持对文件的update操作,所以hbase这么整体内存flush,而不是和磁盘中的小树merge update,这个设计也就能讲通了。内存flush到磁盘上的小树,定期也会合并成一个大树。整体上hbase就是用了lsm tree的思路。

HFile

  • 所有block块都拥有相同的数据结构,HBase将block块抽象为一个统一的HFileBlock。HFileBlock支持两种类型,一种类型不支持checksum,一种不支持。
    • 如上图: BlockHeader用于存储block元数据,BlockData存储具体数据。
    • block元数据中最核心的字段是BlockType字段,用来标示该block块的类型,HBase中定义了8种BlockType,每种BlockType对应的block都存储不同的数据内容,有的存储用户数据,有的存储索引数据,有的存储meta元数据。
    • 下面介绍几种blockType
      • DataBlock是HBase中数据存储的最小单元。数据块:保存表中的数据,每一个数据块由块头和一些keyValue(record)组成,key的值是严格按照顺序存储的。块大小默认为64K(由建表时创建cf时指定或者HColumnDescriptor.setBlockSize(size)。大的Block有利于顺序Scan,小Block利于随机查询,因而需要权衡 。),这一部分可以压缩存储。在查询数据时,是以数据块为单位从硬盘load到内存。查找数据时,是顺序的遍历该块中的keyValue对。
      • BloomFilter Meta Block & Bloom Block:BloomFilter对于HBase的随机读性能至关重要,对于get操作以及部分scan操作可以剔除掉不会用到的HFile文件,减少实际IO次数,提高随机读性能。但是,一个HFile文件越大,里面存储的KeyValue值越多,位数组就会相应越大。一旦太大就不适合直接加载到内存了,因此HFile V2在设计上将位数组进行了拆分,拆成了多个独立的位数组(根据Key进行拆分,一部分连续的Key使用一个位数组)。这样一个HFile中就会包含多个位数组,根据Key进行查询,首先会定位到具体的某个位数组,只需要加载此位数组到内存进行过滤即可,减少了内存开支。
      • 数据索引块:Data Block的索引,每条索引的key是被索引的block的第一条记录的key。HBase对于hfile的访问都是通过索引块来实现的,通过索引来定位所要查的数据到底在哪个数据块里面。类似bloom filter块,当单个的索引块中没有办法存储全部的数据块的信息时,索引块就会分裂,会产生叶索引块和根索引块,根索引块是对叶索引块的索引,如果数据块继续增加就会产生枝索引块,整个索引结果的层次也会加深。【

        想象一下,如果整个hfile中只有根索引块,那么访问真正的数据的路径是,首先查根索引块定位数据块的位置,然后去查询数据块找到需要的数据。整个过程涉及到一次对索引块的扫描和一次对数据块的扫描

        如果hfile总块比较多,整个索引结构有2次的话,访问的路径是,首先访问根索引块定位叶索引块,访问叶索引块定位数据块,整个过程涉及到两次对索引块的扫描和一次对数据块的扫描。

        整个索引树的深度越深,那么访问过程就越长,相应的扫描的时间也会越长。

        那是不是把hfile.index.block.max.size设置得越大越好呢?也不是的,如果索引块太大了,对索引块本身的扫描时间就会显著的增加的。

 整个查询过程

  • 首先,分析下一个相同的cell可能存在的位置

    • 新写入的cell:memStore

    • flush到HDFS中的cell,而存在于某个StoreFile

    • 对于刚读过的cell,他可能存在于BlockCache

  • so,只需要扫描上述三个地方,将结果合并即可(merge read)。在HBase中扫描的顺序依次是:BlockCache、MemStore、StoreFile

读取

  1. 首先根据region的startKey定位所查找的rowkey所属的region,进而去相应的RegionServer
  2. 然后在RegionServer的BlockCache中查找,看是否缓存命中。【这里不对?client端有缓存】
  3. 根据所查找的列族确定要查找的MemStore和StoreFile。
    1. 首先查找memStore
    2. 然后查找StoreFiles:
      1. 过滤StoreFiles:对所有的StoreFiles,首先根据其BloomFilter(如果存在的话,否则,根据时间戳或者查询列的信息来进行过滤)过滤掉部分不可能存在该rowKey的StoreFiles。
      2. 二次过滤StoreFiles:缩小之后,仍然存在多个可能的StoreFiles需要查找。StoreFiles之间是无序的,所以不能是直接对StoreFiles进行顺序。hbase会首先查看每个storefile的最小的rowkey,然后按照从小到大的顺序进行排序,结果放到一个队列中,排序的算法就是按照hbase的三维顺序,按照rowkey,column,ts进行排序,rowkey和column是升序,而ts是降序。之后会对各个storefile中的数据进行探测,只会扫描那些存在比当前查询的rowkey大的记录的storefile
      3. 查询:整个过程用到了类似归并排序的算法,首先找到key所在的HFile【如何定位到key所在的HFile?】。范围缩小到该HFile后,就根据上面介绍的索引查找定位到块,快速的找到对应的记录。