窗口

对于事件时间和水位线,它们又有什么具体应用呢?窗口聚合计算

对于基本的聚合操作,流数据是连续不断的,不可能等到所有的数据到齐了才开始处理;所以更多的是按窗口进行聚合

具体地,窗口是一个左闭右开区间,

但是由于分布式会产生乱序数据,所以实际上窗口其实并不是一个“框”,而是把流切割成有限大小的多个“存储桶”:每个数据都会分发到对应的桶中,当到达结束时间时,就对每个桶中收集的数据进行计算处理

窗口


窗口的分类

1. 按照驱动类型分类

即窗口是以什么标准 来开始和结束数据的截取

时间窗口

“定时发车”

private final long start;
private final long end;

public long maxTimestamp() {
	return end - 1;
}

计数窗口

“满座发车”


2. 按照窗口分配数据的规则分类

定义更加精细的规则,来控制数据应该划分到哪个窗口中去

滚动窗口

窗口间首尾衔接,即窗口进行翻滚

当固定了窗口大小之后,所有分区的窗口划分都是一致的;窗口没有重叠,每个数据只属于一个窗口

滑动窗口

过来就是一个滑铲,它有一个滑动步长,到达指定步长后就重新统计

如果步长为窗口大小的 1/n,那么重叠的窗口会有 n 个

会话窗口

会话窗口之间一定是不会重叠的,而且会留有至少为 size 的间隔

全局窗口

Flink 中的计数窗口(Count Window),底层就是用全局窗口实现的


窗口 API

1. 按键分区(Keyed)和非按键分区(Non-Keyed)

即在调用窗口算子之前,是否有keyBy操作

按键分区

经过按键分区 keyBy 操作后,数据流会按照 key 被分为多条逻辑流(logical streams),这就是 KeyedStream。基于 KeyedStream 进行窗口操作时, 窗口计算会在多个并行子任务上同时执行。相同 key 的数据会被发送到同一个并行子任务,而窗口操作会基于每个 key 进行单独的处理。所以可以认为,每个 key 上都定义了一组窗口,各自独立地进行统计计算

stream.keyBy(...).window(...)

非按键分区

如果没有进行 keyBy,那么原始的 DataStream 就不会分成多条逻辑流。这时窗口逻辑只能在一个任务( task)上执行,就相当于并行度变成了 1。所以在实际应用中一般不推荐使用这种方式

stream.windowAll(...)

这里需要注意的是,对于非按键分区的窗口操作,手动调大窗口算子的并行度也是无效的,windowAll 本身就是一个非并行的操作


2. 代码中窗口 API 的调用

stream.keyBy(<key selector>)
	  .window(<window assigner>)  //需要传入一个窗口分配器,它指明了窗口的类型
	  .aggregate(<window function>)  //传入一个窗口函数作为参数,它用来定义窗口具体的处理逻辑

如果想要实现非按键分区窗口,只要前面不做 keyBy,后面调用.window()时直接换成.windowAll()就可以了

窗口分配器

窗口按照驱动类型可以分成时间窗口和计数窗口,而按照具体的分配规则,又有滚动窗口、滑动窗口、会话窗口、全局窗口四种

除去需要自定义的全局窗口外,其他常用的类型 Flink中都给出了内置的分配器实现,我们可以方便地调用实现各种需求

  1. 滚动处理时间窗口

    stream.keyBy(...)
    	  .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
    	  .aggregate(...)
    
  2. 滑动处理时间窗口

    stream.keyBy(...)
    	  .window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5)))
    	  .aggregate(...)
    
  3. 处理时间会话窗口

    stream.keyBy(...)
    	  .window(ProcessingTimeSessionWindows.withGap(Time.seconds(10)))
    	  .aggregate(...)
    
  4. 滚动事件时间窗口

    stream.keyBy(...)
    	  .window(TumblingEventTimeWindows.of(Time.seconds(5)))
    	  .aggregate(...)
    
  5. 滑动事件时间窗口

    stream.keyBy(...)
    	  .window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
    	  .aggregate(...)
    
  6. 事件时间会话窗口

    stream.keyBy(...)
    	  .window(EventTimeSessionWindows.withGap(Time.seconds(10)))
    	  .aggregate(...)
    

窗口函数

窗口函数

增量聚合函数

public interface AggregateFunction<IN, ACC, OUT> extends Function, Serializable
{
    ACC createAccumulator();
    ACC add(IN value, ACC accumulator);
    OUT getResult(ACC accumulator);
    ACC merge(ACC a, ACC b);
}

AggregateFunction 可以看作是 ReduceFunction 的通用版本,这里有三种类型:输入类型(IN)、累加器类型(ACC)和输出类型(OUT)。 输入类型 IN 就是输入流中元素的数据类型;累加器类型 ACC 则是我们进行聚合的中间状态类型;而输出类型当然就是最终计算结果的类型了

接口中有四个方法:

  • createAccumulator()

    创建一个累加器,这就是为聚合创建了一个初始状态,每个聚合任务只会调用一次

  • add()

    将输入的元素添加到累加器中。这就是基于聚合状态,对新来的数据进行进一步聚合的过程。方法传入两个参数:当前新到的数据 value,和当前的累加器
    accumulator;返回一个新的累加器值,也就是对聚合状态进行更新。每条数据到来之后都会调用这个方法

  • getResult()

    从累加器中提取聚合的输出结果。也就是说,我们可以定义多个状态,然后再基于这些聚合的状态计算出一个结果进行输出。比如之前我们提到的计算平均
    值,就可以把 sum 和 count 作为状态放入累加器,而在调用这个方法时相除得到最终结果。这个方法只在窗口要输出结果时调用

  • merge()

    合并两个累加器,并将合并后的状态作为一个累加器返回。这个方法只在需要合并窗口的场景下才会被调用;最常见的合并窗口(Merging Window)的场景
    就是会话窗口(Session Windows)


全窗口函数

全窗口函数也有两种: WindowFunction 和 ProcessWindowFunction

WindowFunction

stream
    .keyBy(<key selector>)
    .window(<window assigner>)
    .apply(new MyWindowFunction());
    
public interface WindowFunction<IN, OUT, KEY, W extends Window> extends Function,Serializable {
    void apply(KEY key, W window, Iterable<IN> input, Collector<OUT> out) throws Exception;
}

当窗口到达结束时间需要触发计算时,就会调用这里的 apply 方法。我们可以从 input 集合中取出窗口收集的数据,结合 key 和 window 信息,通过收集器(Collector)输出结果。这里 Collector 的用法,与 FlatMapFunction 中相同

不过我们也看到了, WindowFunction 能提供的上下文信息较少,也没有更高级的功能。事实上,它的作用可以被 ProcessWindowFunction 全覆盖,所以之后可能会逐渐弃用。一般在实际应用,直接使用 ProcessWindowFunction 就可以了

ProcessWindowFunction

除了可以拿到窗口中的所有数据之外, ProcessWindowFunction 还可以获取到一个“上下文对象”(Context)。这个上下文对象非常强大,不仅能够获取窗口信息,还可以访问当前的时间和状态信息。这里的时间就包括了处理时间(processing time)和事件时间水位线( event time watermark)

这就使得 ProcessWindowFunction 更加灵活、功能更加丰富。事实上,ProcessWindowFunction 是 Flink 底层 API—处理函数( process function)中的一员

全窗口函数因为运行效率较低,很少直接单独使用,往往会和增量聚合函数结合在一起,共同实现窗口的处理计算

调用的处理机制是:基于第一个参数(增量聚合函数)来处理窗口数据,每来一个数据就做一次聚合;等到窗口需要触发计算时,则调用第二个参数(全窗口函数)的处理逻辑输出结果

// ReduceFunction 与 WindowFunction 结合
public <R> SingleOutputStreamOperator<R> reduce(ReduceFunction<T> reduceFunction, WindowFunction<T, R, K, W> function)

// ReduceFunction 与 ProcessWindowFunction 结合
public <R> SingleOutputStreamOperator<R> reduce(ReduceFunction<T> reduceFunction, ProcessWindowFunction<T, R, K, W> function)

// AggregateFunction 与 WindowFunction 结合
public <ACC, V, R> SingleOutputStreamOperator<R> aggregate(AggregateFunction<T, ACC, V> aggFunction, WindowFunction<V, R, K, W>
windowFunction)

// AggregateFunction 与 ProcessWindowFunction 结合
public <ACC, V, R> SingleOutputStreamOperator<R> aggregate(AggregateFunction<T, ACC, V> aggFunction,ProcessWindowFunction<V, R, K, W> windowFunction)

其他 API

1. 触发器(Trigger)

触发器主要是用来控制窗口什么时候触发计算

stream.keyBy(...)
      .window(...)
      .trigger(new MyTrigger())

Trigger 是窗口算子的内部属性,每个窗口分配器(WindowAssigner)都会对应一个默认的触发器;对于 Flink 内置的窗口类型,它们的触发器都已经做了实现。例如,所有事件时间窗口,默认的触发器都是 EventTimeTrigger;类似还有 ProcessingTimeTrigger 和 CountTrigger

所以一般情况下是不需要自定义触发器的,不过我们依然有必要了解它的原理

Trigger 是一个抽象类,自定义时必须实现下面四个抽象方法:

  • onElement():窗口中每到来一个元素,都会调用这个方法
  • onEventTime():当注册的事件时间定时器触发时,将调用这个方法
  • onProcessingTime ():当注册的处理时间定时器触发时,将调用这个方法
  • clear():当窗口关闭销毁时,调用这个方法。一般用来清除自定义的状态

2. 移除器(Evictor)

移除器主要用来定义移除某些数据的逻辑

3. 允许延迟(Allowed Lateness)

直到水位线推进到了 窗口结束时间 + 延迟时间,才真正将窗口的内容清空,正式关闭窗口

stream.keyBy(...)
      .window(TumblingEventTimeWindows.of(Time.hours(1)))
      .allowedLateness(Time.minutes(1))

举例:赶早班车时,司机发车时没有看见跑来的你,起步后才看见,那么司机会慢慢停下来,等你跑上车后,再关门

4. 将迟到的数据放入侧输出流

我们自然会想到,即使可以设置窗口的延迟时间,终归还是有限的,后续的数据还是会被丢弃。如果不想丢弃任何一个数据,又该怎么做呢?

Flink 还提供了另外一种方式处理迟到数据。我们可以将未收入窗口的迟到数据,放入“侧输出流”(side output)进行另外的处理。所谓的侧输出流,相当于是数据流的一个“分支”,这个流中单独放置那些错过了该上的车、本该被丢弃的数据

基于 WindowedStream 调用.sideOutputLateData() 方法,就可以实现这个功能。方法需要传入一个“输出标签”(OutputTag),用来标记分支的迟到数据流。因为保存的就是流中的原始数据,所以 OutputTag 的类型与流中数据类型相同

DataStream<Event> stream = env.addSource(...);
OutputTag<Event> outputTag = new OutputTag<Event>("late") {};
stream.keyBy(...)
      .window(TumblingEventTimeWindows.of(Time.hours(1)))
      .sideOutputLateData(outputTag)

将迟到数据放入侧输出流之后,还应该可以将它提取出来。基于窗口处理完成之后的DataStream,调用.getSideOutput()方法,传入对应的输出标签,就可以获取到迟到数据所在的流了

SingleOutputStreamOperator<AggResult> winAggStream = stream.keyBy(...)
                                                           .window(TumblingEventTimeWindows.of(Time.hours(1)))
                                                           .sideOutputLateData(outputTag)
                                                           .aggregate(new MyAggregateFunction())
DataStream<Event> lateStream = winAggStream.getSideOutput(outputTag);

这里注意, getSideOutput()是 SingleOutputStreamOperator 的方法,获取到的侧输出流数据类型应该和 OutputTag 指定的类型一致,与窗口聚合之后流中的数据类型可以不同


迟到数据的处理

1. 设置水位线延迟时间

当我们设置了水位线延迟时间后,所有定时器就都会按照延迟后的水位线来触发。如果一个数据所包含的时间戳,小于当前的水位线,那么它就是所谓的“迟到数据”

2. 允许窗口处理迟到数据

这种情况下,由于大部分乱序数据已经被水位线的延迟等到了,所以往往迟到的数据不会太多。这样,我们会在水位线到达窗口结束时间时,先快速地输出一个近似正确的计算结果;然后保持窗口继续等到延迟数据,每来一条数据,窗口就会再次计算, 并将更新后的结果输出。这样就可以逐步修正计算结果,最终得到准确的统计值了

所以我们将水位线的延迟和窗口的允许延迟数据结合起来,最后的效果就是先快速实时地输出一个近似的结果,而后再不断调整,最终得到正确的计算结果

回想流处理的发展过程,这不就是著名的 Lambda 架构吗?原先需要两套独立的系统来同时保证实时性和结果的最终正确性,如今 Flink 一套系统就全部搞定了

3. 将迟到数据放入窗口侧输出流

即使我们有了前面的双重保证,可窗口不能一直等下去,最后总要真正关闭。窗口一旦关闭,后续的数据就都要被丢弃了

那如果真的还有漏网之鱼又该怎么办呢?

那就要用到最后一招了:用窗口的侧输出流来收集关窗以后的迟到数据。这种方式是最后“兜底”的方法,只能保证数据不丢失;因为窗口已经真正关闭,所以是无法基于之前窗口的结果直接做更新的

我们只能将之前的窗口计算结果保存下来,然后获取侧输出流中的迟到数据,判断数据所属的窗口,手动对结果进行合并更新。尽管有些烦琐,实时性也不够强,但能够保证最终结果一定是正确的

如果还用赶早班车来类比,那就是车已经开走了,这班车是肯定赶不上了。不过我们打车或者步行到学校

所以总结起来, Flink 处理迟到数据,对于结果的正确性有三重保障:水位线的延迟,窗口允许迟到数据,以及将迟到数据放入窗口侧输出流

posted @ 2022-11-03 23:43  黄一洋  阅读(21)  评论(0)    收藏  举报