译者:phylips@bmy 2011-10-1
出处:http://duanple.blog.163.com/blog/static/70971767201191661620641/
本文翻译自:http://ofps.oreilly.com/titles/9781449396107/architecture.html
作为开源类BigTable实现。HBase目前已经应用在很多互联网公司中。
无论对于高级用户还是普通使用者来说,完整地理解所选择的系统在底层是如何工作的都是非常有用的。本章我们会解释下HBase的各个组成部分以及它们相互之间是如何协作的。
在研究架构本身之前,我们还是先看一下传统RDBMS与它的替代者之间的根本上的不同点。特别地,我们将快速地浏览下关系型存储引擎中使用的B树及B+树,以及作为Bigtable的存储架构基础的Log-Structured Merge Tree。
注:需要注意的是RDBMSs并不是只能采用B树类型的结构,而且也不是所有的NoSQL解决方案都使用了与之不同的结构。通常我们都能看到各式各样的混搭型的技术方案,它们都具有一个相同的目标:使用那些对手头上的问题来说最佳的策略。下面我们会解释下为什么Bigtable使用了类LSM-tree的方式来实现这个目标。
B+树有一些特性可以让用户根据key来对记录进行高效地插入,查找和删除。它可以利用每个segment(也称为一个page)的下界和上界以及key的数目来建立一个动态,多级索引结构。通过使用segments,达到了比二叉树更高的扇出{!很明显二叉树一个节点只有2个出度,而B+树是个多叉树,一个节点就是一个segment,因此出度大小就由segment本身存储空间决定,出度增加后,就使得树高度变低,减少了所需seek操作的数目},这就大大降低了查找某个特定的key所需的IO操作数。
此外,它也允许用户高效地进行range扫描操作。因为叶子节点相互之间根据key的顺序组成了一个链表,这就避免了昂贵的树遍历操作。这也是关系数据库系统使用B+树进行索引的原因之一。
在一个B+树索引中,可以得到page级别的locality(这里的page概念等价于其他一些系统中block的概念):比如,一个leaf pages结构如下。为了插入一个新的索引条目,比如是key1.5,它会使用一个新的key1.5 → rowid条目来更新leaf page。在page大小未超过它本身的容量之前,都比较简单。如果page大小超出限制,那么就需要将该page分割成两个新的page。参见图8.1
Figure 8.1. An example B+ tree with one full page
这里有个问题,新的pages相互之间不一定是相邻的。所以,现在如果你想查询从key1到key3之间的内容,就有可能需要读取两个相距甚远的leaf pages。这也是为什么大部分的基于B+-树的系统中都提供了OPTIMIZE TABLE命令的原因—该命令会顺序地对table进行重写,以删除碎片,减少文件尺寸,从而使得这种基于range的查询在磁盘上也是顺序进行的。
2.2. Log-Structured Merge-Trees
另一方面,LSM-tree,选择的是一种与之不同的策略。进入系统的数据首先会被存储到日志文件中,以完全顺序地方式。一旦日志中记录下了该变更,它就会去更新一个内存中的存储结构,该结构持有最近的那些更新以便于快速的查找。
当系统已经积累了足够的更新,以及内存中的存储结构填满的时候,它会将key → record对组成的有序链表flush到磁盘,创建出一个新的存储文件。此时,log文件中对应的更新就可以丢弃了,因为所有的更新操作已经被持久化了。
存储文件的组织方式类似于B树,但是专门为顺序性的磁盘访问进行了优化。所有的nodes都被完全填充,存储为单page或者多page的blocks。存储文件的更新是以一种rolling merge的方式进行的,比如,只有当某个block填满时系统才会将对应的内存数据和现有的多page blocks进行合并。
图8.2展示了一个多page的block如何从in-memory tree合并为一个存储磁盘上的树结构。最后,这些树结构会被用来merge成更大的树结构。
Figure 8.2. Multi-page blocks are iteratively merged across LSM trees
随着时间的推进将会有更多的flush操作发生,会产生很多存储文件,一个后台进程负责将这些文件聚合成更大的文件,这样磁盘seek操作就限制在一定数目的存储文件上。存储在磁盘上的树结构也可以被分割成多个存储文件。因为所有的存储数据都是按照key排序的,因此在现有节点中插入新的keys时不需要重新进行排序。
查找通过merging的方式完成,首先会搜索内存存储结构,接下来是磁盘存储文件。通过这种方式,从客户端的角度看到的就是一个关于所有已存储数据的一致性视图,而不管数据当前是否驻留在内存中。删除是一种特殊的更新操作,它会存储一个删除标记,该标记会在查找期间用来跳过那些已删除的keys。当数据通过merging被重新写回时,删除标记和被该标记所遮蔽的key都会被丢弃掉。
用于管理数据的后台进程有一个额外的特性,它可以支持断言式的删除。也就是说删除操作可以通过在那些想丢弃的记录上设定一个TTL(time-to-live)值来触发。比如,设定TTL值为20天,那么20天后记录就变成无效的了。Merge进程会检查该断言,当断言为true时,它就会在写回的blocks中丢弃该记录。
B数和LSM-tree本质上的不同点,实际上在于它们使用现代硬件的方式,尤其是磁盘。
Seek vs. Sort and Merge in Numbers
对于大规模场景,计算瓶颈在磁盘传输上。CPU RAM和磁盘空间每18-24个月就会翻番,但是seek开销每年大概才提高5%。
如前面所讨论的,有两种不同的数据库范式,一种是Seek,另一种是Transfer。RDBMS通常都是Seek型的,主要是由用于存储数据的B树或者是B+树结构引起的,在磁盘seek的速率级别上实现各种操作,通常每个访问需要log(N)个seek操作。
另一方面,LSM-tree则属于Transfer型。在磁盘传输速率的级别上进行文件的排序和merges以及log(对应于更新操作)操作。根据如下的各项参数:
· 10 MB/second transfer bandwidth
· 10 milliseconds disk seek time
· 100 bytes per entry (10 billion entries)
· 10 KB per page (1 billion pages)
在更新100,000,000条记录的1%时,将会花费:
· 1,000 days with random B-tree updates
· 100 days with batched B-tree updates
· 1 day with sort and merge
很明显,在大规模情况下,seek明显比transfer低效。
比较B+树和LSM-tree主要是为了理解它们各自的优缺点。如果没有太多的更新操作,B+树可以工作地很好,因为它们会进行比较繁重的优化来保证较低的访问时间。越快越多地将数据添加到随机的位置上,页面就会越快地变得碎片化。最终,数据传入的速度可能会超过优化进程重写现存文件的速度。更新和删除都是以磁盘seek的速率级别进行的,这就使得用户受限于最差的那个磁盘性能指标。
LSM-tree工作在磁盘传输速率的级别上,同时可以更好地扩展到更大的数据规模上。同时也能保证一个比较一致的插入速率,因为它会使用日志文件+一个内存存储结构把随机写操作转化为顺序写。读操作与写操作是独立的,这样这两种操作之间就不会产生竞争。
存储的数据通常都具有优化过的存放格式。对于访问一个key所需的磁盘seek操作数也有一个可预测的一致的上界。同时读取该key后面的那些记录也不会再引入额外的seek操作。通常情况下,一个基于LSM-tree的系统的开销都是透明的:如果有5个存储文件,那么访问操作最多需要5次磁盘seek。然而你没有办法判断一个RDBMS的查询需要多少次磁盘seek,即使是在有索引的情况下。
HBase一个比较不为人知的方面是数据在底层是如何存储的。大部分的用户可能从来都不需要关注它。但是当你需要按照自己的方式对各种高级配置项进行设置时可能就得不得不去了解它。Chapter 11, Performance Tuning列出了一些例子。Appendix A, HBase Configuration Properties有一个更全的参考列表。
需要了解这些方面的另一个原因是,如果因为各种原因,灾难发生了,然后你需要恢复一个HBase安装版本。这时候,知道所有的数据都存放在哪,如何在HDFS级别上访问它们,就变得很重要了。你就可以利用这些知识来访问那些通常情况下不可访问的数据。当然,这种事情最好不发生,但是谁能保证它不会发生呢?
作为理解HBase的文件存储层的各组成部分的第一步,我们先来画张结构图。Figure 8.3, “HBase handles files in the file system, which stores them transparently in HDFS”展示了HBase和HDFS是如何协作来存储数据的。
Figure 8.3. HBase handles files in the file system, which stores them transparently in HDFS
上图表明,HBase处理的两种基本文件类型:一个用于write-ahead log,另一个用于实际的数据存储。文件主要是由HRegionServer处理。在某些情况下,HMaster也会执行一些底层的文件操作(与0.90.x相比,这在0.92.0中有些差别)。你可能也注意到了,当存储在HDFS中时,文件实际上会被划分为很多小blocks。这也是在你配置系统来让它可以更好地处理更大或更小的文件时,所需要了解的地方。更细节的内容,我们会在
the section called “HFile Format”里描述。
通常的工作流程是,一个新的客户端为找到某个特定的行key首先需要联系Zookeeper Qurom。它会从ZooKeeper检索持有-ROOT- region的服务器名。通过这个信息,它询问拥有-ROOT- region的region server,得到持有对应行key的.META.表region的服务器名。这两个操作的结果都会被缓存下来,因此只需要查找一次。最后,它就可以查询.META.服务器然后检索到包含给定行key的region所在的服务器。
一旦它知道了给定的行所处的位置,比如,在哪个region里,它也会缓存该信息同时直接联系持有该region的HRegionServer。现在,客户端就有了去哪里获取行的完整信息而不需要再去查询.META.服务器。更多细节可以参考the section called “Region Lookups”。
注:在启动HBase时,HMaster负责把regions分配给每个HRegionServer。包括-ROOT-和.META.表。更多细节参考the section called “The Region Life Cycle”
HRegionServer打开region然后创建对应的HRegion对象。当HRegion被打开后,它就会为表中预先定义的每个HColumnFamily创建一个Store实例。每个Store实例又可能有多个StoreFile实例,StoreFile是对被称为HFile的实际存储文件的一个简单封装。一个Store实例还会有一个Memstore,以及一个由HRegionServer共享的HLog实例(见the section called “Write-Ahead Log”)。
客户端向HRegionServer产生一个HTable.put(Put)请求。HRegionServer将该请求交给匹配的HRegion实例。现在需要确定数据是否需要通过HLog类写入write-ahead log(the WAL)。该决定基于客户端使用
方法
Put.setWriteToWAL(boolean)
所设置的flag。WAL是一个标准的Hadoop SequenceFile,里面存储了HLogKey实例。这些keys包含一个序列号和实际的数据,用来replay那些在服务器crash之后尚未持久化的数据。
一旦数据写入(or not)了WAL,它也会被放入Memstore。与此同时,还会检查Memstore是否满了,如果满了需要产生一个flush请求。该请求由HRegionServer的单独的线程进行处理,该线程会把数据写入到位于HDFS上的新HFile里。同时它也会保存最后写入的序列号,这样系统就知道目前为止持久化到哪了。
HBase在HDFS上有一个可配置的根目录,默认设置为”/hbase”。 the section called “Co-Existing Clusters”说明了在共享HDFS集群时如何换用另一个根目录。可以使用hadoop dfs -lsr命令来查看HBase存储的各种文件。在此之前,我们先创建并填写一个具有几个regions的table:
hbase(main):001:0>create ‘testtable’, ‘colfam1’, \
{ SPLITS => [‘row-300’, ‘row-500’, ‘row-700’ , ‘row-900’] }
0 row(s) in 0.1910 seconds
hbase(main):002:0>
for i in ‘0’..’9′ do for j in ‘0’..’9′ do \
for k in ‘0’..’9′ do put ‘testtable’, "row-#{i}#{j}#{k}", \
"colfam1:#{j}#{k}", "#{j}#{k}" end end end
0 row(s) in 1.0710 seconds0 row(s) in 0.0280 seconds0 row(s) in 0.0260 seconds…hbase(main):003:0>
flush ‘testtable’
0 row(s) in 0.3310 seconds
hbase(main):004:0>
for i in ‘0’..’9′ do for j in ‘0’..’9′ do \
for k in ‘0’..’9′ do put ‘testtable’, "row-#{i}#{j}#{k}", \
"colfam1:#{j}#{k}", "#{j}#{k}" end end end
0 row(s) in 1.0710 seconds0 row(s) in 0.0280 seconds0 row(s) in 0.0260 seconds…
Flush命令会将内存数据写入存储文件,否则我们必须等着它直到超过配置的flush大小才会将数据插入到存储文件中。最后一轮的put命令循环是为了再次填充write-ahead log。
下面是上述操作完成之后,HBase根目录下的内容:
$
$HADOOP_HOME/bin/hadoop dfs -lsr /hbase
… 0 /hbase/.logs 0 /hbase/.logs/foo.internal,60020,1309812147645 0 /hbase/.logs/foo.internal,60020,1309812147645/ \foo.internal%2C60020%2C1309812147645.1309812151180 0 /hbase/.oldlogs 38 /hbase/hbase.id 3 /hbase/hbase.version 0 /hbase/testtable 487 /hbase/testtable/.tableinfo 0 /hbase/testtable/.tmp 0 /hbase/testtable/1d562c9c4d3b8810b3dbeb21f5746855 0 /hbase/testtable/1d562c9c4d3b8810b3dbeb21f5746855/.oldlogs 124 /hbase/testtable/1d562c9c4d3b8810b3dbeb21f5746855/.oldlogs/ \hlog.1309812163957 282 /hbase/testtable/1d562c9c4d3b8810b3dbeb21f5746855/.regioninfo 0 /hbase/testtable/1d562c9c4d3b8810b3dbeb21f5746855/.tmp 0 /hbase/testtable/1d562c9c4d3b8810b3dbeb21f5746855/colfam1 11773 /hbase/testtable/1d562c9c4d3b8810b3dbeb21f5746855/colfam1/ \646297264540129145 0 /hbase/testtable/66b4d2adcc25f1643da5e6260c7f7b26 311 /hbase/testtable/66b4d2adcc25f1643da5e6260c7f7b26/.regioninfo 0 /hbase/testtable/66b4d2adcc25f1643da5e6260c7f7b26/.tmp 0 /hbase/testtable/66b4d2adcc25f1643da5e6260c7f7b26/colfam1 7973 /hbase/testtable/66b4d2adcc25f1643da5e6260c7f7b26/colfam1/ \3673316899703710654 0 /hbase/testtable/99c0716d66e536d927b479af4502bc91 297 /hbase/testtable/99c0716d66e536d927b479af4502bc91/.regioninfo 0 /hbase/testtable/99c0716d66e536d927b479af4502bc91/.tmp 0 /hbase/testtable/99c0716d66e536d927b479af4502bc91/colfam1 4173 /hbase/testtable/99c0716d66e536d927b479af4502bc91/colfam1/ \1337830525545548148 0 /hbase/testtable/d240e0e57dcf4a7e11f4c0b106a33827 311 /hbase/testtable/d240e0e57dcf4a7e11f4c0b106a33827/.regioninfo 0 /hbase/testtable/d240e0e57dcf4a7e11f4c0b106a33827/.tmp 0 /hbase/testtable/d240e0e57dcf4a7e11f4c0b106a33827/colfam1 7973 /hbase/testtable/d240e0e57dcf4a7e11f4c0b106a33827/colfam1/ \316417188262456922 0 /hbase/testtable/d9ffc3a5cd016ae58e23d7a6cb937949 311 /hbase/testtable/d9ffc3a5cd016ae58e23d7a6cb937949/.regioninfo 0 /hbase/testtable/d9ffc3a5cd016ae58e23d7a6cb937949/.tmp 0 /hbase/testtable/d9ffc3a5cd016ae58e23d7a6cb937949/colfam1 7973 /hbase/testtable/d9ffc3a5cd016ae58e23d7a6cb937949/colfam1/ \4238940159225512178
注:由于空间的限制,我们对输出内容进行了删减,只留下了文件大小和名称部分。你自己在集群上运行命令时可以看到更多的细节信息。
文件可以分成两类:一是直接位于HBase根目录下面的那些,还有就是位于table目录下面的那些。
第一类文件是由HLog实例处理的write-ahead log文件,这些文件创建在HBase根目录下一个称为.logs的目录。Logs目录下包含针对每个HRegionServer的子目录。在每个子目录下,通常有几个HLog文件(因为log的切换而产生)。来自相同region server的regions共享同一系列的HLog文件。
一个有趣的现象是log file大小被报告为0。对于最近创建的文件通常都是这样的,因为HDFS正使用一个内建的append支持来对文件进行写入,同时只有那些完整的blocks对于读取者来说才是可用的—包括hadoop dfs -lsr命令。尽管put操作的数据被安全地持久化,但是当前被写入的log文件大小信息有些轻微的脱节。
等一个小时log文件切换后,这个时间是由配置项:hbase.regionserver.logroll.period控制的(默认设置是60分钟),你就能看到现有的log文件的正确大小了,因为它已经被关闭了,而且HDFS可以拿到正确的状态了。而在它之后的那个新log文件大小又变成0了:
249962 /hbase/.logs/foo.internal,60020,1309812147645/ \foo.internal%2C60020%2C1309812147645.1309812151180 0 /hbase/.logs/foo.internal,60020,1309812147645/ \foo.internal%2C60020%2C1309812147645.1309815751223
当日志文件不再需要时,因为现有的变更已经持久化到存储文件中了,它们就会被移到HBase根目录下的.oldlogs目录下。这是在log文件达到上面的切换阈值时触发的。老的日志文件默认会在十分钟后被master删除,通过hbase.master.logcleaner.ttl设定。Master默认每分钟会对这些文件进行检查,可以通过hbase.master.cleaner.interval设定。
hbase.id和hbase.version文件包含集群的唯一ID和文件格式版本号:
$
hadoop dfs -cat /hbase/hbase.id
$e627e130-0ae2-448d-8bb5-117a8af06e97
$
hadoop dfs -cat /hbase/hbase.version7
它们通常是在内部使用因此通常不用关心这两个值。此外,随着时间的推进还会产生一些root级的目录。splitlog和.corrupt目录分别是log split进程用来存储中间split文件的和损坏的日志文件的。比如:
0 /hbase/.corrupt 0 /hbase/splitlog/foo.internal,60020,1309851880898_hdfs%3A%2F%2F \localhost%2Fhbase%2F.logs%2Ffoo.internal%2C60020%2C1309850971208%2F \foo.internal%252C60020%252C1309850971208.1309851641956/testtable/ \d9ffc3a5cd016ae58e23d7a6cb937949/recovered.edits/0000000000000002352
上面的例子中没有损坏的日志文件,只有一个分阶段的split文件。关于log splitting过程参见the section called “Replay”。
HBase中的每个table都有它自己的目录,位于HBase根目录之下。每个table目录包含一个名为.tableinfo的顶层文件,该文件保存了针对该table的HTableDescriptor(具体细节参见the section called “Tables”)的序列化后的内容。包含了table和column family schema信息,同时可以被读取,比如通过使用工具可以查看表的定义。.tmp目录包含一些中间数据,比如当.tableinfo被更新时该目录就会被用到。
在每个table目录内,针对表的schema中的每个column family会有一个单独的目录。目录名称还包含region name的MD5 hash部分。比如通过master的web UI,点击testtable链接后,其中User Tables片段的内容如下:
testtable,row-500,1309812163930.d9ffc3a5cd016ae58e23d7a6cb937949.
MD5 hash部分是”d9ffc3a5cd016ae58e23d7a6cb937949”,它是通过对region name的剩余部分进行编码生成的。比如”testtable,row-500,1309812163930”。尾部的点是整个region name的一部分:它表示这是一种包含hash的新风格的名称。在HBase之前的版本中,region name中并不包含hash。
注:需要注意的是-ROOT-和.META.元数据表仍然采用老风格的格式,比如它们的region name不包含hash,因此结尾就没有那个点。
.META.,,1.1028785192
对于存储在磁盘上的目录中的region names编码方式也是不同的:它们使用Jenkins hash来对region name编码。
Hash是用来保证region name总是合法的,根据文件系统的规则:它们不能包含任何特殊字符,比如”/”,它是用来分隔路径的。这样整个的region文件路径就是如下形式:
/<hbase-root-dir>/<tablename>/<encoded-regionname>/<column-family>/<filename>
在每个column-family下可以看到实际的数据文件。文件的名字是基于Java内建的随机数生成器产生的任意数字。代码会保证不会产生碰撞,比如当发现新生成的数字已经存在时,它会继续寻找一个未被使用的数字。
Region目录也包含一个.regioninfo文件,包含了对应的region的HRegionInfo的序列化信息。类似于.tableinfo,它也可以通过外部工具来查看关于region的相关信息。hbase hbck工具可以用它来生成丢失的table条目元数据。
可选的.tmp目录是按需创建地,用来存放临时文件,比如某个compaction产生的重新写回的文件。一旦该过程结束,它们会被立即移入region目录。在极端情况下,你可能能看到一些残留文件,在region重新打开时它们会被清除。
在write-ahead log replay期间,任何尚未提交的修改会写入到每个region各自对应的文件中。这是阶段1(看下the section called “Root Level Files”中的splitlog目录),之后假设log splitting过程成功完成-然后会将这些文件原子性地move到recovered.edits目录下。当该region被打开时,region server能够看到这些recovery文件然后replay相应的记录。
Split vs. Split
在write-ahead log的splitting和regions的splitting之间有明显的区别。有时候,在文件系统中很难区分文件和目录的不同,因为它们两个都涉及到了splits这个名词。为避免错误和混淆,确保你已经理解了二者的不同。
一旦一个region因为大小原因而需要split,一个与之对应的splits目录就会创建出来,用来筹划产生两个子regions。如果这个过程成功了—通常只需要几秒钟或更少—之后它们会被移入table目录下用来形成两个新的regions,每个代表原始region的一半。
换句话说,当你发现一个region目录下没有.tmp目录,那么说明目前它上面没有compaction在执行。如果也没有recovered.edits目录,那么说明目前没有针对它的write-ahead log replay。
注:在HBase 0.90.x版本之前,还有一些额外的文件,目前已被废弃了。其中一个是oldlogfile.log,该文件包含了对于相应的region已经replay过的write-ahead log edits。oldlogfile.log.old(加上一个.old扩展名)表明在将新的log文件放到该位置时,已经存在一个oldlogfile.log。另一个值得注意的是在老版HBase中的compaction.dir,现在已经被.tmp目录替换。
本节总结了下HBase根目录下的各种目录所包含的一系列内容。有很多是由region split过程产生的中间文件。在下一节里我们会分别讨论。
当一个region内的存储文件大于hbase.hregion.max.filesize(也可能是在column family级别上配置的)的大小时,该region就需要split为两个。起始过程很快就完成了,因为系统只是简单地为新regions(也称为daughters)创建两个引用文件,每个只持有原始region的一半内容。
Region server通过在parent region内创建splits目录来完成。之后,它会关闭该region这样它就不再接受任何请求。
Region server然后开始准备生成新的子regions(使用多线程),通过在splits目录内设置必要的文件结构。里面包括新的region目录及引用文件。如果该过程成功完成,它就会把两个新的region目录移到table目录下。.META.table会进行更新,指明该region已经被split,以及子regions分别是谁。这就避免了它被意外的重新打开。实例如下:
ow: testtable,row-500,1309812163930.d9ffc3a5cd016ae58e23d7a6cb937949. column=info:regioninfo, timestamp=1309872211559, value=REGION => {NAME => \ ‘testtable,row-500,1309812163930.d9ffc3a5cd016ae58e23d7a6cb937949. \ TableName => ‘testtable’, STARTKEY => ‘row-500’, ENDKEY => ‘row-700’, \ ENCODED => d9ffc3a5cd016ae58e23d7a6cb937949, OFFLINE => true, SPLIT => true,} column=info:splitA, timestamp=1309872211559, value=REGION => {NAME => \ ‘testtable,row-500,1309872211320.d5a127167c6e2dc5106f066cc84506f8. \ TableName => ‘testtable’, STARTKEY => ‘row-500’, ENDKEY => ‘row-550’, \ ENCODED => d5a127167c6e2dc5106f066cc84506f8,} column=info:splitB, timestamp=1309872211559, value=REGION => {NAME => \ ‘testtable,row-550,1309872211320.de27e14ffc1f3fff65ce424fcf14ae42. \ TableName => [B@62892cc5’, STARTKEY => ‘row-550’, ENDKEY => ‘row-700’, \ ENCODED => de27e14ffc1f3fff65ce424fcf14ae42,}
可以看到原始的region在”row-550”处被分成了两个regions。在info:regioninfo中的”SPLIT=>true”表面该region目前已经分成了两个regions:splitA和splitB。
引用文件的名称是另一个随机数,但是会使用它所引用的region的hash作为后缀,比如:
/hbase/testtable/d5a127167c6e2dc5106f066cc84506f8/colfam1/ \6630747383202842155.d9ffc3a5cd016ae58e23d7a6cb937949
该引用文件代表了hash值为” d9ffc3a5cd016ae58e23d7a6cb937949”的原始region的一半内容。引用文件仅仅有很少量的信息:原始region split点的key,引用的是前半还是后半部分。这些引用文件会通过HalfHFileReader类来读取原始region的数据文件。
现在两个子regions已经就绪,同时将会被同一个服务器并行打开。现在需要更新.META.table,将这两个regions作为可用region对待—看起来就像是完全独立的一样。同时会启动对这两个regions的compaction—此时会异步地将存储文件从原始region真正地写成两半,来取代引用文件。这些都发生在子regions的.tmp目录下。一旦文件生成完毕,它们就会原子性地替换掉之前的引用文件。
原始region最终会被清除,意味着它会从.META.table中删除,它的所有磁盘上的文件也会被删除。最后,master会收到关于该split的通知,它可以因负载平衡等原因将这些新的regions移动到其他服务器上。
ZooKeeper支持
Split中的所有相关步骤都会通过Zookeeper进行追踪。这就允许在服务器出错时,其他进程可以知晓该region的状态。
存储文件处于严密的监控之下,这样后台进程就可以保证它们完全处于控制之中。Memstores的flush操作会逐步的增加磁盘上的文件数目。当数目足够多的时候,compaction进程会将它们合并成更少但是更大的一些文件。当这些文件中的最大的那个超过设置的最大存储文件大小时会触发一个region split过程。(see the section called “Region Splits”).
有两种类型的Compactions:minor和major。Minor compaction负责将一些小文件合并成更大的一个文件。合并的文件数通过hbase.hstore.compaction.min属性进行设置(以前该参数叫做hbase.hstore.compactionThreshold,尽管被弃用了但是目前还支持该参数)。默认该参数设为3,同时该参数必须>=2。如果设得更大点,会延迟minor compaction的发生,但是一旦它启动也会需要更多的资源和更长的时间。一个minor compaction所包含的最大的文件数被设定为10,可以通过hbase.hstore.compaction.max进行配置。
可以通过设置hbase.hstore.compaction.min.size(设定为该region的对应的memstore的flush size)和hbase.hstore.compaction.max.size(默认是Long.MAX_VALUE)来减少需要进行minor compaction的文件列表。任何大于最大的compaction size的文件都会被排除在外。最小的compaction size是作为一个阈值而不是一个限制,也就是说在达到单次compaction允许的文件数上限之前,那些小于该阈值的文件都会被包含在内。
图8.4展示了一个存储文件集合的实例。所有那些小于最小的compaction阈值的文件都被包含进了compaction中。
Figure 8.4. A set of store files showing the minimum compaction threshold
该算法会使用hbase.hstore.compaction.ratio (defaults to 1.2, or 120%)来确保总是能够选出足够的文件来进行compaction。根据该ratio,那些大小大于所有新于它的文件大小之和的文件也能够被选入。计算时,总是根据文件年龄从老到新进行选择,以保证老文件会先被compacted。通过上述一系列compaction相关的参数可以用来控制一次minor compaction到底选入多少个文件。
HBase支持的另外一种compaction是major compaction:它会将所有的文件compact成一个。该过程的运行是通过执行compaction检查自动确定的。当memstore被flush到磁盘,执行了compact或者major_compact命令或者产生了相关API调用时,或者后台线程的运行,就会触发该检查。Region server会通过CompactionChecker类实现来运行该线程。
如果用户调用了major_compact命令或者majorCompact()API调用,都会强制major compaction运行。否则,服务端会首先检查是否该进行major compaction,通过查看距离上次运行是否满足一定时间,比如是否达到24小时。
实际的文件存储是通过HFile类实现的,它的产生只有一个目的:高效存储HBase数据。它基于Hadoop的TFile类,模仿了Google的Bigtable架构中使用的SSTable格式。之前HBase采用的是Hadoop MapFile类,实践证明性能不够高。图8展示了具体的文件格式:
Figure 8.5. The HFile structure
文件是变长的,定长的块只有file info和trailer这两部分。如图所示,trailer中包含指向其他blocks的指针。Trailer会被写入到文件的末尾。Index blocks记录了data和meta blocks的偏移。data和meta blocks实际上都是可选部分。但是考虑到HBase使用数据文件的方式,通常至少可以在文件中找到data blocks。
Block 大小是通过HColumnDescriptor配置的,而它是在table创建时由用户指定的,或者是采用了默认的标准值。实例如下:
{NAME => ‘testtable’, FAMILIES => [{NAME => ‘colfam1’, BLOOMFILTER => ‘NONE’, REPLICATION_SCOPE => ‘0’, VERSIONS => ‘3’, COMPRESSION \=> ‘NONE’, TTL => ‘2147483647’, BLOCKSIZE => ‘65536’, IN_MEMORY => ‘false’, BLOCKCACHE => ‘true’}]}
Block大小默认是64KB(or 65535 bytes)。下面是HFile JavaDoc中的注释:
“Minimum block size。通常的使用情况下,我们推荐将最小的block大小设为8KB到1MB。如果文件主要用于顺序访问,应该用大一点的block大小。但是,这会导致低效的随机访问(因为有更多的数据需要进行解压)。对于随机访问来说,小一点的block大小会好些,但是这可能需要更多的内存来保存block index,同时可能在创建文件时会变慢(因为我们必须针对每个data block进行压缩器的flush)。另外,由于压缩编码器的内部缓存机制的影响,最小可能的block大小大概是20KB-30KB”。
每个block包含一个magic头,一系列序列化的KeyValue实例(具体格式参见 the section called “KeyValue Format” )。在没有使用压缩算法的情况下,每个block的大小大概就等于配置的block size。并不是严格等于,因为writer需要放下用户给的任何大小数据{!如配置的block size可能是64KB,但是用户给了一条1MB的记录,writer也得接受它}。即使是对于比较小的值,对于block size大小的检查也是在最后一个value写入后才进行的{!不是写入前检查,而是写入后检查},所以实际上大部分blocks大小都会比配置的大一些。另一方面,这样做也没什么坏处。
在使用压缩算法的时候,对block大小就更没法控制了。如果压缩编码器可以自行选择压缩的数据大小,它可能能获取更好的压缩率。比如将block size设为256KB,使用LZO压缩,为了适应于LZO内部buffer大小,它仍然可能写出比较小的blocks。
Writer并不知道用户是否选择了一个压缩算法:它只是对原始数据按照设定的block大小限制控制写出。如果使用了压缩,那么实际存储的数据会更小。这意味着对于最终的存储文件来说与不进行压缩时的block数量是相同的,但是总大小要小,因为每个block都变小了。
你可能还注意到一个问题:默认的HDFS block大小是64MB,是HFile默认的block大小的1000倍。这样,HBase存储文件块与Hadoop的块并不匹配。实际上,两者之间根本没有关系。HBase是将它的文件透明地存储到文件系统中的,只是HDFS也恰巧有一个blocks。HDFS本身并不知道HBase存储了什么,它看到的只是二进制文件。图8.6展示了HFile内容如何散布在HDFS blocks上。
Figure 8.6. The many smaller HFile blocks are transparently stored in two much larger HDFS blocks
有时候需要绕过HBase直接访问HFile,比如健康检查,dump文件内容。HFile.main()提供了一些工具来完成这些事情:
$
./bin/hbase org.apache.hadoop.hbase.io.hfile.HFile
usage: HFile [-a] [-b] [-e] [-f <arg>] [-k] [-m] [-p] [-r <arg>] [-v] -a,–checkfamily Enable family check -b,–printblocks Print block index meta data -e,–printkey Print keys -f,–file <arg> File to scan. Pass full-path; e.g. hdfs://a:9000/hbase/.META./12/34 -k,–checkrow Enable row order check; looks for out-of-order keys -m,–printmeta Print meta data of file -p,–printkv Print key/value pairs -r,–region <arg> Region to scan. Pass region name; e.g. ‘.META.,,1’ -v,–verbose Verbose output; emits file and meta data delimiters
Here is an example of what the output will look like (shortened):
$
./bin/hbase org.apache.hadoop.hbase.io.hfile.HFile -f \
/hbase/testtable/de27e14ffc1f3fff65ce424fcf14ae42/colfam1/2518469459313898451 \
-v -m -p
Scanning -> /hbase/testtable/de27e14ffc1f3fff65ce424fcf14ae42/colfam1/2518469459313898451K: row-550/colfam1:50/1309813948188/Put/vlen=2 V: 50K: row-550/colfam1:50/1309812287166/Put/vlen=2 V: 50K: row-551/colfam1:51/1309813948222/Put/vlen=2 V: 51K: row-551/colfam1:51/1309812287200/Put/vlen=2 V: 51K: row-552/colfam1:52/1309813948256/Put/vlen=2 V: 52…K: row-698/colfam1:98/1309813953680/Put/vlen=2 V: 98K: row-698/colfam1:98/1309812292594/Put/vlen=2 V: 98K: row-699/colfam1:99/1309813953720/Put/vlen=2 V: 99K: row-699/colfam1:99/1309812292635/Put/vlen=2 V: 99Scanned kv count -> 300Block index size as per heapsize: 208reader=/hbase/testtable/de27e14ffc1f3fff65ce424fcf14ae42/colfam1/ \2518469459313898451, compression=none, inMemory=false, \firstKey=row-550/colfam1:50/1309813948188/Put, \lastKey=row-699/colfam1:99/1309812292635/Put, avgKeyLen=28, avgValueLen=2, \entries=300, length=11773fileinfoOffset=11408, dataIndexOffset=11664, dataIndexCount=1, \metaIndexOffset=0, metaIndexCount=0, totalBytes=11408, entryCount=300, \version=1Fileinfo:MAJOR_COMPACTION_KEY = \xFFMAX_SEQ_ID_KEY = 2020TIMERANGE = 1309812287166….1309813953720hfile.AVG_KEY_LEN = 28hfile.AVG_VALUE_LEN = 2hfile.COMPARATOR = org.apache.hadoop.hbase.KeyValue$KeyComparatorhfile.LASTKEY = \x00\x07row-699\x07colfam199\x00\x00\x010\xF6\xE5|\x1B\x04Could not get bloom data from meta block
第一部分是序列化的KeyValue实例的实际数据。第二部分除了trailer block的细节信息外,还dump出了内部的HFile.Reader属性。最后一部分,以”FileInfo”开头的,是file info block的值。
提供的这些信息是很有价值的,比如可以确定一个文件是否进行了压缩,采用的压缩方式。它也能告诉用户存储了多少个cell,key和value的平均大小是多少。在上面的例子中,key的长度比value的长度大很多。这是由于KeyValue类存储了很多额外数据,下面会进行解释。
实际上HFile中的每个KeyValue就是一个简单的允许对内部数据进行zero-copy访问的底层字节数组,包含部分必要的解析。图8.7展示了内部的数据格式。
Figure 8.7. The KeyValue format
该结构以两个标识了key和value部分的大小的定长整数开始。通过该信息就可以在数组内进行一些操作,比如忽略key而直接访问value。如果要访问key部分就需要进一步的信息。一旦解析成一个KeyValue Java实例,用户就可以对内部细节信息进行访问,参见the section called “The KeyValue Class”。
在上面的例子中key之所以比value长,就是由于key所包含的这些fields造成的:它包含一个cell的完整的各个维度上的信息:row key,column family name,column qualifier等等。在处理小的value值时,要尽量让key很小。选择一个短的row和column key(1字节family name,同时qualifier也要短)来控制二者的大小比例。
另一方面,压缩也有助于缓解这种问题。因为在有限的数据窗口内,如果包含的都是很多重复性的数据那么压缩率会比较高。同时因为存储文件中的KeyValue都是排好序的,这样就可以让类似的key靠在一起(在使用多版本的情况下,value也是这样的,多个版本的value也会是比较类似的)。