【Flink】窗口和时间

「这是我参与2022首次更文挑战的第23天,活动详情查看:2022首次更文挑战

一、Flink的窗口(TimeWindow

Flink 认为 BatchStreaming 的一个特例, 因此 Flink 底层引擎是一个流式引擎, 在上面实现了流处理和批处理。而 Window 就是从 StreamingBatch 的桥梁。

通俗讲:Window 是用来对一个无限的流设置一个有限的集合, 从而在有界的数据集上进行操作的一种机制。流上的集合由 Window 来划定范围, 比如 “计算过去10分钟” 或者 最后50个元素的和”。

Window 可以由时间 (Time Window) (比如每30s) 或者 数据 (Count Window)(如每100个元素) 驱动。

DataStream API 提供了 TimeCountWindow

窗口数据划分的不同:

  • 滚动窗口:窗口数据有固定的大小,窗口中的数据不会叠加;

  • 滑动窗口:窗口数据有固定的大小,并且有生成间隔;

  • 会话窗口:窗口数据没有固定的大小,根据用户传入的参数进行划分,窗口数据无叠加。

编程基本步骤:

  1. 获取流数据源

  2. 获取窗口

  3. 操作窗口数据

  4. 输出窗口数据

(1)滚动窗口

将数据依据固定的窗口长度对数据进行切分。

特点: 时间对齐, 窗口长度固定, 没有重叠。

如图:

2020-07-1213:36.png

两个场景:

  1. 基于时间驱动
  2. 基于事件驱动

代码示例如下:

package com.donaldy.demo.window;

import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.api.java.tuple.Tuple2;
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.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import java.text.SimpleDateFormat;
import java.util.Random;

/**
 * @author donald
 * @date 2021/04/16
 */
public class TumblingWindow {

    public static void main(String[] args) {

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        DataStreamSource<String> dataStreamSource = env.socketTextStream("127.0.0.1", 7788);

        SingleOutputStreamOperator<Tuple2<String, Integer>> mapStream =
                dataStreamSource.map(new MapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> map(String value) throws Exception {
                        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
                        long timeMillis = System.currentTimeMillis();
                        int random = new Random().nextInt(10);
                        System.out.println("value: " + value + " random: " + random + "timestamp: " + timeMillis + "|" + format.format(timeMillis));
                        return new Tuple2<>(value, random);
                    }
                });

        KeyedStream<Tuple2<String, Integer>, Tuple> keyedStream = mapStream.keyBy(0);

        // 基于时间驱动,每隔10s划分一个窗口
        WindowedStream<Tuple2<String, Integer>, Tuple, TimeWindow> timeWindow = keyedStream.timeWindow(Time.seconds(10));

        // 基于事件驱动, 每相隔3个事件(即三个相同key的数据), 划分一个窗口进行计算
        // WindowedStream<Tuple2<String, Integer>, Tuple, GlobalWindow> countWindow = keyedStream.countWindow(3);
        // apply 是窗口的应用函数, 即apply里的函数将应用在此窗口的数据上。
        timeWindow.apply(new MyTimeWindowFunction()).print();

        // countWindow.apply(new MyCountWindowFunction()).print();
        try {

            // 转换算子都是lazy init的, 最后要显式调用 执行程序
            env.execute();

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
复制代码

1)基于时间驱动

场景: 需要统计每一分钟中用户购买的商品的总数, 需要将用户的行为事件按每一分钟进行切分, 这种切分被成为翻滚时间窗口(Tumbling Time Window)。

代码如下:

package com.donaldy.demo.window;

import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import java.text.SimpleDateFormat;

/**
 * @author donald
 * @date 2021/04/16
 */
public class MyTimeWindowFunction implements WindowFunction<Tuple2<String,Integer>,
        String, Tuple, TimeWindow> {
    @Override
    public void apply(Tuple tuple, TimeWindow window,
                      Iterable<Tuple2<String, Integer>> input, Collector<String> out) {

        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");

        int sum = 0;

        for(Tuple2<String,Integer> tuple2 : input){
            sum += tuple2.f1;
        }

        long start = window.getStart();
        long end = window.getEnd();

        out.collect("key:" + tuple.getField(0) + " value: " + sum + "| window_start :"
                        + format.format(start) + " window_end :" + format.format(end));
    }
}
复制代码

2)基于事件驱动

场景: 当想要每100个用户的购买行为作为驱动, 那么每当窗口中填满 100个 “相同” 元素了, 就会对窗口进行计算。

代码示例如下:

package com.donaldy.demo.window;

import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.windows.GlobalWindow;
import org.apache.flink.util.Collector;
import java.text.SimpleDateFormat;

/**
 * @author donald
 * @date 2021/04/16
 */
public class MyCountWindowFunction implements WindowFunction<Tuple2<String, Integer>, String, Tuple, GlobalWindow> {

    @Override
    public void apply(Tuple tuple, GlobalWindow window,
                      Iterable<Tuple2<String, Integer>> input, Collector<String> out) {
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");

        int sum = 0;

        for (Tuple2<String, Integer> tuple2 : input){

            sum += tuple2.f1;
        }
        //无用的时间戳, 默认值为: Long.MAX_VALUE, 因为基于事件计数的情况下, 不关心时间。
        long maxTimestamp = window.maxTimestamp();
        out.collect("key:" + tuple.getField(0) + " value: " + sum + "| maxTimeStamp :"
                + maxTimestamp + "," + format.format(maxTimestamp));
    }
}
复制代码

(2)滑动窗口

滑动窗口是固定窗口的更广义的一种形式, 滑动窗口由固定的窗口长度和滑动间隔组成。

特点: 窗口长度固定, 可以有重叠。

如图:

2020-07-1213:39.png

1)基于时间的滑动窗口

场景: 可以每30秒计算一次最近一分钟用户购买的商品总数。

2)基于事件的滑动窗口

场景: 每10个 “相同” 元素计算一次最近 100 个元素的总和。

package com.donaldy.demo.window;

import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.api.java.tuple.Tuple2;
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.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.GlobalWindow;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;

import java.text.SimpleDateFormat;
import java.util.Random;

/**
 * @author donald
 * @date 2021/04/16
 */
public class SlidingWindow {
    public static void main(String[] args) {

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        DataStreamSource<String> dataStreamSource = env.socketTextStream("127.0.0.1", 7788);
        SingleOutputStreamOperator<Tuple2<String, Integer>> mapStream =
                dataStreamSource.map(new MapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> map(String value) throws Exception {
                        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
                        long timeMillis = System.currentTimeMillis();
                        int random = new Random().nextInt(10);
                        System.err.println("value : " + value + " random : " + random + " timestamp : " + timeMillis + "|" + format.format(timeMillis));
                        return new Tuple2<>(value, random);
                    }
                });
        KeyedStream<Tuple2<String, Integer>, Tuple> keyedStream = mapStream.keyBy(0);
        // 基于时间驱动, 每隔 5s 计算一下最近 10s 的数据
        WindowedStream<Tuple2<String, Integer>, Tuple, TimeWindow> timeWindow =
                keyedStream.timeWindow(Time.seconds(10), Time.seconds(5));

        // 基于事件驱动,每隔2个事件,触发一次计算,本次窗口的大小为3,代表窗口里的每种事件最多为3个
        WindowedStream<Tuple2<String, Integer>, Tuple, GlobalWindow> countWindow =
                keyedStream.countWindow(3, 2);
        timeWindow.sum(1).print();
        countWindow.sum(1).print();
        timeWindow.apply(new MyTimeWindowFunction()).print();

        try {
            env.execute();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
复制代码

(3)会话窗口

由一系列事件组合一个指定时间长度的 timeout 间隙组成, 类似于 web 应用的 session, 也就是一段时间没有接收到新数据就会生成新的窗口。

session 窗口分配器通过 session 活动来对元素进行分组, session 窗口跟滚动窗口和滑动窗口相比, 不会有重叠和固定的开始时间和结束时间的情况。

session 窗口在一个固定的时间周期内不再收到元素, 即非活动间隔产生, 那么这个窗口就会关闭。

一个 session 窗口通过一个 session 间隔来配置, 这个 session 间隔定义了非活跃周期的长度, 当这个非活跃周期产生, 那么当前的 session 将关闭并且后续的元素将被分配到新的 session 窗口中去。

特点:

  • 会话窗口不重叠, 没有固定的开始和结束时间。

  • 与翻滚窗口和滑动窗口相反, 当会话窗口在一段时间内没有接收到元素时会关闭会话窗口。

  • 后续的元素将会被分配给新的会话窗口。

如图:

2020-07-1213:41.png

案例描述:计算每个用户在活跃期间总共购买的商品数量, 如果用户30秒没有活动则视为会话断开。

二、Flink的时间

Flink 中的时间分为三种:

  • 事件时间(Event Time,即事件实际发生的时间;

  • 摄入时间(Ingestion Time,事件进入流处理框架的时间;

  • 处理时间(Processing Time,事件被处理的时间。

关系如图:

2020-07-1313:42.png

(1)事件时间(Event Time

事件时间(Event Time)指的是数据产生的时间,这个时间一般由数据生产方自身携带,比如 Kafka 消息,每个生成的消息中自带一个时间戳代表每条数据的产生时间。

利用 Event Time 需要指定如何生成事件时间的“水印”,并且一般和窗口配合使用。

可以在代码中指定 Flink 系统使用的时间类型为 EventTime

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//设置时间属性为 EventTime
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);

DataStream<MyEvent> stream = env.addSource(new FlinkKafkaConsumer09<MyEvent>(topic, schema, props));

stream
    .keyBy( (event) -> event.getUser() )
    .timeWindow(Time.hours(1))
    .reduce( (a, b) -> a.add(b) )
    .addSink(...);
复制代码

Flink 注册 EventTime 是通过 InternalTimerServiceImpl.registerEventTimeTimer 来实现的:

public void registerEventTimeTimer(N namespace, long time) {
    this.eventTimeTimersQueue.add(new TimerHeapInternalTimer(time, this.keyContext.getCurrentKey(), namespace));
}
复制代码

可以看到,该方法有两个入参:namespacetime,其中 time 是触发定时器的时间,namespace 则被构造成为一个 TimerHeapInternalTimer 对象,然后将其放入 KeyGroupedInternalPriorityQueue 队列中。

那么 Flink 什么时候会使用这些 timer 触发计算呢?

// InternalTimeServiceImpl.advanceWatermark 中

    public void advanceWatermark(long time) throws Exception {
        this.currentWatermark = time;

        InternalTimer timer;
        while((timer = (InternalTimer)this.eventTimeTimersQueue.peek()) != null && timer.getTimestamp() <= time) {
            this.eventTimeTimersQueue.poll();
            this.keyContext.setCurrentKey(timer.getKey());
            this.triggerTarget.onEventTime(timer);
        }

    }
复制代码

这个方法中的 while 循环部分会从 eventTimeTimersQueue 中依次取出触发时间小于参数 time 的所有定时器,调用 triggerTarget.onEventTime() 方法进行触发。

这就是 EventTime 从注册到触发的流程。

(2)处理时间(Processing Time

处理时间(Processing Time)指的是数据被 Flink 框架处理时机器的系统时间,Processing TimeFlink 的时间系统中最简单的概念,但是这个时间存在一定的不确定性,比如消息到达处理节点延迟等影响。

可以在代码中指定 Flink 系统使用的时间为 Processing Time

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
复制代码

在源码中找到 Flink 是如何注册和使用 Processing Time

    public void registerProcessingTimeTimer(N namespace, long time) {
        InternalTimer<K, N> oldHead = (InternalTimer)this.processingTimeTimersQueue.peek();
        if (this.processingTimeTimersQueue.add(new TimerHeapInternalTimer(time, this.keyContext.getCurrentKey(), namespace))) {
            long nextTriggerTime = oldHead != null ? oldHead.getTimestamp() : 9223372036854775807L;
            if (time < nextTriggerTime) {
                if (this.nextTimer != null) {
                    this.nextTimer.cancel(false);
                }

                this.nextTimer = this.processingTimeService.registerTimer(time, this);
            }
        }

    }
复制代码

registerProcessingTimeTimer() 方法为我们展示了如何注册一个 ProcessingTime 定时器: 每当一个新的定时器被加入到 processingTimeTimersQueue 这个优先级队列中时,如果新来的 Timer 时间戳更小,那么更小的这个 Timer 会被重新注册 ScheduledThreadPoolExecutor 定时执行器上。

Processing Time 被触发是在 InternalTimeServiceImplonProcessingTime() 方法中:

    public void onProcessingTime(long time) throws Exception {
        this.nextTimer = null;

        InternalTimer timer;
        while((timer = (InternalTimer)this.processingTimeTimersQueue.peek()) != null && timer.getTimestamp() <= time) {
            this.processingTimeTimersQueue.poll();
            this.keyContext.setCurrentKey(timer.getKey());
            this.triggerTarget.onProcessingTime(timer);
        }

        if (timer != null && this.nextTimer == null) {
            this.nextTimer = this.processingTimeService.registerTimer(timer.getTimestamp(), this);
        }

    }
复制代码

一直循环获取时间小于入参 time 的所有定时器,并运行 triggerTargetonProcessingTime() 方法。

(3)摄入时间(Ingestion Time

摄入时间(Ingestion Time)是事件进入 Flink 系统的时间,在 FlinkSource 中,每个事件会把当前时间作为时间戳,后续做窗口处理都会基于这个时间。 理论上 Ingestion Time 处于 Event TimeProcessing Time之间。

与事件时间相比,摄入时间无法处理延时和无序的情况,但是不需要明确执行如何生成 watermark。在系统内部,摄入时间采用更类似于事件时间的处理方式进行处理,但是有自动生成的时间戳和自动的 watermark

可以防止 Flink 内部处理数据是发生乱序的情况,但无法解决数据到达 Flink 之前发生的乱序问题。如果需要处理此类问题,建议使用 EventTime

Ingestion Time 的时间类型生成相关的代码在 AutomaticWatermarkContext 中:

猜你喜欢

转载自juejin.im/post/7062743178260021278