今天讲述一下HBase相关的知识。首先HBase作为大数据发展初期伴随Google三大论文问世的一个组件,在今天依旧被广泛的应用,今天我们来仔细的分析一下HBase的内部原理,了解一下HBase的具体内幕,以便在工作中更好使用它。以下内容涉及到的源码基于HBase 的Master分支编译出的最新的3.0.0版本。
HBase相关算法与数据结构基础知识
跳跃表
暂时先不说跳跃表是什么,在Java里面有一个Map叫:ConcurrentSkipListMap,通过对HBase的源码跟踪,我们发现这些地方使用了它:
简单的列了几个,但是观察这几个类所在的模块就可以发现,HBase从客户端,到请求处理,到元数据再到文件存储贯穿HBase的整个生命周期中的各个重要环节,都能看到它的身影,Map那么多,为何偏偏HBase选择了这个?接下来我们仔细分析下。
在算法概念里面有一种数据结构叫跳跃表,顾名思义,之所以叫跳跃表就是因为在查找的时候可以快速的跳过部分列表,用来提升查找效率,跳跃表的查找效率可以和二叉树相比为O(log(N)),这个算法的实现在Java中的ConcurrentSkipListMap就是实现跳跃表的相关算法。
首先我们看一个有序的全量表:
假设我们要从中找出 可以发现需要比较的次数(比较次数不是循环次数)为<3,5,8>共计16次,可以看到对这样一个有序链表进行查找比较的次数会非常多,那么有没有办法对这种查找做优化。当然是有的,对于这种查找耳熟能详从数据结构的基础课程开始大家就知道二叉树,折半查找,值查找都属于解决这类问题的方法,自然跳跃表也是解决这类问题的方法之一。
跳跃表的思路和如今大部分大数据组件像kylin对海量数据下的快速查找的解决思路非常相似,都是通过某种逻辑提前将部分数据做预处理,然后查找的时候进行快速匹配,典型的空间换时间,那么对于跳跃表来说,它的预处理的方式如下:
可以看到,跳跃表是按照层次构造的,最底层是一个全量有序链表,依次向上都是它的精简版,而在上层链表中节点的后续下一步的节点是以随机化的方式进行的,因此在上层链表中可以跳过部分列表,也叫跳跃表,特点如下:
- 链表层从上往下查找
- 跳跃表由很多层组成
- 每一层都是一个有序链表
- 最底层是全量有序链表
- 如果一个元素出现在上层的某个节点那么它一定会出现在下层的链表
- 每一个元素都有两个指针,一个指向当前链表的下一个元素,一个指向下一层链表的相同节点
假设根据前面的图表我们要查询G这个字母,那么在上面的跳跃表中经过的路径如下:
其中红色代表查找所走过的路径。
LSM树
前面讲到了跳跃表的原理,在HBase中较大规模的使用了跳跃表,就是为了增快其查找效率,除了跳跃表之外HBase还使用到了LSM树,LSM树本质上和B+相似,是一种存储在磁盘上的数据的索引格式,但是差异点在于LSM对写入非常高效,实现来说就是无论什么样的写入LSM都是当成一次顺序写入,这一点和HDFS的优点正好契合,HDFS不支持随机写,支持顺序写。LSM数据存储在两个地方,一个是磁盘上一个是内存中,内存中同样使用的跳跃表,内存中是多个有序的文件。
HBase对LSM的应用采用了如上的结构方式,对于HBase具体的存储文件的分析,在后面专门针对HBase的存储部分进行深入的分析。
布隆过滤器
布隆过滤器解决的问题是,如何快速的发现一个元素是否存在于某个集合里面,最简单的办法就是在集合或者链表上查找一遍,但是考虑到在大数据场景下,数据量非常大,即便不考虑性能,也不见得会有足够多的机器来加载对应的集合。所以需要一种新的思路去解决此类问题,那么布隆过滤器就是一种,它的思想为:
- 由一个长度为N的数组构成,每一个元素为0或者1,默认都为0
- 对集合的每个元素做K次哈希,到第i次的时候对N取一个模,会得到一个index
- 将数组中的array[index]变为1
上图是长度为18,进行3次哈希得到的结果,那么在HBase中是如何利用布隆过滤器的呢,首先从操作来说,HBase的Get就经过布隆过滤器,同时HBase支持度对不同的列设置不同的布隆过滤器。
可以看到对HBase来讲可以启用或者禁用过滤器,对于不同的过滤器的实现分别在不同的类中,在查询的时候分别根据不同的过滤器采用不同的实现类:
所以可以通过如上的代码找到对应的过滤器实现,甚至可以新增自己的过滤器。
HBase读写操作
前面提到HBase的相关算法,现在我们讲一下HBase的整个操作的读写流程。首先,摆出HBase的架构图,如下所示:
从这个图可以看到,HBase的一个写操作,大的流程会经过三个地方:1. 客户端,2. RegionServer 3. Memstore刷新到磁盘。也就是说对于HBase的一次写入操作来讲,数据落到Memstore就算写入完成,那么必然需要考虑一个问题,那就是没有落盘的数据,万一机器发生故障,这部分数据如何保障不丢失。解析来我们逐步分解一下这三个部分。
客户端:HBase的客户端和服务器并不是单一的链接,而是在封装完数据后,通过请求HMaster获取该次写入对应的RegionServer的地址,然后直接链接RegionServer,进行写入操作,对于客户端的数据封装来讲,HBase支持在客户端设置本地缓存,也就是批量提交还是实时提交。因为HBase的hbase:meta表中记录了RegionServer的信息,HBase的数据均衡是根据rowkey进行分配,因此客户端会根据rowkey查找到对应的RegionServer,定义在Connection中:
而实现在:AsyncRegionLocator
RegionServer写入:当客户端拿到对应的RegionServer后,便和HMaster没有关系了,开始了直接的数据传输,我们前面提到一个问题,那就是HBase如何防止数据丢失,毕竟HBase的写入是到内存,一次请求就返回了,解决这个问题是通过WAL日志文件来解决的,任何一次写入操作,首先写入的是WAL,这类日志存储格式和Kafka类似的顺序追加,但是具有时效性,也就是当数据落盘成功,并且经过检查无误之后,这部分日志会清楚,以保障HBase具有一个较好的性能,当写完日志文件后,再写入Memstore。
那么在RegionServer的写入阶段会发生什么呢?首先我们知道,HBase是具有锁的能力的,也就是行锁能力,对于HBase来讲,HBase使用行锁保障对同一行的数据的更新要么都成功要么都失败,所以在RegionServer阶段,会经过以下步骤:
- 申请行锁,用来保障本次写入的事务性
- 更新LATEST_TIMESTAMP字段,HBase默认会保留历史的所有版本,但是查询过滤的时候始终只显示最新的数据,然后进行写入前提条件的检查:
以上相关操作的代码都在HRegion,RegionAsTable中,可以以此作为入口去查看,所以这里就不贴大部分的代码了。
写入WAL日志文件,在WALProvider中定义了两个方法:
append用来对每一次的写入操作进行日志追踪,因为有事物机制,所以HBase会将一次操作中的所有的key value变成一条日志信息写入日志文件,aync用来同步将该日志文件落盘到HDFS的文件系统,入场中间发生失败,则立即回滚。
4. 写入Memstore,释放锁,本次写入成功。
所以可以看到对于HBase来讲写入通过日志文件再加Memstore进行配合,最后HBase自身再通过对数据落盘,通过这样一系列的机制来保障了写入的一套动作。
讲完了HBase的写入操作,再来看看HBase的读取流程。
对于读来讲,客户端的流程和写一样,HBase的数据不会经过Master进行转发,客户端通过Master查找到元信息,再根据元信息拿到meta表,找到对应的Region Sever直接取数据。对于读操作来讲,HBase内部归纳下来有两种操作,一种是GET,一种是SCAN。GET为根据rowkey直接获取一条记录,而SCAN则是根据某个条件进行扫描,然后返回多条数据的过程。可以看到GET经过一系列的判断,例如检查是否有coprocessor hook后,直接返回了存储数据集的List:
那么我们再看SCAN就不那么一样了,可以看到,对于SCAN的操作来讲并不是一次的返回所有数据,而是返回了一个Scanner,也就是说在HBase里面,对于Scan操作,将其分成了多个RPC操作,类似于数据的ResultSet,通过next来获取下一行数据。
HBase文件格式
前面讲了HBase的操作流程,现在我们看下HBase的存储机制,首先HBase使用的HDFS存储,也就是在文件系统方面没有自身的文件管理系统,所以HBase仅仅需要设计的是文件格式,在HBase里面,最终的数据都是存储在HFile里面,HFile的实现借鉴了BigTable的SSTable和Hadoop的TFile,一张图先展示HFile的逻辑结构:
可以看到HFie主要由四个部分构成:
- Scanned block section: 顾名思义,表示顺序扫描HFile时所有的数据块将会被读取,包括Leaf Index Block和Bloom Block。
- Non-scanned block section: 表示在HFile顺序扫描的时候数据不会被读取,主要包括Meta Block和* Intermediate Level Data Index Blocks两部分。
- Load-on-open-section: 这部分数据在HBase的region server启动时,需要加载到内存中。包括FileInfo、Bloom filter block、data block index和meta block index。
- Trailer: 这部分主要记录了HFile的基本信息、各个部分的偏移值和寻址信息。
对于一个HFile文件来讲,最终落盘到磁盘上的时候会将一个大的HFile拆分成多个小文件,每一个叫做block块,和HDFS的块相似,每一个都可以自己重新设定大小,在HBase里面默认为64KB,对于较大的块,在SCAN的时候可以在连续的地址上读取数据,因此对于顺序SCAN的查询会非常高效,对于小块来讲则更有利于随机的查询,所以块大小的设置,也是HBase的调参的一个挑战,相关的定义在源码里面使用的HFileBlock类中,HFileBlock的结构如下所示:
每一个block块支持两种类型,一种是支持Checksum的,一种是不支持Checksum的,通过参数usesHBaseChecksum在创建block的时候进行设置:
HFileBlock主要包含两个部分,一个是Header一个是Data,如下图所示:
BlockHeader主要存储block元数据,BlockData用来存储具体数据。前面提到一个大的HFile会被切分成多个小的block,每一个block的header都相同,但是data不相同,主要是通过BlockType字段来进行区分,也就是HFile把文件按照不同使用类型,分成多个小的block文件,具体定义在BlockType中,定义了支持的Type类型:
下面我们仔细分解一下HBase的Data部分的存储,HBase是一个K-V的数据库,并且每条记录都会默认保留,通过时间戳进行筛选,所以HBase的K-V的格式在磁盘的逻辑架构如下所示:
每个KeyValue都由4个部分构成,而Key又是一个复杂的结构,首先是rowkey的长度,接着是rowkey,然后是ColumnFamily的长度,再是ColumnFamily,之后是ColumnQualifier,最后是时间戳和KeyType(keytype有四种类型,分别是Put、Delete、 DeleteColumn和DeleteFamily),而value相对简单,是一串纯粹的二进制数据。
最开始的时候我们介绍了布隆过滤器,布隆过滤器会根据条件减少和跳过部分文件,以增加查询速度:
每一个HFile有自己的布隆过滤器的数组,但是我们也会发现,这样的一个数组,如果HBase的块数足够多,那么这个数组会更加的长,也就意味着资源消耗会更多,为了解决这个问题,在HFile里面又定义了布隆过滤器的块,用来检索对应的Key需要使用哪个数组:
一次get请求进来,首先会根据key在所有的索引条目中进行二分查找,查找到对应的Bloom Index Entry,就可以定位到该key对应的位数组,加载到内存进行过滤判断。
HBase RegionServer
聊完了HBase的流程和存储格式,现在我们来看一下HBase的RegionServer,RegionServer是HBase响应用户读写操作的服务器,内部结构如下所示:
一个RegionServer由一个HLog,一个BlockCache和多个Region组成,HLog保障数据写入的可靠性,BlockCache缓存查询的热点数据提升效率,每一个Region是HBase中的数据表的一个分片,一个RegionServer会承担多个Region的读写,而每一个Region又由多个store组成。store中存储着列簇的数据。例如一个表包含两个列簇的话,这个表的所有Region都会包含两个Store,每个Store又包含Mem和Hfile两部分,写入的时候先写入Mem,根据条件再落盘成Hfile。
RegionServer管理的HLog的文件格式如下所示:
HLog的日志文件存放在HDFS中,hbase集群默认会在hdfs上创建hbase文件夹,在该文件夹下有一个WAL目录,其中存放着所有相关的HLog,HLog并不会永久存在,在整个HBase总HLog会经历如下过程:
- HLog构建: 任何写入操作都会先记录到HLog,因此在发生写入操作的时候会先构建HLog。
- HLog滚动: 因为HLog会不断追加,所以整个文件会越来越大,因此需要支持滚动日志文件存储,所以HBase后台每间隔一段时间(默认一小时)会产生一个新的HLog文件,历史HLog标记为历史文件。
- HLog失效: 一旦数据进入到磁盘,形成HFile后,HLog中的数据就没有存在必要了,因为HFile存储在HDFS中,HDFS文件系统保障了其可靠性,因此当该HLog中的数据都落地成磁盘后,该HLog会变为失效状态,对应的操作是将该文件从WAL移动到oldWAl目录,此时文件依旧存在,并未进行删除。
- HLog删除: hbase有一个后台进程,默认每间隔一分钟会对失效日志文件进行判断,如果没有任何引用操作,那么此时的文件会被彻底的从物理删除。
对于RegionServer来讲,每一个RegionServer都是一个独立的读写请求服务,因此HBase可以水平增加多个RegionServer来达到水平扩展的效果,但是多个RegionServer之间并不存在信息共享,也就是如果一个海量任务计算失败的时候,客户端重试后,链接新的RegionServer后,整个计算会重新开始。
HBase怎么用
虽然HBase目前使用非常广泛,并且默认情况下,只要机器配置到位,不需要特别多的操作,HBase就可以满足大部分情况下的海量数据处理,再配合第三方工具像phoenix,可以直接利用HBase构建一套OLAP系统,但是我们还是要认识到HBase的客观影响,知道其对应的细节差异,大概来说如果我们使用HBase,有以下点需要关心一下:
- 因为HBase在RegionServer对写入的检查机制,会导致客户端在符合条件的情况下出现重试的情况,所以对于较为频繁的写入操作,或者较大数据量的写入操作,推荐使用直接产生HFlie然后load到HBase中的方式,不建议直接使用HBase的自身的Put API。
- 从使用来讲如果业务场景导致HBase中存储的列簇对应的数据量差异巨大,那么不建议创建过多的列簇,因为HBase的存储机制会导致不同列簇的数据存储在同一个HBase的HFile中,但是split机制在数据量增加较大的情况下,会发生拆分,则会导致小数据量的列簇被频繁的split,反而降低了查询性能。
- RegionServer是相互独立的,所以如果想要让集群更加的稳定高效,例如如果想实现RegionServer集群,达到信息共享,任务增量计算,需要自己修改RegionServer的代码。
- 对于HBase来讲,很多场景下,像如果Region正在Split,或者Mem正在Dump,则无法进行对应的操作,此时错误信息会被以异常的形式返回到客户端,再由客户端进行重试,因此在使用过程中,需要结合我们的应用场景,考虑如何设置类似于buffer大小的参数,以尽可能少的降低因为内部操作引起的客户端重试,特别是在使用类似opentsdb的这类集成hhbase的数据的情况下。
结尾
HBase有着非常庞大的架构体系,和较为不错的使用体验,因此使用一篇文章通常很难讲述清楚整个HBase内幕,但是我们可以根据理解逐步渗透到HBase内部,了解这个组件背后的原理,这样当我们在使用它的时候就会变得更加的得心应手。经验也不是一日构成,需要我们日复一日的不断练习,在HBase不断推出的新版下,琢磨和理解它的原理和架构。Apache下有非常多的组件可以实现差不多的功能,但是每一个组件又有着自己独特的特点,本章我们介绍了HBase,后续会逐步分解介绍像Kylin,HDFS,Yarn,以及Atlas等组件。