一、如何处理迟到的数据
三个步骤:
.1 设置水位线延迟时间
因为水位线的延迟主要是用来对付分布式网络传输导致的数据乱序,而网络传输的乱序程度一般并不会很大,大多集中在几毫秒至几百毫秒。所以实际应用中,我们往往会给水位线设置一个“能够处理大多数乱序数据的小延迟”。
一般情况就不应该把它的延迟设置得太大,否则流处理的实时性就会大大降低
2.允许窗口处理迟到数据
由于大部分乱序数据已经被水位线的延迟等到了,所以往往迟到的数据不会太多。这样,我们会在水位线到达窗口结束时间时,先快速地输出一个近似正确的计算结果;然后保持窗口继续等到延迟数据,每来一条数据,窗口就会再次计算,并将更新后的结果输出。
3.将迟到数据放入窗口侧输出流
用窗口的侧输出流来收集关窗以后的迟到数据。这种方式是最后 “兜底”的方法,只能保证数据不丢失。因为窗口已经真正关闭,所以是无法基于之前窗口的结果直接做更新的。我们只能将之前的窗口计算结果保存下来,然后获取侧输出流中的迟到数据,判断数据所属的窗口,手动对结果进行合并更新。尽管有些烦琐,实时性也不够强,但能够保证最终结果一定是正确的。
使用:
1.设置水位线延迟时间
- 默认的策略(无序水位线)
⚠️⚠️有序水位线不存在数据乱序问题,所以不需要水位线延迟时间。
//默认的无序水位线,传入一个参数,表示延迟时间
WatermarkStrategy.<aggregateTest.Event>forBoundedOutOfOrderness(Duration.ofSeconds(3))
- 自定义的策略
onPeriodicEmit表示真正设置水位线时,将当前时间的时间戳减去延迟时间为水位线,相当于“拨慢闹钟”
//自定义水位线延迟时间
static class CustomWatermarkGenerator implements WatermarkGenerator<Event> {
private Long delayTime = 3000L;
private Long maxTs = -Long.MAX_VALUE + delayTime + 1L;
/**
* 让用户自己确认,每次数据来时要不要保留最新的时间戳
*
* @param l
* @param watermarkOutput
*/
@Override
public void onEvent(Event event, long l, WatermarkOutput watermarkOutput) {
maxTs = Math.max(event.timestamp, maxTs);
}
/**
* 真正设置水位线
* setAutoWatermarkInterval表示多久调用一次onPeriodicEmit
*
* @param watermarkOutput 设置水位线的时间戳
*/
@Override
public void onPeriodicEmit(WatermarkOutput watermarkOutput) {
watermarkOutput.emitWatermark(new Watermark(maxTs - delayTime - 1L));
}
}
2.允许窗口处理迟到数据
- 在window函数后面使用allowedLateness方法传入窗口最大延迟时间
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.allowedLateness(Time.seconds(2))
3.将迟到数据放入窗口侧输出流
- 定义一个OutputTag,包含新数据的 数据类型 和 测输出流的名称
- 在window下面,窗口函数上面,通过sideOutputLateData传递进去OutputTag。
- 通过getSideOutput获取到测输出流的数据,自定义进行处理。
OutputTag<Event> outputTag = new OutputTag<Event>("late") {};
DataStream<Event> stream = env.addSource(...);
stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.sideOutputLateData(outputTag)
.aggregate(new MyAggregateFunction());
DataStream<Event> lateStream = stream.getSideOutput(outputTag);
lateStream.print();
4.举例:
统计10秒内某个url点击的次数,超过窗口延迟时间则进入到“late”侧输出流
- 首先确定 数据是否存在乱序,如果存在乱序 水位线 应该如何设置。
为了解决数据乱序,每次在数据来时记录“最大的数据时间戳”,防止水位线设置的不准确
同时,需要设置延迟时间,这里设置为2秒
public static class CustomWatermarkStrategy implements WatermarkStrategy<Event> {
@Override
public WatermarkGenerator<Event> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
return new CustomWatermarkGenerator();
}
@Override
public TimestampAssigner<Event> createTimestampAssigner(TimestampAssignerSupplier.Context context) {
return new SerializableTimestampAssigner<Event>() {
/**
* 具体要用哪个字段作为时间戳
* @param l 程序运行时间
* @return
*/
@Override
public long extractTimestamp(Event event, long l) {
return event.timestamp;
}
};
}
}
static class CustomWatermarkGenerator implements WatermarkGenerator<Event> {
private Long delayTime = 2000L;
private Long maxTs = -Long.MAX_VALUE + delayTime + 1L;
/**
* 让用户自己确认,每次数据来时要不要保留最新的时间戳
*
* @param l
* @param watermarkOutput
*/
@Override
public void onEvent(Event event, long l, WatermarkOutput watermarkOutput) {
maxTs = Math.max(event.timestamp, maxTs);
}
/**
* 真正设置水位线
* setAutoWatermarkInterval表示多久调用一次onPeriodicEmit
*
* @param watermarkOutput 设置水位线的时间戳
*/
@Override
public void onPeriodicEmit(WatermarkOutput watermarkOutput) {
watermarkOutput.emitWatermark(new Watermark(maxTs - delayTime - 1L));
}
}
- 第二步,设置窗口的最大延迟时间
这里设置为3秒,超过3秒窗口就关闭,不再等待迟到的数据进来。
.assignTimestampsAndWatermarks(new CustomWatermarkStrategy())
.keyBy(Event::getUrl)
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.allowedLateness(Time.seconds(3))
- 第三步,设置侧输出流的tag,设置到当前dataStream里
OutputTag<Event> outputTag = new OutputTag<Event>("late") {};
SingleOutputStreamOperator<String> stream=
stream.assignTimestampsAndWatermarks(new CustomWatermarkStrategy())
.keyBy(Event::getUrl)
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.allowedLateness(Time.seconds(3))
.sideOutputLateData(outputTag);
DataStream<Event> sideOutput = stream.getSideOutput(outputTag);
sideOutput.print("late");
- 先来看演示效果:
数据格式是这样的:
bob,/product,1000
bob:访问页面的用户名 /product:访问的地址 1000为访问的时间戳
1.首先确定水位线的延迟时间是否生效
由于水位线延迟时间是2秒,那么统计0-10秒的数据,全窗口函数的情况下,肯定要等待10+延迟时间(2秒)的数据,才会计算0-10秒的全量数据,看看是不是这样的。
控制台没有任何反应,那我们输出一个12000的时间戳数据呢?
可以看到,水位线的延迟时间先生效。
2.再来确定,当前已经统计完0-10秒的数据了,是否还允许“迟到”数据的加入?
可以看到,只要在窗口定义的延迟时间范围内,是可以进行增量聚合的。
那么,我们定义的窗口延迟时间为3秒,什么情况下不可以再处理迟到数据了呢?
答案是:当前数据时间戳 >= 窗口结束时间(10秒)+ 水位线延迟时间(2秒) + 窗口延迟时间(3秒)
为什么要加两个参数呢?注意这两个参数的概念:水位线的延迟时间表示拨慢了整体的闹钟,原先10秒进来的数据必须等到12秒才进行处理,同时窗口延迟时间的含义是,在等到 窗口结束时间+窗口延迟时间的数据来以前,允许一直对迟到的数据进行增量聚合,一旦窗口结束时间+窗口延迟时间的数据进来,窗口立马关闭。
3.我们来校验下,是否15秒的数据进来后,再来一次迟到的数据,是否还对原先0-10秒的数据进行聚合?
可以看到,不再对迟到的数据进行聚合,放入到了late队列里,此时如果业务场景要求强一致性,可以手动对late队列的数据进行处理。
二、前置知识:时间与窗口
1.时间语义
背景:
在分布式系统中,节点“各自为政”,是没有统一时钟的,数据和控制信息都通过网络进行传输。比如现在有一个任务是窗口聚合,我们希望将每个小时的数据收集起来进行统计处理。而对于并行的窗口子任务,它们所在节点不同,系统时间也会有差异。所以统计这类数据,就要区分每个小时到底以哪个时间为准?是每个计算节点的系统的时间还是数据真正产生的时间。
概念:
- 事件时间
是指每个事件最初发生的时间,数据一旦产生,这个时间自然就确定了,所以它可以作为一个属性嵌入到数据中。这其实就是**这条数据记录的“时间戳”(**Timestamp)。
- 处理时间
执行处理操作的机器的系统时间。 相当于不关心数据什么进来,按照计算节点自己的系统时间来统计做时间窗口
这种方法非常简单粗暴,不需要各个节点之间进行协调同步,也不需要考虑数据在流中的位置,简单来说就是“我的地盘听我的”。所以处理时间是最简单的时间语义。
⚠️⚠️从 1.12 版本开始,Flink 已经将默认的时间语义是事件时间
举例:
如下图所示,在事件发生之后,生成的数据被收集起来,首先进入分布式消息队列,然后被 Flink 系统中的 Source 算子读取消费,进而向下游的转换算子(窗口算子)传递,最终由窗口算子进行计算处理。
很明显,这里有两个非常重要的时间点:一个是事件发生的时间,我们把它叫作“事件时间”
另一个是数据真正被处理的时间,叫作“处理时间”
2.水位线
背景:
在事件时间语义下,我们不依赖系统时间,而是基于数据自带的时间戳去定义了一个时钟,用来表示当前时间的进展。于是每个并行子任务都会有一个自己的逻辑时钟,它的前进是靠数据的时间戳来驱动的。
⭐️⭐️说白了就是计算节点自己不记录时间,哪个数据过来,带的时间戳是几点,那就以这个时间戳为准,判断是否以这段时间的数据作为窗口进行计算。
那有了数据自定义的时间,为什么依旧需要水位线?
但在分布式系统中,这种驱动方式又会有一些问题。因为数据本身在处理转换的过程中会变化,如果遇到窗口聚合这样的操作,其实是要攒一批数据才会输出一个结果,那么下游计算节点会一直阻塞住,直到上面一批数据处理完。
并且,数据向下游任务传递窗口内最后一条数据时,只能传输给一个子任务(除广播外),这样其他的并行子任务的时钟就无法推进(没有最后的窗口结束时间戳)。例如一个时间戳为 9 点整的数据到来,当前任务的时钟就已经是 9 点了,处理完当前数据要发送到下游,如果下游任务是一个窗口计算,并行度为 3,那么三个并行子任务都在等9 点或者9点以后的数据进来;但是9点的这个数据只能到一个计算节点,其他两个有可能因为一直没收到数据,一直阻塞住,一直等窗口结束时间戳到达后,才处理。
(1)概念
水位线可以看作一条特殊的数据记录,它是插入到数据流中的一个标记点,主要内容就是一个时间戳,用来指示当前的事件时间。而它插入流中的位置,就应该是在某个数据到来之后;这样就可以从这个数据中提取时间戳,作为当前水位线的时间戳了。
并且这个水位线应该把时钟也以数据的形式传递出去,告诉下游任务当前时间的进展。而且这个时钟的传递不会因为窗口聚合之类的运算而停滞。一种简单的想法是,在数据流中加入一个时钟标记,记录当前的事件时间;这个标记可以直接广播到下游,当下游任务收到这个标记,就可以更新自己的时钟了。这种用来衡量事件时间进展的标记,就被称作“水位线”
举例:
如上图所示,每个事件产生的数据,都包含了一个时间戳。当产生于 2 秒的数据到来之后,当前的事件时间就是 2 秒;在后面插入一个时间戳也为 2 秒的水位线,随着数据一起向下游流动。而当 5 秒产生的数据到来之后,同样在后面插入一个水位线,时间戳也为 5,当前的时钟就推进到了 5 秒。这样,如果出现下游有多个并行子任务的情形,我们只要将水位线广播出去,就可以通知到所有下游任务当前的时间进度了。
水位线就像它的名字所表达的,是数据流中的一部分,随着数据一起流动,在不同任务之间传输。
那设计一个水位线,需要考虑哪些问题?
⭐️(2)拓展问题
- 如果数据量非常大,每条数据都需要插入水位线吗?
同时涌来的数据时间差会非常小(比如几毫秒),往往对处理计算也没什么影响。所以为了提高效率,一般会每隔一段时间生成一个水位线,这个水位线的时间戳,就是当前最新数据的时间戳,其实就是有序流中的一个周期性出现的时间标记。可以看到,每隔一段时间,插入一条水位线。
- 如果数据进来的先后顺序不一致,怎么插入水位线吗?
入新的水位线时,要先判断一下时间戳是否比之前的大,否则就不再生成新的水位线,可以看到数据不按照顺序进来,例如,2,5,9,后面是延迟到达的7,这个时候因为最大的水位线是9,就不会再设置为7了。
- 如果有“迟到的数据”进来该怎么处理?
如第二个问题所示,假设要统计 0~9 秒的所有数据),那么收到9秒的数据,这时窗口就应该关闭、将收集到的所有数据计算输出结果了。但事实上,由于数据是乱序的,有时间戳为 7 秒、8 秒的数据在 9 秒的数据之后才到来,这就是“迟到数据”。它们本来也应该属于 0~9 秒这个窗口,但此时窗口已经关闭,于是这些数据就被遗漏了,这会导致统计结果不正确。
我们可以试着多等几秒,也就是把时钟调得更慢一些。最终的目的,就是要让窗口能够把所有迟到数据都收进来,得到正确的计算结果。对应到水位线上,其实就是要保证,当前时间已经进展到了这个时间戳,在这之后不可能再有迟到数据来了。- 既然选择多等几秒,那迟到的数据进来之前又有不属于该窗口的数据该怎么办?
flink的窗口是按照桶的方式进行存储(具体看下面的窗口处理),即使下个窗口的数据进来,这个窗口计算 不会收集这个数据,被放在下一个窗口计算处理。- 水位线应该如何传递?
背景:
首先要确定一件事情,刚才所提到的如何制定水位线,以及如何传递,是建立在一个上游任务,多个并行子任务的情况的,说白了就是上游任务接收到水位线时间戳,广播给多个并行子任务,那么如果上游任务也存在多个呢?多个并行子任务会收到多个上游任务传递过来的水位线,这个时候应该以哪个为基准呢?
答:应该以所有
上游任务中最小的时间为
时间戳。如图所示,多个上游任务,一个子任务,第一张图,上游任务代表的时间戳分别是2,4,3,6,那么子任务收到了这些时间戳后,以最小的为准,2。同理,第二张图发现3是最小的,以3作为时间戳。
为什么以最小的时间为时间戳呢?
水位线定义的本质:它表示的是“当前时间之前的数据,都已经到齐了”。这是一种保证,告诉下游任务“只要你接到这个水位线,就代表之后我不会再给你发更早的数据了,你可以放心做统计计算而不会遗漏数据”。所以如果一个任务收到了来自上游并行任务的不同的水位线,说明上游各个分区处理得有快有慢,以最慢的为准,虽然不是最新的时间,但是可以100%不会遗漏到后续“迟到”的数据。
(3)使用
- 使用内置的waterstrategy——有序流(你自己确定你的数据是有序的,就用这个)
assignTimestampsAndWatermarks方法:创建水位线
WatermarkStrategy.forMonotonousTimestamps()表示有序
withTimestampAssigner 传入一个 定义水位线时间戳的构造器
SerializableTimestampAssigner.extractTimestamp 表示使用数据的某个字段作为水位线的时间戳
DataStreamSource<Event> stream = env.fromElements(new Event("Mary", "./home", 1000L), new Event("Bob", "./cart", 2000L));
stream.assignTimestampsAndWatermarks(
//有序流使用forMonotonousTimestamps
WatermarkStrategy.<Event>forMonotonousTimestamps()
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
/**
* 将数据的某个字段作为水位线设置的值
* @param event
* @param l
* @return
*/
@Override
public long extractTimestamp(Event event, long l) {
return event.timestamp;
}
}));
- 使用内置的waterstrategy——无序流(你确定你的数据无序的来,就用这个 一般也是用这个)
forBoundedOutOfOrderness (Time) 表示无序
⭐️而Time实际上就是允许时钟调慢,等待“迟到”的数据过来的最大时间
stream.assignTimestampsAndWatermarks(
//无序流使用forBoundedOutOfOrderness
WatermarkStrategy.<Event>forBoundedOutOfOrderness
//设置接受的最大延迟时间
(Duration.ofSeconds(5))
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
/**
* 将数据的某个字段作为水位线设置的值
* @param event
* @param l
* @return
*/
@Override
public long extractTimestamp(Event event, long l) {
return event.timestamp;
}
}));
- 使用自定义的waterstrategy
- 通过 assignTimestampsAndWatermarks 传入一个CustomWatermarkStrategy
- ⭐️setAutoWatermarkInterval表示计算节点隔多长时间设置一次水位线的时间戳,这里传入的是200ms
stream.assignTimestampsAndWatermarks(new CustomWatermarkStrategy());
env.getConfig().setAutoWatermarkInterval(200);
- CustomWatermarkStrategy 需要implementsWatermarkStrategy方法
通过createWatermarkGenerator(水位线构造器)传入自定义的WatermarkGenerator类
通过createTimestampAssigner(水位线定义器)确定具体用数据的哪个字段作为事件时间
public static class CustomWatermarkStrategy implements WatermarkStrategy<Event> {
/**
传入一个自定义的水位线构造器
**/
@Override
public WatermarkGenerator<Event> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
return new CustomWatermarkGenerator();
}
@Override
public TimestampAssigner<Tuple3<String, Integer, Long>> createTimestampAssigner(TimestampAssignerSupplier.Context context) {
return new SerializableTimestampAssigner<Tuple3<String, Integer, Long>>() {
/**
* 具体要用哪个字段作为时间戳
* @param stringIntegerLongTuple3 数据自带的时间戳
* @param l 程序运行时间
* @return
*/
@Override
public long extractTimestamp(Tuple3<String, Integer, Long> stringIntegerLongTuple3, long l) {
return stringIntegerLongTuple3.f2;
}
};
}
}
- CustomWatermarkGenerator需要implements WatermarkGenerator方法
这里仅仅是自定义,下面只是举个例子
- onEvent表示每条数据来时,都会触发,可以用来获取数据的某个字段的时间戳,记录下来
Math.max我们说为了防止数据乱序,水位线混乱,就采用每条数据中最大的时间戳记录。 - onPeriodicEmit表示 flink算子真正要记录的水位线时间戳,由于刚才代码里setAutoWatermarkInterval是200ms,所以这块代码200ms执行一次(周期性定义水位线,提高执行效率)
- 传入new Watermark(maxTs - delayTime - 1L)的原因是因为,为了确认“迟到“的数据能够及时进来,我们要延迟窗口计算的时间,比如,统计1~9秒的数据,如果10秒先来,后到9秒,那么10秒此时设置水位线不能直接设置为10,应该是10-延迟时间的时间,比如最大延迟2秒,那么10秒的数据时间戳为(10-2=8),此时还达不到窗口计算的标准,等到11秒的数据进来的时候,9秒的已经进来了,而11秒的水位线时间戳为9,那么满足窗口计算的需要,开始进行窗口聚合计算
public static class CustomWatermarkGenerator implements WatermarkGenerator<Event> {
private Long delayTime = 5000L;
private Long maxTs = -Long.MAX_VALUE + delayTime + 1L;
/**
* 获取每次数据来时的时间戳
*
* @param event
* @param l
* @param watermarkOutput
*/
@Override
public void onEvent(Event event, long l, WatermarkOutput watermarkOutput) {
maxTs = Math.max(event.timestamp, maxTs);
}
/**
* 每隔多长时间设置一个水位线的时间戳
*
* @param watermarkOutput 设置水位线的时间戳
*/
@Override
public void onPeriodicEmit(WatermarkOutput watermarkOutput) {
watermarkOutput.emitWatermark(new Watermark(maxTs - delayTime - 1L));
}
}
3.窗口
(1)概念
Flink 是一种流式计算引擎,主要是来处理无界数据流的,数据源源不断、无穷无尽。想要更加方便高效地处理无界流,一种方式就是将无限数据切割成有限的“数据块”进行处理,这就是所谓的“窗口”。
这是我们所认知的窗口,每条数据有序的进来,一旦看到数据11进来,表示数据0-10的完全进来,关闭窗口,进行计算。这是理想化的情况。实际情况可能比这个要复杂。
可以看到,由于有乱序数据,我们需要设置一个延迟时间来等所有数据到齐。假设延迟时间就是2秒,那么数据12进来时,正好减去延迟时间就是10,那么至此窗口0-10的收集齐了,但是有个问题也出现,就是数据11和数据12被放在了窗口0-10里,这是不对的,那我们该如何设计窗口的存储方式呢?
⭐️可以看到,窗口是基于桶的方式进行存储。
- 第一个数据时间戳为 2,判断之后创建第一个窗口[0, 10),并将 2 秒数据保存进去;
- 后续数据依次到来,时间戳均在 [0, 10)范围内,所以全部保存进第一个窗口;
- 11 秒数据到来,判断它不属于[0, 10)窗口,所以创建第二个窗口[10, 20),并将 11 秒的数据保存进去。由于水位线设置延迟时间为 2 秒,所以现在的时钟是 9 秒,第一个窗口也没有到关闭时间;
- 之后又有 9 秒数据到来,同样进入[0, 10)窗口中
- 12 秒数据到来,判断属于[10, 20)窗口,保存进去。这时产生的水位线推进到了 10 秒,所以 [0, 10)窗口应该关闭了。第一个窗口收集到了所有的 7 个数据,进行处理并输出结果后,将窗口关闭销毁
⚠️⚠️Flink 中窗口并不是静态准备好的,而是动态创建——当有落在这个窗口区间范围的数据到达时,才创建对应的窗口。另外,这里我们认为到达窗口结束时间时,窗口就触发计算并关闭
⭐️(2)类型
按照驱动类型分为2类:
- 按照时间范围作为窗口
滚动窗口,滑动窗口,会话窗口都支持时间范围这类驱动。
时间窗口以时间点来定义窗口的开始(start)和结束(end),所以截取出的就是某一时间段的数据。到达结束时间时,窗口不再收集数据,触发计算输出结果,并将窗口关闭销毁。所以可以说基本思路就是“定点发车”。- 按照数据个数作为窗口
计数窗口基于元素的个数来截取数据,到达固定的个数时就触发计算并关闭窗口。按照规则分为4类:
- 滚动窗口
窗口之间没有重叠,也不会有间隔,一个窗口接着一个窗口,之前举例的水位线也是基于滚动窗口来说明的,所以每个数据都会被分配到一个窗口,而且只会属于一个窗口。
滚动窗口可以基于时间定义,也可以基于数据个数定义;需要的参数只有一个,就是窗口的大小(window size)。比如我们可以定义一个长度为 1 小时的滚动时间窗口,那么每个小时就会进行一次统计;8点到9点的为一个窗口,9点到10点的为一个窗口,或者定义一个长度为 10 的滚动计数窗口,就会每 10 个数进行一次统计。
举例:可以看到如上图所示,窗口一个接着一个,数据只能在某一个窗口里,与其他窗口不会重叠,也不会有间隔。红线标注的是,按照用户进行分区,表示window1这个窗口包含三个分区的数据,分别是user1、user2、user3三个分区的数据。
- 滑动窗口
与滚动窗口不一样的地方是,每个窗口并非“首尾相接”,而是数据存在交集的情况。
包含两个参数,一个是窗口的大小(window size),一个是滑动的距离(window slide)(相当于窗口计算的频率)
滑动窗口可以基于时间定义,也可以基于数据个数定义
- 怎么理解滑动的距离呢?
我们定义窗口的大小为1个小时,滑动距离为20分钟,那么包含的意思就是每隔20分钟,统计过去一个小时的数据。所以假设来了9:00-10:40的一批数据,那么就包含了9:00-10:00,9:20-10:20,9:40-10:40的三个窗口。那如果是滚动窗口的话,就是两个窗口,9:00-10:00和10:00-10:40- 为什么数据会存在交集呢?
因为存在滑动的情况,只要滑动的距离小于窗口的大小,就会有交集,因为滑动是在窗口里进行滑动,比如我们定义的窗口长度为 1 小时、滑动步长为 30 分钟,那么对于 8 点 55 分的数据,应该同时属于**[8 点, 9 点)和[8 点半, 9 点半)两个窗口;而对于 8 点 10 分的数据,则同时属于[8 点, 9 点)和[7 点半, 8 点半)**两个窗口。- 那如果滑动距离等于窗口大小,还会有交集吗?
不会有交集,并且这就等同于滚动窗口,因为滑动距离表示每隔多长时间或者每隔多少数据就开始建立窗口。- 会话窗口
对于会话窗口而言,最重要的参数就是这段时间的长度(size),它表示会话的超时时间,也就是两个会话窗口之间的最小距离。如果相邻两个数据到来的时间间隔(Gap)小于指定的大小(size),那说明还在保持会话,它们就属于同一个窗口;如果 gap 大于 size,那么新来的数据就应该属于新的会话窗口,而前一个窗口就应该关闭了。在具体实现上,我们可以设置静态固定的大小(size),也可以通过一个自定义的提取器(gap extractor)动态提取最小间隔 gap 的值。
简单点来说,设定一个超时时间,如果两条数据到达的间隔小于这个超时时间,就在一个窗口。如果大于了这个超时时间,就会新建一个窗口。
- 如果存在乱序数据流的情况,也有可能存在迟到数据,该怎么处理?
每来一个新的数据,除了判断存放在那个会话窗口外。还判断已有窗口之间的距离,如果小于给定的 size,就对它们进行merge操作,目的是保证迟到的数据能够进入到窗口,保证精确率。- 全局窗口
会把相同 key 的所有数据都分配到同一个窗口中(等于不分配窗口)。默认不会做触发计算的,一直接受数据,如果希望它能对数据进行计算处理,需要定义一个自定义“触发器”(Trigger)来处理。
应用场景:
Flink 中的计数窗口(Count Window),底层就是用全局窗口实现的。
(3)使用
分为三个方面来说:
- 如何开启一个窗口
- 如何设定窗口类型
- 如何进行窗口运算
1)如何开启窗口
分为分区后的窗口和不分区的窗口
- 分区的窗口
先调用keyBy再调用window
stream.keyBy(<key selector>)
.window(<window assigner>)
- 不分区的窗口(所有数据到了才处理)直接调用windowAll
stream.windowAll(...)
2)选择窗口类型
- 滚动窗口
TumblingProcessingTimeWindows表示滚动窗口,最多两个参数**(Time size, Time offset)**
size:很明显就是窗口的时间,如下图所示,就是5秒一个窗口
offset:注意,这里不是窗口偏移量,是解决不同国家分布在不同的时区的问题。
标准时间戳其实就是 1970 年 1 月 1 日 0 时 0 分 0 秒 0 毫秒开始计算的一个毫秒数,而这个时间是以 UTC 时间,也就是 0 时区(伦敦时间)为标准的。我们所在的时区是东八区,也就是 UTC+8,跟 UTC 有 8 小时的时差。我们定义 1 天滚动窗口时,如果用默认的起始点,那么得到就是伦敦时间每天 0 点开启窗口,这时是北京时间早上 8 点。那怎样得到北京时间每天 0 点开启的滚动窗口呢?只要设置-8 小时的偏移量就可以了。
.keyBy(<key selector>)
//一个窗口包含5秒内的数据
.window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
.keyBy(<key selector>)
//一个窗口包含5秒的数据,并将实际的时间戳 UTC时间偏移量-3秒
.window(TumblingEventTimeWindows.of(Time.seconds(5),Time.seconds(3)))
- 滑动窗口
SlidingProcessingTimeWindows表示滑动窗口,最多三个参数**(Time size, Time slide,Time offset)**
.keyBy(<key selector>)
//表示窗口大小为20,每5秒滑动一个窗口
.window(SlidingProcessingTimeWindows.of(Time.seconds(20),Time.seconds(5)))
.keyBy(<key selector>)
//表示窗口大小为20,每5秒滑动一个窗口,后面那个参数还是offet,就是解决不同国家不同时区问题
.window(SlidingProcessingTimeWindows.of(Time.seconds(20),Time.seconds(5),Time.seconds(5)))
- 会话窗口
ProcessingTimeSessionWindows表示会话窗口 最多1个参数**(Time size)**
withGap 里面要传入两个数据之间的超时时间
.keyBy(<key selector>)
//这里表示两个数据之间的超时时间最多10秒
.window(ProcessingTimeSessionWindows.withGap(Time.seconds(10)))
- 计数窗口
不调用window,直接调用countWindow即可,最多两个参数**(long size, long slide)**
⭐️底层还是通过全局窗口实现的,
而全局窗口底层又是通过触发器实现的。
- size:表示窗口大小(多少个数据作为一个窗口)
slide:表示窗口滑动的大小,例如,12个数据进来,窗口大小为10,滑动大小为4,那么,窗口包含,0-10,4-12,8-12三个窗口
.keyBy(<key selector>)
//10条数据为1个窗口
.countWindow(10)
.keyBy(<key selector>)
//10条数据为1个窗口,滑动大小为3
.countWindow(10,3)
- 全局窗口
⚠️⚠️需要注意使用全局窗口,必须自行定义触发器才能实现窗口计算,否则起不到任何作用。
stream.keyBy(...)
.window(GlobalWindows.create())
.trigger(...)
⭐️3)窗口函数
增量函数
概念:
打个比方,如果要对某一窗口内数据进行聚合(比如统计8-9点内页面访问量总和),是将8-9点的数据全部拿到后一起聚合,还是来一部分算一部分,等9点过来后返回结果,哪个效果更高效?答案当然是后者。
每来一条数据就立即进行计算,中间只要保持一个**简单的聚合状态****就可以了。
等到窗口到了结束时间需要输出计算结果的时候,我们只需要拿出之前聚合的状态直接输出,这无疑就大大提高了程序运行的效率和实时性。
典型的增量聚合函数有两个:ReduceFunction 和 AggregateFunction。
为什么有两个增量聚合函数?两个函数区别是什么?
规约函数,最后返回的结果类型必须是新数据进来的数据类型,说白了传递进来的新数据是Bean类型返回只能是Bean类型,传递进来是Tuple3类型返回只能是Tuple3类型。如果要自定义返回结果类型,要么一开始map()定义好,要么后面再加一个return类型。
聚合函数,可以自定义返回结果的类型,不需要前面加map()或者后面加returns,比较方便
(1)归约函数(ReduceFunction)
- 使用.reduce()方法,传递进去一个ReduceFunction或者RichReduceFunction
- 其中,reduce的入参有两个,第一个是中间的聚合结果值,第二个是新数据的值,我们需要针对这个两个值进行聚合或者其他操作。
.assignTimestampsAndWatermarks(...)
.keyBy(...)
.window(...)
.reduce(new RichReduceFunction<Tuple3<String, Integer, Long>>() {
@Override
public Tuple3<String, Integer, Long> reduce(Tuple3<String, Integer, Long> tmpAllvalue, Tuple3<String, Integer, Long> newValue) throws Exception {
return Tuple3.of(tmpAllvalue.f0, tmpAllvalue.f1 + t1.f1, tmpAllvalue.f2);
}
})
(2)聚合函数(AggregateFunction)
- 使用.aggregate()方法,传递进去一个AggregateFunction
- 两个方面,一个是泛型,一个是方法实现
IN:新数据的数据类型
ACC:累加器类型(可以自定义,不一定是新数据的数据类型)
累加器就是统计中间的聚合数据的,新来的数据优先使用累加器进行聚合
OUT:返回结果类型(可以自定义,不一定是累加器的数据类型)
public interface AggregateFunction<IN, ACC, OUT> extends Function, Serializable {
//初始化累加器 一般默认都是0
ACC createAccumulator();
//新来的数据来一次调用一次
//第一个参数是新来的数据 第二个参数是累加器
ACC add(IN var1, ACC var2);
//最后窗口计算完返回结果,返回类型就是OUT,你自己定义的结果类型
OUT getResult(ACC var1);
//合并两个累加器
//这里需要标明作用。。。
ACC merge(ACC var1, ACC var2);
}
举例:
- 现在要统计每个用户在20秒内访问的总点击量(不涉及访问地址,单纯的用户)
Event对象包含,姓名(user) 访问地址(url)
先设定一个水位线,再设计窗口类型为滚动窗口,大小为20秒 - 通过keyBy根据user进行分区,这样窗口里就是某个用户的
- new AggregateFunction,IN为:Event
累加器为: Tuple2<String, Integer>
key为用户,value为访问的次数
返回的结果类型:Tuple3<String, Integer, String>
key为用户,value为访问的次数,最后一个随便,只是方便区 分IN和OUT,随便写的。
env.getConfig().setAutoWatermarkInterval(200);
env.addSource(new customSource())
.assignTimestampsAndWatermarks(new CustomWatermarkStrategy())
.keyBy(event -> event.user)
.window(TumblingEventTimeWindows.of(Time.seconds(20)))
.aggregate(new AggregateFunction<Event, Tuple2<String, Integer>, Tuple3<String, Integer, String>>() {
/**
* 初始化一个累加器
* @return
*/
@Override
public Tuple2<String, Integer> createAccumulator() {
return Tuple2.of("", 0);
}
/**
* 一旦有数据进来,进行累加
*
* @param event 新来的数据
* @param tuple2 累加器
* @return
*/
@Override
public Tuple2<String, Integer> add(Event event, Tuple2<String, Integer> tuple2) {
tuple2.f0 = event.user;
tuple2.f1 = tuple2.f1 + 1;
return tuple2;
}
/**
*
* 将累加器的结果tuple2
* 转为最后输出的数据结构tuple3
* @param accumulator
* @return
*/
@Override
public Tuple3<String, Integer, String> getResult(Tuple2<String, Integer> accumulator) {
return Tuple3.of(accumulator.f0, accumulator.f1, "123");
}
@Override
public Tuple2<String, Integer> merge(Tuple2<String, Integer> tuple2, Tuple2<String, Integer> acc1) {
return null;
}
})
.print();
另外,Flink 也为窗口的聚合提供了一系列预定义的简单聚合方法,可以直接基于 WindowedStream 调用。主要包括**.sum()/max()/maxBy()/min()/minBy()**,与 KeyedStream 的简单聚合非常相似。它们的底层,其实都是通过 AggregateFunction 来实现的。
.window(...)
.maxBy(...) or .max(...)
全窗口函数
有了增量函数,为什么还需要全窗口函数?
- 如果我们不希望来一批数据就处理一批数据,而是等到所有数据攒齐,一起处理,那么就需要全窗口函数。
- 同时全窗口函数也有增量函数没有的功能,那就是可以获取窗口的详细信息(窗口的开始时间,窗口的结束时间,窗口当前状态等等)。
(1)WindowFunction(用的少):
- 可以使用**.apply()**方法传递进去一个WindowFunction
stream
.keyBy(<key selector>)
.window(<window assigner>)
.apply(new MyWindowFunction());
- 自定义一个WIndowFunction,需要 四个参数,<IN, OUT, KEY, W extends Window>
IN:新来数据的类型
OUT:输出结果的类型
KEY:分区的字段类型,全窗口的话这里一般都填Boolean类型(因为不通过字段进行分区,返回肯定全是true)
Window:窗口的数据类型(因为全窗口可以统计窗口详细信息,所以需要定义窗口类型)
public interface WindowFunction<IN, OUT, KEY, W extends Window> extends Function, Serializable {
//var1就是分区字段的值(由于是全部数据,一般都是true)
//var2 是窗口的详细信息(一般用来获取窗口开始时间和结束时间)
//var3 是窗口全部数据的迭代器
//var4 是结果输出的收集器
void apply(KEY var1, W var2, Iterable<IN> var3, Collector<OUT> var4) throws Exception;
}
- 举例:
- WindowFunction<aggregateTest.Event, String, Boolean, TimeWindow>
新数据类型是Event,返回数据类型是String,分区类型是Boolean,窗口类型是TimeWindow - 当全部数据进来之后,就会调用apply方法
env.addSource(new aggregateTest.customSource())
.assignTimestampsAndWatermarks(new aggregateTest.CustomWatermarkStrategy())
//这里可以看到,因为不按照某个字段分区,相当于全部数据,所以key的类型是boolean类型
.keyBy(event -> true)
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.apply(new CustomWindowFunction())
.print();
env.execute();
public static class CustomWindowFunction implements
WindowFunction<aggregateTest.Event, String, Boolean, TimeWindow> {
@Override
public void apply(Boolean key, TimeWindow window, Iterable<aggregateTest.Event> iterable, Collector<String> collector) throws Exception {
List<aggregateTest.Event> list = new ArrayList<>();
for(aggregateTest.Event event: iterable){
list.add(event);
}
long start = window.getStart();
long end = window.getEnd();
collector.collect("窗口起始时间:" + new Date(start) + "\t 窗口结束时间:" + new Date(end) + "\t拿到的数据包含:" + JSON.toJSONString(list));
}
}
(2)ProcessWindowFunction(用的多)
为什么有了WindowFunction,还要有ProcessWindowFunction?
他们俩的主要区别是,ProcessWindowFunction可以获取到一个 “上下文对象”(Context)。这个上下文对象非常强大,不仅能够获取窗口信息,还可以访问当前的时间和状态信息。这里的时间就包括了
处理时间(processing time)和事件时间水位线(event time watermark)。这就使得ProcessWindowFunction 更加灵活、功能更加丰富。
- 直接看它跟windowFunction的区别,可以看到Context能够获取到额外的信息,包括:
currentProcessingTime 处理时间
currentWatermark 当前水位线
/**
* @param aBoolean 分区key返回的值
* @param context 上下文(可以获取当前处理时间、当前流水线、窗口状态)
* @param iterable 全量数据的迭代器
* @param collector
* @throws Exception
*/
@Override
public void process(Boolean aBoolean, Context context, Iterable<aggregateTest.Event> iterable, Collector<String> collector) throws Exception {
List<aggregateTest.Event> list = new ArrayList<>();
for (aggregateTest.Event event : iterable) {
list.add(event);
}
TimeWindow window = context.window();
long processTime = context.currentProcessingTime();
long currentWatermark = context.currentWatermark();
long start = window.getStart();
long end = window.getEnd();
collector.collect("当前处理时间:" + new Date(processTime) + "\t当前水位线:" + new Date(currentWatermark) +
"窗口起始时间:" + new Date(start) + "\t 窗口结束时间:" + new Date(end) + "\t拿到的数据包含:" + JSON.toJSONString(list));
}
增量、全窗口搭配使用
为什么增量函数要和全窗口函数搭配一起使用?允许这样吗?
允许的。增量聚合相当于把计算量“均摊”到了窗口收集数据的过程中,自然就会比全窗口聚合更加高效、输出更加实时。 而全窗口函数的优势在于提供了更多的信息,可以认为是更加“通用”的窗口操作。它只负责收集数据、提供上下文相关信息,把所有的原材料都准备好,至于拿来做什么我们完全可以任意发挥。这就使得窗口计算更加灵活,功能更加强大。
所以在实际应用中,我们往往希望兼具这两者的优点,把它们结合在一起使用
⚠️⚠️一定要思考两个函数的调用顺序,和参数传递!
假设定义窗口时间为10秒,那么每当数据过来调用一次AggregateFunction的add进行聚合
10秒过去后,调用AggregateFunction的getResult,也就是返回聚合的总数据,之后调用ProcessWindowFunction的process
此时,ProcessWindowFunction传递进来的是AggregateFunction的getResult,也就是聚合好的数据,最终通过process方法收集结果信息。
使用:
**.aggregate()**方法将增量聚合函数和全窗口函数都放进去。
.aggregate(AggregateFunction<T, ACC, V> aggFunction,
ProcessWindowFunction<V, R, K, W> windowFunction)
举例:
统计某个用户在每10秒内的点击量
- 定义一个窗口,用aggregate传递进去两个Function,一个聚合函数,一个全窗口函数
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.getConfig().setAutoWatermarkInterval(200);
env.addSource(new aggregateTest.customSource())
.assignTimestampsAndWatermarks(new aggregateTest.CustomWatermarkStrategy())
.keyBy(event -> event.url)
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.aggregate(new CustomAggregateFunction(),
new CustomProcessWindowFunction())
.print();
env.execute();
- 定义一个聚合函数
AggregateFunction<aggregateTest.Event, Long, Long>
新数据类型Event,累加器Long(只需要聚合点击量即可),最后返回Long类型数据
这里add直接进行累加即可,getResult返回一个聚合的结果即可。
private static class CustomAggregateFunction implements AggregateFunction<aggregateTest.Event, Long, Long> {
@Override
public Long createAccumulator() {
return 0L;
}
@Override
public Long add(aggregateTest.Event event, Long tmpAllValue) {
return tmpAllValue += 1;
}
@Override
public Long getResult(Long tmpAllValue) {
return tmpAllValue;
}
@Override
public Long merge(Long tmpAllValue1, Long tmpAllValue) {
return null;
}
}
- 定义一个全窗口函数
ProcessWindowFunction<Long, String, String, TimeWindow>
聚合函数最后是Long类型数据,那么新数据类型就是Long类型,最后输出一句话,那么返回结果类型是String类型,key因为上面调用.keyBy(event -> event.url),所以也是string类型。
@Override
public void process(String key, Context context, Iterable<Long> iterable, Collector<String> collector) throws Exception {
List<Long> list = new ArrayList<>();
for (Long event : iterable) {
list.add(event);
}
TimeWindow window = context.window();
long start = window.getStart();
long end = window.getEnd();
collector.collect("分区key:" + key + "窗口起始时间:" + new Date(start) + "\t 窗口结束时间:" + new Date(end) + "\t统计的总共访问次数为:" + list.stream().mapToInt(Long::intValue).sum());
}
触发器Trigger
作用:主要是用来控制窗口函数何时触发计算,或者输出结果 ⚠️并不决定何时关闭窗口
- 为什么定义了窗口,还需要触发器?如果我们不自定义触发器,直接写窗口函数可以吗?
可以的,因为无论是选择时间、会话、滑动窗口,它们都有一个默认的触发器,所以我们定义了自定义触发器,那么会覆盖默认的触发器。不专门自定义触发器,实际上用了窗口的默认触发器,这里我们需要了解源码中触发器怎么实现的
- 可以看到,关于时间、会话、滑动窗口,如果仅仅定义了窗口大小为时间类型,那么会设定一个默认的EventTimeTrigger触发器
如果是全局窗口,由于压根没有触发器,所以一定要我们自己去定义触发器的实现。
另外,图中缺少了计数器窗口,实际上底层调用了CountTrigger去实现的。- 为什么是触发计算或者输出结果呢?
因为,控制的窗口函数类型,包含增量函数和全窗口函数
全窗口函数:触发器可以控制何时进行触发计算并输出结果。
增量函数:触发器与累加器计算 是并行的关系,触发器只能控制输出增量函数的结果。
先来了解trigger的使用以及方法、参数含义:
1.使用:
- ⚠️⚠️调用顺序:一定在window函数下面,窗口函数上面,使用.trigger()进行传入一个自定义Trigger类实现Trigger接口。
stream.keyBy(...)
.window(...)
.trigger(new MyTrigger())
.aggragate(....)
2.参数含义:
- trigger 是一个抽象类,自定义时必须实现下面四个抽象方法:
- onElement()
窗口中每到来一个元素,都会调用这个方法。 - onEventTime()
根据窗口中最新的EventTime,判断事件时间是否如果满足,将触发EventTime定时器,最后执行onEventTime方法里的逻辑 - onProcessingTime ()
根据窗口中最新的ProcessingTime判断是否满足定时器的条件,如果满足,将触发ProcessingTime定时器,最后执行onProcessingTime方法。 - clear()
当窗口关闭销毁时,调用这个方法。一般用来清除自定义的状态。
- 因为触发器,是做窗口函数的执行权的,所以只需要考虑何时触发计算以及输出结果就好,那么以下onElement,onEventTime,onProcessingTime这三个方法返回类型都是 TriggerResult,这是一个枚举类型(enum),其中定义了对窗口进行操作的四种类型。
- ⭐️ CONTINUE(继续):什么都不管
如果是增量聚合函数,那么允许新数据通过AggregateFunction执行add方法
如果是全窗口函数,并没有其他任何动作,仅仅是将数据保存下来。不做计算。 - FIRE(触发):触发计算,输出结果
如果是增量聚合函数,那么会执行AggregateFunction的getResult方法
如果是全窗口函数,那么会执行WindowFunction的apply,或者ProcessWindowFunction的process方法。 - PURGE(清除):清空窗口中的所有数据,销毁窗口
- FIRE_AND_PURGE(触发并清除):触发计算输出结果,并清除窗口
CONTINUE 的DEMO如下图所示:
这就是上面所说的,如果触发器在onElement时选择了CONTINUE,那么是允许增量函数进行计算的。它们是并行的关系。
但是如果触发器返回了FIRE,那么只要新数据进来立刻要求输出结果。
可以看到,调用的顺序是,add和onElement是并行关系,但是,getResult一定是在onElement后面,而且onElement返回了FIRE
每一条新数据进来一次,立刻要求输出窗口结果(窗口并不会及时关闭,仅仅是允许输出窗口结果)。
3.源码举例:
- 先来举例计数器窗口(CountTrigger),底层使用的触发器怎么实现
public class CountTrigger<W extends Window> extends Trigger<Object, W> {
private static final long serialVersionUID = 1L;
private final long maxCount;
private final ReducingStateDescriptor<Long> stateDesc;
//1.首先定义一个计数器的窗口长度
private CountTrigger(long maxCount) {
this.stateDesc = new ReducingStateDescriptor("count", new CountTrigger.Sum(), LongSerializer.INSTANCE);
//我们会通过这个参数 来判断是否触发Trigger的函数计算或结果输出
this.maxCount = maxCount;
}
//每次新的数据到来时
public TriggerResult onElement(Object element, long timestamp, W window, TriggerContext ctx) throws Exception {
ReducingState<Long> count = (ReducingState)ctx.getPartitionedState(this.stateDesc);
count.add(1L);
//会判断当前的数字是否达到配置的最大值
if ((Long)count.get() >= this.maxCount) {
count.clear();
//如果大于则进入FIRE状态,也就是触发计算或者输出结果
return TriggerResult.FIRE;
} else {
//否则放任不管,等待新的数据进来。
return TriggerResult.CONTINUE;
}
}
public TriggerResult onEventTime(long time, W window, TriggerContext ctx) {
return TriggerResult.CONTINUE;
}
public TriggerResult onProcessingTime(long time, W window, TriggerContext ctx) throws Exception {
return TriggerResult.CONTINUE;
}
public void clear(W window, TriggerContext ctx) throws Exception {
((ReducingState)ctx.getPartitionedState(this.stateDesc)).clear();
}
public boolean canMerge() {
return true;
}
public void onMerge(W window, OnMergeContext ctx) throws Exception {
ctx.mergePartitionedState(this.stateDesc);
}
- 再来举例时间类型窗口的EventTrigeer,源码怎么实现的
public class EventTimeTrigger extends Trigger<Object, TimeWindow> {
private static final long serialVersionUID = 1L;
private EventTimeTrigger() {
}
//新数据进来时
public TriggerResult onElement(Object element, long timestamp, TimeWindow window, TriggerContext ctx) throws Exception {
//如果 新来的数据的水位线 大于 该窗口所定义的的最大水位线
if (window.maxTimestamp() <= ctx.getCurrentWatermark()) {
//表示该窗口可以进行触发计算并输出结果了
return TriggerResult.FIRE;
} else {
//否则,注册一个定时器
ctx.registerEventTimeTimer(window.maxTimestamp());
return TriggerResult.CONTINUE;
}
}
//定时器会在水位线的时间到达该窗口定义的最大水位线时,调用onEventTime方法
public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) {
return time == window.maxTimestamp() ? TriggerResult.FIRE : TriggerResult.CONTINUE;
}
//时间类型窗口只涉及事件时间,所以不考虑处理时间,这里直接放行
public TriggerResult onProcessingTime(long time, TimeWindow window, TriggerContext ctx) throws Exception {
return TriggerResult.CONTINUE;
}
public void clear(TimeWindow window, TriggerContext ctx) throws Exception {
ctx.deleteEventTimeTimer(window.maxTimestamp());
}
窗口的允许延迟
这里跟水位线的延迟时间有什么关系呢?它们两之间有区别吗?
相同点:
- 无论是水位线的延迟还是窗口的延迟,效果都是窗口不会立刻关闭,保留一段时间。
最终希望能处理迟到的数据,提高准确率。不同点:
- 水位线延迟,实际上是解决数据乱序问题,期望在窗口触发计算并输出结果之前 迟到的数据能够及时到达
- 窗口的允许延迟,实际上是窗口已经计算到达的数据,在某一延迟时间内,增量聚合一些数据。
那如果窗口函数是增量聚合函数,水位线延迟不是也进行运算了吗?
- 如果使用的是增量聚合函数,无论是水位线延迟还是窗口的允许延迟,都会对迟到的数据进行增量聚合。
- 如果使用的是全窗口函数,水位线延迟表示必须达到延迟后的时间戳 才能进行全量计算
而窗口的延迟,一旦达到时间范围马上进行全量运算,可以先快速得出一个近似精确的结果。后续有迟到的数据进来可以先根据我们计算出来近似精确的结果进行累加,不需要再全量计算。
举例:
假设统计0-10秒的数据,1秒来了一个,2秒来了一个,假设设计水位线延迟为12秒,那么必须等到12秒的数据进来,才能对0-10秒的数据进行全量运算,而不设计水位线延迟,只是设计窗口的延迟,那么10秒的数据一进来,马上进行运算,后续11秒进来后,迟到的数据2秒的进来,因为我们已经得到之前0-10秒的结果了了,直接对2秒的数据进行累加,这样效率更高。
使用:
- 使用allowedLateness(Time) 输入窗口等待数据的最大延迟时间
如下图所示,窗口大小为10秒,延迟时间为2秒,现在统计0-10秒的数据,允许的迟到数据范围是10秒的数据之后,12秒的数据进来之前,一旦12秒的数据进来,该窗口就会关闭!无法再对延迟的数据进行处理。
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.allowedLateness(Time.seconds(2))
.apply(new WindowFunction<Event, String, String, TimeWindow>()
最终一致性-测输出流
作用:错过了窗口运算,但是又不想丢弃迟到的数据
我们可以将未收入窗口的迟到数据,放入侧输出流。所谓的侧输出流,相当于是数据流的一个“分支”,这个流中单独放置那些错过了该上的车、本该被丢弃的数据。
⚠️⚠️ 数据被放入到测数据流,不会任何的窗口函数操作,仅仅是保存数据,所以我们要自定义如何进行处理
使用:
- 定义一个OutputTag,包含新数据的 数据类型 和 测输出流的名称
- 在window下面,窗口函数上面,通过sideOutputLateData传递进去。
DataStream<Event> stream = env.addSource(...);
OutputTag<Event> outputTag = new OutputTag<Event>("late") {};
stream.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.sideOutputLateData(outputTag)
.aggregate(new MyAggregateFunction())
- 等到真正窗口完全关闭了,迟到数据过来后,会进入到当前测数据流
DataStream<Event> lateStream = winAggStream.getSideOutput(outputTag);
lateStream.print();
⭐️4)举例
统计20秒内各个用户的点击量,例如,0-20秒内,bob点击了3次,mary点击了2次,最后的结果为
(Bob,3) (mary,2)
可以看到,mock的数据是1秒钟随机一个用户请求,那么一次窗口运算的结果 一定有20次点击
- 先定义用户数据的格式:
user:因为要统计各个用户的点击量,所以要用到姓名进行分区
timestamp:我们基于事件发生时间做统计,那么需要用户自带一个时间戳来记录
@Data
static class Event {
/**
* 用户姓名
*/
public String user;
/**
* url无所谓 就是访问了哪个地址
*/
public String url;
/**
* 用户自带的时间戳(重要)
*/
public Long timestamp;
}
- 定义自定义的数据源:
实现SourceFunction的run和cancel方法
run: 不断通过sourceContext.collect数据,每次发完一条数据 sleep 1秒
static class customSource implements SourceFunction<Event> {
private Boolean running = true;
private int number = 0;
/**
* sourceContext.collect会返回数据
* run一旦结束,数据源就停止发送
*
* @param sourceContext
*/
@Override
public void run(SourceContext<Event> sourceContext) throws InterruptedException {
Random random = new Random();
String[] users = {"Mary", "Alice", "Bob", "Cary"};
String[] urls = {"./home", "./cart", "./fav", "./prod?id=1",
"./prod?id=2"};
while (running) {
sourceContext.collect(new Event(users[random.nextInt(users.length)],
urls[random.nextInt(urls.length)], Calendar.getInstance().getTimeInMillis()
));
Thread.sleep(1000);
}
}
/**
* 中止数据
*/
@Override
public void cancel() {
running = false;
}
}
- 设置水位线的构造器
每次数据进来(onEvent)时要把最大的时间戳设置为自己的时间戳,防止数据乱序后水位线乱了
onPeriodicEmit就是每次真正设置水位线,很明显要设置为maxTs
static class CustomWatermarkGenerator implements WatermarkGenerator<Tuple3<String, Integer, Long>> {
private Long delayTime = 0L;
private Long maxTs = -Long.MAX_VALUE + delayTime + 1L;
/**
* 让用户自己确认,每次数据来时要不要保留最新的时间戳
*
* @param l
* @param watermarkOutput
*/
@Override
public void onEvent(Tuple3<String, Integer, Long> Tuple3, long l, WatermarkOutput watermarkOutput) {
maxTs = Math.max(Tuple3.f2, maxTs);
}
/**
* 真正设置水位线
* setAutoWatermarkInterval表示多久调用一次onPeriodicEmit
*
* @param watermarkOutput 设置水位线的时间戳
*/
@Override
public void onPeriodicEmit(WatermarkOutput watermarkOutput) {
watermarkOutput.emitWatermark(new Watermark(maxTs - delayTime - 1L));
}
}
- 将水位线的构造器加入到水位线策略里
水位线策略包括 水位线的构造器 以及 水位线的定义器
定义器:实现createTimestampAssigner方法,确定是哪个字段为时间戳
public static class CustomWatermarkStrategy implements WatermarkStrategy<Tuple3<String, Integer, Long>> {
@Override
public WatermarkGenerator<Tuple3<String, Integer, Long>> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
return new CustomWatermarkGenerator();
}
@Override
public TimestampAssigner<Tuple3<String, Integer, Long>> createTimestampAssigner(TimestampAssignerSupplier.Context context) {
return new SerializableTimestampAssigner<Tuple3<String, Integer, Long>>() {
/**
* 具体要用哪个字段作为时间戳
* @param stringIntegerLongTuple3 数据自带的时间戳
* @param l 程序运行时间
* @return
*/
@Override
public long extractTimestamp(Tuple3<String, Integer, Long> stringIntegerLongTuple3, long l) {
return stringIntegerLongTuple3.f2;
}
};
}
}
- ⭐️来看最后的程序代码(结果如开头所示):
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
//200ms设置一次水位线(周期性)
env.getConfig().setAutoWatermarkInterval(200);
env.addSource(new customSource())
//将自定义源的部分字段转为元祖类型(目的:减少多余数据的网络传输)
//1表示此次请求用户访问了1次
.map(event -> Tuple3.of(event.user, 1, event.timestamp))
.returns(Types.TUPLE(Types.STRING, Types.INT, Types.LONG))
//做窗口运算
//先添加水位线(利用用户自己的时间戳)
.assignTimestampsAndWatermarks(new CustomWatermarkStrategy())
//再设置分区(按照用户的姓名)
.keyBy(stringIntegerTuple3 -> stringIntegerTuple3.f0)
//在设置窗口类型(滚动窗口 20秒的数据)
.window(TumblingEventTimeWindows.of(Time.seconds(20)))
//规约运算中对窗口内的数据做聚合 stringIntegerTuple3表示新的数据的值
//t1表示已经部分聚合的值
.reduce((stringIntegerTuple3, t1) -> Tuple3.of(stringIntegerTuple3.f0,
stringIntegerTuple3.f1 + t1.f1, stringIntegerTuple3.f2))
.print();
env.execute();