flink-窗口

窗口

窗口是处理无限流的核心。窗口将流分割成有限大小的“桶”,我们可以在桶上应用计算。本文档重点介绍如何在Flink中执行窗口操作,以及程序员如何从其提供的功能中获得最大的好处。

一个有窗口的Flink程序的一般结构如下所示。第一个片段指的是键控流,而第二个片段指的是非键控流。可以看到,唯一的区别是keyBy(…)调用键流,而window(…)调用非键流的windowwall(…)。这也将作为页面其余部分的路标。

键控窗

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"

非键控窗

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允许您以许多不同的方式定制窗口逻辑,以使其最适合您的需要。

窗口生命周期

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

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

除此之外,您还可以指定一个Evictor(请参阅evevtors),它将能够在触发器触发之后以及在函数应用之前和/或之后从窗口中删除元素。

下面我们将更详细地介绍上面的每个组件。我们从上面代码片段中的必修部分开始(参见键控与非键控窗口窗口赋值器窗口函数),然后再转向可选部分。

键控与非键控窗口

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

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

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

窗口赋值器

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

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

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

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

翻转窗口

翻转窗口赋值器将每个元素赋值给一个指定窗口大小的窗口。翻滚的窗户有固定的尺寸,而且不重叠。例如,如果您指定一个大小为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>);

时间间隔可以通过使用Time.milliseconds(x)Time.seconds(x)Time.minutes(x)等来指定。

如上一个例子所示,翻转窗口赋值器也接受一个可选的偏移参数,可以用来改变窗口的对齐方式。例如,如果没有偏移量,每小时翻转的窗口与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)的偏移量。

滑动窗口

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

例如,您可以将大小为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)等来指定。

如上一个示例所示,滑动窗口赋值器也接受一个可选的offset参数,该参数可用于更改窗口的对齐方式。例如,如果没有偏移量,每小时滑动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)的偏移量。

会话窗口

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

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

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。

全局窗口

全局窗口赋值器将具有相同键的所有元素赋值给同一个全局窗口。这种窗口模式只有在您还指定了自定义触发器时才有用。否则,将不执行任何计算,因为全局窗口没有可以处理聚合元素的自然结束点。

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

DataStream<T> input = ...;

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

窗口函数

在定义窗口赋值器之后,我们需要指定我们想要在每个窗口上执行的计算。这是窗口函数的职责,一旦系统确定一个窗口已经准备好进行处理(请参阅触发器了解Flink如何确定一个窗口何时准备好),该函数将用于处理每个窗口(可能是键控的)的元素。

窗口函数可以是ReduceFunction, AggregateFunctionProcessWindowFunction之一。前两个可以更有效地执行(参见State Size一节),因为Flink可以在每个窗口到达时增量地聚合元素。ProcessWindowFunction获取一个窗口中包含的所有元素的Iterable,以及关于元素所属窗口的附加元信息。

使用ProcessWindowFunction的窗口转换不能像其他情况那样有效地执行,因为Flink在调用函数之前必须在内部缓冲窗口的所有元素。这可以通过将ProcessWindowFunctionReduceFunctionAggregateFunction相结合来缓解,以获得窗口元素的增量聚合和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

AggregateFunctionReduceFunction的一般化版本,它有三种类型:输入类型(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 -index键或字符串字段引用,该键类型总是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,用来计算窗口中的元素。此外,窗口函数将有关窗口的信息添加到输出中。

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

带有增量聚合的ProcessWindowFunction

ProcessWindowFunction可以与ReduceFunctionAggregateFunction结合在一起,在元素到达窗口时进行增量聚合。当窗口关闭时,ProcessWindowFunction将提供聚合的结果。这允许它在访问ProcessWindowFunction的附加窗口元信息的同时,递增地计算窗口。

你也可以使用旧的WindowFunction而不是ProcessWindowFunction来增加窗口聚合。

使用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));
  }
}

带有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中使用每个窗口的状态

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

  • 在指定窗口操作时定义的窗口:这可能是1小时的滚动窗口或滑动2小时的滑动窗口(滑动1小时)。
  • 对于给定的键,定义的窗口的实际实例:对于用户id xyz,这可能是12:00到13:00的时间窗口。这是基于窗口定义的,根据作业当前处理的键的数量和事件所处的时间段,会有很多窗口。

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

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

  • globalState(),它允许访问不在窗口范围内的键控状态
  • windowState(),它允许访问同样限定在窗口范围内的键控状态

如果您预期同一窗口会有多次触发,就像您对延迟到达的数据有延迟触发,或者当您有一个定制触发器执行推测性的早期触发时一样,这个特性很有帮助。在这种情况下,您将存储关于以前的触发或每个窗口状态下的触发次数的信息。

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

WindowFunction(遗留)

在一些可以使用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());

触发器

触发器决定窗口(由窗口赋值器形成)何时准备由窗口函数处理。每个WindowAssigner都有一个默认的触发器。如果默认触发器不符合您的需要,您可以使用trigger(…)指定一个自定义触发器。

触发器接口有五个方法,允许触发器对不同的事件作出反应:

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

关于上述方法需要注意的两件事是:

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

发射或清除

一旦触发器确定窗口已经准备好进行处理,它就会触发,也就是说,它返回FIREFIRE_AND_PURGE。这是窗口操作符发出当前窗口结果的信号。给定一个带有ProcessWindowFunction的窗口,所有元素都被传递给ProcessWindowFunction(可能是在将它们传递给一个逐出器之后)。带有ReduceFunctionAggregateFunction的窗口只是发送它们的聚合结果。

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

清除操作只会删除窗口的内容,并将保留有关窗口和任何触发器状态的任何潜在元信息。

WindowAssigners的默认触发器

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

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

通过使用trigger()指定触发器,你重写了WindowAssigner的默认触发器。例如,如果你为TumblingEventTimeWindows指定一个CountTrigger,你将不再得到基于时间的进展的窗口触发,但只通过计数。现在,如果您希望基于时间和计数进行反应,则必须编写自己的自定义触发器。

内置和自定义触发器

Flink自带一些内置触发器。

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

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

驱逐器

Flink的窗口模型允许指定除WindowAssignerTrigger之外的一个可选的Evictor。这可以使用evector(…)方法(如本文开头所示)来完成。驱逐器能够在触发器触发后以及在应用窗口函数之前和/或之后从窗口中删除元素。为此,Evector接口有两个方法:

/**
 * 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()包含应用在窗口函数之前的逐出逻辑,而evictAfter()包含应用在窗口函数之后的逐出逻辑。在窗口函数应用程序之前被驱逐的元素将不会被它处理。

Flink有三个预先实现的驱逐器。这些都是:

  • CountEvictor:从窗口中保留用户指定数量的元素,并丢弃从窗口缓冲区开始的剩余元素。
  • DeltaEvector:取一个DeltaFunction和一个threshold,计算窗口缓冲区中最后一个元素和每个剩余元素之间的增量,并删除增量大于或等于阈值的元素。
  • TimeVictor:以毫秒为参数,对于给定的窗口,它在其元素中找到最大的时间戳max_ts,并删除所有时间戳小于max_ts - interval的元素。

默认情况下,所有预先实现的驱逐器都将它们的逻辑应用于窗口函数之前。

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

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

允许迟到

当使用事件时间窗口时,可能会发生元素延迟到达的情况,即Flink用来跟踪事件时间进程的水印已经超过了元素所属窗口的结束时间戳。关于Flink如何处理事件时间的更深入的讨论,请参阅事件时间,特别是延迟元素

默认情况下,当水印超过窗口的末端时,删除后面的元素。但是,Flink允许为窗口操作符指定允许的最大延迟。允许延迟指定元素延迟多长时间后才被删除,其默认值为0。在水印经过窗口末端之后到达的元素,在水印经过窗口末端加上允许的延迟之前到达的元素,仍然被添加到窗口中。根据所使用的触发器,延迟但未删除的元素可能导致窗口再次触发。这就是EventTimeTrigger的情况。

为了使其工作,Flink将保持窗口的状态,直到它们允许的延迟过期。一旦发生这种情况,Flink就会删除窗口并删除其状态,这在窗口生命周期一节中也有描述。

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

你可以像这样指定一个允许的延迟:

DataStream<T> input = ...;

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

当使用GlobalWindows窗口赋值器时,没有数据被认为是延迟的,因为全局窗口的结束时间戳是Long.MAX_VALUE

获取迟到数据作为副输出

使用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结果元素。在结果元素上设置的唯一相关信息是元素的timestamp。这被设置为所处理窗口允许的最大时间戳,即end timestamp - 1,因为窗口-结束时间戳是独占的。请注意,对于事件时间窗口和处理时间窗口都是如此。例如,在一个窗口操作之后,元素总是有一个时间戳,但这可以是一个事件时间戳或一个处理时间戳。对于处理时间窗口,这没有特殊的含义,但是对于事件时间窗口,结合水印与窗口的交互,可以实现具有相同窗口大小的连续窗口操作。在了解了水印如何与窗口交互之后,我们将讨论这个问题。

水印和窗口的交互作用

在继续本节之前,您可能想看看我们关于事件时间和水印的部分。

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

  • 当最大时间戳(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元素。

有用的状态大小考虑事项

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

  1. Flink为它所属的每个窗口的每个元素创建一个副本。 考虑到这一点,翻转窗口保留每个元素的一个副本(一个元素只属于一个窗口,除非它稍后被删除)。 相比之下,滑动窗口创建每个元素的多个,如窗口分配器部分所解释的。 因此,大小为1天、滑动1秒的滑动窗口可能不是一个好主意。
  2. ReduceFunctionAggregateFunction可以显著减少存储需求,因为它们及时地聚合元素,每个窗口只存储一个值。 相反,仅仅使用ProcessWindowFunction就需要累积所有元素。
  3. 使用Evevtor可以防止任何预聚合,因为在应用计算之前,窗口的所有元素都必须通过evevtor传递(请参阅evevtors)。

实践

maven依赖

implementation group: 'org.apache.flink', name: 'flink-java', version: '1.14.2'
implementation group: 'org.apache.flink', name: 'flink-streaming-java_2.12', version: '1.14.2'
implementation group: 'org.apache.flink', name: 'flink-clients_2.12', version: '1.14.2'
implementation group: 'org.apache.flink', name: 'flink-table-api-java-bridge_2.12', version: '1.14.2'
implementation group: 'org.apache.flink', name: 'flink-table-planner_2.12', version: '1.14.2'

由于在本地调试,所以依赖级别都声明为implementation,如果在flink集群运行,可以酌情声明为compileOnly

main方法

@Slf4j
public class Main {

    public static void main(String[] args) {
        log.info("main start {}", LocalDateTime.now());
        new SimpleWatermark().run();
        log.info("main end {}", LocalDateTime.now());
    }

}
@Slf4j
public class SimpleWatermark implements Runnable {

    long toTimeStamp(LocalDateTime ldt) {
        return ldt.toInstant(ZoneOffset.ofHours(8)).toEpochMilli();
    }

    @SneakyThrows
    @Override
    public void run() {
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.getConfig().setAutoWatermarkInterval(0);

        LocalDateTime now = LocalDateTime.parse("2021-06-06T12:00:00");
        DataStream<CustomerEvent> stream = env.fromElements(
                new CustomerEvent(4L, toTimeStamp(now)),
                new CustomerEvent(2L, toTimeStamp(now.minusSeconds(40))),
                new CustomerEvent(3L, toTimeStamp(now.minusSeconds(20))),
                new CustomerEvent(5L, toTimeStamp(now.plusSeconds(20))),
                new CustomerEvent(6L, toTimeStamp(now.plusMinutes(40))),
                new CustomerEvent(1L, toTimeStamp(now.minusMinutes(60)))
        );

        WatermarkStrategy<CustomerEvent> watermarkStrategy = WatermarkStrategy.<CustomerEvent>forGenerator(ctx -> new WG())
                .withTimestampAssigner(new WA());
        stream
                .assignTimestampsAndWatermarks(watermarkStrategy)
                .setParallelism(1)
                .windowAll(TumblingEventTimeWindows.of(Time.of(10, TimeUnit.MINUTES)))
                .apply(new AP())
                .print()
                .setParallelism(1);

        env.execute("按事件时间排序");

    }

    @Slf4j
    static class WG implements WatermarkGenerator<CustomerEvent> {

        @Override
        public void onEvent(CustomerEvent event, long eventTimestamp, WatermarkOutput output) {
            log.error("onEvent {} {}", event, eventTimestamp);
            output.emitWatermark(new Watermark(eventTimestamp));
        }

        @Override
        public void onPeriodicEmit(WatermarkOutput output) {

        }

    }

    @Slf4j
    static class WA implements SerializableTimestampAssigner<CustomerEvent> {

        @Override
        public long extractTimestamp(CustomerEvent element, long recordTimestamp) {
            log.error("extractTimestamp {} {}", element, recordTimestamp);
            return element.getEventTime();
        }

    }

    @Slf4j
    static class Tri extends Trigger<Long, GlobalWindow> {

        @Override
        public TriggerResult onElement(Long element, long timestamp, GlobalWindow window, TriggerContext ctx) throws Exception {
            log.error("=== onElement {} {} {} {} {}", element, timestamp, timestamp, window.maxTimestamp(), ctx.getCurrentWatermark());
            if (Objects.isNull(element)) {
                return TriggerResult.CONTINUE;
            } else {
                return TriggerResult.FIRE;
            }
        }

        @Override
        public TriggerResult onProcessingTime(long time, GlobalWindow window, TriggerContext ctx) throws Exception {
            log.error("=== onProcessingTime");
            return TriggerResult.CONTINUE;
        }

        @Override
        public TriggerResult onEventTime(long time, GlobalWindow window, TriggerContext ctx) throws Exception {
            log.error("=== onEventTime");
            return TriggerResult.CONTINUE;
        }

        @Override
        public void clear(GlobalWindow window, TriggerContext ctx) throws Exception {
            log.error("=== clear");
        }
    }

    @Slf4j
    static class Agg implements AggregateFunction<CustomerEvent, List<CustomerEvent>, List<CustomerEvent>> {
        @Override
        public List<CustomerEvent> createAccumulator() {
            log.error("=== createAccumulator");
            return new LinkedList<>();
        }

        @Override
        public List<CustomerEvent> add(CustomerEvent value, List<CustomerEvent> accumulator) {
            log.error("=== add {}", value);
            accumulator.add(value);
            return accumulator;
        }

        @Override
        public List<CustomerEvent> getResult(List<CustomerEvent> accumulator) {
            log.error("=== getResult");
            return accumulator.stream()
                    .sorted(Comparator.comparing(CustomerEvent::getEventTime, Comparator.naturalOrder()))
                    .collect(Collectors.toList());
        }

        @Override
        public List<CustomerEvent> merge(List<CustomerEvent> a, List<CustomerEvent> b) {
            log.error("=== merge");
            a.addAll(b);
            return a;
        }
    }

    @Slf4j
    static class FM implements FlatMapFunction<List<CustomerEvent>, Long> {

        @Override
        public void flatMap(List<CustomerEvent> value, Collector<Long> out) throws Exception {
            for (CustomerEvent event : value) {
                out.collect(event.getCustomerId());
            }
        }
    }

    @Slf4j
    static class AP implements AllWindowFunction<CustomerEvent, Long, TimeWindow> {

        @Override
        public void apply(TimeWindow window, Iterable<CustomerEvent> values, Collector<Long> out) throws Exception {
            log.error("apply {} {}", DateFormatUtils.format(window.getStart(), "yyyy-MM-dd HH:mm:ss"), DateFormatUtils.format(window.getEnd(), "yyyy-MM-dd HH:mm:ss"));
            Stream.ofAll(values).sortBy(CustomerEvent::getEventTime)
                    .map(t -> {
                        log.error("apply {} {}", t.getCustomerId(), DateFormatUtils.format(t.getEventTime(), "yyyy-MM-dd HH:mm:ss"));
                        return t;
                    })
                    .map(CustomerEvent::getCustomerId)
                    .forEach(out::collect);
        }
    }

    static class PR extends ProcessFunction<CustomerEvent, Long> {

        @Override
        public void processElement(CustomerEvent value, ProcessFunction<CustomerEvent, Long>.Context ctx, Collector<Long> out) throws Exception {
            log.error("processElement {} {}", DateFormatUtils.format(value.getEventTime(), "yyyy-MM-dd HH:mm:ss"),
                    DateFormatUtils.format(value.getEventTime(), "yyyy-MM-dd HH:mm:ss"));
            out.collect(value.getCustomerId());
        }
    }

    @Slf4j
    static class SO implements MapFunction<CustomerEvent, Long> {

        @Override
        public Long map(CustomerEvent value) throws Exception {
            log.error("late data {}", value);
            return value.getCustomerId();
        }

    }

    @Data
    @AllArgsConstructor
    static class CustomerEvent {
        private long customerId;
        private long eventTime;
    }

}

程序说明

数据

在这里插入图片描述

 @Data
 @AllArgsConstructor
 static class CustomerEvent {
     private long customerId;
     private long eventTime;
 }
时间戳

采用事件时间,从事件数据中提取。

@Slf4j
static class WA implements SerializableTimestampAssigner<CustomerEvent> {

    @Override
    public long extractTimestamp(CustomerEvent element, long recordTimestamp) {
        log.error("extractTimestamp {} {}", element, recordTimestamp);
        return element.getEventTime();
    }

}
水印
@Slf4j
static class WG implements WatermarkGenerator<CustomerEvent> {

    @Override
    public void onEvent(CustomerEvent event, long eventTimestamp, WatermarkOutput output) {
        log.error("onEvent {} {}", event, eventTimestamp);
        output.emitWatermark(new Watermark(eventTimestamp));
    }

    @Override
    public void onPeriodicEmit(WatermarkOutput output) {

    }

}

由于只有几个数据,所以采用基于事件的方式生成水印。关闭周期性水印的配置:

env.getConfig().setAutoWatermarkInterval(0);

如果数据量比较大,可以采用周期性生成水印的方式

时间窗口

定义一个10min的时间窗口:

TumblingEventTimeWindows.of(Time.of(10, TimeUnit.MINUTES))
窗口处理函数

给窗口里的所有事件排序并输出。

@Slf4j
static class AP implements AllWindowFunction<CustomerEvent, Long, TimeWindow> {

    @Override
    public void apply(TimeWindow window, Iterable<CustomerEvent> values, Collector<Long> out) throws Exception {
        log.error("apply {} {}", DateFormatUtils.format(window.getStart(), "yyyy-MM-dd HH:mm:ss"), DateFormatUtils.format(window.getEnd(), "yyyy-MM-dd HH:mm:ss"));
        Stream.ofAll(values).sortBy(CustomerEvent::getEventTime)
                .map(t -> {
                    log.error("apply {} {}", t.getCustomerId(), DateFormatUtils.format(t.getEventTime(), "yyyy-MM-dd HH:mm:ss"));
                    return t;
                })
                .map(CustomerEvent::getCustomerId)
                .forEach(out::collect);
    }
}
并行度

先考虑简单的场景,并行度设置为1。

.setParallelism(1)

场景和结论

窗口范围

所有的窗口由开始时刻和结束时刻组成(包含开始时刻,不包含结束时刻)。

对于翻转窗口和滑动窗口,窗口的开始时刻由一个固定的公式计算而得,而不是由第一个事件的事件时间决定:

public static long getWindowStartWithOffset(long timestamp, long offset, long windowSize) {
    return timestamp - (timestamp - offset + windowSize) % windowSize;
}

也就是说,窗口注册器定义好后,每个给定的时间戳都能够分配到一个(翻转)或多个(滑动)窗口中。比如一个10min的翻转窗口,2021-06-06 12:00:01和2021-06-06 12:00:40都是在这个窗口(2021-06-06 12:00:00 2021-06-06 12:10:00)中。
在这里插入图片描述

窗口触发

事件来了之后,flink会根据事件时间戳把事件分配到不同的窗口中。后续根据窗口的结束时刻和水印比较,如果水印已经领先了窗口的结束时刻,则会触发窗口的处理;否则,以窗口的结束时刻注册一个延迟任务,当延迟任务触发时,就触发窗口的处理。具体逻辑参考EventTimeTrigger
在这里插入图片描述

无水印时

将flink的水印功能关闭。

WatermarkStrategy<CustomerEvent> watermarkStrategy = WatermarkStrategy.<CustomerEvent>noWatermarks()
        .withTimestampAssigner(new WA());

时间戳从事件中提取:
在这里插入图片描述
每个事件都在相应的窗口里被触发,就算是迟到事件,也会打印出来:
在这里插入图片描述
按照窗口的时间顺序,触发了4个窗口。由于每个窗口处理函数是对窗口事件进行排序,所以每个窗口内的事件是有序的。

没有生成水印,也触发了窗口的处理,这是什么原因呢?

通过查看日志发现,所有的窗口都是程序结束时才触发的。具体原因笔者还没有找到,猜测是程序结束时,生成了一个时间戳为Long.MAX_VALUE的水印,触发了所有窗口。

正因为如此,所有的窗口数据都保存在内存中,直到最大水印生成时才触发执行。

有界数据可以不用水印,但是无界数据一定要用水印。

有水印时

现在把水印生成打开:

WatermarkStrategy<CustomerEvent> watermarkStrategy = WatermarkStrategy.<CustomerEvent>forGenerator(ctx -> new WG())
        .withTimestampAssigner(new WA());

在这里插入图片描述
事件时间戳依然从事件提取,并且每个事件生成水印。
在这里插入图片描述
结果符合上面的文档:只触发了两个窗口,所有迟到的事件都被丢弃了,没有打印出来。

设置延迟时间

为了给稍微迟到的事件一个机会,设置一个合理的延迟时间,比如1m,超过1m的数据丢弃。

stream
        .assignTimestampsAndWatermarks(watermarkStrategy)
        .setParallelism(1)
        .windowAll(TumblingEventTimeWindows.of(Time.of(10, TimeUnit.MINUTES)))
        .allowedLateness(Time.of(1, TimeUnit.MINUTES)) 
        .apply(new AP())
        .print()
        .setParallelism(1);

运行程序,查看日志如下:
在这里插入图片描述
可以看到,

  1. 23都是延迟范围(1m)内的迟到元素,对于每个迟到元素的到达,都会触发窗口的处理,并且处理的数据是窗口所有已到的事件2到达时,窗口只有23到达时,窗口既有2又有3
  2. 延迟范围之外的迟到元素,依旧被丢弃了。迟到严重的事件1被丢弃了。
设置副输出

为了给所有迟到事件一个机会,可以把那些迟到严重的事件输出到副输出中。

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

SingleOutputStreamOperator<Long> result = stream
        .assignTimestampsAndWatermarks(watermarkStrategy)
        .setParallelism(1)
        .windowAll(TumblingEventTimeWindows.of(Time.of(10, TimeUnit.MINUTES)))
        .allowedLateness(Time.of(1, TimeUnit.MINUTES))
        .sideOutputLateData(lateOutputTag)
        .apply(new AP());

result.print()
        .setParallelism(1);

result.getSideOutput(lateOutputTag)
        .map(new SO())
        .print()
        .setParallelism(1);

查看日志如下:
在这里插入图片描述
延迟范围内的迟到事件
在这里插入图片描述
延迟范围外的迟到事件

从日志可以看到:延迟范围外的事件出现在副输出中。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值