HBase原理--客户端

小明的数据脚印

HBase提供了面向Java、C/C++、Python等多种语言的客户端。由于HBase本身是Java开发的,所以非Java语言的客户端需要先访问ThriftServer,然后通过ThriftServer的Java HBase客户端来请求HBase集群。当然,有部分第三方团队实现了其他一些HBase客户端,例如OpenTSDB团队使用的asynchbase和gohbase等,但由于社区客户端和服务端协议在大版本之间可能产生较大不兼容,而第三方开发的客户端一般会落后于社区,因此这里不推荐使用第三方客户端,建议统一使用HBase社区的客户端。对其他语言的客户端,推荐使用ThriftServer的方式来访问HBase服务。

另外,HBase也支持Shell交互式客户端。Shell客户端实质是用JRuby(用Java编写的Ruby解释器,方便Ruby脚本跑在JVM虚拟机上)脚本调用官方HBase客户端来实现的。因此,各种客户端的核心实现都在社区Java版本客户端上。本节主要探讨HBase社区Java客户端。

下面我们通过一个访问HBase集群的典型示例代码,阐述HBase客户端的用法和设计,代码如下所示:

public class TestDemo {
    private static final HBaseTestingUtility TEST_UTIL=new HBaseTestingUtility() ;
    public static final TableName tableName=TableName.valueOf("t
            estTable");
    public static final byte[] ROW_KEYO=Bytes.toBytes("rowkey
            0");
    public static final byte[] ROW_KEY1=Bytes.toBytes("rowkey
            1");
    public static final byte[]FAMILY=Bytes.toBytes("family");
    public static final byte[]QUALIFIER=Bytes.toBytes("qualifie
            r");
    public static final byte[] VALUE-Bytes.toBytes("value");
    @BeforeClass
    public static void setUpBeforeClass( throws Exception {
        TEST_UTIL.startMiniCluster();
    }

    @AfterClass
    public static void tearDownAfterClass( throws Exception {
        TEST_UTIL.shutdownMiniCluster(;
        @Test
        public void test() throws IOException {
            Configuration conf=TEST_UTIL.getConfiguration();
            try (Connection conn=ConnectionFactory.createConnection(co
                    nf)){
                try (Table table=conn.getTable(tableName)){
                    for (byte[]rowkey : new byte[][]ROW_KEYO,ROW_KEY1
                }){
                    Put put=new Put(rowkey).addColumn(FAMILY,QUALIFIER,
                            VALUE);
                    table.put(put);
                }
                Scan scan=new Scan().withStartRow(ROW_KEY1).setLimit
                        (1);
                try (ResultScanner scanner=table.getScanner(scan)){
                    List<Cell> cells=new ArrayList<>();
                    for (Result result : scanner){
                        cells.addAll(result.listCells();
                        Assert.assertEquals(cells.size(),1);
                        Cell firstCell=cells.get(O);
                        Assert.assertArrayEquals(CellUtil.cloneRow(firstCel
                                l),ROW_KEY1);
                        Assert.assertArrayEquals(CellUtil.cloneFamily(firstC
                                ell),FAMILY);
                        Assert.assertArrayEquals(CellUtil.cloneQualifier(fir
                                stCel1),QUALIFIER);
                        Assert.assertArrayEquals(CellUtil.cloneValue(firstCe
                                ll),VALUE);
                    }
                }
            }
        }
    }

这个示例是一个访问HBase的单元测试代码。我们在类TestDemo初始化前,通过HBase的HBaseTestingUtility工具启动一个运行在本地的Mini HBase集群,最后跑完所有的单元测试样例之后,同样通过HBaseTestingUtility工具清理相关资源,并关闭集群。

下面重点讲解TestDemo#test方法的实现。主要步骤如下。

步骤1:获取集群的Conf iguration对象。

对访问HBase集群的客户端来说,一般需要3个配置文件:hbase-site.xml、core-site. xml、hdfs-site.xml。只需把这3个配置文件放到JVM能加载的classpath下即可,然后通过如下代码即可加载到Conf iguration对象:

Configuration conf = HBaseConfiguraction.create();

在示例中,由于HBaseTestingUtility拥有API可以方便地获取到Conf iguration对象,所以省去了加载Conf iguration对象的步骤。

步骤2:通过Conf iguration初始化集群Connection。

Connection是HBase客户端进行一切操作的基础,它维持了客户端到整个HBase集群的连接,例如一个HBase集群中有2个Master、5个RegionServer,那么一般来说,这个Connection会维持一个到Active Master的TCP连接和5个到RegionServer的TCP连接。

通常,一个进程只需要为一个独立的集群建立一个Connection即可,并不需要建立连接池。建立多个连接,是为了提高客户端的吞吐量,连接池是为了减少建立和销毁连接的开销,而HBase的Connection本质上是由连接多个节点的TCP链接组成,客户端的请求分发到各个不同的物理节点,因此吞吐量并不存在问题;另外,客户端主要负责收发请求,而大部分请求的响应耗时都花在服务端,所以使用连接池也不一定能带来更高的效益。

Connection还缓存了访问的Meta信息,这样后续的大部分请求都可以通过缓存的Meta信息定位到对应的RegionServer。

步骤3:通过Connection初始化Table。

Table是一个非常轻量级的对象,它实现了用户访问表的所有API操作,例如Put、Get、Delete、Scan等。本质上,它所使用的连接资源、配置信息、线程池、Meta缓存等,都来自步骤2创建的Connection对象。因此,由同一个Connection创建的多个Table,都会共享连接、配置信息、线程池、Meta缓存这些资源。

步骤4:通过Table执行Put和Scan操作。

从示例代码中可以明显看出,HBase操作的rowkey、family、column、value等都需要先序列化成byte[],同样读取的每一个cell也是用byte[]来表示的。

以上就是访问HBase表数据的全过程。

定位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:meta表的基本结构,打开HBase Shell,我们可以看到hbase:meta表的结构如下:

image.png

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

那么,hbase:meta表内具体存放的是哪些信息呢?图4-1较为清晰地描述了hbase:meta表内存储的信息。

image.png

总体来说,hbase:meta的一个rowkey就对应一个Region,rowkey主要由TableName(业务表名)、StartRow(业务表Region区间的起始rowkey)、Timestamp(Region创建的时间戳)、EncodedName(上面3个字段的MD5Hex值)4个字段拼接而成。每一行数据又分为4列,分别是info:regioninfo、info:seqnumDuringOpen、info:server、info:serverstartcode。

• info:regioninfo:该列对应的Value主要存储4个信息,即EncodedName、RegionName、Region的StartRow、Region的StopRow。
• info:seqnumDuringOpen:该列对应的Value主要存储Region打开时的sequenceId。
• info:server:该列对应的Value主要存储Region落在哪个RegionServer上。
• info:serverstartcode:该列对应的Value主要存储所在RegionServer的启动Timestamp。

理解了hbase:meta表的基本信息后,就可以根据rowkey来查找业务的Region了。例如,现在需要查找micloud:note表中rowkey='userid334452'所在的Region,可以设计如下查询语句:

image.png

为什么需要用一个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是比较合理的方案。

在理解了如何根据rowkey去hbase:meta表中定位业务表的Region之后,试着思考另外一个问题:HBase作为一个分布式数据库系统,一个大的集群可能承担数千万的查询写入请求,而hbase:meta表只有一个Region,如果所有的流量都先请求hbase:meta表找到Region,再请求Region所在的RegionServer,那么hbase:meta表的将承载巨大的压力,这个Region将马上成为热点Region,且根本无法承担数千万的流量。那么,如何解决这个问题呢?

事实上,解决思路很简单:把hbase:meta表的Region信息缓存在HBase客户端,如图所示。
image.png
客户端定位Region示意图

HBase客户端有一个叫做MetaCache的缓存,在调用HBase API时,客户端会先去MetaCache中找到业务rowkey所在的Region,这个Region可能有以下三种情况:

•Region信息为空,说明MetaCache中没有这个rowkey所在Region的任何Cache。此时直接用上述查询语句去hbase:meta表中Reversed Scan即可,注意首次查找时,需要先读取ZooKeeper的/hbase/meta-region-server这个ZNode,以便确定hbase:meta表所在的RegionServer。在hbase:meta表中找到业务rowkey所在的Region之后,将(regionStartRow, region)这样的二元组信息存放在一个MetaCache中。这种情况极少出现,一般发生在HBase客户端到服务端连接第一次建立后的少数几个请求内,所以并不会对HBase服务端造成巨大压力。

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

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

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

Scan的复杂之处

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

为便于理解,我们先来看一下客户端Scan的核心流程。在上面的代码示例中,我们已经知道table.getScanner(scan)可以拿到一个scanner,然后只要不断地执行scanner.next()就能拿到一个Result,如图所示。

image.png
客户端读取Result流程

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

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

理解了scanner的执行流程之后,再来理解Scan的几个重要的概念。

• caching:每次loadCache操作最多放caching个result到cache队列中。控制caching,也就能控制每次loadCache向服务端请求的数据量,避免出现某一次scanner.next()操作耗时极长的情况。

• batch:用户拿到的result中最多含有一行数据中的batch个cell。如果某一行有5个cell,Scan设的batch为2,那么用户会拿到3个result,每个result中cell个数依次为2,2,1。

• allowPartial:用户能容忍拿到一行部分cell的result。设置了这个属性,将跳过上图中的第三步重组流程,直接把服务端收到的result返回给用户。

• maxResultSize:loadCache时单次RPC操作最多拿到maxResultSize字节的结果集。

对上面4个概念有了基本认识之后,再来分析以下具体的案例。

例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列表。
image.png
注意,最右栏中,通过虚线框标出了每次loadCache的情况。由于设置caching=2,因此第二次loadCache最多只能拿到2个result。

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

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

image.png

文章基于《HBase原理与实践》一书

阅读 339
58 声望
15 粉丝
0 条评论
58 声望
15 粉丝
宣传栏