Windows #
Flink 在窗口的场景处理上非常有表现力。
在本节中,我们将学习:
- 如何使用窗口来计算无界流上的聚合,
- Flink 支持哪种类型的窗口,以及
- 如何使用窗口聚合来实现 DataStream 程序
概要 #
我们在操作无界数据流时,经常需要应对以下问题,我们经常把无界数据流分解成有界数据流聚合分析:
- 每分钟的浏览量
- 每位用户每周的会话数
- 每个传感器每分钟的最高温度
用 Flink 计算窗口分析取决于两个主要的抽象操作:Window Assigners,将事件分配给窗口(根据需要创建新的窗口对象),以及 Window Functions,处理窗口内的数据。
Flink 的窗口 API 还具有 Triggers 和 Evictors 的概念,Triggers 确定何时调用窗口函数,而 Evictors 则可以删除在窗口中收集的元素。
举一个简单的例子,我们一般这样使用键控事件流(基于 key 分组的输入事件流):
stream.
.keyBy(<key selector>)
.window(<window assigner>)
.reduce|aggregate|process(<window function>)
您不是必须使用键控事件流(keyed stream),但是值得注意的是,如果不使用键控事件流,我们的程序就不能 并行 处理。
stream.
.windowAll(<window assigner>)
.reduce|aggregate|process(<window function>)
窗口分配器 #
Flink 有一些内置的窗口分配器,如下所示:
通过一些示例来展示关于这些窗口如何使用,或者如何区分它们:
- 滚动时间窗口
- 每分钟页面浏览量
TumblingEventTimeWindows.of(Time.minutes(1))
- 滑动时间窗口
- 每10秒钟计算前1分钟的页面浏览量
SlidingEventTimeWindows.of(Time.minutes(1), Time.seconds(10))
- 会话窗口
- 每个会话的网页浏览量,其中会话之间的间隔至少为30分钟
EventTimeSessionWindows.withGap(Time.minutes(30))
以下都是一些可以使用的间隔时间 Time.milliseconds(n)
, Time.seconds(n)
, Time.minutes(n)
, Time.hours(n)
, 和 Time.days(n)
。
基于时间的窗口分配器(包括会话时间)既可以处理 事件时间
,也可以处理 处理时间
。这两种基于时间的处理没有哪一个更好,我们必须折衷。使用 处理时间
,我们必须接受以下限制:
- 无法正确处理历史数据,
- 无法正确处理超过最大无序边界的数据,
- 结果将是不确定的,
但是有自己的优势,较低的延迟。
使用基于计数的窗口时,请记住,只有窗口内的事件数量到达窗口要求的数值时,这些窗口才会触发计算。尽管可以使用自定义触发器自己实现该行为,但无法应对超时和处理部分窗口。
我们可能在有些场景下,想使用全局 window assigner 将每个事件(相同的 key)都分配给某一个指定的全局窗口。 很多情况下,一个比较好的建议是使用 ProcessFunction
,具体介绍在这里。
窗口应用函数 #
我们有三种最基本的操作窗口内的事件的选项:
- 像批量处理,
ProcessWindowFunction
会缓存Iterable
和窗口内容,供接下来全量计算; - 或者像流处理,每一次有事件被分配到窗口时,都会调用
ReduceFunction
或者AggregateFunction
来增量计算; - 或者结合两者,通过
ReduceFunction
或者AggregateFunction
预聚合的增量计算结果在触发窗口时, 提供给ProcessWindowFunction
做全量计算。
接下来展示一段 1 和 3 的示例,每一个实现都是计算传感器的最大值。在每一个一分钟大小的事件时间窗口内, 生成一个包含 (key,end-of-window-timestamp, max_value)
的一组结果。
ProcessWindowFunction 示例 #
DataStream<SensorReading> input = ...
input
.keyBy(x -> x.key)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.process(new MyWastefulMax());
public static class MyWastefulMax extends ProcessWindowFunction<
SensorReading, // 输入类型
Tuple3<String, Long, Integer>, // 输出类型
String, // 键类型
TimeWindow> { // 窗口类型
@Override
public void process(
String key,
Context context,
Iterable<SensorReading> events,
Collector<Tuple3<String, Long, Integer>> out) {
int max = 0;
for (SensorReading event : events) {
max = Math.max(event.value, max);
}
out.collect(Tuple3.of(key, context.window().getEnd(), max));
}
}
在当前实现中有一些值得关注的地方:
- Flink 会缓存所有分配给窗口的事件流,直到触发窗口为止。这个操作可能是相当昂贵的。
- Flink 会传递给
ProcessWindowFunction
一个Context
对象,这个对象内包含了一些窗口信息。Context
接口 展示大致如下:
public abstract class Context implements java.io.Serializable {
public abstract W window();
public abstract long currentProcessingTime();
public abstract long currentWatermark();
public abstract KeyedStateStore windowState();
public abstract KeyedStateStore globalState();
}
windowState
和 globalState
可以用来存储当前的窗口的 key、窗口或者当前 key 的每一个窗口信息。这在一些场景下会很有用,试想,我们在处理当前窗口的时候,可能会用到上一个窗口的信息。
增量聚合示例 #
DataStream<SensorReading> input = ...
input
.keyBy(x -> x.key)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.reduce(new MyReducingMax(), new MyWindowFunction());
private static class MyReducingMax implements ReduceFunction<SensorReading> {
public SensorReading reduce(SensorReading r1, SensorReading r2) {
return r1.value() > r2.value() ? r1 : r2;
}
}
private static class MyWindowFunction extends ProcessWindowFunction<
SensorReading, Tuple3<String, Long, SensorReading>, String, TimeWindow> {
@Override
public void process(
String key,
Context context,
Iterable<SensorReading> maxReading,
Collector<Tuple3<String, Long, SensorReading>> out) {
SensorReading max = maxReading.iterator().next();
out.collect(Tuple3.of(key, context.window().getEnd(), max));
}
}
请注意 Iterable<SensorReading>
将只包含一个读数 – MyReducingMax
计算出的预先汇总的最大值。
晚到的事件 #
默认场景下,超过最大无序边界的事件会被删除,但是 Flink 给了我们两个选择去控制这些事件。
您可以使用一种称为旁路输出 的机制来安排将要删除的事件收集到侧输出流中,这里是一个示例:
OutputTag<Event> lateTag = new OutputTag<Event>("late"){};
SingleOutputStreamOperator<Event> result = stream.
.keyBy(...)
.window(...)
.sideOutputLateData(lateTag)
.process(...);
DataStream<Event> lateStream = result.getSideOutput(lateTag);
我们还可以指定 允许的延迟(allowed lateness) 的间隔,在这个间隔时间内,延迟的事件将会继续分配给窗口(同时状态会被保留),默认状态下,每个延迟事件都会导致窗口函数被再次调用(有时也称之为 late firing )。
默认情况下,允许的延迟为 0。换句话说,watermark 之后的元素将被丢弃(或发送到侧输出流)。
举例说明:
stream.
.keyBy(...)
.window(...)
.allowedLateness(Time.seconds(10))
.process(...);
当允许的延迟大于零时,只有那些超过最大无序边界以至于会被丢弃的事件才会被发送到侧输出流(如果已配置)。
深入了解窗口操作 #
Flink 的窗口 API 某些方面有一些奇怪的行为,可能和我们预期的行为不一致。 根据 Flink 用户邮件列表 和其他地方一些频繁被问起的问题, 以下是一些有关 Windows 的底层事实,这些信息可能会让您感到惊讶。
滑动窗口是通过复制来实现的 #
滑动窗口分配器可以创建许多窗口对象,并将每个事件复制到每个相关的窗口中。例如,如果您每隔 15 分钟就有 24 小时的滑动窗口,则每个事件将被复制到 4 * 24 = 96 个窗口中。
时间窗口会和时间对齐 #
仅仅因为我们使用的是一个小时的处理时间窗口并在 12:05 开始运行您的应用程序,并不意味着第一个窗口将在 1:05 关闭。第一个窗口将长 55 分钟,并在 1:00 关闭。
请注意,滑动窗口和滚动窗口分配器所采用的 offset 参数可用于改变窗口的对齐方式。有关详细的信息,请参见 滚动窗口 和 滑动窗口 。
window 后面可以接 window #
比如说:
stream
.keyBy(t -> t.key)
.window(<window assigner>)
.reduce(<reduce function>)
.windowAll(<same window assigner>)
.reduce(<same reduce function>)
可能我们会猜测以 Flink 的能力,想要做到这样看起来是可行的(前提是你使用的是 ReduceFunction 或 AggregateFunction ),但不是。
之所以可行,是因为时间窗口产生的事件是根据窗口结束时的时间分配时间戳的。例如,一个小时小时的窗口所产生的所有事件都将带有标记一个小时结束的时间戳。后面的窗口内的数据消费和前面的流产生的数据是一致的。
空的时间窗口不会输出结果 #
事件会触发窗口的创建。换句话说,如果在特定的窗口内没有事件,就不会有窗口,就不会有输出结果。
官网链接:流式分析 | Apache Flink