1.写数据的方式

1.1 Single Put

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

CompletableFuture<void> put(Put put);

在Table接口中的定义:

void put(Put put) throw IOException;

1.2 Batch Put

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

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

在AsyncTable接口中的定义:

List<CompletableFuture<void>> put(List<Put> puts);

在Table接口中的定义:

void put(List<Put> puts) throws IOException;

1.3 Bulkload

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

2. HBase写数据流程图以及写数据流程介绍

写数据流程主要分为11步

2.1 写数据之前首先要创建数据

  • 设计合理的RowKey
    RowKey通常是一个或若干个字段的直接组合或经一定处理后的信息,因为一个表中所有的数据都是基于RowKey排序的,RowKey的设计对读写都会有直接的性能影响。
    我们基于本文的样例数据,先给出两种RowKey的设计,并简单讨论各自的优缺点:
  • RowKey Format 1: Mobile1 + StartTime

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

hbase put多行数据 shell hbase put list_RPC


RowKey Format 2: StartTime + Mobile1

hbase put多行数据 shell hbase put list_hbase_02


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

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

假设RowKey设计:reversing(Mobile1) +StartTime

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

hbase put多行数据 shell hbase put list_hbase put多行数据 shell_03


RowKey应该为: 66660000431^201803011300

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

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

hbase put多行数据 shell hbase put list_hbase_04


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

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

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

  • 定义列
    因为Mobile1与StartTime都已经被包含在RowKey中,所以,不需要再在列中存储一份。关于列族名称与列标识名称,建议应该简短一些,因为这些信息都会被包含在KeyValue里面,过长的名称会导致数据膨胀。
  • Put对象
    基于RowKey和列定义信息,就可以组建HBase的Put对象,一个Put对象用来描述待写入的一行数据,一个Put可以理解成与某个RowKey关联的1个或多个KeyValue的集合。

2.2 图中的步骤1:初始化Zookeeper session

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

2.3 图中的步骤2:获取了meta Region的路由信息

通过前面建立的连接,从ZooKeeper中读取meta Region所在的RegionServer,这个读取流程,当前已经是异步的。

2.4 步骤3:获取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。

2.5 步骤4:返回region信息

2.6 步骤4-5:Client数据分组"打包"

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

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

  • 按Region分组:遍历每一条数据的RowKey,然后,依据meta表中记录的Region信息,确定每一条数据所属的Region。此步骤可以获取到Region到RowKey列表的映射关系。
  • 按RegionServer"打包":因为Region一定归属于某一个RegionServer(注:本文内容中如无特殊说明,都未考虑Region Replica特性),那属于同一个RegionServer的多个Regions的写入请求,被打包成一个MultiAction对象,这样可以一并发送到每一个RegionServer中。
  • hbase put多行数据 shell hbase put list_写数据流程_05

2.7 步骤5: Client发RPC请求到RegionServer

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

hbase put多行数据 shell hbase put list_hbase_06


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

service clientService {
// single Put请求所涉及的RPC服务接口方法
  rpc Mutate(MutateRequest)
  returns (MutateResponse);
// batch put请求所涉及的RPC服务接口方法rpc        
  rpc Multi(MultiRequest)
  returns(MNultiResponse);
}

2.8 步骤6: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中,此过程是串行的:

hbase put多行数据 shell hbase put list_写数据流程_07


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

2.9 步骤7:Region内部处理:写WAL

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

2.10 步骤8: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.11 步骤9:数据写入hdfs

2.细节分化(源码解析)

2.1. ZK中存储了HBase的哪些信息

  • 首先我们进入ZK,查看zk中所存储的所有信息

    发现此时hbase有两个节点:
    hbase-secure:如果hbase集群曾经以安全模式启动),则创建的znode将为/hbase-secure。
    hbase-unsecure:如果hbase集群曾经以普通模式启动),则创建的znode将为/hbase-unsecure。
  • 进入/hbase-secure节点中:

    例如:
    rs:查看hbase集群在zookeeper记录的信息

    table:查看所有表

    table-lock:表锁节点会有所有表

    meta-region-server:存储的则直接是这个Meta Table的位置

2.2.meta表中存储的信息

hbase put多行数据 shell hbase put list_数据_08


meta Region中记录了每一个用户表Region的路由以及状态信息,以RegionName(包含表名,Region StartKey,Region ID,副本ID等信息)作为RowKey

2.3 为什么需要WAL?有什么用?

2.4 为什么需要将数据同时写入WAL和Memstore?先写Wal还是先写Memstore?

2.4.2 先写Wal还是先写Memstore

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

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

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

2.5 如果一直往一个文件中写入数据,如何进行拆分和合并?

memstore的flush

2.6 写数据的时候有没有进行预分区?如何进行的?

2.7 设计列族的时候有什么原则?越多越好?为什么?

2.7.1 官网所说列族设计

一张Hbase表包含一个或多个列族,官网上建议一张HBase表的列族个数建议在1-3之间,其实本身HBase对列族的个数是没有限制的。

2.7.2 影响

  • Flush产生一定的影响:列族越多,
  • 对Split产生影响
  • 对Compaction产生影响
  • 对HDFS的影响
  • 对RegionServer内存的影响

2.8 rowkey的设计原则。以及有什么作用?

2.8.1 rowkey在读写流程中发挥的作用

  • 通过RowKey路由到对应的Region
  • MemStore中的数据按RowKey排序
  • HFile中的数据按RowKey排序

2.8.2 rowkey的设计原则


2.9 什么样的业务与rowkey设计有关?

详细请看rowkey的设计

2.10 HFile的结构

HFile中包含了一个多层索引系统。这个多层索引是的HBase可以在不读取整个文件的情况下查找数据。这一多层索引类似于一个B+树。

键值对根据键大小升序排列。

索引指向64KB大小的数据块。

每一个数据块还有其相应的叶索引(leaf-index)。

每一个数据块的最后一个键作为中间索引(intermediate index)。

根索引(root index)指向中间索引。

文件结尾指向meta block。因为meta block是在数据写入硬盘操作的结尾写入该文件中的。文件的结尾同时还包含一些别的信息。比如bloom filter及时间信息。Bloom filter可以帮助HBase加速数据查询的速度。因为HBase可以利用Bloom filter跳过不包含当前查询的键的文件。时间信息则可以帮助HBase在查询时跳过读操作所期望的时间区域之外的文件。

hbase put多行数据 shell hbase put list_数据_09

2.11 HBase的异常恢复(Crash Recovery)

WAL文件和HFile都存储于硬盘上且存在备份,因此恢复它们是非常容易的。那么HBase如何恢复位于内存中的MemStore呢?

hbase put多行数据 shell hbase put list_数据_10

当Region server宕机的时候,其所管理的region在这一故障被发现并修复之前是不可访问的。ZooKeeper负责根据服务器的心跳信息来监控服务器的工作状态。当某一服务器下线之后,ZooKeeper会发送该服务器下线的通知。HMaster收到这一通知之后会进行恢复操作。

HMaster会首先将宕机的Region server所管理的region分配给其他仍在工作的活跃的Region server。然后HMaster会将该服务器的WAL分割并分别分配给相应的新分配的Region server进行存储。新的Region server会读取并顺序执行WAL中的数据操作,从而重新创建相应的MemStore。

hbase put多行数据 shell hbase put list_hbase put多行数据 shell_11

2.12 数据恢复(Data Recovery)

WAL文件之中存储了一系列数据操作。每一个操作对应WAL中的一行。新的操作会顺序写在WAL文件的末尾。

那么当MemStore中存储的数据因为某种原因丢失之后应该如何恢复呢?HBase以来WAL对其进行恢复。相应的Region server会顺序读取WAL并执行其中的操作。这些数据被存入内存中当前的MemStore并排序。最终当MemStore存满之后,这些数据被flush到硬盘上。

如下图所示:

hbase put多行数据 shell hbase put list_写数据流程_12