(十)Flink Datastream API 编程指南 算子-2 Windows窗口计算

窗口是处理无限流的核心。Windows将流分成有限大小的“桶”,我们可以在桶上应用计算。本文主要讨论如何在Flink中执行窗口操作,以及程序员如何最大限度地利用Flink提供的功能。

一个带窗口的Flink程序的总体结构如下所示。第一个片段引用键控流,第二个片段引用非键控流。可以看到,唯一的区别是键控流的keyBy(…)调用和非键控流的window(…)调用,后者变成windowall(…)。这也将作为页面其余部分的路线图。

Keyed Windows

stream
       .keyBy(...)               <-  keyed versus non-keyed windows
       .window(...)              <-  required: "assigner"
      [.trigger(...)]            <-  optional: "trigger" (else default trigger)
      [.evictor(...)]            <-  optional: "evictor" (else no evictor)
      [.allowedLateness(...)]    <-  optional: "lateness" (else zero)
      [.sideOutputLateData(...)] <-  optional: "output tag" (else no side output for late data)
       .reduce/aggregate/apply()      <-  required: "function"
      [.getSideOutput(...)]      <-  optional: "output tag"

Non-Keyed Windows

stream
       .windowAll(...)           <-  required: "assigner"
      [.trigger(...)]            <-  optional: "trigger" (else default trigger)
      [.evictor(...)]            <-  optional: "evictor" (else no evictor)
      [.allowedLateness(...)]    <-  optional: "lateness" (else zero)
      [.sideOutputLateData(...)] <-  optional: "output tag" (else no side output for late data)
       .reduce/aggregate/apply()      <-  required: "function"
      [.getSideOutput(...)]      <-  optional: "output tag"

上面的方括号([…])中的命令是可选的。这表明,Flink允许您以多种不同的方式定制窗口逻辑,以使其最适合您的需求。

窗口的生命周期

简而言之,当应该属于该窗口的第一个元素到达时,就会立即创建窗口,当时间(事件或处理时间)超过它的结束时间戳加上用户指定的允许延迟(参见允许延迟Allowed Lateness)时,该窗口将被完全删除。Flink保证只删除基于时间的窗口,而不删除其他类型的窗口,例如全局窗口(参见窗口分配器Window Assigners)。例如,event-time-based窗口策略创建重叠(暴跌)窗户每5分钟,有一个允许迟到1分钟,Flink将创建一个新窗口为12点之间的间隔和12:05当第一个元素和一个时间戳,在这个区间内,当水印经过12:06的时间戳时就会被删除。

此外,每个窗口将有一个触发器(参见触发器Triggers)和一个函数(ProcessWindowFunction, ReduceFunction,或AggregateFunction)(参见窗口函数Window Functions)附加到它。该函数将包含应用于窗口内容的计算,而Trigger指定窗口被认为已经准备好应用函数的条件。触发策略可以是“当窗口中的元素数量超过4时”,或者“当水印经过窗口末尾时”。触发器还可以决定在创建和删除窗口之间的任何时间清除窗口内容。在这种情况下,清除只涉及窗口中的元素,而不是窗口元数据。这意味着仍然可以添加新的数据。

除了上面提到的,您还可以指定一个驱逐者(请参阅剔除器Evictors),它将能够在触发器触发之后以及在应用函数之前和/或之后从窗口中删除元素。

在下面的内容中,我们将对上面的每个组件进行更详细的介绍。在转向可选部分之前,我们先从上述代码片段中的必要部分开始(参见键与非键窗口Keyed vs Non-Keyed Windows、窗口分配器Window Assigners和窗口函数Window Functions)。

Keyed vs Non-Keyed Windows

要指定的第一件事是您的流是否应该设置键。这必须在定义窗口之前完成。使用keyBy(…)将把无限流分成逻辑键控流。如果没有调用keyBy(…),那么您的流就没有键化。

在键控流的情况下,任何传入事件的属性都可以用作键(这里有更多详细信息)。拥有一个键控流将允许您的窗口计算由多个任务并行执行,因为每个逻辑键控流可以独立于其他流进行处理。所有指向相同键的元素将被发送到相同的并行任务。

在非键流的情况下,你的原始流将不会被拆分为多个逻辑流,所有窗口逻辑将由单个任务执行,即并行度为1。

Window Assigners

在指定流是否为键值后,下一步是定义一个窗口分配器(window assigner)。窗口分配器程序定义了如何将元素分配给窗口。这是通过在window(…)(对于键控流)或windowwall()(对于非键控流)调用中指定你选择的WindowAssigner来完成的。

WindowAssigner负责将每个传入的元素分配给一个或多个窗口。Flink为最常见的用例提供了预定义的WindowAssigner,即翻转窗口、滑动窗口、会话窗口和全局窗口。您还可以通过扩展WindowAssigner类来实现自定义窗口指派器。所有内置的窗口分配器(全局窗口除外)都是基于时间将元素分配给窗口的,时间可以是处理时间,也可以是事件时间。请查看我们关于事件时间的部分,了解处理时间和事件时间之间的差异,以及时间戳和水印是如何生成的。

基于时间的窗口有一个开始时间戳(包含)和一个结束时间戳(独占),它们一起描述窗口的大小。在代码中,Flink在处理基于时间的窗口时使用TimeWindow,该窗口有查询开始时间戳和结束时间戳的方法,还有一个额外的方法maxTimestamp(),该方法返回给定窗口允许的最大时间戳。

在下面的文章中,我们将展示Flink预定义的窗口分配器是如何工作的,以及如何在DataStream程序中使用它们。下图可视化了每个分配器的工作方式。紫色的圆圈表示流的元素,它们被一些键(在本例中是用户1、用户2和用户3)划分。x轴表示时间的进度。在下面的文章中,我们将展示Flink预定义的窗口分配器是如何工作的,以及如何在DataStream程序中使用它们。下图可视化了每个分配器的工作方式。紫色的圆圈表示流的元素,它们被一些键(在本例中是用户1、用户2和用户3)划分。x轴表示时间的进度。

Tumbling Windows

Tumbling Windows分配器将每个元素分配给指定窗口大小的窗口。Tumbling Windows有一个固定的大小和不重叠。例如,如果您指定一个翻滚窗口的大小为5分钟,则当前窗口将被评估,并将每5分钟启动一个新窗口,如下图所示。

在这里插入图片描述

下面的代码片段展示了如何使用翻滚窗口:

DataStream<T> input = ...;

// tumbling event-time windows
input
    .keyBy(<key selector>)
    .window(TumblingEventTimeWindows.of(Time.seconds(5)))
    .<windowed transformation>(<window function>);

// tumbling processing-time windows
input
    .keyBy(<key selector>)
    .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
    .<windowed transformation>(<window function>);

// daily tumbling event-time windows offset by -8 hours.
input
    .keyBy(<key selector>)
    .window(TumblingEventTimeWindows.of(Time.days(1), Time.hours(-8)))
    .<windowed transformation>(<window function>);

具体实现如下:

package com.flink.datastream.windows;

import com.flink.datastream.entity.Student;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.functions.ReduceFunction;
import org.apache.flink.streaming.api.TimeCharacteristic;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.assigners.TumblingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;

import java.time.Duration;
import java.time.LocalDateTime;

/**
 * @author DeveloperZJQ
 * @since 2022-6-1
 */
public class TumblingWindowsInfo {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        String ip = "localhost";
        if (args.length > 0) {
            ip = args[0];
        }

        DataStreamSource<String> rowData = env.socketTextStream(ip, 9999);

        SingleOutputStreamOperator<Student> map = rowData
                .map((MapFunction<String, Student>) s -> {
                    String[] words = s.split(" ");
                    if (words.length != 4) {
                        return null;
                    }
                    return new Student(Integer.parseInt(words[0]), words[1], Double.parseDouble(words[2]), System.currentTimeMillis());
                });

        // 指定事件时间戳
        SingleOutputStreamOperator<Student> watermarks = map.assignTimestampsAndWatermarks(WatermarkStrategy.<Student>forBoundedOutOfOrderness(Duration.ofSeconds(20))
                .withTimestampAssigner((event, timestamp) -> event.getUnixTime()));

        //事件时间滚动窗口
        SingleOutputStreamOperator<Student> evenTimeReduce = watermarks
                .keyBy(Student::getId)
                .window(TumblingEventTimeWindows.of(Time.seconds(5)))
                .reduce((stu, t1) -> new Student(stu.getId(), stu.getName(), stu.getScore() + t1.getScore(), stu.getUnixTime()));

        //处理时间滚动窗口
        SingleOutputStreamOperator<Student> processTimeReduce = map.keyBy(Student::getId)
                .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
                .reduce((s1, s2) -> new Student(s1.getId(), s1.getName(), s1.getScore() + s2.getScore(), s1.getUnixTime()));

        //事件时间偏移量滚动窗口
        SingleOutputStreamOperator<Student> evenTimeOffset8Reduce = watermarks
                .keyBy(Student::getId)
                .window(TumblingEventTimeWindows.of(Time.seconds(5), Time.hours(-8)))
                .reduce((stu, t1) -> new Student(stu.getId(), stu.getName(), stu.getScore() + t1.getScore(), stu.getUnixTime()));

        evenTimeReduce.print();
        processTimeReduce.print();
        evenTimeOffset8Reduce.print();
 
        env.execute();
    }
}

时间间隔可以通过使用Time.milliseconds(x)、Time.seconds(x)、Time.minutes(x)等中的任意一个来指定。
如上一个示例所示,tumbling window assigners还接受一个可选的偏移参数,可用于更改窗口的对齐方式。例如,在没有偏移的情况下,每小时翻滚的窗口与epoch对齐,也就是说,你将得到诸如1:00:00.000 - 1:59:59.999,2:00:00.000 - 2:59:59.999等窗口。如果你想改变它,你可以给一个补偿。用15分钟的偏移量,你可以得到1:15:00.000 - 2:14:59.999,2:15:00.000 - 3:14:59.999等等。偏移量的一个重要用例是将窗口调整为UTC-0以外的时区。例如,在中国,您必须指定Time.hours(-8)的偏移量。

Sliding Windows

sliding windows assigner将元素赋值给固定长度的窗口。与翻滚窗口分配器类似,窗口的大小由窗口大小参数配置。另一个窗口滑动参数控制滑动窗口启动的频率。因此,如果滑动窗口小于窗口大小,则滑动窗口可以重叠。在这种情况下,元素被分配给多个窗口。

例如,您可以有一个10分钟大小的窗口,它可以滑动5分钟。这样,每隔5分钟就会得到一个窗口,其中包含下图所示的在过去10分钟内到达的事件。

在这里插入图片描述
下面的代码片段展示了如何使用滑动窗口。

DataStream<T> input = ...;

// sliding event-time windows
input
    .keyBy(<key selector>)
    .window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
    .<windowed transformation>(<window function>);

// sliding processing-time windows
input
    .keyBy(<key selector>)
    .window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5)))
    .<windowed transformation>(<window function>);

// sliding processing-time windows offset by -8 hours
input
    .keyBy(<key selector>)
    .window(SlidingProcessingTimeWindows.of(Time.hours(12), Time.hours(1), Time.hours(-8)))
    .<windowed transformation>(<window function>);

时间间隔可以通过使用Time.milliseconds(x)、Time.seconds(x)、Time.minutes(x)等中的任意一个来指定。
如上一个示例所示,滑动窗口指派器还接受一个可选的偏移参数,该参数可用于更改窗口的对齐方式。例如,如果没有偏移,每小时滑动30分钟的窗口将与epoch对齐,也就是说,您将得到1:00:00.000 - 1:59:59.999,1:30:00.000 - 2:29:59.999等窗口。如果你想改变它,你可以给一个补偿。用15分钟的偏移量,你可以得到1:15:00.000 - 2:14:59.999,1:45:00.000 - 2:44:59.999等等。偏移量的一个重要用例是将窗口调整为UTC-0以外的时区。例如,在中国必须采用Time.hours(-8)。

Session Windows

会话窗口指派器按活动的会话对元素进行分组。与翻滚窗口和滑动窗口相比,会话窗口不重叠,也没有固定的开始和结束时间。相反,当会话窗口在一段时间内没有接收到元素时,即当不活动的间隙发生时,会话窗口将关闭。会话窗口赋值器可以配置为静态会话间隙,也可以配置为会话间隙提取器函数,该函数定义不活动的时间长度。当这个时间段结束时,当前会话关闭,随后的元素被分配给一个新的会话窗口。

在这里插入图片描述
下面的代码片段展示了如何使用会话窗口:

DataStream<T> input = ...;

// event-time session windows with static gap
input
    .keyBy(<key selector>)
    .window(EventTimeSessionWindows.withGap(Time.minutes(10)))
    .<windowed transformation>(<window function>);
    
// event-time session windows with dynamic gap
input
    .keyBy(<key selector>)
    .window(EventTimeSessionWindows.withDynamicGap((element) -> {
        // determine and return session gap
    }))
    .<windowed transformation>(<window function>);

// processing-time session windows with static gap
input
    .keyBy(<key selector>)
    .window(ProcessingTimeSessionWindows.withGap(Time.minutes(10)))
    .<windowed transformation>(<window function>);
    
// processing-time session windows with dynamic gap
input
    .keyBy(<key selector>)
    .window(ProcessingTimeSessionWindows.withDynamicGap((element) -> {
        // determine and return session gap
    }))
    .<windowed transformation>(<window function>);

静态间隙可以通过使用Time.milliseconds(x)、Time.seconds(x)、Time.minutes(x)等中的一个来指定。
动态间隙通过实现SessionWindowTimeGapExtractor接口来指定。

由于会话窗口没有固定的开始和结束,因此它们的评估方法与翻滚和滑动窗口不同。在内部,会话窗口操作符为每个到达的记录创建一个新窗口,如果窗口之间的距离比定义的间隔更近,则将它们合并在一起。为了可合并,会话窗口操作符需要一个合并触发器和一个合并窗口函数,如ReduceFunction、AggregateFunction或ProcessWindowFunction。

Global Windows

全局窗口分配器将具有相同键的所有元素赋值给同一个全局窗口。只有当您还指定了自定义触发器时,这个窗口方案才有用。否则,将不会执行计算,因为全局窗口没有一个可以处理聚合元素的自然终点。
在这里插入图片描述
下面的代码片段展示了如何使用全局窗口。

DataStream<T> input = ...;

input
    .keyBy(<key selector>)
    .window(GlobalWindows.create())
    .<windowed transformation>(<window function>);

具体实现:

package com.flink.datastream.windows;

import com.flink.datastream.entity.Student;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.GlobalWindows;

/**
 * @author DeveloperZJQ
 * @since 2022-6-5
 */
public class WindowAll {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        String ip = "127.0.0.1";
        if (args.length != 0) {
            ip = args[0];
        }
        DataStreamSource<String> source = env.socketTextStream(ip, 9999);
        SingleOutputStreamOperator<Student> map = source.map(one -> {
            String[] s = one.split(" ");
            if (s.length != 4) {
                return null;
            }
            return new Student(Integer.parseInt(s[0]), s[1], Double.parseDouble(s[2]), Long.parseLong(s[3]));
        });

        SingleOutputStreamOperator<Student> reduce = map
                .keyBy(Student::getId)
                .window(GlobalWindows.create())
                .reduce((r1, r2) -> new Student(r1.getId(), r1.getName(), r1.getScore() + r2.getScore(), Math.max(r1.getUnixTime(), r2.getUnixTime())));

        reduce.print();
        env.execute();
    }
}

Window Functions

定义窗口分配器之后,我们需要指定希望对每个窗口执行的计算。这是window函数的职责,它用于在系统确定一个窗口已经准备好处理时处理每个(可能是键控的)窗口的元素(参见触发器了解Flink如何确定一个窗口何时准备好)。

window函数可以是ReduceFunction、AggregateFunction或ProcessWindowFunction中的一种。前两个可以更有效地执行(请参阅状态大小一节),因为Flink可以在每个窗口的元素到达时增量地聚合它们。ProcessWindowFunction获取包含在窗口中的所有元素的Iterable,以及关于元素所属窗口的附加元信息。

带有ProcessWindowFunction的窗口转换不能像其他情况那样有效地执行,因为Flink在调用函数之前必须在内部缓冲窗口的所有元素。这可以通过将ProcessWindowFunction与ReduceFunction或AggregateFunction组合在一起来缓解,以获得窗口元素和ProcessWindowFunction接收到的额外窗口元数据的增量聚合。我们将看到每种变体的示例。

ReduceFunction

ReduceFunction指定如何组合输入中的两个元素以产生相同类型的输出元素。Flink使用ReduceFunction递增地聚合窗口的元素。

ReduceFunction可以这样定义和使用:

DataStream<Tuple2<String, Long>> input = ...;

input
    .keyBy(<key selector>)
    .window(<window assigner>)
    .reduce(new ReduceFunction<Tuple2<String, Long>>() {
      public Tuple2<String, Long> reduce(Tuple2<String, Long> v1, Tuple2<String, Long> v2) {
        return new Tuple2<>(v1.f0, v1.f1 + v2.f1);
      }
    });

上面的例子汇总了窗口中所有元素的元组的第二个字段。

AggregateFunction

AggregateFunction是ReduceFunction的通用版本,它有三种类型:输入类型(IN)、累加器类型(ACC)和输出类型(OUT)。输入类型是输入流中的元素类型,AggregateFunction有一个向累加器添加一个输入元素的方法。该接口还具有创建初始累加器、将两个累加器合并为一个累加器以及从累加器提取输出(类型为OUT)的方法。我们将在下面的例子中看到它是如何工作的。

与ReduceFunction相同,Flink将在窗口的输入元素到达时增量地聚合它们。

AggregateFunction可以这样定义和使用:

/**
 * The accumulator is used to keep a running sum and a count. The {@code getResult} method
 * computes the average.
 */
private static class AverageAggregate
    implements AggregateFunction<Tuple2<String, Long>, Tuple2<Long, Long>, Double> {
  @Override
  public Tuple2<Long, Long> createAccumulator() {
    return new Tuple2<>(0L, 0L);
  }

  @Override
  public Tuple2<Long, Long> add(Tuple2<String, Long> value, Tuple2<Long, Long> accumulator) {
    return new Tuple2<>(accumulator.f0 + value.f1, accumulator.f1 + 1L);
  }

  @Override
  public Double getResult(Tuple2<Long, Long> accumulator) {
    return ((double) accumulator.f0) / accumulator.f1;
  }

  @Override
  public Tuple2<Long, Long> merge(Tuple2<Long, Long> a, Tuple2<Long, Long> b) {
    return new Tuple2<>(a.f0 + b.f0, a.f1 + b.f1);
  }
}

DataStream<Tuple2<String, Long>> input = ...;

input
    .keyBy(<key selector>)
    .window(<window assigner>)
    .aggregate(new AverageAggregate());

上面的例子计算窗口中元素的第二个字段的平均值。

ProcessWindowFunction

ProcessWindowFunction获得一个包含窗口所有元素的Iterable,以及一个可以访问时间和状态信息的Context对象,这使得它能够提供比其他窗口函数更多的灵活性。这是以性能和资源消耗为代价的,因为元素不能增量地聚合,而是需要在内部进行缓冲,直到认为窗口准备好进行处理。

ProcessWindowFunction的签名如下所示:

public abstract class ProcessWindowFunction<IN, OUT, KEY, W extends Window> implements Function {

    /**
     * Evaluates the window and outputs none or several elements.
     *
     * @param key The key for which this window is evaluated.
     * @param context The context in which the window is being evaluated.
     * @param elements The elements in the window being evaluated.
     * @param out A collector for emitting elements.
     *
     * @throws Exception The function may throw exceptions to fail the program and trigger recovery.
     */
    public abstract void process(
            KEY key,
            Context context,
            Iterable<IN> elements,
            Collector<OUT> out) throws Exception;

   	/**
   	 * The context holding window metadata.
   	 */
   	public abstract class Context implements java.io.Serializable {
   	    /**
   	     * Returns the window that is being evaluated.
   	     */
   	    public abstract W window();

   	    /** Returns the current processing time. */
   	    public abstract long currentProcessingTime();

   	    /** Returns the current event-time watermark. */
   	    public abstract long currentWatermark();

   	    /**
   	     * State accessor for per-key and per-window state.
   	     *
   	     * <p><b>NOTE:</b>If you use per-window state you have to ensure that you clean it up
   	     * by implementing {@link ProcessWindowFunction#clear(Context)}.
   	     */
   	    public abstract KeyedStateStore windowState();

   	    /**
   	     * State accessor for per-key global state.
   	     */
   	    public abstract KeyedStateStore globalState();
   	}

}

key参数是通过为keyBy()调用指定的KeySelector提取的键。在元索引键或字符串字段引用的情况下,这种键类型总是Tuple,您必须手动将其转换为正确大小的元组来提取关键字段。

ProcessWindowFunction可以这样定义和使用:

DataStream<Tuple2<String, Long>> input = ...;

input
  .keyBy(t -> t.f0)
  .window(TumblingEventTimeWindows.of(Time.minutes(5)))
  .process(new MyProcessWindowFunction());

/* ... */

public class MyProcessWindowFunction 
    extends ProcessWindowFunction<Tuple2<String, Long>, String, String, TimeWindow> {

  @Override
  public void process(String key, Context context, Iterable<Tuple2<String, Long>> input, Collector<String> out) {
    long count = 0;
    for (Tuple2<String, Long> in: input) {
      count++;
    }
    out.collect("Window: " + context.window() + "count: " + count);
  }
}

这个例子展示了一个ProcessWindowFunction,它对窗口中的元素进行计数。另外,window函数将窗口信息添加到输出中。

注意,使用ProcessWindowFunction进行简单的聚合(如count)是非常低效的。下一节将展示ReduceFunction或AggregateFunction如何与ProcessWindowFunction结合,以获得ProcessWindowFunction的增量聚合和添加信息。

ProcessWindowFunction with Incremental Aggregation

ProcessWindowFunction可以与ReduceFunction或AggregateFunction结合使用,在元素到达窗口时增量地聚合它们。当窗口关闭时,ProcessWindowFunction将提供聚合结果。这允许它在访问ProcessWindowFunction的附加窗口元信息的同时增量地计算窗口。

您也可以使用旧的WindowFunction来代替ProcessWindowFunction来进行增量窗口聚合。

Incremental Window Aggregation with ReduceFunction

下面的例子展示了如何将递增的ReduceFunction与ProcessWindowFunction结合起来,以返回窗口中最小的事件以及窗口的开始时间。

DataStream<SensorReading> input = ...;

input
  .keyBy(<key selector>)
  .window(<window assigner>)
  .reduce(new MyReduceFunction(), new MyProcessWindowFunction());

// Function definitions

private static class MyReduceFunction implements ReduceFunction<SensorReading> {

  public SensorReading reduce(SensorReading r1, SensorReading r2) {
      return r1.value() > r2.value() ? r2 : r1;
  }
}

private static class MyProcessWindowFunction
    extends ProcessWindowFunction<SensorReading, Tuple2<Long, SensorReading>, String, TimeWindow> {

  public void process(String key,
                    Context context,
                    Iterable<SensorReading> minReadings,
                    Collector<Tuple2<Long, SensorReading>> out) {
      SensorReading min = minReadings.iterator().next();
      out.collect(new Tuple2<Long, SensorReading>(context.window().getStart(), min));
  }
}

Incremental Window Aggregation with AggregateFunction
下面的例子展示了如何将增量AggregateFunction与ProcessWindowFunction结合起来计算平均值,并同时发出键值和窗口值。

DataStream<Tuple2<String, Long>> input = ...;

input
  .keyBy(<key selector>)
  .window(<window assigner>)
  .aggregate(new AverageAggregate(), new MyProcessWindowFunction());

// Function definitions

/**
 * The accumulator is used to keep a running sum and a count. The {@code getResult} method
 * computes the average.
 */
private static class AverageAggregate
    implements AggregateFunction<Tuple2<String, Long>, Tuple2<Long, Long>, Double> {
  @Override
  public Tuple2<Long, Long> createAccumulator() {
    return new Tuple2<>(0L, 0L);
  }

  @Override
  public Tuple2<Long, Long> add(Tuple2<String, Long> value, Tuple2<Long, Long> accumulator) {
    return new Tuple2<>(accumulator.f0 + value.f1, accumulator.f1 + 1L);
  }
  
 @Override
  public Double getResult(Tuple2<Long, Long> accumulator) {
    return ((double) accumulator.f0) / accumulator.f1;
  }

  @Override
  public Tuple2<Long, Long> merge(Tuple2<Long, Long> a, Tuple2<Long, Long> b) {
    return new Tuple2<>(a.f0 + b.f0, a.f1 + b.f1);
  }
}

private static class MyProcessWindowFunction
    extends ProcessWindowFunction<Double, Tuple2<String, Double>, String, TimeWindow> {

  public void process(String key,
                    Context context,
                    Iterable<Double> averages,
                    Collector<Tuple2<String, Double>> out) {
      Double average = averages.iterator().next();
      out.collect(new Tuple2<>(key, average));
  }
}

在ProcessWindowFunction 中使用per-window state

除了访问键控状态(就像任何富函数一样),ProcessWindowFunction还可以使用作用域为当前函数正在处理的窗口的键控状态。在这种情况下,理解每个窗口状态所引用的窗口是什么是很重要的。涉及到不同的“窗口”:

  • 指定窗口操作时定义的窗口:这可能是1小时的翻滚窗口或滑动1小时的2小时滑动窗口。
  • 给定键的已定义窗口的实际实例:这可能是用户id xyz从12:00到13:00的时间窗口。这是基于窗口定义的,将会有许多窗口基于作业当前正在处理的键的数量和事件落入的时间槽。

每个窗口状态与这两者的后一种绑定。这意味着,如果我们为1000个不同的键处理事件,并且它们的事件当前都属于[12:00,13:00)时间窗口,那么将有1000个窗口实例,每个窗口实例都有自己的每个窗口状态键。

process()调用接收到Context对象上的两个方法,它们允许访问两种类型的状态:

  • globalState(),它允许访问不在窗口范围内的键控状态
  • windowState(),它允许访问作用域为窗口的键控状态

如果您预期同一个窗口会多次触发,那么该特性将很有帮助,因为当您对延迟到达的数据进行延迟触发时,或者当您有一个定制触发器进行投机性的早期触发时,就会发生这种情况。在这种情况下,您需要存储关于以前的触发或每个窗口状态下触发次数的信息。

当使用窗口状态时,清除窗口状态也是很重要的。这应该在clear()方法中发生。

WindowFunction (Legacy)

在某些可以使用ProcessWindowFunction的地方,你也可以使用WindowFunction。这是ProcessWindowFunction的一个旧版本,它提供了较少的上下文信息,并且没有一些先进的特性,比如每个窗口的键控状态。这个接口在某些时候会被弃用。
WindowFunction的签名如下所示:

public interface WindowFunction<IN, OUT, KEY, W extends Window> extends Function, Serializable {

  /**
   * Evaluates the window and outputs none or several elements.
   *
   * @param key The key for which this window is evaluated.
   * @param window The window that is being evaluated.
   * @param input The elements in the window being evaluated.
   * @param out A collector for emitting elements.
   *
   * @throws Exception The function may throw exceptions to fail the program and trigger recovery.
   */
  void apply(KEY key, W window, Iterable<IN> input, Collector<OUT> out) throws Exception;
}

它可以这样使用:

DataStream<Tuple2<String, Long>> input = ...;

input
    .keyBy(<key selector>)
    .window(<window assigner>)
    .apply(new MyWindowFunction());

Triggers

触发器决定窗口(由窗口分配器形成)何时可以由窗口函数处理。每个WindowAssigner都附带一个默认触发器。如果默认触发器不能满足您的需求,您可以使用trigger(…)指定一个自定义触发器。

触发器接口有5个方法,它们允许触发器对不同的事件做出反应:

  • 对添加到窗口的每个元素调用onElement()方法。
  • onEventTime()方法在注册的事件时间计时器触发时被调用。
  • 当触发注册的处理时间计时器时,onProcessingTime()方法被调用。
  • onMerge()方法与有状态触发器相关,当两个触发器对应的窗口合并时,它会合并两个触发器的状态,例如使用会话窗口时。
  • 最后,clear()方法执行删除相应窗口时所需的任何操作。

以上方法需要注意两点:

  1. 前三个函数通过返回TriggerResult来决定如何处理它们的调用事件。该动作可以是以下其中之一:
    • CONTINUE: 继续,不做任何事情
    • FIRE: 触发计算
    • PURGE: 清除窗口中的元素
    • FIRE_AND_PURGE: 触发计算,然后清除窗口中的元素。
  2. 这些方法中的任何一个都可以用来为将来的操作注册处理计时器或事件计时器。

Fire and Purge

一旦触发器确定窗口已准备好进行处理,它就会触发,即返回FIRE或FIRE_AND_PURGE。这是窗口操作符发出当前窗口结果的信号。给定一个带有ProcessWindowFunction的窗口,所有元素都被传递给ProcessWindowFunction(可能在传递给一个驱逐者之后)。带有ReduceFunction或AggregateFunction的窗口只会发出它们的热切聚合结果。

当触发器触发时,它可以是FIRE或FIRE_AND_PURGE。FIRE保留窗口的内容,而FIRE_AND_PURGE删除窗口的内容。默认情况下,预实现的触发器只是FIRE,而不清除窗口状态。

清除将简单地删除窗口的内容,并将保留关于窗口和触发器状态的任何潜在元信息。

WindowAssigners的默认触发器

WindowAssigner的默认触发器适用于许多用例。例如,所有事件时间窗口分配器都有一个EventTimeTrigger作为默认触发器。这个触发器只在水印经过窗口末端时触发。

GlobalWindow的默认触发器是NeverTrigger,它从不触发。因此,在使用GlobalWindow时,您总是必须定义一个自定义触发器。

通过使用trigger()指定一个触发器,你正在覆盖一个WindowAssigner的默认触发器。例如,如果你为TumblingEventTimeWindows指定一个CountTrigger,你将不再获得基于时间进度的窗口触发,但只通过计数。现在,如果您想根据时间和计数做出反应,您必须编写自己的自定义触发器。

内置和自定义触发器

Flink自带几个内置触发器。

  • (已经提到的)EventTimeTrigger根据事件时间的进展(通过水印度量)触发。
  • ProcessingTimeTrigger根据处理时间触发。
  • 一旦窗口中的元素数量超过给定的限制,就会触发CountTrigger。
  • PurgingTrigger将另一个触发器作为参数,并将其转换为清除触发器。

如果您需要实现一个自定义触发器,您应该签出抽象的trigger类。请注意,该API仍在发展中,在Flink的未来版本中可能会发生变化。

Evictors(剔除器)

Flink的窗口模型除了WindowAssigner和Trigger外,还允许指定一个可选的Evictor。这可以使用evictor(…)方法完成(在本文档的开头显示)。驱逐函数能够在触发器触发之后、在window函数应用之前和/或之后从窗口中删除元素。为此,Evictor接口有两个方法:

/**
 * Optionally evicts elements. Called before windowing function.
 *
 * @param elements The elements currently in the pane.
 * @param size The current number of elements in the pane.
 * @param window The {@link Window}
 * @param evictorContext The context for the Evictor
 */
void evictBefore(Iterable<TimestampedValue<T>> elements, int size, W window, EvictorContext evictorContext);

/**
 * Optionally evicts elements. Called after windowing function.
 *
 * @param elements The elements currently in the pane.
 * @param size The current number of elements in the pane.
 * @param window The {@link Window}
 * @param evictorContext The context for the Evictor
 */
void evictAfter(Iterable<TimestampedValue<T>> elements, int size, W window, EvictorContext evictorContext);

evictBefore()包含要在window函数之前应用的回收逻辑,而evictAfter()包含要在window函数之后应用的回收逻辑。在window函数应用程序之前被剔除的元素将不会被它处理。

Flink附带了三个预先实现的剔除器。这些都是:

  • CountEvictor:保留窗口中用户指定数量的元素,并丢弃从窗口缓冲区开始的剩余元素。
  • DeltaEvictor:接受一个deltfunction和一个阈值,计算窗口缓冲区中最后一个元素与每一个剩余元素之间的差值,并移除差值大于或等于阈值的元素。
  • TimeEvictor:以毫秒为参数,对于给定窗口,它在其元素中查找最大时间戳max_ts,并移除所有时间戳小于max_ts - interval的元素。

默认情况下,所有预实现的剔除器都在window函数之前应用它们的逻辑。

指定剔除器可以防止任何预聚合,因为窗口的所有元素必须在应用计算之前传递给剔除器。这意味着带有剔除器的窗口将创建更多的状态。

Flink不保证窗口中元素的顺序。这意味着,尽管剔除器可以从窗口的开头删除元素,但这些元素不一定是最先或最后到达的。

Allowed Lateness(允许迟到)

当使用事件时间窗口时,可能会发生元素延迟到达的情况,即Flink用于跟踪事件时间进度的水印已经超过了元素所属窗口的结束时间戳。

默认情况下,当水印超过窗口的末尾时,延迟元素将被删除。但是,Flink允许指定窗口操作符允许的最大延迟时间。允许延迟指定元素在被删除之前延迟的时间,默认值为0。在水印之后到达的元素已经经过了窗口的末端,但在它经过窗口的末端加上允许的延迟之前,仍然被添加到窗口。根据使用的触发器的不同,延迟但未删除的元素可能会导致窗口再次触发。EventTimeTrigg就是这样。

为了使这个工作,Flink保持窗口的状态,直到允许的延迟过期。一旦发生这种情况,Flink就会删除窗口并删除它的状态,正如在窗口生命周期部分所描述的那样。

缺省情况下,允许的延迟时间为0。也就是说,到达水印后面的元素将被删除。

你可以像这样指定一个允许的迟到时间:

DataStream<T> input = ...;

input
    .keyBy(<key selector>)
    .window(<window assigner>)
    .allowedLateness(<time>)
    .<windowed transformation>(<window function>);

当使用GlobalWindows窗口分配器时,没有数据会被认为是晚的,因为全局窗口的结束时间戳是Long.MAX_VALUE。

获取后期数据作为side output(侧输出流)

使用Flink的侧输出特性,您可以获得被丢弃的数据流。

首先需要指定希望使用窗口流上的sideOutputLateData(OutputTag)获取后期数据。然后,你可以获得窗口操作结果的侧输出流:

final OutputTag<T> lateOutputTag = new OutputTag<T>("late-data"){};

DataStream<T> input = ...;

SingleOutputStreamOperator<T> result = input
    .keyBy(<key selector>)
    .window(<window assigner>)
    .allowedLateness(<time>)
    .sideOutputLateData(lateOutputTag)
    .<windowed transformation>(<window function>);

DataStream<T> lateStream = result.getSideOutput(lateOutputTag);

迟到元素考虑

当指定允许的延迟大于0时,该窗口及其内容将在水印通过窗口结束后保留。在这些情况下,当一个延迟但没有被删除的元素到达时,它可能会触发对窗口的另一次触发。这些触发被称为晚触发,因为它们是由晚事件触发的,与主触发(即窗口的第一次触发)形成对比。对于会话窗口,延迟触发可能进一步导致窗口的合并,因为它们可能“弥合”两个已存在的、未合并的窗口之间的差距。

延迟触发触发的元素应该被视为前一个计算的更新结果,也就是说,你的数据流将包含相同计算的多个结果。根据您的应用程序的不同,您需要考虑这些重复的结果或去重复它们。

使用窗口结果

窗口操作的结果再次DataStream数据,没有保留的信息窗口的操作结果元素,所以,如果你想保持元信息的窗口,你必须手动编码信息ProcessWindowFunction结果元素。在结果元素上设置的唯一相关信息是元素时间戳。
这被设置为已处理窗口允许的最大时间戳,即end timestamp - 1,因为窗口-end时间戳是排他的。注意,这对于事件时间窗口和处理时间窗口都是成立的。即窗口操作之后的元素总是有一个时间戳,但这可以是事件时间戳或处理时间戳。
对于处理时间窗口,这没有特殊含义,但对于事件时间窗口,这与水印与窗口的交互方式一起支持具有相同窗口大小的连续窗口操作。我们将在了解水印如何与窗口交互之后讨论这个问题。

水印和窗口的交互

当水印到达窗口操作符时,会触发两件事:

  • 该水印触发计算所有最大时间戳(end-timestamp - 1)小于新水印的Windows
  • 水印按原样转发给下游操作

直观地说,一旦收到水印,任何在下游操作中被认为是延迟的窗口都会被水印“冲洗”出去。

连续窗口的操作

如前所述,计算窗口结果的时间戳以及水印与窗口交互的方式允许将连续的窗口操作串在一起。当您想要执行两个连续的窗口操作,其中您希望使用不同的键,但仍然希望来自同一上游窗口的元素最终位于同一下游窗口时,这可能很有用。考虑一下这个例子:

DataStream<Integer> input = ...;

DataStream<Integer> resultsPerKey = input
    .keyBy(<key selector>)
    .window(TumblingEventTimeWindows.of(Time.seconds(5)))
    .reduce(new Summer());

DataStream<Integer> globalResults = resultsPerKey
    .windowAll(TumblingEventTimeWindows.of(Time.seconds(5)))
    .process(new TopKWindowFunction());

在这个例子中,来自第一个操作的时间窗口[0,5]的结果也将在后续的窗口操作的时间窗口[0,5]中结束。这允许计算每个键的和,然后在第二个操作中计算同一窗口内的top-k元素。

有用的状态大小考虑

窗口可以在很长一段时间内定义(如天、周或月),因此会积累非常大的状态。在估算窗口计算的存储需求时,有几个规则需要记住:

  • Flink为其所属的每个窗口创建一个元素的副本。因此,翻滚窗口会对每个元素都保留一个副本(一个元素只属于一个窗口,除非它被延迟删除)。与之相反,滑动窗口会创建每个元素的若干个,如窗口分配器一节所述。因此,大小为1天、滑动1秒的滑动窗口可能不是一个好主意。
  • ReduceFunction和AggregateFunction可以显著地减少存储需求,因为它们急切地聚合元素,并且每个窗口只存储一个值。相反,仅仅使用ProcessWindowFunction就需要积累所有元素。
  • 使用户剔除器可以防止任何预聚合,因为窗口的所有元素在应用计算之前都必须通过剔除器传递。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

京河小蚁

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值