《Streaming Systems》读书笔记(四):Advanced Windowing

909 阅读6分钟

因为本书是多人合著的,所以不同的章节风格都有所不同。另外还有一个额外的好处是可以带你复习一下前面的概念。

窗口是基于事件时间模型的一等公民。对于处理时间窗口,则有两种通常的方法:

  • 触发器(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就是用自定义TriggerEvictor实现的:

public WindowedStream<T, KEY, GlobalWindow> countWindow(long size, long slide) {
	return window(GlobalWindows.create())
			.evictor(CountEvictor.of(size))
			.trigger(CountTrigger.of(slide));
}