深入理解hbase(一)hbase中java客户端详解

一、Hbase客户端实现

package com.yyds.hbase.com.yyds.demo;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.hbase.Cell;
import org.apache.hadoop.hbase.CellUtil;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.TableName;
import org.apache.hadoop.hbase.client.*;
import org.apache.hadoop.hbase.util.Bytes;
import org.junit.Assert;

import java.util.ArrayList;

public class Test {
   // 定义一些常量
   public static final TableName tableName = TableName.valueOf("test");
   public static final byte[] ROW_KEY0 = Bytes.toBytes("rowkey0");
   public static final byte[] ROW_KEY1 = Bytes.toBytes("rowkey1");
   public static final byte[] FAMILY = Bytes.toBytes("family");
   public static final byte[] QUALIFITER = Bytes.toBytes("qualifier");
   public static final byte[] VALUE = Bytes.toBytes("value");

    public static void main(String[] args) throws Exception {

        // 获取Hbase的连接

        // 步骤1:对访问HBase集群的客户端来说,一般需要3个配置文件: hbase-site.xml、 core-site.xml、hdfs-site.xml。只需把这3个配置文件放到JVM能加载的classpath下即可
        Configuration config = HBaseConfiguration.create();
        config.addResource(new Path(ClassLoader.getSystemResource("hbase-site.xml").toURI()));
        config.addResource(new Path(ClassLoader.getSystemResource("core-site.xml").toURI()));

        //步骤2:通过Configuration初始化集群Connection
        /*
            Connection是HBase 客户端进行一切操作的基础,它维持了客户端到整个HBase集群的连接,例如一个HBase集群中有2个Master、5个 RegionServer,
          那么一般来说,这个Connection会维持一个到Active Master的TCP连接和5个到RegionServer的TCP连接。
            通常,一个进程只需要为一个独立的集群建立一个Connection即可,并不需要建立连接池。建立多个连接,是为了提高客户端的吞吐量,连接池是为了减少建立和销毁连接的开销,
            而HBase的 Connection本质上是由连接多个节点的TCP链接组成,客户端的请求分发到各个不同的物理节点,因此吞吐量并不存在问题;
            另外,客户端主要负责收发请求,而大部分请求的响应耗时都花在服务端,所以使用连接池也不一定能带来更高的效益。

            Connection还缓存了访问的Meta信息,这样后续的大部分请求都可以通过缓存的Meta信息定位到对应的RegionServer 。
         */
        Connection conn = ConnectionFactory.createConnection(config);

        // 步骤3:通过Connection初始化Table
        /*
          Table是一个非常轻量级的对象,它实现了用户访问表的所有API操作,例如Put,Get、Delete、Scan等。
          本质上,它所使用的连接资源、配置信息、线程池、Meta缓存等,都来自步骤⒉创建的Connection对象。因此,由同一个 Connection创建的多个Table,都会共享连接、配置信息、线程池、Meta缓存这些资源。
         */
        Table table = conn.getTable(tableName);


        // 步骤4:通过Table执行Put 和 Scan对象
        // 向hbase表中插入数据
        byte[][] bytes = {ROW_KEY0, ROW_KEY1};
        for (byte[] rowKey : bytes) {
            Put put = new Put(rowKey).addColumn(FAMILY, QUALIFITER, VALUE);
            table.put(put);
        }
        // 扫描hbase中的数据
        Scan scan = new Scan().withStartRow(ROW_KEY0).setLimit(1);
        ResultScanner scanner = table.getScanner(scan);
        ArrayList<Cell> cells = new ArrayList<Cell>();
        for (Result result : scanner) {
           cells.addAll(result.listCells());
        }

        // 断言两个int是相等的 1. 如果两者一致, 程序继续往下运行. 2. 如果两者不一致, 中断测试方法, 抛出异常信息
        Assert.assertEquals(cells.size(),1);
        Cell firstCell = cells.get(0);
        Assert.assertArrayEquals(CellUtil.cloneRow(firstCell),ROW_KEY1);
        Assert.assertArrayEquals(CellUtil.cloneFamily(firstCell),FAMILY);
        Assert.assertArrayEquals(CellUtil.cloneQualifier(firstCell),QUALIFITER);
        Assert.assertArrayEquals(CellUtil.cloneValue(firstCell),VALUE);
    }
}

(1) 如何定位Meta表?

HBase一张表的数据是由多个Region构成,而这些Region是分布在整个集群的RegionServer上的。那么客户端在做任何数据操作时,都要先确定数据在哪个Region上,
然后再根据Region 的RegionServer信息,去对应的RegionServer上读取数据。

因此,HBase系统内部设计了一张特殊的表——hbase:meta表,专门用来存放整个集群所有的Region 信息。

hbase:meta 中的hbase指的是namespace,HBase容许针对不同的业务设计不同的namespace ,系统表采用统一的namespace,
即 hbase;meta指的是 hbase这个namespace下的表名。

hbase(main):003:0> describe 'hbase:meta'
Table hbase:meta is ENABLED                                                                                                                                                                                                                                                   
hbase:meta, {TABLE_ATTRIBUTES => {IS_META => 'true', REGION_REPLICATION => '1', coprocessor$1 => '|org.apache.hadoop.hbase.coprocessor.MultiRowMutationEndpoint|536870911|'}                                                                                                  
COLUMN FAMILIES DESCRIPTION                                                                                                                                                                                                                                                   
{NAME => 'info', VERSIONS => '3', EVICT_BLOCKS_ON_CLOSE => 'false', NEW_VERSION_BEHAVIOR => 'false', KEEP_DELETED_CELLS => 'FALSE', CACHE_DATA_ON_WRITE => 'false', DATA_BLOCK_ENCODING => 'NONE', TTL => 'FOREVER', MIN_VERSIONS => '0', REPLICATION_SCOPE => '0', BLOOMFILTE
R => 'NONE', CACHE_INDEX_ON_WRITE => 'false', IN_MEMORY => 'true', CACHE_BLOOMS_ON_WRITE => 'false', PREFETCH_BLOCKS_ON_OPEN => 'false', COMPRESSION => 'NONE', BLOCKCACHE => 'true', BLOCKSIZE => '8192'}                                                                    
{NAME => 'table', VERSIONS => '3', EVICT_BLOCKS_ON_CLOSE => 'false', NEW_VERSION_BEHAVIOR => 'false', KEEP_DELETED_CELLS => 'FALSE', CACHE_DATA_ON_WRITE => 'false', DATA_BLOCK_ENCODING => 'NONE', TTL => 'FOREVER', MIN_VERSIONS => '0', REPLICATION_SCOPE => '0', BLOOMFILT
ER => 'NONE', CACHE_INDEX_ON_WRITE => 'false', IN_MEMORY => 'true', CACHE_BLOOMS_ON_WRITE => 'false', PREFETCH_BLOCKS_ON_OPEN => 'false', COMPRESSION => 'NONE', BLOCKCACHE => 'true', BLOCKSIZE => '8192'}                                                                   
2 row(s)
Took 0.0585 seconds

hbase:meta表的结构非常简单,整个表只有一个名为info的ColumnFamily。而且 HBase保证 hbase:meta表始终只有一个Region,这是为了确保meta表多次操作的原子性,因为HBase本质上只支持Region级别的事务。

hbase:meta表中存储了哪些信息呢?

cdh hbase java开发 hbase java客户端_java

如上图,总体来说, hbase:meta 的一个rowkey就对应一个 Region,rowkey主要由TableName(业务表名)、StartRow(业务表Region 区间的起始rowkey)、Timestamp ( Region创建的时间戳)、EncodedName(上面3个字段的MD5 Hex值)4个字段拼接而成。
每一行数据又分为4列,分别是info:regioninfo、info:seqnumDuringOpen、info:server、info:serverstartcode。
info:regioninfo:存储的是EncodedName、RegionName,Region的startRow以及StopRow
info:seqnumDuringOpen:该列对应的 Value主要存储Region打开时的sequenceld。
info:server:该列对应的 Value主要存储Region落在哪个RegionServer上。
inforserverstartcode:该列对应的 Value主要存储所在RegionServer 的启动Timestamp。

那么,如何依据rowKey来查找对应的Region呢?
例如查找mi:note表中rowkey=‘userid334452’ 所在的region呢?

scan 'hbase:meta' {STARTROW => 'mi:note,userid334452,9999999999999' , REVERSED => true, LIMIT => 1 }

为什么需要用一个9999999999999的timestamp,以及为什么要用反向查询Reversed Scan呢?

首先,9999999999999是13位时间戳中最大值。
其次因为HBase在设计hbase:meta表的rowkey时,把业务表的StartRow(而不是StopRow)放在hbase:meta表的 rowkey上。

这样,如果某个Region对应的区间是[bbb, ccc),为了定位rowkey=bc的Region,通过正向Scan只会找到[bbb, ccc)这个区间的下一个区间,
但是,即使我们找到了[bbb,ccc)的下一个区间,也没法快速找到[bbb, ccc)这个Region的信息。所以,采用Reversed Scan是比较合理的方案。

Hbase作为一个分布式数据库系统,一个大的集群可能承担数千万的查询写入请求,而hbase:meta表只有一个Region,如果所有的流量都先请求hbase:meta表找到Region,再请求Region所在的RegionServer,那么hbase:meta表将承载巨大的压力,这个Region会马上变为热点,且根本无法承担数千万的流量,如何解决这个问题?

解决思路很简单,把hbase:meta表的Region信息缓存在Hbase客户端。

cdh hbase java开发 hbase java客户端_cdh hbase java开发_02

Hbase客户端有个叫做MetaCache的缓存,在调用Hbase API时,客户端会先去MetaCache中找到业务RowKey所在的Region,这个Region可能有一下三种情况:
1.Region信息为空,说明MetaCache中没有这个RowKey所在Region的任何Cache。此时直接用上述查询语句去hbase:meta表中Reversed Scan即可,注意首次查找时,需要先读取ZK的/hbase/meta-region-server这个ZNode,以便确定hbase:meta表所在的RS。
在hbase:meta表中找到业务rowkey所在的Region之后,将(regionStartRow,region)这样的二元组信息存放在一个MetaCache中。

2.Region信息不为空,但是调用RPC请求对应RS后,发现Region并不在这个RS上;这说明MetaCache过期了,同样直接Reversed Scan Hbase:meta表,找到正确的Region并缓存。
通常,某些Region在两个RS之间移动后会发生这种情况,但事实上,无论RS宕机导致的Region移动,还是由于Balance导致Region移动,发生的概率都极小。而且,也只会对Region移动后的极少数请求产生影响,这些请求只需要通过Hbase客户端自动重试locate meta即可成功。

3.Region信息不为空,且调用RPC请求到对应RS后,发现是正确的RS,绝大部分的请求属于这种情况,也是代价极小的方案。

由于MetaCache的设计,客户端分摊了几乎所有定位Region的流量压力;避免出现所有流量都打在hbase:meta的情况,这也是Hbase具备良好扩展性的基础。

4.需要注意,所谓Region级别事务,就是当多个操作落在同一个Region内时,Hbase能保证这一批操作执行的原子性。如果多个操作分散在不同的Region,则无法保证这批操作的原子性。

(2) hbase中的scan操作

Hbase客户端的Scan操作应该是比较复杂的RPC操作,为了满足客户端多样化的数据库查询需求,Scan必须能设置众多维度的属性。
常用的有startRow、endRow、Filter、caching、batch、reversed、maxResultSize、version、timeRange等。

table.getScanner(scan)可以拿到一个Scanner,然后只要不断地执行scanner.next()就能拿到一个Result。

cdh hbase java开发 hbase java客户端_java_03

用户每次执行scanner.next(),都会尝试去名为cache的队列中拿result(步骤4),如果cache队列为空,则会发起一次RPC向服务端请求当前scanner的后续result数据(步骤1)。客户端收到result列表后(步骤2),通过scanResultCache把这些results内的多个Cell进行重组,最终组成用户需要的result放入到Cache中(步骤3)。其中步骤1+步骤2+步骤3统称为loadCache操作。

为什么要在步骤3对Rpc Responce中的result进行重组呢?
这是因为RS为了避免被当前RPC请求耗尽资源,实现了多个维度的资源限制(例如timeout、单次RPC响应最大字节数),一旦某个维度资源到达阈值,就马上把当前拿到的Cell返回给客户端。这样客户端拿到的result可能就不是一行完整的数据,因此在步骤3需要对result进行重组。

scan中的几个重要的概念

cdh hbase java开发 hbase java客户端_cdh hbase java开发_04

例1 : Scan同时设置caching、allowPartial和 maxResultSize的情况。如下图所示,最左侧表示服务端有4行数据,每行依次有3,1,2,3个cell。中间一栏表示每次RPC收到的result。由于cell-1占用字节超过了maxResultSize,所以单独组成一个result-1,剩余的两个cell组成result-2。同时,由于用户设了allowPartial,RPC返回的result不经重组便可直接被用户拿到。最右侧表示用户通过scanner.next()拿到的result列表。

cdh hbase java开发 hbase java客户端_hbase_05

**例2:**Scan只设置caching和 maxResultSize的情况。和例1类似,都设了maxResultSize,因此RPC层拿到的result结构和例1是相同的;不同的地方在于,本例没有设allowPartial,因此需要把RPC收到的result进行重组。最终重组的结果就是每个result包含该行完整的cell,如下图所示。

cdh hbase java开发 hbase java客户端_hbase_06

例3: Scan同时设置caching、batch、maxResultSize 的情况。RPC收到的result和前两例类似。在重组时,由于batch=2,因此保证每个result最多包含一行数据的2个cell,如下图所示。

cdh hbase java开发 hbase java客户端_hadoop_07

二、Hbase客户端常见问题

(1) RPC重试配置要点

在Hbase客户端到服务端的通信过程中,可能会碰到各种各样的异常,例如有如下几种导致重试的常见异常:
1.待访问Region所在的RS宕机,此时Region已经被移到一个新的RS上,但是由于客户端存在meta缓存,首次RPC请求仍然访问到了旧的RS,后续将重试发起RPC
2.服务端负载较大,导致单次RPC响应超时,客户端后续将继续重试,直到RPC成功或者超过客户容忍最大延迟。

(2) hbase超时参数

1.hbase.rpc.timeout:表示单次RPC请求的超时时间,一旦单次RPC超过该时间,上层将收到TimeOutException,默认时间为60000ms.
2.hbase.client.retries.number:表示调用API时最多容许发生多少次RPC重试,默认为35次。
3.hbase.client.pause:表示连续两次RPC重试之间的休眠时间,默认为100ms。重试策略如下:
第1次 RPC重试 100ms
第2次 RPC重试 200ms
第3次 RPC重试 300ms
第4次 RPC重试 500ms
第5次 RPC重试 1000ms
第6次 RPC重试 2000ms
按照默认配置将会卡在休眠和重试两个步骤。
4.hbase.client.operation.timeout:表示单次API的超时时间,默认值为1200000ms,注意,get/put/delete等表操作称为一次API操作,一次API可能会有多次RPC重试,
这个operation.timeout限制的是API操作的总超时。

(3)CAS接口,吞吐受限

/*
    * @deprecated Since 2.0.0. Will be removed in 3.0.0. Use {@link #checkAndMutate(byte[], byte[])}
    */
  @Deprecated
  default boolean checkAndPut(byte[] row, byte[] family, byte[] qualifier, CompareOperator op,
      byte[] value, Put put)
      
      
  default CompletableFuture<Long> incrementColumnValue(byte[] row, byte[] family, byte[] qualifier,
        long amount)

这些接口在高并发场景下,能很好地保证读取与写入操作的原子性。

例如:有多个分布式的客户端同时更新一个计数器Count,可以通过increment接口来保证任意时刻都只有一个客户端能成功原子地执行count++操作。
需要特别注意的是,这些CAS接口在RS上是Region级别串行执行的,也就是说,同一个Region内部的多个CAS操作是严格串行执行的,不同Region间的多个CAS操作可以并行执行。
以checkAndPut为例,简要说明一下CAS的操作步骤:
1.服务端拿到Region的行锁(row-lock),避免出现两个线程同时修改一行数据,从而破坏了行级别原子性的情况。
2.等待该Region内的所有写入事务都已经成功提交并在mvcc上可见。
3.通过Get操作拿到需要check的行数据,进行条件检查,若条件不符合,则终止CAS。
4.将checkAndPut的put数据持久化。
5.释放1)步拿到的行锁。
关键在于第2)步,必须要等所有正在写入的事务成功提交并在mvcc上可见。
因此那些依赖CAS的接口服务,需要意识到这个操作的吞吐是受限的,因为CAS操作本质上是Region级别串行执行的。当然,在Hbase2.x版已经调整设计,对同一个Region内的不同行可以并行执行CAS,这大大提高了Region内的CAS吞吐。

(4) Scan Filter

有了Filter,大量无效数据可以在服务端内部过滤,相比直接返回全局数据到客户端,然后在客户端过滤,要高效很多。

1.PrefixFilter

PrefixFilter是将RowKey前缀为指定字节串的数据都过滤出来并返回给用户。

例如:如下Scan会返回所有RowKey前缀为’def’的数据:

Scan scan = new Scan();
scan.setFilter(new PrefixFilter(Bytes.toBytes("def")));

注意,这个Scan虽然能得到预期的效果,但并不高效。因为对于rowkey在区间(-♾,def)的数据,Scan会一条条扫描,发现前缀不为def,就读下一行,直到找到第一个Rowkey前缀为def的行为止。

解决方法1:
在Scan中简单加一个startRow即可,RegionServer发现Scan设了startRow,首先寻址定位到这个startRow,然后从这个位置开始扫描数据,这样就跳过了大量的(-♾,def)的数据。代码如下所示:

Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes("def"));
scan.setFilter(new PrefixFilter(Bytes.toBytes("def")));

解决方法2:
将PrefixFilter直接展开,扫描[def,deg)区间的数据,这样效率是最高的

Scan scan = new Scan();
scan.withStartRow(Bytes.toBytes("def"));
scan.withStopRow(Bytes.toBytes("deg"));

2.pageFilter

有一个表,表里面有5个Region,分别为(-oo,111),[111,222),[222,333), [333,444) ,[444,+oo)。
表中这5个Region,每个Region都有超过10000行的数据。他发现通过如下scan扫描出来的数据居然超过了3000行:

Scan scan = new Scan();
        scan.withStartRow(Bytes.toBytes("111"));
        scan.withStopRow(Bytes.toBytes("444"));
        scan.setFilter(new PageFilter(3000));

乍一看确实很诡异,因为PageFilter就是用来做数据分页功能的,应该保证每一次扫描最多返回不超过3000行。
但是需要注意的是,HBase里Filter状态全部都是Region 内有效的,也就是说,Scan一旦从一个Region切换到另一个Region,之前那个Filter 的内部状态就无效了,新Region内用的其实是一个全新的Filter。具体到这个问题,就是PageFilter 内部计数器从一个Region切换到另一个Region,计数器已经被清0.
因此,这个Scan扫描出来的数据将会是:
·在[111,222)区间内扫描3000行数据,切换到下一个region [222,333)
·在[222,333)区间内扫描3000行数据,切换到下一个region [333,444)。
·在[333,444)区间内扫描3000行数据,发现已经到达 stopRow,终止。

因此,最终将返回9000行数据。理论上说,这应该算是HBase 的一个缺陷,PageFilter并没有实现全局的分页功能,因为Filter没有全局的状态。
当然,如果想实现分页功能,可以不通过Filter而直接通过limit实现,代码如下:

Scan scan = new Scan();
        scan.withStartRow(Bytes.toBytes("111"));
        scan.withStopRow(Bytes.toBytes("444"));
        scan.setFilter(new PageFilter(3000));
        scan.setLimit(1000);

所以,对用户来说,正常情况下PageFilter并没有太多存在的价值。

3.SingleColumnPageFilter

这个例子表面上是将列簇为family、列为qualifier、且值为value的Cell返回给用户,但是事实上,对那些不包含family:qualifier列的行,也会默认返回给用户。

Scan scan = new Scan();


        SingleColumnValueFilter scvf = new SingleColumnValueFilter(
                Bytes.toBytes("family"), Bytes.toBytes("Qualifier"), CompareOperator.EQUAL, Bytes.toBytes("value")
        );
        scan.setFilter(scvf);

如果用户不希望读取那些不包含family:qualifier的数据,需要设计如下Scan:

Scan scan = new Scan();
        
        SingleColumnValueFilter scvf = new SingleColumnValueFilter(
                Bytes.toBytes("family"), Bytes.toBytes("Qualifier"), CompareOperator.EQUAL, Bytes.toBytes("value")
        );
        scvf.setFilterIfMissing(true); // 跳过不包含对应列的数据
        scan.setFilter(scvf);

另外,当SingleColumnValueFilter 设置filterIfMissing 为 true,和其他Filter组合成FilterList时,可能导致返回结果不正确。
因为在filterIfMissing 设为true时,SingleColumnValueFilter必须遍历一行数据中的每一个cell,才能确定是否过滤,但在filterList 中,如果其他的Filter返回NEXT_ROW,会直接跳过某个列簇的数据,
导致SingleColumnValueFilter无法遍历一行中所有的cell,从而导致返回结果不符合预期。

因此建议,不要使用SingleColumnValueFilter和其他Filter组合成FilterList,直接指定列,通过ValueFilter替换掉SingleColumnValueFilter,代码如下:

Scan scan = new Scan();

        ValueFilter vf = new ValueFilter(CompareOperator.EQUAL, new BinaryComparator(Bytes.toBytes("value")));
        scan.addColumn( Bytes.toBytes("family"), Bytes.toBytes("Qualifier"));
        scan.setFilter(vf);

(5) 少量写和批量写

Hbase是一种对写入操作非常友好的系统,但是当业务有大批量的数据要写入Hbase时,仍会碰到写入瓶颈,为了适应不同数据量的写入场景,Hbase提供了3种常见的数据写入API,如下:

cdh hbase java开发 hbase java客户端_hadoop_08

(6)Batch数据量太大,可能导致MutilActionResultTooLarge异常

Hbase的batch接口使得用户可以把一批操作通过一次RPC发送到服务端,以便提升系统的吞吐量,这些操作可以是PUT、DELETE、GET、INCREMENT、APPEND等等。

像Get或者INCREMENT的BATCH操作中,需要先把对应的Block从HDFS中读取到HBASE内存中,然后通过RPC返回相关数据给客户端。

如果Batch中的操作过多,则可能导致一次RPC读取的Block数据量很多,容易造成Hbase的RegionServer出现OOM,或者出现长时间的FULL GC。
因此,HBase的RegionServer会限制每次请求的Block总字节数,一旦超过则会报MultiActionResultTooLarge异常,此时客户端最好控制每次Batch操作的个数,以免服务端为单次RPC消耗太多内存。