因为工作需要使用HBase,调研了HBase 相关的内容,在学习HBase之前首先要问自己一个很简单的问题,我们为什么需要HBase?我们已经有了很好用的像MySQL这样的关系数据库,为什么还要折腾新数据库。答案是数据量的暴增。如果抛开性能,无限增加磁盘的MySQL能不能放的下海量数据呢?答案是否定的,这个取决于MySQL和操作系统的很多底层实现,比如innodb的单表最大64TB已及操作系统各种文件格式对文件的限制等都会让我们不能通过无限增加磁盘来存放几百PB的数据。

1 简介
HBase是一个分布式的、列式的、实时查询的、非关系型数据库,可以处理PB级别的数据,吞吐量可以到的百万查询/每秒;其诞生的理论基础是Google大数据三驾马车之一的BigTable论文。
2 架构

● Master:HBase 管理节点。管理 Region Server,分配 Region 到 Region Server,提供负载均衡能力;执行创建表等 DDL 操作。
● Region Server:HBase 数据节点。管理 Region,一个 Region Server 可包含多个 Region,Region 相当于表的分区。客户端可直接与 Region Server 通信,实现数据增删改查等 DML 操作。
● ZooKeeper:协调中心。负责 Master 选举,节点协调,存储 hbase:meta 等元数据。
● HDFS:底层存储系统。负责存储数据,Region 中的数据通过 HDFS 存储。
3 原理
Hbase是面向列的数据库,这里的面向列并不是指简单地按列存储,而是引入了列族Column fammily的概念。
3.1 数据模型
● 在表的维度,其包含若干行,每一行以RowKey来区分。
● 在行的维度,其包含若干列族,列族类似列的归类,但不只是逻辑概念,底层物理存储也是以列族来区分的(一个列族对应不同 Region 中的一个 Store)。
● 在列族的维度,其包含若干列,列是动态的。列实际上是一个个键值对,Key是列名,Value是列值。
一个Hbase表的逻辑结构是这样的:
● RowKey(行键):行键是HBase记录条目的主键,物理存储时会按照RowKey的字典序排序存储,HBase基于RowKey实现索引;
● Column Family(列族):纵向切割,HBase中的每个列都归属于某个列族,列族不能改变,一行可有多个列族,一个列族可有任意个列;
● Column(列):一般都是从属于某个列族,跟列族不一样,这些列都可以动态添加。
● Key-Value(键值对):每一列存储的是一个键值对,Key是列名,Value是列值。通过{行键,列族名,列名}可以唯一确定一个列单元并获取数据Value,和关系型数据库不同的是,HBase中的数据是没有类型的,都是以bytes形式存储;
● Byte(数据类型):数据在HBase中以Byte存储,实际的数据类型交由用户转换;
● Version(多版本):每一列都可配置相应的版本数量,获取指定版本的数据(默认返回最新版本);
● 稀疏矩阵:行与行之间的列数可以不同,但只有实际的列才会占用存储空间。
实际上把这种结构简单理解为有行和列的二维表也可以,只是它的列称为“列族”,列族下面又可以在数据写入时指定很多的 “子列”。其中的Column family表示着列族,c1表示子列。由此以来,Hbase在纯行存储和纯列存储之间找到了一种折中的方案,即对于列族以内的数据按行存储,对于每个列族按“列”存储。上面的Hbase表的物理结构是这样的:

如上图所示 :v1和v2在逻辑上处于同一列族的同一行,因此他们在物理存储上也相邻。然而与他们同属于Rowkey1行的v3,由于处于其他列族,故没有在同一个存储块上。此时查询Rowkey1的整行数据,需要跨三个存储块。
实际上,如果HBase中的一张表只有一个列族的话,等于是这个列族包含了这张表的所有列,也就是将表整行的数据连续存储在了一起,就等于是行式存储了。再比如,一张表有多个列族,并且每个列族下仅有一列(虽然HBase不建议这么做),也就是将表的列数据连续存储在了一起,就等于是列式存储了。
在大数据场景下,列式存储的优势很明显(因为我们可以通过人工设计保证同一列族中的数据为同一类型,以带来最高的压缩比)。但是反过来,列式存储对于整行的请求效率就不怎么高了,因为跨列族查询需要多次IO不同的存储块,然后再合并返回。
3.2 数据存储
HBase中的数据是通过Region(类似 RDBMS 中的分区)做为管理单元来进行管理的,region是管理一张表一块连续数据区间的组件,每个region都是的rowkey的区间,一个ColumnFamily按照rowkey区间可以划分为多个的Region。

- Hbase是通过HLog(WAL机制,写前日志)来保证数据的可靠性的,Region Server中都会有一个HLog的实例,Region Server会将更新操作(put、delete)先记录到HLog中,然后将其写入到Store的MemStore,最后再持久化到HFile中(当MemStore达到配置的内存阀值),这样就保证了HBase的写可靠性;而HFile在HDFS中默认会保存三份,可以认为HFile本身是可靠的
- Region是表的横向切割,一个表由一个或多个Region组成,Region被分配到各个Region Server;
- 每个Region是一个RowKey Range,比如Region A存放的 RowKey 区间为 [aaa,bbb),Region B存放的RowKey区间为 [bbb,ccc) ,以此类推。Region在Region Server 中存储也是有序的,Region A必定在Region B前面。

3.3 数据路由

当一个Client需要访问HBase集群时,Client需要先和Zookeeper来通信,获取路由表hbase-meta的存放地址。通过这个存放地址可以获得hbase:meta文件来找到的Client所需要的Region和对应的Region Server的地址,进行DML操作。
HBase 是分布式数据库,那数据怎么路由?
数据路由借助hbase:meta表完成,hbase:meta记录的是所有Region的元数据信息,它保存了系统中所有的region列表。hbase:meta的位置记录在ZooKeeper ,它类似一个b-tree,结构大致如下:
Key:table, region start key, region id
Value:region server

一条数据的写入流程
数据写入时需要指定表名、Rowkey、数据内容。
- HBase客户端访问ZooKeeper,获取hbase:meta的地址,并缓存该地址;
- 访问相应Region Server的hbase:meta;
- 从hbase:meta 表获取RowKey对应的Region Server地址,并缓存该地址;
- HBase客户端根据地址直接请求Region Server完成数据读写。

3.4 HBase存储引擎——LSM树
绝大多数KV系统,最终会引用《The Design and Implementation of a Log-Structured File System》,HBASE的底层数据结构LSM也引用了该论文,其诞生的背景是数据存储系统广泛痛点:对于海量小文件创建、删除场景,由于大量的随机写,disk时间都花在了查找磁盘上,只有5%的时间真正用于写有效的数据。
像MySQL之类数据库,底层数据结构是B+树去构建的,而HBase借鉴了B+树的思路。传统关系型数据库使用B+树或一些变体作为存储结构,能高效进行查找。但保存在磁盘中时它也有一个明显的缺陷,那就是逻辑上相离很近但物理却可能相隔很远,比如它的节点进行分裂操作时在内存中会拆成两个新的页表,存储到磁盘上很可能就是不连续的;或者其他更新插入删除等操作,需要循环利用磁盘块,也会造成不连续问题。这就可能造成大量的磁盘随机读写,随机读写比顺序读写慢很多,为了提升I/O性能,我们需要一种能将随机操作变为顺序操作的机制,于是便有了LSM树。
Log-Structured Merge-Tree (LSM-Tree),log-structured,日志结构的,只需要不断地Append就好了。“Merge-tree”,也就是“合并-树”,把多个文件合并成一个。LSM-tree最大的特点就是写入速度快,主要利用了磁盘的顺序写。
B+树最大的性能问题是,随着新数据的插入,随机写会产生大量随机IO,举一个插入key跨度很大的例子,如7->1000->3->2000,新插入的数据存储在磁盘上相隔很远,会产生大量的随机写IO(低下的磁盘寻道速度严重影响性能)。
LSM-Tree把一棵大树拆分成N棵小树,首先写入内存中,随着小树越来越大,内存中的小树会flush到磁盘中(随机IO优化为顺序IO),磁盘中的树定期可以做merge操作,合并成一棵大树。

写入流程:一个put(k,v)操作来了,首先追加到WAL(Write Ahead Log,也就是真正写入之前记录的日志,WAL用来在故障时恢复还未被持久化的数据)尾部,接下来加到C0层(也叫MemStore即写缓存),然后服务端就可以向客户端返回ack表示写数据完成。当C0层的数据达到一定大小,就把C0层和C1层合并,类似归并排序,这个过程就是Compaction(合并)。合并出来的新的new-C1会顺序写磁盘,替换掉原来的old-C1。当C1层达到一定大小,会继续和下层合并。合并之后所有旧文件都可以删掉,留下新的。
查询流程:在写入流程中可以看到,最新的数据在C0层,最老的数据在Ck层,所以查询也是先查C0层,如果没有要查的数据,再查 C1,逐层查。因此一次查询可能需要多次单点查询,稍微慢一些。所以LSM-tree主要针对的场景是写密集、少量查询的场景。
读放大:为了查询一个1KB的数据。最坏需要读C0层的内存数据,再读C1到Ck的每一个文件,一共k个文件。而每一个文件内部需要读16KB 的索引,4KB的布隆过滤器,4KB的数据块。一共24*(k+1)/1倍。key-value数据越小读放大越大。

LSM-Tree存储引擎和B+树存储引擎一样,同样支持增、删、读、改、顺序扫描操作,而且通过批量存储技术规避磁盘随机写入问题。当然凡事有利有弊,LSM树和B+树相比,LSM树牺牲了部分读性能,用来大幅提高写性能。
LSM-Tree存储引擎是一个相对较新的方案,其关键思想是系统地将磁盘上的随机写入转为顺序写入,由于硬盘的性能特性,写性能比B-Tree存储引擎高数倍,读性能反之。B树把所有的压力都放到了写操作的时候,从根节点索引到数据存储的位置,可能需要多次读文件;真正插入的时候,又可能会引起page的分裂,多次写文件。而LSM-Tree在插入的时候,直接写入内存,只要利用红黑树或跳表等有序数据结构保持内存中的数据有序即可,所以可以提供更高的写吞吐。
3.5 读数据流程

- HBase客户端先访问ZooKeeper,获取hbase:meta表位于哪个Region Server;
- 访问相应的Region Server获取hbase:meta表,根据请求的Table/RowKey,查询出目标数据位于哪个Region Server,将Table的Region信息以及meta表的位置缓存在客户端内存中;
- 与目标Region Server进行通讯;
- 分别在Block Cache(读缓存),MemStore和Store File(HFile)中查询目标数据,并将查到的所有数据进行合并,此处所有数据是指同一条数据的不同版本(timestamp)或者不同的类型(Put/Delete);
- 将从文件中查询到的数据块(Block,HFile数据存储单元,默认大小为64KB)缓存到Block Cache中;
- 将合并后的最终结果返回给客户端;
3.6 Compaction机制
由于memstore每次刷写都会生成一个新的HFile,且同一个字段的不同版本(timestamp)和不同类型(Put/Delete)有可能分布在不同的HFile中,磁盘HFile文件就会越来越多,查询时需要遍历所有的HFile会导致性能低下。为了优化查询性能——减少HFile的个数、清除掉过期和删除的数据,HBase会合并小的HFile,这种合并HFile的操作称为Compaction。
Compaction分为两种,分别时Minor Compaction和Major Compaction。
- Minor Compaction会将临时的若干较小的HFile合并成一个较大的HFile,但不会清理过期和删除的数据。一次Minor Compaction的结果是更少并且更大的HFile。
- Major Compaction会将一个Store下的所有HFile合并为一个大HFile,这个过程会清理三类没有意义的数据:被删除的数据、TTL过期数据、版本号超过设定版本号的数据。一般情况下,Major Compaction时间会持续比较长,整个过程会消耗大量系统资源,对上层业务有比较大的影响。因此线上业务都会将关闭自动触发Major Compaction功能,改为手动在业务低峰期触发。

HBase触发Compaction的条件有三种:memstore Flush、后台线程周期性检查、手动触发。
4 MySQL VS HBase
(该表引自:HBase 深入浅出)

- 扩展性:从HBase架构图可以看出只要在ZooKeeper允许的情况下,由于节点间彼此近乎独立,HRegionServer可以无限扩张节点,一个Region按照100G算,一个HRegionServer可以存储多个Region,在管理得当的情况下,HBase横向扩展性可以轻松达到PB级。
- 写性能:从底层数据结构来看,顺序追加数据,可带来明显的写能力提升。大量的数据按照Rowkey分散开来,每台节点的压力相对小。
- 读性能:从读数据的流程来看,缓存的数据在内存中,在O(1)时间内可拿到,不涉及任何时延;其次是根据元数据信息去某个Region的MemStore去拿数据,O(logN)时间。最后根据布隆过滤器,对Store File(HFile)中的LSM层级树快速过滤扫描K*logN时间。所以HBase可以做到ms级获取数据。Rowkey的设计相当于一级索引,当我们范围查询或者对指标进行查询的时候,这时缺失了二级索引,这会导致巨大的数据扫描压力。
5 HBase的CURD操作
结合 MySQL 说明 HBase 的 DML 操作,演示如何使用 HBase 来实现 MySQL 的 CREATE、 INSERT、SELECT、UPDATE、DELETE、LIKE 操作。
为方便代码复用,这里提前封装获取 HBase 连接的代码:
// 获取HBase连接
public Connection getHBaseConnect() throws IOException {
// 配置
Configuration conf = HBaseConfiguration.create();
conf.set("hbase.zookeeper.quorum", "127.0.0.1");
conf.set("hbase.zookeeper.property.clientPort", "2181");
conf.set("log4j.logger.org.apache.hadoop.hbase", "WARN");
// 创建连接
Connection connection = ConnectionFactory.createConnection(conf);
return connection;
}
5.1 CREATE操作
// 创建表
public void createTable (String tableName,String columnFamily) {
try {
// 获取连接,DDL操作需要获取Admin
Connection hbaseConnect = hbase.getHBaseConnect();
Admin admin = hbaseConnect.getAdmin();
// 设置表名
HTableDescriptor tableDescriptor = new HTableDescriptor(TableName.valueOf(tableName));
// 设置列族
tableDescriptor.addFamily(new HColumnDescriptor(columnFamily));
// 创建表
admin.createTable(tableDescriptor);
} catch (IOException e) {
e.printStackTrace();
}
}
5.2 INSERT操作
MySQL:
INSERT INTO ct_account_info_demo(account_id, account_owner , account_amount, is_deleted ) VALUES (?,?,?,?)
HBase 实现上述 SQL 语句的功能:
// 插入数据
public int insertAccount(Long accountId, String accountOwner, BigDecimal accountAmount) {
String tableName = "ct_account_info_demo"; // 表名
// 行键(为便于理解,这里将accountID作为RowKey,实际应用中RowKey的设计应该重点考虑)
String rowKey = String.valueOf(accountId);
String familyName = "account_info"; // 列族(在创建表时已定义)
Map<String,String> columns = new HashMap<>(); // 多个列
columns.put("account_id",String.valueOf(accountId));
columns.put("account_owner",accountOwner);
columns.put("account_amount",String.valueOf(accountAmount));
columns.put("is_deleted","n");
updateColumnHBase(tableName,rowKey,familyName,columns); // 更新HBase数据
return 0;
}
private void updateColumnHBase(String tableName, String rowKey, String familyColumn, Map<String,String> columns) {
try {
Connection hbaseConnect = hbase.getHBaseConnect(); // 获取HBase连接
Table table = hbaseConnect.getTable(TableName.valueOf(tableName)); // 获取相应的表
Put put = new Put(Bytes.toBytes(rowKey)); // 封装Put对象
for (Map.Entry<String, String> entry : columns.entrySet()) {
put.addColumn(Bytes.toBytes(familyColumn), Bytes.toBytes(entry.getKey()),
Bytes.toBytes(entry.getValue()));
}
table.put(put); // 提交数据
table.close();
} catch (IOException e) {
e.printStackTrace();
}
}
5.3 SELECT操作
MySQL:
SELECT * from ct_account_info_demo WHERE account_id = ?;
HBase 实现上述 SQL 语句的功能:
// 读取数据
public Account getAccountInfoByID(Long accountId) {
Account account = new Account();
String tableName = "ct_account_info_demo"; // 表名
String familyName = "account_info"; // 列族
String rowKey = String.valueOf(accountId); // 行键
List<String> columns = new ArrayList<>(); // 设置需要返回哪些列
columns.add("account_id");
columns.add("account_owner");
columns.add("account_amount");
columns.add("is_deleted");
// 获取某一行指定列的数据
HashMap<String,String> accountRecord = getColumnHBase(tableName,rowKey,familyName,columns);
if (accountRecord.size()==0) {
return null;
}
// 根据查询结果,封装账户信息
account.setId( Long.valueOf(accountRecord.get("account_id")));
account.setOwner(accountRecord.get("account_owner"));
account.setBalance(new BigDecimal(accountRecord.get("account_amount")));
account.setDeleted(accountRecord.get("isDeleted"));
return account;
}
private HashMap<String, String> getColumnHBase(String tableName, String rowKey, String familyColumn, List<String> columns) {
HashMap<String,String> accountRecord = new HashMap<>(16);
try {
Connection hbaseConnect = hbase.getHBaseConnect(); // 获取HBase连接
Table table = hbaseConnect.getTable(TableName.valueOf(tableName)); // 获取相应的表
Get get = new Get(Bytes.toBytes(rowKey)); // 封装Get对象
for (String column:columns) {
get.addColumn(Bytes.toBytes(familyColumn), Bytes.toBytes(column));
}
Result result = table.get(get); // 获取数据
if (result.listCells() != null) {
for (Cell cell : result.listCells()) {
String k = Bytes.toString(cell.getQualifierArray(), cell.getQualifierOffset(), cell.getQualifierLength());
String v = Bytes.toString(cell.getValueArray(), cell.getValueOffset(), cell.getValueLength());
accountRecord.put(k,v); // 将结果存放在map中
}
}
table.close();
} catch (IOException e) {
e.printStackTrace();
}
return accountRecord; // 返回本次查询的结果
}
5.4 UPDATE操作
MySQL:
UPDATE ct_account_info_demo SET account_amount = account_amount + ? WHERE account_id = ?;
HBase 实现上述 SQL 语句的功能:
// 更新数据
public void transIn(Long accountId, BigDecimal accountAmount) {
String tableName = "ct_account_info_demo"; // 表名
String rowKey = String.valueOf(accountId); // 行键
String familyName = "account_info"; // 列族
List<String> columns = new ArrayList<>(); // 获取账户信息
columns.add("account_amount");
HashMap<String,String> accountRecord = getColumnHBase(tableName, rowKey,familyName,columns);
// 增加账户余额
BigDecimal newAccountAmount = new BigDecimal(accountRecord.get("account_amount")).add(accountAmount);
// 更新账户的余额
Map<String,String> fromColumns = new HashMap<>(1);
fromColumns.put("account_amount",String.valueOf(newAccountAmount));
// 更新HBase数据
updateColumnHBase(tableName,rowKey,familyName,fromColumns);
}
5.5 DELETE操作
MySQL:
DELETE FROM ct_account_info_demo WHERE account_id = ?;
通过 HBase 实现上述 SQL 语句的功能:
// 删除数据
public void deleteAccount (String tableName, Long accountId) {
try {
Connection hbaseConnect = hbase.getHBaseConnect();
// 行键
String rowKey = String.valueOf(accountId);
// 列族
String familyName = "account_info";
Table table = hbaseConnect.getTable(TableName.valueOf(tableName));
Delete delete = new Delete(Bytes.toBytes(rowKey));
// 删除该行指定列的数据
delete.deleteColumn(Bytes.toBytes(familyName), Bytes.toBytes("account_id"));
delete.deleteColumn(Bytes.toBytes(familyName), Bytes.toBytes("account_owner"));
delete.deleteColumn(Bytes.toBytes(familyName), Bytes.toBytes("account_amount"));
delete.deleteColumn(Bytes.toBytes(familyName), Bytes.toBytes("is_deleted"));
// 删除整个列族
//delete.deleteFamily(Bytes.toBytes(familyName));
table.delete(delete);
table.close();
} catch (IOException e) {
e.printStackTrace();
}
}
6 RowKey的设计原则
● 唯一原则,RowKey相同的记录在HBase里被认为是同一条数据的多个版本,查询时默认返回最新版本的数据,所以通常RowKey都需要保证唯一,除非用到多版本特性。
RowKey就好比RDBMS的里的主键,他唯一确定了一条记录,它可以是一个字段也可以是多个字段拼接起来:
- 每个用户只有一条记录:RowKey可直接设置为userid
- 每个用户有多条交易记录:RowKey可直接设置为userid + orderid
HBase只有两种查询方式:get与scan。
- 根据完整的RowKey查询(get),类似传统DB的SQL:
select * from table where rowkey = ‘abcde’;
这种查询方式需要知道完整的RowKey,即组成RowKey的所有字段的值都是确定的。
- 根据RowKey的范围查询(scan),类似传统DB的SQL:
select * from table where rowkey > ‘abc’ and rowkey <’abcx’;
这种查询方式需要知道数据RowKey的范围限定值,就好像一本英文字典,你可以查询pre开头的所有单词,也可以查询prefi开头的所有单词,但是没办法查询中间是efi或结尾是ix的所有单词,除非翻阅整个字典。
● 排序原则,HBase中的数据存储是按照RowKey进行字典方式升序存储,主要是为了方便检索。
● 散列原则,设计的RowKey应均匀的分布在各个HBase节点上,避免产生热点,充分发挥分布式和并发的优势。
- 哈希
基于RowKey的完整或部分数据进行Hash,而后将Hash后的值完整替换或部分替换原RowKey的前缀部分。哈希方法包含MD5、sha1、sha256或sha512等算法。 - 反转
如果经初步设计出的RowKey在数据分布上不均匀,但RowKey尾部的数据却呈现出了良好的随机性,此时可以考虑将RowKey的信息翻转,或者直接将尾部的bytes提前到RowKey的开头。Reversing可以有效的使RowKey随机分布,但是牺牲了RowKey的有序性。 - 加盐
Salting的原理是在原RowKey的前面添加固定长度的随机数,也就是给RowKey分配一个随机前缀使它和之间的RowKey的开头不同。随机数能保障数据在所有Regions间的负载均衡。