Flink 中的时间语义和 WaterMark
实验介绍
在流式数据处理中,如何保证数据的全局有序和 Exactly Once(精准一次消费)是非常重要的。虽然数据在上游产生的时候是唯一并且有序的,但是数据从产生到进入 Flink 的过程中,中间可能会由于负载均衡、网络传输、分区等等原因造成数据乱序,为了应对这种情况,所以我们引入了时间语义和 WaterMark 的概念。
知识点
- Time
- Event Time
- Ingestion Time
- Processing Time
- Watermark
- Watermark 的概念
- Watermark 的使用
时间语义
在 Flink 中分别将时间分为三种语义,分别是 Event Time(事件生成时间)、Ingestion Time(事件接入时间)Processing Time(事件处理时间)。
- Event Time:指的是事件产生的时间。通常由事件中的某个时间戳字段来表示,比如用户登录日志中所携带的时间字段、天气信号检测系统中采集到的天气数据中所携带的时间字段。
- Ingestion Time:指的是事件进入 Flink 中的时间。
- Processing Time:指的是时间被处理时的当前系统时间。比如某条数据进入 Flink 之后,我们运行到某个算子时的系统时间,就是 Processing Time。Processing Time 是 Flink 中的默认时间属性。
为了方便理解,这里举个具体的例子。《中国机长》这部电影相信很多同学都看过,它是根据 2018 年 5 月 14 日四川航空 3U8633 航班的真实事件改编的,该电影于 2019 年 9 月 30 日在电影院上映,而小明同学是在 2019 年 10 月 1 日去电影院观看的。在这个案例中,事件真实发生的时间为 2018-05-14,所以 Event Time 应该是 2018-05-14,因为 Event Time 表示的是事件真实发生的时间;进入影院的时间是 2019-09-30,所以 Ingestion Time 就是 2019-09-30;小明去电影院观看的时间是 2019-10-01,所以这里对应的 Processin Time 应该是 2019-10-01,因为它表示的是该时间被处理的时间。
再举一个我们生产环境中的例子。Flink 中接收到一条如下的用户登录日志:
20210301121533,login,北京,118.112.11.50,0001
该日志表示在 2021-03-01 12:15:33
有 user_id 为 0001 的用户从北京登录了某网站,而该日志进入 Flink 的时间为 2021-03-01 12:15:40
,该日志被 Flink 中的某个算子处理的系统时间为 2021-03-01 12:15:41
。在这个案例中的 Event Time 为 2021-03-01 12:15:33
,Ingestion Time 为 2021-03-01 12:15:40
,Processing Time 为 2021-03-01 12:15:41
。
设置时间语义
Flink 中默认的时间语义是 Processing Time,但是从业务的角度来讲,我们更加关心 Event Time,使用最多的也是 Event Time,只有在 Event Time 不可用的情况下才会勉强使用 Process Time 或 Ingestion Time。所以一般在业务实践中,我们会手动指定时间语义为 Event Time,核心代码如下:
// 设置使用EventTime
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
如果要修改为 Ingestion Time,可以使用下面的代码:
// 设置使用IngestionTime
env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime)
但是要注意,我们说 Event Time 指的是事件的真实发生时间,通常是由事件中的某个时间戳字段来指定的。但是在上面的代码中,我们只是设置的时间语义为 Event Time,并没有指定哪个字段为时间。
Watermark
Watermark 原理
如果指定了 Event Time 时间语义之后,流数据从产生、到 Flume、到 Kafka 等环节,最终进入 Flink 之后,并不一定是按照数据产生时候的先后顺序依次到的。很可能会因为网络传输延迟、以及 Kafka 中的多个分区问题导致数据乱序。而 Window 在执行计算的时候也不能因为某一条数据迟到而无限期地等下去,所以我们就需要引入 Watermark(水位线)机制来解决这种情况。换句话说,Watermark 就是一个延迟触发机制,专门用于处理事件中的时间乱序问题。
在 Flink 中的窗口处理中,最理想的情况就是属于该窗口的全部数据都能够在窗口关闭之前到达,这样我们就可以进行下一步操作了。但是真实情况并非如此,在窗口快要结束的时候,还有部分属于该窗口的数据没有到达,这时候我们就需要通过 Watermark 指定一个延迟时间,保证在延迟时间到达时,迟到的数据能够被纳入窗口进行计算,这样才能最大程度保证我们的数据准确性。说得具体一点,假设窗口本来会在 5 秒的时候执行计算,但是由于我们设置了延迟时间为 2 秒,那么在时间到 5 秒的时候并不会立即触发窗口计算,而是延迟到 7 秒才触发。那么 Watermark 该如何计算呢?假设进入 Flink 的最大事件时间为 maxEventTime
,我们所指定的延迟时间为 t
,那么 Watermark
= maxEventTime
- t
。如果窗口的停止时间小于或者等于 Watermark
,则窗口被关闭并执行。
理想情况下,进入 Flink 的所有事件都是全局有序的,这时候就不需要设置延迟时间 t
,所以 Watermark 等于 maxEventTime。当窗口停止时间等于 Watermark 时就会触发计算。如图:
上图表示的是事件 是全局有序的,所以延迟时间为 0,一旦 Watermark 等于 maxEventTime,窗口就触发。第一个窗口中的 maxEventTime 为 5,所以在 Watermark 为 w(5) 的时候就触发窗口;同理,第二个窗口中 maxEventTime 为 10,所以在 w(10) 的时候触发窗口。
然而现实往往是残酷的,数据并不会按照产生的时间顺序依次到达,如图:
上图中的事件到达很明显是乱序的,并没有按照产生的先后顺序到达,所以我们需要设置一个延迟时间 t
,假设 t=2
。在第一个窗口中,在时间 7 到达之前,maxEventTime 为 5,所以对应的 Watermark 为 5-2=3
,3 小于窗口的停止时间 5,所以并没有触发窗口。而当 7 到达之后,maxEventTime 为 7,Watermark 为 7-2=5
,5 等于当前窗口的停止时间 5,所以窗口触发。在第二个窗口,在事件 12 到达之前,maxEventTime 为 11,所以 Watermark 为 11-2=9
,9 小于该窗口的停止时间 10,所以并没有触发窗口,而当 12 到达之后,maxEventTime 为 12,此时 Watermark 为 12-2=10
,10 等于当前窗口的停止时间,所以窗口触发。
前面我们分析了单个分区中的 Watermark,在多个分区并行的时候,Watermark 是会有一个对齐机制,要以多个分区中最小的 Watermark 为当前 Task 的 Watermark。可以简单理解为短板效应,或者说跑得快的要等一下跑得慢的。如图:
在第一幅图中,从上往下四个分区中的 Watermark 分别为 2、4、3、6,最小的是 2,所以整个 Task 的 Watermark 为 2;第二幅图中,第一个分区中的 4 到了以后,四个 Watermark 分别为 4、4、3、6,此时最小的为 3,所以整个 Task 的 Watermark 为 3;第三幅图中,第二个分区中的 7 到了,四个 Watermark 分别为 4、7、3、6,此时最小的仍然是 3,所以整个 Task 的 Watermark 为 3;第四幅图中,第三个分区的 6 到了以后,四个 Watermark 分别为 4、7、6、6,此时最小的 Watermark 为 4,所以整个 Task 的 Watermark 为 4。
Watermark 的使用
前面一直在讲时间语义和 Watermark 的概念,接下来我们具体看一下在代码中是如何使用 Event Time 和 Watermark 的。在 com.vlab.time
包下创建 PeriodicWatermark
Scala object,其中代码如下:
package com.shiyanlou.time
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.windowing.time.Time
object PeriodicWatermark {
def main(args: Array[String]): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
val data: DataStream[UserLog] = env.socketTextStream("localhost", 9999)
.map(line => {
val arr = line.split(",")
UserLog(arr(0).toLong, arr(1), arr(2), arr(3), arr(4).toLong)
})
data
.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[UserLog](Time.seconds(2)) {
override def extractTimestamp(element: UserLog): Long = {
element.time
}
})
.print()
env.execute("Periodicc Watermark")
}
// 日志样例:20210301121533,login,北京,118.112.11.50,0001
case class UserLog(time: Long, action: String, city: String, ip: String, user_id: Long)
}
然后打开终端,输入 nc -l -p 9999
,然后输入样例:20210301121533,login,北京,118.112.11.50,0001
,执行程序,观察输出情况。
上面的代码中,我们通过 env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
指定了时间语义为 Event Time
,在倒数第二行,我们创建了一个样例类 UserLog
,其中包含了 5 个字段,分别表示日志生成时间,用户行为(登录、登出、点击、浏览等),城市,IP 地址和用户 ID。然后将通过 Socket 获取的字符串通过逗号分割并输出为 UserLog
实例。重点是 assignTimestampsAndWatermarks
方法, assignTimestampsAndWatermarks
的参数是一个 BoundedOutOfOrdernessTimestampExtractor
对象,看名字就知道,它表示的是无界流中乱序数据的时间提取器该提取器处理的泛型为 UserLog
。在 BoundedOutOfOrdernessTimestampExtractor
对象中通过 Time.seconds(2)
延迟时间为 2 秒,并在 extractTimestamp
方法中指定了 Event Time
所需要的字段为 UserLog
对象的 time
字段。
简单来说,和前面几个实验章节中,唯一不同的两个地方就是通过 env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
指定了时间语义为 Event Time
,通过 assignTimestampsAndWatermarks
指定了延迟时间为 2 秒,指定 Event Time
需要的时间字段为 time
。
总结
在本节实验中,我们介绍了 Flink 中的三个时间语义(Event Time、Ingestion Time、Processing Time)和 Watermark(水位线)。时间语义好理解,Watermark 虽然使用起来比较简单,有成熟的 API 可以调用,而且有固定的编程模式,无非就是传个参数。但是要结合到企业的业务实践中去,就必须要能够深入了解它的概念,只有这样才能应对不同的业务场景,并根据不同场景做相应的参数调整。