全部产品
云市场

高性能原生二级索引

更新时间:2019-12-04 11:54:16

二级索引简介

HBase原生提供了主键索引,即按rowkey的二进制排序的索引。Scan可基于此rowkey索引高效的执行整行匹配、前缀匹配、范围查询等操作。但若需要使用rowkey之外的列进行查询,则只能使用filter在指定的rowkey范围内进行逐行过滤。若无法指定rowkey范围,则需进行全表扫描,不仅浪费大量资源,查询RT也无法保证。

有多种解决方案可解决HBase的多维查询的问题。比如以要查询的列再单独写一张表(用户自己维护二级索引),或者将数据导出到Solr或者ES这样的外部系统进行索引。像Solr/ES这样的搜索引擎类产品,提供了强大的ad hoc查询能力,云HBase现已集成了全文索引服务。这样,可以节省掉这些外部系统的部署和运维成本。

Solr/ES固然强大,但对于对于大部分列较少且有固定查询模式的场景来说,有”杀鸡用牛刀”之感。为此,HBase增强版推出了原生的全局二级索引解决方案,以更低的成本解决此类问题。因内置于HBase,提供了强大的吞吐与性能。这个索引方案在阿里内部使用多年,经历了多次双11考验,尤其适合解决海量数据的全局索引场景。下图给出了HBase增强版与Phoenix在索引场景下的性能对比:

rt

下面,我们先介绍索引的两个重要概念,然后介绍HBase增强版二级索引的DDL和DML操作,讨论一些高级主题,如rowkey的二进制排序问题以及查询优化的问题,最后,给出使用约束和FAQ。

基本概念

考虑如下主表和索引表:

  1. create table 'dt' (rowkey varchar, c1 varchar, c2 varchar, c3 varchar, c4 varchar, c5 varchar constraint pk primary key(rowkey));
  2. create index 'idx1' on 'dt' (c1);
  3. create index 'idx2' on 'dt' (c2, c3, c4);
  4. create index 'idx3' on 'dt' (c3) include (c1, c2, c4);
  5. create index 'idx4' on 'dt' (c5) include (ALL);

索引列

索引表的主键列就是其索引列。比如idx1的c1,idx2的c2,c3,c4。索引列及其顺序决定了索引表能支持的查询场景。只有一个索引列的索引表称单列索引,有多个索引列的称组合索引。关于查询如何命中索引表,以及命中哪个索引表,可以参见文末的查询优化一节。

冗余列

如果查询中所需要的列在索引表里没有,则需要回查主表才能完成查询。在分布式场景下,回查主表可能带来额外的多次RPC,导致查询RT大幅度增加。因此,通过空间换时间的策略,将主表中的列冗余在索引表中,来避免命中索引的查询再回查主表。有冗余列的索引叫冗余索引(如idx3和idx4),或覆盖索引(Covered Index)。

考虑到业务变化,可能会增加新列,因此,HBase增强版提供了’冗余所有列’的语义(即idx4)。索引表会自动冗余主表的所有列,从容应对业务变化。

使用前准备

  • 服务器版本要求:2.1.10及以上,如果在此版本之下的集群,请在控制台上点击小版本升级
  • 客户端版本要求: 需要alihbase-client 1.1.9/2.0.4以上,或者alihbase-connector1.0.9/2.09以上,详见Java SDK安装
  • Shell版本要求: 需要 alihbase-2.0.9-bin.tar.gz以上版本,请访问HBase Shell下载最新版增强版Shell。

管理索引(DDL)

可通过HBase shell和Java API进行索引DDL操作。本节介绍如何使用HBase shell来进行索引DDL操作,关于Java API的使用,可以参见AliHBaseUEAdmin的相关接口注释。

下面的shell命令展示了几个常用的DDL操作:

  1. # 创建索引
  2. # 为主表dt创建索引idx1: create index idx1 on dt ('f1:c2', 'f1:c3');
  3. # 冗余主表中的所有列,注意 COVERED_ALL_COLUMNS 是一个关键字,请不要在列名中使用它
  4. hbase(main):002:0> create_index 'idx1', 'dt', {INDEXED_COLUMNS => ['f1:c2', 'f1:c3']}, {COVERED_COLUMNS => ['COVERED_ALL_COLUMNS']}
  5. # 查看索引schema
  6. hbase(main):002:0>describe_index 'dt'
  7. # 禁用dt的idx1索引,此时,更新dt不会再更新idx1
  8. hbase(main):002:0>offline_index 'idx1', 'dt'
  9. # 删除idx1这个表
  10. hbase(main):002:0>remove_index 'idx1', 'dt'

下面进行详细介绍。

创建索引

  1. hbase(main):002:0>create_index 'idx1', 'dt', {INDEXED_COLUMNS => ['f1:c2', 'f2:c3']}

为主表dt创建索引idx1,索引列有2个,f1列族下的c2列,f2列族下的c3列。没有冗余列。

  1. hbase(main):002:0>create_index 'idx2', 'dt', {INDEXED_COLUMNS => ['f1:c1']}, {COVERED_COLUMNS => ['f2:c2']}

为主表dt创建索引idx1,索引列有2个,f1列族下的c1列,冗余f2列族下的c2列。

  1. hbase(main):002:0>create_index 'idx3', 'dt', {INDEXED_COLUMNS => ['f1:c3']}, {COVERED_COLUMNS => ['COVERED_ALL_COLUMNS']}

idx3会冗余dt的所有列。因此,命中idx3的查询一定不会回查主表。注意COVERED_ALL_COLUMNS是一个关键字,表示此索引表会冗余主表的所有列。因此,不要使用这个名字作为列名。

除设定索引表的schema(索引列/冗余列)之外,也支持设置索引表的存储特性,例如:

  1. hbase(main):002:0>create_index 'idx1', 'dt', {INDEXED_COLUMNS => ['f1:c1', 'f2:c2']}, {DATA_BLOCK_ENCODING => 'DIFF', BLOOMFILTER => 'ROW',COMPRESSION => 'LZO'}

查看索引schema

通过list命令可以查看主表及其所有的索引表,例如:

list


通过describe_index命令可以查看指定主表的所有索引表的schema信息,例如:

describe_index

删除索引

与普通的HBase表一样,删除索引也需要先禁用(offline_index),再删除(remove_index),例如:

  1. # 禁用dt的idx1索引,此时,更新dt不会再更新idx1
  2. hbase(main):002:0>offline_index 'idx1', 'dt'
  3. # 删除idx1这个表
  4. hbase(main):002:0>remove_index 'idx1', 'dt'

注意,不能使用disable命令来禁用索引。如果索引表offline,所有的查询都不会走这张索引表

为有数据的表建索引时,历史数据同步问题

在为一张已经有数据的表添加新的索引时,create_index命令会同时将主表的历史数据同步到索引表中。因此,当主表很大时,create_index会非常耗时。注意:create_index的数据同步任务是在服务端执行的,即使杀掉hbase shell进程,数据同步任务也会继续执行下去,直到任务完成。

未来我们会开放异步构建索引的能力,即create_index时不同步历史数据,而是用户显式执行一个命令来触发后台的数据同步流程,并通过检查索引状态是否为active来判断数据同步是否完成。

访问索引(DML)

本节介绍通过java API来访问索引(DML)。HBase Java API的基础使用,连接的创建请参见HBase Java API 访问文档。

数据写入

用户不需要主动向索引表写入数据。写主表时,HBase会自动将变更同步到其所有的索引表中。HBase增强版提供同步更新语义,即:写主表时会同步更新其所有索引表,待主表和索引表都写成功后,写操作才会返回到客户端。可以从以下两个角度来理解:

  • 强一致:写主表成功后,本次更新可以立即被读到。
  • 写进行中或超时:主表和索引表的一致性未决,但保证最终一致,即要么都更新,要么都不更新。

数据查询

与关系型数据库类似,用户不需要直接发起针对索引表的查询,只需按业务要求表达对主表的查询。HBase增强版会根据索引表的schema和查询模式自动选择最合适的索引表进行查询。通过Filter来描述基于非rowkey的查询条件,例如:

  1. byte[] f = Bytes.toBytes("f");
  2. byte[] c1 = Bytes.toBytes("c1");
  3. byte[] value = Bytes.toBytes("yourExpectedValue");
  4. // 等价于 select * from dt where f.c1 == value
  5. Scan scan = new Scan();
  6. SingleColumnValueFilter filter = new SingleColumnValueFilter(f, c1, EQUAL, value);
  7. filter.setFilterIfMissing(true);
  8. scan.setFilter(filter);

注意:

  • 如果使用LESSGREATER等条件,需要注意数据的排序问题,详情请见进阶功能中的有符号数的排序问题一节
  • setFilterIfMissing需要设置为true才可使用索引,否则,请求将退化成主表的全表扫描


通过使用FilterList,可以实现and和or条件的组合与嵌套,以表达更复杂的查询条件,例如:

  1. // 等价于 where f.c1 >= value1 and f.c1 < value2
  2. FilterList filters = new FilterList(FilterList.Operator.MUST_PASS_ALL);
  3. filters.addFilter(new SingleColumnValueFilter(f, c1, GREATER_OR_EQUAL, value1));
  4. filters.addFilter(new SingleColumnValueFilter(f, c1, LESS, value2));

HBase增强版会根据Filter以及索引的schema自动选择合适的索引表进行查询。具体请参见下文的查询优化一节。

进阶功能

有符号数的排序问题

HBase原生API只有一种数据类型byte[],并以此进行二进制排序。因此,所有业务上使用的类型都需要转换为byte[]。这就涉及到数据的原始排序与转换成byte[]之后的排序问题。我们希望,在转换前后数据的排序保持不变。HBase client的Bytes类提供了各种类型与byte[]之间的相互转换,但这些接口仅适用于0和正数,而不适用于负数。下图以int类型为例描述了这个问题:

signd_type

上图中,直接二进制编码就是Bytes.toBytes(int)。可见,在有负数参与的情况下,转换为byte[]之后的int不再保序。该问题可通过符号位反转来解决。对于byteshortlongfloat等类型,都有此类问题。因此,HBase增强版提供了新的工具类org.apache.hadoop.hbase.util.OrderedBytes(依赖了alihbase-client或者alihbase-connector都可以找到该类)来解决这个问题。下面举例说明其用法:

  1. // int转换为保序的byte[]
  2. int x = 5;
  3. byte[] bytes = OrderedBytes.toBytes(x);
  4. // byte[]转换为int
  5. int y = OrderedBytes.toInt(bytes);

更多用法可参见类注释。

查询优化

本节讨论HBase增强版如何根据查询进行索引选择。概括的说,就是前缀匹配。这是一种RBO(Rule Based Optimization)策略。根据查询条件中以and连接的等值比较的条件与匹配索引表的前缀,选择匹配程度最高的索引表作为本次查询使用的索引。下面我们通过一些示例来理解这一规则。

假设有如下主表和索引表:

  1. create table 'dt' (rowkey varchar, c1 varchar, c2 varchar, c3 varchar, c4 varchar, c5 varchar constraint pk primary key(rowkey));
  2. create index 'idx1' on 'dt' (c1);
  3. create index 'idx2' on 'dt' (c2, c3, c4);
  4. create index 'idx3' on 'dt' (c3) include (c1, c2, c4);
  5. create index 'idx4' on 'dt' (c5) include (ALL);

考虑如下查询:

  1. select rowkey from dt where c1 = 'a';
  2. select rowkey from dt where c2 = 'b' and c4 = 'd';
  3. select * from dt where c2 = 'b' and c3 >= 'c' and c3 < 'f';
  4. select * from dt where c5 = 'c';

下面逐个分析

(1)select rowkey from dt where c1 = 'a'

命中索引表idx1

(2)select rowkey from dt where c2 = 'b' and c4 = 'd'

命中索引表idx2,从中查找所有满足c2 = 'b'条件的行,然后逐行按c4 = 'd'进行过滤。虽然C4是索引列之一,但因where条件中缺少C3列,无法匹配上idx2的前缀。

(3)select * from dt where c2 = 'b' and c3 >= 'c' and c3 < 'f'

命中索引表idx2,完美匹配。但因为是select *,而索引表里并未包含主表的所有列,因此在查询索引之后,还要回查一次主表。回查主表时,回查的rowkey可能散布在主表的各个地方,因此,可能会消耗多次RPC。回查的数据量越大,RT越长。

(4)select * from dt where c5 = 'c'

命中索引表idx4,完美匹配。因为idx3是全冗余索引,所以,select *不需要回查主表。


因此,用户需要结合实际查询模式来进行索引表的设计,并考虑好未来一段时间中潜在的业务变化。限于篇幅,这里不对此进行详细展开讨论。有兴趣的读者可以阅读《数据库索引设计与优化》,以更进一步的了解如何用好索引。

约束与限制

  • 不同主表可以有同名索引,如dt有索引idx1,foo也可以有索引idx1;但同一主表下不允许有同名索引
  • 只能为version = 1的表建索引,不支持为多版本的表建索引
  • 对有TTL的主表建索引,不能单独为索引表设置TTL:索引表会自动继承主表的TTL
  • 索引列最多不超过3个
  • 索引列 + 主表rowkey,总长度不能超过30KB;不建议使用大于100字节的列作为索引列
  • 单个主表的索引表个数最多不超过5个
  • 一次查询最多只能命中一个索引,不支持多索引联合查询(Index Merge Query)
  • 创建索引时会将主表的数据同步到索引中,对大表建索引会导致create_index命令耗时过长。异步创建索引的功能会在未来开放。

下列功能因为使用上有一些限制,所以,暂时未开放:

  • 异步创建索引:只建索引,不同步历史数据,通过显式执行一个命令来为历史数据构建索引。
  • 自定义时间戳写入数据

对于索引使用上的任何问题,欢迎钉钉联系云HBase答疑或者工单咨询。