如题,今天来分享下之前解决过的一个问题,即Flink窗口延迟数据丢失。
流计算在支持Event Time的时候,引入了Watermark的概念(MillWheel提出这个概念的时候是叫Low Watermark,即低水位,鄙视翻译成水印的:),所有早于Watermark的数据都被认为是延迟数据。而在Flink的窗口计算当中,允许用户指定数据延迟的最大时间,即设置allowedLateness
,超过这个时间的数据将会被丢弃,不参与窗口计算。Flink会将被丢弃的数据用SideOutput输出。
具体的实现在WindowOperator#processElement
,
for (W window: elementWindows) {
// drop if the window is already late
if (isWindowLate(window)) {
continue;
}
isSkippedElement = false;
windowState.setCurrentNamespace(window);
windowState.add(element.getValue());
triggerContext.key = key;
triggerContext.window = window;
TriggerResult triggerResult = triggerContext.onElement(element);
if (triggerResult.isFire()) {
ACC contents = windowState.get();
if (contents == null) {
continue;
}
emitWindowContents(window, contents);
}
if (triggerResult.isPurge()) {
windowState.clear();
}
registerCleanupTimer(window);
}
/**
* Returns {@code true} if the watermark is after the end timestamp plus the allowed lateness
* of the given window.
*/
protected boolean isWindowLate(W window) {
return (windowAssigner.isEventTime() && (cleanupTime(window) <= internalTimerService.currentWatermark()));
}
/**
* Returns the cleanup time for a window, which is
* {@code window.maxTimestamp + allowedLateness}. In
* case this leads to a value greater than {@link Long#MAX_VALUE}
* then a cleanup time of {@link Long#MAX_VALUE} is
* returned.
*
* @param window the window whose cleanup time we are computing.
*/
private long cleanupTime(W window) {
if (windowAssigner.isEventTime()) {
long cleanupTime = window.maxTimestamp() + allowedLateness;
return cleanupTime >= window.maxTimestamp() ? cleanupTime : Long.MAX_VALUE;
} else {
return window.maxTimestamp();
}
}
可以看到计算cleanupTime
的时候是使用数据所属窗口的最大时间加上用户设置的allowedLateness
,超过cleanupTime
的窗口将不会参与计算。
在某些业务场景下,其实是不希望丢掉这部分数据的,尽管也可以利用SideOutput来处理,但还是比较麻烦,因此我们便需求其他解决方案。在仔细阅读过WindowOperator
的代码后,我发现可以通过实现一个Trigger
来解决这个问题。这里需要大家对Flink的窗口实现有一个基本了解,推荐阅读下官方这篇博客,Introducing Stream Windows in Apache Flink,
博客中的这张图基本上把Flink的窗口实现讲明白了,每一条输入数据都会由WindowAssigner
分配一个窗口,并且会带有一个默认的Trigger
,由Trigger
来决定什么时候输出窗口数据(emitWindowContents
)。
回到我们的问题上来,上面在processElement
的时候可以看到有一个registerCleanupTimer
,
/**
* Registers a timer to cleanup the content of the window.
* @param window
* the window whose state to discard
*/
protected void registerCleanupTimer(W window) {
long cleanupTime = cleanupTime(window);
if (cleanupTime == Long.MAX_VALUE) {
// don't set a GC timer for "end of time"
return;
}
if (windowAssigner.isEventTime()) {
triggerContext.registerEventTimeTimer(cleanupTime);
} else {
triggerContext.registerProcessingTimeTimer(cleanupTime);
}
}
该方法用来注册定时器,当超过cleanupTime
的时候会清理掉窗口的state,代码在WindowOperator#onEventTime
。
要解决这个问题,首先第一步需要确保延迟窗口不会被丢弃,看了上面的代码之后,可以想到,把allowedLateness
设置成Long.MAX_VALUE
,这样的话isWindowLate
就一直是返回false
,但是这样做的话会导致在registerCleanupTimer
的时候没办法注册到定时器来清理窗口的state,所有窗口的state会一直被保存,明显是不可行的。还好,Trigger
可以解决这个问题。以下是自定义Trigger
实现的主要部分,
@Override
public TriggerResult onElement(Object element, long timestamp, TimeWindow window, TriggerContext ctx) throws Exception {
if (window.maxTimestamp() <= ctx.getCurrentWatermark()) {
// if the watermark is already past the window fire immediately
return TriggerResult.FIRE_AND_PURGE;
} else {
ctx.registerEventTimeTimer(window.maxTimestamp());
return TriggerResult.CONTINUE;
}
}
@Override
public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) {
return time == window.maxTimestamp() ?
TriggerResult.FIRE_AND_PURGE :
TriggerResult.CONTINUE;
}
onElement
是在收到数据后判断是否需要输出窗口数据,上面的实现,在收到非延迟窗口时,注册窗口最大时间定时器,在Watermark超过该时间后,onEventTime
方法会被调用,此时我们直接输出窗口数据(即FIRE
)并清理窗口state(即PURGE
);而在收到延迟窗口时,我们就直接就输出窗口数据并清理窗口state。
使用起来大概是像下面这样,
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
...
dataStream.keyBy(...)
.timeWindow(...)
.trigger(new MyTrigger())
.allowedLateness(Time.milliseconds(Long.MAX_VALUE))
...
这么做可以解决上面提到的丢失问题,但是从输出数据来看,同一个窗口,会存在多个输出数据,即非延迟的一个,延迟的一个或多个,因此在使用这些输出数据的时候需要再多做一步聚合,把同一个窗口的这些结果数据聚合起来才是正确的结果。
另外,上面的实现其实有个地方可以优化下,在收到延迟窗口的时候,可以不用马上FIRE_AND_PURGE
,而是注册定时器,这样就可以在处理掉一批延迟数据后再进行FIRE_AND_PURGE
了。
alright,今天就到这里,have fun la 😃