Flink中窗口的触发器、移除器、侧输出流
目录
对于一个窗口算子而言,窗口分配器和窗口函数是必不可少的。除此之外,Flink
还提供
了其他一些可选的
API
,让我们可以更加灵活地控制窗口行为。
1. 触发器(Trigger)
触发器主要是用来控制窗口什么时候触发计算。所谓的“触发计算”,本质上就是执行窗
口函数,所以可以认为是计算得到结果并输出的过程。
基于 WindowedStream
调用
.trigger()
方法,就可以传入一个自定义的窗口触发器(
Trigger
)。
stream.keyBy(...)
.window(...)
.trigger(new MyTrigger())
Trigger 是窗口算子的内部属性,每个窗口分配器(
WindowAssigner
)都会对应一个默认
的触发器;对于
Flink
内置的窗口类型,它们的触发器都已经做了实现。例如,所有事件时间
窗口,默认的触发器都是
EventTimeTrigger
;类似还有
ProcessingTimeTrigger
和
CountTrigger
。
所以一般情况下是不需要自定义触发器的,不过我们依然有必要了解它的原理。
Trigger 是一个抽象类,自定义时必须实现下面四个抽象方法:
onElement():
窗口中每到来一个元素,都会调用这个方法。
onEventTime():
当注册的事件时间定时器触发时,将调用这个方法。
onProcessingTime ():
当注册的处理时间定时器触发时,将调用这个方法。
clear():
当窗口关闭销毁时,调用这个方法。一般用来清除自定义的状态。
可以看到,除了 clear()
比较像生命周期方法,其他三个方法其实都是对某种事件的响应。
onElement()
是对流中数据元素到来的响应;而另两个则是对时间的响应。这几个方法的参数中
都有一个“触发器上下文”(
TriggerContext
)对象,可以用来注册定时器回调(
callback
)。这
里提到的“定时器”(
Timer
),其实就是我们设定的一个“闹钟”,代表未来某个时间点会执行
的事件;当时间进展到设定的值时,就会执行定义好的操作。很明显,对于时间窗口
(
TimeWindow
)而言,就应该是在窗口的结束时间设定了一个定时器,这样到时间就可以触发
窗口的计算输出了。关于定时器的内容,我们在后面讲解处理函数(
process function
)时还会
提到。
上面的前三个方法可以响应事件,那它们又是怎样跟窗口操作联系起来的呢?这就需要了
解一下它们的返回值。这三个方法返回类型都是
TriggerResult
,这是一个枚举类型(
enum
),
其中定义了对窗口进行操作的四种类型。
CONTINUE(继续):什么都不做
FIRE(触发):触发计算,输出结果
PURGE(清除):清空窗口中的所有数据,销毁窗口
FIRE_AND_PURGE(触发并清除):触发计算输出结果,并清除窗口
我们可以看到,Trigger
除了可以控制触发计算,还可以定义窗口什么时候关闭(销毁)。
上面的四种类型,其实也就是这两个操作交叉配对产生的结果。一般我们会认为,到了窗口的
结束时间,那么就会触发计算输出结果,然后关闭窗口——似乎这两个操作应该是同时发生的;
但
TriggerResult
的定义告诉我们,两者可以分开。稍后我们就会看到它们分开操作的场景。
下面我们举一个例子。在日常业务场景中,我们经常会开比较大的窗口来计算每个窗口的
pv
或者
uv
等数据。但窗口开的太大,会使我们看到计算结果的时间间隔变长。所以我们可以
使用触发器,来隔一段时间触发一次窗口计算。我们在代码中计算了每个
url
在
10
秒滚动窗
口的
pv
指标,然后设置了触发器,每隔
1
秒钟触发一次窗口的计算。
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import
org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import
org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.triggers.Trigger;
import org.apache.flink.streaming.api.windowing.triggers.TriggerResult;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
public class TriggerExample {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env
.addSource(new ClickSource())
.assignTimestampsAndWatermarks(
WatermarkStrategy.<Event>forMonotonousTimestamps()
.withTimestampAssigner(new
SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event event, long l) {
return event.timestamp;
}
})
)
.keyBy(r -> r.url)
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.trigger(new MyTrigger())
.process(new WindowResult())
.print();
env.execute();
}
public static class WindowResult extends ProcessWindowFunction<Event,
UrlViewCount, String, TimeWindow> {
@Override
public void process(String s, Context context, Iterable<Event> iterable,
Collector<UrlViewCount> collector) throws Exception {
collector.collect(
new UrlViewCount(
s,
// 获取迭代器中的元素个数
iterable.spliterator().getExactSizeIfKnown(),
context.window().getStart(),
context.window().getEnd()
)
);
}
}
public static class MyTrigger extends Trigger<Event, TimeWindow> {
@Override
public TriggerResult onElement(Event event, long l, TimeWindow timeWindow,
TriggerContext triggerContext) throws Exception {
ValueState<Boolean> isFirstEvent =
triggerContext.getPartitionedState(
new ValueStateDescriptor<Boolean>("first-event",
Types.BOOLEAN)
);
if (isFirstEvent.value() == null) {
for (long i = timeWindow.getStart(); i < timeWindow.getEnd(); i =
i + 1000L) {
triggerContext.registerEventTimeTimer(i);
}
isFirstEvent.update(true);
}
return TriggerResult.CONTINUE;
}
@Override
public TriggerResult onEventTime(long l, TimeWindow timeWindow,
TriggerContext triggerContext) throws Exception {
return TriggerResult.FIRE;
}
@Override
public TriggerResult onProcessingTime(long l, TimeWindow timeWindow,
TriggerContext triggerContext) throws Exception {
return TriggerResult.CONTINUE;
}
@Override
public void clear(TimeWindow timeWindow, TriggerContext triggerContext)
throws Exception {
ValueState<Boolean> isFirstEvent =
triggerContext.getPartitionedState(
new ValueStateDescriptor<Boolean>("first-event",
Types.BOOLEAN)
);
isFirstEvent.clear();
}
}
}
输出结果如下:
UrlViewCount{url='./prod?id=1', count=1, windowStart=2021-07-01 14:44:10.0,windowEnd=2021-07-01 14:44:20.0}172 173UrlViewCount{url='./prod?id=1', count=1, windowStart=2021-07-01 14:44:10.0,windowEnd=2021-07-01 14:44:20.0}UrlViewCount{url='./prod?id=1', count=1, windowStart=2021-07-01 14:44:10.0,windowEnd=2021-07-01 14:44:20.0}UrlViewCount{url='./prod?id=1', count=1, windowStart=2021-07-01 14:44:10.0,windowEnd=2021-07-01 14:44:20.0}
2. 移除器(Evictor)
移除器主要用来定义移除某些数据的逻辑。基于 WindowedStream
调用
.evictor()
方法,就
可以传入一个自定义的移除器(
Evictor
)。
Evictor
是一个接口,不同的窗口类型都有各自预实
现的移除器。
stream.keyBy(...)
.window(...)
.evictor(new MyEvictor())
Evictor 接口定义了两个方法:
evictBefore():定义执行窗口函数之前的移除数据操作
evictAfter():定义执行窗口函数之后的以处数据操作
默认情况下,预实现的移除器都是在执行窗口函数(window fucntions
)之前移除数据的。
3. 允许延迟(Allowed Lateness)
在事件时间语义下,窗口中可能会出现数据迟到的情况。这是因为在乱序流中,水位线
(
watermark
)并不一定能保证时间戳更早的所有数据不会再来。当水位线已经到达窗口结束时
间时,窗口会触发计算并输出结果,这时一般也就要销毁窗口了;如果窗口关闭之后,又有本
属于窗口内的数据姗姗来迟,默认情况下就会被丢弃。这也很好理解:窗口触发计算就像发车,
如果要赶的车已经开走了,又不能坐其他的车(保证分配窗口的正确性),那就只好放弃坐班
车了。
不过在多数情况下,直接丢弃数据也会导致统计结果不准确,我们还是希望该上车的人都
能上来。为了解决迟到数据的问题,
Flink
提供了一个特殊的接口,可以为窗口算子设置一个
“允许的最大延迟”(
Allowed Lateness
)。也就是说,我们可以设定允许延迟一段时间,在这段
时间内,窗口不会销毁,继续到来的数据依然可以进入窗口中并触发计算。
直到水位线推进到
了 窗口结束时间 + 延迟时间,才真正将窗口的内容清空,正式关闭窗口。
基于 WindowedStream
调用
.allowedLateness()
方法,传入一个
Time
类型的延迟时间,就可
以表示允许这段时间内的延迟数据。
stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.allowedLateness(Time.minutes(1))
比如上面的代码中,我们定义了 1 小时的滚动窗口,并设置了允许 1 分钟的延迟数据。也
就是说,在不考虑水位线延迟的情况下,对于
8
点
~9
点的窗口,本来应该是水位线到达
9
点
整就触发计算并关闭窗口;现在允许延迟
1
分钟,那么
9
点整就只是触发一次计算并输出结果,
并不会关窗。后续到达的数据,只要属于
8
点
~9
点窗口,依然可以在之前统计的基础上继续
叠加,并且再次输出一个更新后的结果。直到水位线到达了
9
点零
1
分,这时就真正清空状态、
关闭窗口,之后再来的迟到数据就会被丢弃了。
从这里我们就可以看到,窗口的触发计算(Fire
)和清除(
Purge
)操作确实可以分开。不
过在默认情况下,允许的延迟是
0
,这样一旦水位线到达了窗口结束时间就会触发计算并清除
窗口,两个操作看起来就是同时发生了。当窗口被清除(关闭)之后,再来的数据就会被丢弃。
4. 将迟到的数据放入侧输出流
我们自然会想到,即使可以设置窗口的延迟时间,终归还是有限的,后续的数据还是会被
丢弃。如果不想丢弃任何一个数据,又该怎么做呢?
Flink 还提供了另外一种方式处理迟到数据。我们可以将未收入窗口的迟到数据,放入“侧
输出流”(
side output
)进行另外的处理。所谓的侧输出流,相当于是数据流的一个“分支”,
这个流中单独放置那些错过了该上的车、本该被丢弃的数据。
基于 WindowedStream
调用
.sideOutputLateData()
方法,就可以实现这个功能。方法需要
传入一个“输出标签”(
OutputTag
),用来标记分支的迟到数据流。因为保存的就是流中的原
始数据,所以
OutputTag
的类型与流中数据类型相同。
DataStream<Event> stream = env.addSource(...);
OutputTag<Event> outputTag = new OutputTag<Event>("late") {};
stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.sideOutputLateData(outputTag)
将迟到数据放入侧输出流之后,还应该可以将它提取出来。基于窗口处理完成之后的
DataStream
,调用
.getSideOutput()
方法,传入对应的输出标签,就可以获取到迟到数据所在的
流了。
SingleOutputStreamOperator<AggResult> winAggStream = stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.sideOutputLateData(outputTag)
.aggregate(new MyAggregateFunction())
DataStream<Event> lateStream = winAggStream.getSideOutput(outputTag);
这里注意,getSideOutput()
是
SingleOutputStreamOperator
的方法,获取到的侧输出流数据
类型应该和
OutputTag
指定的类型一致,与窗口聚合之后流中的数据类型可以不同。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· .NET10 - 预览版1新功能体验(一)