Hive MetaStore 在快手遇到的挑战与优化


导读:快手基于Hive构建数据仓库,并把Hive的元数据信息存储在MySql中,随着业务发展和数据增长,一方面对于计算引擎提出了更高的要求,同时也给Hive元数据库的服务稳定性带来了巨大的挑战。本文将主要介绍Hive MetaStore服务在快手的挑战与优化,包括:

  • 快手SQL on Hadoop智能引擎架构

  • Hive MetaStore在快手的挑战

  • Hive MetaStore在快手的优化

  • 快手SQL on Hadoop的技术规划

01
快手SQL on Hadoop智能引擎架构

Apache Hive是由Facebook开源的数据仓库系统,提供SQL查询能力,快手基于Hive搭建数据仓库,随着业务迅速发展和数据规模增长,Hive的性能开始成为瓶颈,无法满足业务需求。

Hive把用户SQL通过解释器转换为一系列MR作业提交到hadoop环境中运行,MR存在作业启动、调度开销大、落盘多磁盘IO重的问题,这导致其性能注定无法太好,针对Hive查询速度慢的问题,业界先后推出了包括presto/impala/spark等查询引擎,在实现和适用场景上各有优缺点。

在计算引擎层面我们所面临的几个挑战是:

  • 高性能:业务要求更高的查询性能,需要引入更高效的计算引擎

  • 易用性:由于不同引擎在语法以及适用场景上各有优缺点,对于业务来说存在学习和使用门槛,需要通过技术手段来降低或者消除这种门槛

  • 扩展性:技术是发展非常快的,未来随着技术发展可能还会有其他更高效的引擎不断出现,我们在架构设计上需要能够考虑到很好地扩展性支持这些新的计算引擎,需要做到计算引擎的可插拔、易扩展

  • 低成本:围绕Hive我们构建了大量周边工具及服务,包括资源管理、血缘管理、权限控制等各个方面。如果每引入一个引擎都再各自开发一套周边工具及服务的建设会是一个非常昂贵的事,所以这一块需要做到低成本接入

基于上述考虑,我们最终基于HiveServer本身的Hook架构,实现一个BeaconServer。所有的查询仍然以HiveServer作为统一入口,从而解决易用性和低成本的问题。

BeaconServer作为后端Hook Server服务,配合HS2中的Hook,在HS2 服务之外实现了所需的功能,包括根据一定规则路由SQL到适当的引擎,从而起到查询加速的效果。当前支持的模块包括路由、审计、SQL 重写、错误分析、优化建议等。

BeaconServer本身是一个无状态服务,我们可以很方便进行水平扩容,并且BeaconServer服务调整升级不影响HiveServer服务本身。

基于上述架构,我们很好的应对了前面所提到的四大挑战,引入了更高效的计算引擎,在业务无感知的前提下大幅提升了查询效率。

这里特别提一下,除了引入presto、spark等高效计算引擎并对齐进行优化之外,我们针对Hive本身的FetchTask机制(本地读取hdfs文件返回结果,不存在作业提交开销)也进行了系列改进,使其适用场景更广,查询效率更高,在日常查询中也占了很大比重。

02

Hive MetaStore在快手的挑战

在介绍完我们智能引擎架构之后,接下来进入今天重点的主题Hive MetaStore在快手的挑战。

首先,我们基于整体服务架构简单说明一下Hive MetaStore服务的作用以及其重要性:Hive Metastore是hive用来管理元数据的服务,包含database、table、partition等元信息,presto/spark也都以Hive Metastore作为统一的元数据中心。

除了计算引擎本身,数据血缘、数据地图、数据依赖等上层服务也重度依赖Hive Metatstore。

接下来介绍一下Hive MetaStore服务当前所面临的挑战。

由于快手业务使用场景需要大面积使用动态分区,同时数据量和查询量也在随着业务快速增长,这对Hive MetaStore服务的性能和稳定性带来挑战:

  • 访问量非常大,目前每天查询量是50万+,此外数据地图、数据依赖服务也会直接调用Hive MetaStore服务API

  • 动态分区大量使用,很多业务还需要采用多级动态分区,单表一天的子分区数可能上万,部分头部表的总分区数达到数百万规模

  • Hive表总分区达到1.5亿,由于多级分区的存在,元数据库中存储分区信息的单表记录已超过10亿

  • 最后就是分区增速快,当前单日新增分区数近50万

03

Hive MetaStore在快手的优化

针对上述问题,我们考虑从几个方面进行优化:

首先,访问量多的问题,在大数据场景下,存在写少读多的特定,对于元数据主库,当前压力也主要来自于大量的读操作导致QPS过高,因此第一个优化方向是通过读写分离来降低主库压力;

其次,在HIVE的元数据查询上,存在大量的多表联合查询,尤其存储分区信息的两个大表(PARTITONS和PARTITION_KEY_VALS)之间的联合查询,会对服务带来很大压力,可能导致查询超时以及慢查询等问题,因此第二个优化方向是优化元数据API调用;

最后,从长远考虑,随着业务发展,数据量和访问量还会持续上涨,我们需要具备在极端情况下对于不同优先级的访问进行流量控制的能力,满足服务分级保障的需求,同时具备在服务容量不足时对服务进行水平扩容的能力。

接下来我们从四个优化方向分别进行介绍:

1. MetaStore读写分离架构设计

首先,我们介绍一下MetaStore读写分离架构设计。

根据业务应用场景和需求不同,例如数据血缘、数据地图、数据依赖等服务只有读请求,我们可以直接把MetaStore服务拆分为读写和只读,只读服务链接从库来承接这部分读请求。从库本身可水平扩展,能够很好的降低服务QPS压力,把服务访问延迟控制在较好的水平,满足业务需求。

在查询场景中,既有读请求也有写请求,没有办法直接从服务层面进行拆分。由于大数据场景下普遍写少读多,大量读请求直接发送到主库会导致QPS峰值高,服务抖动引发慢查询,进而影响服务稳定性。对此我们的优化方案是在查询层面实现HiveMetaStore API粒度的读写分离,通过把对主库的读请求尽量路由到从库,从而有效降低主库的QPS压力。这个方案要解决的一个主要问题是如何保证数据一致性,避免由于主从同步延迟,导致读请求在从库中漏读数据或者读取到错误的过期数据。

整体解决思路也很简单,我们在把读请求路由到从库之前,先确保当前服务所连接的从库已经完成数据同步即可。

具体流程为:在HiveServer或者Spark提交SQL创建会话链接时,会首先从主库获取并保存当前最新的GTID,在同一个会话中,每次写请求操作完成后,都会更新当前会话所持有的GTID;对于读请求,会首先获取从库当前的GTID,通过比较GTID来判断从库是否已经完成了数据同步,只有当从库GTID大于等于当前会话持有的GTID时,这次读操作才会被真正路由到从库。


通过上述读写分离方案,我们主库的QPS负载下降70%+,并且由于压力下降,主库的慢查询问题也同步大幅减少,有效提升了服务稳定性。

2. MetaStore API优化

我们通过读写分离手段卸载了主库压力,把大量访问请求转移到从库,一方面我们可以通过水平扩容进行负载均衡来缓解从库压力,另一方面通过优化MetaStore接口调用,也能够有效提升服务性能和稳定性。

根据我们分析定位,MetaStore API调用当前主要面临的问题包括:

第一,查询层面存在大量的API调用,造成底层服务QPS过高;

第二,在Hive MetaStore层,单次API调用访问的数据量过大,容易导致服务瞬时压力大;

第三,对于存储分区信息的两个大表(单表记录超10亿)查询时延过高。

第二个单次访问数据量大,造成服务瞬时压力高,改成分批方式返回,就能起到削峰作用;第三个分析对两张大表的查询性能瓶颈,针对具体问题采用合适的优化方案。

首先针对API调用量大的问题,我们需要查一下为什么有这么多的调用,是不是都是正常必要的调用以及如何减少冗余API的调用。这里我们主要进行了两点优化:

HIVE的DDL DESC TABLE命令,社区默认行为除了返回表相关元信息外,还会遍历获取这个表所有的分区信息,对于一个包含大量分区的表来说,这个操作会非常耗时同时也是不必要的。对此我们做的优化是默认跳过这个遍历获取分区元信息的操作。通过测试对比,对于一个包含十万分区的表执行DESC命令,优化前需要两百多秒,优化之后只需要0.2秒。

然后通过对于MetaStore API调用占比进一步分析发现,get_functions/get_function这两个接口被大量调用,这个不太符合预期。经过排查发现这个调用行为是Spark SQL在初始化Hive MetaStore的时候所触发。社区Spark在3.0版本之前底层所依赖的Hive版本一直是1.2,在这个版本中的初始化实现会先通过get_database获取所有的HIVE库,然后针对每个HIVE库再逐个调用get_functions接口,接口调用次数和总的HIVE库数量成正比,导致了大量冗余调用。在Hive2.3版本中这块行为已经得到了优化,我们通过升级Spark所依赖的HIVE包到2.3版本解决了该问题。根据我们的统计,优化后整体API调用次数减少近30%。

其次针对单次访问数据量大,造成服务瞬时压力高的问题,我们可以考虑改成分批方式完成大数据量的扫描,从而起到削峰作用。

例如查询一个大表某个时间范围内的所有分区,涉及分区数11W,优化前由于需要一次性扫描大量数据并返回,导致元数据服务压力过大,接口调用超时,任务查询失败;我们通过把一次大查询拆分成一系列小查询,分批轮次返回需要的数据,就能有效规避服务层面瞬时压力过大造成的一系列不良后果,优化后这次查询总共耗时17115毫秒。通过上图元数据服务测试时的网络压力等指标变化,也可以间接反映优化效果。

然后我们再分析一下存储分区信息的两个大表查询时延过高的问题,看看性能瓶颈究竟在什么地方以及如何进行针对性优化。

对于select * from table where p_date=‘20200101’ and p_product=‘a’这样一条Hive查询,在进行分区下推时发送给元数据服务的查询表达式为:where ((( “FILTER0”.“PART_KEY_VAL”= ?) and (“FILTER2”.“PART_KEY_VAL”= ?)))。

这个查询表达式使用PARTITION_KEY_VAL表中的PART_KEY_VAL字段来进行匹配过滤,存在的问题是:PARTITION_KEY_VAL表中没有TBL_ID字段,导致会扫描到无关表的同名分区;PARTITION_KEY_VAL表中没有索引列,无法通过索引加速。

针对上述问题,我们的优化方案是应用PARTITONS表中的分区名索引加速查询,并且PARTITIONS表中包含TBL_ID字段,也能够有效避免对无关表的分区扫描。

通过分析expresssionTree,解析时间范围子树,获取最长子串前缀:‘20200101’,从而得到优化后的查询表达式为:where ((( “FILTER0”.“PART_KEY_VAL”= ?) and (“FILTER2”.“PART_KEY_VAL”= ?))) and(“PARTITIONS”.“PART_NAME”like ? )。

这个查询优化前后的耗时对比为2662ms VS 786ms,取得了很大提升。

接下来我们再看另一种可优化的场景,对于select * from table where p_date=20200101 and p_hourmin=1000这样一条Hive查询,由于其分区字段类型是string类型,但是Hive查询中给的是整型值,导致无法通过分区名进行过滤,会命中该表的全部子分区。

优化方案也很简单,在SQL解析时,如果filter字段为分区字段,并且类型为string,强制转换constantValue到string类型。

优化前耗时:32288ms,优化后耗时:586ms。

3. MetaStore流量控制架构设计

我们接着聊一下MetaStore流量控制架构设计:

Hive MetaStore作为核心的底层依赖服务,需要具备服务分级保障能力,当服务压力过高响应能力出现瓶颈时,要能够优先满足高优先级任务请求、限制或者阻断低优先级请求的能力,防止元数据库出现雪崩状况。

整体流量控制架构设计如上图所示,核心点在于引入BeaconServer服务作为中控层。Beacon Server作为中控层,支持动态更新设置流控策略,以及实时获取当前元数据服务压力状况。Hive MetaStore作为客户端会周期性去中控层获取当前最新的元数据服务压力状况和流控策略,并针对不同优先级的API调用请求采取对应的流控措施。

基于上述架构,我们可以实现在服务流量高峰期出现性能瓶颈时,能够按比例延迟或阻断部分低优先级的访问请求,保证高优先级请求继续得到正常响应,当服务压力缓解状态恢复正常后,再自动恢复对低优先级访问请求的响应。

流量控制架构在上线后,有效缓解了生命周期管理服务定期清理大批量分区时对于元数据服务造成TPS过高的压力。

4. MetaStore Federation架构设计

最后介绍一下MetaStore Federation架构设计,长远看,随着业务量持续增加,MySQL单机依然会存在性能及存储瓶颈的风险。解决MySQL单机瓶颈和压力,业界通用方案是分库分表,由于Hive元数据信息存储采用三范式设计,表关联较多,直接在MySQL层面进行拆库拆表会存在改造成本大、风险高且不利于未来扩容的问题,因此我们考虑采取HiveMetaStore层面的Federation方案,实现元数据水平扩展能力。

首先看一下Hive MetaStore内部实现逻辑,持久化元数据层被抽象成了RawStore,比如MySQL对应的实现时ObjectStore,HBase对应的实现则是HBaseStore。

基于上述原理,我们首先想到的方案1是基于ObjectStore已有功能和代码实现KwaiStore,在KwaiStore中实现Hive DB路由数据源的功能,配置不同Hive DB到对应MySQL数据源的映射关系。

方案1的优点在于可以保持包和配置的统一,降低韵味成本;缺点在于对Hive的侵入性较大,并且上线时如果要做到数据完全一致,需要暂停服务。

方案2是通过引入路由层,使用代理转发请求的方式来实现。这个方案下Metastore代码不用做任何改动,新增Router层,根据请求数据的Hive DBName来决定路由到哪个Metastore上去。Router层可以水平扩容,可以在Router层做很多扩展功能,白名单、多数据源支持(统一元数据)、Hive DB 禁用、元数据权限等操作。在扩容时,可以在Router层添加规则,指定某个Hive DB 暂时不可访问,待数据源完全准备好后,再添加路由规则到Router层,同时取消DB不可用限制;可以做到只影响部分Hive DB的使用。

总结一下,方案2的优点在于对Hive 没有侵入性,升级版本比较容易,可以灵活定制Router层策略,HA水平扩容,扩容MySQL时相对影响较小,上线风险较小,统一元数据入口,方便审计和溯源;不足之处在于新引入服务层,增加运维成本,Metastore被划分标签,配置不完全统一。

综合考虑方案1和方案2的优缺点,我们最终选择了在HMHandler层来实现路由功能,在HMSHandler中维护一组HiveDB与RawStore的映射关系,在getMS()时传入 Hive DB,路由根据db判断应该使用那个RawStore,不修改RawStore中API的实现,不涉及到持久化层的侵入改造。

这个方案的优点是配置统一,运维成本低;不修改持久化层,改造难度小;缺点是需要调整大量Thrift API,在调用时传入DB。

▌结束语

以上是本次关于HiveMetaStore服务在快手遇到的挑战与优化的全部内容。今天的分享就到这里,谢谢大家。



分享嘉宾:
王磊,任职于快手数据平台部,担任离线计算引擎方向负责人,负责SQL引擎研发和平台架构建设,技术栈领域包括资源调度、分布式离线计算。

最后说一句(求关注,别白嫖我)

扫一扫,我们的故事就开始了。

另外公众号改变了推送规则,大家看文章不要忘记点击最下方的在看,点赞按钮,这样微信自动识别为常看公众号,否则很可能推送的文章可能淹没在别的文章找不到,谢谢大家。


让我知道你在看

举报
评论 0