## HBase SQL Phoenix 在读延迟敏感场景的应用和探索
文/李扬
>本文主要介绍 Phoenix 对接低延迟业务遇到的问题和解决过程,并在最后列举了几个 Phoenix 应用比较成功的场景,帮助读者更好地将 Phoenix 应用到企业应用中。
### 背景
HBase 作为一个优秀的分布式数据库,满足了大量 KV 及 Scan 查询的场景,但其 API 语义定义得偏底层,更专注于 Bytes,需要应用层自己维护类型和编码关系,使用门槛偏高。随着公司业务发展,用户对支持海量存储、并且支持 SQL 的 DB 需求越发强烈,所以引入了 Phoenix。
Phoenix 作为将 HBase从NoSQL 向 SQL 的封装,不仅为 HBase 带来了门槛更低的 SQL 语义,也让其支持了聚合查询、salting、二级索引、join 等扩充 HBase 使用范围的功能。随着 Phoenix 版本的更替,以及各种已知 Bug 的 fixed,在稳定性和高可用性上有了很大的提升和进步,越来越多的企业将 Phoenix 实际应用到实际线上业务中,通过从社区获取到反馈社区的迭代,使 Phoenix 成为了一个可靠的开源产品,在简化架构和业务逻辑方面起着重要的积极作用。
### Phoenix 原理
了一张 SYSTEM.CATALOG 表维护 SQL 二维表的各列到 HBase ColumnFamily 和 Column 的映射。并且为了减轻 Client 端的任务,利用了 HBase的Observer 和
Endpoint 两个接口,实现了 Scan、UnGroupedAggregate、ServerCaching 等coprossor。
图1 Phoenix 实现架构
这样的实现让 HBase 与 Phoenix 结合得更紧密,但是有个致命的缺点,Phoenix 可利用的内存太小。
图2 HBase RegionServer 内存分布图
如图2,RegionServer 的 Heap 大致上可以分为三个部分,BlockCache、Memstore 和 Rest。BlockCache 主要负责缓存近期读命令中的 HFile Block,以供下次访问时缩减磁盘 I/O 带来的延迟。Memstore 则负责将近期写入的数据缓存在内存,等到每个 Region 缓存一定量的大小后(默认 128M)再向 HDFS Flush一个HFile,有助于减少 StoreFile 的数量、减小读延迟。最后 Rest 部分负责 RegionServer 的处理逻辑、Compaction 和 Coprossor 等其他事情。 Phoenix 的 Server 部分逻辑就是工作在 Rest 这个部分。
但是要注意的是,生产环境为了更好地提高吞吐性能,BlockCache 和 Memstore 通常会占用整个 RegionServer Heap 的70%到80%,所以留给 Phoenix 的可用内存并不多。
### Phoenix Join
既然 Phoenix 让 HBase 支持了 SQL,Join 对于 SQL 是很重要的一个操作,所以 Phoenix 也实现了Join 操作。在大数据各组件的实现中,Join 主要是有这三种实现方式:broadcast hash join、shuffle hash join 和 sort merge join。Broadcast hash join 和 shuffle hash join 本质都是 hash join。
Broadcast hash join 大致分为两个步骤:1. 将小表广播分发到大表所在的所有 server;2. 执行 hash join,小表数据全部生成 hash 映射,大表逐条试探,最后将成功匹配的结果集输出。
Shuffle Hash join,也是 Hash join,只是由于大表和大表 Join,需要把数据进行 Partition。Shuffle hash join 分为三个步骤:1. 将两个将要join 的表按照 join key 进行分区,将相同 join key 分布到同一个计算节点,两张表的数据会被重新分布到负责计算 join 的所有节点,这个是 shuffle 过程;2. hash join 阶段,与 broadcast hash join
里面的2步骤一样;3. 输出匹配成功的结果集。
Sort-Merge Join 整个过程分为三个步骤:1. shuffle:将两张大表根据 join key 进行
partition,两张表被分布到整个集群;2. sort 阶段:对每个 partition 里的两张表分别进行排序;3. merge 阶段:对排序号的两个表分区进行 join。join 的过程分别遍历两个已经有序的序列,碰到相同的 join key 就 merge 输出。
Broadcast hash join 可以很容易地看出来性能瓶颈在于只能进行大小表 Join,并且小表要足够放进执行
join 计算的所有 worker 内存中。Sort-Merge 计算代价过高,不适合低延迟场景。
Phoenix join 对第一种和第三种方法进行了实现。再结合前面讲的 RegionServer 内存结构,可以知道 HBase 留给 Phoenix 用来 Join 的可用内存并不多,所以可以推测 Phoenix 只适合单表聚合查询,或者事实表与极小的维度表进行 join。为了证实观点,使用了 TPC-H 与另外两个离线计算引擎 Hive 和 Spark-sql 进行对比测试,如表1所示。测试数据集使用 TPC-H 生成 1G 大小数据,RegionServer Heap 50G × 3台,磁盘为 6T SSD。Hive 版本1.2.1,Spark-sql 版本 2.1 standalone 模式, Phoenix 版本4.11。
可以看出测试的结果与推测观点一致,在非单表聚合查询和事实与订单 join 的情况下,其他情况不如另外两个离线计算引擎。
表1 Phoenix 与 Hive、Spark-sql 运行 TPC-H 对比
### Secondary Index
Phoenix 支持的二级索引分为四种:Global index、Local Index、Functional Index 和 Cover Index。
Global index:适用于读多写少的业务场景。使用 Global index 在写数据时会有大量在更新索引时带来的写性能开销,所有数据表的增删改操作都会引起索引表的更新,而索引表是分布在不同的数据节点上的,跨节点的数据传输也带来了较大的性能消耗。但是在读数据的时候如果命中索引表,Phoenix 会选择索引表来降低查询消耗的时间。
Local index:适用于写操作多的场景。使用 Local Inde x时,索引数据和数据表的数据会存放在相同的服务器中,用以避免在写操作的时候往不同服务器的索引表中写索引带来额外的开销。使用 Local Index 的时候即使查询的字段不是索引字段索引表也会被使用。与 Global Index 不同的是,一个数据表的所有索引都存储在一个单一的、独立的、可共享的表中。
Covered Index:类似于 Global index,也会在主表有增删改的时候向索引表维护数据,但是 Global index 只维护索引列和主表 pk 的映射,covered index 则会额外维护指定的列到索引表,可以达到只通过索引表就可以返回数据结果的效果。
Functional Index:可以对适合的表达式创建索引,当查询时用到表达式就直接返回结果。
### 一个有代表性的案例
前面讲了一些 Phoenix 的原理,以及实现带来的优缺点。下面结合一个案例介绍将 Phoenix 应用在读延迟敏感场景的过程。
随着公司业务的发展,传统关系数据库为了应对越来越多的数据量选择分库分表,但是这种方式维护成本过高。 Phoenix 由于可用 SQL,并继承了 HBase 海量存储的特点,对于业务方来说可以用 Phoenix 来告别分库分表。可是又由于关系数据库和 NoSQL 实现机制的不同,在使用的时候碰到了很多和使用 MySQL 习惯冲突的地方。比如习惯低冗余多表 Join(前面的对比测试表明要避免这么做),全部使用 global index,不理解什么是热点。对用户进行简单的科普和简单的修改后,还是表示查询速度过慢希望帮助优化。我们看到业务提交过来的表结构是130列的 DDL(为了避免 Join,将三张 MySQL 合并为 Phoenix 的一张),其中有108 default,使用了 Global index,并且为了避免热点使用了 salting。实测下来每次使用二级索引查询主表的数据一次耗时 180ms。(select * from order _ table where driver _ id=xxx,drive r_ id为二级索引列)。
### Phoenix 的 select 优化过程
画一张图来说明一次 Phoenix Select 经历的过程,如图3所示。
图3 Phoenix 在执行 Select 经历的步骤
Select 可大致分为这样五个步骤:client 执行 executeQuery 后,在 client 端生成 SQL Compiler 对象解析 SQL 语句,按照 SQL 的要求生成执行计划,之后按照生成的执行计划去 Server 端执行 HBase Scan,最后通过 ResultSet.next() 方法输出查询到的结果。
那么上面案例的 Phoenix select 慢在哪呢?我们来把上面的流程细化,如图4。
图4 Phoenix 耗时较高的过程细化
2过程,在编译 SQL 的过程中,2.3和2.4这两个步骤在获取列描述信息时是一个串行的过程,这种实现在表比较窄时性能还好,实测在5列的较窄表每次这个步骤耗时只需要2ms,但是在上面举的130列宽表的例子中(由于 Join 性能差,所以直接将多张事实表转换成一张宽表,但是因为是宽表,所以列的数量随之变得过多),执行完1和2两个过程共花了100ms(已经第二次执行 sql,meta 和
catalog 会从缓存读取。如果是第一次执行 SQL,会额外的访问 meta 和 SYSTEM.CATALOG 表进行缓存)。
3过程,在使用了二级索引的表、并且查询条件命中二级索引的情况,会先从二级索引列在索引中查到主表的 pk,再去主表反查,也就是说有二级索引时3过程会经历先查从表后再查主表共两个查询过程,因此增加了额外的查询时间。并且注意,如果使用了 salt _ bucket,并且查询条件部分命中二级索引,则会并行起 salt _ bucket 个线程去查询 pk,负载低的情况下效率还好,负载高时性能下降严重,所以量级较大并且负荷高的表,尽量通过更合理的 pk 设计来规避热点问题,而不是使用 salting。
最后5过程,executeQuery 拿到 ResultSet 对象,但并不是真的包含了最终数据结果,只是拿到了最后结果所在的 rowkey 范围,在第一次执行 rs.next() 方法,才会去真的从主表查询到最终数据返回给客户端,也就是说没有一个 prefetch 过程。最后整个查询共耗时
180ms。
根据上面介绍的点进行如下优化:
首先修改表的 DDL,翻转几个 ID 列内容,关闭 Salting,改为使用 Split on 关键字指定 Region Split point。
2过程将 Phoenix 社区的实现逻辑由原来的串行获取 Column Expression 修改为并行获取,实测在5列窄表情况下与串行速度相同,但是在130列宽表的案例上,1和2两个过程可以从原来的 100ms 降到 60ms,减少了 40ms。
3过程,在存储量级大,负荷高的表避免使用 salting,并且由于 global index 会从到主生成两个查询过程,所以将索引类型修改为 Covered index,使命中二级索引的查询直接通过索引表返回最终结果。
所有上面介绍的优化项应用到这个案例后,每次 SQL 查询从原来的180ms降为30ms。如果对
PrepareStatement 进行复用,查询 p99 可以控制在 20ms 以内。图5和图6展现了优化前后两个 SQL 的执行计划对比。
图5 优化前执行计划
图6 优化后执行计划
### 适合 Phoenix 的场景
下面介绍几个将 Phoenix 应用在低延迟读取场景比较成功的典型案例。
#### 订单类事实数据
Phoenix 由于存储成本低廉和不是很高的延迟查询延迟,适合存储历史订单类事实数据,供端上用户久远的历史订单和 Mis 客服使用,甚至也供运营人员做小范围的聚合分析。为了照顾端上使用体验,可以将近期热数据存在 MySQL 或 Redis,久远的数据存储 Phoenix ,兼顾低成本和查询速度。
图7 历史订单数据可以使用 Phoenix
#### 小范围聚合数据
监控大屏类的数据,向 Phoenix 写入事件数据,每次展示时使用 Group By 聚合相同特征的数据得到结果。比如运力可视化,将地图切割成一个个地理位置块,使用 Phoenix 记录和聚合查询每个位置 ID 里面的事件,如发单量、派单量、司机乘客数、运力差等。
图8 使用 Phoenix 展示司机和乘客的运力差
#### 数仓报表结果数据
在公司初期,数据仓库每天产生的报表大多存储在 MySQL,随着业务发展数据量增加,MySQL 带来的性能问题日益严重。 Phoenix 海量和快速查询的特点可以很好地满足这类场景。
### 总结与建议
Phoenix 定位于一个 OLTP,是对 HBase 的 SQL 封装,因而 Phoenix 在使用时需要规避热点,大对象,GC问题的特点都与 HBase 相同。所以保障 Phoenix 低延迟的前提,依然是合理的表结构设计。有了这个前提,可以大幅度降低 Phoenix 的计算复杂度从而提高延迟吞吐等性能。在此之上,再去按照实际情况对 Phoenix 进行定制化开发,以达到准线上甚至线上应用的要求,从而扩充 HBase 和 Phoenix 在低延迟业务上的应用。在滴滴推广 Phoenix 的过程中,我们也遇到了诸多 Phoenix 的问题与不足,对此进行了实现上的改进或者重构,未来将会把这些补丁回馈社区,推进 Phoenix 项目的发展。