因为本书是多人合著的,所以不同的章节风格都有所不同。另外还有一个额外的好处是可以带你复习一下前面的概念。
窗口是基于事件时间模型的一等公民。对于处理时间窗口,则有两种通常的方法:
- 触发器(Trigger)
- 到达时间(Ingress time)
第一个就是定时触发计算,而第二个就是根据数据的到达时间来计算。这里的概念比较容易让人迷惑,主要是两种方法的区别其实也不大,只能在多阶段的数据流处理中才能体现:前者没有使用一个特定的时间戳,因此如果存在多个 Stage,可能同一个数据会被划分到不同的 window 中(根据数据进入 Stage 的时间不同)。
不管是哪种方式,处理时间窗口都会受到数据到达的顺序影响。
处理时间窗口很难精确实现动态的窗口,如 Session Window。Session Window 通常针对的是那些无法用某个标识符(比如 SessionId)来标识数据的情况。
Session Window 有一个很重要的概念,窗口合并。更程序化的来看,Session Window 可以看作是多个超时时长大小的小窗口合并得到的窗口。这里书中给了一幅图:
Custom Windowing
除了固定、滑动、Session 三种常见窗口,还有许多用户可能需要自定义的情况。实现自定义窗口需要两步:
- 分配窗口
- 合并窗口(可选)
了解这一点,就很容易理解 Flink 为什么要提供WindowAssigner
。Flink 所有的 window 都继承于WindowAssigner
(当然其实它们并不是窗口),所以当我们写下:
input
.keyBy(<key selector>)
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.<windowed transformation>(<window function>);
SlidingEventTimeWindows
将提供一个根据事件时间分配数据到不同窗口的方法(更直接的说就是实现assignWindows
方法)。
这里书中举了几个自定义窗口的例子,并和一般的固定窗口进行对比。首先我们直接看 Flink 的固定窗口实现:
public class TumblingEventTimeWindows extends WindowAssigner<Object, TimeWindow> {
public Collection<TimeWindow> assignWindows(Object element, long timestamp, WindowAssignerContext context) {
if (timestamp > -9223372036854775808L) {
long start = TimeWindow.getWindowStartWithOffset(timestamp, this.offset, this.size);
return Collections.singletonList(new TimeWindow(start, start + this.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(...)'?");
}
}
...
}
这里TimeWindow
才是 Flink 真正意义上窗口的实现,TumblingEventTimeWindows
只是窗口的分配者。getWindowStartWithOffset
方法是这样的:
public static long getWindowStartWithOffset(long timestamp, long offset, long windowSize) {
return timestamp - (timestamp - offset + windowSize) % windowSize;
}
可以看到这个和书中的实现如出一辙。比如,现在时间戳为 1593495401000(对应2020/6/30 13:36:41),窗口大小为1 min,不考虑 offset,那么这里 start 为1593495360000(对应2020/6/30 13:36:00),即分配到这个窗口。Offset 的一个典型应用就是时区的计算,比如给东八区增加8个小时。
自定义窗口的第一个例子是非对齐固定窗口。这个使用到了哈希取模的技巧,也是大数据中屡见不鲜的(比如避免数据倾斜):
public class UnalignedFixedWindows
extends WindowFn<KV<K, V>, IntervalWindow> {
private final Duration size;
private final Duration offset;
public Collection<IntervalWindow> assignWindow(AssignContext c) {
long perKeyShift = hash(c.element().key()) % size;
long start = perKeyShift + c.timestamp().getMillis()
- c.timestamp()
.plus(size)
.minus(offset)
return Arrays.asList(IntervalWindow(new Instant(start), size));
}
}
非对齐窗口将时间戳偏移到不同的窗口中,从而实现负载均衡:
第二个例子是 Google Dataflow 的用户提供的。他们在使用时希望根据不同的消费者提供不同的聚合窗口,并且这个窗口大小可以携带在数据中。因此和前面的非对齐窗口类似:
public class PerElementFixedWindows<T extends HasWindowSize
extends WindowFn<T, IntervalWindow> {
private final Duration offset;
public Collection<IntervalWindow> assignWindow(AssignContext c) {
long perElementSize = c.element().getWindowSize();
long start = perKeyShift + c.timestamp().getMillis()
- c.timestamp()
.plus(size)
.minus(offset)
.getMillis() % size.getMillis();
return Arrays.asList(IntervalWindow(
new Instant(start), perElementSize));
}
接下来是 Session Window 的自定义。首先看一下 Flink 的 Session Window 实现:
public class EventTimeSessionWindows extends MergingWindowAssigner<Object, TimeWindow> {
private static final long serialVersionUID = 1L;
protected long sessionTimeout;
protected EventTimeSessionWindows(long sessionTimeout) {
if (sessionTimeout <= 0L) {
throw new IllegalArgumentException("EventTimeSessionWindows parameters must satisfy 0 < size");
} else {
this.sessionTimeout = sessionTimeout;
}
}
public Collection<TimeWindow> assignWindows(Object element, long timestamp, WindowAssignerContext context) {
return Collections.singletonList(new TimeWindow(timestamp, timestamp + this.sessionTimeout));
}
public Trigger<Object, TimeWindow> getDefaultTrigger(StreamExecutionEnvironment env) {
return EventTimeTrigger.create();
}
public String toString() {
return "EventTimeSessionWindows(" + this.sessionTimeout + ")";
}
public static EventTimeSessionWindows withGap(Time size) {
return new EventTimeSessionWindows(size.toMilliseconds());
}
@PublicEvolving
public static <T> DynamicEventTimeSessionWindows<T> withDynamicGap(SessionWindowTimeGapExtractor<T> sessionWindowTimeGapExtractor) {
return new DynamicEventTimeSessionWindows(sessionWindowTimeGapExtractor);
}
public TypeSerializer<TimeWindow> getWindowSerializer(ExecutionConfig executionConfig) {
return new Serializer();
}
public boolean isEventTime() {
return true;
}
public void mergeWindows(Collection<TimeWindow> windows, MergeCallback<TimeWindow> c) {
TimeWindow.mergeWindows(windows, c);
}
}
这个实现还是非常简单的,因为核心的逻辑被提取到了TimeWindow
里。这个mergeWindows
的核心逻辑如下:
Collections.sort(sortedWindows, new Comparator<TimeWindow>() {
@Override
public int compare(TimeWindow o1, TimeWindow o2) {
return Long.compare(o1.getStart(), o2.getStart());
}
});
for (TimeWindow candidate: sortedWindows) {
if (currentMerge == null) {
currentMerge = new Tuple2<>();
currentMerge.f0 = candidate;
currentMerge.f1 = new HashSet<>();
currentMerge.f1.add(candidate);
} else if (currentMerge.f0.intersects(candidate)) {
currentMerge.f0 = currentMerge.f0.cover(candidate);
currentMerge.f1.add(candidate);
} else {
merged.add(currentMerge);
currentMerge = new Tuple2<>();
currentMerge.f0 = candidate;
currentMerge.f1 = new HashSet<>();
currentMerge.f1.add(candidate);
}
}
逻辑和书中一样,就是针对窗口是否重叠来进行合并。关于这个代码有一点很有意思,就是它实际上是 LeetCode 上一道经典题目 Merge Intervals 的应用(像这么贴合算法的应用场景也是很难遇到)。正如 LeetCode 一样这里也用了先排序的方法。其实不用排序也是可以的,可以把这个当作是一个动态排序问题,然后用树形结构解决。
最后一个自定义窗口的例子是有界(Bounded)的 Session Window,即 Session Window 不能超过某一限制(可能是时间、个数等等)。这个例子也很简单,不过 FLink 里似乎找不到原生的支持,可能需要我们自定义WindowAssigner
解决。
注意到这里最后有3个窗口,而前两个窗口之间原本是可以合并的(Session Timeout 为 1分钟),但由于第二个已经达到了这里的限制(3分钟),所以最后变成了2个窗口。
这些例子说明了自定义窗口具有重要的意义。否则,如果系统不支持的话,就需要在下游实现切分窗口的逻辑,从而失去了增量聚合的优点。另外书中也提到,他们原先的 Bounded Session Window 的应用就是反 Spam 保护。如果系统不支持自定义窗口,那么就需要先增长大某个大小,然后再切分,那么可能就失去了第一时间判别的能力。总之,系统的原生支持会让流处理更加符合直觉。
实际上,Flink 比 Beam 的自定义窗口还要更进一步,提供了Evictor
。这个函数提供了在窗口计算前后淘汰掉指定数据的逻辑,比如熟悉的countWindow
就是用自定义Trigger
和Evictor
实现的:
public WindowedStream<T, KEY, GlobalWindow> countWindow(long size, long slide) {
return window(GlobalWindows.create())
.evictor(CountEvictor.of(size))
.trigger(CountTrigger.of(slide));
}