关于flink中的窗口基本概念请自行百度,本文主要使用案例来说明flink中的窗口触发时间及如何处理迟到数据的
关于水位线的一些理论知识:
水位线是什么
窗口有了,但是要知道我们面对的是实时数据,而这些数据随时会出现延迟的情况,从几秒到几小时都有可能。如果要忽略这些数据,那么显然对于结果的计算是不准确的,可是要等待这些延迟数据的话, 那岂不是等同于批处理了,我们等不了那么久的。这个时候水位线恰好就是来描述和解决这个问题的。它指定一个时间 T,表示时间 T 之前的数据已经全部到达,后续再迟到的数据会被直接丢弃。
水位线用在哪里
显然,使用处理时间来处理事件不会有延迟,因此也不需要水位线。所以水位线只出现在事件时间窗口,因而也可以将水位线看成是事件时间的进度条。通常,当水位线通过窗口的末尾时,会触发窗口的计算操作。
在 Flink 中如何产生水位线
在 data source 中发射水位线
即在 data source 函数中,使用 SourceContext 的 emitWatermark 方法来发射一个水位线 T。
使用水位线生成器
- 按照固定周期生成(周期水位线)
这种情况下,Flink 会定时(可以自定义)获取水位线,这里水位线的具体方法由用户实现。
- 从特定元素生成(无规则水位线)
在数据流中有某种中止信号(如-1、EOF)的时候,特别有用。此外需要注意的是由于这种获取水位线的操作会作用于每一个元素,所以可能会带来性能影响。
并发中的水位线
关于水位线的使用,有两点需要知道:
- 水位线是单调递减的,也就是说不允许后出现的水位线比之前的小。
- 当有操作依赖于多个流或者并发集合时,该操作会依赖于之前的所有水位线中最小的那个,也就是说该操作可能会等到之前所有的操作的水位线都通过了窗口的末尾,才会触发。(这个需要通过扒一扒它的源码看看它是怎么实现的)
下面通过具体的代码来说明flink中的窗口触发机制及延迟数据处理问题
生成并跟踪watermark代码
程序说明
我们从socket接收数据,然后经过map后立刻抽取timetamp并生成watermark,之后应用window来看看watermark和event time如何变化,才导致window被触发的。
代码如下:
object WatermarkTestDemo {
def main(args: Array[String]): Unit = {
println("当前机器的线程数: "+Runtime.getRuntime().availableProcessors())
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
// 这个位置并行度必须设置为1,否则无法模拟效果
env.setParallelism(1)
// 这个参数的设置表示每隔多少毫秒调用一次获取水印的方法(即下面的getCurrentWatermark方法)
// 如果不设置,那么就会每隔200毫秒调用一次。底层是一个while循环
// env.getConfig.setAutoWatermarkInterval(300)
val input:DataStream[String] = env.socketTextStream("127.0.0.1",6789)
val inputMap = input.map(f=> {
val arr = f.split("\\W+")
val code = arr(0)
val time = arr(1).toLong
(code,time)
})
val watermark = inputMap.assignTimestampsAndWatermarks
(new AssignerWithPeriodicWatermarks[(String,Long)] {
var currentMaxTimestamp = 0L
val maxOutOfOrderness = 10000L//最大允许的乱序时间是10s
var WM : Watermark = null
val format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS")
override def getCurrentWatermark: Watermark = {
WM = new Watermark(currentMaxTimestamp - maxOutOfOrderness)
WM
}
override def extractTimestamp(t: (String,Long), l: Long): Long = {
val timestamp = t._2
currentMaxTimestamp = Math.max(timestamp, currentMaxTimestamp)
//这个位置的水印(WM),其实是上一条数据的水印,并不是本条数据的水印
println("timestamp:" + t._1 +","+ t._2 + "|" +format.format(t._2) +","+ currentMaxTimestamp + "|"+ format.format(currentMaxTimestamp) + ","+ WM.toString)
timestamp
}
})
val window = watermark
.keyBy(_._1)
// 滚动窗口,窗口大小为3秒
.window(TumblingEventTimeWindows.of(Time.seconds(3)))
/**
* 允许延迟5s之后才销毁计算过的窗口
* 一旦设置了这个属性,意味着即使窗口已经触发过,但是不会立即销毁,而是当满足以下条件时才销毁:
* 水位位置 - window_end_time = 5 时,窗口才销毁
* 但是设置这个属性也有一个问题,可能导致数据被重复计算,因为如果没有这个属性,那么这个窗口一旦
* 被触发过,那么窗口立即被销毁了,后续如果又出现了属于这个窗口的数据会被直接丢弃,如果设置了这个属性,
* 那么窗口中的数据会被缓存起来,每来一个,累加一次,直至不再满足条件,所以这个数据会有重复
*/
.allowedLateness(Time.seconds(5))
.apply(new WindowFunctionTest)
window.print()
env.execute()
}
class WindowFunctionTest extends WindowFunction[(String,Long),(String, Int,String,String,String,String),String,TimeWindow]{
override def apply(key: String, window: TimeWindow, input: Iterable[(String, Long)], out: Collector[(String, Int,String,String,String,String)]): Unit = {
val list = input.toList.sortBy(_._2)
val format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS")
println("当前的水印为: "+window.maxTimestamp())
println(list)
// 输出窗口区间
out.collect(key,input.size,format.format(list.head._2),format.format(list.last._2),format.format(window.getStart),format.format(window.getEnd))
}
}
}
代码详解:
(1)接收socket数据
(2)将每行数据按照字符分隔,每行map成一个tuple类型(code,time)
(3)抽取timestamp生成watermark。并打印(code,time,格式化的time,currentMaxTimestamp,currentMaxTimestamp的格式化时间,watermark时间)。
(4)event time每隔3秒触发一次窗口,输出(code,窗口内元素个数,窗口内最早元素的时间,窗口内最晚元素的时间,窗口自身开始时间,窗口自身结束时间)
通过数据跟踪watermark的时间
我们重点看看watermark与timestamp的时间,并通过数据来看看window的触发时机。
测试前的说明:此时测试时没有加: allowedLateness(Time.seconds(5)) 这个设置的。
首先,我们开启socket,输入第一条数据:
000001,1461756862000
输出的out文件如下:
timestamp:000001,1461756862000|2016-04-27 19:34:22.000,1461756862000|2016-04-27 19:34:22.000,Watermark @ -10000
这里,看下watermark的值,-10000,即0减去10000得到的。这就说明程序先执行timestamp,后执行watermark。所以,每条记录打印出的watermark,都应该是上一条的watermark。(其实这个位置解释的不完全对,实际上AssignerWithPeriodicWatermarks子类是每隔一段时间执行的,这个具体由ExecutionConfig.setAutoWatermarkInterval设置,如果没有设置默认会每隔200毫秒调用getCurrentWatermark方法,之所以会出现-10000时因为你没有数据进入窗口,所以它执行的是 0-10000,另外这个循环获取水印的方法和提取时间戳的方法(extractTimestamp)是由同一个线程执行,一旦开始执行提取时间戳的方法,那么这个获取水印的方法就会在其之后执行,所以这个执行是有顺序的,局部看确实是先执行提取时间戳的方法,再执行获取水印的方法) 为了观察方便,我汇总了输出如下:
此时,wartermark的时间按照逻辑,已经落后于currentMaxTimestamp10秒了。我们继续输入:
000001,1461756862000
000001,1461756866000
此时,输出内容如下:
我们再次汇总,见下表:
我们继续输入,这时我们再次输入:
000001,1461756862000
000001,1461756866000
000001,1461756872000
输出如下:(其实这里可以看出,每条打印的水印数据,都是上一条数据的水印)
汇总如下:
到这里,window仍然没有被触发,此时watermark的时间已经等于了第一条数据的Event Time了。那么window到底什么时候被触发呢?我们再次输入:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
输出结果如下:
汇总:
OK,window仍然没有触发,此时,我们的数据已经发到2016-04-27 19:34:33.000了,距离最早的数据已经过去了11秒了,还没有开始计算。
我们再次增加1秒,输入:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
000001,1461756874000
输出:
汇总:
注意:上面那个标注的窗口的区间指的是第一条数据(000001,1461756862000)所在的窗口区间;
到这里,我们做一个说明:
window的触发机制,是先按照自然时间将window划分,如果window大小是3秒,那么1分钟内会把window划分为如下的形式:
[00:00:00,00:00:03)
[00:00:03,00:00:06)
...
[00:00:57,00:01:00)
如果window大小是10秒,则window会被分为如下的形式:
[00:00:00,00:00:10)
[00:00:10,00:00:20)
...
[00:00:50,00:01:00)
window的设定无关数据本身,而是系统定义好了的。
上面的测试中,最后一条数据到达后,其水位线已经升至19:34:24秒,正好是最早的一条记录所在window的window_end_time,所以window就被触发了。
关于窗口的计算方式:(以滚动时间窗口计算)
根据上面的计算公式,我们实际上可以计算出每个事件(时间戳)对应的窗口计算区间
为了验证window的触发机制,我们继续输入数据:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
000001,1461756874000
000001,1461756876000
输出:
汇总:
此时,watermark时间虽然已经达到了第二条数据的时间,但是由于其没有达到第二条数据所在window的结束时间,所以window并没有被触发。那么,第二条数据所在的window时间是:
[19:34:24,19:34:27)
也就是说,我们必须输入一个19:34:27秒的数据,第二条数据所在的window才会被触发。我们继续输入:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
000001,1461756874000
000001,1461756876000
000001,1461756877000
输出:
汇总:
此时,我们已经看到,window的触发要符合以下几个条件:
1、watermark时间 >= window_end_time
2、在[window_start_time,window_end_time)中有数据存在
同时满足了以上2个条件,window才会触发。
而且,这里要强调一点,watermark是一个全局的值,不是某一个key下的值,所以即使不是同一个key的数据,其warmark也会增加,例如:
输入:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
000001,1461756874000
000001,1461756876000
000001,1461756877000
000002,1461756879000
输出:
我们看到,currentMaxTimestamp也增加了。
通过上面的分析我们已经知道了时间窗口的划分以及窗口触发的条件,接下来我们继续分析:
watermark+window处理乱序
我们上面的测试,数据都是按照时间顺序递增的,现在,我们输入一些乱序的(late)数据,看看watermark结合window机制,是如何处理乱序的。
输入:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
000001,1461756874000
000001,1461756876000
000001,1461756877000
000002,1461756879000
000001,1461756871000
输出:
汇总:
可以看到,虽然我们输入了一个19:34:31的数据,但是currentMaxTimestamp和watermark都没变(所以水印是单调递增的,不会后退)。此时,按照我们上面提到的公式:
1、watermark时间 >= window_end_time
2、在[window_start_time,window_end_time)中有数据存在
watermark时间(19:34:29) < window_end_time(19:34:33)注意:这里窗口结束时间(19:34:33),指的是输入的第三条数据(000001,1461756872000),它所在的时间窗口就是[2016-04-27 19:34:30.000,2016-04-27 19:34:33.000)。因此不能触发window。
另外我们这里再次输入刚才输入的第一条和第二条数据(000001,1461756862000和000001,1461756866000)来验证 allowedLateness(Time.seconds(5)) 这个设置,这个设置是允许窗口延迟5秒再销毁,如果没有这个设置,那么意味着一个窗口一旦计算过了,就会被立即销毁;
输入:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
000001,1461756874000
000001,1461756876000
000001,1461756877000
000002,1461756879000
000001,1461756871000
000001,1461756862000
000001,1461756866000
结果:
从上面的结果可以看出,虽然我们输入了 000001,1461756862000 和 000001,1461756866000 这两条数据,并且这两条数据的窗口结束时间(分别为 19:34:24.000 和 19:34:27.000)是小于目前的水位线时间(19:34:29)的,但是窗口并没有被触发,证明一旦一个窗口被触发过,那么这个窗口立即会被销毁;记住这里的结论,后面我们会验证 allowedLateness 这个属性;
接下来我们继续输入:
那如果我们再次输入一条19:34:43的数据,此时watermark时间会升高到19:34:33,这时的window一定就会触发了(窗口时间在 19:34:30.000 和 19:34:33.000的会被触发),我们试一试:
输入:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
000001,1461756874000
000001,1461756876000
000001,1461756877000
000002,1461756879000
000001,1461756871000
000001,1461756866000
000001,1461756862000
000001,1461756883000
结果:
这里,我们看到,窗口中有2个数据,19:34:31和19:34:32的,但是没有19:34:33的数据,原因是窗口是一个前闭后开的区间,19:34:33的数据是属于[19:34:33,19:34:36)的窗口的。
上边的结果,已经表明,对于out-of-order的数据,Flink可以通过watermark机制结合window的操作,来处理一定范围内的乱序数据。
下面我们继续修改代码,加入允许窗口延迟销毁的设置:allowedLateness(Time.seconds(5)) 这里我们设置允许窗口延迟5秒销毁;
这里我们重新启动项目,把上面的数据重新再次发送一遍:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
000001,1461756874000
000001,1461756876000
000001,1461756877000
000002,1461756879000
000001,1461756871000
000001,1461756866000
000001,1461756862000
我们重点观察,当我们输入 000001,1461756862000 和 000001,1461756866000 这两条数据时,窗口会不会被重复触发:
从上面对比可以发现,如果我们设置了窗口延迟5秒再销毁,那么当我们再次输入 000001,1461756866000 这条数据时,是可以继续触发窗口计算的;
另外针对上面的测试,还有一点需要说明,如果水位线一直不涨(上面的例子中水位线是位于 19:34:29),那么后续如果有来自于 (19:34:24-19:34:27]时间窗口内的数据,来一个就会触发一次窗口内数据的重复计算;
通过上面的演示我们做一下总结:
1、窗口的时间范围是自然时间决定的,和数据无关
2、水印不是和某一条数据绑定的,而是一个全局的概念。我们设置水位的时候,需要把水位设置成持续上升,不然没有作用
3、水位到达窗口结束时间的时候,会触发这个窗口的计算
4、窗口计算完毕后,会立即销毁
5、如果设置了 allowedLateness,水位位置 - window_end_time < 5 的时候,落在窗口的数据都会被计算。
当 水位位置 - window_end_time = 5 的时候,窗口被销毁,落在窗口的数据不再被计算。
另外我们再次来理解 val maxOutOfOrderness = 10000L//最大允许的乱序时间是10s ,这个属性的意思就是告诉程序我们允许数据延迟10秒到达,比如上面的输入中,我们在 000002,1461756879000 后又再次输入了 000001,1461756871000,而71000这个数据就是属于迟到的数据,如果我们没有设置延迟时间,那么这个迟到的数据就被抛弃了,后续也不会再次计算,显然这对于我们的计算结果来说是不准确的;换个角度来看,如果我们的数据的单调递增的,不会有延迟数据,那么我们完全也没有必要设置这个属性,但是实际生产中,我们的数据到达时间总是有一部分迟到的;
测试: 我们设置 val maxOutOfOrderness = 0L 即不允许数据迟到,我们来看看会发生什么:
我们再次重启项目输入:
000001,1461756862000
000001,1461756866000
000001,1461756872000
000001,1461756873000
000001,1461756874000
000001,1461756876000
000001,1461756877000
000002,1461756879000
000001,1461756871000 迟到的数据
000001,1461756883000
结果如下: