分享按钮

二级索引与索引Join是多数业务系统要求存储引擎提供的基本特性,RDBMS早已支持,NOSQL阵营也在摸索着符合自身特点的最佳解决方案。
这篇文章会以HBase做为对象来讨论如何基于Hbase构建二级索引与实现索引join。文末同时会列出目前已知的包括0.19.3版secondaryindex,ITHbase,Facebook方案和官方Coprocessor的介绍。

理论目标
在HBase中实现二级索引与索引Join需要考虑三个目标:
1,高性能的范围检索。
2,数据的低冗余(存储所占的数据量)。
3,数据的一致性。

性能与数据冗余,一致性是相互制约的关系。
如果你实现了高性能地范围检索,必然需要靠冗余索引数据来提升性能,而数据冗余会导致更新数据时难以实现一致性,特别是分布式场景下。
如果你不要求高效地范围检索,那么可以不考虑产生冗余数据,一致性问题也可以间接避免,毕竟sharenothing是公认的最简单有效的解决方案。

理论结合实际,下文会以实例的方式来阐述各个方案是如何选择偏重点。
这些方案是经过笔者资料查阅和同事的不断交流后得出的结论,如有错误,欢迎指正:

1,按索引建表
每一个索引建立一个表,然后依靠表的rowkey来实现范围检索。rowkey在HBase中是以B+tree结构化有序存储的,所以scan起来会比较效率。
单表以rowkey存储索引,columnvalue存储id值或其他数据,这就是Hbase索引表的结构。

如何Join?
多索引(多表)的join场景中,主要有两种参考方案:

1,按索引的种类扫描各自独立的单索引表,最后将扫描结果merge。
这个方案的特点是简单,但是如果多个索引扫描结果数据量比较大的话,merge就会遇到瓶颈。

比如,现在有一张1亿的用户信息表,建有出生地和年龄两个索引,我想得到一个条件是在杭州出生,年龄为20岁的按用户id正序排列前10个的用户列表。
有一种方案是,系统先扫描出生地为杭州的索引,得到一个用户id结果集,这个集合的规模假设是10万。
然后扫描年龄,规模是5万,最后merge这些用户id,去重,排序得到结果。

这明显有问题,如何改良?
保证出生地和年龄的结果是排过序的,可以减少merge的数据量?但Hbase是按rowkey排序,value是不能排序的。
变通一下–将用户id冗余到rowkey里?OK,这是一种解决方案了,这个方案的图示如下:

HBase二级索引与Join_第1张图片

merge时提取交集就是所需要的列表,顺序是靠索引增加了_id,以字典序保证的。

2,按索引查询种类建立组合索引。
在方案1的场景中,想象一下,如果单索引数量多达10个会怎么样?10个索引,就要merge10次,性能可想而知。

HBase二级索引与Join_第2张图片

解决这个问题需要参考RDBMS的组合索引实现。
比如出生地和年龄需要同时查询,此时如果建立一个出生地和年龄的组合索引,查询时效率会高出merge很多。
当然,这个索引也需要冗余用户id,目的是让结果自然有序。结构图示如下:HBase二级索引与Join_第3张图片

这个方案的优点是查询速度非常快,根据查询条件,只需要到一张表中检索即可得到结果list。缺点是如果有多个索引,就要建立多个与查询条件一一对应的组合索引,存储压力会增大。

在制定Schema设计方案时,设计人员需要充分考虑场景的特点,结合方案一和二来使用。下面是一个简单的对比:

单索引 组合索引
检索性能 优异 优异
存储 数据不冗余,节省存储。 数据冗余,存储比较浪费。
事务性 多个索引保证事务性比较困难。 多个索引保证事务性比较困难。
join 性能较差 性能优异
count,sum,avg,etc 符合条件的结果集全表扫描 符合条件的结果集全表扫描

从上表中可以得知,方案1,2都存在更新时事务性保证比较困难的问题。如果业务系统可以接受最终一致性的话,事务性会稍微好做一些。否则只能借助于复杂的分布式事务,比如JTA,Chubby等技术。
count,sum,avg,max,min等聚合功能,Hbase只能通过硬扫的方式,并且很悲剧,你可能需要做一些hack操作(比如加一个CF,value为null),否则你在扫描时可能需要往客户端传回所有数据。
当然你可以在这个场景上做一些优化,比如增加状态表等,但复杂性带来的风险会更高。
还有一种终极解决方案就是在业务上只提供上一页和下一页,这或许是最简单有效的方案了。

2,单张表多个列族,索引基于列
Hbase提供了列族ColumnFamily特性。
列索引是将ColumnFamily做为index,多个index值散落到Qualifier,多个column值依据version排列(CF,Qualifer,VersionHbase会保证有序,其中CF和Qualifier正序,Version倒序)。

举个典型的例子,就是用户卖了很多商品,这些商品的标题title需要支持like%title%查询。传统基于RDMBS就是模糊查询,基于searchengine就是分词+倒排表。
在HBase中,模糊查询显然不满足我们的要求,接下来只能通过分词+倒排的方式来存储。基于CF的倒排表索引结构见下图:

HBase二级索引与Join_第4张图片

取数据的时候,只需要根据用户id(rowkey)定位到一个row,然后根据分词定位到qualifier,再通过version的有序list,取topn条记录即可。不过大家可能会发现个问题,versionlist的总数量是需要scan全versionlist才能知道的,这里需要业务系统本身做一些改进。

如何join?
实现方式同方案1里的join,多个CF列索引扫描结果后,需要走merge,将多个索引的查询结果conjunction。

两个方案的对比似乎变化就是一个表,一个列,但其实这个方案有个最大的好处,就是解决了事务性的问题,因为所有的索引都是跟单个rowkey绑定的,我们知道单个row的更新,在hbase中是保证原子更新的,这就是这个方案的天然优势。当你在考虑单索引时,使用基于列的索引会比单表索引有更好的适用性。
而组合索引在以列为存储粒度的方案里,也同样可以折中实现。理解这种存储模式的同学可能已经猜到了,就是基于qualifier。

下表对比了表索引和列索引的优缺点:

列索引 表索引
检索性能 检索数据需要走多次scan,第一次scanrowkey,第二次scanqualifier,第三次scanversion。 只需要走一次rowkey的scan即可。
存储 在没有组合索引时,存储较节省 在没有组合索引时,存储较节省
事务性 容易保证 保证事务性比较困难
join 性能较差,只有在建立组合条件Qualifier的时候性能会有所改善 性能较差,只有在建立组合表索引的时候性能会有所改善
额外的问题 1,同一个row里每个qualifier的version是有大小限制的,不能超过Int的最大值。(别以为这个值很大,对于海量数据存储,上亿很平常)
2,version的count总数需要额外做处理获取。
3,单个row数据超过split大小时,会导致不能compaction或compaction内存吃紧,增加风险。
count,sum,avg,etc 符合条件的结果集全表扫描 符合条件的结果集全表扫描

虽然列索引缺点这么多,但是存储节省带来的成本优势有时还是值得我们去这么做的,何况它还解决了事务性问题,需要用户自己去权衡。
值得一提的是,Facebook的消息应用服务器就是基于类似的方案来实现的。

3,ITHBase
方案一中的多表,解决了性能问题,同时带来了存储冗余和数据一致性问题。这两个问题中,只要解决其中一项,其实也就满足了大多数业务场景。
本方案中,着重关注的是数据一致性。ITHbase的全称是IndexedTransactionalHBase,从名字中就能看出,事务性是它的重要特性。

ITHBase的事务原理简介
建一张事务表__GLOBAL_TRX_LOG__,每次开启事务时,在表中记录状态。因为是基于Hbase的HTable,事务表同样会写WAL用于恢复,不过这个日志格式被ITHbase改造过,它称之为THLog。
客户端对多张表更新时,先启动事务,然后每次PUT,将事务id传递给HRegionServer。
ITHbase通过继承HRegionServer和HReogin类,重写了大多数操作接口方法,比如put,update,delete,用于获取transactionalId和状态。
当server收到操作和事务id后,先确认服务端收到,标记当前事务为待写入状态(需要再发起一次PUT)。当所有表的操作完成后,由客户端统一做commit写入,做二阶段提交。HBase二级索引与Join_第5张图片

4,Map-reduce
这个方案没什么好说的,存储节省,也不需要建索引表,只需要靠强大的集群计算能力即可导出结果。但一般不适合online业务。

5,Coprocessor协处理器
官方0.92.0新版正在开发中的新功能-Coprocessor,支持region级别索引。详见:
https://issues.apache.org/jira/browse/HBASE-2038

协处理器的机制可以理解为,server端添加了一些回调函数。这些回调函数如下:

TheCoprocessorinterfacedefinesthesehooks:

  • preOpen,postOpen:Calledbeforeandaftertheregionisreportedasonlinetothemaster.

  • preFlush,postFlush:Calledbeforeandafterthememstoreisflushedintoanewstorefile.

  • preCompact,postCompact:Calledbeforeandaftercompaction.

  • preSplit,postSplit:Calledaftertheregionissplit.

  • preCloseandpostClose:Calledbeforeandaftertheregionisreportedasclosedtothemaster.

TheRegionObserverinterfaceisdefinesthesehooks:

  • preGet,postGet:CalledbeforeandafteraclientmakesaGetrequest.

  • preExists,postExists:CalledbeforeandaftertheclienttestsforexistenceusingaGet.

  • prePutandpostPut:Calledbeforeandaftertheclientstoresavalue.

  • preDeleteandpostDelete:Calledbeforeandaftertheclientdeletesavalue.

  • preScannerOpenpostScannerOpen:Calledbeforeandaftertheclientopensanewscanner.

  • preScannerNext,postScannerNext:Calledbeforeandaftertheclientasksforthenextrowonascanner.

  • preScannerClose,postScannerClose:Calledbeforeandaftertheclientclosesascanner.

  • preCheckAndPut,postCheckAndPut:CalledbeforeandaftertheclientcallscheckAndPut().

  • preCheckAndDelete,postCheckAndDelete:CalledbeforeandaftertheclientcallscheckAndDelete().

利用这些hooks可以实现region级二级索引,实现count,sum,avg,max,min等聚合操作而不需要返回所有的数据,详见https://issues.apache.org/jira/browse/HBASE-1512。

二级索引的原理猜测
因为coprocessor的最终方案还未公布,就提供的这些hooks来说,二级索引的实现应该是拦截同一个region的put,get,scan,delete等操作。与此同时在同一个reigon里维护一个索引CF,建立对应的索引表。
基于region的索引表其实有很多局限性,比如全局排序就很难做。

不过我觉得Coprocessor最大的好处在于其提供了server端的完全扩展能力,这对于Hbase来说是一个大的跃进。

如何join?

目前还未发布,不过就了解很难从本质上有所突破。解决方案无非就是merge和compositeindex,同样事务性是需要解决的难题之一。

业界已经公开的二级索引方案罗列:

0.19.3版SecondaryIndex

一直关注HBase的同学,或许知道,早在HBase0.19.3版发布时,曾经加入过secondaryindex的功能,Issue详见这里。
它的使用例子也很简单:http://blog.rajeevsharma.in/2009/06/secondary-indexes-in-hbase.html

0.19.3版SecondaryIndex通过将列值以rowkey方法存储,提供索引scan。
但HBase早期的需求主要来自Hadoop。事务的复杂性以及当时发现hadoop-core里有个很难解决的与ITHBase兼容的问题,致使官方在0.20.0版将其核心代码移出了hbase-core,改为contrib第三方扩展,Issue详见这里。

Transactionaltableindexed-ITHBase

这个方案就是在0.19.3版被官方剥离出核心的第三方扩展,它的方案上面已经介绍过了。目前支持最新的Hbase0.90。
是否具备工业强度的稳定性是用户选择它的主要障碍。

https://github.com/hbase-trx/hbase-transactional-tableindexed

Facebook方案

facebook采用的是单表多列索引的解决方案,上面已经提到过了。很完美地解决了数据一致性问题,这主要跟他们的使用场景有关。

HBase二级索引与Join_第6张图片

感兴趣的同学可以看下这篇blog,本文不作详述:

blog.huihoo.com/?p=688

HBase官方方案0.92.0版开发中–Coprocessor协处理器

还未发布,不过hbase官方blog有篇介绍:http://hbaseblog.com/2010/11/30/hbase-coprocessors

LilyHbaseindexingLibrary

这是一个索引构建,查询,管理的框架。结构上,就是通过一张indexmeta表管理多张indexdata索引表。
特点是,有一套非常完善的针对int,string,utf-8,decimal等类型的rowkey排序机制。这个机制在这篇博文中有详细介绍:

http://brunodumon.wordpress.com/2010/02/17/building-indexes-using-hbase-mapping-strings-numbers-and-dates-onto-bytes/

此外,框架针对join场景(原理=merge),提供了封装好的conjunction和disjunction工具类。
针对索引构建场景,Hbaseindexinglibrary也提供了很方便的接口。

IHbase

IHBase非常类似ITHBase。
IHBase同样从HBase源码级别进行了扩展了,重新定义和实现了一些Server,Client端处理逻辑,所以,它是具备强侵入性的。
不幸的是,这个工具在fix完Hbase0.20.5版兼容bug以后再也没更新。是否支持0.90以上版本,笔者还未尝试。
IHBase与ITHBase的一个对比(仁者见仁)
Feature ITHBase IHBase Comment
globalordering yes no IHBasehasanindexforeachregion.TheflipsideofnothavingglobalorderingiscompatibilitywiththegoodoldHRegion:resultsarecomingbackinroworder(andnotvalueorderasinITHBase)
Fulltablescan? no no THbasedoesapartialscanontheindextable.ITHBasesupportsspecifyingstart/endrowstolimitthenumberofscannedregions
MultipleIndexUsage no yes IHBasecantakeadvantageofmultipleindexesinthesamescan.IHBaseIdxScanobjectacceptsanExpressionwhichallowsintersection/unisonofseveralindexedcolumncriteria
Extradiskstorage yes no IHBaseindexesarecreatedwhentheregionstarts/flushesanddonotrequireanyextrastorage
ExtraRAM yes yes IHBaseindexesareinmemoryandhenceincreasethememoryoverhead.THBbaseindexesincreasethenumberofregionseachregionserverhastosupportthuscostingmemorytoo
Parallelscanningsupport no yes InITHBasetheindextableneedstobeconsultedandthenGETsareissuedforeachmatchingrow.ThebehaviorofIHBase(asperceivedbytheclient)isnodifferentthanaregularscanandhencesupportsparallelscanningseamlessly.parallelGETcanbeimplementedtospeedupTHbasescans
原理简介
在Memstore满了以后刷磁盘时,IHBase会进行拦截请求并为这个memstore的数据构建索引。索引另一个CF的方式存储在表内。不过只支持region级别(类似coprocessor)
scan的时候,IHBase会结合索引列中的标记,来加速scan。
http://github.com/ykulbak/ihbase

欢迎访问我的个人博客,获取其他相关资料!