在上篇文章中,我详细讨论了 Flink 是如何为 record 分配窗口的。接下来我们就要讨论一下什么时机触发对窗口的计算了。这就像响指的功能,当集齐五个石头后,什么时候毁灭宇宙一半的生命呢?大家都知道了, 还差一个响指。我们今天讨论的触发时机,和响指的功能差不多,在 Flink 中,Flink 的开发者给他起了一个非常形象的名字——Trigger。
请看下面的图片, Trigger 就是手枪的扳机。扳动扳机就能射出子弹,在 Flink 里面,Trigger 类决定了,是否对窗口中的数据进行计算,并将计算结构发到后面的算子。
比喻说完了,下面进入到正题。
正题
总体流程
有一个类是 Trigger 类,window 该不该触发计算,是由它决定的。它返回一个 TriggerResualt 这样的一个 Enumerate 。 当它的值为 Fire 的时候,就会触发窗口的计算。
想要弄清楚 Trigger 是怎么触发的,就涉及到 WindowOperator 和 Trigger(以 EventTimerTrigger 为例),还有 InternalTimeServerImpl 这几个类的调用关系。
我是看了 Flink 的官方文档,知道有 Trigger 这货的,然后我用 ideal 来看的源码,再加上打断点,终于搞清楚这几个的调用过程。调用的过程如下所示:
InternalTimeServerImpl 是 Flink 的一个定时器实现,如果调用它的 registerEventTimeTimer,注册一个定时时间(可以比作闹钟),等到了时间,InternalTimeServerImpl 会调用 WindowOperator 的 onElement() 函数,WindowOperator 的 onEventTime 方法中也调用了 Trigger 的 onEventTime() 方法,当 onEventTime() 返回 fire 这种 TriggerResualt 的时候,就会触发窗口的计算。
另外一个调用 trigger 的时间是当有新的元素进入到窗口后,WindowOperator 会调用 trigger.onElement 的方法,当 trigger 返回 fire 类型的 triggerResult 后,就要触发窗口的计算。
总体的代码执行逻辑说完了,接下来,我就要从细节上,结合测试数据来看一下,代码的执行过程。
下面我就来描述一下。我们重点来看一下 EventtimeTrigger里面的逻辑。
流程中的细节
使用一个案例来说明 trigger 的作用:
import org.apache.flink.api.common.ExecutionConfig;
import org.apache.flink.api.common.eventtime.*;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.typeinfo.TypeInformation;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.api.java.tuple.Tuple6;
import org.apache.flink.streaming.api.TimeCharacteristic;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.AssignerWithPeriodicWatermarks;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
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;
import javax.annotation.Nullable;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
/**
* @className: WaterMarkTest
* @Description:
* @Author: wangyifei
* @Date: 2024/4/7 17:26
*/
public class WaterMarkTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
ExecutionConfig config = env.getConfig();
// 默认的 autoWatermarkInterval 的时间是 200 ms
// config.setAutoWatermarkInterval(2000L);
// 并发度的设置是必须的,如果 assignerTimestampAndWatermark() 返回的 DataStream 的并发度大于后面窗口的并发度,
// 则 DataStream 总会有一个分区的时间戳为 0 ,到了窗口中是取最小的那个, 就是 0 了,这样的话就不能触发窗口的计算了。
config.setParallelism(1);
DataStreamSource<String> source = env.socketTextStream("localhost", 8080);
SingleOutputStreamOperator<Tuple2<String, Long>> withWaterMark = source.map(new MapFunction<String, Tuple2<String, Long>>() {
@Override
public Tuple2<String, Long> map(String value) throws Exception {
String[] split = value.split("\\s*,\\s*");
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
long l = Long.parseLong(split[1]);
String format = simpleDateFormat.format(l);
System.out.println(format);
return Tuple2.of(split[0], l);
}
}).assignTimestampsAndWatermarks(new MyWaterStrategy());
withWaterMark.keyBy(x -> x.f0)
.window(TumblingEventTimeWindows.of(Time.seconds(3)))
.trigger(new Trigger<Tuple2<String, Long>, TimeWindow>(){
@Override
public TriggerResult onElement(Tuple2<String, Long> element, long timestamp, TimeWindow window, TriggerContext ctx) throws Exception {
System.out.println("window current watermark: " + ctx.getCurrentWatermark()
+ " start:" + window.getStart() + " end:" + window.getEnd()
+ " max timestamp: " + window.maxTimestamp()
);
if (window.maxTimestamp() <= ctx.getCurrentWatermark()) {
// if the watermark is already past the window fire immediately
return TriggerResult.FIRE;
} else {
ctx.registerEventTimeTimer(window.maxTimestamp());
return TriggerResult.CONTINUE;
}
}
@Override
public TriggerResult onProcessingTime(long time, TimeWindow window, TriggerContext ctx) throws Exception {
return TriggerResult.CONTINUE;
}
@Override
public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) throws Exception {
return time == window.maxTimestamp() ?
TriggerResult.FIRE :
TriggerResult.CONTINUE;
}
@Override
public void clear(TimeWindow window, TriggerContext ctx) throws Exception {
}
})
.apply(new MyWindowFunction()).print("------");
env.execute("watermark-test");
}
public static class MyWindowFunction implements WindowFunction<Tuple2<String, Long>
, Tuple6<String , Integer,String,String,String,String>
, String
, TimeWindow
> {
@Override
public void apply(String key, TimeWindow window, Iterable<Tuple2<String, Long>> input, Collector<Tuple6<String, Integer, String, String, String, String>> out) throws Exception {
Iterator<Tuple2<String, Long>> iterator = input.iterator();
List<Tuple2<String, Long>> list = new ArrayList<>();
while (iterator.hasNext()) {
Tuple2<String, Long> next = iterator.next();
System.out.println(next);
list.add(next);
}
Tuple6<String, Integer, String, String, String, String> of
= Tuple6.of(key, list.size(), "", window.maxTimestamp() + ""
, window.getStart() + "", window.getEnd() + "");
out.collect(of);
}
}
public static class MyWaterStrategy implements WatermarkStrategy<Tuple2<String, Long>>{
@Override
public WatermarkGenerator<Tuple2<String, Long>> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
return new MyWatermarkGenerator();
}
@Override
public TimestampAssigner<Tuple2<String, Long>> createTimestampAssigner(TimestampAssignerSupplier.Context context) {
return new MyTimestampAssigner() ;
}
}
public static class MyWatermarkGenerator implements WatermarkGenerator<Tuple2<String, Long>>{
private long maxTimestamp = 0L;
@Override
public void onEvent(Tuple2<String, Long> event, long eventTimestamp, WatermarkOutput output) {
long cur = eventTimestamp - 1000L;
if(cur > maxTimestamp){
maxTimestamp = cur ;
}
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
output.emitWatermark(new Watermark(maxTimestamp));
}
}
public static class MyTimestampAssigner implements TimestampAssigner<Tuple2<String, Long>>{
@Override
public long extractTimestamp(Tuple2<String, Long> element, long recordTimestamp) {
// System.out.println(element.f0 + " timestamp: " + recordTimestamp);
return element.f1;
}
}
}
一开始测试没有通过,后来将并发度统一设置为 1 ,就通过了。这和 assignerTimestampAndWatermark 的并发度、窗口的并发的数量相关,一个并发度就是一个分区,从上到下,watermark 都是从分区里面取最小的。所以如果并发度设置的不对,发到窗口的 watermark 始终不会更新的。所以窗口也不会触发计算。
在 WindowOperator 里面有两个方法,一个是 onElement() ;另外一个是 onEventTime() ,在这两个方法里面分别调用 onElement 和 onEventTime 这两个。EventtimeTrigger 两个方法的代码逻辑如下:
@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;
} 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;
}
从代码中,我们可以看到 onElement 里面,用 window.maxTimestamp() <= ctx.getCurrentWatermark() 的判断条件,true 的话返回 fire。否则返回 continue,在 else 里面,我们要注意的是在 content 里面注册了一个以 maxTimestamp 为节点的定时器。当 watermark 的值大于等于了窗口的 end 时间戳,trigger 就会返回 fire ,
当输入, 1461756862000 ~ 1461756873000 的时候,watermark 和 endTime 的值分别为,
1461756873000(watermark) < 1461756874000(endTime)
由于我们使用 watermark 的生成方式是 periodWatermark
可以在 ExecutionConfig#getAutoWatermarkInterval(Long n) 配置的值,从源码上看,它的默认值是0L,也就是说,在默认的配置下,会不断得调 getCurrentWatermartk ,来生成一个 waterMark。
当我们输入 1461756874000 的时候,watermark 才变成了 1461756873000 , 这里需要说明的是,只有 1461756874000 流到下游的 operoter 以后,AssignerWithPeriodicWatermarks.getCurrentWatermark() 方法才生成一个 1461756873000 的 water mark。注意此时我并没有输入新的数据,所以 onElement 的 trigger 不能背调用。
那为什么,出触发窗口的计算呢?着是因为,还有一个 onEventtime 这个方法,它是由 InternalTimeServiceManager 管理的,它实际上是一个定时器,当 watermark 的时间戳大于等于窗口的 end 时间戳后,就会执行调用 WindowOperator.onEventtime 这个方法,这个方法里面就会触发 trigger 的判断。他们之间的调用关系如下所示(从下往上调用):
测试数据如下所示:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
000001,1461756874000
当输入 000001,1461756874000 这一行数据后,各个值为,
watermark:1461756864000
窗口的 end :1461756864000
不过在 window.getMaxTimestamp() 返回的是 end - 1 = 1461756863999 ,如下所示。
1461756863999 + 1 = 1461756864000
总结一下
触发窗口的计算有两种方式。
- EventtimeTrigger.onElement() 这个里面,在这个方法里面,首先会判断当前的 watermark 是否大于等于 end -1 ,如果成立,则返回 fire ,触发窗口的计算,否则,在 timeServer 中注册一个 event time 的定时器,当 event time 到了 end -1 后,触发 WindowOperator 的 onEventTime() 方法。代码如下所示:
2. 当经历了 periodWatermark 生成器的周期后,如果生成最新 watermark >= maxTimestamp, WindowOperator 的 onEventTime() 就会被调用。这个时候被调用,肯定会触发窗口的计算。
现在来整理一下 triger 的种类,如下图所:
其他各种 trigger 的逻辑
ContinuousEventTimeTrigger
看一下下面的代码,它是 ContinuousEventTimeTrigger 里 onElement() 和 onEventTime() 方法里面主要的逻辑。
if (window.maxTimestamp() <= ctx.getCurrentWatermark()) {
// if the watermark is already past the window fire immediately
return TriggerResult.FIRE;
} else {
ctx.registerEventTimeTimer(window.maxTimestamp());
}
ReducingState<Long> fireTimestamp = ctx.getPartitionedState(stateDesc);
if (fireTimestamp.get() == null) {
long start = timestamp - (timestamp % interval);
long nextFireTimestamp = start + interval;
ctx.registerEventTimeTimer(nextFireTimestamp);
fireTimestamp.add(nextFireTimestamp);
}
return TriggerResult.CONTINUE;
这段代码的逻辑分析如下:
- window.maxTimestamp() <= ctx.getCurrentWatermark() 是否成立,如果成立, 则返回 fire。
- 否则的话,注册一个定时器。
- 从分区状态哪里看看有没有保存这个窗口的定时时间,如果没有的话,就注册一个。并将触发事件方法快分区状态里面。
- 从 nextFireTime 的公式看,它的计算有点像 window 窗口的过程。就是按照 event time 的时间戳上,每 interval 长度划一条杠。说白就是 watermark 的到了 interval 末尾时间点,就会触发窗口的计算。
CountTrigger
下面的代码是 onElement 主要的代码逻辑。
@Override
public TriggerResult onElement(Object element, long timestamp, W window, TriggerContext ctx) throws Exception {
ReducingState<Long> count = ctx.getPartitionedState(stateDesc);
count.add(1L);
if (count.get() >= maxCount) {
count.clear();
return TriggerResult.FIRE;
}
return TriggerResult.CONTINUE;
}
从代码中,我们可以总结:
- 先从分区哪里拿出窗口中数据的个数,当数据的个数大于等于了我们预设的阈值,就返回 fire ,然后触发窗口的计算。
- 如果没有数据数量没有到达阈值,则返回 continue 。
如下代码所示,是 onEventTime 的逻辑,这段代码很简单,就是始终的返回 continue,也就是说,CountTrigger 不会理会 InternalTimeServer ,它就是盯着窗口中的数据个数。
public TriggerResult onEventTime(long time, W window, TriggerContext ctx) {
return TriggerResult.CONTINUE;
}
DeltaTrigger
Delta 是一个拉丁字母,在数学上表是差值的意思,通过备注来看,Delta trigger 是要比较一些两个值的差值来判断是否触发窗口计算。
那是哪两个值的差值呢?从下面代码上看,是最新那条数据上某些属性值和上次窗口计算的时间点的那条记录上的属性值,这两个两条记录,使用 getDelta 来计算出的值大于了我们输入的阈值,则触发窗口的计算。否则 continue,我看到了默认有两个 deltaFunctin,有两个,一个是计算空间中两个向量的余玄值,另外一个是计算空间中两个向量的欧几里得距离,其实更多的时候,我们需要自己使用 lambda 来计算出计算出差值。
@Override
public TriggerResult onElement(T element, long timestamp, W window, TriggerContext ctx) throws Exception {
ValueState<T> lastElementState = ctx.getPartitionedState(stateDesc);
if (lastElementState.value() == null) {
lastElementState.update(element);
return TriggerResult.CONTINUE;
}
if (deltaFunction.getDelta(lastElementState.value(), element) > this.threshold) {
lastElementState.update(element);
return TriggerResult.FIRE;
}
return TriggerResult.CONTINUE;
}
@Override
public TriggerResult onEventTime(long time, W window, TriggerContext ctx) {
return TriggerResult.CONTINUE;
}
@Override
public TriggerResult onProcessingTime(long time, W window, TriggerContext ctx) throws Exception {
return TriggerResult.CONTINUE;
}
PurgingTrigger
从代码中看,这个 trigger 的功能很简单,它是一个代理Trigger,它有一个成员变量是 trigger ,这个 trigger 可能是 countTrigger 、ContinuousTrigger、DeltaTrigger、EventtimeTrigger ,PuergingTrigger 都能把它包装起来,只要看他们返回是 fire ,它都反掌 Fire_AND_PURGE.
@Override
public TriggerResult onElement(T element, long timestamp, W window, TriggerContext ctx) throws Exception {
TriggerResult triggerResult = nestedTrigger.onElement(element, timestamp, window, ctx);
return triggerResult.isFire() ? TriggerResult.FIRE_AND_PURGE : triggerResult;
}
@Override
public TriggerResult onEventTime(long time, W window, TriggerContext ctx) throws Exception {
TriggerResult triggerResult = nestedTrigger.onEventTime(time, window, ctx);
return triggerResult.isFire() ? TriggerResult.FIRE_AND_PURGE : triggerResult;
}
@Override
public TriggerResult onProcessingTime(long time, W window, TriggerContext ctx) throws Exception {
TriggerResult triggerResult = nestedTrigger.onProcessingTime(time, window, ctx);
return triggerResult.isFire() ? TriggerResult.FIRE_AND_PURGE : triggerResult;
剩下的
看一下,有几个没有讲到的:
ContinuousProcessingTimeTrigger、ProcessingTimeTrigger、NeverTrigger。
其中,
- ContinuousProcessingTimeTrigger、ProcessingTimeTrigger 和他们对应 Event time 处理逻辑相同,只是使用的时间不相同。
- NeverTrigger 是针对没有分区的窗口。