Flink 基础概念 —— 窗口(续)
内置方法
WindowedStream
通过 KeyedStream
可以直接创建 Count Window和 Time Window。他们最终都是基于 window(WindowAssigner)
方法创建,在window方法中创建 WindowedStream
实例,参数使用当前的 KeyedStream对象和指定的 WindowAssigner。
def window[W <: Window](assigner: WindowAssigner[_ >: T, W]): WindowedStream[T, K, W] = { new WindowedStream(new WindowedJavaStream[T, K, W](javaStream, assigner)) }
构造方法
@PublicEvolving public WindowedStream(KeyedStream<T, K> input, WindowAssigner<? super T, W> windowAssigner) { this.input = input; this.windowAssigner = windowAssigner; this.trigger = windowAssigner.getDefaultTrigger(input.getExecutionEnvironment()); }
Trigger
初始化默认的 trigger,也提供了单独的 trigger方法用来覆盖默认的trigger。Flink内置的计数窗口就使用 windowedStream.trigger
方法覆盖了默认的trigger。
public WindowedStream<T, K, W> trigger(Trigger<? super T, ? super W> trigger) { if (windowAssigner instanceof MergingWindowAssigner && !trigger.canMerge()) { throw new UnsupportedOperationException("A merging window assigner cannot be used with a trigger that does not support merging."); } if (windowAssigner instanceof BaseAlignedWindowAssigner) { throw new UnsupportedOperationException("Cannot use a " + windowAssigner.getClass().getSimpleName() + " with a custom trigger."); } this.trigger = trigger; return this; }
evictor
此外,WindowedStream 中还有一个比较重要的属性evictor
,可以通过evictor
方法设置。
@PublicEvolving public WindowedStream<T, K, W> evictor(Evictor<? super T, ? super W> evictor) { if (windowAssigner instanceof BaseAlignedWindowAssigner) { throw new UnsupportedOperationException("Cannot use a " + windowAssigner.getClass().getSimpleName() + " with an Evictor."); } this.evictor = evictor; return this; }
在 WindowedStream 的处理函数的相关实现中,根据 evictor属性是否空(null == evictor
) 决定是创建 WindowOperator
还是 EvictingWindowOperator
。EvictingWindowOperator
继承自 WindowOperator
,它主要扩展了evictor属性以及相关的逻辑处理。
public class EvictingWindowOperator extends WindowOperator { private final Evictor evictor; }
在 EvictingWindowOperator 的emitWindowContents
方法中实现了数据清理逻辑。
private void emitWindowContents(W window, Iterable<StreamRecord<IN>> contents, ListState<StreamRecord<IN>> windowState) throws Exception { /** Window处理前数据清理 */ evictorContext.evictBefore(recordsWithTimestamp, Iterables.size(recordsWithTimestamp)); /** Window处理 */ userFunction.process(triggerContext.key, triggerContext.window, processContext, projectedContents, timestampedCollector); /** Window处理后数据清理 */ evictorContext.evictAfter(recordsWithTimestamp, Iterables.size(recordsWithTimestamp)); }
源码剖析
一、Count Window API
1. Window API
下面代码片段是 KeyedStream
提供创建 Count Window的API。
// 滚动窗口 public WindowedStream<T, KEY, GlobalWindow> countWindow(long size) { return window(GlobalWindows.create()).trigger(PurgingTrigger.of(CountTrigger.of(size))); } // 滑动窗口 public WindowedStream<T, KEY, GlobalWindow> countWindow(long size, long slide) { return window(GlobalWindows.create()) .evictor(CountEvictor.of(size)) .trigger(CountTrigger.of(slide)); }
2. Assigner
通过方法window(GlobalWindows.create())
创建 WindowedStream实例,滚动计数窗口处理和滑动计数窗口处理都是基于 GlobalWindows
作为 WindowAssigner来创建窗口处理器。GlobalWindows
将所有数据都分配到同一个 GlobalWindow
中。
@PublicEvolving public class GlobalWindows extends WindowAssigner<Object, GlobalWindow> { private static final long serialVersionUID = 1L; private GlobalWindows() { } @Override public Collection<GlobalWindow> assignWindows(Object element, long timestamp, WindowAssignerContext context) { return Collections.singletonList(GlobalWindow.get()); } }
注意 GlobalWindows
是一个WindowAssigner,而GlobalWindow
是一个Window。GlobalWindow 继承了 Window,表示为一个窗口。对外提供 get()方法返回 GlobalWindow实例,并且是个全局单例。所以当使用 GlobalWindows作为 WindowAssigner时,所有数据将被分配到一个窗口中。
@PublicEvolving public class GlobalWindow extends Window { private static final org.apache.flink.streaming.api.windowing.windows.GlobalWindow INSTANCE = new org.apache.flink.streaming.api.windowing.windows.GlobalWindow(); private GlobalWindow() { } public static org.apache.flink.streaming.api.windowing.windows.GlobalWindow get() { return INSTANCE; } }
3. Trigger & Evictor
翻滚计数窗口并不带evictor,只注册了一个 trigger。该 trigger是带purge功能的 CountTrigger。也就是说每当窗口中的元素数量达到了 window-size,trigger就会返回fire+purge,窗口就会执行计算并清空窗口中的所有元素,再接着储备新的元素。从而实现了tumbling的窗口之间无重叠。
滑动计数窗口的各窗口之间是有重叠的,但我们用的 GlobalWindows assinger 从始至终只有一个窗口,不像 sliding time assigner 可以同时存在多个窗口。所以trigger结果不能带purge,也就是说计算完窗口后窗口中的数据要保留下来(供下个滑窗使用)。另外,trigger的间隔是 slide-size,evictor的保留的元素个数是 window-size。也就是说,每个滑动间隔就触发一次窗口计算,并保留下最新进入窗口的 window-size个元素,剔除旧元素。
计数窗口 | WindowAssigner | Evictor | Trigger | 说明 |
---|---|---|---|---|
滚动计数窗口 | GlobalWindows | - | PurgingTrigger | 窗口处理数据前后不清理数据,由Trigger返回值声明直接清理数据,清理数据依赖Trigger返回结果 |
滑动计数窗口 | GlobalWindows | CountEvictor | CountTrigger | Trigger返回结果不能清理数据(返回结果不带PURGE),在窗口处理完后数据会被保留下来,为下一个滑动窗口使用。因为使用了CountEvictor,会在窗口处理前清除不需要的数据 |
二、Time Window API
1. Window API
下面代码片段是 KeyedStream
提供创建 Time Window的API。
// 滚动窗口 public WindowedStream<T, KEY, TimeWindow> timeWindow(Time size) { if (environment.getStreamTimeCharacteristic() == TimeCharacteristic.ProcessingTime) { return window(TumblingProcessingTimeWindows.of(size)); } else { return window(TumblingEventTimeWindows.of(size)); } } // 滑动窗口 public WindowedStream<T, KEY, TimeWindow> timeWindow(Time size, Time slide) { if (environment.getStreamTimeCharacteristic() == TimeCharacteristic.ProcessingTime) { return window(SlidingProcessingTimeWindows.of(size, slide)); } else { return window(SlidingEventTimeWindows.of(size, slide)); } }
2. Assginer
下面的表格中展示了窗口类型和时间类型对应的 WindowAssigner 的实现类
时间窗口类型 | 时间类型 | WindowAssigner |
---|---|---|
滚动时间窗口 | ProcessingTime | TumblingProcessingTimeWindows |
滚动时间窗口 | IngestionTime | TumblingEventTimeWindows |
滚动时间窗口 | EventTime | TumblingEventTimeWindows |
滑动时间窗口 | ProcessingTime | SlidingProcessingTimeWindows |
滑动时间窗口 | IngestionTime | SlidingEventTimeWindows |
滑动时间窗口 | EventTime | SlidingEventTimeWindows |
① TumblingProcessingTimeWindows
初始化
构造参数 timestamp 与 offset 。比如按小时切割窗口,但每次都从一小时的第 15分钟开始。则可使用 of(Time.hours(1),Time.minutes(15)),你将会获得 0:15:00,1:15:00,2:15:00 ...区间的窗口。
public static TumblingProcessingTimeWindows of(Time size) { return new TumblingProcessingTimeWindows(size.toMilliseconds(), 0); } public static TumblingProcessingTimeWindows of(Time size, Time offset) { return new TumblingProcessingTimeWindows(size.toMilliseconds(), offset.toMilliseconds()); } private TumblingProcessingTimeWindows(long size, long offset) { if (offset < 0 || offset >= size) { throw new IllegalArgumentException(); } this.size = size; this.offset = offset; }
assignWindows
@Override public Collection<TimeWindow> assignWindows(Object element, long timestamp, WindowAssignerContext context) { final long now = context.getCurrentProcessingTime(); long start = TimeWindow.getWindowStartWithOffset(now, offset, size); return Collections.singletonList(new TimeWindow(start, start + size)); }
该方法每次都会返回一个新的窗口,也就是说窗口是不重叠的。但因为TimeWindow实现了equals
方法,所以通过计算后start, start + size相同的数据,在逻辑上是同一个窗口。
public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } TimeWindow window = (TimeWindow) o; return end == window.end && start == window.start; }
示例:
如果我们希望从第15秒开始,每过1分钟计算一次窗口数据,这种场景需要用到offset。基于处理时间的滚动窗口可以这样写
keyedStream.window(TumblingProcessingTimeWindows.of(Time.minutes(1), Time.seconds(15)))
我们假设数据从2019年1月1日 12:00:14到达,那么窗口以下面方式切割
Window[2019年1月1日 11:59:15, 2019年1月1日 12:00:15)
如果在2019年1月1日 12:00:16又一数据到达,那么窗口以下面方式切割
Window[2019年1月1日 12:00:15, 2019年1月1日 12:01:15)
② SlidingEventTimeWindows
初始化
public static SlidingEventTimeWindows of(Time size, Time slide) { return new SlidingEventTimeWindows(size.toMilliseconds(), slide.toMilliseconds(), 0); } public static SlidingEventTimeWindows of(Time size, Time slide, Time offset) { return new SlidingEventTimeWindows(size.toMilliseconds(), slide.toMilliseconds(),offset.toMilliseconds() % slide.toMilliseconds()); } protected SlidingEventTimeWindows(long size, long slide, long offset) { if (offset < 0 || offset >= slide || size <= 0) { throw new IllegalArgumentException(); } this.size = size; this.slide = slide; this.offset = offset; }
assignWindows
public Collection<TimeWindow> assignWindows(Object element, long timestamp, WindowAssignerContext context) { if (timestamp > Long.MIN_VALUE) { List<TimeWindow> windows = new ArrayList<>((int) (size / slide)); long lastStart = TimeWindow.getWindowStartWithOffset(timestamp, offset, slide); for (long start = lastStart; start > timestamp - size; start -= slide) { windows.add(new TimeWindow(start, start + size)); } return windows; } 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; }
首先根据事件时间、偏移量与滑动大小计算窗口的起始时间,再根据窗口大小切分成多个窗口。因为滑动窗口中一个数据可能落在多个窗口。
示例:
比如,希望每5秒滑动一次处理最近10秒窗口数据keyedStream.timeWindow(Time.seconds(10), Time.seconds(5))
。当数据源源不断流入Window Operator时,会按10秒切割一个时间窗,5秒滚动一次。
我们假设一条付费事件数据付费时间是2019年1月1日 17:11:24,那么这个付费数据将落到下面两个窗口中(请注意,窗口是左闭右开
)。
Window[2019年1月1日 17:11:20, 2019年1月1日 17:11:30) Window[2019年1月1日 17:11:15, 2019年1月1日 17:11:25)
3. trigger
ProcessingTimeTrigger
public class ProcessingTimeTrigger extends Trigger<Object, TimeWindow> { @Override // 每个元素进入窗口都会调用该方法 public TriggerResult onElement(Object element, long timestamp, TimeWindow window, TriggerContext ctx) { // 注册定时器,当系统时间到达window end timestamp时会回调该trigger的onProcessingTime方法 ctx.registerProcessingTimeTimer(window.getEnd()); return TriggerResult.CONTINUE; } @Override // 返回结果表示执行窗口计算并清空窗口 public TriggerResult onProcessingTime(long time, TimeWindow window, TriggerContext ctx) { return TriggerResult.FIRE_AND_PURGE; } ... }
EventTimeTrigger
public class EventTimeTrigger extends Trigger<Object, TimeWindow> { @Override // 每个元素进入窗口都会调用该方法 public TriggerResult onElement(Object element, long timestamp, TimeWindow window, TriggerContext ctx) throws Exception { if (window.maxTimestamp() <= ctx.getCurrentWatermark()) { // 窗口结束时间小于等于水印时间,立即返回触发计算 return TriggerResult.FIRE; } else { // 窗口结束时间大于水印时间,将窗口结束时间加入排重队列,等待相应水印时间到达时触发 ctx.registerEventTimeTimer(window.maxTimestamp()); return TriggerResult.CONTINUE; } } @Override public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) { return time == window.maxTimestamp() ? TriggerResult.FIRE : TriggerResult.CONTINUE; } }
Flink 处理水印时,一旦窗口结束时间小于或等于水印时间,即水印到达窗口结束时间,此时触发回调 onEventTime
public void advanceWatermark(long time) throws Exception { currentWatermark = time; InternalTimer<K, N> timer; while ((timer = eventTimeTimersQueue.peek()) != null && timer.getTimestamp() <= time) { eventTimeTimersQueue.poll(); keyContext.setCurrentKey(timer.getKey()); triggerTarget.onEventTime(timer); } }
参考文章
https://tianshushi.github.io/2019/02/17/Flink-Window
http://wuchong.me/blog/2016/05/25/flink-internals-window-mechanism
https://blog.csdn.net/lmalds/article/details/52704170