本节书摘来自华章出版社《HBase企业应用开发实战》一 书中的第3章,第3.4节,作者:马延辉 孟鑫 李立松 ,更多章节内容可以访问云栖社区“华章计算机”公众号查看。
3.4 数据模型的特殊属性
讨论完HBase的基本概念,我们来学习一下它特有的功能。这些常常使人感到困惑,对使用过关系型数据库的读者来说尤为如此。HBase的这些特性使得完成某些功能更加方便,同时它也存在一些有待完善的地方,这也正是HBase在大力改进的地方。
3.4.1 版本
Rowkey、Column(列族和列)、Version组合在一起称为HBase中的一个单元格。有可能会有很多单元的行和列是相同的,要区分不同的单元可以使用版本。
Rowkey和Column的值是用字节数组表示的,Version则是用一个长整型表示的。这个长整型值是使用java.util.Date.getTime()或者System.currentTimeMillis()产生的,这就意味着它的含义是“当前时间和1970-01-01 UTC的时间差,单位毫秒”。
在HBase中,版本是按倒序排列的,因此当读取这个文件的时候,最先找到的是最近的版本。关于版本的一些常见问题主要有如下两个:
如果有多个包含版本的写操作同时发起,HBase会保存全部还是会保持最新的一个?
可以发起包含版本的写操作,但是它们的版本顺序和操作顺序相反吗?
下面介绍一下在HBase中版本是如何工作的。
1.?含版本的操作
这里详细讲解如何在HBase的各个主要操作中使用版本属性。
(1)Get/Scan
Get是在Scan的基础上实现的。Get同样可以用Scan来描述。在默认情况下,如果没有指定版本,一旦使用Get操作,会返回最近版本的Cell(该Cell可能是最新写入的,但不能保证一定是)。默认的操作可以这样修改:
如果想要返回两个以上的版本,可以使用Get类的setMaxVersions(),如果想要返回的版本不只是最近的,可以使用Get类的setTimeRange()。
要想查询的最新版本小于或等于给定的这个值,这就意味着给定的“最近”的值可以是某一个时间点。可以使用0到想要的时间来设置,还要把Max Versions设置为1。
默认Get例子如下,其中的Get操作会只获得最新的一个版本。
Get get = new Get(Bytes.toBytes("row1"));
Result r = htable.get(get);
byte[] b = r.getValue(Bytes.toBytes("cf"), Bytes.toBytes("attr"));
// returns current version of value
含有版本的Get例子如下,其中的Get操作会获得最近的3个版本。
Get get = new Get(Bytes.toBytes("row1"));
get.setMaxVersions(3); // will return last 3 versions of row
Result r = htable.get(get);
byte[] b = r.getValue(Bytes.toBytes("cf"), Bytes.toBytes("attr"));
// returns current version of value
List<KeyValue> kv = r.getColumn(Bytes.toBytes("cf"), Bytes.toBytes("attr"));
// returns all versions of this column
(2)Put
一个Put操作会为一个Cell创建一个版本,默认使用当前时间戳,当然也可以自己设置时间戳,这就意味着可以把时间设置在过去或者未来,或者随意使用一个长整型值。覆盖一个现有的值,就意味着新写入Cell的Rowkey、Column Family、Column Qulifier和Version必须和原来的完全相同。
下面是不指明版本的例子,其中的Put操作不指明版本,所以HBase会将当前时间作为版本。
Put put = new Put(Bytes.toBytes(row));
put.add(Bytes.toBytes("cf"), Bytes.toBytes("attr1"), Bytes.toBytes( data));
htable.put(put);
下面是指明版本的例子,其中的Put操作指明了版本。
Put put = new Put( Bytes.toBytes(row ));
long explicitTimeInMs = 555; // just an example
put.add(Bytes.toBytes("cf"), Bytes.toBytes("attr1"), explicitTimeInMs, Bytes.toBytes(data));
htable.put(put);
(3)Delete
内部删除标记有三种不同类型:
Delete:删除列的指定版本。
Delete Column:删除列的所有版本。
Delete Family:删除特定列族所有列。
当删除一行时,HBase将内部为每个列族创建墓碑(非每个单独列)标记。
删除操作的实现是创建一个墓碑标记。例如,要删除一个版本,HBase不会去改那些数据,数据不会立即从文件中删除。它使用删除标记来屏蔽掉这些值。如果被标记的是最新一个版本的数据,就意味着这一行中的所有数据都会被删除。
2.?现有的限制
关于版本还有一些未实现的功能,计划在以后的版本中实现。
(1)删除标记后错误读取新数据
删除标记操作可能会标记其后Put的数据。注意,在写下一个墓碑标记后,只有下一个主合并(major compact)操作发起之后,墓碑标记才会清除。假设删除所有小于等于时间T的数据,但之后又执行了一个Put操作,其时间戳小于等于T,那么就算这个Put发生在删除操作之后,该数据也会被打上墓碑标记。这个Put并不会失败,但你做Get操作时,则无法查询刚刚Put进去的数据。只有一个主合并(major compact)执行后,一切才会恢复正常。所以使用一系列时间戳一直增长的Put操作就不会发生该问题。
(2)主合并改变查询的结果
一个Cell有三个版本数据t1、t2和t3,maxVersion设置为2,当请求获取全部版本的时候,只会返回两个:t2和t3。如果将t2和t3删除,就会返回t1。但是如果在删除之前,发生了major compaction操作,t1的值将会从磁盘上被彻底删除,结果是什么值都不会返回了。
3.4.2 排序
Get和Scan操作返回的是经过排序的数据。列在服务器端也是字典排序的,所以按照名称的字典序返回。总体来说,返回的数据首先按行字典序排序,其次是列族,然后是列修饰符(column qualifier),最后是时间戳反向排序,最新的在最前面。
3.4.3 列的元数据
对于HBase表中的列族,除了KeyValue实例以外,没有关于元数据的描述,KeyValue对象表示HBase的最小单位是Cell。HBase的表不仅在一行中支持很多列,而且支持行之间有不同的列,所以需要单独维护行和列之间的关系。获取列族的完整列名的唯一方法是处理所有行。
3.4.4 连接查询
HBase是否支持连接查询,即Join查询,是一个常见问题。简单来说是不支持,至少不像传统RDBMS那样支持。正如前面描述的,读数据模型只有Get和Scan两种操作。但这并不表示Join查询不能在应用程序中使用,只是必须用户自己实现。一般来讲,实现方法有两种:要么写入HBase的时候已经做好连接;要么查询表并在应用层实现连接。哪个更好?依赖于准备做什么,所以没有一个准确的答案适合所有情况。
3.4.5 计数器
IncrementColumnValue(简称ICV)是HBase的计数器,可以使用它完成一些诸如计算页面浏览量(PV)的操作。如果没有ICV,则需要首先读出HBase单元格中的值,然后加1再存入。ICV操作发生在RegionServer上,而不是在客户端,所以速度快,使用方式也没有那么烦琐。当其他客户端也在访问同一个单元格时,可以避免出现不一致的情况。可以把ICV等同于Java的AtomicLong.addAndGet()方法。如果想了解计数器的详细用法请阅读10.2节。
3.4.6 原子操作
类似Java的原子类,HTableInterface接口也提供checkAndPut()和checkAndDelete()方法,它们可以在维持原子语义的同时提供更精细的控制。可以用checkAndPut()来实现上一节提到的incrementColumnValue()方法:
Configuration conf = HBaseConfiguration.create();
HTable htable = new HTable(conf,"table1"); // instantiate HTable
Get g=new Get(Bytes.toBytes(rowkey));
Result r= htable.get(g);
long curVal=Bytes.toLong(r.getColumnLatest(Bytes.toBytes(family),
Bytes.toBytes(qualifier)).getValue());
long incVal=curVal+1;
Put P=new Put(Bytes.toBytes(rowkey));
P.add(Bytes.toBytes(family), Bytes.toBytes(qualifier), Bytes.toBytes(incVal));
htable.checkAndPut(Bytes.toBytes(rowkey), Bytes.toBytes(family),
Bytes.toBytes(qualifier), Bytes.toBytes(curVal),P);
上面的代码虽然有点长,但可以试试。使用checkAndDelete()的方式与此类似。
3.4.7 事务特性ACID
传统的SQL数据库的事务通常都是支持ACID的强事务机制。而HBase这种NoSQL数据库仅提供对行级别的原子性保证,也就是说同时对同一个Key下的数据进行的两个操作,在实际执行的时候是会串行的执行,保证了每一个KeyValue对不会被破坏。
之前版本的HBase提供行级的事务,不过每次事务只能执行一个写操作,假如连续地执行一系列Put、Delete操作,那么这些操作是单独一个个的事务,其整体并不是原子性执行的。而在0.94.*版本中,可以实现Put、Delete在同一个事务中一起原子性执行。
同一Region有多行原子性,因此对一个多Region表来说,还是无法保证每次修改都能封装为一个事务。HBase不是一个具备完整ACID特性的数据库,它只实现了某些属性。
HBase的ACID操作是复杂的,下面总结了ACID操作的一些主要原则,这些原则可以由点及面,逐步了解HBase ACID的特征。
HBase中考虑了事务(ACID)特性的数据操作包含以下这些:
获取数据的API:Get、Scan
修改数据的API:Put、Batch put、Delete
多项操作在一起的API:IncrementColumnValue、CheckAndPut
1)关于HBaseACID设计原则如下:对于同一行所有列的修改是原子性的,对于该行的Put操作要么整体成功要么整体失败。
2)一个返回“成功”标志的操作肯定是完全成功的。
3)一个返回“失败”标志的操作肯定是完全失败的。
4)超时的操作可能成功也可能失败。但也不会是部分成功或失败。
5)对于同一行跨多个列族的操作也遵循上面的原则。
6)多行操作不能保证原子性,例如:对a、b、c三行进行操作,一些可能有返回,一些可能没有,在这种情况下,API会返回一个对这三行操作的结果列表,包括成功、失败或者超时。
7)CheckAndPut()操作就像许多编程语言的CompareAndSet()操作一样是原子性的。
8)所有修改操作是保证顺序的,例如:如果一个写操作将数据修改成"a=1,b=1,c=1",另一个修改成"a=2,b=2,c=2",那么行的状态肯定是"a=1,b=1,c=1"或者"a=2,b=2,c=2",不可能出现"a=1,b=2,c=1"这种状态。
9)请注意批量修改操作不能跨越多行。
10)一致性和隔离性。通过API得到的行的数据是一个完整的行,数据由表中某个时刻的数据构成。
11)持久性。所有可以读取到的数据保证都是已经被持久化到磁盘上的。也就是说不会读到没有写到磁盘上的数据。所有返回成功的操作的数据都是处于持久化到磁盘上的。返回失败的都没有持久化。
关于HBase的ACID语义详情可以参见:http://hbase.apache.org/acid-semantics.html。
3.4.8 行锁
HBase API中put()、delete()、checkAndPut()等修改操作是独立执行的,这意味着在一个串行方式的执行中,对于每一行必须保证行级别的操作是原子性的。RegionServer提供了一个行锁特性,这个特性保证了只有一个客户端能获取一行数据相应的锁,同时对该行进行修改。
3.4.9 自动分区
HBase中一个表的数据会被划分成很多的Region,Region可以动态扩展并且HBase保证Region的负载均衡。Region实际上是行键排序后的按规则分割的连续的存储空间。如果Region太大,会被动态拆分,相反,多个Region会合并成一个较大的Region,以减少HDFS上存储文件的数量。这两个过程就是HBase的split和compaction,在第9章会详细讲解这两个操作。
刚刚创建的表只有一个Region,随着数据的写入,达到Region上限配置值时,Region会按照中间键自动地拆分成两个大致相等的Region,每个Region由一个RegionServer管理,一个RegionServer处理器管理着许多的Region。图3-3展示了多个Region是如何分布在不同RegionServer上的,注意每个Region包含起始Rowkey的记录,不包含结束Rowkey的记录。
每个RegionServer管理多少个Region合适?每个Region大小是多少合适?按照现在主流硬件的配置,每个RegionServer可以管理大约100~1000个Region,每个Region的大小可以是1~20GB。
Region的拆分和转移是由HBase自动完成的,用户感知不到,当一个RegionServer服务器发生故障时,Region可以快速地被转移到其他服务器上,Region的拆分过程也是瞬间完成的。当Region进行拆分时,首先要将该Region下线(offline),拆分完成后新的Region再上线(online),下线的Region暂时不可用,不过由于速度极快,通常不会对数据的读写造成影响。