​第四章 Flink 窗口和水位线​

Flink 系列教程传送门

第一章 Flink 简介

第二章 Flink 环境部署

第三章 Flink DataStream API

第四章 Flink 窗口和水位线

第五章 Flink Table API&SQL

第六章 新闻热搜实时分析系统


一、时间概念:事件时间和处理时间

在流式处理的过程中,数据是在不同的节点间不停流动的;这样一来,就会有网络传输的延迟,当上下游任务需要跨节点传输数据时,它们对于“时间”的理解也会有所不同。

当基于特定时间段(通常称为Windows,窗口),或者当执行事件处理时,事件的时间发生很重要。

  •  事件时间(Event Time):是指每个事件在对应的设备上发生的时间,也就是数据生成的时间。数据一旦产生,这个时间自然就确定了,所以它可以作为一个属性嵌入到数据中。这其实就是这条数据记录的“时间截”。
  • 摄取时间(Ingestion Time) 摄取时间是事件进入到Flink系统的时间,即该事件在数据源中被读取的时间。
  • 处理时间(Window Processing Time):处理时间的概念非常简单,就是指执行处理操作的机器的系统时间。如果我们以它作为衡量标准,那么数据属于哪个窗口就很明显了:只看窗口任务处理这条数据时,当前的系统时间。

通常来说,处理时间是我们计算效率的衡量标准,而事件时间会更符合我们的业务计算逻辑。所以更多时候我们使用事件时间;不过处理时间也不是一无是处。对于处理时间而言,由于没有任何附加考虑,数据一来就直接处理,因此这种方式可以让我们的流处理延迟降到最低,效率达到最高。

二、事件时间和水位线(Watermark)

支持事件时间的流处理器需要一种方法来衡量事件时间的进度。例如,每小时构建一次窗口运算符,当事件时间超过结束时间时,需要通知窗口以便操作员可以关闭正在进行的窗口。

Flink 中衡量事件时间进度的机制是水位线,水位线作为数据流的一部分,随着数据一起流动,在不同的任务之间传输,并带有时间戳t。专门用于处理数据中的乱序问题,通常会结合窗口使用

如上图所示,每个事件产生的数据,都包含了一个时间戳,用了一个整数表示。当产生于7秒的数据到来之后,当前的事件时间就是7秒;在后面插入一个时间戳也为7秒的水位线W(7),随着数据一起向下游流动。这样如果出现下游有多个并行子任务的情形,我们只要将水位线广播出去,就可以通知到所有下游任务当前的时间进度了

水位线,其实就是流中的一个周期性出现的时间标记,水位线插入的“周期”,本身也是一个时间概念,它指的是处理时间(系统时间),而不是事件时间。

水位线对于无序流至关重要,如上图所示,其中事件不按其时间戳排序。一般来说,水位线是一种声明,即在流中的该点之前,直到某个时间戳的所有事件都应该已经到达。一旦水位线到达操作员,操作员可以将其内部事件时钟提前到水位线的值

水位线计算规则

当使用事件时间窗口时,可能会发生元素延迟到达的情况,即Flink用来跟踪事件时间进展的水位线已经超过了元素所属窗口的结束时间戳。默认情况下,当水位线超过窗口的结束时间戳时,将删除晚到的元素。但是,Flink允许为窗口操作符指定最大允许延迟,允许延迟指定元素在被删除之前可以延迟多少时间,其默认值为0。如果元素在窗口的结束时间戳 + 最大允许延迟之前到达,元素仍旧被添加到窗口。

假设设置的允许最大延迟时间为3分钟,当事件时间戳为9:11的事件到达时,由于该事件时间是进入Flink的当前最大事件时间,因此Watermark=9:11‒3分钟=9:08。此时水位线在窗口内部不会触发窗口计算,窗口继续等待延迟数据,接下来当事件时间戳为9:15的事件到达时,由于该事件时间是进入Flink的当前最大事件时间,因此Watermark=9:15‒3分钟=9:12。此时水位线在窗口外部,满足窗口触发计算的规则:Watermark>=窗口结束时间,因此窗口会立即触发计算,计算完毕后发射出计算结果并销毁窗口。

在不设置水位线的情况下,当数据C到达时,由于C的事件时间大于窗口结束时间,窗口已经关闭,因此后面的数据B和数据A虽然属于该窗口,但是不会被计算,将被丢弃。

设置水位线后,假设允许最大延迟时间为5分钟,当数据C到达时,Watermark=当前最大事件时间‒允许最大延迟时间=9:11‒5分钟=9:06<窗口结束时间,因此窗口不会被触发计算。而数据C的事件时间大于窗口结束时间,数据C不属于该窗口,将属于下一个窗口。

当数据B到达时,Watermark=当前最大事件时间‒允许最大延迟时间=9:11‒5分钟=9:06 <窗口结束时间,因此窗口不会被触发计算。窗口开始时间<=数据B的事件时间<窗口结束时间,数据B属于该窗口。

当数据D到达时,Watermark=当前最大事件时间‒允许最大延迟时间=9:15‒5分钟=9:10=窗口结束时间,此时窗口触发计算。而数据D的事件时间大于窗口结束时间,数据D不属于该窗口,将属于下一个窗口。

整个过程中,由于设置了水位线,数据B没有丢失,数据A虽然属于该窗口,但当数据A到达时窗口已经触发了计算,因此数据A将丢失。这说明水位线机制可以在一定程度上解决数据延迟到达问题,但不能完全解决。如果希望数据A不丢失,可以延长允许的最大延迟时间,但是这样可能会增加不必要的处理延迟。

对于一些使用水印解决不了的、更为严重的延迟数据,Flink默认是丢弃的,为了保证数据不丢失,Flink提供了允许延迟(Allowed Lateness)和侧道输出机制(Side Output)。

  • 允许延迟使用allowedLateness(lateness: Time)方法设置延迟时间,该方法只对事件时间窗口有效。允许延迟机制不会延迟窗口触发计算,而是窗口触发计算之后不会销毁,保留计算状态,继续等待一段时间。
  • 侧道输出使用sideOutputLateData(outputTag: OutputTag[T])方法将延迟到达的数据保存到outputTag对象中,后面可通过getSideOutput(outputTag)方法得到被丢弃数据组成的数据流。

水位线生成策略(Watermark Strategies)

Flink 中的水位线,其实是流处理中对低延迟和结果正确性的一个权衡机制,而且把控制的权力交给了开发人员,我们可以在代码中定义水位线的生成策略。

原始的时间戳只是写入日志数据的一个字段,如果不提取出来并明确把它分配给数据, Flink 是无法知道数据真正产生的时间的。用于生成水位线的方法是.assignTimestampsAndWatermarks(),它主要用来为流中的数据分配时间戳,并生成水位线来指示事件时间。该方法需要传入一个 WatermarkStrategy 作为参数,这就是所谓的“水位线生成策略”。WatermarkStrategy中包含了一个“时间戳分配器TimestampAssigner和一个“水位线生成器WatermarkGenerator

val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(1)
// 设置水印的生命周期,默认是200毫秒,即每个200毫秒生成一次水印
env.getConfig.setAutoWatermarkInterval(200)

// 设置水位线生成策略
dataStream.assignTimestampsAndWatermarks(new WatermarkStrategy[Student]{
    // 水位线生成器
    override def createWatermarkGenerator(context: WatermarkGeneratorSupplier.Context): WatermarkGenerator[Student] = ???
    // 时间戳分配器
    override def withTimestampAssigner(timestampAssigner: SerializableTimestampAssigner[Student]): WatermarkStrategy[Student] = super.withTimestampAssigner(timestampAssigner)
  })

Flink内置的水位线生成器

  • forMonotonousTimestamps(单调时间戳):主要针对有序流,有序流的主要特点就是时间戳单调增长,所以永远不会出现迟到数据的问题。这是周期性生成水位线的最简单的场景,直接拿当前最大的时间戳作为水位线就可以了。
  • forBoundedOutOfOrderness(有界乱序数据):主要针对乱序流,由于乱序流中需要等待迟到数据到齐,所以必须设置一个固定量的延迟时间。这时生成水位线的时间戳,就是当前数据流中最大的时间戳减去延迟的结果,相当于把表调慢,当前时钟会滞后于数据的最大时间戳。这个方法需要传入一个 maxOutOfOrderness 参数,表示“ 最大乱序程度 ” ,它表示数据流中乱序数据时间戳的最大差值。
  • noWatermarks(无水位线)
 不需要水位线生成策略 ///
dataStream.assignTimestampsAndWatermarks(WatermarkStrategy.noWatermarks[Student]())

 有序流水位线生成策略 ///
dataStream.assignTimestampsAndWatermarks(WatermarkStrategy
  .forMonotonousTimestamps()
  // .withTimestampAssigner((element: Student, recordTimestamp: Long) => element.createTime))
  .withTimestampAssigner(new SerializableTimestampAssigner[Student] {
    override def extractTimestamp(element: Student, recordTimestamp: Long): Long = element.createTime
  })
)

// 升序的时间戳水位线(并行度是1的情况可以使用)
dataStream.assignAscendingTimestamps(_.createTime)

/ 无序流水位线生成策略 //
dataStream.assignTimestampsAndWatermarks(WatermarkStrategy
  .forBoundedOutOfOrderness(Duration.ofMillis(100L))
  // .withTimestampAssigner((element: Student, recordTimestamp: Long) => element.createTime))
  .withTimestampAssigner(new SerializableTimestampAssigner[Student] {
    override def extractTimestamp(element: Student, recordTimestamp: Long): Long = element.createTime
  })
)

三、窗口(Window)

Flink是一种流式计算引擎,主要是来处理无界数据流的,数据源源不断、无穷无尽。想要更加方便高效地处理无界流,一种方式就是将无限数据切割成有限的“数据块”进行处理,这就是所谓的“窗口”。

我们可以把窗口想象成一个固定位置的“框”,数据源源不断地流过来,到某个时间点窗口该关闭了,就停止收集数据、触发计算并输出结果,最后清空窗口再次收集计算。

窗口可以是时间驱动(每10秒)或者是数据驱动(每3个元素),如下图所示:

时间窗口和计数窗口,只是对窗口的一个大致划分,在具体应用时,还需要定义更加精细的规则,来控制数据应该划分到哪个窗口中去。不同分配数据的方式,就可以有不同的功能应用。

根据分配数据的规则,窗口的具体实现可以分为4类:滚动窗口(Tumbling Window)、滑动窗口(Sliding Window)、会话窗口(Session Window),以及全局窗口(Global Window)。

Keyed 和 Non-Keyed Windows

在定义窗口操作之前,首先需要确定是基于按键分区(Keyed)的数据流KeyedStream来开窗,还是直接在没有按键分区DataStream上开窗。简单理解就是在调用窗口算子之前,是否有keyBy()操作。

(1) 按键分区窗口(Keyed Windows)

经过按键分区keyBy()操作后,数据流会按照key被分为多条逻辑流,窗口计算会在多个并行子任务上同时执行,每个key上都定义了一组窗口,各自独立地进行统计计算。在代码实现上,我们需要先对DataStream调用keyBy()进行按键分区,然后再调用window()定义窗口。

stream
      .keyBy(...)                <-  按照一个Key进行分组
      .window(...)               <-  必填项:"将数据流中的元素分配到相应的窗口中"
      [.trigger(...)]            <-  可选项:"指定触发器Trigger" (省略则使用默认 trigger) 定义window什么时候关闭,触发计算并输出结果
      [.evictor(...)]            <-  可选项:"指定清除器Evictor" (省略则不使用 evictor) 定义移除某些数据的逻辑
      [.allowedLateness(...)]    <-  可选项:"lateness" (省略则为 0) 允许处理迟到的数据
      [.sideOutputLateData(...)] <-  可选项:"output tag" (省略则不对迟到数据使用 side output)  将迟到的数据数据放入侧输出流
      .reduce/aggregate/apply()  <-  必填项:"窗口处理函数Window Function"  处理算子
      [.getSideOutput(...)]      <-  可选项:"output tag"  获取侧输出流

(2) 非按键分区(Non-Keyed Windows)

如果没有进行keyBy(),原始的DataStream不会被分割为多个逻辑上的DataStream, 所以所有的窗口计算会被同一个Task完成,也就是Parallelis为1,所以在实际应用中一般不推荐使用这种方式。在代码中,直接基于DataStream调用windowAll()定义窗口。

stream
      .windowAll(...)            <-  必填项:"assigner"
      [.trigger(...)]            <-  可选项:"trigger" (else default trigger)
      [.evictor(...)]            <-  可选项:"evictor" (else no evictor)
      [.allowedLateness(...)]    <-  可选项:"lateness" (else zero)
      [.sideOutputLateData(...)] <-  可选项:"output tag" (else no side output for late data)
      .reduce/aggregate/apply()  <-  必填项:"function"
      [.getSideOutput(...)]      <-  可选项:"output tag"

这里需要注意的是,对于非按键分区的窗口操作,手动调大窗口算子的并行度也是无效的,windowAll本身就是一个非并行的操作。

Window Assigners(窗口分配器)

Window Assigner 定义了Stream中的元素如何被分发到各个窗口。 通过算子window(...)windowAll(...)中指定一个WindowAssigner。 Flink 为最常用的情况提供了一些定义好的窗口分配器,也就是tumbling windowssliding windowssession windowsglobal windows。 也可以继承 WindowAssigner 类来实现自定义的窗口分配器。

  • 基于时间的窗口用 start timestamp包含)和 end timestamp不包含)描述窗口的大小。
  • 基于计数的窗口根据固定的数量定义窗口的大小。Flink提供了.countWindow()的方式调用。

滚动窗口

滚动窗口有固定的大小,是一种对数据进行“均匀切片”的划分方式。窗口之间没有重叠,也不会有间隔,是“首尾相接”的状态。定义滚动窗口的参数只有一个,就是窗口大小

滚动窗口-窗口分配器使用语法

val source: DataStream[T] = ...

// 基于计数的滚动窗口
source
	.keyBy(<key selector>)
	.countWindow(4L)  // 窗口大小为4个元素
	.<windowed transformation>(<window function>)
  
// 基于事件时间的滚动窗口
source
	.keyBy(<key selector>)
	.window(TumblingEventTimeWindows.of(Time.minutes(1)))  // 窗口大小为1分钟
	.<windowed transformation>(<window function>)

// 基于处理时间的滚动窗口
source
	.keyBy(<key selector>)
	.window(TumblingProcessingTimeWindows.of(Time.minutes(1)))  // 窗口大小1分钟
	.<windowed transformation>(<window function>)

// 滚动窗口分配器还可以使用可选的偏移量(Offset)参数,用于更改窗口的对齐方式。比如以1小时为单位的窗口流,但是窗口需要从每小时的第15分钟开始,则可以设置偏移量。
TumblingEventTimeWindows.of(Time.hours(1), Time.minutes(15))

案例:每隔5秒钟统计一次用户的访问量 

// Event类
case class Event(name: String, url: String, timestamp: Long)

class EventSource extends SourceFunction[Event] {

  private var running = true
  private val names = Array("张三丰", "张无忌", "赵敏", "貂蝉", "赵云")
  private val url = Array("/index", "/product/detail", "/product?id=1", "/product?id=2", "/product?id=3", "/about")

  // 产生数据的方法
  override def run(sourceContext: SourceFunction.SourceContext[Event]): Unit = {
    while (running) {
      val randomEvent = Event(names(Random.nextInt(names.length)), url(Random.nextInt(url.length)), Calendar.getInstance().getTimeInMillis)
      // 把产生的数据进行收集发送
      sourceContext.collect(randomEvent)

      // 每隔1秒钟产生一条event数据
      Thread.sleep(1000L)
    }
  }

  // 停止执行自动调用方法
  override def cancel(): Unit = running = false
}

  基于处理事件的计算

object TumblingProcessingTimeWindowWindowTest {
  def main(args: Array[String]): Unit = {

    val env = StreamExecutionEnvironment.getExecutionEnvironment
    env.setParallelism(1)

    env.addSource(new EventSource)
      .map(item => (item.name, 1))
      .keyBy(_._1)
      .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))  // 使用处理时间
      .sum(1)
      .print()

    env.execute()
  }
}

 基于事件事件的处理

object TumblingEventTimeWindowTest {
  def main(args: Array[String]): Unit = {

    val env = StreamExecutionEnvironment.getExecutionEnvironment
    env.setParallelism(1)

    env.addSource(new EventSource)
      .assignTimestampsAndWatermarks(WatermarkStrategy
        .forMonotonousTimestamps()
        .withTimestampAssigner(new SerializableTimestampAssigner[Event] {  // 指定水位线生成策略
          override def extractTimestamp(element: Event, recordTimestamp: Long): Long = element.timestamp
        }))
      // .withTimestampAssigner((element: MyRandomEventSource.UserClick,timestamp: Long)=>element.clickTime)
      // 对于有序数据的简化版
      // .assignAscendingTimestamps(uc => uc.clickTime)
      .map(item => (item.name, 1))
      .keyBy(_._1)
      .window(TumblingEventTimeWindows.of(Time.seconds(5)))
      .sum(1)
      .print()

    env.execute()
  }
}

滑动窗口

滑动窗口与滚动窗口类似,滑动窗口的大小也是固定的。区别在于,窗口之间并不是首尾相接的,而是可以“错开”一定的位置,向前滑动。具体每一步滑多远,是可以控制的。所以定义滑动窗口的参数有两个:除去窗口大小(window size)之外,还有一个滑动步长(window slide),代表了窗口计算的频率。

滑动窗口-窗口分配器使用语法

val source: DataStream[T] = ...

// 基于计数的滑动窗口,窗口大小为4个元素,滑动步长为2个元素
source
.keyBy(<key selector>)
..countWindow(4L, 2L)
.<windowed transformation>(<window function>)
  
// 基于事件时间的滑动窗口,窗口大小为10秒,滑动步长为5秒
source
.keyBy(<key selector>)
.window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.<windowed transformation>(<window function>)

// 基于处理时间的滑动窗口,窗口大小为10秒,滑动步长为5秒
source
.keyBy(<key selector>)
.window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5)))
.<windowed transformation>(<window function>)

// 与滚动窗口一样,滑动窗口分配器也有一个可选的偏移量参数,可以用来改变窗口的对齐方式。如果系统时间基于UTC-0(世界标准时间),中国当地时间是UTF+08:00	,就需要设置偏移量为Time.hours(-8)
SlidingEventTimeWindows.of(Time.hours(12), Time.hours(1), Time.hours(-8))

每隔2秒钟统计用户在10秒内的访问次数  

object SlidingEventTimeWindowTest {
  def main(args: Array[String]): Unit = {

    val env = StreamExecutionEnvironment.getExecutionEnvironment
    env.setParallelism(1)

    env.addSource(new EventSource)
      .assignAscendingTimestamps(event => event.timestamp)
      .map(item => (item.name, 1))
      .keyBy(_._1)
      .window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(2)))
      .sum(1)
      .print()

    env.execute()
  }
}

会话窗口

会话窗口顾名思义,是基于会话(session)来对数据进行分组的,根据会话间隙(Session Gap)切分不同的窗口,当一个窗口在大于会话间隙的时间内没有接收到新数据时,窗口将关闭。会话窗口分配器可以配置静态会话间隔,也可以根据业务逻辑自定义动态会话间隔,该功能定义不活动的时间长度。当该时间的到期时,当前会话窗口将关闭,随后的事件将分配给新的会话窗口。在这种模式下,窗口的长度是可变的,每个窗口的开始和结束时间并不是确定的。需要注意的是如果数据一直不间断地进入窗口,也会导致窗口始终不触发的情况。

创建动态间隔会话窗口,需要实现SessionWindowTimeGapExtractor接口,并实现其中的extract()方法,可以在该方法中加入相应的业务逻辑来动态控制会话间隔。

全局窗口

全局窗口将整个输入看作一个简单的窗口,因为Flink是基于事件的流式无界处理,所以我们需要指定一个“触发器Trigger”才能触发窗口执行聚合运算,否则他不会进行任何运算,而全局窗口的默认触发器是永不触发的NeverTrigger

窗口函数

事件被窗口分配器分配到窗口后,接下来需要指定想要在每个窗口上执行的计算函数,以便对窗口内的数据进行处理。Flink提供的窗口函数有:ReduceFunctionAggregateFunctionProcessWindowFunction

  • ReduceFunctionAggregateFunction增量计算函数,都可以基于中间状态对窗口中的元素进行递增聚合。例如,窗口每流入一个新元素,新元素就会与中间数据进行合并,生成新的中间数据,再保存到窗口中。
  • ProcessWindowFunction全量计算函数,与增量聚合函数不同,全量计算函数需要先收集窗口中的数据,并在内部缓存起来,等到窗口要输出结果的时候再取出数据进行计算。使用它还可以获取窗口中的状态数据和窗口元数据(窗口开始时间、结束时间等)。在效率上肯定是不如增量计算的,不过在需要依赖窗口所有做计算的时候它就非常灵活,例如对整个窗口数据排序取TopN

ReduceFunction

ReduceFunction指定如何聚合输入中的两个元素以产生相同类型的输出元素。Flink使用ReduceFunction递增聚合窗口的元素。

每隔5秒钟统计一次用户的访问量

object WindowFunctionTest {

  def main(args: Array[String]): Unit = {

    val env = StreamExecutionEnvironment.getExecutionEnvironment
    env.setParallelism(1)

    val source = env.addSource(new MyRandomEventSource)

    source
      .map(item => (item.name, 1))
      .keyBy(_._1)
      .window(TumblingProcessingTimeWindows.of(Time.seconds(5L)))
    	//.sum(1)
      .reduce((state, data) => (state._1, state._2 + data._2))
      .print("每个5秒统计一次用户访问的页面数")

    env.execute()
  }
}

该聚合函数有一个限制就是输入和输出的类型需要保持一致。

AggregateFunction

AggregateFunction是聚合函数的基本接口,也是ReduceFunction的通用版本。与ReduceFunction相同,Flink将在窗口输入元素到达时对其进行增量聚合。

AggregateFunction的泛型具有3种类型:输入类型(IN)、累加器类型(ACC)和输出类型(OUT)

AggregateFunction是一种灵活的聚合函数,具体有以下特点:

  1. 支持对输入值、中间聚合以及结果使用不同的类型,以支持广泛的聚合类型。
  2. 支持分布式聚合,不同的中间聚合可以合并在一起,以运行预聚合/最终聚合优化。

AggregateFunction接口源码解析

/**
 * @param <IN>  聚集的值的类型(输入值)
 * @param <ACC> 累加器的类型(中间聚合状态)
 * @param <OUT> 聚合结果的类型
 */
@PublicEvolving
public interface AggregateFunction<IN, ACC, OUT> extends Function, Serializable {
    // 创建新的累加器,开始新的聚合
    ACC createAccumulator();
    // 将给定输入值与给定累加器相加,返回新的累加器值
    ACC add(IN var1, ACC var2);
	// 从累加器获取聚合结果
    OUT getResult(ACC var1);
	// 合并两个累加器,返回具有合并状态的累加器(窗口合并的使用使用)
    ACC merge(ACC var1, ACC var2);
}

每隔2秒钟统计用户在10秒内的平均访问次数 

object AggregateFunctionTest {

  def main(args: Array[String]): Unit = {

    val env = StreamExecutionEnvironment.getExecutionEnvironment
    env.setParallelism(1)

    val source = env.addSource(new MyRandomEventSource)

    source
      .keyBy(item => "default") //全部分到default组中
      .window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(2)))
      .aggregate(new AverageAggregateFunction)
      .print("每隔2秒钟统计用户在10秒内的平均访问次数")

    env.execute()
  }

  class AverageAggregateFunction extends AggregateFunction[RandomEvent, (Long, Set[String]), Double] {
    // 初始化累加器
    override def createAccumulator(): (Long, Set[String]) = (0L, Set[String]())

    // (点击次数,用户数)
    override def add(value: RandomEvent, accumulator: (Long, Set[String])): (Long, Set[String]) = (accumulator._1 + 1L, accumulator._2 + value.name)

    // 点击次数/用户数
    override def getResult(accumulator: (Long, Set[String])): Double = accumulator._1.toDouble / accumulator._2.size

    // 合并累加器(只会对会话窗口生效,其他窗口不会调用,这里就是用默认实现)
    override def merge(a: (Long, Set[String]), b: (Long, Set[String])): (Long, Set[String]) = ???
  }
}

ProcessWindowFunction

使用ProcessWindowFunction可以获得一个包含窗口所有元素的可迭代对象(Iterable),以及一个可以访问时间和状态信息的上下文对象(Context),这使得它比其他窗口函数提供了更多的灵活性。这种灵活性是以性能和资源消耗为代价的,因为元素不能递增聚合,而是需要在调用处理函数之前在内部缓冲窗口中的所有元素,直到认为窗口已经准备好进行处理。

每隔10秒钟统计URL的访问量

object ProcessWindowFunctionTest {

  def main(args: Array[String]): Unit = {

    val env = StreamExecutionEnvironment.getExecutionEnvironment
    env.setParallelism(1)

    val source = env.addSource(new MyRandomEventSource)

    source.keyBy(item => item.url)
      .window(TumblingProcessingTimeWindows.of(Time.seconds(10)))
      .process(new UrlCountProcessWindowFunction)
      .print()

    env.execute()
  }

  class UrlCountProcessWindowFunction extends ProcessWindowFunction[RandomEvent, String, String, TimeWindow] {
    
    private val sdf = new SimpleDateFormat("HH:mm:ss")

    override def process(key: String, context: Context, elements: Iterable[RandomEvent], out: Collector[String]): Unit = {
      // 在上下文中获取窗口元数据
      val startTime = sdf.format(new Date(context.window.getStart))
      val endTime = sdf.format(new Date(context.window.getEnd))

      // 收集计算结果并发射出去
      out.collect(s"在【$startTime ~ $endTime】期间\t$key\t的访问量为:${elements.size}")
    }
  }
}

我的代码要怎么改才能满足题目要求:/*编写Scala代码,使用Flink消费Kafka中Topic为order的数据并进行相应的数据统计计算(订单信息对应表结构order_info, 订单详细信息对应表结构order_detail(来源类型来源编号这两个字段不考虑,所以在实时数据中不会出现), 同时计算中使用order_info或order_detail表中create_time或operate_time取两者中值较大者作为EventTime, 若operate_time为空值或无此列,则使用create_time填充,允许数据延迟5s, 订单状态order_status分别为1001:创建订单、1002:支付订单、1003:取消订单、1004:完成订单、1005:申请退回、1006:退回完成。 */ /*1、使用Flink消费Kafka中的数据,统计商城实时订单数量(需要考虑订单状态,若有取消订单、申请退回、退回完成则不计入订单数量,其他状态则累加), 将key设置成totalcount存入Redis中。使用redis cli以get key方式获取totalcount值.*/ /*2、在任务1进行的同时,使用侧边流,使用Flink消费Kafka中的订单详细信息的数据, 实时统计商城中销售量前3的商品(不考虑订单状态,不考虑打折),将key设置成top3itemamount存入Redis中( value使用String数据格式,value为前3的商品信息并且外层用[]包裹,其中按排序依次存放商品id:销售量,并用逗号分割)。 使用redis cli以get key方式获取top3itemamount值。 示例如下: top3itemamount:[1:700,42:500,41:100]*/ import org.apache.flink.api.common.eventtime.{SerializableTimestampAssigner, WatermarkStrategy} import org.apache.flink.api.common.serialization.SimpleStringSchema import org.apache.flink.api.common.state.{MapState, MapStateDescriptor, ValueState} import org.apache.flink.configuration.Configuration import org.apache.flink.connector.jdbc.{JdbcConnectionOptions, JdbcExecutionOptions, JdbcSink, JdbcStatementBuilder} import org.apache.flink.connector.kafka.source.KafkaSource import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer import org.apache.flink.streaming.api.functions.sink.{RichSinkFunction, SinkFunction} import org.apache.flink.streaming.api.functions.KeyedProcessFunction import org.apache.flink.streaming.api.scala._ import org.apache.flink.streaming.connectors.redis.RedisSink import org.apache.flink.streaming.connectors.redis.common.config.FlinkJedisPoolConfig import org.apache.flink.streaming.connectors.redis.common.mapper.{RedisCommand, RedisCommandDescription, RedisMapper} import org.apache.flink.util.Collector import java.sql.PreparedStatement import java.text.SimpleDateFormat import java.time.Duration import java.sql.{Connection, DriverManager, PreparedStatement} import sc
03-20
<think>好的,我现在需要帮助用户修改他们的Scala代码,以实现使用Flink消费Kafka中的订单数据并进行统计计算。用户的需求包括两个部分:实时订单总数统计销售量前三的商品统计,并将结果存入Redis。我需要仔细分析他们的需求,并基于他们提供的引用代码来调整。 首先,用户提到了现有的引用代码,特别是引用[2]中的MyRedisMapper类。这个类目前处理的是(String, Int)类型的数据,并使用SET命令存入Redis。但用户的需求可能需要对不同的数据结构进行处理,比如前三是需要多个键值对还是某种结构化的数据。这里可能需要调整RedisMapper的实现。 接下来,用户需要从Kafka的'order'主题读取数据。根据引用[3],他们的系统已经搭建了Flink、KafkaRedis的环境,所以这部分基础设施应该没问题,但需要确认数据格式。订单数据可能包含商品ID、数量、状态等字段,需要解析。 第一个任务是过滤订单状态,统计实时订单总数。这里需要先过滤掉不符合状态的订单,然后统计总数。由于是实时统计,可能需要使用窗口函数,比如滚动窗口,但用户要求的是每五分钟统计一次,但问题描述中可能是指实时累计的总数?需要确认。不过根据用户的问题描述,他们可能希望持续更新的总数,所以可以使用一个滚动窗口,比如每5分钟输出一次结果,或者使用process function来持续更新。但根据引用[1]中的例子,每五分钟统计一次,所以可能需要时间窗口。 第二个任务是统计销量前三的商品,忽略订单状态。这里需要按商品ID进行keyBy,然后统计每个商品的总销量,之后在一个全局窗口内处理,使用ProcessFunction或者Window函数结合AllWindow来处理,然后排序取前三。需要注意,因为每个商品的总销量是不断更新的,所以可能需要使用增量聚合,然后定时触发计算。 关于输出到Redis,实时订单总数需要写入'totalcount'键,而前三商品信息需要序列化为特定格式的字符串存入'top3itemamount'。这里的序列化可能需要将前三的商品ID销量组合成字符串,如"item1:100,item2:90,item3:80"。 现在,我需要考虑如何修改现有的RedisMapper。对于实时订单总数,可能是一个简单的键值对,totalcount对应的值就是总数。对于前三商品,可能需要将三个条目序列化为一个字符串,这时候可能需要自定义RedisMapper,或者在数据流处理时将前三组合成一个字符串再写入。 在引用[2]的代码中,MyRedisMapper处理的是(String, Int)类型,而如果我们要写入top3itemamount,可能需要将前三的商品信息合并为一个字符串,比如将List[(String, Int)]转换为一个字符串,再作为value存储。因此,可能需要两个不同的RedisMapper,或者调整数据类型。 或者,可以创建两个不同的RedisSink,每个对应不同的键数据结构。例如,对于totalcount,键是固定的,值为总数;对于top3,键也是固定的,值是一个序列化的字符串。这时候可能需要两个不同的RedisMapper实例。 可能的步骤: 1. 解析Kafka数据源,转换为订单对象,提取商品ID、数量、状态等信息。 2. 过滤订单状态,统计实时订单总数。 - 使用filter过滤状态符合条件的订单。 - 使用map转换为计数1,然后使用sum聚合。 - 或者直接使用count算子。 - 注意,可能需要时间窗口,比如每5分钟统计一次总数,或者持续更新。根据用户的问题,是否每五分钟统计一次?原问题中的引用[1]提到每五分钟生产总数,所以这里可能也需要每五分钟的窗口。 3. 对于销售量前三的商品: - 过滤掉状态不符合的订单?但用户说明忽略订单状态,所以不需要过滤。 - 将每个订单中的商品ID数量提取出来,可能每个订单对应一个商品的销售数量,或者一个订单可能包含多个商品?需要假设每个订单对应一个商品,或者需要处理每个订单中的多个商品项。假设每个订单对应一个商品,那么数据流中的每个元素是(商品ID,数量)。 - 按商品ID分组,统计每个商品的总销量。 - 然后,收集所有商品的总销量,按销量排序,取前三。 - 可能需要使用窗口,比如每5分钟统计一次,或者使用滑动窗口。但用户的问题描述中没有明确时间窗口,可能需要根据具体情况处理。例如,全局统计或者滚动窗口。 - 使用KeyedStream进行每个商品的计数,然后使用windowAll将所有数据合并,再排序取前三。 4. 输出到Redis: - 实时订单总数,键为'totalcount',值为总数。 - 销量前三,键为'top3itemamount',值为序列化的字符串。 可能的代码结构: 解析Kafka数据: 假设订单数据的格式为JSON,包含字段:orderId, itemId, quantity, status。 首先定义订单的case class: case class Order(orderId: String, itemId: String, quantity: Int, status: String) 然后使用Flink的Kafka消费者读取数据,解析为Order对象。 过滤订单状态: val filteredOrders = stream.filter(_.status == "已检验") // 假设状态为"已检验" 统计订单总数: val totalCount = filteredOrders .map(_ => 1L) .timeWindowAll(Time.minutes(5)) // 每5分钟窗口 .sum(0) 或者使用事件时间,需要指定时间戳水位线。 对于商品销量统计: val itemSales = stream // 注意这里不需要过滤状态 .map(order => (order.itemId, order.quantity)) .keyBy(_._1) .timeWindow(Time.minutes(5)) // 同样每5分钟窗口 .sum(1) .windowAll(TumblingEventTimeWindows.of(Time.minutes(5))) // 可能需要重新窗口化 .process(new Top3ItemsProcessFunction()) 这里可能需要调整窗口的类型触发方式。另一种方法是使用全局窗口结合自定义触发器,或者使用增量聚合ProcessWindowFunction。 然后,将totalCounttop3写入Redis。 对于RedisSink,需要为每个输出流创建对应的sink。例如,totalCount的数据类型是Long,对应的键是"totalcount",所以需要调整MyRedisMapper。原来的MyRedisMapper处理的是(String, Int),而totalCount可能是一个Long类型,键固定为"totalcount",所以可能需要新的RedisMapper: class TotalCountRedisMapper extends RedisMapper[(String, Long)] { override def getCommandDescription = new RedisCommandDescription(RedisCommand.SET) override def getKeyFromData(data: (String, Long)) = data._1 // 这里data是("totalcount", count) override def getValueFromData(data: (String, Long)) = data._2.toString } 这样,当totalCount流输出时,元素是("totalcount", 1234L),然后使用TotalCountRedisMapper即可。 对于top3itemamount,假设处理后的结果是一个字符串,如"itemA:100,itemB:90,itemC:80",那么对应的RedisMapper可以处理一个元组("top3itemamount", "itemA:100..."),同样使用SET命令。 因此,可能需要另一个RedisMapper: class Top3RedisMapper extends RedisMapper[(String, String)] { override def getCommandDescription = new RedisCommandDescription(RedisCommand.SET) override def getKeyFromData(data: (String, String)) = data._1 override def getValueFromData(data: (String, String)) = data._2 } 然后在处理top3的流时,将结果转换为("top3itemamount", "序列化后的字符串"),并使用Top3RedisMapper写入Redis。 现在需要考虑如何将商品销量统计结果转换为这个字符串。例如,在ProcessFunction中收集所有商品的销量,排序后取前三,然后格式化为字符串。 可能的处理步骤: 在商品销量统计流中,每个窗口结束时,得到所有商品的总销量,收集后排序,取前三,并将结果格式化为字符串。 例如,使用WindowAll来收集所有商品的数据: val itemSales = stream .map(order => (order.itemId, order.quantity)) .keyBy(_._1) .timeWindow(Time.minutes(5)) .sum(1) .map(data => (data._1, data._2)) // (itemId, totalQuantity) .windowAll(TumblingEventTimeWindows.of(Time.minutes(5))) .process(new Top3ProcessFunction()) 在Top3ProcessFunction中,收集所有元素,按总销量排序,取前三,然后生成字符串。 然后,将结果转换为("top3itemamount", "item1:count1,item2:count2,item3:count3"),并写入Redis。 需要注意的是,Flink窗口处理可能需要设置合适的时间特征,比如事件时间或处理时间。用户需要根据实际情况配置。 此外,关于侧输出流,用户提到要通过侧输出流统计销量前三的商品。可能原来的订单总数统计使用主流,而侧输出流用于处理top3。不过根据用户的问题,可能更倾向于将两个统计分开处理,各自使用不同的流。 综上,代码的大致结构可能如下: 1. 设置Flink环境,读取Kafka源,解析为Order对象。 2. 过滤订单状态,统计总数,写入Redis。 3. 处理商品销量,统计top3,写入Redis。 现在需要将代码结构转换为具体的Scala代码,并参考用户提供的MyRedisMapper进行调整。 例如,主程序可能如下: import org.apache.flink.streaming.api.scala._ import org.apache.flink.streaming.api.windowing.time.Time import org.apache.flink.streaming.api.functions.sink.SinkFunction import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer import org.apache.flink.streaming.connectors.redis.RedisSink import org.apache.flink.streaming.connectors.redis.common.config.FlinkJedisPoolConfig import org.apache.flink.util.Collector object OrderAnalysisJob { def main(args: Array[String]): Unit = { val env = StreamExecutionEnvironment.getExecutionEnvironment // Kafka配置 val properties = new Properties() properties.setProperty("bootstrap.servers", "kafka:9092") properties.setProperty("group.id", "order-consumer-group") // 创建Kafka消费者 val consumer = new FlinkKafkaConsumer[String]("order", new SimpleStringSchema(), properties) val stream = env.addSource(consumer).map(parseOrder) // 解析JSON为Order对象 // 实时订单总数统计(已过滤状态) val totalCountStream = stream .filter(_.status == "已检验") // 根据实际情况调整状态过滤条件 .map(_ => 1L) .timeWindowAll(Time.minutes(5)) // 每5分钟窗口 .sum(0) .map(count => ("totalcount", count)) // 配置Redis连接 val redisConfig = new FlinkJedisPoolConfig.Builder().setHost("redis").setPort(6379).build() // 创建RedisSink用于totalcount val totalCountSink = new RedisSink[(String, Long)](redisConfig, new TotalCountRedisMapper) totalCountStream.addSink(totalCountSink) // 销售量前3商品统计(忽略状态) val top3Stream = stream .map(order => (order.itemId, order.quantity)) .keyBy(_._1) .timeWindow(Time.minutes(5)) // 每5分钟窗口 .sum(1) .windowAll(TumblingEventTimeWindows.of(Time.minutes(5))) // 收集所有商品的数据 .process(new Top3ProcessFunction()) .map(top3Str => ("top3itemamount", top3Str)) // 创建RedisSink用于top3 val top3Sink = new RedisSink[(String, String)](redisConfig, new Top3RedisMapper) top3Stream.addSink(top3Sink) env.execute("Order Analysis Job") } // 解析JSON字符串为Order对象 def parseOrder(json: String): Order = { // 使用JSON库解析,这里简化为样例类 // 例如,使用Jackson或Circe库 // 假设JSON格式正确,解析逻辑省略 ??? } } // RedisMapper for totalcount (Long) class TotalCountRedisMapper extends RedisMapper[(String, Long)] { override def getCommandDescription: RedisCommandDescription = new RedisCommandDescription(RedisCommand.SET) override def getKeyFromData(data: (String, Long)): String = data._1 override def getValueFromData(data: (String, Long)): String = data._2.toString } // RedisMapper for top3itemamount (String) class Top3RedisMapper extends RedisMapper[(String, String)] { override def getCommandDescription: RedisCommandDescription = new RedisCommandDescription(RedisCommand.SET) override def getKeyFromData(data: (String, String)): String = data._1 override def getValueFromData(data: (String, String)): String = data._2 } // 处理函数,计算销量前3 class Top3ProcessFunction extends AllWindowFunction[(String, Int), String, TimeWindow] { override def apply(window: TimeWindow, input: Iterable[(String, Int)], out: Collector[String]): Unit = { // 将商品按总销量排序,取前3 val top3 = input.toList .groupBy(_._1) // 按商品ID分组,可能不需要,因为前面已经按商品ID keyBy并sum .map { case (itemId, list) => (itemId, list.map(_._2).sum) } // 再次求,可能多余,但确保正确 .toList .sortBy(-_._2) // 降序排序 .take(3) // 格式化为字符串 val resultStr = top3.map { case (item, count) => s"$item:$count" }.mkString(",") out.collect(resultStr) } } 需要注意的问题: 1. 解析JSON的部分需要根据实际数据格式实现,这里可能需要使用JSON解析库,如Jackson或Circe。 2. 窗口的类型时间特性:这里假设使用事件时间,可能需要设置env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime),并指定时间戳生成水位线的方法,例如在数据源中分配时间戳。 3. 在Top3ProcessFunction中,由于前面已经按商品ID分组并sum,每个窗口输出的数据已经是每个商品的累计销量,所以在windowAll后的输入应该是所有商品的累计销量。但可能由于前面的keyBy,每个窗口结束时,每个商品只有一个记录。因此,在apply函数中,可以直接收集所有元素,然后排序取前三。 4. 侧输出流的问题:用户提到通过侧输出流统计销量最高的前三,但上述代码并没有使用侧输出流,而是分开处理了两个不同的流。可能用户的意思是使用主流处理订单总数,而将top3通过侧输出流处理,但根据问题描述,可能不需要侧输出流,而是两个独立的处理流程。 5. 是否需要处理乱序事件?如果使用事件时间,可能需要设置水位线延迟。 6. Redis的键值设置是否正确。例如,totalcount的键是固定的,而top3itemamount的键也是固定的,每次写入会覆盖之前的值,这符合用户的需求。 7. 是否需要使用不同的Redis数据结构?例如,使用有序集合来存储top3,但用户要求value是特定格式的字符串,所以使用SET命令覆盖即可。 可能还需要调整MyRedisMapper,以适配不同的数据类型。例如,用户提供的引用[2]中的MyRedisMapper处理的是(String, Int),而这里需要处理(String, Long)(String, String)。 另外,在Top3ProcessFunction中,是否可能因为窗口太大导致内存问题?这里假设数据量不大,可以一次性处理所有数据。 综上,上述代码框架应该能够满足用户的需求,但需要根据具体的数据格式环境进行调整。例如,JSON解析部分需要实现,时间窗口的配置需要根据实际时间语义设置,以及Redis的连接配置是否正确。</think>以下是如何基于Scala实现Flink消费Kafka订单数据并完成统计的解决方案: --- ### 一、代码实现步骤 #### 1. 创建Flink流环境与Kafka消费者 ```scala import org.apache.flink.streaming.api.scala._ import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer import org.apache.flink.streaming.connectors.redis.RedisSink import org.apache.flink.streaming.connectors.redis.common.config.FlinkJedisPoolConfig val env = StreamExecutionEnvironment.getExecutionEnvironment val properties = new Properties() properties.setProperty("bootstrap.servers", "kafka:9092") properties.setProperty("group.id", "order-group") // 从Kafka读取订单数据 val orderStream: DataStream[Order] = env .addSource(new FlinkKafkaConsumer[String]("order", new SimpleStringSchema(), properties)) .map(parseOrder) // 解析JSON为Order对象 ``` #### 2. 实时订单总数统计 ```scala // 过滤有效订单并统计总数 val validOrders = orderStream.filter(_.status == "PAID") // 根据业务定义有效状态 val totalCount = validOrders .map(_ => 1L) .timeWindowAll(Time.minutes(5)) // 5分钟滚动窗口 .sum(0) .map(count => ("totalcount", count.toString)) // 配置Redis连接 val redisConfig = new FlinkJedisPoolConfig.Builder() .setHost("redis-host") .setPort(6379) .build() // 自定义RedisMapper(适配Long类型) class TotalCountMapper extends RedisMapper[(String, String)] { override def getCommandDescription = new RedisCommandDescription(RedisCommand.SET) override def getKeyFromData(data: (String, String)) = data._1 override def getValueFromData(data: (String, String)) = data._2 } // 写入Redis totalCount.addSink(new RedisSink(redisConfig, new TotalCountMapper)) ``` #### 3. 商品销量Top3统计 ```scala // 统计各商品销量 val itemSales = orderStream .flatMap(order => order.items.map(item => (item.id, item.quantity))) // 假设订单含多个商品 .keyBy(_._1) .timeWindow(Time.minutes(5)) .sum(1) // 计算全局Top3 val top3Items = itemSales .windowAll(TumblingEventTimeWindows.of(Time.minutes(5))) .process(new Top3Processor()) // 序列化Top3为字符串 val serializedTop3 = top3Items.map { items => val formatted = items.map { case (id, count) => s"$id:$count" }.mkString(",") ("top3itemamount", formatted) } // 自定义RedisMapper(存储字符串) class Top3Mapper extends RedisMapper[(String, String)] { override def getCommandDescription = new RedisCommandDescription(RedisCommand.SET) override def getKeyFromData(data: (String, String)) = data._1 override def getValueFromData(data: (String, String)) = data._2 } // 写入Redis serializedTop3.addSink(new RedisSink(redisConfig, new Top3Mapper)) ``` #### 4. 处理函数实现 ```scala // 定义订单数据结构 case class Order(id: String, items: List[Item], status: String) case class Item(id: String, quantity: Int) // Top3处理逻辑 class Top3Processor extends ProcessAllWindowFunction[(String, Int), List[(String, Int)], TimeWindow] { override def process(context: Context, elements: Iterable[(String, Int)], out: Collector[List[(String, Int)]]) = { val top3 = elements.toList .groupBy(_._1) .map { case (k, v) => (k, v.map(_._2).sum) } .toList .sortBy(-_._2) .take(3) out.collect(top3) } } ``` --- ### 二、关键点说明 1. **数据过滤**:通过`filter`筛选有效订单(如状态为"PAID"),确保统计准确性[^1]; 2. **窗口机制**:使用`timeWindowAll`实现5分钟滚动窗口,符合定时统计需求; 3. **Redis序列化**:通过自定义`RedisMapper`适配不同数据结构,其中Top3需转换为特定格式字符串[^2]; 4. **扩展性**:若需处理事件时间,需添加`assignTimestampsAndWatermarks`定义时间属性[^3]。 --- ### 三、执行流程示意图 ``` Kafka订单数据 → Flink消费 → 过滤有效订单 → 5分钟窗口计数 → Redis存储totalcount │ └→ 展开商品 → 按商品聚合 → 全局排序Top3 → 格式化为字符串 → Redis存储top3itemamount ``` ---
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CDHong.it

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值