Flink-2(窗口)

窗口定义

窗口是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(数据处理时间)
  • 使用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超过窗口最大时间时,将会触发计算
ProcessingTimeTriggerEventTimeTrigger类似,区别在于基于事件处理时间
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为驱逐的意思,即剔除窗口中的某些元素,EvictorsTrigger触发后才能剔除元素,可以选择在计算之前剔除还是计算之后剔除。

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抽象类,需要实现的方法只有两个:evictBeforeevictAfter,分别调用于计算之前和之后。下面通过案例代码演示:

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数据属于第一个窗口,被放入到了侧输出流。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值