因为工作需要使用HBase,调研了HBase 相关的内容,在学习HBase之前首先要问自己一个很简单的问题,我们为什么需要HBase?我们已经有了很好用的像MySQL这样的关系数据库,为什么还要折腾新数据库。答案是数据量的暴增。如果抛开性能,无限增加磁盘的MySQL能不能放的下海量数据呢?答案是否定的,这个取决于MySQL和操作系统的很多底层实现,比如innodb的单表最大64TB已及操作系统各种文件格式对文件的限制等都会让我们不能通过无限增加磁盘来存放几百PB的数据。
HBase是一个分布式的、列式的、实时查询的、非关系型数据库,可以处理PB级别的数据,吞吐量可以到的百万查询/每秒;其诞生的理论基础是Google大数据三驾马车之一的BigTable论文。
● 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 存储。
● 在表的维度,其包含若干行,每一行以 RowKey 来区分。
● 在行的维度,其包含若干列族,列族类似列的归类,但不只是逻辑概念,底层物理存储也是以列族来区分的(一个列族对应不同 Region 中的一个 Store)。
● 在列族的维度,其包含若干列,列是动态的。列实际上是一个个键值对,Key是列名,Value是列值。
HBase表结构如下:
● RowKey(行键):RowKey 是字典有序的,HBase 基于 RowKey 实现索引;
● Column Family(列族):纵向切割,一行可有多个列族,一个列族可有任意个列;
● Key-Value(键值对):每一列存储的是一个键值对,Key 是列名,Value 是列值;
● Byte(数据类型):数据在 HBase 中以 Byte 存储,实际的数据类型交由用户转换;
● Version(多版本):每一列都可配置相应的版本数量,获取指定版本的数据(默认返回最新版本);
● 稀疏矩阵:行与行之间的列数可以不同,但只有实际的列才会占用存储空间。
HBase中的数据是通过Region(类似 RDBMS 中的分区)做为管理单元来进行管理的,region是管理一张表一块连续数据区间的组件,每个region都是的rowkey的区间,一个ColumnFamily按照rowkey区间可以划分为多个的Region。
当一个 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、数据内容。
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在插入的时候,直接写入内存,只要利用红黑树或跳表等有序数据结构保持内存中的数据有序即可,所以可以提供更高的写吞吐。
(该表引自:HBase 深入浅出)
结合 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;
}
// 创建表
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();
}
}
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();
}
}
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; // 返回本次查询的结果
}
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);
}
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();
}
}
● RowKey相同的记录在hbase里被认为是同一条数据的多个版本,查询时默认返回最新版本的数据,所以通常rowkey都需要保证唯一,除非用到多版本特性。
RowKey就好比RDBMS的里的主键,他唯一确定了一条记录,它可以是一个字段也可以是多个字段拼接起来:
每个用户只有一条记录: [userid]
每个用户有多条交易记录:[userid][orderid]
● RowKey的设计限制了数据的查询方式,hbase只有两种查询方式(get、scan)
select * from table where rowkey = ‘abcde’;
这种查询方式需要知道完整的RowKey,即组成RowKey的所有字段的值都是确定的。
select * from table where rowkey > ‘abc’ and rowkey <’abcx’;
这种查询方式需要知道数据RowKey的范围限定值,就好像一本英文字典,你可以查询pre开头的所有单词,也可以查询prefi开头的所有单词,但是没办法查询中间是efi或结尾是ix的所有单词,除非翻阅整个字典
● 通过散列的方式将RowKey打散,将数据分散到不同的分区,不至于产生热点,把某一台服务器累死,其他服务器闲置,充分发挥分布式和并发的优势。打散数据的常用技巧有两种
md5
[orderid] => [md5(orderid).subStr(0,4)][orderid]
反转
[orderid] => [reverse(orderid)]