FlinkSQL优化---持续更新

217 阅读18分钟

文章学习自FFA

一.FlinkSQL的解析、执行

image.png Flink 引擎接收到一个 SQL 文本后

  1. 通过 SqlParser 将其解析成 SqlNode。
  2. 通过查询 Catalog 中的元数据信息,对 SqlNode 中的表、字段、类型、udf 等进行校验(这也是为啥debug的时候还没运行sql呢,就会报字段类型不匹配的原因),校验通过后会转换为 LogicalPlan。
  3. 通过 Optimizer 优化后转换为 ExecPlan。
  4. ExecPlan 是 SQL 层的最终执行计划,通过 CodeGen 技术将执行计划翻译成可执行算子,用 Transformation 来描述,最后转换为 JobGraph 并提交到 Flink 集群上执行。

二.FlinkSQL的调优

1.大体思路

在上面的SQL解析和执行过程中,发现ExePlan通过CodeGen转为JobGraph这块不可能进行优化,因为转化都是基于一定逻辑的,那么优化的重点就在Optimizer这里将LogicalPlan转为ExecPlan

下图展示了优化器内部的细节。一个 Flink 作业通常包含多个 Insert 语句,当多个 Insert 语句翻译成 Logical Plan,会形成一个 DAG,我们称该优化器为 DAG 优化器。

Optimizer 的输入包括 5 部分:LogicalPlan、Flink 配置、约束信息(例如 PK 信息),统计信息、用户在 SQL 中提供的 Hints。

4

Optimizer具体的优化过程包括:

  1. 首先需要将 DAG 基于 View 进行分解,该做法是为了尽可能保证执行计划可复用。
  2. 分解完后会得到一个个独立的 Tree,从叶子节点向根节点依次将这个 Tree 交给 Calcite 的 Optimizer 进行优化。然后将优化完的结果重新组装为 DAG,形成 PhysicalPlan 的 DAG,
  3. 使用子图复用技术(Sub-Plan Reuse)继续改写 PhysicalPlan,进一步消除重复计算。
  4. 最后将 PhysicalPlan 转换为 ExecPlan。

基于 Exec DAG,可以做更多的优化:

  • MultipleInput Rewrite,减少网络 Shuffle 带来的开
  • DynamicFiltering Rewrite,减少无效数据的读取和计算等。

补充:

  1. Calcite Optimizer 是经典的数据库中的关系代数优化,有基于规则的优化——RBO,基于代价模型的优化——CBO,同时还定义了大量的优化规则,大量的 Meta 信息的推导(如 PrimaryKey推导、RowCount 的推导),来帮助优化器获得最优的执行计划。
  2. 在 Calcite Optimizer 里,结合了经典的优化思路:首先是对 LogicalPlan 进行一些确定性的改写,例如 SubQuery 改写、解关联、常量折叠等。然后做一些通用的改写,例如各种 push down,形成 FlinkLogicalPlan。这部分优化是流批通用的。然后流和批根据各自底层的实现,进行特定的优化,例如批上会根据 Cost 选择不同的 Join 算法,流上会根据配置决定是否将 Retract 处理转换为 Upsert 处理,是否开启 Local/Global 解决数据热点问题等。

2.各种场景加优化方法的解析

(1) Sub-Plan Reuse 子图复用

首先是 Sub-Plan Reuse(即子图复用)优化。下图中的两句 SQL 会被优化成两个独立的 Pipeline,见下图中左下角的执行计划,Scan 算子和 Filter 算子中 a>10 会计算两次。

5

通过配置table.optimizer.reuse-sub-plan-enabled:true开启 Sub-Plan Reuse 优化,优化器会自动发现执行计划中计算逻辑完全一样的子图并将它们进行合并,从而避免重复计算。这个例子中,由于两个 Scan 算子完全一样,所以经过优化之后变成一个 Scan 算子,从而减小数据的读取的次数,优化后的 DAG 如上图右下角所示。

逻辑上,优化器也可以复用 Filter 中的 a>10 的部分的计算。但是当遇到非常复杂的 Query 时,同时结合各种 push down 的优化,要找到理想的可复用的执行计划是一件非常麻烦的事情。可以使用基于视图(View)的子图复用的方式来达到最大化子图复用的效果。

这里的 View,可以理解为一个子图复用单元。基于用户定义的 View,优化器能够自动识别哪些 View 能够被复用。下图中的 SQL 会翻译成左下角的执行计划,经过子图复用优化后 Scan 和 Filter a>10 都将被复用(如下图右下角执行计划所示)。 image.png

(2) Fast Aggregation---MiniBatch、LocalGlobal、PartialFinal、Incremental

<1> 总结

最终建议全部都开启 参数如下

 -- 开启MiniBatch
table.exec.mini-batch.enabled: true
table.exec.mini-batch.allow-latency: 5s
-- 开启LocalGlobal
table.optimizer.agg-phase-strategy:TWO_PHASE/AUTO
-- 开启PartilFinal Aggregation
table.optimizer.distinct-agg.split.enabled: true
table.optimizer.distinct-agg.split.bucket-num:1024
-- 开启incremental Agg
table.optimizer.incremental-agg-enabled:true

功能:

  1. MiniBatch:将数据进行攒批,攒成一个一个Buffer,然后交给下游
  2. LocalGlobal其实是1次shuffle,2次聚合,分LocalAgg和GlobalAgg,LocalAgg和上游算子chain起来,不走shuffle,对当前并行度的上游数据进行预聚合,然后将结果发送给下游GlobalAgg,这里才开始shuffle,然后最终二次聚合
  3. Partial/Final Aggreation其实是2次shuffle,2次聚合,第一次是partial,第二次是final,PartialAgg 通过 Group Key + Distinct Key 进行 Shuffle,这样确保相同的 Group Key + Distinct Key 能被 Shuffle 到同一个 PartialAgg 实例中,完成第一层的聚合,从而减少 FinalAgg 的输入数据量。
  4. Incremental Agg其实是2次shuffle,3次聚合,为了优化PartialAgg的,因PartialAgg会将结果放在State中,造成状态问题,采用Incremental它会同时结合LocalGlobal和PartialFinal优化,将Partial的GlobalAgg和Final的localAgg合成一个IncrementalAgg算子,然后前面是Partial的LocalAgg,后面是Final的GlobalAgg

应用场景

  1. MiniBatch:适用于对延迟要求不高、频繁访问State、下游算子处理能力不足
  2. LocalGlobal:适用于数据倾斜、热点数据、不存在distinct的聚合,要求:所有 Aggregate Function 都实现了 merge 方法,不然没法在 GlobalAgg 在对 LocalAgg 的结果进行合并;
  3. Partial/Final Aggreation:sql存在distinct的聚合且造成数据倾斜
  4. Incremental Agg:优化PartialAgg的状态问题
<2> MiniBatch

Aggregation Query 在生产中被高频使用,这里以“select a,sum(b) from my_table group by a; ”为例进行介绍 Aggregation 相关优化。该 SQL 将被翻译成下图中左边的逻辑执行计划,而图中右边是执行拓扑:Scan 算子的并发为 3,Aggregation 算子的并发为 2。

7

图中的一个小方框表示一条数据,用颜色来表示字段 a 的值,用数字表示字段 b 的值,所有的 Scan 算子的数据会按字段 a 进行 Shuffle 输出给 Aggregate 算子。流计算中,当 Aggregate 算子接收到一条数据后,首先会从数据中抽取它对应的 Group Key,然后从 State 里获得对应 Key 之前的聚合结果,并基于之前的聚合结果和当前的数据进行聚合计算。最后将结果更新到 State 里并输出到下游。(逻辑可参考上图中的伪代码)

问题:上图中 Aggregate 算子的输入有 15 条数据,Aggregate 计算会触发 15 次 State 操作(15 次 get 和 put 操作),这么频繁的操作可能会造成性能影响。与此同时,第一个 Aggregate 实例接收了 8 条红色数据,会形成热点 Key。如何解决 State 访问频繁和热点 Key 的问题在生产中经常遇到。

通过开启 MiniBatch 能减少 State 的访问和减少热点 Key 的访问。对应的配置为:

  • table.exec.mini-batch.enabled: true
  • table.exec.mini-batch.allow-latency: 5s 用户按需配置

allow-latency 表明了 MiniBatch 的大小是 5 秒,即 5 秒内的数据会成为为一个 Mini Batch。开启 Mini Batch 之后,Aggregation 的计算逻辑就会发生变化:当 Aggregate 算子接收到一条数据时,不会直接触发计算,而是把它放到一个 Buffer 里。当 Buffer 攒满之后,才开始计算:将 Buffer 的数据按 Key 进行分组,以每个组为单位进行计算。对于每个 Key,先从 State 中获取该 Key 对应的之前的聚合结果,然后逐条计算该 Key 对应的所有数据,计算完成后将结果更新 State 并输出给下游。 image.png 这里 State 的访问次数等于 Key 的个数。如果将上图中所示的数据都放到一个 Buffer 的话,这里 Key 的个数只有 4 个,因此只有 4 次 State 访问。相比于原来的 15 次访问,开启 MiniBatch 优化后大大减少了 State 访问的开销。

开启 MiniBatch 适用的场景为:

  • 作业对延迟没有严格要求(因为攒 Batch 本身会带来延迟);
  • 当前 Aggregate 算子访问 State 是瓶颈;
  • 下游算子的处理能力不足(开启 MiniBatch 会减少往下游输出的数据量)。
<3> LocalGlobal

问题:上图中,我们观察到第一个 Aggregate 实例中虽然访问 State 频率很少,但是要处理的数据量还是没变,其整体数据量和单 Key 数据量相比其他 Aggregate 实例都多很多,即存在数据倾斜。我们可以通过两阶段 Aggregate(Local/Global)来解决数据倾斜避免热点。

开启两阶段 Aggregate 需要开启 MiniBatch并配置table.optimizer.agg-phase-strategy:TWO_PHASE/AUTO将 agg-phase-strategy 设置为 TWO PHASE/AUTO

开启后的 Plan 如下图所示。 image.png LocalAgg 和上游算子 chain 在一起,即 Scan 和 LocalAgg 采用相同并发,执行中间不存在数据 Shuffle。在 LocalAgg 里,会按照 MiniBatch 的大小进行聚合,并把聚合的结果输出到 GlobalAgg。

上图示例中,经过 LocalAgg 聚合后的红色数据在第一个 GlobalAgg 实例中从 8 条减到 3 条,各个 GlobalAgg 实例之间不存在数据倾斜。

开启 Local/Global 适用的场景为:

  • 所有 Aggregate Function 都实现了 merge 方法,不然没法在 GlobalAgg 在对 LocalAgg 的结果进行合并;
  • LocalAgg 聚合度比较高,或者 GlobalAgg 存在数据倾斜,否则开启 LocalAgg 会引入额外的计算开销。
<4> Partial/Final Aggreation

问题:当 Query 中有 Distinct Aggreation Function 时,Local/Global 解决不了热点问题。通过下图的例子可以看出来,LocalAgg 按 a、b 字段进行聚合,聚合效果并不明显。因此,第一个 GlobalAgg 实例还是接收到大量的热点数据,共 7 条红色数据。LocalAgg 的聚合 Key(a、b)和下游的 Shuffle Key(a)不一致导致 Local/Global 无法解决 DistinctAgg 的数据热点问题。 image.png 我们可以通过开启 Partial/Final Aggreation 来解决 DistinctAgg 的数据热点问题。PartialAgg 通过 Group Key + Distinct Key 进行 Shuffle,这样确保相同的 Group Key + Distinct Key 能被 Shuffle 到同一个 PartialAgg 实例中,完成第一层的聚合,从而减少 FinalAgg 的输入数据量。可以通过下面配置开启 Partial/Final 优化:

  • table.optimizer.distinct-agg.split.enabled: true
  • table.optimizer.distinct-agg.split.bucket-num:1024 指定PartialAgg的桶数

开启配置之后,优化器会将原来的 plan 翻译成下图的 plan: image.png 上图例子中,经过 PartialAgg 的聚合之后,第一个 FinalAgg 实例的热点 Key 从 7 条减少到 3 条。开启 Partial/Final 适用的场景为:

  • Query 中存在 Distinct Function,且存在数据倾斜;
  • 非 Distinct Function 只能是如下函数:count,sum,avg,max,min;
  • 数据集比较大的时候效果更好,因为 Partial/Final 两层 Agg 之间会引入额外的网络 Shuffle 开销;
  • Partial Agg 还会引入额外的计算和 State 的开销。
<5> Incremental Agg

问题:和 LocalAgg 不一样,PartialAgg 会将结果存放在 State 中,这会造成 State 翻倍。为了解决 PartialAgg 引入额外的 State 开销的问题,在 Partital/Final 基础上需要配置table.optimizer.incremental-agg-enabled:true开启 Increment 优化。它同时结合了 Local/Global 和 Partial/Final 优化,并将 Partial 的 GlobalAgg 和 Final 的 LocalAgg 合并成一个 IncrementalAgg 算子,它只存放 Distinct Value 相关的值,因此减少了 State 的大小。 开启 IncrementalAgg 之后的执行计划如下图所示: image.png

<6> 其他情况

一些 Query 改写也能减少 Aggregate 算子的 State。很多用户使用 COUNT DISTICNT + CASE WHEN 统计同一个维度不同条件的结果。在 Aggregate 算子里,每个 COUNT DISTICNT 函数都采用独立的 State 存储 Distinct 相关的数据,而这部分数据是冗余的。可以通过将 CASE WHEN 改写为 FILTER,优化器会将相同维度的 State 数据共享,从而减少 State 大小。 image.png

(3) Fast Join

<1> Regular Join的优化方面

Join 是我们生产中非常常见的 Query,Join 的优化带来的性能提升效果非常明显。以下图简单的 Join Query 为例介绍 Join 相关的优化:该 Query 将被翻译成 Regular Join(也常被称为双流 Join),其 State 会保留 left 端和 right 端所有的数据。当数据集较大时,Join State 也会非常大,在生产中可能会造成严重的性能问题。优化 Join State 是流计算非常重要的内容。

14

针对 Regular Join,有两种常见的优化方式:

  1. 当 Join Key 中带有 PrimaryKey(以下简称 PK) 时,State 中存储的数据较少,它只存了输入数据和对应的关联次数
  2. 当 Join 的输入存有 PK 时,State 中存储了一个 Map,Map Key 是 PK,Value 是输入数据以及关联的次数。
  3. 当 Join Key 和 Join 输入都没有 PK 时,State 仍用 Map 存储,但它的 Key 是输入的数据,Value 是这一行数据的出现次数和关联次数。
    • 虽然它们都是用 Map 存储,Join 输入带 PK 时的 State 访问效率高于没有 PK 的情况。因此建议在 Query 中尽量定义 PK 信息,帮助优化器更好的优化。
  4. 建议在 Join 之前只保留必要的字段,让 Join State 里面存储的数据更少。

15

除了 Regular Join 外,Flink 还提供了其他类型的 Join:Lookup Join、Interval Join、Temporal Join、Window Join。在满足业务需求的条件下,用户可以将 Regular Join 进行改写成其他类型的 Join 以节省 State。Regular Join 和其他 Join 的转换关系如下图所示:

16

  • 将 Regular Join 改写成 Lookup Join。主流来一条数据会触发 Join 计算,Join 会根据主流的数据查找维表中相关最新数据,因此 Lookup Join 不需要 State 存储输入数据。目前很多维表 Connector 提供了点查机制和缓存机制,执行性能非常好,在生产中被大量使用。后面章节会单独介绍 Lookup Join 相关优化。Lookup Join 的缺点是当维表数据有更新时,无法触发 Join 计算。
  • 将 Regular Join 改写为 Interval Join。Interval Join 是在 Regluar Join 基础上对 Join Condition 加了时间段的限定,从而在 State 中只需要存储该时间段的数据即可,过期数据会被及时清理。因此 Interval Join 的 State 相比 Regular Join 要小很多。
  • 把 Regular Join 改写成 Window Join。Window Join 是在 Regluar Join 基础上定义相关 Window 的数据才能被 Join。因此,State 只存放的最新 Window,过期数据会被及时清理。
  • 把 Regular Join 改写成 Temporal Join。Temporal Join 是在 Regular Join 上定义了相关版本才能被 Join。Temporal Join 保留最新版本数据,过期数据会被及时清理。
<2> Delta Join--无状态Join的全新模式

这就是 Delta Join 的用武之地——它带来了一种截然不同的设计思路。与传统 Join 方式将所有数据缓存在 Flink 状态后端不同,Delta Join 转而依赖外部存储系统(例如基于 RocksDB 构建的  Apache Fluss),将数据存于外部,实现真正的无状态计算。其工作原理如下: image.png Fluss 会持续发送变更日志(changelog)更新,确保 Join 数据始终最新。每当有新事件到达时,Delta Join 只需在 Fluss 中执行一次索引查询——就像访问一个键值存储(key-value store)一样简单高效。

可以看到,现在的 Delta Join 已经完全无状态。此前困扰流处理的各种“大状态”问题也随之消失。这使得过去难以实现的大规模 Join 任务,如今成为可能。

??疑问点??:Delta Join是把数据存储在外部系统,那RocksDB状态后端也是把Join的数据存入到外部RocksDB中,他俩有什么区别呢?

<3> 多流Join冗余问题

Flink 的 Regular Join 是一个二元操作:一次只能连接两条流。如果我们想关联 T1、T2 和 T3 三张表,系统必须构建一个级联式执行计划:先将 T1 与 T2 关联,再将结果与 T3 关联。

问题:

  1. 每个 Join 阶段都需要维护自己完整的状态;
  2. 中间结果(如 T1 与 T2 的关联结果)会被重复存储;整体状态体积呈倍数增长,检查点时间急剧上升。

缓解方案:虽然 FLIP-415的mini-batch join 能缓解部分中间结果输出的压力,但无法解决状态重复存储的根本问题。

破局答案Multi-Way Join image.png Multi-Way Join 是一种全新的 Join 策略,从设计源头就杜绝了冗余。

它允许使用同一个关联键,在单个算子内同时关联多条流。不同于级联式的二元 Join,Multi-Way Join 为每条输入流维护一个独立的索引状态表,不再生成中间 Join 结果,也无需嵌套存储。

这意味着:

  • 没有中间状态复制
  • 没有嵌套检查点延迟 关联的流越多,优势越明显。尤其在复杂事件处理、多维事实表关联等场景下,性能提升显著。

(4) Fast Lookup Join

Lookup Join 在生产中被广泛使用,因此我们这里对其做单独的优化介绍。当前 Flink 对 Lookup Join 提供了多种优化方式:

<1> 开启异步查询

如下图所示,在同步查询中,当一条数据发送给维表 Collector 后,需要等结果返回,才能处理下一条,中间会有大量的时间等待。在异步查询过程中,会将一批数据同时发送给维表 Collector,在这批数据完成查询会统一返回给下游。通过异步查询模式模式,查询性能得到极大提升。Flink 提供了 Query Hint 的方式开启同步和异步模式,请参考下图所示 image.png

<2> 异步非排序

在异步模式下,提供了 Ordered 和 Unordered 机制。在 Order 模式之下,需要等所有数据返回后并对数据排序,以确保输出的顺序和输入的顺序一致,排序完成才能发送给下游。在 Unordered 模式下,对输出顺序没有要求,只要查询到结果,就可以直接发送给下游。 因此,相比于 Order 模式,Unordered 模式能够有极大提升性能。Flink 提供了 Query Hint 的方式开启 Ordered 和 Unordered 模式,请参考下图所示。(Flink 也提供 Job 级别的配置,开启所有维表查询的都采用相同输出模式。) image.png

<3> 开启cache机制

Cache 机制。这是一种很常见的优化,用 本地 Memory Lookup 提高查询效率。目前,Flink 提供了三种 Cache 机制:

  • Full Caching,将所有数据全部 Cache 到内存中。该方式适合小数据集,因为数据量过大会导致 OOM。Flink 提供了 Table Hints 开启。同时,还可以通过 Hints 定义 reload 策略。
  • Partial Caching,适合大数据集使用,框架底层使用 LRU Cache 保存最近被使用的数据。当然,也可以通过 Hint 定义 LRU Cache 的大小和 Cache 的失效时间。
  • No Caching,即关闭 Cache。 image.png

(5) Fast Deduplication--采用窗口函数去重

流计算中经常遇到数据重复,因此“去重”使用频率非常高。在早期版本,Flink 通过 group by + first_value 的方式查找第一行数据;通过 group by + last_value 查找最后一行数据。示例如下图所示:

20

上述 Query 会被转换为 Aggregate 算子,它的 State 非常大且无法保证语义完整。因为下游的输出数据可能来源于不同行,导致与按行去重的语义语义相悖。 在最近版本中,Flink 提供了更高效的去重方式。由于 Flink 没有专门提供去重语法,目前通过 Over 语句表达去重语义,参考下图所示。对于查找第一行场景,可以按照时间升序,同时输出 row_number,再基于 row_number 进行过滤。此时,算子 State 只需要存储去重的 Key 即可。对于查找最后一行场景,把时间顺序变为降序,算子 State 只需要存储最后一行数据即可。

21 这里应该是写错了,应该是order by

3.针对json数据如何优化

由于解析json需要用到JSON_VALUE去解json数据,JSON_VALUE(data,'$.metadata.device')。这就导致一个问题在底层,每次调用都会触发完整的JSON 解析

比如我一条json数据,假设我要解析pvid也要解析click_id,那这就要解析两次json数据,且JSON内部没有索引,SQL的优化器都无法优化,那怎么解决呢? 解决方案:FlinkSQL的新类型--VARIANT,专门处理半结构化类型,就是给json格式的每个字段都加上offset标识,相当于加一个索引 image.png Flink 2.1 引入了新的VARIANT类型,这是一个原生的、二进制编码的半结构化类型。与普通的JSON字符串不同,VARIANT以结构化方式存储元数据和值。因此访问data.metadata.device只是一个直接的偏移查找,不再是完整的解析。

因为它是感知 schema 的,SQL 规划器可以在未来版本中应用查询优化。这使其非常适合数据管道。VARIANT为大规模处理 JSON 解锁了性能和灵活性。

4.去重优化

  • 去重优化:使用ROW_NUMBER()替代DISTINCT

5. 如何优化UDF(用户自定义函数)的性能?

  • 避免频繁对象创建:在open()方法中初始化资源。
  • 使用RuntimeContext缓存:对静态数据预加载。
  • 向量化处理:批量处理数据(如Flink SQL的向量化执行引擎)。