一条数据的 HBase 之旅,简明 HBase 入门教程 -Write 全流程



转载请注明 AIQ - 最专业的机器学习大数据社区  http://www.6aiq.com

AIQ 机器学习大数据 知乎专栏 点击关注

如果将上篇内容理解为一个冗长的 "铺垫",那么,从本文开始,"剧情" 才开始正式展开。本文基于所给出的样例数据,介绍了将数据从 Client 写到 RegionServer 的全流程。

本文整体思路:

1. 前文内容回顾

2. 示例数据

3. HBase 可选接口介绍

4. 表服务接口介绍

5. 介绍几种写数据的模式

6. 如何构建 Put 对象 (含 RowKey 定义以及列定义)

7. 数据路由

8. Client 侧的分组打包

9. Client 发 RPC 请求到 RegionServer

10. 安全访问控制

11. RegionServer 侧处理:Region 分发

12. Region 内部处理:写 WAL

13. Region 内部处理:写 MemStore

为了保证 "故事" 的完整性,导致本文篇幅过长,非常抱歉,读者可以按需跳过不感兴趣的内容。

前文内容回顾

上篇文章《一条数据的 HBase 之旅,简明 HBase 入门教程 - 开篇》主要介绍了如下内容:

  • HBase 项目概况 (搜索引擎热度 / 社区开发活跃度)

  • HBase 数据模型 (RowKey,稀疏矩阵,Region,Column Family,KeyValue)

  • 基于 HBase 的数据模型,介绍了 HBase 的适合场景(以实体 / 事件为中心的简单结构的数据)

  • 介绍了 HBase 与 HDFS 的关系,集群关键角色以及部署建议

  • 写数据前的准备工作:建立连接,建表

示例数据

(上篇文章已经提及,这里再复制一次的原因,一是为了让下文内容更容易理解,二是个别字段名称做了调整)

给出一份我们日常都可以接触到的数据样例,先简单给出示例数据的字段定义:

本文力求简洁,仅给出了最简单的几个字段定义。如下是 "虚构" 的样例数据:

在本文大部分内容中所涉及的一条数据,是上面加粗的最后一行 "Mobile1“为”13400006666" 这行记录。在下面的流程图中,我们使用下面这样一个红色小图标来表示该数据所在的位置:

可选接口

HBase 中提供了如下几种主要的接口:

Java Client API

HBase 的基础 API,应用最为广泛。

HBase Shell

基于 Shell 的命令行操作接口,基于 Java Client API 实现。

Restful API

Rest Server 侧基于 Java Client API 实现。

Thrift API

Thrift Server 侧基于 Java Client API 实现。

MapReduce Based Batch Manipulation API

基于 MapReduce 的批量数据读写 API。

除了上述主要的 API,HBase 还提供了基于 Spark 的批量操作接口以及C++ Client接口,但这两个特性都被规划在了 3.0 版本中,当前尚在开发中。

无论是 HBase Shell/Restful API 还是 Thrift API,都是基于 Java Client API 实现的。因此,接下来关于流程的介绍,都是基于 Java Client API 的调用流程展开的。

关于表服务接口

同步连接异步连接,分别提供了不同的表服务接口抽象:

  • Table 同步连接中的表服务接口定义

  • AsyncTable 异步连接中的表服务接口定义

异步连接 AsyncConnection 获取 AsyncTable 实例的接口默认实现:

同步连接 ClusterConnection 的实现类 ConnectionImplementation 中获取 Table 实例的接口实现:

写数据的几种方式

Single Put

单条记录单条记录的随机 put 操作。Single Put 所对应的接口定义如下:

在 AsyncTable 接口中的定义:

在 Table 接口中的定义:

Batch Put

汇聚了几十条甚至是几百上千条记录之后的小批次随机 put 操作。

Batch Put 只是本文对该类型操作的称法,实际的接口名称如下所示:

在 AsyncTable 接口中的定义:

在 Table 接口中的定义:

Bulkload

基于 MapReduce API 提供的数据批量导入能力,导入数据量通常在 GB 级别以上,Bulkload 能够绕过 Java Client API 直接生成 HBase 的底层数据文件 (HFile),因此性能非常高。

构建 Put 对象

设计合理的 RowKey

RowKey 通常是一个或若干个字段的直接组合或经一定处理后的信息,因为一个表中所有的数据都是基于 RowKey 排序的,RowKey 的设计对读写都会有直接的性能影响。

我们基于本文的样例数据,先给出两种 RowKey 的设计,并简单讨论各自的优缺点:

样例数据:

RowKey Format 1: Mobile1 + StartTime

为了方便读者理解,我们在两个字段之间添加了连接符 "^"。如下是 RowKey 以及相关排序结果:

RowKey Format 2: StartTime + Mobile1

从上面两个表格可以看出来,不同的字段组合顺序设计,带来截然不同的排序结果,我们将 RowKey 中的第一个字段称之为 "先导字段“。第一种设计,有利于查询 "手机号码 XXX 的在某时间范围内的数据记录”,但不利于查询" 某段时间范围内有哪些手机号码拨出了电话?",而第二种设计却恰好相反。

上面是两种设计都是两个字段的直接组合,这种设计在实际应用中,会带来读写热点问题,难以保障数据读写请求在所有 Regions 之间的负载均衡。避免热点的常见方法有如下几种:

Reversing

如果先导字段本身会带来热点问题,但该字段尾部的信息却具备良好的随机性,此时,可以考虑将先导字段做反转处理,将尾部几位直接提前到前面,或者直接将整个字段完全反转。

先导字段Mobile1 翻转后,就具备非常好的随机性。

例如:

    13400001111^201803010800

将先导字段 Mobile1 反转后的 RowKey 变为:

    11110000431^201803010800

Salting

Salting 的原理是在 RowKey 的前面添加固定长度的随机 Bytes,随机 Bytes 能保障数据在所有 Regions 间的负载均衡。

Salting 能很好的保障写入时将数据均匀分散到各个 Region 中,但对于读取却是不友好的,例如,如果读取 Mobile1 为 "13400001111" 在 20180301 这一天的数据记录时,因为 Salting Bytes 信息是随机选择添加的,查询时并不知道前面添加的 Salting Bytes 是 "A",因此 {“A”, “B”, “C”} 所关联的 Regions 都得去查看一下是否有所需的数据。

Hashing

Hashing 是将一个 RowKey 通过一个 Hash 函数生成一组固定长度的 bytes,Hash 函数能保障所生成的随机 bytes 具备良好的离散度,从而也能够均匀打散到各个 Region 中。Hashing 既有利于随机写入,又利于基于知道 RowKey 各字段的确切信息之后的随机读取操作,但如果是基于 RowKey 范围的 Scan 或者是 RowKey 的模糊信息进行查询的话,就会带来显著的性能问题,因为原来在字典顺序相邻的 RowKey 列表,通过 Hashing 打散后导致这些数据被分散到了多个 Region 中。

因此,RowKey 的设计,需要充分考虑业务的读写特点

本文内容假设 RowKey 设计:reversing(Mobile1) +StartTime

也就是说,RowKey 由反转处理后的 Mobile1 与 StartTime 组成。对于我们所关注的这行数据:

RowKey 应该为: 66660000431^201803011300

因为创建表时预设的 Region 与 RowKey 强相关,我们现在才可以给出本文样例所需要创建的表的 "Region 分割点" 信息:

假设,Region 分割点为 "1,2,3,4,5,6,7,8,9",基于这 9 个分割点,可以预先创建 10 个 Region,这 10 个 Region 的 StartKey 和 StopKey 如下所示:

  • 第一个 Region 的 StartKey 为空,最后一个 Region 的 StopKey 为空

  • 每一个 Region 区间,都包含 StartKey 本身,但不包含 StopKey

  • 由于 Mobile1 字段的最后一位是 0~9 之间的随机数字,因此,可以均匀打散到这 10 个 Region 中

定义列

每一个列在 HBase 中体现为一个 KeyValue,而每一个 KeyValue 拥有特定的组成结构,这一点在上一篇文章中的数据模型章节部分已经提到过。

所谓的定义列,就是需要定义出每一个列要存放的列族 (Column Family) 以及列标识 (Qualifier) 信息。

我们假设,存放样例数据的这个表名称为 "TelRecords",为了简单起见,仅仅设置了 1 个名为"I" 的列族。

因为 Mobile1 与 StartTime 都已经被包含在 RowKey 中,所以,不需要再在列中存储一份。关于列族名称与列标识名称,建议应该简短一些,因为这些信息都会被包含在 KeyValue 里面,过长的名称会导致数据膨胀。

基于 RowKey 和列定义信息,就可以组建 HBase 的 Put 对象,一个 Put 对象用来描述待写入的一行数据,一个 Put 可以理解成与某个 RowKey 关联的 1 个或多个 KeyValue 的集合。

至此,这条数据已经转变成了 Put 对象,如下图所示:

数据路由

初始化 ZooKeeper Session

因为 meta Region 的路由信息存放于 ZooKeeper 中,在第一次从 ZooKeeper 中读取 META Region 的地址时,需要先初始化一个 ZooKeeper Session。ZooKeeper Session 是 ZooKeeper Client 与 ZooKeeper Server 端所建立的一个会话,通过心跳机制保持长连接。

获取 Region 路由信息

通过前面建立的连接,从 ZooKeeper 中读取 meta Region 所在的 RegionServer,这个读取流程,当前已经是异步的。获取了 meta Region 的路由信息以后,再从 meta Region 中定位要读写的 RowKey 所关联的 Region 信息。如下图所示:

因为每一个用户表 Region 都是一个 RowKey Range,meta Region 中记录了每一个用户表 Region 的路由以及状态信息,以 RegionName( 包含表名,Region StartKey,Region ID,副本 ID 等信息 ) 作为 RowKey。基于一条用户数据 RowKey,快速查询该 RowKey 所属的 Region 的方法其实很简单:只需要基于表名以及该用户数据 RowKey,构建一个虚拟的 Region Key,然后通过 Reverse Scan 的方式,读到的第一条 Region 记录就是该数据所关联的 Region。如下图所示:

Region 只要不被迁移,那么获取的该 Region 的路由信息就是一直有效的,因此,HBase Client 有一个 Cache 机制来缓存 Region 的路由信息,避免每次读写都要去访问 ZooKeeper 或者 meta Region。

进阶内容 1:meta Region 究竟在哪里?

meta Region 的路由信息存放在 ZooKeeper 中,但 meta Region 究竟在哪个 RegionServer 中提供读写服务?

在 1.0 版本中,引入了一个新特性,使得 Master 可以 "兼任" 一个 RegionServer 角色 (可参考 HBASE-5487, HBASE-10569),从而可以将一些系统表的 Region 分配到 Master 的这个 RegionServer 中,这种设计的初衷是为了简化 / 优化 Region Assign 的流程,但这依然带来了一系列复杂的问题,尤其是 Master 初始化和 RegionServer 初始化之间的 Race,因此,在 2.0 版本中将这个特性暂时关闭了。详细信息可以参考:HBASE-16367,HBASE-18511,HBASE-19694,HBASE-19785,HBASE-19828

Client 数据分组 "打包"

如果这条待写入的数据采用的是 Single Put 的方式,那么,该步骤可以略过(事实上,单条 Put 操作的流程相对简单,就是先定位该 RowKey 所对应的 Region 以及 RegionServer 信息后,Client 直接发送写请求到 RegionServer 侧即可)。

但如果这条数据被混杂在其它的数据列表中,采用 Batch Put 的方式,那么,客户端在将所有的数据写到对应的 RegionServer 之前,会先分组 "打包",流程如下:

  1. 按 Region 分组:遍历每一条数据的 RowKey,然后,依据 meta 表中记录的 Region 信息,确定每一条数据所属的 Region。此步骤可以获取到 Region 到 RowKey 列表的映射关系。

  2. 按 RegionServer"打包":因为 Region 一定归属于某一个 RegionServer(注:本文内容中如无特殊说明,都未考虑 Region Replica 特性),那属于同一个 RegionServer 的多个 Regions 的写入请求,被打包成一个 MultiAction 对象,这样可以一并发送到每一个 RegionServer 中。

Client 发 RPC 请求到 RegionServer

类似于 Client 发送建表到 Master 的流程,Client 发送写数据请求到 RegionServer,也是通过 RPC 的方式。只是,Client 到 Master 以及 Client 到 RegionServer,采用了不同的 RPC 服务接口。

single put 请求与 batch put 请求,两者所调用的 RPC 服务接口方法是不同的,如下是 Client.proto 中的定义:

安全访问控制

如何保障 UserA 只能写数据到 UserA 的表中,以及禁止 UserA 改写其它 User 的表的数据,HBase 提供了 ACL 机制。ACL 通常需要与 Kerberos 认证配合一起使用,Kerberos 能够确保一个用户的合法性,而 ACL 确保该用户仅能执行权限范围内的操作。

HBase 将权限分为如下几类:

  • READ(‘R’)

  • WRITE(‘W’)

  • EXEC(‘X’)

  • CREATE(‘C’)

  • ADMIN(‘A’)

可以为一个用户 / 用户组定义整库级别的权限集合,也可以定义 Namespace、表、列族甚至是列级别的权限集合。

RegionServer:Region 分发

RegionServer 的 RPC Server 侧,接收到来自 Client 端的 RPC 请求以后,将该请求交给 Handler 线程处理。

如果是 single put,则该步骤比较简单,因为在发送过来的请求参数 MutateRequest 中,已经携带了这条记录所关联的 Region,那么直接将该请求转发给对应的 Region 即可。

如果是 batch puts,则接收到的请求参数为 MultiRequest,在 MultiRequest 中,混合了这个 RegionServer 所持有的多个 Region 的写入请求,每一个 Region 的写入请求都被包装成了一个 RegionAction 对象。RegionServer 接收到 MultiRequest 请求以后,遍历所有的 RegionAction,而后写入到每一个 Region 中,此过程是串行的:

从这里可以看出来,并不是一个 batch 越大越好,大的 batch size 甚至可能导致吞吐量下降。

Region 内部处理:写 WAL

HBase 也采用了LSM-Tree的架构设计:LSM-Tree 利用了传统机械硬盘的“顺序读写速度远高于随机读写速度”的特点。随机写入的数据,如果直接去改写每一个 Region 上的数据文件,那么吞吐量是非常差的。因此,每一个 Region 中随机写入的数据,都暂时先缓存在内存中 (HBase 中存放这部分内存数据的模块称之为MemStore,这里仅仅引出概念,下一章节详细介绍 ),为了保障数据可靠性,将这些随机写入的数据顺序写入到一个称之为 WAL(Write-Ahead-Log) 的日志文件中,WAL 中的数据按时间顺序组织:

如果位于内存中的数据尚未持久化,而且突然遇到了机器断电,只需要将 WAL 中的数据回放到 Region 中即可:

在 HBase 中,默认一个 RegionServer 只有一个可写的 WAL 文件。WAL 中写入的记录,以Entry为基本单元,而一个 Entry 中,包含:

  • WALKey 包含 {Encoded Region Name,Table Name,Sequence ID,Timestamp} 等关键信息,其中,Sequence ID 在维持数据一致性方面起到了关键作用,可以理解为一个事务 ID。

  • WALEdit WALEdit 中直接保存待写入数据的所有的 KeyValues,而这些 KeyValues 可能来自一个 Region 中的多行数据。

也就是说,通常,一个 Region 中的一个 batch put 请求,会被组装成一个 Entry,写入到 WAL 中:

将 Entry 写到文件中时是支持压缩的,但该特性默认未开启。

WAL 进阶内容

WAL Roll and Archive

当正在写的 WAL 文件达到一定大小以后,会创建一个新的 WAL 文件,上一个 WAL 文件依然需要被保留,因为这个 WAL 文件中所关联的 Region 中的数据,尚未被持久化存储,因此,该 WAL 可能会被用来回放数据。

如果一个 WAL 中所关联的所有的 Region 中的数据,都已经被持久化存储了,那么,这个 WAL 文件会被暂时归档到另外一个目录中:

注意,这里不是直接将 WAL 文件删除掉,这是一种稳妥且合理的做法,原因如下:

  • 避免因为逻辑实现上的问题导致 WAL 被误删,暂时归档到另外一个目录,为错误发现预留了一定的时间窗口

  • 按时间维度组织的 WAL 数据文件还可以被用于其它用途,如增量备份,跨集群容灾等等,因此,这些 WAL 文件通常不允许直接被删除,至于何时可以被清理,还需要额外的控制逻辑

另外,如果对写入 HBase 中的数据的可靠性要求不高,那么,HBase 允许通过配置跳过写 WAL 操作。

思考:put 与 batch put 的性能为何差别巨大?

在网络分发上,batch put 已经具备一定的优势,因为 batch put 是打包分发的。

而从写 WAL 这块,看的出来,batch put 写入的一小批次 Put 对象,可以通过一次 sync 就持久化到 WAL 文件中了,有效减少了 IOPS。

但前面也提到了,batch size 并不是越大越好,因为每一个 batch 在 RegionServer 端是被串行处理的。

利用 Disruptor 提升写并发性能

在高并发随机写入场景下,会带来大量的 WAL Sync 操作,HBase 中采用了 Disruptor 的RingBuffer来减少竞争,思路是这样:如果将瞬间并发写入 WAL 中的数据,合并执行 Sync 操作,可以有效降低 Sync 操作的次数,来提升写吞吐量。

Multi-WAL

默认情形下,一个 RegionServer 只有一个被写入的 WAL Writer,尽管 WAL Writer 依靠顺序写提升写吞吐量,在基于普通机械硬盘的配置下,此时只能有单块盘发挥作用,其它盘的 IOPS 能力并没有被充分利用起来,这是Multi-WAL设计的初衷。Multi-WAL 可以在一个 RegionServer 中同时启动几个 WAL Writer,可按照一定的策略,将一个 Region 与其中某一个 WAL Writer 绑定,这样可以充分发挥多块盘的性能优势。

关于 WAL 的未来

WAL 是基于机械硬盘的 IO 模型设计的,而对于新兴的非易失性介质,如 3D XPoint,WAL 未来可能会失去存在的意义,关于这部分内容,请参考文章《从 HBase 中移除 WAL?3D XPoint 技术带来的变革》。

Region 内部处理:写 MemStore

每一个 Column Family,在 Region 内部被抽象为了一个 HStore 对象,而每一个 HStore 拥有自身的 MemStore,用来缓存一批最近被随机写入的数据,这是 LSM-Tree 核心设计的一部分。

MemStore 中用来存放所有的 KeyValue 的数据结构,称之为CellSet,而 CellSet 的核心是一个ConcurrentSkipListMap,我们知道,ConcurrentSkipListMap 是 Java 的跳表实现,数据按照 Key 值有序存放,而且在高并发写入时,性能远高于 ConcurrentHashMap。

因此,写 MemStore 的过程,事实上是将 batch put 提交过来的所有的 KeyValue 列表,写入到 MemStore 的以 ConcurrentSkipListMap 为组成核心的 CellSet 中:

MemStore 因为涉及到大量的随机写入操作,会带来大量 Java 小对象的创建与消亡,会导致大量的内存碎片,给 GC 带来比较重的压力,HBase 为了优化这里的机制,借鉴了操作系统的内存分页的技术,增加了一个名为 MSLab 的特性,通过分配一些固定大小的 Chunk,来存储 MemStore 中的数据,这样可以有效减少内存碎片问题,降低 GC 的压力。当然,ConcurrentSkipListMap 本身也会创建大量的对象,这里也有很大的优化空间,去年阿里的一篇文章透露了阿里如何通过优化 ConcurrentSkipListMap 的结构来有效降低 GC 时间。

进阶内容 2:先写 WAL 还是先写 MemStore?

在 0.94 版本之前,Region 中的写入顺序是先写 WAL 再写 MemStore,这与 WAL 的定义也相符。

但在 0.94 版本中,将这两者的顺序颠倒了,当时颠倒的初衷,是为了使得行锁能够在 WAL sync 之前先释放,从而可以提升针对单行数据的更新性能。详细问题单,请参考 HBASE-4528。

在 2.0 版本中,这一行为又被改回去了,原因在于修改了行锁机制以后 (下面章节将讲到),发现了一些性能下降,而 HBASE-4528 中的优化却无法再发挥作用,详情请参考 HBASE-15158。改动之后的逻辑也更简洁了。

进阶内容 3:关于行级别的 ACID

在之前的版本中,行级别的任何并发写入 / 更新都是互斥的,由一个行锁控制。但在 2.0 版本中,这一点行为发生了变化,多个线程可以同时更新一行数据,这里的考虑点为:

  • 如果多个线程写入同一行的不同列族,是不需要互斥的

  • 多个线程写同一行的相同列族,也不需要互斥,即使是写相同的列,也完全可以通过 HBase 的 MVCC 机制来控制数据的一致性

  • 当然,CAS 操作 (如 checkAndPut) 或 increment 操作,依然需要独占的行锁

更多详细信息,可以参考 HBASE-12751。

至此,这条数据已经被同时成功写到了 WAL 以及 MemStore 中:

总结

本文主要内容总结如下:

  • 介绍 HBase 写数据可选接口以及接口定义。

  • 通过一个样例,介绍了 RowKey 定义以及列定义的一些方法,以及如何组装 Put 对象

  • 数据路由,数据分发、打包,以及 Client 通过 RPC 发送写数据请求至 RegionServer

  • RegionServer 接收数据以后,将数据写到每一个 Region 中。写数据流程先写 WAL 再写 MemStore,这里展开了一些技术细节

  • 简单介绍了 HBase 权限控制模型

需要说明的一点,本文所讲到的 MemStore 其实是一种 "简化" 后的模型,在 2.0 版本中,这里已经变的更加复杂,这些内容将在下一篇介绍 Flush 与 Compaction 的流程中详细介绍。


更多高质资源 尽在AIQ 机器学习大数据 知乎专栏 点击关注

转载请注明 AIQ - 最专业的机器学习大数据社区  http://www.6aiq.com