Window相关概念与API
概述
streaming 流式计算是一种被设计用于处理无限数据集的数据处理引擎,而无限数据集是指一种不断增长的本质上无限的数据集,而 window 是一种切割无限数据为有限块进行处理的手段。
Window 是无限数据流处理的核心,Window 将一个无限的 stream 拆分成有限大小的”buckets”桶,我们可以在这些桶上做计算操作。
窗口类型
窗口可以分为两大类:
- CountWindow:按照指定的数据条数生成一个 Window,与时间无关。
- TimeWindow:按照时间生成 Window。
窗口的开闭性:前闭后开
根据实现原理的不同又可以分为
- 滚动窗口
- 特点:时间对齐,窗口长度固定,没有重叠。
- 滑动窗口
- 特点:时间对齐,窗口长度固定,可以有重叠。
- 会话窗口
- 特点:时间无对齐。
还有一种比较特殊的窗口是
- 全局窗口:即将整个输入流看做一个窗口
时间语义与Watermark
- Ingestion Time:是数据进入 Flink 的时间。
- Event Time:是事件创建的时间。它通常由事件中的时间戳描述,例如采集的日志数据中,每一条日志都会记录自己的生成时间,Flink 通过时间戳分配器访问事件时间戳。
- Processing Time:是每一个执行基于时间操作的算子的本地系统时间,与机器相关,默认的时间属性就是 Processing Time。
Processing Time是flink1.12版本以前 默认的窗口分割方式,一般大多数业务中使用的是Event Time。flink 1.12开始,默认使用EventTime
Watermark:主要是用来处理乱序数据的一种机制。可以理解成一个延迟触发机制,我们可以设置Watermark 的延时时长 t,每次系统会校验已经到达的数据中最大的 maxEventTime,然后认定 eventTime小于 maxEventTime - t 的所有数据都已经到达,如果有窗口的停止时间等于maxEventTime – t,那么这个窗口被触发执行。
个人理解:
以EventTime处理为例,Watermark与EvenTime结合使用,用来控制乱序数据的乱序程度。正常的话(即数据有序流入)数据按照EventTime划分窗口进行计算,但是实际场景下数据流大部分情况下可能是乱序流入的,Watermark会在EventTime的基础上,将触发窗口计算、输出的时间节点向后推移。
比如目前数据是顺序为1、2、5、3、6、7、8、4,时间窗口是5,watermark是EventTime - 2。那么目前默认窗口应该是1-5为一个窗口,6-10为一个窗口。下面我们分析数据窗口以及关闭情况。
1号数据,水位线为-1,创建1-5号窗口,放入数据
2号数据,水位线为-0,进入1-5号窗口
5号数据,水位线为3,进入1-5号窗口,此时水位线为3,不能输出数据关闭窗口
6号数据,水位线为4,创建6-10号窗口,放入数据
7号数据,水位线为5,进入6-10号窗口,此时1-5号窗口的已经达到水位线,计算数据输出数据关闭窗口
8号数据,水位线为6,进入6-10号窗口
4号数据,水位线为2,1-5号窗口已关闭,进入侧写流,不能被正常处理,但是通过侧写流最终可以保证正确的结果输出。
通俗讲:也就是当水位线到达或者超过某个窗口的截止时间,将会计算输出并关闭窗口,之后再出现此窗口的数据,也将被写入到侧写流。
Watermark API
watermark是一种特殊事件,从产生的周期来看,watermark的生成方式有两类,分别是
- 周期性生成:周期性生成主要通过
env.getConfig.setAutoWatermarkInterval(5000);
设置,默认200ms。适合数据事件密集型的。 - 间断式生成:即没处理一条事件,就触发一次。适合小量数据流。
在周期性生成watermark的策略中,存在一种特殊的数据,即本身就是有序的,此时可以用AscendingTimestampExtractor
,这个类会直接使用数据的时间戳生成 watermark。
总结
WatermarkStrategy.forBoundedOutOfOrderness
:周期性生成watermark,数据乱序,延迟=周期间隔长度+无序边界。周期间隔:env.getConfig.setAutoWatermarkInterval(xx)
,无需边界即入参。WatermarkStrategy.forMonotonousTimestamps
:单调递增(事件本身是有序的),边界延迟0ms,术语特殊的BoundedOutOfOrderness。
WatermarkStrategy.withIdleness
:支持空闲检测,解决数据倾斜问题,提高时延。个人理解,当某分区超过未接收到新数据,则标记为空闲,空闲的分区不参与watermark的生成,自然也就减少了压力,当有新数据产生再次被激活,参与watermark的生成,从而提高flink的效率。env.getConfig().setAutoWatermarkInterval(0)
:我所使用版本(1.13.2)通过设置间隔为0,禁用周期。即每个事件触发一次。
Window API
Keyed Windows
stream
.keyBy(...) <- keyed versus non-keyed windows
.window(...) <- required: "assigner"
[.trigger(...)] <- optional: "trigger" (else default trigger)
[.evictor(...)] <- optional: "evictor" (else no evictor)
[.allowedLateness(...)] <- optional: "lateness" (else zero)
[.sideOutputLateData(...)] <- optional: "output tag" (else no side output for late data)
.reduce/aggregate/apply() <- required: "function"
[.getSideOutput(...)] <- optional: "output tag"
Non-Keyed Windows
stream
.windowAll(...) <- required: "assigner"
[.trigger(...)] <- optional: "trigger" (else default trigger)
[.evictor(...)] <- optional: "evictor" (else no evictor)
[.allowedLateness(...)] <- optional: "lateness" (else zero)
[.sideOutputLateData(...)] <- optional: "output tag" (else no side output for late data)
.reduce/aggregate/apply() <- required: "function"
[.getSideOutput(...)] <- optional: "output tag"
常用开窗
- TumblingProcessingTimeWindows:处理时间的滚动窗口
- TumblingEventTimeWindows:事件时间的滚动窗口
- SlidingProcessingTimeWindows:处理时间的滑动窗口
- SlidingEventTimeWindows:事件时间的滑动窗口
以上方法需要在keyBy后开窗,类比SQL开窗需要group by
滚动时间窗口
案例:
监听socket数据,计算N秒内的wordcount
使用ProcessingTime
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
ExecutionConfig config = env.getConfig();
env.setParallelism(1);
config.setAutoWatermarkInterval(0);
DataStream<String> dataStream = env.socketTextStream("hadoop101", 9999);
dataStream
.flatMap((FlatMapFunction<String, Tuple2<String, Long>>) (value, out) -> {
if (StringUtils.isNotBlank(value)) {
String[] split = value.split("\\s+");
for (String word : split) {
out.collect(new Tuple2<>(word, 1L));
}
}
})
//lamda表达式,泛型擦除,需要声明返回类型
.returns(Types.TUPLE(Types.STRING,Types.LONG))
//全部发往一个分区
.keyBy(data -> "all")
//重点:设置滚动窗口,Processing Time
.window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
.sum(1)
.print();
env.execute();
}
延伸:
需要通过.window(WindowAssigner<? super T, W> assigner)
设置窗口
案例升级
使用事件时间
统计N秒内的单词总数
描述:
这次我们把输入的一段文本在文本的最前面追加一个事件时间,如
1631343572195 aa bb cc dd
即第一个字段为事件时间。同时我们要求watermark=3s
此案例需要解决的问题
- 如何读取数据中的EventTime
- 如何设置watermark
- 如何开启一个EventTime滚动时间窗口
测试数据
1631343601000 aa bb
1631343603000 aa bb cc
1631343606000 aa dd
1631343607000 aa ee
1631343604000 aa dd ff
1631343608000 aa cc bb
分析上述数据
当我们输入完毕最后一条数据后,第一个窗口将会输出值,输出结果为:aa,8
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
ExecutionConfig config = env.getConfig();
env.setParallelism(1);
DataStream<String> dataStream = env.socketTextStream("hadoop101", 9999);
dataStream
.assignTimestampsAndWatermarks(
WatermarkStrategy.
//设置watermark
<String>forBoundedOutOfOrderness(Duration.ofSeconds(3))
//设置事件时间提取:取切分后第一个字段
.withTimestampAssigner((event, timestamp) -> {
String[] split = event.split("\\s+");
return Long.valueOf(split[0]);
}))
.flatMap((FlatMapFunction<String, Tuple2<String, Long>>) (value, out) -> {
if (StringUtils.isNotBlank(value)) {
String[] split = value.split("\\s+");
for (int i = 1; i < split.length; i++) {
String word = split[i];
out.collect(new Tuple2<>(word, 1L));
}
}
})
//lamda表达式,泛型擦除,需要声明返回类型
.returns(Types.TUPLE(Types.STRING, Types.LONG))
//全部发往一个分区
.keyBy(data -> "all")
//重点:设置滚动窗口,Event Time
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.sum(1)
.print();
env.execute();
}
值得注意的一点:
时区问题
假设我们有需求统计一天内的XXX。此时我们可能想到的是按照天进行开窗,由于flink默认是UTC进行窗口计算的,而我们国家的时间是UTC+8,此时就会出现我们实际统计的时间区间是前一天8点到今天8点的数据。
我们看一下下面的案例:
将上面的时间窗口改为1天,水位线改为0.
假设我们现在输入如下数据
#2021-09-11 00:00:00
1631289600000 aa
#2021-09-11 08:00:00
1631318400000 aa bb cc
#2021-09-12 00:00:00
1631376000000 dd
此时我们发现,在输入第二条数据的时候,结果输出了,而且结果是1!!!。
我们的预期是什么?第三条数据时输出,且结果是4。
所以我们需要将水位线向前推移8小时。
//增加了一个时间偏移量,-8小时
.window(TumblingEventTimeWindows.of(Time.days(1),Time.hours(-8)))
当然这个问题的主要原因是因为flink允许时的时间是UTC+0,而我们数据的事件时间时按照UTC+8产生的,所以另一个解决方向是将集群的flink配置时区修改为UTC+8,统一时区也是一种解决方案。传送门
所以我们最终的java代码如下
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
ExecutionConfig config = env.getConfig();
env.setParallelism(1);
DataStream<String> dataStream = env.socketTextStream("hadoop101", 9999);
dataStream
.assignTimestampsAndWatermarks(
WatermarkStrategy.
//设置watermark
<String>forBoundedOutOfOrderness(Duration.ofSeconds(0))
//设置事件时间提取:取切分后第一个字段
.withTimestampAssigner((event, timestamp) -> {
String[] split = event.split("\\s+");
return Long.valueOf(split[0]);
}))
.flatMap((FlatMapFunction<String, Tuple2<String, Long>>) (value, out) -> {
if (StringUtils.isNotBlank(value)) {
String[] split = value.split("\\s+");
for (int i = 1; i < split.length; i++) {
String word = split[i];
out.collect(new Tuple2<>(word, 1L));
}
}
})
//lamda表达式,泛型擦除,需要声明返回类型
.returns(Types.TUPLE(Types.STRING, Types.LONG))
//全部发往一个分区
.keyBy(data -> "all")
//重点:设置滚动窗口,Event Time
//增加了一个时间偏移量,-8小时
.window(TumblingEventTimeWindows.of(Time.days(1),Time.hours(-8)))
.sum(1)
.print();
env.execute();
}
滑动时间窗口
上面计算的都是每个滚动N秒窗口的单词总量
现在我们需要计算当前5秒内的窗口单词总量,所以需要用到滑动窗口
滑动窗口有两个参数,分别是窗口大小和滑动步长
关键代码
每1秒更新一次5秒内的单词总量
//重点:设置滑动窗口,Event Time
.window(SlidingEventTimeWindows.of(Time.seconds(5),Time.seconds(1)))
滚动计数窗口
根据窗口中相同 key 元素的数量来触发执行,执行时只计算元素数量达到窗口大小的 key 对应的结果。
只需要指定一个窗口大小参数。
.countWindow(10)
滑动计数窗口
滑动窗口和滚动窗口的函数名是完全一致的,只是在传参数时需要传入两个参数,一个是 window_size,一个是 sliding_size。
.countWindow(10,1)
Window Function
主要分为两类
- 增量聚合函数(incremental aggregation functions)
- 每条数据到来就进行计算,保持一个简单的状态。
- 典型的增量聚合函数有ReduceFunction, AggregateFunction。
- 全窗口函数(full window functions)
- 先把窗口所有数据收集起来,等到计算的时候会遍历所有数据。
- ProcessWindowFunction 就是一个全窗口函数。
其它可选 API
- .trigger() —— 触发器
- 定义 window 什么时候关闭,触发计算并输出结果
- .evitor() —— 移除器
- 定义移除某些数据的逻辑
- .allowedLateness() —— 允许处理迟到的数据(窗口延迟关闭,稍微等一会迟到数据)
- .sideOutputLateData() —— 将迟到的数据放入侧输出流
- .getSideOutput() —— 获取侧输出流
ProcessFunction API(底层API)
Flink 提供了 8 个 Process Function:
• ProcessFunction
• KeyedProcessFunction
• CoProcessFunction
• ProcessJoinFunction
• BroadcastProcessFunction
• KeyedBroadcastProcessFunction
• ProcessWindowFunction
• ProcessAllWindowFunction
KeyedProcessFunction
processElement(I value, Context ctx, Collector<O> out)
流中的每一个元素都会调用这个方法,调用结果将会放在 Collector 数据类型中输出。Context以访问元素的时间戳,元素的 key,以及 TimerService 时间服务。Context 还可以将结果输出到别的流(side outputs)。onTimer(long timestamp, OnTimerContext ctx, Collector<O> out)
是一个回调函数。当之前注册的定时器触发时调用。参数 timestamp 为定时器所设定的触发的时间戳。Collector 为输出结果的集合。OnTimerContext 和processElement 的 Context 参数一样,提供了上下文的一些信息,例如定时器触发的时间信息(事件时间或者处理时间)。
案例:
计算某API接口10秒内的TPS连续上升
分析:
此案例无论是滚动窗口还是滑动窗口,都无法实现检测连续10S内的TPS上升。
测试的数据,这里的时间无意义,因为定时器是使用的processing time
2021-09-12 12:00:00|aa|1
2021-09-12 12:00:01|aa|10
2021-09-12 12:00:01|aa|5
2021-09-12 12:00:01|aa|50
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
ExecutionConfig config = env.getConfig();
config.setAutoWatermarkInterval(0);
env.setParallelism(1);
DataStream<String> dataStream = env.socketTextStream("hadoop101", 9999);
dataStream.map((value)->{
String[] split = value.split("\\|+");
ApiBean apiBean = new ApiBean();
apiBean.setInputTime(split[0]);
apiBean.setInterfaceId(split[1]);
apiBean.setTps(Integer.valueOf(split[2]));
return apiBean;
}).returns(ApiBean.class)
//未定义事件时间,则使用的是Processing Time
// .assignTimestampsAndWatermarks(
// WatermarkStrategy.
// //设置watermark
// <ApiBean>forMonotonousTimestamps()
// //设置事件时间提取:取切分后第一个字段
// .withTimestampAssigner((event, timestamp) -> {
// String inputTime = event.getInputTime();
// try {
// return DateUtils.parseDate(inputTime,"yyyy-MM-dd HH:mm:ss").getTime();
// } catch (ParseException e) {
// e.printStackTrace();
// }
// return -1;
// }))
.keyBy(data -> data.getInterfaceId())
.process(new KeyedProcessFunction<String, ApiBean, String>() {
private Integer interval = 10*1000;
private Long timer;
private Integer lastTps;
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
}
@Override
public void processElement(ApiBean value, Context ctx, Collector<String> out) throws Exception {
/*
* 判断当前tps是否大于上次tps,且timer为空,成立则设置一个10S的定时器
*/
Integer tps = value.getTps();
if(timer == null && (lastTps == null || tps > lastTps)){
this.timer = ctx.timerService().currentProcessingTime()+ interval;
//注册定时器
ctx.timerService().registerProcessingTimeTimer(this.timer);
}else if(timer != null && (lastTps != null && tps <= lastTps)){
//清除定时器
ctx.timerService().deleteProcessingTimeTimer(timer);
this.timer = null;
}
this.lastTps = tps;
}
/*
* timestamp : 定时器触发的时间戳
*/
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
String msg = "时间区间"+ DateFormatUtils.format(new Date(timestamp),"yyyy-MM-dd HH:mm:ss") + "至"+ DateFormatUtils.format(new Date(timestamp+interval),"yyyy-MM-dd HH:mm:ss")+",TPS持续上升,当前最大TPS为"+this.lastTps;
out.collect(msg);
this.timer = null;
}
})
.print();
env.execute();
}
实现一个侧写流
TPS小于50的,侧写至lowTps一份
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
ExecutionConfig config = env.getConfig();
config.setAutoWatermarkInterval(0);
env.setParallelism(1);
DataStream<String> dataStream = env.socketTextStream("hadoop101", 9999);
//值得注意:tag是匿名内部类
OutputTag<ApiBean> lowTpsTag = new OutputTag<ApiBean>("lowTps"){};
SingleOutputStreamOperator<ApiBean> allDataStream = dataStream.map((value) -> {
String[] split = value.split("\\|+");
ApiBean apiBean = new ApiBean();
apiBean.setInputTime(split[0]);
apiBean.setInterfaceId(split[1]);
apiBean.setTps(Integer.valueOf(split[2]));
return apiBean;
}).returns(ApiBean.class)
.assignTimestampsAndWatermarks(
WatermarkStrategy.
//设置watermark
<ApiBean>forMonotonousTimestamps()
//设置事件时间提取:取切分后第一个字段
.withTimestampAssigner((event, timestamp) -> {
String inputTime = event.getInputTime();
try {
return DateUtils.parseDate(inputTime, "yyyy-MM-dd HH:mm:ss").getTime();
} catch (ParseException e) {
e.printStackTrace();
}
return -1;
}));
allDataStream.print("allData");
//获取侧写流
allDataStream
.process(new ProcessFunction<ApiBean, String>() {
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
}
@Override
public void processElement(ApiBean value, Context ctx, Collector<String> out) throws Exception {
Integer tps = value.getTps();
if(tps<50){
ctx.output(lowTpsTag,value);
}
}
/*
* timestamp : 定时器触发的时间戳
*/
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
}
})
.getSideOutput(lowTpsTag)
.print("lowTps");
env.execute();
}