在流数据处理应用中,一个很重要、也很常见的操作就是窗口计算。所谓的“窗口”,一般就是划定的一段时间范围,也就是“时间窗”;对在这范围内的数据进行处理,就是所谓的窗口计算。所以窗口和时间往往是分不开的。
在事件发生之后,生成的数据被收集起来,首先进入分布式消息队列,然后被 Flink 系统中的 Source 算子读取消费,进而向下游的转换算子(窗口算子)传递,最终由窗口算子进行计算处理。
很明显,这里有两个非常重要的时间点:一个是数据产生的时间,我们把它叫作“事件时间”(Event Time);另一个是数据真正被处理的时刻,叫作“处理时间”(Processing Time)。我们所定义的窗口操作,到底是以那种时间作为衡量标准,就是所谓的“时间语义”(Notions of Time)。由于分布式系统中网络传输的延迟和时钟漂移,处理时间相对事件发生的时间会有所滞后。
1. 处理时间(Processing Time)
处理时间的概念非常简单,就是指执行处理操作的机器的系统时间。
在这种时间语义下处理窗口非常简单粗暴,不需要各个节点之间进行协调同步,也不需要考虑数据在流中的位置,简单来说就是“我的地盘听我的”。所以处理时间是最简单的时间语义。
2. 事件时间(Event Time)
事件时间,是指每个事件在对应的设备上发生的时间,也就是数据生成的时间。
数据一旦产生,这个时间自然就确定了,所以它可以作为一个属性嵌入到数据中。这其实就是这条数据记录的“时间戳”(Timestamp)。
在事件时间语义下,我们对于时间的衡量,就不看任何机器的系统时间了,而是依赖于数据本身。但是由于分布式系统中网络传输延迟的不确定性,实际应用中我们要面对的数据流往往是乱序的。在这种情况下,就不能简单地把数据自带的时间戳当作时钟了,而需要用另外的标志来表示事件时间进展,在 Flink 中把它叫作事件时间的“水位线”(Watermarks)。
在计算机系统的实际应用中,事件时间语义会更为常见。一般情况下,业务日志数据中都会记录数据生成的时间戳(timestamp),它就可以作为事件时间的判断基础。
通常来说,处理时间是我们计算效率的衡量标准,而事件时间会更符合我们的业务计算逻辑。所以更多时候我们使用事件时间;不过处理时间也不是一无是处。对于处理时间而言,由于没有任何附加考虑,数据一来就直接处理,因此这种方式可以让我们的流处理延迟降到最低,效率达到最高。
在事件时间语义下,我们不依赖系统时间,而是基于数据自带的时间戳去定义了一个时钟,用来表示当前时间的进展。于是每个并行子任务都会有一个自己的逻辑时钟,它的前进是靠数据的时间戳来驱动的。
我们可以把时钟也以数据的形式传递出去,告诉下游任务当前时间的进展;而且这个时钟的传递不会因为窗口聚合之类的运算而停滞。一种简单的想法是,在数据流中加入一个时钟标记,记录当前的事件时间;这个标记可以直接广播到下游,当下游任务收到这个标记,就可以更新自己的时钟了。由于类似于水流中用来做标志的记号,在 Flink 中,这种用来衡量事件时间(Event Time)进展的标记,就被称作“水位线”(Watermark)。
具体实现上,水位线可以看作一条特殊的数据记录,它是插入到数据流中的一个标记点,主要内容就是一个时间戳,用来指示当前的事件时间。而它插入流中的位置,就应该是在某个数据到来之后;这样就可以从这个数据中提取时间戳,作为当前水位线的时间戳了。
在理想状态下,数据应该按照它们生成的先后顺序、排好队进入流中;而在实际应用中,如果当前数据量非常大,可能会有很多数据的时间戳是相同的,这时每来一条数据就提取时间戳、插入水位线就做了大量的无用功。所以为了提高效率,一般会每隔一段时间生成一个水位线,这个水位线的时间戳,就是当前最新数据的时间戳,所以这时的水位线,其实就是有序流中的一个周期性出现的时间标记。
在分布式系统中,数据在节点间传输,会因为网络传输延迟的不确定性,导致顺序发生改变,这就是所谓的“乱序数据”。
对于连续数据流,我们插入新的水位线时,要先判断一下时间戳是否比之前的大,否则就不再生成新的水位线。也就是说,只有数据的时间戳比当前时钟大,才能推动时钟前进,这时才插入水位线。
如果考虑到大量数据同时到来的处理效率,我们同样可以周期性地生成水位线。这时只需要保存一下之前所有数据中的最大时间戳,需要插入水位线时,就直接以它作为时间戳生成新的水位线。
为了让窗口能够正确收集到迟到的数据,我们可以等上 2 秒;也就是用当前已有数据的最大时间戳减去 2 秒,就是要插入的水位线的时间戳。
如果仔细观察就会看到,这种“等 2 秒”的策略其实并不能处理所有的乱序数据。所以我们可以试着多等几秒,也就是把时钟调得更慢一些。最终的目的,就是要让窗口能够把所有迟到数据都收进来,得到正确的计算结果。对应到水位线上,其实就是要保证,当前时间已经进展到了这个时间戳,在这之后不可能再有迟到数据来了。
现在我们可以知道,水位线就代表了当前的事件时间时钟,而且可以在数据的时间戳基础上加一些延迟来保证不丢数据,这一点对于乱序流的正确处理非常重要。
水位线是 Flink 流处理中保证结果正确性的核心机制,它往往会跟窗口一起配合,完成对乱序数据的正确处理。
如果我们希望计算结果能更加准确,那可以将水位线的延迟设置得更高一些,等待的时间
越长,自然也就越不容易漏掉数据。不过这样做的代价是处理的实时性降低了,我们可能为极
少数的迟到数据增加了很多不必要的延迟。
如果我们希望处理得更快、实时性更强,那么可以将水位线延迟设得低一些。这种情况下,
可能很多迟到数据会在水位线之后才到达,就会导致窗口遗漏数据,计算结果不准确。对于这
些 “漏网之鱼”,Flink 另外提供了窗口处理迟到数据的方法,我们会在后面介绍。当然,如
果我们对准确性完全不考虑、一味地追求处理速度,可以直接使用处理时间语义,这在理论上
可以得到最低的延迟。
所以 Flink 中的水位线,其实是流处理中对低延迟和结果正确性的一个权衡机制,而且把
控制的权力交给了程序员,我们可以在代码中定义水位线的生成策略。
在 Flink 的 DataStream API 中 , 有 一 个 单 独 用 于 生 成 水 位 线 的 方 法 :
assignTimestampsAndWatermarks()
,它主要用来为流中的数据分配时间戳,并生成水位线来指
示事件时间。
具体使用时,直接用 DataStream 调用该方法即可。
val stream = env.addSource(new ClickSource)
val withTimestampsAndWatermarks =
stream.assignTimestampsAndWatermarks(<watermark strategy>)
assignTimestampsAndWatermarks()
方法需要传入一个 WatermarkStrategy 作为参数,这就是
所谓的“水位线生成策略”。WatermarkStrategy
中包含了一个“时间戳分配器”TimestampAssigner
和一个“水位线生成器”WatermarkGenerator。
onEvent()
和 onPeriodicEmit()
。WatermarkOutput
发出水位线。周期时间为处理时间,可以调用环境配置env.getConfig
的setAutoWatermarkInterval()
方法来设置,系统默认为200ms。 env.getConfig.setAutoWatermarkInterval(500L) //自动生成水位线的周期时间间隔,他是长整型的,这里设置为500毫秒
建议使用,flink内置的乱序流水位线策略,就可以了。
1、有序流的水位线生成策略
思路:
我们直接用当前的 DataStream流数据对象
调用 assignTimestampsAndWatermarks
方法,分配时间戳,然后里面使用 WatermarkStrategy
这个是水位线生成策略,他下面两个方法,一个是有序流的,一个是乱序流的,我们这里使用有序流的,forMonotonousTimestamps[Events]()
他有个泛型,是当前的数据类型,然后继续调用 withTimestampAssigner()
方法来指定哪个字段为时间戳,这里面 new SerializableTimestampAssigner[Events]
这个是可以序列化的提取时间戳,这种比较简单,然后里面重写一个方法 extractTimestamp(t: Events, l: Long): Long
l里面两个字段第一个是当前的每一条数据,第二个参数是指定的时间戳,最后返回的也是这个,我们直接 t.shijian
就把我们泛型 Events
中 shijian
字段指定为时间戳了。
//1、有序流的水位线生成策略
//WatermarkStrategy 这是flink考虑到我们实现太麻烦,所以给我们写好了这种策略,下面有两种方法,
// 一种是有序流的forMonotonousTimestamps(),还有一种forBoundedOutOfOrderness() 乱序流的
stream.assignTimestampsAndWatermarks( WatermarkStrategy.forMonotonousTimestamps[Events]() 泛型需要指定输入的数据类型
.withTimestampAssigner( //withTimestampAssigner() 需要用这个方法来制定哪个时间为时间戳
new SerializableTimestampAssigner[Events]{ //这种是可以序列化的提取时间戳,这种会比较简单
override def extractTimestamp(t: Events, l: Long): Long = { // 重写 extractTimestamp 提取时间戳,两个参数,第一个是当前每条数据
t.shijian //还有一个是指定好的当前的时间戳,我们直接 t.时间戳,就把指定好的时间戳字段提取出来了
}
}
))
2、乱序流水位线生成策略
思路: 与有序流很相似,只需要改一点点
我们直接用当前的 DataStream流数据对象
调用 assignTimestampsAndWatermarks
方法,分配时间戳,然后里面使用 WatermarkStrategy
这个是水位线生成策略,他下面两个方法,一个是有序流的,一个是乱序流的,我们这里使用乱序流的,forBoundedOutOfOrderness[Events](Duration.ofSeconds(2))
,Duration.ofSeconds(2)
指定最大的延迟时间,因为是乱序的,有些数据还没来,所以我们设置个延迟时间,他有个泛型,是当前的数据类型,然后继续调用 withTimestampAssigner()
方法来指定哪个字段为时间戳,这里面 new SerializableTimestampAssigner[Events]
这个是可以序列化的提取时间戳,这种比较简单,然后里面重写一个方法 extractTimestamp(t: Events, l: Long): Long
l里面两个字段第一个是当前的每一条数据,第二个参数是指定的时间戳,最后返回的也是这个,我们直接 t.shijian
就把我们泛型 Events
中 shijian
字段指定为时间戳了。
//2、乱序流的水位线生成策略
//orBoundedOutOfOrderness() 乱序流水位线生成策略
stream.assignTimestampsAndWatermarks( WatermarkStrategy.forBoundedOutOfOrderness[Events](Duration.ofSeconds(2)) //最大延迟时间,因为是乱序的有些还没来,我们这里等延迟数据两秒
.withTimestampAssigner(
new SerializableTimestampAssigner[Events] {
override def extractTimestamp(t: Events, l: Long): Long = {
t.shijian
}
}
))
建议使用,flink 内置的乱序流水位线生成策略
package chat02
import org.apache.flink.api.common.eventtime._
import org.apache.flink.api.scala._
import org.apache.flink.streaming.api.scala.{DataStream, StreamExecutionEnvironment}
// 自定义水位线
class flink02_Watermark2 {
}
object flink02_Watermark2{
def main(args: Array[String]): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setParallelism(4)
env.getConfig.setAutoWatermarkInterval(500L) //自动生成水位线的周期时间间隔,他是长整型的,这里设置为500毫秒
val stream: DataStream[Events] = env.fromElements(
Events("Mary", "./home", 1000L),
Events("Bob", "./cart", 2000L),
Events("Alice", "./cart", 3000L),
Events("Mary", "./prod?id=1", 4000L),
Events("Mary", "./prod?id=2", 6000L),
Events("Mary", "./prod?id=3", 5000L)
)
stream.assignTimestampsAndWatermarks(new WatermarkStrategy[Events] { // WatermarkStrategy[Events] 水位线生成策略
override def createTimestampAssigner(context: TimestampAssignerSupplier.Context): TimestampAssigner[Events] = { //重写
new SerializableTimestampAssigner[Events] {
override def extractTimestamp(t: Events, l: Long): Long = t.shijian //提取时间戳
}
}
override def createWatermarkGenerator(context: WatermarkGeneratorSupplier.Context): WatermarkGenerator[Events] = {
new WatermarkGenerator[Events] { //这里是重写水位线生成器
//定义一个延迟时间
val delay = 5000L
//定义属性保存最大时间戳
var maxTs = Long.MinValue + delay + 1
override def onEvent(t: Events, l: Long, watermarkOutput: WatermarkOutput): Unit = { //这种是每个数据来都标记依次
maxTs = Math.max(maxTs, t.shijian) //更新当前的最大时间戳
}
override def onPeriodicEmit(watermarkOutput: WatermarkOutput): Unit = { //这种是周期性的标记
val watermark = new Watermark(maxTs - delay - 1L)
watermarkOutput.emitWatermark(watermark)
}
}
}
})
}
}
这两步经过之后,后面都不用在去定义水位线策略了。就不能再调用assignTimestampsAndWatermarks
这个方法去指定水位线生成策略了,因为之前都定义好了,这是一件二选一的事,一般在生产过程中,还是在使用中去定义的。
//为要发送的数据分配时间戳
sourceContext.collectWithTimestamp(event,event.shijian) //两个参数,第一个是当前每条数据,第二个是提取时间戳
//向下游发送水位线
sourceContext.emitWatermark(new Watermark(event.shijian - 1L)) //经过这两步,后面都不用经过水位线生成策略了
package chat01
import org.apache.flink.streaming.api.functions.source.SourceFunction
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.watermark.Watermark
import java.util.Calendar
import scala.util.Random
class Source {
}
object Source{
def main(args: Array[String]): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
val data = env.addSource(new ClickSource) //使用 addSource 方法
.setParallelism(1)
data.print()
env.execute()
}
}
class ClickSource extends SourceFunction[Events] {
var running = true
override def run(sourceContext: SourceFunction.SourceContext[Events]): Unit = {
// 实例化一个随机数发生器
val random = new Random()
// 供随机选择的用户名的数组
val users = Array("Mary", "Bob", "Alice", "Cary")
// 供随机选择的 城市 的数组
val urls = Array("四川", "上海", "北京", "山西", "苏杭")
//通过 while 循环发送数据,running 默认为 true,所以会一直发送数据
while (running) {
val event = Events(users(random.nextInt(users.length)), // 随机选择一个用户名
urls(random.nextInt(urls.length)), // 随机选择一个 url
Calendar.getInstance.getTimeInMillis // 当前时间戳
)
//为要发送的数据分配时间戳
sourceContext.collectWithTimestamp(event,event.shijian)
//向下游发送水位线
sourceContext.emitWatermark(new Watermark(event.shijian - 1L)) //经过这两步,后面都不用经过水位线生成策略了
// 调用 collect 方法向下游发送数据
// //sourceContext是SourceFunction.SourceContext[Events]类型的参数,它是一个上下文对象,用于向下游发送数据。
// 在run方法中,通过调用ctx.collect方法向下游发送数据。
sourceContext.collect( event )// 使用上下文对象 .collent 方法来采集数据
54
// 隔 1 秒生成一个点击事件,方便观测
Thread.sleep(1000)
}
}
override def cancel(): Unit = running = false
}
在“重分区”(redistributing)的传输模式下,一个任务有可能会收到来自不同分区上游子
任务的数据。而不同分区的子任务时钟并不同步,所以同一时刻发给下游任务的水位线可能并
不相同。这说明上游各个分区处理得有快有慢,进度各不相同,这时我们应该以最慢的那个时
钟,也就是最小的那个时间戳水位线为准。
水位线在上下游任务之间的传递,非常巧妙地避免了分布式系统中没有统一时钟的问题,每个任务都以“处理完之前所有数据”为标准来确定自己的时钟,就可以保证窗口处理的结果总是正确的。对于有多条流合并之后进行处理的场景,水位线传递的规则是类似的。
在 Flink 中,提供了非常丰富的窗口操作。
Flink 是一种流式计算引擎,主要是来处理无界数据流的,数据源源不断、无穷无尽。想
要更加方便高效地处理无界流,一种方式就是将无限数据切割成有限的“数据块”进行处理,这
就是所谓的“窗口”(Window)。在 Flink 中, 窗口就是用来处理无界流的核心。
这里注意为了明确数据划分到哪一个窗口,定义窗口都是包含起始时间、不包含结束时间的,用数学符号表示就是一个左闭右开的区间。对于处理时间下的窗口而言,这样理解似乎没什么问题。然而如果我们采用事件时间语义,就会有些令人费解了。由于有乱序数据,我们需要设置一个延迟时间来等所有数据到齐。比如上面的例子中,我们可以设置延迟时间为 2 秒,如图 6-12 所示,这样 0~10 秒的窗口会在时间戳为 12 的数据到来之后,才真正关闭计算输出结果,这样就可以正常包含迟到的 9 秒数据了。
但是这样一来,0~10 秒的窗口不光包含了迟到的 9 秒数据,连 11 秒和 12 秒的数据也包
含进去了。我们为了正确处理迟到数据,结果把早到的数据划分到了错误的窗口——最终结果
都是错误的。
所以在 Flink 中,窗口其实并不是一个“框”,流进来的数据被框住了就只能进这一个窗
口。相比之下,我们应该把窗口理解成一个“桶”,如图0所示。在 Flink 中,窗口可以把
流切割成有限大小的多个“存储桶”(bucket);每个数据都会分发到对应的桶中,当到达窗口
结束时间时,就对每个桶中收集的数据进行计算处理。
这里需要注意的是,Flink 中窗口并不是静态准备好的,而是动态创建——当有落在这个窗口区间范围的数据达到时,才创建对应的窗口。另外,这里我们认为到达窗口结束时间时,窗口就触发计算并关闭,事实上“触发计算”和“窗口关闭”两个行为也可以分开,这部分内容我们会在后面详述。
上面的例子其实是最为简单的一种时间窗口。在 Flink 中,窗口的应用非常灵活,我们可以使用各种不同类型的窗口来实现需求。接下来我们就从不同的角度,对 Flink中内置的窗口做一个分类说明。
窗口本身是截取有界数据的一种方式,所以窗口一个非常重要的信息其实就是“怎样截取
数据”。换句话说,就是以什么标准来开始和结束数据的截取,我们把它叫作窗口的“驱动类
型”。
我们最容易想到的就是按照时间段去截取数据,这种窗口就叫作“时间窗口”(Time Window)。这在实际应用中最常见,之前所举的例子也都是时间窗口。除了由时间驱动之外,窗口其实也可以由数据驱动,也就是说按照固定的个数,来截取一段数据集,这种窗口叫作“计数窗口”(Count Window) 如图所示,下面是时间窗口和计数窗口。
时间窗口以时间点来定义窗口的开始(start)和结束(end),所以截取出的就是某一时间段的数据。到达结束时间时,窗口不再收集数据,触发计算输出结果,并将窗口关闭销毁。用结束时间减去开始时间,得到这段时间的长度,就是窗口的大小(window size)。这里的时间可以是不同的语义,所以我们可以定义处理时间窗口和事件时间窗口。
Flink 中有一个专门的类来表示时间窗口,名称就叫作 TimeWindow
。这个类只有两个私有属性:start 和 end
,表示窗口的开始和结束的时间戳,单位为毫秒。
我们可以调用公有的 getStart()和 getEnd()方法直接获取这两个时间戳。另外,TimeWindow
还提供了一个 maxTimestamp(
)方法,用来获取窗口中能够包含数据的最大时间戳。
很明显,窗口中的数据,最大允许的时间戳就是 end - 1
,这也就代表了我们定义的窗口时间范围都是左闭右开的区间[start,end)
。
计数窗口基于元素的个数来截取数据,到达固定的个数时就触发计算并关闭窗口。每个窗
口截取数据的个数,就是窗口的大小。
计数窗口相比时间窗口就更加简单,我们只需指定窗口大小,就可以把数据分配到对应的窗口中了。在 Flink 内部也并没有对应的类来表示计数窗口,底层是通过“全局窗口”(Global Window)来实现的。
时间窗口和计数窗口,只是对窗口的一个大致划分;在具体应用时,还需要定义更加精细的规则,来控制数据应该划分到哪个窗口中去。不同的分配数据的方式,就可以有不同的功能应用。
根据分配数据的规则,窗口的具体实现可以分为 4 类:
滚动窗口(Tumbling Window)
、滑动窗口(Sliding Window)
、会话窗口(Session Window)
,以及全局窗口(Global Window)
。
滚动窗口有固定的大小,是一种对数据进行“均匀切片”的划分方式。窗口之间没有重叠,也不会有间隔,是“首尾相接”的状态。如果我们把多个窗口的创建,看作一个窗口的运动,那就好像它在不停地向前“翻滚”一样。这是最简单的窗口形式,我们之前所举的例子都是滚动窗口。
滚动窗口可以基于时间定义,也可以基于数据个数定义;需要的参数只有一个,就是窗口的大小(window size)。比如我们可以定义一个长度为 1 小时的滚动时间窗口,那么每个小时就会进行一次统计;或者定义一个长度为 10 的滚动计数窗口,就会每 10 个数进行一次统计。
滚动窗口应用非常广泛,它可以对每个时间段做聚合统计,很多 BI 分析指标都可以用它来实现。
与滚动窗口类似,滑动窗口的大小也是固定的。区别在于,窗口之间并不是首尾相接的,而是可以“错开”一定的位置。如果看作一个窗口的运动,那么就像是向前小步“滑动”一样。
既然是向前滑动,那么每一步滑多远,就也是可以控制的。所以定义滑动窗口的参数有两个:除去窗口大小(window size)之外,还有一个“滑动步长”(window slide),它其实就代表了窗口计算的频率。同样,滑动窗口可以基于时间定义,也可以基于数据个数定义。
我们可以看到,当滑动步长小于窗口大小时,滑动窗口就会出现重叠,这时数据也可能会被同时分配到多个窗口中。而具体的个数,就由窗口大小和滑动步长的比值(size/slide)来决定。所以,滑动窗口其实是固定大小窗口的更广义的一种形式。
在一些场景中,可能需要统计最近一段时间内的指标,而结果的输出频率要求又很高,甚至要求实时更新,比如股票价格的 24 小时涨跌幅统计,或者基于一段时间内行为检测的异常报警。这时滑动窗口无疑就是很好的实现方式。
会话窗口顾名思义,是基于“会话”(session)来来对数据进行分组的。这里的会话类似Web 应用中 session 的概念,不过并不表示两端的通讯过程,而是借用会话超时失效的机制来描述窗口。
与滑动窗口和滚动窗口不同,会话窗口只能基于时间来定义。对于会话窗口而言,最重要的参数就是会话超时时间的长度(size),也就是两个会话窗口之间的最小距离。如果相邻两个数据到来的时间间隔(Gap)小于指定的大小(size),那说明还在保持会话,它们就属于同一个窗口;如果 gap 大于 size,那么新来的数据就应该属于新的会话窗口,而前一个窗口就应该关闭了。在具体实现上,我们可以设置静态固定的大(size),也可以通过一个自定义的提取器(gap extractor)动态提取最小间隔 gap 的值。
在一些类似保持会话的场景下,往往可以使用会话窗口来进行数据的处理统计。
还有一类比较通用的窗口,就是“全局窗口”。这种窗口全局有效,会把相同 key 的所有数据都分配到同一个窗口中。无界流的数据永无止尽,所以这种窗口也没有结束的时候,默认是不会做触发计算的。如果希望它能对数据进行计算处理,还需要自定义“触发器”(Trigger)。
Flink 中的计数窗口(Count Window),底层就是用全局窗口实现的。
已经了解了Flink窗口中的概念和分类,对Window API 有了一个基本的整体认识,接下来了解一下是怎样调用的。
在定义窗口操作之前,首先需要确定,到底是基于按键分区(Keyed)的数据流 KeyedStream
来开窗,还是直接在没有按键分区的DataStream
上开窗。也就是说,在调用窗口算子之前,是否有 keyBy()
操作。这两种方式的调用方式是不一样的。
经过按键分区 keyBy()
操作后,数据流会按照 key 被分为多条逻辑流(logical streams),这就KeyedStream
。基于 KeyedStream 进行窗口操作时, 窗口计算会在多个并行子任务上同时执行。相同 key 的数据会被发送到同一个并行子任务,而窗口操作会基于每个 key 进行单独的处理。所以可以认为,每个 key 上都定义了一组窗口,各自独立地进行统计计算。
在代码实现上,我们需要先对DataStream调用keyBy()
进行按键分区,然后再调用window()定义窗口。
stream.keyBy(_.user)
.window(...) //keyBy 按键分区之后是这个样子进行调用。
如果没有进行 keyBy(),那么原始的 DataStream 就不会分成多条逻辑流。这时窗口逻辑只能在一个任务(task)上执行,就相当于并行度变成了 1
。所以在实际应用中一般不推荐使用这种方式。
在代码中,直接基于 DataStream 调用 windowAll()
定义窗口。
stream.windowAll(...)
这是没按键分区的调用方式
这里需要注意的是,对于非按键分区的窗口操作,手动调大窗口算子的并行度也是无效的
,windowAll 本身就是一个非并行的操作。
有了前置的基础,接下来我们就可以真正在代码中实现一个窗口操作了。简单来说,窗口操作主要有两个部分:窗口分配器(Window Assigners)和窗口函数(Window Functions)
。
stream.keyBy(<key selector>)
.window(<window assigner>)
.aggregate(<window function>)
定义窗口分配器(Window Assigners)是构建窗口算子的第一步,它的作用就是定义数据应该被“分配”到哪个窗口。而窗口分配数据的规则,其实就对应着不同的窗口类型。所以可以说,窗口分配器其实就是在指定窗口的类型。
窗口分配器最通用的定义方式,就是调用 window()
方法。这个方法需要传入一个WindowAssigner
作为参数,返回 WindowedStream
。如果是非按键分区窗口,那么直接调用windowAll()方法,同样传WindowAssigner,返回的是 AllWindowedStream。窗口按照驱动类型可以分成时间窗口和计数窗口
,而按照具体的分配规则,又有滚动窗口、滑动窗口、会话窗口、全局窗口四种
。除去需要自定义的全局窗口外,其他常用的类型 Flink中都给出了内置的分配器实现,我们可以方便地调用实现各种需求。