摘要
本文是一篇Phoenix综述文章,本文基于Phoenix4.8.0,不断更新中。
关于HBase,可参考HBase学习
1 Phoenix基础概念
1.1 Phoenix是什么
- Phoenix最早是saleforce的一个开源项目,后来成为Apache基金的顶级项目。
- Phoenix是构建在HBase上的一个SQL层(SQL 92),能让我们用标准的JDBC API而不是简陋的HBase API来对HBase数据进行操作。
- Phoenix查询引擎会将SQL转换为若干HBase查询,并编排执行。普通查询响应时间能达到毫秒级。
- 传统RDBMS类似的元数据原理
- 算子、过滤器等下推到Server端并行执行
- 轻松实现二级索引、事务表等
- 可集成Spark/Pig/Flume/Kafka/Hive
1.2 设计目标
通过定义明确的行业标准API,使得Phoenix成为Hadoop的OLTP和OLAP的可靠的数据平台。
1.3 Phoenix架构
1.3.1 不加QueryServer架构
-
客户端
可以看到这个架构里,Phoenix-Fat-Client自己完成了SQL解析、优化、QueryPlan生成(即若干Hbase Scan)等工作,然后使用HBaseClient直连HBase RegionServer进行查询得到结果。 -
服务端
Phoenix在HBase RegionServer上放了phoenix-xxx-server.jar
,包括了若干Phoenix定制化开发的协处理器,可处理二级索引、聚合查询、Join连接等操作。 -
问题
- 但需要注意的是,这套架构的问题就是需要对客户端暴露HBase集群的Zookeeper地址,一旦知道了登录用户便可任意操作ZK,风险很大。
- Fat-Client包体量过大,包括了
phoenix-core
等大量核心依赖。 - 还因为这种架构Fat-Client是臃肿的,较容易与用户代码产生包冲突问题。
1.3.2 加QueryServer架构
可参考QueryServer
1.3.2.1 概述
用户使用Thin-Client
中的PhoenixDriver连接Phoenix Query Server
,Query Server负责语句解析优化并使用HBase Client与HBase交互。该QueryServer属于StandAlone
设计,可以部署多台,用Nginx/Haproxy等做负载均衡,对客户端暴露代理地址即可。这套架构中,语句解析、执行计划生成等重的工作放在了QueryServer执行,使得客户端很轻。
该架构中,Phoenix同样在HBase RegionServer上放了phoenix-xxx-server.jar
,包括了若干Phoenix定制化开发的协处理器,可处理二级索引、聚合查询、Join连接等操作。
该架构使用了Apache Calcite的子项目Avatica实现Phoenix-JDBC客户端和服务端RPC通信,支持Java/Python/Go等多种语言客户端。
QueryServer是一个JVM进程服务端,负责管理客户端的PhoenixConnection。而QueryServerClient是一个JDBC Driver以及很少的依赖(Avtica等)。目前有两个传输通信方式:JSON和Protocol Buffers(ProtoBuf)。
sqlline-thin.py
就是使用了thin-client
来连接QueryServer。
1.3.2.2 Apache Calcite
Apache Calcite是一个构建高性能数据库的基础框架,主要特性是
-标准的sql解析器,并带有验证器和JDBC Driver
- 查询优化:以关系代数来表示查询语句,使用执行计划规则进行转换,并根据成本模型进行优化(CBO)。
- 连接第三方数据源,方便浏览元数据,并以将计算推到数据侧的方式进行优化
- 子项目为
Avatica
1.3.2.3 Avatica
Avatica是一个构建数据库Driver的框架。其重要概念是wire API
,可由JSON或Protobuf
定义,服务于JDBC Driver客户端<->Http服务端。这样的设计可使得Client的实现语言不受约束。
1.4 逻辑结构
Phoenix底层就是基于HBase表实现。
1.4.1 NameSpace
1.4.1.1 NameSpace概念
如果同时在客户端和服务端的hbase-site.xml
配置了phoenix.schema.isNamespaceMappingEnabled
为true,则Phoenix的Schema会自动映射HBase中的Namespace,否则默认情况下是放在HBase default namespace中。
1.4.1.2 Phoenix各类表和Namespace关系
DDL | Table Type | Physical Table | Description |
---|---|---|---|
CREATE TABLE S.T (ID INTEGER PRIMARY KEY) | TABLE | S:T | Table T 会在 namespace S 中创建 |
CREATE INDEX IDX ON S.T(ID) (ID INTEGER PRIMARY KEY) | INDEX | S:IDX | 索引表会从数据表继承schema 和 namespace |
CREATE VIEW V SELECT * FROM S.T | VIEW with default schema | S:T | 视图不会继承schema,可自定义schema |
CREATE VIEW X.V SELECT * FROM S.T | VIEW with different schema than physical table | S:T | 视图仅使用数据表,但不会继承schema,可自定义schema |
CREATE VIEW S.V SELECT * FROM S.T | VIEW with same schema as of physical table | S:T | 视图仅使用数据表,但不会继承schema,可自定义schema |
CREATE VIEW idx on S.V | VIEW INDEX | S:_IDX_T | 视图索引表也会继承schema,映射对应的namespace |
1.4.1.3 NameSpace好处
NameSpace是一个逻辑分组,他类似于RDBMS中的DB概念。他对于多租户的概念意义重大:
- 租户配额管理
限制NameSpace可以使用的资源量(即Region,表)。 - Namespace安全管理
为租户在另一个层级提供安全管理。 - RegionServer分组
可以将NameSpace/表固定到RegionServers的子集上,从而保证进程级别的隔离。
1.4.2 Table
Phoenix表对应了HBase的表,但列却不一定能对应上,比如索引表。
1.5 数据类型.
主要包括:
- INTEGER Type
- UNSIGNED_INT Type
- BIGINT Type
- UNSIGNED_LONG Type
- TINYINT Type
- UNSIGNED_TINYINT Type
- SMALLINT Type
- UNSIGNED_SMALLINT Type
- FLOAT Type
- UNSIGNED_FLOAT Type
- DOUBLE Type
- UNSIGNED_DOUBLE Type
- DECIMAL Type
- BOOLEAN Type
- TIME Type
- DATE Type
- TIMESTAMP Type
- UNSIGNED_TIME Type
- UNSIGNED_DATE Type
- UNSIGNED_TIMESTAMP Type
- VARCHAR Type
- CHAR Type
- BINARY Type
- VARBINARY Type
- ARRAY
注意:其中UNSIGNED类型是映射HBase已有表的类型。
1.5.1 参考
1.5.2注意事项
- VARCHAR字段若使用空字符串
''
,则会被当做null
,需要业务逻辑自己处理 - VARCHAR不能使用变长字段的分隔字符
\0
,会导致查询异常 - 使用时间字段TIMESTAMP/DATE/TIME时注意时区问题,字符串拼接的SQL需要和Statement联用,而setXXX方式传入的参数需要和PreparedStatement联用,否则会导致异常!请参考Phoenix关于时区的处理方式说明
2 Phoenix原理
2.1 SQL执行流程
2.2 Salted Tables
2.2.1 概念和原理
Phoenix Salted Table实现策略类似于之前提到过的HBase中的加盐,避免数据热点。原理是Phoenix会为用户透明的增加一个额外字节(使用Hash函数对给定的ROWKEY来计算并辅以SALT_BUCKETS
指定的值来确定该额外字节)到每个ROWKEY前,使得读写请求更均匀的分布到集群中各个HBase RegionServer上。
这种做法尤其适合单调递增的ROWKEY即容易引发写入热点的情况,当然即使不是这种场景,使用Salted Table
也可以使得数据和读写请求更均匀负载到整个HBase集群,大大提升写入性能,比起不使用该技术的表的性能提升可达到80%。
只需通过在创建表的时候使用如下类似语句:
CREATE TABLE test.chengc_0104 (a_key VARCHAR PRIMARY KEY, a_col VARCHAR) SALT_BUCKETS = 5;
该语句中的SALT_BUCKETS
值范围为0-256,其中0是特殊值,表示该salting表的索引表关闭salting功能,默认情况下索引表和数据表的SALT_BUCKETS数目相同。如果没有指定ROWKEY分割点,会自动在集群region中划分RowKey 边界。
2.2.2 有序性
-
HBase侧
每个Region中的KeyValue拥有相同的salt byte
,因此是排序的。但是总体来说,因为数据分不到多个RS且加了前缀,所以数据不是按ROWKEY整体有序。HBase当跨多个RegionServer执行并行的
scan
时,可以利用Region内部有序性的特点来在客户端执行归并排序, 最终结果仍然有序。使用RowKey排序的scan方法是在
hbase-sites.xml
中设置phoenix.query.rowKeyOrderSaltedTable
为true
。此时就不能再用户自定义加盐表的拆分点,也就是说由Phoenix自动设定拆分点,保证每个Region上的Key拥有相同salt byte
。此时salting表就跟普通表相同,scan
时会以rowkey顺序返回数据。 -
Phoenix侧
直接查询时,不会按ROWKEY排序,需要使用order by
来进行排序。
2.2.3 实例
2.2.3.1 创建salting表
CREATE TABLE test.chengc_20190826 (id INTEGER PRIMARY KEY, NAME VARCHAR, AGE INTEGER) SALT_BUCKETS = 5;
创建的表如下:
可以看到该表按SALT_BUCKETS = 5
自动预分区为5个Region,并自动设定了Split Point
。
2.2.3.2 数据插入及Phoenix侧数据查询
下面开始验证数据我插入四条数据后phoenix侧查询结果如下:
可以看到此时数据是没有按我们的主键即id顺序来排列的。
2.2.3.3 HBase侧数据查询
下面我们查询HBase侧数据情况,进入到HBase RS机器的$HBASE_HOME/bin
目录后,使用hbase shell
启动hbase shell客户端,随后使用以下语句查询:
scan 'TEST.CHENGC_20190826'
结果如下:
ROW COLUMN+CELL
\x00\x80\x00\x00\x02 column=0:AGE, timestamp=1566805473418, value=\x80\x00\x00J
\x00\x80\x00\x00\x02 column=0:NAME, timestamp=1566805473418, value=tatoo
\x00\x80\x00\x00\x02 column=0:_0, timestamp=1566805473418, value=x
\x01\x80\x00\x00\x01 column=0:AGE, timestamp=1566793628359, value=\x80\x00\x00\x1C
\x01\x80\x00\x00\x01 column=0:NAME, timestamp=1566793628359, value=OO
\x01\x80\x00\x00\x01 column=0:_0, timestamp=1566793628359, value=x
\x01\x84\x12\xEE\xE9 column=0:AGE, timestamp=1566793613545, value=\x80\x00\x00\x12
\x01\x84\x12\xEE\xE9 column=0:NAME, timestamp=1566793613545, value=Tom
\x01\x84\x12\xEE\xE9 column=0:_0, timestamp=1566793613545, value=x
\x04\x80\x00\x00\x03 column=0:AGE, timestamp=1566794758210, value=\x80\x00\x000
\x04\x80\x00\x00\x03 column=0:NAME, timestamp=1566794758210, value=lix
\x04\x80\x00\x00\x03 column=0:_0, timestamp=1566794758210, value=x
4 row(s) in 0.0380 seconds
可以看到,这里数据存放顺序又和Phoenix查询时的自然顺序不同了。仔细看,每个ROWKEY最前面的\x00
等就是被添加的SALTING字节。我们4条数据被分布存储到了3个RegionServer上。
经过几个查询后,该表的属性如下:
可以看到最后一列Requests
随着我们对有值的几个Row进行查询在增加,其他没数据的RS的该列值始终为0。
3 二级索引
可参考
3.1 基本概念
3.1.1 概述
在HBase中,只有单一的按字典序排列的rowKey作为索引。当查询使用rowKey速度很快,但不是用时会造成full scan
,效率底下。
Phoenix中提供了二级索引,是一种rowKey之外的有效备选方案。二级索引其实也是一张表,可以在表或视图上创建。 随着数据的变化,索引将自动与表保持同步。
在查询时,如果索引包含了查询中引用的所有列,则Phoenix优化器将使用索引来生成最高效的执行计划。
3.1.2 一致性保证
当客户端请求将数据写入主表时,如果成功返回,则由HBase提供的强一致性可保证同步写入数据表以及关联的所有索引表。
但如果因为服务端侧发生崩溃,则数据表和索引表之间一致性可能有问题,需要设计时思考解决方案,以下是几个不同级别的一致性保证:
3.1.2.1 事务表
通过将表声明为事务性,您可以在表和索引之间实现最高级别的一致性保证。此时数据表内容更新和关联的索引表更新是原子性的,有最强的ACID保证。即提交过程发生失败时,不会更新任何数据表或索引表。
3.1.2.2 不可变表
非事务的不可变索引不能自动处理提交写入请求失败。所以此时,我们必须设计方案来在客户端处理该失败导致的不一致性。 最简单的就是重新执行upsert语句到数据表,因为按主键幂等。
3.1.2.3 可变表
对于非事务可变索引表,是通过将索引的更新操作增加到对应的数据表行的WAL Log
中来维持索引更新的持久化的,只有该WAL成功刷入磁盘后,Phoniex才会尝试使索引/主表更新。开启WAL时先写入数据表再写入索引表,禁用WAL时相反。
如果写入索引更新时服务器发生崩溃,则会重复WAL中的数据来重新写入索引更新。注意,因为upsert幂等性所以重试不会有问题。
-
关于单个写入路径
使用一个单一的可保证失败属性的写入路径。所有写入HRegion的内容都被协处理器拦截,然后根据被拦截挂起更新内容来构建索引的更新,最终将这些更新附加到原始更新的WAL中。过程中如果有失败会将失败结果返回给客户端,这时不会有数据会被持久化,客户端无法看到这些数据。
而一旦写入WAL成功,就能确保即使在失败时索引和数据表的数据终将对客户端可见。具体如下:
- 如果RegionServer崩溃,系统会使用WAL重放机制去更新索引表。
- 如果RegionServer未崩溃,只需要将索引更新插入到它们各自对应的表即可。
- 如果索引更新失败,后文会降到保持一致性的三种方法。
- 如果Phoenix catalog表在发生故障时无法访问,Phoenix将通过调用
System.exit
来强制服务器立即中止并人造失败导致RegionServer终止,以确保在恢复时可重放WAL来将索引更新到相应的表中。这可确保在二级索引处于已知无效状态时不能继续使用二级索引。
-
下面讲三种级别一致性保证方式
-
禁止表写入,直到数据表和可变索引达成一致状态 - 非事务性可变索引表中的最高级别一致性
此时,索引表盒和数据表都将保持故障前的时间戳,不允许写入,直到同步后才回复。但在此期间,索引状态一直为Active
,继续提供服务。需要在服务端加入以下配置:
phoenix.index.failure.block.write
需配置为true,使得在数据/索引表同步前使对数据表的写操作失败。phoenix.index.failure.handling.rebuild
需配置为true(默认值),在提交失败(commit failure
)时在后台自动重建可变索引。
-
在写入提交时若失败则禁用可变索引,直到一致性恢复
还会在后台重建这些可变索引,一旦一致性恢复后便重设为Active
状态重新启用可变索引。注意:在此一致性模式下,重建二级索引时不会阻塞数据表写入,但此期间查询将不会使用辅助索引。
需要在服务端加入以下配置:
phoenix.index.failure.handling.rebuild
需设置为true(默认值),以允许在提交失败时后台重建可变索引phoenix.index.failure.handling.rebuild.interval
服务端检查是否需要重建索引来追上数据表的时间间隔,默认为10000毫秒(10秒)。phoenix.index.failure.handling.rebuild.overlap.time
控制当重建部分索引发生时,从发生故障的时间戳往前追溯的毫秒数。默认值是1毫秒。
-
在写入提交时若失败则禁用可变索引,需要手动重建索引 - 最低级别一致性
当二级索引写入失败时,索引将被标记为禁用,需要手动重建索引以使其再次能被查询使用。需要在服务端加入以下配置:
phoenix.index.failure.handling.rebuild
需设置为false,以禁止在提交失败时后台重建可变索引
3.1.3 索引分类
- 功能上
普通索引、Covered Indexes
(覆盖索引)和Functional Indexes
(函数索引)。 - 策略上
Global Indexes
和Local Indexes
- 可变性
Immutable index
和Mutable index
- 同步/异步
同步创建和异步创建索引
3.1.4 后序例子用到的表
CREATE TABLE test.mljk_20190827 (uid INTEGER PRIMARY KEY, NAME VARCHAR, AGE INTEGER, GENDER VARCHAR) SALT_BUCKETS = 5;
3.2 按功能划分
3.2.1 普通索引
就是直接建立在某些列上的索引。
CREATE INDEX age_idx ON test.mljk_20190827(AGE DESC);
- Phoenix中此时数据表和索引表情况如下。可以看到普通二级索引其实就是新增了一列存储目标列,还存了一列是原表的主键列。查询的时候就能通过目标列来找原表的主键,然后回表查询。
- HBase scan结果如下。可以看到,原表其实主键列
ID
名字都变为_0
了,经过Phoenix一定规则转换为了ROWKEY。而索引表更简洁,每行Phoenix索引表数据只对应HBase内一个ROWKEY对应一行,应该是分别用ROWKEY和value存储了二级索引表中的0:AGE
和:ID
从上图可以观察到,普通索引将索引列和原始表的主键列组合起来再按一定规则(如加盐等)放在索引表的ROWKEY里了。
结论:
比如原始数据的UID为2的Tony,Age为17,ROWKEY为\x01\x80\x00\x00\x02
。在索引表TEST.AGE_IDX
里,对应的ROWKEY是\x03>\xED\xFF\x80\x00\x00\x02
。
- 数据表行后半部分和对应索引表行后半部分相同
即后半部分\x80\x00\x00\x02
和数据表后半部分相同,其中\x02就是uid的值2。 - 加盐部分不同,暗含不一定在同一个Region
但加盐部分不同,数据表是\x01
,索引表是\x03
,即全局索引中数据表某行和索引表对应的某行不一定在同一个Region。 - 时间戳timestamp相同
还需要注意的是他们时间戳timestamp相同都为1566901232081。
3.2.2 Covered Indexes
- 优点
覆盖索引行就已经包含目标数据,不用再回原表查询,效率很高。 - 缺点
索引表还要存一份覆盖列的数据,数据量增大了不少
下面这个sql就是在v1和v2上建立了覆盖索引,同时包含了v3列。
CREATE INDEX na_index ON test.mljk_20190827(name,age) INCLUDE(gender);
那么通过name和age查询gender时就不用再回原表找gender列了。
- Phoenix覆盖索引表情况如下。可以看到此时索引不仅有索引列name和age,也有原表主键列uid,还有覆盖索引列
GENDER
!
- HBase覆盖索引表情况如下。可以看到,现在Phoenix索引表每行对应了HBase一个ROWKEY的两行,其中一行是覆盖索引列的值。而且可以看到ROWKEY包括了我们的索引列的值!
- 从上图可以观察到,覆盖索引类似普通索引将索引列和原始表的主键列组合起来再按一定规则(如加盐等)放在索引表的ROWKEY里了,而覆盖列单独存储,结构类似下图:
- 此时查询
explain select gender from test.mljk_20190827 where name = 'Tony' and age = 18;
结果如下:
可以看到,使用了RANGE SCAN
而不是FULL SCAN
,使用了我们加入的覆盖索引NA_INDEX
。而且此时不用回表查询。
3.2.3 Functional Indexes
Phoeinx4.3以后开始支持函数索引。其索引不局限于列,还能用于任意的表达式,当在查询时用到了这些表达式时就直接返回表达式结果。
下面这个例子,创建了一个函数索引。
CREATE INDEX UPPER_NAME_IDX ON EMP (UPPER(FIRST_NAME||' '||LAST_NAME))
那么,就可以用以下函数直接在索引表查找FIRST_NAME+ ' ' + LAST_NAME
并转为大写等于JOHN DOE
的行,而不用回表查询。
SELECT EMP_ID FROM EMP WHERE UPPER(FIRST_NAME||' '||LAST_NAME)='JOHN DOE'
3.3 按策略划分
3.3.1 Global Indexes
-
创建index时不加local就默认是Global Index
-
适用场景
全局索引适用于读多写少的场景。 -
主要开销
全局索引在数据写入时,拦截写入请求(DELETE
和UPSERT
),然后据此将相关的更改写入到所有与该数据表相关的索引表中。因索引表是分布在不同的数据节点上的,跨节点的数据传输带来了较大的性能消耗。也就是说,全局索引的主要开销为写入时。 -
全局索引特性
如果数据表SALTING,则全局索引表会以相同方式SALTING。此外,索引表的
MAX_FILESIZE
相比于数据表略小。 -
关于ROWKEY
前面提到过,比如原始数据的UID为2的Tony,Age为17,ROWKEY为\x01\x80\x00\x00\x02
。在索引表TEST.AGE_IDX
里,对应的ROWKEY是\x03>\xED\xFF\x80\x00\x00\x02
。- 数据表行后半部分和对应索引表行后半部分相同
即后半部分\x80\x00\x00\x02
和数据表后半部分相同, - 加盐部分不同,暗含不一定在同一个Region
但加盐部分不同,数据表是\x01
,索引表是\x03
,即全局索引中数据表某行和索引表对应的某行不一定在同一个Region。 - 时间戳timestamp相同
还需要注意的是他们时间戳timestamp相同都为1566901232081。
- 数据表行后半部分和对应索引表行后半部分相同
-
索引使用规则
读数据时,Phoenix自动挑选那些能产生最快查询的索引表,然后直接在该索引表执行scan
操作。需要注意的是,除非显示声明hint,否则当查询的列不在索引表列时不会使用全局索引。比如一个表只有v1列有索引,则以下语句不会使用全局索引:
SELECT v2 FROM my_table WHERE v1 = 'foo'
3.3.2 Local Indexes
- 创建index时加上local就是Local Index
- 适用场景
本地索引适用于写很多、空间有限的场景 - 原理
本地索引中的本地
,是指索引表的数据和原表数据在同一个RegionServer,可避免写入数据和索引时的数据传输带来的网络开销。特别是在Phoenix 4.8.0以后,直接将本地索引作为了原表的一个影子列族。 - 对比全局索引
- 相同点
Phoenix会自动判定在进行查询时是否使用本地索引 - 不同点
- 当查询的若干列中的一部分属于索引表列时也会使用本地索引,而其余列自动回原表查询,因为本地索引和数据表存在一起(4.8以后本地索引就是数据表的一个列族)没有网络传输。
- 使用本地索引读取数据时,必须检查每个Region,因为不能直接确定索引数据位置。
- 不同于全局索引
SALT_BUCKETS
和数据表相同,本地索引禁用SALT_BUCKETS
- 相同点
- 主要开销
本地索引在读取时有不菲的开销,因为不知道索引在哪个Region,所以必须检查每个Region,不能直接确定索引数据位置。 - 示例
CREATE local INDEX gender_idx ON test.mljk_20190827(gender DESC);
这里我们会发现跟普通的全局索引不同,本地索引表在HBase找不到。
前面提到过,在Phoenix 4.8.0以后,直接将本地索引作为了原表的一个影子列族(注意,这个时候用Phoenix查询是看不到这个特殊的本地索引列族的,所以称为影子列族):
可以看到,本地索引也跟加盐数据表一样在最前面加了一字节的盐,可保证和数据表在一个Region中。
3.4 按可变性划分
3.4.1 immutable index
-
概念
immutable index,不可变索引,适用于数据只增加不更新的场景。针对这种数据有做专门的优化。比如按照时间先后顺序存储(time-series data)的如日志数据或者行为点击流事件数据等。不可变索引的存储方式是
write one
,append only
。 -
分类
不可变索引分为Global immutable index
和Local immutable index
两种:- Global immutable index
全局不可变索引,由客户端维护更新,即数据表更新时也更新索引表 - Local immutable index
本地不可变索引,更新由服务端维护。
- Global immutable index
-
使用
在create table
的时候显式地指定IMMUTABLE_ROWS = true
即可。 -
好处
不可变索引的好处是减少写入数据时的时间开销。 -
指定方式
当在Phoenix使用create table
或alter table
语句时指定IMMUTABLE_ROWS = true
表示该表上创建的索引将被设置为不可变索引。默认情况下如果在create table
时不指定IMMUTABLE_ROW = true
时,表示该表索引为mutable
即可变索引。 -
注意点
-
所谓不可变索引,其实底层没有强制措施来限制不可变索引的数据更新,因为如果加了限制逻辑会降低不可变索引的写入优化效率。
如果你非要强制更新不可变索引表,则会导致索引表不再和数据表同步的严重错误!
所以如果你需要将不可变索引改为可变索引,请使用以下命令:
ALTER TABLE my_table SET IMMUTABLE_ROWS=false
-
非事务的不可变索引不能自动处理提交写入请求失败
所以此时,我们必须设计方案来在客户端处理该失败导致的不一致性。 最简单的就是重新执行upsert语句到数据表,因为按主键幂等。
-
3.4.2 mutable index
-
概念
mutable index,可变索引,适用于数据有增删改的场景。Phoenix默认情况创建的索引都是可变索引,除非在create table
的时候显式地指定IMMUTABLE_ROWS = true
。可变索引同样分为Global mutable index和Local mutable index两种。
全局可变索引写入流程如下:
-
缺点
写入速度较慢,数据修改时,服务端侧需要维护增量的索引修改。 -
注意点
- 最终一致性
对于非事务可变索引表,是通过将索引的更新操作增加到对应的数据表行的WAL Log中来维持索引更新的持久化的,只有该WAL成功刷入磁盘后,Phoniex才会尝试使索引/主表更新,开启WAL时先写入数据表再写入索引表,禁用WAL时相反。
如果写入索引更新时服务器发生崩溃,则会重复WAL中的数据来重新写入索引更新。注意,因为upsert幂等性所以重试不会有问题。
-
默认情况下,写如索引更新是并行的,吞吐量很高。
-
非事务性表,索引表可能和主表不同步。不过很短时间后就会同步了。
也就是说由HBase保证了写入每行到数据表和索引表的最终一致性和原子性,只能都成功或都失败。
- 最终一致性
-
配置
非事务性、可变的索引需要在每个RegionServer上做一些配置。请在每个RS的hbase-site.xml
里面增加如下内容,并重启所有RS节点:- 来允许自定义WAL写入:允许正确的写入/重放索引更新。
<property> <name>hbase.regionserver.wal.codec</name> <value>org.apache.hadoop.hbase.regionserver.wal.IndexedWALEditCodec</value> </property>
- 以下两个配置的作用是防止死锁,主要两方面:
- 确保索引更新的优先级高于数据更新,可防止在全局索引(HBase 0.98.4+和Phoenix 4.3.1+)的索引维护(增改)过程中发生死锁。
- 还通过确保元数据rpc调用比数据rpc调用具有更高的优先级来防止死锁。
<property> <name>hbase.region.server.rpc.scheduler.factory.class</name> <value>org.apache.hadoop.hbase.ipc.PhoenixRpcSchedulerFactory</value> <description>Factory to create the Phoenix RPC Scheduler that uses separate queues for index and metadata updates</description> </property> <property> <name>hbase.rpc.controllerfactory.class</name> <value>org.apache.hadoop.hbase.ipc.controller.ServerRpcControllerFactory</value> <description>Factory to create the Phoenix RPC Scheduler that uses separate queues for index and metadata updates</description> </property>
- Phoenix 4.8.0以后不需要在进行local索引配置
- 来允许自定义WAL写入:允许正确的写入/重放索引更新。
3.5 同步/异步索引
3.5.1 同步索引
默认用CREATE INDEX
创建的就是同步索引。
3.5.2 异步索引
异步创建索引到v列。
CREATE INDEX async_index ON my_schema.my_table (v) ASYNC
初始时索引表不会有数据,需要搭配基于mapreduce的IndexTool,工具启动方法如下:
${HBASE_HOME}/bin/hbase org.apache.phoenix.mapreduce.index.IndexTool
--schema MY_SCHEMA --data-table MY_TABLE --index-table ASYNC_IDX
--output-path ASYNC_IDX_HFILES
其中output-path
用于指定用来写入异步索引表的HFile文件的HDFS目录。
提交异步索引后,会启动一个MR任务,任务结束后将数据生成的索引放入索引表并激活,索引方可使用。
3.6 语法
3.6.1 概览
跟Phoenix表一样,索引表底层也是使用的HBase表实现,也可以使用HBase的一些属性,如:
CREATE INDEX my_index ON my_table (v2 DESC, v1) INCLUDE (v3)
SALT_BUCKETS=10, DATA_BLOCK_ENCODING='NONE'
3.6.2 若干示例
- 普通二级索引,建立在last_updated_date列
CREATE INDEX my_idx ON sales.opportunity(last_updated_date DESC)
- 覆盖索引,建立在created_date列,同时覆盖name和payload列,并且自动划分为10个SALT_BUCKET
CREATE INDEX my_idx ON log.event(created_date DESC) INCLUDE (name, payload) SALT_BUCKETS=10
- 如果不存在就创建索引在gc_time和created_date列上,编码使用NONE(可选NONE, PREFIX, DIFF, FAST_DIFF, ROW_INDEX_V1)
CREATE INDEX IF NOT EXISTS my_comp_idx ON server_metrics ( gc_time DESC, created_date DESC ) DATA_BLOCK_ENCODING='NONE',VERSIONS=3,MAX_FILESIZE=2000000 split on (?, ?, ?)
- 函数索引
UPPER
,建立在contact_name
列CREATE INDEX my_idx ON sales.opportunity(UPPER(contact_name))
- 不可变索引
CREATE TABLE my_table (k VARCHAR PRIMARY KEY, v VARCHAR) IMMUTABLE_ROWS=true
3.6.3 解决全局索引无法使用
前面例子中my_table表只有v1列有索引所以以下语句不会使用全局索引:
SELECT v2 FROM my_table WHERE v1 = 'foo'
此时有三种解决方案来使用索引:
-
使用覆盖索引,将v2列加入到v1索引列的索引表中,与数据表变更同步。此方案会增加索引表的大小:
CREATE INDEX my_index ON my_table (v1) INCLUDE (v2)
-
使用
Hint
来强制该查询使用索引:SELECT /*+ INDEX(my_table my_index) */ v2 FROM my_table WHERE v1 = 'foo'
该方式的原理是用二级索引列v1的值得到原表ROWKEY后回表查询v2列。
Hint方式应该仅在索引列的区分度很强时使用,比如上述例子的v1列等于
foo
的很少时才使用Hint方式,否则还不如直接用默认行为即full scan性能更好。 -
创建本地索引
CREATE LOCAL INDEX my_index ON my_table (v1)
不同于全局索引,就算查询中的列不全部包含在索引列中,本地索引也会使用索引。本地索引这样做的原因是本地索引文件本身就和数据文件在同一个RegionServer上可直接本地查找不需要网络传输。
3.6.4 移除索引
移除索引语句:
DROP INDEX my_index ON my_table
注意:当数据表中的被索引或被索引覆盖的列被drop后,对应的索引表中的该列也会被自动drop。
3.7 多个索引
Phoenix会在存在多个索引时,根据查询语句进行综合评估择优选取索引执行查询。
3.8 索引性能
因为组合索引是按顺序组合Rowkey的,所以Phoenix索引也是有最左前缀匹配规则。
比如有下表:
CREATE INDEX B_C_D_IDX ON DATA_TABLE(B,C,D);
那么等值条件查询语句使用索引的效率如下表,需要注意的是以下语句的条件顺序可变:
- 以上语句中1-4符合最左前缀原则,可以使用索引。
- 而5-7会触发full scan,效率很差
3.9 索引调优
参考
- Secondary Indexing 之Tuning章节
默认属性值已经很快了,但也可以在某些特定的情况下(如环境、硬件、工作负载等)进行定制化调优。将以下属性在hbase-site.xml
中设置,
属性 | 默认值 | 描述 | 指南 |
---|---|---|---|
index.builder.threads.max | 10 | 用于从主数据表更新来构建索引表更新的线程数 | 调大此值可克服从底层HRegion读取当前行状态的瓶颈,但如果过高又会成为HRegion的瓶颈,因为这样一来就无法处理足够多的的并发scan请求 |
index.builder.threads.keepalivetime | 60 | 索引更新构建线程池中线程的最大空闲时间(单位为秒) | 在该时间之后立即释放未使用的线程,并且不保留核心线程 |
index.writer.threads.max | 10 | 写入索引表时使用的线程数 | 应该大致等于索引表的数量 |
index.writer.threads.keepalivetime | 60 | 索引更新写入线程池中线程的最大空闲时间(单位为秒) | 在该时间之后立即释放未使用的线程,*并且不保留核心线程 |
hbase.htable.threads.max | 2147483647 | 每个索引表可用于写入的最大线程数 | |
hbase.htable.threads.keepalivetime | 60 | HTable线程池中线程的最大空闲时间(单位为秒) | |
index.tablefactory.cache.size | 10 | 在内存中缓存的索引的HTable实例数量 | 适当增加该值,可以使得我们不需要每次尝试写入索引表时重建HTable实例。如果此值设置得太高,会使得内存压力过大。 |
org.apache.phoenix.regionserver.index.priority.min | 1000 | 指定索引优先级所在范围的最低(包括)的阈值。 | |
org.apache.phoenix.regionserver.index.priority.max | 1050 | 指定索引优先级所在范围的最高(不包括)的阈值。 | 索引最小/最大范围内的较高优先级却并不意味着更新被处理更快 |
org.apache.phoenix.regionserver.index.handler.count | 30 | 为全局索引维护提供索引写入请求时要使用的线程数 |
3.10 其他注意事项
3.10.1 索引带来的写放大问题
实际生产环境中,我们要合理设计索引,因为我们可以看到其实索引表本质上也是数据表,再优化还是会占用大量空间。
所以需要根据使用场景,严肃认真活泼合理地设计二级索引。
比如使用联合主键来进行覆盖而不是覆盖索引,避免写放大问题。
3.10.2 使用了ROW_TIMESTAMP特性后不能使用全局索引
3.10.3 关于索引状态、索引重建、雪崩效应等
必须维持索引表处于ACTIVE状态,才能确保索引数据的完整一致。.
还可参考Phoenix 索引生命周期
3.10.4 索引有效性验证工具
请参考phoenix学习四节 Secondary Indexing 二级索引Index Scrutiny Tool
部分
3.10.5 回表查询时数据量过大可能引起空返回或客户端OOM的情况
3.10.6 慎用select *
比如我有一个表只有UID | NAME | AGE | GENDER
四列,其中UID
是主键列,并使用列NAME
和AGE
做了全局索引并覆盖了GENDER
列。此时我如果使用explain select * from test.mljk_20190827 where name='Tony' and age = 17;
结果如下:
可以发现此时居然有个FULL SCAN!
我改用explain select uid,name,age,gender from test.mljk_20190827 where name='Tony' and age = 17;
结果如下:
此时又变为RANGE SCAN了。所以我们还是明确指定列比较好!
3.10.7 当可能创建多个索引时使用hint
请用hint强制指定使用评估执行效果最好的那个索引,这样可以确保即使以后新增其他索引也不会影响到现在使用的最优查询计划。
3.11 索引源码
3.11.1 简介
Phoenix安装的时候会将phoenix-[version]-server.jar
放入HBase的lib目录并重启各RS和Master节点,其实这里面就包含了大量的协处理器。其中org.apache.phoenix.hbase.indexIndex
类就是索引所用的RegionObserver.
该协处理器会拦截所有PUT
/DELETE
修改操作,将更新应用到索引表上。如果开启了WAL,就先写入WAL然后尝试更新,如果索引更新失败就利用WAL进行重放更新索引。如果没有开启WAL就直接尝试更新索引,但此时不再有一致性保证了。
3.11.2 源码浅析
//TODO 本节待完善。
- Indexer类继承自
BaseRegionObserver
抽象类,而不是RegionObserver
接口,原因是有些方法不必实现。
public class Indexer extends BaseRegionObserver
- 下面分析
postPut
方法@Override public void postPut(ObserverContext<RegionCoprocessorEnvironment> e, Put put, WALEdit edit, final Durability durability) throws IOException { if (this.disabled) { // 禁止时调用父类super.postPut super.postPut(e, put, edit, durability); return; } // 否则调用doPost doPost(edit, put, durability, true); }
doPost
实际调用的是doPostWithExceptions
private void doPostWithExceptions(WALEdit edit, Mutation m, final Durability durability, boolean allowLocalUpdates)
throws Exception {
// HBase 禁用WAL或IndexBuildManager不允许该次修改,就直接返回了
if (durability == Durability.SKIP_WAL || !this.builder.isEnabled(m)) {
// already did the index update in prePut, so we are done
return;
}
// get the current span, or just use a null-span to avoid a bunch of if statements
try (TraceScope scope = Trace.startSpan("Completing index writes")) {
Span current = scope.getSpan();
if (current == null) {
current = NullSpan.INSTANCE;
}
// 从WALEdit中找到首个IndexedKeyValue
IndexedKeyValue ikv = getFirstIndexedKeyValue(edit);
// 没有IndexedKeyValue,说明没有需要写入的索引数据,直接返回
// 这里没有释放锁的原因是,若没有索引数据则在doPre阶段就不会获取锁
if (ikv == null) {
return;
}
/*
* only write the update if we haven't already seen this batch. We only want to write the batch
* once (this hook gets called with the same WALEdit for each Put/Delete in a batch, which can
* lead to writing all the index updates for each Put/Delete).
*/
// 只写一次索引
if (!ikv.getBatchFinished() || allowLocalUpdates) {
// 从WAL中得到本次所有更新动作和关联的索引表
Collection<Pair<Mutation, byte[]>> indexUpdates = extractIndexUpdate(edit);
try {
if (!ikv.getBatchFinished()) {
current.addTimelineAnnotation("Actually doing index update for first time");
// 利用IndexWriter开始写索引更新
// 默认会在写索引错误时RS服务器直接挂掉,来保证可在RS重启恢复时通过WAL重放索引更新
writer.writeAndKillYourselfOnFailure(indexUpdates, allowLocalUpdates);
} else if (allowLocalUpdates) {
// 更新本地索引
Collection<Pair<Mutation, byte[]>> localUpdates =
new ArrayList<Pair<Mutation, byte[]>>();
current.addTimelineAnnotation("Actually doing local index update for first time");
for (Pair<Mutation, byte[]> mutation : indexUpdates) {
if (Bytes.toString(mutation.getSecond()).equals(
environment.getRegion().getTableDesc().getNameAsString())) {
localUpdates.add(mutation);
}
}
if(!localUpdates.isEmpty()) {
writer.writeAndKillYourselfOnFailure(localUpdates, allowLocalUpdates);
}
}
} finally { // With a custom kill policy, we may throw instead of kill the server.
// Without doing this in a finally block (at least with the mini cluster),
// the region server never goes down.
// mark the batch as having been written. In the single-update case, this never gets check
// again, but in the batch case, we will check it again (see above).
// 标记该批已经被写入完毕
ikv.markBatchFinished();
}
}
}
}
3.12 二级索引使用场景
- 适用
- 该列基数较高,区分度大
- 查询条件过滤后结果较少,如果结果太多的话适用索引开销也很大
- 有固定查询套路,可对固定需要返回但不需要查询的列使用覆盖索引
- 需要group by聚合或order by排序的字段
- 不适用
- 区分度低的列,如性别
- 非前缀模糊匹配
- 不符合最左前缀索引的查询
- 单表索引最多不要超过十个
3.13 索引FAQ
请参考phoenix构建二级索引及探索第七章。
4 Phoenix事务
可参考Phoenix-事务
4.1 概念
HBase只支持行级事务,Phoenix通过与Apache Tephra集成,增加了对跨行、跨表的事务ACID语义的支持。 Tephra通MVCC提供并发事务的快照隔离。
4.2 配置
- 客户端侧
hbase-site.xml
<property> <name>phoenix.transactions.enabled</name> <value>true</value> </property>
- 服务端侧
hbase-site.xml
<!--Tephra事务管理器配置--> <property> <name>data.tx.snapshot.dir</name> <value>/tmp/tephra/snapshots</value> </property> <!--设置每个事务的超时时间--> <property> <name>data.tx.timeout</name> <value>60</value> </property>
- 设置
$HBASE_HOME
,然后开启Transaction Manager,事务管理器通常配置为在HBase集群中的一个或多个主节点上运行。
$PHOENIX_HOME/bin/tephra
- 创建表启用事务
CREATE TABLE my_table (k BIGINT PRIMARY KEY, v VARCHAR) TRANSACTIONAL=true;
- 或是修改现有非事务表为事务表(但要注意事务表无法回退为非事务表!)
ALTER TABLE my_other_table SET TRANSACTIONAL=true;
4.3 事务隔离级别
Phoenix事务是读提交(RC)
事务开始后,statement
不会看到其他事务的未提交数据,但能看到本事务的未提交数据。
比如:
-- 开启事务
SELECT * FROM my_table;
-- 本事务插入一条数据
UPSERT INTO my_table VALUES (1,'A');
-- 可以看到本事务插入的数据虽然未提交
SELECT count(*) FROM my_table WHERE k=1;
-- 删除一条数据
DELETE FROM my_other_table WHERE k=2;
-- 事务提交,随后可以看到其他事务的已提交数据,别的事务也能看到本事务提交的数据
!commit
4.4 事务冲突
因为隔离级别是RC,所以如果两个事务提交时有冲突,则后提交的事务会抛出异常。例子如下:
- A事务执行SQL,但尚未提交事务
UPSERT INTO my_table VALUES (1,'A');
- B事务执行SQL并提交事务
UPSERT INTO my_table VALUES (1,'B');
!commit
- A事务提交,报错
java.sql.SQLException: ERROR 523 (42900): Transaction aborted due to conflict with other mutations. Conflict detected for transaction 1454112544975000000.
4.5 事务与索引
-
创建方式
如下方式建事务索引:CREATE INDEX my_table (k BIGINT PRIMARY KEY, v VARCHAR) TRANSACTIONAL=true;
-
一致性
此时该事务索引会和数据表的事务保持一致。当commit时index或data写失败都会抛异常导致提交都不可见,由客户端决定回滚或者重试。**通过将表声明为事务性,您可以在表和索引之间实现最高级别的一致性保证。**此时数据表内容更新和关联的索引表更新是原子性的,有最强的ACID保证。即提交过程发生失败时,不会更新任何数据表或索引表。
-
不能无脑建索引表
但不能不加思考的把所有表设为事务表。事务表最适合的是数据表内容不可变即只追加不更新的情况,此时事务开销很小。如果是可变的情况,则冲突检测及运行事务管理器的相关操作的开销是较大的,需要衡量是否可接受。另一方面,建索引事务表会降低整个表的可用性,因为数据表或索引表只要一个不可用就将造成整个写入过程失败。
4.6 事务,最大版本数和快照
设置了最大版本数会影响当前事务快照的数量。
4.7 事务注意事项
4.7.1 异步索引
如果使用异步方式来为已存在的事务表增加索引,必须确保在此之前执行一次major compaction
,否则会导致执行无效/未提交的事务出现在索引表等异常情况。
4.7.2 invalid list
当事务超时或客户端无法回滚时,事务会被添加到invalid list
,所以该列表会持续增大。
list清理:
- 管理员可在major compaction后手动清除此列表中的事务。
- TEPHRA-35描述了在删除与事务关联的所有数据后自动从invalid list中删除事务的信息。
5 Phoenix使用
5.1 DDL
5.1.1 create table
Phoenix用CREATE TABLE
创建的表元数据存放在HBase。创建方式有以下两类:
-
创建全新的表
Phoenix会自动在HBase创建表和列族等 -
映射到已存在的HBase表。
需要注意,目标HBase表的RowKey和KeyValue的二进制格式必须满足Phoenix的数据类型约束,否则不行。可以创建两种类型:- 读写表
自动创建不存在的列族,并添加空KeyValue到现有行的第一个列族,以最小化查询投影的大小 - 只读视图(View)
所有列族必须已存在。还会在HBase表上增加Phoenix的协处理器,用来加速查询处理。因不可修改原表,所以查询性能可能低于创建读写Phoenix表。原因可回到上面看看读写表。
更多关于映射表,可查看hbase已有表与phoenix映射
- 读写表
5.1.2 alter table
ALTER TABLE
可修改表。4.7版本中可以在DDL中指定到HBase更新元数据(例如,添加或删除表列或更新表统计信息)的频率。该值可选:
ALWAYS
(默认,导致客户端在每次statement执行引用表语句或每次提交·UPSERT VALUE·语句时的更新)NEVER
- 毫秒数
客户端去服务端查元数据来更新本地缓存的间隔时间
比如10分钟刷新元数据频率的表:
CREATE TABLE
FOO (k BIGINT PRIMARY KEY, v VARCHAR)
UPDATE_CACHE_FREQUENCY=600000;
5.2 DQL
5.2.1 SCAN
Phoenix SCAN
分为 RANGE SCAN
, FULL SCAN
, SKIP SCAN
及 DEGENERATE SCAN
:
5.2.1.1 RANGE SCAN
RANGE SCAN
是指,仅扫描表中的一部分行。如果您使用主键约束中的一个或多个前导列,则会发生这种情况。
比如DDL语句:
CREATE TABLE TEST
(pk1 char(1) not null, pk2 char(1) not null, pk3 char(1) not null,
non-pk varchar CONSTRAINT PK PRIMARY KEY(pk1, pk2, pk3));
那么下面的SQL就不是在前导主键列上使用过滤:
select * from test where pk2='x' and pk3='y';
此sql会导致FULL SCAN
,需要在pk2 pk3上建立二级索引。
而下面的SQL则会使用RANGE SCAN
:
select * from test where pk1='x' and pk2='y';
这就跟Mysql索引中的最左前缀的要求一样。解决方法是在pk2
和pk3
列上建立二级索引,就可以利用索引表使用RANGE SCAN
了。
5.2.1.2 FULL SCAN
FULL SCAN
意味着将扫描表的所有行(但如果sql中包含WHERE
子句,则可能会应用过滤器)
5.2.1.3 SKIP SCAN
Phoenix使用SKIP_SCAN
应对行内scan
。当根据给定的一组key来搜索时,与Range Scan
相比能显着提高性能。
他的原理是利用了HBase Filter
的SEEK_NEXT_USING_HINT。 它存储了每个列中正在被搜索的key的key set
/key range
的信息。 它接收一个key(在过滤器评估期间传递给它),并确定该key是否在其中一个key set或key range内。 如果没有,它会计算出要吓一跳的目标最大key值。
SkipScanFilter
的输入是List <List <KeyRange >>
。
- 外层list表示RowKey对应的每一成分列(即组成的ROWKEY每个主键)
- 内层list表示对字节阵列边界进行OR运算。
考虑T表是两个主键列(KEY1,KEY2)组成的联合主键。现有下面这个SQL:
SELECT * from T
WHERE ((KEY1 >='a' AND KEY1 <= 'b') OR (KEY1 > 'c' AND KEY1 <= 'e'))
AND KEY2 IN (1, 2)
那么以上这个sql对应的SkipScanFilter List
为[ [ [ a - b ], [ d - e ] ], [ 1, 2 ] ]
- 外层list包括KEY1, KEY2两个主键列
- 内层list
[ [ a - b ], [ d - e ] ]
代表KEY1的范围 - 内层list
[ 1, 2 ]
代表KEY2范围
- 内层list
上图就是一个SKIP_SCAN
示意图。
- 黄色代表满足SKIP_SCAN,并直接跳跃到下一个最高的key。
- 白色代表被直接跳过的key。
- 当
[<KEY1,b>, <KEY2,1>]
满足条件后,SKIP_SCAN会评估下一个最高key[<KEY1,b>, <KEY2,2>]
,然后该row并不存在,所以直接跳到下一个最高的key[<KEY1,d>, <KEY2,1>]
如果前导主键列上没有过滤器,则不执行SKIP SCAN
,但您可以使用/ + SKIP_SCAN /
来强制执行。
在某些情况下,即当前导主键列的基数较低时,它将比FULL SCAN更有效。
5.2.1.4 DEGENERATE SCAN
DEGENERATE SCAN
意味着查询不可能返回任何行。 如果我们可以在编译时确定,那么我们甚至可以不运行该次scan查询。
5.2.2 Select
5.2.3 Join
官网Join说明的中文翻译见Join
在Phoenix4.8中做了性能测试,测试中极小的Join并发查询就导致QueryServer直接挂掉。如果一定要使用
的话,可以考虑使用子查询。
Phoenix Join主要是Broadcast Hash Join
(需要将小表压入服务端内存)和Sort-Merge Join
(可使用USE_SORT_MERGE_JOIN HINT
强制使用)。
关于Join类型原理可以参考SparkSQLJoin
5.3 DML
5.3.1 动态列
lastGCTime
/usedMemory
/maxMemory
都不存在时动态加入列到表:
UPSERT INTO EventLog (eventId, eventTime, eventType, lastGCTime TIME, usedMemory BIGINT, maxMemory BIGINT) VALUES(1, CURRENT_TIME(), ‘abc’, CURRENT_TIME(), 512, 1024);
5.3.2 ON DUPLICATE KEY
- ON DUPLICATE KEY UPDATE
不存在就插入,存在就原子性更新若干行UPSERT INTO my_table(id, counter1, counter2) VALUES ('abc', 0, 0) ON DUPLICATE KEY UPDATE counter1 = counter1 + 1, counter2 = counter2 + 1;
- ON DUPLICATE KEY IGNORE
仅当不存在时才插入UPSERT INTO my_table(id, my_col) VALUES ('abc', 100) ON DUPLICATE KEY IGNORE;
- 使用限制参考atomic_upsert
如不能在全局索引表上UPSERT ON DUPLICATE KEY
5.3.3 Upsert
5.3.4 Upsert Select
5.4 Fuctions
内置函数可参考Phoenix-Fuctions
5.5 UDF
5.5.1 概念
用户可以创建临时/永久用户定义或特定于域的UDF,就像其他内嵌函数一样在查询中使用。Phoniex利用HBase的动态ClassLoader来在客户端和RegionServer上从HDFS动态加载UDF的相关jar,无需重启服务。
5.5.2 分类
UDF分类如下:
- 临时UDF
临时UDF的生命周期为Session/Connection级别,并与其他Session/Connection相互隔离。 - 永久UDF
永久UDF的元数据局将存储在名为SYSTEM.FUNCTION
的系统表中。 - 租户级别UDF
组合特定的连接中创建的UDF对其他组合连接不可见,只有全局UDF可以对所有连接可见。
5.5.3 配置
在Phoenix客户端的ClassPath下的hbase-site.xml
中配置如下内容:
<property>
<name>phoenix.functions.allowUserDefinedFunctions</name>
<value>true</value>
</property>
<property>
<name>fs.hdfs.impl</name>
<value>org.apache.hadoop.hdfs.DistributedFileSystem</value>
</property>
<property>
<name>hbase.rootdir</name>
<value>${hbase.tmp.dir}/hbase</value>
<description>The directory shared by region servers and into
which HBase persists. The URL should be 'fully-qualified'
to include the filesystem scheme. For example, to specify the
HDFS directory '/hbase' where the HDFS instance's namenode is
running at namenode.example.org on port 9000, set this value to:
hdfs://namenode.example.org:9000/hbase. By default, we write
to whatever ${hbase.tmp.dir} is set too -- usually /tmp --
so change this configuration or else all data will be lost on
machine restart.</description>
</property>
<property>
<name>hbase.dynamic.jars.dir</name>
<value>${hbase.rootdir}/lib</value>
<description>
The directory from which the custom udf jars can be loaded
dynamically by the phoenix client/region server without the need to restart. However,
an already loaded udf class would not be un-loaded. See
HBASE-1936 for more details.
</description>
</property>
其中phoenix.functions.allowUserDefinedFunctions
可以设为getConnection时的属性,hbase.rootdir
和hbase.dynamic.jars.dir
需要和HBase服务端上的设置一致。
配置完成后重启phoenix queryserver。
5.5.4 编写UDF并打包成jar
可参照Phoenix自带的UpperFunction
等类的实现代码改写,需要认真阅读各个方法含义,有点复杂。
我这里实现了一个截取前两个字符的UDF,作为示例:
package com.mljk.demos.udfs.test1;
import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
import org.apache.phoenix.expression.Expression;
import org.apache.phoenix.expression.function.ScalarFunction;
import org.apache.phoenix.schema.tuple.Tuple;
import org.apache.phoenix.schema.types.PDataType;
import org.apache.phoenix.schema.types.PVarchar;
import java.sql.SQLException;
import java.util.List;
/**
* @Author: mljk
* @Date: 2019-08-28 17:31
*/
public class FirstTwoCharsFunction extends ScalarFunction {
public static final String NAME = "FTC";
public static final int FIRST_N = 2;
public FirstTwoCharsFunction() {
}
public FirstTwoCharsFunction(List<Expression> children) throws SQLException {
super(children);
}
@Override
public String getName() {
return NAME;
}
/**
* 计算每行数据的结果
* @param tuple 表示当前行的状态
* @param ptr 需要填充内容,即该UDF返回的结果
* @return false意味着没有足够信息来计算结果(通常因为某个参数未知);true代表成功
*/
public boolean evaluate(Tuple tuple, ImmutableBytesWritable ptr) {
if (!getStrExpression().evaluate(tuple, ptr)) {
return false;
}
String sourceStr = (String) PVarchar.INSTANCE.toObject(ptr, getStrExpression().getSortOrder());
if (sourceStr == null) {
return true;
}
if (sourceStr.length() <= FIRST_N) {
ptr.set(PVarchar.INSTANCE.toBytes(sourceStr));
return true;
}
ptr.set(PVarchar.INSTANCE.toBytes(sourceStr.substring(0,2)));
return true;
}
/**
* 决定UDF的返回类型
* @return 决定UDF的返回类型
*/
public PDataType getDataType() {
return getStrExpression().getDataType();
}
private Expression getStrExpression() {
return children.get(0);
}
}
编写完成后,打包成jar,不需要fat-jar因为一般的依赖Phoenix-QueryServer已经带了,但是其他第三方依赖需要打进jar里。
5.5.5 上传UDF jar
最好是上传到hbase-site.xml
中使用hbase.dynamic.jars.dir
配置的HDFS目录下。
$ hdfs dfs -put ftc.jar /hbase/lib
$ hadoop dfs -ls /hbase/lib
-rw-r--r-- 3 hbase supergoup 3298 2019-08-28 18:32 /hbase/lib/ftc.jar
5.5.6 注册UDF jar
我这里使用sqlline
注册一个永久的UDF:
CREATE FUNCTION ftc(varchar) returns varchar as 'com.mljk.demos.udfs.test1.FirstTwoCharsFunction';
注意:如果没有放在刚才提到的hbase.dynamic.jars.dir
配置的HDFS目录下,则还需要加USING JAR jarPath
。
5.5.7 使用UDF
查询时,就用刚才注册的函数名即可,查询结果对比如下:
5.5.8 卸载UDF jar
DROP FUNCTION IF EXISTS ftc;
DROP FUNCTION ftc;
5.5.9 Jar相关SQL
- 上传jar包到
hbase.dynamic.jars.dir
配置的HDFS目录下示例
add jars ’/parentpath/udfs/xxx.jar‘
- 删除
hbase.dynamic.jars.dir
配置的HDFS目录下的jar包示例
delete jar '/parentpath/udfs/xxx.jar'
- 展示所有
hbase.dynamic.jars.dir
配置的HDFS目录下的jar包
list jars
5.6 Phoenix Shell
5.6.1 直连HBase
- 默认连接本地HBase
$PHOENIX_HOME/bin/sqlline.py
- 连接指定地址的HBase
$PHOENIX_HOME/bin/sqlline.py 192.168.1.1:8765
5.6.2 连接Phoenix-queryserver访问HBase
-
启动
queryserver
$PHOENIX_HOME/bin/queryserver.py start
-
启动
sqlline-thin
连接本地queryserver
$PHOENIX_HOME/bin/sqlline.py
-
启动
sqlline-thin
连接指定地址queryserver
$PHOENIX_HOME/bin/sqlline.py 192.168.1.1
5.7 JavaAPI
Thin-Client Connection请参考Avatica-client_reference
Class.forName("org.apache.phoenix.queryserver.client.Driver")
// for thick driver, please refer to http://phoenix.apache.org/faq.html#What_is_the_Phoenix_JDBC_URL_syntax
// for thin driver (used with the Phoenix Query Server) is as follows:
Connection conn = DriverManager.getConnection("jdbc:phoenix:thin:url=http://192.168.1.2:8765;serialization=JSON");
Statement stmt = conn.createStatement();
stmt....
statement.close();
conn.close();
5.8 批量导入
请参考:
5.8.1 MR
基于MR的数据导入工具,适用于CSV和JSON格式数据。
注意:目前,如果已经存在数据发生更新,则无法为可变索引表生成正确的更新。
注意:使用BULKLOAD时,必须先对ROWKEY列去重,否则会造成数据表和索引表数据不一致
5.8.2 PSQL
是一个单线程数据导入工具,适用于10MB级别以下的CSV格式数据,插入速度通常为20K-50K行/秒。
5.8.3 DataX
请参考:
5.9 HBase表映射
可以为已经存在的HBase表建立Phoenix表映射或视图。若列不存在,会自动创建并设空值。
具体参见How I map Phoenix table to an existing HBase table?
5.10 Expalin执行计划
5.10.1 概述
使用EXPLAIN来获知query怎么执行,可以参考以下原则来修改查询语句:
- 在Server侧上跨集群并行执行操作,而不是在Client单节点执行,即尽可能将过滤条件或计算下推到Server侧进行
- 尽可能使用
RANGE SCAN
或SKIP SCAN
,而不是效率低下的FULL SCAN
- 使用主键中的前导列来进行条件过滤,主键列一般使用频繁访问或经常用来条件过滤的列
- 如果必要可以使用覆盖查询的本地索引或全局索引,减少回表查询
- 如果查询时本可用某个索引但优化器没有发现或是用他,则可以用hint语句:
/*+ INDEX() */
5.10.2 全局索引例子
比如如下sql语句
explain select uid,age from test.mljk_20190827 where age = 1;
explain结果如下:
结果解释如下:
-
CLIENT/SERVER
表明操作在客户端或是服务端执行。比如本例中:- 客户端执行的就是
5-CHUNK PARALLEL 5-WAY ROUND ROBIN RANGE SCAN OVER TEST.AGE_IDX [0,~1] - [4,~1]
, - 服务端执行的是
FILTER BY FIRST KEY ONLY
- 客户端执行的就是
-
5-CHUNK
执行此次查询的线程数为5。该值最大为线程池中的线程数,而最小并行化对应于表在此次scan的startRow和stopRow之间具有的Region的数量。根据查询统计信息,可把一个HBaseion Region分成多个CHUNK, 则这里的5表示将要扫描的CHUNK数量为5,使用多线程并发扫描这些CHUNK。
-
PARALLEL 5-WAY
需要归并排序5个并行SCAN的结果 -
SERIAL 1-WAY
单线程串行执行。比如下面这个SQL:explain select uid from test.mljk_20190827 where uid = 1 limit 1;
-
ROUND ROBIN
查询语句中无ORDER BY
时, 可以任何顺序排序返回。这里用了ROUND ROBIN
, 可最大化客户端的并行化。 -
MERGE SORT
查询语句中包含ORDER BY
时,需要在执行结果上使用归并排序。 -
RANGE SCAN OVER TEST.AGE_IDX [0,~1] - [4, ~1]
使用RANGE SCAN扫描某表,括号内表示ROWKEY之startRow, stopRow的部分组成部分,比如这里0和4代表的是SALT_BUCKET的加盐字节范围。这个需要小心,因为可能explain结果是
RANGE SCAN
但实际上是FULL SCAN。比如我在被覆盖索引覆盖的列上执行where查询explain select uid,name,age,gender from test.mljk_20190827 where gender = 'ksharp';
,结果却如下:
-
FULL SCAN OVER tableName
全表扫描某张表 -
INNER-JOIN
该查询会按join条件连接多张表 -
SERVER FILTER BY FIRST KEY ONLY
服务端使用的过滤器为FIRST KEY ONLY
。
5.10.3 聚合查询例子
explain select gender,count(name) as age23_g from test.mljk_20190827 where age = 23 group by gender order by age23_g;
-
SERVER AGGREGATE INTO DISTINCT ROWS BY [“GENDER”]
将返回结果按聚合条件进行聚合运算,这里是以GENDER
做分组来进行对NAME
进行count,也可以sum等。当使用了ORDERED时,groupy by操作应用于主键约束的前导部分,这允许聚合在适当的位置完成,而不是将所有不同的组保留在服务器端的内存中。
-
SERVER AGGREGATE INTO SINGLE ROW
对所有结果使用一个聚合函数(没有GROUP BY)聚合成单一行。比如只是用COUNT(*)
我发现,count distinct是在客户端做的,比如在上面的sql改为count distinct:
explain select gender,count(distinct name) as age23_g from test.mljk_20190827 where age = 23 group by gender order by age23_g;
可以看到,客户端COUNT前增加了DISTINCT_
字样。
5.10.4 使用联合索引例子
explain select uid,name,age,gender from test.mljk_20190827 where name='Tony' and age = 17;
可以看到,本查询直接在索引表NA_INDEX
上进行了RANGE SCAN
,ROWKEY范围直接就是由加盐字节、NAME、AGE三部分组成。
5.10.5 新版本EXPLAIN
新版本PHONIEX还支持了三个EXPLIAN参数:
- EST_BYTES_READ
查询扫描的总字节数的估计 - EST_ROWS_READ
查询扫描的总行数的估计 - EST_INFO_TS
以毫秒为单位的epoch time,用于收集估计信息
5.11 UPDATE STATISTICS
STATISTICS统计信息默认是开启的。而UPDATE STATISTICS
被用来更新表的统计信息,并默认更新其关联的所有索引表。
-
guidepost
该命令在Region基础上又细分了一层,划分点被称为guidepost
,划分点之间等距。Phoenix就是使用这些guidepost将查询拆分为多个并行的Scan。可参考参数:phoenix.stats.guidepost.width
和phoenix.stats.guidepost.per.region
。需要注意的是,guidepost宽度设置越小,则并行度越高,但会导致chunk
变多,客户端所做的归并动作更多。在某些情况下,将guidepost宽度调大,由于95%查询可命中读缓存速度很快(大多RS采用SSD),且该配置可减小并发从而减少并发任务排队情况,查询速度和系统整体吞吐量可以得到提升,所以到底应该将guidepost调小还是调大需要多次测试确定。在Phoenix4.9以后,用户可以自定义每个表的guidepost宽度。 最优guidepost宽度取决于许多因素,例如集群大小,集群使用情况,每个节点的核心数,表大小和磁盘I / O等。
-
phoenix.stats.cache.maxSize
该参数指定表统计信息缓存阈值。表过大时请调大该值,否则执行sql每次都需要重加载表的统计信息。
在Phoenix 4.12中,我们添加了一个新的配置phoenix.use.stats.parallelization
,它控制是否应该使用统计信息来驱动并行化。 请注意,仍然可以运行stats集合。 收集的信息用于表示在为其生成EXPLAIN时查询将扫描的字节数和行数的估计值。
命令选项如下:
- 仅需更新数据表,请使用
COLUMNS
选项; - 仅更新索引表,使用
INDEX
选项。 - 还可以使用
tableRef
的完整索引名称来更新单个索引的统计信息。 - 可以通过在
SET
关键字后指定值来覆盖默认的guidepost
(路标)属性。 请注意,发生major compact时,将再次使用默认guidepost属性。
示例:
UPDATE STATISTICS my_table
UPDATE STATISTICS my_schema.my_table INDEX
UPDATE STATISTICS my_index
UPDATE STATISTICS my_table COLUMNS
UPDATE STATISTICS my_table SET phoenix.stats.guidepost.width=50000000
6 其他重要概念
6.1 多租户(Multi tenancy)
通过多租户表及指定不同的租户连接(只能访问属于该租户的数据),实现数据访问的隔离。租户只能看到自己的多租户表中的数据,但非多租户表对所有租户可见。
还可以在做租户表上创建租户视图。
定义多租户表语句如下:
CREATE TABLE base.event (tenant_id VARCHAR, event_type CHAR(1), created_date DATE, event_id BIGINT)
MULTI_TENANT=true;
该表与多租户连接
联合使用,第一个主键列代表租户,使得租户只能看到该表内租户id为当前连接租户的数据。而常规连接使用该类表没有限制。
6.2 CurrentSCN
6.2.1 概念
默认情况下数据插入时,HBase系统自己管理时间戳,用Phoenix每次查出最新的一个版本数据。但在Phoenix中也可以使用CurrentSCN
自定义该时间。
6.2.2 更新例子
Properties props = new Properties();
props.setProperty(PhoenixRuntime.CURRENT_SCN_ATTRIB, Long.toString(System.currentTimeMillis()));
Connection conn = DriverManager.connect(myUrl, props);
conn.createStatement().execute("UPSERT INTO myTable VALUES ('a')");
conn.commit();
以上代码相当于以下HBase API代码:
myTable.put(Bytes.toBytes('a'),System.currentTimeMillis());
还可参考例子
6.2.3 查询与CurrentSCN
当查询中使用CurrentSCN
时,只能看见CurrentSCN之前生成的数据。可以使用该特性来生成快照等。
注意,使用了CurrentSCN时就不能再使用事务。
6.3 Metrics
默认情况下Phoenix Query Server以JMX方式暴露了很多全局指标,请参见Phoenix-Metrics
6.4 ROW_TIMESTAMP
6.4.1 概念
Phoenix 4.6后提供了一种将HBase本地Row时间戳映射到Phoenix列的方法,这有助于为HBase为StoreFiles做各种TimeRange方面的优化,此外Phoenix也能做出一些查询优化(不仅包括常规主键列优化,还能适当设置该scan的min/max时间范围(scan.setTimeRange),HBase能直接跳过那些不在该时间范围的StoreFile)。
要被设计为ROW_TIMESTAMP
的列需要遵循以下规则:
- 只有
TIME
,DATE
,TIMESTAMP
,BIGINT
,UNSIGNED_LONG
类型的主键列可以被指定为ROW_TIMESTAMP。 - 只能将每张表的某一个主键列指定为
ROW_TIMESTAMP
。 - ROW_TIMESTAMP列的值不能为null(因为它直接映射到HBase ROW_TIMESTAMP)。 这也意味着只有在创建表时才能将列声明为ROW_TIMESTAMP,而不能修改列为ROW_TIMESTAMP。
- ROW_TIMESTAMP列的值不能为负数。 这意味着对于
DATE
/TIME
/TIMESTAMP
时间(以毫秒为单位)不能小于零。
当使用ROW_TIMESTAMP添加行时,若用UPSERT VALUES
或UPSERT SELECT
,则可以显式提供行的时间戳列的值。 未指定时,Phoenix将行时间戳列的值设置为服务器端时间。 该列的值也最终成为HBase中相应行的时间戳。
需要注意的是,目前查询ROW_TIMESTAMP对于in
过滤条件有bug,请使用>
/<
6.4.2 例子
- 表结构
CREATE TABLE DESTINATION_METRICS_TABLE (CREATED_DATE DATE NOT NULL, METRIC_ID CHAR(15) NOT NULL, METRIC_VALUE LONG CONSTRAINT PK PRIMARY KEY(CREATED_DATE ROW_TIMESTAMP, METRIC_ID)) SALT_BUCKETS = 8;
- 指定CREATED_DATE的时间
UPSERT INTO DESTINATION_METRICS_TABLE VALUES (?, ?, ?)
- 没有指定该ROW_TIMESTAMP列时使用服务器时间
UPSERT INTO DESTINATION_METRICS_TABLE (METRIC_ID, METRIC_VALUE) VALUES (?, ?)
6.4.3 注意事项
- 使用了ROW_TIMESTAMP后就不能再用全局索引了。
- 使用了ROW_TIMESTAMP后,不能使用可变索引表
7 优化
最重要的是schema设计,会影响到底层HBase。
还需要注意的是,Phoenix on HBase只适合精确查询和小范围的scan,不适合大量full scan,请选择如Parquet类似的列式存储。
7.1 不同场景优化
7.1.1 数据随机访问
使用SSD硬盘可以提高随机访问性能。
7.1.2 数据是读还是写更多?
- 读多场景:
- 创建全局索引
虽然写入速度受影响,因为如果有多个索引时,每个索引需要写入到自己的单独索引表中。但全局索引的读性能优于需要扫描所有Range的本地索引。 - 针对高频查询,如果一个索引不能覆盖就创建多个索引
- 为RegionServer配置高性能多核CPU机器资源
- 创建全局索引
- 写多场景:
- 预分区表
如果主键单调递增,联用加盐表避免数据热点。 - 使用高级数据类型而不是原始字节数据。
- 创建本地索引
Phoenix4.8以后本地索引作为数据表的一个影子列族,所以写入很快。但注意从本地索引读取有性能损失,所以做性能测试很有必要。
- 预分区表
7.1.3 哪些列将经常访问
- 选择最常被查询的列作为主键。
- 创建二级索引来支持其他常见的查询。
7.1.4 只追加不可变的数据?
- 不可变索引
如果数据是不可变的或仅追加的,则在创建时使用IMMUTABLE_ROWS
选项将表及其索引声明为不可变,以减少写入时间成本。如果您需要使现有的表不可变,那么可以在创建后使用ALTER TABLE xxx SET IMMUTABLE_ROWS = true
。 - 禁用WAL
如果速度比数据完整性更重要,则可以使用DISABLE_WAL
选项,但风险是RS故障时丢失数据。 - 降低元数据刷新频率
如果元数据不经常更改,请将UPDATE_CACHE_FREQUENCY 选项设置为15分钟左右。此属性指定以RPC方式到HBase更新元数据的频率,以确保您看到最新schema。 - 数据不稀疏时
如果数据不稀疏(超过50%的Cell都有值),请使用Phoenix 4.10中引入的[SINGLE_CELL_ARRAY_WITH_OFFSETS数据编码](Column Mapping and Immutable Data Encoding)方案,可通过减小数据大小来获得更快的性能。
7.1.5 表很大的情况
- 异步更新索引
- SKIP SCAN
如果数据太大而无法完全扫描表,则使用联合主键,以便返回数据的一个子集或便于SKIP SCAN。 - 调大更新表元数据时间
调大UPDATE_CACHE_FREQUENCY
,否则可能每次缓存失效时加载元数据时会造成慢查询。 - 调大统计信息缓存阈值
表过大时请调大phoenix.stats.cache.maxSize
,否则执行sql每次都需要重加载表的统计信息。
7.1.6 需要事务的场景
HBase只支持行级事务,Phoenix通过与Apache Tephra集成,增加了对跨行、跨表的事务ACID语义的支持。 Tephra通MVCC提供并发事务的快照隔离。可参考这里
7.1.7 压缩和块编码
使用压缩或编码是必要的。
FAST_DIFF编码默认在所有Phoenix表上自动启用,并且通过允许更多的数据适合HBase BlockCache,几乎总是可以降低整体读取延迟和提高吞吐量。
注意:FAST_DIFF编码会增加请求处理过程中产生的垃圾,增加GC开销。
Phoenix/HBase适合Gzip压缩而不是Snappy:
- Snapyy设计目标是兼顾压缩速度和压缩率。离线任务一般使用传统的SATA盘,存储空间充足,CPU核数较少利用率较高。而离线任务多是连续的扫盘/写盘动作,执行时长和压缩/解压缩速度关系密切。
- 而Gzip压缩时间长,压缩率通常更高。HBase RegionServer一般使用存储有限的SSD和多CPU高配机器,而HBase的写是到Memstore再异步压缩后刷入磁盘;读是按Memstore->Blockcache->HFile顺序,所以用户读写受压缩/解压缩影响较小。而Phoenix查询HBase时,RegionServer其实CPU使用率较低,可腾出来给压缩使用,压缩后可节约昂贵的SSD存储资源。
在表创建时设置FAST_DIFF
编码,使用gzip
格式压示例:
CREATE TABLE TEST
(HOST VARCHAR NOT NULL PRIMARY KEY, DESCRIPTION VARCHAR)
DATA_BLOCK_ENCODING ='FAST_DIFF',COMPRESSION='GZ'
7.1.8 需要fast-fail而不是持续等待成功的场景
请调低客户端hbase-site.xml
配置中的phoenix.query.timeoutMs
客户端可容忍的最大毫秒数。
7.1.9 可容忍稍微过时信息的场景
设置客户端hbase-site.xml
配置中的phoenix.connection.consistency
7.2 整体设计
7.2.1 Salting Table
-
加盐原理
利用原key经过某个hash算法,并对SALT_BUCKET
求余数的方式后转为byte,该字节数据加到原ROWKEY前缀,这样将数据较均匀的分布到集群中的所有RegionServer。 -
加盐好处
加盐表+预分区可以提升HBase集群负载均衡能力,大大提升读写能力。 -
加盐、预分区16个Region/Bucket示例:
CREATE TABLE TEST (HOST VARCHAR NOT NULL PRIMARY KEY, DESCRIPTION VARCHAR) SALT_BUCKETS=16
-
注意事项
- 在理想情况下,对于具有四核CPU的16个RS群集,选择32-64之间的
SALT_BUCKETS
可获得最佳性能。 - 最多加盐256个,因为盐是1字节(8位,0-255)
- 加盐后,会多一步计算前缀的过程,有一定性能损耗
- 加盐表一旦创建不可再修改
- 加盐表初始时Bucket和Region数量相同,随着数据增加Region也会跟其他Region一样拆分。
- SALT_BUCKETS设置过大可能降低Range Scan性能。再比如用
in
查Phoenix主键前导字段,那么字段枚举数*盐数就有很高的并发了,致使一个较为精确的查询需要大量scan,反而增加查询时间。
- 在理想情况下,对于具有四核CPU的16个RS群集,选择32-64之间的
7.2.2 预分区
示例:
CREATE TABLE TEST
(HOST VARCHAR NOT NULL PRIMARY KEY, DESCRIPTION VARCHAR)
SPLIT ON ('CS','EU','NA')
7.2.3 使用若干列族
将经常查询、强相关的列放在一个列族,提升数据读取效率,因为HBase可以只读查询用到的列族的文件,而不用管其他列族。
创建A B两个列族的sql:
CREATE TABLE TEST
(MYKEY VARCHAR NOT NULL PRIMARY KEY,
A.COL1 VARCHAR, A.COL2 VARCHAR,
B.COL3 VARCHAR)
7.2.4 结构化数据
不要使用JSON,因为他并不紧凑。可以使用protobuf, Avro, BSOn等。
7.2.5 数字化列名映射
Phoenix4.10以后,可以对非主键列使用数字化的HBase列名而不是直接使用列的字符型名字。这样可以减少HBase返回的Cell的大小,减少HBase表大小,加快DDL操作。
7.3 索引
可参考索引调优实例:
7.3.1 主键索引
由Phoenix主键并按一定规则生成的HBase的Rowkey是Phoenix性能表现如何的最重要的一个因素,在不重写数据和索引表的情况下无法再次进行更改。
Phoenix主键应该选择那些最频繁查询的列,并且放在前导位置的列是对性能至关重要的。例如,可以使用包含organization标识值的列来做签到列,则可以轻松选择与特定organization有关的数据行。也可以将HBase行的时间戳添加到主键,这样能直接跳过设定的time range以外的行,能提高scan效率。
每个主键都会产生一定的成本,因为ROWKEY作为HBase的KeyValue的一部分作为基本代为被存在各个相关的内存和磁盘上,所以ROWKEY越大,存储开销就越大.
总而言之,最佳做法:
- 是设计主键以使得对应生成的ROWKEY能支持扫描最小量的数据。(这不是废话吗,重点是如何设计?)
- 选择主键时,首先选择那些在查询中过滤最频繁的列,这是最重要的优化点。
- 如果您在查询中使用
ORDER BY
,请确保您的主键列匹配ORDER BY
子句中的表达式。
关于单调递增主键:
-
如果主键单调递增,则使用加盐表来打散写入,提高并行性。为获得最佳性能,SALT_BUCKET的数量应大致等于RegionServer的数量。
但需要注意的是,加盐表的缺点是查询时需要开启多个查询来做Range Scan。
7.3.2 二级索引
- 二级索引原理和参数调优参考:
- secondary-index
注意项如下:
- 建立索引超时或失败需要重建,可通过
SYSTEM.STATS
表查看索引状态,也可以用full scan的方式count(*)索引表,速度较慢。 - 写多读少时使用本地索引
- 读多写少时使用全局索引
- 合理使用覆盖索引
- 数据太多时使用异步索引避免阻塞
- 设计索引时请深思熟虑,不要构建不必要的索引,造成读写放大等不必要开销。特别需要限制频繁更新的表上的索引数。
7.4 并行化
7.4.1 将表拆分多个Region
Phoenix将聚合查询拆分成多个Scan,然后提交到RS上的Phoenix开发的协处理器并行执行。
7.4.2 参数
网上提到
phoenix.query.targetConcurrency
和 phoenix.query.maxConcurrency
但我在官网和Phoenix源码中并未找到这两个配置。
所以请参考本文的读并行优化
7.4.3 合理拆分表
参考记一次phoenix在不加索引的情况调优,由6s以上时间变成不到1s
7.5 读优化
7.5.1 概述
使用EXPLAIN
来了解查询分别在客户端侧和服务端侧做了什么是十分有必要的,可以找出网络IO和其他影响性能的瓶颈源头。数据量很大时,我们应该尽可能让更多计算运行在服务端侧,少量、必要的运算在客户端侧运行(如全局排序,需要客户端聚合各个Region查出的数据和再排序)。
Phoenix在4.2以后的版本中可通过查询统计来优化查询并行性,自动提升性能。
7.5.2 原则
- 除非至少一个表数据量很小,否则避免join,特别是频繁查询的场景
- 尽量在WHERE子句中,过滤主键约束中的前导列
- 使用WHERE子句中的IN或OR过滤第一个前导列可启用SKIP SCAN优化。
- 使用WHERE子句中的
=
或比较符号(<
或>
)来启用RANGE SCAN优化。
7.5.3 Range Query
-
如果经常使用scan,而且是传统旋转磁盘,那最好是使用GZIP格式压缩数据(注意写入速度)。
-
Phoenix利用多核进行并行Scan,提升性能。
-
但HBase BlockCache对于RANGE SCAN帮助不大
对于大的RANGE SCAN,应考虑设置
Scan.setCacheBlocks(false)
。如果大部分是执行大的RANGE SCAN,可以将HBase RegionServer的Heap调小、并调小BlockCache,而只依赖操作系统缓存。这样能减少GC开销。
7.5.4 精确查询
对于精确查询来说,使用数据缓存(HBase BlockCache)很重要。
7.5.5 Hint
- 强制使用全局索引
查询中某列不在索引中,可用Hint强制使用索引并回表查询 - 指定join类型
- 按需通过
/*+ USE_SORT_MERGE_JOIN */
使用big join
,但注意在海量数据行时这是开销昂贵的操作。 - 如果join时所有右侧的表的总大小超过内存大小限制,请使用
/ * + NO_STAR_JOIN * /
。
- 按需通过
- 指定scan类型
7.5.6 Explain
观察查询语句的执行计划做出适当优化。
7.5.7 并行化
UPDATE STATISTICS可优化并行度
可参考参数:phoenix.stats.guidepost.width
和phoenix.stats.guidepost.per.region
.
关于并行化chunk过多bug可参考phoenix优化之旅(一)物理执行计划的源码优化
7.6 写优化
7.6.1 批量写入
可参考
当使用UPSERT插入大量数据时,请关闭autocommit
,并尝试batch提交,从100开始调试找到合适的批大小。需要注意的是,由于Phoenix客户端在内存中保留未提交的行,请不要将commitSize设置得太高以免丢失大量数据。
- 如果使用fat Phoenix Driver,则不要使用
executeBatch
,而应该多次调用UPSERT VALUES
,并在合适时候调用commit
提交该batch。/** * FatDriver upsert batch * @param baseSql * @param nameList * @param conn * @throws SQLException * @throws ClassNotFoundException */ public static void upsertBatchFatDriver(String baseSql, List<String> nameList,Connection conn) throws SQLException, ClassNotFoundException { conn.setAutoCommit(false); int batchSize = 0; // 每个batch大小 int commitSize = 1000; try (PreparedStatement ps = conn.prepareStatement(baseSql)){ for(String name : nameList){ stmt.setString(1, name); stmt.executeUpdate(); batchSize++; if (batchSize % commitSize == 0) { conn.commit(); } } // commit the last batch of records conn.commit(); } }
- 如果使用thin Phoenix Driver,使用executeBatch十分重要,可最小化客户端和服务端的RPC连接数
/** * Thin Driver upsert batch * @param baseSql * @param nameList * @param conn * @throws SQLException * @throws ClassNotFoundException */ public static void upsertBatchThinDriver(String baseSql, List<String> nameList,Connection conn) throws SQLException, ClassNotFoundException { conn.setAutoCommit(false); int batchSize = 0; // 每个batch大小 int commitSize = 1000; try (PreparedStatement ps = conn.prepareStatement(baseSql)) { for(String name : nameList){ ps.setString(1, name); ps.addBatch(); batchSize++; if (batchSize % commitSize == 0) { ps.executeBatch(); conn.commit(); } } // execute and commit the last batch of records ps.executeBatch(); conn.commit(); } }
7.6.2 UPSERT SELECT
当在一个Statement中使用UPSERT SELECT
写入大量数据时,启用autocommit
,则将根据phoenix.mutate.batchSize
自动批处理。 这将最小化返回到客户端的数据量,并且是更新大量行时的最有效方法。
7.6.3 加盐和预分区预防写入热点
7.7 删除优化
删除大型数据集时,请在发出DELETE
的查询语句之前启用autoCommit,以便客户端在删除所有行时不需要记住这些ROWKEY,Phoenix可以直接在RegionServer上删除它们,无需返回给客户端。
7.8 减少RPC
为了减少RPC流量,在创建表或索引时设置UPDATE_CACHE_FREQUENCY
属性,以指定到HBase更新元数据的频率。
7.9 HBase调优
参考:
7.10 更多Phoenix参数调优
详见:
部分重要参数如下:
参数 | 说明 | 默认值 |
---|---|---|
data.tx.snapshot.dir | 服务端指定用来存放事务状态的快照的HDFS目录路径 | - |
data.tx.timeout | 服务端指定事务完成的超时时间,单位为秒 | 30 |
phoenix.query.timeoutMs | 客户端查询超时毫秒数。如果由于某种原因无法异步创建索引,则将查询超时增加到大于构建索引所需的时间 | 600000 |
phoenix.query.keepAliveMs | 当线程总数大于客户端线程池核心线程数时,最大空闲时间毫秒数 | 60000 |
phoenix.query.threadPoolSize | 客户端线程池中的线程数(核心和最大相同),随着集群中的机器/CPU数量的增长,这个值应该增加 | 128 |
phoenix.query.queueSize | 客户端线程池(有界的ROUND RONBIN)队列大小,队列满了就拒绝请求抛出RejectedExecutionException。如果设为0就使用SynchronousQueue,即无等待队列而是创建新线程执行,看起来效率最高但是达到一定程度可能造成线程过多OOM。 | 5000 |
phoenix.stats.guidepost.width | 服务端侧的参数,指定guidepost(划分点)大小。划分点之间等距,Phoenix就是使用这些guidepost将查询拆分为多个并行的Scan执行。如果调低,可增加并行度,但会增加chunk数,从而增加客户端侧合并工作。默认100MB。 | 104857600 |
phoenix.stats.guidepost.per.region | 服务端侧参数,指定每个Region的guidepost数量。设为大于0的值时,guidepost的宽度值就被定义为表的MAX_FILE_SIZE / phoenix.stats.guidepost.per.region。这里的MAX_FILE_SIZE是HBase配置每个Region拆分阈值。未设定该值时使用phoenix.stats.guidepost.width参数值。 | - |
phoenix.query.spoolThresholdBytes | 并行查询任务执行完毕后将结果写到磁盘的阀值,单位为字节,默认20MB | 20971520 |
phoenix.query.maxSpoolToDiskBytes | 并行查询任务执行失败后写入磁盘的阀值,默认是1GB | 1024000000 |
phoenix.query.maxGlobalMemoryPercentage | 所有线程可能使用的总heap内存的百分比。仅跟踪粗粒度内存使用情况,主要考虑通过聚合在组内构建的中间映射中的内存使用情况。 达到此限制时,客户端会阻止尝试获取更多内存,从而限制内存使用量。 | 15 |
phoenix.query.maxGlobalMemorySize | 和phoenix.query.maxGlobalMemoryPercentage含义相仿,取较低值 | - |
phoenix.query.maxGlobalMemoryWaitMs | 当无足够的可用内存时,客户端阻塞等待的最大时间,超时就抛出InsufficientMemoryException,单位为秒 | 10 |
phoenix.mutate.maxSize | 每个客户端操作batch能容纳最大的行数,超过后就必须commit或者rollback | 500000 |
phoenix.query.useIndexes | 检查索引是否用优化器来查询 | true |
phoenix.groupby.spillFiles | 将GROUP BY不同值溢出到磁盘时要使用的内存映射溢出文件数 | 2 |
phoenix.coprocessor.maxNetaDataCacheSize | 元数据最大的缓存值 | 20MB |
phoenix.connection.autoCommit | 获取Connection时是否开启auto-commit | false |
phoenix.stats.cache.maxSize | 表统计信息缓存阈值,超过时需要移除。如果大表统计信息超过该值,会造成每次sql执行都需要重新加载统计信息,此时可调大该值。默认250MB。 | 268435456 |
8 Phoenix On HBase改造实践
8.1 数据多版本支持
HBase支持数据多版本,但Phoenix不支持,我们对Phoenix进行了源码改造,可支持查询指定数量的MultiVersion数据。
8.2 语句严格模式
Full Scan时响应极慢,影响性能,我们禁止了使用Phoenix Full Scan,鼓励用户使用主键索引或二级索引等方式查询。
8.3 权限管理
原生Phoenix无鉴权,我们加入了鉴权系统,可控制到表级别的访问权限。
8.4 系统核心指标统计
8.5 使用审计
8.6 慢查询统计
9 性能
10 Phoenix FAQ
完整版可参考Phoenix-FAQ
10.1 Phoenix有多快?为什么Phoenix这么快
- Phoenix对一亿行的表(中等大小的集群上的窄表),执行full scan 通常在20秒内返回。
- 主键列和filter
如果查询包含了在主键列上的filter
,那查询时间会减少到毫秒级。 - 二级索引
对于非主键列或非前导键列,可添加二级索引提升性能表现,甚至和对主键列key使用过滤器效果相当 - 计算下推
可将where过滤条件转换后下推到Server端执行,利用过滤器 - 协处理器聚合
Server段协处理器聚合后将结果返回给客户端做最终聚合,利用了本地计算的优势,减少了大量网络传输。 - 基于统计的优化,RBO/CBO
10.2 为什么执行FULL SCAN依然很快?
- 并行执行查询
Phoenix使用Region的边界来将查询进行分块,并使用可配数量的线程在客户端上并行运行Phoenix使用Region的边界来将查询进行分块,并使用可配数量的线程在客户端上并行运行 - 协处理器处理数据聚合
聚合将在服务器端的协处理器中完成,大大减少返回给客户端的数据量。
10.3 应该使用PhoenixJDBC连接池吗?
不应该。
因为Phoenix的JDBC连接和大多JDBC客户端不同,他是一个很轻的组件,创建开销很低,底层是到HBase的连接。
如果重用Phoenix连接池,可能会因为前一个用户没有将底层使用的HBase连接保持健康状态,从而使得复用的用户使用该不健康的HBase连接导致意外的问题。
所以不要用连接池复用Phoenix连接。
请记住,创建新的PhoenixConnection并不是一项开销很大的操作。 因为同一集群的所有Connection其实底层使用同样的HConnection,因此创建新的PhoenixConnection就像是只用实例化一些对象而已。
10.4 Phoenix on HBase和Impala On Kudu区别
- 总的来说,
Phoenix on HBase
还是适合OLTP场景,响应延时更低,普通非scan查询响应时间优化后能达到毫秒级。 - 而
Impala On Kudu
随着数据增加查询性能确实不如Phoenix on HBase,有时候响应甚至长达10秒以上甚至无法响应。
10.5 为什么Phoenix在插入数据时增加了一个空的或虚的KeyValue?
为了应对除了主键列以外的列值为空或者只有主键列的情况。
10.6 听说事务索引表有强一致性,那么是不是可以都建成事务索引表?
不是。详见这里
11 其他组件与Phoenix
11.1 SQuirreL连接Phoenix
- SQuirreL
SQuirreL SQL Client是一个开源免费软件, 可以通过jdbc对多种数据库进行可视化的管理,查询等\