什么是 Window?
下面我们结合一个现实的例子来说明。
就拿交通传感器的示例:统计经过某红绿灯的汽车数量之和?
假设在一个红绿灯处,我们每隔 15 秒统计一次通过此红绿灯的汽车数量,如下图:
可以把汽车的经过看成一个流,无穷的流,不断有汽车经过此红绿灯,因此无法统计总共的汽车数量。但是,我们可以换一种思路,每隔 15 秒,我们都将与上一次的结果进行 sum 操作(滑动聚合),如下:
这个结果似乎还是无法回答我们的问题,根本原因在于流是无界的,我们不能限制流,但可以在有一个有界的范围内处理无界的流数据。因此,我们需要换一个问题的提法:每分钟经过某红绿灯的汽车数量之和?
这个问题,就相当于一个定义了一个 Window(窗口),Window 的界限是 1 分钟,且每分钟内的数据互不干扰,因此也可以称为翻滚(不重合)窗口,如下图:
第一分钟的数量为 18,第二分钟是 28,第三分钟是 24……这样,1 个小时内会有 60 个 Window。
再考虑一种情况,每 30 秒统计一次过去 1 分钟的汽车数量之和:
此时,Window 出现了重合。这样,1 个小时内会有 120 个 Window。
Window 有什么作用?
通常来讲,Window 就是用来对一个无限的流设置一个有限的集合,在有界的数据集上进行操作的一种机制。Window 又可以分为基于时间(Time-based)的 Window 以及基于数量(Count-based)的 window。
flink在KeyedStream(DataStream 的继承类) 中提供了三种窗口类型:
- 以时间驱动的 Time Window
- 以事件数量驱动的 Count Window
- 以会话间隔驱动的 Session Window
Time Window的使用:
dataStream.keyBy(1)
.timeWindow(Time.minutes(1)) //time Window 每分钟统计一次数量和
.sum(1);
同时也支持滑动的时间窗口,比如每隔 30s 去统计过去一分钟窗口内的数据
dataStream.keyBy(1)
.timeWindow(Time.minutes(1), Time.seconds(30)) //sliding time Window 每隔 30s 统计过去一分钟的数量和
.sum(1);
Count Window的使用:
Flink 还提供计数窗口功能,如果计数窗口的值设置的为 3 ,那么将会在窗口中收集 3 个事件,并在添加第 3 个元素时才会计算窗口中所有事件的值。
dataStream.keyBy(1)
.countWindow(3) //统计每 3 个元素的数量之和
.sum(1);
同时也支持滑动的计数窗口,比如:比如定义了一个每 3 个事件滑动一次的 4 个事件的计数窗口,它会每隔 3 个事件去统计过去 4 个事件计数窗口内的数据
dataStream.keyBy(1)
.countWindow(4, 3) //每隔 3 个元素统计过去 4 个元素的数量之和
.sum(1);
Session Window的使用:
什么是Session Window,就是其中某条数据超过我们规定的时长后都没有再收到下条数据,则触发该窗口的计算,使用如下:
dataStream.keyBy(1)
.window(ProcessingTimeSessionWindows.withGap(Time.seconds(5)))//表示如果 5s 内没出现数据则认为超出会话时长,然后计算这个窗口的和
.sum(1);
Window的开始时间
注意,这里的开始时间可能并不是你设置的EventTime或者ProcessTime,而是通过计算得到的一个开始时间,打开源码可以看到计算的开始时间的代码:
@Override
public Collection<TimeWindow> assignWindows(Object element, long timestamp, WindowAssignerContext context) {
if (timestamp > Long.MIN_VALUE) {
// Long.MIN_VALUE is currently assigned when no timestamp is present
long start = TimeWindow.getWindowStartWithOffset(timestamp, offset, size);
return Collections.singletonList(new TimeWindow(start, start + size));
} else {
throw new RuntimeException("Record has Long.MIN_VALUE timestamp (= no timestamp marker). " +
"Is the time characteristic set to 'ProcessingTime', or did you forget to call " +
"'DataStream.assignTimestampsAndWatermarks(...)'?");
}
}
public static long getWindowStartWithOffset(long timestamp, long offset, long windowSize) {
return timestamp - (timestamp - offset + windowSize) % windowSize;
}
重点就是这个timestamp - (timestamp - offset + windowSize) % windowSize
offset表示你偏移量,windowSize表示滚动的窗口大小。比如计算timestamp = 1599623712,windowSize=5s的一个开始时间是1599623710。可以发现flink窗口的开始时间都是取的自然时间的开始。
但是在跨天的时候,由于flink取的是时间纪元,也就是格林威治时间,而我们是东八区的时区,所以存在8小时的时差,跨天的时候,窗口开始时间是格林威治时间的0点,而此时东八区是早上8点,所以如果你想要开天窗口计算从0点到24的数据,其实是计算的早上8点到第二天早上8点的数据。解决这个问题的话,需要给窗口设置偏移量。在源码中有例子说明:意思就是如果你是东八区,并且想开一天的窗口从0点到24点,可以这样设置offset为-8 。
Window 的使用
Window分为Keyed Windows和Non-Keyed Windows
区别就在于一个是按照key分组的,一个是不分组的;分组之后相同key的数据在同一个组中并且在同一个窗口中参与计算,比如计算每分钟内不同类型机器的告警次数,就要先keyBy();Non-Keyed Windows就是所有数据都在一个窗口中参与计算,没有明确的的分组需求;
用法如下:
stream
.keyBy(...) // keyedStream上使用window
.window(...) // 必选: 指定窗口分配器( window assigner)
[.trigger(...)] // 可选: 指定触发器(trigger),如果不指定,则使用默认值
[.evictor(...)] // 可选: 指定清除器(evictor),如果不指定,则没有
[.allowedLateness(...)] // 可选: 指定是否延迟处理数据,如果不指定,默认使用0
[.sideOutputLateData(...)] // 可选: 配置side output,如果不指定,则没有
.reduce/aggregate/fold/apply() // 必选: 指定窗口计算函数
[.getSideOutput(...)] // 可选: 从side output中获取数据
stream
.windowAll(...) // 必选: 指定窗口分配器( window assigner)
[.trigger(...)] // 可选: 指定触发器(trigger),如果不指定,则使用默认值
[.evictor(...)] // 可选: 指定清除器(evictor),如果不指定,则没有
[.allowedLateness(...)] // 可选: 指定是否延迟处理数据,如果不指定,默认使用0
[.sideOutputLateData(...)] // 可选: 配置side output,如果不指定,则没有
.reduce/aggregate/fold/apply() // 必选: 指定窗口计算函数
[.getSideOutput(...)] // 可选: 从side output中获取数据
Window Functions介绍
通常会对窗口中的数据做一些计算,这时就用到了窗口函数。Flink提供了两大类窗口函数,分别为增量聚合函数和全量窗口函数。其中增量聚合函数的性能要比全量窗口函数高,因为增量聚合窗口是基于中间结果状态计算最终结果的,即窗口中只维护一个中间结果状态,不要缓存所有的窗口数据。相反,对于全量窗口函数而言,需要对所以进入该窗口的数据进行缓存,等到窗口触发时才会遍历窗口内所有数据,进行结果计算。如果窗口数据量比较大或者窗口时间较长,就会耗费很多的资源缓存数据,从而导致性能下降。
- 增量聚合函数
包括:ReduceFunction、AggregateFunction和FoldFunction - 全量窗口函数
包括:ProcessWindowFunction(Keyed Windows下的函数)、ProcessAllWindowFunction(Non-Keyed Windows下的函数)
ReduceFunction
用上一次的结果值与当前值进行聚合,要求输入元素的数据类型与输出元素的数据类型必须一致。
比较简单,不举例说明。
AggregateFunction
与ReduceFunction相似,AggregateFunction也是基于中间状态计算结果的增量计算函数,相比ReduceFunction,AggregateFunction在窗口计算上更加灵活,但是实现稍微复杂,需要实现AggregateFunction接口,重写四个方法。其最大的优势就是中间结果的数据类型和最终的结果类型不依赖于输入的数据类型。
/**
* @param <IN> 输入元素的数据类型
* @param <ACC> 中间聚合结果的数据类型
* @param <OUT> 最终聚合结果的数据类型
*/@PublicEvolvingpublic interface AggregateFunction<IN, ACC, OUT> extends Function, Serializable {
/**
* 创建一个新的累加器
*/
ACC createAccumulator();
/**
* 将新的数据与累加器进行聚合,返回一个新的累加器
*/
ACC add(IN value, ACC accumulator);
/**
从累加器中计算最终结果并返回
*/
OUT getResult(ACC accumulator);
/**
* 合并两个累加器并返回结果
*/
ACC merge(ACC a, ACC b);
}
FlinkKafkaConsumer010<String> myConsumer = new FlinkKafkaConsumer010<>("test5", new SimpleStringSchema(), properties);
DataStream<String> numDataStream = env.addSource(myConsumer);
SingleOutputStreamOperator<Tuple2<Integer, Integer>> stream = numDataStream
.map(item -> Tuple2.of(NumberUtils.toInt(item) % 2, NumberUtils.toInt(item)))
.returns(Types.TUPLE(Types.INT, Types.INT));
stream.print();
WindowedStream<Tuple2<Integer, Integer>, Tuple, TimeWindow> timeStream = stream.keyBy(0).timeWindow(Time.seconds(10));
timeStream.aggregate(new AggregateFunction<Tuple2<Integer, Integer>, Tuple2<Integer, Integer>, Double>() {
@Override
public Tuple2<Integer, Integer> createAccumulator() {
return Tuple2.of(0, 0);
}
@Override
public Tuple2<Integer, Integer> add(Tuple2<Integer, Integer> value, Tuple2<Integer, Integer> acc) {
acc.f0 += 1;
acc.f1 += value.f1;
return acc;
}
@Override
public Double getResult(Tuple2<Integer, Integer> acc) {
return acc.f1 / (double) acc.f0;
}
@Override
public Tuple2<Integer, Integer> merge(Tuple2<Integer, Integer> a, Tuple2<Integer, Integer> b) {
return null;
}
}).print();
env.execute();
关于merge方法的一点说明:merge()就算不重写,直接reture null,好像对结果没有什么影响,官网有说,merge是在使用session窗口的时候用到,因为需要合并窗口。具体的session窗口没有实际使用过,后面持续关注merge方法吧。
ProcessWindowFunction
ProcessWindowFunction处理的窗口会将所有已分配的数据存储到ListState中,通过将数据收集起来且提供对于窗口的元数据及其他一些特性的访问和使用;ProcessWindowsFunction能够更加灵活地支持基于窗口全部数据元素的结果计算。
extends ProcessWindowFunction ,重写process()方法即可。
ReduceFunction与ProcessWindowFunction组合
ProcessWindowFunction提供了很强大的功能,但是唯一的缺点就是需要更大的状态存储数据。在很多时候,增量聚合的使用是非常频繁的,那么如何实现既支持增量聚合又支持访问窗口元数据的操作呢?可以将ReduceFunction和AggregateFunction与ProcessWindowFunction整合在一起使用。通过这种组合方式,分配给窗口的元素会立即被执行计算,当窗口触发时,会把聚合的结果传给ProcessWindowFunction,这样ProcessWindowFunction的process方法的Iterable参数被就只有一个值,即增量聚合的结果。
AggregateFunction与ProcessWindowFunction组合
和上面是一样的应用场景,看需求选择ReduceFunction还是AggregateFunction。