在本章中,您将了解用于时间处理和基于时间的运算符的DataStream API方法,例如window。正如您在第2章中学到的,Flink中的基于时间的操作符【time-based operators】可以应用于不同的时间概念。

在本章中,您将首先学习如何定义时间特征、时间戳和水印。 然后,您将了解ProcessFunction,它是一种低级转换,提供了对数据记录的时间戳和水印的访问,并可以注册定时器。 接下来,您将使用Flink的窗口【window】API,它提供了对最常见的窗口类型的内置实现。您还将了解自定义、用户定义的窗口操作以及核心窗口构造组件,如分配器、触发器和回收器。最后,我们将讨论处理延迟事件的策略。

6.1 配置时间特征【Time Characteristics】

正如您在第2章中看到的,在分布式流处理程序中定义时间窗口操作时,理解时间的含义非常重要。当您指定一个窗口,用来在一个一分钟的bucket中收集事件时,每个bucket究竟将包含哪些事件呢?在DataStream API中,您可以使用时间特征【Time Characteristics】来指示Flink在创建窗口时如何推断时间。时间特征【Time Characteristics】是StreamExecutionEnvironment的一个属性,它接受以下值:

  • ProcessingTime:意味着操作符使用其执行时所在的机器的系统时钟,来决定当前数据流的当前时间。处理时间窗口【Processing-time windows】根据机器时间触发,并包含在此时间点之前到达该操作符的任何数据元素。通常,对窗口操作【window operations】使用处理时间【processing time】会导致不确定的结果,因为窗口内的内容取决于元素到达的速度。从好的方面来讲,这个设置提供了非常低的延迟,因为不存在操作必须等待的无序数据。
  • EventTime:事件时间意味着操作符通过使用来自数据自身的信息来确定当前时间。每个事件都带有一个时间戳,系统的逻辑时间由水印定义。 正如您在第3章中看到的那样,在进入数据处理管道之前,时间戳就已经存在于数据中了,或者由应用程序在数据源/发生器【source】处对时间戳进行了分配。 当水印通知该基于事件时间的窗口已经接收到特定时间间隔的所有时间戳时,即会触发事件时间窗口。事件事件窗口【Event-time windows】的结果是确定性的(这正与ProcessingTime相反),即使事件无序到达 ,窗口结果将始终保持一致,并且与读取或处理流的速度无关。
  • IngestionTime:摄取时间是EventTime和ProcessingTime的混合体。事件的摄取时间指的是它进入流处理器的时间。您可以将摄入时间视为将数据源/发生器操作符的处理时间作为事件的时间戳分配给每个摄入的数据记录。与事件时间相比,摄入时间没有多大实用价值,因为它无法提供结果的确定性,但它具有与事件时间类似的性能影响。

在清单6-1中,我们可以看到如何通过重新访问在第5章中编写的传感器流应用程序代码来设置时间特征。

object AverageSensorReadings {

  // main() defines and executes the DataStream program
  def main(args: Array[String]) {

    // set up the streaming execution environment
    val env = StreamExecutionEnvironment.getExecutionEnvironment
    
    // use event time for the application
    env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)

  // ingest sensor stream
  val sensorData: DataStream[SensorReading] = env.addSource(...)
 }
}

Example 6-1. Setting the time characteristic to event time.

将时间特征【time characteristic】设置为EventTime,将启用记录时间戳和水印处理,从而实现事件时间窗口或操作。当然,即使您选择了EventTime时间特性,您仍然可以使用处理时间窗口【processing-time windows】和定时器【timer】。

如果要使用处理时间【processing time】作为时间特征【time characteristic】,请将代码中的使用TimeCharacteristic.EventTime替换为TimeCharacteristic.ProcessingTime。

Timestamps and watermarks for event-time applications

正如在第3章中Event Time Processing所讨论的,您的应用程序需要为Flink提供两个重要的信息,以便在事件发生时进行操作。每个事件必须与一个时间戳关联,该时间戳通常指示事件实际发生的时间。此外,事件时间流需要携带水印,这样操作人符就可以从中推断出当前事件时间。

时间戳和水印都是通过用自1970-01-01T00:00:00Z开始以来的毫秒数来指定的。水印告诉操作符,必须不再期望出现时间戳小于或等于水印的事件。时间戳和水印可以由SourceFunction分配和生成,也可以使用用户定义的显式的时间戳分配器和水印生成器来生成。第8章将讨论在SourceFunction中如何分配时间戳和生成水印。在这里,我们将解释如何使用用户自定义的函数来实现这一点。

NOTE:如果使用时间戳分配程序,则将覆盖任何现有的时间戳和水印。

DataStream API提供了一个TimestampAssigner接口,用于在数据元素进入流式应用程序后从元素中提取时间戳。通常,在源函数之后会立即调用时间戳分配器。这是因为大多数分配器根据数据元素的时间戳对元素的顺序进行假设,以生成水印。由于元素通常是并行摄取的,因此任何导致Flink跨并行流分区重新分发元素的操作(如并行度改变、keyBy()或其他显式的重新分发)都会打乱元素的时间戳顺序。

最好的做法是分配的时间戳和生成的水印尽可能的接近于数据源,甚至在SourceFunction中生成水印。根据用例,如果这样的操作不引起元素的重新分配,例如通过改变并行度,则可以在分配时间戳之前对输入流应用初始过滤或变换。

为了确保事件时间操作按预期运行,应在任何依赖于事件时间的转换之前调用分配器,例如, 在第一个事件时间窗口之前。

时间戳分配器的行为与其他转换操作符类似。它们在一个数据元素流上调用,并生成一个新的数据元素流,流中的数据是带有时间戳的元素和水印。请注意,如果输入流已包含时间戳和水印,那么它们将被时间戳分配器替换。

例6-2中的代码显示了如何使用时间戳分配器。 在此示例中,在读取流之后,我们首先应用一个过滤器转换,然后调用assignTimestampsAndWatermarks()方法,在该方法中定义时间戳分配器MyAssigner()。注意,如何分配时间戳和水印不会更改数据流的类型。

val env = StreamExecutionEnvironment.getExecutionEnvironment

// set the event time characteristic
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)

// ingest sensor stream
val readings: DataStream[SensorReading] = env
  .addSource(new SensorSource)
  // assign timestamps and generate watermarks
  .assignTimestampsAndWatermarks(new MyAssigner())

Example 6-2. Using a timestamp assigner.

在上面的示例中,MyAssigner可以是AssignerWithPeriodicWatermarks类型或AssignerWithPunctuatedWatermarks类型。 这两个接口扩展了DataStream API提供的TimestampAssigner。 第一个接口允许定义周期性发出水印的分配器,而第二个接口允许根据输入事件的属性注入水印。 我们接下来详细描述两个接口。

ASSIGNER WITH PERIODIC WATERMARKS

周期性地分配水印意味着我们指示系统以固定的机器时间间隔检查事件时间的进度。 默认时间间隔设置为200毫秒,但是可以使用ExecutionConfig.setAutoWatermarkInterval()方法配置它,如示例6-3所示。

val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
// generate watermarks every 5 seconds
env.getConfig.setAutoWatermarkInterval(5000)

Example 6-3. Assigning periodic watermarks.

在上面的示例中,您指示程序每5秒检查一次当前水印值。 实际上,Flink每5秒调用一次AssignerWithPeriodicWatermarks的getCurrentWatermark()方法。 如果该方法返回非空值的时间戳大于前一个水印的时间戳,则转发新的水印。 请注意,此检查对于确保事件时间持续增加是必要的。否则,如果方法返回一个空值,或者返回的水印的时间戳小于最后发出的水印的时间戳,则不会生成水印。

示例6-4显示了一个具有周期性时间戳的分配程序,它通过跟踪到目前为止所看到的最大元素时间戳来生成水印。当被要求获得一个新的水印时,分配器返回一个水印,该水印是使用最大时间戳减去1分钟容忍间隔来计算所得。

class PeriodicAssigner 
    extends AssignerWithPeriodicWatermarks[SensorReading] {

  val bound: Long = 60 * 1000     // 1 min in ms
  var maxTs: Long = Long.MinValue // the maximum observed timestamp

  override def getCurrentWatermark: Watermark = {
    // generated watermark with 1 min tolerance
    new Watermark(maxTs - bound)
  }

  override def extractTimestamp(
      r: SensorReading, 
      previousTS: Long): Long = {
    // update maximum timestamp
    maxTs = maxTs.max(r.timestamp)
    // return record timestamp
    r.timestamp
  }
}

Example 6-4. An periodic watermark assigner.
DataStream API为具有周期性水印的时间戳分配程序的两种常见情况提供了实现。如果您的输入元素具有单调递增的时间戳,则可以使用快捷方法assignrising时间戳。此方法使用当前时间戳生成水印,因为不能出现更早的时间戳。示例6-5显示了如何为升序时间戳生成水印。
DataStream API为具有周期性水印的时间戳分配器的两种常见情况提供了实现。 如果输入数据元素的时间戳单调递增,则可以使用快捷方法assignAscendingTimestamps。 此方法使用当前时间戳生成水印,因为不能出现更早的时间戳。示例6-5显示了如何为升序时间戳生成水印。

val stream: DataStream[MyEvent] = ...
val withTimestampsAndWatermarks = stream
      .assignAscendingTimestamps(e => e.getCreationTime)

Example 6-5. A shortcut to generate watermarks for elements with monotonically increasing timestamps.

生成周期性水印的另一种常见情况是,当您知道输入流中将会遇到的最大延迟时,即元素的时间戳与所有先前摄取的元素的最大时间戳之间的最大差异。 对于这种情况,Flink提供BoundedOutOfOrdernessTimestampExtractor,它将最大预期延迟作为参数。

val stream: DataStream[MyEvent] = ...
val output = stream.assignTimestampsAndWatermarks(
 new BoundedOutOfOrdernessTimestampExtractor[MyEvent](
   Time.seconds(10))( _.getCreationTime)

Example 6-6. Generating watermarks with a bounded tolerance interval.

在例6-6中,允许元素延迟10秒。 也就是说,如果数据元素的事件时间与先前所有元素的最大时间戳之间的差值大于10秒,则元素可能在相关计算完成并发出结果之后到达。 Flink提供了处理此类延迟事件的不同策略,我们将在本章后面讨论这些策略。

ASSIGNER WITH PUNCTUATED WATERMARKS

有时输入流包含特殊的元组或标记,用于指示流的进度。对于这种情况,或者可以根据输入元素的某些其他属性定义水印时,Flink提供AssignerWithPunctuatedWatermarks。该接口包含checkAndGetNextWatermark()方法,该方法在extractTimestamp()之后立即为每个事件调用。该方法可以决定是否生成新的水印。如果方法返回的非空水印大于最新发出的水印,则会发出新水印。

示例6-7显示了一个带标记的水印分配器,它为从id为“sensor_1”的传感器上读取的每个读数发出水印。

class PunctuatedAssigner 
    extends AssignerWithPunctuatedWatermarks[SensorReading] {

  val bound: Long = 60 * 1000 // 1 min in ms

  override def checkAndGetNextWatermark(
      r: SensorReading, 
      extractedTS: Long): Watermark = {
    if (r.id == "sensor_1") {
      // emit watermark if reading is from sensor_1
      new Watermark(extractedTS - bound)
    } else {
      // do not emit a watermark
      null
    }
  }

  override def extractTimestamp(
      r: SensorReading, 
      previousTS: Long): Long = {
    // assign record timestamp
    r.timestamp
  }
}

Example 6-7. A punctuated watermark assigner.

Watermarks, Latency, and Completeness

到目前为止,我们讨论了如何使用TimestampAssigner生成水印。我们还没有讨论的是水印对流式应用程序的影响。

水印是一种权衡结果延迟和结果完整性的机制。 它们控制在执行计算之前等待数据到达的时间,例如完成窗口计算并发出结果。 基于事件时间的操作员使用水印来确定其摄取记录的完整性及其操作的进度。 基于水印,操作符计算一个时间点,在这个时间点上,它期望接收到所有具有较小时间戳的记录。

然而,分布式系统的现实是我们永远不会有完美的水印。 这意味着我们始终确定没有延迟记录。 实际上,您需要进行有根据的猜测,并使用启发式在应用程序中生成水印。 通常,您需要使用有关数据源,网络,分区的任何信息来估计进度,还可能需要使用输入记录的延迟上限。 估计意味着存在错误的空间,在这种情况下,您可能生成不准确的水印,从而导致数据延迟或不必要地增加应用程序的延迟。 考虑到这一点,您可以使用水印来权衡应用程序的结果延迟和结果完整性。

如果生成松散的水印,即水印远远落后于已处理记录的时间戳,则会增加生成结果的延迟。 你可以早点生成一个结果,但你必须等待水印。 此外,状态大小通常会增加,因为应用程序需要缓冲更多数据,直到它可以执行计算。 然而,当您执行计算时,您可以非常确定所有相关的数据都是可用的。

另一方面,如果你产生非常紧密的水印,即—如果水印可能大于某些后来到达的记录的时间戳,则可能在所有相关数据到达之前执行基于时间的操作。 在执行计算之前,您应该等待更长时间才能收到延迟事件。 虽然这可能会产生不完整或不准确的结果,但结果会以较低的延迟及时生成。

延迟–完整性之间的权衡是流处理的基本特征,与批处理应用程序无关,批处理应用程序是围绕所有数据可用的前提构建的。 水印是一种强大的功能,可以控制应用程序的行为与时间的关系。 除了水印之外,Flink还提供了许多旋钮来调整基于时间的操作的确切行为,例如窗口触发器和ProcessFunction,并提供了处理后期数据的不同方法,例如,在执行计算后到达的元素。我们将在本章末尾的专门部分中讨论这些特性。

6.2 Process Functions

尽管时间信息和水印对许多流式应用程序都很重要,但您可能已经注意到,我们无法通过目前为止看到的基本DataStream API转换访问它们。例如,一个MapFunction不能访问与时间相关的构造。

DataStream API提供了一系列底层转换,即流程函数【Process Functions】,这些函数还可以访问数据记录的时间戳和水印,并注册可以在将来某个特定时间触发的计时器【timer】。此外,流程函数【Process Functions】具有侧输出【side outputs】特性,可将记录发送到多个输出流。流程函数【Process Functions】通常用于构建事件驱动的应用程序,并实现可能不适合预定义窗口和转换的自定义逻辑。例如,Flink中支持SQL的大多数操作符都是使用流程函数【Process Functions】实现的。

目前,Flink提供8种不同的流程函数【Process Functions】:ProcessFunction、KeyedProcessFunction、CoProcessFunction、ProcessJoinFunction、BroadcastProcessFunction、KeyedBroadcastProcessFunction、ProcessWindowFunction和ProcessAllWindowFunction。顾名思义,这些函数适用于不同的上下文中。但是,它们具有非常相似的特性集。我们将通过详细讨论KeyedProcessFunction继续讨论这些常见特性。

KeyedProcessFunction是一个非常通用的函数,可以应用于KeyedStream。为流的每个数据记录应用该函数,并返回零条、一条或多条数据记录。所有流程函数【Process Functions】都实现RichFunction接口,因此提供了open()和close()方法。此外,一个KeyedProcessFunction[KEY, IN, OUT]还提供了以下两种方法:

  • processElement(v: IN, ctx: Context, out: Collector[OUT]):为流的每个记录调用。与往常一样,通过将结果记录传递给Collector【收集器】来发出结果记录。Context 对象是使得ProcessFunction变得特殊之处。它允许访问时间戳和当前记录的键以及TimerService。此外,Context 还可以将记录发送到侧输出【side outputs】。
  • onTimer(timestamp: Long, ctx: OnTimerContext, out: Collector[OUT]):这是一个回调函数,当先前注册的计时器触发时将调用该回调函数。timestamp参数给出触发计时器的时间戳,Collector 允许发出记录。OnTimerContext提供与processElement()方法中的Context对象相同的服务,并且它还返回触发触发器的时间域【time domain】(处理时间或事件时间)。

6.2.1 The TimerService and Timers

Context和OnTimerContext对象的TimerService提供了以下方法:

  • currentProcessingTime():Long 返回当前处理时间
  • currentWatermark():Long 返回当前水印的时间戳
  • registerProcessingTimeTimer(timestamp: Long): Unit 为当前键注册一个处理时间计时器。当执行机器的处理时间达到所提供的时间戳时,计时器将触发。
  • registerEventTimeTimer(timestamp: Long): Unit 为当前键注册事件时间计时器。当水印被更新为与计时器的时间戳相等或更大的时间戳时,计时器将触发。
  • deleteProcessingTimeTimer(timestamp: Long): Unit 删除先前为当前键注册的处理时间计时器。如果不存在这样的计时器,则该方法无效果。
  • deleteEventTimeTimer(timestamp: Long): Unit 删除先前为当前键注册的事件时间计时器。如果不存在这样的计时器,则该方法无效果。

当计时器触发时,将调用onTimer()回调函数。processElement()和onTimer()方法是同步的,以防止并发访问和状态操作。注意,定时器只能在键控流上注册。定时器的一个常见用例是在某个键失活一段时间后清除键控状态,或者实现基于时间的自定义窗口逻辑。

NOTE:TIMERS ON NON-KEYED STREAMS
要在非键控流上使用计时器,可以使用带有常量虚拟键的KeySelector创建一个键控流。请注意,这会将所有数据移动到单个任务,以便以1的并行度有效地执行操作符。

对于每个键和时间戳,可以注册一个计时器,即,每个键可以有多个计时器,但每个时间戳之下只能有一个计时器。默认情况下,KeyedProcessFunction在堆上的一个优先级队列中,保存这所有计时器的时间戳。不过,您可以配置RocksDB状态后端,还可以在RocksDB中存储计时器。

定时器与函数的任何其他状态一起被检查点。如果应用程序需要从故障中恢复,则在应用程序重新启动时,过期的所有处理时间计时器将在应用程序恢复时立即触发。对于保存在保存点中的处理时间计时器也是如此。定时器总是异步检查点,但是有一个例外。如果您使用带有增量检查点的RocksDB状态后端,并将计时器存储在堆上(默认设置),那么检查点就是同步的。在这种情况下,建议不要过度使用计时器,以避免长时间的检查点。

NOTE:REGISTERING TIMERS IN THE PAST
当我们所注册的计时器所对应的时间是一个已过去的时间,那么它也是会被处理的。如果我们注册时一个处理时间计时器,那么当注册方法返回后就会立即出发;如果我们注册的是一个时间时间计时器,那么在下一个水印到达时立即出发。

例6-8显示了如何将KeyedProcessFunction应用于KeyedStream。该函数监控传感器的温度,如果传感器的温度在1秒的处理时间内一直处于单调递增,那么就会发出警告。

val warnings = readings
  // key by sensor id
  .keyBy(_.id)
  // apply ProcessFunction to monitor temperatures
  .process(new TempIncreaseAlertFunction)

Example 6-8. Applying a KeyedProcessFunction.

KeyedProcessFunction的实现如例6-9所示。

/** Emits a warning if the temperature of a sensor
  * monotonically increases for 1 second (in processing time).
  */
class TempIncreaseAlertFunction
    extends KeyedProcessFunction[String, SensorReading, String] {
  // stores temperature of last sensor reading
  lazy val lastTemp: ValueState[Double] = getRuntimeContext.getState(
      new ValueStateDescriptor[Double]("lastTemp", Types.of[Double]))
  // stores timestamp of currently active timer
  lazy val currentTimer: ValueState[Long] = getRuntimeContext.getState(
      new ValueStateDescriptor[Long]("timer", Types.of[Long]))

  override def processElement(
      r: SensorReading,
      ctx: KeyedProcessFunction[String, SensorReading, String]#Context,
      out: Collector[String]): Unit = {
    // get previous temperature
    val prevTemp = lastTemp.value()
    // update last temperature
    lastTemp.update(r.temperature)

    val curTimerTimestamp = currentTimer.value();
    if (prevTemp == 0.0 || r.temperature < prevTemp) {
      // temperature decreased. Delete current timer.
      ctx.timerService().deleteProcessingTimeTimer(curTimerTimestamp)
      currentTimer.clear()
    } else if (r.temperature > prevTemp && curTimerTimestamp == 0) {
      // temperature increased and we have not set a timer yet.
      // set processing time timer for now + 1 second
      val timerTs = ctx.timerService().currentProcessingTime() + 1000
      ctx.timerService().registerProcessingTimeTimer(timerTs)
      // remember current timer
      currentTimer.update(timerTs)
    }
  }

  override def onTimer(
      ts: Long,
      ctx: KeyedProcessFunction[String, SensorReading, String]#OnTimerContext,
      out: Collector[String]): Unit = {
    out.collect("Temperature of sensor '" + ctx.getCurrentKey +
      "' monotonically increased for 1 second.")
    currentTimer.clear()
  }
}

Example 6-9. A KeyedProcessFunction that emits a warning if the temperature of a sensor monotonically increases for 1 second in processing time.

6.2.2 Emitting to Side Outputs

DataStream API的大多数操作符都只有一个输出,即它们生成一个具有特定数据类型的结果流。只有split 操作符允许将流拆分为多个相同类型的流。侧输出【 Side Outputs】是一种从可以从一个函数中输出多种不同类型的输出流的机制。除主输出之外的侧输出的数量不受限制。每个单独的侧输出【Side Outputs】由OutputTag [X]对象标识,该对象使用侧输出【Side Outputs】流的类型X和标识侧输出【Side Outputs】的名字进行实例化。 ProcessFunction可以通过Context对象向一个或多个侧输出发出记录

【Docs:摘自官方文档】
除了DataStream操作产生的主流之外,还可以生成任意数量的额外的侧输出结果流。结果流中的数据类型不必匹配于主流中的数据类型,不同侧输出【 Side Outputs】的类型也可以不同。当您希望分割一个数据流时,这个操作非常有用,通常您必须复制这个数据流,然后从每个数据流中过滤出您不想要的数据。

示例6-10显示了如何应用ProcessFunction以及如何访问侧输出【Side Outputs】的数据流。

val monitoredReadings: DataStream[SensorReading] = readings
  // monitor stream for readings with freezing temperatures
  .process(new FreezingMonitor)

// retrieve and print the freezing alarms
monitoredReadings
  .getSideOutput(new OutputTag[String]("freezing-alarms"))
  .print()

// print the main output
readings.print()

Example 6-10. Applying a ProcessFunction that emits to a side output.

例6-11显示了一个ProcessFunction,它监视传感器读数流,当读取到的温度低于32华氏度时,它向侧输出【 Side Outputs】发出警报消息。

例6-11显示了一个ProcessFunction,它监视传感器读数流并向侧输出发出警告,读取温度低于32F的读数。

/** Emits freezing alarms to a side output for readings 
  * with a temperature below 32F. */
class FreezingMonitor extends ProcessFunction[SensorReading, SensorReading] {

  // define a side output tag
  lazy val freezingAlarmOutput: OutputTag[String] =
    new OutputTag[String]("freezing-alarms")

  override def processElement(
      r: SensorReading,
      ctx: ProcessFunction[SensorReading, SensorReading]#Context,
      out: Collector[SensorReading]): Unit = {
    // emit freezing alarm if temperature is below 32F.
    if (r.temperature < 32.0) {
      ctx.output(freezingAlarmOutput, s"Freezing Alarm for ${r.id}")
    }
    // forward all readings to the regular output
    out.collect(r)
  }
}

Example 6-11. A ProcessFunction that monitors the temperatures of sensors and emits a warning to a side output if the temperature of a sensor is below 32F.

6.2.3 The CoProcessFunction

对于两个输入的低级操作,Datastream API还提供CoProcessFunction。与CoFlatMapFunction类似,CoProcessFunction为每个输入提供转换方法,即processElement1()和processElement2()。与ProcessFunction类似,这两个方法都具有Context对象作为入参,该对象提供对元素或计时器时间戳,TimerService和侧输出的访问。 CoProcessFunction还提供了一个onTimer()回调方法。

示例6-12显示了如何应用CoProcessFunction来组合两个流。

// ingest sensor stream
val sensorData: DataStream[SensorReading] = ...

// filter switches enable forwarding of readings
val filterSwitches: DataStream[(String, Long)] = env
  .fromCollection(Seq(
    ("sensor_2", 10 * 1000L), // forward sensor_2 for 10 seconds
    ("sensor_7", 60 * 1000L)) // forward sensor_7 for 1 minute
  )

val forwardedReadings = readings
  // connect readings and switches
  .connect(filterSwitches)
  // key by sensor ids
  .keyBy(_.id, _._1)
  // apply filtering CoProcessFunction
  .process(new ReadingFilter)

Example 6-12. Applying a CoProcessFunction.

示例6-13中显示了一个CoProcessFunction的实现,该函数根据一个过滤开关流动态过滤传感器读数流:

class ReadingFilter
    extends CoProcessFunction[SensorReading, (String, Long), SensorReading] {

  // switch to enable forwarding
  lazy val forwardingEnabled: ValueState[Boolean] = getRuntimeContext.getState(
      new ValueStateDescriptor[Boolean]("filterSwitch", Types.of[Boolean]))

  // hold timestamp of currently active disable timer
  lazy val disableTimer: ValueState[Long] = getRuntimeContext.getState(
      new ValueStateDescriptor[Long]("timer", Types.of[Long]))

  override def processElement1(
      reading: SensorReading,
      ctx: CoProcessFunction[SensorReading, (String, Long), SensorReading]#Context,
      out: Collector[SensorReading]): Unit = {
    // check if we may forward the reading
    if (forwardingEnabled.value()) {
      out.collect(reading)
    }
  }

  override def processElement2(
      switch: (String, Long),
      ctx: CoProcessFunction[SensorReading, (String, Long), SensorReading]#Context,
      out: Collector[SensorReading]): Unit = {
    // enable reading forwarding
    forwardingEnabled.update(true)
    // set disable forward timer
    val timerTimestamp = ctx.timerService().currentProcessingTime() + switch._2
    val curTimerTimestamp = disableTimer.value()
      if (timerTimestamp > curTimerTimestamp) {
      // remove current timer and register new timer
      ctx.timerService().deleteEventTimeTimer(curTimerTimestamp)
      ctx.timerService().registerProcessingTimeTimer(timerTimestamp)
      disableTimer.update(timerTimestamp)
    }
  }

  override def onTimer(
      ts: Long,
      ctx: CoProcessFunction[SensorReading, (String, Long), SensorReading]#OnTimerContext,
      out: Collector[SensorReading]): Unit = {
    // remove all state. Forward switch will be false by default.
    forwardingEnabled.clear()
    disableTimer.clear()
  }
}

Example 6-13. Implementation of a CoProcessFunction that dynamically filters a stream of sensor readings.

6.3 Window Operators

Windows是流式应用程序中的常见操作。窗口支持对无界流的有界区间进行转换,不如聚合。通常,这些间隔是使用基于时间的逻辑【time-based logic】定义的。窗口操作符提供了一种将事件分组到有限大小的桶中的方法,并对这些桶的有界内容应用计算。例如,窗口操作符可以将一个流的事件分组为5分钟的窗口,并计算每个窗口已经接收了多少事件。
DataStream API为最常见的窗口操作提供了内置方法,并提供了非常灵活的窗口机制来定义定制的窗口逻辑。在本节中,我们将向您展示如何定义窗口操作符,展示DataStream API的内置窗口类型,讨论可以应用于窗口的函数,最后解释如何定义自定义窗口逻辑。

6.3.1 定义窗口操作符:Defining Window Operators

窗口操作符可以应用于键控流或非键控流。键控窗口上的窗口操作符是并行计算的,非键控窗口在一个线程中处理。

要创建窗口操作符,需要指定两个窗口组件。

  1. 一个Window Assigner:该分配器用于确定输入流的元素如何分组到窗口中。窗口分配器会生成一个WindowedStream(如果应用于非键数据流,则生成AllWindowedStream)。
  2. 一个Window Function :该函数应用于WindowedStream (或AllWindowedStream),并处理分配给窗口的元素。

示例6-14展示了如何在键控或非键控流上指定窗口分配器【Window Assigner】和窗口函数【Window Function】。

// define a keyed window operator
stream
  .keyBy(...)                 
  .window(...)                   // specify the window assigner
  .reduce/aggregate/process(...) // specify the window function

// define a non-keyed window-all operator
stream
  .windowAll(...)                // specify the window assigner
  .reduce/aggregate/process(...) // specify the window function

Example 6-14. Defining a window operator

在本章的其余部分,我们只关注键控窗口。非键控窗口(在DataStream API中也称为all-windows)的行为完全相同,只是它们不是并行计算的。

注意,您可以通过提供自定义触发器【Trigger】或收回器【Evictor 】以及声明如何处理延迟元素的策略来自定义窗口运算符。本节稍后将详细讨论自定义窗口操作符。

6.3.2 内置窗口分配器:Built-in Window Assigners

Flink为最常见的窗口用例提供了内置的窗口分配器。我们在这里讨论的所有分配器都是基于时间的。基于时间的窗口分配器根据元素的事件时间戳或当前处理时间向窗口分配元素。时间窗口有一个开始和一个结束时间戳。

所有内置的windows分配器都提供一个默认触发器,一旦(处理或事件)时间超过窗口的结束时间时,该触发器触发对窗口的计算。 请务必注意,在为其分配第一个元素时会创建一个窗口。因此,Flink永远不会计算空窗口。

NOTE:Count-based Windows
除了基于时间的窗口,Flink还支持基于计数的窗口,即,将固定数量的元素按它们到达窗口操作符的顺序分组。由于它们依赖于摄入顺序,基于计数的窗口通常是不确定的。此外,如果在没有自定义触发器的情况下使用它们,可能会导致问题,因为自定义触发器会在某些时候丢弃不完整和陈旧的窗口。

Flink的内置窗口分配器创建类型为TimeWindow的窗口。此窗口类型本质上表示两个时间戳之间的时间间隔,其中前闭后开(即包含start时间戳,但不包含end时间戳)。它功暴露了许多有用的方法,帮助我们检出窗口边界、检查窗口是否相交以及合并重叠窗口。

在下文中,我们将展示DataStream API的不同内置窗口分配器以及如何使用它们来定义窗口操作符。

TUMBLING WINDOWS

翻滚窗口【tumbling window】分配器将元素放置到非重叠的固定大小的窗口,如图6-1所示。

flink输出到文件中 flink print 输出内容的含义_big data


Figure 6-1. A Tumbling windows assigner places elements into fixed-size, non-overlapping windows.

Datastream API为翻滚的事件时间窗口和处理时间窗口分别提供了两个分配器,TumblingEventTimeWindows和TumblingProcessingTimeWindows 。翻滚窗口【tumbling window】分配器接收一个参数,该参数是以时间单位为单位的窗口大小,我们可以使用通过分配器的of(Time size)方法指定。时间间隔可以设置为毫秒、秒、分钟、小时或天。

示例6-15和示例6-16展示了如何在传感器数据测量流上定义事件时间和处理时间的翻滚窗口。

val sensorData: DataStream[SensorReading] = ...

val avgTemp = sensorData
.keyBy(_.id)
// group readings in 1s event-time windows
.window(TumblingEventTimeWindows.of(Time.seconds(1)))
.process(new TemperatureAverager)

Example 6-15. Defining an event-time tumbling windows assigner.

val avgTemp = sensorData
.keyBy(_.id)
// group readings in 1s processing-time windows
.window(TumblingProcessingTimeWindows.of(Time.seconds(1)))
.process(new TemperatureAverager)

Example 6-16. Defining a processing-time tumbling windows assigner.

如果您还记得我们在第二章的“Operations on data streams”的示例,你会发现在窗口定义上看起来有点不同。当时,我们使用timeWindow(size)方法定义了一个事件时间翻转窗口,它是window.(TumblingEventTimeWindows.of(size))或window.(TumblingProcessingTimeWindows.of(size))的快捷方式,具体取决于配置的时间特性。

val avgTemp = sensorData
.keyBy(_.id)
// shortcut for window.(TumblingEventTimeWindows.of(size))
.timeWindow(Time.seconds(1))
.process(new TemperatureAverager)

Example 6-17. Defining a tumbling windows assigner with a shortcut.

默认情况下,翻滚窗口与纪元时间对齐,即1970 - 01 - 01 - 00:00:00.000。例如,一个大小为1小时的委派者将在00:00:00、01:00:00、02:00:00定义窗口,依此类推。或者,您可以指定偏移量作为分配程序中的第二个参数。示例6-18中的示例显示了从00:15:00、01:15:00、02:15:00等开始的偏移量为15分钟的窗口。

例如,窗口大小为1小时的分配器将在00:00:00,01:00:00,02:00:00定义窗口,依此类推。 或者,您可以将偏移量指定为分配器中的第二个参数。 例6-18中的示例显示了从00:15:00,01:15:00,02:15:00等开始的偏移量为15分钟的窗口。

val avgTemp = sensorData
.keyBy(_.id)
// group readings in 1 hour windows with 15 min offset
.window(TumblingEventTimeWindows.of(Time.hours(1), Time.minutes(15)))
.process(new TemperatureAverager

Example 6-18. Defining a tumbling windows assigner with an offset.

SLIDING WINDOWS

滑动窗口分配器将流元素放置到可能重叠的固定大小的窗口中,如图6-2所示。

flink输出到文件中 flink print 输出内容的含义_big data_02


Figure 6-2. A Sliding windows assigner places elements into fixed-size, possibly overlapping windows.

对于滑动窗口,必须指定窗口大小和滑动间隔,以定义新窗口启动的频率。当滑动间隔小于窗口大小时,窗口重叠并将元素分配给多个窗口。如果滑动间隔的大小大于窗口的大小,一些元素可能不会被分配到任何窗口,因此会被删除。

示例6-19显示了如何在滑动窗口中对传感器读数进行分组,滑动窗口的窗口大小为1小时,滑动间隔为15分钟。每个传感器读数将被添加到四个窗口中。DataStream API提供事件时间分配器和处理时间分配器,以及对应的快捷方法,同时可以将时间间隔偏移量设置为窗口分配器的第三个参数。

// event-time sliding windows assigner
val slidingAvgTemp = sensorData
.keyBy(_.id)
// create 1h event-time windows every 15 minutes
.window(SlidingEventTimeWindows.of(Time.hours(1), Time.minutes(15)))
.process(new TemperatureAverager)

// processing-time sliding windows assigner
val slidingAvgTemp = sensorData
.keyBy(_.id)
// create 1h processing-time windows every 15 minutes
.window(SlidingProcessingTimeWindows.of(Time.hours(1), Time.minutes(15)))
.process(new TemperatureAverager)

// sliding windows assigner using a shortcut method
val slidingAvgTemp = sensorData
.keyBy(_.id)
// shortcut for window.(TumblingEventTimeWindows.of(size))
.timeWindow(Time.hours(1), Time(minutes(15)))
.process(new TemperatureAverager

Example 6-19. Defining a sliding windows assigner.

SESSION WINDOWS

会话窗口分配器将元素放置到没有固定大小的活动的非重叠窗口中。会话窗口的边界由失活的间隙定义,即,不接收记录的时间间隔。图6-3说明了如何将元素分配到会话窗口。

flink输出到文件中 flink print 输出内容的含义_scala_03


Figure 6-3. A Session windows assigner places elements into varying-size, windows defined by a session gap.

下面的例子展示了如何将传感器读数分组到会话窗口,其中会话定义为15分钟的失活时间:

// event-time session windows assigner
val sessionWindows = sensorData
.keyBy(_.id)
// create event-time session windows with a 15 min gap
.window(EventTimeSessionWindows.withGap(Time.minutes(15)))
.process(...)

// processing-time session windows assigner
val sessionWindows = sensorData
.keyBy(_.id)
// create processing-time session windows with a 15 min gap
.window(ProcessingTimeSessionWindows.withGap(Time.minutes(15)))
.process(...)

Example 6-20. Defining a session windows assigner.

由于会话窗口没有预定义的开始和结束时间戳,所以窗口分配器无法立即将它们分配到正确的窗口。因此,SessionWindow分配器最初将每个传入元素映射到其自己的窗口中,以元素的时间戳作为开始时间,会话间隔作为窗口大小。 随后,它合并所有具有重叠范围的窗口。

6.3.3 Applying Functions on Windows

窗口函数定义对窗口中的元素执行的计算。有两种类型的函数可以应用于窗口。

  1. Incremental Aggregation Functions:当元素被添加到窗口时,将直接对该元素应用增量聚合函数,该函数持有一个称之为窗口状态的值,并在应用到元素时更新该值。这些函数通常非常节省空间,并最终产生出一个聚合值。ReduceFunction和AggregateFunction就是增量聚合函数的例子。
  2. Full Window Functions:全窗口函数收集窗口的所有元素,并在计算时对收集了所有数据元素的列表进行遍历。全窗口函数通常需要占用更多的空间,但是允许比增量聚合函数更复杂的逻辑。ProcessWindowFunction就是一个全窗口函数。
    在本节中,我们将讨论可以应用于窗口的不同类型的函数,以便对窗口的内容执行聚合或任意计算。我们还展示了如何在窗口操作符中联合应用增量聚合和全窗口函数。
ReduceFunction

我们在第五章的“KeyedStream Transformations”中讨论在键控流上运行聚合时候,首次提到了ReduceFunction 。ReduceFunction接收两个同一类型的值,并将它们组合成同一类型的单个值。当该函数被应用于窗口化的流时,ReduceFunction增量地聚合分配给窗口的元素。窗口只存储聚合的当前结果(不存储聚合历史)。当接收到新元素时,使用新元素调用ReduceFunction,并从窗口状态读取结果。窗口的状态被ReduceFunction的结果替换。

在窗口上应用ReduceFunction的优点是,每个窗口的状态大小通常是常量大小(通常都不大)的,而且函数接口很简单。然而,使用ReduceFunction的应用程序的能力也受到了限制,而且通常仅限于简单的聚合,因为输入和输出类型必须相同。

示例6-21显示了一个简短的示例,其中使用ReduceFunction计算15秒的窗口内的最小温度。

val minTempPerWindow: DataStream[(String, Double)] = sensorData
    .map(r => (r.id, r.temperature))
    .keyBy(_._1)
    .timeWindow(Time.seconds(15))
    .reduce((r1, r2) => (r1._1, r1._2.min(r2._2)))

Example 6-21. Compute the minimum temperature per sensor and window using a lambda function

如示例6-21,我们使用lambda函数来指定如何组合窗口的两个元素来生成相同类型的输出。我们亦可以通过实现ReduceFunction来实现本例子相同的逻辑,如示例6-22所示:

val minTempPerWindow: DataStream[(String, Double)] = sensorData
  .map(r => (r.id, r.temperature))
  .keyBy(_._1)
  .timeWindow(Time.seconds(15))
  .reduce(new MinTempFunction)

// A reduce function to compute the minimum temperature per sensor.
class MinTempFunction extends ReduceFunction[(String, Double)] {
  override def reduce(r1: (String, Double), r2: (String, Double)) = {
    (r1._1, r1._2.min(r2._2))
  }
}
AggregateFunction

与ReduceFunction类似,AggregateFunction也增量地应用于分配给窗口的元素。此外,使用AggregateFunction操作的窗口的状态也是一个单值。

但是,AggregateFunction的接口要比ReduceFunction的接口灵活得多,但是实现起来也更加复杂。示例6-23显示了AggregateFunction的接口:

public interface AggregateFunction<IN, ACC, OUT> extends Function, Serializable {

 // create a new accumulator to start a new aggregate.
 ACC createAccumulator();

 // add an input element to the accumulator and return the accumulator.
 ACC add(IN value, ACC accumulator);

 // compute the result from the accumulator and return it.
 OUT getResult(ACC accumulator);

 // merge two accumulators and return the result.
 ACC merge(ACC a, ACC b);
}

Example 6-23. The interface of the AggregateFunction

该接口中定义了输入类型IN、累加类型ACC和一个结果类型OUT。与ReduceFunction相反,中间数据类型和输出类型不依赖于输入类型。

示例6-24展示了如何使用AggregateFunction计算每个窗口中的传感器读数的平均温度。累加器中维护着处于持续计算状态的温度总计【sum】和数据记录总数【count】数据,getResult()方法计算平均值。

val avgTempPerWindow: DataStream[(String, Double)] = sensorData
  .map(r => (r.id, r.temperature))
  .keyBy(_._1)
  .timeWindow(Time.seconds(15))
  .aggregate(new AvgTempFunction)


// An AggregateFunction to compute the average tempeature per sensor.
// The accumulator holds the sum of temperatures and an event count.
class AvgTempFunction
    extends AggregateFunction[(String, Double), (String, Double, Int), (String, Double)] {
  
  override def createAccumulator() = {
    ("", 0.0, 0)
  }

  override def add(in: (String, Double), acc: (String, Double, Int)) = {
    (in._1, in._2 + acc._2, 1 + acc._3)
  }

  override def getResult(acc: (String, Double, Int)) = {
    (acc._1, acc._2 / acc._3)
  }

  override def merge(acc1: (String, Double, Int), acc2: (String, Double, Int)) = {
    (acc1._1, acc1._2 + acc2._2, acc1._3 + acc2._3)
  }
}

Example 6-24. Compute the minimum temperature per sensor and window using an AggregateFunction class implementation.

ProcessWindowFunction

ReduceFunction和AggregateFunction增量地应用于分配给窗口的事件。然而,有时我们需要访问窗口的所有元素来执行更复杂的计算,例如计算窗口中值的中位数或最频繁出现的值。对于这样的应用程序,ReduceFunction和AggregateFunction都不合适。Flink的DataStream API提供ProcessWindowFunction来对窗口的内容执行任意计算。

NOTE:关于WindowFunction的说明
Flink 1.7的DataStream API具有WindowFunction接口。WindowFunction已经被ProcessWindowFunction所取代,这里不再讨论。

示例6-25显示了ProcessWindowFunction的接口

public abstract class ProcessWindowFunction<IN, OUT, KEY, W extends Window> 
    extends AbstractRichFunction {

  // Evaluates the window.
  void process(KEY key, Context ctx, Iterable<IN> vals, Collector<OUT> out) throws Exception;

  // Deletes any custom per-window state when the window is purged.
  public void clear(Context ctx) throws Exception {}

  // The context holding window metadata.
  public abstract class Context implements Serializable {
      
    // Returns the metadata of the window
    public abstract W window();

    // Returns the current processing time.
    public abstract long currentProcessingTime();

    // Returns the current event-time watermark.
    public abstract long currentWatermark();

    // State accessor for per-window state.
    public abstract KeyedStateStore windowState();

    // State accessor for per-key global state.
    public abstract KeyedStateStore globalState();

    // Emits a record to the side output identified by the OutputTag.
    public abstract <X> void output(OutputTag<X> outputTag, X value);
  }
}

Example 6-25. The interface of the ProcessWindowFunction.

调用process()方法时需要使用了窗口的键、一个能够访问窗口元素的迭代器以及和一个用来发射结果的收集器【Collector 】。此外,该方法还具有一个Context参数,与其他的处理方法类似。ProcessWindowFunction的Context对象可以访问窗口的元数据,当前处理时间和水印,状态存储以管理每个窗口【per-window】和每个键【per-key】 的全局状态,以及用于发出记录的侧输出【side outputs】。

我们在介绍ProcessFunction时已经讨论了Context对象的一些功能,例如访问当前处理和事件时间以及侧输出。但是,ProcessWindowFunction的Context对象也提供了独特的特性。窗口的元数据通常包含可用作窗口标识符的信息,例如在时间窗口的情况下的开始和结束时间戳。

另一个特性是每个窗口【per-window】和每个键【per-key】全局状态。全局状态指的是不限于任何窗口的键控状态,而每个窗口状态【Per-window state】指的是当前正在评估的窗口实例。每个窗口状态【Per-window state】对于维护应在同一窗口上的process()方法的多个调用之间共享的信息很有用,这可能由于配置允许延迟或使用自定义触发器而发生。利用了每个窗口状态【Per-window state】的ProcessWindowFunction需要实现其clear()方法,以在清除窗口之前清除任何特定于窗口的状态。全局状态可用于在同一键上的多个窗口之间共享信息。

示例6-26将传感器读数流分组为5秒的翻滚窗口,并使用ProcessWindowFunction计算窗口内发生的最低和最高温度。然后输出每个窗口的开始和结束时间戳,然后输出这两个温度值:

// output the lowest and highest temperature reading every 5 seconds
val minMaxTempPerWindow: DataStream[MinMaxTemp] = sensorData
  .keyBy(_.id)
  .timeWindow(Time.seconds(5))
  .process(new HighAndLowTempProcessFunction)


case class MinMaxTemp(id: String, min: Double, max:Double, endTs: Long)

/**
 * A ProcessWindowFunction that computes the lowest and highest temperature
 * reading per window and emits a them together with the 
 * end timestamp of the window.
 */
class HighAndLowTempProcessFunction
    extends ProcessWindowFunction[SensorReading, MinMaxTemp, String, TimeWindow] {

  override def process(
      key: String,
      ctx: Context,
      vals: Iterable[SensorReading],
      out: Collector[MinMaxTemp]): Unit = {

    val temps = vals.map(_.temperature)
    val windowEnd = ctx.window.getEnd

    out.collect(MinMaxTemp(key, temps.min, temps.max, windowEnd))
  }
}

Example 6-26. Compute the minimum and maximum temperature per sensor and window using a ProcessWindowFunction.

在内部,由ProcessWindowFunction计算的窗口将所有已分配的事件存储在ListState中。通过收集所有事件并提供对窗口元数据和其他特性的访问,ProcessWindowFunction可以处理比ReduceFunction或AggregateFunction更多的用例。但是,收集所有事件的窗口的状态可能比元素增量聚合的窗口的状态大得多。

INCREMENTAL AGGREGATION AND PROCESSWINDOWFUNCTION

ProcessWindowFunction是一个非常强大的窗口函数,但您需要谨慎使用它,因为它通常在状态中保存的数据要多于增量聚合函数。事实上,大多数需要应用于窗口的逻辑都可以表示为增量聚合,但它还需要访问窗口元数据或状态,这是很常见的。

在这种情况下,您可以将执行增量聚合的ReduceFunction或AggregateFunction与提供对更多功能的访问的ProcessWindowFunction结合使用。分配给窗口的元素将立即被处理,当窗口的触发器触发时,聚合结果将被传递给ProcessWindowFunction。 ProcessWindowFunction.process()方法的Iterable参数只会提供单个值,即增量聚合的结果。

在DataStream API中,这是通过提供ProcessWindowFunction作为reduce()或aggregate()方法的第二个参数来完成的,如例6-27和例6-28所示。

input
  .keyBy(...)
  .timeWindow(...)
  .reduce(incrAggregator: ReduceFunction[IN],
    function: ProcessWindowFunction[IN, OUT, K, W])

Example 6-27. Using a reduce pre-aggregation with the ProcessWindowFunction

input
  .keyBy(...)
  .timeWindow(...)
  .aggregate(incrAggregator: AggregateFunction[IN, ACC, V],
    windowFunction: ProcessWindowFunction[V, OUT, K, W])

Example 6-28. Using an aggregate pre-aggregation with the ProcessWindowFunction

示例6-29和示例6-30中的代码显示了如何使用ReduceFunction和ProcessWindowFunction的结合来解决与示例6-26中的代码相同的用例,即如何每5秒发出最小值和最大值每个传感器的温度和每个窗口的结束时间戳。

case class MinMaxTemp(id: String, min: Double, max:Double, endTs: Long)

val minMaxTempPerWindow2: DataStream[MinMaxTemp] = sensorData
  .map(r => (r.id, r.temperature, r.temperature))
  .keyBy(_._1)
  .timeWindow(Time.seconds(5))
  .reduce(
    // incrementally compute min and max temperature
    (r1: (String, Double, Double), r2: (String, Double, Double)) => {
      (r1._1, r1._2.min(r2._2), r1._3.max(r2._3))
    },
    // finalize result in ProcessWindowFunction
    new AssignWindowEndProcessFunction()
  )

Example 6-29. Apply a ReduceFunction for incremental aggregation and a ProcessWindowFunction for finalizing the window result.

如示例6-29所示,ReduceFunction和ProcessWindowFunction都在reduce()方法调用中定义。由于聚合是由ReduceFunction执行的,ProcessWindowFunction只需要将窗口结束时间戳附加到增量计算的结果中,如示例6-30所示。

class AssignWindowEndProcessFunction
  extends ProcessWindowFunction[(String, Double, Double), MinMaxTemp, String, TimeWindow] {

  override def process(
      key: String,
      ctx: Context,
      minMaxIt: Iterable[(String, Double, Double)],
      out: Collector[MinMaxTemp]): Unit = {

    val minMax = minMaxIt.head
    val windowEnd = ctx.window.getEnd
    out.collect(MinMaxTemp(key, minMax._2, minMax._3, windowEnd))
  }
}

Example 6-30. Implementation of a ProcessWindowFunction that assigns the window end timestamp to an incrementally computed result.

6.4 Customizing Window Operators

使用Flink内置的窗口分配器【assigners】定义的窗口操作符可以处理许多常见的业务用例。然而,当您开始编写更高级的流式应用程序时,您可能会发现自己需要实现更复杂的窗口逻辑,比如一个最终一致性的窗口:先发射一个早期结果【early results】,如果之后遇到延迟元素【late element】到达则更新该结果;或者通过接收特定的事件记录来启动/结束的窗口。

DataStream API公开了接口和方法,通过实现您自己的分配器【assigners】、触发器【triggers】和回收器【evictors】来定义自定义窗口操作符。与前面讨论的窗口函数一起,这些组件在窗口操作符中协同工作,对窗口中的元素进行分组和处理。

当元素到达窗口操作符时,它被传递给WindowAssigner。该分配器【assigner】决定将元素路由到哪个窗口。如果一个窗口还不存在,就创建它。

如果窗口操作符通过一个增量聚合函数配置的,例如ReduceFunction或AggregateFunction,则立即聚合新添加的元素,并将结果存储为窗口的内容【contents。如果窗口操作符没有增量聚合函数,则将新元素附加到包含所有分配元素的列表状态。

如果窗口操作符配置的是增量聚合函数,例如ReduceFunction或AggregateFunction,则立即聚合新添加的元素,并将结果存储为窗口的内容【contents】。如果窗口操作符没有使用增量聚合函数,那么新元素会被添加到ListState(它持有所有已分配的元素)上。

每向窗口中添加一个元素时,它也被传递给窗口的触发器【trigger】。触发器【trigger】定义了何时认为窗口已准备好进行计算,何时清除窗口并清除其中的内容。触发器【trigger】可以根据分配的元素或注册计时器(类似于process function)来决定在特定的时间点计算或清除窗口的内容。

当触发器触发时会发生什么,这完全取决于窗口操作符所配置的函数。如果仅使用增量聚合函数来配置操作符,则会发射出当前聚合结果。这种情况如图6-4所示:

flink输出到文件中 flink print 输出内容的含义_flink_04


图6-4 使用增量聚合函数的操作符

如果操作符只有一个全窗口函数【full window function】,那么该函数将应用于窗口的所有元素,并发出结果,如图6-5所示:

flink输出到文件中 flink print 输出内容的含义_java_05


图6 - 5 具有全窗口函数【full window function】的窗口操作符。最后,如果操作符有一个增量聚合函数和一个全窗口函数【full window function】,则将全窗口函数【full window function】应用于聚合值并发出结果。图6-6描述了这种情况。

flink输出到文件中 flink print 输出内容的含义_flink_06


图6-6 具有增量聚合函数和全窗口函数的窗口操作符。

回收器【evictors】是一个可选组件,可以在调用ProcessWindowFunction之前或之后注入。回收器【evictors】可以从窗口的内容【content 】中删除收集到的元素。因为它必须遍历所有元素,所以只能在没有指定增量聚合函数的情况下使用它。

Example 6-31 演示如何使用自定义触发器和回收器定义窗口操作符。

stream
  .keyBy(...)
  .window(...)                   // specify the window assigner
 [.trigger(...)]                 // optional: specify the trigger
 [.evictor(...)]                 // optional: specify the evictor
  .reduce/aggregate/process(...) // specify the window function

Example 6-31 使用自定义触发器和回收器定义窗口操作符

虽然【evictors】是可选组件,但是每个窗口操作符都需要一个触发器【trigger】来决定何时计算其窗口。为了提供简洁的窗口操作符API,每个WindowAssigner都有一个默认触发器,除非显式定义了触发器,否则将使用该触发器。请注意,显式指定的触发器覆盖现有的触发器,而不是对其进行补充,即,窗口将只根据最后定义的触发器进行计算。

在接下来的部分中,我们将讨论windows的生命周期,并介绍定义自定义窗口分配器、触发器和回收器的接口。

6.4.1 WINDOW LIFECYCLE

窗口操作符在处理传入流的元素时,可以创建窗口,通常还可以删除窗口。如前所述,元素由WindowAssigner分配给窗口,触发器决定何时计算窗口,窗口函数执行实际的窗口计算。在本节中,我们将讨论窗口的生命周期,即、何时创建、包含哪些信息以及何时删除。

当WindowAssigner将第一个元素分配给窗口时,将创建一个窗口。因此,如果一个元素也没有,那么窗口也不存在。窗口由不同的状态块组成。

  • The window content:窗口内容【window content】持有分配给该窗口的元素,或者,如果窗口子操作符具有ReduceFunction或AggregateFunction,那么窗口内容【window content】则持有的是增量聚合的结果。
  • The window object:WindowAssigner返回零个、一个或多个窗口对象【window object】。窗口操作符根据返回的对象对元素进行分组。因此,窗口对象持有可以彼此区分窗口的信息。每个窗口对象都有一个结束时间戳,该时间戳定义了窗口可以删除的时间点。
  • Timers of a Trigger:触发器【Trigger 】可以注册计时器,以便在特定的时间点被调用,例如计算窗口或清除其内容。这些计时器由窗口操作符维护。
  • Custom-defined state in a Trigger:触发器【Trigger】可以定义和使用自定义、每个窗口【per-window】和每个键【per-key 】的状态。此状态完全由触发器【trigger】控制,而不是由窗口操作符维护。

当窗口的结束时间(由窗口对象【window object】的结束时间戳定义)达到时,窗口操作符将删除窗口。处理时间语义【processing time semantics】或事件时间语义【event time semantics】是否会发生这种情况,取决于WindowAssigner.isEventTime()方法返回的值。

当窗口被删除时,窗口操作符自动清除窗口内容并丢弃窗口对象。自定义的触发器状态和注册的触发器计时器都不会被清理,因为这个状态对窗口是不透明的。因此,触发器必须在Trigger.clear()方法中清理所有的状态,以防止状态泄露。

6.4.2 WINDOW ASSIGNERS

WindowAssigner为每个抵达的元素确定分配到哪个窗口。元素可以添加到零个、一个窗口,也可以添加到多个窗口。
Example 6-32 展示了WindowAssigner接口

public abstract class WindowAssigner<T, W extends Window> 
    implements Serializable {

  // Returns a collection of windows to which the element is assigned.
  public abstract Collection<W> assignWindows(
    T element, 
    long timestamp, 
    WindowAssignerContext context);

  // Returns the default Trigger of the WindowAssigner.
  public abstract Trigger<T, W> getDefaultTrigger(
    StreamExecutionEnvironment env);

  // Returns the TypeSerializer for the windows of this WindowAssigner.
  public abstract TypeSerializer<W> getWindowSerializer(
    ExecutionConfig executionConfig);

  // Indicates whether this assigner creates event-time windows.
  public abstract boolean isEventTime();

  // A context that gives access to the current processing time.
  public abstract static class WindowAssignerContext {

    // Returns the current processing time.
    public abstract long getCurrentProcessingTime();
  }
}

Example 6-32 WindowAssigner接口
WindowAssigner被键入到传入元素的类型以及元素被分配到的窗口的类型。它还需要提供一个默认触发器,如果没有显式指定触发器,则使用该触发器。
Example 6-33中的代码创建了一个自定义的分配器,每30秒对事件时间窗口【event-time windows】做滚动【tumbling 】。

/** A custom window that groups events into 30 second tumbling windows. */
class ThirtySecondsWindows
    extends WindowAssigner[Object, TimeWindow] {

  val windowSize: Long = 30 * 1000L

  override def assignWindows(
      o: Object,
      ts: Long,
      ctx: WindowAssigner.WindowAssignerContext): java.util.List[TimeWindow] = {

    // rounding down by 30 seconds
    val startTime = ts - (ts % windowSize)
    val endTime = startTime + windowSize
    // emitting the corresponding time window
    Collections.singletonList(new TimeWindow(startTime, endTime))
  }

  override def getDefaultTrigger(
      env: environment.StreamExecutionEnvironment): Trigger[Object, TimeWindow] = {
    EventTimeTrigger.create()
  }

  override def getWindowSerializer(
      executionConfig: ExecutionConfig): TypeSerializer[TimeWindow] = {
    new TimeWindow.Serializer
  }

  override def isEventTime = true
}

Example 6-33 A windows assigner for tumbling event-time windows

GLOBAL WINDOWS:DataStream API还提供了一个到目前为止尚未讨论过的内置窗口分配器。GlobalWindows 分配器将所有元素映射到同一个全局窗口。它的默认触发器是NeverTrigger ,顾名思义,永不触发。因此,全局窗口分配程序需要一个自定义的触发器和潜在的回收器来选择性地从窗口状态删除元素。全局窗口的结束时间戳是Long.MAX_VALUE。因此,全局窗口永远不会被完全清除。当应用于键空间不断变化的KeyedStream时,全局窗口会在每个键上留下一些状态。因此,您应该小心使用它。

除了WindowAssigner接口之外,还有一个MergingWindowAssigner接口,它扩展了WindowAssigner。MergingWindowAssigner用于需要合并现有窗口的窗口操作符。这种分配器的一个例子是我们之前讨论过的EventTimeSessionWindows分配器,它的工作原理是为每个到达的元素创建一个新窗口,然后合并重叠的窗口。

在合并窗口时,您需要确保所有合并的窗口及其触发器的状态也被恰当地合并。Trigger 接口提供了一个回调方法,当合并窗口以合并与窗口关联的状态时调用该方法(The Trigger interface features a callback method that is invoked when windows are merged to merge state that is associated to the windows. )。下一节将更详细地讨论窗口的合并。

6.4.3 TRIGGERS

触发器定义何时计算窗口并发射结果。触发器可以根据时间进度或特定于数据的条件(如元素计数或某些观察到的元素值)来决定是否触发。例如,当处理时间或水印超过窗口结束边界的时间戳时,前面讨论的时间窗口的默认触发器将触发。

触发器可以访问时间属性【time properties】、定时器【timers】,并且可以和状态【state】一起工作。因此,它们与处理函数【process functions】一样强大。例如,当窗口接收到一定数量的元素时,当将具有特定值的元素添加到窗口时,或者在检测到添加元素上的模式(如“5秒内发生两起相同类型的事件”)之后,可以实现触发逻辑。自定义触发器还可以用于计算和从事件时间窗口发射早期结果【early results】,即,在水印到达窗口的结束时间戳之前。这是一种常见的策略,尽管使用了保守的水印策略,但仍然可以生成(不完整的)低延迟结果。

每次调用触发器时,它都会生成一个TriggerResult,用于确定窗口应该发生什么。TriggerResult可以取以下值之一:

  • CONTINUE:没有采取任何行动
  • FIRE:如果窗口操作符具有ProcessWindowFunction,则调用该函数并发射结果。如果窗口只有增量聚合函数(ReduceFunction或AggregateFunction),则发射当前聚合结果。窗口的状态没有改变。
  • PURGE:将完全丢弃窗口中的内容【content 】,并删除窗口(包括所有的窗口元数据)。还会调用ProcessWindowFunction.clear()方法来清除每个窗口的所有自定义状态。
  • FIRE_AND_PURGE:首先计算窗口(FIRE),然后删除所有状态和元数据(PURGE)。
    Example 6-34 展示了Trigger API
public abstract class Trigger<T, W extends Window> implements Serializable {

  // Called for every element that gets added to a window.
  TriggerResult onElement(
    T element, long timestamp, W window, TriggerContext ctx);

  // Called when a processing-time timer fires.
  public abstract TriggerResult onProcessingTime(
    long timestamp, W window, TriggerContext ctx);

  // Called when an event-time timer fires.
  public abstract TriggerResult onEventTime(
    long timestamp, W window, TriggerContext ctx);

  // Returns true if this trigger supports merging of trigger state.
  public boolean canMerge();

  // Called when several windows have been merged into one window 
  // and the state of the triggers needs to be merged.
  public void onMerge(W window, OnMergeContext ctx);

  // Clears any state that the trigger might hold for the given window. 
  // This method is called when a window is purged.
  public abstract void clear(W window, TriggerContext ctx);
}

// A context object that is given to Trigger methods to allow them
// to register timer callbacks and deal with state.
public interface TriggerContext {

  // Returns the current processing time.
  long getCurrentProcessingTime();

  // Returns the current watermark time.
  long getCurrentWatermark();

  // Registers a processing-time timer.
  void registerProcessingTimeTimer(long time);

  // Registers an event-time timer
  void registerEventTimeTimer(long time);

  // Deletes a processing-time timer
  void deleteProcessingTimeTimer(long time);

  // Deletes an event-time timer.
  void deleteEventTimeTimer(long time);

  // Retrieves a state object that is scoped to the window and the key of the trigger.
  <S extends State> S getPartitionedState(StateDescriptor<S, ?> stateDescriptor);
}

// Extension of TriggerContext that is given to the Trigger.onMerge() method.
public interface OnMergeContext extends TriggerContext {
  .reduce/aggregate/process() //  define the window function
  // Merges per-window state of the trigger.
  // The state to be merged must support merging.
  void mergePartitionedState(StateDescriptor<S, ?> stateDescriptor);
}

Example 6-34 Trigger API

如您所见,Trigger API可以通过提供对时间【time】和状态【state】的访问来实现复杂的逻辑。需要特别注意的触发器有两个方面:清理状态和合并触发器。

在触发器中使用每窗口状态【per-window state】时,您需要确保在删除窗口时正确删除了此状态。否则,随着时间的推移,窗口操作符将积累越来越多的状态,您的应用程序可能在将来的某个时候会失败。为了在删除窗口时清理所有状态,触发器的clear()方法需要删除每个窗口的所有自定义状态,并使用TriggerContext对象删除所有处理时间【processing-time】和事件时间【event-time 】的计时器。在定时器回调方法中清理状态是不可能的,因为这些方法在窗口被删除后不会被调用。

如果触发器与MergingWindowAssigner一起使用,则需要能够处理两个窗口被合并【merge】时的情况。在这种情况下,还需要合并它们的触发器的任何自定义状态。canMerge()声明触发器支持合并,onMerge()方法需要实现执行合并的逻辑。如果触发器不支持合并,则不能将其与MergingWindowAssigner组合使用。

计时器【timers 】的合并需要向OnMergeContext对象的mergePartitionedState()方法提供所有自定义状态的状态描述符。请注意,可合并的触发器【mergable triggers 】可能只使用可以自动合并的状态原语,即,ListState、ReduceState或AggregatingState。

Example 6-35显示了一个提早触发【fires early】的触发器,即在窗口的结束时间到达之前,就发射了结果。当第一个事件在当前水印前一秒分配给窗口时,触发器注册一个计时器。当计时器【timer 】触发时,将注册一个新的计时器【timer 】。因此,触发器最多每秒触发一次。

/** A trigger that fires early. The trigger fires at most every second. */
class OneSecondIntervalTrigger
    extends Trigger[SensorReading, TimeWindow] {

  override def onElement(
      r: SensorReading,
      timestamp: Long,
      window: TimeWindow,
      ctx: Trigger.TriggerContext): TriggerResult = {

    // firstSeen will be false if not set yet
    val firstSeen: ValueState[Boolean] = ctx.getPartitionedState(
      new ValueStateDescriptor[Boolean]("firstSeen", classOf[Boolean]))

    // register initial timer only for first element
    if (!firstSeen.value()) {
      // compute time for next early firing by rounding watermark to second
      val t = ctx.getCurrentWatermark + (1000 - (ctx.getCurrentWatermark % 1000))
      ctx.registerEventTimeTimer(t)
      // register timer for the window end
      ctx.registerEventTimeTimer(window.getEnd)
      firstSeen.update(true)
    }
    // Continue. Do not evaluate per element
    TriggerResult.CONTINUE
  }

  override def onEventTime(
      timestamp: Long,
      window: TimeWindow,
      ctx: Trigger.TriggerContext): TriggerResult = {
    if (timestamp == window.getEnd) {
      // final evaluation and purge window state
      TriggerResult.FIRE_AND_PURGE
    } else {
      // register next early firing timer
      val t = ctx.getCurrentWatermark + (1000 - (ctx.getCurrentWatermark % 1000))
      if (t < window.getEnd) {
        ctx.registerEventTimeTimer(t)
      }
      // fire trigger to evaluate window
      TriggerResult.FIRE
    }
  }

  override def onProcessingTime(
      timestamp: Long,
      window: TimeWindow,
      ctx: Trigger.TriggerContext): TriggerResult = {
    // Continue. We don't use processing time timers
    TriggerResult.CONTINUE
  }

  override def clear(
      window: TimeWindow,
      ctx: Trigger.TriggerContext): Unit = {

    // clear trigger state
    val firstSeen: ValueState[Boolean] = ctx.getPartitionedState(
      new ValueStateDescriptor[Boolean]("firstSeen", classOf[Boolean]))
    firstSeen.clear()
  }
}

Example 6-35 An early firing trigger.

注意,触发器在clear()方法中清理用户自定义的状态。由于我们使用的是一个简单且不可合并的ValueState,因此触发器也不是可合并的。

6.4.4 EVICTORS

在Flink的窗口机制中,回收器【Evictor 】是一个可选组件。它可以在窗口函数被计算之前或之后从窗口中删除元素。

Example 6-36 展示了Evictor接口

public interface Evictor<T, W extends Window> extends Serializable {

  // Optionally evicts elements. Called before windowing function.
  void evictBefore(
      Iterable<TimestampedValue<T>> elements, 
      int size, 
      W window, 
      EvictorContext evictorContext);

  // Optionally evicts elements. Called after windowing function.
  void evictAfter(
     Iterable<TimestampedValue<T>> elements, 
     int size, 
     W window, 
     EvictorContext evictorContext);

// A context object that is given to Evictor methods.
interface EvictorContext {

  // Returns the current processing time.
  long getCurrentProcessingTime();

  // Returns the current event time watermark.
  long getCurrentWatermark();
}

Example 6-36 Evictor接口

分别在窗口函数【window function 】应用于窗口内容【content of a window】之前和之后调用evictBefore()和evictAfter()方法。这两个方法需要一个Iterable的入参(它服务于添加到窗口中的所有元素)、窗口中的元素数量(size)、窗口对象(window)和一个EvictorContext,后者(即EvictorContext)提供对当前处理时间和水印的访问。通过调用Iterator (可从Iterable上获得)上的remove()方法,可以从窗口中删除元素。

PRE-AGGREGATION AND EVICTORS:回收器【Evictor 】遍历窗口中的元素列表。只有当窗口收集所有添加的事件,而不应用ReduceFunction或AggregateFunction增量地聚合窗口内容时,才可以应用它们。

回收器通常应用在全局窗口上,用于对窗口进行部分清理,即,不清除完整的窗口状态。

6.5 Joining Streams on Time

处理流时的一个常见需求是connect或join两个流中的事件。Flink的DataStream API提供了两个内置的操作符来join流,即Interval Join和Window Join。在本节中,我们将描述这两种操作。

如果不能使用Flink内置的连接操作符表达所需的连接【join】语义,可以使用CoProcessFunction、BroadcastProcessFunction或KeyedBroadcastProcessFunction实现自定义连接【join】逻辑。注意,您应该使用有效的状态访问模式和有效的状态清理策略来设计这样的操作符。