Flink - DataStream Window & Time

198 阅读10分钟

Flink 之所以这么流行,离不开它的核心的四大基石。

image.png

Window

概述

聚合事件(例如,计数、总和)在流上的工作方式与批处理不同。 例如,不可能计算流中的所有元素,因为流通常是无限的(无界)。 相反,流上的聚合(计数、总和等)由窗口限定,例如“过去 5 分钟的计数”或“最后 100 个元素的总和”。

Windows 可以是时间驱动的(例如:每 30 秒)或数据驱动的(例如:每 100 个元素)。 通常区分不同类型的窗口,例如滚动窗口 - tumbling windows(无重叠)、滑动窗口 - sliding windows(有重叠)和会话窗口 - session windows(由不活动的间隙打断)。

image.png

请查看此博客文章以获取更多窗口示例,或查看 DataStream API 的窗口文档

实例

考虑一个交通传感器的例子,它每 15 秒计算一次通过某个位置的车辆数量。 结果流可能如下所示:

image.png

如果您想知道有多少车辆通过了该位置,您只需将各个计数相加即可。 然而,传感器流的本质是它不断地产生数据。 这样的流永远不会结束,并且不可能计算出可以返回的最终总和。 相反,可以计算滚动总和,即为每个输入事件返回更新的总和记录。 这将产生一个新的部分和的流。

image.png 然而,部分和的流可能不是我们想要的,因为它不断更新计数,更重要的是,一些信息(例如随时间的变化)丢失了。 因此,我们可能想重新表述我们的问题并询问每分钟通过该位置的汽车数量。 这要求我们将流的元素分组为有限集,每个集对应于 60 秒。 此操作称为滚动窗口操作tumbling windows

image.png

滚动窗口将流离散为不重叠的窗口。 对于某些应用程序,重要的是窗口不要分离,因为应用程序可能需要平滑聚合。 例如,我们可以每三十秒计算最后一分钟通过的汽车数量。 这种窗口称为滑动窗口sliding windows

image.png

如前所述,在数据流上定义窗口是一种非并行操作。 这是因为流的每个元素都必须由同一个窗口运算符处理,该运算符决定元素应添加到哪些窗口。 全流上的 Windows 在 Flink 中称为 AllWindows。 对于许多应用程序,需要将数据流分组为多个逻辑流,每个逻辑流都可以应用窗口运算符。 例如,考虑来自多个交通传感器(而不是我们前面的示例中只有一个传感器)的车辆计数流,其中每个传感器监控不同的位置。 通过按传感器 id 对流进行分组,我们可以并行计算每个位置的窗口流量统计信息。 在 Flink 中,我们将这种分区窗口简称为 Windows,因为它们是分布式流的常见情况。 下图显示了通过 (sensorId, count) 对元素流收集两个元素的滚动窗口。

image.png

基于时间的滚动和滑动

  • 需求1: 每 5 秒中统计一次,最近 5 秒钟内各个路口信灯的汽车数量 --》基于时间的滚动窗口
  • 需求2: 每 5 秒中统计一次,最近 10 秒钟内各个路口信灯的汽车数量 --》基于时间的滑动窗口 代码示例:
package com.learn.flink.window;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.flink.api.common.RuntimeExecutionMode;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.SlidingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;

/**
 * window - flink window 的滚动/滑动
 * 模拟不同交通信号编号的车辆的数量
 * 2,5
 * 3,5
 * 4,5
 * 1,5
 * 需求1: 每 5 秒中统计一次,最近 5 秒钟内各个路口信灯的汽车数量 --》基于时间的滚动窗口
 * 需求2: 每 5 秒中统计一次,最近 10 秒钟内各个路口信灯的汽车数量 --》基于时间的滑动窗口
 */
public class WindowDemo1 {

    public static void main(String[] args) throws Exception {
        //0: env
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setRuntimeMode(RuntimeExecutionMode.AUTOMATIC);
        //1: source
        DataStream<String> ds = env.socketTextStream("node01", 999);
        //2: transformation
        final DataStream<CarInfo> carDS = ds.map((String value) -> {
            final String[] arr = value.split(",");
            return new CarInfo(arr[0], Integer.parseInt(arr[1]));
        });

        final KeyedStream<CarInfo, String> grouped = carDS.keyBy(CarInfo::getSensorId);
        // 需求1: 每 5 秒中统计一次,最近 5 秒钟内各个路口信灯的汽车数量
//        final SingleOutputStreamOperator<CarInfo> result1 = grouped
//                .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
//                .sum("count");
        //需求2: 每 5 秒中统计一次,最近 10 秒钟内各个路口信灯的汽车数量
        final SingleOutputStreamOperator<CarInfo> result2 = grouped
                .window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5)))
                .sum("count");
        //3: sink
        result2.print();
        //4: execute
        env.execute();
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class CarInfo {
        private String sensorId;
        private int count;
    }
}

基于数量的滚动和滑动

  • 需求1: 统计最近 5 条消息中各个路口信灯的汽车数量, 相同的 key 出现 5 次进行统计;
  • 需求2: 统计最近 5 条消息中各个路口信灯的汽车数量, 相同的 key 出现 3 次进行统计;
package com.learn.flink.window;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.flink.api.common.RuntimeExecutionMode;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

/**
 * window - flink window 的滚动/滑动
 * 模拟不同交通信号编号的车辆的数量
 * 1,1
 * 1,1
 * 2,1
 * 1,1
 * 1,1
 * 需求1: 统计最近 5 条消息中各个路口信灯的汽车数量, 相同的 key 出现 5 次进行统计 --》基于数量的滚动窗口
 * 需求2: 统计最近 5 条消息中各个路口信灯的汽车数量, 相同的 key 出现 3 次进行统计 --》基于数量的滑动窗口
 * 这里的 key 就是分组的 key
 */
public class WindowDemo2 {

    public static void main(String[] args) throws Exception {
        //0: env
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setRuntimeMode(RuntimeExecutionMode.AUTOMATIC);
        //1: source
        DataStream<String> ds = env.socketTextStream("node01", 999);
        //2: transformation
        final DataStream<CarInfo> carDS = ds.map((String value) -> {
            final String[] arr = value.split(",");
            return new CarInfo(arr[0], Integer.parseInt(arr[1]));
        });

        final KeyedStream<CarInfo, String> grouped = carDS.keyBy(CarInfo::getSensorId);
        // 需求1: 统计最近 5 条消息中各个路口信灯的汽车数量, 相同的 key 出现 5 次进行统计
        final SingleOutputStreamOperator<CarInfo> result1 = grouped.countWindow(5).sum("count");
        //需求2: 统计最近 5 条消息中各个路口信灯的汽车数量, 相同的 key 出现 3 次进行统计
        final SingleOutputStreamOperator<CarInfo> result2 = grouped.countWindow(5, 3).sum("count");
        //3: sink
        result2.print();
        //4: execute
        env.execute();
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class CarInfo {
        private String sensorId;
        private int count;
    }
}

会话窗口

需求: 设置会话超时间为 10 秒,10 秒内没有数据到来,则触发上个窗口的计算

package com.learn.flink.window;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.flink.api.common.RuntimeExecutionMode;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.ProcessingTimeSessionWindows;
import org.apache.flink.streaming.api.windowing.time.Time;

/**
 * window - flink window session
 * 模拟不同交通信号编号的车辆的数量
 * 1,1
 * 1,1
 * 2,1
 * 1,1
 * 1,1
 * 需求: 设置会话超时间为 10 秒,10 秒内没有数据到来,则触发上个窗口的计算(前提是上个窗口有数据)
 */
public class WindowDemo3 {

    public static void main(String[] args) throws Exception {
        //0: env
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setRuntimeMode(RuntimeExecutionMode.AUTOMATIC);
        //1: source
        DataStream<String> ds = env.socketTextStream("node01", 999);
        //2: transformation
        final DataStream<CarInfo> carDS = ds.map((String value) -> {
            final String[] arr = value.split(",");
            return new CarInfo(arr[0], Integer.parseInt(arr[1]));
        });

        final KeyedStream<CarInfo, String> grouped = carDS.keyBy(CarInfo::getSensorId);

        final SingleOutputStreamOperator<CarInfo> result = grouped.window(ProcessingTimeSessionWindows.withGap(Time.seconds(10))).sum("count");
        //3: sink
        result.print();
        //4: execute
        env.execute();
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class CarInfo {
        private String sensorId;
        private int count;
    }
}

Time

概述

image.png 针对事件流会有几个时间的概念:

  • Event time:事件时间是每个单独事件在其生成设备上发生的时间,就是事件真真正正发生的时间。
  • Processing time:处理时间是指正在执行相应操作的机器的系统时间。指事件真正被处理或者计算的时间。

使用事件的 event time无疑是最准确,最能真实的反应客观世界的情况的。但是想要使用 event time是很有难度的。 举例:

  • 假设你正在去往地下停车场的路上,并且打算用手机点了一份外卖。选好了外卖之后,你就用在线支付功能付款,这个时候是 11:59。恰好这个时候,你走进了地下停车库,没有信号。因此外卖的在线支付没有立刻成功,而支付系统一直再重试这个支付操作。当你开车离开地下停车场时,已经时12:01 了。这个时候手机重新有了信号,手机上的支付数据成功发送到了外卖在线支付系统,支付完成。 上面的情况,支付事件的事件时间是11:59,而支付数据的处理时间是12:01.
    如果要统计12:00 之前的订单金额,那么这笔交易是否应该被统计呢?理论上,这笔支付应该是被统计的,因为该数据时间发生的真正时间是 11:59。

  • 一条错误日志的内容为:2020-11:11 22:59:00 error NullPointExcep -- 事件时间,进入Flink 的时间为 2020-11:11 23:00:00 -- 摄入时间,到达 window 的时间为 23:00:10 -- 处理时间。

对于业务来说,要统计 1 小时内的故障日志个数,哪个时间是最有意义的?显然是错误日志的发生时间。只有事件时间才能真正反映/代表事件的本质。

  • 某 App 会记录用户的所有点击行为,并回传日志(在网络不好的情况下,先保存在本地,延后回传)。A 用户在11:01:00 对 App 操作,B 用户在11:02:00 操作。但是 A 用户的网络不太稳定,回传日志延迟了,导致服务端先接收到 B 用户的消息,然后再接收到 A 用户的消息,消息乱序了。 如果这是一个抢购的业务,那么是 A 成功还是 B 成功呢?理论上应该是 A 成功,A 操作的事件确实是比 B 要早的。但是实际考虑到实现难度,可能会直接按 B 成功算。

为了解决上面的问题,可以借助 Watermark 水印机制/水位线机制一定程度上解决这种数据乱序或者延迟到达的问题。但是只能是一定程度上解决,是不可能完全解决这个问题的。

Watermark

Watermark 其实就是一个时间戳。
Watermark = 当前窗口的最大的事件时间  -  最大允许的延迟时间或乱序时间

这样可以保证 Watermark 水位线会一直上升(变大),不会下降。

Watermark 触发窗口计算
触发条件

  • 窗口有数据
  • Watermark >= 窗口的结束时间

公式变形:

  • Watermark = 当前窗口的最大的事件时间 - 最大允许的延迟时间或乱序时间
  • 当前窗口的最大的事件时间 - 最大允许的延迟时间或乱序时间 >= 窗口的结束时间
  • 当前窗口的最大的事件时间 >= 窗口的结束时间 + 最大允许的延迟时间或乱序时间

image.png

分析:

  • 如果没有 Watermark 机制,B 数据(至少迟到 2 分钟), A 数据(至少迟到 3 分钟)肯定是丢失了。
  • 有 Watermark 机制,设置最大允许的延迟时间或者乱序时间为 5 分钟
  1. C 数据到达时,Watermark = max{10:11:00} -5 = 10:06:00 < 10:10:00, 不触发计算
  2. B 数据到达时,Watermark = max{10:11:00, 10:09:00} -5 = 10:06:00 < 10:10:00, 不触发计算
  3. D 数据到达时,Watermark = max{10:11:00, 10:09:00, 10:15:00} -5 = 10:10:00 = 10:10:00, 触发计算, B 数据不会丢失

Watermark 机制可以一定程度上解决数据乱序或者延迟到达的问题,但是更加严重的还是无法解决的。

示例:

package com.learn.flink.watermaker;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.flink.api.common.RuntimeExecutionMode;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.source.RichParallelSourceFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingProcessingTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;

import java.time.Duration;
import java.util.Random;
import java.util.UUID;

/**
 * 演示事件时间的窗口计算,并使用 watermark 解决一定程度的数据乱序/延迟
 */
public class WatermarkDemo1 {

    public static void main(String[] args) throws Exception {
        //0: env
        final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setRuntimeMode(RuntimeExecutionMode.AUTOMATIC);
        //1: source
        DataStream<Order> orderDS = env.addSource(new MyOrderSource());
        //2: transformation
        // 计算 5 秒内数据求每个用户订单总额
        //设置 watermark
        final SingleOutputStreamOperator<Order> orderDSWithWatermark = orderDS.assignTimestampsAndWatermarks(
                        WatermarkStrategy.<Order>forBoundedOutOfOrderness(Duration.ofSeconds(3)) // 指定最大的的延迟时间或者乱序时间
                                .withTimestampAssigner((order, timestamp) -> order.getEventTime())
        );
        //进行窗口计算
        final SingleOutputStreamOperator<Order> result = orderDSWithWatermark.keyBy(Order::getUserId)
                .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
                .sum("money");

        //3: sink
        result.print();
        //4: execute
        env.execute();
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Order {
        private String id;
        private Integer userId;
        private Integer money;
        private Long eventTime;
    }
    /**
     * 自定义数据源继承 RichParallelSourceFunction
     * 多功能并行数据源(并行度可以》=1)
     */
    public static class MyOrderSource extends RichParallelSourceFunction<Order> {

        private boolean run = true;
        @Override
        public void run(SourceContext<Order> sourceContext) throws Exception {
            final Random random = new Random();
            while (run) {
                final String oid = UUID.randomUUID().toString();
                final int uid = random.nextInt(3);
                final int money = random.nextInt(101);

                // 模拟时间时间迟到 5 秒以内
                final long eventTime = System.currentTimeMillis() -random.nextInt(5) * 1000;

                sourceContext.collect(new Order(oid, uid, money, eventTime));

                // 每个 1 秒执行一次
                Thread.sleep(1000);
            }
        }

        @Override
        public void cancel() {
            // 在 cancel 执行时,不再产生数据
            run = false;
        }
    }
}