参考文献:
[1] 《HBase原理与实践》胡争,范欣欣 著
1. 前言
介绍大数据技术的原理书籍有很多,但《HBase原理与实践》这本书真的是我觉得写得特别棒的一本。条理清晰,内容简单明了,个人认为是这两年当之无愧的No.1。
本来想在文章里放个京东链接的,然而发现自己无法开通广告权限,想买的童鞋自行搜索。
在之前大致读过该书后,决定细读第二遍。这次要把所有的知识点都整理下来,记在脑子里。
2. HBase架构
hbase-arch.png
HBase体系结构借鉴了Google的BigTable论文,这是众所周知的。它是典型的Master-Slave模型。系统中有一个管理集群的Master节点,以及大量实际提供存储和读写服务的RegionServer节点。HBase中所有数据最终都存储在HDFS上,这与BigTable实际数据存储在GFS中对应。另外还使用了ZooKeeper协助Master进行集群管理。
2.1 HBase客户端
提供了Shell、Java API、Thrift/REST API接口以及MapReduce接口。
2.2 ZooKeeper
与Hadoop一样,协助HBase实现高可用,当Master宕机了以后,ZooKeeper会帮忙选出新的Master。
除此之外,ZooKeeper还管理系统核心元数据:比如,管理当前系统中正常工作的RegionServer集合,保存系统元数据表hbase:meta所在的RegionServer地址等。
HBase还通过ZooKeeper实现分布式表锁。HBase对一张表进行各种管理操作需要先上锁,这是通过ZooKeeper来实现的。
2.3 Master
Master主要负责HBase系统的各种管理工作:
- 处理用户的各种管理请求:包括建表、修改表、权限操作、切分表、合并数据分片以及Compaction等。
- 管理所有RegionServer,包括RegionServer中Region的负载均衡,RegionServer的宕机恢复,和Region的迁移等。
- 清理过期WAL。
2.4 RegionServer
是HBase最核心的模块。由WAL(HLog)、BlockCache和多个Region构成。
- WAL(HLog):HLog在HBase中有两个核心作用:其一,用于实现数据高可靠性。,HBase数据随机写入时,并非直接写入HFile数据文件,而是先写入缓存,再异步刷新落盘。为了防止缓存数据丢失,数据写入缓存之前需要首先顺序写入HLog,这样,即使缓存数据丢失,仍然可以通过HLog日志恢复;其二,用于实现HBase集群间主从复制,通过回放主集群推送过来的HLog日志实现主从复制。
- BlockCache:HBase的读缓存。客户端从磁盘读取数据之后通常会将数据缓存到系统内存中,后续访问同一行数据可以直接从内存中获取而不需要访问磁盘。对于带有大量热点读的业务请求来说,缓存机制会带来极大的性能提升。
BlockCache缓存对象是一系列Block块,一个Block默认为64K,由物理上相邻的多个KV数据组成。BlockCache同时利用了空间局部性和时间局部性原理,前者表示最近将读取的KV数据很可能与当前读取到的KV数据在地址上是邻近的,缓存单位是Block(块)而不是单个KV就可以实现空间局部性;后者表示一个KV数据正在被访问,那么近期它还可能再次被访问。当前BlockCache主要有两种实现——LRUBlockCache和BucketCache,前者实现相对简单,而后者在GC优化方面有明显的提升。 - Region:数据表的一个分片。当数据表大小超过一定阈值就会“水平切分”,分裂为两个Region。Region是集群负载均衡的基本单位。通常一张表的Region会分布在整个集群的多台RegionServer上,一个RegionServer上会管理多个Region,当然,这些Region一般来自不同的数据表。
每个Store由一个MemStore和一个或多个HFile组成。MemStore称为写缓存,用户写入数据时首先会写到MemStore,当MemStore写满之后(缓存数据超过阈值,默认128M)系统会异步地将数据flush成一个HFile文件。显然,随着数据不断写入,HFile文件会越来越多,当HFile文件数超过一定阈值之后系统将会执行Compact操作,将这些小文件通过一定策略合并成一个或多个大文件。
2.5 HDFS
HBase底层依赖HDFS存储。HBase内部封装了一个名为DFSClient的HDFS客户端组件,负责对HDFS的实际数据进行读写访问。
3. HBase的优缺点
3.1 优点
- 容量巨大:HBase的单表可以支持千亿行、百万列的数据规模,数据容量可以达到TB甚至PB级别。
- 良好的可扩展性:HBase集群可以非常方便地实现集群容量扩展,主要包括数据存储节点扩展以及读写服务节点扩展。HBase底层数据存储依赖于HDFS系统,HDFS可以通过简单地增加DataNode实现扩展,HBase读写服务节点也一样,可以通过简单的增加RegionServer节点实现计算层的扩展。
- 稀疏性:HBase支持大量稀疏存储,即允许大量列值为空,并不占用任何存储空间。
- 高性能:HBase目前主要擅长于OLTP场景,数据写操作性能强劲,对于随机单点读以及小范围的扫描读,其性能也能够得到保证。对于大范围的扫描读可以使用MapReduce提供的API,以便实现更高效的并行扫描。
- 多版本:HBase支持多版本特性,即一个KV可以同时保留多个版本,用户可以根据需要选择最新版本或者某个历史版本。
- 支持过期:HBase支持TTL过期特性,用户只需要设置过期时间,超过TTL的数据就会被自动清理,不需要用户写程序手动删除。
- Hadoop原生支持:HBase是Hadoop生态中的核心成员之一,很多生态组件都可以与其直接对接。HBase数据存储依赖于HDFS,这样的架构可以带来很多好处,比如用户可以直接绕过HBase系统操作HDFS文件,高效地完成数据扫描或者数据导入工作;再比如可以利用HDFS提供的多级存储特性(ArchivalStorageFeature),根据业务的重要程度将HBase进行分级存储,重要的业务放到SSD,不重要的业务放到HDD。或者用户可以设置归档时间,进而将最近的数据放在SSD,将归档数据文件放在HDD。另外,HBase对MapReduce的支持也已经有了很多案例,后续还会针对Spark做更多的工作。
3.2 缺点
- HBase本身不支持很复杂的聚合运算(如Join、GroupBy等)。如果业务中需要使用聚合运算,可以在HBase之上架设Phoenix组件或者Spark组件,前者主要应用于小规模聚合的OLTP场景,后者应用于大规模聚合的OLAP场景。
- HBase本身并没有实现二级索引功能,所以不支持二级索引查找。好在针对HBase实现的第三方二级索引方案非常丰富,比如目前比较普遍的使用Phoenix提供的二级索引功能。
- HBase原生不支持全局跨行事务,只支持单行事务模型。同样,可以使用Phoenix提供的全局事务模型组件来弥补HBase的这个缺陷。
4. 基本概念和数据结构
4.1 KeyValue存储格式
HBase最底层的数据存储格式。
hbase-kv.png
总体来说,字节数组主要分为以下几个字段。其中Rowkey、Family、Qualifier、Timestamp、Type这5个字段组成KeyValue中的key部分。
- keyLen:占用4字节,用来存储KeyValue结构中Key所占用的字节长度。
- valueLen:占用4字节,用来存储KeyValue结构中Value所占用的字节长度。
- rowkeyLen:占用2字节,用来存储rowkey占用的字节长度。
- rowkeyBytes:占用rowkeyLen个字节,用来存储rowkey的二进制内容。
- familyLen:占用1字节,用来存储Family占用的字节长度。
- familyBytes:占用familyLen字节,用来存储Family的二进制内容。
- timestamp:占用8字节,表示timestamp对应的long值。
- type:占用1字节,表示这个KeyValue操作的类型,HBase内有Put、Delete、DeleteColumn、DeleteFamily,等等。注意,这是一个非常关键的字段,表明了LSM树内存储的不只是数据,而是每一次操作记录。
- qualifierBytes:占用qualifierLen个字节,用来存储Qualifier的二进制内容。注意,HBase并没有单独分配字节用来存储qualifierLen,因为可以通过keyLen和其他字段的长度计算出qualifierLen。
qualifierLen = keyLen(整个key的长度) - 2B(存储rowkeyLen的字节) - rowkeyLen(存储rowkeyBytes) - 1B(存储familyLen的字节) - familyLen(存储familyBytes) - 8B(存储timestamp) - 1B(存储type)
在比较KeyValue的大小顺序时,HBase按照如下方式确定大小关系:
int compare(KeyValue a,KeyValue b) {
int ret = Bytes.compare(a.rowKeyBytes, b.rowKeyBytes);
if (ret != 0) return ret;
ret = Bytes.compare(a.familyBytes, b.familyBytes);
if (ret != 0) return ret;
ret = Bytes.compare(a.qualifierBytes, b.qualifierBytes);if (ret != 0) return ret;
//注意:timestamp越大,排序越靠前
ret = b.timestamp - a.timestamp;
if (ret != 0) return ret;
ret = a.type - b.type;
return ret;
}
注意,在HBase中,timestamp越大的KeyValue,排序越靠前。因为用户期望优先读取到那些版本号更新的数据。
4.2 跳跃表
跳跃表(SkipList)是一种能高效实现插入、删除、查找的内存数据结构,这些操作的期望复杂度都是O(logN)。与红黑树以及其他的二分查找树相比,跳跃表的优势在于实现简单,而且在并发场景下加锁粒度更小,从而可以实现更高的并发性。正因为这些优点,跳跃表广泛使用于KV数据库中,如Redis、LevelDB。
4.3 LSM树
LSM树本质上和B+树一样,是一种磁盘数据的索引结构。但和B+树不同的是,LSM树的索引对写入请求更友好。因为无论是何种写入请求,LSM树都会将写入操作处理为一次顺序写,而HDFS擅长的正是顺序写(且HDFS不支持随机写),因此基于HDFS实现的HBase采用LSM树作为索引是一种很合适的选择。LSM树的索引一般由两部分组成,一部分是内存部分,一部分是磁盘部分。内存部分一般采用跳跃表来维护一个有序的KeyValue集合。磁盘部分一般由多个内部KeyValue有序的文件组成。
4.3.1 LSM树的索引结构
一个LSM树的索引主要由两部分构成:内存部分和磁盘部分。内存部分是一个ConcurrentSkipListMap,Key就是前面所说的Key部分,Value是一个字节数组。数据写入时,直接写入MemStore中。随着不断写入,一旦内存占用超过一定的阈值时,就把内存部分的数据导出,形成一个有序的数据文件,存储在磁盘上。
hbase-lsm.png
LSM树索引结构如图28所示。内存部分导出形成一个有序数据文件的过程称为flush
。为了避免flush影响写入性能,会先把当前写入的MemStore设为Snapshot,不再容许新的写入操作写入这个Snapshot的MemStore。另开一个内存空间作为MemStore,让后面的数据写入。一旦Snapshot的MemStore写入完毕,对应内存空间就可以释放。这样,就可以通过两个MemStore来实现稳定的写入性能。
在整个数据写入过程中,LSM树全部都是使用append操作(磁盘顺序写)来实现数据写入的,没有使用任何seek+write(磁盘随机写)的方式来写入。无论HDD还是SSD,磁盘的顺序写操作性能和延迟都远好于磁盘随机写。因此LSM树是一种对写入极为友好的索引结构,它能将磁盘的写入带宽利用到极致。
随着写入的增加,内存数据会不断地刷新到磁盘上。最终磁盘上的数据文件会越来越多。如果数据没有任何的读取操作,磁盘上产生很多的数据文件对写入并无影响,而且这时写入速度是最快的,因为所有IO都是顺序IO。但是,一旦用户有读取请求,则需要将大量的磁盘文件进行多路归并
,之后才能读取到所需的数据。因为需要将那些Key相同的数据全局综合起来,最终选择出合适的版本返回给用户,所以磁盘文件数量越多,在读取的时候随机读取的次数也会越多,从而影响读取操作的性能。
为了优化读取操作的性能,我们可以设置一定策略将选中的多个hfile进行多路归并,合并成一个文件。文件个数越少,则读取数据时需要seek操作的次数越少,读取性能则越好。
4.3.2 Compaction
按照选中的文件个数,我们将compact操作分成两种类型。major compaction和minor compaction。
- major compaction,是将所有的hfile一次性多路归并成一个文件。这种方式的好处是,合并之后只有一个文件,这样读取的性能肯定是最高的;但它的问题是,合并所有的文件可能需要很长的时间并消耗大量的IO带宽,所以major compaction不宜使用太频繁,适合周期性地跑。
- minor compaction,即选中少数几个hfile,将它们多路归并成一个文件。这种方式的优点是,可以进行局部的compact,通过少量的IO减少文件个数,提升读取操作的性能,适合较高频率地跑;但它的缺点是,只合并了局部的数据,对于那些全局删除操作,无法在合并过程中完全删除。因此,minor compaction虽然能减少文件,但却无法彻底清除那些delete操作。而major compaction能完全清理那些delete操作,保证数据的最小化。
4.4 布隆过滤器
前文提到,一个Block默认64KB。把集合A的元素按照顺序分成若干个块,每块不超过64KB,每块内的多个元素都算出一个布隆过滤器串,多个块的布隆过滤器组成索引数据。为了判断元素w是否存在于集合A中,先对w计算每一个块的布隆过滤器串的存在性结果,若结果为肯定不存在,则继续判断w是否可能存在于下一个数据块中。若结果为可能存在,则读取对应的数据块,判断w是否在数据块中,若存在则表示w存在于集合A中;若不存在则继续判断w是否在下一个数据块中。
正是由于布隆过滤器只需占用极小的空间,便可给出“可能存在”和“肯定不存在”的存在性判断,因此可以提前过滤掉很多不必要的数据块,从而节省了大量的磁盘IO。HBase的Get
操作就是通过运用低成本高效率的布隆过滤器来过滤大量无效数据块的,从而节省大量磁盘IO。
在HBase1.x版本中,用户可以对某些列设置不同类型的布隆过滤器,共有3种类型。
- NONE:关闭布隆过滤器功能。
- ROW:按照rowkey来计算布隆过滤器的二进制串并存储。Get查询的时候,必须带rowkey,所以用户可以在建表时默认把布隆过滤器设置为ROW类型。
- ROWCOL:按照rowkey+family+qualifier这3个字段拼出byte[]来计算布隆过滤器值并存储。如果在查询的时候,Get能指定rowkey、family、qualifier这3个字段,则肯定可以通过布隆过滤器提升性能。但是如果在查询的时候,Get中缺少rowkey、family、qualifier中任何一个字段,则无法通过布隆过滤器提升性能,因为计算布隆过滤器的Key不确定。
注意,一般意义上的Scan操作,HBase都没法使用布隆过滤器来提升扫描数据性能。原因很好理解,同样是因为布隆过滤器的Key值不确定,所以没法计算出哈希值对比。但是,在某些特定场景下,Scan操作同样可以借助布隆过滤器提升性能。
对于ROWCOL类型的布隆过滤器来说,如果在Scan操作中明确指定需要扫某些列,如下所示:
Scan scan = new Scan()
.addColumn(FAMILY0, QUALIFIER0)
.addColumn(FAMILY1, QUALIFIER1)
那么在Scan过程中,碰到KV数据从一行换到新的一行时,是没法走ROWCOL类型布隆过滤器的,因为新一行的key值不确定;但是,如果在同一行数据内切换列时,则能通过ROWCOL类型布隆过滤器进行优化,因为rowkey确定,同时column也已知,也就是说,布隆过滤器中的Key确定,所以可以通过ROWCOL优化性能,详见HBASE-4465。
另外,在HBASE-20636中,腾讯团队介绍了一种很神奇的设计。他们的游戏业务rowkey是这样设计的:
rowkey=#
也就是用userid和其他字段拼接生成rowkey。而且业务大部分的请求都按照某个指定用户的userid来扫描这个用户下的所有数据,即按照userid来做前缀扫描。基于这个请求特点,可以把rowkey中固定长度的前缀计算布隆过滤器,这样按照userid来前缀扫描时(前缀固定,所以计算布隆过滤器的Key值也就固定),同样可以借助布隆过滤器优化性能,HBASE20636中提到有一倍以上的性能提升。另外,对于Get请求,同样可以借助这种前缀布隆过滤器提升性能。因此,这种设计对Get和基于前缀扫描的Scan都非常友好。这个功能已经在HBase2.x版本上实现。