1. 窗口的API概念
窗口的API使用分为按键分区和非按键分区,在定义窗口操作之前,首先就要确定好是基于按键分区Keyed
的数据流KeyedStream
来开窗还是基于没有按键分区的DataStream
上开窗。
1.1 按键分区窗口(Keyed Windows)
按键分区窗口就是按照key分为多条逻辑流logical streams
,这就是KeyedStream。基于KeyedStream进行窗口操作时,窗口计算会在多个并行子任务上同时执行。相同key的数据会被发送到同一个并行子任务,而窗口操作会基于每个key进行单独的计算。
代码实现:
stream.keyBy(...)
.window(...)
1.2 非按键分区(Non-Keyed Windows)
如果没有进行 keyBy
,那么原始的DataStream
就不会分成多条逻辑流。这时窗口逻辑只能在一个任务(task)上执行,就相当于并行度变成了 1
。所以在实际应用中一般不推荐使用这种方式。
代码实现:
stream.windowAll(...)
这里需要注意的是,对于非按键分区的窗口操作,手动调大窗口算子的并行度也是无效的,windowAll
本身就是一个非并行的操作。
1.3 代码中窗口 API 的调用
简单来说,窗口操作主要有两个部分:窗口分配器(Window Assigners)
和窗口函数(Window Functions)
。
代码实现:
stream.keyBy(<key selector>)
.window(<window assigner>)
.aggregate(<window function>)
其中.window()方法需要传入一个窗口分配器,它指明了窗口的类型;而后面的.aggregate()方法传入一个窗口函数作为参数,它用来定义窗口具体的处理逻辑。窗口分配器有各种形式,而窗口函数的调用方法也不只.aggregate()一种
,我们接下来就详细展开讲解。另外,在实际应用中,一般都需要并行执行任务,非按键分区很少用到,所以我们之后都以按键分区窗口为例;如果想要实现非按键分区窗口,只要前面不做 keyBy,后面调用.window()时直接换成.windowAll()就可以了。
2. 窗口分配器(Window Assigners)
定义窗口分配器(Window Assigners)
是构建窗口算子的第一步,它的作用就是定义数据应该被“分配”到哪个窗口。窗口分配数据的规则,其实就对应着不同的窗口类型。所以,窗口分配器其实就是在指定窗口的类型。
窗口分配器最通用的定义方式,就是调用.window()
方法。这个方法需要传入一个WindowAssigner
作为参数,返回的WindowedStream
。如果是非按键分区窗口,那么直接调用.windowAll()
方法,同样传入一个 WindowAssigner
,返回的是AllWindowedStream
。
窗口按照驱动类型可以分成时间窗口
和计数窗口
,而按照具体的分配规则,又有滚动窗口
、滑动窗口
、会话窗口
、全局窗口
四种。除去需要自定义的全局窗口外,其他常用的类型 Flink中都给出了内置的分配器实现,我们可以方便地调用实现各种需求。
2.1 时间窗口
时间窗口
是最常用的窗口类型,又可以细分为滚动
、滑动
和会话
三种。
在使用中直接调用.window()
,在里面传入对应时间语义下的窗口分配器。这样一来,我们不需要专门定义时间语义,默认就是事件时间
;如果想用处理时间
,那么在这里传入处理时间
的窗口分配器
就可以了。
2.1.1 滚动处理时间窗口(TumblingProcessingTimeWindows)
窗口分配器由类 TumblingProcessingTimeWindows
提供,需要调用它的静态方法.of()
。
代码实现:
stringDataStreamSource.keyBy(...)
.window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
.aggregate(...);
这里.of()
方法需要传入一个 Time
类型的参数 size
,表示滚动窗口的大小
,我们这里创建了一个长度为 5 秒
的滚动窗口。
另外.of()
还有一个重载方法public static TumblingProcessingTimeWindows of(Time size, Time offset)
,可以传入两个 Time 类型的参数:size
和 offset
。第一个参数表示窗口大小,第二个参数表示窗口起始点的偏移量。这个偏移量主要是解决时区问题,比如我们定义 1 天滚动窗口时,如果用默认的起始点,那么得到就是伦敦时间每天 0点开启窗口,这时是北京时间早上 8 点。那怎样得到北京时间每天 0 点开启的滚动窗口呢?只要设置-8 小时的偏移量就可以了:.window(TumblingProcessingTimeWindows.of(Time.days(1), Time.hours(-8)))
2.1.2 滑动处理时间窗口(SlidingProcessingTimeWindows)
窗口分配器由类 SlidingProcessingTimeWindows
提供,同样需要调用它的静态方法.of()
。
代码实现:
stringDataStreamSource.keyBy(...)
.window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.aggregate(...)
这里.of()
方法需要传入两个 Time
类型的参数:size
和 slide
,前者表示滑动窗口的大小
,后者表示滑动窗口的滑动步长
。我们这里创建了一个长度为 10 秒、滑动步长为 5 秒的滑动窗口。同样滑动窗口也可以追加第三个参数offset -偏移量
,用于指定窗口起始点的偏移量,用法与滚动窗口完全一致。
2.1.3 处理时间会话窗口(ProcessingTimeSessionWindows)
窗口分配器由类 ProcessingTimeSessionWindows
提供,需要调用它的静态方法.withGap()
或者.withDynamicGap()
。
代码实现:
stream.keyBy(...)
.window(ProcessingTimeSessionWindows.withGap(Time.seconds(10)))
.aggregate(...)
这里.withGap()
方法需要传入一个 Time
类型的参数 size
,表示会话的超时时间
,也就是最小间隔 session gap
。我们这里创建了静态会话超时时间为 10 秒的会话窗口。
我们也可以使用.withDynamicGap()
方法动态定义session gap
时间,这就需要传入一个SessionWindowTimeGapExtractor
作为参数,用来定义 session gap
的动态提取逻辑。在这里,我们提取了数据元素的第一个字段,用它的长度乘以 1000 作为会话超时的间隔。
.window(ProcessingTimeSessionWindows.withDynamicGap(new
SessionWindowTimeGapExtractor<Tuple2<String, Long>>() {
@Override
public long extract(Tuple2<String, Long> element) {
// 提取 session gap 值返回, 单位毫秒
return element.f0.length() * 1000;
}
}))
2.1.4 滚动事件时间窗口(TumblingEventTimeWindows)
窗口分配器由类 TumblingEventTimeWindows 提供,用法与滚动处理事件窗口完全一致。
stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.aggregate(...)
这里.of()
方法也可以传入第二个参数 offset
,用于设置窗口起始点的偏移量。
2.1.5 滑动事件时间窗口(SlidingEventTimeWindows)
窗口分配器由类 SlidingEventTimeWindows
提供,用法与滑动处理事件窗口完全一致。
stream.keyBy(...)
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.aggregate(...)
2.1.6 事件时间会话窗口(EventTimeSessionWindows)
窗口分配器由类 EventTimeSessionWindows
提供,用法与处理事件会话窗口完全一致。
stream.keyBy(...)
.window(EventTimeSessionWindows.withGap(Time.seconds(10)))
.aggregate(...)
2.2 计数窗口
计数窗口本身底层是基于全局窗口(Global Window)实现的。直接调用.countWindow()
方法。根据分配规则的不同,又可以分为滚动计数窗口
和滑动计数窗口
两类。
2.2.1 滚动计数窗口
滚动计数窗口只需要传入一个长整型的参数 size,表示窗口的大小。
stream.keyBy(...)
.countWindow(10)
我们定义了一个长度为 10 的滚动计数窗口,当窗口中元素数量达到 10 的时候,就会触发计算执行并关闭窗口。
2.2.2 滑动计数窗口
与滚动计数窗口类似,不过需要在.countWindow()
调用时传入两个参数:size
和 slide
,前者表示窗口大小,后者表示滑动步长。
stream.keyBy(...)
.countWindow(10,3)
我们定义了一个长度为 10、滑动步长为 3 的滑动计数窗口。每个窗口统计 10 个数据,每隔 3 个数据就统计输出一次结果。
2.3 全局窗口
全局窗口是计数窗口的底层实现,一般在需要自定义窗口时使用。它的定义同样是直接用.window()
,分配器由 GlobalWindows
类提供。
stream.keyBy(...)
.window(GlobalWindows.create());
需要注意使用全局窗口,必须自行定义触发器
才能实现窗口计算,否则起不到任何作用。
3. 窗口函数(Window Functions)
定义窗口分配器只是知道了数据属于哪个窗口,可以将数据收集起来;但是如果想让收集的数据到底做什么就需要在接上一个定义窗口计算的窗口函数(window functions)
。
经过窗口分配器处理之后,数据可以分配到对应窗口中,而数据流经过转换得到的数据类型是WindowedStream
。不是 DataStream
,所以并不能直接进行其他转换,必须进一步调用窗口函数,对收集的数据进行处理计算之后,才能最终得到 DataStream
,如下图:
窗口函数定义了要对窗口中收集的数据做的计算处理,根据处理方式分为两类:增量聚合函数
和全窗口函数
。
3.1 增量聚合函数(incremental aggregation functions)
窗口将数据收集起来,最基本的处理操作就是聚合。窗口对无限流的切分,可以看作得到了一个有界数据集。如果如果等到所有数据都到了在窗口到了结束时间再去聚合就很不高效——这就相当于真的在用批处理的思路来做实时流处理。
为了提高实时性,流处理就像 DataStream 的简单聚合一样,每来一条数据就立即进行计算,中间只要保持一个简单的聚合状态就可以了;区别只是在于不立即输出结果,而是要等到窗口结束时间。等到窗口到了结束时间需要输出计算结果的时候,我们只需要拿出之前聚合的状态直接输出即可,这就提高了程序运行的效率和实时性。
典型的增量聚合函数有两个:ReduceFunction
和AggregateFunction
3.1.1 归约函数(ReduceFunction)
最简单的聚合就是归约(reduce)
,就是将窗口中收集到的数据两两进行归约。当我们进行流处理时,就是要保存一个状态;每来一个新的数据,就和之前的聚合状态做归约,这样就实现了增量式的聚合。基于 WindowedStream
调用.reduce()
方法,然
后传入 ReduceFunction
作为参数,就可以指定以归约两个元素的方式去对窗口中数据进行聚合了。这里的 ReduceFunction
与简单聚合时用到的 ReduceFunction
是同一个函数类接口,所以使用方式也是完全一样的。
代码实例:
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(