Flink 中的窗口
实验介绍
在流式计算中,我们所接入的数据集是无限流,或者说是没有边界的数据流。那么有没有办法将无限流转换为有限流呢?这里就需要引入 Window(窗口)的概念,通过 Window 我们可以按照固定时间或长度将无限数据流切分成不同长度的有限数据块,然后在每个窗口内针对数据块进行聚合运算。
知识点
- Window 分类
- Keyed Window 和 Global Window
- Time Window 和 Count Window
- Window API
Window 分类
众所周知,如果要对人进行分类的话,按照性别可以分为男和女,按照肤色可以分为黄种人、白种人和黑种人。对于 Window 而言,根据上游数据集的类型可以分为 Keyed Window 和 Global Window,根据业务场景来分,又可以分为 Count Window 和 Time Window。
Time Window 和 Count Window
Time Window
基于时间定义的窗口。根据不同业务场景又可以分为滚动窗口(Tumbling Window)、滑动窗口(Sliding Window)和会话窗口(Session Window)三种。
-
滚动窗口(Tumbing Window):滚动窗口是按照固定时间进行切分,而且所有窗口之间的数据不会重叠,使用时只需要指定一个窗口长度即可。
面的示例图中可以看到,滚动窗口的窗口大小(window size)是固定的,而且相邻窗口之间是连续的。现在有这样的业务场:某公司要求每 10 秒统计一次最近 10 秒内各个电商平台的订单数量并输出到大屏幕,这时候就需要用到滚动窗口了,我们只需要将窗口大小设置为 10 秒就可以。我们使用 netcat 发送 Socket 数据来模拟订单流量。
在 com.vlab.window
包下创建 TumblingWindow
Scala Object,代码如下:
package com.vlab.window
import org.apache.flink.api.scala._
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.functions.AssignerWithPeriodicWatermarks
import org.apache.flink.streaming.api.scala.{DataStream, StreamExecutionEnvironment}
import org.apache.flink.streaming.api.watermark.Watermark
import org.apache.flink.streaming.api.windowing.time.Time
/**
* @projectName FlinkLearning
* @package com.vlab.window
* @className com.vlab.window.TumblingWindow
* @description Tumbling Window Example
* @author pblh123
* @date 2025/2/8 15:22
* @version 1.0
*
*/
object TumblingWindow {
def main(args: Array[String]): Unit = {
// 参数数量判断
if (args.length != 1) {
System.err.println("Usage: TumblingWindow <input ip>")
System.exit(5)
}
// 获取输入IP
val inputIp = args(0)
// 获取流式环境变量
val env = StreamExecutionEnvironment.getExecutionEnvironment
// 设置时间特性为事件时间
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
// 读取socket数据,并分配时间戳和水印
val dataWithTimestampsAndWatermarks: DataStream[String] = env.socketTextStream(inputIp, 9999)
.assignTimestampsAndWatermarks(new AssignerWithPeriodicWatermarks[String] {
var currentMaxTimestamp: Long = _
override def extractTimestamp(element: String, previousElementTimestamp: Long): Long = {
val timestamp = System.currentTimeMillis() // 假设使用当前时间作为时间戳
currentMaxTimestamp = timestamp
timestamp
}
override def getCurrentWatermark: Watermark = {
if (currentMaxTimestamp == Long.MinValue) null else new Watermark(currentMaxTimestamp - 1000) // 水印滞后1秒
}
})
// 过滤掉空行,将数据映射为元组,包含单词和出现次数
// 使用keyBy对数据进行分组,并使用timeWindow进行滚动窗口计算
// 使用sum对每个分组内的数据进行求和,得到每个窗口内的单词总数
val dataStream: DataStream[(String, Int)] = dataWithTimestampsAndWatermarks.filter(_.nonEmpty)
.map(line => (line, 1))
.keyBy(_._1) // 注意这里的变化,确保正确地按第一个元素(即单词)分组
.timeWindow(Time.seconds(5))
.sum(1)
// 打印输出结果
dataStream.print().setParallelism(1)
// 启动Flink作业
env.execute("TumblingWindow")
}
}
在上面的代码中,我们监听了 localhost
的 9999 端口,将接收到的数据转换为(key, 1)这样的键值对,然后按 key 分组,设定窗口大小为 10 秒,最后针对每个分组里的数据进行求和并输出。
在终端中输入 nc -l -p 9999
,然后运行程序,在终端中输入以下内容,然后观察控制台输出:
在终端输入的时候注意控制时间间隔,由于代码中设置的窗口大小是 10 秒,所以会每隔 10 秒打印一次最近 10 秒内输入的数据。最终输出的统计结果和上图中不一致属于正常情况。另外 timeWindow()
方法中的时间参数除了 Time.seconds()
,还有 Time.days()
、Time.hours()
、Time.minutes()
和 Time.milliseconds()
,在 idea 中输入的时候会有提示。
-
滑动窗口(Sliding Window):滑动窗口有两个参数,分别是窗口大小和窗口滑动时间,它是允许不同窗口的元素重叠的(同一个元素可以出现在不同的窗口中)。窗口大小指定数据统计的时间跨度,而滑动时间指定的是相邻两个窗口时间的时间偏移量。当滑动时间小于窗口大小的时候,数据会发生重叠;当滑动窗口大于窗口大小的时候,窗口会出现不连续的情况(部分元素不会纳入统计);当滑动时间和窗口大小相等的时候,滑动窗口就是滚动窗口,从这个角度来看,滚动窗口是滑动窗口的一个特殊存在。
窗口和滚动窗口在使用过程中的唯一区别就是多了一个滑动大小的参数。假设将上面的业务修改为“公司要求每隔 5 秒统计一次最近 10 秒内的各平台订单量”,那么这里的窗口大小(window size)就是 10 秒,而窗口滑动大小(window slide)是 5 秒,从上图中我们可以看到,当窗口滑动大小小于窗口大小的时候,元素是会重叠的。完整代码如下:
package com.vlab.window
import org.apache.flink.api.scala._
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.functions.AssignerWithPeriodicWatermarks
import org.apache.flink.streaming.api.scala.{DataStream, StreamExecutionEnvironment}
import org.apache.flink.streaming.api.watermark.Watermark
import org.apache.flink.streaming.api.windowing.time.Time
object SlidlingWindow {
def main(args: Array[String]): Unit = {
// 参数数量判断
if (args.length != 1) {
System.err.println("Usage: SlidlingWindow <input ip>")
System.exit(5)
}
val inputIp = args(0)
// 获取执行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
// 设置时间特性为事件时间
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
// 获取socket数据流,并分配时间戳和水印
val dataWithTimestampsAndWatermarks: DataStream[String] = env.socketTextStream(inputIp, 9999)
.assignTimestampsAndWatermarks(new AssignerWithPeriodicWatermarks[String] {
var currentMaxTimestamp: Long = _
override def extractTimestamp(element: String, previousElementTimestamp: Long): Long = {
// 假设每条记录的时间戳是其内容的一部分,这里简化处理,直接使用当前时间
val timestamp = System.currentTimeMillis()
currentMaxTimestamp = Math.max(timestamp, currentMaxTimestamp)
timestamp
}
override def getCurrentWatermark: Watermark = {
// 水印滞后于最大时间戳1秒
new Watermark(currentMaxTimestamp - 1000)
}
})
// 从非空数据中创建一个数据流,计算每个窗口内非空字符串的数量
val dataStream: DataStream[(String, Int)] = dataWithTimestampsAndWatermarks.filter(_.nonEmpty)
.map((_, 1))
.keyBy(_._1) // 注意这里的变化,确保正确地按第一个元素(即单词)分组
.timeWindow(Time.seconds(10), Time.seconds(5)) // 定义一个滑动时间窗口
.sum(1) // 在每个窗口内对每个键的元组的第二个元素求和
// 打印结果数据流,并设置并行度为1以确保输出顺序
dataStream.print().setParallelism(1)
// 执行Flink作业,命名为"SlidlingWindow"
env.execute("SlidlingWindow")
}
}
- 会话窗口(Session Window):与前滚动窗口和滑动窗口不同的是,会话窗口没有固定的滑动时间和窗口大小,而是通过一个 session gap 来指定窗口间隔。如果在 session gap 规定的时间内没有活跃数据进入的话,则认为当前窗口结束,下一个窗口开始。session gap 可以理解为相邻元素的最大时间差。
代码如下:
package com.vlab.window
import org.apache.flink.api.scala._
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.functions.AssignerWithPeriodicWatermarks
import org.apache.flink.streaming.api.scala.{DataStream, StreamExecutionEnvironment}
import org.apache.flink.streaming.api.watermark.Watermark
import org.apache.flink.streaming.api.windowing.assigners.EventTimeSessionWindows
import org.apache.flink.streaming.api.windowing.time.Time
/**
* @projectName FlinkLearning
* @package com.vlab.window
* @className com.vlab.window.SessionWindow
* @description 示例展示了如何使用Flink进行基于事件时间的会话窗口计算。
* @author pblh123
* @date 2025/2/8 16:04
* @version 1.0
*
*/
object SessionWindow {
def main(args: Array[String]): Unit = {
// 参数数量判断
if (args.length != 1) {
System.err.println("Usage: SessionWindow <input ip>")
System.exit(5)
}
// 获取输入IP
val inputIp = args(0)
// 获取流式环境变量
val env = StreamExecutionEnvironment.getExecutionEnvironment
// 设置时间特性为事件时间
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
// 读取socket数据,并分配时间戳和水印
val dataWithTimestampsAndWatermarks: DataStream[String] = env.socketTextStream(inputIp, 9999)
.assignTimestampsAndWatermarks(new AssignerWithPeriodicWatermarks[String] {
var currentMaxTimestamp: Long = _
override def extractTimestamp(element: String, previousElementTimestamp: Long): Long = {
// 假设每条记录的时间戳是其内容的一部分,这里简化处理,直接使用当前时间
val timestamp = System.currentTimeMillis()
currentMaxTimestamp = Math.max(timestamp, currentMaxTimestamp)
timestamp
}
override def getCurrentWatermark: Watermark = {
// 水印滞后于最大时间戳1秒
new Watermark(currentMaxTimestamp - 1000)
}
})
// 定义一个会话窗口并处理数据
val dataStream: DataStream[(String, Int)] = dataWithTimestampsAndWatermarks.filter(_.nonEmpty)
.map(line => (line, 1))
.keyBy(_._1) // 注意这里的变化,确保正确地按第一个元素(即单词)分组
.window(EventTimeSessionWindows.withGap(Time.seconds(10))) // 使用正确的会话窗口方法
.sum(1) // 在每个窗口内对每个键的元组的第二个元素求和
// 打印输出结果
dataStream.print().setParallelism(1)
// 启动Flink作业
env.execute("SessionWindow")
}
}
在上面的代码中,我们使用 window(EventTimeSessionWindows.withGap(Time.seconds(10)))
指定当前窗口为会话窗口,而且最大等待时间为 10 秒,也就是说如果相邻两个元素的抵达时间小于等于 10 秒,则不会触发当前窗口,一旦超过 10秒未接收到新数据,当前窗口触发计算。大家可以使用 nc -l -p 9999
命令启动终端发送数据并观察控制台输出,注意把控相邻元素的时间间隔。
Count Window
基于输入数据量定义,与时间无关。Count Window 也可以细分为滚动窗口和滑动窗口,逻辑和 Time Window 中的滚动窗口和滑动窗口的逻辑类似,只是窗口大小和触发条件由时间换成了相同 Key 元素的数量。窗口大小是由相同 Key 元素的数量来触发执行,执行时只计算元素数量达到窗口大小的 key 对应的结果。
滚动窗口代码如下:
package com.vlab.window
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
/**
* @projectName FlinkLearning
* @package com.vlab.window
* @className com.vlab.window.CountTumblingWindow
* @description ${description}
* @author pblh123
* @date 2025/2/8 16:34
* @version 1.0
*
*/
object CountTumblingWindow {
def main(args: Array[String]): Unit = {
// 参数数量判断
// 当传入的参数数量不等于1时,输出错误信息并退出程序
if (args.length != 1) {
System.err.println("Usage: CountTumblingWindow <input ip>")
System.exit(5)
}
// 获取输入的IP地址
val inputIp = args(0)
// 获取执行环境
// 创建一个流执行环境,用于执行Flink作业
val env = StreamExecutionEnvironment.getExecutionEnvironment
// 读取socket数据
// 从指定的IP地址和端口读取数据流
val dataStream = env.socketTextStream(inputIp, 9999)
// 导入必要的包
import org.apache.flink.streaming.api.scala._
// 使用count窗口进行分组和聚合
// 过滤掉空行,将每行数据映射为元组,然后按照元组的第一个元素进行分组
// 使用计数窗口对每3个元素进行聚合,计算每个窗口内元素的数量
val dataSum: DataStream[(String, Int)] = dataStream
.filter(_.nonEmpty)
.map(line => (line, 1))
.keyBy(0)
.countWindow(3)
.sum(1)
// 打印结果并设置并行度为1
// 输出转换后的数据流,并确保输出是顺序的
dataSum.print().setParallelism(1)
// 启动Flink作业
// 开始执行Flink作业,作业名称为"CountTumblingWindow"
env.execute("CountTumblingWindow")
}
}
在上面的代码中我们设置的窗口触发条件为 3,也就是相同 Key 元素达到 3 之后就触发计算。注意是相同 Key 元素的个数而不是所有元素的总数。在终端输入nc -l -p 9999
之后依次输入以下内容:
tianmao
jindong
taobao
tianmao
tianmao
taobao
taobao
jindong
jindong
在输入以上内容的时候注意观察控制台输出:
(tianmao,3)
(taobao,3)
(jindong,3)
滑动窗口代码如下:
package com.vlab.window
import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment
/**
* @projectName FlinkLearning
* @package com.vlab.window
* @className com.vlab.window.CountslidlingWindow
* @description ${description}
* @author pblh123
* @date 2025/2/8 16:53
* @version 1.0
*
*/
object CountslidlingWindow {
def main(args: Array[String]): Unit = {
// 参数数量判断
if (args.length != 1) {
System.err.println("Usage: CountslidlingWindow <input ip>")
System.exit(5)
}
val inputIp = args(0)
// 获取执行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
import org.apache.flink.streaming.api.scala._
// 获取socket数据源
val dataStream = env.socketTextStream(inputIp, 9999)
// 处理数据流,包括过滤空值、映射转换、窗口计算等步骤
val datadl: DataStream[(String, Int)] = dataStream.filter(_.nonEmpty)
.map((_, 1))
.keyBy(0)
.countWindow(3, 2)
.sum(1)
// 输出处理结果
datadl.print().setParallelism(1)
// 启动Flink程序
env.execute("Sliding Tumblint Window")
}
}
在上面的代码中,countWindow()
方法有两个参数,分别是size
和slide
,其中size
为 3,slide
为 2。也就是说每收到两个相同 Key 的元素就触发一次计算,计算的范围是相邻的 3 个相同元素。
使用 nc -l -p 9999
命令启动终端并发送以下内容:
taobao
taobao
tianmao
taobao
tianmao
taobao
taobao
taobao
输出如下:
(taobao,2)
(tianmao,2)
(taobao,3)
(taobao,3)
Keyed Window 和 Global Window
Keyed Window
在前面学习聚合算子的时候我们有提到过 KeyedStream 类型。如果上游数据集的类型是 KeyedStream,则调用window()
方法,数据会根据 Key 在不同的 Task 实例中分别计算,最后将得到针对每个 Key 的统计结果。
Global Window
如果上游数据集是非键值对类型的,则调用windowAll()
方法,所有的数据都会在窗口算子中一个 Task 中计算,并得到全局的结果。
Keyed Window 和 Global Window 大家作为了解即可,由于使用较少,这里不做赘述。
总结
Window 是流处理中非常常用,也是非常重要的一种处理方式。其中 Time Window 可以说是重点中的重点,大家在学习的时候要认真理解示例图,搞清楚窗口大小和窗口滑动大小的关系。万变不离其宗,不论业务过程如何复杂,最终都会转化到本实验的编程模型中,唯一需要替换的就是聚合部分的业务逻辑。Keyed Window 和 Global Window 大家简单了解就好,有兴趣的同学可以自行实验。