Flink的窗口处理机制(二)

在前面,我们介绍了Flink的窗口概述以及WindowAssigner窗口指派器,接下来,我们继续介绍Flink窗口函数、Trigger触发器、Evictor清除器等的相关机制。

五、Window Functions 窗口函数

引用转载自:https://zhuanlan.zhihu.com/p/102325190 (强烈推荐)

定义好窗口分配器之后,无限流上的数据已经被我们划分到了一个个窗口里,接下来,我们需要对每个窗口中的数据进行处理。这可以通过指定Window Function来实现,一旦系统确定了某个窗口已经准备好进行处理,该函数将会处理窗口中的每个元素。

Window Function通常有这几种:ReduceFunction,AggregateFunction,FoldFunction以及ProcessWindowFunction、WindowFunction(旧版)。

窗口函数分为两类,一种是增量聚合,如reduce和aggregate,一种是全量聚合,如process、apply

增量聚合:

窗口保存一份中间数据,每流入一个新元素,新元素与中间数据两两合一,生成新的中间数据,再保存到窗口中。reduce(ReduceFunction),aggregate(AggregateFunction),fold(FoldFunction)都是这种。

全量聚合:

窗口先缓存该窗口所有元素,等到触发条件后对窗口内的全量元素执行计算。

process(ProcessWindowFunction)就是这种。

apply(WindowFunction) --- 不过1.3之后被弃用

增量聚合和全量聚合对比:

1、增量聚合执行非常高效,因为Flink可以在每个元素到达窗口时增量的聚合这些元素。

但是增量聚合缺少窗口 的meta元数据信息。

2、全量聚合执行效率很低,因为在调用函数之前Flink必须在内部缓存窗口中的所有元素。

但是ProcessWindowFunction持有一个窗口中包含的所有元素的Iterable对象,以及元素所属窗口的附加meta信息。【可以实现对窗口内的数据进行排序等需求】

增量+全量聚合函数结合:(推荐,也常用)

我们可以将ProcessWindowFunction和ReduceFunction,AggregateFunction, 或者FoldFunction函数结合来缓解这个问题,从而可以让窗口增量聚合以及获取ProcessWindowFunction接收的窗口meta数据。

1、ReduceFunction

RedceFunction的算子是reduce(),使用reduce算子时,我们要重写一个ReduceFunction。

ReduceFunction用于指明如何将输入流中的两个元素组合在一起来生成一个相同类型的输出元素。Flink使用ReduceFunction增量地聚合窗口中的元素。

这里的reduce同我们之前的reduce算子原理是一样的。

reduce/fold/aggregate算子计算原理:

reduce将stream中元素前两个传给输入函数,产生一个新的return值,将新产生的return值与RDD中下一个元素(即第三个元素)组成两个元素,再被传给输入函数,这样递归运作,直到最后只有一个值为止。*/

fold就是在reduce的基础上,增加了一个初始值,这个初始值会加入到每个分区的头部。

因为fold分为分区内的汇总,和分区间的全局汇总,在局部汇总的每个子分区头部都会加入这个初始值,在全局汇总的头部也会加入这个初始值。

aggregate,比reduce和fold更加灵活,reduce和fold的分区内局部汇总和分区间的全局汇总的算法都是一致的,但是aggregate可以不同。

ReduceFunction的api源码:

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

应用案例:

case class StockPrice(symbol: String, price: Double)

val input: DataStream[StockPrice] = ...

senv.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime)

// reduce的返回类型必须和输入类型StockPrice一致
val sum = input
      .keyBy(s => s.symbol)
      .timeWindow(Time.seconds(10))
      .reduce((s1, s2) => StockPrice(s1.symbol, s1.price + s2.price))

上面的代码使用Lambda表达式对两个元组进行操作,由于对symbol字段进行了keyBy,相同symbol的数据都分组到了一起,接着我们将price加和,返回的结果必须也是StockPrice类型,否则会报错。

这里s1、s2、和返回的数据都是相同类型,都是StockPrice类型

上面是对price价格进行聚合,s1是之前计算的结果,s2是当前输入的元素数据。

最初始状态时:s1、s2是流里前面两个元素数据。

后面计算时:s1是前面计算的结果值,保存为中间状态,和当前输入的元素s2,继续组成两个参数传入到函数当中。

优缺点:

1、使用reduce的好处是窗口的状态数据量非常小,实现一个ReduceFunction也相对比较简单,可以使用Lambda表达式,也可以重写函数。

2、缺点是能实现的功能非常有限,因为中间状态数据的数据类型、输入类型以及输出类型三者必须一致,而且只保存了一个中间状态数据,当我们想对整个窗口内的数据进行操作时,仅仅一个中间状态数据是远远不够的。

2、AggregateFunction

AggregateFunction也是一种增量计算窗口函数,也只保存了一个中间状态数据

aggregate与reduce最大的不同就是,aggregate允许输入类型(IN),累加器类型(ACC)以及输出类型(OUT)可以不一样。

AggregateFunction可以称之为广义上的ReduceFunction,它包含三种元素类型:输入类型(IN),累加器类型(ACC)以及输出类型(OUT)。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> {

   // 在一次新的aggregate发起时,创建一个新的Accumulator,Accumulator是我们所说的中间状态数据,简称ACC
   // 这个函数一般在初始化时调用
  @Override
  public Tuple2<Long, Long> createAccumulator() {
    return new Tuple2<>(0L, 0L);
  }

  // 当一个新元素流入时,将新元素与状态数据ACC合并,返回状态数据ACC
  @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;
  }

  // 将两个ACC合并
  @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());

输入类型是IN,输出类型是OUT,中间状态数据是ACC,这样复杂的设计主要是为了解决输入类型、中间状态和输出类型不一致的问题,同时ACC可以自定义,我们可以在ACC里构建我们想要的数据结构。比如我们要计算一个窗口内某个字段的平均值,那么ACC中要保存总和以及个数,下面是一个平均值的示例:

case class StockPrice(symbol: String, price: Double)

// IN: StockPrice
// ACC:(String, Double, Int) - (symbol, sum, count)
// OUT: (String, Double) - (symbol, average)
class AverageAggregate extends AggregateFunction[StockPrice, (String, Double, Int), (String, Double)] {

  override def createAccumulator() = ("", 0, 0)

  override def add(item: StockPrice, accumulator: (String, Double, Int)) =
  (item.symbol, accumulator._2 + item.price, accumulator._3 + 1)

  override def getResult(accumulator:(String, Double, Int)) = (accumulator._1 ,accumulator._2 / accumulator._3)

  override def merge(a: (String, Double, Int), b: (String, Double, Int)) =
  (a._1 ,a._2 + b._2, a._3 + b._3)
}

senv.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime)

val input: DataStream[StockPrice] = ...

val average = input
      .keyBy(s => s.symbol)
      .timeWindow(Time.seconds(10))
      .aggregate(new AverageAggregate)

这几个函数的工作流程如下图所示。在计算之前要创建一个新的ACC,这时ACC还没有任何实际表示意义,当有新数据流入时,Flink会调用add方法,更新ACC,并返回最新的ACC,ACC是一个中间状态数据。当有一些跨节点的ACC融合时,Flink会调用merge,生成新的ACC。当所有的ACC最后融合为一个ACC后,Flink调用getResult生成结果。

图片来源:https://zhuanlan.zhihu.com/p/102325190

clipboard

3、FoldFunction

FoldFunction指定如何将窗口的输入元素与输出类型的元素组合。对添加到窗口的每个元素和当前输出值增量调用FoldFunction。

FoldFunction与ReduceFunction基本一致。区别在于FoldFunction需要预设一个初始值,reduce不用。

如下的示例将所有输入的值追加到最初为空的String上。

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

input
    .keyBy(<key selector>)
    .window(<window assigner>)
    .fold("", new FoldFunction<Tuple2<String, Long>, String>> {
       public String fold(String acc, Tuple2<String, Long> value) {
         return acc + value.f1;
       }
    });

注意:fold()不能用于会话窗口或其他可合并的窗口

flink中已经Deprecated警告,且建议使用AggregateFunction代替。

4、ProcessWindowFunction

与前两种方法不同,ProcessWindowFunction要对窗口内的全量数据都缓存。在Flink所有API中,process算子以及其对应的函数是最底层的实现,使用这些函数能够访问一些更加底层的数据,比如,直接操作状态等。它在源码中的定义如下:

/**
 * IN   输入类型
 * OUT  输出类型
 * KEY  keyBy中按照Key分组,Key的类型
 * W    窗口的类型
 */
public abstract class ProcessWindowFunction<IN, OUT, KEY, W extends Window> extends AbstractRichFunction {

  /**
   * 对一个窗口内的元素进行处理,窗口内的元素缓存在Iterable<IN>,进行处理后输出到Collector<OUT>中
   * 我们可以输出一到多个结果
   */
    public abstract void process(KEY key, Context context, Iterable<IN> elements, Collector<OUT> out) throws Exception;

  /** 
    * 当窗口执行完毕被清理时,删除各类状态数据。
    */
    public void clear(Context context) throws Exception {}

  /**
   * 一个窗口的上下文,包含窗口的一些元数据、状态数据等。
   */
    public abstract class Context implements java.io.Serializable {

    // 返回当前正在处理的Window
        public abstract W window();

    // 返回当前Process Time
        public abstract long currentProcessingTime();

    // 返回当前Event Time对应的Watermark
        public abstract long currentWatermark();

    // 返回某个Key下的某个Window的状态
        public abstract KeyedStateStore windowState();

    // 返回某个Key下的全局状态
        public abstract KeyedStateStore globalState();

    // 迟到数据发送到其他位置
        public abstract <X> void output(OutputTag<X> outputTag, X value);
    }
}

使用时,Flink将某个Key下某个窗口的所有元素都缓存在Iterable<IN>中,我们需要对其进行处理,然后用Collector<OUT>收集输出。我们可以使用Context获取窗口内更多的信息,包括时间、状态、迟到数据发送位置等。

下面的代码是一个ProcessWindowFunction的简单应用,我们对价格出现的次数做了统计,选出出现次数最多的输出出来。

case class StockPrice(symbol: String, price: Double)

class FrequencyProcessFunction extends ProcessWindowFunction[StockPrice, (String, Double), String, TimeWindow] {

  override def process(key: String, context: Context, elements: Iterable[StockPrice], out: Collector[(String, Double)]): Unit = {

    // 股票价格和该价格出现的次数
    var countMap = scala.collection.mutable.Map[Double, Int]()

    for(element <- elements) {
      val count = countMap.getOrElse(element.price, 0)
      countMap(element.price) = count + 1
    }

    // 按照出现次数从高到低排序
    val sortedMap = countMap.toSeq.sortWith(_._2 > _._2)

    // 选出出现次数最高的输出到Collector
    if (sortedMap.size > 0) {
      out.collect((key, sortedMap(0)._1))
    }

  }
}

senv.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime)

val input: DataStream[StockPrice] = ...

val frequency = input
      .keyBy(s => s.symbol)
      .timeWindow(Time.seconds(10))
      .process(new FrequencyProcessFunction)

Context中有两种状态,一种是针对Key的全局状态,它是跨多个窗口的,多个窗口都可以访问;另一种是该Key下单窗口的状态,单窗口的状态只保存该窗口的数据,主要是针对process函数多次被调用的场景,比如处理迟到数据或自定义Trigger等场景。当使用单个窗口的状态时,要在clear函数中清理状态。

ProcessWindowFunction相比AggregateFunction和ReduceFunction的应用场景更广,能解决的问题也更复杂。但ProcessWindowFunction需要将窗口中所有元素作为状态存储起来,这将占用大量的存储资源,尤其是在数据量大窗口多的场景下,使用不慎可能导致整个程序宕机。比如,每天的数据在TB级,我们需要Slide为十分钟Size为一小时的滑动窗口,这种设置会导致窗口数量很多,而且一个元素会被复制好多份分给每个所属的窗口,这将带来巨大的内存压力。

5、ProcessWindowFunction与增量计算相结合(常用)

为了解决ProcessWindowFunction将整个窗口元素缓存起来占用大量资源的情况,flink提供了可以将ProcessWindowFunction和reduce和aggregate组合的操作。

即当元素到达窗口时进行增量计算,当窗口结束的时候,将增量计算结果发送给ProcessWindowFunction作为输入再进行处理。ProcessWindowFunction会将增量结果进行处理输出结果。该组合操作即可以增量计算窗口,同时也可以访问窗口的一些元数据、状态信息等。

可以在ProcessWindowFunction里计算最大值,最小值,获取窗口启动时间,结束时间等信息。

5.1、ReduceFunction + ProcessWindowFunction

reduce算子里传入两个参数,一个继承ReduceFunction 接口,实现增量聚合操作。

一个继承ProcessWindowFunction,以返回窗口中的最小事件和窗口的开始时间

DataStream<SensorReading> input = ...;

input
  .keyBy(<key selector>)
  .timeWindow(<duration>)
  .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));
  }
}

5.2、AggregateFunction + ProcessWindowFunction (推荐)

在aggregate算子中传入两个参数,一个继承AggregateFunction,实现增量聚合操作。

一个继承ProcessWindowFunction,获取窗口状态信息,以及进行整体的计算。

示例:计算元素平均值,同时输出key值与均值。

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

input
  .keyBy(<key selector>)
  .timeWindow(<duration>)
  .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));
  }
}

5.3、FoldFunction+ ProcessWindowFunction

在fold()算子中传入三个参数,

一个作为fold的初始值

一个继承AggregateFunction,实现增量聚合操作。

一个继承ProcessWindowFunction,获取窗口状态信息,

DataStream<SensorReading> input = ...;

input
  .keyBy(<key selector>)
  .timeWindow(<duration>)
  .fold(new Tuple3<String, Long, Integer>("",0L, 0), new MyFoldFunction(), new MyProcessWindowFunction())

// Function definitions

private static class MyFoldFunction
    implements FoldFunction<SensorReading, Tuple3<String, Long, Integer> > {

  public Tuple3<String, Long, Integer> fold(Tuple3<String, Long, Integer> acc, SensorReading s) {
      Integer cur = acc.getField(2);
      acc.setField(cur + 1, 2);
      return acc;
  }
}

private static class MyProcessWindowFunction
    extends ProcessWindowFunction<Tuple3<String, Long, Integer>, Tuple3<String, Long, Integer>, String, TimeWindow> {

  public void process(String key,
                    Context context,
                    Iterable<Tuple3<String, Long, Integer>> counts,
                    Collector<Tuple3<String, Long, Integer>> out) {
    Integer count = counts.iterator().next().getField(2);
    out.collect(new Tuple3<String, Long, Integer>(key, context.window().getEnd(),count));
  }
}

6、apply(WindowFunction) 旧版

窗口每触发一次时,会调用一次apply方法,相当于是对窗口中的全量数据进行计算。

1、apply方法中,可以添加WindowFunction对象,会将该窗口中所有的数据先缓存,当时间到了一次性计算

* 2、需要设置4个类型,分别是:输入类型,输出类型,keyBy时key的类型(如果用字符串来划分key类型为Tuple,窗口类型

* 3、所有的计算都在apply中进行,可以通过window获取窗口的信息,比如开始时间,结束时间

它跟process类似,在某些ProcessWindowFunction可以使用的地方,您也可以使用WindowFunction。这是ProcessWindowFunction的旧版本,提供较少的上下文信息,并且没有某些高级功能,例如每个窗口的状态。该接口将在某个时候被弃用。

apply用法:

val input: DataStream[(String, Long)] = ...

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

WindowFunction源码定义:

trait WindowFunction[IN, OUT, KEY, W <: Window] extends Function with 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.
    */
  def apply(key: KEY, window: W, input: Iterable[IN], out: Collector[OUT])
}

使用案例:

package cn._51doit.flink.day09;

import com.alibaba.fastjson.JSON;
import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.streaming.api.TimeCharacteristic;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.datastream.WindowedStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.ProcessFunction;
import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.SlidingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;


/**
 * apply是在窗口内进行全量的聚合,浪费资源
 */
public class HotGoodsTopN {

    public static void main(String[] args) throws Exception{

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
        env.enableCheckpointing(60000);
        env.setParallelism(1);
        //json字符串
        DataStreamSource<String> lines = env.socketTextStream("localhost", 8888);

        SingleOutputStreamOperator<MyBehavior> behaviorDataStream = lines.process(new ProcessFunction<String, MyBehavior>() {
            @Override
            public void processElement(String value, Context ctx, Collector<MyBehavior> out) throws Exception {
                try {
                    MyBehavior behavior = JSON.parseObject(value, MyBehavior.class);
                    //输出
                    out.collect(behavior);
                } catch (Exception e) {
                    //e.printStackTrace();
                    //TODO 记录出现异常的数据
                }
            }
        });

        //提取EventTime生成WaterMark
        SingleOutputStreamOperator<MyBehavior> behaviorDataStreamWithWaterMark = behaviorDataStream.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<MyBehavior>(Time.seconds(0)) {
            @Override
            public long extractTimestamp(MyBehavior element) {
                return element.timestamp;
            }
        });
        
        //按照指定的字段进行分组
        KeyedStream<MyBehavior, Tuple> keyed = behaviorDataStreamWithWaterMark.keyBy("itemId", "type");

        //窗口长度为10分组,一分钟滑动一次
        WindowedStream<MyBehavior, Tuple, TimeWindow> window = keyed.window(SlidingEventTimeWindows.of(Time.minutes(10), Time.minutes(1)));

        //SingleOutputStreamOperator<MyBehavior> sum = window.sum("counts");
        SingleOutputStreamOperator<ItemViewCount> sum = window.apply(new WindowFunction<MyBehavior, ItemViewCount, Tuple, TimeWindow>() {

            //当窗口触发是,会调用一次apply方法,相当于是对窗口中的全量数据进行计算
            @Override
            public void apply(Tuple tuple, TimeWindow window, Iterable<MyBehavior> input, Collector<ItemViewCount> out) throws Exception {
                //窗口的起始时间
                long start = window.getStart();
                //窗口的结束时间
                long end = window.getEnd();
                //获取分组的key
                String itemId = tuple.getField(0);
                String type = tuple.getField(1);

                int count = 0;
                for (MyBehavior myBehavior : input) {
                    count++;
                }
                //输出结果
                out.collect(ItemViewCount.of(itemId, type, start, end, count++));
            }
        });

        sum.print();

        env.execute();

    }
}

此处的计算是全量计算,效率不高,因为其要等到窗口数据攒足了才触发定时器,执行apply方法,这个apply方法相当于对窗口中的全量数据进行计算。假设窗口一直不触发,其会将数据缓存至窗口内存中,其实就是state中,窗口内部会有state,无需自己定义。窗口若是很长的话,缓存在内存中的数据就会很多。,解决办法是,窗口来一条数据就进行一次累加计算,即增量计算(效率更高,内存中存的知识次数)

7、Using per-window state in ProcessWindowFunction

在ProcessWindowFunction中使用每个窗口状态

ProcessWindowFunction与WindowFunction不同点在于使用ProcessWindowFunction不仅仅可以拿到窗口的院内数据信息,还可以获取WindowState和GlobalState。

  • WindowState - 表示窗口的状态,该状态值和窗口绑定的,一旦窗口消亡状态消失。
  • GlobalState - 表示窗口的状态,该状态值和Key绑定,可以累计多个窗口的值。

如果同一窗口会多次触发(如event-time触发器加上允许最大延迟时间,则有肯触发多次计算),则此功能很有用。例如可以存储每次窗口触发的次数以及最新一次触发的信息,为下一次窗口触发提供逻辑处理信息。使用Per-window State数据时要及时清理状态数据,可以覆写,调用ProcessWindowFunction的clear()完成状态数据的清理。

package com.baizhi.jsy.windowFunction
import java.text.SimpleDateFormat
import org.apache.flink.api.common.state.{ValueState, ValueStateDescriptor}
import org.apache.flink.configuration.Configuration
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.scala.function.ProcessWindowFunction
import org.apache.flink.streaming.api.windowing.assigners.TumblingProcessingTimeWindows
import org.apache.flink.streaming.api.windowing.time.Time
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
import org.apache.flink.util.Collector
object FlinkWindowProcessTumblingWithProcessWindowFunctionState {
  def main(args: Array[String]): Unit = {
    //1.创建流计算执⾏环境
    val env = StreamExecutionEnvironment.getExecutionEnvironment
    //2.创建DataStream - 细化
    val text = env.socketTextStream("Centos",9999)
    //3.执⾏行行DataStream的转换算⼦
    val counts = text.flatMap(line=>line.split("\\s+"))
      .map(word=>(word,1))
      .keyBy(t=>t._1)
      .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
        .process(new UserDefineProcessWindowFunction3)
        .print()
    //5.执⾏流计算任务
    env.execute("Aggregate Window Stream WordCount")
  }
}
class UserDefineProcessWindowFunction3 extends ProcessWindowFunction[(String,Int),(String,Int),String,TimeWindow]{
  val sdf = new SimpleDateFormat("HH;mm:ss")
  var wvsd:ValueStateDescriptor[Int]=_
  var gvsd:ValueStateDescriptor[Int]=_

  override def open(parameters: Configuration): Unit = {
    wvsd=new ValueStateDescriptor[Int]("ws",createTypeInformation[Int])
    gvsd=new ValueStateDescriptor[Int]("gs",createTypeInformation[Int])
  }
  override def process(key: String,
                       context: Context,
                       elements: Iterable[(String, Int)],
                       out: Collector[(String, Int)]): Unit = {
    val window = context.window//获取窗口元数据
    val start = sdf.format(window.getStart)
    val end = sdf.format(window.getEnd)
    val sum = elements.map(_._2).sum

    var wvs:ValueState[Int]=context.windowState.getState(wvsd)
    var gvs:ValueState[Int]=context.globalState.getState(gvsd)
    wvs.update(wvs.value()+sum)
    gvs.update(gvs.value()+sum)
    println("Window Count\t"+wvs.value()+"\tGlobal Count\t"+gvs.value())
    out.collect((key+"\t["+start+"---"+end+"]",sum))
  }
}

六、Triggers触发器

参考:https://blog.csdn.net/x950913/article/details/106203894/

1、概述:

触发器(Trigger)决定了何时启动Window Function来处理窗口中的数据以及何时将窗口内的数据清理。

增量计算窗口函数对每个新流入的数据直接进行聚合,Trigger决定了在窗口结束时将聚合结果发送出去;全量计算窗口函数需要将窗口内的元素缓存,Trigger决定了在窗口结束时对所有元素进行计算然后将结果发送出去。

每个窗口都有一个默认的Trigger,比如前文这些例子都是基于Event Time的时间窗口,当到达窗口的结束时间时,Trigger以及对应的计算被触发,触发窗口函数计算。如果我们有一些个性化的触发条件,比如窗口中遇到某些特定的元素、元素总数达到一定数量或窗口中的元素到达时满足某种特定的模式时,我们可以自定义一个Trigger。我们甚至可以在Trigger中定义一些提前计算的逻辑,比如在Event Time语义中,虽然Watermark还未到达,但是我们可以定义提前计算输出的逻辑,以快速获取计算结果,获得更低的延迟。

触发器决定窗口何时将数据交给窗口函数处理。每个窗口都有一个默认触发器。如果默认触发器不符合业务需要,也可以使用自定义的触发器。

trigger接口有五个方法允许trigger对不同的事件做出反应:

  • onElement() :窗口每收到一个元素调用该方法,返回结果决定是否触发算子
  • onEventTime(): 当注册一个event-time 定时器时会被调用。根据注册的事件事件触发
  • onProcessingTime(): 当注册一个processing-time 定时器时被调用。根据注册的处理时间定时器触发
  • onMerge(): 窗口合并时触发。与状态性触发器相关,两个窗口合并时,合并两个触发器的状态,如使用session window时,窗口会进行合并,此时调用该方法。
  • clear() :窗口关闭时触发,用于做一些清理工作。

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

1) 前三个函数通过返回TriggerResult来决定如何处理它们的调用事件。操作可以是以下操作之一:

  • CONTINUE:什么都不做
  • FIRE:触发计算并将结果发送给下游,不清理窗口数据。
  • PURGE:清理窗口数据但不执行计算。
  • FIRE_AND_PURGE:触发计算,发送结果然后清除窗口中的元素。

2) 这些方法中的任何一个都可以用于为将来的操作注册processing-time 定时器或event-time定时器。

在继续介绍Trigger的使用之前,我们可以先了解一下定时器(Timer)的使用方法。我们可以把Timer理解成一个闹钟,使用前先注册未来一个时间,当时间到达时,就像闹钟会响一样,程序会启用一个回调函数,来执行某个时间相关的任务。对于自定义Trigger来说,我们需要考虑注册时间的逻辑,当到达这个时间时,Flink会启动Window Function,清理窗口数据。

2、触发与清除

一旦一个触发器认为一个窗口已经可以进行处理,它将触发并返回FIRE或者FIRE_AND_PURGE。这意味着当前窗口马上就要触发计算,并将元素发送给计算方法。如一个带有ProcessWindowFunction的窗口,当触发器触发fire后,所有元素都被传递给ProcessWindowFunction(如果有剔除器,则先经过剔除器)。

如果窗口的计算函数时ReduceFunction、AggregateFunction或FoldFunction,则只发出它们聚合的结果,因为在窗口内部已经由这些预聚合方法进行ji's。

触发器触发的方式有两种:FIRE或者FIRE_AND_PURGE。如果是FIRE的话,将保留window中的内容,FIRE_AND_PURGE则会清除window的内容。默认情况下,触发器使用的是FIRE。

注意:清除操作仅清除window的内容,但会保留窗口的元数据信息和触发器状态。

3、默认触发器和自定义触发器

WindowAssigner的默认触发器适用于各种用例。例如,所有事件时间窗口分配程序都有一个event time trigger作为默认触发器。一旦watermark 大于窗口的endtime,那么这个触发器就会触发。

PS:GlobalWindow默认的触发器时NeverTrigger,该触发器从不出发,所以在使用GlobalWindow时必须自定义触发器。

注意:通过使用trigger()指定触发器后,将覆盖WindowAssigner的默认触发器。例如,如果为TumblingEventTimeWindows指定CountTrigger,则不再根据时间进度而仅按count触发窗口,两者不同时生效。所以,如果想同时基于时间和计数触发窗口,就必须编写自定义触发器。

flink的默认触发器有四种:

  • EventTimeTrigger:事件时间触发器,根据watermark触发。
  • ProcessingTimeTrigger:处理时间触发器,根据元素在被处理的那台机器的系统时间触发。
  • CountTrigger:计数触发器,根据元素的个数触发。
  • PurgingTrigger :清除触发器,将另一个触发器作为参数,将其转换为清除触发器,即在原有触发器的基础上,添加清除窗口内容功能。

如果需要自定义触发器的话,则需要实现Trigger抽象类 。

4、Trigger接口源码

我们看一下Flink的Trigger源码。

/**
    * T为元素类型
    * W为窗口
  */
public abstract class Trigger<T, W extends Window> implements Serializable {

    /**
     * 当某窗口增加一个元素时调用onElement方法,返回一个TriggerResult
     */
    public abstract TriggerResult onElement(T element, long timestamp, W window, TriggerContext ctx) throws Exception;

    /**
     * 当一个基于Processing Time的Timer触发了FIRE时调用onProcessTime方法
     */
    public abstract TriggerResult onProcessingTime(long time, W window, TriggerContext ctx) throws Exception;

    /**
     * 当一个基于Event Time的Timer触发了FIRE时调用onEventTime方法
     */
    public abstract TriggerResult onEventTime(long time, W window, TriggerContext ctx) throws Exception;

    /**
     * 如果这个Trigger支持状态合并,则返回true
     */
    public boolean canMerge() {
        return false;
    }

    /**
     * 当多个窗口被合并时调用onMerge
     */
    public void onMerge(W window, OnMergeContext ctx) throws Exception {
        throw new UnsupportedOperationException("This trigger does not support merging.");
    }

    /**
     * 当窗口数据被清理时,调用clear方法来清理所有的Trigger状态数据
     */
    public abstract void clear(W window, TriggerContext ctx) throws Exception

    /**
     * 上下文,保存了时间、状态、监控以及定时器
     */
    public interface TriggerContext {

        /**
         * 返回当前Processing Time
         */
        long getCurrentProcessingTime();

        /**
         * 返回MetricGroup 
         */
        MetricGroup getMetricGroup();

        /**
         * 返回当前Watermark时间
         */
        long getCurrentWatermark();

        /**
         * 将某个time注册为一个Timer,当系统时间到达time这个时间点时,onProcessingTime方法会被调用
         */
        void registerProcessingTimeTimer(long time);

        /**
         * 将某个time注册为一个Timer,当Watermark时间到达time这个时间点时,onEventTime方法会被调用
         */
        void registerEventTimeTimer(long time);

        /**
         * 将注册的Timer删除
         */
        void deleteProcessingTimeTimer(long time);

        /**
         * 将注册的Timer删除
         */
        void deleteEventTimeTimer(long time);

        /**
         * 获取该窗口Trigger下的状态
         */
        <S extends State> S getPartitionedState(StateDescriptor<S, ?> stateDescriptor);

    }

    /**
     * 将多个窗口下Trigger状态合并
     */
    public interface OnMergeContext extends TriggerContext {
        <S extends MergingState<?, ?>> void mergePartitionedState(StateDescriptor<S, ?> stateDescriptor);
    }
}

5、应用案例:

案例一:

clipboard

案例二:

接下来我们以一个提前计算的案例来解释如何使用自定义的Trigger。在股票或任何交易场景中,我们比较关注价格急跌的情况,默认窗口长度是60秒,如果价格跌幅超过5%,则立即执行Window Function,如果价格跌幅在1%到5%之内,那么10秒后触发Window Function。

class MyTrigger extends Trigger[StockPrice, TimeWindow] {

  override def onElement(element: StockPrice,
                         time: Long,
                         window: TimeWindow,
                         triggerContext: Trigger.TriggerContext): TriggerResult = {
    val lastPriceState: ValueState[Double] = triggerContext.getPartitionedState(new ValueStateDescriptor[Double]("lastPriceState", classOf[Double]))

    // 设置返回默认值为CONTINUE
    var triggerResult: TriggerResult = TriggerResult.CONTINUE

    // 第一次使用lastPriceState时状态是空的,需要先进行判断
    // 状态数据由Java端生成,如果是空,返回一个null
    // 如果直接使用Scala的Double,需要使用下面的方法判断是否为空
    if (Option(lastPriceState.value()).isDefined) {
      if ((lastPriceState.value() - element.price) > lastPriceState.value() * 0.05) {
        // 如果价格跌幅大于5%,直接FIRE_AND_PURGE
        triggerResult = TriggerResult.FIRE_AND_PURGE
      } else if ((lastPriceState.value() - element.price) > lastPriceState.value() * 0.01) {
        val t = triggerContext.getCurrentProcessingTime + (10 * 1000 - (triggerContext.getCurrentProcessingTime % 10 * 1000))
        // 给10秒后注册一个Timer
        triggerContext.registerProcessingTimeTimer(t)
      }
    }
    lastPriceState.update(element.price)
    triggerResult
  }

  // 我们不用EventTime,直接返回一个CONTINUE
  override def onEventTime(time: Long, window: TimeWindow, triggerContext: Trigger.TriggerContext): TriggerResult = {
    TriggerResult.CONTINUE
  }

  override def onProcessingTime(time: Long, window: TimeWindow, triggerContext: Trigger.TriggerContext): TriggerResult = {
    TriggerResult.FIRE_AND_PURGE
  }

  override def clear(window: TimeWindow, triggerContext: Trigger.TriggerContext): Unit = {
    val lastPrice: ValueState[Double] = triggerContext.getPartitionedState(new ValueStateDescriptor[Double]("lastPrice", classOf[Double]))
    lastPrice.clear()
  }
}

senv.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime)

val input: DataStream[StockPrice] = ...

val average = input
      .keyBy(s => s.symbol)
      .timeWindow(Time.seconds(60))
      .trigger(new MyTrigger)
      .aggregate(new AverageAggregate)

注意:在自定义Trigger时,如果使用了状态,一定要使用clear方法将状态数据清理,否则随着窗口越来越多,状态数据会越积越多。

七、Evictors 驱逐器

Flink的窗口模型允许除了WindowAssigner和Trigger之外,还指定一个可选的Evictor,用于从窗口中移除元素。

Evictor作用在触发器启动之后、窗口函数作用之前或之后移出窗口中的元素。

api调用如下:

/**
    * T为元素类型
    * W为窗口
  */
public interface Evictor<T, W extends Window> extends Serializable {

    /**
     * 在Window Function前调用,即可以在窗口处理之前剔除数据
   */
    void evictBefore(Iterable<TimestampedValue<T>> elements, int size, W window, EvictorContext evictorContext);

    /**
     * 在Window Function后调用
     */
    void evictAfter(Iterable<TimestampedValue<T>> elements, int size, W window, EvictorContext evictorContext);


    /**
     * Evictor的上下文
     */
    interface EvictorContext {

        long getCurrentProcessingTime();

        MetricGroup getMetricGroup();

        long getCurrentWatermark();
    }
}
  • victBefore():用于在窗口函数执行之前剔除元素。
  • evictAfter():用于在窗口函数执行之后剔除元素。

窗口中所有的元素被放在Iterable<TimestampedValue<T>>中,我们可以实现自己的清除逻辑。对于增量计算如ReduceFunction和AggregateFunction,没必要使用Evictor。

Flink提供了三种已实现的Evictor:

  • CountEvictor:保存指定数量的元素,多余的元素按照从前往后的顺序剔除
  • DeltaEvictor:需要传入一个DeltaFunction和一个threshold,使用DeltaFunction计算Window中最后一个元素与其余每个元素之间的增量(delta),丢弃增量大于或等于阈值(threshold)的元素
  • TimeEvictor:对于给定的窗口,提供一个以毫秒为单位间隔的参数interval,找到最大的时间max_ts,然后删除所有时间戳小于max_ts-interval的元素。

默认情况下:所有预定义的Evictor均会在窗口函数作用之前执行。

注意:

1、如果指定了剔除器,则预聚合不生效,因为在进行计算之前,每一条元素都会经过剔除器才会进入窗口。

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

3、默认情况下,Evictor都在窗口函数调用之前执行。

八、如何处理迟到的事件元素?

1、Allowed Lateness

使用事件时间(event-time)窗口时,可能会发生元素到达晚的情况,即本应该在上一个窗口处理的元素,由于延迟到达flink,而watermark已经超过窗口的endtime而启动计算,导致这个元素没有在上一个窗口中处理。

关于watermark,可以参考:https://blog.csdn.net/x950913/article/details/106246807

默认情况下,当watermark超过窗口的endtime时,将删除延迟到达的元素。但是,Flink可以为窗口指定一个最大延迟时间。Allowed lateness指定watermark超过endtime多少时间后,再收到事件时间在该窗口内的元素时,会再次触发窗口的计算,其默认值为0。watermark超过endtime后,会触发一次计算,在允许延迟的时间范围内到达的元素,仍然会添加到窗口中,且再次触发计算。所以如果使用的是EventTimeTrigger触发器,延迟但未丢弃的元素可能会导致窗口再次触发。

默认情况下,允许的延迟设置为0。也就是说,在endtime后到达的元素将被删除。

指定允许延迟时间,如下所示:

val input: DataStream[T] = ...
 
input
    .keyBy(<key selector>)
    .window(<window assigner>)
    .allowedLateness(<time>)
    .<windowed transformation>(<window function>)

另外,当使用GlobalWindows窗口时,任何数据都不会被认为是延迟的,因为全局窗口的结束时间戳是Long的最大值.

2、将迟到元素从侧输出流输出

可以将延迟到达的元素从侧输出流中输出。

首先需要创建一个OutputTag用于接收延迟数据。然后,指定将窗口中的延迟数据发送到OutputTag中:

val lateOutputTag = OutputTag[T]("late-data")
 
val input: DataStream[T] = ...
 
val result = input
    .keyBy(<key selector>)
    .window(<window assigner>)
    .allowedLateness(<time>)
    .sideOutputLateData(lateOutputTag)
    .<windowed transformation>(<window function>)
 
val lateStream = result.getSideOutput(lateOutputTag)

3、迟到元素处理注意事项

当指定允许的延迟大于0时,在watermark超过窗口的endtime后,仍会保留窗口及其内容。在这些情况下,当一个延迟但未被丢弃的元素到达时,它可能会再次触发这个窗口的触发器。此时这些触发器的Fire被称为late firings,因为它们是由迟到元素触发的,而主Fire是窗口的第一次Fire。在会话窗口(session windows)的情况下,延迟触发可能进一步导致窗口合并,因为它们可能“桥接”两个预先存在的未合并窗口。

注意:延迟到达的元素所触发的计算应被视为对先前计算的结果的更新,即watermark到达endtime后窗口会进行一次计算,之后延迟到达的元素会触发新的计算来更新计算结果,所以数据流将包含同一计算的多个结果。根据应用场景不同,需要考虑是否需要消除重复数据。

九、窗口计算后还可以做什么?

使用窗口计算后得出的结果仍是一个DataStream,该DataStream中的元素不会保留有关窗口的信息。所以,如果需要保存窗口的元数据信息,就必须编写代码,在ProcessWindowFunction中将窗口的元数据信息与元素进行整合。输出元素中的时间戳是唯一与窗口相关的信息,可以将其设置为窗口的最大允许延迟时间,即endtime-1,因为endtime之前的元素才属于这个窗口,大于等于endtime的则属于下一个窗口(event-time windows 和processing-time windows都是这样的)。

在经过窗口函数处理后的元素总会包含一个时间戳,可以是event-time时间戳也可以是 processing-time时间戳。

对于 processing-time 窗口可能没什么作用,但对于event-time窗口,配合watermark机制,可以将event-time属于同一窗口的元素放在另一个窗口中处理,即连续的窗口操作,在下面有介绍。

watermarks对窗口的作用

在此稍微提及一点关于watermark和窗口的作用。

触发器基于watermark有两种处理方式:

  • 当watermark大于等于窗口的endtime时,触发窗口计算。
  • 当watermark小于于窗口的endtime时,则将watermark转发给下游操作(维护watermark为窗口中所有元素中的最大时间戳)

连续的窗口操作

如上所述,经过窗口函数计算后的结果仍然带有时间戳,且与watermark搭配使用,就可以连续使用多个窗口操作。如在上游窗口计算之后,仍可以对得出的结果以不同的key、不同的function进行计算。如:

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秒),经过第一个窗口计算后,产生的结果传入第二个窗口时也属于0~5秒的窗口,即同窗口的元素在下游窗口中仍属于同一窗口。如第一个窗口计算0~5秒内每个key的和,再在第二个窗口中取出0~5秒内key的和的TopK。

十、如何估计窗口存储大小?

定义窗口的时间范围时,可以定义一个很长的时间,甚至是几天、几周或几月。因此窗口可以累积相当多的数据。在估计窗口计算的存储量时,需要记住以下几个规则:

Flink会为每个窗口的所有元素都创建一个副本,因此,滚动窗口对每个元素仅保留一个副本,因为一个元素恰好仅属于一个窗口,除非它被搁置(dropped late)。相反,滑动窗口会为元素保存多个副本,因为一个元素可能属于多个窗口,每个窗口都会保存一次。所以,如果使用滑动窗口,那么应该尽量避免窗口大小太大,而滑动步长小的情况,如窗口大小为1天,滑动步长为1秒。

ReduceFunction,AggregateFunction和FoldFunction可以大大减少存储需求,因为他们会尽量早地对元素进行聚合,且每个窗口仅存储一个值,而不是所有元素。相反,ProcessWindowFunction需要存储每个元素。

使用剔除器Evictor会阻止所有预聚合操作,因为在算之前,必须将窗口的所有元素传递到剔除器。


参考引用:

官网:https://nightlies.apache.org/flink/flink-docs-release-1.14/docs/dev/datastream/operators/windows/

https://segmentfault.com/a/1190000022106275 (赞)

https://zhuanlan.zhihu.com/p/102325190 (爆赞)

https://blog.csdn.net/x950913/article/details/106203894/ (爆赞)

posted @ 2021-12-24 15:20  doublexi  阅读(2474)  评论(0编辑  收藏  举报