写在前面
本文2014年7月份发表于InfoQ。HBase的PMC成员Ted Yu先生參与了审稿并于给予了肯定。
该方案设计之初仅寄希望于通过二级索引提升查询性能,由于在前期架构时充分考虑了通用性以及对复杂条件的支持,在后来的演变中逐渐被剥离出来形成了一个通用的查询引擎。
Ted Yu对“查询决策器”表示了关心,他指出类似的组件同一时候也是Phoenix, Impala用于支持SQL查询的核心组件。可是这类组件非常难引入到HBase中。由于HBase专注于byte[]的操作。对此,方案在设计时避开了“SQL解析”和“在各种数据类型与byte[]之间进行转化”的棘手问题,而是使用了一组能够描写叙述查询的Query API,这与Hibernate中提供Criteria接口的做法非常类似,在Hibernate中既支持HQL语句的查询又支持使用Criteria接口以编程方式描写叙述的查询,对于我们来说选择类似后者的做法实现起来要高速和easy的多,而查询条件中的值在构造之初就以byte[]的形式传递,避免了决策器解析时的类型判定和转化问题。本文原文出处: http://blog.csdn.net/bluishglc/article/details/31799255 严禁不论什么形式的转载,否则将托付CSDN官方维护权益。
题记
——索引的实质是还有一种编排形式的数据冗余。高效的检索源自于面向查询特别设计的编排形式,假设再辅以分布式的计算框架。就能够支撑起高性能的大数据查询。
正文
Apache HBase™是一个分布式、可伸缩的NoSQL数据库,它构建在Hadoop基础设施之上。依托于Hadoop的迅猛发展。HBase在大数据领域的应用越来越广泛,成为眼下NoSQL数据库中表现最耀眼,呼声最高的产品之中的一个。
像其它NoSQL数据库一样,HBase也有其适用范围。就应对复杂条件的查询来说。一般觉得它并非非常适合[i]。熟悉HBase的开发人员对此应该有一定的体会,可是基于普遍的需求。开发人员们希望HBase在保持高性能优势的同一时候能对复杂条件的查询给予一定的支持,而本文将要介绍的正是一种在HBase现行机制下以非侵入式实现的基于二级多列索引的高性能复杂条件查询引擎。
问题
眼下HBase主要应用在结构化和半结构化的大数据存储上。其在插入和读取上都具有极高的性能表现,这与它的数据组织方式有着密切的关系,在逻辑上,HBase的表数据按RowKey进行字典排序, RowKey实际上是数据表的一级索引(Primary Index),由于HBase本身没有二级索引(Secondary Index)机制,基于索引检索数据仅仅能单纯地依靠RowKey,为了能支持多条件查询,开发人员须要将全部可能作为查询条件的字段一一拼接到RowKey中。这是HBase开发中极为常见的做法,可是不管怎样设计,单一RowKey固有的局限性决定了它不可能有效地支持多条件查询。通常来说。RowKey仅仅能针对条件中含有其首字段的查询给予令人惬意的性能支持。在查询其它字段时,表现就差强人意了,在极端情况下某些字段的查询性能可能会退化为全表扫描的水平。这是由于字段在RowKey中的地位是不等价的,它们在RowKey中的排位决定了它们被检索时的性能表现。排序越靠前的字段在查询中越具有优势,特别是首位字段具有特别的先发优势,假设查询中包括首位字段。检索时就能够通过首位字段的值确定RowKey的前缀部分。从而大幅度地收窄检索区间,假设不包括则仅仅能在全体数据的RowKey上逐一查找,由此能够想见两者在性能上的差距。
受限于单一RowKey在复杂查询上的局限性,基于二级索引(Secondary Index)的解决方式成为最受关注的研究方向,而且开源社区已经在这方面已经取得了一定的成果,像ITHBase、IHBase以及华为的hindex项目。这些产品和框架都依照自己的方式实现了二级索引,各自具有不同的优势。同一时候也都有一定局限性,本文阐述的方案借鉴了它们的一些长处。在确保非侵入的前提下,以高性能为首要目标,通过建立二级多列索引实现了对复杂条件查询的支持,同一时候通过提供通用的查询API,以及全然基于配置的索引结构,全然封装了索引的创建和使用细节。使之成为一种通用的查询引擎。
原理
“二级多列索引”是针对目标记录的某个或某些列建立的“键-值”数据。以列的值为键,以记录的RowKey为值。当以这些列为条件进行查询时,引擎能够通过检索对应的“键-值”数据高速找到目标记录。
由于HBase本身并没有索引机制,为了确保非侵入性,引擎将索引视为普通数据存放在数据表中,所以,怎样解决索引与主数据的划分存储是引擎第一个须要处理的问题,为了能获得最佳的性能表现。我们并没有将主数据和索引分表储存,而是将它们存放在了同一张表里,通过给索引和主数据的RowKey加入特别设计的Hash前缀,实现了在Region切分时。索引能够尾随其主数据划归到同一Region上。即随意Region上的主数据其索引也必然驻留在同一Region上,这样我们就能把从索引抓取目标主数据的性能损失减少到最小。与此同一时候,特别设计的Hash前缀还在逻辑上把索引与主数据进行了自己主动的分离,当全体数据按RowKey排序时,排在前面的都是索引,我们称之为索引区,排在后面的均为主数据,我们称之为主数据区。
最后。通过给索引和主数据分配不同的Column Family,又在物理存储上把它们隔离了起来。逻辑和物理上的双重隔离避免了将两类数据存放在同一张表里带来的副作用,防止了它们之间的相互干扰。减少了数据维护的复杂性,能够说这是在性能和可维护性上达到的最佳平衡。
图1:Sample表Region 1的数据逻辑视图
让我们通过一个演示样例来具体了解一下二级多列索引表的结构。假定有一张Sample表。使用四位数字构成Hash前缀[ii]。范围从0000到9999,规划切分100个Region。则100个Region的RowKey区间分别为[0000,0099],[0100,0199],……。[9900,9999],以第一个Region为例。请看图1,全部数据按RowKey进行字典排序,自己主动分成了索引区和主数据区两段。主数据区的Column Family是d,下辖q1,q2,q3等Qualifier,为了简单起见,我们假定q1,q2,q3的值都是由两位数字组成的字符串。索引区的Column Family是i,它不含不论什么Qualifier。这是一个典型的“Dummy Column Family“,作为差别于d的还有一个Column Family。它的作用就是让索引独立于主数据单独存储。接下来是最重要的部分。即索引和主数据的RowKey,我们先看主数据的RowKey,它由四位Hash前缀和原始ID两部分组成,当中Hash前缀是由引擎分配的一个范围在0000到9999之间的随机值。通过这个随机的Hash前缀能够让主数据均匀地散列到全部的Region上,我们看图1,由于Region 1的RowKey区间是[0000,0099]。所以没有不论什么例外。凡是且必须是前缀从0000到0099的主数据都被分配到了Region 1上。接下来看索引的RowKey。它的结构要相对复杂一些,格式为:RegionStartKey-索引名-索引键-索引值。与主数据不同,索引RowKey的前缀部分尽管也是由四位数字组成,但却不是随机分配的。而是固定为当前Region的StartKey。这是非常重要而巧妙的设计,一方面,这个值处在Region的RowKey区间之内,它确保了索引必然尾随其主数据被划分到同一个Region里;还有一方面。这个值是RowKey区间内的最小值,这保证了在同一Region里全部索引会集中排在主数据之前。接下来的部分是“索引名”。这是引擎给每类索引加入的一个标识。用于区分不同类型的索引。图1中展示了两种索引:a和b,索引a是为字段q1和q2设计的两列联合索引,索引b是为字段q2和q3设计的两列联合索引,依次类推,我们能够依据须要设计随意多列的联合索引。
再接下来就是索引的键和值了。索引键是由目标记录各对应字段的值组成,而索引值就是这条记录的RowKey。
如今,假定须要查询满足条件q1=01 and q2=02的Sample记录,分析查询字段和索引匹配情况可知应使用索引a,也就是说我们首先确定了索引名,于是在Region 1上进行scan的区间将从主数据全集收窄至[0000-a, 0000-b),接着拼接查询字段的值,我们得到了索引键:0102,scan区间又进一步收窄为[0000-a-0102, 0000-a-0103),于是我们能够非常快地找到0000-a-0102-0000|63af51b2这条索引,进而得到了索引值,也就是目标数据的RowKey:0000|63af51b2,通过在Region内运行Get操作,终于得到了目标数据。须要特别说明的是这个Get操作是在本Region上运行的,这和通过HTable发出的Get有非常大的不同。它专门用于获取Region的本地数据,其运行效率是非常高的。这也是为什么我们一定要将索引和它的主数据放在同一张表的同一个Region上的原因。
架构
在了解了引擎的工作原理之后来我们来看一下它的总体架构:
图2:引擎的总体架构
引擎构建在HBase的Coprocessor机制之上,由Client端和Server端两部分构成,对于查询而言,查询请求从Client端经由HTable的coprocessorExec方法推送到全部的RegionServer上,RegionServer接收到查询请求后使用“查询决策器”分析查询条件,比对索引元数据,在找到适合该查询的最优索引后。解析索引区间,然后托付“索引查询器”基于给定的最优索引和解析区间进行数据检索。假设没有找到合适的索引则托付“全表查询器”进行全表扫描。当各RegionServer的局部查询结果返回之后,引擎的Client端还负责对它们并进行合并汇总和排序,从而得到终于的结果集。
对于插入而言,当主数据试图写入时会被Coprocessor拦截,托付“索引构造器”依据“索引配置文件”创建指向当前主数据的全部索引,然后一同插入到数据表中。
让我们来深入了解一下引擎的几个核心组件。对于引擎的client来讲,最重要的组件是一套用于表达复杂查询请求的Query API,在这套API的设计上我们借鉴了IHBase的一些做法,通过对查询条件(Condition)进行抽象和建模。得到一套典型的基于“复合模式”(Composite Pattern)的Class Hierarchy,使之能够优雅地表达基于AND和OR的多反复合条件。以图1所看到的的Sample表为例。使用Query API构造一个查询条件为“(q1=01 and q2<02) or (q1=03 and q2>04)”的Java代码例如以下:
图3:引擎client的Query API示意代码
查询请求到达Server端以后,由Coprocessor委派查询决策器进行分析以确定使用何种查询策略应对。这是查询处理流程上的一个关键结点。查询决策器须要分析查询请求的各项细节,包括条件字段、排序字段和排序,然后和索引的元数据进行比对找出性能最优的索引,有时候对于一个查询请求可能会有多个适用索引。可是查询性能却有高下之分,因此须要对每个候选索引进行性能评估,找出最优者,性能评估的方法是看哪个索引能最大限度地收窄检索区间。索引的元数据来自于索引配置文件。图4展示了一份简单的索引配置,配置中描写叙述的正是图1中使用的索引a和b的元数据,索引元数据主要是由索引名和一组field组成,filed描写叙述的是索引针对的目标列(ColumnFamily:Qualifier)。
实际的索引配置通常比我们看到的这份要复杂,由于在生成索引时有非常多细节须要通过索引配置给出指引。比方怎样处理不定长字段,目标列使用正序还是倒序(比如时间数据在HBase中经常须要按补值进行倒序处理)。是否须要使用自己定义格式化器对目标列的值进行格式化等等,全然配置化的索引元数据使创建和维护索引的成本大大减少。为上层应用依据实际需求灵活设计索引提供了保障。
图4:一份简单的索引配置文件
在确定最优索引之后,查询决策器開始基于最优索引对查询条件进行解析,解析的结果是一组索引区间,区间内的数据未必都满足查询条件,但却是通过计算所能得到的最小区间。索引查询器就在这些区间上进行检索。通过配备的专用Filter对区间内的每一条数据进行最后的匹配推断。图5展示了一个条件为q1=01 and 01<=q2<=03的查询请求在Sample表Region 1上的解析和运行过程。
图5 :查询请求q1=01 and 01<=q2<=03在Sample表Region 1上的解析和运行过程示意
对于那些找不到索引的查询请求来说,查询决策器将委派全表查询器处理,全表查询器将跳过索引区。从主数据区開始通过配备的专用Filter进行全表扫描。
显然。相对于索引查询。全表扫描的运行效率是非常低的,它的存在是为了在全部索引都不适用的情况下起“托底”作用,以此保证随意复杂条件的查询都能得到处理,所以这里引出一个非常重要的问题,就是在索引查询和全表扫描之间的选择与权衡问题。通常人们总是希望全部的查询都越快越好,尽管从理论上讲建立覆盖随意条件查询的索引是可能的,但这是不现实的。由于创建索引是有代价的,除了占用大量的存储空间之外还会影响到数据插入的性能,所以不能无克制地创建索引。理性的做法是分析并筛选出最为经常使用的查询,针对这些查询建立对应的索引,优化查询性能,而对于那些较为“生僻”的查询则使用全表扫描的方式进行处理,以此在存储成本、插入性能和查询性能之间找到一种理想的平衡。
最后要补充说明的是。不管是使用索引查询还是进行全表扫描。这些动作都是通过Coprocessor机制分发到全部Region上去并发运行的,即使是全表扫描其性能也将远超过HBase原生的Scan操作!
应用
由于引擎设计之初就以非侵入性为前提,所以引擎的部署与集成就与引入第三方类库无异,唯一须要上层应用提供的是面向数据表的索引配置文件。设计索引主要以业务需求为导向。先分析并梳理出经常使用的查询用例,然后针对查询用例所涉及的字段和排序要求按类似性进行分组,尽可能让单个索引同一时候支持多种相近的查询,减少索引的种类和数量,提升索引复用率。在这方面例如以下设计原则可供參考(注:下面原则均以“不考虑排序”为前提):
- N个字段组合的查询仅仅须要建立一个包括该N个字段的索引,建立按这个N字段其它顺序排列的索引是没有意义的。因此,以N个字段组合为条件的查询仅仅须要C(n, n)=1个索引。
- 一个包括N个字段的索引同一时候是以从第1到第N-1个字段为条件的查询索引,以及从第1到第N-2个字段为条件的查询索引,依此类推,也是仅以第1个字段为条件的查询索引。
因此,包括N个字段的索引总计能够支持C(n,1)=n种查询组合。
- 基于上述两点,随意一个索引的字段组合不应该是还有一个索引字段组合的前缀部分。这样设计的索引才会有较高的复用率。
假如某表有A、B、C、D四个字段,在不考虑排序的前提下,假设要用索引支持以随意字段或字段组合为条件的查询,则索引的设计方法例如以下:四字段索引仅仅须要一个,假定取ABCD(它将同一时候支持ABCD、ABC、AB和A四种查询)。
三字段索引分别以A、B、C、D开头向后循环取足三个字段。得到:ABC、BCD(它将同一时候支持BCD、BC和B三种查询)、CDA(它将同一时候支持CDA、CD和C三种查询)和DAB(它将同一时候支持DAB、DA和D三种查询),当中ABC是ABCD的前缀,故舍弃。
依照相同的方法,两字段索引要分别从保留下来的三个三字段索引中依次以每个字段开头取足两个字段。然后去除反复和前缀重叠的索引,终于得到DB(它将同一时候支持DB和D两种查询)和AC(它将同一时候支持AC和A两种查询),总计是6个索引,最后能够再依据实际需求剪裁掉不须要的索引。
在上述原则的表述中特别注明了“不考虑排序“这个前提。对于索引来说,”排序“是一个非常“敏感”的要求,索引本身仅仅有一种排序(即按索引首字段进行的字典排序),假设查询请求的排序与索引排序不同,则索引直接出局。即使它们的字段全然匹配,也就是说排序会极大地消弱索引的复用度,对于我们的引擎来说,排序字段应该受到严格的控制。实际上,非常多大数据系统都须要对排序进行限制,比方淘宝上的商品检索,可供排序的字段仅仅有人气。销量,信用和价格,由于排序须要针对数据全集进行计算,假设不是针对有限的排序字段建立索引或是离线计算并缓存结果,按随意字段排序的查询是非常难在线返回的。
小结
综合前文所述。方案主要有例如以下几个显著的优势:
- 高性能:引擎的高性能源自双方面,一是二级多列索引,二是基于Coprocessor的并行计算
- 非侵入性:引擎构建在HBase之上,既没有对HBase进行不论什么修改,也不须要上层应用做不论什么妥协
- 高度可配置:索引元数据是全然基于配置的,能够轻便灵活地创建和维护索引
- 通用性:引擎的前端查询接口和后端索引处理都是基于通用目标设计的。不依赖于不论什么具体表
限于HBase自身的特点,方案本身也有一定的局限性,一是它不能随意地支持随意的条件查询,这一点前文已经给出了分析和建议,二是在插入主数据时须要伴随插入多份索引从而对写入性能产生了一定的影响。怎样控制写入和查询的竞争关系须要依据系统的读写比进行权衡。对于数据写入实时性要求不高或者数据是离线导入的系统来说,能够考虑使用批量导入工具,特别是以直接生成HFile的方式导入的话能够在非常大程度上消除引入索引后的写入压力。
[i]理论上基于HBase的 Filter机制能够实现随意复杂条件的查询,可是那样做就彻底放弃了RowKey作为索引的利用价值,大多数查询的性能都将变得非常差。
[ii]Hash前缀的长度和Region数量有着密切的关系,由于索引和主数据的分配高度依赖RowKey前缀和Region的RowKey区间,引擎严禁Region进行自己主动切分,开发人员须要在前期对Region数量和前缀长度进行规划,本例中取四位前缀意味着最多能够支持10000个Region。
相关阅读:
HBase Block Cache的重要实现细节和In-Memory Cache的特点