HBase权威指南

978-7-115-31889-3
作者: 【美】Lars George
译者: 代志远刘佳蒋杰
编辑: 杨海玲赵越
分类: HBase

图书目录:

详情

探讨了与Hadoop的高度集成如何使HBase的可伸缩性变得简单;把大型数据集分布到相对廉价的商业服务器集群中;使用本地Java客户端,或者通过提供了REST、Avro和Thrift应用编程接口的网关服务器来访问HBase;了解HBase架构的细节,包括存储格式、预写日志、后台进程等;在HBase中集成MapReduce框架;了解如何调节集群、设计模式、拷贝表、导入批量数据和删除节点等。

图书摘要

版权信息

书名:HBase权威指南

ISBN:978-7-115-31889-3

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美] Lars George

  译    代志远 刘 佳 蒋 杰

  责任编辑 杨海玲

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


Copyright ©2011 by O’Reilly Media, Inc.

Simplified Chinese Edition, jointly published by O’Reilly Media, Inc. and Posts & Telecom Press, 2011. Authorized translation of the English edition, 2011 O’Reilly Media, Inc., the owner of all rights to publish and sell the same.

All rights reserved including the rights of reproduction in whole or in part in any form.

本书中文简体版由O’Reilly Media, Inc.授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式复制或抄袭。

版权所有,侵权必究。


本书探讨了如何通过使用与HBase高度集成的Hadoop将HBase的可伸缩性变得简单;把大型数据集分布到相对廉价的商业服务器集群中;使用本地Java客户端,或者通过提供了REST、Avro和Thrift应用编程接口的网关服务器来访问HBase;了解HBase架构的细节,包括存储格式、预写日志、后台进程等;在HBase中集成MapReduce框架;了解如何调节集群、设计模式、拷贝表、导入批量数据和删除节点等。

本书适合使用HBase进行数据库开发的高级数据库研发人员阅读。


O’Reilly Media通过图书、杂志、在线服务、调查研究和会议等方式传播创新知识。自1978年开始,O’Reilly一直都是前沿发展的见证者和推动者。超级极客们正在开创着未来,而我们关注真正重要的技术趋势——通过放大那些“细微的信号”来刺激社会对新科技的应用。作为技术社区中活跃的参与者,O’Reilly的发展充满了对创新的倡导、创造和发扬光大。

O’Reilly为软件开发人员带来革命性的“动物书”;创建第一个商业网站(GNN);组织了影响深远的开放源代码峰会,以至于开源软件运动以此命名,创立了Make杂志,从而成为DIY革命的主要先锋;公司一如既往地通过多种形式缔结信息与人的纽带。O’Reilly的会议和峰会集聚了众多超级极客和高瞻远瞩的商业领袖,共同描绘出开创新产业的革命性思想。作为技术人士获取信息的选择,O’Reilly现在还将先峰专家的知识传递给普通的计算机用户。无论是通过书籍出版,在线服务或者面授课程,每一项O’Reilly的产品都反映了公司不可动摇的理念——信息是激发创新的力量。

业界评论

“O’Reilly Radar博客有口皆碑。”

——Wired

“O’Reilly凭借一系列(真希望当初我也想到了)非凡想法建立了数百万美元的业务。”

——Busincss 20

“O’Reilly Conference是聚集关键思想领袖的绝对典范。”

——CRN

“一本O’Reilly的书就代表一个有用、有前途、需要学习的主题。”

——Irish Times

“Tim是位特立独行的商人,他不光放眼于最长远,最广阔的视野并且切实地按照Yogi Berra的键议去做了‘如果你在路上遇到岔路口,走小路(岔路)。’回顾过去Tim似乎每一次都选择了小路,而且有几次都是一闪即逝的机会,尽管大路也不错。”

——Linux Journal


近年来,新兴的互联网服务领域,以及电信、金融和交通等各传统行业出现了数据资产的爆炸性增长,这些数据资产的类型以非结构化和半结构化为主,如何低成本且高效率地存储和处理PB甚至EB量级的数据成为了极大的挑战。

Google公司提出的MapReduce编程框架、GFS文件系统和BigTable存储系统成为了大数据处理技术的开拓者和领导者,而源于这三项技术的Apache Hadoop等开源项目则成为了大数据处理技术的事实标准,迅速推广至国内外各大互联网企业,成为了PB量级大数据处理的成熟技术和系统。面对不同的应用需求,基于Hadoop的数据处理工具也应运而生,例如,Hive、Pig等已能够很好地解决大规模数据的离线式批量处理问题。但是,Hadoop HDFS适合于存储非结构化数据,且受限于HadoopMapReduce编程框架的高延迟数据处理机制,使得Hadoop无法满足大规模数据实时处理应用的需求。

传统的信息系统和Web应用大多采用LAMP架构构建,并使用关系型数据库存储、组织和管理结构化或半结构化数据。通用的关系型数据库无法很好地应对在数据规模剧增时导致的系统扩展性和性能问题。因此,业界出现了一类面向半结构化数据存储和处理的高可扩展、低写入/查询延迟的系统,例如,键值存储系统、文档存储系统和类BigTable存储系统等,这些特性各异的系统也可统称为NoSQL系统。Apache HBase就是其中已迈向实用的成熟系统之一。HBase之所以能成为迈向实用的成熟系统,一是核心思想来源于Google的BigTable,二是有Apache及Hadoop开源社区的支撑,三是有诸如Facebook、淘宝和支付宝等互联网公司的应用实践,保证了HBase系统的稳定性和可用性。目前,作为关系型数据库的有益补充,HBase已成功应用于互联网服务领域和传统行业的众多在线式数据分析处理系统中。

本书涉及HBase使用和开发过程中的各方面内容,章节组织由浅入深,内容阐述细致入微并且贴近实际,可以作为参考书以方便读者在开发过程中随时查阅。本书译者之一刘佳向HBase开源社区提交过多项错误修复和新功能,参与过多项HBase有关的大数据分析系统研发项目,积累了丰富的HBase系统开发经验。我相信本书对于HBase使用者和开发者来说,都是及时和不可或缺的。

查礼

于中科院计算所

2013年7月


随着历史数据的积累和数据量的高速增长,海量数据领域越来越被重视,且该领域涌现出了非常多的新技术。技术的发展和时间的沉淀使得HBase开始被大家广泛认可,成为海量数据在线存储领域的首选。

本书是讲述HBase相关技术的第一本图书,也是著名图书出版商O’Reilly出版发行的HBase权威书籍。

本书从架构、开发、应用和运维等多个角度描述了HBase,深入介绍了HBase内核的原理和机制以及社区的发展方向,并提供了应用层面的多种示例和源代码。本书为每个用例和知识点提供了丰富的解释和注意要点,使用户可以由浅入深地了解原理并深度使用其功能,并且体现了在HBase教学方面的最新进展和最高水平。

本书的成功离不开Lars George的努力。在HBase还处于萌芽时期时,Lars George就开始投入了大量的精力,从修复HBase中的问题到优化性能,推广HBase并编写HBase可用性文档,他是HBase领域里大师级的人物。而这本《HBase权威指南》花费了Lars George许多的时间和精力。

阅读本书后,我们不得不承认这本大师级的著作很好地应对了社区中HBase发展所面临的挑战。不得不说的是,本书著作和翻译经历的时间较长,而社区中HBase发展速度较快,许多版本已经发行,许多问题也得以修复,因此,本书最终落地后会与最新HBase版本的功能特性有少许描述性出入,还望广大读者见谅。

在翻译过程中,我们深刻地发现国外技术领域的专业性,深深地被世界级的高水平技术所震撼。我们由衷地希望本书中文版的出版能够推动国内HBase教学、使用和发展。本书译者代志远在翻译期间就职于阿里巴巴,译者刘佳是中科院计算所研究生,现为普泽天玑技术总监,译者蒋杰在腾讯担任数据与运营支撑平台副总经理。

感谢人民邮电出版社的编辑,他们为保证本书质量付出了大量的努力。

本书中概念和术语较多,许多概念和术语尚无公认的中文译法,加之译者水平有限,译文中若有不妥之处,恳请读者批评指正。

代志远

2013年7月


HBase的故事开始于2006年,当时旧金山的Powerset创业公司试图建立一个网页的自然语言搜索引擎,但他们构建索引时涉及一个复杂的过程,比标准的分词索引结果集大了两个数量级。他们曾经使用Amazon Web Service存储索引,但是爬虫抓取过程中的负荷主要集中在此。(叮铃铃,叮铃铃“您好!这里是AWS,无论你正在运行什么,请停止运行!”)他们恰好在寻求解决方案,而此时Google的BigTable论文发表了。

Powerset公司的工程负责人Chad Walters此时发表了如下的言论:

与Google基于GFS(Google File System)构建的BigTable一样,在Hadoop的分布式文件系统(HDFS)基础上构建一个开源系统是一个非常不错的主意:(1)这套架构是成熟的并且可拓展;(2)我们可以直接利用Hadoop的HDFS;(3)我们可以扩大Hadoop生态系统的影响力。

BigTable论文发表后,在社区中,人们一次又一次地讨论基于Hadoop构建类BigTable系统的可行性。在2007年年初,Mike Cafarela出乎意料地在Hadoop的问题跟踪系统中上传了一个包含30多个Java文件的tar包:“我实现了一个类BigTable架构的存储系统demo,叫做HBase,虽然它还不完善,但是它已经做好准备让用户进行实验和检查了。”Mike与Doug Cutting在Nutch(一个开源搜索引擎)项目中长期共事,Doug Cutting在Nutch中实现了一个类似于Google分布式文件系统的项目来管理磁盘,因此Nutch中构建的索引存储可以不仅仅存储在一台机器中(Nutch分布式文件系统最后发展成为了HDFS)。

Powerset公司的Jim Kellerman增加了测试用例并填补了其他空白,使得HBase可以作为Hadoop的一部分代码进行提交。Doug Cutting在2007年4月3日完成了HBase的第一次代码提交,代码提交到了Hadoop工程根目录的contrib子目录中。HBase的第一个版本在2007年10月作为Hadoop 0.15.0的一部分发布了。

没过多久,本书作者Lars开始在#hbase IRC交流频道出现。当时Lars面临大数据的问题,并且尝试用HBase来解决这个问题。经过一番辛苦的摸索,Lars成为了Powerset之外的HBase的第一个用户。我清楚地记得,Lars当时记录了他在WorldLingo公司的生产集群的问题反馈清单,Lars当时在这家公司担任CTO。清单展示了他们的生产集群中HBase的10个版本(从Hadoop 0.15.1到HBase 0.20),每个版本的集群都有将近40台机器。

在这些年来所有为HBase做出贡献的人中,具有史诗般意义的就是Lars,因为他写了这本书。Lars一直在为HBase贡献文档,HBase想要被更好地使用和推广,就需要有良好的文档。每个人都同意Lars的想法,并且能够专注地投入编程工作中,因此Lars在工作和欧洲旅行期间开始编写如何使用HBase的文档和架构描述,并承担起了HBase非官方的欧洲大使职责。Lars在其关于HBase的博客(http://www.larsgeorage.com)中记录了HBase的工作原理,并在关键阶段推动了HBase社区的发展(一篇重要的博客文章解释了HBase依赖Ivy进行编译是个非常棒的主意)。

在微软公司赞助HBase的时期,HBase也发生了非常有趣的事情。Powerset在2008年7月被微软收购,在此期间其员工不允许贡献代码,因为微软法务部门需要审核HBase代码库并查看HBase与SQLServer的关系,直到一个月后才宣布重新贡献代码给社区(我是微软的一名员工,全职为Apache开源项目工作)。之后Facebook也开始使用HBase,用于存储海量的邮件信息或点击信息,后来Yahoo部署了1000台HBase集群用于定位微软Bing的爬虫快照。同期非运行在HDFS上的MapR系统也仍处在开发中。

我很清楚,社区和HBase的发展得利于一群HBase的核心committer的辛苦努力。一些核心开发成员,如Todd Lipcon、Gary Helmling和Nicolas Spiegelberg,已经付出了多年的努力,没有他们我们无法走到今天这一步,HBase目前已经从一个分支代码成长为了一个独立存储项目。Jonathan Gray冒险将其初创的streamy.com网站基于HBase进行建设,Andrew Purtell在趋势科技组建了一只HBase团队,Ryan Rawson得到了StumbleUpon的赞助,这是HBase在Powerset、微软之后获得的最主要的赞助,并且还发掘了一个非常厉害的commiter——John-Daniel Cryans,而当时Cryans还只是一个繁忙的学生。之后Lars不断地修复缺陷,并撰写文档。因此,Lars是撰写第一本关键的HBase书籍的最佳人选,也让所有人都可以了解HBase。

——Michael Stack,HBase项目管理人


你阅读本书的理由可能有很多。可能是因为听说了Hadoop,并了解到它能够在合理的时间范围内处理PB级的数据,在研读Hadoop的过程中发现了一个处理随机读写的系统,它叫做HBase。或者将其称为目前流行的一种新的数据存储架构,传统数据库解决大数据问题时成本更高,更适合的技术范围是NoSQL。

无论你是如何来到这里的,我都希望你能够了解并学习如何在企业或组织中使用HBase解决海量数据问题。你可能有关系型数据库的背景,但更希望去研究这个“列式存储”系统;也许你听说HBase能够不费力地进行线性拓展,并且有足够的理由成为下一代网络系统。

在2007年年底,我曾面临百万级的文档存储需求,并且需要满足容错和可扩展等要求。我拥有丰富的MySQL数据库经验,并使用这种数据库来存储数据,最终服务于我的网站的用户。MySQL可以在运行于一台服务器的同时,拥有另一台备份服务器,其无法应对如此海量数据的处理,于是我只好寻找其他可用的存储数据库。

我的口头禅是:“Google是如何解决这类问题的?”后来我接触了Hadoop,在短暂使用Hadoop之后,我面临随机读写的问题——但是这个问题已经得以解决:2006年Google发表了BigTable论文,Hadoop开发者拥有了BigTable的开源实现,并称其为HBase。这就是解决我的问题的答案,所以这一切看起来顺理成章……

如今,我已经不再回忆自己刚开始接触Hadoop和HBase的日子有多艰难了。我希望可以从今天开始使用HBase,HBase目前已经成熟,接近1.0版本,并且目前已经有大量知名企业在使用,如Facebook、Adobe、Twitter、Yahoo!、趋势科技和StumbleUpon(http://wiki.apache.org/hadoop/HBase/PoweredBy)。我的集群是第一个生产集群(迄今为止),到目前也遇到了许多有趣的问题。

如预期所料,HBase从0.1x版本开始成为社区项目,我有幸为这个项目贡献代码,并最终被要求成为全职的committer。

过去几年我从其他开发者身上学到了许多知识,并且一直在努力地学习。我的信念是,我们还远没有达到这个技术的顶峰,而这个技术也会随着时间的推移不断地成长和演变。让我们用这本书对整个HBase开发者社区致以敬意,我的写作目标不仅仅是覆盖HBase的工作机制,而且还要为用户提供如何将这一技术用到自己的使用场景中。

我强烈地感觉到你来到这里的原因是打算使用HBase解决你遇到的问题。现在让我们来解开谜底。

在我们开始之前,以下是一些基本介绍。

在写这本书时,HBase社区已经决定发布0.92.0版本,社区主干的代码已经开发完成 (http://svn.apache.org/viewvc/hbase/trunk/),之前刚刚发布了0.91.0- SNAPSHOT版本。

我们很难跟上开发的步伐,因为这本书有一个截止日期——在0.92.0版本发布之前。因此,它只能记录到一个特定的修订版1130916(http://svn.apache.org/viewvc/hbase/trunk/?pathrev=1130916)为止。如果你发现书中描述的和HBase提供的之间似乎并不匹配,你可以使用以上的版本与最新版本比对近期的修改。

我们正尽一切努力更新本书网站(http://www.hbasebook.com)上的JDiff(比较不同软件版本的工具)文档。使用它可以快速看到不同版本间有什么变化。

有关这本书的示例代码可以在GitHub中(http://github.com/larsgeorge/hbase-book)找到更详细的信息。为了简洁,本书只提供了部分代码片段,即重要代码,尽量避免出现重复的样板代码。

每个例子的名字都能与链接库中的文件名匹配上,因此很容易就能找到这些示例。每章都有独立目录,呈树形结构,更利于用户查找。例如,正在读第3章,你可以到对应目录下查找第3章完整的示例源代码。

许多示例中所示特性都使用了内部辅助类来协助运行。例如,使用了HBaseHelper类来建立测试环境和收集测试结果。你可以根据具体情况来修改测试代码,或植入错误数据以观察示例中所示特性的行为。

编译示例代码需要借助以下辅助的命令行工具。

Java

HBase是使用Java语言实现的系统,因此必然需要Java环境。2.2.3节的“Java”描述了应该如何安装Java环境。

Git

示例源代码是通过GitHub来提供托管服务,因此我们还需要支持Git——一个分布式版本协作控制系统,Linux内核开发最初就依托此系统做版本控制。开源社区提供了非常多的Git客户端二进制安装包。

此外,用户还可以通过GitHub提供的下载链接(https://github.com/larsgeorge/hbase-book/archives/master)下载文件的静态快照。

Maven

书中提供的代码编译过程是通过Apache Maven完成的。它使用Project Object Model(POM)来描述编译的过程,其中包括有哪些代码可以编译以及哪些不需要编译。因此读者需要首先下载Maven安装库并在本机上进行安装。

一旦读者按照上述信息安装好所需要的工具,就可以按照以下命令开始编译项目。

~$ cd /tmp
/tmp$ git clone git://github.com/larsgeorge/hbase-book.git
Initialized empty Git repository in /tmp/hbase-book/.git/
remote: Counting objects: 420, done.
remote: Compressing objects: 100% (252/252), done.
remote: Total 420 (delta 159), reused 144 (delta 58)
Receiving objects: 100% (420/420), 70.87 KiB, done.
Resolving deltas: 100% (159/159), done.
/tmp$ cd hbase-book/
/tmp/hbase-book$ mvn package
[INFO] Scanning for projects...
[INFO] Reactor build order: 
[INFO]    HBase Book
[INFO]    HBase Book Chapter 3
[INFO]    HBase Book Chapter 4
[INFO]    HBase Book Chapter 5
[INFO]    HBase Book Chapter 6
[INFO]    HBase Book Chapter 11
[INFO]    HBase URL Shortener
[INFO] ---------------------------------------------------------------
[INFO] Building HBase Book
[INFO]    task-segment: [package]
[INFO] ---------------------------------------------------------------
[INFO] [site:attach-descriptor {execution: default-attach-descriptor}]
[INFO] ---------------------------------------------------------------
[INFO] Building HBase Book Chapter 3
[INFO]    task-segment: [package]
[INFO] ---------------------------------------------------------------
[INFO] [resources:resources {execution: default-resources}]
...
[INFO] ---------------------------------------------------------------
[INFO] Reactor Summary:
[INFO] --------------------------------------------------------------
[INFO] HBase Book ................................... SUCCESS [1.601s]
[INFO]   HBase Book Chapter 3 ......................... SUCCESS [3.233s]
[INFO]  HBase Book Chapter 4 ......................... SUCCESS [0.589s]
[INFO]  HBase Book Chapter 5 ......................... SUCCESS [0.162s]
[INFO]  HBase Book Chapter 6 ......................... SUCCESS [1.354s]
[INFO]  HBase Book Chapter 11 ........................ SUCCESS [0.271s]
[INFO]  HBase URL Shortener .......................... SUCCESS [4.910s]
[INFO] --------------------------------------------------------------
[INFO] --------------------------------------------------------------
[INFO] BUILD SUCCESSFUL
[INFO] --------------------------------------------------------------
[INFO] Total time: 12 seconds
[INFO] Finished at: Mon Jun 20 17:08:30 CEST 2011
[INFO] Final Memory: 35M/81M
[INFO] --------------------------------------------------------------

这段信息意味着有依赖关系的库已经下载到了本地目录,源代码也随后编译成功了。在每个子目录的target文件夹下都留下了编译后的JAR文件,即每章的源代码和.class文件归档:

/tmp/hbase-book$ ls -l ch04/target/
total 152
drwxr-xr-x  48  larsgeorge  wheel   1632  Apr 15  10:31  classes
drwxr-xr-x   3  larsgeorge   wheel    102  Apr 15  10:31  generated-sources
-rw-r--r--   1  larsgeorge  wheel   75754  Apr 15  10:31  hbase-book-ch04-1.0.jar
drwxr-xr-x   3  larsgeorge   wheel    102  Apr 15  10:31  maven-archiver

上述场景中,hbase-book-ch04-1.0.jar文件包含了第4章的示例代码。我们可以使用如下命令进行测试:

/tmp/hbase-book$ cd ch04/
/tmp/hbase-book/ch04$ bin/run.sh client.PutExample
/tmp/hbase-book/ch04$ bin/run.sh client.GetExample
Value: val1

bin/run.sh已经配置了所需的Java classpath,并添加了依赖的JAR文件。

通过了解HBase提供的功能来了解HBase是一个好办法。本书使用了具有代表性的表的集合作为例子,并包含具体的数据,这样可以很容易地理解经过操作后数据状态前后改变的过程。读者可以执行每一个示例并查阅结果,该结果应该与书中提供的结果匹配。修改示例是进一步探讨和研究HBase的好方法,借助辅助类可以进行更有效的验证。

在前期的学习过程中,了解系统所有的功能是非常重要的一个环节,而这本书恰好提供了一个现实中的例子来展示HBase的大部分功能。同时这个例子也被用于与其他存储数据库进行对比,例如,与传统的基于RDBMS的系统进行对比。

这个应用的名称叫做Hush——HBase URL Shortener(HBase URL简写)。互联网提供了非常多的服务,每个服务都可以通过URL来访问。例如,你提交了一个网页,但你得到了一个返回的短网址。例如,Twitter每次只运行并发送最多140个字符,但URL最长可达到4096字节,因此不得不将URL简化到20字节以下,以节省更多的空间给实际内容。

例如,这是由Google 地图引用California的Sebastopol的URL:

http://maps.google.com/maps?f=q&source=s_q&hl=en&geocode=&q=Sebastopol, \ +CA,+United+States&aq=0&sll=47.85931,10.85165&sspn=0.93616,1.345825&ie=UTF8& \ hq=&hnear=Sebastopol,+Sonoma,+California&z=14 

经过Hush模式,其可以转化成如下URL:

http://hush.li/1337

显然,这个URL更短,更有利于复制到电子邮件,或通过限制字数的媒体(如Twitter和SMS)发送。

但是,这种服务并不仅仅是一张大的查询表。诚然,非常多的用户希望能够映射短网址到完整网址,但需求并不仅仅如此。用户更多的是想了解短网址究竟被使用了多少次?因此短网址就需要保留一个计数器,用于在用户点击网址的时候进行统计。

更高级的功能是,用户可以使用固定的域名或定制的短网址ID而不是自动生成的地址,就像上面例子描述的那样。用户必须能够登录短网址跟踪,并查看日、周、月报表。

所有的这些都在Hush模式中实现了,用户可以很容易地编译和运行。它使用了HBase的大多数功能,在适当的时机我们会讨论这些问题。

用户可以自己创建账号并使用Hush,这也是一个了解如何从已有系统导入数据的好例子。本书使用了网络上提供的数据集合:Delicious RSS feed。这里面有少量集合是可以随意下载的。

使用场景:Hush

留意本书中从Hush角度解释的功能。很多地方使用了Hush的示例代码,但是它仅仅保持在非常简单的展示这一层面。Hush作为一个使用场景更多的是应用在生产系统中。

Hush没有华丽的UI,只有朴实的功能,Hush在经过负载均衡后达到数千的TPS请求完全没有困难。

有关Hush如何运行的代码片段已经在本书中进行了描述,完整的代码可以从Git库中下载并独立运行、调整和学习有关它的一切!

使用示例代码编译和运行Hush非常简单,一旦用户可以克隆或下载,就执行以下命令:

$ mvn package

编译整个工程后,读者就可以使用以下命令来运行Hush:

$ hush/bin/start-hush.sh
=====================
 Starting Hush...
=====================
 INFO [main]  (HushMain.java:57) - Initializing HBase
 INFO [main]  (HushMain.java:60) - Creating/updating HBase schema
 ...
 INFO [main]  (HushMain.java:90) - Web server setup.
 INFO [main]  (HushMain.java:111)- Configuring security.
 INFO [main]  (Slf4jLog.java:55) - jetty-7.3.1.v20110307
 INFO [main]  (Slf4jLog.java:55) - started ... 
 INFO [main]  (Slf4jLog.java:55)  - Started SelectChannelConnector@0.0.0. 0:8080

看到控制台中的最后一行输出后,读者可以直接通过浏览器访问http://localhost:8080进入Hush服务器进行处理。

由于数据已经存储在了HBase中,因此用户可以放心地执行Ctrl+C来停止Hush的启动脚本。

本书使用如下排版约定。


本书的目标是帮助你完成工作。一般而言,你可以在自己的程序和文档中使用本书中的代码,如果你要复制的不是核心代码,则无须取得我们的许可。例如,你可以在程序中使用本书中的多个代码块,无须获取我们许可。但是,要销售或分发来源于O’Reilly图书中的示例的光盘则需要取得我们的许可。通过引用本书中的示例代码来回答问题时,不需要事先获得我们的许可。但是,如果你的产品文档中融合了本书中的大量示例代码,则需要取得我们的许可。

在引用本书中的代码示例时,如果能列出本书的属性信息是最好不过了。属性信息通常包括书名、作者、出版社和ISBN。例如:“HBase: The Definitive Guide by Lars George (O’Reilly). Copyright 2011 Lars George, 978-1-449-39610-7”。

在使用书中的代码时,如果不确定是否属于合理使用,或是否超出了我们的许可,请通过permissions@oreilly.com与我们联系。

如果你想就本书发表评论或有任何疑问,敬请联系出版社。

美国:

  O’Reilly Media Inc.

  1005 Gravenstein Highway North

  Sebastopol, CA 95472

中国:

  北京市西城区西直门南大街2号成铭大厦C座807室(100035)

  奥莱利技术咨询(北京)有限公司

我们还为本书建立了一个网页,其中包含了勘误表、示例和其他额外的信息。你可以通过如下地址访问该网页:

  http://www.oreilly.com/catalog/9781449396107

作者还为本书创建了一个网站:

  http://hbasebook.com/

关于本书的技术性问题或建议,请发邮件到:

  bookquestions@oreilly.com

欢迎登录我们的网站(http://www.oreilly.com),查看更多我们的书籍、课程、会议和最新动态等信息。

我们的其他联系方式如下。

  Facebook:http://facebook.com/oreilly

  Twitter:http://twitter.com/oreillymedia

  YouTube:http://www.youtube.com/oreillymedia

首先,我要感谢我已故的父亲Reiner和我的母亲Ingrid,他们一直支持我的理想,使我成为一个更优秀的人。

在写这本书时我获得了HBase社区的很多支持,没有这些支持,HBase不可能有今天的成功。现在遍布世界各地的核心公司向社区提交了非常多的代码,还有邮件列表中对许多问题的支持、博客文章,这些都促进了开源的发展。我是站在你们的肩上前进的!

感谢本书贡献者Jean-Daniel Cryans、Jonathan Gray、Gary Helmling、Todd Lipcon、Andrew Purtell、Ryan Rawson、Nicolas Spiegelberg、Michael Stack和Ted Yu,以及名誉贡献者:Mike Cafarella、Bryan Duxbury和Jim Kellerman。

还要感谢本书的审校者Patrick Angeles、Doug Balog、Jeff Bean、Po Cheung、Jean-Daniel Cryans、Lars Francke、Gary Helmling、Michael Katzenellenbogen、Mingjie Lai、Todd Lipcon、Ming Ma、Doris Maassen、Cameron Martin、Matt Massie、Doug Meil、Manuel Meßner、Claudia Nielsen、Joseph Pallas、Josh Patterson、Andrew Purtell、Tim Robertson、Paul Rogalinski、Stefan Rudnitzki、Eric Sammer、Michael Stack和Suraj Varma。

所有的HBase贡献者,请继续努力!

最后,还要感谢我的雇主——Cloudera公司,它为我提供了写这本书的时间。

见链接http://labs.google.com/papers/bigtable-osdi06.pdf

见网址http://git-scm.com/

见网址http://maven.apache.org/


在探究HBase的功能之前,我认为有必要先来思考一下为什么要设计出这样一套新的存储架构。传统的关系型数据库管理系统(Relational Database Management System,RDBMS)早在20世纪70年代已经出现,并且帮助无数的公司和机构实现了给定问题的解决方案,时至今日,RDBMS仍旧非常有用。很多情况下关系模型都能够非常完美地阐述问题,但是在面对一些特殊的场景时关系模型并不是最佳的解决方案。

我们生活在一个互联网时代,无论是想搜索最佳的火鸡菜谱,还是送妈妈什么样的生日礼物,都希望能够通过互联网迅速地检索到问题的答案,同时希望查询到的结果有用,而且非常切合我们的需要。

因此,很多公司开始致力于提供更有针对性的信息,例如推荐或在线广告,这种能力会直接影响公司在商业上的成败。现在类似Hadoop这样的系统能够为公司提供存储和处理PB级数据的能力,随着新机器学习算法的不断发展,收集更多数据的需求也在与日俱增。

以前,因为缺乏划算的方式来存储所有信息,很多公司会忽略某些数据源,但是现在这样的处理方式会让公司丧失竞争力。存储和分析每一个数据点的需求在不断增长,这种需求的增长直接导致各公司电子商务平台产生了更多的数据。

过去,唯一的选择就是将收集到的数据删减后保存起来,例如只保留最近N天的数据。然而,这种方法只在短期内可行,它无法存储几个月或几年收集到的所有数据,因此建议:构建一种数学模型覆盖整个时间段或者改进一个算法,重跑以前所有的数据,以达到更好的效果。

对于海量数据的重要性,Ralph Kimball博士指出

“数据资产会取代20世纪传统有形资产的地位,成为资产负债表的重要组成部分。”

还指出:

“数据的价值已经超越了传统企业广泛认同的价值边界。”

Google和Amazon是认识到数据价值的典范,它们已经开始开发满足自己业务需求的解决方案。例如,Google在一系列的技术出版物中描述了基于商业硬件的可扩展的存储和处理系统。开源社区利用Google的这些思想实现了开源Hadoop项目的两个模块:HDFS和MapReduce。

Hadoop擅长存储任意的、半结构化的数据,甚至是非结构化的数据,可以帮助用户在分析数据的时候决定如何解释这些数据,同样允许用户随时更改数据分类的方式:一旦用户更新了算法,只需要重新分析数据。

目前Hadoop几乎是所有现有数据库系统的一种补充,它给用户提供了数据存储的无限空间,支持用户在恰当的时候存储和获取数据,并且针对大文件的存储、批量访问和流式访问做了优化。这使得用户对数据的分析变得简单快捷,但是用户同样需要访问分析后的最终数据,这种需求需要的不是批量模式而是随机访问模式,这种模式相对于在数据库系统来说,相当于一种全表扫描和使用索引。

通常用户在随机访问结构化数据的时候都会查询数据库。RDBMS在这方面最为突出,但是也有一些少量的有差异的实现方式,比如面向对象的数据库。大多数RDBMS一直遵守科德十二定律(Codd’s 12 rules),这个定律对于RDBMS来说是刚性标准,并且由于RDBMS的底层架构是经过仔细研究的,所以在相当长的一段时间里这种架构都不会有明显的改变。近年来出现的各种处理方法,如列式存储的(column-oriented)数据库和大规模并行处理(Massively Parallel Processing,MPP)数据库,表明业界重新思考了技术方案以满足新的工作负载,但是大多数解决方案仍旧是基于科德十二定律来实现的,并没有打破传统的法则。

列式存储数据库

列式存储数据库以列为单位聚合数据,然后将列值顺序地存入磁盘,这种存储方法不同于行式存储的传统数据库,行式存储数据库连续地存储整行。图1-1形象地展示了列式存储和行式存储的不同物理结构。

列式存储的出现主要基于这样一种假设:对于特定的查询,不是所有的值都是必需的。尤其是在分析型数据库里,这种情形很常见,因此需要选择一种更为合适的存储模式。

在这种新型的设计中,减少I/O只是众多主要因素之一,它还有其他的优点:因为列的数据类型天生是相似的,即便逻辑上每一行之间有轻微的不同,但仍旧比按行存储的结构聚集在一起的数据更利于压缩,因为大多数的压缩算法只关注有限的压缩窗口。

像增量压缩或前缀压缩这类的专业算法,是基于列存储的类型定制的,因而大幅度提高了压缩比。更好的压缩比有利于在返回结果时降低带宽的消耗。

图1-1 列式存储结构与行式存储结构

值得注意的是,从典型RDBMS的角度来看,HBase并不是一个列式存储的数据库,但是它利用了磁盘上的列存储格式,这也是RDBMS与HBase最大的相似之处,因为HBase以列式存储的格式在磁盘上存储数据。但它与传统的列式数据库有很大的不同:传统的列式数据库比较适合实时存取数据的场景,HBase比较适合键值对的数据存取,或者有序的数据存取。

如今数据的产生速度比几年以前已经有了迅猛的增长,随着全球化步伐加快,这个增长速度只会越来越迅猛,由此所产生的数据处理问题也会越来越严峻。像Google、Amazon、eBay和Facebook这样网站的用户已经覆盖到了地球上的绝大多数人。全球化网络应用(planet-size web application)的概念已经形成,在这种背景下,企业使用HBase更合适。

举例来说,Facebook每天增量存储到它们Hadoop集群的数据量超过15 TB,并且随后会对所有这些数据进行处理。这些数据一部分是点击流日志,用户点击了它们的网站或点击了使用Facebook提供的社交插件的网站,每一步点击操作都会被记录并保存,这非常适合以批处理的模式,为预测和推荐系统构建机器学习模型。

Facebook还有一个实时组件,就是它们的消息系统,其中包括聊天、涂鸦墙和电子邮件,每个月会产生超过1350亿条数据,存储几个月之后便会产生一个量级庞大的尾部数据,并且这些尾部数据需要被有效地处理。尽管电子邮件中占用存储量较大的部分(如附件)通常存储在二级系统中,但这些消息产生的数据量还是令人难以置信的。以Facebook的数据产生条目为基础,假如按Twitter中的每条数据占用140字节来计算,Facebook每个月将会产生超过17 TB的数据,在将这些数据导入到HBase之前,现存的系统每个月也要处理超过25 TB的数据。

目前在少数的重点行业中,面向Web业务的公司收集的数据量也在不断增长。

金融

如股票涨跌产生的数据。

生物信息学

如全球生物多样性信息机构(Global Biodiversity Information Facility,http://www.gbif.org/)。

智能电网

如OpenPDC(http://openpdc.codeplex.com/)项目。

销售

如销售终端(POS机)产生的数据,或者是股票系统、库存系统。

基因组学

如Crossbow(http://bowtie-bio.sourceforge.net/crossbow/index.shtml)项目。

移动电话服务、军事、环境工程

也产生了海量的数据。

有效存储PB级的数据并能高效地检索和更新并不是一件容易的事情。下面深入探讨一下我们将面临的挑战。

RDBMS在设计和实现商业应用方面扮演了一个不可或缺的角色(至少在可预见的未来仍旧如此)。只要用户需要保留用户、产品、会话、订单等信息,就会采用一些存储后端为前端应用服务器提供持久化数据的服务。这种结构非常适合有限的数据量,但对于数据急剧增长的情况,这种结构就显得力不从心了。

以我们之前提到的HBase短网址(HBase URL Shortener)服务——Hush为例。假设要构建一个初始就能支持几千个用户的系统,并且要节省成本,换句话说就是使用免费软件。这种经典案例就可以使用开源的LAMP 快速地搭建一个原型。

关系数据库模型通过用外键关联url表、shorturl表和click表,规范了存入user表的数据。其中这些数据表都有索引,可以保证你既能通过short ID检索到URL,又能通过username检索到用户。如果需要找出一个特定用户列表的所有短址URL,可以运行一个SQL的JOIN语句关联这两张表,得到一个全面的URL表,其中不仅包括短址URL,还包括所需的用户明细。

此外,还可以利用数据库内置的功能,如存储过程(stored procedure)。当数据系统需要始终保证多张表的数据一致性时,可以利用存储过程来解决多个客户端同时更新数据的一致性问题。

事务(transaction)提供了原子性跨表更新数据的特性,可以让修改同时可见或同时不可见。RDBMS提供了所谓的ACID特性,这意味着用户数据是强一致性的(在稍后的“一致性模型”中有详细介绍)。参照完整性(referential integrity)负责约束不同的表结构之间的关系,利用特定域语言,即SQL,能够写出任意复杂的查询语句。最终,用户不需要关心实际上数据是怎样存储的,只需要关心更高层次的概念,例如,表结构,表结构在应用程序中提供了非常固定的访问模型。

通常这种模式的设计能在较长的一段时间里满足需求。如果足够幸运,你可能会成为下一个互联网上的热点,每天有越来越多的用户加入你的网站。但随着你网站用户数量的增加,共享数据库服务器的压力也会越来越大。增加应用服务器的数量相对来说比较容易,因为应用服务器之间是共享中央数据库的,但随着共享中央数据库的CPU和I/O负载的上升,你将很难预测你能承受这种增速度多久。

减少压力的第一步是增加用于并行读取的从服务器,将读写分离。这种方案保留了一个主数据库服务器,但是这个主数据库服务器现在只服务于写请求,这样做主要是考虑到网站的请求主要由用户浏览产生,因此写请求远少于读请求。如果这个方案也因用户数的持续增加而失败了,或者降低了系统的性能,又该怎么办呢?

下一步常见的做法是增加缓存,如Memcached。现在可以将读操作接入到高速的在内存中缓存数据的系统中,但是这种方案无法保证数据一致性,因为用户更新数据到数据库,而数据库并不会主动更新缓存系统中的数据,所以需要尽可能快地同步缓存和数据库视图,把更新缓存数据与更新数据库数据的时间差最小化。

虽然这种方案能够缓解读请求的压力,但是写请求压力的增加问题还是没有得到解决。一旦主数据库服务器写性能下降,可以把主服务器换成加强服务器,即垂直扩容,让加强服务器使用更多的内核、更多的内存、更快的磁盘……总之,与初始的主服务器相比要花费更多的金钱。同时值得注意的是,用户如果已经选择了主从的配置方案,就必须要让从服务器的性能与主服务器同样强,否则从服务器的更新速度就会跟不上主服务器的更新速度,这样增加一倍到两倍的成本,甚至更多。

伴随着网站受欢迎程度增加,网站会需要增加更多新功能,而这些新功能无疑都会转化为后台数据库的查询语句。以前顺利执行的SQL JOIN语句执行突然变慢了,或是干脆无法执行,这时候就不得不采用逆范式化存储结构。如果情况越来越糟,就不得不停止使用存储过程,因为存储过程最后会慢得无法执行。本质上讲,减少数据库中的存储数据才能优化访问。

所以随着用户越来越多,负载会不断提高,合乎逻辑的方式就是不时地预先实现最昂贵的查询方案,从而给用户提供更快的数据服务。最终,不得不放弃辅助索引的使用,原因就是数据量增大的同时,索引量也大到了足以让数据库的性能直线下降。最后所能提供的查询模式只剩下了按照主键查询。

这时该怎么办?如果负载在未来的几个月里预期会增加一个数量级或更多又该怎么办?此时用户可以考虑将数据分区(sharding)到多个数据库中,但是采用此方案会使运维操作变成噩梦,而且代价非常昂贵,因此也不是最合理的解决方案。但从本质来讲,采用RDBMS也是因为没有其他可以选择的方案。

分区

分区(sharding)主要描述了逻辑上水平划分数据的方案。这个方案的特点是将数据分文件或分服务器存储,而不是连续存储。

数据的分区是在固定范围内实施的:在传入数据之前,必须提前划分好数据的存储范围,如果一个水平划分的压力超过其所能提供的容量,就需要将数据重分区(reshard)并迁移数据。

重分区并迁移数据是非常消耗资源的操作,等同于数据重做。需要重新划分边界然后横向拆分(split)。大规模的复制操作会消耗大量的I/O资源,同时还会临时性地增加存储需求。在对数据重分区的过程中,客户端应用仍然会有更新操作要执行,不过此时的更新操作受重分区的影响会执行得非常慢。

可以采用虚拟分区(virtual shard)的方式来减少这种资源消耗,虚拟分区按照关键词定义范围较大的数据分区,每个服务器加载同等数量的数据分区。但是在新增服务器的时候,需要重新加载数据分区到新服务器,并且这个过程仍旧需要将数据迁移到新服务器。

分区是简单的完全脱离用户操作的事后操作,如果没有数据库的支持,可能会对生产系统造成严重破坏。

对于关系数据库系统的问题就讲到这里,客观地讲,RDBMS已经在大量的公司里使用,并形成了较大规模的技术体系。例如,Facebook、Google都已经大规模地使用了MySQL,因为MySQL的安装和使用都已经非常简洁,相关的参考资料和范例也非常充分。这个数据库适合特定场景的业务,并且短期内不会被取代。这里有个问题是:如果你想开发一个新产品,并且在设计阶段就已经预料到系统拓展速度非常快,那么,你是希望所有的功能都可用,还是使用某些有一定制约的功能?

过去的四五年时间里,为了解决问题,创新的前进步伐由缓慢变得出奇得快,好像每周都会发布新的框架和项目来满足需求。我们看到了所谓的NoSQL解决方案问世了,NoSQL是Eric Evans针对Johan Oskarsson提出的“为新兴的新数据存储空间命名”问题而创造的一个名词。

正是因为这类新产品还没有合适的名称,NoSQL一举成名。在激烈的讨论中,它被认为是“SQL”的克星_,或者说,它给仍旧考虑使用传统RDBMS的人带来了瘟疫……只是开个玩笑!

标示符号化(tagword)实际上是一个不错的选择:最新的存储系统不提供通过SQL查询数据的手段,只提供一些比较简单的、类似于API接口的方式来存取数据。

但是,也有一些工具为NoSQL数据存储提供了SQL语言的入口,用于执行一些关系数据库中常用的复杂条件查询。因此,从查询方式上的限制来说,关系型数据库和非关系型数据库并没有严格的区分。

实际上两者在底层上是有区别的,尤其涉及到模式或者ACID事务特性时,因为这与实际的存储架构是相关的。很多这一类的新系统首先做的事情是:抛弃一些限制因素以提升扩展性(这一点会在1.3.1节讨论)。例如,它们通常不支持事务或辅助索引。更重要的是,这一类系统是没有固定模式的,可以随着应用的改变而灵活变化。

一致性模型

在这本书里,我们经常会提到一致性问题,所以有必要在这里对它稍加介绍。一开始的一致性是保证数据库客户端操作的正确性,数据库必须保证每一步操作都是从一个一致的状态到下一个一致的状态。系统没有明确地指定如何实现这个功能,以便系统可以有多种选择。最终,系统要选择是进入下一个一致的状态,还是回退到上一个一致的状态,从而保证一致性。

一致性可以按照严格程度由强到弱分类,或者是按照对客户端的保证程度分类,下面是一个非正式的分类列表。

严格一致性:数据的变化是原子的,一经改变即时生效,这是一致性的最高形式。

顺序一致性:每个客户端看到的数据依照它们操作执行的顺序而变化。

因果一致性:客户端以因果关系顺序观察到数据的改变。

最终一致性:在没有更新数据的一段时间里,系统将通过广播保证副本之间的数据一致性。

弱一致性:没有做出保证的情况下,所有的更新会通过广播的形式传递,展现给不同客户端的数据顺序可能不一样。

采用最终一致性策略的系统还可以细分为几个子类,并且这些子策略还可以共存。亚马逊的首席技术官Werner Vogels在一篇名为“Eventually Consistent”的文章中列举了这几个子类。这篇文章还谈到了CAP定理(CAP theorem),其中指出,一个分布式系统只能同时实现一致性、可用性和分区容忍性(或分区容错性)中的两个。CAP定理是热点话题,不过它不是区分分布式系统的唯一方法,但CAP定理指出了,开发一套同时满足以上需求的分布式系统是比较困难的。例如,Vogels提到:

“在一系列的研究结果里发现,在较大型的分布式系统中,由于网络分隔,一致性与可用性不能同时满足。这意味着三个要素最多只能同时实现两个,不可能三者兼顾;放宽一致性的要求会提升系统的可用性……提升一致性意味着系统需要牺牲一定的可用性。”

放宽一致性来提高系统可用性是一个非常有效的提议。不过这种方案会强制让应用层去解决一致性的问题,因此也会增加系统的复杂度。

各种非关系型数据库有许多共同的特性,同时这其中的许多特性与传统的存储方案也有很多共同点。因此新系统并不是革命性的产品,从工程的角度来看更像是产品的进化。

假使连Memcached这样的项目都划入到NoSQL范畴的话,那就成了只要不是RDBMS就可以认为是NoSQL。这个说法导致了错误的二分法,二分法掩盖了这些系统提供的令人振奋的技术可行性。在NoSQL范畴内,还有很多的维度可以区分系统的特定优势所在。

让我们来挑几种维度简单介绍一下。需要注意的是,列举的这些维度并不全面,并且这也不是唯一的区分方式。

数据模型

数据有多种存储的方式,包括键/值对(类似于HashMap)、半结构化的列式存储和文档结构存储。用户的应用如何存取数据?同时数据模式是否随着时间而变化?

存储模型

内存还是持久化?坦率来说做出这个决定并不难,其主要原因是,我们可以将其与RDBMS进行对比,它们通常持久化存储数据到磁盘中。即使需要的是纯粹的内存模式,也仍旧有其他方案。一旦考虑持久化存储,就需要考虑选择的方案是否会影响到访问模式?

一致性模型

严格一致性还是最终一致性?问题是存储系统如何实现它的目标:必须降低一致性要求吗?虽然这种问题很粗浅,但是在特定的场景中会产生巨大影响。因为一致性可能会影响操作延时,即系统响应读写请求的速度。这需要权衡投入和产出后得到一个折中结果。

物理模型

分布式模式还是单机模式?这种架构看起来像什么?是仅仅运行在单个机器上,还是分布在多台机器上,但分布及扩展规则由客户端管理,换句话说,由用户自己的代码管理?也许分布式模式仅仅是个事后的工作,并且只会在用户需要扩展系统时产生问题。如果系统提供了一定的扩展性,那么需要用户采取特定的操作吗?最简单的解决方案就是一次增加一台机器,并且设置好分区(这点对于不支持虚拟分区的系统非常重要),设置时需要考虑同时提高每个分区的处理能力,因为系统的每个部分都需要提供均衡的性能。

读/写性能

用户必须了解自己的应用程序的访问模式。是读多写少?还是读写相当?或者是写多读少?是用范围扫描数据好,还是用随机读请求数据更好?有些系统仅仅对这些情况中的一种支持得非常好,有些系统则对各种情况都提供了很好的支持。

辅助索引

辅助索引支持用户按不同的字段和排序方式来访问表。这个维度覆盖了某些完全没有辅助索引支持且不保证数据排序的系统(类似于HashMap,即用户需要知道数据对应键的值),到某些可能通过外部手段简单支持这些功能的系统。如果存储系统不提供这项功能,用户的应用可以应对或自已模拟辅助索引吗?

故障处理

机器会崩溃是一个客观存在的问题,需要有一套数据迁移方案来应对这种情况(关于这一点可以参考在“一致性模型”中讨论的CAP定理)。每个数据存储如何进行服务器故障处理?故障处理完毕之后是否可以正常工作?这与之前讨论的“一致性模型”维度有关系,因为失去一台服务器可能会造成数据存储的空洞(hole),甚至使整个数据存储不可用。如果替换掉故障服务器,那么恢复100%服务的难度有多大?从一个正在提供服务的集群中卸载一台服务器时,也会遇到类似的问题。

压缩

当用户需要存储TB级的数据时,尤其当这些数据差异性很小或由可读性文本组成时,压缩会带来非常好的效果,即能节省大量的原始数据存储。有些压缩算法可以将此类的数据压缩到原始文件大小的十分之一。有可选择的压缩组件吗?又有哪些压缩算法可用?

负载均衡

假如用户有高读写吞吐率的需求,就要考虑配置一套能够随着负载变化自动均衡处理能力的系统。虽然这样不能完全解决该问题,但是也可以帮助用户设计高读写吞吐量的程序。

原子操作的读-修改-写

RDBMS提供了很多这类的操作(因为它是一个集中式的面向单服务器的系统),但这些操作在分布式系统中较难实现,这些操作可以帮助用户避免多线程造成的资源竞争,也可以帮助用户完成无共享应用服务器的设计。有了这些比较并交换(compare and swap,CAS)操作,或者说检查并设置(check and set)操作,在设计系统的时候可以有效地降低客户端的复杂度。

加锁、等待和死锁

众所周知,复杂的事务处理,如两阶段提交,会增加多个客户端竞争同一个资源的可能性。最糟糕的情况就是死锁,这种情况也很难解决。用户需要支持的系统采用哪种锁模型?这种锁模型能否避免等待和死锁?

稍后我们会回顾这些维度,看看HBase适合用在哪里,其优势何在。现在需要指出的是,一定要根据实际的需求来仔细选择最适合的维度。按照实际情况来设计解决方案,要知道没有硬性规定说:RDBMS不能很好地解决的问题,NoSQL就能完美解决。重要的是正确地评估需求,然后再做出明智的选择,有需要的话甚至可以采用混合使用的方案。

可以用一个有趣的词来形容这个情况——阻抗匹配(impedance match),意思就是要为一个给定问题找到一个理想的解决方案,除了使用通用的解决方案,还应该知道有什么可用的解决方案,从而找到最适合于解决该问题的系统。

RDBMS非常适合事务性操作,但不见长于超大规模的数据分析处理,因为超大规模的查询需要进行大范围的数据记录扫描或全表扫描。分析型数据库可以存储数百或数千TB的数据,在一台服务器上做查询工作的响应时间,会远远超过用户可接受的合理响应时间。垂直扩展服务器性能,即增加CPU核数和磁盘数目,也并不能很好地解决该问题。

更糟糕的是,RDBMS的等待和死锁的出现频率,与事务和并发的增加并不是线性关系,准确地说,与并发数目的平方以及事务规模的3次方甚至5次方相关。分区通常是一个不切合实际的解决方案,因为它需要客户端采用非常复杂的方式和较高的代价来维护分区信息。

一些商业RDBMS也解决过类似的问题,但它们往往只是特定地解决了问题的某几个方面,更重要的是,它们非常非常的昂贵。而一些开源的RDBMS解决方案中,往往放弃了其中的一些甚至全部的关系型特性,如辅助索引,来换取更高的性能拓展能力。

问题是,为了性能而一直放弃以上关系型特性是否值得?用户可以反范式化(见1.3.3节)数据模型来避免等待,并且可以通过降低锁粒度的方式来尽量避免死锁。数据增长时,无需重新分区迁移数据并内嵌水平扩展性的方法。最后,用户还要面对容错和数据可用性问题,采用提高扩展性的机制,用户最终会得到一个NoSQL的解决方案,更确切地说,HBase可以满足以上多种需求。

不同的规模,经常需要设计不同的系统结构,对这种原则的最佳描述是:反范式化、复制和智能的主键(Denormalization, Duplication, and Intelligent Keys,简称DDI )。这就需要重新思考在类似BigTable的存储系统中如何才能高效合理地存储数据。

部分原则是采用反范式化模式,例如将数据复制到多张表中,这样在读取的时候就不需从多张表中聚合数据了。或者预先物化所需的视图,一次优化从而避免进一步的处理来提高读取性能。

这一主题在第9章里有更详细的介绍,主要阐述了如何充分利用HBase的特性去解决实际问题。让我们来看一个例子,理解传统的关系数据库模型转到列式存储的HBase的几点基本原则。

再来看看HBase短网址,即Hush,Hush允许用户将长网址映射为短网址(short URL),见图1-2表示的实体关系图(entity relationship diagram,ERD,简称ER图)。在附录E 中可以查看完整的SQL模式。

图1-2 Hush模式的实体关系图

短网址存储在shorturl表中,用户可以点击短网址来链接到完整网址。系统会跟踪每次点击,记录该网址的使用次数,还会记录一些其他信息,例如,点击该链接的用户所在的国家。这些信息会记录在click表中,这个表的功能类似于一个计数器,以天为周期统计每天的访问量。

用户信息存储在user表中,用户可以在Hush网站上注册并创建个人短网址列表,同时也可以在此网站上增加描述。user表与shorturl表之间维护了一个外键关系。

系统还会在后台下载链接到的页面,并提取一些TITLE之类的HTML标签。将整个页面保存下来的目的是,供后续的异步任务进行处理和分析。这些内容都由url表存储。

每个链接页面只存储一份,不过,由于许多用户可能会链接同一个长网址,并且还想保存他们自己的详细信息,例如,使用统计信息,因此会在短链接表中创建多个项来加以区分。通过这段逻辑,将url表、shorturl表和click表关联在了一起。

这使得通过统计原始的短网址标识refShortId,就可以统计任意一个短网址映射到同一个长网址的使用率。shortIdrefShortId利用散列ID的方式被唯一地分配给了短网址。例如:

http://hush.li/a23eg

在上述地址中,散列ID是a23eg

图1-3展现了Hush应用在HBase中的对应模式。每个短网址都存储在独立的表shorturl中,表中还包含了使用统计信息,按统计时间范围不同,存放于不同的列族中,同时每个值都有其生存期(time-to-live)。列名是日期和一个可选维度后缀的组合,例如国家代码,列值则是对应计数器的值。

下载下来的页面和提取的详细信息存储在url表中,并且要通过压缩最大限度地减少存储量,因为存储的页面主要是HTML,这种格式本身冗余量大,并且包含了大量文本。

图1-3 HBase中的Hush模式

系统通过user-shorturl表可以快速查到指定用户的所有短网址标识。这个功能被用在用户主页中,只要用户一登录就会被记录下来。user表存储着实际用户的详细信息。

虽然表的数量相同,都是4个,但表的含义发生了变化:clicks表被合并到了shorturl表中,统计列使用日期为列键,格式为YYYYMMDD,例如,20110502,这样用户可以顺序访问数据。新增的user-shorturl表代替了外键,使查询用户相关信息变得更为快捷。

有非常多的方法来转换一对一、一对多、多对多的关系,以适应HBase的底层架构。这种简单的例子有多种实现方式,用户需要充分理解HBase存储设计的潜在能力,然后深思熟虑地决定用哪一种实现方式。

对稀疏矩阵、宽表、列式存储的支持使得数据在存储的时候无需范式化,同时也可以避免查询时采用开销很大的JOIN操作聚合数据。使用智能的主键可以控制数据怎样去存储以及存储在什么位置。由于可以使用行键的部分内容进行范围检索,行键作为组合键设计时,与字典序左部分为头的索引效果相似。因此,正确的设计能够使性能不会因为数据增长而下降,例如当数据条目从10条增加到1000万条时,系统仍旧可以保持相同的读写性能。

本节首先介绍HBase的架构,然后介绍一些关于HBase起源的背景资料,之后将介绍其数据模型的一般概念和可用的存储API,最后在一个更高的层次上对其实现细节进行分析。

2003年,Google发表了一篇论文,叫“The Google File System”(http://labs.google.com/papers/gfs.html)。这个分布式文件系统简称GFS,它使用商用硬件集群存储海量数据。文件系统将数据在节点之间冗余复制,这样的话,即使一台存储服务器发生故障,也不会影响数据的可用性。它对数据的流式读取也做了优化,可以边处理边读取。

不久,Google又发表了另外一篇论文,叫“MapReduce: Simplified Data Processing on Large Clusters”,参见http://labs.google.com/paper/mapreduce.html。MapReduce是GFS架构的一个补充,因为它能够充分利用GFS集群中的每个商用服务器提供的大量CPU。MapReduce加上GFS形成了处理海量数据的核心力量,包括构建Google的搜索索引。

不过以上描述的两个系统都缺乏实时随机存取数据的能力(意味着尚不足以处理Web服务)。GFS的另一个缺陷就是,它适合存储少许非常非常大的文件,而不适合存储成千上万的小文件,因为文件的元数据信息最终要存储在主节点的内存中,文件越多主节点的压力越大。

因此,Google尝试去找到一个能够驱动交互应用的解决方案,例如,Google邮件或者Google分析,能够同时利用这种基础结构、依靠GFS存储的数据冗余和数据可用性较强的特点。存储的数据应该拆分成特别小的条目,然后由系统将这些小记录聚合到非常大的存储文件中,并提供一些索引排序,让用户可以查找最少的磁盘就能够获取数据。最终,它应该能够及时存储爬虫的结果,并跟MapReduce协作构建搜索索引。

意识到RDBMS在大规模处理中的缺点(8.1节会针对这一点进行深入讨论),工程师们开始考虑问题的其他切入点:摒弃关系型的特点,采用简单的API来进行增、查、改、删(Create, Read, Update, and Delete,简称CRUD)操作,再加一个扫描函数,在较大的键范围或全表范围上迭代扫描。这些努力的成果最终在2006年的论文“BigTable: A Distributed Storage System for Structured Data”中发表了。

“BigTable是一个管理结构化数据的分布式存储系统,它可以扩展到非常大:如在成千上万的商用服务器上存储PB级的数据。……一个稀疏的、分布式的、持久的多维排序映射。”

强烈建议对HBase感兴趣的人去阅读这篇论文,它介绍了很多BigTable的设计原理,用户最终都能在HBase中找到BigTable的影子。我们会借用这篇论文的基本概念来贯穿我们的这本书。

HBase实现了BigTable存储架构,因此我们也可以用HBase来解释每样东西。附录F介绍了两者之间的不同。

首先,做一个简要总结:最基本的单位是(column)。一列或多列形成一(row),并由唯一的行键(row key)来确定存储。反过来,一个(table)中有若干行,其中每列可能有多个版本,在每一个单元格(cell)中存储了不同的值。

除了每个单元格可以保留若干个版本的数据这一点,整个结构看起来像典型的数据库的描述,但很明显有比这更重要的因素。

所有的行按照行键字典序进行排序存储。例1.1展现了如何通过不同的行键增加多行数据。

例1.1 行序是按照行键的字典序进行排序的

hbase(main):001:0> scan 'table1'
ROW                COLUMN+CELL
row-1                 column=cf1:,timestamp=1297073325971 ...
row-10               column=cf1:,timestamp=1297073337383 ...
row-11               column=cf1:,timestamp=1297073340493 ...
row-2                 column=cf1:,timestamp=1297073329851 ...
row-22               column=cf1:,timestamp=1297073344482 ...
row-3                 column=cf1:,timestamp=1297073333504 ...
row-abc               column=cf1:,timestamp=1297073349875 ...
7 row(s) in 0.1100 seconds

注意,排列的顺序可能和你预期的不一样,可能需要通过补键来获得正确排序。在字典序中,是按照二进制逐字节从左到右依次对比每一个行键,例如,row-1…小于row-2…,因此,无论后面是什么,将始终按照这个顺序排列。

按照行键排序可以获得像RDBMS的主键索引一样的特性,也就是说,行键总是唯一的,并且只出现一次,否则你就是在更新同一行。虽然BigTable的论文里只考虑了行键单一索引,但是HBase增加了对辅助索引(见9.3节)的支持。行键可以是任意的字节数组,但它并不一定是人直接可读的。

一行由若干列组成,若干列又构成一个列族(column family),这不仅有助于构建数据的语义边界或者局部边界,还有助于给它们设置某些特性(如压缩),或者指示它们存储在内存中。一个列族的所有列存储在同一个底层的存储文件里,这个存储文件叫做HFile

列族需要在表创建时就定义好,并且不能修改得太频繁,数量也不能太多。在当前的实现中有少量已知的缺陷,这些缺陷使得列族数量只限于几十,实际情况可能还小得多(详情见第9章)。列族名必须由可打印字符组成,这与其他名字或值的命名规范有显著不同。

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

图1-4用可视化的方式展现了普通数据库与列式HBase在行设计上的不同,行和列没有像经典的电子表格模型那样排列,而是采用了标签描述(tag metaphor),也就是说,信息保存在一个特定的标签下。

图1-4 HBase中的行与列

所有列和行的信息都会通过列族在表中定义,关于这一点我们在下文进行讨论。

每一列的值或单元格的值都具有时间戳,默认由系统指定,也可以由用户显式设置。时间戳可以被使用,例如通过不同的时间戳来区分不同版本的值。一个单元格的不同版本的值按照降序排列在一起,访问的时候优先读取最新的值。这种优化的目的在于让新值比老值更容易被读取。

用户可以指定每个值所能保存的最大版本数。此外,还支持谓词删除(predicate deletion,见8.1.2节关于LSM树的内容),例如,允许用户只保存过去一周内写入的值。这些值(或单元格)也只是未解释的字节数组,客户端需要知道怎样去处理这些值。

前面提到过,HBase是按照BigTable模型实现的,是一个稀疏的、分布式的、持久化的、多维的映射,由行键、列键和时间戳索引。将以上特点联系在一起,我们就有了如下的数据存取模式:

(Table,RowKey,Family,Column,Timestamp)→ Value

可以用一种更像编程语言的风格表示如下:

SortedMap<  
   RowKey,List<  
     SortedMap<  
        Column,List<  
          Value,Timestamp  
        >  
     >  
   >
>

或者用一行来表示:

SortedMap< RowKey,List< SortedMap< Column,List< Value,Timestamp>>>>

第一个SortedMap代表那个表,包含一个列族的List。列族中包含了另一个SortedMap存储列和相应的值。这些值在最后的List中,存储了值和该值被设置的时间戳。

这个数据存取模型的一个有趣的特性是单元格可以存在多个版本,不同的列被写入的次数不同。API默认提供了一个所有列的统一视图,API会自动选择单元格的当前值。图1-5展示了示例表中的某一行。

图1-5用表示单元格被写入的时间戳tn可视化了时间组件,升序显示了这些值被插入的不同时间。图1-6是另一种查看数据的方式,在这种更类似电子表格的形式中,将时间戳添加到了它自己的那一列中。

图1-5 基于时间的行的组成部分

图1-6 用电子表格展示行中相同的部分

尽管这些值插入的次数不同,并且存在多个版本,但是仍然能将行看作是所有列以及这些列的最新版本(即每一列的最大tn)的组合。这里提供了查询一个特定时间戳或者是特定时间戳之前的值的方式,也可以一次查询多个版本的值,关于这一点请参见第3章。

webtable

BigTable和HBase的典型使用场景是webtable,存储从互联网中抓取的网页。

行键是反转的网页URL,如org.hbase.www。有一个用于存储网页HTML代码的列族contents,还有其他的列族,如anchor,用于存储外向链接和入站链接,还有一个用于存储元数据的列族language

contents列族使用多版本,允许用户存储一些旧的HTML副本,使用多版本是有益的,例如帮助分析一个页面的变化频率。使用的时间戳是抓取该页面的实际次数。

行数据的存取操作是原子的(atomic),可以读写任意数目的列。目前还不支持跨行事务和跨表事务。原子存取也是促成系统架构具有强一致性(strictly consistent)的一个因素,因为并发的读写者可以对行的状态作出安全的假设。

使用多版本和时间戳同样能够帮助应用层解决一致性问题。

HBase中扩展和负载均衡的基本单元称为region,region本质上是以行键排序的连续存储的区间。如果region太大,系统就会把它们动态拆分,相反地,就把多个region合并,以减少存储文件数量。

一张表初始的时候只有一个region,用户开始向表中插入数据时,系统会检查这个region的大小,确保其不超过配置的最大值。如果超过了限制,系统会在中间键(middle key,region中间的那个行键)处将这个region拆分成两个大致相等的子region(详情见第8章)。

每一个region只能由一台region服务器(region server)加载,每一台region服务器可以同时加载多个region。图1-7展示了一个表,这个表实际上是一个由很多region服务器加载的region集合的逻辑视图。

图1-7 region中的行分组加载到不同的服务器中

BigTable的论文中指出,每台服务器中region的最佳加载数量是10~1000,每个region的最佳大小是100 MB~200 MB。这个标准是以2006年(以及更早以前)的硬件配置为基准参数建议的。按照HBase和现在的硬件能力,每台服务器的最佳加载数量差不多还是10~1000,但每个region的最佳大小是1 GB~2 GB了。

虽然数量增加了,但是基本原理还是一样的:每台服务器能加载的region数量和每个region的最佳存储大小取决于单台服务器的有效处理能力。

region拆分和服务相当于其他系统提供的自动分区(autosharding)。当一个服务器出现故障后,该服务器上的region可以快速恢复,并获得细粒度的负载均衡,因为当服务于某个region的服务器当前负载过大、发生错误或者被停止使用导致不可用时,系统会将该region移到其他服务器上。

region拆分的操作也非常快——接近瞬间,因为拆分之后的region读取的仍然是原存储文件,直到合并把存储文件异步地写成独立的文件,详情见第8章。

“BigTable并不支持完整的关系数据模型;相反,它提供了具有简单数据模型的客户端,这个简单的数据模型支持动态控制数据的布局格式……”

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

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

在这些基本功能的基础上,还有一些更高级的特性。系统支持单行事务,基于这个特性,系统实现了对单个行键下存储的数据的原子读-修改-写(read-modify-write)序列。虽然还不支持跨行和跨表的事务,但客户端已经能够支持批量操作以获得更好的性能。

单元格的值可以当作计数器使用,并且能够支持原子更新。这个计数器能够在一个操作中完成读和修改,因此尽管是分布式的系统架构,客户端依然可以利用此特性实现全局的、强一致的、连续的计数器。

还可以在服务器的地址空间中执行来自客户端的代码,支持这种功能的服务端框架叫做协处理器(coprocessor)。这个代码能直接访问服务器本地的数据,可以用于实现轻量级批处理作业,或者使用表达式并基于各种操作来分析或汇总数据。

最后,系统通过提供包装器集成了MapReduce框架,该包装器能够将表转换成MapReduce作业的输入源和输出目标。

与RDBMS不同,HBase系统没有提供查询数据的特定域语言,例如SQL。数据存取不是以声明的方式完成的,而是通过客户端API以纯粹的命令完成的。HBase的API主要是Java代码,但是也可以用其他编程语言来存取数据。

“BigTable……允许客户端推断在底层存储中表示的数据的位置属性。”

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

每一个HFile都有一个块索引,通过一个磁盘查找就可以实现查询。首先,在内存的块索引中进行二分查找,确定可能包含给定键的块,然后读取磁盘块找到实际要找的键。

存储文件通常保存在Hadoop分布式文件系统(Hadoop Distributed File System,HDFS)中,HDFS提供了一个可扩展的、持久的、冗余的HBase存储层。存储文件通过将更改写入到可配置数目的物理服务器中,以保证不丢失数据。

每次更新数据时,都会先将数据记录在提交日志(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是一个可靠的、高可用的、持久化的分布式协调系统。

Apache ZooKeeper

ZooKeeper是Apache软件基金会旗下的一个独立开源系统,它是Google公司为解决BigTable中问题而提出的Chubby算法的一种开源实现。它提供了类似文件系统一样的访问目录和文件(称为znode)的功能,通常分布式系统利用它协调所有权、注册服务、监听更新。

每台region服务器在ZooKeeper中注册一个自己的临时节点,主服务器会利用这些临时节点来发现可用服务器,还可以利用临时节点来跟踪机器故障和网络分区。

在ZooKeeper服务器中,每个临时节点都属于某一个会话,这个会话是客户端连接上ZooKeeper服务器之后自动生成的。每个会话在服务器中有一个唯一的id,并且客户端会以此id不断地向ZooKeeper服务器发送“心跳”,一旦发生故障ZooKeeper客户端进程死掉,ZooKeeper服务器会判定该会话超时,并自动删除属于它的临时节点。

HBase还可以利用ZooKeeper确保只有一个主服务器在运行,存储用于发现region的引导位置,作为一个region服务器的注册表,以及实现其他目的。ZooKeeper是一个关键组成部分,没有它HBase就无法运作。ZooKeeper使用分布式的一系列服务器和Zab协议(确保其状态保持一致)减轻了应用上的负担。

图1-8展示了HBase的各个组件是如何利用像HDFS和ZooKeeper这样的现有系统协调地组织起来的,而且还增加了自己的层以形成一个完整的平台。

图1-8 HBase利用自身组件的同时还平衡地利用了已有的系统

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

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

8.5节详细介绍了客户端如何执行region查找。

“数十亿行×数百万列×数千个版本 = TB级或PB级的存储”

我们已经见识到,BigTable的存储架构是怎样使用多台服务器将按键归类的行拆分成多个范围来负载均衡的,还看到了它是怎样使用上千台机器存储PB级数据的。使用的存储格式对于顺序读相邻的键/值对来说是很理想的,这种格式针对块I/O操作做了优化,能最大限度地利用磁盘传输通道。

表的扫描与时间呈线性关系,行键的查找以及修改操作与时间呈对数关系——极端情况下是常数关系(使用了布隆过滤器)。HBase在设计上完全避免了显式的锁,提供了行原子性操作,这使得系统不会因为读写操作性能而影响系统扩展能力。

当前的列式存储结构允许表在实际存储时不存储NULL值,因此表可以看作是个无限的、稀疏的表。表中每行数据只由一台服务器所服务,因此HBase具有强一致性,使用多版本可以避免因并发解耦过程引起的编辑冲突,而且可以保留这一行的历史变化。

事实上,至少从2005年开始,BigTable就已经应用于Google生产,拥有非常多的应用场景,从批处理到实时数据服务都有它的身影。BigTable存储的数据既可以非常小(例如URL),也可以特别大(如网页和卫星图片),还成功地为许多知名的Google产品提供了灵活的、高性能的解决方案,这些产品包括Google Earth、Google Reader、Google Finance和Google Analytics。

看过BigTable的架构之后,我们可能会简单地认为HBase完全是Google的BigTable的开源实现。但是这个说法可能过于简单,因为两者之间还有些差异(大多是细微的)值得一提。

HBase是Powerset在2007年创建的,最初是Hadoop的一部分。之后,它逐步成为Apache软件基金会旗下的顶级项目,具备Apache软件许可证,版本为2.0。

HBase项目的主页是http://hbase.apache.org/,通过这个主页可以链接到文档(documentation)、wiki、源代码库(source repository),以及已经发布的库和源代码的下载站点。

下面是一个HBase随时间发展的简短概述。

2010年5月前后,HBase的开发者决定打破一直依赖的、步调一致的Hadoop的版本编号。原因是HBase有一个更快的发布周期,同时更接近1.0版本的水平,比Hadoop的预期更快。

为此,版本号从0.20.x跳到了0.89.x,跳跃相当明显。此外,还做了一个决定,将0.89.x定为早期的开发版本。在0.89的基础上最终发布了0.90,即面向所有用户的稳定版。

HBase与BigTable最大的不同就是命名。表1-1罗列了两个系统之间相同组件的命名有哪些不同。

表1-1 命名差异

HBase

BigTable

Region

Tablet

RegionServer

Tablet server

Flush

Minor compaction

Minor compaction

Merging compaction

Major compaction

Major compaction

Write-ahead log

Commit log

HDFS

GFS

Hadoop MapReduce

MapReduce

MemStore

memtable

HFile

SSTable

ZooKeeper

Chubby

更多的差异参见附录F。

让我们回到1.3.1节来看看怎样用维度来描述HBase系统。HBase是一个分布式的、持久的、强一致性的存储系统,具有近似最优的写性能(能使I/O利用率达到饱和) 和出色的读性能,它充分利用了磁盘空间,支持特定列族切换可选压缩算法。

HBase继承自BigTable模型,只考虑单一的索引,类似于RDBMS中的主键,提供了服务器端钩子,可以实施灵活的辅助索引解决方案。此外,它还提供了过滤器功能,减少了网络传输的数据量。

HBase并未将说明性查询语言作为核心实现的一部分,对事务的支持也有限。但行原子性和“读-修改-写”操作在实践中弥补了这个缺陷,它们覆盖了大部分的使用场景并消除了在其他系统中经历过的死锁、等待问题。

HBase在进行负载均衡和故障恢复时对客户端是透明的。在生产系统中,系统的可扩展性体现在系统自动伸缩的过程中。更改集群并不涉及重新全量负载均衡和数据重分区,但整个处理过程完全是自动化的。

例如,参见Michael Stonebraker和UğurÇetintemel撰写的文章“‘One Size Fits All’:An Idea Whose Time Has Come and Gone”(http://www.cs.brown.edu/~ugur/fits_all.pdf)。

相关信息可以在Hadoop的官方网站http://hadoop.apache.org/中找到。也可以到Tom White编写的《Hadoop权威指南(第2版)》(原出版社为O’Reilly)一书中查阅你想了解的Hadoop知识。

此处引用的是Kimball集团的Ralph Kimball博士的一篇题为“Rethinking EDW in the Era of Expansive Information Management”的演讲(http://www.informatica.com/campaigns/rethink_edw_kimball.pdf),这个演讲讨论了一个不断发展的企业数据仓库市场的需求。

Edgar F. Codd定义了13个规则(编号为0~12),这些规则促使数据库管理系统(Datebase Management System,DBMS)被考虑为RDBMS。HBase需要满足更多的通用规则,但也有一些规则没有满足,最重要的是规则5:全面的数据子语言规则,这个规则定义了至少需要支持一种关系型语言。详情见维基百科关于科德十二定律的链接http://en.wikipedia.org/wiki/Codd's_12_rules

见Facebook提供的信息http://www.facebook.com/note.php?note_id=89508453919

请看博文http://www.facebook.com/note.php?note_id=454991608919,这篇博文来自Facebook的工程团队。150亿条墙消息和1200亿条聊天消息,共计1350亿条消息一个月。此外,Facebook还添加了SMS和其他一些应用,这些都会使数据量变得更为庞大。

Facebook使用了Haystack,Haystack优化了二进制大对象的存储结构,提供了二进制小对象存储,例如图片。

http://www.slideshare.net/brizzzdotcom/facebook-messages-hbase,这是Facebook的员工Nicolas Spiegelberg写的,他也是HBase的committer。

Linux、Apache、MySQL和PHP(或者Perl和Python)的缩写。

Atomicity、Consistency、Isolation和Durability的缩写。

Memcached是基于内存的、非持久化的、非分布式的键值存储系统。参见Memcached项目的主页http://memcached.org/

见维基百科中的“NoSQL”(http://en.wikipedia.org/wiki/NoSQL)。

见Eric Brewer的论文http://www.cs.berkeley.edu/~brewer/cs262b-2004/PODC-keynote.pdf,后期Gilbert与Lynch发表了PDF版,详情见http://lpd.epfl.ch/sgilbert/pubs/BrewersConjecture-SigAct.pdf

见Brewer的论文“Lessons from giant-scale services. Internet Computing”,IEEE,2001,5 (4):46~55 (http://ieeexplore.ieee.org/xpl/freeabs_all.jsp?arnumber=939450)。

见Jim Gray等人的“FT 101”(http://research.microsoft.com/en-us/um/people/gray/talks/UCBerkeley_Gray_FT_Avialiability_talk.ppt)。

DDI这个词是Salmen博士等人于2009年在“Cloud Data Structure Diagramming Techniques and Design Patterns”一文中提出的。

请注意,这仅仅是一个演示用例,所以故意将模式设计得很简单。

在5.1.3节中会看到,还可以不设置qualifier

虽然HBase不支持在线的region合并,但是有离线处理合并的工具,详情见11.6节。

有关Apache ZooKeeper的更多信息请参见Apache ZooKeeper官方网站(http://hadoop.apache.org/zookeeper/)。

Powerset公司位于旧金山,开发了一套用于互联网的自然语言搜索引擎。在2008年7月1日,微软公司收购了Powerset,之后Powerset放弃了对HBase开发的后续支持。

在Apache JIRA(网站上的问题追踪系统)中找到HBASE-287,里面可以找到当时的记录,读者可以看到Mike Cafarella提交的最初代码,这个代码很快就被Jim Kellerman采纳了,Jim Kellerman当时就职于Powerset。


本章将会介绍HBase提供的客户端API。在前文提到过,HBase是使用Java编写的,所以原生的API也是Java开发的,不过这并不意味着必须通过Java访问HBase。我们会在第6章介绍如何通过其他编程语言使用HBase。

HBase的主要客户端接口是由org.apache.hadoop.hbase.client包中的HTable类提供的,通过这个类,用户可以完成向HBase存储和检索数据,以及删除无效数据之类的操作。在介绍这个类的各个方法之前,让我们先了解一下它的大体功能。

所有修改数据的操作都保证了行级别的原子性,这会影响到这一行数据所有的并发读写操作。换句话说,其他客户端或线程对同一行的读写操作都不会影响该行数据的原子性:要么读到最新的修改,要么等待系统允许写入该行修改。更多内容请参考第8章。

通常,在正常负载和常规操作下,客户端读操作不会受到其他修改数据的客户端影响,因为它们之间的冲突可以忽略不计。但是,当许多客户端需要同时修改同一行数据时就会产生问题。所以,用户应当尽量使用批量处理(batch)更新来减少单独操作同一行数据的次数。

写操作中涉及的列的数目不会影响该行数据的原子性,行原子性会同时保护到所有列。

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

如果用户需要使用多个HTable实例,应考虑使用HTablePool类(详情见4.4节),它为用户提供了一个复用多个实例的便捷方式。

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

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

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

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

下面介绍的这组操作可以被分为两类:一类操作用于单行,另一类操作用于多行。鉴于后面有一些内容比较复杂,我们会分开介绍这两类操作。同时,我们还会介绍一些衍生的客户端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实例,检查其中包含的详细信息。

用户可以采用以下这些方法检查是否存在特定的单元格,而不需要遍历整个集合:

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中进行了概括。

表3-1 Put类提供的其他方法一览表

方法 描述
getRow() 返回创建Put实例时所指定的行键
getRowLock() 返回当前Put实例的行RowLock实例
getLockId() 返回使用rowlock参数传递给构造函数的可选的锁ID,当未被指定时返回-1L
setWriteToWAL() 允许关闭默认启用的服务器端预写日志(WAL)功能
getWriteToWAL() 返回代表是否启用了WAL的值
getTimeStamp() 返回相应Put实例的时间戳,该值可在构造函数中由ts参数传入。当未被设定时返回Long.MAX_VALUE
heapSize() 计算当前Put实例所需的堆大小,既包含其中的数据,也包含内部数据结构所需的空间
isEmpty() 检测FamilyMap是否含有任何KeyValue实例
numFamilies() 查询FamilyMap的大小,即所有的KeyValue实例中列族的个数
size() 返回本次Put会添加的KeyValue实例的数量

例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来载入两个配置文件:hbase-default.xmlhbase-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命令使用了相同的行键和列键,但它们的值不同:分别为val1val2。然后使用scan操作查看了这张表的所有内容。你可能并不惊讶于只看到了val2,因为你可能已经假设第二次put操作覆盖了val1

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

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

正如最大版本的术语所表现出来的意思一样,对于一个特定的单元格,有可能只有少于最大版本数个数版本。示例将VERSIONSMAX_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)

数据和坐标都是以Java的byte[]形式存储的,即以字节数组的形式存储的。使用这种底层存储类型的目的是,允许存储任意类型的数据,并且可以有效地只存储所需的字节,这保证了最少的内部数据结构开销。另一个原因是,每一个字节数组都有一个offset参数和一个length参数,它们允许用户提交一个已存在的字节数组,并进行效率很高的字节级别的操作。

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

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列出了这些比较器。

表3-2 KeyValue类提供的比较器的简要概述

比较器 描述
KeyComparator 比较两个KeyValue实例的字节数组格式的行键,即getKey()方法的返回值
KVComparator KeyComparator的封装,基于两个给定的KeyValue实例,提供与KeyComparator一样的功能
RowComparator 比较两个KeyValue实例的行键(getRow()的返回值)
MetaKeyComparator 比较两个以字节数组格式表示的.META.条目的行键
MetaComparator KVComparator类的一个特别版本,用于比较.META.目录表中的条目,是MetaKeyComparator的封装
RootKeyComparator 比较两个以字节数组格式表示的-ROOT-条目的行键
RootComparator KVComparator类的一个特别版本,用于比较-ROOT-目录表中的条目,是RootKeyComparator的封装

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

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

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

表3-3 KeyValue实例所有可能的类型值

类型 描述
Put KeyValue实例代表一个普通的Put操作
Delete KeyValue实例代表一个Delete操作,也称为墓碑标记
DeleteColumn Delete相同,但是会删除一整列
DeleteFamily Delete相同,但是会删除整个列族,包括该列族的所有列

可以通过使用另外一个方法来查看一个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请求将数据传送到服务器的。

图3-1 客户端put操作按所属region服务器排序和分组

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

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

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

< 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 getWriteBuffer() 。这个方法可以获取table.put(put)添加到缓冲区中的Put实例列表。

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

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

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

再次提到往返时间,如果用户只存储大单元格,客户端缓冲区的作用就不大了,因为传输时间占用了大部分的请求时间。在这种情况下,建议最好不要增加客户端缓冲区大小。

4.Put列表

客户端的API可以插入单个Put实例,同时也有批量处理操作的高级特性。其调用形式如下:

void put(List<Put> 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的行中,这两列使用了两个不同的列名,qual1qual2,在同一行创建了两个不同名称的列。

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实例会被保存在本地写缓冲区中,下一次缓冲区刷写的时候会重试。用户也可以通过HTablegetWriteBuffer()方法访问它们,并对它们做一些处理,例如,清除操作。

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

用户可以捕获异常并手动刷写写缓冲区来执行已经添加的操作。例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()

当使用基于列表的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,并保证没有其他客户端已经做了同样的事情,你就可以使用这个有原子性保证的操作,先比较原值,再做修改。

这个方法返回一个布尔类型的值,表示put操作成功执行还是失败,对应的值分别是truefalse。例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

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

下面我们将介绍从客户端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实例都通过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类中其他方法的介绍。

表3-4 Get类提供的其他方法概览

方法 描述
getRow() 返回创建Get实例时指定的行键
getRowLock() 返回当前Get实例的RowLock实例
getLockId() 返回创建时指定rowLock的锁ID,如果没有指定则返回-1L
getTimeRange() 返回指定的Get实例的时间戳范围。注意,Get类中已经没有getTimeStamp()方法了,因为API会在内部将setTimeStamp()赋的值转换成TimeRange实例,设定给定时间戳的最大值和最小值
setFilter()/getFilter() 用户可以使用一个特定的过滤器实例,通过多种规则和条件来筛选列和单元格。使用这些方法,用户可以设定或查看Get实例的过滤器成员,详情参见4.1节
setCacheBlocks()/getCacheBlocks() 每个HBase的region服务器都有一个块缓存来有效地保存最近存取过的数据,并以此来加速之后的相邻信息的读取。不过在某些情况下,例如完全随机读取时,最好能避免这种机制带来的扰动。这些方法能够控制当次读取的块缓存机制是否启效
numFamilies() 快捷地获取列族FamilyMap大小的方法,包括用addFamily()方法和addColumn()方法添加的列族
hasFamilies() 检查列族或列是否存在于当前的Get实例中
familySet()/getFamilyMap() 这些方法能够让用户直接访问addFamily()addColumn()添加的列族和列。FamilyMap列族中键是列族的名称,键对应的值是指定列族的限定符列表。familySet()方法返回一个所有已存储列族的Set,即一个只包含列族名的集合

以前提到过,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<KeyValue> list()

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

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

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

另外还有一些面向列的存取函数如下:

List<KeyValue> 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()方法对应,用户可以用一次请求获取多行数据。它允许用户快速高效地从远程服务器获取相关的或完全随机的多行数据。

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

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

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

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

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

可以从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参数,返回的结果将总是按字典序排在表尾的那一行。

此前介绍了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()的功能,可读性更强。

表3-5 detele()功能表

方法 无时间戳的删除 有时间戳的删除
none 删除整行,即所有列的所有版本 从所有列族的所有列中删除与给定时间戳相同或更旧的版本
deleteColumn() 只删除给定列的最新版本,保留旧版本 只删除与时间戳匹配的给定列的指定版本,如果不存在,则不删除
deleteColumns() 删除给定列的所有版本 从给定列中删除与给定时间戳相等或更旧的版本
deleteFamily() 删除给定列族中的所有列(包括所有版本) 从给定列族下的所有列中删除与给定时间戳相等或更旧的版本

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

表3-6 Delete类提供的其他方法概览

方法 描述
getRow() 返回创建Delete实例时指定的行键
getRowLock() 返回当前Delete实例的RowLock实例
getLockId() 返回使用raulk参数创建实例时可选参数锁ID的值,如果没有指定则返回-1L
getTimeStamp() 检索Delete实例相关的时间戳
isEmpty() 检查FamilyMap是否含有任何条目,即用户所指定的想要删除的列族或者列
getFamilyMap() 这个方法可以获取用户通过deleteFamily()以及deleteColumn()/deleteColumns()添加的要删除的列和列族,返回的FamilyMap是使用列族名作为键,它的值是当前列族下要删除的列限定符的列表

例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<Delete> deletes) throws IOException

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

例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的数据。

之后,使用两个指定了列的删除操作,先后清除了row2colfam1: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实例自身的行键不同,所以在执行检查时会相应地抛出异常。这个方法允许用户做跨列族检查,例如,用户可以使用一组列控制筛选其他列。

这个例子还不足以证明检查并删除(check-and-delete)操作的重要性。在分布式系统中,很难可靠地完成这种操作,并且很难避免由外部锁带来的性能上的损失。换句话说,如果原子性由客户端保证,那么就要对整行数据加排他锁。如果在已加锁的情况下客户端崩溃,那么服务器端必须通过超时机制来对数据解锁。这样也需要额外的RPC请求,这比一次服务器端的操作要慢很多。

现在我们已经介绍过添加、检索和删除表中数据的操作了,不过前面介绍的操作都是基于单个实例或基于列表的操作。这一节将会介绍一些API调用,这些调用可以批量处理跨多行的不同操作。

下面的客户端API方法提供了批量处理操作。用户可能注意到这里引入了一个新的名为Row的类,它是PutGetDelete的祖先,或者说是父类。

void batch(List< Row> actions,Object[] results)
  throws IOException,InterruptedException
Object[] batch(List< Row> actions)
  throws IOException,InterruptedException

使用同样的父类允许在列表中实现多态,即放入以上3种不同的子类。这种调用跟之前介绍的基于列表的调用方法一样简单易用。下面的例3.16展示了如何把不同的操作融合为一个服务器端调用。

例3.16 使用批量处理操作的应用示例

private final static byte[] ROW1 = Bytes.toBytes("row1");
private final static byte[] ROW2 = Bytes.toBytes("row2");
private final static byte[] COLFAM1 = Bytes.toBytes("colfam1");❶
private final static byte[] COLFAM2 = Bytes.toBytes("colfam2");
private final static byte[] QUAL1 = Bytes.toBytes("qual1");
private final static byte[] QUAL2 = Bytes.toBytes("qual2");

List< Row> batch = new ArrayList< Row>();❷

Put put = new Put(ROW2);
put.add(COLFAM2,QUAL1,Bytes.toBytes("val5"));❸
batch.add(put);

Get get1 = new Get(ROW1);
get1.addColumn(COLFAM1,QUAL1);❹
batch.add(get1);

Delete delete = new Delete(ROW1);
delete.deleteColumns(COLFAM1,QUAL2);❺
batch.add(delete);

Get get2 = new Get(ROW2);
get2.addFamily(Bytes.toBytes("BOGUS"));❻
batch.add(get2);

Object[] results = new Object[batch.size()];❼
try {
  table.batch(batch,results);
} catch(Exception e){
  System.err.println("Error: " + e);❽
}

for(int i = 0;i < results.length;i++){
  System.out.println("Result[" + i + "]: " + results[i]);❾
}

❶使用常量可以方便重用。

❷创建列表存放所有操作。

❸添加一个Put实例。

❹添加一个针对不同行的Get实例。

❺添加一个Delete实例。

❻添加一个会失败的Get实例。

❼创建一个结果数组。

❽打印捕获的异常。

❾打印所有结果。

从控制台上可以看到以下结果:

Before batch call...
KV: row1/colfam1:qual1/1/Put/vlen=4,Value: val1
KV: row1/colfam1:qual2/2/Put/vlen=4,Value: val2
KV: row1/colfam1:qual3/3/Put/vlen=4,Value: val3

Result[0]: keyvalues=NONE
Result[1]: keyvalues={row1/colfam1:qual1/1/Put/vlen=4}
Result[2]: keyvalues=NONE
Result[3]: org.apache.hadoop.hbase.regionserver.NoSuchColumnFamilyException:
   org.apache.hadoop.hbase.regionserver.NoSuchColumnFamilyException:
   Column family BOGUS does not exist in ...

After batch call...
KV: row1/colfam1:qual1/1/Put/vlen=4,Value: val1
KV: row1/colfam1:qual3/3/Put/vlen=4,Value: val3
KV: row2/colfam2:qual1/1308836506340/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:60020,

与之前的例子一样,在执行批量处理之前,由于插入了测试行的数据,因此先打印了测试行的相关输出。首先输出的是表的原内容,然后是示例代码产生的输出,最后输出的是操作以后的表的内容。由输出结果可见,要删除的列被删除了,新添加的列也成功添加了。

Get操作的结果需要观察输出结果的中间部分,即示例代码产生的输出。那些以Result[n]n从0到3)开头的输出是actions参数中对应操作的结果。示例中第一个操作是Put,对应的结果是一个空的Result实例,其中没有KeyValue实例。这是批量处理调用返回值的通常规则,它们给每个输入操作返回一个最佳匹配的结果,可能的返回值如表3-7所示。

表3-7 batch()调用可能的返回结果

结果 描述
null 操作与远程服务器的通信失败
EmptyResult PutDelete操作成功后的返回结果
Result Get操作成功的返回结果,如果没有匹配的行或列,会返回空的Result
Throwable 当服务器端返回一个异常时,这个异常会按原样返回给客户端。用户可以使用这个异常检查哪里出了错,也许可以在自己的代码中自动处理异常

更进一步地观察控制台上输出的返回结果数组,你会发现空的Result实例打印出来是:keyvalues=NONEGet请求成功找到了相匹配的结果,返回一个对应的KeyValue实例。最后,有一个BOGUS列族的操作请求并返回一个异常供用户参考。

有两种不同的批量处理操作看起来非常相似。不同之处在于,一个需要用户输入包含返回结果的Object数组,而另一个由函数帮助用户创建这个数组。为什么需要两个方法呢?它们的语义有什么不同吗(如果有不同的话)?它们都可能抛出之前见到过的RetriesExhaustedWithDetailsException,所以关键的不同在于:

void batch(List<Row> actions,Object[] results)
     throws IOException,InterruptedException

上面这个方法让用户可以访问部分结果,而下面这个方法不行:

Object[] batch(List<Row> actions)
  throws IOException,InterruptedException

后面这个方法如果抛出异常的话,不会有任何返回结果,因为新结果数组返回之前,控制流就中断了。

而之前的方法会先向用户提供的数组中填充数据,然后再抛出异常。例3.16的代码就采用了前一种方法,并提交了结果数组。下面是一些batch()方法特性的汇总。

两种方法的共同点

get、put和delete都支持。如果执行时出现问题,客户端将抛出异常并报告问题。它们都不使用客户端写缓冲区。

void batch(actions, results)

能够访问成功操作的结果,同时也可以获取远程远程什么需要两个方法呢?他失败时的异常。

Object[] batch(actions)

只返回客户端异常,不能访问程序执行中的部分结果。

在检查结果之前,所有的批量处理操作都被执行了:即使用户收到一个操作的异常,其他操作也都已经执行了。不过,在最坏的情况下,可能所有操作都会返回异常。

另外,批量处理可以感知暂时性错误,例如NotServingRegionException(表明一个region已经被移动)会多次重试这个操作。用户可以通过调整hbase.client.retries.number配置项(默认是10)来增加或减少重试次数。

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

用户应该尽可能地避免使用行锁。就像在RDBMS中,两个客户端很可能在拥有对方要请求的锁时,又同时请求对方已拥有的锁,这样便形成了一个死锁。

锁超时之前,两个被阻塞的客户端会占用一个服务器端的处理线程(handler),而这个线程是一种十分稀缺的资源。如果在一个频繁操作的行上发生了这种情况,那么很多其他的客户端会占用掉其所有的处理线程,阻塞所有其他客户端访问这台服务器,导致这个region服务器将不能为其负责的region内的行提供服务。

重申一下:在不必要的情况下,尽量不要使用行锁。如果必须使用,那么一定要节约占用锁的时间!

比如,当使用put()访问服务器时,Put实例可以通过以下构造函数生成:

Put(byte[] row)

这个构造函数就没有RowLock实例参数,所以服务器会在调用期间创建一个锁。实际上,通过客户端的API,得不到这个生存期短暂的服务器端的锁的实例。

除了服务器端隐式加锁之外,客户端也可以显式地对单行数据的多次操作进行加锁,通过以下调用便可以做到:

RowLock lockRow(byte[] row) throws IOException
void unlockRow(RowLock rl) throws IOException

第一个调用lockRow()需要一个行键作为参数,返回一个RowLock的实例,这个实例可以供后续的Put或者Delete的构造函数使用。一旦不再需要锁时,必须通过unlockRow()调用来释放它。

每一个排他锁(unique lock),无论是由服务器提供的,还是通过客户端API传入的,都能保护这一行不被其他锁锁定。换句话说,锁必须针对整个行,并且指定其行键,一旦它获得锁定权就能防止其他的并发修改。

当一个锁被服务器端或客户端显式获取之后,其他所有想要对这行数据加锁的客户端将会等待,直到当前锁被释放,或者锁的租期超时。后者是为了确保错误进程不会占用锁太长时间或无限期占用。

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

通过添加以上代码,超时时间被设置为原来的两倍——120秒也就是2分钟。小心不要将这个值设得太大,因为每一个想获取被锁住的行的客户端都会阻塞并等待锁的恢复。

例3.17展示了如何在行上创建一个锁,该锁阻塞所有的并发读取。

例3.17 显式使用行锁

static class UnlockedPut implements Runnable { ❶
  @Override
   public void run() {
   try {
     HTable table = new HTable(conf,"testtable");
     Put put = new Put(ROW1);
     put.add(COLFAM1,QUAL1,VAL3);
       long time = System.currentTimeMillis();
     System.out.println("Thread trying to put same row now...");
     table.put(put);❷
     System.out.println("Wait time: " +
      (System.currentTimeMillis() - time)+ "ms");
    } catch(IOException e){
     System.err.println("Thread error: " + e);
  }
 }
}

System.out.println("Taking out lock...");
RowLock lock = table.lockRow(ROW1);❸
System.out.println("Lock ID: " + lock.getLockId());

Thread thread = new Thread(new UnlockedPut());❹
thread.start();

try {
  System.out.println("Sleeping 5secs in main()...");❺
  Thread.sleep(5000);
} catch(InterruptedException e){
 // ignore
}

try {
  Put put1 = new Put(ROW1,lock);❻
  put1.add(COLFAM1,QUAL1,VAL1);
  table.put(put1);

  Put put2 = new Put(ROW1,lock);❼
  put2.add(COLFAM1,QUAL1,VAL2);
  table.put(put2);
} catch(Exception e){
  System.err.println("Error: " + e);
} finally {
 System.out.println("Releasing lock...");❽
 table.unlockRow(lock);
}

❶使用一个异步的线程更新同一个行,但是不显式加锁。

put()调用会阻塞,直到锁被释放。

❸给整行加锁。

❹启动那个会阻塞的异步线程。

❺休眠一会儿,以阻塞其他写入操作。

❻在拥有锁的情况下创建Put

❼在拥有锁的情况下创建另外一个Put

❽释放锁,让阻塞线程继续执行。

执行这个例子代码时,应该能在控制台看到以下输出:

Taking out lock...
Lock ID: 4751274798057238718
Sleeping 5secs in main()...
Thread trying to put same row now...
Releasing lock...
Wait time: 5007ms
After thread ended...
KV: row1/colfam1:qual1/1300775520118/Put/vlen=4,Value: val2
KV: row1/colfam1:qual1/1300775520113/Put/vlen=4,Value: val1
KV: row1/colfam1:qual1/1300775515116/Put/vlen=4,Value: val3

从这个例子能看出,一个显示的锁是如何阻塞另一个使用隐式锁的线程的。主线程休眠了5秒,一醒来就调用了两次put(),分别将同一列设置为两个不同的数值。

主线程的锁一释放,阻塞线程的run()方法就继续执行并调用了第三个put。观察put操作在服务器端的执行情况,会觉得很有意思。读者可能注意到了,KeyValue实例的时间戳显示第三个put拥有最小的时间戳,虽然这个put表面上是最后执行的。这是因为线程中的put()调用是在两个主线程中的put()之前执行的,这之后主线程休眠了5秒。当put被发送到服务器时,如果它的时间戳没有被显式指定,服务器端会帮它设定时间戳,同时试图获得这一行的锁。但是示例代码中主线程已经获得了该行的锁,因此服务器端的处理一直等待了5秒多,锁被释放才得已继续。从上面的输出可以看出,主线程中两个put调用的执行以及行的解锁只花费了7毫秒的时间。

Get需要锁吗?

修改行时锁定行是有意义的,那么获取数据时是否需要加锁呢?Get类有一个构造器允许用户指定一个显式的锁:

Get(byte[] row,RowLock rowLock)

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

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

当用户试图使用之前申请的显式锁,但锁的租约已经超时并恢复,用户将会从服务器得到一个以UnknownRowLockException形式报告的错误。这个异常告诉用户服务器已经废弃了用户尝试使用的锁。用户应该在代码中丢弃这个锁,然后请求一个新的锁再试图恢复锁定状态。

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

扫描操作的使用跟get()方法非常类似。同样,和其他函数类似,这里也提供了Scan类。但是由于扫描操作的工作方式类似于迭代器,所以用户无需调用scan()方法创建实例,只需调用HTablegetScanner()方法,此方法在返回真正的扫描器(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参数用来限定读取到何处停止。

扫描操作有一个特点:用户提供的参数不必精确匹配这两行。扫描会匹配相等或大于给定的起始行的行键。如果没有显式地指定起始行,它会从表的起始位置开始获取数据。

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

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

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

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

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

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。

表3-8 Scan类的其他方法概览

方法 描述
getStartRow()/getStopRow() 查询当前设定的值
getTimeRange() 检索Get实例指定的时间范围或相关时间戳。注意,当需要指定单个时间戳时,API会在内部通过setTimeStamp()TimeRange实例的起止时间戳设为传入值,所以Get类中此时已经没有getTimeStamp()方法了
getMaxVersions() 返回当前配置下应该从表中获取的每列的版本数
getFilter() 可以使用特定的过滤器实例,通过多种规则来筛选列和单元格。使用这个方法,用户可以设定或查看Scan实例的过滤器成员。没有设置的话则返回null,详情参见4.1节
setCacheBlocks()/getCacheBlocks() 每个HBase的region服务器都有一个块缓存,可以有效地保存最近访问过的数据,并以此来加速之后相邻信息的读取。不过在某些情况下,例如全表扫描,最好能避免这种机制带来的扰动。这个方法能够控制本次读取的块缓存机制是否启效
numFamilies() 快捷地获取FamilyMap大小的方法,包括用addFamily()addColumn()方法添加的列族和列
hasFamilies() 检查是否有添加过列族或列
getFamilies()/setFamilyMap()/getFamilyMap() 这些方法能够让用户直接访问addFamily()addColumn()添加的列族和列。FamilyMap中键是列族的名称,键对应的值是特定列族下列限定符的列表。getFamilies()方法返回一个只包含列族名的数组

一旦设置好了Scan实例,就可以调用HtablegetScanner()方法,获得用于检索数据的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把行键补齐,这样扫描出来结果顺序更有可读性。这些都是在你的控制下完成的,所以请仔细设计行键。

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

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

void setScannerCaching(int scannerCaching)
int getScannerCaching()
< 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异常。

例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列。

表3-9 示例设置及其影响

缓存 批量处理 Result个数 RPC次数 说明
1 1 200 201 每个列都作为一个Result实例返回。最后还多一个RPC确认扫描完成
200 1 200 2 每个Result实例都只包含一列的值,不过它们都被一次RPC请求取回(加一次完成检查)
2 10 20 11 批量参数是一行所包含的列数的一半,所以200列除以10,需要20个Result实例。同时需要10次RPC请求取回(加一次完成检查)
5 100 10 3 对于一行来讲,这个批量参数太大了,所以一行的20列都被放入了一个Result实例中。同时缓存为5,所以10个Result实例被两次RPC请求取回(加一次完成检查)
5 20 10 3 同上,不过这次的批量值与一行的列数正好相同,所以输出与上面一种情况相同
10 10 20 3 这次把表分成了较小的Result实例,但使用了较大的缓存值,所以也是只用了两次RPC请求就取回了数据

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

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

此外,还需要一些请求来打开和关闭扫描器。用户或许需要把这两次请求也考虑在内。

图3-2展示了缓存和批量两个参数如何联动。图3-2中有一个包含9行数据的表,每行都包含一些列。使用了一个缓存为6、批量大小为3的扫描器,读者可以观察到需要3个PRC请求来传送数据(虚线圆角方框)。

图3-2 扫描器缓存和批量两个参数控制RPC的次数

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

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

在深入介绍客户端可以利用的特性之前,让我们先介绍一下HBase和其客户端API提供的各种特性或功能。

客户端API是由HTable类的实例提供的,用户可以用它来操作HBase表。除了之前提到过的一些主要特性外,还有以下一些值得注意的方法。

void close()

这个方法之前提到过,不过为了它的完整性和重要性,我们在这儿要重新讨论一下。用户使用完一个HTable实例之后,需要调用一次close()。这个方法会刷写所有客户端缓冲的写操作:close()方法会隐式调用flushCache()方法。

byte[] getTableName()

这是一个获取表名称的快捷方法。

Configuration getConfiguration()

这个方法允许用户访问HTable实例中使用的配置。因为得到的是Configuration实例的引用,所以用户修改的参数(针对客户端的)都会立即生效。

HTableDescriptor getTableDescriptor()

这个方法会在5.1.1节进行详细介绍,每个表都使用一个HTableDescriptor实例来定义自己的表结构。用户可以使用这个方法访问这个表的底层结构定义。

static boolean isTableEnabled(table)

HTable类有4个不同的静态辅助方法,它们都需要一个显式的配置和一个表名。如果没有提供显式的配置,方法会找到classpath下的配置文件,使用默认值创建一个隐式的配置。这个方法可以检查表在ZooKeeper中是否被标志为启用。

byte[][] getStartKeys()
byte[][] getEndKeys()
Pair< byte[][], byte[][]> getStartEndKeys()

这些方法让用户可以查看当前表的物理分布情况,不过这个分布很可能在添加一些数据之后发生变化。这些方法返回了表的所有region的起始行键或/和终止行键。它们以二维字节数组形式返回,用户可以使用Bytes.toStringBinary()方法打印这些键。

void clearRegionCache()
HRegionLocation getRegionLocation(row)
Map< HRegionInfo, HServerAddress> getRegionsInfo()

这些方法可以帮助用户获取某一行数据的具体位置信息,或者说这行数据所在的region信息,以及所有region的分布信息。用户也可以在必要的时候清空缓存的region位置信息。这些方法可以让高级用户了解并使用这些信息,例如,控制集群流量或者调整数据的位置。

void prewarmRegionCache(Map< HRegionInfo, HServerAddress> regionMap)
static void setRegionCachePrefetch(table, enable)
static boolean getRegionCachePrefetch(table)

这又是一组高级方法。在1.4.5节提到过,可以先预取region位置信息来避免耗时较多的操作(查找每行数据所属region地址直到本地region地址缓存相对稳定)。使用这些方法,用户可以先获取一个region的信息表来预热一下region缓存(例如,用户可以使用getRegionsInfo()访问这个region的信息表,然后再处理它),也可以把整张表的预取功能打开。

前面介绍过,如何使用这个类转化Java的数据类型,例如,将Stringlong转化为HBase原生支持的原始字节数组。关于这个类和它的功能还有一些值得一提。

大部分方法都有3种形式,例如:

static long toLong(byte[] bytes)
static long toLong(byte[] bytes,int offset)
static long toLong(byte[] bytes,int offset,int length)

用户可以输入一个字节数组,或者一个字节数组再加一个偏移值,或者一个字节数组、一个偏移值和一个长度值。具体使用哪一种方法取决于最初这个字节数组是怎么生成的。如果这个字节数组之前是由toBytes()方法生成的,用户就可以安全地使用第一种形式的方法将其转化回来,只需送入这个字节数组而不需要其他额外信息。整个数组的内容都是转换过来的值。

不过,API和HBase内部都把数据存储为一个较大的数组,例如,使用下面的方法:

static int putLong(byte[] bytes,int offset,long val)

这个方法允许用户把一个long值写入一个字节数组的特定偏移位置。用户可以使用后面的两种toLong()方法存取这种较大的字节数组的数据。

Bytes类支持以下原生Java类型到字节数组的互转:Stringbooleanshortintlongdoublefloat。除了之前介绍的方法外,还有一些值得一提的方法列举在表3-10中。

表3-10 Bytes类提供的其他方法概述

方法 描述
toStringBinary() toString()方法非常相似,这个方法可以安全地把不能打印的信息转换为人工可读的十六进制数。如果用户不清楚字节数组中的内容,就可以使用这个方法把内容打印出来,比如打印到控制台或日志文件中
compareTo()/equals() 这个方法让用户可以对两个byte[](即字节数组)进行比较。前者返回一个比较结果,后者返回一个布尔值,表示两个数组是否相等
add()/head()/tail() 用这些方法可以把两个字节数组连接在一起形成一个新的数组,或者可以取到字节数组头或尾的一部分
binarySearch() 在用户给定的字节数组中二分查找一个目标值,该方法可以在字节数组上对用户要查找的值和键进行操作
incrementBytes() 一个long类型数据转化成的字节数组与long的数据相加并返回字节数组,用户可以使用负数参数进行减法

Bytes类与Java提供的ByteBuffer类的功能有所重叠。不同的是,前者所有的操作都不需要创建一个新的实例。这是考虑到某些情况下的性能优化,因为HBase内部使用了其中的许多方法并多次调用,不创建实例也就避免了许多不必要的垃圾回收。

完整的文档请参阅基于JavaDoc的API文档

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

全局唯一标识符(Universally Unique Identifier)细节请参阅http://en.wikipedia.org/wiki/Universally_unique_identifier

见维基百科的“Unix time”(http://en.wikipedia.org/wiki/Unix_epoch)。

完整的描述请参阅API文档中KeyValue类的介绍(http://hbase.apache.org/apidocs/org/apache/hadoop/hbase/KeyValue.html)。

参见维基百科的“Remote procedure call”(http://en.wikipedia.org/wiki/Remote_procedure_call)。

为了方便阅读,相关细节被分为多组,组之间用空行隔开。

见链接http://en.wikipedia.org/wiki/Multiversion_concurrency_control

扫描操作与不可回滚的游标操作相似。用户需要先声明,然后打开,并获取数据,最后关闭数据库游标。不过扫描操作没有声明这一步,否则扫描操作和游标操作的使用方法就是一样的了。参见维基百科中的Cursors。

参阅在线文档Bytes一节(http://hbase.apache.org/apidocs/org/apache/hadoop/hbase/util/Bytes/html)。


对于高级用户或乐于尝试的初级开发者来说,充分理解他们所选择系统的内部运行机制是一件很有益处的事情。本章将深入介绍HBase各个组成部分以及它们之间如何协同工作。

我们详细分析架构前,会首先介绍典型的RDBMS和其他非关系型数据库底层存储结构之间的不同。我们会重点介绍B树和B+树,这两种数据结构被关系型存储引擎广泛采用,同时还有LSM树(Log-Structured Merge Tree),它在某种程度上构成了BigTable的底层存储架构,这些曾在1.4节中讨论过。

B+树的一些特性使其能够通过主键对记录进行高效插入、查找以及删除。它表示为一个动态、多层并有上下界的索引。同时要注意维护每一段(也被称作页表)所包含的主键数目。分段B+树的效果远好于二叉树的数据划分,其大大减少了查询特定主键所需的I/O操作。

除此以外,B+树能够提供高效的范围扫描功能,这得益于它的叶节点相互连接并且按主键有序,扫描时避免了耗时的遍历树操作。这也是B+树被关系型数据库用作索引的原因之一。

在一棵B+树索引中,用户可以得到页表(在一些系统中被称作块)级别的位置信息,例如,叶节点页表如下:

[link to previous page]
[link to next page]
key1 →rowid
key2 →rowid
key3 →rowid

为了添加一个新的索引项key1.5,需要更新叶节点页表并添加一个新的索引项key1.5并指向对应的rowid。对于一个有固定大小的页表来说,页表大小超过容量限制时就会产生问题。这时需要将该页表拆分成两个新的页表,同时更新原页表的父节点,并使其指向刚创建的两个新节点。可以把图8-1当做一个例子,向图中的一个满页表添加新的键时会拆分页表。

图8-1 一个满页表的B+树

现在的问题是,新建的两个页表在硬盘中并不一定是彼此相邻的。如果要进行一次从key1key3的范围查询,则需要读取两个在磁盘上不连续甚至可能相隔很远的叶节点页表。这也是为什么我们在大部分基于B+树的设计中都能找到一组被称为OPTIMIZE TABLE(优化表)命令的原因,B+树这种数据组织方式只是简单地按顺序把表重写,从而使表的范围查询变成了磁盘的多段连续读取。

LSM树(log-structured merge-tree)则按另一种方式组织数据。输入数据首先被存储在日志文件,这些文件内的数据完全有序。当有日志文件被修改时,对应的更新会被先保存在内存中来加速查询。

当系统经历过许多次数据修改,且内存空间被逐渐被占满后,LSM树会把有序的“键-记录”对写到磁盘中,同时创建一个新的数据存储文件。此时,因为最近的修改都被持久化了,内存中保存的最近更新就可以被丢弃了。

存储文件的组织与B树相似,不过其为磁盘顺序读取做了优化,所有节点都是满的并按页存储。修改数据文件的操作通过滚动合并完成,也就是说,系统将现有的页与内存刷写数据混合在一起进行管理,直到数据块达到它的容量。

图8-2展示了在内存中多个块存储归并到磁盘的过程,合并写入会产生一个新的结果块,最终多个块被合并为更大块。

图8-2 LSM树中的多页数据块迭代合并的过程

多次数据刷写之后会创建许多数据存储文件,后台线程就会自动将小文件聚合成大文件,这样磁盘查找就会被限制在少数几个数据存储文件中。磁盘上的树结构也可以拆分成独立的小单元,这样更新就可以被分散到多个数据存储文件中。所有的数据存储文件都按键排序,所以没有必要在存储文件中为新的键预留位置。

查询时先查找内存中的存储,然后再查找磁盘上的文件。这样在客户端看来数据存储文件的位置是透明的。

删除是一种特殊的更改,当删除标记被存储之后,查找会跳过这些删除过的键。当页被重写时,有删除标记的键会被丢弃。

此外,后台运维过程可以处理预先设定的删除请求。这些请求由TTL(time-to-live)触发,例如,当TTL设为20天后,合并进程会检查这些预设的时间戳,同时在重写数据块时丢弃过期的记录。

B树和LSM树最主要的区别在于它们的结构如何利用硬件,特别是磁盘。

查找与排序和合并的性能瓶颈

我们的大规模计算策略受制于磁盘传输,尽管CPU、RAM和磁盘大小每18~24个月翻一番,但数据查找的速度每年只能提高5%。

如上面讨论的,在数据库中有两种范式,一种是利用存储的随机查找能力,另一种是利用存储的连续传输能力。随机查找在RDBMS中是由B-树和B+树数据结构组织。它工作的速度受制于磁盘的寻道速度,每次查找需要访问磁盘log(N)次。

另一方面,存储的连续传输能力被LSM树使用,并以一定的传输速率排序和合并文件,需要执行log(updates)次操作。在以下给定数值的情况下,其性能对比如下:

  • 10 MB/s的传输带宽;

  • 10 ms的磁盘寻道时间;

  • 每条目100字节(100亿条目);

  • 每页10 KB(10亿页)。

更新1%条目(100000000)所需的时间:

  • 使用随机B-树更新需要1000天;

  • 使用批量B-树更新需要100天;

  • 使用排序和合并需要1天。

由此我们能断定,与利用存储的连续传输能力相比,大规模数据查找非常低效。

比较B+树和LSM树的意义在于理解它们的相对优势和不足。在没有太多的修改时,B+树表现得很好,因为这些修改要求执行高代价的优化操作以保证查询能在有限时间内完成。在任意位置添加数据的规模越大、速度越快,这些页成为碎片的速度就越快。最后,用户写入的速度可能比优化后重写文件的处理速度更快。由于更新和删除以磁盘寻道的速率完成,这就强制用户就范于磁盘提供的较差的性能指标。

LSM树以磁盘传输速率工作并能较好地扩展以处理大量的数据。它们使用日志文件和内存存储来将随机写转换成顺序写,因此也能保证稳定的数据插入速率。由于读和写独立,因此在这两种操作之间没有冲突。

由于存储数据的布局较优,查询一个键需要的磁盘寻道次数在一个可预测的范围内,并且读取与该键连续的任意数量的记录都不会引发任何额外的磁盘寻道。一般来说,基于LSM树的系统强调的是成本透明:假如有5个存储文件,一个访问需要最多5次磁盘寻道。反观关系型数据库,即使在存在索引的情况下,它也没有办法确定一次查询需要的磁盘寻道次数。

最终,HBase与BigTable一样,都是基于LSM树的系统。下一节将解释存储架构,并将涉及本书之前章节中的内容。

HBase一个很少为人知的内容就是数据存储,因为大部分用户都不会接触到它的底层存储结构,不过如果用户想使用一些进阶的配置选项就有必要了解这些内容了。第11章列举了一些常用的进阶配置,同时附录A中有一份完整的列表。

用户需要了解底层存储结构的另一个原因是,当某些原因导致系统出现重大问题时,用户可能需要恢复HBase中的数据。此时用户就需要知道数据存在何处,以及如何从HDFS中访问这些内容。当然,这种情况不应当发生,但在实际的系统中,任何异常情况都是可能的。

了解HBase存储层中大量移动块的第一步是画一个顶层结构图。图8-3展示了HBase是如何与Hadoop文件系统协作完成数据存储的。

图8-3 HBase如何透明地操作存储在HDFS上文件的概览

从这张图可以看出HBase主要处理两种文件:一种是预写日志(Write-Ahead Log,WAL),另一种是实际的数据文件。这两种文件主要由HRegionServer管理。在某些情况下,HMaster也可以进行一些底层的文件操作(在0.92.0中与0.90.x中稍有不同)。当存储数据到HDFS中时,用户可能注意到实际的数据文件会被切分成更小的块。也正是这一点,用户可以配置系统来更好地处理较大或较小的文件。更多的信息请参阅8.2.4节。

一个基本的流程是客户端首先联系ZooKeeper子集群(quorum)(一个由ZooKeeper节点组成的单独集群)查找行键。上述过程是通过ZooKeeper获取含有-ROOT-的region服务器名(主机名)来完成的。通过含有-ROOT-的region服务器可以查询到含有.META.表中对应的region服务器名,其中包含请求的行键信息。这两处的主要内容都被缓存下来了,并且都只查询一次。最终,通过查询.META.服务器来获取客户端查询的行键数据所在region的服务器名。

一旦知道了数据的实际位置,即region的位置,HBase会缓存这次查询的信息,同时直接联系管理实际数据的HRegionServer。所以,之后客户端可以通过缓存信息很好地定位所需的数据位置,而不用再次查找.META.表,参见8.5节。

HRegionServer负责打开region,并创建对应的HRegion实例。当HRegion被打开后,它会为每个表的HColumnFamily创建一个Store实例,这些列族是用户之前创建表时定义的。每个Strore实例包含一个或多个StoreFile实例,它们是实际数据存储文件HFile的轻量级封装。每个Store还有其对应的一个MemStore,一个HRegionServer分享了一个HLog实例(参见8.3节)。

当用户向HRegionServer发起HTable.put(Put)请求时,其会将请求交给对应的HRegion实例来处理。第一步是要决定数据是否需要写到由HLog类实现的预写日志中。WAL是标准的Hadoop SequenceFile,并且存储了HLogKey实例。这些键包括序列号和实际数据,所以在服务器崩溃时可以回滚还没有持久化的数据。

一旦数据被写入到WAL中,数据就会被放到MemStore中。同时还会检查MemStore是否已经满了,如果满了,就会被请求刷写到磁盘中去。刷写请求由另外一个HRegionServer的线程处理,它会把数据写成HDFS中的一个新HFile。同时也会保存最后写入的序号,系统就知道哪些数据现在被持久化了。

关闭前预刷写

memstore被刷写到磁盘的第二个理由是:预刷写(prefushing)。当region服务器被要求关闭时,会首先检查memstore,任何大于配置值hbase.hregion.preclose. flush.size(默认值为5 MB)的memstore会刷写到磁盘,然后在最后一轮阻塞正常访问的刷写后关闭region。

另一方面,关闭region服务器会强制所有的memstore被刷写到磁盘,而不会关心memstore是否达到了配置的最大值,可以使用配置项hbase.hregion.memstore. flush.size(默认值为64 MB)或者通过创建表(查看5.1.2节的“文件大小限制”)来进行设置。一旦所有的memstore都被刷写到了磁盘,region会被关闭,且在转移到其他region服务器时不会重做WAL。

使用额外的一轮预刷写会提高region的可用性:在预刷写时,服务器与region仍旧可用,这类似于通过API或Shell命令调用刷写(flush)。当剩下的比较小的memstore完成了第二轮刷写时,此时会停止所有请求。这一轮刷写会保存预刷写过程中的所有修改,以保证服务器可以干净地退出。

HBase使用一个HDFS中可配置的根目录,默认设为"/hbase"。12.3.1节展示了如何使用不同根目录来让多个HBase集群共享HDFS。用户可以使用hadoop dfs-lsr命令来查看HBase的目录结构。在这之前,我们先建立一个包含多个region的表。

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 seconds 0 row(s)in 0.0280 seconds 0 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 seconds 0 row(s)in 0.0280 seconds 0 row(s)in 0.0260 seconds ... 

flush命令可以将内存中的数据写到存储文件中,否则就必须等插入的数据达到配置的刷写大小。最后的循环使用put命令来再次填充WAL。经过这些操作后,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根目录下,另一类位于根目录中的表目录下。

1.根级文件

第一组文件是被HLog实例管理的WAL文件,这些日志文件被创建在HBase的根目录下一个名为.logs的目录中。对于每个HRegionServer,日志目录中都包含一个对应的子目录。在每个子目录中有多个HLog文件(因为日志滚动)。一个region 服务器的所有region共享同一组HLog文件。

一个有趣的现象是,由于文件最近才被创建,所以日志文件大小被报告为0。这个现象很典型,由于HDFS使用内置的append机制来追加写入此文件,所以只有等到文件大小达到一个完整的块时,文件对用户才是可见的——包括hadoop dfs–lsr命令。虽然put操作的数据被安全持久化了,但当前写入日志文件的数据大小是稍微偏离的。

在等待一个小时后,日志文件被滚动(参见8.3.6节),这个时间是由hbase.regionserver.logroll.period配置属性(默认设置为60分钟)控制的。此时,由于日志文件被关闭,HDFS能列出其正确的大小,紧接着它的下一个新日志文件的大小又从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目录中。当条件满足配置上的阀值会触发日志的滚动。在10分钟(默认情况下)后,旧的日志文件将被master删除,这是通过hbase.master.logcleaner.ttl属性设置的。master每分钟(默认情况下)检查一次这些文件,这是通过hbase.master.cleaner.interval属性设置的。

hbase.idhbase.version文件包含集群的唯一ID和文件格式版本信息。

$ hadoop dfs -cat /hbase/hbase.id
$e627e130-0ae2-448d-8bb5-117a8af06e97
$ hadoop dfs -cat /hbase/hbase.version
7

它们在内部使用,且携带的信息不多。此外,随着时间的推移又会有更多根级别的目录出现。splitlog.corrupt文件夹分别被用来存储日志拆分过程中产生的中间拆分文件和损坏的日志。例如:

   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

这个例子中没有损坏的日志文件,所以仅显示了一个中间拆分文件。日志拆分过程在8.3.7节中有解释。

2.表级文件

在HBase中,每张表都有自己的目录,其位于文件系统中HBase根目录下。每张表目录包括一个名为.tableinfo的顶层文件,该文件存储表对应序列化后的HTableDescriptor(详情请查看5.1.1节)。其中包括表和列族的定义,同时其内容也可以被读取,例如,用户可以使用一些工具查看表的大致结构。.tmp目录中包含一些临时数据,例如,当更新.tableinfo文件时生成的临时数据就会被存放到该目录中。

3.region级文件

在每张表的目录里面,表模式中每个列族都有一个单独的目录。目录的名字是一部分region名字的MD5散列值。例如,下面的内容是在点击master网页界面中用户表区域的testtable链接时得到的。

testtable,row-500,1309812163930.d9ffc3a5cd016ae58e23d7a6cb937949.

MD5散列值是d9ffc3a5cd016ae58e23d7a6cb937949,它是通过编码region名字得到的,例如,testtable,row-500,1309812163930.,末尾的点是这个region名字的一部分:它表示这是一个包含散列值的新样式名字,在以前的HBase版本中,region名字不包含散列值。

.META.,,1.1028785192

它们在磁盘目录的region名字的编码也不同:它们用Jenkins散列值来编码region的名字。

散列能保证目录名称不违背文件系统的命名规则:它们不能包含任何特殊字符,例如,用来划分路径的斜线(“/”)。region文件的总体结构是:

/< hbase-root-dir>/< tablename>/< encoded-regionname>/< column-family>/< filename>

用户可以在每个列族目录中看到实际的数据文件,在8.2.4节有更详细的介绍。这些文件的名字仅仅是一个由Java内置的随机数生成器生成的随机数。代码能够智能地检查出发生的碰撞,即新生成号码对应的文件已经存在。程序将一直循环,直到找到一个未使用的数字。

region目录中也有一个.regioninfo文件,这个文件包含了对应region的HRegionInfo实例序列化后的信息。与.tableinfo文件类似,它能被外部工具用来查看region的相关信息。例如,HBase的hbck工具就用它来检查并生成元数据表中丢失的条目。

可选的.tmp目录是按需求创建的,它被用来存放临时文件,例如,一次合并的重写文件。一旦这个过程完成,这些临时生成的文件通常会被移到region目录中。在极少的情况下,用户可能发现遗留的文件,这些文件将在region重新打开时被清理掉。

在WAL回放时,任何未提交的修改都会被写入到每个region的一个单独的文件中。以上是第一步(查看本节的“Root级文件”的splitlog目录),然后如果日志拆分过程已经成功完成,这些文件将被自动移动到临时的recovered.edits目录中。当region被打开时,region服务器将会看到需要恢复的文件,并且回放其中相应的条目。

一旦region超过了配置中region大小的最大值,region就需要拆分,其会创建一个对应的splits目录,它被用来临时存放两个子region相关的数据。如果拆分过程成功(通常这个过程持续几秒或更短时间),之后它们会被移动到表目录中,并形成两个新的region,每个region代表原始region的一半。

换而言之,当用户看见一个region的目录中没有.tmp目录,这就意味着还没有进行过压缩操作。如果没有recovered.edits目录,这就意味着WAL还没有进行过回放操作。

在HBase0.90.x之前的版本中还有些额外的文件,这些文件现在已经不再使用了。一个是oldlogfile.log文件,它包含给定region已经回放过的WAL。oldlogfile.log.old文件(注意额外的.old后缀名)表示当一个新的oldlogfile.log被放进来时已经存在一个旧的文件。

在旧版本的HBase中,另外一个值得注意的文件是compaction.dir,它现在已经被.tmp目录代替。

以上总结了在HBase根文件夹中经常出现的各种目录的列表。region拆分的过程还会产生一些中间文件,这些文件将在下一节单独讨论。

4.region拆分

当一个region里的存储文件增长到大于配置的hbase.hregion.max.filesize大小或者在列族层面配置的大小时,region会被一分为二。这个过程通常非常迅速,因为系统只是为新region(也称作孩子)创建了两个对应的文件,每个region是原始region(称作父亲)的一半。

region 服务器通过在父region中创建splits目录来完成这个过程。接下来关闭该region,此后这个region不再接受任何请求。

然后region服务器通过在splits目录中设立必需的文件结构来准备新的子region(使用多线程),包括新region目录和参考文件。如果这个过程成功完成,它将把两个新region目录移到表目录中。.META.表中父region的状态会被更新,以表示其现在拆分的节点和子节点是什么。以上过程可以避免父region被意外重新打开。.META.表中的内容大致如下:

row: 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如何被拆分成两个region。在info:regioninfo列值中的SPLIT => true也表示region现在正在拆分成叫做info:splitAinfo:splitB的region。

引用文件的名字是另外一个随机数字,不过其后带有被引用的region的散列值作为后缀,例如:

/hbase/testtable/d5a127167c6e2dc5106f066cc84506f8/colfam1/ \
6630747383202842155.d9ffc3a5cd016ae58e23d7a6cb937949

上面所示的引用文件代表散列值为d9ffc3a5cd016ae58e23d7a6cb937949的原始region的一半,这个region也出现在上面的例子中。参考文件仅包含很少的信息:原始region被拆分处的键以及它是否为表顶部或者底部的引用文件。值得注意的是,这些引用文件后面会被HalfHFileReader类(这个在前面的概述中被省略了,因为它只是短暂地被使用)用来读原始region的数据文件。

两个子region都准备好后,将会被同一个服务器并行打开。打开的过程包括更新.META.表,这样可以把两个region像其他region一样作为可用region列出来。之后这两个region会上线并开始服务请求。

同时也会初始化为两个region并对region中的内容执行合并,合并过程在替换引用文件之前会把父region的存储文件异步重写到两个子region中。以上过程会在子region的.tmp目录中执行。一旦生成了重写之后的文件,它们将自动取代引用文件。

最终父region会被清理掉,这意味着它在.META.表中的表项会被移除,并且它在磁盘上所有的文件都会被删除。最后,master被告知关于拆分的情况,并且可以由于负载均衡而把新region移动到其他region服务器上。

5.合并

存储文件会被后台的管理进程仔细地监控起来以确保它们处于控制之下。随着memstore的刷写会生成很多磁盘文件。如果文件的数目达到阈值,合并(compaction)过程将把它们合并成数量更少的体积更大的文件。这个过程持续到这些文件中最大的文件超过配置的最大存储文件大小,此时会触发一个region拆分。

压缩合并有两种,即minor和major。minor合并负责重写最后生成的几个文件到一个更大的文件中。文件数量是由hbase.hstore.compaction.min(之前被称做hbase. hstore.compactionThreshold,尽管已经废弃了,但是这个属性依然可用)属性设置的。它的默认值为3,并且最小值需要大于或等于2。过大的数字将会延迟minor合并的执行,同时也会增加执行时消耗的资源及执行的时间。

minor合并可以处理的最大文件数量默认为10,用户可以通过hbase.hstore.compaction.max来配置。hbase.hstore.compaction.min.size(默认设置为region的memstore刷写大小)和hbase.hstore.compaction.max.size(默认设置为Long.MAX_VALUE)配置项属性进一步减少了需要合并的文件列表。任何比最大合并大小大的文件都会被排除在外。最小合并大小的功能稍有不同:它是一个阈值,而不是每个文件的限制。它包括所有小于限制的文件,直到达到每次压缩允许的总文件数量。

图8-4显示了所有小于最小压缩阈值的文件都被包括在了压缩过程中。

图8-4 最小压缩阈值限制下的文件集

算法使用hbase.hstore.compaction.ratio(默认为1.2,或者120%)来确保在选择过程中包括足够的文件。经过跟新文件总的存储文件比较之后,这个比例仍将选择达到那个值的文件。评估总是按照从老文件到新文件的顺序来进行的,这样可以确保更老的文件首先被合并。这些属性的组合允许用户微调一个minor合并包括文件的数量。

HBase支持的另外一种合并是major合并:它们把所有文件压缩成一个单独的文件。在执行压缩检查时,系统自动决定运行哪种合并。在memstore被刷写到磁盘后会触发检查,或在Shell命令compactmajor_compact之后触发检查,或是相应API在被调用后触发检查,抑或是被一个异步的后台进程触发后。region服务器运行这个线程,而其功能由CompactionChecker类实现,它以一个固定的周期触发检查,这个周期由hbase.server.thread.wakefrequency参数控制(乘以hbase.server.thread.wakefrequency.multiplier,设为1000,这样它的执行频率不会像其他基于线程的任务那么频繁)。

除非用户使用Shell命令major_compact或者使用majorCompact()这个API,将强制运行major合并,否则服务器会首先检查上次运行到现在是否达到hbase. hregion.majorcompaction(设为24小时)指定的时限。hbase.hregion.majorcompaction.jitter(设为0.2,即20%)参数会将所有存储的时间周期分开。如果没有这个抖动,所有的存储文件都将在每24小时的同一时间运行一个major合并。参见11.4.1节,用户可了解以上方法的问题以及如何更好地进行管理。

如果还没有达到major合并的执行周期,系统会选择minor合并执行。基于以上配置属性,服务器将检查是否有足够的文件供minor合并执行,如果有就继续。

当minor合并包括所有的存储文件,且所有文件均未达到设置的每次压缩的最大文件数时,minor合并可能被提升成major合并。

实际的存储文件功能是由HFile类实现的,它被专门创建以达到一个目的:有效地存储HBase的数据。它们基于Hadoop的TFile,并模仿-Google的BigTable架构使用的SSTable格式。曾在HBase中使用过的Hadoop的MapFile类被证明性能不够好。图8-5显示了文件格式的详细信息。

图8-5 HFile结构

这些文件是可变长度的,唯一固定的块是File Info块和Trailer块。如图8-5所示,Trailer有指向其他块的指针。它是在持久化数据到文件结束时写入的,写入后即确定其成为不可变的数据存储文件。Index块记录Data和Meta块的偏移量。Data和Meta块实际上都是可选的,但是考虑到HBase如何使用数据文件,在存储文件中用户几乎总能找到Data块。

块大小是由HColumnDescriptor配置的,而该配置可以在创建表时由用户指定或者使用比较合理的默认值。下面是在master的Web界面中显示的例子:

{NAME => 'testtable',FAMILIES => [{NAME => 'colfam1',
BLOOMFILTER => 'NONE',REPLICATION_SCOPE => '0',VERSIONS => '3',
COMPRESSION \=> 'NONE',TTL => '2147483647',BLOCKSIZE => '65536',
IN_MEMORY => 'false',BLOCKCACHE => 'true'}]}

这里的默认值是64 KB(或655 356字节)。下面是HFile的JavaDoc解释:

“块大小的最小值。对于一般的应用,我们建议将最小的块大小设置为8 KB~1 MB。如果应用主要涉及顺序访问,较大的块大小将更加合适。不过这会降低随机读性能(因为需要解压缩更多的数据)。较小的块更有利于随机数据访问,不过同时也需要更多的内存来存储块索引,并且可能创建过程也会变得更慢(因为我们必须在每个数据块结束的时候刷写压缩流,这会导致了一个FS I/O刷写)。此外,由于压缩解码器存在内部缓存,导致可能的最小的块大小是20 KB~30 KB。”

每个块都包含一个magic头部和一定数量的序列化的KeyValue实例(详见8.2.5节,并查看它们的格式)。如果用户没有使用压缩算法,每个块大小和配置的块大小差不多。但这并不是什么精密科学,写入程序必须适应用户写入的数据:如果用户存储了一个比块大小更大的KeyValue实例,则HBase也必须接受它。不过即使是较小的值,对于块大小的检查也是在最后一个值写入后才进行的,所以在实际情况中,大部分块会稍大。

当使用压缩算法时,用户对于块大小的控制力将更弱。压缩解码器在能够自己控制获取的数据量时才能达到最有效的压缩比率。例如,把块大小设置为256 KB,并使用LZO压缩算法,系统将写更小的块来适应LZO的内部缓冲区大小。

HBase不知道用户是否选择了一个压缩算法:它将按照块大小的限制来写原始数据,并尽量让原始数据的大小与这个限制接近。如果用户启用了压缩,则保存到磁盘上的数据将更少。这意味着最终的存储文件由相同数量的块组成,但是由于每一个块都更小,所以总大小也更小。

在HDFS中,文件的默认块大小是64 MB,这个是HFile默认块大小的1024倍。因此HBase存储文件的块与Hadoop的块之间没有匹配关系。事实上,这两种块类型之间根本没有相关性。HBase把它的文件透明地存储到文件系统中,而HDFS也使用块来切分文件仅仅是一个巧合,并且HDFS不知道HBase存储的是什么,它只能看到二进制文件。图8-6展示了HFile的内容怎样在整个HDFS块中进行分布。

图8-6 很多更小的HFile块透明地存储在两个更大的HDFS块中

有时候,用户有必要绕过HBase并直接访问一个HFile,例如,检查它的健康程度,或者转存它的内容。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

上面的例子展示了输出的形式(精简过):

$ ./bin/hbase org.apache.hadoop.hbase.io.hfile.HFile -f \
/hbase/testtable/de27e14ffc1f3fff65ce424fcf14ae42/colfam1/2518469459313898451 \
-v -m -p
Scanning -> /hbase/testtable/de27e14ffc1f3fff65ce424fcf14ae42/colfam1/2518469459313898451
K: row-550/colfam1:50/1309813948188/Put/vlen=2 V: 50
K: row-550/colfam1:50/1309812287166/Put/vlen=2 V: 50
K: row-551/colfam1:51/1309813948222/Put/vlen=2 V: 51
K: row-551/colfam1:51/1309812287200/Put/vlen=2 V: 51
K: row-552/colfam1:52/1309813948256/Put/vlen=2 V: 52
...
K: row-698/colfam1:98/1309813953680/Put/vlen=2 V: 98
K: row-698/colfam1:98/1309812292594/Put/vlen=2 V: 98
K: row-699/colfam1:99/1309813953720/Put/vlen=2 V: 99
K: row-699/colfam1:99/1309812292635/Put/vlen=2 V: 99
Scanned kv count -> 300
Block index size as per heapsize: 208
reader=/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=11773
fileinfoOffset=11408,dataIndexOffset=11664,dataIndexCount=1,\
metaIndexOffset=0,metaIndexCount=0,totalBytes=11408,entryCount=300,\
version=1
Fileinfo:
MAJOR_COMPACTION_KEY = \xFF
MAX_SEQ_ID_KEY = 2020
TIMERANGE = 1309812287166....1309813953720
hfile.AVG_KEY_LEN = 28
hfile.AVG_VALUE_LEN = 2
hfile.COMPARATOR = org.apache.hadoop.hbase.KeyValue$KeyComparator
hfile.LASTKEY = \x00\x07row-699\x07colfam199\x00\x00\x010\xF6\xE5|\x1B\ x04
Could not get bloom data from meta block

输出的第一部分是序列化的KeyValue实例所存储的真实数据。第二部分转存内部的HFile.Reader属性和trailer块的详细信息。最后一个部分以Fileinfo开头,是file info块的值。

这里提供的信息是有价值的,例如,用来确认一个文件是否被压缩过以及所使用的压缩类型。它也会显示已经存储了多少个单元,它们的键和值的平均大小。在这个例子中,上面创建的键比值大得多。这是因为KeyValue类需要存储许多相关数据,下面再进一步解释。

本质上,HFile中的每个KeyValue都是一个低级的字节数组,它允许零复制访问数据。图8-7显示了所包含数据的布局。

图8-7 KeyValue格式

该结构以两个分别表示键长度(Key Lengh)和值长度(Value Lengh)的定长数字开始。有了这个信息,用户就可以在数据中跳跃,例如,可以忽略键直接访问值。其他情况下,用户也可以从键中获取必要的信息。一旦其被转换成一个KeyValue的Java实例,用户就能通过对应的getter方法得到更多的细节信息,在3.2.1节的“KeyValue类”部分有详细介绍。

上面的例子中,平均键比平均值大的原因可以归结为键中包含的数据项:它包含了指定单元的全维度内容。键包含了行键、列族名和列限定符等。相对于一个较小的有效负载,这将导致相当巨大的开销。如果用户处理的值较小,那么应当保持键尽量小。选择一个短的行和列键(列族名是一个单字节,同时列限定符也一样短)来保证键值比率合适。

另一方面,压缩有助于缓解这一问题,因为它着眼于有限的数据窗口,并且其中所有重复的数据都能够被有效地压缩。存储文件中所有的KeyValue都被有序地存储,这样有助于把类似的键(如果用户使用了版本,那么相似的值也会这样)放在一起。

region服务器会将数据保存到内存中,直到积攒足够多的数据再将其刷写到硬盘上,这样可以避免创建很多小文件。存储在内存中的数据是不稳定的,例如,在服务器断电的情况下数据就可能会丢失。这是一个典型的问题,已经在8.1节中进行了解释。

一个比较常见的解决这个问题的方法是预写日志(WAL):每次更新(也叫做“编辑”)都会写入日志,只有写入成功才会通知客户端操作成功,然后服务器可以按需自由地批量处理或聚合内存中的数据。

当灾难发生的时候,WAL就是所需的生命线。类似于MySQL的binary log,WAL存储了对数据的所有更改。这在主存储器出现意外的情况下非常重要。如果服务器崩溃,它可以有效地回放日志,使得服务器恢复到服务器崩溃以前。这也就意味着如果将记录写入到WAL失败时,整个操作也会被认为是失败的。

8.2.1节展示了WAL是怎样和HBase的架构结合在一起的。因为它被同一个region服务器的所有region共享,所以对于每一次修改它就像一个日志中心一样。图8-8展示了编辑流是怎样在memstore和WAL之间分流的。

图8-8 所有的修改都先保存到WAL,再传递给memstore

处理过程如下:首先客户端启动一个操作来修改数据。例如,可以对put()delete()increment()进行调用。每一个修改都封装到一个KeyValue对象实例中,并通过RPC调用发送出去。这些调用(理想情况下)成批地发送给含有匹配region的HRegionServer

一旦KeyValue实例到达,它们会被发送到管理相应行的HRegion实例。数据被写入到WAL,然后被放入到实际拥有记录的存储文件的MemStore中。实质上,这就是HBase大体的写路径。

最后,当memstore达到一定的大小或是经历一个特定的时间之后,数据就会异步地连续写入到文件系统中。在写入的过程中,数据以一种不稳定的状态存放在内存中,即使在服务器完全崩溃的情况下,WAL也能够保证数据不会丢失,因为实际的日志存储在HDFS上。其他服务器可以打开日志文件然后回放这些修改——恢复操作并不在这些崩溃的物理服务器上进行。

实现了WAL的类叫做HLog。当HRegion被实例化时,HLog实例会被当做一个参数传入到HRegion的构造器中。当一个region接收到一个更新操作时,它可以直接将数据保存到一个共享的WAL实例中去。

HLog类的核心功能是append()方法。注意,为了提高性能,在PutDeleteIncrement中可以使用一个额外的参数集合:setWriteToWAL(false)。如果用户在设置时调用这个方法,例如,用户在设置一个Put实例时调用该方法会导致向WAL写入数据的过程停止。这也是为什么图8-8中使用虚线创建的向下的箭头来表示可选步骤。默认情况下,用户最好使用WAL。但是,如果用户在运行一个离线的大批量导入数据的MapReduce作业时,其可以获得额外的性能,但是需要特别注意是否有数据在导入的时候丢失。

HLog的另一个特性是追踪修改,这个特性可以通过使用序列号来实现。它在内部使用一个进程安全的AtomicLong,且从0开始或从保存在文件系统中的最后一个所知的数字开始:当region打开它的存储文件时,它读取存储在每一个HFile中meta域中最大的序列号,并且如果这个序列号大于之前记录的序列号,它就会把HLog的序列号设定为这个值。所以在打开所有存储文件的结尾后,HLog就会被初始化以反映存储在哪里结束以及从哪里继续存储。

图8-9展示了3个不同的region,它们存储在同一个region 服务器上,并且每一个都包含不同的行键范围。每一个region都共享同一个HLog实例。这意味着数据按照到达的顺序写入到WAL中,当日志需要回放(查看8.3.7节)时会产生额外工作。但是由于这很少发生,所以最优的做法就是按顺序存储,这样能够提供最好的I/O性能。

图8-9 WAL按照修改的时间顺序存储,包括在同一个服务器上的所有region

WAL当前使用的是Hadoop的SequenceFile,这种文件格式按照键/值集合的方式存储记录。对WAL来说,值仅仅是客户端发送的修改请求。Key被HLogKey实例代表:由于KeyValue仅仅代表行键、列族、列限定词、时间戳、类型以及值,所以要有一个地方来存储KeyValue的归属,即region和表名,这个信息存储在HLogKey中。HLogKey还存储了上面所提到的序列号。每一条记录的数字是递增的,以保持一个连续的编辑序列。

它还记录了写入时间,这是一个表示修改是什么时候被写入到日志的时间戳。最后,这个类存储了多个集群之间进行复制所需要的集群ID(cluster ID)。

客户端发送的每一个修改都会被封装到一个WALEdit实例。它通过日志级别来管理原子性。假设更新了一行中的10列,每一列或每一个单元格都是一个单独的KeyValue实例。如果服务器将它们中的5个写入到WAL后就失败了,用户就会得到一半修改内容被持久化了的行。

这可以通过将包含多个单元格的,且所有被认为是原子的更新都写入到一个WALEdit实例中来解决。这一组的修改都会在一次操作中被写入,以保证日志的一致性。

表的描述符允许用户设置一个叫做延迟日志刷写(deferred log flush)的标志,这个标志已经在5.1.2节中进行了解释。这个值默认为false,这意味着每一次编辑被发送到服务器时,它都会调用写日志的sync()方法。这个调用强迫写入日志的更新都会被文件系统确认,所以用户获得了持久性保证。

不幸的是,调用这个方法会涉及一对N的服务器管道写(其中N是WAL文件的复制因子)。由于这是一个高代价的操作,所以可以选择稍微延迟这个调用,并让它在后台执行。记住,如果不调用sync()方法,那么在服务器出现故障的时候,将有一定几率造成数据丢失。请小心使用这个设置。

管道与多路写的对比

当前sync()的实现是管道写,这意味着当修改被写入时,它会被发送到第一个Data Node进行存储。一旦成功,第一个DataNode就会把修改发送到另一个Data Node来进行相同的工作。只有3个DataNode都已经确认了写操作,客户端才被允许继续进行。

另一种存储修改的方法是多路写,也就是写入被同时发送到3台主机上。当所有主机确认了写操作之后,客户端才可以继续。

这两种方法的区别是管道写需要时间去完成,所以它有很高的延迟,但是它能更好地利用网络带宽。多路写有着比较低的延迟,因为客户端只需要等待最慢的DataNode确认(假设其余已经成功确认)。但是写入需要共享发送服务器的网络带宽,这对于有着很高负载的系统来说会是一个瓶颈。

目前有正在进行的工作能让HDFS支持上面两种方式,这能让你选择一种方式来达到最佳性能。

用户将deferred log flush标志位设置为true会导致修改被缓存在region服务器中,然后在服务器上LogSyncer类会作为一个线程运行,负责在非常短的时间间隔内调用sync()方法。默认的时间间隔为1秒,可以通过hbase.regionserver.optionallogflushinterval`` 属性来设置。

注意这只作用于用户表:所有的目录表会一直保持同步。

日志的写入是有大小限制的。LogRoller类会作为一个后台线程运行,并且在特定的时间间隔内滚动日志。这可以通过hbase.regionserver.logroll.period属性来控制,默认值是1小时。

每60分钟旧的日志文件被关闭,然后开始使用新的日志文件。经过一段时间,系统会积攒一系列数量不断递增的日志文件,这些文件也需要维护。LogRoller会调用HLog.rollWriter()方法来做上面所说的滚动当前日志文件的工作,接着HLog.rollWriter()会调用HLog.cleanOldLogs()

HLog.cleanOldLogs()会检查写入到存储文件中的最大序列号是多少,这是因为到这个序列号为止(小于或等于这个序列号)的所有修改都已经被保存了。然后它会检查是不是有日志文件的序列号都小于这个数字。如果是的话,它就会将这些文件移动到.oldlogs文件夹中,留下其余的日志。

2011-06-15 01:45:48,427 INFO org.apache.hadoop.hbase.region server.HLog: \
 Too many hlogs: logs=130,maxlogs=96;forcing flush of 8 region(s):
 testtable,row-500,1309872211320.d5a127167c6e2dc5106f066c c8 4506f8.,...

这些信息被打印出来是因为需要保留的日志文件数超过了设置的最大日志文件数,但是仍有一些数据更新没有被保存。造成这种情况的原因之一是文件系统负载较大以至于它不能以新数据被添加进来的速率来存储数据;否则memstore的刷写不会受此影响。

注意,当这条信息被打印的时候,服务器会进入到一个特殊的模式来强制刷写内容中的更新数据,以减少需要保存的日志量。

其他控制日志滚动的参数有hbase.regionserver.hlog.blocksize(设置为文件系统默认的块大小或fs.local.block.size,默认值是32MB)和hbase.region server.logroll.multiplier(设为0.95),这个参数表示当日志达到块大小的95%时就会滚动日志。所以,不管日志文件被认为已经满了,还是经过一定的时间文件达到了预设的大小,日志文件就会被滚动。

master和region 服务器需要配合起来精确地处理日志文件,特别是需要从服务器失效中恢复的时候。WAL用来保持数据更新的安全,而回放则是一个使得系统恢复到一致性状态的更加复杂的过程。

1.单日志

因为所有的数据更新都会被写入到region服务器中的一个基于HLog的日志文件中去,为什么不分开将每个region的所有数据更改都写入到一个单独的日志文件中去呢?下面是引自BigTable论文的相关内容:

如果我们将不同表的日志提交到不同日志文件中去的话,就需要向GFS并发地写入大量文件。以上操作依赖于每个GFS服务器文件系统的底层实现,这些写入会导致大量的硬盘寻道来向不同的物理日志文件中写入数据。

由于相同的原因,HBase也遵循这个原则:同时写入太多的文件,且需要保留滚动的日志会影响系统的扩展性。这种设计最终是由底层文件系统决定的。虽然在HBase中可以替换底层文件系统,但是通常情况下安装还是会选用HDFS。

通常情况下,以上设计不会造成什么问题,但当系统遇到一些错误时,以上设计可能将带来一些麻烦。如果用户的数据被及时地、安全地持久化了,那么所有事情就非常正常。但是只要遇到服务器崩溃,系统就需要拆分日志,即把日志分成合适的片,这些在下一节有相应的描述。现在的问题是,所有的数据更改的日志都混在一个日志文件中,并且没有任何索引。正是由于这个原因,master不可能立即把一个崩溃的服务器上的region部署到其他服务器上,它需要等待对应region的日志被拆分出来。如果服务器崩溃之前已经来不及将数据更新刷写到文件系统,对应的需要拆分的WAL数量也将非常庞大。

2.日志拆分

有两种日志文件需要被回放的情况:集群启动时或服务失效时。当master启动的时候——这也包括备用的master接管系统的时候——它会检查文件系统中HBase根目录.logs文件夹下是不是有日志文件,以及这些日志有没有分配的region服务器。日志的名字不仅包含服务器的名字,还包含服务器的启动码(start code)。这个数字会在每次region 服务器重启的时候重置。master可以通过这个数字来检查日志是否被遗弃了。

master还需要负责监控服务器如何使用ZooKeeper。当master检测到一个服务器失效时,它就会在重新分配region到新的服务器前立即启动一个进程来恢复日志文件。这项工作是由ServerShutdownHandler类完成的。

在日志中的数据改动被回放之前,日志需要被单独放在每个region对应的单独的日志文件中。这个过程叫做日志拆分(log splitling):读取混在一起的日志,并且所有的条目都按照它所归属的region来分组。这些分组的修改记录被存放在一个紧挨着目标region的文件中以供接下来的数据恢复过程使用。

日志拆分的实质操作过程几乎在每一个HBase版本中都不太一样:早期版本会直接在master上通过一个线程来读取文件。这个后来又提升为至少不同region对应的修改是通过多线程的方式来重新执行。在0.92.0版本中,终于引入了分布式日志拆分(distributed log splitting)的概念,切分日志的实际工作从master转移到了region服务器上。

现在我们考虑一个更大的region服务器集群,该集群有很多的region服务器和很大的日志文件,过去master只能分别串行地恢复每个日志文件,这样它才不会在I/O和内存使用方面过载。这就意味着,每一个拥有被挂起的数据更改的region都会被阻塞,直到日志拆分以及恢复完成之后才能被打开。

最新的分布式模式使用ZooKeeper来将每一个被丢弃的日志文件分发给一个region服务器。它们通过监测ZooKeeper来发现需要执行的工作,一旦master指出某个日志是可以被处理的,那么它们会竞争这个任务。获胜的region服务器就会在一个线程(为了不使已经负载很重的region 服务器过载)中读取并且拆分这个日志文件。

可以通过hbase.master.distributed.log.splitting设置属性来关闭新的分布式日志拆分。设置该项为false可以停用分布式拆分,则系统只能直接通过master来进行这项工作。

在非分布式模式下写入是多线程的,这是通过hbase.regionserver.hlog. splitlog.writer.threads属性来控制的,其默认值为3。由于增加这个值后,读取日志的性能很可能会受限于单一的日志读者,所以用户需要权衡。

拆分过程会首先将数据改动写入到HBase根目录下的splitlog暂存文件夹。它们已经被放置在与所需的目标region相同的路径下。例如:

   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

为了与其他日志区分开能够执行并发操作,路径中包含了日志文件名。路径中还包含了表名、region名(散列值)和recovered.edits文件夹。最后,拆分文件的名称是对应region的日志中第一次数据更改所对应的序列ID。

.corrupt文件夹包含所有不能被解析的日志文件。这种情况可以通过hbase.hlog. split.skip.errors属性来改变,通常将其默认设置为true。这表示任何不可以从文件中读出来的数据更改都会使整个日志文件被移动到.corrupt文件夹中。如果将这一标志设置为false,则会抛出IOExecption异常,并且整个日志拆分过程都会被停止。

一旦日志文件被成功拆分,则每个region对应的文件就会被移动到实际的region目录。然后region就可以使用对应的日志来恢复数据了。这也就是为什么拆分需要阻塞打开region,因为它需要首先提供挂起的修改来回放。

3.数据恢复

当集群启动,或region从一个region服务器移动到另一个region服务器时,region都会被打开,且此时region会首先检查recovered.edits目录是否存在。如果该目录存在,它就会打开该目录中的文件,并开始读取文件所包含的数据更改记录。由于文件是按照含序列ID的文件名排序的,region便可以按照序列ID的顺序来恢复数据。

任何序列ID小于或者等于保存在硬盘中存储文件序列ID的更改记录都会被忽略,因为这些记录之前的数据已经被刷写了。其他数据更新都会被添加到对应region的memstore中来恢复之前的数据状态。最后,一次强制的memstore刷写会将当前数据写入到硬盘中。

一旦recovered.edits文件夹中的文件都被处理完,且其中的数据更改也都被写入到硬盘后,该文件夹就会被删除。当出现文件不可读的情况时,hbase.skip.errors属性决定了接下来系统的行为:该属性为默认值false时,整个region恢复过程失败;该属性为true时,对应文件就会被重命名为原始文件名加上.<currentTimeMillis>。不管哪种情况,用户都需要小心地检查日志文件来判断为什么恢复会遇到问题,然后修复问题来让恢复过程继续执行。

用户都想依靠系统来保存自己写入的所有数据,不论系统在内部使用了什么新奇的算法。在HBase系统中,用户可以尽量降低日志的刷写次数,也可以在每次数据更改时都同步日志。不管怎样做,用户都需要依赖上文提到的文件系统来做最后的持久化工作。用来存储数据的输出流已经被刷写了,但是对应的数据是否已经写入到硬盘中了呢?我们在谈论的是fsync相关的问题。对HBase来说,大多数情况下还是会选用Hadoop的HDFS作为存储数据的文件系统。

到现在为止,大家应当已经清楚日志是用来保证数据安全的。正是由于这个原因,日志有可能保持打开状态一小时(或者更多,如果用户配置过的话)。随着数据被不断写入,新的键/值对被写入到SequenceFile中,同时也间断地刷写到硬盘中。

但是,这种使用文件的方式并不是Hadoop设计的使用模式通常情况下,Hadoop会提供一套API并通过它向文件中写入数据(最好是大量的数据),此后立即关闭这个文件,并使之成为一个不可更改的文件,随后大家都可以多次读取这个文件。

同时文件只有在被关闭之后才对其他人可见和可读。如果在写入数据的过程中进程崩溃了,那么这个文件很大可能性会被认为丢失了。但对日志文件来说,能够读到上一次服务器崩溃时写操作的位置。这种属性被添加到了新版本的HDFS中,通常称为追加(append)属性。

插曲:HDFS追加、hflush、hsync和sync……

追加(append)功能被HBase用来保证持久性,但过去的Hadoop不支持此功能。很长一段时间后。HBase才支持了此功能,并需要打若干补丁。所有这些内容都源自HADOOP-1700问题(http://issues.apache.org/jira/browse/HADOOP-1700)。问题是在Hadoop 0.19.0中被提交的,其本意是解决文件追加的问题。但是实际并不是这样,Hadoop 0.19.0的追加兼容性很差,以至于一个简单的hadoop fsck / 都会因为HBase的日志文件是打开状态而报告HDFS已损坏。

所以这个问题又在HADOOP-4379或HDFS-200(http://issues.apache.org/jira/brawse/HDFS-200)中被重新处理,并实现了syncFs()来帮助文件的同步修改更可靠。曾有一段时间我们使用一种特定的代码(见HBASE-1470,http://issues.apache.org/jira/browse/HBASE-1470)来检测拥有这个API的打过补丁的Hadoop。

然后是HDFS-265(http://issues.apache.org/jira/browse/HDFS-265),它从整体上重新审视了追加的想法,并引入了Syncable接口,这个接口拥有hsync()hflush()方法。

要注意的是SequenceFile.Writer.sync()方法和上面所提到的同步方法并不相同:它向文件写入一个可以帮助读取或恢复数据的同步标记。

HBase现在会检测当前的Hadoop库是否支持syncFs()hflush()。如果在写入日志的时候触发sync(),则HBase会在内部调用以上两种方法之一。但是,如果HBase运行在一个不需要持久的设置下,它什么也不会调用。sync()使用的是管道写来保证日志文件中数据更改记录的持久性,这部分内容在8.3.5节中进行了介绍。在服务器崩溃的情况下,系统可以安全地从废弃的日志文件中读取到最新的数据更改。

总之,如果没有使用Hadoop 0.21.0及以后的版本,或移植了追加支持的特殊的0.20.x版本的话,用户就可能面对数据丢失。更多信息详见2.2.3节中的“Hadoop”部分。

HBase的每个列族使用多个存储文件来进行数据存储,这些文件包含着实际的数据单元或KeyValue实例。当memstore中存储的对HBase的修改信息最后作为存储文件被刷写到硬盘中时,这些文件就被创建了。后台合并进程通过将小文件写入到大文件中来减少文件的数目,从而保证文件的总数在可控范围之内。major合并最后将文件集合中的文件合并成一个文件,此后刷写又会不断创建小文件。

由于所有的存储文件都是不可变的,从这些文件中删除一个特定的值是做不到的,通过重写存储文件将已经被删除的单元格移除也是毫无意义的。墓碑标记就是用于此类情况的,它标记着“已删除”信息,这个标记可以是单独一个单元格、多个单元格或一整行。

假设用户今天在给定的一行中写入了一列数据,然后在未来的几天里不停地在其他几行中添加数据,然后再在之前给定行的其他列中写入数据。需要考虑的问题是,假设之前添加的列数据已经作为KeyValue在硬盘存储了一段时间,由于新写入的列数据会存储在memstore里或者也被刷写到硬盘中,那么逻辑上的一整行数据到底存储在了哪里呢?

换句话说,当使用Shell对那一行执行一个get命令时,系统怎么知道该返回什么?作为客户端,我们希望两列都被返回——就好像它们被存储在同一个实体中一样。但是实际上数据存储在分离的KeyValue实例中,横跨任意数目个存储文件。

如果删除了最初的列值,然后再次执行get命令时,我们期望这个值已经被删除,然而实际上它仍然存在于某处,只是墓碑标记指出用户已经删除了它。但是标记可能存储在距离所需删除数据很远的地方。8.1节详细解释了这个方式背后的架构。

HBase通过使用QueryMatcherColumnTracker来解决这个问题:其中一个需要精确地匹配用户要取出的列,另一个则需要包含所有的列。它们都可以设定最大匹配的版本数。它们都会跟踪要被包含到最终结果中的内容。

为什么Get都是Scan

在HBase以前的版本中,Get方法是作为一个单独的代码路径实现的。在最近的版本中,这个方式作出了改变并且在内部完全被Scan API所使用的代码替换。

既然单独一个Get应该会比Scan快,用户可能会怀疑为什么要这样做。一个单独的代码路径可以使用一类特殊的知识来快速访问用户所要求的数据。

这时该介绍一下HBase的架构了。HBase中没有可以使我们直接访问特定行或列的索引文件。HFile中最小的单元是块,并且为了找到所要求的数据,RegionServer代码和它底层实现的Store实例必须载入整个可能存储着所需数据的块并且扫描这个块。以上就是Scan做的事情。

也就是说,一次Get就仅仅对单独一行进行扫描。用户可以创建一次Scan,并且将开始行设定为你寻找的那行,且将结束行设定为start row + 1

在读取所有存储文件来查找匹配的条目之前,需要有一个快速的排除检查阶段:使用时间戳以及可选的布隆过滤器来跳过那些绝对不包含所需KeyValue的文件。然后,扫描剩下的存储文件以及memstore来寻找匹配的键。

扫描是通过RegionScanner类实现的,该类为每一个Store实例获取一个StoreScanner,每个Store实例代表着一个列族。如果读操作排除了某些列族,那么它们的存储也同样会被省略。

StoreScanner类合并了Store实例包含的存储文件和memstore。这也是基于布隆过滤器或时间戳的筛选发生的地方。如果用户想要最近一个小时内的版本的数据,那么用户可以跳过所有存储时间超过最近一小时的文件:它们不会包含任何有用的信息。9.1节详细介绍了如何排除无用信息以及如何利用StoreScanner

StoreScanner类也同样包含了QueryMatcher(这里是ScanQueryMatcher类),它会记录到底哪个KeyValue需要被包含在最终的结果中。

RegionScanner在内部是使用KeyValueHeap类,并按时间戳排序来处理存储扫描器。StoreScanner也使用这个类按照同样的方法对存储进行排序。这保证了用户可以按照正确的顺序来读取KeyValue(例如,按时间戳降序)。

当存储扫描器打开的时候,它们会直接定位到所要求的行键上,或者在调用get()的情况下定位到下一个不匹配的行键上(大于起始键的键)。然后扫描器就准备读取数据了,图8-10展示了其运行机制。

图8-10 横跨存储文件、硬盘以及内存来存储或扫描行

对于一次get()调用,所有的服务器需要做的是在RegionScanner上调用next()。这个调用在内部读取所有可能会包含在最后结果中的东西。这将包含所有需要的版本。假设一列有3个版本,用户要求取得所有的版本,而这3个KeyValue实例有可能分散在任意存储文件、硬盘以及内存中。next()一直读取所有的存储文件直到到达下一行,或已经找到足够多的版本来返回数据。

与此同时,它也会持续跟踪删除标记。由于它扫描过当前行所有的KeyValue,因此会遇到这些删除标记,并会意识到所有时间戳小于或等于此标记的数据都是已经被删除了的数据。

图8-10将一个逻辑行表示为一组KeyValue,其中一些在相同的存储文件中,一些在另外一些文件中,并能够横跨多个列族。一个存储文件和memstore在基于时间戳和布隆过滤器的排除过程中被跳过。最后一个存储文件中的删除标记被用来屏蔽条目,但是它们仍然属于同一行数据。扫描器——被表示为存储文件附近的箭头——处于文件中的第一个符合的条目或是紧挨着所要求的行,后者是因为这个存储文件中没有直接匹配的条目。

在调用next()的期间,只有那些在合适的行上的扫描器才会被考虑。内部循环会按照时间降序一个接一个地从存储文件中读取KeyValue,直到它们超过所需要的行键。

对于扫描操作,ResultScanner会重复调用next()直到到达结束行或表的结尾,或者对于当前的一批(扫描器缓存设定)已经读了足够多的行。

最终的结果是符合给定getscan操作要求的一组KeyValue实例。它们会返回给客户端,然后客户端可以通过API方法来访问其中所包含的列。

为了让客户端找到包含特定主键的region,HBase提供了两张特殊的目录表-ROOT-.META.

-ROOT-表用来查询所有.META.表中region的位置。HBase的设计中只有一个root region,即root region从不进行拆分,从而保证类似于B+树结构的三层查找结构:第一层是ZooKeeper中包含root region位置信息的节点,第二层是从-ROOT-表中查找对应meta region的位置,第三层是从.META.表中查找用户表对应region的位置。

目录表中的行键由region的表名、起始行和ID(通常是以毫秒表示的当前时间)连接而成。从HBase 0.90.0版本开始,主键上有另一个散列值附加在后面。不过目前这个附加部分只用在用户表的region中。示例请见8.2.3节。

虽然客户端缓存了region的地址,但是初始化需求时需要重新查找region,例如,缓存过期了,并发生了region的拆分、合并或移动。客户端库函数使用递归查找的方式从目录表中重新定位当前region的位置。它会从对应的.META. region查找对应行键的地址。如果对应的.META.region地址无效,它就向root表询问当前对应的.META.表的位置。最后,如果连root表的地址也失效了,它会向ZooKeeper节点查询root表的新地址。

在最坏的情况下,客户端需要6次网络往返请求来定位一个用户region,由于系统假设了region的分配情况,特别是meta region的分配情况不会经常变化,所以只有当查找失败时,客户端才会认为缓存的region地址失效。当缓存为空时,客户端需要3次网络往返请求来更新缓存。如果用户想减少未来请求region地址的次数,可以在请求之前预刷写缓存地址。参考3.6节详细了解主动刷写region地址缓存的方法。

图8-11展示了先通过meta表,最终通过root表来确定一个用户region位置的过程。一旦获取到用户region的位置,其数据就可以被直接访问。图中的查询被编号了,并假定开始时缓存是空的。如果缓存不为空,但缓存的用户region、meta region和root region的位置都失效,则需要额外的3次查询,即总共需要6次查询来完成这次定位。

图8-11 用户region的映射从空缓存开始,客户端需要进行3次查询

region的各种状态均由master触发,并使用AssignmentManager类进行管理。这个类会从region的下线(offline)状态开始一直跟踪,并管理它的状态。表8-1列举了region可能的所有状态。

表8-1 region的可能状态

状态

描述

Offline

region下线

Pending Open

打开 region的请求已经发送到了服务器

Opening

服务器开始打开region

Open

region已经打开,并且完全可以使用

Pending Close

关闭region的请求被送到服务器

Closing

服务器正在处理要关闭的region

Closed

region已经被关闭了

Splitting

服务器开始切分region

Split

region已经被切分了

状态的改变可能由master发起,也可能由region服务器发起。例如,当master把region 分配到一个服务器后,由服务器来完成打开过程。此外,拆分过程由region服务器发起,这个过程可能引发一系列的region关闭和打开事件。

由于事件都是分布式的,服务器使用ZooKeeper来跟踪一个特定znode的状态。

从0.20.x版本开始,HBase使用ZooKeeper作为其协同服务组件。其主要功能包括跟踪region服务器、保存root region的地址等。在0.90.x中引入了一个新的master实现,使其与ZooKeeper集成得更紧密。它使HBase可以移除在master和region服务器间传递的心跳信息,这部分功能现在被放在了ZooKeeper中。现在,如果某一方有变动,HBase能及时通知到集群的其他部分,而之前需要等待一个固定的时间间隔才能发出通知。

这里有HBase建立的znode列表。默认为/hbase,这个znode的名称由zookeeper.znode.parent属性决定。以下是znode的列表以及它们的作用。

$ $ZK_HOME/bin/zkCli.sh -server _< quorum-server>

输出的某些内容被简化了。

 /hbase/hbaseid 

包含cluster ID,与存储在HDFS上的*hbase.id*文件内容相同,例如:

   [zk: localhost(CONNECTED) 1] get /hbase/hbaseid
   e627e130-0ae2-448d-8bb5-117a8af06e97

/hbase/master

包含服务器名(参见5.2.5节),例如:

   [zk: localhost(CONNECTED)2] get /hbase/master
   foo.internal,60000,1309859972983

/hbase/replication

包含副本信息,参见8.8.2节。

/hbase/root-region-server

包含-ROOT-region所在region服务器的机器名,这个经常在region定位中使用(参考8.5节)。例如:

   [zk: localhost(CONNECTED) 3] get /hbase/root-region-server
   rs1.internal,60000,1309859972983

/hbase/rs

这个znode是作为所有region服务器的根节点,集群用它来跟踪服务器异常。每个znode都是临时节点,并且node名是region服务器的名称,例如:

   [zk: localhost(CONNECTED)4] ls /hbase/rs
   [rs1.internal,60000,1309859972983,rs2.internal,60000,1309859345233]

/hbase/shutdown

这个节点用来跟踪集群状态信息,其包括集群启动的时间,以及当集群被关闭时的空状态,例如:

   [zk: localhost(CONNECTED) 5] get /hbase/shutdown
   Tue Jul 05 11:59:33 CEST 2011

/hbase/splitlog

协调日志拆分相关的父节点,参见8.3.8节的“日志拆分”部分:

  [zk: localhost(CONNECTED) 6] ls /hbase/splitlog
  [hdfs%3A%2F%2Flocalhost%2Fhbase%2F.logs%2Ffoo.internal%2C60020%2C \
  1309850971208%2Ffoo.internal%252C60020%252C1309850971208.1309851636647,
  hdfs%3A%2F%2Flocalhost%2Fhbase%2F.logs%2Ffoo.internal%2C60020%2C \
  1309850971208%2Ffoo.internal%252C60020%252C1309850971208.1309851641956,
  ...
  hdfs%3A%2F%2Flocalhost%2Fhbase%2F.logs%2Ffoo.internal%2C60020%2C \
  1309850971208%2Ffoo.internal%252C60020%252C1309850971208.1309851784396]

  [zk: localhost(CONNECTED) 7] get /hbase/splitlog/ \
  \hdfs%3A%2F%2Flocalhost%2Fhbase%2F.logs%2Fmemcache1.internal%2C \
  60020%2C1309850971208%2Fmemcache1.internal%252C60020%252C1309850971208. \
  1309851784396
  unassigned foo.internal,60000,1309851879862

  [zk: localhost(CONNECTED) 8] get /hbase/splitlog/ \
  \hdfs%3A%2F%2Flocalhost%2Fhbase%2F.logs%2Fmemcache1.internal%2C \
  60020%2C1309850971208%2Fmemcache1.internal%252C60020%252C1309850971208. \
  1309851784396
  owned foo.internal,60000,1309851879862

  [zk: localhost(CONNECTED) 9] ls /hbase/splitlog
  [RESCAN0000293834,hdfs%3A%2F%2Flocalhost%2Fhbase%2F.logs%2Fmemcache1. \
  internal%2C60020%2C1309850971208%2Fmemcache1.internal%252C \
  60020%252C1309850971208.1309851681118,RESCAN0000293827,RESCAN0000293828,\
  RESCAN0000293829,RESCAN0000293838,RESCAN0000293837]

这个例子展示了许多东西:用户可以看到被拆分的日志先是被unassign,之后被一个region服务获取。RESCAN节点代表workers(region 服务器)要进行很多检查工作来防止拆分工作在其他机器上失败。

/hbase/table

当表被禁用,信息会被添加到这个znode之下。表名是新建的znode名,内容是“DISABLED”,例如:

  [zk: localhost(CONNECTED) 10] ls /hbase/table
  [testtable]
  [zk: localhost(CONNECTED) 11] get /hbase/table/testtable
  DISABLED

/hbase/unassigned

这个znode被AssignmentManager用来跟踪集群的region状态。它包含未打开(open)的region的znode,不过znode的状态是变化的,znode名是region的散列值。例如:

  [zk: localhost(CONNECTED) 11] ls /hbase/unassigned
  [8438203023b8cbba347eb6fc118312a7]

HBase复制是一种在不同HBase部署中复制数据的方法。它可以作为一种灾难恢复的方法,并且可以提供HBase层的高可用性。同时在实际应用中,例如,将数据从一个面向页面的集群复制到一个MapReduce集群,后者可以同时处理新数据和历史数据。然后再自动将数据传回面向页面请求的集群。

HBase复制中最基本的架构模式是“主推送”(master-push),因为每个region服务器都有自己的WAL(或HLog),所以及很容易保存现在正在复制的位置,其类似于其他众所周知的解决方案,例如,MySQL的主/从复制只使用二进制日志来跟踪修改。一个主集群可以将数据复制到任意数目的从集群,每个region 服务器都会参与复制自己的修改。

复制是异步进行的,意味着集群可以是地理上彼此远离的,它们之间的连接可以在某些时间断开,在主集群上的修改不能马上在从集群上进行同步(最终一致性)。图8-12展示了复制的工作流程和架构。

图8-12 集群复制架构图

这里使用的复制格式与MySQL基于语句的复制概念相同。与SQL语句不同,所有的WALEdits(包括来自客户端的PutDelete产生的多单元格操作)都会被复制以保证原子性。

来自每个region服务器的HLog是HBase复制的基础,并且只要它们需要将数据复制到从集群,它们就必须被保存在HDFS上。每个region服务器从它需要的最老的日志开始复制,同时在ZooKeeper中保存当前恢复的位置来简化错误恢复。每个从集群恢复的位置可能不同,但它们处理的HLog队列内容是相同的。

参与复制的集群的规模可以不对等,主集群会通过随机分配尽量均衡从集群的负载。

接下来将介绍了一条数据修改如何从客户端发起之后同主集群交互,并同时复制到从集群的过程。

1.常规处理

客户端利用HBase API发送一个PutDeleteIncrement到region服务器。这些请求包含的键/值对被region服务器转化为WALEdit,同时WALEdit会被复制程序检查,并以列族为单元复制数据。修改被添加到WAL中,并把实际数据添加到MemStore

另外一个线程从日志中读取数据(作为批量处理的一部分),并且只有可以复制的KeyValue被保存下来(只有在拥有用户数据日志的情况下,并且列族中有GLOBAL 域定义的复制才会被保存,目录表的修改不被保存)。当缓冲区满了或读到了文件末尾,缓冲区中的修改被发送到从集群一个随机的region服务器上。

同步地,当region服务器收到这些修改后,它会顺序读取这些修改信息,并且把它们分成不同的缓冲区,且每张表一个缓冲区。一旦所有的修改都被读取后,每个缓冲使用HBase客户端(使用HTablePool管理的HTable)来把缓冲区中的修改写入表中。这样可以达到并行写入的效果(MultiPut)。

在主集群的region服务器中,当前WAL中被复制的位置会在ZooKeeper中注册。

2.没有反馈的从集群

数据修改信息以相同的方式插入到主集群。在另外的线程中,region服务器读出日志、过滤并缓冲修改信息,这些过程与正常过程一样。如果从集群的region服务器没有响应RPC请求,主集群的region服务器将会睡眠并按照配置的次数重试。如果从集群region 服务器还是不可用,主集群服务器会重新选择一台从集群服务器来提交修改。

与此同时,WAL会回滚并存储在一个ZooKeeper的队列中。日志被region 服务器归档(归档只是简单地把日志从region服务器的一个目录移动到一个集中的归档目录中),并更新它们在复制线程内存队列中的路径。

当从集群最终可用之后,缓冲区中的修改会被按照正常情况执行。主集群的region服务器将会把积压的日志复制到从集群。

本节将深入介绍复制的各种内部机制和操作。

1.挑选要复制的目标服务器

当主集群region 服务器初始化复制源到从集群时,它会首先用提供给它的集群键连接从集群的ZooKeeper群组(使用到的集群键包括hbase.zookeeper.quorumzookeeper.znode.parenthbase.zookeeper.property.clientPort的值)。然后它会扫描/hbase/rs目录来发现所有可用的汇聚(可以用来接收复制流的服务器)并随机挑选一部分服务器来复制数据(默认是10%)。例如,当从集群有150台服务器时,15台服务器会被挑选为接收复制流的服务器,由于所有的主集群region服务器都会向这10%的服务器发送复制流,所以很可能从集群region服务器的负载非常高。例如,当主集群中10台服务器复制数据到一个5台服务器的从集群时,主集群每次复制时都会随机挑选从集群中一台region服务器,这样从集群中服务器被重复选中的概率会变高。

2.跟踪日志中被复制到的位置

每个主集群的region服务器在znode目录中都有它自己对应的znode,并且每个从集群都有其对应的znode(5个从集群,就会有5个znode被创建),并且每个znode都包含一个需要处理的HLog队列。每个队列都会跟踪region服务器创建的HLog,不过它们队列的大小可能不同。例如,如果一个从集群一段时间不可用,那么它的HLog不应被删除,而需要保存在队列中(而其他从集群的日志都被处理完了)。请参考本节的“region服务器失效”部分。

当源初始化好后,它便包括当前region 服务器要写入的HLog。当日志滚动时,新的文件在可用之前被添加到每个从集群的znode队列中。这样可以保证所有资源都知道有新的HLog可以复制到自己的集群中,但这种操作现在还是十分消耗系统资源的。当复制线程从日志文件中读不到新的日志条目(因为它到达了文件的最后一个块),并且队列中还有其他文件的路径时,队列中的旧路径就会被丢弃。这意味着,当一个源是最新的,且region服务器正在写入时,读到当前文件末尾的文件不会被删除。

当日志被归档(由于日表、不再被使用,或日志的数目超过了hbase.regionserver. maxlogs配置的值,也有可能是由于修改的写入速度比region刷写速度快),它会通知源线程日志地址发生的变化。如果一个源已经同步完了这个日志,便会忽略这条信息。如果这个文件在队列中,那么路径就会被更新。如果日志正在被同步,且修改是原子性的,因此读进程在文件移动完成之前不会尝试读取文件。同时,移动文件是由NameNode操作完成的,所以当日志正在被读取时,不会产生什么异常。

3.读取、过滤以及发送数据修改

默认情况下,一个源会尝试读取日志文件,并尽可能快地将它们传送到从集群的接收服务器上。这件事首先受到日志过滤的限制,只有被分为GLOBAL类的且不属于目录表日志项的KeyValue会被保留。第二个限制是每个从集群同步总大小,默认为64 MB。这意味着3个从集群会占用192 MB来存储要同步的数据,且不包括被过滤的数据。

一旦达到缓冲修改信息的最大值,或读到了日志文件的末尾,源线程将会停止读取并随机挑选一个可接收数据的从集群服务器来同步数据(从一个生成的从集群服务器子集列表中挑选)。它会直接把RPC请求发到挑选的服务器上,如果返回成功,源会判断当前文件是否读完。如果读完,则源会从队列中删掉这个znode。如果没有读完,则在日志的znode中注册一个新的位移。如果RPC抛出异常,源会在重试10次之后挑选一个新的服务器。

4.清理日志

如果没有启用同步复制,master的日志清理线程会按照配置的生存期(Time-To-Live,TTL)删除旧的日志。这种机制在有复制时表现不太好,因为归档日志超出TTL后有可能还在队列中。所以,默认行为被增强了一些,即如果日志超过了TTL,清理线程会查看每个队列直到找到日志(同时缓存其找到的日志)。如果在队列中没有找到日志,则日志会被删除。下次还需要查找日志时,它会先检查缓存。

5.region服务器异常

只要region服务器没有失效,在ZooKeeper中跟踪日志就不会添加新值。不幸的是,服务器失效是一种常见的情况。因为ZooKeeper是高度可靠的,所以我们可以依靠它和它的语义来帮助我们管理队列的传输。

主集群的region服务器都会为其他服务器保留一个监听器(watcher),以便当其他服务器崩溃时收到通知(master也是这样做的)。当有其他服务器崩溃时,它们会竞争着为宕机服务器的znode创建一个叫做lock的znode,且该znode中包含其队列。创建成功的服务器会把队列添加到自己的znode中去(由于ZooKeeper不支持改名操作,所以必须逐个进行添加),并且在这个过程完成之后,它将删除旧的队列。它恢复队列的znode会用当前服务器的ID附加宕机服务器的名称来命名。

一旦这些完成,主集群region服务器会为每个复制后的队列创建一个新的源线程,并且每个线程都会按照读取/过滤/传输的模式工作。最主要的不同是,队列不会有新的数据,因为它们不属于新的region服务器,也就是说,当读进程读到日志的结尾时,队列的znode会被删除,并且主集群的region服务器会关闭这个复制源。

例如,假设一个主集群有3个region服务器同时将数据同步到id为2的从集群。下面的目录结构代表了znode的布局。可以发现region服务器的znode都包含一个peers znode,其中包括一个队列。队列中znode的名字代表HDFS中实际的文件名,格式为“address,port.timestamp”。

/hbase/replication/rs/
           1.1.1.1,60020,123456780/
            peers/
               2/
                1.1.1.1,60020.1234 (Contains a position)
                1.1.1.1,60020.1265
           1.1.1.2,60020,123456790/
            peers/
               2/
                1.1.1.2,60020.1214 (Contains a position)
                1.1.1.2,60020.1248
                1.1.1.2,60020.1312
           1.1.1.3,60020, 123456630/
            peers/
               2/
                1.1.1.3,60020.1280 (Contains a position)

现在,我们可以认为1.1.1.2的ZooKeeper会话丢失。其他region服务器会通过竞争来创建锁,此时1.1.1.3创建成功。然后,它将队列加上宕机服务器的名字后,将所有队列都转移到自己的对等znode下。在1.1.1.3清理老znode前,ZooKeeper中的结构如下:

/hbase/replication/rs/
           1.1.1.1,60020,123456780/
            peers/
               2/
                1.1.1.1,60020.1234 (Contains a position)
                1.1.1.1,60020.1265
           1.1.1.2,60020,123456790/
            lock
            peers/
               2/
                1.1.1.2,60020.1214 (Contains a position)
                1.1.1.2,60020.1248
                1.1.1.2,60020.1312
           1.1.1.3,60020,123456630/
            peers/
               2/
                1.1.1.3,60020.1280 (Contains a position)
               2-1.1.1.2,60020,123456790/
                1.1.1.2,60020.1214 (Contains a position)
                1.1.1.2,60020.1248
                1.1.1.2,60020.1312

之后,在1.1.1.3完成从1.1.1.2复制最后一条HLog前,我们可以认为它也宕机了(也会有一些新的日志在正常队列中)。最后剩下的region服务器会锁定1.1.1.3的znode,同时开始转移队列到它的peer znode下。目录结构如下:

/hbase/replication/rs/
           1.1.1.1,60020,123456780/
            peers/
               2/
                1.1.1.1,60020.1378 (Contains a position)
               2-1.1.1.3,60020,123456630/
               1.1.1.3,60020.1325 (Contains a position)
               1.1.1.3,60020.1401
              2-1.1.1.2,60020,123456790-1.1.1.3,60020,123456630/
               1.1.1.2,60020.1312 (Contains a position)
           1.1.1.3,60020,123456630/
            lock
            peers/
               2/
                1.1.1.3,60020.1325 (Contains a position)
                1.1.1.3,60020.1401
               2-1.1.1.2,60020,123456790/
                1.1.1.2,60020.1312 (Contains a position)

复制仍被认为是一个实验性功能,使用前请仔细考虑它是否满足你的需求。

见维基百科中的“B+ trees”页面(http://en.wikipedia.org/wiki/B%2B_tree)。

见O’Neil 在1996年发表的论文“LSM树”(http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.44.2782)。

来自Doug Cutting在2005年12月5日发表的一篇论文“Open Source Search”(http://www.haifa.ibm.com/Workshops/ir2005/papers/DougCutting-Haifa05.pdf)。

在特殊情况下,用户可以通过Put.setwriteToWAL(boolean)方法关闭该步骤,但并不推荐禁用持久。

见社区官方问题跟踪记录HADOOP-3315的细节(http://issues.apache.org/jira/browse/HADOOP-3315)。

见维基百科中“Write-ahead logging”一节(http://en.wikipedia.org/wiki/write-aheadlogging)。

后面它们分别被引用为root表和meta表,例如,-ROOT-反映了它在HBase中实际的表名,而称它为root表则强调了它的作用。

查看在线文档http://dev.mysql.com/doc/refman/5.1/en/replication-formats.html以获得更多细节信息。


相关图书

HBase入门与实践(第2版)
HBase入门与实践(第2版)
HBase入门与实践
HBase入门与实践
HBase实战
HBase实战
HBase管理指南
HBase管理指南

相关文章

相关课程