(二)Flink窗口函数全解析

2,793 阅读14分钟

1. Window编程接口

// Keyed Window
stream
    .keyBy(...)                  <-  按照一个Key进行分组
    .window(...)                 <-  将数据流中的元素分配到相应的窗口中
    [.trigger(...)]              <-  指定触发器Trigger(可选)
    [.evictor(...)]              <-  指定清除器Evictor(可选)
    .reduce/aggregate/process()  <-  窗口处理函数Window Function

// Non-Keyed Window
stream
    .windowAll(...)               <-  不分组,将数据流中的所有元素分配到相应的窗口中
    [.trigger(...)]               <-  指定触发器Trigger(可选)
    [.evictor(...)]               <-  指定清除器Evictor(可选)
    .reduce/aggregate/process()   <-  窗口处理函数Window Function

首先,我们要决定是否对一个DataStream按照Key进行分组,这一步必须在窗口计算之前进行。

针对经过keyBy的窗口(Keyed Window)数据流将被分成多组数据,下游算子可以多个实例并行计算。

经过windowAll的算子是不分组的窗口(Non-Keyed Window)数据流,所有数据将发送到下游算子单个实例上。

1.1 抽象概念

  • TimestampAssigner: 时间戳分配器,假如我们使用的是 EventTime 时间语义,就需要通过 TimestampAssigner 来告诉Flink 框架,元素的哪个字段是事件时间,用于后面的窗口计算。
  • State:状态,用来存储窗口内的元素,如果有 AggregateFunction,则存储的是增量聚合的中间结果。
  • KeySelector:Key 选择器,用来告诉 Flink 框架做聚合的维度有哪些。
  • WindowAssigner:窗口分配器,用来确定哪些数据被分配到哪些窗口。
  • WindowFunction:窗口函数,用来对窗口内的数据做计算。
  • AggregateFunction(可选):增量聚合函数,主要用来做窗口的增量计算,减轻窗口内 State 的存储压力。
  • Trigger:触发器,用来确定何时触发窗口的计算。
  • Evictor(可选):驱逐器,用于在窗口函数计算之前(后)对满足驱逐条件的数据做过滤。
  • Collector:收集器,用来将窗口的计算结果发送到下游。

1.2 Flink窗口中有两个必须的操作步骤

  1. 使用窗口分配器(WindowAssigner)将数据流中的元素分配到对应的窗口。
  2. 当满足窗口触发条件后,对窗口内的数据使用窗口处理函数(Window Function)进行处理,常用的Window Functionreduceaggregateprocess

窗口的生命周期

上图是窗口的生命周期示意图,设置的是一个10分钟的滚动窗口,第一个窗口的起始时间是0:00,结束时间是0:10,后面的时间区间以此类推。

当数据流中的元素流入后,窗口分配器会根据时间(Event Time/Processing Time)分配给相应的窗口。

相应窗口满足了触发条件,比如已经到了窗口的结束时间,会触发相应的Window Function进行计算。

2. 窗口分配器WindowAssigner

窗口主要有两种,一种基于时间(Time-based Window),一种基于数量(Count-based Window)。

TimeWindow 中Flink为我们提供了一些内置的 WindowAssigner,即滚动窗口、滑动窗口和会话窗口。

Count Window根据事件到达窗口的先后顺序管理窗口,到达窗口的先后顺序和Event Time并不一致,因此Count-based Window的结果具有不确定性。

关于窗口的部分我们其他文章已经说过,这里就不展开来了。

3. 窗口函数

数据经过了windowWindowAssigner之后,已经被分配到不同的窗口里,接下来,我们要通过窗口函数对每个窗口上的数据进行处理。

窗口函数主要分为两种:一种是增量计算,如reduceaggregate,增量计算指的是窗口保存一份中间数据,每流入一个新元素,新元素与中间数据两两合一,生成新的中间数据,再保存到窗口中。另一种是全量计算,如process。全量计算指的是窗口先缓存该窗口所有元素,等到触发条件后对窗口内的全量元素执行计算。

3.1 ReduceFunction

ReduceFunction接受两个相同类型的输入,生成一个输出。即两两合一地进行汇总操作,生成一个同类型的新元素。

在窗口上进行reduce的原理与之类似,只不过多了一个窗口状态数据,这个状态数据的数据类型和输入的数据类型是一致的,是之前两两计算的中间结果数据。

当数据流中的新元素流入后,ReduceFunction将中间结果和新流入数据两两合一,生成新的数据替换之前的状态数据。

package windowfunction

import model.StockPrice
import org.apache.flink.api.common.functions.ReduceFunction
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.windowing.time.Time

/**
 * @Author Natasha
 * @Description
 * @Date 2020/11/18 14:46
 **/
 
object ReduceFunctionDemo {
  def main(args: Array[String]): Unit = {
    val renv: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
    renv.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
    renv.setParallelism(1)

    val resource = getClass.getResource("/AggregateFunctionLog.csv").getPath
    val socketStream = renv.readTextFile(resource)
    val input = socketStream
      .map(data => {
        val arr = data.split(",")
        StockPrice(arr(0), arr(1).toDouble, arr(2).toLong)
      })
      .assignAscendingTimestamps(_.timestamp * 1000L)

    // 1. 使用 Lambda 表达式
    val lambdaStream = input
      .keyBy(_.symbol)
      .timeWindow(Time.seconds(10))
      .reduce((s1, s2) => StockPrice(s1.symbol, s1.price + s2.price, 0))
      .print()

    // 2. 实现ReduceFunction
    /*
    val reduceFunctionStream = input
    .keyBy(item => item.symbol)
    .timeWindow(Time.seconds(10))
    .reduce(new MyReduceFunction)
    .print()
    */

    renv.execute()
  }
}

class MyReduceFunction() extends ReduceFunction[StockPrice] {
  // reduce 接受两个输入,生成一个同类型的新的输出
  override def reduce(s1: StockPrice, s2: StockPrice): StockPrice = {
    StockPrice(s1.symbol, s1.price + s2.price, 0)
  }
}

上面的代码使用Lambda表达式对两个元组进行操作,由于对symbol字段进行了keyBy,相同symbol的数据都分组到了一起,接着我们将price加和,返回的结果必须也是StockPrice类型,否则会报错。

使用reduce的好处是窗口的状态数据量非常小,实现一个ReduceFunction也相对比较简单,可以使用Lambda表达式,也可以重写函数。缺点是能实现的功能非常有限,因为中间状态数据的数据类型、输入类型以及输出类型三者必须一致,而且只保存了一个中间状态数据,当我们想对整个窗口内的数据进行操作时,仅仅一个中间状态数据是远远不够的。

3.2 AggregateFunction

AggregateFunction也是一种增量计算窗口函数,也只保存了一个中间状态数据,但AggregateFunction使用起来更复杂一些。

aggregate的工作流程

在计算之前要创建一个新的ACC,这时ACC还没有任何实际表示意义。

当有新数据流入时,Flink会调用add方法,更新ACC,并返回最新的ACC,ACC是一个中间状态数据。

当有一些跨节点的ACC融合时,Flink会调用merge,生成新的ACC。

当所有的ACC最后融合为一个ACC后,Flink调用getResult生成结果。

public interface AggregateFunction<IN, ACC, OUT> extends Function, Serializable {
   // 在一次新的aggregate发起时,创建一个新的Accumulator,Accumulator是我们所说的中间状态数据,简称ACC
   // 这个函数一般在初始化时调用
   ACC createAccumulator();

   // 当一个新元素流入时,将新元素与状态数据ACC合并,返回状态数据ACC
   ACC add(IN value, ACC accumulator);

   // 将两个ACC合并
   ACC merge(ACC a, ACC b);

   // 将中间数据转成结果数据
   OUT getResult(ACC accumulator);
}

输入类型是IN,输出类型是OUT,中间状态数据是ACC,这样复杂的设计主要是为了解决输入类型、中间状态和输出类型不一致的问题(ReduceFunction的输入中间输出类型要求必须一致)。

同时ACC可以自定义,我们可以在ACC里构建我们想要的数据结构。比如我们要计算一个窗口内某个字段的平均值,那么ACC中要保存总和以及个数,下面是一个平均值的示例:

package windowfunction

import model.StockPrice
import org.apache.flink.api.common.functions.AggregateFunction
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.windowing.time.Time

/**
 * @Author Natasha
 * @Description
 * @Date 2020/11/18 14:51
 **/
 
object AggregateFunctionDemo {
  def main(args: Array[String]): Unit = {
    val aenv: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
    aenv.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
    aenv.setParallelism(1)

    val resource = getClass.getResource("/AggregateFunctionLog.csv").getPath
    val socketStream = aenv.readTextFile(resource)
    val input = socketStream
      .map(data => {
        val arr = data.split(",")
        StockPrice(arr(0), arr(1).toDouble, arr(2).toLong)
      })
      .assignAscendingTimestamps(_.timestamp * 1000L)

    val average = input
      .keyBy(_.symbol)
      .timeWindow(Time.seconds(10))
      .aggregate(new AverageAggregate)
      .print()

    aenv.execute()
  }
}

/*
  IN: StockPrice
  ACC:(String, Double, Int) - (symbol, sum, count)
  OUT: (String, Double) - (symbol, average)
*/
class AverageAggregate extends AggregateFunction[StockPrice, (String, Double, Int), (String, Double)] {

  override def createAccumulator() =
    ("", 0, 0)

  override def add(item: StockPrice, accumulator: (String, Double, Int)) =
    (item.symbol, accumulator._2 + item.price, accumulator._3 + 1)

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

  override def merge(a: (String, Double, Int), b: (String, Double, Int)) =
    (a._1, a._2 + b._2, a._3 + b._3)
}

3.3 ProcessWindowFunction

与前两种方法不同,ProcessWindowFunction要对窗口内的全量数据都缓存。在Flink所有API中,process算子以及其对应的函数是最底层的实现,使用这些函数能够访问一些更加底层的数据,比如,直接操作状态等。

使用时,Flink将某个Key下某个窗口的所有元素都缓存在Iterable<IN>中,我们需要对其进行处理使用Collector<OUT>收集输出。我们可以使用Context获取窗口内更多的信息,包括时间、状态、迟到数据发送位置等。

下面的代码是一个ProcessWindowFunctionDemo的简单应用,我们对价格出现的次数做了统计,选出出现次数最多的输出出来。

package windowfunction

import model.StockPrice
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.scala.function.ProcessWindowFunction
import org.apache.flink.streaming.api.windowing.time.Time
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
import org.apache.flink.util.Collector

/**
 * @Author Natasha
 * @Description
 * @Date 2020/11/18 15:57
 **/
object ProcessWindowFunctionDemo {
  def main(args: Array[String]): Unit = {
    val aenv: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
    aenv.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
    aenv.setParallelism(1)

    val resource = getClass.getResource("/AggregateFunctionLog.csv").getPath
    val socketStream = aenv.readTextFile(resource)
    val input = socketStream
      .map(data => {
        val arr = data.split(",")
        StockPrice(arr(0), arr(1).toDouble, arr(2).toLong)
      })
      .assignAscendingTimestamps(_.timestamp * 1000L)

    val frequency = input
      .keyBy(s => s.symbol)
      .timeWindow(Time.seconds(10))
      .process(new FrequencyProcessFunction)
      .print()

    aenv.execute()
  }
}

class FrequencyProcessFunction extends ProcessWindowFunction[StockPrice, (String, Double), String, TimeWindow] {

  override def process(key: String, context: Context, elements: Iterable[StockPrice], out: Collector[(String, Double)]): Unit ={

    // 股票价格和该价格出现的次数
    var countMap = scala.collection.mutable.Map[Double, Int]()

    for(element <- elements) {
      val count = countMap.getOrElse(element.price, 0)
      countMap(element.price) = count + 1
    }

    // 按照出现次数从高到低排序
    val sortedMap = countMap.toSeq.sortWith(_._2 > _._2)

    // 选出出现次数最高的输出到Collector
    if (sortedMap.size > 0) {
      out.collect((key, sortedMap(0)._1))
    }
  }
}

ProcessWindowFunction相比AggregateFunctionReduceFunction的应用场景更广,能解决的问题也更复杂。但ProcessWindowFunction需要将窗口中所有元素作为状态存储起来,这将占用大量的存储资源,尤其是在数据量大窗口多的场景下,使用不慎可能导致整个程序宕机。比如,每天的数据在TB级,我们需要Slide为十分钟,Size为一小时的滑动窗口,这种设置会导致窗口数量很多,而且一个元素会被复制好多份分给每个所属的窗口,这将带来巨大的内存压力。

apply 和 process方法的区别

flink中TimeWindow的apply 和 process方法的区别:继承的类不同,其中prcess方法包含context,里面可以获取窗口时间,自定义延迟数据。

package windowfunction

import org.apache.flink.api.common.functions.FlatMapFunction
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.functions.timestamps.BoundedOutOfOrdernessTimestampExtractor
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.scala.function.{ProcessWindowFunction, WindowFunction}

import scala.collection.mutable
import scala.util.Random
//import org.apache.flink.streaming.api.scala.function.ProcessWindowFunction
import org.apache.flink.streaming.api.windowing.time.Time
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
import org.apache.flink.util.Collector

import scala.collection.mutable.ListBuffer

/**
 * @Author Natasha
 * @Description
 * @Date 2020/11/18 17:30
 **/
class WindowAndProcessWindowDemo {
  def main(args: Array[String]): Unit = {
    
    val env: StreamExecutionEnvironment = StreamExecutionEnvironment.createLocalEnvironmentWithWebUI()
    env.setParallelism(1)
    
    val input = env.socketTextStream("localhost", 9000)
    
    env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
    val stream = input.flatMap(new FlatMapFunction[String, (Long, String,String)] {
      override def flatMap(value: String, out: Collector[(Long, String,String)]) = {
        out.collect(Random.nextInt(6), value, "aa")
      }
    })

    val value = stream
      .assignTimestampsAndWatermarks(
        new BoundedOutOfOrdernessTimestampExtractor[(Long, String, String)](Time.seconds(4)) {
          override def extractTimestamp(element: (Long, String, String)) = element._1 * 1000
        })
      .keyBy(_._3)
      .timeWindow(Time.seconds(10))
      .apply(new MyWindow)
//      .process(new MyWindowFunction)
      .print()

    env.execute("windowTest")
  }
}
class MyWindow extends WindowFunction[(Long, String, String),String,String,TimeWindow] {
  override def apply(key: String,
                     window: TimeWindow,
                     elements: Iterable[(Long, String, String)],
                     out: Collector[String]): Unit = {
    out.collect(elements.size.toString())
  }
}
class MyWindowFunction extends ProcessWindowFunction[(Long,String,String),String,String,TimeWindow] {
  override def process(key: String,
                       context: Context,
                       elements: Iterable[(Long, String, String)],
                       out: Collector[String]): Unit = {

    var list = ListBuffer[(Long,String,String)]()
    for (e <- elements){
      list.append(e)
    }
    val sortList: mutable.Seq[(Long, String, String)] = list.sortWith(_._1>_._1)
    out.collect(elements.size.toString() + "," + sortList + "," + context.window.getEnd)
  }
}
  • apply(windowFunction)
  • process(processWindowFunction)
  • processWindowFunction 比 windowFunction 提供了更多的上下文信息

3.4 全量计算和增量计算相结合

当我们既想访问窗口里的元数据,又不想缓存窗口里的所有数据时,可以将ProcessWindowFunction与增量计算函数相reduceaggregate结合。对于一个窗口来说,Flink先增量计算,窗口关闭前,将增量计算结果发送给ProcessWindowFunction作为输入再进行处理。

下面的代码中,Lambda函数对所有内容进行最大值和最小值的处理,这一步是增量计算。计算的结果以数据类型(String, Double, Double)传递给WindowEndProcessFunctionWindowEndProcessFunction只需要将窗口结束的时间戳添加到结果MaxMinPrice中即可。

package windowfunction

import model.{MaxMinPrice, StockPrice}
import org.apache.flink.api.common.functions.ReduceFunction
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
import org.apache.flink.streaming.api.windowing.time.Time
import org.apache.flink.streaming.api.scala.function.ProcessWindowFunction
import org.apache.flink.util.Collector
import org.apache.flink.streaming.api.scala._

/**
 * @Author Natasha
 * @Description
 * @Date 2020/11/18 16:09
 **/
 
object IncrementalAndFullDemo {
  def main(args: Array[String]): Unit = {
    val aenv: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
    aenv.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
    aenv.setParallelism(1)

    val resource = getClass.getResource("/AggregateFunctionLog.csv").getPath
    val socketStream = aenv.readTextFile(resource)
    val input = socketStream
      .map(data => {
        val arr = data.split(",")
        StockPrice(arr(0), arr(1).toDouble, arr(2).toLong)
      })
      .assignAscendingTimestamps(_.timestamp * 1000L)

    // reduce的返回类型必须和输入类型相同
    // 为此我们将StockPrice拆成一个三元组 (股票代号,最大值、最小值)
    val maxMin = input
      .map(s => (s.symbol, s.price, s.price))
      .keyBy(s => s._1)
      .timeWindow(Time.seconds(10))
      .reduce(new MyReduceFunction, new WindowEndProcessFunction)
      .print()

    aenv.execute()
  }
}

class WindowEndProcessFunction extends ProcessWindowFunction[(String, Double, Double), MaxMinPrice, String, TimeWindow] {
  override def process(key: String,
                       context: Context,
                       elements: Iterable[(String, Double, Double)],
                       out: Collector[MaxMinPrice]): Unit = {
    val maxMinItem = elements.iterator.next()
    val windowEndTs = context.window.getEnd
    out.collect(MaxMinPrice(key, maxMinItem._2, maxMinItem._3, windowEndTs))
  }
}

class MyReduceFunction extends ReduceFunction[ (String, Double, Double)] {
  // reduce 接受两个输入,生成一个同类型的新的输出
  override def reduce(s1: (String, Double, Double), s2: (String, Double, Double)):  (String, Double, Double) = {
    (s1._1, Math.max(s1._2, s2._2), Math.min(s1._3, s2._3))
  }
}

4. Trigger

触发器(Trigger)决定了何时启动窗口函数来处理窗口数据以及何时将窗口内的数据清理干净。

我们可以自定义一个Trigger,比如在Event Time语义中,虽然Watermark还未到达,但是我们可以根据定义的注册时间,提前计算输出的逻辑,以快速获取计算结果,获得更低的延迟。

4.1 Flink 内置的Trigger

640 8.png

WindowAssigner都有一个默认的Trigger。比如基于Event Time的窗口会有一个EventTimeTrigger,每当窗口的Watermark时间戳到达窗口的结束时间,Trigger会发送FIRE。此外,ProcessingTimeTrigger对应的是Processing Time窗口。CountTrigger对应Count-based窗口。

当满足某个条件,Trigger会返回一个名为TriggerResult的结果:

  • CONTINUE:什么都不做。
  • FIRE:启动计算并将结果发送给下游,不清理窗口数据。
  • PURGE:清理窗口数据但不执行计算。
  • FIRE_AND_PURGE:启动计算,发送结果然后清理窗口数据。

例子:我们以一个提前计算的案例来解释如何使用自定义的Trigger。在股票或任何交易场景中,我们比较关注价格急跌的情况,默认窗口长度是60秒,如果价格跌幅超过5%,则立即执行Window Function,如果价格跌幅在1%到5%之内,那么10秒后触发Window Function。

package trigger

import model.StockPrice
import org.apache.flink.api.common.functions.AggregateFunction
import org.apache.flink.api.common.state.{ValueState, ValueStateDescriptor}
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.windowing.time.Time
import org.apache.flink.streaming.api.windowing.triggers.{Trigger, TriggerResult}
import org.apache.flink.streaming.api.windowing.windows.TimeWindow

/**
 * @Author Natasha
 * @Description
 * @Date 2020/11/18 17:16
 **/
object MyTrigger {
  def main(args: Array[String]): Unit = {
    val renv: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
    renv.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
    renv.setParallelism(1)

    val resource = getClass.getResource("/AggregateFunctionLog.csv").getPath
    val socketStream = renv.readTextFile(resource)
    val input = socketStream
      .map(data => {
        val arr = data.split(",")
        StockPrice(arr(0), arr(1).toDouble, arr(2).toLong)
      })
      .assignAscendingTimestamps(_.timestamp * 1000L)

    val average = input
      .keyBy(s => s.symbol)
      .timeWindow(Time.seconds(60))
      .trigger(new MyTrigger)
      .aggregate(new AverageAggregate)

  }
}

class MyTrigger extends Trigger[StockPrice, TimeWindow] {

  /**
   * 当某窗口增加一个元素时调用onElement方法,返回一个TriggerResult
   */
  override def onElement(element: StockPrice,
                         time: Long,
                         window: TimeWindow,
                         triggerContext: Trigger.TriggerContext): TriggerResult = {
    val lastPriceState: ValueState[Double] = triggerContext.getPartitionedState(new ValueStateDescriptor[Double]("lastPriceState", classOf[Double]))

    // 设置返回默认值为CONTINUE
    var triggerResult: TriggerResult = TriggerResult.CONTINUE

    // 如果直接使用Scala的Double,需要使用下面的方法判断是否为空
    if (Option(lastPriceState.value()).isDefined) {
      if ((lastPriceState.value() - element.price) > lastPriceState.value() * 0.05) {
        // 如果价格跌幅大于5%,直接FIRE_AND_PURGE
        triggerResult = TriggerResult.FIRE_AND_PURGE
      } else if ((lastPriceState.value() - element.price) > lastPriceState.value() * 0.01) {
        val t = triggerContext.getCurrentProcessingTime + (10 * 1000 - (triggerContext.getCurrentProcessingTime % 10 * 1000))
        // 给10秒后注册一个Timer
        triggerContext.registerProcessingTimeTimer(t)
      }
    }
    lastPriceState.update(element.price)
    triggerResult
  }

  // 我们不用,Processing Time直接返回一个CONTINUE
  override def onEventTime(time: Long, window: TimeWindow, triggerContext: Trigger.TriggerContext): TriggerResult = {
    TriggerResult.FIRE_AND_PURGE
  }

  /**
   * 当一个基于EventTime的Timer触发了FIRE时调用onProcessTime方法
   */
  override def onProcessingTime(time: Long, window: TimeWindow, triggerContext: Trigger.TriggerContext): TriggerResult = {
    TriggerResult.CONTINUE
  }

  /**
   * 当窗口数据被清理时,调用clear方法来清理所有的Trigger状态数据
   */
  override def clear(window: TimeWindow, triggerContext: Trigger.TriggerContext): Unit = {
    val lastPrice: ValueState[Double] = triggerContext.getPartitionedState(new ValueStateDescriptor[Double]("lastPrice", classOf[Double]))
    lastPrice.clear()
  }
}

class AverageAggregate extends AggregateFunction[StockPrice, (String, Double, Int), (String, Double)] {

  override def createAccumulator() = ("", 0, 0)

  override def add(item: StockPrice, accumulator: (String, Double, Int)) =
    (item.symbol, accumulator._2 + item.price, accumulator._3 + 1)

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

  override def merge(a: (String, Double, Int), b: (String, Double, Int)) =
    (a._1 ,a._2 + b._2, a._3 + b._3)
}

在自定义Trigger时,如果使用了状态,一定要使用clear方法将状态数据清理,否则随着窗口越来越多,状态数据会越积越多。

4.2 ContinuousEventTimeTrigger

640 9.png

  1. 假如我们定义一个5分钟的基于 EventTime 的滚动窗口,定义一个每2分触发计算的 Trigger,有4条数据事件时间分别是20:01、20:02、20:03、20:04,对应的值分别是1、2、3、2,我们要对值做 Sum 操作。初始时,State 和 Result 中的值都为0。

640 10 .png

  1. 当第一条数据在20:01进入窗口时,State 的值为1,此时还没有到达 Trigger 的触发时间。

640 11.png

  1. 第二条数据在20:02进入窗口,State 中的值为1+2=3,此时达到2分钟满足 Trigger 的触发条件,所以 Result 输出结果为3。

640 12.png

  1. 第三条数据在20:03进入窗口,State 中的值为3+3 = 6,此时未达到 Trigger 触发条件,没有结果输出。

640 13.png

  1. 第四条数据在20:04进入窗口,State中的值更新为6+2=8,此时又到了2分钟达到了 Trigger 触发时间,所以输出结果为8。如果我们把结果输出到支持 update 的存储,比如 MySQL,那么结果值就由之前的3更新成了8。

问题来了, Result 是不是只能 append?

640 14.png

如果 Result 不支持 update 操作,只能 append 的话,则会输出2条记录,在此基础上再做计算处理就会引起错误。这样就需要 PurgingTrigger 来处理上面的问题。

4.2 PurgingTrigger

640 15.png

  1. 和上面的示例一样,唯一的不同是在 ContinuousEventTimeTrigger 外面包装了一个 PurgingTrigger,其作用是在 ContinuousEventTimeTrigger 触发窗口计算之后将窗口的 State 中的数据清除。

640 16.png

  1. 前两条数据先后于20:01和20:02进入窗口,此时 State 中的值更新为3,同时到了Trigger的触发时间,输出结果为3。

640 17.png

  1. 由于 PurgingTrigger 的作用,State 中的数据会被清除。

640 18.png

  1. 当后两条数据进入窗口之后,State 重新从0开始累计并更新为5,输出结果为5。由于结果输出是 append 模式,会输出3和5两条数据,然后再做 Sum 也能得到正确的结果。

4.3 DeltaTrigger

例子:一个车辆区间测试的需求,车辆每分钟上报当前位置与车速,每行进10公里,计算区间内最高车速。

640 19.png

首先需要考虑的是如何来划分窗口?考虑到这个场景不是传统意义上的时间窗口或数量窗口,这种情况下我们考虑使用 DeltaTrigger 来实现,简单代码实现如下:

640 20.png

创建一个 GlobalWindow,所有数据都在一个窗口中,我们通过定义一个 DeltaTrigger,并设定一个阈值(这里是10000米)。

每个元素和上次触发计算的元素比较是否达到设定的阈值:如果当前元素与上次上报的元素的位置相隔了10000(米),那么当前元素和上一个触发计算的元素之间的所有元素都落在同一个窗口里,然后通过 Max 聚合计算出最大的车速。

5. Evictor

清除器Evictor是在WindowAssignerTrigger的基础上的一个可选选项,用来清除一些数据。我们可以在Window Function执行前或执行后调用Evictor。

640 22.png

  1. 基于上面的区间测速的场景,每行进10公里,计算区间内最近15分钟最高车速。

640 23.png

640 24.png

  1. 实现上只是在前面的基础上增加了 Evictor 的使用,过滤掉窗口最后15分钟以前的数据。

6. Github

本文章中相关代码样例已上传 github: github.com/ShawnVanorG…