Flink时间窗口和水位线

  • 一、 Flink中的时间和窗口
  • 1.窗口(Window)
  • 2.窗口API
  • 二、窗口函数(Window Functions)
  • 1.增量聚合函数
  • 2.全窗口函数
  • 3. 增量聚合和全窗口函数的结合使用
  • 4.窗口的生命周期
  • 三、时间语义和水位线
  • 1.时间语义的选择
  • 1.水位线(Watermark)
  • 3.水位线和窗口的工作原理
  • 4.生成水位线
  • 5.水位线的传递
  • 6.迟到数据的处理
  • 四、双流结合
  • 1.基于时间的合流——双流联结(Join)
  • 2.间隔联结(Interval Join)


一、 Flink中的时间和窗口

在批处理统计中,我们可以等待一批数据都到齐后,统一处理。但是在实时处理统计中,我们是来一条就得处理一条,那么我们怎么统计最近一段时间内的数据呢?
引入“窗口”。所谓的“窗口”,一般就是划定的一段时间范围,也就是“时间窗”;对在这范围内的数据进行处理,就是所谓的窗口计算。所以窗口和时间往往是分不开的。接下来我们就深入了解一下Flink中的时间语义和窗口的应用。

1.窗口(Window)

Flink是一种流式计算引擎,主要是来处理无界数据流的,数据源源不断、无穷无尽。想要更加方便高效地处理无界流,一种方式就是将无限数据切割成有限的“数据块”进行处理,这就是所谓的“窗口”(Window)。

flink 水位线生成间隔 flink设置水位线_flink

注意:Flink中窗口并不是静态准备好的,而是动态创建——当有落在这个窗口区间范围的数据达到时,才创建对应的窗口。另外,这里我们认为到达窗口结束时间时,窗口就触发计算并关闭,事实上“触发计算”和“窗口关闭”两个行为也可以分开。

1)按照驱动类型分

flink 水位线生成间隔 flink设置水位线_windows_02


2)按照窗口分配数据的规则分类

根据分配数据的规则,窗口的具体实现可以分为4类:滚动窗口(Tumbling Window)、滑动窗口(Sliding Window)、会话窗口(Session Window),以及全局窗口(Global Window)。

滚动窗口(Tumbling Window)

flink 水位线生成间隔 flink设置水位线_数据_03

滚动窗口可以基于时间也可以基于数据长度进行滚动,滚动窗口统计的数据是没有重复的

滑动窗口(Sliding Window)

flink 水位线生成间隔 flink设置水位线_flink 水位线生成间隔_04

滑动窗口基于时间进行滑动,两个窗口有可能会包含相同的数据。

会话窗口(Session Windows)

flink 水位线生成间隔 flink设置水位线_大数据_05

当在规定的时间间隔内,会话都没有收到数据,那么将开始下一个会话

全局窗口(Global Windows)

flink 水位线生成间隔 flink设置水位线_windows_06

2.窗口API

(1)按键分区窗口(Keyed Windows)
经过按键分区keyBy操作后,数据流会按照key被分为多条逻辑流(logical streams),这就是KeyedStream。基于KeyedStream进行窗口操作时,窗口计算会在多个并行子任务上同时执行。相同key的数据会被发送到同一个并行子任务,而窗口操作会基于每个key进行单独的处理。所以可以认为,每个key上都定义了一组窗口,各自独立地进行统计计算。
(2)非按键分区(Non-Keyed Windows)
如果没有进行keyBy,那么原始的DataStream就不会分成多条逻辑流。这时窗口逻辑只能在一个任务(task)上执行,就相当于并行度变成了1。
非按键分区的流使用AllWindowedStream进行开窗。

SingleOutputStreamOperator<WaterSensor> source = 
			env.socketTextStream("localhost", 7777)
                .map(new WaterSensorMapFunction());
        KeyedStream<WaterSensor, String> sensorKS = source.keyBy(sensor -> sensor.id);
		//todo 基于时间的窗口
        //滚动窗口,窗口长度10s
        sensorKS.window(TumblingEventTimeWindows.of(Time.seconds(10)));
        //滑动窗口,窗口长度10s,步长2s
        sensorKS.window(SlidingProcessingTimeWindows.of(Time.seconds(10),Time.seconds(2)));
        //会话窗口,超时间隔5s
        sensorKS.window(ProcessingTimeSessionWindows.withGap(Time.seconds(5)));
        //todo 基于计数的窗口
        //滚动窗口 累计5条数据进行滚动
        sensorKS.countWindow(5);
        //滑动窗口 累计5条数据进行2条数据的滑动
        sensorKS.countWindow(5,2);
        //todo 全局窗口,需要自定义触发器
        sensorKS.window(GlobalWindows.create());

GlobalWindows是能够自定义的窗口,前边的窗口底层实现都是使用GlobalWindows,不过Flink实现了对应的触发器。

二、窗口函数(Window Functions)

flink 水位线生成间隔 flink设置水位线_flink 水位线生成间隔_07


窗口函数定义了要对窗口中收集的数据做的计算操作,根据处理的方式可以分为两类:

增量聚合函数和全窗口函数。

1.增量聚合函数

增量聚合函数(ReduceFunction / AggregateFunction)
窗口将数据收集起来,最基本的处理操作当然就是进行聚合。我们可以每来一个数据就在之前结果上聚合一次,这就是“增量聚合”。(来一条数据计算一次)
典型的增量聚合函数有两个:ReduceFunction和AggregateFunction

public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStreamSource<String> socketDS = env.socketTextStream("localhost", 7777);
        SingleOutputStreamOperator<WaterSensor> sensorDS = socketDS.map(new WaterSensorMapFunction());
        KeyedStream<WaterSensor, String> sensorKS = sensorDS.keyBy(value -> value.getId());
        WindowedStream<WaterSensor, String, TimeWindow> sensorWS =
                sensorKS.window(TumblingProcessingTimeWindows.of(Time.seconds(5)));

        SingleOutputStreamOperator<WaterSensor> reduceDS = sensorWS.reduce((value1, value2) -> {
            System.out.println("value1Id" + value1.getId() + "value2Id" + value2.getId());
            return new WaterSensor(value1.getId(), value2.getTs(), value1.getVc() + value2.getVc());
        });
        reduceDS.print();
        env.execute();
        部分运行结果:
        2> WoaterSensor{id='s2', ts=1, vc=8}
		value1Ids2value2Ids2
		value1Ids2value2Ids2
		2> WoaterSensor{id='s2', ts=1, vc=12}
    }

使用输出语句对数据的计算过程进行检验,WindowReduce属于增量聚合函数,每条数据计算一次,但只有在窗口结束时,才会进行数据结果的打印输出。

聚合函数(AggregateFunction)
ReduceFunction可以解决大多数归约聚合的问题,但是这个接口有一个限制,就是聚合状态的类型、输出结果的类型都必须和输入数据类型一样。
Flink Window API中的aggregate就突破了这个限制,可以定义更加灵活的窗口聚合操作。这个方法需要传入一个AggregateFunction的实现类作为参数。

AggregateFunction可以看作是ReduceFunction的通用版本,这里有三种类型:输入类型(IN)、累加器类型(ACC)和输出类型(OUT)。输入类型IN就是输入流中元素的数据类型;累加器类型ACC则是我们进行聚合的中间状态类型;而输出类型当然就是最终计算结果的类型了。

SingleOutputStreamOperator<WaterSensor> sensorDS = socketDS.map(new WaterSensorMapFunction());
        KeyedStream<WaterSensor, String> sensorKS = sensorDS.keyBy(value -> value.getId());
        WindowedStream<WaterSensor, String, TimeWindow> sensorWS =
                sensorKS.window(TumblingProcessingTimeWindows.of(Time.seconds(10)));

        SingleOutputStreamOperator<String> aggregateDS = sensorWS.aggregate(
                new AggregateFunction<WaterSensor, Integer, String>() {
                    @Override
                    public Integer createAccumulator() {
                        System.out.println("创建累加器");
                        return 0;
                    }

                    @Override
                    public Integer add(WaterSensor waterSensor, Integer integer) {
                        System.out.println("调用计算逻辑");
                        //integer是之前的计算结果
                        return waterSensor.getVc() + integer;
                    }

                    @Override
                    public String getResult(Integer integer) {
                        System.out.println("获取计算结果");
                        return integer.toString();
                    }

                    @Override
                    public Integer merge(Integer integer, Integer acc1) {
                        //合并两个累加器
                        System.out.println("调用merge");
                        //todo 该方法通常只有会话窗口才会使用
                        return null;
                    }
                }
        );

        aggregateDS.print();
        //计算结果
        创建累加器
		调用计算逻辑
		获取计算结果
		1
		创建累加器
		调用计算逻辑
		调用计算逻辑
		调用计算逻辑
		获取计算结果
		3

所以可以看到,AggregateFunction的工作原理是:首先调用createAccumulator()为任务初始化一个状态(累加器);而后每来一个数据就调用一次add()方法,对数据进行聚合,得到的结果保存在状态中;等到了窗口需要输出时,再调用getResult()方法得到计算结果。很明显,与ReduceFunction相同,AggregateFunction也是增量式的聚合;而由于输入、中间状态、输出的类型可以不同,使得应用更加灵活方便。

2.全窗口函数

有些场景下,我们要做的计算必须基于全部的数据才有效,这时做增量聚合就没什么意义了;另外,输出的结果有可能要包含上下文中的一些信息(比如窗口的起始时间),这是增量聚合函数做不到的。
所以,我们还需要有更丰富的窗口计算方式。窗口操作中的另一大类就是全窗口函数。与增量聚合函数不同,全窗口函数需要先收集窗口中的数据,并在内部缓存起来,等到窗口要输出结果的时候再取出数据进行计算。

窗口函数
ProcessWindowFunction是Window API中最底层的通用窗口函数接口。之所以说它“最底层”,是因为除了可以拿到窗口中的所有数据之外,ProcessWindowFunction还可以获取到一个“上下文对象”(Context)。这个上下文对象非常强大,不仅能够获取窗口信息,还可以访问当前的时间和状态信息。这里的时间就包括了处理时间(processing time)和事件时间水位线(event time watermark)。这就使得ProcessWindowFunction更加灵活、功能更加丰富,其实就是一个增强版的WindowFunction。

DataStreamSource<String> socketDS = env.socketTextStream("localhost", 7777);
        SingleOutputStreamOperator<WaterSensor> sensorDS = socketDS.map(new WaterSensorMapFunction());
        KeyedStream<WaterSensor, String> sensorKS = sensorDS.keyBy(value -> value.getId());
        WindowedStream<WaterSensor, String, TimeWindow> windowDS =
                sensorKS.window(TumblingProcessingTimeWindows.of(Time.seconds(10)));
        windowDS.process(
                //ProcessWindowFunction(数据类型,key类型,输出类型)
                new ProcessWindowFunction<WaterSensor, String, String, TimeWindow>() {
                    @Override
                    public void process(String s, Context context, Iterable<WaterSensor> iterable, Collector<String> collector) throws Exception {
                        //使用上下文获取窗口信息
                        long start = context.window().getStart(); //获取窗口开始时间
                        long end = context.window().getEnd(); //获取窗口结束时间
                        String windowStart = DateFormatUtils.format(start, "yyyy-MM-dd HH:mm:ss.SSS");
                        String windowEnd = DateFormatUtils.format(end, "yyyy-MM-dd HH:mm:ss.SSS");

                        long amount = iterable.spliterator().estimateSize();//获取数据条数

                        collector.collect("key="+s+"的窗口["+windowStart+"-"+windowEnd+")包含"+amount+"条数据==>"+iterable.toString());
                    }
                }
        ).print();
        //部分结果
        8> key=s1的窗口[2023-06-10 17:58:50.000-2023-06-10 17:59:00.000)包含2条数据==>[WoaterSensor{id='s1', ts=1, vc=1}, WoaterSensor{id='s1', ts=2, vc=2}]

使用全窗口函数虽然能够获取窗口的上下文,但是全窗口需要等到窗口时间结束后才将数据一并进行逻辑计算,这种方式有可能会造成数据的堆积,导致程序运行失败。

3. 增量聚合和全窗口函数的结合使用

在实际应用中,我们希望既能够对每条到来的数据进行计算,又希望能够使用窗口的上下文,并且在窗口结束时进行逻辑处理。 Flink的Window API就给我们实现了这样的用法。

public class WindowAggregateAndProcessDemo {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        DataStreamSource<String> socketDS = env.socketTextStream("localhost", 7777);
        env.setParallelism(1);
        SingleOutputStreamOperator<WaterSensor> sensorDS = socketDS.map(new WaterSensorMapFunction());
        KeyedStream<WaterSensor, String> sensorKS = sensorDS.keyBy(value -> value.getId());

        WindowedStream<WaterSensor, String, TimeWindow> sensorWS =
                sensorKS.window(TumblingProcessingTimeWindows.of(Time.seconds(10)));

        sensorWS.aggregate(new MyAgg(),new MyProcess()).print();

        env.execute();
    }

    public static class MyAgg implements AggregateFunction<WaterSensor, Integer, String> {
        @Override
        public Integer createAccumulator() {
            System.out.println("创建累加器");
            return 0;
        }
        @Override
        public Integer add(WaterSensor waterSensor, Integer integer) {
            System.out.println("调用计算逻辑");
            //integer是之前的计算结果
            return waterSensor.getVc() + integer;
        }
        @Override
        public String getResult(Integer integer) {
            System.out.println("获取计算结果");
            return integer.toString();
        }
        @Override
        public Integer merge(Integer integer, Integer acc1) {
            //合并两个累加器
            System.out.println("调用merge");
            //todo 该方法通常只有会话窗口才会使用
            return null;
        }
    }

    public static class MyProcess extends ProcessWindowFunction<String, String, String, TimeWindow> {
        @Override
        public void process(String s, Context context, Iterable<String> iterable, Collector<String> collector) throws Exception {
            //使用上下文获取窗口信息
            long start = context.window().getStart(); //获取窗口开始时间
            long end = context.window().getEnd(); //获取窗口结束时间
            String windowStart = DateFormatUtils.format(start, "yyyy-MM-dd HH:mm:ss.SSS");
            String windowEnd = DateFormatUtils.format(end, "yyyy-MM-dd HH:mm:ss.SSS");
            long amount = iterable.spliterator().estimateSize();//获取数据条数
            collector.collect("key=" + s + "的窗口[" + windowStart + "-" + windowEnd + ")包含" + amount + "条数据==>" + iterable.toString());
        }
    }
}
运行结果:====
创建累加器
调用计算逻辑
调用计算逻辑
调用计算逻辑
获取计算结果
key=S1的窗口[2023-06-10 18:27:00.000-2023-06-10 18:27:10.000)包含1条数据==>[3]

全窗口函数和增量聚合的结合使用,每当对应的key分区有数据到来时,使用聚合函数对数据进行处理,当窗口结束时,将数据的处理结果传递给全窗口函数,交给全窗口函数进行处理。

增量聚合:来一条数据计算一条,存储数据的中间计算结果,占用的空间资源少。
全窗口函数:能够通过上下文实现灵活的功能。

窗口的触发时间:
时间进展(窗口的创建时间开始的时间进展) >=窗口最大时间戳-1ms
窗口的划分:
start = 向下取整,取窗口长度的整数倍
end = start + 窗口长度
窗口是左闭右开的

4.窗口的生命周期

窗口的生命周期包括四个阶段:窗口分配、窗口触发、窗口计算和窗口清除。

在 Flink 中,窗口的创建时间取决于窗口类型和触发器类型。例如,如果定义了一个大小为 5 分钟的滑动窗口,滑动步长为 5 分钟,则第一个窗口的创建时间将是当前时间向下取整到最近的 5 分钟的时间点。如果当前时间是 10:23,则第一个窗口的创建时间将是 10:20。

按照上述,就是说第一次的窗口计算将在10:25计算(滑动步长就是计算间隔),如果第一条数据在10:25后到来,那么这个窗口将被舍弃,并将数据分配到对应时间窗口[10:25-10:30)

窗口的分配和触发:
当一个元素被分配到一个窗口中时,该窗口就会被创建并开始等待后续的元素。当窗口中的元素数量达到指定的阈值或者窗口的时间戳超过了指定的时间范围时,该窗口就会被触发,对窗口中的元素进行计算和聚合。因此,窗口的生成是在数据流中有新元素到达时动态进行的

创建: 属于本窗口的第一条数据到来的时候,窗口将被创建并放入singleton单例集合中

窗口的清除:
当一个窗口被触发计算完成后,窗口中的元素会被清除,并且该窗口的状态也会被清除。如果一个窗口在指定的时间范围内没有被触发,那么该窗口也会被清除。窗口的清除是由窗口清除器(Window Evictor)来控制的。窗口清除器会根据指定的清除策略,定期地检查窗口中的元素,并将过期的元素从窗口中删除,以保证窗口中的元素数量不会无限增长。因此,窗口的销毁是在窗口清除阶段进行的,以释放窗口占用的资源和内存。

销毁: 时间进展=>窗口最大时间戳(end-1ms)+运行迟到的时间(默认0)
默认情况下销毁和关窗是同时的,但是这是两个不同的操作,flink允许窗口的延迟关闭。

三、时间语义和水位线

1.时间语义的选择

flink 水位线生成间隔 flink设置水位线_数据_08

一条数据的生成时间和处理时间是有区别的,一条数据从生成到被处理可能需要消耗几秒的时间差,对于分时统计而言,如果按照处理时间为衡量标准,那么该条数据有可能会被分配到错误的时间段。

数据处理系统中的时间语义

在实际应用中,事件时间语义会更为常见。一般情况下,业务日志数据中都会记录数据生成的时间戳(timestamp),它就可以作为事件时间的判断基础。
在Flink中,由于处理时间比较简单,早期版本默认的时间语义是处理时间;而考虑到事件时间在实际应用中更为广泛,从Flink1.12版本开始,Flink已经将事件时间作为默认的时间语义了

使用数据的时间作为逻辑时间,让计算脱离系统时间。

flink 水位线生成间隔 flink设置水位线_flink 水位线生成间隔_09

1.水位线(Watermark)

在Flink中,用来衡量事件时间进展的标记,就被称作“水位线”(Watermark)。

具体实现上,水位线可以看作一条特殊的数据记录,它是插入到数据流中的一个标记点,主要内容就是一个时间戳,用来指示当前的事件时间。而它插入流中的位置,就应该是在某个数据到来之后;这样就可以从这个数据中提取时间戳,作为当前水位线的时间戳了。

flink 水位线生成间隔 flink设置水位线_大数据_10

实际生成过程中,无法保证生成的数据是有序流

flink 水位线生成间隔 flink设置水位线_大数据_11


flink 水位线生成间隔 flink设置水位线_数据_12

假如存在一个时间长度为10秒的滚动窗口,正常情况下会在时间戳是10s的数据到来后窗口触发计算,但是考虑到网络延迟问题,有可能存在10秒的数据比8秒的数据先行到达,因此我们将当前水位线在数据时间戳的基础上增加延迟,来保证数据的不丢失。

flink 水位线生成间隔 flink设置水位线_大数据_13

3.水位线和窗口的工作原理

flink 水位线生成间隔 flink设置水位线_flink_14


flink 水位线生成间隔 flink设置水位线_flink 水位线生成间隔_15

每个窗口都是相互独立的,水位线的更新是参考当前数据流中时间戳最大的数据窗口关闭的参考标准是水位线(时间进展)数据的分配是按照数据时间戳的向下取整进行分配的,时间窗口和水位线和数据时间可以看作是三条独立的线。

flink 水位线生成间隔 flink设置水位线_windows_16


Flink中窗口并不是静态准备好的,而是动态创建——当有落在这个窗口区间范围的数据达到时,才创建对应的窗口。另外,这里我们认为到达窗口结束时间时,窗口就触发计算并关闭,事实上“触发计算”和“窗口关闭”两个行为也可以分开。

简单理解:窗口(桶)是提前准备好的,数据根据其事件时间自动分配到对应的桶中,真正的计算需要等到水位线推进到闭窗时间。

4.生成水位线

完美的水位线是“绝对正确”的,也就是一个水位线一旦出现,就表示这个时间之前的数据已经全部到齐、之后再也不会出现了。不过如果要保证绝对正确,就必须等足够长的时间,这会带来更高的延迟。

如果我们希望处理得更快、实时性更强,那么可以将水位线延迟设得低一些。这种情况下,可能很多迟到数据会在水位线之后才到达,就会导致窗口遗漏数据,计算结果不准确。当然,如果我们对准确性完全不考虑、一味地追求处理速度,可以直接使用处理时间语义,这在理论上可以得到最低的延迟。

所以Flink中的水位线,其实是流处理中对低延迟和结果正确性的一个权衡机制,而且把控制的权力交给了程序员,我们可以在代码中定义水位线的生成策略。

public class WatermarkOutOfOrdermessDemo {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("localhost", 7777)
                .map(new WaterSensorMapFunction());
        //todo 定制水位线策略
        WatermarkStrategy<WaterSensor> watermarkStrategy = WatermarkStrategy
                *******
                forMonotonousTimestamps() //有序流
                forBoundedOutOfOrderness(等待时间) //乱序流
                *******
                .<WaterSensor>forMonotonousTimestamps()
                //指定时间戳分配器
                .withTimestampAssigner(new SerializableTimestampAssigner<WaterSensor>() {
                    @Override
                    public long extractTimestamp(WaterSensor waterSensor, long l) {
                        System.out.println("数据=" + waterSensor + ",recordTS=" + l);
                        return waterSensor.getTs() * 1000L;
                    }
                });
        SingleOutputStreamOperator<WaterSensor> sensorWithWaterMark = sensorDS
                .assignTimestampsAndWatermarks(watermarkStrategy);
        sensorWithWaterMark
                .keyBy(value -> value.getId())
                .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                .process(
                        new ProcessWindowFunction<WaterSensor, String, String, TimeWindow>() {
                            @Override
                            public void process(String s, Context context, Iterable<WaterSensor> iterable, Collector<String> collector) throws Exception {
                                //使用上下文获取窗口信息
                                long start = context.window().getStart(); //获取窗口开始时间
                                long end = context.window().getEnd(); //获取窗口结束时间
                                String windowStart = DateFormatUtils.format(start, "yyyy-MM-dd HH:mm:ss.SSS");
                                String windowEnd = DateFormatUtils.format(end, "yyyy-MM-dd HH:mm:ss.SSS");
                                long amount = iterable.spliterator().estimateSize();//获取数据条数
                                collector.collect("key="+s+"的窗口["+windowStart+"-"+windowEnd+")包含"
                                                +amount+"条数据==>"+iterable.toString());
                            }
                        }
                ).print();
        env.execute();
    }
}

运行结果:

flink 水位线生成间隔 flink设置水位线_数据_17

使用水位线要求窗口为事件窗口,不能使用时间窗口。

Flink内置的Watermark生成原理
1.Watermark是真实存在的数据,不可能接收一条数据生成一次,因此是周期性生成,默认200ms
2.有序流: watermark = 当前最大的事件时间 - 1ms
3.乱序流: watermark = 当前最大的事件时间 - 延迟时间 - 1ms

自定义水位线生成器

1.周期性水位线生成器(Periodic Generator)

public class MyPeriodWatermarkGenerator<T> implements WatermarkGenerator<T> {
    private long delayTS;
    private long maxTS;

    public MyPeriodWatermarkGenerator(long delayTS) {
        this.delayTS = delayTS;
        this.maxTS = Long.MIN_VALUE + this.delayTS + 1;
    }

    //TODO 每条数据到来时都会调用一次
    //@params l 提取到的事件时间
    @Override
    public void onEvent(T t, long l, WatermarkOutput watermarkOutput) {
        Math.max(maxTS,l);
    }

    //todo 周期性调用,生成watermark
    @Override
    public void onPeriodicEmit(WatermarkOutput watermarkOutput) {
        watermarkOutput.emitWatermark(new Watermark(maxTS-delayTS-1));
    }
}
//设置水位线采集周期
env.getConfig().setAutoWatermarkInterval(2000);
WatermarkStrategy<WaterSensor> watermarkStrategy = WatermarkStrategy
                .<WaterSensor>forGenerator(new WatermarkGeneratorSupplier<WaterSensor>() {
                    @Override
                    public WatermarkGenerator<WaterSensor> createWatermarkGenerator(Context context) {
                        return new MyPeriodWatermarkGenerator<>(3000L);
                    }
                })

2.断点式水位线生成器(Punctuated Generator)
断点式生成器会不停地检测onEvent()中的事件,当发现带有水位线信息的事件时,就立即发出水位线。我们把发射水位线的逻辑写在onEvent方法当中即可。(每条数据都会记录水位线)

@Override
    public void onEvent(T t, long l, WatermarkOutput watermarkOutput) {
        Math.max(maxTS,l);
        watermarkOutput.emitWatermark(new Watermark(maxTS-delayTS-1));
    }

3.在数据源中发送水位线

env.fromSource(KafkaSource,WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(3)));

在数据源中使用了水位线后,就不再运行再次使用水位线。

5.水位线的传递

Flink每个算子都有自己的水位线,事件时钟并不相同。

flink 水位线生成间隔 flink设置水位线_数据_18


在流处理中,上游任务处理完水位线、时钟改变之后,要把当前的水位线再次发出,广播给所有的下游子任务而当一个任务接收到多个上游并行任务传递来的水位线时,应该以上游任务中最小的那个作为当前任务的事件时钟

即使上游和下游算子的关系是1对多,上游数据只会发送到下游的一个分区中,watermark也会广播到所有的下游分区中。

水位线在上下游任务之间的传递,非常巧妙地避免了分布式系统中没有统一时钟的问题,每个任务都以“处理完之前所有数据”为标准来确定自己的时钟。

因为并行度的原因,数据是流式处理的,算子之间的水位线是存在差异的,watermark在生成后会跟随数据一起传递,因此水位线代表的是某一结点的进度
例如; source->map(生成水位线)->process->print
source处理数据2的时候,print的正在处理数据1

在多并行度下,窗口的关闭可能不是当临界watermark到来的时候,因为有可能临界watermark和别的watermark同时到来,flink会取最小的watermark作为当前任务的水位线,只有当临界wateramrk为到来的watermark中最小时,窗口才会触发。

空闲等待时间
在多个上游并行任务中,如果有其中一个没有数据,由于当前Task是以最小的那个作为当前任务的事件时钟,就会导致当前Task的水位线无法推进,就可能导致窗口无法触发。这时候可以设置空闲等待。

WatermarkStrategy.withIdlenness(Duration.ofSecond(5))

时间窗口的水位线是参考所有上游任务发送的最小的水位线的,如果存在一个上游任务一直没有数据发送,那么该上游任务的水位线为Long的最小值,窗口将无法触发,所以设置空闲等待时间;
在空闲等待时间过后,如果该上游一直没有水位线推送,窗口将不再参考这个上游任务的水位线。

6.迟到数据的处理

1.设置窗口的延迟关闭

sensorWithWaterMark
                .keyBy(value -> value.getId())
                .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                .allowedLateness(Time.seconds(3)) //延迟关窗

窗口的触发计算和窗口关闭是两个不同的操作,使用allowedLatenessAIP设置窗口的推迟关闭,即使水位线触发了关窗,因为设置的推迟关窗,窗口只会触发计算,之后属于该窗口的迟到数据到来后仍会立即触发计算(每来一条延迟数据都会触发一次计算),只有水位线到达延迟关窗时间后窗口才会真正关闭。

2.使用侧流接收迟到的数据

OutputTag<WaterSensor> lateTag = 
	new OutputTag<>("late-data", Types.POJO(WaterSensor.class));
        sensorWithWaterMark
                .keyBy(value -> value.getId())
                .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                .allowedLateness(Time.seconds(3))
                .sideOutputLateData(lateTag)

窗口关闭后到来的迟到严重的数据会放入测输出流中

值得注意的是:
如果watermark等待3s,窗口允许迟到2s,为什么不直接watermark等待5s或者窗口允许迟到5s?

如果watermark等待时间设置太长,会影响计算的延迟,结果的输出会延后
如果窗口允许迟到时间设置太长,会导致窗口频繁输出

窗口的允许迟到一般只考虑大部分迟到数据,一些迟到很久的数据可以放到测输出流中进行处理

四、双流结合

1.基于时间的合流——双流联结(Join)

可以发现,根据某个key合并两条流,与关系型数据库中表的join操作非常相近。事实上,Flink中两条流的connect操作,就可以通过keyBy指定键进行分组后合并,实现了类似于SQL中的join操作;另外connect支持处理函数,可以使用自定义实现各种需求,其实已经能够处理双流join的大多数场景。

不过处理函数是底层接口,所以尽管connect能做的事情多,但在一些具体应用场景下还是显得太过抽象了。比如,如果我们希望统计固定时间内两条流数据的匹配情况,那就需要自定义来实现——其实这完全可以用窗口(window)来表示。为了更方便地实现基于时间的合流操作,Flink的DataStrema API提供了内置的join算子。

窗口联结(Window Join)
Flink为基于一段时间的双流合并专门提供了一个窗口联结算子,可以定义时间窗口,并将两条流中共享一个公共键(key)的数据放在窗口中进行配对处理。

一个程序中,两条流数据的keyBy算子会将key相同的数据发送往同一个子任务(分区)

public class WindowJoinDemo {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        SingleOutputStreamOperator<Tuple2<String, Integer>> ds1 = env
                .fromElements(
                        Tuple2.of("a", 1),
                        Tuple2.of("b", 2),
                        Tuple2.of("a", 3),
                        Tuple2.of("c", 4),
                        Tuple2.of("b", 5)
                )
                .assignTimestampsAndWatermarks(WatermarkStrategy
                        .<Tuple2<String, Integer>>forMonotonousTimestamps()
                        .withTimestampAssigner((value, ts) -> value.f1 * 1000L)
                );

        SingleOutputStreamOperator<Tuple3<String, Integer,Integer>> ds2 = env
                .fromElements(
                        Tuple3.of("a", 1,1),
                        Tuple3.of("b", 2,1),
                        Tuple3.of("a", 6,6),
                        Tuple3.of("c", 2,3),
                        Tuple3.of("b", 6,5)
                )
                .assignTimestampsAndWatermarks(WatermarkStrategy
                        .<Tuple3<String, Integer,Integer>>forMonotonousTimestamps()
                        .withTimestampAssigner((value, ts) -> value.f1 * 1000L)
                );
        //todo window join
        DataStream<String> join = ds1.join(ds2)
                .where(r1 -> r1.f0)
                .equalTo(r2 -> r2.f0)
                .window(TumblingEventTimeWindows.of(Time.seconds(5)))
                .apply(new JoinFunction<Tuple2<String, Integer>, Tuple3<String, Integer, Integer>, String>() {
                    @Override
                    //todo join(ds1的数据,ds2的数据)
                    //关联上的数据会调用join方法
                    public String join(Tuple2<String, Integer> stringIntegerTuple2, Tuple3<String, Integer, Integer> stringIntegerIntegerTuple3) throws Exception {
                        return stringIntegerTuple2 + "<--------->" + stringIntegerIntegerTuple3;
                    }
                });

        join.print();

        env.execute();
    }
}
======结果======
(a,1)<--------->(a,1,1)
(a,3)<--------->(a,1,1)
(b,2)<--------->(b,2,1)
(c,4)<--------->(c,2,3)
(b,5)<--------->(b,6,5)

其实仔细观察可以发现,窗口join的调用语法和我们熟悉的SQL中表的join非常相似:

SELECT * FROM table1 t1, table2 t2 WHERE t1.id = t2.id;

这句SQL中where子句的表达,等价于inner join … on,所以本身表示的是两张表基于id的“内连接”(inner join)。而Flink中的window join,同样类似于inner join。也就是说,最后处理输出的,只有两条流中数据按key配对成功的那些;如果某个窗口中一条流的数据没有任何另一条流的数据匹配,那么就不会调用JoinFunction的.join()方法,也就没有任何输出了。

只有key相同并且属于同一时间窗口内的数据才能相互匹配,window join不推荐使用,因为数据必须在一个窗口内才能够进行匹配,一些外部原因可能会使数据原来能匹配的数据被另一个窗口处理。

2.间隔联结(Interval Join)

在有些场景下,我们要处理的时间间隔可能并不是固定的。这时显然不应该用滚动窗口或滑动窗口来处理——因为匹配的两个数据有可能刚好“卡在”窗口边缘两侧,于是窗口内就都没有匹配了;会话窗口虽然时间不固定,但也明显不适合这个场景。基于时间的窗口联结已经无能为力了。

为了应对这样的需求,Flink提供了一种叫作“间隔联结”(interval join)的合流操作。顾名思义,间隔联结的思路就是针对一条流的每个数据,开辟出其时间戳前后的一段时间间隔,看这期间是否有来自另一条流的数据匹配。

间隔联结的原理

间隔联结具体的定义方式是,我们给定两个时间点,分别叫作间隔的“上界”(upperBound)和“下界”(lowerBound);于是对于一条流(不妨叫作A)中的任意一个数据元素a,就可以开辟一段时间间隔:[a.timestamp + lowerBound, a.timestamp + upperBound],即以a的时间戳为中心,下至下界点、上至上界点的一个闭区间:我们就把这段时间作为可以匹配另一条流数据的“窗口”范围。所以对于另一条流(不妨叫B)中的数据元素b,如果它的时间戳落在了这个区间范围内,a和b就可以成功配对,进而进行计算输出结果。所以匹配的条件为:

a.timestamp + lowerBound <= b.timestamp <= a.timestamp + upperBound

这里需要注意,做间隔联结的两条流A和B,也必须基于相同的key;下界lowerBound应该小于等于上界upperBound,两者都可正可负;间隔联结目前只支持事件时间语义

如下图所示,我们可以清楚地看到间隔联结的方式:

flink 水位线生成间隔 flink设置水位线_flink_19


下方的流A去间隔联结上方的流B,所以基于A的每个数据元素,都可以开辟一个间隔区间。我们这里设置下界为-2毫秒,上界为1毫秒。于是对于时间戳为2的A中元素,它的可匹配区间就是[0, 3],流B中有时间戳为0、1的两个元素落在这个范围内,所以就可以得到匹配数据对(2, 0)和(2, 1)。同样地,A中时间戳为3的元素,可匹配区间为[1, 4],B中只有时间戳为1的一个数据可以匹配,于是得到匹配数据对(3, 1)。

只支持事件时间
指定上界,下届的偏移,负号代表时间往前,正号代表时间往后
两条流关联后的的watermark,以两条流中最小的为准
如果当前数据的事件事件 < 当前的watermark,就是迟到数据,主流不处理,可以放入测输出流中。

Flink状态后端: