目的:降低计算资源的消耗,提高任务执行的性能,提升任务产出的时间。
(1)背景
在默认的 Instance 算法下,小任务存在资源浪费,而大任务却资源不足
(2)HBO的提出
通过数据分析,发现在系统中存在大量的周期性调度的脚本(物理计划稳定),且这些脚本的输入 般比较稳定,如果能对这部分脚本进行优化,那么对整个集群的计算资源的使用率将会得到显著提升。由此,想到了 HBO ,根据任务的执行历史为其分配更合理的计算资源。HBO一般通过自造应调整系统参数来达到控制计算资源的目的。
(3)原理
● 前提 :最近7天内任务代码没有发生变更且任务运行4次。
● Instance 分配逻辑:基础资源估算值+加权资源估算值。
(1)基础资源数量的逻辑
● 对于 MapTask ,系统需要初始化不同的输入数据 ,根据期望的每个Map能处理的数据量,再结合用户提交任务的输入数据量,就可以估算出用户提交的任务所需要的 Map量。为了保证集群上任务的整体吞吐量,保证集群的资源不会被一些超大任务占有,我们采用分层的方式,提供平均每个 Map 能处理的数据量。
● 对于 Reduce Task ,比较 Hive 使用 Map 输入数据量, MaxCompute 使用最近 Reduce 对应 Map 的平均输出数据量作为 Reduce 的输入数据 ,用于计算 Instance 的数量。对于 Reduce 个数的估算与 Map 估算基本相同,不再赘述
(2)加权资源数量的逻辑
● 对于 Map Task ,系统需要初始化期望的每个 Map 能处理的数据量。通过该 Map 在最近一段时间内的平均处理速度与系统设定的期望值做比较,如果平均处理速度小于期望值,则按照同等比例对基础资源数量进行加权,估算出该 Map 的加权资源数量。
● 对于 ReduceTask ,方法同上。
最终的 Instance 个数为:基础资源估算值+加权资源估算值。
● CPU/内存分配逻辑:类似于 Instance 分配逻辑,也是采用基础资源估算值+加权资源估算值的方法。
(4)效果
提高CPU利用率
提高内存利用率
提高Instance并发数
降低执行时长
(5)改进:
HBO 是基于执行历史来设置计划的,对于日常来说,数据量波动不大,工作良好。但是某些任务在特定场合下依旧有数据量暴涨的情况发生,尤其是在大促“双 ”和“双 12 ”期间,这个日常生成的 HBO计划就不适用了。针对这个问题, HBO 也增加了根据数据量动态调整Instance 数的功能,主要依据 Map 的数据量增长情况进行调整。
① Meta Manager
Meta 模块主要提供元数据信息,包括表的元数据、统计信息元数据等。当优化器在选择计划时,需要根据元数据的一些信息进行优化。比如表分区裁剪( TableScan Part on Prunning )优化时, 需要通过 Meta信息获取表数据有哪些分区,然后根据过滤条件来裁剪分区。同时还有一些基本的元数据,如表是否是分区表、表有哪些列等。
对于 Meta 的管理, MaxCompute 提供了 Meta Manager 与优化器行交互。 Meta Manager 与底层的 Meta 部分对接 ,提供了优化器所需要的信息。② Statistics
Statistics 主要是帮助优化器选择计划时,提供准确的统计信息,如表的 Count 值、列的 Distinct 值、 TopN 值等。优化器只有拥有准确的统计信息,才能计算出真正最优的计划。比如 Join 是选择 Hash Join还是Merge Join ,优化器会根据 Join 的输入数据量( Count 值)来进行选择。
优化器提供了 UDF 来收集统计信息,包括 Distinct 值、 TopN 等,而Count 值等统计信息是由底层 Meta 直接提供的。③ Rule Set
优化规则是根据不同情况选择不同的优化点,然后由优化器根据代价模型( Cost Model )来选择启用哪些优化规则。比如工程合并规则(Project Merge Rule ), 将临近的两个 Project 合并成一个 Project ;过滤条件下推规则( Filter Push Down ),将过滤条件尽量下推,使得数据先进行过滤,然后再进行其他计算,以减少其他操作的数据量。这些所有的优化都放置在优化规则集中。
Max Compute 优化器提供了大量的优化规则,用户也可以通过 set等命令来控制所使用的规则。规则被分为 Substitute Rule (被认为是优化了肯定好的规则)、 Explore Rule (优化后需要考虑多种优化结果)、Build Rule (可以认为优化后的结果不能再次使用规则进行优化)。Volcano Planner 是整个优化器的灵魂,它会将所有信息( Meta息、统计信息、规则)统一起来处理,然后根据代价模型的计算,获得一个最优计划。
① 代价模型。代价模型会根据不同操作符(如 Join Project 等)计算不同的代价,然后计算出整个计划中最小代价的计划。 MaxCompter代价模型目前提供的 Cost 由三个维度组成 ,即行数、 1/0 开销、 CPU开销,通过这三个维度来衡量每一个操作符的代价。
②工作原理。假设 Planner 的输入是一棵由 ompiler 解析好的计划树,简称 RelNode 树,每个节点简称 Re!Node
● Volcanno Planner创建
Planner 的创建主要是将 Planner 在优化过程中要用到的信息传递给执行计划器,比如规则集,用户指定要使用的规划 Meta Provider ,每个ReINode Meta 计算,如 RowCount 值计算、 Distinct 值计算等;代价模型,计算每个 RelNode 的代价等。这些都是为以后 Planner 提供的必要信息。
● Planner 优化
规则匹配( Rule Match ):是指 RelNode 满足规则的优化条件而建立的一种匹配关系。 Planner 首先将整个 RelNode 树的每一个 ReIN ode 注册到 Planner 内部,同时在注册过程中,会在规则集中找到与每个RelNode 匹配的规则,然后加入到规则应用( Rule Apply )的队列中。所以整个注册过程处理结束后,所有与 RelNode 可以匹配的规则全部加入到队列中,以后应用时只要从队列中取出来就可以了。
规则应用( Rule Apply ):是指从规则队列( Rule Queue )中弹出( Pop)一个已经匹配成功的规则进行优化。当获取到一个已经匹配的规则进行处理时,如果规则优化成功,则肯定会产生至少一个新的 RelNode ,因为进行了优化,所以与之前未优化时的 ReINode 有差异。这时需要再次进行注册以及规则匹配操作,把与新产生的 RelNode 匹配的规则加入到规则队列中,然后接着下次规则应用。
Planner 会一直应用所有的规则,包括后来叠加的规则,直到不会有新的规则匹配到。至此,整个优化结束,这时就可以找到一个最优计划。
代价计算( Cost Compute ): 每当规则应用之后,如果规则优化成功,则会产生新的 ReINode 。在新的 ReINode 注册过程中,有 个步骤是计算 RelNode 的代价。
代价计算由代价模型对每个 RelNode 的代价进行估算。① 如果不存在代价,或者 Child 的代价还没有估算(默认是最大值),则忽略。
② 如果存在代价,则会将本身的代价和 Child (即输入的所有RelNode )的代价进行累加, 若小于 Best ,则认为优化后的ReINode 是当前最优的 。并且会对其 Parent 进行递归估算代价,即传播代价计算( Propagate Calculate Cost )。比如计划: Project->TableScan ,当 TableScan 计算代价为1时,则会继续估算 Project 的代价,假设为1,则整个 Project 的代价就是 1+1=2
也就是说,当 ReINode 本身的代价估算出来后,会递归地对 Parent进行代价估算,这样就可以对整条链路的计划进行估算。在这个估算过程中借助了 Meta Manager Statistics 提供的信息。
(2)新特性
① 重新排序
② 自动MapJoin
(3)使用
规则白名单 —— odps.optimizer.cbo.rule.filter.white
规则黑名单 —— odps.optimizer.cbo.rule.filter.black
如果用户需要使用哪些优化规则,只要将规则缩写名称加人白名单即可;反之,需要关闭哪些优化规则,只要将名称加入黑名单即可。比如 set odps.optimizer.cbo.rule.filter.black=xxx,yyy;, 就表示将 xxx,yyy 对应的优化规则关闭。
(4)注意事项
Optimizer 会提供谓词下推( Predicate Push Down )优化 ,主要目的
是尽量早地进行谓词过滤,以减少后续操作的数据量,提高性能。但需
要注意的是:
① UDF
② 不确定函数
③ 隐式类型转换
SQL / MR 作业一般会生成 MapReduce 任务,在 MaxCompute 中则会生成 MaxCompute Instance,通过唯一 ID 进行标识;
Fuxi Job:对于 MaxCompute Instance,会生成一个或多个由 Fuxi Task 组成的有向无环图。
Fuxi Task(任务类型):主要包含三种类型,分别是 Map、Reduce、Join,类似于 Hive 中 Task 的概念;
Fuxi Instance:真正的计算单元,和 Hive 中的概念类似,一般和槽位(slot)对应;
● 每个输入分片会让一个 Map Instance 来处理,在默认情况下,以Pangu 文件系统的一个文件块的大小(默认为 256MB )为一个分片。 Map Instance 输出的结果会暂时放在一个环形内存缓冲区中,当该缓冲区快要溢出时会在本地文件系统中创建一个溢出文件,即Write Dump Map 读数据阶段,可以通过“ set odps.mapper.split.size=256 ”来调节 Map Instance 的个数,提高数据读入的效率,同时也可以通过“ set odps.mapper.merge. limit.size=64 ”来控制 Map Instance 读取文件的个数。如果输数据的文件大小差异比较大,那么每个 Map Instance 读取的数据量和读取时间差异也会很大。
● 在写人磁盘之前,线程首先根据 Reduce Instance 的个数划分分区,数据将会根据 Key Hash 到不同的分区上,一个 ReduceInstance 对应一个分区的数据。 Map 端也会做部分聚合操作,以减少输入 Reduce 端的数据量。由于数据是根据 Hash 分配的,因此也会导致有些 Reduce Instance 会分配到大量数据,而有些Reduce Instance 却分配到很少数据,甚至没有分配到数据。
(2)问题
在Map 端读数据时,由于读入数据的文件大小分布不均匀,因此会导致有些 Map Instance 读取并且处理的数据特别多,而有些 MapInstance 处理的数据特别少,造成 Map 端长尾。以下两种情况可能会导Map 端长尾:
● 上游表文件的大小特别不均匀,并且小文件特别多,导致当前表Map 端读取的数据分布不均匀,引起长尾。
● Map 端做聚合时,由于某些 Map Instance 读取文件的某个值特别多而引起长尾,主要是指 Count Distinct 操作
(3)方案
第一种情况导致的 Map 端长尾,可通过对上游合并小文件,同时调节本节点的小文件的参数来进行优化:
//数用于调节 Map 任务的 Map Instance个数
set odps.sql.mapper.merge.limit.size=64
//用于调节单个 Map Instance 读取的小文件个数,防止由于小文件过多导致 Map Instance 读取的数据量很不均匀
set odps.sql.mapper.split.size=256
第二种情况可以使用 “distribute by rand()” 来打乱数据分布,使数据尽可能分布均匀
SELECT ...
FROM
(
SELECT ds
, unique_id
, pre_page
FROM tmp _app_ut_1
WHERE ds = '${bizdate}'
AND pre_page is not null
DISTRIBUTE BY rand() --修改后,增加
) a
LEFT OUTER JOIN
(
SELECT t.*
, length(t.page_type_rule) rule_length
FROM page_ut t
WHERE ds = '${bizdate}'
AND is_enable = 'Y'
) b
ON 1 = 1
WHERE a.pre_page rlike b.page_type_rule;
(3)思考
Map 长尾的根本原因是由于读人的文件块的数据分布不均匀,再加上 UDF 函数性能、 Join 、聚合操作等,导致读人数据量大的 Map lnstance 耗时较长。在开发过程中如果遇到 Map 端长尾的情况,首先考虑如何让 Map Instance 读取的数据量足够均匀,然后判断是哪些操作导Map Instance 比较慢 ,最后考虑这些操作是否必须在 Map 完成,在其他阶段是否会做得更好。
(1)倾斜场景
① Join 的某路输入比较小,可以采用 MapJoin ,避免分发引起长尾。
② Join 的每路输入都较大,且长尾是空值导致的,可以将空值处理成随机值,避免聚集。
③ Join 的每路输入都较大,且长尾是热点值导致的,可以对热点值和非热点值分别进行处理,再合并数据。
(2)方案
① MapJoin
② 将空值设置成随机值
③ 先将热点 key 取出,对于主表数据用热点 key 切分成
热点数据和非热点数据两部分分别处理,最后合并
(1)倾斜情况
Reduce 端产生长尾的主要原因就是 key 的数据分布不均匀。比如有些 Reduce 任务 Instance 处理的数据记录多,有些处理的数据记录少,造成 Reduce 端长尾 。如下几种情况会造成 Reduce 端长尾:
① 对同一个表按照维度对不同的列进行 Count Distinct 操作,造成Map 端数据膨胀,从而使得下游的 Join Reduce 出现链路上的长尾。
② Map 端直接做聚合时出现 key 值分布不均匀,造成 Reduce 端长尾。
③ 动态分区数过多时可能造成小文件过多,从而引起 Reduce 端长尾。
④ 多个 Distinct 同时出现在 SQL 代码中时,数据会被分发多次,不仅会造成数据膨胀N倍,还会把长尾现象放大N倍。
(2)方案
情况②:对热点 key 单独处理,然后通过 “Union All” 合并;(具体操作与 “Join 因为热点值导致长尾” 的处理方式一样)
情况③:把符合不同条件的数据放到不同的分区,避免通过多次 “Insert Overwrite” 写入表中,特别是分区数比较多时,能够很多的简化代码; 对动态分区的处理是引入额外一级的Reduce Task,把相同的目标分区交由同一个Reduce Instance来写入。
情况④:先分别进行查询,执行 Group By 原表粒度 + buyer_id,计算出 PC 端、无线端、所有终端以及 7 天、30 天等统计口径下的 buyer_id(这里可以理解为买家支付的次数);然后在子查询外,Group By 原表粒度:当上一步的 Count 值大于 0 时,说明这一买家在这个统一口径下有过支付,计入支付买家数,否则不计入;
(3)思考
对 Multi DIstinct 的思考如下:
●上述方案中如果出现多个需要去重的指标,那么在把不同指Join在一起之前, 一定要确保指标的粒度是原始表的数据粒度。比如支付买家数和支付商品数,在子查询中指标粒度分别是:原始表的数据粒度+ buyer_id 和原始表的数据粒度 item_id ,这时两个指标不是同一数据粒度,所以不能 Join ,需要再套一层代码,分别把指标 Group By 到“原始表的数据粒度”,然后再进行 Join操作。
● 修改前 Multi Distinct 代码的可读性比较强,代码简洁,便于维护;修改后的代码较为复杂。当出现的 Distinct 个数不多、表的数据量也不是很大、表的数据分布较均匀时,不使用 MultiDistinct 的计算效果也是可以接受的。所以,在性能和代码简洁、可维护之间需要根据具体情况进行权衡。另外,这种代码改动还是比较大的,需要投入一定的时间成本,因此可以考虑做成自动化,通过检测代码、优化代码自动生成将会更加方便。
● 当代码比较膝肿时,也可以将上述子查询落到中间表里,这样数据模型更合理、复用性更强、层次更清晰。当需要去除类似的多Distinct 时,也可以查一下是否有更细粒度的表可用,避免重复计算。
目前 Reduce 端数据倾斜很多是由 Count Distinct 问题引起的,因ETL 开发工作中应该予以重视 Count Distinct 问题,避免数据膨胀。对于一些表的 Join 阶段的 Null 值问题,应该对表的数据分布要有清楚的认识,在开发时解决这个问题。