Flink 中的时间和窗口

在批处理统计中,我们可以等待一批数据都到齐后,统一处理。但是在实时处理统计中,我们是来一条就得处理一条,那么我们怎么统计最近一段时间内的数据呢?引入“窗口”。

所谓的“窗口”,一般就是划定的一段时间范围,也就是“时间窗”;对在这范围内的数据进行处理,就是所谓的窗口计算。所以窗口和时间往往是分不开的。

1.窗口

1.概念

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

在Flink中,将流切割成有限大小的多个窗口;每个数据都会分发到对应的窗口中,当到达窗口结束时间时,就对每个窗口中收集的数据进行计算处理。

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

2.分类

1.按照驱动类型分

窗口本身是截取有界数据的一种方式,所以窗口一个非常重要的信息其实就是“怎样截取数据”。换句话说,就是以什么标准来开始和结束数据的截取,我们把它叫作窗口的“驱动类型”。

1.时间窗口(Time Window)

时间窗口以时间点来定义窗口的开始(start)和结束(end),所以截取出的就是某一时间段的数据。到达结束时间时,窗口不再收集数据,触发计算输出结果,并将窗口关闭销毁。

2.计数窗口(Count Window)

计数窗口基于元素的个数来截取数据,到达固定的个数时就触发计算并关闭窗口。每个窗口截取数据的个数,就是窗口的大小。

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

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

1.滚动窗口(Tumbling Windows)

滚动窗口有固定的大小,是一种对数据进行“均匀切片”的划分方式。窗口之间没有重叠,也不会有间隔,是“首尾相接”的状态。这是最简单的窗口形式,每个数据都会被分配到一个窗口,而且只会属于一个窗口。

滚动窗口可以基于时间定义,也可以基于数据个数定义;需要的参数只有一个,就 是 窗口的大小 ( window size)。比如我们可以定义一个长度 为1小时的滚动时间窗口,那么每个小时就会进行一次统计;或者定义一个长度为10的滚动计数窗口,就会每10个数进行一次统计。

滚动窗口应用非常广泛,它可以对每个时间段做聚合统计,很多BI分析指标都可以用它来实现。

2.滑动窗口(Sliding Windows)

滑动窗口的大小也是固定的。但是窗口之间并不是首尾相接的,而是可以“错开”一定的位置。

定义滑动窗口的参数有两个:除去窗口大小(window size)之外,还有一个“滑动步长”(window slide),它其实就代表了窗口计算的频率。窗口在结束时间触发计算输出结果,那么滑动步长就代表了计算频率。

当滑动步长小于窗口大小时,滑动窗口就会出现重 叠,这时数据也可能会被同时分配到多个窗口中。而具体的个数,就由窗口大小和滑动步长的比值(size/slide)来决定。

滚动窗口也可以看作是一种特殊的滑动窗口-窗口大小等于滑动步长(size = slide)。

滑动窗口适合计算结果更新频率非常高的场景

3.会话窗口(Session Windows)

会话窗口,是基于“会话”(session)来来对数据进行分组的。会话窗口只能基于时间来定义。

会话窗口中,最重要的参数就是会话的超时时间,也就是两个会话窗口之间的最小距离。如果相邻两个数据到来的时间间隔(Gap)小于指定的大小(size),那说明还在保持会话,它们就属于同一个窗口;如果gap大于size,那么新来的数据就应该属于新的会话窗口,而前一个窗口就应该关闭了。

会话窗口的长度不固定,起始和结束时间也是不确定的,各个分区之间窗口没有任何关联。会话窗口之间一定 是不会重叠的,而且会留有至少为size的间隔(session gap)。

在一些类似保持会话的场景下,可以使用会话窗口来进行数据的处理统计。

4.全局窗口(Global Windows)

“全局窗口”,这种窗口全局有效,会把相同key的所有数据都分配到同一个窗口中。这种窗口没有结束的时候,默认是不会做触发计算的。如果希望它能对数据进行计算处理,还需要自定义“触发器”(Trigger)。

全局窗口没有结束的时间点,所以一般在希望做更加灵活的窗口处理时自定义使用。Flink中的计数窗口(Count Window),底层就是用全局窗口实现的。

3.窗口 API 概览

1.按键分区(Keyed)和非按键分区(Non-Keyed)

在定义窗口操作之前,首先需要确定,到底是基于按键分区(Keyed)的数据流
KeyedStream 来开窗,还是直接在没有按键分区的 DataStream 上开窗。也就是说,在调用窗口算子之前,是否有 keyBy 操作。

1.按键分区窗口(Keyed Windows)

经过按键分区 keyBy 操作后,数据流会按照 key 被分为多条逻辑流(logical streams),这就是 KeyedStream。基于 KeyedStream进行窗口操作时,窗口计算会在多个并行子任务上同时执行。相同 key 的数据会被发送到同一个并行子任务,而窗口操作会基于每个 key 进行单独的处理。所以可以认为,每个 key 上都定义了一组窗口,各自独立地进行统计计算。

在代码实现上,我们需要先对 DataStream 调用.keyBy()进行按键分区,然后再调用.window()定义窗口。

stream.keyBy(...)
 .window(...)
2.非按键分区(Non-Keyed Windows)

如果没有进行 keyBy,那么原始的 DataStream 就不会分成多条逻辑流。这时窗口逻辑只能在一个任务(task)上执行,就相当于并行度变成了 1。

在代码中,直接基于 DataStream 调用.windowAll()定义窗口。

stream.windowAll(...)

注意:对于非按键分区的窗口操作,手动调大窗口算子的并行度也是无效的,windowAll本身就是一个非并行的操作。

2.代码中窗口 API 的调用

窗口操作主要有两个部分:窗口分配器(Window Assigners)和窗口函数(Window Functions)。

stream.keyBy(<key selector>)
 .window(<window assigner>)
 .aggregate(<window function>)

其 中.window()方法需要传入一个窗口分配器,它指明了窗口的类型;而后面的.aggregate()方法传入一个窗口函数作为参数,它用来定义窗口具体的处理逻辑。窗口分配器有各种形式,而窗口函数的调用方法也不只.aggregate()一种,

4.窗口分配器

定义窗口分配器(Window Assigners)是构建窗口算子的第一步,它的作用就是定义数据应该被“分配”到哪个窗口。所以可以说,窗口分配器其实就是在指定窗口的类型。

窗口分配器最通用的定义方式,就是调用.window()方法。这个方法需要传入一个WindowAssigner 作为参数,返回 WindowedStream。如果是非按键分区窗口,那么直接调用.windowAll()方法,同样传入一个 WindowAssigner,返回的是 AllWindowedStream。

窗口按照驱动类型可以分成时间窗口和计数窗口,而按照具体的分配规则,又有滚动窗口、滑动窗口、会话窗口、全局窗口四种。除去需要自定义的全局窗口外,其他常用的类型Flink 中都给出了内置的分配器实现,我们可以方便地调用实现各种需求。

1.时间窗口

时间窗口是最常用的窗口类型,又可以细分为滚动、滑动和会话三种。

1.滚动处理时间窗口

窗口分配器由类 TumblingProcessingTimeWindows 提供,需要调用它的静态方法.of()。

stream.keyBy(...)
 .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
 .aggregate(...)

这里.of()方法需要传入一个 Time 类型的参数 size,表示滚动窗口的大小,我们这里创建了一个长度为 5 秒的滚动窗口。

另外,.of()还有一个重载方法,可以传入两个 Time 类型的参数:size 和 offset。第一个参数当然还是窗口大小,第二个参数则表示窗口起始点的偏移量。

2.滑动处理时间窗口

窗口分配器由类 SlidingProcessingTimeWindows 提供,同样需要调用它的静态方法.of()。

stream.keyBy(...)
 .window(SlidingProcessingTimeWindows.of(Time.seconds(10) ,
Time.seconds(5)))
 .aggregate(...)

这里.of()方法需要传入两个 Time 类型的参数:size 和 slide,前者表示滑动窗口的大小,后者表示滑动窗口的滑动步长。我们这里创建了一个长度为 10 秒、滑动步长为 5 秒的滑动窗口。

滑动窗口同样可以追加第三个参数,用于指定窗口起始点的偏移量,用法与滚动窗口完全一致。

3.处理时间会话窗口

窗口分配器由类 ProcessingTimeSessionWindows 提供,需要调用它的静态方法.withGap()或者.withDynamicGap()。

stream.keyBy(...)
 .window(ProcessingTimeSessionWindows.withGap(Time.seconds(10
)))
 .aggregate(...)

这里.withGap()方法需要传入一个 Time类型的参数 size,表示会话的超时时间,也就是最小间隔 session gap。我们这里创建了静态会话超时时间为 10 秒的会话窗口。

另外,还可以调用 withDynamicGap()方法定义 session gap 的动态提取逻辑。

        // 会话窗口,动态间隔,每条来的数据都会更新 间隔时间
        WindowedStream<String, String, TimeWindow> window = keyedStream.window(ProcessingTimeSessionWindows.withDynamicGap(
                new SessionWindowTimeGapExtractor<String>() {
                    @Override
                    public long extract(String element) {
                        // 以数据值作为间隔,单位毫秒
                        return Long.valueOf(element) * 1000;
                    }
                }
        ));
4.滚动事件时间窗口

窗口分配器由类 TumblingEventTimeWindows提供,用法与滚动处理事件窗口完全一致。

stream.keyBy(...)
 .window(TumblingEventTimeWindows.of(Time.seconds(5)))
 .aggregate(...)
5.滑动事件时间窗口

窗口分配器由类 SlidingEventTimeWindows 提供,用法与滑动处理事件窗口完全一致。

stream.keyBy(...)
 .window(SlidingEventTimeWindows.of(Time.seconds(10) ,
Time.seconds(5)))
 .aggregate(...)
6.事件时间会话窗口

窗口分配器由类 EventTimeSessionWindows 提供,用法与处理事件会话窗口完全一致。

stream.keyBy(...)
 .window(EventTimeSessionWindows.withGap(Time.seconds(10)))
 .aggregate(...)

2.计数窗口

计数窗口概念非常简单,本身底层是基于全局窗口(Global Window)实现的。Flink 为我们提供了非常方便的接口:直接调用.countWindow()方法。根据分配规则的不同,又可以分为滚动计数窗口和滑动计数窗口两类,下面我们就来看它们的具体实现。

1.滚动计数窗口

滚动计数窗口只需要传入一个长整型的参数 size,表示窗口的大小。

stream.keyBy(...)
 .countWindow(10)

我们定义了一个长度为 10 的滚动计数窗口,当窗口中元素数量达到 10 的时候,就会触发计算执行并关闭窗口。

2.滑动计数窗口

与滚动计数窗口类似,不过需要在.countWindow()调用时传入两个参数:size 和 slide,前者表示窗口大小,后者表示滑动步长。

stream.keyBy(...)
 .countWindow(10,3)

我们定义了一个长度为 10、滑动步长为 3 的滑动计数窗口。每个窗口统计 10个数据,每隔 3 个数据就统计输出一次结果。

3.全局窗口

全局窗口是计数窗口的底层实现,一般在需要自定义窗口时使用。它的定义同样是直接调用.window(),分配器由 GlobalWindows 类提供。

stream.keyBy(...)
 .window(GlobalWindows.create());

需要注意使用全局窗口,必须自行定义触发器才能实现窗口计算,否则起不到任何作用。

        // 1.指定窗口分配器,指定用 哪一种窗口 --- 时间 || 计数 ? 滚动 || 滑动 || 会话 ?
        //  1.1 没有keyBy的窗口:窗口内的 所有数据 进入同一个 子任务, 并行度只能为1
        // keyedStream.windowAll()

        // 1.2 有keyBy的窗口:每个key上都定义了一组窗口,各自独立的进行统计计算

        // 基于时间的
        keyedStream.window(TumblingProcessingTimeWindows.of(Time.seconds(10))); //滚动窗口,窗口长度10s
        keyedStream.window(SlidingProcessingTimeWindows.of(Time.seconds(10),Time.seconds(2)));  //滑动窗口,窗口长度10s,滑动步长2s
        keyedStream.window(ProcessingTimeSessionWindows.withGap(Time.seconds(5)));  // 会话窗口,超时间隔5s

        // 基于计数的
        keyedStream.countWindow(5); //滚动窗口,窗口长度=5个元素
        keyedStream.countWindow(5,2);   //滑动窗口,窗口长度=5个元素,滑动步长=2个元素
        keyedStream.window(GlobalWindows.create()); // 全局窗口,计数窗口的底层就是用的这个,需要自定义的时候才会用

5.窗口函数

定义了窗口分配器,我们只是知道了数据属于哪个窗口,可以将数据收集起来了;至于收集起来到底要做什么,其实还完全没有头绪。所以在窗口分配器之后,必须再接上一个定义窗口如何进行计算的操作,这就是所谓的“窗口函数”(window functions)。

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

1.增量聚合函数(ReduceFunction / AggregateFunction)

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

1.归约函数(ReduceFunction)
        KeyedStream<String, String> keyedStream = socketDS.keyBy(item -> item.split(",")[0]);
        // 1.窗口分配器
        WindowedStream<String, String, TimeWindow> window = keyedStream.window(TumblingProcessingTimeWindows.of(Time.seconds(10)));
        // 2.指定窗口函数,窗口内数据的计算逻辑
        // 增量聚合 - 来一条数据 计算一条数据 窗口触发的时候输出计算结果
        // 增量聚合 reduce
        //  1.相同key的第一条数据来的时候,不会调用reduce方法
        //  2.增量聚合,来一条数据,就会计算一次,但是不会输出
        //  3.在窗口触发的时候,才会输出窗口的最终计算结果
        SingleOutputStreamOperator<String> reduce = window.reduce(new ReduceFunction<String>() {
            @Override
            public String reduce(String value1, String value2) throws Exception {
                Integer temp = Integer.valueOf(value1) + Integer.valueOf(value2);
                return temp.toString();
            }
        });

        reduce.print();
2.聚合函数(AggregateFunction)

上面的规约函数 ReduceFunction 可以解决大多数归约聚合的问题,但是这个接口有一个限制,就是聚合状态的类型、输出结果的类型都必须和输入数据类型一样

Flink Window API 中的 aggregate 就突破了这个限制,可以定义更加灵活的窗口聚合操作。这个方法需要传入一个 AggregateFunction 的实现类作为参数。

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

接口中有四个方法:

  • createAccumulator():创建一个累加器,这就是为聚合创建了一个初始状态,每个聚合任务只会调用一次。
  • add():将输入的元素添加到累加器中。
  • getResult():从累加器中提取聚合的输出结果。
  • merge():合并两个累加器,并将合并后的状态作为一个累加器返回。

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

        KeyedStream<String, String> keyedStream = socketDS.keyBy(item -> item.split(",")[0]);
        // 1.窗口分配器
        WindowedStream<String, String, TimeWindow> window = keyedStream.window(TumblingProcessingTimeWindows.of(Time.seconds(10)));
        // 2.指定窗口函数,窗口内数据的计算逻辑
        // 增量聚合 - 来一条数据 计算一条数据 窗口触发的时候输出计算结果
        // 窗口函数 - 增量聚合 -  aggregate
        //  1.属于本窗口的第一条数据来,创建窗口,创建累加器
        //  2.增量聚合,来一条计算一条,调用一次 add 方法
        //  3.窗口输出时,调用一次 getResult方法
        //  4.输入、中间累加器、输出 类型可以不一致,非常灵活
        SingleOutputStreamOperator<String> aggregate = window.aggregate(

                /**
                 * 第一个参数类型: 输入数据的类型
                 * 第二个参数类型: 累加器的类型,存储的中间计算结果的类型
                 * 第三个参数类型: 输出的类型
                 */
                new AggregateFunction<String, Integer, String>() {
                    /**
                     * 创建累加器,初始化累加器
                     * @return
                     */
                    @Override
                    public Integer createAccumulator() {
                        return 0;
                    }

                    /**
                     * 计算和累加的逻辑 - 聚合逻辑
                     * @param value 传入的值
                     * @param accumulator 累加器的计算结果
                     * @return
                     */
                    @Override
                    public Integer add(String value, Integer accumulator) {
                        return accumulator + Integer.valueOf(value);
                    }

                    /**
                     * 获取最终结果,窗口触发时输出
                     * @param accumulator The accumulator of the aggregation
                     * @return
                     */
                    @Override
                    public String getResult(Integer accumulator) {
                        return accumulator.toString();
                    }

                    /**
                     *
                     * 只有会话窗口才会用到
                     * @param a
                     * @param b
                     * @return
                     */
                    @Override
                    public Integer merge(Integer a, Integer b) {
                        return null;
                    }
                }
        );

        aggregate.print();

另外,Flink 也为窗口的聚合提供了一系列预定义的简单聚合方法,可以直接基于WindowedStream 调用。主要包括.sum()/max()/maxBy()/min()/minBy(),与 KeyedStream 的简单聚合非常相似。它们的底层,其实都是通过 AggregateFunction 来实现的。

2.全窗口函数(full window functions)

有些场景下,我们要做的计算必须基于全部的数据才有效,这时做增量聚合就没什么意义了;另外,输出的结果有可能要包含上下文中的一些信息(比如窗口的起始时间),这是增量聚合函数做不到的。

所以,我们还需要有更丰富的窗口计算方式。窗口操作中的另一大类就是全窗口函数。与增量聚合函数不同,全窗口函数需要先收集窗口中的数据,并在内部缓存起来,等到窗口要输出结果的时候再取出数据进行计算。

在 Flink 中,全窗口函数也有两种:WindowFunction 和 ProcessWindowFunction。

1.窗口函数(WindowFunction)

WindowFunction 字面上就是“窗口函数”,它其实是老版本的通用窗口函数接口。我们可以基于 WindowedStream 调用.apply()方法,传入一个 WindowFunction 的实现类。

这个类中可以获取到包含窗口所有数据的可迭代集合(Iterable),还可以拿到窗口(Window)本身的信息。

不过 WindowFunction 能提供的上下文信息较少,也没有更高级的功能。事实上,它的作用可以被 ProcessWindowFunction 全覆盖,所以之后可能会逐渐弃用。

        KeyedStream<String, String> keyedStream = socketDS.keyBy(item -> item.split(",")[0]);
        // 1.窗口分配器
        WindowedStream<String, String, TimeWindow> window = keyedStream.window(TumblingProcessingTimeWindows.of(Time.seconds(10)));

        // 2.全窗口函数 - 数据来了不计算,存起来,窗口触发的时候,计算并输出结果
        SingleOutputStreamOperator<String> apply = window.apply(
                /**
                 * 第一个参数: 输入类型
                 * 第二个参数: 输出类型
                 * 第三个参数: Key的类型
                 * 第四个参数: 窗口的类型 - 时间 | 条数
                 */
                new WindowFunction<String, String, String, TimeWindow>() {

                    /**
                     *
                     * @param s 分组的key
                     * @param window 窗口对象
                     * @param input 存储的数据
                     * @param out 采集器
                     * @throws Exception
                     */
                    @Override
                    public void apply(String s, TimeWindow window, Iterable<String> input, Collector<String> out) throws Exception {

                    }
                }
        );
2.处理窗口函数(ProcessWindowFunction)

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

事实上,ProcessWindowFunction 是 Flink 底层 API——处理函数(process function)中的一员

        KeyedStream<String, String> keyedStream = socketDS.keyBy(item -> item.split(",")[0]);
        // 1.窗口分配器
        WindowedStream<String, String, TimeWindow> window = keyedStream.window(TumblingProcessingTimeWindows.of(Time.seconds(10)));

        // 2.全窗口函数 - 数据来了不计算,存起来,窗口触发的时候,计算并输出结果
        SingleOutputStreamOperator<String> process = window.process(

                /**
                 * 第一个参数: 输入类型
                 * 第二个参数: 输出类型
                 * 第三个参数: Key的类型
                 * 第四个参数: 窗口的类型 - 时间 | 条数
                 */
                new ProcessWindowFunction<String, String, String, TimeWindow>() {

                    /**
                     * 全窗口函数计算逻辑:窗口触发时才会调用一次,统一计算窗口的所有数据
                     * @param s 分组的Key
                     * @param context 上下文
                     * @param elements 存储的数据
                     * @param out 采集器
                     * @throws Exception
                     */
                    @Override
                    public void process(String s, ProcessWindowFunction<String, String, String, TimeWindow>.Context context, Iterable<String> elements, Collector<String> out) throws Exception {
                        // 上下文可以拿到window对象,还有其他东西;侧输出流 等等
                        long count = elements.spliterator().estimateSize();
                        long windowStartTs = context.window().getStart();
                        long windowEndTs = context.window().getEnd();
                        String windowStart = DateFormatUtils.format(windowStartTs, "yyyy-MM-dd HH:mm:ss.SSS");
                        String windowEnd = DateFormatUtils.format(windowEndTs, "yyyy-MM-dd HH:mm:ss.SSS");
                        out.collect("key=" + s + "的窗口[" +
                                windowStart + "," + windowEnd + ")包含" + count + "条数据===>" +
                                elements.toString());

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

在实际应用中,我们往往希望兼具这两者的优点,把它们结合在一起使用。Flink 的Window API 就给我们实现了这样的用法。

我们之前在调用 WindowedStream 的.reduce()和.aggregate()方法时,只是简单地直接传入了一个 ReduceFunction 或 AggregateFunction 进行增量聚合。除此之外,其实还可以传入第二个参数:一个全窗口函数,可以是 WindowFunction 或者 ProcessWindowFunction。

// ReduceFunction 与 WindowFunction 结合
public <R> SingleOutputStreamOperator<R> reduce(
 ReduceFunction<T> reduceFunction,WindowFunction<T,R,K,W> 
function) 

// ReduceFunction 与 ProcessWindowFunction 结合
public <R> SingleOutputStreamOperator<R> reduce(
 ReduceFunction<T> reduceFunction,ProcessWindowFunction<T,R,
K,W> function)

// AggregateFunction 与 WindowFunction 结合
public <ACC,V,R> SingleOutputStreamOperator<R> aggregate(
 AggregateFunction<T,ACC,V> aggFunction,WindowFunction<V,R,
K,W> windowFunction)

// AggregateFunction 与 ProcessWindowFunction 结合
public <ACC,V,R> SingleOutputStreamOperator<R> aggregate(
 AggregateFunction<T,ACC,V> aggFunction,
 ProcessWindowFunction<V,R,K,W> windowFunction)

这样调用的处理机制是:基于第一个参数(增量聚合函数)来处理窗口数据,每来一个数据就做一次聚合;等到窗口需要触发计算时,则调用第二个参数(全窗口函数)的处理逻辑输出结果。需要注意的是,这里的全窗口函数就不再缓存所有数据了,而是直接将增量聚合函数的结果拿来当作了 Iterable 类型的输入。

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.createLocalEnvironmentWithWebUI(new Configuration());
        // 读取socket数据
        DataStreamSource<String> socketDS = env.socketTextStream("localhost", 7878);
        
        KeyedStream<String, String> keyedStream = socketDS.keyBy(item -> item.split(",")[0]);
        // 1.窗口分配器
        WindowedStream<String, String, TimeWindow> window = keyedStream.window(TumblingProcessingTimeWindows.of(Time.seconds(10)));

        // 2. 窗口函数:
        /**
         * 增量聚合 Aggregate + 全窗口 process
         * 1、增量聚合函数处理数据: 来一条计算一条
         * 2、窗口触发时, 增量聚合的结果(只有一条) 传递给 全窗口函数
         * 3、经过全窗口函数的处理包装后,输出
         *
         * 结合两者的优点:
         * 1、增量聚合: 来一条计算一条,存储中间的计算结果,占用的空间少
         * 2、全窗口函数: 可以通过 上下文 实现灵活的功能
         */
        SingleOutputStreamOperator<String> aggregate = window.aggregate(
                new MyAgg(),
                new MyProcess()
        );

        aggregate.print();

        // 执行
        env.execute();
    }

    /**
     * 第一个参数类型: 输入数据的类型
     * 第二个参数类型: 累加器的类型,存储的中间计算结果的类型
     * 第三个参数类型: 输出的类型
     */
    public static class MyAgg implements AggregateFunction<String, Integer, String>{

        /**
         * 创建累加器,初始化累加器
         * @return
         */
        @Override
        public Integer createAccumulator() {
            return 0;
        }

        /**
         * 计算和累加的逻辑 - 聚合逻辑
         * @param value 传入的值
         * @param accumulator 累加器的计算结果
         * @return
         */
        @Override
        public Integer add(String value, Integer accumulator) {
            return accumulator + Integer.valueOf(value);
        }

        /**
         * 获取最终结果,窗口触发时输出
         * @param accumulator The accumulator of the aggregation
         * @return
         */
        @Override
        public String getResult(Integer accumulator) {
            return accumulator.toString();
        }

        /**
         *
         * 只有会话窗口才会用到
         * @param a
         * @param b
         * @return
         */
        @Override
        public Integer merge(Integer a, Integer b) {
            return null;
        }
    }

    /**
     * 第一个参数: 输入类型 = 上面的聚合函数的输出类型
     * 第二个参数: 输出类型
     * 第三个参数: Key的类型
     * 第四个参数: 窗口的类型 - 时间 | 条数
     */
    public static class MyProcess extends ProcessWindowFunction<String, String, String, TimeWindow> {

        /**
         * 全窗口函数计算逻辑:窗口触发时才会调用一次,统一计算窗口的所有数据
         * @param s 分组的Key
         * @param context 上下文
         * @param elements 存储的数据
         * @param out 采集器
         * @throws Exception
         */
        @Override
        public void process(String s, ProcessWindowFunction<String, String, String, TimeWindow>.Context context, Iterable<String> elements, Collector<String> out) throws Exception {
            // 上下文可以拿到window对象,还有其他东西;侧输出流 等等
            long count = elements.spliterator().estimateSize();
            long windowStartTs = context.window().getStart();
            long windowEndTs = context.window().getEnd();
            String windowStart = DateFormatUtils.format(windowStartTs, "yyyy-MM-dd HH:mm:ss.SSS");
            String windowEnd = DateFormatUtils.format(windowEndTs, "yyyy-MM-dd HH:mm:ss.SSS");
            out.collect("key=" + s + "的窗口[" +
                    windowStart + "," + windowEnd + ")包含" + count + "条数据===>" +
                    elements.toString());

        }
    }

6.其他 API

对于一个窗口算子而言,窗口分配器和窗口函数是必不可少的。除此之外,Flink 还提供了其他一些可选的 API,让我们可以更加灵活地控制窗口行为。

1.触发器(Trigger)

触发器主要是用来控制窗口什么时候触发计算。所谓的“触发计算”,本质上就是执行窗口函数,所以可以认为是计算得到结果并输出的过程。

基于WindowedStream调用.trigger()方法,就可以传入一个自定义的窗口触发器(Trigger)。

stream.keyBy(...)
 .window(...)
 .trigger(new MyTrigger())

2.移除器(Evictor)

移除器主要用来定义移除某些数据的逻辑。基于 WindowedStream 调用.evictor()方法,就可以传入一个自定义的移除器(Evictor)。Evictor是一个接口,不同的窗口类型都有各自预实现的移除器。

stream.keyBy(...)
 .window(...)
 .evictor(new MyEvictor())

触发器、移除器 现成的几个窗口中都有默认的实现,一般不需要自定义
以 时间类型 的滚动窗口为例,分析原理:

  • 窗口什么时候触发输出?
    时间进展 >= 窗口的最大时间戳(end - 1ms)
  • 窗口是怎么划分的?
    start = 向下取整,取窗口长度的整数倍
    end = start + 窗口长度
    窗口左闭右开 ==> 属于本窗口的最大时间戳 = end - 1ms
  • 窗口的生命周期
    创建:属于本窗口范围的第一条数据来的时候,现new出来的,放入一个singleton单例的集合中
    销毁(关窗):时间进展 >= 窗口的最大时间戳(end - 1ms) + 允许迟到的时间(默认0)

2.时间语义

  • 事件时间★:一个是数据产生的时间(时间戳Timestamp)。
  • 处理时间:数据真正被处理的时刻。

3.水位线(Watermark)- 数据的时间线

1.事件时间和窗口

在窗口的处理过程中,我们可以基于数据的时间戳,自定义一个“逻辑时钟”。这个时钟的时间不会自动流逝;它的时间进展,就是靠着新到数据的时间戳来推动的。

好处在于,计算的过程可以完全不依赖处理时间(系统时间),不论什么时候进行统计处理,得到的结果都是正确的。而一般实时流处理的场景中,事件时间可以基本与处理时间保持同步,只是略微有一点延迟,同时保证了窗口计算的正确性。

2.什么是水位线

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

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

1.有序流中的水位线

  1. 理想状态(数据量小),数据应该按照生成的先后顺序进入流中,每条数据产生一个水位线;
  2. 实际应用中,如果当前数据量非常大,且同时涌来的数据时间差会非常小(比如几毫秒),往往对处理计算也没什么影响。所以为了提高效率,一般会每隔一段时间生成一个水位线。

2.乱序流中的水位线

在分布式系统中,数据在节点间传输,会因为网络传输延迟的不确定性,导致顺序发生改变,这就是所谓的“乱序数据”。

  1. 乱序 + 数据量小:我们还是靠数据来驱动,每来一个数据就提取它的时间戳、插入一个水位线。不过现在的情况是数据乱序,所以插入新的水位线时,要先判断一下时间戳是否比之前的大,否则就不再生成新的水位线。也就是说,只有数据的时间戳比当前时钟大,才能推动时钟前进,这时才插入水位线。
  2. 乱序 + 数据量大:如果考虑到大量数据同时到来的处理效率,我们同样可以周期性地生成水位线。这时只需要保存一下之前所有数据中的最大时间戳,需要插入水位线时,就直接以它作为时间戳生成新的水位线。
  3. 乱序 + 迟到数据:我们无法正确处理“迟到”的数据。为了让窗口能够正确收集到迟到的数据,我们也可以等上一段时间,比如2秒;也就是用当前已有数据的最大时间戳减去2秒,就是要插入的水位线的时间戳。这样的话,9秒的数据到来之后,事件时钟不会直接推进到9秒,而是进展到了7秒;必须等到11秒的数据到来之后,事件时钟才会进展到9秒,这时迟到数据也都已收集齐,0~9秒的窗口就可以正确计算结果了。

现在我们可以知道,水位线就代表了当前的事件时间时钟,而且可以在数据的时间戳基础上加一些延迟来保证不丢数据,这一点对于乱序流的正确处理非常重要。

3.水位线特性

  • 水位线是插入到数据流中的一个标记,可以认为是一个特殊的数据
  • 水位线主要的内容是一个时间戳,用来表示当前事件时间的进展
  • 水位线是基于数据的时间戳生成的
  • 水位线的时间戳必须单调递增,以确保任务的事件时间时钟一直向前推进
  • 水位线可以通过设置延迟,来保证正确处理乱序数据
  • 一个水位线Watermark(t),表示在当前流中事件时间已经达到了时间戳t,这代表t之前的所有数据都到齐了,之后流中不会出现时间戳t’ ≤ t的数据

水位线是Flink流处理中保证结果正确性的核心机制,它往往会跟窗口一起配合,完成对乱序数据的正确处理。

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

误解:在Flink中,窗口就是用来处理无界流的核心。我们很容易把窗口想象成一个固定位置的“框”,数据源源不断地流过来,到某个时间点窗口该关闭了,就停止收集数据、触发计算并输出结果。

例如,我们定义一个时间窗口,每10秒统计一次数据,那么就相当于把窗口放在那里,从0秒开始收集数据;到10秒时,处理当前窗口内所有数据,输出一个结果,然后清空窗口继续收集数据;到20秒时,再对窗口内所有数据进行计算处理,输出结果;依次类推。

注意:为了明确数据划分到哪一个窗口,定义窗口都是包含起始时间、不包含结束时间的,用数学符号表示就是一个左闭右开的区间,例如0~10秒的窗口可以表示为[0, 10),这里单位为秒。

问题描述:对于处理时间下的窗口而言,这样理解似乎没什么问题。然而如果我们采用事件时间语义,就会有些费解了。由于有乱序数据,我们需要设置一个延迟时间来等所有数据到齐。比如上面的例子中,我们可以设置延迟时间为2秒,这样0~10秒的窗口会在时间戳为12的数据到来之后,才真正关闭计算输出结果,这样就可以正常包含迟到的9秒数据了。

但是这样一来,0~10秒的窗口不光包含了迟到的9秒数据,连11秒和12秒的数据也包含进去了。我们为了正确处理迟到数据,结果把早到的数据划分到了错误的窗口——最终结果都是错误的。

正确理解:在Flink中,窗口其实并不是一个“框”,应该把窗口理解成一个“桶”。在Flink中,窗口可以把流切割成有限大小的多个“存储桶”(bucket);每个数据都会分发到对应的桶中,当到达窗口结束时间时,就对每个桶中收集的数据进行计算处理。

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

4.生成水位线

1.生成水位线的总体原则

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

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

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

2.水位线生成策略

在Flink的DataStream API中,有一个单独用于生成水位线的方法:.assignTimestampsAndWatermarks(),它主要用来为流中的数据分配时间戳,并生成水位线来指示事件时间。具体使用如下:

DataStream<Event> stream = env.addSource(new ClickSource());

DataStream<Event> withTimestampsAndWatermarks = 
stream.assignTimestampsAndWatermarks(<watermark strategy>);

说明:WatermarkStrategy 作为参数,这就是所谓的“水位线生成策略”。WatermarkStrategy 是一个接口,该接口中包含了一个“时间戳分配器”TimestampAssigner 和一个“水位线生成器”WatermarkGenerator。

public interface WatermarkStrategy<T> extends TimestampAssignerSupplier<T>, WatermarkGeneratorSupplier<T>{
 // 负责从流中数据元素的某个字段中提取时间戳,并分配给元素。时间戳的分配是生成水位线的基础。
 @Override
 TimestampAssigner<T> createTimestampAssigner(TimestampAssignerSupplier.Context context);
 // 主要负责按照既定的方式,基于时间戳生成水位线
 @Override
 WatermarkGenerator<T> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context);
}
1.有序流中内置水位线设置

对于有序流,主要特点就是时间戳单调增长,所以永远不会出现迟到数据的问题。这是周期性生成水位线的最简单的场景,直接调用 WatermarkStrategy.forMonotonousTimestamps()方法就可以实现。

        StreamExecutionEnvironment env =
                StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("hadoop102", 7777)
                .map(new WaterSensorMapFunction());
        // TODO 1.定义 Watermark 策略
        WatermarkStrategy<WaterSensor> watermarkStrategy = WatermarkStrategy
                // 1.1 指定 watermark 生成:升序的 watermark,没有等待时间
                .<WaterSensor>forMonotonousTimestamps()
                // 1.2 指定 时间戳分配器,从数据中提取
                .withTimestampAssigner(new SerializableTimestampAssigner<WaterSensor>() {
                    @Override
                    public long extractTimestamp(WaterSensor element, long
                            recordTimestamp) {
                        // 返回的时间戳,要 毫秒
                        System.out.println("数据=" + element + ",recordTs=" +
                                recordTimestamp);
                        return element.getTs() * 1000L;
                    }
                });

        // TODO 2. 指定 watermark 策略
        SingleOutputStreamOperator<WaterSensor> sensorDSwithWatermark =
                sensorDS.assignTimestampsAndWatermarks(watermarkStrategy);

        sensorDSwithWatermark.keyBy(sensor -> sensor.getId())
                // TODO 3.使用 事件时间语义 的窗口
                .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                .process(
                        new ProcessWindowFunction<WaterSensor, String, String, TimeWindow>() {
                            @Override
                            public void process(String s, Context context, Iterable<WaterSensor> elements, Collector<String> out) throws Exception {
                                long startTs = context.window().getStart();
                                long endTs = context.window().getEnd();
                                String windowStart = DateFormatUtils.format(startTs, "yyyy-MM-dd HH:mm:ss.SSS");
                                String windowEnd = DateFormatUtils.format(endTs, "yyyy-MM-dd HH:mm:ss.SSS");
                                long count = elements.spliterator().estimateSize();
                                out.collect("key=" + s + "的窗口[" + windowStart
                                        + "," + windowEnd + ")包含" + count + "条数据===>" + elements.toString());
                            }
                        }
                )
                .print();
        env.execute();
2.乱序流中内置水位线设置

由于乱序流中需要等待迟到数据到齐,所以必须设置一个固定量的延迟时间。这时生成水位线的时间戳,就是当前数据流中最大的时间戳减去延迟的结果,相当于把表调慢,当前时钟会滞后于数据的最大时间戳。调用 WatermarkStrategy. forBoundedOutOfOrderness()方法就可以实现。这个方法需要传入一个 maxOutOfOrderness 参数,表示“最大乱序程度”,它表示数据流中乱序数据时间戳的最大差值;如果我们能确定乱序程度,那么设置对应时间长度的延迟,就可以等到所有的乱序数据了。

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        env.setParallelism(1);

        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("hadoop102", 7777)
                .map(new WaterSensorMapFunction());

        // TODO 1.定义 Watermark 策略
        WatermarkStrategy<WaterSensor> watermarkStrategy = WatermarkStrategy
                // 1.1 指定 watermark 生成:乱序的,等待 3s
                .<WaterSensor>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                // 1.2 指定 时间戳分配器,从数据中提取
                .withTimestampAssigner(
                        (element, recordTimestamp) -> {
                            // 返回的时间戳,要 毫秒
                            System.out.println("数据=" + element + ",recordTs="
                                    + recordTimestamp);
                            return element.getTs() * 1000L;
                        });
        // TODO 2. 指定 watermark 策略
        SingleOutputStreamOperator<WaterSensor> sensorDSwithWatermark =
                sensorDS.assignTimestampsAndWatermarks(watermarkStrategy);

        sensorDSwithWatermark.keyBy(sensor -> sensor.getId())
                // TODO 3.使用 事件时间语义 的窗口
                .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                .process(
                        new ProcessWindowFunction<WaterSensor, String, String, TimeWindow>() {
                            @Override
                            public void process(String s, Context context, Iterable<WaterSensor> elements, Collector<String> out) throws Exception {
                                long startTs = context.window().getStart();
                                long endTs = context.window().getEnd();
                                String windowStart = DateFormatUtils.format(startTs, "yyyy-MM-dd HH:mm:ss.SSS");
                                String windowEnd = DateFormatUtils.format(endTs, "yyyy-MM-dd HH:mm:ss.SSS");
                                long count = elements.spliterator().estimateSize();
                                out.collect("key=" + s + "的窗口[" + windowStart
                                        + "," + windowEnd + ")包含" + count + "条数据===>" + elements.toString());
                            }
                        }
                )
                .print();
        
        env.execute();

内置Watermark的生成原理

  1. 都是周期性生成的,默认200ms
  2. 有序流: Watermark = 当前最大的事件时间 - 1ms
  3. 无序流: Watermark = 当前最大的事件时间 - 延迟时间 - 1ms

4.自定义水位线生成器

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

周期性生成器一般是通过 onEvent()观察判断输入的事件,而在 onPeriodicEmit()里发出水位线。

        // TODO 1.定义 Watermark 策略
        WatermarkStrategy<WaterSensor> watermarkStrategy = WatermarkStrategy
                // 1.1 指定 自定义的生成器,等待 3s
                .forGenerator(new WatermarkGeneratorSupplier<WaterSensor>() {
                    @Override
                    public WatermarkGenerator<WaterSensor> createWatermarkGenerator(Context context) {
                        return new MyPeriodWatermarkGenerator<>(3000L);
                    }
                })
                // 1.2 指定 时间戳分配器,从数据中提取
                .withTimestampAssigner(
                        (element, recordTimestamp) -> {
                            // 返回的时间戳,要 毫秒
                            System.out.println("数据=" + element + ",recordTs="
                                    + recordTimestamp);
                            return element.getTs() * 1000L;
                        });
						
    public static class MyPeriodWatermarkGenerator<T> implements WatermarkGenerator<T> {
        // 乱序等待时间, 延迟时间
        private Long delayTime;

        // 用来保存 当前为止 最大的 事件时间
        private Long maxTs;

        public MyPeriodWatermarkGenerator(Long delayTime) {
            this.delayTime = delayTime;
            this.maxTs = Long.MIN_VALUE + delayTime + 1;
        }

        /**
         * 每条数据来,就会调用一次,用来提取最大的事件时间,保存下来
         * @param event
         * @param eventTimestamp 提取到的数据的 事件时间
         * @param output
         */
        @Override
        public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
            // 每来一条数据就调用一次
            maxTs = Math.max(maxTs,eventTimestamp); // 更新最大时间戳
        }

        /**
         * 周期性调用,发射 watermark
         * @param output
         */
        @Override
        public void onPeriodicEmit(WatermarkOutput output) {
            // 发射水位线,默认 200ms 调用一次
            output.emitWatermark(new Watermark(maxTs - delayTime - 1));
        }
    }
2.断点式水位线生成器(Punctuated Generator)

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

    public static class MyPuntuatedWatermarkGenerator<T> implements WatermarkGenerator<T> {
        // 乱序等待时间, 延迟时间
        private Long delayTime;

        // 用来保存 当前为止 最大的 事件时间
        private Long maxTs;

        public MyPuntuatedWatermarkGenerator(Long delayTime) {
            this.delayTime = delayTime;
            this.maxTs = Long.MIN_VALUE + delayTime + 1;
        }

        /**
         * 每条数据来,就会调用一次,用来提取最大的事件时间,保存下来, 并发射 watermark
         * @param event
         * @param eventTimestamp 提取到的数据的 事件时间
         * @param output
         */
        @Override
        public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
            // 每来一条数据就调用一次
            maxTs = Math.max(maxTs,eventTimestamp); // 更新最大时间戳
            // 发射水位线,默认 200ms 调用一次
            output.emitWatermark(new Watermark(maxTs - delayTime - 1));
        }

        /**
         * 周期性调用,不需要
         * @param output
         */
        @Override
        public void onPeriodicEmit(WatermarkOutput output) {

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

我们也可以在自定义的数据源中抽取事件时间,然后发送水位线。这里要注意的是,在自定义数据源中发送了水位线以后,就不能再在程序中使用 assignTimestampsAndWatermarks方法来生成水位线了。在自定义数据源中生成水位线和在程序中使用assignTimestampsAndWatermarks 方法生成水位线二者只能取其一。

env.fromSource(kafkaSource, WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(3)), "kafkasource")

5.水位线的传递

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

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

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

        SingleOutputStreamOperator<Integer> socketDS = env
                .socketTextStream("localhost", 7777)
                .partitionCustom(new MyPartitioner(), r -> r)
                .map(r -> Integer.parseInt(r))
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<Integer>forMonotonousTimestamps() // 生成
                                .withTimestampAssigner((r, ts) -> r * 1000L)    // 时间戳提取
                                .withIdleness(Duration.ofSeconds(5))  //空闲等待 5s
                );

6.迟到数据的处理

1.推迟水印推进

在水印产生时,设置一个乱序容忍度,推迟系统时间的推进,保证窗口计算被延迟执行,为乱序的数据争取更多的时间进入窗口。

WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(10));
2.设置窗口延迟关闭

Flink的窗口,也允许迟到数据。当触发了窗口计算后,会先计算当前的结果,但是此时并不会关闭窗口。

以后每来一条迟到数据,就触发一次这条数据所在窗口计算(增量计算)。直到 wartermark 超过了窗口结束时间+推迟时间,此时窗口会真正关闭。

.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.allowedLateness(Time.seconds(3))

注意:
允许迟到只能运用在 event time 上

3.使用侧流接收迟到的数据
.windowAll(TumblingEventTimeWindows.of(Time.seconds(5)))
.allowedLateness(Time.seconds(3))
.sideOutputLateData(lateWS)

完整代码示例:

        StreamExecutionEnvironment env =
                StreamExecutionEnvironment.getExecutionEnvironment();

        env.setParallelism(1);

        SingleOutputStreamOperator<WaterSensor> sensorDS = env
                .socketTextStream("localhost", 7777)
                .map(new WaterSensorMapFunction());

        WatermarkStrategy<WaterSensor> watermarkStrategy =
                WatermarkStrategy
                        .<WaterSensor>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                        .withTimestampAssigner((element, recordTimestamp) ->
                                element.getTs() * 1000L);

        SingleOutputStreamOperator<WaterSensor> sensorDSwithWatermark =
                sensorDS.assignTimestampsAndWatermarks(watermarkStrategy);
        
        OutputTag<WaterSensor> lateTag = new OutputTag<>("late-data", Types.POJO(WaterSensor.class));
        
        SingleOutputStreamOperator<String> process =
                sensorDSwithWatermark.keyBy(sensor -> sensor.getId())
                        .window(TumblingEventTimeWindows.of(Time.seconds(10
                        )))
                        .allowedLateness(Time.seconds(2)) // 推迟 2s 关窗
                        .sideOutputLateData(lateTag) // 关窗后的迟到数据,放入 侧输出流
                        .process(
                                new ProcessWindowFunction<WaterSensor, String, String, TimeWindow>() {
                                    @Override
                                    public void process(String s, Context context, Iterable<WaterSensor> elements, Collector<String> out) throws Exception {
                                        long startTs = context.window().getStart();
                                        long endTs = context.window().getEnd();
                                        String windowStart = DateFormatUtils.format(startTs, "yyyy-MM-dd HH:mm:ss.SSS");
                                        String windowEnd = DateFormatUtils.format(endTs, "yyyy-MM-dd HH:mm:ss.SSS");
                                        long count = elements.spliterator().estimateSize();
                                        out.collect("key=" + s + "的窗口[" +
                                                windowStart + "," + windowEnd + ")包含" + count + "条数据===>" +
                                                elements.toString());
                                    }
                                }
                        );
        
        process.print();
        // 从主流获取侧输出流,打印
        process.getSideOutput(lateTag).printToErr("关窗后的迟到数据");
        
        env.execute();

乱序与迟到的区别:

  • 乱序:数据的顺序乱了,出现时间小的 比 时间大的 晚来
  • 迟到:数据的时间戳 < 当前的watermark

乱序、迟到数据的处理

  1. watermark中指定乱序等待时间
  2. 如果开窗,设置窗口允许迟到
    • 推迟关窗时间,在关窗之前,迟到数据来了,还能被窗口计算,来一条迟到数据触发一次计算。
    • 关窗后,迟到数据不会被计算。
  3. 关窗后的迟到数据,放入侧输出流

如果watermark等待3s,窗口允许迟到2s,为什么不直接watermark等待5s或者窗口允许迟到5s?

  • watermark等待时间不会设太大 ---> 影响计算延迟
    • 如果3s ---> 窗口第一次触发计算和输出,13s的数据来, 13-3=10s
    • 如果5s ---> 窗口第一次触发计算和输出,15s的数据来,15-5=10s
  • 窗口允许迟到,是对大部分迟到数据的处理,尽量让结果准确
    • 如果只设置允许迟到5s,那么就会导致频繁重新输出

设置经验:

  1. watermark等待时间,设置一个不算特别大的,一般是秒级,在乱序和延迟取舍。
  2. 设置一定的窗口允许迟到,只考虑大部分的迟到数据,极端小部分迟到很久的数据,不管。
  3. 极端小部分迟到很久的数据,放到侧输出流,获取到之后可以做各种处理。

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

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

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

1.窗口联结(Window Join)

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

窗口联结在代码中的实现,首先需要调用DataStream的.join()方法来合并两条流,得到一个 JoinedStreams;接着通过.where()和.equalTo()方法指定两条流中联结的 key;然后通过.window()开窗口,并调用.apply()传入联结窗口函数进行处理计算。通用调用形式如下:

stream1.join(stream2)
 .where(<KeySelector>)
 .equalTo(<KeySelector>)
 .window(<WindowAssigner>)
 .apply(<JoinFunction>)

上面代码中.where()的参数是键选择器(KeySelector),用来指定第一条流中的 key;而.equalTo()传入的 KeySelector 则指定了第二条流中的 key。两者相同的元素,如果在同一窗口中,就可以匹配起来,并通过一个“联结函数”(JoinFunction)进行处理了。

这里.window()传入的就是窗口分配器,之前讲到的三种时间窗口都可以用在这里:滚动窗口(tumbling window)、滑动窗口(sliding window)和会话窗口(session window)。

而后面调用.apply()可以看作实现了一个特殊的窗口函数。注意这里只能调用.apply(),没有其他替代的方法。

传入的 JoinFunction 也是一个函数类接口,使用时需要实现内部的.join()方法。这个方法有两个参数,分别表示两条流中成对匹配的数据。

其实仔细观察可以发现,窗口 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()方法,也就没有任何输出了。

案例代码:

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        
        env.setParallelism(1);
        
        SingleOutputStreamOperator<Tuple2<String, Integer>> ds1 = env
                .fromElements(
                        Tuple2.of("a", 1),
                        Tuple2.of("a", 2),
                        Tuple2.of("b", 3),
                        Tuple2.of("c", 4)
                )
                .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("a", 11, 1),
                        Tuple3.of("b", 2, 1),
                        Tuple3.of("b", 12, 1),
                        Tuple3.of("c", 14, 1),
                        Tuple3.of("d", 15, 1)
                )
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<Tuple3<String, Integer, Integer>>forMonotonousTimestamps()
                                .withTimestampAssigner((value, ts) -> value.f1 * 1000L)
                );
        
        // TODO window join
        // 1. 落在同一个时间窗口范围内才能匹配
        // 2. 根据 keyby 的 key,来进行匹配关联
        // 3. 只能拿到匹配上的数据,类似有固定时间范围的 inner join
        DataStream<String> join = ds1.join(ds2)
                .where(r1 -> r1.f0) // ds1 的 keyby
                .equalTo(r2 -> r2.f0) // ds2 的 keyby
                .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                .apply(new JoinFunction<Tuple2<String, Integer>, Tuple3<String, Integer, Integer>, String>() {
                    /**
                     * 关联上的数据,调用 join 方法
                     *
                     * @param first  ds1 的数据
                     * @param second ds2 的数据
                     * @return
                     * @throws Exception
                     */
                    @Override
                    public String join(Tuple2<String, Integer> first, Tuple3<String, Integer, Integer> second) throws Exception {
                        return first + "<----->" + second;
                    }
                });
        
        join.print();
        env.execute();

2.间隔联结(Interval Join)

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

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

1.间隔联结的原理

间隔联结具体的定义方式是,我们给定两个时间点,分别叫作间隔的“上界”(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,两者都可正可负;间隔联结目前只支持事件时间语义。

间隔联结同样是一种内连接(inner join)。与窗口联结不同的是,interval join 做匹配的时间段是基于流中数据的,所以并不确定;而且流 B 中的数据可以不只在一个区间内被匹配。

2.间隔联结的调用

间隔联结在代码中,是基于 KeyedStream 的联结(join)操作。DataStream 在 keyBy 得到KeyedStream之后,可以调用.intervalJoin()来合并两条流,传入的参数同样是一个KeyedStream,两者的 key 类型应该一致;得到的是一个 IntervalJoin 类型。后续的操作同样是完全固定的:先通过.between()方法指定间隔的上下界,再调用.process()方法,定义对匹配数据对的处理操作。调用.process()需要传入一个处理函数,这是处理函数家族的最后一员:“处理联结函数”ProcessJoinFunction。

通用调用形式如下:

stream1
 .keyBy(<KeySelector>)
 .intervalJoin(stream2.keyBy(<KeySelector>))
 .between(Time.milliseconds(-2), Time.milliseconds(1))
 .process (new ProcessJoinFunction<Integer, Integer, String(){
 @Override
 public void processElement(Integer left, Integer right, Context ctx, Collector<String> out) {
     out.collect(left + "," + right);
   }
 });

可以看到,抽象类 ProcessJoinFunction 就像是 ProcessFunction 和 JoinFunction 的结合,内部同样有一个抽象方法.processElement()。与其他处理函数不同的是,它多了一个参数,这自然是因为有来自两条流的数据。参数中 left 指的就是第一条流中的数据,right 则是第二条流中与它匹配的数据。每当检测到一组匹配,就会调用这里的.processElement()方法,经处理转换之后输出结果。

3.间隔联结实例

1.正常使用
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        env.setParallelism(1);

        SingleOutputStreamOperator<Tuple2<String, Integer>> ds1 = env
                .fromElements(
                        Tuple2.of("a", 1),
                        Tuple2.of("a", 2),
                        Tuple2.of("b", 3),
                        Tuple2.of("c", 4)
                )
                .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("a", 11, 1),
                        Tuple3.of("b", 2, 1),
                        Tuple3.of("b", 12, 1),
                        Tuple3.of("c", 14, 1),
                        Tuple3.of("d", 15, 1)
                )
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<Tuple3<String, Integer, Integer>>forMonotonousTimestamps()
                                .withTimestampAssigner((value, ts) -> value.f1 * 1000L)
                );
        
        // TODO interval join
        //1. 分别做 keyby,key 其实就是关联条件
        KeyedStream<Tuple2<String, Integer>, String> ks1 = ds1.keyBy(r1 -> r1.f0);
        
        KeyedStream<Tuple3<String, Integer, Integer>, String> ks2 = ds2.keyBy(r2 -> r2.f0);
        
        //2. 调用 interval join
        ks1.intervalJoin(ks2)
                .between(Time.seconds(-2), Time.seconds(2))
                .process(
                        new ProcessJoinFunction<Tuple2<String,Integer>, Tuple3<String, Integer, Integer>, String>() {
                            
                            /**
                             * 两条流的数据匹配上,才会调用这个方法
                             *
                             * @param left  ks1 的数据
                             * @param right ks2 的数据
                             * @param ctx   上下文
                             * @param out   采集器
                             * @throws Exception
                             */
                            @Override
                            public void
                            processElement(Tuple2<String, Integer> left, Tuple3<String, Integer, Integer> right, Context ctx, Collector<String> out) throws Exception {
                                // 进入这个方法,是关联上的数据
                                out.collect(left + "<------>" + right);
                            }
                        })
                .print();
        env.execute();
2.处理迟到数据
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        env.setParallelism(1);

        SingleOutputStreamOperator<Tuple2<String, Integer>> ds1 = env
                .socketTextStream("localhost", 7777)
                .map(new MapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public Tuple2<String, Integer> map(String value) throws Exception {
                        String[] datas = value.split(",");
                        return Tuple2.of(datas[0], Integer.valueOf(datas[1]));
                    }
                })
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<Tuple2<String, Integer>>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                                .withTimestampAssigner((value, ts) -> value.f1 * 1000L)
                );

        SingleOutputStreamOperator<Tuple3<String, Integer, Integer>> ds2 = env
                .socketTextStream("localhost", 8888)
                .map(new MapFunction<String, Tuple3<String, Integer, Integer>>() {
                    @Override
                    public Tuple3<String, Integer, Integer> map(String value) throws Exception {
                        String[] datas = value.split(",");
                        return Tuple3.of(datas[0], Integer.valueOf(datas[1]), Integer.valueOf(datas[2]));
                    }
                })
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<Tuple3<String, Integer, Integer>>forBoundedOutOfOrderness(Duration.ofSeconds(3))
                                .withTimestampAssigner((value, ts) -> value.f1 * 1000L)
                );
        
        /**
         * TODO Interval join
         * 1、只支持事件时间
         * 2、指定上界、下界的偏移,负号代表时间往前,正号代表时间往后
         * 3、process 中,只能处理 join 上的数据
         * 4、两条流关联后的 watermark,以两条流中最小的为准
         * 5、如果 当前数据的事件时间 < 当前的 watermark,就是迟到数据, 主
         流的 process 不处理
         * => between 后,可以指定将 左流 或 右流 的迟到数据 放入侧输出流
         */
        //1. 分别做 keyby,key 其实就是关联条件
        KeyedStream<Tuple2<String, Integer>, String> ks1 = ds1.keyBy(r1 -> r1.f0);
        
        KeyedStream<Tuple3<String, Integer, Integer>, String> ks2 = ds2.keyBy(r2 -> r2.f0);
        
        //2. 调用 interval join
        OutputTag<Tuple2<String, Integer>> ks1LateTag = new OutputTag<>("ks1-late", Types.TUPLE(Types.STRING, Types.INT));
        
        OutputTag<Tuple3<String, Integer, Integer>> ks2LateTag = new OutputTag<>("ks2-late", Types.TUPLE(Types.STRING, Types.INT, Types.INT));

        SingleOutputStreamOperator<String> process =
                ks1.intervalJoin(ks2)
                        .between(Time.seconds(-2), Time.seconds(2))
                        .sideOutputLeftLateData(ks1LateTag) // 将 ks1 的迟到 数据,放入侧输出流
                        .sideOutputRightLateData(ks2LateTag) // 将 ks2 的迟到 数据,放入侧输出流
                        .process(
                                new ProcessJoinFunction<Tuple2<String, Integer>, Tuple3<String, Integer, Integer>, String>() {
                                    /**
                                     * 两条流的数据匹配上,才会调用这个方法
                                     *
                                     * @param left  ks1 的数据
                                     * @param right ks2 的数据
                                     * @param ctx   上下文
                                     * @param out   采集器
                                     * @throws Exception
                                     */
                                    @Override
                                    public void processElement(Tuple2<String, Integer> left, Tuple3<String, Integer, Integer> right, Context ctx, Collector<String> out) throws Exception {
                                        // 进入这个方法,是关联上的数据
                                        out.collect(left + "<------>" + right);
                                    }
                                });
        
        process.print("主流");
        process.getSideOutput(ks1LateTag).printToErr("ks1 迟到数据");
        process.getSideOutput(ks2LateTag).printToErr("ks2 迟到数据");
        
        env.execute();
posted @ 2024-01-24 10:12  SpringCore  阅读(466)  评论(0编辑  收藏  举报