本节书摘来异步社区《HBase权威指南》一书中的第3章,第3.5节,作者: 【美】Lars George 译者: 代志远 , 刘佳 , 蒋杰 责编: 杨海玲

3.5 扫描

在讨论过基本的CRUD类型的操作之后,现在来看一下扫描(scan)技术,这种技术类似于数据库系统中的游标(cursor),并利用到了HBase提供的底层顺序存储的数据结构。⑧

3.5.1 介绍

扫描操作的使用跟get()方法非常类似。同样,和其他函数类似,这里也提供了Scan类。但是由于扫描操作的工作方式类似于迭代器,所以用户无需调用scan()方法创建实例,只需调用HTable的getScanner()方法,此方法在返回真正的扫描器(scanner)实例的同时,用户也可以使用它迭代获取数据。可用方法如下:

ResultScanner getScanner(Scan scan) throws IOException
ResultScanner getScanner(byte[] family)throws IOException
ResultScanner getScanner(byte[] family,byte[] qualifier)
   throws IOException

后两个为了方便用户,隐式地帮用户创建了一个Scan实例,逻辑中最后调用getScanner(Scan scan)方法。

Scan类拥有以下构造器:

Scan()
Scan(byte[] startRow,Filter filter)
Scan(byte[] startRow)
Scan(byte[] startRow,byte[] stopRow)

这与Get类的不同点是显而易见的:用户可以选择性地提供startRow参数,来定义扫描读取HBase表的起始行键,即行键不是必须指定的。同时可选stopRow参数用来限定读取到何处停止。

图像说明文字起始行包括在内,而终止行是不包括在内的。一般用区间表示法表示为[startRow, stopRow]。
扫描操作有一个特点:用户提供的参数不必精确匹配这两行。扫描会匹配相等或大于给定的起始行的行键。如果没有显式地指定起始行,它会从表的起始位置开始获取数据。

当遇到了与设置的终止行相同或大于终止行的行键时,扫描也会停止。如果没有指定终止行键,会扫描到表尾。

另一个可选参数叫做过滤器(filter),可直接指向Filter实例。尽管Scan实例通常由空白构造器构造,但其所有可选参数都有对应的getter方法和setter方法。

创建Scan实例之后,用户可能还要给它增加更多限制条件。这种情况下,用户仍然可以使用空白参数的扫描,它可以读取整个表格,包括所有列族以及它们的所有列。可以用多种方法限制所要读取的数据:

Scan addFamily(byte [] family)
Scan addColumn(byte[] family,byte[] qualifier)

这里有很多与Get类相似的功能:可以使用addFamily()方法限制返回数据的列族,或者通过addColumn()方法限制返回的列。

图像说明文字如果用户只需要数据的子集,那么限制扫描的范围就能发挥HBase的优势。因为HBase中的数据是按列族存储的,如果扫描不读取某个列族,那么整个列族文件就都不会被读取,这就是列式存储架构的优势。

Scan setTimeRange(long minStamp,long maxStamp) throws IOException
  Scan setTimeStamp(long timestamp)
  Scan setMaxVersions()
  Scan setMaxVersions(int maxVersions)

用户可以通过setTimestamp()设置详细的时间戳,或者通过setTimeRange()设置时间范围,进一步对结果进行限制。还可以使用setMaxVersions()方法,让扫描只返回每一列的一些特定版本,或者全部的版本。

Scan setStartRow(byte[] startRow)
Scan setStopRow(byte[] stopRow)
Scan setFilter(Filter filter)
boolean hasFilter()

还可以使用setStartRow()、setStopRow()以及setFilter(),进一步限定返回的数据。这3个方法中的参数可以与构造器中的一样。附加的hasFilter()方法可以检查是否已经设定过滤器。

还有一些相关的方法,见表3-8。

hbase获取数据慢 hbase resultscanner_数据

一旦设置好了Scan实例,就可以调用Htable的getScanner()方法,获得用于检索数据的ResultScanner实例。我们将在下一节中详细讨论这个类。

3.5.2 ResultScanner类

扫描操作不会通过一次RPC请求返回所有匹配的行,而是以行为单位进行返回。很明显,行的数目很大,可能有上千条甚至更多,同时在一次请求中发送大量数据,会占用大量的系统资源并消耗很长时间。

ResultScanner把扫描操作转换为类似的get操作,它将每一行数据封装成一个Result实例,并将所有的Result实例放入一个迭代器中。ResultScanner的一些方法如下:

Result next() throws IOException
Result[] next(int nbRows)throws IOException
void close()

有两种类型的next()调用供用户选择。调用close()方法会释放所有由扫描控制的资源。

扫描器租约

要确保尽早释放扫描器实例,一个打开的扫描器会占用不少服务端资源,累积多了会占用大量的堆空间。当使用完ResultScanner之后应调用它的close()方法,同时应当把close()方法放到try/finally块中,以保证其在迭代获取数据过程中出现异常和错误时,仍然能执行close()。

注意为了简洁,示例代码并未遵循这个建议。

就像行锁一样,扫描器也使用同样的租约超时机制,保护其不被失效的客户端阻塞太久。用户可以使用修改锁租约处提到的那个配置属性来修改超时时间(单位为毫秒):

< property> 
 < name>hbase.regionserver.lease.period< /name>
 < value>120000< /value>
< /property>

用户需要确保该属性值适当,这个值要同时适用于锁租约和扫描器租约。
next()调用返回一个单独的Result实例,这个实例代表了下一个可用的行。此外,用户可以使用next(int nbRows)一次获取多行数据,它返回一个数组,数组中包含的Result实例最多可达nbRows个,每个实例代表唯一的一行。当用户扫描到表尾或到终止行时,由于没有足够的行来填充数据,返回的结果数组可能会小于既定长度。有关怎样使用Result实例的问题请参阅前面介绍的“Result类”,更详细的内容请参阅3.2.2节。

例3.18集中使用了之前解释过的功能,扫描了一张表,逐行处理了其中的列数据。

例3.18 使用扫描器获取表中数据

Scan scan1 = new Scan();
ResultScanner scanner1 = table.getScanner(scan1);
for(Result res : scanner1){
  System.out.println(res);
}
scanner1.close();

Scan scan2 = new Scan();
scan2.addFamily(Bytes.toBytes("colfam1"));
ResultScanner scanner2 = table.getScanner(scan2);
for(Result res : scanner2){
  System.out.println(res);
}
scanner2.close();

Scan scan3 = new Scan();
scan3.addColumn(Bytes.toBytes("colfam1"),Bytes.toBytes("col-5")).
  addColumn(Bytes.toBytes("colfam2"),Bytes.toBytes("col-33")). 
  setStartRow(Bytes.toBytes("row-10")).
  setStopRow(Bytes.toBytes("row-20"));
ResultScanner scanner3 = table.getScanner(scan3);
for(Result res : scanner3){
  System.out.println(res);
}
scanner3.close();

创建一个空的Scan实例。

取得一个扫描器迭代访问所有的行。

打印行内容。

关闭扫描器释放远程资源。

只添加一个列族,这样可以禁止获取“colfam2”的数据。

使用builder模式将详细限制条件添加到Scan中。

代码插入了100行数据,每行有两个列族,每个列族下包含100个列。第一个扫描操作扫描全表内容,第二个扫描操作只扫描一个列族,最后一个扫描操作有严格的限制条件,其中包括对行范围的限制,同时还要求只扫描两个特定的列。输出如下:

Scanning table #3...
keyvalues={row-10/colfam1:col-5/1300803775078/Put/vlen=8,
       row-10/colfam2:col-33/1300803775099/Put/vlen=9}
keyvalues={row-100/colfam1:col-5/1300803780079/Put/vlen=9,
       row-100/colfam2:col-33/1300803780095/Put/vlen=10}
keyvalues={row-11/colfam1:col-5/1300803775152/Put/vlen=8,
       row-11/colfam2:col-33/1300803775170/Put/vlen=9}
keyvalues={row-12/colfam1:col-5/1300803775212/Put/vlen=8,
       row-12/colfam2:col-33/1300803775246/Put/vlen=9}
keyvalues={row-13/colfam1:col-5/1300803775345/Put/vlen=8,
       row-13/colfam2:col-33/1300803775376/Put/vlen=9}
keyvalues={row-14/colfam1:col-5/1300803775479/Put/vlen=8,
       row-14/colfam2:col-33/1300803775498/Put/vlen=9}
keyvalues={row-15/colfam1:col-5/1300803775554/Put/vlen=8,
       row-15/colfam2:col-33/1300803775582/Put/vlen=9}
keyvalues={row-16/colfam1:col-5/1300803775665/Put/vlen=8,
       row-16/colfam2:col-33/1300803775687/Put/vlen=9}
keyvalues={row-17/colfam1:col-5/1300803775734/Put/vlen=8,
       row-17/colfam2:col-33/1300803775748/Put/vlen=9}
keyvalues={row-18/colfam1:col-5/1300803775791/Put/vlen=8,
       row-18/colfam2:col-33/1300803775805/Put/vlen=9}
keyvalues={row-19/colfam1:col-5/1300803775843/Put/vlen=8,
       row-19/colfam2:col-33/1300803775859/Put/vlen=9}
keyvalues={row-2/colfam1:col-5/1300803774463/Put/vlen=7,
       row-2/colfam2:col-33/1300803774485/Put/vlen=8}

再强调一次,匹配的行键都是按词典序排列的,这使得结果非常有趣。用户可以简单地用0把行键补齐,这样扫描出来结果顺序更有可读性。这些都是在你的控制下完成的,所以请仔细设计行键。

3.5.3 缓存与批量处理

到目前为止,每一个next()调用都会为每行数据生成一个单独的RPC请求,即使使用next(int nbRows)方法,也是如此,因为该方法仅仅是在客户端循环地调用next()方法。很显然,当单元格数据较小时,这样做的性能不会很好(参见3.2.1节中“客户端的写缓冲区”的讨论)。因此,如果一次RPC请求可以获取多行数据,这样会更有意义。这样的方法可以由扫描器缓存(scanner caching)实现,默认情况下,这个缓存是关闭的。

可以在两个层面上打开它:在表的层面,这个表所有扫描实例的缓存都会生效;也可以在扫描层面,这样便只会影响当前的扫描实例。用户可以使用以下的HTable方法设置表级的扫描器缓存:

void setScannerCaching(int scannerCaching)
int getScannerCaching()

文字用户可以修改整个HBase集群的默认值1。只要把下面的配置项添加到hbase-site.xml中即可:

< property> 
  < name>hbase.client.scanner.caching< /name>
  < value>10< /value>
< /property>

这样所有Scan实例的扫描器缓存大小就都被设置为10了。用户还可以从表或扫描两个层面覆盖默认配置,但是需要明确这样做的目的。
setScannerCaching()可以设置缓存大小,getScannerCaching()可以返回当前缓存大小的值。每次用户调用getScanner(scan)之后,API都会把设定值配置到扫描实例中——除非用户使用了扫描层面的配置并覆盖了表层面的配置,扫描层面的配置优先级最高。可以使用下列Scan类的方法设置扫描级的缓存:

void setCaching(int caching)
int getCaching()

这两个方法的作用和表层面的方法一样,能控制每次RPC调用取回的行数。两种next()方法都会受这些配置影响。

用户需要为少量的RPC请求次数和客户端以及服务器端的内存消耗找到平衡点。很多时候,将扫描器缓存设得比较高能提高扫描的性能,不过设得太高就会产生不良影响:每次next()调用将会占用更长的时间,因为要获取更多的文件并传输到客户端,如果返回给客户端的数据超出了其堆的大小,程序就会终止并抛出OutOfMemoryException异常。

文字当传输和处理数据的时间超过配置的扫描器租约超时时间时,用户将会收到一个以ScannerTimeoutException形式抛出的租约过期(lease expired)错误。

例3.19展示了扫描器超时的情况。

例3.19 使用扫描器时超时

Scan scan = new Scan();
ResultScanner scanner = table.getScanner(scan);

int scannerTimeout =(int)conf.getLong(
   HConstants.HBASE_REGIONSERVER_LEASE_PERIOD_KEY,-1);
try {
  Thread.sleep(scannerTimeout + 5000);
} catch(InterruptedException e){
  // ignore
}

while(true){
  try {
   Result result = scanner.next();
   if(result == null)break;
   System.out.println(result);
 }   catch(Exception e){
     e.printStackTrace();
   break;
 }
}
scanner.close();

得到当前配置的租约超时时间。

休眠的时间比租约超时时间再长一点。

打印行的内容。

这段代码得到当前配置的租约时间,休眠了比这个时间更长的时间,然后服务器端感知租约超时并触发租约恢复操作。控制台输出的结果与如下结果类似(为了方便阅读做了精简):

Adding rows to table...
Current(local)lease period: 60000
Sleeping now for 65000ms...
Attempting to iterate over scanner...
Exception in thread "main" java.lang.RuntimeException:
  org.apache.hadoop.hbase.client.ScannerTimeoutException: 65094ms passed
    since the last invocation,timeout is currently set to 60000
   at org.apache.hadoop.hbase.client.HTable$ClientScanner$1.hasNext
   at ScanTimeoutExample.main
Caused by: org.apache.hadoop.hbase.client.ScannerTimeoutException: 65094ms
    passed since the last invocation,timeout is currently set to 60000
   at org.apache.hadoop.hbase.client.HTable$ClientScanner.next
   at org.apache.hadoop.hbase.client.HTable$ClientScanner$1.hasNext
   ... 1 more
Caused by: org.apache.hadoop.hbase.UnknownScannerException:
  org.apache.hadoop.hbase.UnknownScannerException: Name: -315058406354472427
   at org.apache.hadoop.hbase.regionserver.HRegionServer.next
...

示例代码打印了执行的进度,在休眠一定的时间之后,尝试迭代获取扫描器提供的行。由于租约超时,这个操作触发服务器端超时异常,同时返回的异常信息中还包括了当前配置的超时时间。

文字用户可能会尝试向配置中添加如下信息:

Configuration conf = HBaseConfiguration.create()
conf.setLong(HConstants.HBASE_REGIONSERVER_LEASE_PERIOD_KEY,120000)

假设这个修改把超时时间延长了(在这个例子里,延长到了2分钟)。由于这个值是在客户端应用中配置的,不会被传递到远程region服务器,所以这样的修改是无效的。

如果用户要修改之前讨论的超时时间,用户必须修改服务器端(region服务器)的配置文件hbase-site.xml,修改完之后别忘了重启服务器使配置生效!
从上面打印出的堆栈追踪信息中还可以看出:ScannerTimeoutException异常是如何包装在UnknownScannerException异常的外面抛出的。以上信息表明扫描器的next()方法使用扫描器 ID在服务器端查找已经建立的扫描器,但由于这个扫描器 ID的租约超时,已经被删除了。换句话说,客户端缓存的扫描器ID在region服务器上已经查找不到了,这与这个异常名称所表达的含义相符。

到目前为止,我们已经介绍了如何使用客户端的扫描器缓存来从远程region服务器向客户端整批传输数据。不过还有之前提到过的一件事需要注意:数据量非常大的行,这些行有可能超过客户端进程的内存容量。HBase和它的客户端API对这个问题有一个解决方法:批量。用户可以使用以下方法控制批量获取操作:

void setBatch(int batch)
int getBatch()

缓存是面向行一级的操作,而批量则是面向列一级的操作。批量可以让用户选择每一次ResultScanner实例的next()操作要取回多少列。例如,在扫描中设置setBatch(5),则一次next()返回的Result实例会包括5列。

文字如果一行包括的列数超过了批量中设置的值,则可以将这一行分片,每次next操作返回一片。

当一行的列数不能被批量中设置的值整除时,最后一次返回的Result实例会包含比较少的列,例如,如果一行有17列,用户把batch值设为5,则一共会返回4个Result实例,这4个实例中包括的列数应当分别为5、5、5和2。
组合使用扫描器缓存和批量大小,可以让用户方便地控制扫描一个范围内的行键时所需要的RPC调用次数。例3.20为了控制RPC请求的次数,使用了这两个参数来调节每次Result实例的大小。

例3.20 在扫描中使用缓存和批量参数

private static void scan(int caching,int batch)throws IOException {
  Logger log = Logger.getLogger("org.apache.hadoop");
  final int[] counters = {0,0};
 Appender appender = new AppenderSkeleton() {
   @Override
   protected void append(LoggingEvent event){
    String msg = event.getMessage().toString();
    if(msg != null && msg.contains("Call: next")){
     counters[0]++;
    }
  }
  @Override
  public void close() {}
  @Override
  public boolean requiresLayout() {
    return false;
  }
 };
 log.removeAllAppenders();
 log.setAdditivity(false);
 log.addAppender(appender);
 log.setLevel(Level.DEBUG);

 Scan scan = new Scan();
 scan.setCaching(caching); 
 scan.setBatch(batch);
 ResultScanner scanner = table.getScanner(scan);
 for(Result result : scanner){
   counters[1]++;
 }
 scanner.close();
 System.out.println("Caching: " + caching + ",Batch: " + batch +
   ",Results: " + counters[1] + ",RPCs: " + counters[0]);
}

public static void main(String[] args) throws IOException {
  scan(1,1);
  scan(200,1);
  scan(2000,100);
  scan(2,100);
  scan(2,10);
  scan(5,100);
  scan(5,20);
  scan(10,10);
}

设置缓存和批量处理两个参数。

对返回的Result实例计数。

用不同的参数组合测试。

代码打印出了这两个参数的值、服务器返回的Result实例数目以及获取数据过程所发起的RPC请求的数目。结果如下:

Caching: 1,Batch: 1,Results: 200,RPCs: 201
Caching: 200,Batch: 1,Results: 200,RPCs: 2
Caching: 2000,Batch: 100,Results: 10,RPCs: 1
Caching: 2,Batch: 100,Results: 10,RPCs: 6
Caching: 2,Batch: 10,Results: 20,RPCs: 11
Caching: 5,Batch: 100,Results: 10,RPCs: 3
Caching: 5,Batch: 20,Results: 10,RPCs: 3
Caching: 10,Batch: 10,Results: 20,RPCs: 3

用户可以修改调整这两个参数来查看它们对输出结果的影响。表3-9展示了一些组合。这些组合与例3.20相关,例3.20中我们建立了一张有两个列族的表,添加了10行数据,每个行的每个列族下有10列。这意味着整个表一共有200列(或单元格,因为每个列只有一个版本),其中每行有20列。

hbase获取数据慢 hbase resultscanner_hbase获取数据慢_02

文字要计算一次扫描操作的RPC请求的次数,用户需要先计算出行数和每行列数的乘积(至少了解大概情况)。然后用这个值除以批量大小和每行列数中较小的那个值。最后再用除得的结果除以扫描器缓存值。用数学公式表示如下:

RPC请求的次数 =(行数×每行的列数)/
 Min(每行的列数,批量大小)/扫描器缓存>

此外,还需要一些请求来打开和关闭扫描器。用户或许需要把这两次请求也考虑在内。*
图3-2展示了缓存和批量两个参数如何联动。图3-2中有一个包含9行数据的表,每行都包含一些列。使用了一个缓存为6、批量大小为3的扫描器,读者可以观察到需要3个PRC请求来传送数据(虚线圆角方框)。

hbase获取数据慢 hbase resultscanner_大数据_03

小的批量值使服务端把3个列装入一个Result实例,同时扫描器缓存为6,使每次RPC请求传输6行,即6个被批量封装的Result实例。如果没有指定批量大小,但指定了扫描器缓存,那么一个调用结果就能包含所有的行,因为每一行都包含在一个Result实例中。只有当用户使用批量模式之后,行内(intra-row)扫描功能才会启用。

最初,用户可能不必为扫描器缓存和批量模式的使用操心,但当用户想尽量提高和利用系统性能时,可能就需要为这两个参数选择一个合适的组合了。