开发者社区> 异步社区> 正文
阿里云
为了无法计算的价值
打开APP
阿里云APP内打开

《HBase权威指南》一3.2 CRUD操作

简介:
+关注继续查看

本节书摘来异步社区《HBase权威指南》一书中的第3章,第3.2节,作者: 【美】Lars George 译者: 代志远 , 刘佳 , 蒋杰 责编: 杨海玲,更多章节内容可以访问云栖社区“异步社区”公众号查看。

3.2 CRUD操作

数据库的初始基本操作通常被称为CRUD(Create,Read,Update,Delete),具体指增、查、改、删。HBase中有与之相对应的一组操作,随后我们会依次介绍。这些方法都由HTable类提供,本章后面将直接引用这个类的方法,不再特别提到这个包含类。

接下来介绍的操作大多都能不言自明,但本书有一些细节需要大家注意。这意味着,对于书中出现的一些重复的模式,我们不会多次赘述。

文字你所看到的示例源代码都可以从GitHub的公用源中下载,具体地址为https://github.com/larsgeorge/hbase-book。如果需要了解源码编译的细节,请参考前言中的“编译示例程序”一节。

读者一开始会在一些程序的开头看到import语句,但为了简洁,后续将会省略import语句。同时,一些与主题不太相关的代码部分也会被省略。如有疑问,请到上面的地址中查阅完整源代码。

3.2.1 put方法

下面介绍的这组操作可以被分为两类:一类操作用于单行,另一类操作用于多行。鉴于后面有一些内容比较复杂,我们会分开介绍这两类操作。同时,我们还会介绍一些衍生的客户端API特性。

1.单行put
也许你现在最想了解的就是如何向HBase中存储数据,下面就是实现这个功能的调用:

void put(Put put) throws IOException

这个方法以单个Put或存储在列表中的一组Put对象作为输入参数,其中Put对象是由以下几个构造函数创建的:

Put(byte[] row)
Put(byte[] row, RowLock rowLock)
Put(byte[] row, long ts)
Put(byte[] row, long ts, RowLock rowLock)

创建Put实例时用户需要提供一个行键row,在HBase中每行数据都有唯一的行键(row key)作为标识,跟HBase的大多数数据类型一样,它是一个Java的byte[]数组。用户可以按自己的需求来指定每行的行键,且可以参考第9章,其中专门有一节详细讨论了行键的设计(见9.1节)。现在我们假设用户可以随意设置行键,通常情况下,行键的含义与真实场景相关,例如,它的含义可以是一个用户名或者订单号,它的内容可以是简单的数字,也可以是较复杂的UUID②等。

HBase非常友好地为用户提供了一个包含很多静态方法的辅助类,这个类可以把许多Java数据类型转换为byte[]数组。例3.1提供了方法的部分清单。

例3.1 Bytes类所提供的方法

static byte[] toBytes(ByteBuffer bb)
static byte[] toBytes(String s)
static byte[] toBytes(boolean b)
static byte[] toBytes(long val)
static byte[] toBytes(float f)
static byte[] toBytes(int val)

...
创建Put实例之后,就可以向该实例添加数据了,添加数据的方法如下:

Put add(byte[] family, byte[] qualifier, byte[] value)
Put add(byte[] family, byte[] qualifier, long ts, byte[] value)
Put add(KeyValue kv) throws IOException

每一次调用add()都可以特定地添加一列数据,如果再加一个时间戳选项,就能形成一个数据单元格。注意,当不指定时间戳调用add()方法时,Put实例会使用来自构造函数的可选时间戳参数(也称作ts),如果用户在构造Put实例时也没有指定时间戳,则时间戳将会由region服务器设定。

系统为一些高级用户提供了KeyValue实例的变种,这里所说的高级用户是指知道怎样检索或创建这个内部类的用户。KeyValue实例代表了一个唯一的数据单元格,类似于一个协调系统,该系统使用行键、列族、列限定符、时间戳指向一个单元格的值,像一个三维立方体系统(其中,时间成为了第三维度)。

获取Put实例内部添加的KeyValue实例需要调用与add()相反的方法get():

List< KeyValue> get(byte[] family, byte[] qualifier)
Map< byte[], List< KeyValue>> getFamilyMap()

以上两个方法可以查询用户之前添加的内容,同时将特定单元格的信息转换成KeyValue实例。用户可以选择获取整个列族(column family)的全部数据单元,一个列族中的特定列或是全部数据。后面的getFamilyMap()方法可以遍历Put实例中每一个可用的KeyValue实例,检查其中包含的详细信息。

图像说明文字每一个KeyValue实例包含其完整地址(行键、列族、列限定符及时间戳)和实际数据。KeyValue是HBase在存储架构中最底层的类。8.2节将会详细介绍相关内容。对于客户端API所用到的KeyValue类中的方法参见3.2.1节。
用户可以采用以下这些方法检查是否存在特定的单元格,而不需要遍历整个集合:

boolean has(byte[] family, byte[] qualifier)
boolean has(byte[] family, byte[] qualifier, long ts)
boolean has(byte[] family, byte[] qualifier, byte[] value)
boolean has(byte[] family, byte[] qualifier, long ts, byte[] value)

随着以上方法所使用参数的逐步细化,获得的信息也越详细,当找到匹配的列时返回true。第一个方法仅检查一个列是否存在,其他的方法则增加了检查时间戳、限定值的选项。

Put类还提供了很多的其他方法,在表3-1中进行了概括。


b1

文字注意,表3-1所列的Put类中的那些getter函数仅能够获取用户预先设定的内容,实际应用中很少用到它们,仅当用户在代码的私有方法中准备实现一个Put实例,并在其他地方检查其内容时,才会用到它们。

例3.2展示了如何在一个简单的程序里使用上述方法。

文字本章的示例使用了一个非常有限的精确数据集。当读者查看整个源代码时,会注意到源代码使用了一个名叫HBaseHelper的内部类。该内部类会创建一个有特定行和列数量的数据测试表。这让我们更容易对比处理前后的差异。

读者可以将代码直接放到本地主机上的独立HBase实例中来测试,也可以放到HBase集群上测试。前言中的“编译示例程序”一节解释了如何编译这些例子。读者可以大胆地修改这部分代码,以便更好地体会各部分功能。

为了清除前一步示例程序执行时产生的数据,示例代码通常会删除前一步执行时所创建的表。如果你在生产集群上运行示例,请先确保表名无冲突。通常示例代码创建的表为testtable,这个名称容易让人联想到表的用途。
例3.2 向HBase插入数据的示例应用

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.client.HTable;
import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.util.Bytes;

import java.io.IOException;

public class PutExample {

 public static void main(String[] args) throws IOException {
   Configuration conf = HBaseConfiguration.create();

   HTable table = new HTable(conf,"testtable");

   Put put = new Put(Bytes.toBytes("row1"));

   put.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
    Bytes.toBytes("val1"));
   put.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual2"),
    Bytes.toBytes("val2"));

   table.put(put); 
  }
}

创建所需的配置。

实例化一个新的客户端。

指定一行来创建一个Put。

向Put中添加一个名为“colfam1:qual1”的列。

向Put中添加另一个名为“colfam1:qual2”的列。

将这一行存储到HBase表中。

这个示例代码(几乎)十分完整,并且每一行都进行了解释。以后的示例会逐渐减少样板代码,以便读者能将注意力集中到重要的部分。

通过客户端代码访问配置文件

2.6.7节介绍了HBase客户端应用程序使用的配置文件。应用程序需要通过默认位置(classpath)下的hbase-site.xml文件来获知如何访问集群,此外也可以在代码里指定集群的地址。

无论哪种方式,都需要在代码中使用一个HBaseConfiguration类来处理配置的属性。可以使用该类提供的以下静态方法构建Configuration实例:

static Configuration create()
static Configuration create(Configuration that)

例3.2中使用了create()来获得Configuration实例。第二个方法允许你使用一个已存在的配置,该配置会融合并覆盖HBase默认配置。

当你调用任何一个静态create()方法时,代码会尝试使用当前的Java classpath来载入两个配置文件:h`javascript
base-default.xml和hbase-site.xml。


如果使用create(Configuration that)方法指定一个已存在的配置,那么与所有从classpath载入的配置相比,用户指定的配置优先级最高。

HBaseConfiguration类继承自Hadoop的Configuration类,但是HBaseConfiguration类仍然和Configuration类兼容:用户可以提交一个Hadoop的Configuration实例,它们的内容可以很好地合并。

当用户获得了一个HBaseConfiguration实例之后,其实已获得了一个已经合并过的配置,其中包括默认值和在hbase-site.xml配置文件中重写的属性,以及一些用户提交的可选配置。在使用HTable实例之前,用户可以任意地修改配置。例如,可以重写ZooKeeper的可用连接地址来定位到另一个集群:
Configuration config = HBaseConfiguration.create();
config.set("hbase.zookeeper.quorum","zk1.foo.com,zk2.foo.com");
换句话说,可以简单地忽略任何外部的客户端配置文件,而直接在代码中设置hbase.zookeeper.quorum属性。这样就创建了一个不需要额外配置的客户端。

同时应该共享配置实例,4.6节解释了这样做的原因。
现在又可以使用Shell命令行(详情见2.1节)来验证插入是否成功了:

hbase(main):001:0>list
TABLE
testtable
1 row(s) in 0.0400 seconds

hbase(main):002:0>scan 'testtable'
ROW         COLUMN+CELL
row1        column=colfam1:qual1,timestamp=1294065304642,value=val1
1 row(s) in 0.2050 seconds

在创建Put实例时用到的另一个可选参数是ts,即时间戳(timestamp)。在HBase表中,时间戳使用户可以在HBase表中将数据存储为一个特定版本。

数据的版本化

HBase的一个特殊功能是,能为一个单元格(一个特定列的值)存储多个版本的数据。这是通过每个版本使用一个时间戳。并且按照降序存储来实现的。每个时间戳是一个长整型值,以毫秒为单位。它表示自世界标准时间(UTC)1970年1月1日0时以来所经过的时间,这个时间又称为Unix时间③或Unix纪元。大多数操作系统都有一个时钟获取函数来读取这个时间。例如,在Java中可以使用System.currentTimeMillis()函数。

将数据存入HBase时,要么显式地提供一个时间戳,要么忽略该时间戳。如果用户忽略该时间戳的话,RegionServer会在执行put操作的时候填充该时间戳。

如2.2节所述,必须确保服务器的时间是正确的,并且互相之间是同步的。用户可能无法控制客户端时间,所以很可能会与服务器时间不同,可能会相差几小时甚至相差几年。

如果用户不指定时间,那么客户端API调用会以服务器端时间为准。一旦需要使用并指定明确的时间戳,用户需要确保不受一些可能发生的意外的影响。客户端可能会使用意想不到的时间戳来插入数据,从而产生看似无序的版本历史。

虽然大多数程序并不担心版本化问题,并且依赖于HBase内置的处理时间戳的方法,但是如果要使用自定义的时间戳,就应该了解这些特性。

下面展示了如何向一个单元格插入并且获取多版本数据。

hbase(main):001:0> create 'test','cf1
0 row(s) in 0.9810 seconds

hbase(main):002:0> put 'test','row1','cf1','val1'
0 row(s)in 0.0720 seconds

hbase(main):003:0> put 'test','row1','cf1','val2'
0 row(s) in 0.0520 seconds

hbase(main):004:0> scan 'test'
ROW         COLUMN+CELL
row1        column=cf1:,timestamp=1297853125623,value=val2
1 row(s) in 0.0790 seconds

hbase(main):005:0> scan 'test',{ VERSIONS => 3 }
ROW         COLUMN+CELL
row1        column=cf1:,timestamp=1297853125623,value=val2
row1        column=cf1:,timestamp=1297853122412,value=val1
1 row(s) in 0.0640 seconds

该示例在test表中创建了一个名为cf1的列族。两个put命令使用了相同的行键和列键,但它们的值不同:分别为val1和val2。然后使用scan操作查看了这张表的所有内容。你可能并不惊讶于只看到了val2,因为你可能已经假设第二次put操作覆盖了val1。

但是在HBase中并不是这样的。默认情况下,HBase会保留3个版本的数据,用户可以利用这种特性,稍稍修改scan操作以便获取所有可获得的数据(即版本)。示例中的最后一个命令列出了所有存储的数据版本。注意,即使所有输出的行键都是相同的,在Shell的输出中,所有的单元格都是以单独的一行输出的。

scan操作和get操作只会返回最后的(也叫最新的)版本,这是因为HBase默认按照版本的降序存储,并且只返回一个版本。在调用中加入最大版本(maximum version)参数就可以获得多个版本的数据,如果将参数值设定为Integer.MAX_VALUE,就可以获得所有的版本。

正如最大版本的术语所表现出来的意思一样,对于一个特定的单元格,有可能只有少于最大版本数个数版本。示例将VERSIONS(MAX_VERSIONS的缩写)设为3,但是该单元格只存储了两个版本的数据,所以就列出了两个。

另一个获取多个版本数据的方法是,使用时间范围参数。只需要设置开始时间和结束时间,就能获得所有满足时间范围的版本数据。更多有关这一方面的内容,请参考3.2.2节和3.5节。

关于版本化,有很多细小(有些也不算小)的问题,将在8.4节继续讨论,而且还会在9.6节重新讨论更高级的概念,以及不标准的行为。
如果读者不指定该参数,当数据存储到底层文件系统时,RegionServer会将当前行的时间戳隐式地设定为系统当前时间。

Put类的构造函数还有一个名为rowlock的可选参数,它允许提交一个额外的行锁(row lock),详见3.4节。最后还要说一句,若需要频繁地重复修改某些行,用户有必要创建一个RowLock实例来防止其他客户端访问这些行。

2.KeyValue类
在代码中有时需要直接处理KeyValue实例。你可能还记得之前讨论过的那些实例,它们都含有一个特定单元格的数据以及坐标(coordinate)。坐标包括行键、列族名、列限定符以及时间戳。该类提供了特别多的构造器,允许以各种方式组合这些参数。下面展示了包括所有参数的构造器:

KeyValue(byte[] row, int roffset, int rlength,
  byte[] family, int foffset, int flength, byte[] qualifier, int qoffset,
  int qlength, long timestamp, Type type, byte[] value, int voffset,
  int vlength)

图像说明文字建议将KeyValue类和它的比较器都设计为HBase内部使用。只在客户端API的几个地方出现,以便用户访问原始数据,这样可以避免额外的复制操作。还可以允许基于字节的比较,而不是依靠比较慢的基于类的比较。
数据和坐标都是以Java的byte[]形式存储的,即以字节数组的形式存储的。使用这种底层存储类型的目的是,允许存储任意类型的数据,并且可以有效地只存储所需的字节,这保证了最少的内部数据结构开销。另一个原因是,每一个字节数组都有一个offset参数和一个length参数,它们允许用户提交一个已存在的字节数组,并进行效率很高的字节级别的操作。

坐标中任意一个成员都有一个getter方法,可以获得字节数组以及它们的参数offset和length。不过也可以在最顶层访问它们,即直接读取底层字节缓冲区:

byte[] getBuffer()
int getOffset()
int getLength()

它们返回当前KeyValue实例中字节数组完整信息。用户用到这些方法的场景很少,但是在需要的时候,还是可以使用这些方法的。

还有两个有意思的方法:

byte [] getRow()
byte [] getKey()

读者也许会问这样一个问题:行(row)和键(key)有什么区别?关于它们的区别将在8.2节中描述。行目前来说指的是行键,即Put构造器里的row参数。而在之前介绍的内容中,键是一个单元格的坐标,用的是原始的字节数组格式。在实践中,几乎用不到getKey(),但有可能会用到getRow()。

KeyValue类还提供一系列实现了Comparator接口的内部类,可以在代码里使用它们来实现与HBase内部一样的比较器。当需要使用API获取KeyValue实例时,并进一步排序或按顺序处理时,就要用到这些比较器。表3-2列出了这些比较器。

<span style='display:block;text-align:center'>
![2](https://yqfile.alicdn.com/7abd9e1c88f8259e5a771a9fdc5e721e68ddfe27.png)

</span>
KeyValue类将大部分的比较器按照静态实例提供给其他类使用。例如,有一个公有变量KEY_COMPARATOR,让用户可以访问KeyComparator实例。COMPARATOR变量指向使用更频繁的KVComparator实例。所以可以不用创建自己的实例,而是使用提供的实例。例如,可以按照以下方法创建一个KeyValue实例的集合,这个集合可以按照HBase内部使用的顺序来排序:

TreeSet< KeyValue> set =
  new TreeSet< KeyValue>(KeyValue.COMPARATOR)

KeyValue实例还有一个变量(一个额外的属性),代表该实例的唯一坐标:类型。表3-3列出了所有可能的值。

<span style='display:block;text-align:center'>
![3](https://yqfile.alicdn.com/7f5541c94b4f71e7cf452f8dd3373276440fdcda.png)

</span>
可以通过使用另外一个方法来查看一个KeyValue实例的类型,例如:

String toString()

该方法会按照以下格式打印出当前KeyValue实例的元信息:

< row-key>/< family>:< qualifier>/< version>/< type>/< value-length>

这个方法会用在本书的一些示例代码中,用于检查数据是否被标记或者被恢复,同时也可以查看元信息。

该类有很多更便捷的方法:允许对存储数据的其中一部分进行比较,检查实例的类型是什么,获得它已经计算好的堆大小,克隆或者复制该类等。有一些静态方法可以创建一些特殊的KeyValue实例,用以在HBase内更底层地比较或者操作数据。可以参考Java文档来了解更多的内容④。还可以查看8.2节,该节详细地解释了KeyValue原始的二进制格式内容。

3.客户端的写缓冲区
每一个put操作实际上都是一个RPC⑤操作,它将客户端数据传送到服务器然后返回。这只适合小数据量的操作,如果有个应用程序需要每秒存储上千行数据到HBase表中,这样的处理就不太合适了。

图像说明文字减少独立RPC调用的关键是限制往返时间(round-trip time),往返时间就是客户端发送一个请求到服务器,然后服务器通过网络进行响应的时间。这个时间不包含数据实际传输的时间,它其实就是通过线路传送网络包的开销。一般情况下,在LAN网络中大约要花1毫秒的时间,这意味着在1秒钟的时间内只能完成1000次RPC往返响应。

另一个重要的因素就是消息大小。如果通过网络发送的请求内容较大,那么需要请求返回的次数相应较少,这是因为时间主要花费在数据传递上。不过如果传送的数据量很小,比如一个计数器递增操作,那么用户把多次修改的数据批量提交给服务器并减少请求次数,性能会有相应提升。
HBase的API配备了一个客户端的写缓冲区(write buffer),缓冲区负责收集put操作,然后调用RPC操作一次性将put送往服务器。全局交换机控制着该缓冲区是否在使用,以下是其方法:

void setAutoFlush(boolean autoFlush)
boolean isAutoFlush()

默认情况下,客户端缓冲区是禁用的。可以通过将自动刷写(autoflush)设置为false来激活缓冲区,调用如下:

table.setAutoFlush(false)

启用客户端缓冲机制后,用户可以通过isAutoFlush()方法检查标识的状态。当用户初始化创建一个HTable实例时,这个方法将返回true,如果有用户修改过缓冲机制,它会返回用户当前所设定的状态。

激活客户端缓冲区之后,用户可以像3.2.1节“单行put”中介绍的那样,将数据存储到HBase中。此时的操作不会产生RPC调用,因为存储的Put实例保存在客户端进程的内存中。当需要强制把数据写到服务端时,可以调用另外一个API函数:

void flushCommits() throws IOException

flushCommits()方法将所有的修改传送到远程服务器。被缓冲的Put实例可以跨多行。客户端能够批量处理这些更新,并把它们传送到对应的region服务器。和调用单行put()方法一样,用户不需要担心数据分配到了哪里,因为对于用户来说,HBase客户端对这个方法的处理是透明的。图3-1展示了在客户端请求传送到服务器之前,是怎样按region服务器排序分组,并通过每个region服务器的RPC请求将数据传送到服务器的。

<span style='display:block;text-align:center'>
![1](https://yqfile.alicdn.com/5cca6f87bf042cb7250fb10fcd13724a7e350f58.png)

</span>

用户可以强制刷写缓冲区,不过这通常不是必要的,因为API会追踪统计每个用户添加的实例的堆大小,从而计算出缓冲的数据量。除了追踪所有的数据开销,还会追踪必要的内部数据结构,一旦超出缓冲指定的大小限制,客户端就会隐式地调用刷写命令。用户可以通过以下调用来配置客户端写缓冲区的大小:

long getWriteBufferSize()
void setWriteBufferSize(long writeBufferSize) throws IOException

默认的大小是2 MB(即2 097 152字节),这个大小比较适中,一般用户插入HBase中的数据都相当小,即每次插入的数据都远小于缓冲区的大小。如果需要存储较大的数据,可能就需要考虑增大这个数值,从而允许客户端更高效地将一定数量的数据组成一组,通过一个RPC请求来执行。

图像说明文字给每一个用户创建的HTable实例都设定缓冲区大小十分麻烦,为了避免这个麻烦,用户可以在hbase-site.xml配置文件中添加一个较大的预设值。例如:

< property>
  < name>hbase.client.write.buffer< /name>
  < value>20971520< /value>
< /property>

这会将缓冲区大小增加到20 MB。
缓冲区仅在以下两种情况下会刷写。

显式刷写

用户调用flushCommits()方法,把数据发送到服务器做永久存储。

隐式刷写

隐式刷写会在用户调用put()或setWriteBufferSize()方法时触发。这两个方法都会将目前占用的缓冲区大小与用户配置的大小做比较,如果超出限制则会调用flushCommits()方法。如果缓冲区被禁用,可以设置setAutoFlush(true),这样用户每次调用put()方法时都会触发刷写。

此外调用HTable类的close()方法时也会无条件地隐式触发刷写。

例3.3展示了客户端API如何控制写缓冲区。

例3.3 使用客户端写缓冲区

HTable table = new HTable(conf,"testtable");
System.out.println("Auto flush: " + table.isAutoFlush());

table.setAutoFlush(false);

Put put1 = new Put(Bytes.toBytes("row1"));
put1.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val1"));
table.put(put1);

Put put2 = new Put(Bytes.toBytes("row2"));
put2.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val2"));
table.put(put2);
Put put3 = new Put(Bytes.toBytes("row3"));
put3.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val3"));
table.put(put3);

Get get = new Get(Bytes.toBytes("row1"));
Result res1 = table.get(get);
System.out.println("Result: " + res1);

table.flushCommits();

Result res2 = table.get(get);
System.out.println("Result: " + res2);

检查自动刷写标识位的设置,应该会打印出“Auto flush: true”。

设置自动刷写为false,启用客户端写缓冲区。

将一些行和列数据存入HBase。

试图加载先前存储的行,结果会打印出“Result: keyvalues=NONE”。

强制刷写缓冲区,会导致产生一个RPC请求。

现在,这一行被持久化了,可以被读取了。

这个例子展示了一个用户之前意想不到的,使用缓冲区之后产生的现象。让我们看看当执行它时会打印出什么:

Auto flush: true
Result: keyvalues=NONE
Result: keyvalues={row1/colfam1:qual1/1300267114099/Put/vlen=4}

虽然还没有介绍过get()操作,但你应该能够正确地推断出它是用于从服务器读取数据的。例子中的第一个get()操作返回了一个NONE,这是什么意思呢?这是由于客户端的写缓冲区是一个内存结构,存储了所有未刷写的记录,这些数据记录尚未发送到服务器,因此用户无法访问它。

>文字用户可以使用以下方法访问客户端写缓冲区的内容:ArrayList<Put> getWriteBuffer()。这个方法可以获取table.put(put)添加到缓冲区中的Put实例列表。

前面提到过,正是由于该列表,使得HTable类被多个线程操作时不安全。直接操作那个列表的时候要非常小心,因为这将绕过堆大小的检查,同时还有可能遇到缓冲区正在刷写其内容。


图像说明文字由于客户端缓冲区是一个简单的保存在客户端进程内存里的列表,用户需要注意不能在运行时终止程序。如果发生这种情况,那些尚未刷写的数据就会丢失!服务器将无法收到数据,因此这些数据没有任何副本可以用来做数据恢复。

另外请注意,一个更大的缓冲区需要客户端和服务器端消耗更多的内存,因为服务器端也需要先将数据写入到服务器的写缓冲区中,然后再处理它。另一方面,一个大的缓冲区减少了RPC请求的次数。估算服务器端内存的占用可使用hbase.client.write.buffer × hbase.regionserver.handler.count ×region服务器的数量。

再次提到往返时间,如果用户只存储大单元格,客户端缓冲区的作用就不大了,因为传输时间占用了大部分的请求时间。在这种情况下,建议最好不要增加客户端缓冲区大小。
4.Put列表
客户端的API可以插入单个Put实例,同时也有批量处理操作的高级特性。其调用形式如下:

void put(List puts)throws IOException

用户需要建立一个Put实例的列表。例3.4修改了之前的例子,创建了一个列表保存所有的修改,最后调用了以列表为参数的put()方法。

例3.4 使用列表向HBase中添加数据

List< Put> puts = new ArrayList< Put>();

Put put1 = new Put(Bytes.toBytes("row1"));
put1.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val1"));
puts.add(put1);

Put put2 = new Put(Bytes.toBytes("row2"));
put2.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val2"));
puts.add(put2);

Put put3 = new Put(Bytes.toBytes("row2"));
put3.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual2"),
  Bytes.toBytes("val3"));
puts.add(put3);

table.put(puts);

创建一个列表用于存储Put实例。

将一个Put实例添加到列表中。

将另外一个Put实例添加到列表中。

将第三个Put实例添加到列表中。

向HBase中存入多行多列数据。

用HBase Shell可以快速查看存入的数据是否与预期一致。请注意,例3.4实际上修改了三列,不过它们只属于两行。有两列内容存入了键为row2的行中,这两列使用了两个不同的列名,qual1和qual2,在同一行创建了两个不同名称的列。

hbase(main):001:0>scan 'testtable'
ROW       COLUMN+CELL
 row1      column=colfam1:qual1,timestamp=1300108258094,value=val1
 row2      column=colfam1:qual1,timestamp=1300108258094,value=val2
 row2      column=colfam1:qual2,timestamp=1300108258098,value=val3
2 row(s)in 0.1590 seconds

由于用户提交的修改行数据的列表可能涉及多行,所以有可能会有部分修改失败。造成修改失败的原因有很多,例如,一个远程的region服务器出现了问题,导致客户端的重试次数超过了配置的最大值,因此不得不放弃当前操作。如果远程服务器的put调用出现问题,错误会通过随后的一个IOException异常反馈给客户端。

例3.5使用了一个错误的(bogus)列族名来插入列。由于客户端不知道远程表的结构(可能在本次操作之前,实际的表结构已经有所变化),因此对列族的检查会在服务器端完成。

例3.5 向HBase中插入一个错误的列族

Put put1 = new Put(Bytes.toBytes("row1"));
put1.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val1"));
puts.add(put1);
Put put2 = new Put(Bytes.toBytes("row2"));
put2.add(Bytes.toBytes("BOGUS"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val2"));
puts.add(put2);
Put put3 = new Put(Bytes.toBytes("row2"));
put3.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual2"),
  Bytes.toBytes("val3"));
puts.add(put3);

table.put(puts);

将使用不存在列族的Put实例加入列表。

将多行多列数据存储到HBase中。

那个插入错误列族的put()调用失败,会返回如下的(或类似的)错误信息:

org.apache.hadoop.hbase.client.RetriesExhaustedWithDetailsException:
Failed 1 action: NoSuchColumnFamilyException: 1 time,
servers with issues: 10.0.0.57:51640,

用户可能想知道列表中没有发生异常的put的情况如何。再次使用命令行工具,应该可以看见两个正确的put的数据已经被添加到HBase中了:

hbase(main):001:0>scan 'testtable'
ROW         COLUMN+CELL
 row1        column=colfam1:qual1,timestamp=1300108925848,value=val1
 row2        column=colfam1:qual2,timestamp=1300108925848,value=val3
2 row(s) in 0.0640 seconds

服务器遍历所有的操作并设法执行它们,失败的会返回,然后客户端会使用RetriesExhaustedWithDetailsException报告远程错误,这样用户可以查询有多少个操作失败、出错的原因以及重试的次数。要注意的是,对于错误列族,服务器端的重试次数会自动设为1(见NoSuchColumnFamilyException:1 time),因为这是一个不可恢复的错误类型。

这些在服务器上失败的Put实例会被保存在本地写缓冲区中,下一次缓冲区刷写的时候会重试。用户也可以通过HTable的getWriteBuffer()方法访问它们,并对它们做一些处理,例如,清除操作。

有一些检查是在客户端完成的,例如,确认Put实例的内容是否为空或是否指定了列。在这种情况下,客户端会抛出异常,同时将出错的Put留在客户端缓冲区中不做处理。

>文字调用基于列表的put()时,客户端会先把所有的Put实例插入到本地写缓冲区中,然后隐式地调用flushCache()。在插入每个Put实例的时候,客户端API都会执行之前提到过的检查。如果检查失败,例如,5个Put中的第3个失败了,则前两个会被添加到缓冲区中,最后两个则不会,同时也不会触发刷写命令。

用户可以捕获异常并手动刷写写缓冲区来执行已经添加的操作。例3.6展示了如何处理这个异常。

例3.6 向HBase中插入一个空的Put实例

Put put1 = new Put(Bytes.toBytes("row1"));
put1.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val1"));
puts.add(put1);
Put put2 = new Put(Bytes.toBytes("row2"));
put2.add(Bytes.toBytes("BOGUS"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val2"));
puts.add(put2);
Put put3 = new Put(Bytes.toBytes("row2"));
put3.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual2"),
  Bytes.toBytes("val3"));
puts.add(put3);
Put put4 = new Put(Bytes.toBytes("row2"));
puts.add(put4);

try {
  table.put(puts);
}  catch(Exception e){
System.err.println("Error: " + e);
table.flushCommits();
}

将没有内容的Put添加到列表中。

捕获本地异常然后提交更新。

这个例子会抛出两个异常,异常信息如下:

Error: java.lang.IllegalArgumentException: No columns to insert
Exception in thread "main"
org.apache.hadoop.hbase.client.RetriesExhaustedWithDetailsException:
Failed 1 action: NoSuchColumnFamilyException: 1 time,
servers with issues: 10.0.0.57:51640,

第一个错误(Error)是客户端检查发现的,第二个错误是在try/catch代码块中调用下面的函数引起的远程异常:

table.flushCommits()

>文字如果用户激活了客户端写缓冲区功能(请参考3.2.1节中“客户端的写缓冲区”)就会发现没有马上报告异常,而是延迟到了缓冲区刷写的时候才抛出。

当使用基于列表的put调用时,用户需要特别注意:用户无法控制服务器端执行put的顺序,这意味着服务器被调用的顺序也不受用户控制。如果要保证写入的顺序,需要小心地使用这个操作,最坏的情况是,要减少每一批量处理的操作数,并显示地刷写客户端写缓冲区,强制把操作发送到远程服务器。

5.原子性操作compare-and-set
有一种特别的put调用,其能保证自身操作的原子性:检查写(check and put)。该方法的签名如下:

boolean checkAndPut(byte[] row,byte[] family,byte[] qualifier,
  byte[] value,Put put) throws IOException

有了这种带有检查功能的方法,就能保证服务器端put操作的原子性。如果检查成功通过,就执行put操作,否则就彻底放弃修改操作。这种方法可以用于需要检查现有相关值,并决定是否修改数据的操作。

这种有原子性保证的操作经常被用于账户结余、状态转换或数据处理等场景。这些应用场景的共同点是,在读取数据的同时需要处理数据。一旦你想把一个处理好的结果写回HBase,并保证没有其他客户端已经做了同样的事情,你就可以使用这个有原子性保证的操作,先比较原值,再做修改。

图像说明文字有一种特别的检查通过checkAndPut()调用来完成,即只有在另外一个值不存在的情况下,才执行这个修改。要执行这种操作只需要将参数value设置为null即可,只要指定列不存在,就可以成功执行修改操作。
这个方法返回一个布尔类型的值,表示put操作成功执行还是失败,对应的值分别是true和false。例3.7展示了客户端与服务器端不同操作返回值的交互过程。

例3.7 使用原子性操作compare-and-set

Put put1 = new Put(Bytes.toBytes("row1"));
put1.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val1"));

boolean res1 = table.checkAndPut(Bytes.toBytes("row1"),
Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),null,put1);
System.out.println("Put applied: " + res1);

boolean res2 = table.checkAndPut(Bytes.toBytes("row1"),
Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),null,put1);
System.out.println("Put applied: " + res2);

Put put2 = new Put(Bytes.toBytes("row1"));
put2.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual2"),
   Bytes.toBytes("val2"));

boolean res3 = table.checkAndPut(Bytes.toBytes("row1"),
Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val1"),put2);
System.out.println("Put applied: " + res3);

Put put3 = new Put(Bytes.toBytes("row2"));
put3.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
 Bytes.toBytes("val3"));

boolean res4 = table.checkAndPut(Bytes.toBytes("row1"),
Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
  Bytes.toBytes("val1"),put3);
System.out.println("Put applied: " + res4);⓫

创建一个新的Put实例。

检查指定列是否存在,按检查的结果决定是否执行put操作。

输出结果,此处应为:“Put applied: true”。

再次向同一个单元格写入数据。

因为那个列的值已经存在,此时的输出结果应为“Put applied: false”。

创建另一个新的Put实例,这次使用一个不同的列限定符。

当上一次的put值存在时,写入新的值。

因为已经存在,所以输出的结果应当为“Put applied: true”。

再创建一个Put实例,这回使用一个不同的行键。

检查一个不同行的值是否相等,然后写入另一行。

⓫程序执行不到这里,因为在处会抛出异常。

例子中最后一次调用会抛出以下异常:

Exception in thread "main" org.apache.hadoop.hbase.DoNotRetryIOException:
Action's getRow must match the passed row

>文字HBase提供的compare-and-set操作,只能检查和修改同一行数据。与其他的许多操作一样,这个操作只提供同一行数据的原子性保证。检查和修改分别针对不同行数据时会抛出异常。

compare-and-set(CAS)操作十分强大,尤其是在分布式系统中,且有多个独立的客户端同时操作数据时。通过这个方法,HBase与其他复杂的设计结构区分了开来,提供了使不同客户端可以并发修改数据的功能。

###3.2.2 get方法
下面我们将介绍从客户端API中获取已存储数据的方法。HTable类中提供了get()方法,同时还有与之对应的Get类。get方法分为两类:一类是一次获取一行数据;另一类是一次获取多行数据。

1.单行get
这种方法可以从HBase表中取一个特定的值:

Result get(Get get) throws IOException

与put()方法有对应的Put类相似,get()方法也有对应的Get类,此外还有一个相似之处,那就是在使用下面的方法构造Get实例时,也需要设置行键:

Get(byte[] row)
Get(byte[] row,RowLock rowLock)

图像说明文字虽然一次get()操作只能取一行数据,但不会限制一行当中取多少列或者多少单元格。
这两个Get实例都通过row参数指定了要获取的行,其中第二个Get实例还增加了一个可选的rowLock参数,允许用户设置行锁。与put操作一样,用户有许多方法可用,可以通过多种标准筛选目标数据,也可以指定精确的坐标获取某个单元格的数据:

Get addFamily(byte[] family)
Get addColumn(byte[] family,byte[] qualifier)
Get setTimeRange(long minStamp,long maxStamp) throws IOException
Get setTimeStamp(long timestamp)
Get setMaxVersions()
Get setMaxVersions(int maxVersions) throws IOException

addFamily()方法限制get请求只能取得一个指定的列族,要取得多个列族时需要多次调用。addColumn()的使用方式与之类似,用户通过它可以指定get取得哪一列的数据,从而进一步缩小地址空间。有一些方法允许用户设定要获取的数据的时间戳,或通过设定一个时间段来取得时间戳属于该时间段内的数据。

最后,如果用户没有设定时间戳的话,也有方法允许用户设定要获取的数据的版本数目。默认情况下,版本数为1,即get()请求返回最新的匹配版本。如果有疑问,可以使用getMaxVersions()来检查这个Get实例所要取出的最大版本数。不带参数的setMaxVersions()方法会把要取出的最大版本数设为Integer.MAX_VALUE,这是用户在列族描述符(column family descriptor)中可配置的最大版本数,此时系统会返回这个单元格中所有的版本,换句话说,此时系统会返回用户在列族中已配置的最大版本数以内的所有数据。

表3-4列出了Get类中其他方法的介绍。

<span style='display:block;text-align:center'>
![b4](https://yqfile.alicdn.com/fbef72bf36c1f878311bb20ce13a1ac63588e807.png)

</span>

>文字表3-4中所列的getter函数只能得到所属的Get实例中用户预先设定好的筛选条件。它们的应用场景很少,而且只能在类似如下的场景中使用,例如,用户的一个私有方法中有一个Get实例,需要在其他地方检查Get实例中各筛选条件是否正确。

以前提到过,HBase为用户提供了Bytes这个工具类,该类有许多可以把Java的常用数据类型转化为byte[]数组的静态方法。同时,它也可以做一些反向转化的工作:例如当用户从HBase中取得一行数据时,可使用Bytes对应的方法把byte[]的内容转化为Java的数据类型。下面是它提供的相关方法的列表:

static String toString(byte[] b)
static boolean toBoolean(byte[] b)
static long toLong(byte[] bytes)
static float toFloat(byte[] bytes)
static int toInt(byte[] bytes)
...

例3.8展示了从HBase中获取数据的完整过程。

例3.8 从HBase中获取数据的应用

Configuration conf = HBaseConfiguration.create();
HTable table = new HTable(conf,"testtable");
Get get = new Get(Bytes.toBytes("row1"));
get.addColumn(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"));
Result result = table.get(get);
byte[] val = result.getValue(Bytes.toBytes("colfam1"),
  Bytes.toBytes("qual1"));
System.out.println("Value: " + Bytes.toString(val));

创建配置实例。

初始化一个新的表引用。

使用一个指定的行键构建一个Get实例。

向Get实例中添加一个列。

从HBase中获取指定列的行数据。

从返回的结果中获取对应列的数据。

将数据转化为字符串打印输出。

如果用户在执行这个例子之前执行过前面的例子,如例3.2,那么会有如下的输出结果:

Value: val1

虽然上面的这个输出结果很普通,但却展示了一次完整的读取数据的过程。这个例子只添加并取回了一个特定的列,取回的版本数为默认值1。get()方法调用后返回一个Result类的实例,这个类将在下面介绍。

2.Result类
当用户使用get()方法获取数据时,HBase返回的结果包含所有匹配的单元格数据,这些数据将被封装在一个Result实例中返回给用户。用它提供的方法,可以从服务器端获取匹配指定行的特定返回值,这些值包括列族、列限定符和时间戳等。

以下是一些获取特定返回值的工具方法,和前面使用的例3.8一样,可以设定一些具体的查询维度。如果用户之前要求服务器端返回一个列族下的所有列,现在就可以从返回值中取得这个列族下所需的任意列。换句话说,用户使用get()方法时需要提供一些具体的信息,以便数据取回之后客户端可以筛选出对应的数据。Result类提供的方法如下:

byte[] getValue(byte[] family,byte[] qualifier)
byte[] value()
byte[] getRow()
int size()
boolean isEmpty()
KeyValue[] raw()
List list()

getValue()方法允许用户取得一个HBase中存储的特定单元格的值。因为该方法不能指定时间戳(或者说版本),所以用户只能获得数据最新的版本。value()方法的使用更简单,它会返回第一个列对应的最新单元格的值。因为列在服务器端是按字典序存储的,所以会返回名称(包括列族和列限定符)排在首位的那一列的值。

之前我们介绍过getRow()方法:它返回创建Get类当前实例时使用的行键。size()方法返回服务器端返回值中键值对(KeyValue实例)的数目。用户可以使用size()方法或者isEmpty()方法查看键值对的数目是否大于0,这样可以检查服务器端是否找到了与查询相对应的结果。

raw()方法返回原始的底层KeyValue的数据结构,具体来说,是基于当前的Result实例返回KeyValue实例的数组。list()调用则把raw()中返回的数组转化为一个List实例,并返回给用户,创建的List实例由原始返回结果中的KeyValue数组成员组成,用户可以方便地迭代存取数据。

图像说明文字raw()方法返回的数组已经按字典序排列,排列时考虑了KeyValue实例的所有坐标。先按列族排序,列族内再按列限定符排序,此后再按时间戳排序,最后按类型排序。
另外还有一些面向列的存取函数如下:

List getColumn(byte[] family,byte[] qualifier)
KeyValue getColumnLatest(byte[] family,byte[] qualifier)
boolean containsColumn(byte[] family,byte[] qualifier)

这个方法返回一个特定列的多个值,解答了前文提出的一个问题:如何获得一个列的多个版本。返回值中的版本数取决于用户调用get()方法之前,创建Get实例时设置的最大版本数,默认是1。换句话说,getColumn()返回的列表中包括0(当本行没有该列值时)或1个条目,这一条目是该列最新版本的值。如果用户指定了一个比默认值1大的版本数(可以是最大值范围内的任意值),返回的列表中就可能会有多个条目。

getColumnLatest()方法返回对应列的最新版本值,不过与getValue()不同,它不返回值的原始字节数组,而是返回一个KeyValue实例。如果用户需要的不仅仅是数据,那么这个方法将会非常有用。containsColumn()是一个十分简便的方法,它检查返回值中是否有对应的列。

图像说明文字这些方法也可以不指定列限定符,即将列限定符设置为null,这样方法会匹配没有列限定符的特殊列。

不使用限定符就意味着这一列没有标签。当查询表时,例如,用户通过命令行查询时,需要自己明白数据所表示的具体含义。可能只有一种情况能用到空限定符:即一个列族下只有一列,同时列族名就能够表示数据的含义及目的。
下面是第三类取值函数,以映射形式返回结果:

NavigableMap< byte[],NavigableMap< byte[],
 NavigableMap< Long,byte[]>>> getMap()
NavigableMap< byte[],
 NavigableMap< byte[],byte[]>> getNoVersionMap()
NavigableMap< byte[],byte[]> getFamilyMap(byte[] family)

最常用的方法是getMap(),它把所有get()请求返回的内容都装入一个Java的Map类实例中,这样用户可以使用该方法遍历所有结果。getNoVersionMap()与getMap()形式上相似,不过它只返回每个列的最新版本。getFamilyMap()允许用户指定一个特定的列族,返回这次结果中这个列族下的所有版本。

不论用户使用什么方法获取Result中的数据,都不会产生额外的性能和资源消耗,因为这些数据都已经通过网络从服务器端传输到了客户端。

转储内容

所有的Java对象都有toString()方法,这个方法通常会被重载,用于将实例数据转化为文本表示。这样做一般不是为了序列化对象,而是为了方便调试程序。

同样Result类也有toString()方法的实现,它把实例的内容转储为一个可读的字符串,下面是输出的例子:

keyvalues={row-2/colfam1:col-5/1300802024293/Put/vlen=7,
      row-2/colfam2:col-33/1300802024325/Put/vlen=8}

这个方法只是简单地打印实例所包含的KeyValue实例,也就是逐个调用KeyValue. toString()方法。如果Result的实例为空,则返回结果如下:

keyvalues=NONE

这种情况表示查询没有KeyValue实例返回。本书的代码示例使用toString()方法来打印之前读取操作的结果。
3.Get列表
使用列表参数的get()方法与使用列表参数的put()方法对应,用户可以用一次请求获取多行数据。它允许用户快速高效地从远程服务器获取相关的或完全随机的多行数据。

>文字如图3-1所示,实际上,请求有可能被发往多个不同的服务器,但这部分逻辑已经被封装起来,因此对于客户端代码来说,还是表现为一次请求。
API提供的方法签名如下:

Result[] get(List< Get> gets) throws IOException

这个方法的含义十分直白,跟之前介绍的类似方法一样:用户需要创建一个列表,并把之前准备好的Get实例添加到其中。然后将这个列表传给get(),会返回一个与列表大小相等的Result数组。例3.9展示了用两种方法获取数据的整个流程。

例3.9 使用Get实例的列表从HBase中获取数据

byte[] cf1 = Bytes.toBytes("colfam1");
byte[] qf1 = Bytes.toBytes("qual1");
byte[] qf2 = Bytes.toBytes("qual2");
byte[] row1 = Bytes.toBytes("row1");
byte[] row2 = Bytes.toBytes("row2");

List< Get> gets = new ArrayList< Get>();

Get get1 = new Get(row1);
get1.addColumn(cf1,qf1);
gets.add(get1);

Get get2 = new Get(row2);
get2.addColumn(cf1,qf1);
gets.add(get2);

Get get3 = new Get(row2);
get3.addColumn(cf1,qf2);
gets.add(get3);

Result[] results = table.get(gets);

System.out.println("First iteration...");
for(Result result : results){
  String row = Bytes.toString(result.getRow());
  System.out.print("Row: " + row + " ");
  byte[] val = null;
  if(result.containsColumn(cf1,qf1)){
   val = result.getValue(cf1,qf1);
    System.out.println("Value: " + Bytes.toString(val));
  }
  if(result.containsColumn(cf1,qf2)){
   val = result.getValue(cf1,qf2);
   System.out.println("Value: " + Bytes.toString(val));
  }
}

System.out.println("Second iteration...");
for(Result result : results){
  for(KeyValue kv : result.raw()){
   System.out.println("Row: " + Bytes.toString(kv.getRow())+
   " Value: " + Bytes.toString(kv.getValue()));
 }
}

准备好共用的字节数组。

准备好要存放Get实例的列表。

将Get实例添加到列表中。

从HBase中获取这些行和选定的列。

遍历结果并检查哪些行中包含选定的列。

再次遍历,打印所有结果。

如果先运行例3.4,然后再运行例3.9,可能会看到如下结果:

First iteration...
Row: row1 Value: val1
Row: row2 Value: val2
Row: row2 Value: val3
Second iteration...
Row: row1 Value: val1
Row: row2 Value: val2
Row: row2 Value: val3

两次遍历返回了同样的值,说明从服务器端得到结果之后,用户可以有多种方式访问结果。现在就差查询时出现的异常如何反馈没有介绍了。get()返回异常的方法与3.2.1节中的“Put列表”不同。get()方法要么返回与给定列表大小一致的Result数组,要么抛出一个异常。例3.10展示了这个行为。

例3.10 尝试读取一个错误的列族

List< Get> gets = new ArrayList< Get>();

Get get1 = new Get(row1);
get1.addColumn(cf1,qf1);
gets.add(get1);

Get get2 = new Get(row2);
get2.addColumn(cf1,qf1);
gets.add(get2);

Get get3 = new Get(row2);
get3.addColumn(cf1,qf2);
gets.add(get3);

Get get4 = new Get(row2);
get4.addColumn(Bytes.toBytes("BOGUS"),qf2);
gets.add(get4);

Result[] results = table.get(gets);
System.out.println("Result count: " + results.length);

将Get实例添加到列表中。

添加包含有错的(bogus)列族的Get。

抛出异常,操作停止。

不会执行到此行。

执行这个例子会导致整个get()操作终止,程序会抛出类似下面这样的异常,并且没有返回值:

org.apache.hadoop.hbase.client.RetriesExhaustedWithDetailsException:
 Failed 1 action: NoSuchColumnFamilyException: 1 time,
 servers with issues: 10.0.0.57:51640,

对于批量操作中的局部错误,有一种更为精细的处理方法,即使用batch()方法,这部分内容将在3.3节详细介绍。

4.获取数据的相关方法
还有一些方法可以用来获取或检查存储的数据,第一个是:

boolean exists(Get get)throws IOException

可以和使用HTable的get()方法一样,先创建一个Get类的实例。exists()方法通过RPC验证请求的数据是否存在,但不会从远程服务器返回请求的数据,只返回一个布尔值表示这个结果。

>文字exists()方法会引发region服务器端查询数据的操作,包括加载文件块来检查某行或某列是否存在。用户通过这种方法只能避免网络数据传输的开销,不过在需要检查或频繁检查一个比较大的列时,这种方法还是十分实用的。

某些情况下,用户在检索数据时可能需要查找一个特定的行,或者某个请求行之前的一行。下面的方法可以帮助用户实现这种查找:

Result getRowOrBefore(byte[] row,byte[] family) throws IOException

用户需要指定要查找的行键和列族。指定后者的原因是,HBase是一个列式存储的数据库,不存在没有列的行数据。设定列族之后,服务器端会检查要查找的那一行里是否有任何属于指定列族的列值。

>文字请注意,在使用getRowOrBefore()方法时,需要指定一个已经存在的列族,否则服务端会因为要访问一个不存在的存储文件而抛出一个Java的NullPointerException异常。

可以从getRowOrBefore()返回的Result实例中得到要查找的行键。这个行键要么与用户设定的行一致,要么刚好是设定行键之前的一行。如果没有匹配的结果,本方法返回null。例3.11使用getRowOrBefore()方法查找用户之前使用put示例存入的数据。

例3.11 使用特殊检索方法

Result result1 = table.getRowOrBefore(Bytes.toBytes("row1"),
  Bytes.toBytes("colfam1"));
System.out.println("Found: " + Bytes.toString(result1.getRow()));

Result result2 = table.getRowOrBefore(Bytes.toBytes("row99"),
  Bytes.toBytes("colfam1"));
System.out.println("Found: " + Bytes.toString(result2.getRow()));

for(KeyValue kv : result2.raw()){
  System.out.println(" Col: " + Bytes.toString(kv.getFamily())+
   "/" + Bytes.toString(kv.getQualifier())+
   ",Value: " + Bytes.toString(kv.getValue()));
}

Result result3 = table.getRowOrBefore(Bytes.toBytes("abc"),
  Bytes.toBytes("colfam1"));
System.out.println("Found: " + result3);

尝试查找已经存在的行。

打印查找结果。

尝试查找不存在的行。

返回已排好序的表中的最后一条结果。

打印返回结果。

尝试查找测试行之前的一行。

由于没有匹配的结果,返回null。

假如已经执行过例3.4,那上面的代码将会输出如下结果:

Found: row1
Found: row2
  Col: colfam1/qual1,Value: val2
  Col: colfam1/qual2,Value: val3
Found: null

第一次调用找到一个匹配的行,成功返回。第二次调用使用了一个大数字作为后缀来查找表的最后一行。从row-前缀开始,查找到对应的row-2行。最后一个例子要查找abc这一行或这行之前的一行,不过之前插入数据的前缀都是row-,所以abc以及之前的行不存在。因此返回值为null,表示查找失败。

令人感兴趣的是,这个循环打印出了与匹配条件的行一起返回的数据。返回了指定列族的所有列,包括这些列的最新版本。用户可以使用这种方法快速取回特定排序规则下一个列族中所有列的最新值。例如,假设像Put示例一样,所有的行键都使用row-作为前缀,调用getRowOrBefore()时送入row-999999999作为row参数,返回的结果将总是按字典序排在表尾的那一行。

###3.2.3 删除方法
此前介绍了HBase表的创建、读取和更新,就剩如何从表中删除数据没讲了。HTable提供了删除的方法,同时与之前的方法一样有一个相应的类命名为Delete。

1.单行删除
delete()方法有许多变体,其中一个只需要一个Delete实例:

void delete(Delete delete) throws IOException
与前面讲过的get()方法和put()方法一样,用户必须先创建一个Delete实例,然后再添加你想要删除的数据的详细信息。构造函数是:

Delete(byte[] row)
Delete(byte[] row,long timestamp,RowLock rowLock)

用户需要提供要修改的行,如果要多次频繁地修改同一行的话,还可以提供rowLock参数(RowLock类的一个实例),以指定自己的RowLock。此外,最好缩小要删除的给定行中涉及数据的范围,可使用下列方法:

Delete deleteFamily(byte[] family)
Delete deleteFamily(byte[] family,long timestamp)
Delete deleteColumns(byte[] family,byte[] qualifier)
Delete deleteColumns(byte[] family,byte[] qualifier,long timestamp)
Delete deleteColumn(byte[] family,byte[] qualifier)
Delete deleteColumn(byte[] family,byte[] qualifier,long timestamp)
void setTimestamp(long timestamp)

有4种调用可缩小删除所涉及的数据范围。首先,用户可以使用deleteFamily()方法来删除一整个列族,包括其下所有的列。用户也可以指定一个时间戳,触发针对单元格数据版本的过滤,从所有的列中删除与这个时间戳相匹配的版本和比这个时间戳旧的版本。

另一种方法是deleteColumns(),它作用于特定的一列,如果用户没有指定时间戳,这个方法会删除该列的所有版本,如果用户指定了时间戳,这个方法会删除所有与这个时间戳相匹配的版本和更旧的版本。

第三种方法与第二种类似,使用deleteColumn(),它也操作一个具体的列,但是只删除最新的版本或者指定的版本,即用一个精确匹配的时间戳执行删除操作。

最后一个方法是setTimestamp(),这个方法在调用其他3种方法时经常被忽略。但是,如果不指定列族或列,则此调用与删除整行不同,它会删除匹配时间戳的或者比给定时间戳旧的所有列族中的所有列。表3-5以表格的形式展示了delete()的功能,可读性更强。

<span style='display:block;text-align:center'>
![b5](https://yqfile.alicdn.com/cedd30930ba5582f15d75b0d6986d9528b7588ad.png)

</span>
表3-6列举了Delete类提供的其他方法,以供用户查阅。

<span style='display:block;text-align:center'>
![b6](https://yqfile.alicdn.com/bcbffc3bf65bd4e4890c232f3c181b476e1e60d2.png)

</span>
例3.12展示了怎样在客户端代码中调用delete()函数。

例3.12 从HBase中删除数据的应用示例

Delete delete = new Delete(Bytes.toBytes("row1"));

delete.setTimestamp(1);

delete.deleteColumn(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),1);

delete.deleteColumns(Bytes.toBytes("colfam2"),Bytes.toBytes("qual1"));
delete.deleteColumns(Bytes.toBytes("colfam2"),Bytes.toBytes("qual3"),15);

delete.deleteFamily(Bytes.toBytes("colfam3"));
delete.deleteFamily(Bytes.toBytes("colfam3"),3);

table.delete(delete);
table.close();

创建针对特定行的Delete实例。

设置时间戳。

删除一列中的特定版本。

删除一列中的全部版本。

删除一列中的给定版本和所有更旧的版本。

删除整个列族,包括所有的列和版本。

删除给定列族中所有列的给定版本和所有更旧的版本。

从HBase表中删除数据。

这个例子列举出了用户通过设定不同参数操作delete()方法的方法。像这样一个接着一个调用没有太大实际意义,读者可以随意注释掉一些删除调用,观察控制台上显示结果的变化。

删除操作所设定的时间戳只对匹配的单元格有影响,即匹配给定时间戳的列和值。另一方面,如果不设定时间戳,服务器会强制检索服务器端最新的时间戳,这比执行一个具有明确时间戳的删除要慢。

如果尝试删除未设置时间戳的单元格,什么都不会发生。例如,某一列有两个版本,版本10和版本20,删除版本15将不会影响现存的任何版本。

这个例子同时展示了用户自定义数据版本的用法。它使用从1开始自增的序号,不依靠隐式或显式的时间戳。这种方式非常有用,用户必须按需求自己设置版本,因为服务器并不知道客户端的使用模式,只会使用Unix时间戳来代替。

>文字就本文而言,不建议用户自定义版本号。自定义版本号可能会起作用,但是没有经过很好的测试。请确保使用这项技术之前仔细评估过你的选择。

使用自定义版本的另一个例子可以在9.4节中找到。

2.Delete的列表
基于列表的delete()调用与基于列表的put()调用非常相似,需要创建一个包含Delete实例的列表,对其进行配置,并调用下面的方法:

void delete(List deletes) throws IOException

例3.13展示了影响三个不同行的删除操作,删除了它们所包含的各种细节。当运行这个例子时,你会看到打印输出的删除前后的状态,还能看到使用KeyValue.toString()打印输出的原始的KeyValue实例。

>文字正如其他基于列表的操作,用户不能对删除操作在远程服务器上的执行顺序做任何假设。API会重新排列它们,并将同一个region服务器的操作集中到一个RPC请求中以提升性能。如果需要确保执行的顺序,用户需要把这些调用分成更小的组,并控制组之间的执行顺序。在最坏的情况下,需要发送单独的Delete调用以保证顺序。

例3.13 删除值列表的应用示例

List< Delete> deletes = new ArrayList< Delete>();

Delete delete1 = new Delete(Bytes.toBytes("row1"));
delete1.setTimestamp(4);
deletes.add(delete1);

Delete delete2 = new Delete(Bytes.toBytes("row2"));
delete2.deleteColumn(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"));
delete2.deleteColumns(Bytes.toBytes("colfam2"),Bytes.toBytes("qual3"),5);
deletes.add(delete2);

Delete delete3 = new Delete(Bytes.toBytes("row3"));
delete3.deleteFamily(Bytes.toBytes("colfam1"));
delete3.deleteFamily(Bytes.toBytes("colfam2"),3);
deletes.add(delete3);

table.delete(deletes);


table.close();
创建一个列表,保存Delete实例。

为删除行的Delete实例设置时间戳。

删除一列的最新版本。

在另一列中删除给定的版本及所有更旧的版本。

删除整个列族,包括所有的列和版本。

在整个列族中,删除给定的版本以及所有更旧的版本。

删除HBase表中的多行。

你会看到如下输出⑥:

Before delete call...
KV: row1/colfam1:qual1/2/Put/vlen=4,Value: val2
KV: row1/colfam1:qual1/1/Put/vlen=4,Value: val1
KV: row1/colfam1:qual2/4/Put/vlen=4,Value: val4
KV: row1/colfam1:qual2/3/Put/vlen=4,Value: val3
KV: row1/colfam1:qual3/6/Put/vlen=4,Value: val6
KV: row1/colfam1:qual3/5/Put/vlen=4,Value: val5

KV: row1/colfam2:qual1/2/Put/vlen=4,Value: val2
KV: row1/colfam2:qual1/1/Put/vlen=4,Value: val1
KV: row1/colfam2:qual2/4/Put/vlen=4,Value: val4
KV: row1/colfam2:qual2/3/Put/vlen=4,Value: val3
KV: row1/colfam2:qual3/6/Put/vlen=4,Value: val6
KV: row1/colfam2:qual3/5/Put/vlen=4,Value: val5

KV: row2/colfam1:qual1/2/Put/vlen=4,Value: val2
KV: row2/colfam1:qual1/1/Put/vlen=4,Value: val1
KV: row2/colfam1:qual2/4/Put/vlen=4,Value: val4
KV: row2/colfam1:qual2/3/Put/vlen=4,Value: val3
KV: row2/colfam1:qual3/6/Put/vlen=4,Value: val6
KV: row2/colfam1:qual3/5/Put/vlen=4,Value: val5

KV: row2/colfam2:qual1/2/Put/vlen=4,Value: val2
KV: row2/colfam2:qual1/1/Put/vlen=4,Value: val1
KV: row2/colfam2:qual2/4/Put/vlen=4,Value: val4
KV: row2/colfam2:qual2/3/Put/vlen=4,Value: val3
KV: row2/colfam2:qual3/6/Put/vlen=4,Value: val6
KV: row2/colfam2:qual3/5/Put/vlen=4,Value: val5

KV: row3/colfam1:qual1/2/Put/vlen=4,Value: val2
KV: row3/colfam1:qual1/1/Put/vlen=4,Value: val1
KV: row3/colfam1:qual2/4/Put/vlen=4,Value: val4
KV: row3/colfam1:qual2/3/Put/vlen=4,Value: val3
KV: row3/colfam1:qual3/6/Put/vlen=4,Value: val6
KV: row3/colfam1:qual3/5/Put/vlen=4,Value: val5

KV: row3/colfam2:qual1/2/Put/vlen=4,Value: val2
KV: row3/colfam2:qual1/1/Put/vlen=4,Value: val1
KV: row3/colfam2:qual2/4/Put/vlen=4,Value: val4
KV: row3/colfam2:qual2/3/Put/vlen=4,Value: val3
KV: row3/colfam2:qual3/6/Put/vlen=4,Value: val6
KV: row3/colfam2:qual3/5/Put/vlen=4,Value: val5

After delete call...
KV: row1/colfam1:qual3/6/Put/vlen=4,Value: val6
KV: row1/colfam1:qual3/5/Put/vlen=4,Value: val5

KV: row1/colfam2:qual3/6/Put/vlen=4,Value: val6
KV: row1/colfam2:qual3/5/Put/vlen=4,Value: val5

KV: row2/colfam1:qual1/1/Put/vlen=4,Value: val1
KV: row2/colfam1:qual2/4/Put/vlen=4,Value: val4
KV: row2/colfam1:qual2/3/Put/vlen=4,Value: val3
KV: row2/colfam1:qual3/6/Put/vlen=4,Value: val6
KV: row2/colfam1:qual3/5/Put/vlen=4,Value: val5

KV: row2/colfam2:qual1/2/Put/vlen=4,Value: val2
KV: row2/colfam2:qual1/1/Put/vlen=4,Value: val1
KV: row2/colfam2:qual2/4/Put/vlen=4,Value: val4
KV: row2/colfam2:qual2/3/Put/vlen=4,Value: val3
KV: row2/colfam2:qual3/6/Put/vlen=4,Value: val6

KV: row3/colfam2:qual2/4/Put/vlen=4,Value: val4
KV: row3/colfam2:qual3/6/Put/vlen=4,Value: val6
KV: row3/colfam2:qual3/5/Put/vlen=4,Value: val5

“Before delete call...”中突出显示(粗体)的部分是将要被删除的原始数据。这3行包含同样的数据,由两个列族组成,每个列族下有3列,每个列有两个版本。

示例代码先删除了整行数据中版本小于等于4的数据,这次操作留下了版本为5和6的数据。

之后,使用两个指定了列的删除操作,先后清除了row2上colfam1:qual1列的最新单元格,以及colfam1:qual3中小于等于5的所有单元格。这两个删除操作都只有一个单元格满足条件,它们将依次被删除。

最后,在row-3上,示例代码删除了列族colfam1下的全部数据,然后还删除了colfam2中版本小于等于3的所有数据。在示例代码执行的过程中,使用以下的方法可以看到KeyValue的详细情况:

System.out.println("KV: " + kv.toString() +
      ",Value: " + Bytes.toString(kv.getValue()))

现在,我们熟悉了Bytes类的使用,可以用它打印由getValue()返回的KeyValue实例的值。这样做是有必要的,因为KeyValue.toString()方法(见3.2.1节)无法打印出实际的值,只能打印出关键的部分。toString()无法打印出值的原因是,值的内容可能非常大。

示例代码插入的列值非常短,并且内容是可读的,所以在控制台上把结果打印出来是安全的。用户也可以在调试时使用类似的方法。

请参阅本书的源代码库,那里有例子中全部的源码。用户可以从中查看数据如何插入,并最终形成前面示例代码中的输出。

最后要介绍的是基于列表的delete()操作的异常处理。下面对传入的deletes参数(即Delete实例的列表)做一下修改,使得在调用返回时,还有一个错误的Delete实例。换句话说,如果所有的操作都成功了,这个列表会为空。但是,如果最后还有一个实例的话,远程服务器会报告这个错误,这个调用也要抛出异常。用户需要用try/catch语句捕获异常,并做相应处理。例3.14是一个简单的示范。

例3.14 从HBase中删除错误数据

Delete delete4 = new Delete(Bytes.toBytes("row2"));
delete4.deleteColumn(Bytes.toBytes("BOGUS"),Bytes.toBytes("qual1"));
deletes.add(delete4);

try {
  table.delete(deletes);
} catch(Exception e){
  System.err.println("Error: " + e);
}
table.close();

System.out.println("Deletes length: " + deletes.size());
for(Delete delete : deletes){
  System.out.println(delete);
}

添加一个错误的列族来触发错误。

从HBase表中删除多行数据。

捕获远程异常。

检查调用之后列表的长度。

把失败的delete操作打印出来用于调试。

这个例子修改了例3.13,添加了一个出错的删除细节,即添加了一个出错的(BOGUS)列族。输出结果与例3.13相似,只是中间有一些其他的信息:

Before delete call...
KV: row1/colfam1:qual1/2/Put/vlen=4,Value: val2
KV: row1/colfam1:qual1/1/Put/vlen=4,Value: val1
...
KV: row3/colfam2:qual3/6/Put/vlen=4,Value: val6
KV: row3/colfam2:qual3/5/Put/vlen=4,Value: val5

Error: org.apache.hadoop.hbase.client.RetriesExhaustedWithDetailsException:
 Failed 1 action: NoSuchColumnFamilyException: 1 time,
  servers with issues: 10.0.0.43:59057,
Deletes length: 1
row=row2, ts=9223372036854775807,families={(family=BOGUS,keyvalues=\
 (row2/BOGUS:qual1/9223372036854775807/Delete/vlen=0)}

After delete call...
KV: row1/colfam1:qual3/6/Put/vlen=4,Value: val6
KV: row1/colfam1:qual3/5/Put/vlen=4,Value: val5
...
KV: row3/colfam2:qual3/6/Put/vlen=4,Value: val6
KV: row3/colfam2:qual3/5/Put/vlen=4,Value: val5

如预期的一样,列表中还剩下一个Delete实例,就是包含错误列族的那个。打印这个实例(Java默认使用toString()方法来打印一个对象),结果展示了这个出错的实例的内部细节。失败的主要原因是,列族名明显是错误的。读者可以用这个方法检查一个操作出错的原因,出错原因通常都十分明显。

最后,注意一下例子中catch语句捕获的异常RetriesExhaustedWithDetailsException,它在之前的例子中已经出现过两次了。这个异常报告出错的操作个数,报告重试的次数及对应的服务器。更进一步的处理方法包括检查和监控服务器,这些细节将在后面的章节中提到,这里返回的出错的服务器地址可以帮助我们定位错误的根源。

3.原子性操作compare-and-delete
前文已经在“原子性操作compare-and-set”一节介绍过,如何使用原子性的条件操作向表中插入数据。有一个相似的删除操作,提供了让用户可以在服务器端读取并修改(read-and-modify)的功能:

boolean checkAndDelete(byte[] row,byte[] family,byte[] qualifier,
  byte[] value,Delete delete) throws IOException

用户必须指定行键、列族、列限定符和值来执行删除操作之前的检查。如果检查失败,则不执行删除操作,调用返回false。如果检查成功,则执行删除操作,调用返回true。例3.15展示了这里介绍的内容。

例3.15 使用原子操作compare-and-delete删除值的应用示例

Delete delete1 = new Delete(Bytes.toBytes("row1"));
delete1.deleteColumns(Bytes.toBytes("colfam1"),Bytes.toBytes("qual3"));

boolean res1 = table.checkAndDelete(Bytes.toBytes("row1"),
 Bytes.toBytes("colfam2"),Bytes.toBytes("qual3"),null,delete1);
System.out.println("Delete successful: " + res1);

Delete delete2 = new Delete(Bytes.toBytes("row1"));
delete2.deleteColumns(Bytes.toBytes("colfam2"),Bytes.toBytes("qual3"));
table.delete(delete2);

boolean res2 = table.checkAndDelete(Bytes.toBytes("row1"),
 Bytes.toBytes("colfam2"),Bytes.toBytes("qual3"),null,delete1);
System.out.println("Delete successful: " + res2);

Delete delete3 = new Delete(Bytes.toBytes("row2"));
delete3.deleteFamily(Bytes.toBytes("colfam1"));

try{
  boolean res4 = table.checkAndDelete(Bytes.toBytes("row1"),
    Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),
    Bytes.toBytes("val1"),delete3);
 System.out.println("Delete successful: " + res4);
} catch(Exception e){
  System.err.println("Error: " + e);
}

创建一个Delete实例。

检查指定列是否不存在,依检查结果执行删除操作。

打印结果,结果应当为“Delete successful: false”。

手工删除已经检查过的列。

尝试再一次删除同一个单元格。

打印结果,应当为“Delete successful: true”,因为这个列之前存在所以成功删除。

创建另一个Delete实例,这次使用一个不同的行。

检查这个不同的行,并执行删除操作。

执行不到这里,在此行之前有异常抛出。

示例的全部输出如下:

Before delete call...
KV: row1/colfam1:qual1/2/Put/vlen=4,Value: val2
KV: row1/colfam1:qual1/1/Put/vlen=4,Value: val1
KV: row1/colfam1:qual2/4/Put/vlen=4,Value: val4
KV: row1/colfam1:qual2/3/Put/vlen=4,Value: val3
KV: row1/colfam1:qual3/6/Put/vlen=4,Value: val6
KV: row1/colfam1:qual3/5/Put/vlen=4,Value: val5
KV: row1/colfam2:qual1/2/Put/vlen=4,Value: val2
KV: row1/colfam2:qual1/1/Put/vlen=4,Value: val1
KV: row1/colfam2:qual2/4/Put/vlen=4,Value: val4
KV: row1/colfam2:qual2/3/Put/vlen=4,Value: val3
KV: row1/colfam2:qual3/6/Put/vlen=4,Value: val6
KV: row1/colfam2:qual3/5/Put/vlen=4,Value: val5
Delete successful: false
Delete successful: true
After delete call...
KV: row1/colfam1:qual1/2/Put/vlen=4,Value: val2
KV: row1/colfam1:qual1/1/Put/vlen=4,Value: val1
KV: row1/colfam1:qual2/4/Put/vlen=4,Value: val4
KV: row1/colfam1:qual2/3/Put/vlen=4,Value: val3
KV: row1/colfam2:qual1/2/Put/vlen=4,Value: val2
KV: row1/colfam2:qual1/1/Put/vlen=4,Value: val1
KV: row1/colfam2:qual2/4/Put/vlen=4,Value: val4
KV: row1/colfam2:qual2/3/Put/vlen=4,Value: val3
Error: org.apache.hadoop.hbase.DoNotRetryIOException:
 org.apache.hadoop.hbase.DoNotRetryIOException:
  Action's getRow must match the passed row
...

将null作为value参数的传入值,将会触发一次“不存在”(nonexistence)检查,即所指定的列只要不存在,检查就会成功。因为上面这个例子在检查之前插入了检查过的列,所以第一次检查会失败,调用会返回false并放弃删除操作。

之后这一列被手工删除,第二次执行检查并修改(check-and-modify)操作。这次检查成功,删除了数据,最后返回true。

跟之前关于Put的CAS调用一样,用户只能对同一行数据进行检查并修改。例子中检查的行键与Delete实例自身的行键不同,所以在执行检查时会相应地抛出异常。这个方法允许用户做跨列族检查,例如,用户可以使用一组列控制筛选其他列。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

相关文章
《HBase权威指南》一导读
你阅读本书的理由可能有很多。可能是因为听说了Hadoop,并了解到它能够在合理的时间范围内处理PB级的数据,在研读Hadoop的过程中发现了一个处理随机读写的系统,它叫做HBase。或者将其称为目前流行的一种新的数据存储架构,传统数据库解决大数据问题时成本更高,更适合的技术范围是NoSQL。
2215 0
Hadoop原理与技术——Hbase实操
Hadoop原理与技术——Hbase实操
0 0
+关注
异步社区
异步社区(www.epubit.com)是人民邮电出版社旗下IT专业图书旗舰社区,也是国内领先的IT专业图书社区,致力于优质学习内容的出版和分享,实现了纸书电子书的同步上架,于2015年8月上线运营。公众号【异步图书】,每日赠送异步新书。
文章
问答
文章排行榜
最热
最新
相关电子书
更多
玩转HBase和Lindorm 大数据入门和实战
立即下载
《玩转HBase和Lindorm大数据入门和实战》
立即下载
云数据库HBase企业级安全解析
立即下载