Flink中的时间和窗口 完整使用
在流数据处理应用中,一个很重要、也很常见的操作就是窗口计算。所谓的“窗口”,一般就是划定的一段时间范围,也就是“时间窗”;对在这范围内的数据进行处理,就是所谓的窗口计算。所以窗口和时间往往是分不开的。接下来我们就深入了解一下Flink中的时间语义和窗口的应用。
一、时间语义
时间本身就有着“流”的特性,它可以用来判断事件发生的先后以及间隔;所以如果我们想要划定窗口来收集数据,一般就需要基于时间。对于批处理来说,这似乎没什么讨论的必要,因为数据都收集好了,想怎么划分窗口都可以;而对于流处理来说,如果想处理更加实时,就必须对时间有更加精细的控制。
1、Flink中的时间语义
对于一台机器而言,“时间”自然就是指系统时间。但我们知道,Flink是一个分布式处理系统。分布式架构最大的特点,就是节点彼此独立、互不影响,这带来了更高的吞吐量和容错性;但有利必有弊,最大的问题也来源于此。
我们重新梳理一下流式数据处理的过程。在事件发生之后,生成的数据被收集起来,首先进入分布式消息队列,然后被Flink系统中的Source算子读取消费,进而向下游的转换算子(窗口算子)传递,最终由窗口算子进行计算处理。
很明显,这里有两个非常重要的时间点:一个是数据产生的时间,我们把它叫作“事件时间”(Event Time)
;另一个是数据真正被处理的时刻,叫作“处理时间”(Processing Time)
。我们所定义的窗口操作,到底是以那种时间作为衡量标准,就是所谓的“时间语义”(Notions of Time)。由于分布式系统中网络传输的延迟和时钟漂移,处理时间相对事件发生的时间会有所滞后。
1. 处理时间(Processing Time)
处理时间的概念非常简单,就是指执行处理操作的机器的系统时间。
2. 事件时间(Event Time)
事件时间,是指每个事件在对应的设备上发生的时间,也就是数据生成的时间。
数据一旦产生,这个时间自然就确定了,所以它可以作为一个属性嵌入到数据中。这其实就是这条数据记录的“时间戳”(Timestamp)。
3、问题
这里有个前提,就是“先产生的数据先被处理”,这要求我们可以保证数据到达的顺序。但是
由于分布式系统中网络传输延迟的不确定性,实际应用中我们要面对的数据流往往是乱序的
。在这种情况下,就不能简单地把数据自带的时间戳当作时钟了,而需要用另外的标志来表示事件时间进展,在Flink中把它叫作事件时间的“水位线”(Watermarks)
。
二、水位线
1、事件时间和窗口
在这个处理过程中,
我们其实是基于数据的时间戳,自定义了一个“逻辑时钟”
。这个时钟的时间不会自动流逝;它的时间进展,就是靠着新到数据的时间戳来推动的
。这样的好处在于,计算的过程可以完全不依赖处理时间(系统时间),不论什么时候进行统计处理,得到的结果都是正确的。比如双十一的时候系统处理压力大,我们可能会把大量数据缓存在Kafka中;过了高峰时段之后再读取出来,在几秒之内就可以处理完几个小时甚至几天的数据,而且依然可以按照数据产生的时间段进行统计,所有窗口都能收集到正确的数据。而一般实时流处理的场景中,事件时间可以基本与处理时间保持同步,只是略微有一点延迟,同时保证了窗口计算的正确性。
2、什么是水位线
具体实现上,水位线可以看作一条特殊的数据记录,它是插入到数据流中的一个标记点,主要内容就是一个时间戳,用来指示当前的事件时间。而它插入流中的位置,就应该是在某个数据到来之后;这样就可以从这个数据中提取时间戳,作为当前水位线的时间戳了。
每条数据后插入一个水位线
1)有序流中的水位线 乱序流中的水位线
在理想状态下,数据应该按照它们生成的先后顺序、排好队进入流中;也就是说,它们处理的过程会保持原先的顺序不变,遵守先来后到的原则
。这样的话我们从每个数据中提取时间戳,就可以保证总是从小到大增长的
,从而插入的水位线也会不断增长、事件时钟不断向前推进。
实际应用中,如果当前数据量非常大
,可能会有很多数据的时间戳是相同的,这时每来一条数据就提取时间戳、插入水位线就做了大量的无用功
。而且即使时间戳不同,同时涌来的数据时间差会非常小(比如几毫秒),往往对处理计算也没什么影响。所以为了提高效率,一般会每隔一段时间生成一个水位线,这个水位线的时间戳,就是当前最新数据的时间戳,如图6-6所示。所以这时的水位线,其实就是有序流中的一个周期性出现的时间标记。
有序流中周期性插入水位线
水位线插入的“周期”,本身也是一个时间概念
。在当前事件时间语义下,假如我们设定了每隔100ms生成一次水位线,那就是要等事件时钟推进100ms才能插入;但是事件时钟本身的进展,本身就是靠水位线来表示的——现在要插入一个水位线,可前提又是水位线要向前推进100ms,这就陷入了死循环。所以对于水位线的周期性生成,周期时间是指处理时间(系统时间),而不是事件时间
。
2)乱序流中的水位线
有序流的处理非常简单,看起来水位线也并没有起到太大的作用。但这种情况只存在于理想状态下。
我们知道在分布式系统中,数据在节点间传输,会因为网络传输延迟的不确定性,导致顺序发生改变,这就是所谓的“乱序数据”
。
这里所说的“乱序”(out-of-order),是指数据的先后顺序不一致,主要就是基于数据的产生时间而言的
。一个7秒时产生的数据,生成时间自然要比9秒的数据早;但是经过数据缓存和传输之后,处理任务可能先收到了9秒的数据,之后7秒的数据才姗姗来迟。这时如果我们希望插入水位线,来指示当前的事件时间进展,又该怎么做呢?
乱序流
最直观的想法自然是跟之前一样,
我们还是靠数据来驱动,每来一个数据就提取它的时间戳、插入一个水位线。不过现在的情况是数据乱序
,所以有可能新的时间戳比之前的还小,如果直接将这个时间的水位线再插入,我们的“时钟”就回退了——水位线就代表了时钟
,时光不能倒流,所以水位线的时间戳也不能减小。
解决思路也很简单:
我们插入新的水位线时,要先判断一下时间戳是否比之前的大,否则就不再生成新的水位线
,也就是说,只有数据的时间戳比当前时钟大,才能推动时钟前进,这时才插入水位线。
乱序流中的水位线
如果考虑到大量数据同时到来的处理效率,我们同样可以周期性地生成水位线。这时只需要保存一下之前所有数据中的最大时间戳,需要插入水位线时,就直接以它作为时间戳生成新的水位线,
乱序流中周期性生成水位线
这样做尽管可以定义出一个事件时钟,却也会带来一个非常大的问题:我们无法正确处理“迟到”的数据
回到上面的例子,为了让窗口能够正确收集到迟到的数据,我们也可以等上2秒;也就是用当前已有数据的最大时间戳减去2秒,就是要插入的水位线的时间戳,如图6-10所示。这样的话,9秒的数据到来之后,事件时钟不会直接推进到9秒,而是进展到了7秒;必须等到11秒的数据到来之后,事件时钟才会进展到9秒,这时迟到数据也都已收集齐,0~9秒的窗口就可以正确计算结果了。
乱序流中“等2秒”策略
如果仔细观察就会看到,这种“等2秒”的策略其实并不能处理所有的乱序数据
比如22秒的数据到来之后,插入的水位线时间戳为20,也就是当前时钟已经推进到了20秒;对于1020秒的窗口,这时就该关闭了。但是之后又会有17秒的迟到数据到来,它本来应该属于1020秒窗口,现在却被遗漏丢弃了。那又该怎么办呢?
既然现在等2秒还是等不到17秒产生的迟到数据,那自然我们可以试着多等几秒,也就是把时钟调得更慢一些。最终的目的,就是要让窗口能够把所有迟到数据都收进来,得到正确的计算结果。对应到水位线上,其实就是要保证,当前时间已经进展到了这个时间戳,在这之后不可能再有迟到数据来了。
下面是一个示例,我们可以使用周期性的方式生成正确的水位线。
乱序流中周期性生成正确的水位线
第一个水位线时间戳为7,它表示当前事件时间是7秒,7秒之前的数据都已经到齐,之后再也不会有了;同样,第二个、第三个水位线时间戳分别为12和20,表示11秒、20秒之前的数据都已经到齐,如果有对应的窗口就可以直接关闭了,统计的结果一定是正确的。这里由于水位线是周期性生成的,所以插入的位置不一定是在时间戳最大的数据后面。
另外需要注意的是,这里一个窗口所收集的数据,并不是之前所有已经到达的数据。因为数据属于哪个窗口,是由数据本身的时间戳决定的,一个窗口只会收集真正属于它的那些数据。也就是说,上图中尽管水位线W(20)之前有时间戳为22的数据到来,10~20秒的窗口中也不会收集这个数据,进行计算依然可以得到正确的结果。关于窗口的原理,我们会在后面继续展开讲解。
3) 水位线的特性
现在我们可以知道,水位线就代表了当前的事件时间时钟,而且可以在数据的时间戳基础上加一些延迟来保证不丢数据,这一点对于乱序流的正确处理非常重要。
我们可以总结一下水位线的特性:
1. 水位线是插入到数据流中的一个标记,可以认为是一个特殊的数据
2. 水位线主要的内容是一个时间戳,用来表示当前事件时间的进展
3. 水位线是基于数据的时间戳生成的
4. 水位线的时间戳必须单调递增,以确保任务的事件时间时钟一直向前推进
5.水位线可以通过设置延迟,来保证正确处理乱序数据
6. 一个水位线Watermark(t),表示在当前流中事件时间已经达到了时间戳t, 这代表t之前的所有数据都到齐了,
之后流中不会出现时间戳t’ ≤ t的数据水位线是Flink流处理中保证结果正确性的核心机制,它往往会跟窗口一起配合,
完成对乱序数据的正确处理。关于这部分内容,我们会稍后进一步展开讲解。
3、如何生成水位线
设置多久?
1) 生成水位线的总体原则
如果我们希望计算结果能更加准确
,那可以将水位线的延迟设置得更高
一些,等待的时间越长,自然也就越不容易漏掉数据。不过这样做的代价是处理的实时性降低了,我们可能为极少数的迟到数据增加了很多不必要的延迟
如果我们希望处理得更快
、实时性更强,那么可以将水位线延迟设得低一些
。这种情况下,可能很多迟到数据会在水位线之后才到达,就会导致窗口遗漏数据,计算结果不准确。对于这些 “漏网之鱼”,Flink另外提供了窗口处理迟到数据的方法,我们会在后面介绍。当然,如果我们对准确性完全不考虑、一味地追求处理速度,可以直接使用处理时间语义,这在理论上可以得到最低的延迟。
2) 水位线生成策略(Watermark Strategies)
3)Flink内置水位线生成器
WatermarkStrategy这个接口是一个生成水位线策略的抽象,让我们可以灵活地实现自己的需求;但看起来有些复杂,如果想要自己实现应该还是比较麻烦的。好在Flink充分考虑到了我们的痛苦,提供了内置的水位线生成器(WatermarkGenerator),不仅开箱即用简化了编程,而且也为我们自定义水位线策略提供了模板。
这两个生成器可以通过调用WatermarkStrategy的静态辅助方法来创建。它们都是周期性生成水位线的,分别对应着处理有序流和乱序流的场景。
1、有序流
对于有序流,主要特点就是时间戳单调增长(Monotonously Increasing Timestamps),所以永远不会出现迟到数据的问题。这是周期性生成水位线的最简单的场景,直接调用WatermarkStrategy.forMonotonousTimestamps()方法就可以实现。简单来说,就是直接拿当前最大的时间戳作为水位线就可以了
2、编码
package com.example.chapter06;
import com.example.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.TimestampAssignerSupplier;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import java.time.Duration;
/**
* Flink中的时间和窗口
* 水位线在代码中生成
*/
public class WatermarkTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4);
env.getConfig().setAutoWatermarkInterval(100); //去设置生成水位线的时间间隔
SingleOutputStreamOperator<Event> operator = env.fromElements(
new Event("Mary", "/home", 1000L), new Event("Silly", "/Persion", 2000L),
new Event("Alice", "/Persion?id=100", 3000L),
new Event("Bob", "/Persion?id=1", 4000L),
new Event("Bob", "/home", 5000L),
new Event("Alice", "/Persion?id=100", 3000L),
new Event("Bob", "/prod?id=2", 6000L))
//TODO 1 有序流的watermark生成 测试用
//WatermarkStrategy.<Event>forMonotonousTimestamps() Watermark 的生成器
//withTimestampAssigner 时间戳提取器
.assignTimestampsAndWatermarks(WatermarkStrategy.
<Event>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
})
);
operator.print();
env.execute();
}
}
3、说明
上面代码中我们调用.withTimestampAssigner()方法,将数据中的timestamp字段提取出来,作为时间戳分配给数据元素;然后用内置的有序流水位线生成器构造出了生成策略。这样,提取出的数据时间戳,就是我们处理计算的事件时间。
这里需要注意的是,时间戳和水位线的单位,必须都是毫秒。
2、乱序流
由于乱序流中需要等待迟到数据到齐,所以必须设置一个固定量的延迟时间(Fixed Amount of Lateness)。这时生成水位线的时间戳,就是当前数据流中最大的时间戳减去延迟的结果,相当于把表调慢,当前时钟会滞后于数据的最大时间戳。调用WatermarkStrategy. forBoundedOutOfOrderness()方法就可以实现。这个方法需要传入一个maxOutOfOrderness参数,表示“最大乱序程度”,它表示数据流中乱序数据时间戳的最大差值;如果我们能确定乱序程度,那么设置对应时间长度的延迟,就可以等到所有的乱序数据了。
1、编码
package com.example.chapter06;
import com.example.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.TimestampAssignerSupplier;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import java.time.Duration;
/**
* Flink中的时间和窗口
* 水位线在代码中生成
*/
public class WatermarkTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4);
env.getConfig().setAutoWatermarkInterval(100); //去设置生成水位线的时间间隔
SingleOutputStreamOperator<Event> operator = env.fromElements(
new Event("Mary", "/home", 1000L), new Event("Silly", "/Persion", 2000L),
new Event("Alice", "/Persion?id=100", 3000L),
new Event("Bob", "/Persion?id=1", 4000L),
new Event("Bob", "/home", 5000L),
new Event("Alice", "/Persion?id=100", 3000L),
new Event("Bob", "/prod?id=2", 6000L))
//TODO 2 乱序流的watermark生成 ----forBoundedOutOfOrderness
//插入水位线
.assignTimestampsAndWatermarks(WatermarkStrategy.
//针对乱序流插入水位线,延迟时间设置5-------------Duration.ofSeconds(2) 最大乱序时间
<Event>forBoundedOutOfOrderness(Duration.ofSeconds(2))
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
//抽取时间戳的逻辑
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
})
);
operator.print();
env.execute();
}
}
2、说明
上面代码中,我们同样提取了timestamp字段作为时间戳,并且以5秒的延迟时间创建了处理乱序流的水位线生成器。
事实上,有序流的水位线生成器本质上和乱序流是一样的,相当于延迟设为0的乱序流水位线生成器,两者完全等同:
WatermarkStrategy.forMonotonousTimestamps()
WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(0))
4、自定义水位线策略
一般来说,Flink内置的水位线生成器就可以满足应用需求了。不过有时我们的业务逻辑可能非常复杂,这时对水位线生成的逻辑也有更高的要求,我们就必须自定义实现水位线策略WatermarkStrategy了。
在WatermarkStrategy中,时间戳分配器TimestampAssigner都是大同小异的,指定字段提取时间戳就可以了;而不同策略的关键就在于WatermarkGenerator的实现。整体说来,Flink有两种不同的生成水位线的方式:一种是周期性的(Periodic),另一种是断点式的(Punctuated)。
还记得WatermarkGenerator接口中的两个方法吗?——onEvent()和onPeriodicEmit(),
前者是在每个事件到来时调用,而后者由框架周期性调用。周期性调用的方法中发出水位线,自然就是周期性生成水位线;而在事件触发的方法中发出水位线,自然就是断点式生成了。两种方式的不同就集中体现在这两个方法的实现上。
(1)周期性水位线生成器(Periodic Generator)
周期性生成器一般是通过onEvent()观察判断输入的事件,而在onPeriodicEmit()里发出水位线。
import org.apache.flink.api.common.eventtime.*;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
// 自定义水位线的产生
public class CustomWatermarkTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env
.addSource(new ClickSource())
.assignTimestampsAndWatermarks(new CustomWatermarkStrategy())
.print();
env.execute();
}
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 CustomPeriodicGenerator();
}
}
public static class CustomPeriodicGenerator implements 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));
}
}
}
我们在onPeriodicEmit()里调用output.emitWatermark(),就可以发出水位线了;这个方法由系统框架周期性地调用,默认200ms一次。所以水位线的时间戳是依赖当前已有数据的最大时间戳的(这里的实现与内置生成器类似,也是减去延迟时间再减1),但具体什么时候生成与数据无关。
(2)断点式水位线生成器(Punctuated Generator)
断点式生成器会不停地检测onEvent()中的事件,当发现带有水位线信息的特殊事件时,就立即发出水位线。一般来说,断点式生成器不会通过onPeriodicEmit()发出水位线。
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方法中发射了水位线
}
}
我们在onEvent()中判断当前事件的user字段,只有遇到“Mary”这个特殊的值时,才调用output.emitWatermark()发出水位线。这个过程是完全依靠事件来触发的,所以水位线的生成一定在某个数据到来之后。
5、 在自定义数据源中发送水位线
我们也可以在自定义的数据源中抽取事件时间,然后发送水位线。这里要注意的是,在自定义数据源中发送了水位线以后,就不能再在程序中使用assignTimestampsAndWatermarks方法来生成水位线了。在自定义数据源中生成水位线和在程序中使用assignTimestampsAndWatermarks方法生成水位线二者只能取其一
1、编码
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.source.SourceFunction;
import org.apache.flink.streaming.api.watermark.Watermark;
import java.util.Calendar;
import java.util.Random;
public class EmitWatermarkInSourceFunction {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.addSource(new ClickSourceWithWatermark()).print();
env.execute();
}
// 泛型是数据源中的类型
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;
}
}
}
在自定义水位线中生成水位线相比assignTimestampsAndWatermarks方法更加灵活,可以任意的产生周期性的、非周期性的水位线,以及水位线的大小也完全由我们自定义。所以非常适合用来编写Flink的测试程序,测试Flink的各种各样的特性。
6、水位线的传递
我们知道水位线是数据流中插入的一个标记,用来表示事件时间的进展,它会随着数据一起在任务间传递。如果只是直通式(forward)的传输,那很简单,数据和水位线都是按照本身的顺序依次传递、依次处理的;一旦水位线到达了算子任务, 那么这个任务就会将它内部的时钟设为这个水位线的时间戳。
1)画图
总结
1、前面有四个并行任务、下游三个并行任务、以单条发送出去2 、下游接受到 更新水位线、还是判断最小的水位线
2变成3了、就把自己的时钟更新成3、并把3下游广播出去、当发出去6的时候 3一样、不变就不用广播下去了、
我们可以用一个具体的例子,将水位线在任务间传递的过程完整梳理一遍。如图6-12所示,当前任务的上游,有四个并行子任务,所以会接收到来自四个分区的水位线;而下游有三个并行子任务,所以会向三个分区发出水位线。具体过程如下:
(1)上游并行子任务发来不同的水位线,当前任务会为每一个分区设置一个“分区水位线”(Partition Watermark),这是一个分区时钟;而当前任务自己的时钟,就是所有分区时钟里最小的那个。
(2)当有一个新的水位线(第一分区的4)从上游传来时,当前任务会首先更新对应的分区时钟;然后再次判断所有分区时钟中的最小值,如果比之前大,说明事件时间有了进展,当前任务的时钟也就可以更新了。这里要注意,更新后的任务时钟,并不一定是新来的那个分区水位线,比如这里改变的是第一分区的时钟,但最小的分区时钟是第三分区的3,于是当前任务时钟就推进到了3。当时钟有进展时,当前任务就会将自己的时钟以水位线的形式,广播给下游所有子任务。
(3)再次收到新的水位线(第二分区的7)后,执行同样的处理流程。首先将第二个分区时钟更新为7,然后比较所有分区时钟;发现最小值没有变化,那么当前任务的时钟也不变,也不会向下游任务发出水位线。
(4)同样道理,当又一次收到新的水位线(第三分区的6)之后,第三个分区时钟更新为6,同时所有分区时钟最小值变成了第一分区的4,所以当前任务的时钟推进到4,并发出时间戳为4的水位线,广播到下游各个分区任务。
水位线在上下游任务之间的传递,非常巧妙地避免了分布式系统中没有统一时钟的问题,每个任务都以“处理完之前所有数据”为标准来确定自己的时钟,就可以保证窗口处理的结果总是正确的。对于有多条流合并之后进行处理的场景,水位线传递的规则是类似的。关于Flink中的多流转换,我们会在后续章节中介绍。
2)水位线的总结
水位线在事件时间的世界里面,承担了时钟的角色。也就是说在事件时间的流中,水位线是唯一的时间尺度。如果想要知道现在几点,就要看水位线的大小。后面讲到的窗口的闭合,以及定时器的触发都要通过判断水位线的大小来决定是否触发。
水位线是一种特殊的事件,由程序员通过编程插入的数据流里面,然后跟随数据流向下游流动。
水位线的默认计算公式:水位线 = 观察到的最大事件时间 – 最大延迟时间 – 1毫秒。
所以这里涉及到一个问题,就是不同的算子看到的水位线的大小可能是不一样的。因为下游的算子可能并未接收到来自上游算子的水位线,导致下游算子的时钟要落后于上游算子的时钟。比如map->reduce这样的操作,如果在map中编写了非常耗时间的代码,将会阻塞水位线的向下传播,因为水位线也是数据流中的一个事件,位于水位线前面的数据如果没有处理完毕,那么水位线不可能弯道超车绕过前面的数据向下游传播,也就是说会被前面的数据阻塞。这样就会影响到下游算子的聚合计算,因为下游算子中无论由窗口聚合还是定时器的操作,都需要水位线才能触发执行。这也就告诉了我们,在编写Flink程序时,一定要谨慎的编写每一个算子的计算逻辑,尽量避免大量计算或者是大量的IO操作,这样才不会阻塞水位线的向下传递。
在数据流开始之前,Flink会插入一个大小是负无穷大(在Java中是-Long.MAX_VALUE)的水位线,而在数据流结束时,Flink会插入一个正无穷大(Long.MAX_VALUE)的水位线,保证所有的窗口闭合以及所有的定时器都被触发。
对于离线数据集,Flink也会将其作为流读入,也就是一条数据一条数据的读取。在这种情况下,Flink对于离线数据集,只会插入两次水位线,也就是在最开始处插入负无穷大的水位线,在结束位置插入一个正无穷大的水位线。因为只需要插入两次水位线,就可以保证计算的正确,无需在数据流的中间插入水位线了。
水位线的重要性在于它的逻辑时钟特性,而逻辑时钟这个概念可以说是分布式系统里面最为重要的概念之一了,理解透彻了对理解各种分布式系统非常有帮助。具体可以参考Leslie Lamport的论文。
三、窗口(Window)
1、 窗口的概念
Flink是一种流式计算引擎,主要是来处理无界数据流的,数据源源不断、无穷无尽。想要更加方便高效地处理无界流,一种方式就是将无限数据切割成有限的“数据块”进行处理,这就是所谓的“窗口”(Window)。
我们可以梳理一下事件时间语义下,之前例子中窗口的处理过程:
(1)第一个数据时间戳为2,判断之后创建第一个窗口[0, 10),并将2秒数据保存进去;
(2)后续数据依次到来,时间戳均在 [0, 10)范围内,所以全部保存进第一个窗口;
(3)11秒数据到来,判断它不属于[0, 10)窗口,所以创建第二个窗口[10, 20),并将11秒的数据保存进去。由于水位线设置延迟时间为2秒,所以现在的时钟是9秒,第一个窗口也没有到关闭时间;
(4)之后又有9秒数据到来,同样进入[0, 10)窗口中;
(5)12秒数据到来,判断属于[10, 20)窗口,保存进去。这时产生的水位线推进到了10秒,所以 [0, 10)窗口应该关闭了。第一个窗口收集到了所有的7个数据,进行处理计算后输出结果,并将窗口关闭销毁;
(6)同样的,之后的数据依次进入第二个窗口,遇到20秒的数据时会创建第三个窗口[20, 30)并将数据保存进去;遇到22秒数据时,水位线达到了20秒,第二个窗口触发计算,输出结果并关闭。
这里需要注意的是,Flink中窗口并不是静态准备好的,而是动态创建——当有落在这个窗口区间范围的数据达到时,才创建对应的窗口
。另外,这里我们认为到达窗口结束时间时,窗口就触发计算并关闭,事实上“触发计算”和“窗口关闭”两个行为也可以分开,这部分内容我们会在后面详述。
2、窗口分类
1. 按照驱动类型分类
我们最容易想到的就是按照时间段去截取数据,这种窗口就叫作“时间窗口”(Time Window)。这在实际应用中最常见,之前所举的例子也都是时间窗口
时间窗口与计数窗口
(1)时间窗口(Time Window)
时间窗口以时间点来定义窗口的开始(start)和结束(end),所以截取出的就是某一时间段的数据。到达结束时间时,窗口不再收集数据,触发计算输出结果,并将窗口关闭销毁。所以可以说基本思路就是“定点发车”。
用结束时间减去开始时间,得到这段时间的长度,就是窗口的大小(window size)。这里的时间可以是不同的语义,所以我们可以定义处理时间窗口和事件时间窗口。
Flink中有一个专门的类来表示时间窗口,名称就叫作TimeWindow。这个类只有两个私有属性:start和end,表示窗口的开始和结束的时间戳,单位为毫秒。
上多少人算多少人、
(2)计数窗口(Count Window)
计数窗口基于元素的个数来截取数据,到达固定的个数时就触发计算并关闭窗口。这相当于座位有限、“人满就发车”,是否发车与时间无关。每个窗口截取数据的个数,就是窗口的大小。
计数窗口相比时间窗口就更加简单,我们只需指定窗口大小,就可以把数据分配到对应的窗口中了。在Flink内部也并没有对应的类来表示计数窗口,底层是通过“全局窗口”(Global Window)来实现的。关于全局窗口,我们稍后讲解。
3、按照窗口分配数据的规则分类
时间窗口和计数窗口,只是对窗口的一个大致划分;在具体应用时,还需要定义更加精细的规则,来控制数据应该划分到哪个窗口中去。不同的分配数据的方式,就可以有不同的功能应用。
根据分配数据的规则,窗口的具体实现可以分为4类:滚动窗口(Tumbling Window)、滑动窗口(Sliding Window)、会话窗口(Session Window),以及全局窗口(Global Window)。下面我们来做具体介绍。
(1)滚动窗口(Tumbling Windows)
滚动窗口有固定的大小,是一种对数据进行“均匀切片”的划分方式。窗口之间没有重叠,也不会有间隔,是“首尾相接”的状态
。如果我们把多个窗口的创建,看作一个窗口的运动,那就好像它在不停地向前“翻滚”一样。这是最简单的窗口形式,我们之前所举的例子都是滚动窗口。也正是因为滚动窗口是“无缝衔接”,所以每个数据都会被分配到一个窗口,而且只会属于一个窗口。
滚动窗口可以基于时间定义,也可以基于数据个数定义
;需要的参数只有一个,就是窗口的大小(window size)。比如我们可以定义一个长度为1小时的滚动时间窗口,那么每个小时就会进行一次统计;或者定义一个长度为10的滚动计数窗口,就会每10个数进行一次统计
。
小圆点表示流中的数据,我们对数据按照userId做了分区。当固定了窗口大小之后,所有分区的窗口划分都是一致的;窗口没有重叠,每个数据只属于一个窗口。
滚动窗口应用非常广泛,它可以对每个时间段做聚合统计,很多BI分析指标都可以用它来实现。
(2)滑动窗口(Sliding Windows)
与滚动窗口类似,滑动窗口的大小也是固定的。区别在于,窗口之间并不是首尾相接的,而是可以“错开”一定的位置。如果看作一个窗口的运动,那么就像是向前小步“滑动”一样。
既然是向前滑动,那么每一步滑多远,就也是可以控制的。所以定义滑动窗口的参数有两个:除去窗口大小(window size)之外,还有一个“滑动步长”(window slide),它其实就代表了窗口计算的频率。滑动的距离代表了下个窗口开始的时间间隔,而窗口大小是固定的,所以也就是两个窗口结束时间的间隔;窗口在结束时间触发计算输出结果,那么滑动步长就代表了计算频率。例如,我们定义一个长度为1小时、滑动步长为5分钟的滑动窗口,那么就会统计1小时内的数据,每5分钟统计一次。同样,滑动窗口可以基于时间定义,也可以基于数据个数定义。
我们可以看到,当滑动步长小于窗口大小时,滑动窗口就会出现重叠,这时数据也可能会被同时分配到多个窗口中。而具体的个数,就由窗口大小和滑动步长的比值(size/slide)来决定。滑动步长刚好是窗口大小的一半,那么每个数据都会被分配到2个窗口里。比如我们定义的窗口长度为1小时、滑动步长为30分钟,那么对于8点55分的数据,应该同时属于[8点, 9点)和[8点半, 9点半)两个窗口;而对于8点10分的数据,则同时属于[8点, 9点)和[7点半, 8点半)两个窗口。
所以,
滑动窗口其实是固定大小窗口的更广义的一种形式
;换句话说,滚动窗口也可以看作是一种特殊的滑动窗口——窗口大小等于滑动步长(size = slide)。当然,我们也可以定义滑动步长大于窗口大小,这样的话就会出现窗口不重叠、但会有间隔的情况;这时有些数据不属于任何一个窗口,就会出现遗漏统计。所以一般情况下,我们会让滑动步长小于窗口大小,并尽量设置为整数倍的关系
。
所以,
滑动窗口其实是固定大小窗口的更广义的一种形式;换句话说,滚动窗口也可以看作是一种特殊的滑动窗口——窗口大小等于滑动步长(size = slide)
。当然,我们也可以定义滑动步长大于窗口大小,这样的话就会出现窗口不重叠、但会有间隔的情况;这时有些数据不属于任何一个窗口,就会出现遗漏统计。所以一般情况下,我们会让滑动步长小于窗口大小,并尽量设置为整数倍的关系。
在一些场景中
,可能需要统计最近一段时间内的指标,而结果的输出频率要求又很高,甚至要求实时更新
,比如股票价格的24小时涨跌幅统计, 或者基于一段时间内行为检测的异常报警 。这时滑动窗口
无疑就是很好的实现方式。
(3)会话窗口(Session Windows)
简单来说,
就是数据来了之后就开启一个会话窗口,如果接下来还有数据陆续到来,那么就一直保持会话;如果一段时间一直没收到数据,那就认为会话超时失效,窗口自动关闭
。这就好像我们打电话一样,如果时不时总能说点什么,那说明还没聊完;如果陷入了尴尬的沉默,半天都没话说,那自然就可以挂电话了。
与滑动窗口和滚动窗口不同,
会话窗口只能基于时间来定义
,而没有“会话计数窗口”的概念。这很好理解,“会话”终止的标志就是“隔一段时间没有数据来”
,如果不依赖时间而改成个数,就成了“隔几个数据没有数据来”,这完全是自相矛盾的说法。
最重要的参数就是这段时间的长度(size),它表示会话的超时时间,也就是两个会话窗口之间的最小距离。如果相邻两个
数据到来的时间间隔(Gap)小于指定的大小(size)
,那说明还在保持会话,它们就属于同一个窗口;如果gap大于size,那么新来的数据就应该属于新的会话窗口,而前一个窗口就应该关闭了。在具体实现上,我们可以设置静态固定的大小(size),也可以通过一个自定义的提取器(gap extractor)动态提取最小间隔gap的值。
(4)全局窗口(Global Windows)
还有一类比较通用的窗口,就是“全局窗口”
。这种窗口全局有效,会把相同key的所有数据都分配到同一个窗口中;说直白一点,就跟没分窗口一样。无界流的数据永无止尽,所以这种窗口也没有结束的时候,默认是不会做触发计算的。如果希望它能对数据进行计算处理,还需要自定义“触发器”(Trigger)。关于触发器,
可以看到,全局窗口没有结束的时间点,所以一般在希望做更加灵活的窗口处理时自定义使用。Flink中的计数窗口(Count Window),底层就是用全局窗口实现的。
3、窗口API概述
1. 按键分区(Keyed)和非按键分区(Non-Keyed)
在定义窗口操作之前,首先需要确定,到底是基于按键分区(Keyed)的数据流KeyedStream来开窗,还是直接在没有按键分区的DataStream上开窗。也就是说,在调用窗口算子之前,是否有keyBy操作。
(1)按键分区窗口(Keyed Windows)
推荐-先做keyBy在开窗
经过按键分区keyBy操作后,数据流会按照key被分为多条逻辑流(logical streams),这就是KeyedStream。基于KeyedStream进行窗口操作时, 窗口计算会在多个并行子任务上同时执行。相同key的数据会被发送到同一个并行子任务,而窗口操作会基于每个key进行单独的处理。所以可以认为,每个key上都定义了一组窗口,各自独立地进行统计计算。
在代码实现上,我们需要先对DataStream调用.keyBy()进行按键分区,然后再调用.window()定义窗口。
stream.keyBy(...)
.window(...)
(2)非按键分区(Non-Keyed Windows)
如果没有进行keyBy,那么原始的DataStream就不会分成多条逻辑流。这时窗口逻辑只能在一个任务(task)上执行,就相当于并行度变成了1。所以在实际应用中一般不推荐使用这种方式。
在代码中,直接基于DataStream调用.windowAll()定义窗口
使用 windowAll 把当前的并行度强制变为一、所以不推荐这样用
stream.windowAll(...)
2. 代码中窗口API的调用
有了前置的基础,接下来我们就可以真正在代码中实现一个窗口操作了。简单来说,窗口操作主要有两个部分:
窗口分配器(Window Assigners
)和窗口函数(Window Functions)。
.window() 窗口分配器、指定当前的数据分配到那个窗口去
.aggregate() 计算操作
stream.keyBy(<key selector>)
.window(<window assigner>)
.aggregate(<window function>)
其中.window()方法需要传入一个窗口分配器,它指明了窗口的类型;而后面的.aggregate()方法传入一个窗口函数作为参数,它用来定义窗口具体的处理逻辑。窗口分配器有各种形式,而窗口函数的调用方法也不只.aggregate()一种,我们接下来就详细展开讲解。
另外,在实际应用中,一般都需要并行执行任务,非按键分区很少用到,所以我们之后都以按键分区窗口为例;如果想要实现非按键分区窗口,只要前面不做keyBy,后面调用.window()时直接换成.windowAll()就可以了
4、窗口分配器(Window Assigners)
窗口分配器最通用的定义方式,
就是调用.window()方法。这个方法需要传入一个WindowAssigner作为参数,返回WindowedStream
。如果是非按键分区窗口,那么直接调用.windowAll()方法,同样传入一个WindowAssigner,返回的是AllWindowedStream。
1. 时间窗口
时间窗口是最常用的窗口类型,又可以细分为滚动、滑动和会话三种
在较早的版本中,可以直接调用.timeWindow()来定义时间窗口;这种方式非常简洁
,但使用事件时间语义时需要另外声明,程序员往往因为忘记这点而导致运行结果错误
。所以在1.12版本之后,这种方式已经被弃用了
,标准的声明方式就是直接调用.window(),在里面传入对应时间语义下的窗口分配器
。这样一来,我们不需要专门定义时间语义,默认就是事件时间;如果想用处理时间,那么在这里传入处理时间的窗口分配器就可以了。
(1)滚动事件时间窗口
窗口分配器由类TumblingEventTimeWindows提供,用法与滚动处理事件窗口完全一致。
.keyBy(data -> data.f0)
.window(TumblingEventTimeWindows.of(Time.seconds(10))) //TODO 滚动事件时间窗口 1小时
这里.of()方法也可以传入第二个参数offset,用于设置窗口起始点的偏移量。
(2)滑动事件时间窗口
窗口分配器由类SlidingEventTimeWindows提供,用法与滑动处理事件窗口完全一致。
.keyBy(data -> data.f0)
.window(SlidingEventTimeWindows.of(Time.hours(1), Time.minutes(5))); //TODO 滑动事件时间窗口---当前的大小(1小时) 滑动步长(5分钟滑一次)
(3)事件时间会话窗口
窗口分配器由类EventTimeSessionWindows提供,用法与处理事件会话窗口完全一致。
stream.keyBy(...)
.window(EventTimeSessionWindows.withGap(Time.seconds(2))) //TODO 事件时间会话窗口 两秒
.aggregate(...)
2、计数窗口
(1)滚动计数窗口
滚动计数窗口只需要传入一个长整型的参数size,表示窗口的大小。
stream.keyBy(...)
.countWindow(10)
我们定义了一个长度为10的滚动计数窗口,当窗口中元素数量达到10的时候,就会触发计算执行并关闭窗口。
(2)滑动计数窗口
与滚动计数窗口类似,不过需要在.countWindow()调用时传入两个参数:size和slide,前者表示窗口大小,后者表示滑动步长。
tream.keyBy(...)
.countWindow(10,3)
3、 全局窗口
全局窗口是计数窗口的底层实现,一般在需要自定义窗口时使用。它的定义同样是直接调用.window(),分配器由GlobalWindows类提供。
stream.keyBy(...)
.window(GlobalWindows.create());
需要注意使用全局窗口,必须自行定义触发器才能实现窗口计算,否则起不到任何作用。
5、窗口函数
定义了窗口分配器,我们只是知道了数据属于哪个窗口,可以将数据收集起来了;至于收集起来到底要做什么,其实还完全没有头绪。所以在窗口分配器之后,
必须再接上一个定义窗口如何进行计算的操作,这就是所谓的“窗口函数”(window functions)。
经窗口分配器处理之后,数据可以分配到对应的窗口中,而数据流经过转换得到的数据类型是WindowedStream。这个类型并不是DataStream,所以并不能直接进行其他转换,而必须进一步调用窗口函数,
对收集到的数据进行处理计算之后,才能最终再次得到DataStream
1. 增量聚合函数(incremental aggregation functions)
窗口将数据收集起来,最基本的处理操作当然就是进行聚合。我们可以每来一个数据就在之前结果上聚合一次,这就是“增量聚合”。
典型的增量聚合函数有两个:ReduceFunction和AggregateFunction。
(1)归约函数(ReduceFunction)
最基本的聚合方式就是归约(reduce)。聚合算子中介绍过reduce的用法,
窗口的归约聚合也非常类似,就是将窗口中收集到的数据两两进行归约当我们进行流处理时,就是要保存一个状态;每来一个新的数据,就和之前的聚合状态做归约,这样就实现了增量式的聚合。
窗口函数中也提供了ReduceFunction:
只要基于WindowedStream调用.reduce()方法,然后传入ReduceFunction作为参数,就可以指定以归约两个元素的方式去对窗口中数据进行聚合了
ReduceFunction中需要重写一个reduce方法
,它的两个参数代表输入的两个元素,而归约最终输出结果的数据类型,与输入的数据类型必须保持一致
。也就是说,中间聚合的状态和输出的结果,都和输入的数据类型是一样的。
package com.example.chapter06;
import com.example.chapter05.ClickSource;
import com.example.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.functions.ReduceFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import java.time.Duration;
/**
* 窗口reduce
* 归约函数(ReduceFunction)
*/
public class WindowsTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4);
env.getConfig().setAutoWatermarkInterval(100); //去设置生成水位线的时间间隔
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
//TODO 乱序流的watermark生成 ----forBoundedOutOfOrderness
//插入水位线
.assignTimestampsAndWatermarks(WatermarkStrategy.
//针对乱序流插入水位线,延迟时间设置5
<Event>forBoundedOutOfOrderness(Duration.ZERO)
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
//抽取时间戳的逻辑
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
})
);
stream.map(new MapFunction<Event, Tuple2<String, Long>>() {
@Override
public Tuple2<String, Long> map(Event value) throws Exception {
return Tuple2.of(value.user, 1L);
}
})
.keyBy(data -> data.f0)
// windos-窗口 SlidingEventTimeWindows -滑动的类型
// .window(SlidingEventTimeWindows.of(Time.hours(1), Time.minutes(5))); //TODO 滑动事件时间窗口---当前的大小(1小时) 滑动步长(5分钟滑一次)
.window(TumblingEventTimeWindows.of(Time.seconds(10))) //TODO 滚动事件时间窗口 1小时
// .window(EventTimeSessionWindows.withGap(Time.seconds(2))) //TODO 事件时间会话窗口 两秒
// .countWindow(10,2)//TODO 滑动计数窗口 10个数统计一次、每隔两个数滑动一次 1个参数滚动、2个参数滑动
.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);
}
}).print();
env.execute();
}
}
(2) 聚合函数(AggregateFunction)
ReduceFunction 可以解决大多数归约聚合的问题,
但是这个接口有一个限制,就是聚合状 态的类型、输出结果的类型都必须和输入数据类型一样
。这就迫使我们必须在聚合前,先将数据转换(map)成预期结果类型
使用 ReduceFunction 就会非常麻烦。
如果我们希望计算一组数据的平均值,应该怎样做聚合呢?很明显,这时我们需要 计算两个状态量:数据的总和(sum),以及数据的个数(count),而最终输出结果是两者的商 (sum/count)。
如果用 ReduceFunction,那么我们应该先把数据转换成二元组(sum, count)的形
式,然后进行归约聚合,最后再将元组的两个元素相除转换得到最后的平均值。本来应该只是 一个任务,可我们却需要 map-reduce-map 三步操作,这显然不够高效。
package com.example.chapter06;
import com.example.chapter05.ClickSource;
import com.example.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.AggregateFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import java.sql.Timestamp;
import java.time.Duration;
/**
* 增量聚合函数
* AggregateTest
*/
public class WindowAggregateTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4);
env.getConfig().setAutoWatermarkInterval(100); //去设置生成水位线的时间间隔
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
//TODO 乱序流的watermark生成 ----forBoundedOutOfOrderness
//插入水位线
.assignTimestampsAndWatermarks(WatermarkStrategy.
//针对乱序流插入水位线,延迟时间设置5
<Event>forBoundedOutOfOrderness(Duration.ZERO).withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
//抽取时间戳的逻辑
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
}));
stream.keyBy(ada -> ada.user).window(TumblingEventTimeWindows.of(Time.seconds(10)))
/**
* String 最后输出
* Tuple2<Long, Integer> 保存中间聚合状态
* Event 输入数据
*/
.aggregate(new AggregateFunction<Event, Tuple2<Long, Integer>, String>() {
//创建累加器
@Override
public Tuple2<Long, Integer> createAccumulator() {
return Tuple2.of(0L, 0);
}
/**
* 返回的类型
* value 每来一次就做一个累加
* accumulator 状态
* @param value
* @param accumulator
* Tuple2<Long, Integer> (Long)和,(Integer)个数
* @return
*/
@Override
public Tuple2<Long, Integer> add(Event value, Tuple2<Long, Integer> accumulator) {
return Tuple2.of(accumulator.f0 + value.timestamp, accumulator.f1 + 1);
}
/**
* 输出结果的类型
* 窗口出触发计算到底要什么结果、触发一次计算、就调用一次
* @param accumulator
* @return
*/
@Override
public String getResult(Tuple2<Long, Integer> accumulator) {
Timestamp timestamp = new Timestamp(accumulator.f0 / accumulator.f1);
return timestamp.toString();
}
//会话窗口(合并)和+和 个+个
@Override
public Tuple2<Long, Integer> merge(Tuple2<Long, Integer> a, Tuple2<Long, Integer> b) {
return Tuple2.of(a.f0 + b.f0, a.f1 + b.f1);
}
}).print();
env.execute();
}
}
1、说明
AggregateFunction 可以看作是 ReduceFunction 的通用版本
这里有三种类型:
输入类型(IN)
、累加器类型(ACC)
和输出类型(OUT)
。输入类型 IN 就是输入流中元素的数据类型;累加器类型 ACC 则是我们进行聚合的中间状态类型;而输出类型当然就是最终计算结果的类
型了。
2、接口中有四个方法
⚫ createAccumulator():
创建一个累加器,这就是为聚合创建了一个初始状态,每个聚
合任务只会调用一次。
⚫ add():
将输入的元素添加到累加器中。这就是基于聚合状态,对新来的数据进行进
一步聚合的过程。方法传入两个参数:当前新到的数据 value,和当前的累加器
accumulator;
返回一个新的累加器值,也就是对聚合状态进行更新。每条数据到来之
后都会调用这个方法。
⚫ getResult():
从累加器中提取聚合的输出结果。也就是说,我们可以定义多个状态,
然后再基于这些聚合的状态计算出一个结果进行输出。比如之前我们提到的计算平均
值,就可以把 sum 和 count 作为状态放入累加器,而在调用这个方法时相除得到最终
结果。这个方法只在窗口要输出结果时调用。
⚫ merge():
合并两个累加器,并将合并后的状态作为一个累加器返回。这个方法只在
需要合并窗口的场景下才会被调用;最常见的合并窗口(Merging Window)的场景
就是会话窗口(Session Windows)
3、工作原理
AggregateFunction 的工作原理是:
首先调用 createAccumulator()为任务初
始化一个状态(累加器);而后每来一个数据就调用一次 add()方法,对数据进行聚合,得到的
结果保存在状态中;等到了窗口需要输出时,再调用 getResult()方法得到计算结果。很明显,
与 ReduceFunction 相同,AggregateFunction 也是增量式的聚合;而由于输入、中间状态、输
出的类型可以不同,使得应用更加灵活方便。
4、实战
在电商网站中,PV(页面浏览量)和 UV(独立访客
数)是非常重要的两个流量指标。一般来说,PV 统计的是所有的点击量;而对用户 id 进行去
重之后,得到的就是 UV。所以有时我们会用 PV/UV 这个比值,来表示“人均重复访问量”,
也就是平均每个用户会访问多少次页面,这在一定程度上代表了用户的粘度。
package com.example.chapter06;
import com.example.chapter05.ClickSource;
import com.example.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.AggregateFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.SlidingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import java.time.Duration;
import java.util.HashSet;
/**
* 统计pv和uv,两者相除得到
* <p>
* TODO pv不排除重复的点击和重复的访问 所有的数据
* <p>
* TODO uv访问的用户的个数、那么一除、平均每个用户访问的次数
*/
public class WindowAggregateTest_PvUV {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.getConfig().setAutoWatermarkInterval(100); //去设置生成水位线的时间间隔
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
//TODO 乱序流的watermark生成 ----forBoundedOutOfOrderness
//插入水位线
.assignTimestampsAndWatermarks(WatermarkStrategy.
//针对乱序流插入水位线,延迟时间设置5
<Event>forBoundedOutOfOrderness(Duration.ZERO)
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
//抽取时间戳的逻辑
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
})
);
stream.print("data");
//指定为true、所有的数据同一组
stream.keyBy(data -> true)
//滚动话10秒滚动一次
//滑动的话0-10 一个窗口 (滑动两秒 2-12 在下个窗口 4 -14
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(2)))
.aggregate(new AvgPv()).print();
env.execute();
}
/**
* 自定义一个AggregateFunction
* 用Long 保存pv个数,用HashSet做uv去重
*/
public static class AvgPv implements AggregateFunction<Event, Tuple2<Long, HashSet<String>>, Double> {
//初始化
@Override
public Tuple2<Long, HashSet<String>> createAccumulator() {
return Tuple2.of(0L, new HashSet<>());
}
@Override
public Tuple2<Long, HashSet<String>> add(Event value, Tuple2<Long, HashSet<String>> accumulator) {
//每来一条数据、pv个数加1,将user放入HashSet中
accumulator.f1.add(value.user);
return Tuple2.of(accumulator.f0 + 1, accumulator.f1);
}
@Override
public Double getResult(Tuple2<Long, HashSet<String>> accumulator) {
//窗口触发时,输出pv和uv的对比值
return (double) accumulator.f0 / accumulator.f1.size();
}
@Override
public Tuple2<Long, HashSet<String>> merge(Tuple2<Long, HashSet<String>> a, Tuple2<Long, HashSet<String>> b) {
return null;
}
}
}
6、全窗口函数
增量聚合
如果流的处理思路、来一个就聚合一个
全窗口函数
彻底的批处、来的数据先存储起来、等到计算的时候、统一安排
有些时候需要所有的数据才能得到的结果
flink提供的一种方式、你可以把所有的数据纂起了、才处理、全窗口函数
1、窗口函数(full window functions)弃用
WindowFunction 字面上就是“窗口函数”,它其实是老版本的通用窗口函数接口。我们可以基于 WindowedStream 调用.apply()方法,传入一个 WindowFunction 的实现类。
stream
.keyBy(<key selector>)
.window(<window assigner>)
.apply(new MyWindowFunction());
这个类中可以获取到包含窗口所有数据的可迭代集合(Iterable),还可以拿到窗口(Window)本身的信息。WindowFunction 接口在源码中实现如下
public interface WindowFunction<IN, OUT, KEY, W extends Window> extends Function, Serializable {
void apply(KEY key, W window, Iterable<IN> input, Collector<OUT> out) throws
Exception;
}
当窗口到达结束时间需要触发计算时,就会调用这里的 apply 方法。我们可以从 input 集合中取出窗口收集的数据,结合 key 和 window 信息,通过收集器(Collector)输出结果。这里 Collector 的用法,与 FlatMapFunction 中相同。
不过我们也看到了,WindowFunction 能提供的上下文信息较少,也没有更高级的功能。事实上,它的作用可以被 ProcessWindowFunction 全覆盖,所以之后可能会逐渐弃用。一般在实际应用,直接使用 ProcessWindowFunction 就可以了。
2、处理窗口函数(ProcessWindowFunction)
ProcessWindowFunction 是 Window API 中最底层的通用窗口函数接口
。之所以说它“最底层”,是因为除了可以拿到窗口中的所有数据之外,ProcessWindowFunction 还可以获取到一个“上下文对象”(Context)。这个上下文对象非常强大,不仅能够获取窗口信息,还可以访问当 前的时间和状态信息。
这里的时间就包括了处理时间(processing time)和事件时间水位线(event time watermark)。这就使得 ProcessWindowFunction 更加灵活
、功能更加丰富
当 然 ,
这 些 好 处 是 以 牺 牲 性 能 和 资 源 为 代 价 的
。 作 为 一 个 全 窗 口 函 数 ,ProcessWindowFunction 同样需要将所有数据缓存下来、等到窗口触发计算时才使用。它其实就是一个增强版的 WindowFunction。
具体使用跟 WindowFunction 非常类似,我们可以基WindowedStream 调用.process()方法,传入一个 ProcessWindowFunction 的实现类。
下面是一个电商网站统计每小时 UV 的例子:
(1) 编码
package com.example.chapter06;
import com.example.chapter05.ClickSource;
import com.example.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
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.windows.TimeWindow;
import org.apache.flink.util.Collector;
import java.sql.Timestamp;
import java.time.Duration;
import java.util.HashSet;
/**
* ProcessWindowsFunction 主要用这个个
* 处理窗口函数(ProcessWindowFunction)
*/
public class WindowProcessTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.getConfig().setAutoWatermarkInterval(100); //去设置生成水位线的时间间隔
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
//TODO 乱序流的watermark生成 ----forBoundedOutOfOrderness
//插入水位线
.assignTimestampsAndWatermarks(WatermarkStrategy.
//针对乱序流插入水位线,延迟时间设置5
<Event>forBoundedOutOfOrderness(Duration.ZERO)
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
//抽取时间戳的逻辑
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
})
);
stream.print("input");
stream.keyBy(data -> true)
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.process(new UvCountByWindows()).print();
env.execute();
}
//实现自定义的ProcessWindowFunction()
// INPUT-> Event
// OUTPUT-> String
// KEY-> String
public static class UvCountByWindows extends ProcessWindowFunction<Event, String, Boolean, TimeWindow> {
/**
* Collector<String> out 输出
* Iterable<Event> elements 输入
* @param aBoolean
* @param context
* @param elements
* @param out
* @throws Exception
*/
@Override
public void process(Boolean aBoolean, ProcessWindowFunction<Event, String, Boolean, TimeWindow>.Context context, Iterable<Event> elements, Collector<String> out) throws Exception {
//用一个HashSet 保存user
HashSet<String> userSet = new HashSet<>();
//从element中遍历数据,放到Set中去重
for (Event event : elements) {
userSet.add(event.user);
}
Integer uv = userSet.size();
//结合窗口信息
long start = context.window().getStart();
long end = context.window().getEnd();
out.collect("窗口 " + new Timestamp(start) + " ~ " + new Timestamp(end)
+ "UV值为:" + uv
);
}
}
}
(2) 总结
这里我们使用的是事件时间语义。
定义 10 秒钟的滚动事件窗口后,直接使用ProcessWindowFunction 来定义处理的逻辑
。我们可以创建一个 HashSet,将窗口所有数据的userId 写入实现去重,最终得到 HashSet 的元素个数就是 UV 值。
当 然 ,
这 里 我 们 并 没 有 用 到 上 下 文 中 其 他 信 息 , 所 以 其 实 没 有 必 要 使 用ProcessWindowFunction
。全窗口函数因为运行效率较低
,很少直接单独使用
,往往会和增量聚合函数结合在一起
,共同实现窗口的处理计算。
3、增量聚合和全窗口函数的结合使用
package com.example.chapter06;
import com.example.chapter05.ClickSource;
import com.example.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.AggregateFunction;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
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.windows.TimeWindow;
import org.apache.flink.util.Collector;
import java.sql.Timestamp;
import java.time.Duration;
import java.util.HashSet;
/**
* 两种窗口结合
* ProcessWindowFunction
* AggregateFunction
* 增量聚合和全窗口函数的结合使用
*/
public class UvCountExample {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.getConfig().setAutoWatermarkInterval(100); //去设置生成水位线的时间间隔
SingleOutputStreamOperator<Event> stream = env.addSource(new ClickSource())
//TODO 乱序流的watermark生成 ----forBoundedOutOfOrderness
//插入水位线
.assignTimestampsAndWatermarks(WatermarkStrategy.
//针对乱序流插入水位线,延迟时间设置5
<Event>forBoundedOutOfOrderness(Duration.ZERO)
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
//抽取时间戳的逻辑
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
})
);
stream.print("input");
stream.keyBy(data -> true)
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.aggregate(new UvAgg(), new UvCountResult()).print();
env.execute();
}
//自定义实现AggregateFunction,增量聚合计算uv值
public static class UvAgg implements AggregateFunction<Event, HashSet<String>, Long> {
@Override
public HashSet<String> createAccumulator() {
return new HashSet<>();
}
@Override
public HashSet<String> add(Event value, HashSet<String> accumulator) {
// 每次来添加进行
accumulator.add(value.user);
return accumulator;
}
@Override
public Long getResult(HashSet<String> accumulator) {
return (long) accumulator.size();
}
@Override
public HashSet<String> merge(HashSet<String> a, HashSet<String> b) {
return null;
}
}
//自定义实现ProcessWindowFunction,包装窗口信息输出
//接收统计出来的Uv值 Long
//包装 String
//当前key的类型 Boolean
//窗口类型 TimeWindow
public static class UvCountResult extends ProcessWindowFunction<Long, String, Boolean, TimeWindow> {
@Override
public void process(Boolean aBoolean, ProcessWindowFunction<Long, String, Boolean, TimeWindow>.Context context, Iterable<Long> elements, Collector<String> out) throws Exception {
long start = context.window().getStart();
long end = context.window().getEnd();
//直接取一个 获取的
Long uv = elements.iterator().next();
out.collect("窗口 " + new Timestamp(start) + " ~ " + new Timestamp(end)
+ "UV值为:" + uv
);
}
}
}
7、其他API
1、触发器(Trigger)
主要控制窗口到底什么时间触发计算、只收数据、一直等着是不往下面发送的数据的
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的定义告诉我们,两者可以分开。稍后我们就会看到它们分开操作的场景。
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的类型与流中数据类型相同。
1、编码
package com.example.chapter06;
import com.example.chapter05.ClickSource;
import com.example.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.util.OutputTag;
import java.time.Duration;
/**
* 将迟到数据放入窗口侧输出流
*/
public class LateDataTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.getConfig().setAutoWatermarkInterval(100); //去设置生成水位线的时间间隔
SingleOutputStreamOperator<Event> stream = env.socketTextStream("localhost", 7777).map(
new MapFunction<String, Event>() {
@Override
public Event map(String value) throws Exception {
String[] fields = value.split(",");
//解析文本流生成Event对象
return new Event(fields[0].trim(),
fields[1].trim(),
Long.valueOf(fields[2].trim()));
}
}) //插入水位线
.assignTimestampsAndWatermarks(WatermarkStrategy.
//针对乱序流插入水位线,延迟时间设置5
<Event>forBoundedOutOfOrderness(Duration.ofSeconds(2))
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
//抽取时间戳的逻辑
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
}));
stream.print("input");
/**
* 侧输出流的标签、对flink流的提取过程当中、他的泛型会被擦除掉
* 以匿名内部类的方式把 侧输出流 定义出来、这样方便flink获取内部的信息
*
* 0-10秒窗口
* 延迟一分钟到多少秒后才真正的意义关掉 10秒 +60秒
*
*
*/
OutputTag tag = new OutputTag<Event>("late"){};
SingleOutputStreamOperator<UrlViewCount> result = stream.keyBy(data -> data.url)
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.allowedLateness(Time.minutes(1)) //延迟1分钟拿到数据
.sideOutputLateData(tag)
.aggregate(new UrlCountViewExample.UrlViewCountAgg(),
new UrlCountViewExample.UrlViewCountResult());
result.print("result");
result.getSideOutput(tag).print("late");
env.execute();
}
}
所以允许窗口处理迟到数据之后,相当于窗口有了一段等待时间,在这期间所有的迟到数据都会立即触发窗口计算,更新之前的结果。
我们设置窗口等待的时间为1分钟,所以当时间推进到10000 + 60 * 1000 = 70000时,窗口就会真正被销毁。此前的所有迟到数据可以直接更新窗口的计算结果,而之后的迟到数据已经无法整合进窗口,就只能用侧输出流来捕获了。需要注意的是,这里的“时间”依然是由水位线来指示的,所以时间戳为70000的数据到来,并不会触发窗口的销毁;当时间戳为72000的数据到来,水位线推进到了72000 – 2 * 1000 = 70000,此时窗口真正销毁关闭,之后再来的迟到数据就会输出到侧输出流了:
总结
在流处理中,
由于对实时性的要求非常高
,同时又要求能够保证窗口操作结果的正确
,所以必须引入水位线来描述事件时间
。而窗口正是时间相关的最佳应用场景,所以Flink为我们提供了丰富的窗口类型和处理操作;与此同时,在实际应用中很难对乱序流给出一个最佳延迟时间,单独依赖水位线去保证结果正确性是不够的,所以需要结合窗口(Window)
处理迟到数据的相关API,这些内容对于实时流处理来说非常重要。
Flink的时间语义和窗口,
主要就是为了处理大规模的乱序数据流时,同时保证低延迟、高吞吐和结果的正确性
。这部分设计基本上是对谷歌(Google)著名论文《数据流模型:一种在大规模、无界、无序数据处理中平衡正确性、延迟和性能的实用方法》