在 Flink 中,用来衡量事件时间(Event Time)进展的标记,就被称作“水位线”。
public interface WatermarkStrategy<T> extends TimestampAssignerSupplier<T>, WatermarkGeneratorSupplier<T>{
@Override //时间戳分配器
TimestampAssigner<T> createTimestampAssigner(
TimestampAssignerSupplier.Context context);
@Override //水位线生成器
WatermarkGenerator<T> createWatermarkGenerator(
WatermarkGeneratorSupplier.Context context);
}
TimestampAssigner:主要负责从流中数据元素的某个字段中提取时间戳,并分配给元素。时间戳的分配是生成水位线的基础。
WatermarkGenerator:主要负责按照既定的方式,基于时间戳生成水位线。WatermarkGenerator 接口中,主要又有两个方法:onEvent()和 onPeriodicEmit()。
onEvent:每个事件到来都会调用,它的参数有当前事件、时间戳,以及允许发出水位线的一个WatermarkOutput,可以基于事件做各种操作
onPeriodicEmit:周期性调用,可以由 WatermarkOutput 发出水位线。周期时间为处理时间,可以调用环境配置的.setAutoWatermarkInterval()方法来设置,默认200ms。
env.getConfig().setAutoWatermarkInterval(60 * 1000L)
有序流
对于有序流,主要特点就是时间戳单调[Monotonously]增长,不会出现迟到数据。
stream.assignTimestampsAndWatermarks(
WatermarkStrategy.<Event>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element,long recordTimestamp){
return element.timestamp;
}
})
);
乱序流
由于乱序流中需要等待迟到数据到齐,所以必须设置一个固定量的延迟时间。这时生成水位线的时间戳,就是当前数据流中最大的时间戳减去延迟的结果,相当于把表调慢,当前时钟会滞后于数据的最大时间戳。
stream.assignTimestampsAndWatermarks(
//maxOutOfOrderness 参数,表示“最大乱序程度”
WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5)
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element,long recordTimestamp){
return element.timestamp;
}
})
);
乱序流中生成的水位线真正的时间戳,其实是 当前最大时间戳 – 延迟时间 – 1,因为时间戳为 t 的水位线,表示时间戳≤t 的数据全部到齐,不会再来了,实际上为t的数据还会来。
public void onPeriodicEmit(WatermarkOutput output) {
output.emitWatermark(new Watermark(maxTimestamp - outOfOrdernessMillis - 1));
}
周期性水位线生成器(Periodic Generator)
public static class CustomWatermarkStrategy implements WatermarkStrategy<Event> {
@Override
public TimestampAssigner<Event> createTimestampAssigner( TimestampAssignerSupplier.Context context) { return new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp){
return element.timestamp; // 告诉程序数据源里的时间戳是哪一个字段
}
};
}
@Override
public WatermarkGenerator<Event> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
return new WatermarkGenerator<Event> {
private Long delayTime = 5000L; // 延迟时间
// 观察到的最大时间戳
private Long maxTs = Long.MIN_VALUE + delayTime + 1L;
@Override
public void onEvent(Event event, long eventTimestamp, WatermarkOutput output) {
//每来一条数据就调用一次
maxTs = Math.max(event.timestamp, maxTs); // 更新最大时间戳
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 发射水位线,默认200ms调用一次
output.emitWatermark(new Watermark(maxTs - delayTime - 1L));
}
};
}
}
断点式水位线生成器(Punctuated Generator)
public class CustomPunctuatedGenerator implements WatermarkGenerator<Event> {
@Override
public void onEvent(Event r,long eventTimestamp, WatermarkOutput output) {
// 只有在遇到特定的itemId时,才发射水位线
if (r.user.equals("Mary")) {
output.emitWatermark(new Watermark(r.timestamp- 1));
}
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 不需要做任何事情,因为我们在onEvent方法中发射了水位线
}
}
// 泛型是数据源中的类型
public static class ClickSourceWithWatermark implements SourceFunction<Event> {
private boolean running = true;
@Override
public void run(SourceContext<Event> sourceContext) throws Exception {
Random random = new Random();
String[] userArr = {"Mary", "Bob", "Alice"};
String[] urlArr = {"./home", "./cart", "./prod?id=1"};
while (running) {
long currTs = Calendar.getInstance().getTimeInMillis();
String username = userArr[random.nextInt(userArr.length)];
String url = urlArr[random.nextInt(urlArr.length)];
Event event = new Event(username, url, currTs);
// 使用collectWithTimestamp方法将数据发送出去,并指明数据中的时间戳的字段
sourceContext.collectWithTimestamp(event, event.timestamp);
// 发送水位线
sourceContext.emitWatermark(new Watermark(event.timestamp-1L));
Thread.sleep(1000L);
}
}
@Override
public void cancel() {
running = false;
}
}
在数据流开始之前,Flink 会插入一个大小是负无穷大(在 Java 中是-Long.MAX_VALUE)的水位线,而在数据流结束时,Flink 会插入一个正无穷大(Long.MAX_VALUE)的水位线,保证所有的窗口闭合以及所有的定时器都被触发。
对于离线数据集,Flink 也会将其作为流读入,也就是一条数据一条数据的读取。在这种情况下,Flink 对于离线数据集,只会插入两次水位线,也就是在最开始处插入负无穷大的水位线,在结束位置插入一个正无穷大的水位线。因为只需要插入两次水位线,就可以保证计算的正确,无需在数据流的中间插入水位线了。
Watermark是一条携带时间戳的特殊数据,从代码指定生成的位置,插入到流里面。
Flink 是一种流式计算引擎,主要是来处理无界数据流的,数据源源不断、无穷无尽。想要更加方便高效地处理无界流,一种方式就是将无限数据切割成有限的“数据块”进行处理,这就是所谓的“窗口”(Window)。
在 Flink 中,窗口其实并不是一个“框”,流进来的数据被框住了就只能进这一个窗口。相比之下,我们应该把窗口理解成一个“桶”。在 Flink 中,窗口可以把流切割成有限大小的多个“存储桶”(bucket);每个数据都会分发到对应的桶中,当到达窗口结束时间时,就对每个桶中收集的数据进行计算处理。
**窗口的分类:**按照驱动类型分类
按照窗口分配数据的规则分类
滚动窗口(Tumbling Windows)
滚动窗口有固定的大小,是一种对数据进行“均匀切片”的划分方式。窗口之间没有重叠,也不会有间隔,是“首尾相接”的状态。
滚动窗口可以基于时间定义,也可以基于数据个数定义;需要的参数只有一个,就是窗口的大小(window size)。
滑动窗口(Sliding Windows)
与滚动窗口类似,滑动窗口的大小也是固定的。区别在于,窗口之间并不是首尾相接的,而是可以“错开”一定的位置。定义滑动窗口的参数有两个:除去窗口大小(window size)之外,还有一个“滑动步长”(window slide),它其实就代表了窗口计算的频率。
滑动窗口可以基于时间定义,也可以基于数据个数定义。
会话窗口(Session Windows)
这里的会话类似 Web 应用中 session 的概念,不过并不表示两端的通讯过程,而是借用会话超时失效的机制来描述窗口。据来了之后就开启一个会话窗口,如果接下来还有数据陆续到来,那么就一直保持会话;如果一段时间一直没收到数据,那就认为会话超时失效,窗口自动关闭。
如果相邻两个数据到来的时间间隔(Gap)小于指定的大小(size),那说明还在保持会话,它们就属于同一个窗口;如果 gap 大于 size,那么新来的数据就应该属于新的会话窗口,而前一个窗口就应该关闭了。
乱序流下,每来一个新的数据,都会创建一个新的会话窗口;然后判断已有窗口之间的距离,如果小于给定的 size,就对它们进行合并(merge)操作。
会话窗口只能基于时间来定义 。
全局窗口(Global Windows)
这种窗口全局有效,会把相同 key 的所有数据都分配到同一个窗口中;说直白一点,就跟没分窗口一样。无界流的数据永无止尽,所以这种窗口也没有结束的时候,默认是不会做触发计算的。如果希望它能对数据进行计算处理,还需要自定义“触发器”(Trigger)。
在调用窗口算子之前,是否有 keyBy 操作。
定义窗口分配器(Window Assigners)是构建窗口算子的第一步,作用是定义数据应该被“分配”到哪个窗口。窗口分配数据的规则,其实就对应着不同的窗口类型,窗口分配器其实就是在指定窗口的类型。
下面是窗口的使用方式示例,窗口分配器后面需要有窗口函数。
stream.keyBy(<key selector>) //返回KeyedStream
.window(<window assigner>) //返回WindowedStream
.aggregate(<window function>)
stream.windowAll(<window assigner>) //返回AllWindowedStream
.aggregate(<window function>)
不同窗口类型下的窗口分配器:
时间窗口
时间窗口是最常用的窗口类型,又可以细分为滚动、滑动和会话三种。时间窗口的调用方式是直接调用 .window(),而 .timeWindow() 由于事件时间语义时需要另外声明,实践中容易忘记声明,故1.12 版本之后已弃用。
滚动处理时间窗口
keyedStream.window(TumblingProcessingTimeWindows.of(Time.seconds(5))) // 窗口大小
of的重载方法两个参数size 和 offset。第一个参数当然还是窗口大小,第二个参数则表示窗口起始点的偏移量。比如北京时间每天 0 点开启:
keyedStream.window(TumblingProcessingTimeWindows.of(Time.days(1), Time.hours(-8)))
滑动处理时间窗口
//of的参数依次是窗口大小、滑动步长、窗口起始点偏移量(可选)
keyedStream.window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5) [,offset] ))
处理时间会话窗口
//方式一
keyedStream.window(ProcessingTimeSessionWindows.withGap(Time.seconds(10)) //超时时间 10秒
//方式二
keyedStream.window(ProcessingTimeSessionWindows.withDynamicGap( //session gap 的动态提取
new SessionWindowTimeGapExtractor<Tuple2<String, Long>>() {
@Override
public long extract(Tuple2<String, Long> element) {
return element.f0.length() * 1000; // 提取session gap值返回, 单位毫秒
}
}))
滚动事件时间窗口
keyedStream.window(TumblingEventTimeWindows.of(Time.seconds(5))) // 窗口大小
滑动事件时间窗口
//of的参数依次是窗口大小、滑动步长
keyedStream.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
事件时间会话窗口
keyedStream.window(EventTimeSessionWindows.withGap(Time.seconds(10))) //超时时间 10秒
计数窗口
滚动计数窗口
stream.keyBy(...).countWindow(10)
滑动计数窗口
stream.keyBy(...).countWindow(10,3)
全局窗口
stream.keyBy(...).window(GlobalWindows.create()); //必须自行定义触发器才能实现窗口计算
在窗口分配器之后,必须再接上一个定义窗口如何进行计算的操作,这就是所谓的“窗口函数”(window functions)。
窗口函数定义了要对窗口中收集的数据做的计算操作,根据处理的方式可以分为两类:增量聚合函数和全窗口函数。
增量聚合函数(incremental aggregation functions)
窗口对无限流的切分,可以看作得到了一个有界数据集。如果我们等到所有数据都收集齐,在窗口到了结束时间要输出结果的一瞬间再去进行聚合,显然就不够高效了——这相当于真的在用批处理的思路来做实时流处理。
为了提高实时性,我们可以再次将流处理的思路发扬光大:就像 DataStream 的简单聚合一样,每来一条数据就立即进行计算,中间只要保持一个简单的聚合状态就可以了;区别只是在于不立即输出结果,而是要等到窗口结束时间。等到窗口到了结束时间需要输出计算结果的时候,我们只需要拿出之前聚合的状态直接输出,这无疑就大大提高了程序运行的效率和实时性。
归约函数(ReduceFunction)
与简单聚合时用到的 ReduceFunction 是同一个函数类接口。ReduceFunction 可以对已有的数据进行归约处理,把每一个新输入的数据和当前已经归约出来的值,再做一个聚合计算,不会改变流的元素数据类型,所以输出类型和输入类型是一样的。
windowedStream.reduce(
new ReduceFunction<Tuple2<String, Long>>() {
@Override
public Tuple2<String, Long> reduce(Tuple2<String, Long> value1,
Tuple2<String, Long> value2) throws Exception {
// 定义累加规则,窗口闭合时,向下游发送累加结果
return Tuple2.of(value1.f0, value1.f1 + value2.f1);
}
})
聚合函数(AggregateFunction)
AggregateFunction 可以看作是 ReduceFunction 的通用版本,这里有三种类型:输入类型(IN)、累加器类型(ACC)和输出类型(OUT)。输入类型 IN 就是输入流中元素的数据类型;累加器类型 ACC 则是我们进行聚合的中间状态类型;而输出类型当然就是最终计算结果的类型,输入数据、中间状态、输出结果三者类型都可以不同。
windowedStream.aggregate(
new AggregateFunction<Event, Tuple2<HashSet<String>, Long>, Double> {
@Override // 创建一个累加器,这就是为聚合创建了一个初始状态
public Tuple2<HashSet<String>, Long> createAccumulator() {
return Tuple2.of(new HashSet<String>(), 0L);
}
@Override //属于本窗口的数据来一条累加一次,并返回累加器
public Tuple2<HashSet<String>,Long> add(Event value,
Tuple2<HashSet<String>, Long> accumulator) {
accumulator.f0.add(value.user);
return Tuple2.of(accumulator.f0, accumulator.f1 + 1L);
}
@Override // 窗口闭合时,增量聚合结束,将计算结果发送到下游
public Double getResult(Tuple2<HashSet<String>, Long> accumulator) {
return (double) accumulator.f1 / accumulator.f0.size();
}
@Override //合并两个累加器,需要合并窗口的场景下才会被调用。最常见的是会话窗口
public Tuple2<HashSet<String>, Long> merge(
Tuple2<HashSet<String>, Long> a, Tuple2<HashSet<String>, Long> b) {
return null;
}
}
)
另外,直接基于 WindowedStream 调用的简单聚合方法.sum()/max()/maxBy()/min()/minBy()底层,其实都是通过 AggregateFunction 来实现。
全窗口函数(full window functions)
与增量聚合函数不同,全窗口函数需要先收集窗口中的数据,并在内部缓存起来,等到窗口要输出结果的时候再取出数据进行计算。
为什么还需要有全窗口函数呢?有些场景下,我们要做的计算必须基于全部的数据才有效,如中位数。输出的结果有可能要包含上下文中的一些信息(比如窗口的起始时间)。
窗口函数(WindowFunction)
处理窗口函数 ProcessWindowFunction 完全覆盖了 WindowFunction 的功能,它基本上被 ProcessWindowFunction 替代了。
stream.keyBy(<key selector>)
.window(<window assigner>)
.apply(
new WindowFunction<IN, OUT, KEY, W extends Window> extends Function, Serializable {
void apply(KEY key, W window, Iterable<IN> input, Collector<OUT> out) throws Exception;
});
处理窗口函数(ProcessWindowFunction)
Context不仅能够获取窗口信息,还可以访问当前的时间和状态信息。这里的时间就包括了处理时间(processing time)和事件时间水位线(event time watermark)。
stream.keyBy(data -> true)
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.process(new UvCountByWindow())
// 自定义窗口处理函数 统计UV 去重用户数
public static class UvCountByWindow extends ProcessWindowFunction<Event, String, Boolean, TimeWindow> {
@Override
public void process(Boolean aBoolean, Context context,
Iterable<Event> elements, Collector<String> out) throws Exception {
HashSet<String> userSet = new HashSet<>();
// 遍历所有数据,放到Set里去重
for (Event event : elements) {
userSet.add(event.user);
}
// 结合窗口信息,包装输出内容
Long start = context.window().getStart();
Long end = context.window().getEnd();
Long currentWatermark = context.currentWatermark();
out.collect("窗口: " + new Timestamp(start) + " ~ "
+ new Timestamp(end) + " 的独立访客数量是:" + userSet.size());
}
}
全窗口函数因为运行效率较低,很少直接单独使用,往往会和增量聚合函数结合在一起,共同实现窗口的处理计算。
增量聚合和全窗口函数的结合使用
我们之前在调用 WindowedStream 的 .reduce() 和 .aggregate() 方法时,只是简单地直接传入了一个 ReduceFunction 或 AggregateFunction 进行增量聚合。除此之外,其实还可以传入第二个参数:一个全窗口函数,可以是 WindowFunction 或者 ProcessWindowFunction。
stream.keyBy(data -> data.url)
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
// 同时传入增量聚合函数和全窗口函数
.aggregate(new UrlViewCountAgg(), new UrlViewCountResult())
.print();
// 自定义增量聚合函数,来一条数据就加一
public static class UrlViewCountAgg
implements AggregateFunction<Event, Long, Long> {
@Override
public Long createAccumulator() { return 0L; }
@Override
public Long add(Event value, Long accumulator) { return accumulator + 1; }
@Override
public Long getResult(Long accumulator) { return accumulator; }
@Override
public Long merge(Long a, Long b) { return null; }
}
// 自定义窗口处理函数,只需要包装窗口信息
public static class UrlViewCountResult extends ProcessWindowFunction<Long,
UrlViewCount, String, TimeWindow> {
@Override
public void process(String url, Context context, Iterable<Long> elements,
Collector<UrlViewCount> out) throws Exception {
// 结合窗口信息,包装输出内容
Long start = context.window().getStart();
Long end = context.window().getEnd();
// 迭代器中只有一个元素,就是增量聚合函数的计算结果
out.collect(new UrlViewCount(url, elements.iterator().next(), start, end));
}
}
触发器(Trigger)
触发器主要是用来控制窗口什么时候触发计算。所谓的“触发计算”,本质上就是执行窗口函数,所以可以认为是计算得到结果并输出的过程。
Trigger 是窗口算子的内部属性,每个窗口分配器(WindowAssigner)都会对应一个默认的触发器。EventTimeTrigger、ProcessingTimeTrigger 和 CountTrigger。
全局窗口(GlobalWindow)的默认触发器是永不会被触发的NeverTrigger。因此,在使用全局窗口时,必须自定义一个触发器。
通过使用 trigger() 方法指定触发器,将会覆盖窗口分配器的默认触发器。
如窗口开的太大,会使我们看到计算结果的时间间隔变长。所以我们可以使用触发器,来隔一段时间触发一次窗口计算。我们在代码中计算了每个 url 在 10 秒滚动窗口的 pv 指标,然后设置了触发器,每隔 1 秒钟触发一次窗口的计算。
//触发器三个方法响应事件后的返回类型:可以控制窗口触发计算,还可以定义窗口什么时候关闭(销毁)
public enum TriggerResult {
CONTINUE(false, false), //什么都不做
FIRE_AND_PURGE(true, true),//触发计算输出结果,并清除窗口
FIRE(true, false), //触发计算,输出结果
PURGE(false, true); //清空窗口中的所有数据,销毁窗口
// ------------------------------------------------------------------------
private final boolean fire;
private final boolean purge;
}
public static class MyTrigger extends Trigger<Event, TimeWindow> {
@Override //窗口中每到来一个元素,【TriggerContext对象,可以用来注册定时器回调】
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;
}
//public boolean canMerge() { return false; }
//onMerge()方法,与状态触发器相关,并且在相应的窗口合并时合并两个触发器的状态,一般用于会话窗口。
@Override //当窗口关闭销毁时, 一般用来清除自定义的状态。
public void clear(TimeWindow timeWindow, TriggerContext triggerContext)
throws Exception {
ValueState<Boolean> isFirstEvent =triggerContext.getPartitionedState(
new ValueStateDescriptor<Boolean>("first-event", Types.BOOLEAN));
isFirstEvent.clear();
}
//Trigger上下文可以拿到的东西,和处理函数的定时服务差不多,定时服务见 处理函数 一章
public interface TriggerContext {
long getCurrentProcessingTime();
MetricGroup getMetricGroup();
long getCurrentWatermark();
void registerProcessingTimeTimer(long time);
void registerEventTimeTimer(long time);
void deleteProcessingTimeTimer(long time);
void deleteEventTimeTimer(long time);
<S extends State> S getPartitionedState(StateDescriptor<S, ?> stateDescriptor);
<S extends Serializable> ValueState<S> getKeyValueState(
String name, Class<S> stateType, S defaultState);
<S extends Serializable> ValueState<S> getKeyValueState(
String name, TypeInformation<S> stateType, S defaultState);
}
}
stream.keyBy(r -> r.url).window(...).trigger(new MyTrigger()).process(...);
移除器(Evictor)
驱逐器能够在触发器触发之后,以及在应用窗口函数之前或之后从窗口中移除元素。
默认情况下,预实现的移除器是在执行窗口函数(window fucntions)之前移除数据。
stream.keyBy(...).window(...).evictor(new MyEvictor())
Evictor 接口定义了两个方法:
Flink带有三个内置的驱逐器:
允许延迟(Allowed Lateness)
“允许的最大延迟”(Allowed Lateness):我们可以设定允许延迟一段时间,在这段时间内,窗口不会销毁,继续到来的数据依然可以进入窗口中并触发计算。直到水位线推进到了 窗口结束时间 + 延迟时间,才真正将窗口的内容清空,正式关闭窗口。窗口的触发计算(Fire)和清除(Purge)操作被分开。
stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.allowedLateness(Time.minutes(1))
OutputTag<Event> outputTag = new OutputTag<Event>("late") {};
SingleOutputStreamOperator<AggResult> winAggStream = stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.sideOutputLateData(outputTag)
.aggregate(new MyAggregateFunction())
DataStream<Event> lateStream = winAggStream.getSideOutput(outputTag);
窗口的创建
窗口的类型和基本信息由窗口分配器指定,但窗口不会预先创建好,而是由数据驱动创建。当第一个应该属于这个窗口的数据元素到达时,就会创建对应的窗口。
窗口计算的触发
每个窗口还会有自己的窗口函数和触发器。窗口函数可以分为增量聚合函数和全窗口函数,主要定义了窗口中计算的逻辑;而触发器则是指定调用窗口函数的条件。
对于不同的窗口类型,触发计算的条件也会不同。滚动事件时间窗口,应该在水位线到达窗口结束时间触发计算,属于“定点发车”;计数窗口,元素数量达到定义大小时触发计算,属于“人满发车”。窗口的触发时机:watermark>=end-1ms
当我们设置了事件时间窗口的允许延迟,如果水位线超过了窗口结束时间、但还没有到达设定的最大延迟时间,这期间内到达的迟到数据也会触发窗口计算。
窗口的销毁
一般情况下,当时间达到了结束点,就会直接触发计算输出结果、进而清除状态销毁窗口。Flink中只对时间窗口有销毁机制;由于计数窗口是基于GlobalWindw实现的,而全局窗口不会清除状态,所以就不会被销毁。
在特殊的场景下,窗口的销毁和触发计算会有所不同。事件时间语义下,如果设置了允许延迟,那么在水位线到达窗口结束时间时,仍然不会销毁窗口;窗口真正被完全删除的时间点,是窗口的结束时间加上用户指定的允许延迟时间。
Flink 处理迟到数据,对于结果的正确性有三重保障:水位线的延迟,窗口允许迟到数据,以及将迟到数据放入窗口侧输出流。
下一章:Flink 1.13 处理函数(ProcessFunction)