「这是我参与2022首次更文挑战的第23天,活动详情查看:2022首次更文挑战」
一、Flink
的窗口(TimeWindow
)
Flink
认为Batch
是Streaming
的一个特例, 因此Flink
底层引擎是一个流式引擎, 在上面实现了流处理和批处理。而Window
就是从Streaming
到Batch
的桥梁。
通俗讲:Window
是用来对一个无限的流设置一个有限的集合, 从而在有界的数据集上进行操作的一种机制。流上的集合由 Window
来划定范围, 比如 “计算过去10分钟” 或者 最后50个元素的和”。
Window
可以由时间 (Time Window
) (比如每30s) 或者 数据 (Count Window
)(如每100个元素) 驱动。
DataStream API
提供了 Time
和 Count
的 Window
。
窗口数据划分的不同:
-
滚动窗口:窗口数据有固定的大小,窗口中的数据不会叠加;
-
滑动窗口:窗口数据有固定的大小,并且有生成间隔;
-
会话窗口:窗口数据没有固定的大小,根据用户传入的参数进行划分,窗口数据无叠加。
编程基本步骤:
-
获取流数据源
-
获取窗口
-
操作窗口数据
-
输出窗口数据
(1)滚动窗口
将数据依据固定的窗口长度对数据进行切分。
特点: 时间对齐, 窗口长度固定, 没有重叠。
如图:
两个场景:
- 基于时间驱动
- 基于事件驱动
代码示例如下:
package com.donaldy.demo.window;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.datastream.WindowedStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import java.text.SimpleDateFormat;
import java.util.Random;
/**
* @author donald
* @date 2021/04/16
*/
public class TumblingWindow {
public static void main(String[] args) {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
DataStreamSource<String> dataStreamSource = env.socketTextStream("127.0.0.1", 7788);
SingleOutputStreamOperator<Tuple2<String, Integer>> mapStream =
dataStreamSource.map(new MapFunction<String, Tuple2<String, Integer>>() {
@Override
public Tuple2<String, Integer> map(String value) throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
long timeMillis = System.currentTimeMillis();
int random = new Random().nextInt(10);
System.out.println("value: " + value + " random: " + random + "timestamp: " + timeMillis + "|" + format.format(timeMillis));
return new Tuple2<>(value, random);
}
});
KeyedStream<Tuple2<String, Integer>, Tuple> keyedStream = mapStream.keyBy(0);
// 基于时间驱动,每隔10s划分一个窗口
WindowedStream<Tuple2<String, Integer>, Tuple, TimeWindow> timeWindow = keyedStream.timeWindow(Time.seconds(10));
// 基于事件驱动, 每相隔3个事件(即三个相同key的数据), 划分一个窗口进行计算
// WindowedStream<Tuple2<String, Integer>, Tuple, GlobalWindow> countWindow = keyedStream.countWindow(3);
// apply 是窗口的应用函数, 即apply里的函数将应用在此窗口的数据上。
timeWindow.apply(new MyTimeWindowFunction()).print();
// countWindow.apply(new MyCountWindowFunction()).print();
try {
// 转换算子都是lazy init的, 最后要显式调用 执行程序
env.execute();
} catch (Exception e) {
e.printStackTrace();
}
}
}
复制代码
1)基于时间驱动
场景: 需要统计每一分钟中用户购买的商品的总数, 需要将用户的行为事件按每一分钟进行切分, 这种切分被成为翻滚时间窗口(Tumbling Time Window
)。
代码如下:
package com.donaldy.demo.window;
import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import java.text.SimpleDateFormat;
/**
* @author donald
* @date 2021/04/16
*/
public class MyTimeWindowFunction implements WindowFunction<Tuple2<String,Integer>,
String, Tuple, TimeWindow> {
@Override
public void apply(Tuple tuple, TimeWindow window,
Iterable<Tuple2<String, Integer>> input, Collector<String> out) {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
int sum = 0;
for(Tuple2<String,Integer> tuple2 : input){
sum += tuple2.f1;
}
long start = window.getStart();
long end = window.getEnd();
out.collect("key:" + tuple.getField(0) + " value: " + sum + "| window_start :"
+ format.format(start) + " window_end :" + format.format(end));
}
}
复制代码
2)基于事件驱动
场景: 当想要每100个用户的购买行为作为驱动, 那么每当窗口中填满 100个 “相同” 元素了, 就会对窗口进行计算。
代码示例如下:
package com.donaldy.demo.window;
import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.functions.windowing.WindowFunction;
import org.apache.flink.streaming.api.windowing.windows.GlobalWindow;
import org.apache.flink.util.Collector;
import java.text.SimpleDateFormat;
/**
* @author donald
* @date 2021/04/16
*/
public class MyCountWindowFunction implements WindowFunction<Tuple2<String, Integer>, String, Tuple, GlobalWindow> {
@Override
public void apply(Tuple tuple, GlobalWindow window,
Iterable<Tuple2<String, Integer>> input, Collector<String> out) {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
int sum = 0;
for (Tuple2<String, Integer> tuple2 : input){
sum += tuple2.f1;
}
//无用的时间戳, 默认值为: Long.MAX_VALUE, 因为基于事件计数的情况下, 不关心时间。
long maxTimestamp = window.maxTimestamp();
out.collect("key:" + tuple.getField(0) + " value: " + sum + "| maxTimeStamp :"
+ maxTimestamp + "," + format.format(maxTimestamp));
}
}
复制代码
(2)滑动窗口
滑动窗口是固定窗口的更广义的一种形式, 滑动窗口由固定的窗口长度和滑动间隔组成。
特点: 窗口长度固定, 可以有重叠。
如图:
1)基于时间的滑动窗口
场景: 可以每30秒计算一次最近一分钟用户购买的商品总数。
2)基于事件的滑动窗口
场景: 每10个 “相同” 元素计算一次最近 100 个元素的总和。
package com.donaldy.demo.window;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.java.tuple.Tuple;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.datastream.WindowedStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.GlobalWindow;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import java.text.SimpleDateFormat;
import java.util.Random;
/**
* @author donald
* @date 2021/04/16
*/
public class SlidingWindow {
public static void main(String[] args) {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
DataStreamSource<String> dataStreamSource = env.socketTextStream("127.0.0.1", 7788);
SingleOutputStreamOperator<Tuple2<String, Integer>> mapStream =
dataStreamSource.map(new MapFunction<String, Tuple2<String, Integer>>() {
@Override
public Tuple2<String, Integer> map(String value) throws Exception {
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
long timeMillis = System.currentTimeMillis();
int random = new Random().nextInt(10);
System.err.println("value : " + value + " random : " + random + " timestamp : " + timeMillis + "|" + format.format(timeMillis));
return new Tuple2<>(value, random);
}
});
KeyedStream<Tuple2<String, Integer>, Tuple> keyedStream = mapStream.keyBy(0);
// 基于时间驱动, 每隔 5s 计算一下最近 10s 的数据
WindowedStream<Tuple2<String, Integer>, Tuple, TimeWindow> timeWindow =
keyedStream.timeWindow(Time.seconds(10), Time.seconds(5));
// 基于事件驱动,每隔2个事件,触发一次计算,本次窗口的大小为3,代表窗口里的每种事件最多为3个
WindowedStream<Tuple2<String, Integer>, Tuple, GlobalWindow> countWindow =
keyedStream.countWindow(3, 2);
timeWindow.sum(1).print();
countWindow.sum(1).print();
timeWindow.apply(new MyTimeWindowFunction()).print();
try {
env.execute();
} catch (Exception e) {
e.printStackTrace();
}
}
}
复制代码
(3)会话窗口
由一系列事件组合一个指定时间长度的 timeout
间隙组成, 类似于 web
应用的 session
, 也就是一段时间没有接收到新数据就会生成新的窗口。
session
窗口分配器通过 session
活动来对元素进行分组, session
窗口跟滚动窗口和滑动窗口相比, 不会有重叠和固定的开始时间和结束时间的情况。
session
窗口在一个固定的时间周期内不再收到元素, 即非活动间隔产生, 那么这个窗口就会关闭。
一个 session
窗口通过一个 session
间隔来配置, 这个 session
间隔定义了非活跃周期的长度, 当这个非活跃周期产生, 那么当前的 session
将关闭并且后续的元素将被分配到新的 session
窗口中去。
特点:
-
会话窗口不重叠, 没有固定的开始和结束时间。
-
与翻滚窗口和滑动窗口相反, 当会话窗口在一段时间内没有接收到元素时会关闭会话窗口。
-
后续的元素将会被分配给新的会话窗口。
如图:
案例描述:计算每个用户在活跃期间总共购买的商品数量, 如果用户30秒没有活动则视为会话断开。
二、Flink
的时间
Flink
中的时间分为三种:
-
事件时间(
Event Time
),即事件实际发生的时间; -
摄入时间(
Ingestion Time
),事件进入流处理框架的时间; -
处理时间(
Processing Time
),事件被处理的时间。
关系如图:
(1)事件时间(Event Time
)
事件时间(Event Time
)指的是数据产生的时间,这个时间一般由数据生产方自身携带,比如 Kafka
消息,每个生成的消息中自带一个时间戳代表每条数据的产生时间。
利用 Event Time
需要指定如何生成事件时间的“水印”,并且一般和窗口配合使用。
可以在代码中指定 Flink
系统使用的时间类型为 EventTime
:
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//设置时间属性为 EventTime
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
DataStream<MyEvent> stream = env.addSource(new FlinkKafkaConsumer09<MyEvent>(topic, schema, props));
stream
.keyBy( (event) -> event.getUser() )
.timeWindow(Time.hours(1))
.reduce( (a, b) -> a.add(b) )
.addSink(...);
复制代码
Flink
注册 EventTime
是通过 InternalTimerServiceImpl.registerEventTimeTimer
来实现的:
public void registerEventTimeTimer(N namespace, long time) {
this.eventTimeTimersQueue.add(new TimerHeapInternalTimer(time, this.keyContext.getCurrentKey(), namespace));
}
复制代码
可以看到,该方法有两个入参:
namespace
和time
,其中time
是触发定时器的时间,namespace
则被构造成为一个TimerHeapInternalTimer
对象,然后将其放入KeyGroupedInternalPriorityQueue
队列中。
那么 Flink
什么时候会使用这些 timer
触发计算呢?
// InternalTimeServiceImpl.advanceWatermark 中
public void advanceWatermark(long time) throws Exception {
this.currentWatermark = time;
InternalTimer timer;
while((timer = (InternalTimer)this.eventTimeTimersQueue.peek()) != null && timer.getTimestamp() <= time) {
this.eventTimeTimersQueue.poll();
this.keyContext.setCurrentKey(timer.getKey());
this.triggerTarget.onEventTime(timer);
}
}
复制代码
这个方法中的
while
循环部分会从eventTimeTimersQueue
中依次取出触发时间小于参数time
的所有定时器,调用triggerTarget.onEventTime()
方法进行触发。
这就是 EventTime
从注册到触发的流程。
(2)处理时间(Processing Time
)
处理时间(Processing Time
)指的是数据被 Flink
框架处理时机器的系统时间,Processing Time
是 Flink
的时间系统中最简单的概念,但是这个时间存在一定的不确定性,比如消息到达处理节点延迟等影响。
可以在代码中指定 Flink
系统使用的时间为 Processing Time
:
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
复制代码
在源码中找到 Flink
是如何注册和使用 Processing Time
:
public void registerProcessingTimeTimer(N namespace, long time) {
InternalTimer<K, N> oldHead = (InternalTimer)this.processingTimeTimersQueue.peek();
if (this.processingTimeTimersQueue.add(new TimerHeapInternalTimer(time, this.keyContext.getCurrentKey(), namespace))) {
long nextTriggerTime = oldHead != null ? oldHead.getTimestamp() : 9223372036854775807L;
if (time < nextTriggerTime) {
if (this.nextTimer != null) {
this.nextTimer.cancel(false);
}
this.nextTimer = this.processingTimeService.registerTimer(time, this);
}
}
}
复制代码
registerProcessingTimeTimer()
方法为我们展示了如何注册一个ProcessingTime
定时器: 每当一个新的定时器被加入到processingTimeTimersQueue
这个优先级队列中时,如果新来的Timer
时间戳更小,那么更小的这个Timer
会被重新注册ScheduledThreadPoolExecutor
定时执行器上。
Processing Time
被触发是在 InternalTimeServiceImpl
的 onProcessingTime()
方法中:
public void onProcessingTime(long time) throws Exception {
this.nextTimer = null;
InternalTimer timer;
while((timer = (InternalTimer)this.processingTimeTimersQueue.peek()) != null && timer.getTimestamp() <= time) {
this.processingTimeTimersQueue.poll();
this.keyContext.setCurrentKey(timer.getKey());
this.triggerTarget.onProcessingTime(timer);
}
if (timer != null && this.nextTimer == null) {
this.nextTimer = this.processingTimeService.registerTimer(timer.getTimestamp(), this);
}
}
复制代码
一直循环获取时间小于入参
time
的所有定时器,并运行triggerTarget
的onProcessingTime()
方法。
(3)摄入时间(Ingestion Time
)
摄入时间(Ingestion Time
)是事件进入 Flink
系统的时间,在 Flink
的 Source
中,每个事件会把当前时间作为时间戳,后续做窗口处理都会基于这个时间。 理论上 Ingestion Time
处于 Event Time
和 Processing Time
之间。
与事件时间相比,摄入时间无法处理延时和无序的情况,但是不需要明确执行如何生成
watermark
。在系统内部,摄入时间采用更类似于事件时间的处理方式进行处理,但是有自动生成的时间戳和自动的watermark
。可以防止
Flink
内部处理数据是发生乱序的情况,但无法解决数据到达Flink
之前发生的乱序问题。如果需要处理此类问题,建议使用EventTime
。
Ingestion Time
的时间类型生成相关的代码在 AutomaticWatermarkContext
中: