窗口的概念:
Flink 是一种流式计算引擎,主要是来处理无界数据流。想 要更加方便高效地处理无界流,一种方式就是将无限数据切割成有限的“数据块”进行处理,这 就是所谓的“窗口”(Window)。 在 Flink 中, 窗口就是用来处理无界流的核心。我们很容易把窗口想象成一个固定位置的 “框”,数据源源不断地流过来,到某个时间点窗口该关闭了,就停止收集数据、触发计算并输 出结果。例如,我们定义一个时间窗口,每 10 秒统计一次数据,那么就相当于把窗口放在那 里,从 0 秒开始收集数据;到 10 秒时,处理当前窗口内所有数据,输出一个结果,然后清空 窗口继续收集数据;到 20 秒时,再对窗口内所有数据进行计算处理,输出结果;依次类推, 如图所示。
这里注意为了明确数据划分到哪一个窗口,用数学符号表示就是一个左闭右开的区间,例如 0~10 秒的窗口可以表示为[0, 10),这里单 位为秒。对于处理时间下的窗口而言,这样理解似乎没什么问题。因为窗口的关闭是基于系统时间的,当前窗口关闭就只能去下一个窗口正如上图中,0~10 秒的窗口关闭后,可能还有时间戳为 9 的数据会来,它就只能进入 10~20 秒的窗口了。这样会造成窗口处理结果的不准确。所以我们需要设置一个延迟时间来等所有数据到齐。比如上面的例子中,我们可以设置延迟时间为 2 秒,这样 0~10 秒的窗口会在时间戳为 12 的数据到来之后,才真正关闭计算输出结果,这 样就可以正常包含迟到的 9 秒数据了如图所示。
1)时间窗口(Time Window):
时间窗口以时间点来定义窗口的开始时间 和结束时间,截取出的就是某一时间段的数据。到结束时间时, 窗口不再收集数据, 触发计算输出结果, 并将窗口关闭销毁。
用结束时间减去开始时间,得到这段时间的长度, 就是窗口的大小(window size)。这里的时间可以是不同的语义,既可以是处理时间窗口也可以是事件时间窗口。
Flink 中有一个专门的类来表示时间窗口, 名称就叫作 TimeWindow。这个类只有两个私 有属性:start 和 end ,表示窗口的开始和结束的时间戳,单位为毫秒。另外,TimeWindow 还提供了一个 maxTimestamp()方法,用来获取窗口中能够包含数据的最大时间戳
2)计数窗口(Count Window):
计数窗口基于元素的个数来截取数据,到达固定的个数时就触发计算并关闭窗口。每个窗口截取数据的个数,就是窗口的大小。
计数窗口相比时间窗口就更加简单,只需指定窗口大小,就可以把数据分配到对应的窗口中了。在 Flink 内部也并没有对应的类来表示计数窗口,底层是通过“全局窗口”(Global Window)来实现的。
口大小之后,所有分区的窗口划分都是一致的;窗口没有重叠,每个数据只属于一个窗口。 滚动窗口应用非常广泛,它可以对每个时间段做聚合统计,很多 BI 分析指标都可以用它 来实现。
2)滑动窗口(Sliding Windows):
与滚动窗口类似,滑动窗口的大小也是固定的。区别在于,窗口之间并不是首尾相接的, 而是可以“错开”一定的位置。滑动窗口的参数有两个:除去窗口大小(window size)之外,还有一个“滑动步长”(window slide),它其实就代表了窗口计算的频率。滑动的距离代表了下个窗口开始的时间间隔,而窗口大小是固定的,所 以也就是两个窗口结束时间的间隔;窗口在结束时间触发计算输出结果,那么滑动步长就代表了计算频率。例如,我们定义一个长度为 1 小时、滑动步长为 5 分钟的滑动窗口,那么就会统 计1 小时内的数据,每 5 分钟统计一次。同样,滑动窗口可以基于时间定义,也可以基于数据个数定义。
可以看到,与前两种窗口不同,会话窗口的长度不固定,起始和结束时间也是不确定 的,各个分区之间窗口没有任何关联。如图上图所示,会话窗口之间一定是不会重叠的,而 且会留有至少为 size 的间隔(session gap)。 在一些类似保持会话的场景下,往往可以使用会话窗口来进行数据的处理统计。
窗口 API 概览:
已经了解了 Flink 中窗口的概念和分类,接下来我们就要看看在代码中怎样使用。
stream.keyBy(...)
.window(...)
stream.windowAll(...)
2)代码中窗口 API 的调用:
接下来我们就可以真正在代码中实现一个窗口操作了。简单来说,窗口 操作主要有两个部分:窗口分配器(Window Assigners)和窗口函数(Window Functions)。
stream.keyBy()
.window()
.aggregate()
stream.keyBy(...)
.window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
.aggregate (...)
这里.of()方法需要传入一个 Time 类型的参数 size,表示滚动窗口的大小, 我们这里创建 了一个长度为 5 秒的滚动窗口。stream.keyBy(...)
.window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5))) .aggregate (...)
这里.of()方法需要传入两个 Time 类型的参数:size 和 slide ,前者表示滑动窗口的大小, 后者表示滑动窗口的滑动步长。我们这里创建了一个长度为 10 秒、滑动步长为 5 秒的滑动窗口。
3、处理时间会话窗口stream.keyBy(...)
.window(ProcessingTimeSessionWindows.withGap(Time.seconds(10)))
.aggregate (...).
这里.withGap()方法需要传入一个 Time 类型的参数 size,表示会话的超时时间,也就是最 小间隔 session gap。我们这里创建了静态会话超时时间为 10 秒的会话窗口。 .window(ProcessingTimeSessionWindows.withDynamicGap(
new SessionWindowTimeGapExtractor>() {
@Override
public long extract(Tuple2 element) {
// 提取 session gap 值返回, 单位毫秒
return element.f0.length() * 1000;
}
}
)
)
这里.withDynamicGap()方法需要传入一个 SessionWindowTimeGapExtractor 作为参数,用 来定义 session gap 的动态提取逻辑。在这里, 我们提取了数据元素的第一个字段, 用它的长 度乘以 1000 作为会话超时的间隔。stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.aggregate (...)
5、滑动事件时间窗口:stream.keyBy(...)
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.aggregate (...)
6、事件时间会话窗口stream.keyBy(...)
.window(EventTimeSessionWindows.withGap(Time.seconds(10)))
.aggregate (...)
stream.keyBy(...)
.countWindow(10)
定义了一个长度为 10 的滚动计数窗口,当窗口中元素数量达到 10 的时候,就会触发计算执行并关闭窗口。
2、滑动计数窗口:
与滚动计数窗口类似,不过需要在.countWindow()调用时传入两个参数: size 和 slide,前者表示窗口大小,后者表示滑动步长。
stream.keyBy(...)
.countWindow(10,3)
定义了一个长度为 10、滑动步长为 3 的滑动计数窗口。每个窗口统计 10 个数据,每 隔 3个数据就统计输出一次结果。stream.keyBy(...)
.window(GlobalWindows.create());
注意使用全局窗口, 必须自行定义触发器才能实现窗口计算, 否则起不到任何作用。定义了窗口分配器, 我们只是知道了数据属于哪个窗口, 可以将数据收集起来了;至于收集起来干什么 必须再接上一个定义窗口如何进行计算的操作, 这就是所谓的“窗口函数”(window functions)。
经窗口分配器处理之后,数据可以分配到对应的窗口中, 而数据流经过转换得到的数据类 型是 WindowedStream。这个类型并不是 DataStream,所以并不能直接进行其他转换,而必须 进一步调用窗口函数,对收集到的数据进行处理计算之后,才能最终再次得到 DataStream,如 图 所示。
窗口将数据收集起来,最基本的处理操作当然就是进行聚合。窗口对无限流的切分,可以 看作得到了一个有界数据集。如果我们等到所有数据都收集齐,在窗口到了结束时间要输出结 果的一瞬间再去进行聚合,显然就不够高效了——这相当于真的在用批处理的思路来做实时流 处理。
为了提高实时性,可以每来一条数据就立即进行计算,中间只要保持一个简单的聚合状态就可以了;区别只是 在于不立即输出结果,而是要等到窗口结束时间。等到窗口到了结束时间需要输出计算结果的时候, 只需要拿出之前聚合的状态直接输出,这就大大提高了程序运行的效率和实时性。
典型的增量聚合函数有两个: ReduceFunction 和 AggregateFunction。
1)归约函数(ReduceFunction):
最基本的聚合方式就是归约,就是将窗口中收集到的数据两两进行归约。当我们进行流处 理时, 就是要保存一个状态; 每来一个新的数据, 就和之前的聚合状态做归约, 这样就实现了增量式的聚合。
窗口函数中也提供了 ReduceFunction:只要基于 WindowedStream 调用.reduce()方法, 然 后传入 ReduceFunction 作为参数,就可以指定以归约两个元素的方式去对窗口中数据进行聚 合了。这里的 ReduceFunction 其实与简单聚合时用到的 ReduceFunction 是同一个函数类接口, 所以使用方式也是完全一样的。
2)聚合函数(AggregateFunction):
ReduceFunction 可以解决大多数归约聚合的问题,但是这个接口有一个限制,就是聚合状态的类型、输出结果的类型都必须和输入数据类型一样。这就迫使我们必须在聚合前, 先将数据转换(map) 成预期结果类型; 而在有些情况下, 还需要对状态进行进一步处理才能得到输出结果,这时它们的类型可能不同, 使用ReduceFunction 就会非常麻烦。
Flink 的 Window API 中的 aggregate 就提供了这样的操作。直接基于 WindowedStream 调 用 .aggregate() 方法 ,就可以定义更加灵活的窗口 聚合操作 。这个方法 需要传入 一个 AggregateFunction 的实现类作为参数。AggregateFunction 在源码中的定义如下:
public interface AggregateFunction extends Function, Serializable {
//创建一个累加器,这就是为聚合创建了一个初始状态,每个聚
//合任务只会调用一次。
ACC createAccumulator();
//将输入的元素添加到累加器中。这就是基于聚合状态,对新来的数据进行进
//一步聚合的过程。方法传入两个参数:当前新到的数据 value,和当前的累加器
//accumulator;返回一个新的累加器值,也就是对聚合状态进行更新。每条数据到来之
//后都会调用这个方法。
ACC add(IN value, ACC accumulator);
//从累加器中提取聚合的输出结果。也就是说,我们可以定义多个状态,
//然后再基于这些聚合的状态计算出一个结果进行输出。比如要计算平均
//值,就可以把 sum 和 count 作为状态放入累加器,而在调用这个方法时相除得到最终
//结果。这个方法只在窗口要输出结果时调用。
OUT getResult(ACC accumulator);
//合并两个累加器,并将合并后的状态作为一个累加器返回。这个方法只在
//需要合并窗口的场景下才会被调用;最常见的合并窗口(Merging Window)的场景
//就是会话窗口(Session Windows)。
ACC merge(ACC a, ACC b);
}
2、 全窗口函数(full window functions)
窗口操作中的另一大类就是全窗口函数。与增量聚合函数不同,全窗口函数需要先收集窗 口中的数据,并在内部缓存起来,等到窗口要输出结果的时候再取出数据进行计算。
很明显,这就是典型的批处理思路了——先攒数据,等一批都到齐了再正式启动处理流程。 这样做毫无疑问是低效的。
那为什么还需要有全窗口函数呢?因为有些场景下,我们要做的计算必须基于全部的 数据才有效,这时做增量聚合就没什么意义了;另外, 输出的结果有可能要包含上下文中的一 些信息(比如窗口的起始时间),这是增量聚合函数做不到的。所以,我们还需要有更丰富的 窗口计算方式, 这就可以用全窗口函数来实现。
全窗口函数也有两种:WindowFunction 和 ProcessWindowFunction。
1)窗口函数(WindowFunction):
WindowFunction 字面上就是“窗口函数”,它其实是老版本的通用窗口函数接口。我们可 以基于 WindowedStream 调用.apply()方法, 传入一个 WindowFunction 的实现类。
public interface WindowFunction extends Function, Serializable {
void apply(KEY var1, W var2, Iterable var3, Collector var4) throws Exception;
}
当窗口到达结束时间需要触发计算时,就会调用这里的 apply 方法。我们可以从 input 集 合中取出窗口收集的数据,结合 key 和 window 信息,通过收集器(Collector)输出结果。WindowFunction 能提供的上下文信息较少, 也没有更高级的功能。 事实上,它的作用可以被 ProcessWindowFunction 全覆盖,所以之后可能会逐渐弃用。一般在 实际应用, 直接使用 ProcessWindowFunction 就可以了。
2)处理窗口函数(ProcessWindowFunction):
ProcessWindowFunction 是 Window API 中最底层的通用窗口函数接口。之所以说它“最底 层”,是因为除了可以拿到窗口中的所有数据之外, ProcessWindowFunction 还可以获取到一个“上下文对象”(Context)。这个上下文对象非常强大,不仅能够获取窗口信息,还可以访问当 前的时间和状态信息。这里的时间就包括了处理时间(processing time)和事件时间水位线(event time watermark)。这就使得 ProcessWindowFunction 更加灵活、功能更加丰富。
public abstract class ProcessWindowFunction extends AbstractRichFunction {
public abstract void process(KEY var1, ProcessWindowFunction.Context var2, Iterable var3, Collector var4) throws Exception;
public void clear(ProcessWindowFunction.Context context) throws Exception {
}
}
stream.keyBy(...)
.window(...)
.trigger(new MyTrigger())
移除器(Evictor):
移除器主要用来定义移除某些数据的逻辑。基于 WindowedStream 调用.evictor()方法, 就 可以传入一个自定义的移除器(Evictor)。Evictor 是一个接口, 不同的窗口类型都有各自预实现的移除器。
stream.keyBy(...)
.window(...)
.evictor(new MyEvictor())
默认情况下,预实现的移除器都是在执行窗口函数(window fucntions) 之前移除数据的。
允许延迟(Allowed Lateness):
在事件时间语义下, 窗口中可能会出现数据迟到的情况。这是因为在乱序流中,水位线 (watermark) 并不一定能保证时间戳更早的所有数据不会再来。当水位线已经到达窗口结束时间时, 窗口会触发计算并输出结果, 这时一般窗口也就销毁了; 如果窗口关闭之后, 又有属于本窗口的数据,默认情况下就会被丢弃。
在多数情况下,直接丢弃数据会导致统计结果不准确,为了解决迟到数据的问题, Flink 提供了一个特殊的接口, 可以为窗口算子设置一个 “允许的最大延迟”(Allowed Lateness)。也就是说,我们可以设定允许延迟一段时间,在这段时间内,窗口不会销毁, 继续到来的数据依然可以进入窗口中并触发计算。直到水位线推进到 了 窗口结束时间 + 延迟时间, 才真正将窗口的内容清空,正式关闭窗口。
基于 WindowedStream 调用.allowedLateness()方法, 传入一个 Time 类型的延迟时间, 就可 以表示允许这段时间内的延迟数据。
stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.allowedLateness(Time.minutes(1))
比如上面的代码中, 定义了 1 小时的滚动窗口,并设置了允许 1 分钟的延迟数据。在不考虑水位线延迟的情况下, 对于 8 点~9 点的窗口, 本来应该是水位线到达 9 点 整就触发计算并关闭窗口;现在允许延迟 1 分钟,那么 9 点整就只是触发一次计算并输出结果, 并不会关窗。后续到达的数据, 只要属于 8 点~9 点窗口, 依然可以在之前统计的基础上继续 叠加,并且再次输出一个更新后的结果。直到水位线到达了 9 点零 1 分,这时就真正清空状态、关闭窗口, 之后再来的迟到数据就会被丢弃了。
侧输出流
即使可以设置窗口的延迟时间, 终归还是有限的,后续的数据还是会被丢弃。如果不想丢弃任何一个数据, 又该怎么做呢? Flink 还提供了另外一种方式处理迟到数据。可以将未收入窗口的迟到数据,放入“侧 输出流”(side output)进行另外的处理。所谓的侧输出流,相当于是数据流的一个“分支”, 这个流中单独放置那些本该被丢弃的数据。
1、 窗口的创建
窗口的类型和基本信息由窗口分配器(window assigners) 指定, 但窗口不会预先创建好, 而是由数据驱动创建。当第一个应该属于这个窗口的数据元素到达时, 就会创建对应的窗口。
2、窗口计算的触发
除了窗口分配器,每个窗口还会有自己的窗口函数(window functions)和触发器(trigger)。 窗口函数可以分为增量聚合函数和全窗口函数,主要定义了窗口中计算的逻辑;而触发器则是 指定调用窗口函数的条件。
3. 窗口的销毁
一般情况下,当时间达到了结束点,就会直接触发计算输出结果、进而清除状态销毁窗口。 这时窗口的销毁可以认为和触发计算是同一时刻。这里需要注意,Flink 中只对时间窗口 (TimeWindow) 有销毁机制; 由于计数窗口(CountWindow) 是基于全局窗口(GlobalWindw) 实现的,而全局窗口不会清除状态, 所以就不会被销毁。在特殊的场景下,窗口的销毁和触发计算会有所不同。事件时间语义下, 如果设置了允许 延迟,那么在水位线到达窗口结束时间时,仍然不会销毁窗口;窗口真正被完全删除的时间点, 是窗口的结束时间加上用户指定的允许延迟时间。