HBase权威指南(一)

165 阅读29分钟

HBase权威指南(一)

1.起源

一切,起始于Google发布的三驾马车,Google File System(GFS),MapReduce和BigTable。 GFS是一种可扩展的分布式文件系统。MapReduce是一个分布式计算模型。BigTable是一个分布式存储系统,专门用于存储结构化数据。

HBase借鉴了Google基于GFS构建BigTable的思想。HBase构建与Hadoop的HDFS之上。

当我们面临一些难以解决的问题时,可以想一想谷歌是如何解决的,朝这个方向去查找资料,你可能能得到答案。HBase解决了Hadoop无法进行随机读写的问题。 HBase是用Java语言实现的系统。

HBase使用的是列式存储。在特定的查询,不是所有的值都是必须的,尤其是在分析型数据库里。在这种新型设计中,减少I/O只是众多主要因素之一,他还有其他优点。因为列的数据类型天生是相似的,这更有利于压缩。

当一个使用MySQL等ALTP数据库的传统软件系统访问量太高,压力太大时。该怎么做?

1:增加用于并行读取的服务器,将读写分离。如果这个方案也无法满足,那么可以考虑第二种方案。

2:增加缓存,如Memcached(基于内存,非持久化的,非分布式的键值存储系统)。现在可以将读操作接入到高速的在内存中缓存数据的系统中。但这种方案无法保证数据的一致性。虽然这种方案能够缓解地、、读请求的压力,但是写请求压力的增加问题没有得到解决。

3:采用逆范式化存储结构。本质上讲,减少数据库中存储数据才能优化访问。 合乎逻辑的方式是不时地预先实现最昂贵的查询方案,从而给用户提供更快的数据服务。最终,不得不放弃辅助索引的使用,原因就是数据量增大的同时,索引量也大到足以让数据库的性能直线下降。最后所能提供的查询模式只剩下了按照主键查询。 那此时该怎么办?用户可以讲数据分区(sharding)到多个数据库中,但是采用此方案会使得运维操作变成噩梦,而且代价非常昂贵,因此也不是最合理的解决方案。但是从本质来讲,采用RDBMS也是因为没有其他可以选择的方案。

2.HBase基础介绍

HBase是一款NoSQL数据库,本身并不支持SQL语句查询。只提供一些比较简单的,类似于API接口的方式来存取数据。但是也有一些工具为NoSQL数据存储提供了SQL语言的入口,但功能并不完整。

关系型数据库和非关系型数据库在底层上是有区别的,尤其涉及到模式或者ACID事务特性时。

一致性模型: 一开始的一致性时保证数据库客户端操作的正确性,数据库必须保证每一步操作都是从高一个一致的状态到下一个一致的状态。最终,系统要选择是进入下一个一致状态,还是回退到上一个一致的状态,从而保证一致性。

一致性可以按照严格程度由强到弱分类,或者是按照对客户端的保证程度分类,下面是一个非正式的分类列表。 严格一致性:数据的变化是原子的,已经改变立刻生效,这是一致性的最高形式。 顺序一致性:每个客户端看到的数据依照他们操作执行的顺序而变化。 因果一致性:客户端以因果关系顺序观察到数据的变化。 最终一致性:在没有更新的一段时间里,系统将通过广播保证副本之间的数据一致性。 弱一致性:没有做出保证的情况下,所有的更新会通过广播的形式传递,展现给不同客户端的数据顺序可能不一样。

HBase最基本的单位是列(column)。一列或多列形成一行(row),并由唯一的行键(row key)来确定存储。一个表(table)中有若干男男女女行,其中每列可能有多个版本,在每一个单元格(cell)中存储了不同的值。所有的行按照行键字典序进行排序。按照行键排序可以获得像RDBMS的主键索引一样的特性。 一行由若干列组成,若干列又构成了一个列族(column family),这有利于构建数据的语义边界或者局部边界,还有助于给他们设置某些特性(如压缩),或者指示他们存储在内存中。一个列族的所有列存储在同一个底层文件里,这个存储文件叫做HFile。

列族需要在表创建时就定义好,而且不能修改的太频繁,数量也不能太多。

常见的引用列的格式为family:qualifier ,qualifier时任意的字节数组。与列族的数量有限制相反,列的数量没有限制:一个列族里可以有数百万个列。列值也没有类型和长度的限定。

在固定模式的数据库在没有值的地方必须存储NULL值,但是在HBase的存储架构中,可以干脆省略整个列,换句话说,空值是没有任何消耗的,他们不占用任何存储空间。

每一列的值或单元格的值都具有时间戳,默认由系统指定,也可以由用户显示设置。可以通过不同的时间戳来区分不同版本的值。一个单元格的不同版本的值按照降序排列在一起,访问的时候优先读取最新的值。 用户可以指定每个值所能保存的最大版本数。此外,还支持谓词删除(predicate deletion),例如,允许用户只保存过去一周内写入的值。这些值(或单元格)也只是未解释的字节数组,客户端需要知道怎样去处理这些值。

HBase行数据的存取操作是原子的(atomic),可以读写任意数目的列。目前不支持跨行事务和跨表事务。原子存取也是促成系统架构具有强一致性(strictly consistent)的一个因素。

HBase中扩展和负载均衡的基本单元称为region,region本质上是以行键排序的连续存储的区间。如果region太大,系统就会把他们动态拆分,相反的,就把多个region合并,以减少存储文件数量。HBase不支持在线的region合并,但是有离线处理合并的工具。

一张表初始的时候只有一个region,用户开始向表中插入数据时,系统会检查这个region的大小,确保其不超过配置的最大值。如果超过了限制,系统会在中间键(middle key,region中间的那个键)处将这个region拆分成两个大致相等的子region。每一个region只能由一台region服务器(region server)加载,每一台region服务器可以同时加载多个region。

HBase API提供了建表,删表,增加列族和删除列族操作,同时还提供了修改表和列族元数据的功能,如压缩和设置块大小。此外,它还提供了客户端对给定的行键值进行增加,删除和查找操作的功能。

scan API提供了高效遍历某个范围的行的功能,同时可以限定返回哪些列或返回的版本数。通过设置过滤器可以匹配返回的列。通过设置起始和终止时间范围可以选择查询的版本。

数据存储在存储文件(store file)中,称为HFile,HFile中存储的是经过排序的键值映射结构。文件内部由连续的块组成,块的索引信息存储在文件的尾部。当把HFile打开并加载到内存中时,索引信息会优先加载到内存中,每个块的默认大小时64KB,可以根据需要配置不同的块大小。存储文件提供了一个设定起始和终止行键范围的API用于扫描特定的值。

每次更新数据时,都会先将数据记录在提交日志(commit log)中,在HBase中这叫做预写日志(write-ahead log,WAL),然后才会将这些数据写入内存中的memstore中。一旦内存保存的写入数据的累计大小超过了一个给定的最大值,系统就会将这些数据移除内存作为HFile文件刷写到磁盘中。数据移除内存之后,系统会丢弃对应的提交日志,只保留未持久化到磁盘中的提交日志。在系统将数据移除memstore写入磁盘的过程中,可以不必阻塞系统的读写,通过滚动内存中的memstore就能达到这个目的,即用空的新memstore获取更新数据,将满的旧memstore转换成一个文件。请注意,memstore中的数据已经按照行键排序,持久化到磁盘中的HFile也是按照这个顺序排列的,所以不必执行排序或其他特殊处理。

因为存储文件是不可被改变的,所以无法通过移除某个键/值对来简单地删除值。可行的解决办法是,做个删除标记(delete marker,又称墓碑标记),表明给定行已经被删除的事实。在检索过程中,这些删除标记掩盖了实际值,客户端读不到实际值。

读回的数据是两部分数据合并的结果,一部分是memstore中还没有写入磁盘的数据,另一部分是磁盘上的存储文件。值得二注意的是,数据检索是用不着WAL,只有服务器内存中的数据在服务器崩溃前没有写入到磁盘,而后进行恢复数据时才会用到WAL。

随着memstore中的数据不断刷写到磁盘中,会产生越来越多的HFile文件,HBase内部有一个解决这个问题的管家机制,即用合并将多个文件合并成一个较大的文件。合并有两种类型:minor合并(minor compaction)和major压缩合并(majar compaction)。minor合并将多个小文件重写为数量较少的大文件,减少存储文件的数量,这个过程实际上是个多路归并的过程。因为HFile的每个文件都是经过归类的,所以合并速度很快,只受到I/O性能的影响。

major合并将一个region中一个列族的若干个HFile重写为一个新HFile,与minor合并相比,还有更独特的功能:major合并能扫描所有的键/值对,顺序重写全部的数据,重写数据的过程中会略过做了删除标记的数据。断言删除此时生效,例如,对于那些超过版本号限制的数据以及生存时间到期的数据,在重写数据时就不再写入磁盘了。

HBase中有3个主要组件:客户端库,一台主服务器,堕胎region服务器。可以动态地增加和移除region服务器,以适应不断变化的负载。主服务器主要负责利用Apache Zookeeper为region服务器分配region,Apache Zookeeper是一个可靠的,高可用的,持久化的分布式协调系统。

image-20241118090359574.png

Zookeeper是整个HBase系统中不可缺少的服务,没有它HBase就无法运作。

master服务器负责跨region服务器的全局region的负载均衡,将繁忙的服务器中的region移到负责较轻的服务器中。主服务器不是实际数据存储或者检索路径的组成部分,它仅提供了负载均衡和集群管理,不为region服务器或者客户端提供任何的数据服务,因此是轻量级服务器。此外,主服务器还提供了元数据的管理操作,例如,建表和创建列族。

region服务器负责为他们服务的region提供读写请求,也提供了拆分超过配置大小的region的接口。客户端则直接与region服务器通信,处理所有数据相关的操作。

若使用HBase单机模式(Standalone Mode)模式,且没有自行配置数据存储目录,那么HBase会将数据存储到/tem这个默认目录下,服务器一旦重启,测试数据就会丢失。数据一旦被操作系统删除,将无法回复。

HBase基础操作命令

创建一个简单表并新增几行数据:

//创建表,列族
create 'testtable','colfaml'
//查询表是否存在
list 'testtable'
//为testtable添加数据
put 'testtable','myrow-1','colfaml:q1','value-1'
put 'testtable','myrow-2','colfaml:q2','value-2'
put 'testtable','myrow-2','colfaml:q3','value-3'

我们创建了一张testtable,testtable表有一个名为colfaml的列族。我们向testtable表添加了三行数据。

接下里我们检索这三行数据。

scan 'testtable'

image-20241118092245358.png

我们可以看到,在testtable表中有一个列族colfaml,有两行数据。这两个数据的row-id分别是myrow-1,myrow2myrow-1这一行数据有一个列,名为colfaml:q1,其值为value-1myrow-2这一行数据有两个列,分为是colfaml:q2colfaml:q3,其值分别是value-2value-3

如果想获取单行数据,可以使用get命令,这个命令有很多选项,这些选项暂时按下不表。

// 获取 testtable表的行键为myrow-1的数据
get 'testtable','myrow-1'

image-20241118093020818.png

删除数据也是基本操作之一,同样delete命令也有很多选项,我们暂且按下不表。

//删除testtable表行键为myrow-2,列名为colfaml:q2的cell(单元格)数据
delete 'testtable','myrow-2','colfaml:q2'

image-20241118093331530.png

HBase要删除表,需要先对表做禁用操作。

//禁用表testtable
disable 'testtable'
//删除表testtable
drop 'testtable'

2.1.硬件

HBase能在多种不同配置的硬件上运行。通常的描述是商用(commodity)硬件。

硬件之上需要支持当前的Java运行时环境。region服务器的内存主要服务于内部数据结构,例如,memstore和块缓存,因此需要安装64位操作系统才能分配和使用大于4G的内存空间。

在实践中,为了能够像MapReduce一样有效的利用HDFS,HBase大多是与Hadoop安装在一起的。这样能很大程度地减少对网络I/O的需求,同时能加快处理速度。当在同一台服务器上运行Hadoop和HBase时,最少会有3个Java进程(DataNode,TaskTracker和RegionServer)在运行,而且在执行MapReduce作业时,进程数还会激增。要有效的运行这些进程,需要保证拥有一定数量的内存,磁盘和CPU资源。

2.2.Hadoop

目前HBase只能依赖特定的Hadoop版本,其中的主要原因之一是HBase与Hadoop之间的远程过程调用(Remote Procedure Call,RPC)API,RPC协议是版本化的,并且需要调用方与被调用方相互匹配,细微差异就可能导致通信失败。

HBase最常使用的文件系统是HDFS,但不仅仅是HDFS,因为HBase使用的文件系统是一个可插拔的架构。HDFS作为底层存储层,它被证明是稳定可靠的的系统,它的机制包含了冗余,容错性和可扩展性。HBase需要假定文件系统中的数据存储是可靠的,并且HBase本身没有办法复制数据并维护自身存储文件的副本。因此较低层次的文件系统必须提供此功能。

3.客户端API:基础知识

HBase所有修改数据的操作都保证了行级别的原子性,这会影响到这一行数据所有的并发读写操作。写操作中涉及的列的数目不会影响到该行数据的原子性,行原子性会同时保护到所有列。

最后创建HTable实例是有代价的。每个实例都需要扫描.META.表,以检查该表是否存在,是否可用,此外还要执行一些其他操作,这些检查和操作导致实例调用非常耗时。因此,推荐用户只创建一次HTable实例,而且是每个线程创建一个,然后在客户端应用的生存期内复用这个对象。

region服务器采用了一种多版本并发控制机制(RWCC)。这种机制保证了读程序读取数据时可以不用等待写程序完成写操作。写程序则需要等待其他写程序完成写操作之后才能继续执行。

创建Put实例时用户需要提供一个行键row,在HBase中每行数据都有唯一的行键(row key)作为标识,它是一个Java的byte[]数组。

3.1.插入

向HBase插入数据的示例应用

public class PutExample{
    public static void main(String[] args) throws IOException{
        //创建所需的配置
        Configuration conf = HBaseConfiguration.create();
        //实例化一个新的客户端
        HTable table = new HTable(conf,"testtable");
        //指定一行来创建一个Put
        Put put = new Put(Bytes.toBytes("row1"));
        //向Put中添加一个名为 colfaml:qual1 的列
        put.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),Bytes.toBytes("val1"));
        //向Put中添加一个名为 colfaml:qual2 的列
        put.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual2"),Bytes.toBytes("val2"));
        //将这一行存储到HBase表中
        table.put(put);
    }
}

HBase的一个特殊功能是,能为一个单元格(一个特定列的值)存储多个版本的数据。这是通过每个版本使用一个时间戳。并且按照降序存储来实现的。每个时间戳是一个长整型值,以毫秒为单位。将数据存入HBase时,要么显式的提供一个时间戳,要么忽略该时间戳。如果用户忽略该时间戳的话,RegionServer会在执行put操作的时候填充该时间戳。

// 创建表 test 列族 cf1
create 'test','cf1'
// 添加数据
put 'test','row1','cf1','val1'
put 'test','row1','cf1','val2'

image-20241118111531870.png

该示例在test表中创建了一个名为cf1的列族。两个put命令使用了相同的行键和列键,但他们的值不同:分别为val1和val2。然后使用scan操作查看了这张表的所有内容。你可能并不惊讶于只看到了val2,因为你可能已经架设第二次put操作覆盖了val1.但是在HBase中并不是这样的。默认情况下,HBase会保留3个版本的数据,用户可以利用这种特性,稍稍修改scan操作一遍获取所有可获得的数据(即不同版本数据)。

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

客户端写操作

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

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

另一个重要的因素就是消息太小。如果通过网络发送的请求内容较大,那么需要的请求返回的次数就相应较少,这是因为时间主要花费在数据传输上。不过如果传送的数据量很小,比如一个计数器递增操作,那么用户把多次修改的数据批量提交给服务器并减少请求次数,性能会相应提升。

HBase的API配备了一个客户端的写缓冲区(write buffer),缓冲区负责收集put操作,然后调用RPC操作一次性将put送往服务器。全局交换机控制着缓冲区是否在使用。

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

table.setAutoFlush(false)

flushCommits()方法将所有的修改传送到远程服务器。

使用客户端写缓冲区

HTable table = new HTable(conf,"testtable");
//检查自动刷写标识位的设置,应该会打印出 "Auto flush:true"
System.out.println("Auto flush:" + table.isAutoFlush());
//设置自动刷写为false,启用客户端写缓冲区
table.setAutoFlush(false);
​
Put put1 = new Put(Bytes.toBytes("row1"));
put1.add(Bytes.toBytes("colfam1"),Bytes.toBytes("qual1"),Bytes.toBytes("val1"));
//将一些行和列数据存入HBase
table.put(put1);
​
Put put2 = new Put(Bytes.toBytes("row2"));
put1.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);
//视图加载先前存储的行,结果会打印出 "Result: keyvalues=NONE"
System.out.println("Result: " + res1);
​
//强制刷写缓冲区,会导致产生一个RPC请求。
table.flushCommits();
​
Result res2 = table.get(get);
//现在,这一行被持久化了,可以被读取了
System.out.println("Result: " + res2);

由于客户端的写缓冲区是一个内存结构,存储了所有未刷写的记录,这些数据记录尚未发送到服务器,因此用户无法访问它。

使用列表向HBase中添加数据

//创建一个列表用于存储Put实例
List<Put> puts = new ArrayList<Put>();
​
Put put1 = new Put(Bytes.toBytes("row1"));
put1.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),Bytes.toBytes("val1"));
//将一个Put实例添加到列表中
puts.add(put1);
​
Put put2 = new Put(Bytes.toBytes("row2"));
put1.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),Bytes.toBytes("val2"));
//将另一个Put实例添加到列表中
puts.add(put2);
​
Put put3 = new Put(Bytes.toBytes("row2"));
put1.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual2"),Bytes.toBytes("val3"));
//将第三个Put实例添加到列表中
puts.add(put3);
​
//向HBase中存入多行多列数据
table.put(puts);

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

下面使用一个错误列族名插入列。

Put put1 = new Put(Bytes.toBytes("row1"));
put1.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),Bytes.toBytes("val1"));
puts.add(put1);
Put put2 = new Put(Bytes.toBytes("row2"));
//将不存在的列族的Put实例加入列表
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("colfaml"),Bytes.toBytes("qual2"),Bytes.toBytes("val3"));
puts.add(put3);
​
//将多行多列数据存储到HBase中
table.put(puts);

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

image-20241118142701099.png 列表中没有发生异常的两个put数据已经被添加到HBase中了。

image-20241118142804256.png

服务器遍历所有操作并设法执行他们,失败的会返回,然后客户端会使用RetriesExhaustedWithDetailsException报告远程错误,这样用户可以查询有多少个操作失败,出错的原因已经重试次数。要注意的是,对于错误列族,服务器的重试次数会自动设为1,因为这是一个不可恢复的错误类型。这些在服务器上失败的Put实例会被保存在本地写缓冲区,下一次缓冲区刷写的时候会重试。用户也可以通过HTable的getWriteBuffer()方法访问他们,并对他们做一些处理,例如,清除操作。

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

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

用户可以捕获异常并手动刷写缓冲区来执行已经添加的操作。如下代码所示。

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

Put put1 = new Put(Bytes.toBytes("row1"));
put1.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),Bytes.toBytes("val1"));
puts.add(put1);
​
Put put2 = new Put(Bytes.toBytes("row2"));
put1.add(Bytes.toBytes("BOGUS"),Bytes.toBytes("qual1"),Bytes.toBytes("val2"));
puts.add(put2);
​
Put put3 = new Put(Bytes.toBytes("row2"));
put1.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual2"),Bytes.toBytes("val3"));
puts.add(put3);
​
Put put4 = new Put(Bytes.toBytes("row2"));
//将没有内容的Put添加到列表中
puts.add(put4);
​
try{
    table.put(puts);
}catch(Exception e){
    System.err.println("Error: "+e);
    //捕获本地异常然后提交更新
    table.flushCommits();
}
​

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

image-20241118144613620.png

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

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

原子性操作 compare-and-set

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

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

该方法可以保证服务器端put操作的原子性。如果检查成功通过,就执行put操作,负责就彻底放弃修改操作。这种有原子性保证的操作经常被用于账户结余,状态装换或数据处理等场景。这些应用场景的共同点是,在读取数据的同时需要处理数据。一旦你想把一个处理好的结果写回HBase,并且保证没有其他客户端已经做了同样的事情,你就可以使用这个有原子性保证的操作,先比较值,在做修改。

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

Put put1 = new Put(Bytes.toBytes("row1"));
//创建一个新Put实例
put1.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),Bytes.toBytes("val1"));
​
//检查指定列是否存在,按检查的结果决定是否执行put操作
boolean res1 = table.checkAndPut(Bytes.toBytes("row1"),Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),null,put1);
//输出结果,此处应为 “Put applied:true”
System.out.println("Put applied:"+res1);
​
//再次向同一单元格写入数据
boolean res2 = table.checkAndPut(Bytes.toBytes("row1"),Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),null,put1);
//因为那个列的值已经存在,此时的输出结果应为 “Put applied:false”
System.out.println("Put applied:"+res2);
​
Put put2 = new Put(Bytes.toBytes("row1"));
//创建另一个新的Put实例,这次使用一个不同的列限定符
put2.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual2"),Bytes.toBytes("val2"));
​
//当上一次的put值存在时,写入新的值
boolean res3 = table.checkAndPut(Bytes.toBytes("row1"),Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),Bytes.toBytes("val1"),put2);
//因为已经存在,所以输出的结果应当为 “Put applied true”
System.out.println("Put applied:"+res3);
​
Put put3 = new Put(Bytes.toBytes("row2"));
//再创建一个Put实例,这回使用一个不同的行键
put3.add(Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),Bytes.toBytes("val3"));
​
//检查一个不同行的值是否相等,然后写入另一行
boolean res4 = table.checkAndPut(Bytes.toBytes("row1"),Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"),Bytes.toBytes("val1"),put3);
//程序执行不到这里,因为在上一句代码处抛出异常
System.out.println("Put applied:"+res4);

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

image-20241118152454536.png

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

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

3.2.查询

从HBase中获取数据的应用**

//创建配置实例
Configuration conf = HBaseCOnfiguration.create();
//初始化一个新的表引用
HTable table = new HTable(conf,"testtable");
//使用一个指定的行键构建一个Get实例
Get get = new Get(Bytes.toBytes("row1"));
//向Get实例中添加一个列
get.addColumn(Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"));
//从HBase中获取指定列的行数据
Result result = table.get(get);
//从返回的结果中获取对应列的数据
byte[] val = result.getValue(Bytes.toBytes("colfaml"),Bytes.toBytes("qual1"));
//将数据转换为字符串打印输出
System.out.println("Value:"+Bytes.toString(val));

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

//准备好共用字节数组
byte[] cf1 = Bytes.toBytes("colfaml");
byte[] qf1 = Bytes.toBytes("qual1");
byte[] qf2 = Bytes.toBytes("qual2");
byte[] row1 = Bytes.toBytes("row1");
byte[] row2 = Bytes.toBytes("row2");
​
//准备好要存放Get实例的列表
List<Get> gets = new ArrayList<Get>();
​
Get get1 = new Get(row1);
get1.addColumn(cf1,qf1);
gets.add(get1);
​
//将Get实例添加到列表中
Get get2 = new Get(row2);
get2.addColum(cf1,qf1);
gets.add(get2);
​
Get get3 = new Get(row2);
get3.addColumn(cf1,qf2);
gets.add(get3);
​
//从HBase中获取这些行和选定的列
Result[] results = table.get(gets);
​
System.out.println("First iteration...");
for(Results 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()方法返回异常的方法与Put列表不同。get()方法要么返回与给定列表大小一致的Result数组,要么抛出一个异常。

3.3.删除

HTable提供了删除的方法,有一个相应的类命名为Delete。

4.批量操作

不可以将针对同一行的Put和Delete操作放在同一个批量处理请求中。为了保证最好的性能,这些操作的处理顺序可能不同,但是这样会产生不可预料的结果。由于资源竞争,某些情况下,用户看到波动的结果。

5.行锁

像put(),delete(),checkAndPut()这样的修改操作是独立执行的,这意味着在一个串行方式的执行中,对于每一行必须保证行级别的操作是原子性的。region服务器提供了一个行锁(row lock)的特性,这个特性保证了只有一个客户端能获取一行数据相应的锁,同时对该行进行修改。在实践中,大部分客户端应用程序都没有提供给显式的锁,而是使用这个机制来保障每个操作的独立性。在不必要的情况下,尽量不要使用行锁,如果必须使用,那么一定要节约占用锁的时间。

第一个调用lockRow()需要一个行键作为参数,返回一个RowLock的实例,这个实例可以供后续的Put或者Delete的构造函数使用。一旦不再需要锁时,必须通过unlockRow()调用来释放它。每一个排他锁(unique lock),无论是由服务器提供的,还是通过客户端API传入的,都能保护这一行不被其他锁锁定。换句话说,锁必须针对整个行,并且指定其行键,一旦它获得锁定权就能防止其他的并发修改。

修改行时锁定行是有意义的,那么获取数据时是否需要加锁呢?Get类有一个构造器允许用户指定一个显式的锁。Get(byte[] row,RowLock rowLock)

这是遗留的方法,但服务器端根本用不着这种方法,因为在获取数据的过程中,服务器根本不需要任何锁,而是应用了一个多版本的并发控制机制来保证行级读操作。例如,get()调用永远不会返回写了一半的数据,比如当这些数据是另一个线程或客户端写的。

这个就像是小规模的事务系统:只有当一个变动被应用到整个行之后,客户端才能读出这个改动。当改动在进行中时,所有的客户端读取操作得到的都将是所有列以前的状态。

6.扫描

ResultScanner getScanner(Scan scan) throws IOException;
ResultScanner getScanner(byte[] family)throws IOException;
ResultScanner getScanner(byte[] family,byte[] qualifier) throws IOException;
​
Scan setStartRow(byte[] startRow);
Scan setStopRow(byte[] stopRow);
Scan setFilter(Filter filter);
boolean hasFilter();
​
//ResultScanner的一些方法
Result next() throws IOException;
Result[] next(int nbRows)throws IOException;
void close();

7.缓存与批量处理

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

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

void setScannerCaching(int scannerCaching);
int getScannerCaching();

可以使用下列Scan类的方法设置扫描级的缓存:

void setCaching(int caching);
int getCaching();

如果数据量非常大的行,这些行有可能超过客户端进程的内存容量。HBase和它的客户端API对这个问题有一个解决方法:批量。用户可以使用以下方法控制批量获取操作。

void setBatch(int batch);
int getBatch();