窗口定义
窗口是Flink流计算的核心,Flink中提供了两大类窗口,有key的窗口和无key的窗口(只能调用XxxWindowAll方式创建),重点介绍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"
数据准备:
import java.util.Properties
import d0628.transform.SensorReading
import org.apache.flink.api.common.serialization.SimpleStringSchema
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer011
import org.apache.kafka.clients.consumer.ConsumerConfig
object WindowDemo1 {
def main(args: Array[String]): Unit = {
val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
val properties = new Properties()
properties.setProperty(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.226.10:9092")
properties.setProperty(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer")
properties.setProperty(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer")
properties.setProperty(ConsumerConfig.GROUP_ID_CONFIG, "Window1")
val source: DataStream[String] = env.addSource(new FlinkKafkaConsumer011[String]("sensor", new SimpleStringSchema(), properties))
val kafkaSource: DataStream[SensorReading] = source.filter(_.matches("^sensor_\\d{1},\\d{13},\\d{2}\\.\\d{2}$"))
.map(line => {
val fields: Array[String] = line.split(",")
SensorReading(fields(0), fields(1).toLong, fields(2).toDouble)
})
env.execute
}
}
以下使用上面获取的kafkaSource
对象作为数据源演示
窗口类型
窗口可以分为时间窗口和计数窗口。根据窗口尺寸和滑动步长的关系,可以再划分为滑动窗口和滚动窗口,时间窗口包含另一种特殊的会话窗口。
几种窗口收集数据的逻辑与其他流处理框架(SparkStreaming和KafkaStreaming)没什么区别,就不再单独介绍。
下面演示如何创建以上几种类型的窗口:
kafkaSource.keyBy("id")
// .window(TumblingEventTimeWindows.of(Time.seconds(2))) 时间滚动窗口的第一种创建方式
// .timeWindow(Time.seconds(3)) 时间滚动窗口的第二种创建方式
// .window(SlidingEventTimeWindows.of(Time.seconds(3), Time.seconds(1))) 时间滑动窗口的第一种创建方式
// .timeWindow(Time.seconds(3), Time.seconds(1)) 时间滑动窗口的第二种创建方式
// .countWindow(3) 计数滚动窗口
// .countWindow(3, 1) 计数滑动窗口
// .window(EventTimeSessionWindows.withGap(Time.seconds(5))) 会话窗口
- 使用
window()
创建的时间窗口可以传入第二个函数,用于表示和标准时间的差值,可以用于切换时区; - 使用
window
创建的时间窗口会才有指定的时间语义,以上例子中均为采用event time
的窗口,如果要采用process time
的话,Flink提供了与之对应的窗口类- Flink中的时间语义有三种:
event time
(事件发生时间)、ingestion time
(数据获取时间)、process time
(数据处理时间)
- Flink中的时间语义有三种:
- 使用
timeWindow
创建的时间窗口默认采用的是process time
,可以使用环境对象对其进行修改:env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
在将时间语义定义为event time
后,需要注意,系统是无法获取真实的事件发生时间的,需要在每条数据中包含事件的发生时间,并指明发生时间:
val kafkaSource: DataStream[SensorReading] = source.filter(_.matches("^sensor_\\d{1},\\d{13},\\d{2}\\.\\d{2}$"))
.map(line => {
val fields: Array[String] = line.split(",")
SensorReading(fields(0), fields(1).toLong, fields(2).toDouble)
// 定义水位线,先暂定为0
}).assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[SensorReading](Time.seconds(0)) {
// 指定timestamp值为事件发生时间,便于查看,可以使最后一位为秒,所以需要乘以1000
override def extractTimestamp(element: SensorReading): Long = element.timeStamp * 1000
})
源码中根据时间语义判断创建哪种类型的的时间窗口,timeWindow
创建窗口本质上还是调用了window
函数创建
默认时间语义设定为ProcessTime
创建窗口后,获得的对象为WindowedStream
对象,可以调用max、min、sum、reduce、aggregate和apply等函数进行聚合运算。
apply函数和其他几个函数不太相同,其他函数都是每收到一条数据后进行一次判断和处理,而apply是当窗口关闭后
才会进行处理的。
apply应用案例:
SingleOutputStreamOperator<Tuple4<String, Long, Long, Integer>> res = mapStream.keyBy("id")
.timeWindow(Time.seconds(15))
.apply(new WindowFunction<SensorReading, Tuple4<String, Long, Long, Integer>, Tuple, TimeWindow>() {
@Override
public void apply(Tuple tuple, TimeWindow window, Iterable<SensorReading> input, Collector<Tuple4<String, Long, Long, Integer>> out) throws Exception {
String key = tuple.getField(0);
long start = window.getStart();
long end = window.getEnd();
Iterator<SensorReading> iterator = input.iterator();
int size = IteratorUtils.toList(iterator).size();
// 获取当前窗口的key,起止时间和数据条数
Tuple4<String, Long, Long, Integer> tuple4 = new Tuple4<>(key, start, end, size);
// 输出结果
out.collect(tuple4);
}
});
res.print("Apply");
其余函数逻辑和Spark类似,不再一一演示,但要注意,Flink是事件驱动的,开启和关闭窗口都是由一条新事件到来触发的。
下面看下新事件传入后,源码中是如何开启窗口的(滑动时间窗口)
@Override
public Collection<TimeWindow> assignWindows(Object element, long timestamp, WindowAssignerContext context) {
if (timestamp > Long.MIN_VALUE) {
List<TimeWindow> windows = new ArrayList<>((int) (size / slide));
// 计算时间触发前,最近一个窗口的开启时间
long lastStart = TimeWindow.getWindowStartWithOffset(timestamp, offset, slide);
// 计算包含当前时间的全部窗口,并将其放入集合中
for (long start = lastStart;
start > timestamp - size;
start -= slide) {
windows.add(new TimeWindow(start, start + size));
}
// 最终返回了包含全部窗口的集合
return windows;
} else {
throw new RuntimeException("Record has Long.MIN_VALUE timestamp (= no timestamp marker). " +
"Is the time characteristic set to 'ProcessingTime', or did you forget to call " +
"'DataStream.assignTimestampsAndWatermarks(...)'?");
}
}
窗口开启时间的计算逻辑:
public static long getWindowStartWithOffset(long timestamp, long offset, long windowSize) {
return timestamp - (timestamp - offset + windowSize) % windowSize;
}
windowSize
在滑动窗口中指滑动步长,在滚动窗口中指窗口尺寸;
offset
不会大于windowSize
,因为在创建窗口时做了条件限定:
举例说明,假如我们在滚动时间窗口中传入的时间戳为1624864851298000
,窗口尺寸为3s,那么实际窗口开启时间为:
1624864851298000 - (1624864851298000 - 0 + 3000) % 3000 = 1624864851297000
可以看到,实际窗口开启时间未必和第一条事件的时间戳一致。但这个机制保证了Flink中创建的全部窗口都是可以相互吻合的。
要理解上面机制的作用,可以假设一个场景(仍为滚动时间窗口,窗口尺寸为3s):
假设第一条数据产生时间为0s,那么第一个窗口对应的时间就是[0,3)
,后面第二条接受的数据产生的时间为5s,假设我们按照5s作为窗口开启时间的话,那么窗口时间就应该为[5,8)
,而后由于网络延时等原因,第三条接受的数据为4s,此时就会发现,这条数据没办法再安排窗口了。
Triggers
Trigger(触发器)的作用是设置窗口中的元素何时触发聚合计算,每个窗口都有默认的Trigger。
当默认的触发机制不满足需求时,可以自定义Trigger,自定义的Trigger有以下几个函数需要实现,各函数和作用:
函数 | 作用 |
---|---|
onElement() | 每条数据进入窗口时会调用该函数 |
onEventTime() | 当水位线到达设定的时间时,会调用该方法,需要和ctx.registerEventTimeTimer 搭配 |
onProcessingTime() | 当处理时间到达设定的时间时,会调用该方法,需要和ctx.registerProcessingTimeTimer 搭配 |
onMerge() | 对于有状态的trigger,当两个对应的窗口合并时,调用该方法合并两个trigger的状态 |
clear() | 清空窗口中的元素 |
以上函数除了clear()
外,其余函数均需返回TriggerResult
对象,TriggerResult
是一个枚举类,用于描述触发器的状态。状态有四种
状态 | 含义 |
---|---|
CONTINUE | 不采取任何行动,继续接收数据 |
FIRE | 发出窗口中的数据进行运算,不清空窗口中的数据 |
PURGE | 清空窗口的数据,关闭窗口 |
FIRE_AND_PURGE | 发出窗口中的数据进行计算,清空窗口数据 |
Flink提供了几个常用的触发器:
触发器 | 工作模式 |
---|---|
EventTimeTrigger | 当事件发生时间得出的watermark超过窗口最大时间时,将会触发计算 |
ProcessingTimeTrigger | 与EventTimeTrigger 类似,区别在于基于事件处理时间 |
CountTrigger | 当窗口内的元素个数达到要求时,触发计算 |
PurgingTrigger | 以其他触发器作为参数,触发机制与传入的触发器相同,区别在于会自动清空窗口数据 |
理解上述几个函数和TriggerResult的含义的话,以上几个函数的源码是比较容易理解的
下面我们自定义一个Trigger,要求进入窗口的数据只要达到设定的条数或时间两个条件中的一个就进行聚合计算。
自定义Trigger类实现代码:
private class EventTimeAndCountTrigger[W <: Window] extends Trigger[SensorReading, W] {
// 设定触发计算所需的数据条数
private var requiredCount: Int = _
// 记录当前已有条数
private val stateDesc = new ValueStateDescriptor[Int]("count", classOf[Int])
def this(count: Int) {
this()
this.requiredCount = count
}
override def onElement(element: SensorReading, timestamp: Long, window: W, ctx: Trigger.TriggerContext): TriggerResult = {
// 获取已有条数,当前数据收到,数量更新
val parState: ValueState[Int] = ctx.getPartitionedState(stateDesc)
parState.update(parState.value() + 1)
// 注册回调函数(即当时间到达窗口最大时间时,调用onEventTime函数)
ctx.registerEventTimeTimer(window.maxTimestamp())
if (parState.value() >= requiredCount || ctx.getCurrentWatermark >= window.maxTimestamp()) {
// 触发计算前,将设定的条数归零
parState.update(0)
TriggerResult.FIRE
} else
TriggerResult.CONTINUE
}
// 与处理时间无关,直接设为CONTINUE
override def onProcessingTime(time: Long, window: W, ctx: Trigger.TriggerContext): TriggerResult = TriggerResult.CONTINUE
// 与onElement逻辑类似,满足条件则触发计算,否则继续接收
override def onEventTime(time: Long, window: W, ctx: Trigger.TriggerContext): TriggerResult = {
if (ctx.getPartitionedState(stateDesc).value() >= requiredCount || ctx.getCurrentWatermark >= window.maxTimestamp())
TriggerResult.FIRE
else
TriggerResult.CONTINUE
}
// 清空数据
override def clear(window: W, ctx: Trigger.TriggerContext): Unit = {
ctx.getPartitionedState(stateDesc).clear()
ctx.deleteEventTimeTimer(window.maxTimestamp())
}
}
效果演示:
可以看到,当事件时间到达计算得出的窗口最大值[285,300)时,触发了计算
接下来尝试满足数量的条件
输入5条时间戳相同的数据,也同样触发了计算(当继续输入可以因时间条件触发计算的数据时,会再触发一次计算)
Evictor
Evictor为驱逐的意思,即剔除窗口中的某些元素,Evictors
在Trigger
触发后才能剔除元素,可以选择在计算之前剔除还是计算之后剔除。
Flink内置了一些Evictor
Evictor | 工作模式 |
---|---|
CountEvictor | 只保留指定个数的元素,保留最新的几个元素,即当窗口内元素个数达到设定个数时,再接收一条数据时,会将当前窗口内保存的时间最早的一条数据丢弃掉 |
DeltaEvictor | 需要传入一个DeltaFunction 和一个threshold 阈值,当新传入一个条数据时,会根据DeltaFunction 计算最后一条数据与其他数据的之间的增量,若增量大于或等于设定的阈值,就丢弃掉最后一条数据 |
TimeEvictor | 需要传入一个interval (毫秒单位的时间间隔),该Evictor 会找到窗口内最大的时间戳作为max_ts ,所有时间戳小于max_ts - interval 的数据将会被丢弃 |
以上Evictor
的元素剔除逻辑默认均在计算之前剔除,若需要计算之后剔除的,使用doEvictAfter
参数的重载方法,并将doEvictAfter
设置为true
自定义Evictor
需要继承org.apache.flink.streaming.api.windowing.evictors.Evictor
抽象类,需要实现的方法只有两个:evictBefore
和evictAfter
,分别调用于计算之前和之后。下面通过案例代码演示:
private class MyEvictor extends Evictor[SensorReading, TimeWindow] {
private var maxValue: Double = _
def this(max: Double) {
this()
this.maxValue = max
}
// 计算触发前剔除数据(温度超出设定的最大值的部分数据剔除)
override def evictBefore(elements: lang.Iterable[TimestampedValue[SensorReading]], size: Int, window: TimeWindow, evictorContext: Evictor.EvictorContext): Unit = {
val iter: util.Iterator[TimestampedValue[SensorReading]] = elements.iterator()
while (iter.hasNext)
if (iter.next().getValue.temperature > maxValue)
// 调用remove函数剔除指定元素
iter.remove()
}
// 计算触发后剔除元素
override def evictAfter(elements: lang.Iterable[TimestampedValue[SensorReading]], size: Int, window: TimeWindow, evictorContext: Evictor.EvictorContext): Unit = {
val iter: util.Iterator[TimestampedValue[SensorReading]] = elements.iterator()
while (iter.hasNext)
if (iter.next().getValue.temperature % 2 == 0)
iter.remove()
}
}
逻辑比较简单,具体效果就不演示了
AllowedLateness
为了防止数据因设备或网络原因导致数据迟到甚至先发送的数据后接收到,Flink设定了allowedLateness
函数,等待迟到数据,即在原定的窗口结束时间后,不会直接关闭窗口,而是在检测(如果是event time
则视输入的时间而定)到时间达到或超过原定窗口结束时间后一段时间(这段时间就是allowedLateness
中定义的间隔)时,才会关闭窗口。
定义:
kafkaSource.keyBy("id")
.timeWindow(Time.seconds(5))
.allowedLateness(Time.seconds(10))
.max("temperature")
.print("max")
在该案例中,假设收到的数据流前几项(时间单位s,"temperature"省略)为:
0 1 2 5 6 7 3 8 9 10 11 12 13 14 15 4
从左向右表示一条一条的输入,输入0时(假设)确定窗口为[0,5),在0 1 2
时不会输出,因为还未达到该窗口的end time
,当输入5
时,就会被判定为已达到end time
,可以输出结果,其结果就是0 1 2
这三条数据中最大的temperature值;
接下来输入的6 7
属于下一个窗口[5,10),逻辑与第一个类似,我们把重点放在第一个窗口上,当系统再接受的3
时,虽然已经超过了窗口的end tine
,但是由于我们有10秒的允许延迟,即在15秒数据被处理时才会真正关闭窗口,所以3
会被收进窗口内,控制台会再输出一条第一窗口的数据;
直到15
数据被接收和处理,此时[0,5)窗口将被关闭,其后到来的4
数据将无法收到[0,5)窗口内
SideOutputDataStream
侧输出流是Flink针对迟到数据做的又一道保险,由于接收上述的迟到数据以外的数据。即上述案例中的数据3
将被发送到侧输出流中。
使用方式:
// 定义标记侧输出流所需的OutputTag
val tag = new OutputTag[SensorReading]("late")
val res: DataStream[SensorReading] = kafkaSource.keyBy("id")
.timeWindow(Time.seconds(3))
.allowedLateness(Time.seconds(10))
.sideOutputLateData(tag) // 将窗口关闭后的迟到数据存入侧输出流
.max("temperature")
res.print("max")
res.getSideOutput(tag).print("late") // 取出侧输出流数据
效果演示:
上述第一条数据的时间戳为1624864851297
,根据上文的计算可以得到第一个窗口为[1624864851297,1624864851300)
,允许延迟时间10s,即当1624864851310
及其之后的数据被接收处理时,第一个窗口将关闭。可以看到,在窗口关闭之后传入的1624864851299
数据属于第一个窗口,被放入到了侧输出流。