Flink Forward Asia 2020:Flink SQL的功能扩展与深度优化

作者介绍: 杜立,腾讯高级工程师
整理: DJG
校对: MWT
摘要: 本文由腾讯高级工程师杜立分享,主要介绍了FlinkSQL现有的一些语法和逻辑及其存在的问题,以及腾讯Oceanus实时计算平台针对Flink SQL所做的优化,主要包括:

  1. FlinkSQL现状
  2. 窗口功能的扩展
  3. 回撤流的优化
  4. 未来的规划

1. 背景及现状

1.1 三种模式的分析

在这里插入图片描述

Flink作业目前有三种创建方式:JAR模式、画布模式和SQL模式。不同的提交作业的方式针对的人群也是不一样的。

  1. Jar模式:Jar模式基于DataStream/DataSet API开发,主要针对的是底层的开发人员。

优点:

  • 功能灵活多变,因为它底层的DataStream/DataSet API是Flink的原生API,你可以用它们开发任何你想要的算子功能或者DAG图;
  • 性能优化方便,可以非常有针对性的去优化每一个算子的性能。

缺点:

  • 依赖更新繁琐,无论扩展作业逻辑或是Flink版本的升级,都要去更新作业的代码以及依赖版本;
  • 学习门槛较高

  1. 画布模式: 所谓的画布模式,一般来讲会提供一个可视化的拖拉拽界面,让用户通过界面化的方式去进行拖拉拽操作,以完成Flink作业的编辑。它面向一些小白用户。

优点:

  • 操作便捷,画布上可以很方便地定义Flink的作业所包含的各种算子;
  • 功能较全,它基于Table API开发,功能覆盖比较完整;
  • 易于理解,DAG图比较直观,用户能够非常容易的去理解整个作业的运行流程。

缺点:

  • 配置复杂:每一个算子都需要去逐个的去配置,如果整个DAG图非常复杂,相应的配置工作也会非常大;
  • 逻辑重用困难:如果作业非常的多,不同的作业之间想去共享DAG逻辑的话非常困难。

  1. SQL模式: SQL语言已经存在了很长时间了,它有有自己的一套标准,主要面向数据分析人员。只要遵循既有的SQL标准,数据分析人员就可以在不同的平台和计算引擎之间进行切换。

优点:

  • 清晰简洁,易于理解和阅读;
  • 与计算引擎解耦,SQL与计算引擎及其版本是解耦的,在不同的计算引擎之间迁移业务逻辑不需要或极少需要去更改整段SQL。同时,如果想升级Flink版本,也是不需要去更改SQL;
  • 逻辑重用方便,可以通过 create view 的方式去重用我们的SQL逻辑,

缺点:

  • 语法不统一,比如说流与维表Join,Flink1.9之前使用 Lateral Table Join 语法,但是在1.9之后,更改成了PERIOD FOR SYSTEM_TIME语法,这种语法遵循了SQL ANSI 2011标准。语法的变动使得用户有一定的学习成本;
  • 功能覆盖不全:Flink SQL这个模块存在的时间不是很长,导致它的功能的一个覆盖不是很全。
  • 性能调优困难:一段SQL的执行效率主要由几个部分来决定,一个就是SQL本身所表达的业务逻辑;另一部分是翻译SQL所产生的执行计划的一个优化;第三部分的话,在产生最优的逻辑执行计划之后,翻译成本地的native code的时候方案也决定了SQL的执行效率;对于用户来讲的,他们所能优化的内容可能只局限于SQL所表达的业务逻辑。
  • 问题定位困难:SQL是一个完整的执行流程,如果我们发现某些数据不对,想针对性地去排查到底是哪个算子出了问题,是比较的困难的。一般来讲,我们想定位Flink SQL的问题,只能先不断的精简我们的整个SQL逻辑,然后不断地去尝试输出,这个成本是非常高的。腾讯Oceanus平台后期会针对这个问题,增加trace日志和metrics信息,输出到产品侧以帮助用户定位Flink SQL使用上的问题。

1.2 腾讯目前的工作

  1. 扩展语法: 定义了window table-valued function语法,以帮助用户实现基于窗口的流流Join和流流之间的交并差操作。另外,实现了自己的流与维表Join的语法。
  2. 新增功能: 新增的一些功能,包括两个新的window的类型,Incremental Window(增量窗口)和Ehanced Tumble Window(增强窗口)。实现了Eventtime Field与Table Source的解偶,很多时候Eventtime Field并不能通过Table Source字段定义出来,比如Table Source是一个子查询或者某个时间字段是由函数转换得出,想要用这些中间生成的时间字段作为Eventtime Field目前是做不到的,腾讯Oceanus团队目前的方案是,让用户可以选择物理表中任意的时间字段来定义Window的时间属性,并输出WaterMark。
  3. 性能调优:
    • 回撤流优化;
    • 内联UDF,如果相同的UDF即出现在LogicalProject中,又出现在Where条件中,那么UDF会进行多次调用。将逻辑执行计划中重复调用的UDF提取出来,将该UDF的执行结果进行缓存,避免多次调用;
  4. Bucket Join: 流表维表Join中存在数据冷启动问题,如果Flink任务在启动时大量加载外部数据,很容易造成反压。可以在启动时利用State Processor API等手段将全部数据预加载到内存中。但这种方案存在一种问题,维表数据加载到所有的subtask里面会造成较大的内存消耗。因此Oceanus团队的解决方案是,在维表的定义中指定一个bucket信息,流与维表进行Join的时候会基于bucket信息去加载维表中对应分片的数据,同时在翻译执行计划的时候左流拿到bucket信息,以保证流与维表的数据都会基于同一个bucket信息进行Join。这种方式能大大减少全量维表数据预加载带来的内存消耗问题。

2. 窗口功能扩展

腾讯Oceanus基于现有Flink SQL语法进行了一些扩展,并另外定义了两种新的Window类型。

2.1 新的窗口操作

现有如下需求,需要在两条流上针对某个时间窗口做Join操作或者交并差操作。
使用Flink SQL基于某个Window去做双流Join,现有的方案有两种,第一种方案就是先做Join再做Group By,第二种就是Interval Join。首先来分析一下第一种方案能否满足需求。

2.1.1 先Join再开窗

在这里插入图片描述
先Join再开窗的逻辑如上图所示,根据逻辑执行计划可以看到Join节点在Window Aggregate节点之下,所以会先进行流与流的Join,Join完了之后再去做Window Aggregate。图中右侧的流程图也可以看出,首先两边的流会做一个Connect,然后基于Join Key做Keyby操作,以此保证两条流中拥有相同Join Key的数据能够Shuffle到同一个task上。左流会将数据存到自己的状态中,同时会去右流的状态中进行Match,如果能Match上会将Match后的结果输出到下游。这种方案存在以下两个问题:

  1. 状态无法清理: 因为Join在开窗之前,Join里面并没有带Window的信息,即使下游的Window触发并完成计算,上游两条流的Join状态也无法被清理掉,顶多只能使用基于TTL的方式去清理。
  2. 语义无法满足需求: 原始的需求是想在两条流中基于相同的时间窗口去把数据进行切片后再Join,但是当前方案并不能满足这样的需求,因为它先做Join,使用Join后的数据再进行开窗,这种方式不能确保两条流中参与Join的数据是基于同一窗口的。

2.1.2 Interval Join

在这里插入图片描述

Interval Join相对于前面一种写法,好处就是不存在状态无法清理的问题,因为在扫描左右两条流的数据时可以基于某一确定的窗口,过了窗口时间后,状态是可以被清理掉的。但是这种方案相对于第一种方案而言,数据准确性可能会更差一点,因为它对于窗口的划分不是基于一个确定窗口,而是基于数据进行驱动,即当前数据可以Join的另一条流上的数据的范围是基于当前数据所携带的Eventtime的。这种窗口划分的语义与我们的需求还是存在一定差距的。

想象一下现有两条速率不一致的流,以low和upper两条边界来限定左流可以Join的右流的数据范围,在如此死板的范围约束下,右流总会存在一些有效数据落在时间窗口[left + low, left + upper]之外,导致计算不够准确。因此,最好还是按照窗口对齐的方式来划分时间窗口,让两条流中Eventtime相同的数据落在相同的时间窗口。

2.1.3 Windowing Table-Valued Function

腾讯扩展出了Windowing Table-Valued Function语法,该语法可以满足“在两条流上针对某个时间窗口做Join操作或者交并差操作”的需求。在SQL 2016标准中就有关于这一语法的描述,同时该语法在Calcite1.23里面就已存在。
在这里插入图片描述

Windowing Table-Valued Function语法中的Source可以把它整个的语义描述清楚,From子句里面包含了Window定义所需要的所有信息,包括Table Source,Eventtime Field,Window Size等等。从上图的逻辑计划可以看出,该语法在LogiclTableScan上加了一个叫LogicalTableFunctionScan的节点。另外,LogicalProject节点(输出节点)多了两个字段叫作WindowStart和WindowEnd,基于这两个字段可以把数据归纳到一个确定的窗口。基于以上原理,Windowing Table-Valued Function语法可以做到下面这些事情:
在这里插入图片描述

  1. 在单流上面,可以像现有的Group Window语法一样去划分出一个时间窗口。写法如上图,Window信息全部放到From子句中,然后再进行Group By。这种写法应该更符合大众对于时间窗口的理解,比当前FlinkSQL中的的Group Window的写法更加直观一点。腾讯Oceanus在翻译单流上的Windowing Table-Valued Function语法时做了一个讨巧,即在实现这段SQL的一个物理翻译的时候,并没有去翻译成具体的DataStream API,而是将其逻辑执行计划直接变换到现在的Group Window的逻辑执行计划,也就是说共用了底层物理执行计划的代码,只是做了一个逻辑执行计划的等价;另外,可以对Window里面的数据做一些Sort或者TopN的一些输出,因为Windowing Table-Valued Function语法已经提前把数据划分进了一个个确定的窗口。如上图所示,首先在From子句里面把窗口划分好,然后Order By和Limit紧接其后,直接表达了排序和TopN语义。
    在这里插入图片描述
  2. 在双流上面,可以满足“在两条流上针对某个时间窗口做Join操作或者交并差操作”的原始需求。语法如上图,首先把两个窗口的Window Table构造好,然后利用Join关键字进行Join操作即可;交并差操作也一样,与传统数据库SQL的交并差操作无二。

2.1.4 实现细节

下面简单介绍一下腾讯Oceanus团队在实现Windowing Table-Valued Function语法时的一些细节。

2.1.4.1 窗口的传播

原始的逻辑计划翻译方式,先基于LogicalTableScan,然后再翻译到Windowing Table-Valued Function,最后再翻译到OrderBy Limit子句。整个过程会存储很多次状态,对于性能来讲会是比较大的一个消耗,因此做了如下优化,把多个Logical Relnode合并在一起去翻译,这样可以减少中间环节代码的产生,从而提高性能。

2.1.4.2 时间属性字段

可以看到Windowing Table-Valued Function的语法:

SELECT * FROM TABLE(TUMBLE(TABLE <data>, DESCRIPTOR(<timecol>), <size> [, <offset>]))

table<data> 不仅仅可以是一张表,还可以是一个子查询。所以如果定义Eventtime Field的时候,把时间属性和Table Source绑定,且Table Source恰好是一个子查询,此时就无法满足我们的需求,所以Oceanus团队在实现语法的时候,把时间属性字段跟Table Source解耦,反之,用户使用物理表中的任意一个时间字段来作为时间属性,从而产生watermark。

2.1.4.3 时间水印

Watermark的使用逻辑与在其他语法中一样,两条流的所有的Input Task的最小时间水印,决定窗口的时间水印,以此来触发窗口计算。

2.1.4.4 使用约束

目前Windowing Table-Valued Function的使用存在一些约束。首先,两条流的窗口类型必须是一致的,而且窗口大小也是一样的。然后,目前还没有实现Session Window相关的功能。

2.2 新的窗口类型

接下来的介绍扩展出两个新的窗口类型。

2.2.1 Incremental Window

有如下需求,用户希望能够绘制一天内的pv/uv曲线,即在一天内或一个大的窗口内,输出多次结果,而非等窗口结束之后统一输出一次结果。针对该需求,Oceanus团队扩展出了Incremental Window。

2.2.1.1 多次触发

基于Tumble Window,自定义了Incremental Trigger。该触发器确保,不仅仅是在Windows结束之后才去触发窗口计算,而是每隔SQL中所定义的Interval周期都会触发一次窗口计算。
在这里插入图片描述
如上图中的SQL案例,总的窗口大小是一秒,且每0.2秒触发一次,所以在窗口内会触发5次窗口计算。且下一次的输出结果是基于上一次结果进行累计计算。

2.2.1.2 Lazy Trigger

针对Incremental Window做了一个名为Lazy Trigger的优化。在实际的生产过程中,一个窗口相同Key的值在多次触发窗口计算后输出的结果是一样的。 对于下游来讲,对于这种数据是没必要去重复接收的。因此,如果配置了Lazy Trigger的话,且在同一个窗口的同一个Key下,下一次输出的值跟上一次的是一模一样的,下游就不会接收到这次的更新数据,由此减少下游的存储压力和并发压力。

2.2.2 Enhanced Tumble Window

在这里插入图片描述

有如下需求,用户希望在Tumble Window触发之后,不去丢弃迟到的数据,而是再次触发窗口计算。如果使用DataStream API,使用SideOutput就可以完成需求。但是对于SQL,目前是没办法做到的。因此,扩展了现有的Tumble Window,把迟到的数据也收集起来,同时迟到的数据并不是每来一条就重新触发窗口计算并向下游输出,而是会重新定义一个Trigger,Trigger的时间间隔使用SQL中定义的窗口大小,以此减少向下游发送数据的频率。同时,侧输出流在累计数据的时候也会使用Window的逻辑再做一次聚合。这里需要注意,如果下游是类似于HBase这样的数据源,对于相同的Window相同的Key,前一条正常被窗口触发的数据会被迟到的数据覆盖掉。理论上,迟到的数据跟正常窗口触发的数据的重要性是一样的,不能相互覆盖。最后,下游会将收到的同一个窗口同一个Key下的正常数据和延迟数据再做一次二次聚合。

3. 回撤流优化

接下来介绍一下在回撤流上所做的一些优化。

3.1 流表二义性

回顾一下关于在Flink SQL中关于回撤流的一些概念。
首先介绍一下持续查询(Continuous Query),相对于批处理一次执行输出一次结果的特点,流的聚合是上游来一条数据,下游的话就会接收一条更新的数据,即结果是不断被上游的数据所更新的。因此,对于同一个Key下游能够接收到多条更新结果。

3.2 回撤流

在这里插入图片描述

以上图的SQL为例,当第二条 Java 到达聚合算子时,会去更新第一条 Java 所产生的状态并把结果发送到下游。如果下游对于多次更新的结果不做任何处理,就会产生错误的结果。针对这种场景,Flink SQL引入了回撤流的概念。所谓回撤流的话,就是在原始数据前加了一个标识位,以True/False进行标识。如果标识位是False,就表示这是一条回撤消息,它通知下游对这条数据做Delete操作;如果标识位是True,下游直接会做Insert操作。

3.2.1 什么时候产生回撤流

目前,FlinksQL里面产生回撤流有以下四种场景:

  • Aggregate Without Window(不带Window的聚合场景)
  • Rank
  • Over Window
  • Left/Right/Full Outer Join

解释一下Outer Join为什么会产生回撤。以Left Outer Join为例,且假设左流的数据比右流的数据先到,左流的数据会去扫描右流数据的状态,如果找不到可以Join的数据,左流并不知道右流中是确实不存在这条数据还是说右流中的相应数据迟到了。为了满足Outer join的语义的话,左边流数据还是会产生一条Join数据发送到下游,类似于MySQL Left Join,左流的字段以正常的表字段值填充,右流的相应字段以Null填充,然后输出到下游,如下图所示:
在这里插入图片描述

后期如果右流的相应数据到达,会去扫描左流的状态再次进行Join,此时,为了保证语义的正确性,需要把前面已经输出到下游的这条特殊的数据进行回撤,同时会把最新Join上的数据输出到下游。注意,对于相同的Key,如果产生了一次回撤,是不会再产生第二次回撤的,因为如果后期再有该Key的数据到达,是可以Join上另一条流上相应的数据的。

3.2.2 如何处理回撤消息

在这里插入图片描述

下面介绍Flink中处理回撤消息的逻辑。
对于中间计算节点,通过上图中的4个标志位来控制,这些标识位表示当前节点是产生Update信息还是产生Retract信息,以及当前节点是否会消费这个Retract信息。这4个标识位能够决定整个关于Retract的产生和处理的逻辑。
对于Sink节点,目前Flink中有三种sink类型,AppendStreamTableSink、RetractStreamTableSink和UpsertStreamTableSink。AppendStreamTableSink接收的上游数据是一条Retract信息的话会直接报错的,因为它只能描述Append-Only语义;RetractStreamTableSink则可以处理Retract信息,如果上游算子发送一个Retract信息过来,它会对消息做Delete操作,如果上游算子发送的是正常的更新信息,它会对消息做Insert操作;UpsertStreamTableSink可以理解为对于RetractStreamTableSink做了一些性能的优化。如果Sink数据源支持幂等操作,或者支持按照某key做Update操作,UpsertStreamTableSink会在SQL翻译的时候把上游 Upsert Key传到Table Sink里面,然后基于该Key去做Update操作。

3.2.3 Oceanus的优化

Oceanus团队基于回撤流做以下优化。

3.2.3.1 中间节点的优化

在这里插入图片描述
产生回撤信息最根本的一个原因是不断地向下游多次发送更新结果,因此,为了减少更新的频率并降低并发,可以把更新结果累计一部分之后再发送出去。如上图所示:
第一个场景是一个嵌套AGG的场景(例如两次Count操作),在第一层Group By尝试将更新结果发送到下游时候会先做一个Cache,从而减少向下游发送数据频率。当达到了Cache的触发条件时,再把更新结果发送到下游。
第二个场景是Outer Join,前面提到,Outer Join产生回撤消息是因为左右两边数据的速率不匹配。以 Left Outer Join为例,可以把左流的数据进行Cache。左流数据到达时会去右流的状态里面查找,如果能找到可以与之Join的数据则不作缓存;如果找不到相应数据,则对这条Key的数据先做缓存,当到达某些触发条件时,再去右流状态中查找一次,如果仍然找不到相应数据,再去向下游发送一条包含Null值的Join数据,之后右流相应数据到达就会将Cache中该Key对应的缓存清空,并向下游发送一条回撤消息。
以此来减小向下游发送回撤消息的频率。

3.2.3.2 Sink节点的优化

在这里插入图片描述

针对Sink节点做了一些优化,在 AGG节点和Sink节点之间做了一个Cache,以此减轻Sink节点的压力。当回撤消息在Cache中再做聚合,当达到Cache的触发条件时,统一将更新后的数据发送到Sink节点。以下图中的SQL为例:
在这里插入图片描述

参考优化前后的输出结果可以看到,优化后下游接收到的数据量是有减少的,例如用户Sam,当回撤消息尝试发送到下游时,先做一层Cache,下游接收到的数据量可以减少很多。

4. 未来规划

在这里插入图片描述

下面介绍一下腾讯Oceanus团队接下来的工作规划:

  1. Cost-Based Optimization: 现在Flink SQL的逻辑执行计划的优化还是基于RBO(Rule Based Optimization)的方式。Oceanus团队想基于CBO所做一些事,主要的工作还是统计信息的收集。统计信息不仅仅来自Flink SQL本身,可能还会来自公司内其他产品,例如元数据,不同Key所对应的数据分布,或者其他数据分析结果。通过跟公司内其他产品打通,拿到最准的统计数据,产生最优的执行计划。
  2. More New Features(CEP Syntax etc.): 基于Flink SQL定义一些CEP的语法,以满足用户关于CEP的一些需求。
  3. Continuous Performance Optimization(Join Operator etc.): Oceanus团队在做的不仅仅是执行计划层的优化,也在做Join Operator或者说数据Shuffle的一些细粒度的优化。
  4. Easier To Debug: 最后是关于Flink SQL任务的调试和定位。目前Flink SQL在这方面是比较欠缺的,特别是线上关于数据对不齐的问题,排查起来非常的棘手。Oceanus团队目前的思路是通过配置的方式,让SQL在执行的过程中吐出一些Trace信息或者一些Metrics信息,然后发送到其他平台。通过这些Trace信息和Metric信息,帮助用户定位出问题的算子。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值