摘要:本文整理自阿里集团爱橙科技高级开发工程师、Apache Flink Contributor孙夏老师在Flink Forward Asia 2024 核心技术(二)专场中的分享。内容主要为以下三部分:
1、自适应逻辑执行计划
2、自适应 Join 算子优化
3、未来展望
01、自适应逻辑执行计划
1.1作业的执行计划
首先,让我们先来简单回顾下目前Flink支持了哪些执行计划。左边是一段简单的用户 SQL,这段 SQL 从提交到运行一般会经历几个过程。首先 Flink Table Planner 会对它进行编译优化,生成一张 StreamGraph,即逻辑执行计划,这是最接近用户逻辑的 DAG 表达,它描述了作业的执行拓扑,以及各个算子的计算逻辑,包括算子间的连边方式。Flink 接下来会在 StreamGraph 的基础之上对它再进行一次优化,在这一步当中它会将并发相同且连边方式为Forward的算子chain到一起生成新的 JobVertex 节点,以避免额外的网络开销以及序列化、反序化开销,到此为止 StreamGraph 和 JobGraph 的生成过程都是在编译期确定的,接下 Flink Client 就会将 JobGraph 提交给 JobMaster。JobMaster 会根据节点并行度、数据传输方式等信息对JobGraph进行分布式展开,生成物理执行计划,即 ExecutionGraph。
可以看到从逻辑执行计划到物理执行计划生成的过程中,算子层面对外暴露的信息是逐步减少的。因此如果我们希望在 Flink Runtime 支持对特定算子做优化,就必须让Flink能够感知到原始的逻辑执行计划。
1.2 自适应批处理调度器
由于传统的批处理作业是遵循 All Edges Blocking 的方式进行调度的,意味着在下游节点在被调度之前,它的所有上游应该是已经产出的状态,Runtime就能够获取到上游完整的数据分布,以及数据分区的大小。目前 Flink 自适应批调度器已经可以根据这些运行时信息来自动推断下游节点的并发度,也能够在一定程度上支持均衡数据分发的能力,可以尽可能得让每一个并发 Task 都能够消费到相近的数据量。
除此之外,如果我们能更进一步,将运行时信息和特定算子逻辑相结合,就能够进一步优化作业的性能。比如,对于Join节点,如果我们在运行时发现它的某一边的输入足够小,就可以把这一边通过broadcast到方式传输到下游,避免潜在的数据传输和排序的开销;对于Join倾斜的情况,我们也能够提前定位存在倾斜的数据分区,通过优化数据划分策略,来缓解潜在的批处理长尾问题。这也是我们引入自适应逻辑执行计划的目的。
1.3基于 StreamGraph 的作业提交
为了能够支持自适应逻辑执行计划,我们首先要做的就是让 Flink Runtime 能够直接"看到"逻辑执行计划,所以我们在FLIP-468中支持了基于StreamGraph的作业提交方式,同时把 JobGraph的生成及Operator Chain的操作也延迟到了 Runtime来执行。
当然,这并不意味着要完全抛弃基于 JobGraph 的作业提交方式,因为部分场景仍可能依赖基于JobGraph的提交。 因此我们对目前的执行计划表达抽象出了 ExecutionPlan接口,它是一种更通用的执行计划表达,StreamGraph 和 JobGraph 会同时实现该接口,以此让 Flink 同时支持两种Graph的提交。
值得一提的是,引入 ExecutionPlan 之后,意味着解耦了具体的图的实现,原则上只要是一个合法的作业表达,我们都可以基于现有的架构去提交作业。一种可能的思路是未来可以引入通过CompiledPlan来提交作业,以获取相较于 StreamGraph 更底层的拓扑信息。
1.4动态逻辑执行计划
在引入基于StreamGraph 的作业提交方式之后,我们还需要一个能力是让 Flink 能够在运行时对 StreamGraph 做动态调整,这就意味着不能在作业初始化时就把 StreamGraph 当中所有的 StreamNode 转化成 JobVertex,而是将那些还不需要立刻执行的节点先置为Pending,等待合适的时机创建。
因此,我们在FLIP-469引入了一种新机制,即渐进式构建 JobGraph。在这种新机制下,JobGraph 中的 JobVertex 生成时机将由 Flink 在运行时灵活决定。
整体而言,Flink 会在以下两种情况创建出JobVertex:
- 对于Source节点会单独考虑,由于Source 节点没有上游,因此Flink会在作业初始化阶段就将其创建出来,为作业的启动提供起点。
- 除了 Source 之外的其余节点将会等待上游所有节点执行完成之后再进行生成,此时可以获取到准确的输入数据的信息,来帮助 Runtime 做出更好的优化决策。
为了支持渐进式生成JobGraph的能力,我们在 AdaptiveBatchScheduler 的基础上又分别引入的几个新的组件:
- AdaptiveExecutionHandler:负责监听 Scheduler发送的JobEvent,例如节点Finish信息,并将其转发给其他组件,同时还负责把 JobGraph 的更新信息回调给 AdaptiveBatchScheduler。
- StreamGraphOptimizer: 负责具体的逻辑图优化,它持有一组StreamGraph优化策略,可以根据 AdaptiveExecutionHandler 传递的运行时信息对StreamGraph进行链式优化。
- AdaptiveGraphManager: 负责管理及更新StreamGraph和JobGraph。
我们可以通过一个具体的 Case 来将这些组件串联起来:
- 当某一个上游节点执行结束后,AdaptiveExecutionHandler 将会首先接收到Scheduler的通知。
- AdaptiveExecutionHandler会先将信息进行封装后放入上下文, 并调用 StreamGraphOptimizer 尝试优化 StreamGraph。
如果命中优化条件,则会对StreamGraph中的算子或边进行调整。
- 接下来AdaptiveGraphManager 会去尝试更新 JobGraph,将下游仍处于Pending状态且上游都执行结束的StreamNodes进行 OperatorChain 优化来生成新的 JobVertex, 并把更新结果反馈给AdaptiveExecutionHandler。
- 最终 AdaptiveExecutionHandler会把 JobGraph 的更新信息回调通知给 AdaptiveBatchScheduler, 接下来AdaptiveBatchScheduler将会更新物理执行计划,进行后续的节点调度。
1.5 New Web UI
引入动态逻辑图生成机制后,我们在Flink UI上也做了相应的调整。由于 JobGraph 是渐进式构建的,因此目前在UI上也只会展示运行中或执行结束的节点, 剩余节点将会在它初始化完成之后才会加入到JobGraph中,届时UI也会自动刷新。
同时我们也保留了查看全局信息的能力,UI上会提供Show Pending Operators的开关,打开开关之后就可以把还在 Pending 状态的 StreamNodes 也展示到UI拓扑当中,帮助用户掌握全局的拓扑信息。此外,为了在 UI 上和已经生成的 JobVertex 节点区分,未被调度的StreamNodes 节点在视觉上会相对更小。同时它的图形以及连边会变成虚线以做区分。
02、自适应 Join 节点优化
基于上述批处理调度框架的升级,我们在 Flink2.0 中为 Join 算子引入的两个新的优化,分别是自适应Broadcast Hash Join优化和Join倾斜优化。
2.1整体优化流程
下面对其中的一些关键设计展开说明, 整体的优化流程主要分为两个部分:
- 第一部分是 Table Planner 阶段,在这一阶段的主要工作是将符合条件的Join算子识别并标记为 AdaptiveJoin 算子。Flink Table Planner 首先会解析SQL语句,生成一颗抽象语法数,产出Logical RelNode Tree。之后, Planner通过一系列Cost-Based或Rule-Based优化策略对它进一步优化,生成Physical RelNode Tree。接着,Physical RelNode Tree会被转换成一个ExecNode Graph,在这个图上,Flink会做一些更偏向于执行层的优化,如算子的串联优化(Multiple-Input)以及动态分区裁剪(Dynamic Partition Pruning)等,而AdaptiveJoin算子就是在这一阶段进行识别和替换的。之后,Table Planner会继续将图转化为一系列的Transformation,并最终转换为一张StreamGraph。其中有一点需要补充说明下,选择在ExecNode Graph阶段中引入Adaptive Join优化的原因是,该阶段逻辑执行计划已接近成型,在这一步进行算子注入不会破坏 Table层一些既有的约定。同时,在这一层我们可以规避掉一些不支持优化的场景:例如,出于收益和实现复杂度的考虑,现阶段我们暂时还不支持对Multiple-Input节点中的Join算子进行优化; 其次,对于存在相同Hash Key的连续Hash Shuffle场景,由于Forward、Hash Partitioner的转换可能会带来正确性和性能问题,在这种情况下我们也会避免替换Join算子。
- 第二部分是在 Runtime 阶段,AdaptiveBatchScheduler及相关组件会根据配置的优化项结合运行时信息对StreamGraph进行优化,最后会基于渐进式生成的JobGraph生成优化后的物理执行拓扑做后续的调度。详细过程已在上一章节讲述,此处不做赘述。
2.2 自适应BroadcastHashJoin优化
接下来我们先介绍自适应Broadcast Hash Join的优化过程。
在大多数的生产实践中, 由于可以省去Sort及Shuffle的开销,Broadcast Hash Join 性能是被认为要优于 Sort Merge Join 和 Shuffle Hash Join 的,因此我们会更倾向于选择前者进行Join。 然而,由于缺乏统计信息或无法动态感知实际Join的输入数据量等因素的存在,在实际使用时会较为困难。
我们先看一下上图的案例:这是一个简单的带Join算子的作业,特殊之处在于它的右表在Join之前有一个Filter算子。如果用静态Broadcast Hash Join优化策略对它进行优化,我们只能通过统计信息得到右表的原始数据量,由于表的大小15MB大于Broadcast的阈值10MB,因此它不会在编译期被优化为Broadcast Hash Join,但是在实际运行的时候,经过Filter算子的过滤,它所在的stage产出的实际数据量只有5MB,又满足了Broadcast的阈值,但是在原有的架构下,由于逻辑拓扑无法被修改,因此它还是会按照SortMergeJoin的方式去执行。
针对这类情况我们引入了自适应Broadcast Hash Join优化策略,使得Runtime调度器能够让Join算子根据实际消费的数据量重新选择Join方式,优化步骤如下:
- 当上游的执行节点运行结束,优化器会去遍历节点的下游,寻找是否存在标记为AdaptiveJoin的算子。
- 如果发现了AdaptiveJoin算子,那么它将通过2个条件来判断它是否能够被优化为Broadcast Hash Join。第一个条件是 检查产出的数据量大小是否小于Broadcast的阈值。第二个条件是判断当前输入端是否能够被Broadcast。对于不同的Join方式存在不同的约束条件,如果是Inner Join,那么它的任意一边都可以被Broadcast,而如果是Left Outer Join,那么只有它的右边可以被Broadcast,否则会产生数据正确性问题,比如可能会导致数据的重复。
- 如果AdaptiveJoin算子两个条件都满足,那么优化器会对它采取优化手段。我们会重新确定Join的Input顺序,以及Hash Join的Build端和Probe端。 其次,输入端的数据分发方式也需要改变,需要将小表端的数据分发方式更改为Broadcast Partitioner,大表端更改为Forward Partitioner。 最后,我们会在Runtime动态地生成OperatorFactory,确定最终的Join逻辑。
Adaptive Broadcast Join在批任务下会自动开启,用户可以通过开关 table.optimizer.adaptive-broadcast-join.strategy
来调整策略,"none"代表关闭Runtime优化,"auto"代表交由Flink自助选择Join方式,"runtime_only"代表关闭编译期BroadcastJoin优化,只开启Runtime优化。" 同时,Adaptive Broadcast Join与普通Broadacst Join复用Broadcst阈值参数,可以通过 table.optimizer.join.broadcast-threshold
进行阈值的调整。
不过上述方式生成的Broadcast Join在性能上相比于普通的 Broadcast Join 是有回退的,主要在于两方面:(1)普通 Broadcast Join 在编译阶段可以把大表端和 Join节点chain在一起的,可以避免一次完整的网络shuffle,而Adaptive Broadcast Join在被转换时它的上游可能已经开始执行或已经结束,因此我们无法将两者chain在一起。(2)对于小表侧的数据传输方式,普通Broadcast Join小表侧产出的数据分区是完整的Broadcast分区。而Adaptive Broadcast Join的小表侧是由Hash Partitioner转换而来的,这就会导致下游在读取数据分区的时候,会有多次的小文件的读取开销,这也会造成性能的回退。
为此,我们又引入了其他的优化措施,来优化上述提到的问题。
- 首先,对于小表侧Hash Partitioner存在较多小文件的问题,我们引入了一种新的Buffer格式FullFilledBuffer来优化网络传输性能。它的基本原理是上游在往下游发数据时会尝试去填满一个FullFilledBuffer,尽可能将小buffer聚合发送,在优化之前,下游task的Input Channel读取上游Subpartition时需要一对一得去读取,在引入FullFilledBuffer优化之后,我们支持一个InputChannel读取多个Subpartition,以此来优化读取性能。值得一提的是,该优化并非Adaptive Broadcast Join专属,它将会自动开启,并在使用 Sort-merge Shuffle时自动生效。
- 其次,如果是基于TM Shuffle的场景,我们可以复用Input Locality的能力来优化大表侧的数据传输问题,它能够让上下游Task尽可能得部署到同一个计算节点上,从而实现本地传输。
- 最后,我们会把大表侧的Forward Partitioner更改为Rescale Partitioner,它的一个作用是当Broadcast Join存在数据倾斜问题时我们可以通过Rescale的方式对单个数据分区进行拆解,以此来优化数据倾斜的问题。同时,改为Rescale后也可以将Join节点的并发度与上游解耦,可以通过Adaptive Batch Scheduler的自动并发度推导能力来重新推导合适的并发度。
2.3 倾斜Join优化
接下来介绍下Join倾斜优化。在实际生产过程中,Join倾斜是一个常见的问题,我们先来看下上图的例子:两张表Hash之后进行Join,每个上游Vertex分别产出了3个子分区,然后每组子分区经过聚合后生成了对应的Key Group,可以看到紫色的Key Group数据量明显大于其他并发Task, 假设每个物理节点的算力近似,就会导致下游的Task1计算长尾。
针对这类case,我们引入了自动Join倾斜优化的能力。那么它是如何进行优化的呢?一个符合直觉的思路是对大的数据分区进行切片,拆成更小的分区,但是这又会引入一个新的问题,就是对于Join算子来说是否会导致数据不正确的情况。针对这个问题,经过相关的论证,可以分情况讨论:
- 对Inner Join来说,只要保证所有数据能够以笛卡尔积的形式被遍历处理即可保证该算子的数据正确性,因此我们可以将任意一侧的数据进行拆分,同时对另一侧的数据进行复制;
- 对Left Outer Join来说,它的右表不能拆分,但左表是可以拆分的,因此我们可以将左表的数据进行拆分,同时对右表的数据进行复制。其他Join类型如Right Outer Join也是同理。
总体来说,我们可以通过拆分 + 复制的方式来优化Join倾斜问题。
我们再来看一下具体的优化步骤:
- 和前一个优化类似,当上游的执行节点运行结束了,优化器会去遍历它的下游节点,寻找是否存在标记为 Adaptive Join 的算子。
- 如果发现了 Adaptive Join 算子,那么它将通过2个条件来判断它是否能够被优化:(1)首先判断当前Join类型及其Input是否支持分区切片。(2)判断是否存在数据倾斜。当某一个数据分区的大小大于整体数据分区的中位数乘上某一个系数,并且大于配置的最小倾斜阈值,那么这个分区会被认为是存在数据倾斜的。
- 我们会先在逻辑图优化阶段对符合优化条件的节点的输入分区进行标记,在AdaptiveBatchScheduler 的数据划分阶段对倾斜做进一步的优化。
自动倾斜Join优化同样在批任务下自动开启,用户可以通过 table.optimizer.skewed-join-optimization.strategy
来调整策略, "none"代表关闭Join倾斜优化,"auto"代表交由Flink选择是否优化,"forced"代表即使会产生额外Shuffle,也开启优化。此外,用户可以调整 table.optimizer.skewed-join-optimization.skewed-factor
来调整倾斜分区的倍率因子,调整table.optimizer.skewed-join-optimization.skewed-threshold
来控制最小倾斜阈值。
2.4数据均衡分发策略优化
Join倾斜优化依赖于Flink的数据划分算法,而数据划分是Flink一个重要的能力,目前自适应批调度器就应用了这一能力来实现数据的均衡划分。
但是目前的数据划分策略对于 Join 算子是不太公平的,它限制了 Join 上游的同一组 Key Group 必须发送到相同的下游并发,因此在之前架构下无法实现 Key Group 的拆分,我们需要对目前Flink的数据划分算法进行拓展,使其能够支持 Key Group的拆分和复制的语义。
为此,我们引入了两种关联关系来描述这种拆分和复制的关系。
- 第一种关联关系为IntraInputCorrelation,即内部关联,它表示一个 Input 内的单个 Key Group 的数据必须在同一个下游并发实例中进行处理,也就是一个 Input 的 Key Group 是不可拆分的。具体到 Join 场景下,存在 IntraInputCorrelation 约束的输入端数据的 Key Group 是不可拆分的,如Left Outer Join的右表端。
- 第二种关联关系是 InterInputCorrelation,即外部关联。具体的含义是多个 Input 之间如果存在此关联关系,它们的相同 Key Group 的数据需要在同一个下游并发实例中进行处理。因此在对其中某一个 Input 的 Key Group 数据做拆分时,其对应的另一端的 Key Group 数据必须要进行复制,以保证正确性语义。对于 Join场景,由于其必定存在2个上游,因此外部关联始终为True。
接下来我们来详细分析几个案例,来解释说明。上图描述了Full Outer Join存在数据倾斜的案例,它存在两个上游,这两个上游内部关联都被标记为 True,也就意味着这两个上游都不可以被拆分。在合并完 Key Group 之后,只能按部就班的把对应的 Key Group 发送给具体的下游。即使它存在数据倾斜,也无法对它做拆解,因为可能会有数据正确性问题。
第二类情况是Left Outer Join / Right Outer Join,即输入的上游中某一端可以被拆分。上图的Case中我们可以发现上侧的输入可以被拆分,因此我们就可以在 Key Group 的划分阶段将存在数据倾斜的紫色分区进行切分,而另一端的数据保持原样。最后,对两个数据分区做笛卡尔积,就可以把原来一组存在数据倾斜的 Key Group 生成两组数据更均匀的 Key Group,分别发往不同的下游。
第三个场景是 Inner Join的情况,即两个输入上游都可以支持被拆分。这种情况下我们可以对两侧倾斜的分区都进行Split,对拆解后的 Key Group 做笛卡尔积, 拆分成四个数据更均匀的Key Group,保证最优的计算性能。
这里需要补充的一点是: 实际算法中并没有对最终拆分的Key Group个数做限制,数据划分算法会尽可能地保证最终下发的Key Group数据量低于用户配置的倾斜因子。
新的数据划分算法除了对 ALL-To-ALL 的数据传输方式做了优化,在 Point-Wise 场景也进行了优化。原有的 Point-Wise 划分算法,只能将数据分区划分为一组连续的 Partitions,或者是同一Partition内连续的 Subpartitions,上图 Case 中展示了 Rescale的数据传输方式,它上游有三个 Partition,下游有两个并发实例,由于存在奇数比例的分配,所以下游的并发实例当中,其中一个实例势必会比另一个并发实例多消费一份分区的数据,这样就会引入天然的不平等,导致数据的倾斜。
在引入新的数据划分算法后,现在已经能够支持跨 Partition 和 Subpartition 的数据划分策略,也就意味着下游可以只消费Partition当中的1组Subpartition数据,从而实现更均衡的划分方案。
03、未来改进
最后来谈一下未来的规划,主要包含以下三方面:
- 支持更多的场景,例如 Multiple-Input 的场景。 目前Flink在Table阶段会执行算子串联优化,将多个输入的节点进行合并优化生成新的Multiple-Input算子,来避免额外的Shuffle开销,不过由于Multiple-Input的优化在ExecNode Graph阶段就已经定型了,在StreamGraph中无法再对Multiple-Input中的算子进行拆解,相对应的解决方式是将该优化像Operator Chain一样也延迟到Runtime来执行,以此覆盖更多的Join场景。
- 与Multiple-Input优化类似,未来希望能够与Operator Fusion Codegen结合,我们同样也需要将这一步优化Delay到Runtime。
- 最后是提供更智能的自适应优化策略,例如:对于自适应 Broadcast Hash Join,由于目前的实现仍然会引入大表侧和Join节点的网络传输开销,因此一种可能的优化方式是我们在统计信息完备的前提下,能够提前预判小表,然后在运行时 Pending 大表的执行,等小表执行完再做决策,这时候由于大表还未被调度,因此我们在Runtime阶段也可以将大表侧和Join节点Chain到一起,来减少一次Shuffle。