流计算中的Window计算 | 青训营笔记

179 阅读11分钟

这是我参与「第四届青训营 」笔记创作活动的第3天

1. 概述

1.1 流式计算VS批式计算

image.png

1.2 批处理

批处理模型典型的数仓架构为T+1架构,即数据计算时天级别的,当天只能看到前一天的的计算结果。 通常使用的计算引擎为Hive或者Spark等。计算的时候,数据是完全ready的,输入和输出都是确定性的。

image.png

1.2 小时级批计算

image.png

1.3 处理窗口

实时计算:处理时间窗口 数据实时流动,实时计算,窗口结束直接发送结果,不需要周期调度任务。

image.png

1.4 处理时间VS事件时间

  • 处理时间:数据在流式计算系统中真正处理时所在机器当前的时间。
  • 事件时间:数据产生的时间,比如客户端、传感器、后端代码等上报数据的时间。

image.png

1.5 事件时间窗口

实时计算:事件时间窗口 数据实时进入到真实事件发生的窗口中进行计算,可以有效的处理数据延迟和乱序。

image.png

1.6 Watermark

  • 在数据中插入一些watermark来表示当前的真实时间。

image.png

  • 在数据存在乱序的时候,watermark就比较重要了,它可以用来在乱序容忍和实时性之间做一个平衡。

image.png

1.7 小结

  • 批式计算一般是T+1的数仓架构
  • 数据实时性越高,数据的价值越高
  • 实时计算分为处理时间和事件时间
  • 事件时间需要watermark配合来处理乱序

2. Watermark

2.1 什么是Watermark?

表示系统认为的当前真实的事件时间

2.2 如何产生Watermark?

Watermark产生:一般是从数据的事件时间来产生,产生策略可以灵活多样,最常见的包括使用当前事件时间的时间减去一个固定的delay,来表示可以可以容忍多长时间的乱序。

  1. SQL

image.png 2. DataStream

image.png

2.3 如何传递Watermark?

Watermark传递:这个类似于上节课中介绍的Checkpoint的制作过程,传递就类似于Checkpoint的barrier,上下游task之间有数据传输关系的,上游就会将watermark传递给下游;下游收到多个上游传递过来的watermark后,默认会取其中最小值来作为自身的watermark,同时它也会将自己watermark传递给它的下游。经过整个传递过程,最终系统中每一个计算单元就都会实时的知道自身当前的watermark是多少。

2.4 Per-partition / Per-subtask 生成

  • 在Flink里早期都是per-subtask的方式进行watermark的生成,这种方式比较简单。但是如果每个source task如果有消费多个partition的情况的话,那多个partition之间的数据可能会因为消费的速度不同而最终导致数据的乱序程度增加。

  • 后期(上面图中)就逐步的变成了per-partition的方式来产生watermark,来避免上面的问题。

2.5 部分partition/subtask断流

根据上面提到的watermark传递机制,下游subtask会将上游所有subtask的watermark值的最小值作为自身的watermark值。如果上游有一个subtask的watermark不更新了,则下游的watermark都不更新。

  • 解决方案: Idle source
    • 当某个subtask断流超过配置的idle时间时,将当前subtask置为idle,并下发一个idle的状态下游。下游在计算自身watermark的时候,可以忽略掉当前是idle的那些subtask。

2.6 迟到数据处理

因为watermark表示当前事件发生的真实时间,那晚于watermark的数据到来时,系统会认为这种数据是迟到的数据。

  • 算子自身来决定如何处理迟到的数据:
    • Window聚合,默认会丢弃迟到数据;
    • 双流join,如果是outer join,则可以认为它不能join到任何数据;
    • CEP,默认丢弃。

3. Window

3.1 window分类

典型的window:

  1. Tumble Window(滚动窗口)
  2. Sliding Window(滑动窗口)
  3. Session Window(会话窗口)

其它Window

  1. 全局Window
  2. Count Window
  3. 累计窗口
  4. ......

3.1 Window使用

image.png

3.1 滚动窗口

image.png

  • 窗口划分
    • 每个key单独划分
    • 每条数据只会属于一个窗口
  • 窗口触发
    • Window结束时间到达的时候一次性触发

3.2 滑动窗口

image.png

  • 窗口划分
    • 每个key单独划分
    • 每条数据可能会属于多个窗口
  • 窗口触发
    • Window结束时间到达的时候一次性触发

3.3 会话窗口

image.png

  • 窗口划分
    • 每个key单独划分
    • 每条数据会单独划分为一个窗口,如果Window之间有交集,则会对窗口进行merge.
  • 窗口触发
    • Window结束时间到达的时候一次性触发

3.4 迟到数据处理

3.4.1 怎么定义迟到?

一条数据到来后,会用WindowAssigner给它划分一个window,一般时间窗口是一个时间区间,比如[10:00,11:00),如果划分出来的window end比当前的watermark值还小,说明这个窗口已经触发了计算了,这条数据会被认为是迟到数据。

3.4.2 什么情况下会产生迟到数据?

只有事件时间下才会有迟到数据。

3.4.3 迟到数据默认怎么处理?

  • 丢弃

3.4.4 迟到数据的两个处理方法

  • Allow lateness
    • 这种方式需要设置一个允许迟到的时间。设置之后,窗口正常计算结束后,不会马上清理状态,而是会多保留allowLateness这么长时间,在这段时间内如果还有数据到来,则继续之前的状态进行计算。
    • 使用于datadream、SQL
  • SideOutput(侧输出流)
    • 这种方式需要对迟到数据打一个tag,然后在Datadream上根据这个tag获取迟到数据流,然后业务层面自行选择进行处理。
    • 适用于datadream

image.png

3.5 增量计算VS全量计算

  • 增量计算:
    • 每条数据到来,直接进行计算,window只存储计算结果。比如计算sum,状态中只需要存储sum的结果,不需要保存每条数据。
    • 典型的reduce、aggregate等函数都是增量计算
    • SQL中的聚合只有增量计算

image.png

  • 全量计算:
    • 每条数据到来,会存储到window的state中,等到window触发计算的时候,将所有数据拿出来一起计算。
    • 典型的process函数就是全量计算

image.png

3.6 EMIT触发

上面讲到,正常的窗口都是窗口结束的时候才会进行输出,比如一个1天的窗口,只有到每天结束的时候,窗口的结果才会输出。这种情况下就失去了实时计算的意义了。

那么EMIT触发就是在这种情况下,可以提前把窗口内容输出出来的一种机制。比如我们可以配置一个1天的窗口,每隔5s输出一次它的最新结果,那这样下游就可以更快的获取到窗口计算的结果了。

这个功能只在SQL中,如果是在DataStream中需要完成类似的功能,需要自己定义一些trigger来做。

上节课中,有讲到retract机制,这里需要提一下,这种emit的场景就是一个典型的retract的场景,发送的结果类似于+[1], -[1], +[2], -[2], +[4]这样子。这样才能保证window的输出的最终结果是符合语义的。

Trigger的结果可以是:

  • CONTINUE
  • FIRE(触发计算,但是不清理)
  • PURGE
  • FIRE_AND_PURGE

SQL也可以使用,通过配置:

  • table.exec.emit.early-fire.enabled = true
  • table.exec.emit.early-fire.delay = {time}

3.7 window高级优化

以下说的所有的高级优化,都只限于在SQL中的window中才有。在DataStream中,用户需要自己通过代码来实现类似的能力。

3.7.1 Mini-batch 优化

一般来讲,Flink的状态比较大一些都推荐使用rocksdb statebackend,这种情况下,每次的状态访问就都需要做一次序列化和反序列化,这种开销还是挺大的。为了降低这种开销,我们可以通过降低状态访问频率的方式来解决,这就是mini-batch最主要解决的问题:即赞一小批数据再进行计算,这批数据每个key的state访问只有一次,这样在单个key的数据比较集中的情况下,对于状态访问可以有效的降低频率,最终提升性能。

这个优化主要是适用于没有窗口的聚合场景,字节内部也扩展了window来支持mini-batch,在某些场景下的测试结果可以节省20-30%的CPU开销。

mini-batch看似简单,实际上设计非常巧妙。假设用最简单的方式实现,那就是每个算子内部自己进行攒一个小的batch,这样的话,如果上下游串联的算子比较多,任务整体的延迟就不是很容易控制。所以真正的mini-batch实现,是复用了底层的watermark传输机制,通过watermark事件来作为mini-batch划分的依据,这样整个任务中不管串联的多少个算子,整个任务的延迟都是一样的,就是用户配置的delay时间。

下面这张图展示的是普通的聚合算子的mini-batch原理,window的mini-batch原理是一样的。

3.7.2 Local-global

local-global优化是分布式系统中典型的优化,主要是可以降低数据shuffle的量,同时也可以缓解数据的倾斜。

所谓的local-global,就是将原本的聚合划分成两阶段,第一阶段先做一个local的聚合,这个阶段不需要数据shuffle,是直接跟在上游算子之后进行处理的;第二个阶段是要对第一个阶段的结果做一个merge(还记得上面说的session window的merge么,这里要求是一样的。如果存在没有实现merge的聚合函数,那么这个优化就不会生效)。

如下图所示,比如是要对数据做一个sum,同样颜色的数据表示相同的group by的key,这样我们可以再local agg阶段对他们做一个预聚合;然后到了global阶段数据倾斜就消除了。

3.7.3 Distinct状态复用

对于distinct的优化,一般批里面的引擎都是通过把它优化成aggregate的方式来处理,但是在流式window中,我们不能直接这样进行优化,要不然算子就变成会下发retract的数据了。所以在流式中,对于count distinct这种情况,我们是需要保存所有数据是否出现过这样子的一个映射。

在SQL中,我们有一种方式可以在聚合函数上添加一些filter,如下面的SQL所示:

像这种情况,我们会对同一个字段用不同的filter来进行count distinct的计算。如果每个指标都单独用一个map来记录每条数据是否出现过,那状态量是很大的。

我们可以把相同字段的distinct计算用一个map的key来存储,在map的value中,用一个bit vector来实现就可以把各个状态复用到一起了。比如一个bigint有64位,可以表示同一个字段的64个filter,这样整体状态量就可以节省很多了。

3.7.4 滑动窗口pane复用

image.png 滑动窗口如上面所述,一条数据可能会属于多个window。所以这种情况下同一个key下的window数量可能会比较多,比如3个小时的窗口,1小时的滑动的话,每条数据到来会直接对着3个窗口进行计算和更新。这样对于状态访问频率是比较高的,而且计算量也会增加很多。

优化方法就是,将窗口的状态划分成更小粒度的pane,比如上面3小时窗口、1小时滑动的情况,可以把pane设置为1h,这样每来一条数据,我们就只更新这条数据对应的pane的结果就可以了。当窗口需要输出结果的时候,只需要将这个窗口对应的pane的结果merge起来就可以了。

注意:这里也是需要所有聚合函数都有merge的实现的

3.8 小结

  • Mini-batch 优化解决频繁访问状态的问题
  • local-global 优化解决倾斜问题
  • Distinct状态复用降低状态量
  • Pane 优化降低滑动窗口的状态存储量