Flink DataStream Window Processing

653 阅读6分钟

窗口计算就是将无界连续的流数据分成一个一个有界的窗口,窗口有开始和结束限制,然后对窗口内的数据进行统计分析。
如果没有窗口计算,那么无界的数据量无法做统计的。 可以理解为,把无界流数据转换成一批一批数据进行处理。

从窗口计算的给角度,我之前一直有一个问题,为什么Flink是更好的流处理框架,而Spark是微批处理?其实Flink和Spark都是要把流计算分成微批计算的,否则无界数据是无法计算分析的。
其实,Flink和Spark对流计算的最大区别并不应该窗口计算来看,窗口计算只是表象,而是它们底层的数据模型决定了Flink是比Spark延时更低的更好的流计算处理引擎。
Flink是有状态计算,在每一个分区,一个事件进来之后就会被计算并更新状态。那么每一个事件在一个节点上计算完成之后就可以进入下一个节点计算,不用等待。而Spark需要在一个节点全部计算完毕之后才能进入下一个节点计算,这就需要等待。

分类:

  • Keyed window processing
.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 window processing
.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"

下面我们一个一个了解一下每一个组件。

Assigner窗口分配器

Assigner定义使用什么样的窗口。

Tumbling window 滚动窗口

image.png
数据不会重复计算。

//keyed time: 
stream.timeWindow(Time.minutes(5))
stream.window(TumblingEventTimeWindows.of(Time.minutes(5)))
stream.window(TumblingProcessingTimeWindows.of(Time.minutes(5)))
//keyed count: 
stream.countWindow(5)
//non-keyed time:
stream.timeWindowAll(Time.minutes(5))
stream.timeWindowAll(TumblingProcessingTimeWindows.of(Time.minutes(5)))
//non-keyed count:
stream.countWindowAll(5)

Sliding windows 滑动窗口

image.png
上图的窗口长度为8,是第一个参数,滑动size是4,是第二个参数。

//keyed time: 
stream.timeWindow(Time.minutes(10),Time.minutes(5))
stream.window(SlidingEventTimeWindows.of(Time.minutes(10),Time.minutes(5)))
stream.window(SlidingProcessingTimeWindows.of(Time.minutes(10),Time.minutes(5)))
//keyed count: 
stream.timeWindow(10,5)
//non-keyed time:
stream.timeWindowAll(Time.minutes(10),Time.minutes(5))
stream.timeWindowAll(SlidingProcessingTimeWindows.of(Time.minutes(10),Time.minutes(5)))
//non-keyed count:
stream.countWindowAll(10,5)

Session windows 会话窗口

image.png
当事件间隔时间(Session Gap)大于设定只的时候切分窗口,如上图设置的是3。

keyedStream.window(EventTimeSessionWindows.withGap(Time.seconds(3)))
keyedStream.window(ProcessingTimeSessionWindows.withGap(Time.seconds(3)))
//动态生成session gap
stream.window(DynamicProcessingTimeSessionWindows.withDynamicGap(
        new SessionWindowTimeGapExtractor<Tuple2<String, Integer>>() {
            @Override
            public long extract(Tuple2<String, Integer> element) {
                if (element.f0.equals("custom_id")){
                    return 3000;
                }else {
                    return 5000;
                }
            }
        }
))

stream.window(DynamicEventTimeSessionWindows.withDynamicGap(
        new SessionWindowTimeGapExtractor<Tuple2<String, Integer>>() {
            @Override
            public long extract(Tuple2<String, Integer> element) {
                if (element.f0.equals("custom_id")){
                    return 3000;
                }else {
                    return 5000;
                }
            }
        }
))

Global windows 全局窗口

Global windows将具有相同键的所有元素分配给同一个全局窗口。它必须配合自定义的Trigger一起使用,否则将不进行计算。

stream.window(GlobalWindows.create()).trigger(CountTrigger.of(3)) //3条数据触发

Trigger触发器 -Required

Trigger定义了什么时候触发窗口计算。一般来说Assigner是有默认的Trigger的。所以Trigger是Optional的。
如果需要自定义Trigger,需要实现Trigger类。实现以下方法:

  • onElement() 每一个新元素都会调用此方法。
  • onEventTime() 事件时间计时器触发时,将调用此方法。
  • onProcessingTime() 处理时间计时器触发时,将调用此方法。
  • onMerge() 与有状态触发器相关,并在两个触发器对应的窗口合并时合并它们的状态,例如在使用会话窗口时。
  • clear() 执行删除相应窗口时所需的任何操作。(一般是删除定义的状态、定时器等)

在1-3个方法中需要返回TriggerResult,result来决定是否需要触发窗口计算。

  • CONTINUE: 不触发
  • FIRE: 触发计算
  • PURGE: 清除窗口内的数据
  • FIRE_AND_PURGE: 触发计算并清除窗口内的数据

Flink内置Trigger

  • ProcessingTimeTrigger: 基于ProcessingTime触发,当机器时间大于窗口结束时间时触发
  • EventTimeTrigger: 基于EventTime,当watermark大于窗口结束时间触发
  • ContinuousProcessingTimeTrigger: 基于processingtine的固定时间间隔触发
  • ContinuousEventTimeTrigger: 基于eventtime的固定时间间隔触发
  • CountTrigger : 基于Element的固定条数触发
  • DeltaTrigger: 本次Element和上次Element做Delta计算,超过指定阈值就触发窗口计算
  • PuringTrigger: 用于Trigger触发后额外清理中间状态数据

自定义Trigger

需求: 窗口被EventTime触发或者窗口数据达到10条触发

public class MyCustomTrigger<T> extends Trigger<T, TimeWindow> {
    private int maxCount;
    private ValueStateDescriptor<Integer> countState = new ValueStateDescriptor("countState", Integer.class);
    public MyCustomTrigger(int maxCount) {
        this.maxCount = maxCount;
    }
    private TriggerResult fireAndPurge(TimeWindow window, TriggerContext ctx) throws Exception {
        clear(window, ctx);
        return TriggerResult.FIRE_AND_PURGE;
    }
    @Override
    public TriggerResult onElement(T element, long timestamp, TimeWindow window, TriggerContext ctx) throws Exception {
        ValueState<Integer> state = ctx.getPartitionedState(countState);
        if (null == state.value()) {
            state.update(0);
        }
        int newValue = state.value()+1;
        state.update(newValue);
        if (state.value() >= 2) {
            fireAndPurge(window, ctx);
        }
        return TriggerResult.CONTINUE ;
    }
    @Override
    public TriggerResult onProcessingTime(long time, TimeWindow window, TriggerContext ctx) throws Exception {
        return TriggerResult.CONTINUE;

    }
    @Override
    public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) throws Exception {
        if (time >= window.getEnd()) {
            return TriggerResult.CONTINUE;
        } else {
            return fireAndPurge(window, ctx);
        }
    }
    @Override
    public void clear(TimeWindow window, TriggerContext ctx) throws Exception {
        ValueState<Integer> state = ctx.getPartitionedState(countState);
        state.clear();
    }
}

//SlidingEventTimeWindows触发的同事,count也能触发
.window(SlidingEventTimeWindows.of(Time.minutes(10), Time.minutes(5)))
.trigger(new MyCustomTrigger(10))

Evictor过滤器 -Optional

Evictor可以在窗口函数前后过滤掉窗口中的一部分数据。在窗口函数之前过滤掉的数据,将不会被函数计算。
Flink内置的Evictor:

  • CountEvictor: 保留一定数量的数据,并从缓冲区头部剔除数据。
  • DeltaEvictor: 每一个事件与上一个事件比较,超过阈值就剔除数据。
  • TimeEvictor: 给定一个毫秒为单位的时间,一个窗口中最大的时间减去这个设定值,比这个结果时间小的数据全部剔除.

Lateness -Optional

默认情况下,Lateness为0,当窗口的结束时间比watermarks小,那么这个窗口数据就会就算结束并且被丢弃。
如果设置Lateness大于0,那么当窗口的结束时间比watermarks小时,窗口会延时Lateness时间然后才会被丢弃。而这个时间段内进来的数据仍然会加入到窗口中,并且,新的数据依然会触发窗口计算。

.allowedLateness(<time>)

SideOutput

Flink中如果想获取所有的迟到数据,可以使用SideOutput。

//定义标签
final OutputTag<T> lateOutputTag = new OutputTag<T>("late-data"){};

stream
    .keyBy(<key selector>)
    .window(<window assigner>)
    .allowedLateness(<time>)
    //打标签
    .sideOutputLateData(lateOutputTag)
    .<windowed transformation>(<window function>);
//根据标签获取迟到数据
DataStream<T> lateStream = result.getSideOutput(lateOutputTag);

你可以对任意的数据打标签,然后旁路输出,在一下方法中都可以打标签:

  • ProcessFunction
  • KeyedProcessFunction
  • CoProcessFunction
  • KeyedCoProcessFunction
  • ProcessWindowFunction
  • ProcessAllWindowFunction
//Example
final OutputTag<String> outputTag = new OutputTag<String>("side-output"){};
stream
.process(new ProcessFunction<Integer, Integer>() {
  @Override
  public void processElement(
      Integer value,
      Context ctx,
      Collector<Integer> out) throws Exception {
    // 发送数据到主要的输出
    out.collect(value);
    // 发送数据到旁路输出
    ctx.output(outputTag, "sideout-" + String.valueOf(value));
  }
});

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

Function -Required

窗口函数定义了如何对窗口中的数据进行计算,分为以下三类:

  • ReduceFunction
  • AggregateFunction
  • ProcessWindowFunction

ReduceFunction

ReduceFunction定义了如何将输入的两个事件数据进行合并,并输出与输入一致数据格式的结果。

stream
    .keyBy(...)
    .window(...)
    // 累加value值
    .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

sum/min/max/sumBy/minBy/maxBy AggregateFunction定义了将两个输入的事件数据进行合并,但是它的输入输出可以是不一致的数据结构。
并且在AggregateFunction中可以定义中间变量,使得函数可以做更复杂的处理。
比如统计平均值,需要统计sum 和 count:

//第一个参数是输入数据类型
//第二个参数是中间变量的数据类型
//第三个参数是输出数据类型
private static class AverageAggregate implements AggregateFunction<Tuple2<String, Integer>, Tuple3<String,Integer, Integer>, Tuple2<String, Double>> {
    @Override
    public Tuple3<String, Integer, Integer> createAccumulator() {
        return new Tuple3<>("",0, 0);
    }

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

    @Override
    public Tuple2<String, Double> getResult(Tuple3<String, Integer, Integer> accumulator) {
        return new Tuple2<>(accumulator.f0, ((double) accumulator.f1) / accumulator.f2);
    }

    @Override
    public Tuple3<String, Integer, Integer> merge(Tuple3<String, Integer, Integer> a, Tuple3<String, Integer, Integer> b) {
        return new Tuple3<>(a.f0, a.f1 + b.f1, a.f2 + b.f2);
    }
}

SingleOutputStreamOperator<Tuple2<String, Double>> aggregate = stream
        .window(SlidingEventTimeWindows.of(Time.minutes(10), Time.minutes(5)))
        .aggregate(new AverageAggregate());

ProcessWindowFunction

ProcessWindowFunction的效率比Reduce和Aggregate低,前面两种可以在每一个元素到达的时候,进行计算并记录计算状态,而这种方式会缓冲所有的事件数据,等到窗口关闭的时候,载获取所有的事件迭代器。
ProcessWindowFunction中可以获取窗口的元数据信息以及状态信息Context对象,所以在ProcessWindow中可以更加灵活,更加复杂的处理数据。 自定义ProcessWindowFunction必须继承ProcessWindowFunction:

/**
* IN 输入数据类型
* OUT 输出数据类型
* KEY 输入数据key的类型
* W window的类型
*/
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;
    //统计count值
    for (Tuple2<String, Long> in: input) {
      count++;
    }
    out.collect("Window: " + context.window() + "count: " + count);
  }
}

由于ProcessWindowFunction的性能比Reduce/Aggregate差,所以尽量使用Reduce/Aggregate代替ProcessWindow。
Reduce & ProcessWindowFunction

//结果输出窗口的开始时间和最小value
//单独使用reduce只能得到最小的value,得不到window开始时间
//单独使用ProcessWindow可以实现但是计算最小的value要等到窗口关闭时计算,性能差
stream
  .keyBy(<key selector>)
  .window(<window assigner>)
  //结合Reduce and ProcessWindow,这样每一条数据进来的时候都可以计算最小value
  .reduce(new MyReduceFunction(), new MyProcessWindowFunction());

//获取最小value
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) {
      //不用循环计算最小value
      SensorReading min = minReadings.iterator().next();
      out.collect(new Tuple2<Long, SensorReading>(context.window().getStart(), min));
  }
}

Aggregate & ProcessWindowFunction

stream
  .keyBy(<key selector>)
  .window(<window assigner>)
  //结合Aggregate and ProcessWindow
  .aggregate(new AverageAggregate(), new MyProcessWindowFunction());

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