Apache Flink中的时间(Flink Time)和窗口(Flink Window)是流处理中的关键概念。时间机制用于处理和管理数据流中的时间相关操作,包括处理时间和事件时间,通过指定时间属性,我们可以对数据进行排序、窗口划分和触发等操作。窗口机制用于将无限的数据流划分为有限大小的窗口,以便进行聚合、分析或转换,在Flink中有各种窗口类型,它们根据时间或其他属性划分数据流进而在窗口内执行聚合、计数、求和等操作,以获得窗口内数据的统计结果。本章节我们将对Flink的时间和窗口机制进行介绍。
时间语义Time
在Flink流处理中,经常会涉及到根据时间进行数据的划分(如:窗口划分),这里的时间可以是Flink框架当时处理该数据的时间也可以是事件本身携带的时间,根据使用时间的不同Flink中将时间划分为三种语义:事件生成时间(Event Time)、事件摄入时间(Ingestion Time)和事件处理时间(Processing Time),三者关系如下图所示,下面分别介绍三种时间语义。

- Event Time - 事件生成时间
Event Time是每个事件在其生成设备上发生的时间,这个时间往往是嵌入在事件记录中,例如一条数据中的时间戳记录了该事件数据的产生时间,该时间与下游Flink处理时系统时间无关。如果每个事件包含事件时间,当事件经过网络传输流转到Flink中处理时,理论上来说,先产生的事件会比后产生的事件先到达Flink系统中被处理,但实际情况往往由于网络传输延迟导致早先产生的事件后到达Flink系统被处理的情况(数据延迟到达),这就出现了数据乱序,但基于Event Time的时间概念,我们可以让Flink进行数据处理时基于事件产生的时间处理,这样就可以还原事件的先后关系,保证数据处理的准确性。Event Time 时间语义在实际生产环境中使用较多,该时间语义能保证乱序数据处理的准确性。
- Ingestion Time - 事件摄入时间
Ingestion Time是指数据进入到Flink系统的时间,该时间依赖于Flink对应Source Operator所在主机节点的系统时间。在Flink中使用Ingestion Time时,针对每个事件进入到Flink系统后内部都会维护一个摄入时间,后续窗口划分处理都是基于该时间,与后续各个Operator处理数据的时间无关。这种时间语义不能处理事件乱序问题,在实际工作场景中使用较少。
- Processing Time - 事件处理时间
事件处理时间是指数据被Operator处理时当前所在主机的系统时间。当用户选择使用Processing Time时,在Flink中所有和时间相关的操作都会按照当前系统时间进行处理,例如:Window窗口划分。Processing Time是Flink中最简单的时间语义,使用这种语义时Flink中处理数据延迟较低、处理性能高,无论进入到Flink中的源头数据是否有乱序,只要被Flink系统接收的数据都会按照当前数据处理时的系统时间赋值时间语义,可见这种语义虽然处理数据性能高但不能解决数据乱序和延迟问题,从而导致数据统计不是太精准。Processing Time适合时间计算精度要求不高的计算场景。
Flink中最常用的时间语义是事件时间EventTime,在Flink1.12版本之前Flink中默认使用的是Process Time时间语义,在Flink1.12版本后,默认的时间语义为EventTime。在早先的Flink版本中可以通过如下方式指定使用时间语义:
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime);
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
目前版本中如果想要使用处理时间可以在编写代码时指定不使用watermark即可,代码如下,关于watermark后续章节将会讨论。
ds.assignTimestampsAndWatermarks(WatermarkStrategy.noWatermarks());
Watermark水位线
watermark水位线介绍
在Flink流处理场景中建议使用EventTime来处理数据,也就是使用事件产生的时间来进行数据处理,这样就算遇到由于网络之间传输数据延迟导致的数据乱序问题,也可以很好的解决。例如:在Flink窗口处理中,通过事件本身携带的事件时间可以很好地将迟到数据划分到各自所属的窗口内。
举例如下,下图每个数据的标号可以看成当前对应的事件时间(后文没有特殊指定,默认为s),通过下图我们发现数据没有按照事件各自产生的顺序被Flink系统接收处理,产生了数据乱序问题:

为了能更好的了解Watermark机制,这里我们需要先了解下窗口划分及处理数据的原理:在Flink窗口处理中,一个窗口时间起始范围是从1970-01-01 00:00:00开始固定往后划分的,假设5秒划分一个窗口,那么每个窗口对应的起始时间点是固定的,如:第一个窗口起始时间为[1970-01-01 08:00:00 ~1970-01-01 08:00:05),第二个窗口起始时间为[1970-01-01 08:00:05~1970-01-01 08:00:10)... ,并且这些窗口"含头不含尾",即第一个窗口中不包含1970-01-01 08:00:05时间产生的数据,该时刻产生的数据会归到下一个窗口,下个窗口同样也是"含头不含尾"的方式处理窗口内的数据,这里关于Flink窗口内容后续小节会继续介绍。

接着以上案例,如果每5秒划分一个窗口,按照Flink窗口划分规则,第一个窗口的起始时间为[0~5)、第二个窗口起始时间为[5~10),那么如何将数据按照事件时间正确划分到对应的窗口内成为一个挑战:第一个窗口为[0,5),该窗口起始时间都是依据事件时间进行划分,当0,1,3,2数据来了之后,这3条数据很清晰属于该窗口,但是当7这条数据来了之后,假设数据没有延迟乱序,Flink程序是按照事件时间先后顺序依次接收数据,那么此时Flink程序可以认为7时间戳之前的数据都已到达,[0~5)窗口数据就应当被触发计算。但如果Flink程序接收到的事件是乱序的,那么此时触发[0~5)窗口显然会丢失后续的4这条数据,导致后续计算结果不准确,为了正确的统计结果还需要继续"等一等"后续的事件,但是这里我们又不能无限期的等待下去,所以这里需要有一个时间标记来决定何时触发窗口,这个时间标记就是watermark。
在Flink中,watermark是一种衡量事件时间进展的机制,watermark是一种特殊的数据记录,watermark本质就是一个时间戳,基于Flink接收到的事件时间(Event Time)计算得到,并且该时间标记会随着数据流往后流动,当Flink算子接收到Watermark(t)事件时,可以认为早于或等于t时刻的事件时间已经完全到达。结合上例,当Flink窗口函数收到watermark=5时,就可以"大胆放心"的触发[0~5)窗口即可,因为此时Flink认为5时刻之前的所有事件都已经到达Flink。
watermark原理及特点
上文说到watermark是一种衡量事件时间(Event Time)进展的机制,watermark本质是一个时间戳,该时间戳是基于Flink接收到的事件时间(EventTime)周期性计算得到,为了更加清晰的了解Flink中watermark的生成机制,下面我们按照进入Flink系统中的事件流的事件时间是否有序分别来讨论watermark的生成方式。
- 有序事件watermark生成
Flink接收到的事件如果是有序的,watermark的值就是当前计算watermark时刻Flink接收到的事件时间,并且watermark的值会随着Flink接收到的事件时间默认每隔200ms计算一次,往后推移。此时,如果进行窗口计算,由于流数据没有乱序,所以在接收到窗口结束对应时刻的事件时间时也没有必要"等一等",直接触发窗口计算即可,这样就能将数据正确的划分到正确的窗口内进行计算。
如下图所示,在11事件到达后,周期计算watermark的值为11,此刻,可以认为11时刻之前的所有数据全部到达Flink程序,随着Flink接收到新的事件,watermark时间也会往后推移,如下一次计算watermark的值为20,此刻,可以认为20时刻之前的所有数据已经全部到达Flink没有延迟。

- 无序事件watermark生成
当Flink接收到的事件流是乱序的,那么此时周期性计算watermark的值不再像有序流中这么简单。假设在Flink中进行窗口计算,Flink接收到的事件是乱序的,当Flink接收到窗口结束时刻对应的事件时,不能立刻触发该窗口,因为有可能属于该窗口的部分数据"迟到"还未到达,我们还需基于Flink接收到的事件时间继续"等一等",那么此时watermark的值为使用Flink此刻已经接收到的最大事件时间减去"等待"的时间,公式如下:
Watermark=进入Flink的最大的事件时间(maxEventTime)-指定的延迟时间(t)-1ms
以上公式中使用"进入Flink的最大的事件时间"的原因是waterkmark是一个时间标记,随着时间的推移,只能增大不能减少,这也是watermark叫做水位线名称的缘由;减去"指定的延迟时间"的目的是等待迟到的事件,关于这里的减去1ms的原理可以参照后续章节"乱序流中设置watermark"部分。当周期性计算watermark的值一旦确定,那么Flink认为该watermark时刻之前的所有数据(即使有乱序)都已全部达到。
为了更加清晰的了解乱序事件流中watermark的生成,可以参照下图来理解。下图中计算watermark时指定的延迟时间为4s(后续默认统一为s单位)。当事件15到达Flink后,如果此刻计算watermark的值那么watermark的值为当前Flink程序接收到的最大事件时间15减去等待延迟时间4为11,后续继续有乱序事件9、12到达Flink,但由于watermark的值只能增加不能减少的特点,watermark的值还是11。按照下图的乱序事件到达的情况,当接收到20事件时,Flink计算watermark的值为此刻接收到的事件时间最大值21减去延迟时间4得到17,那么此刻watermark的值为17。

通过以上有序和无序流中watermark的生成机制,实际上我们可以总结Flink中watermark的生成公式如下:
Watermark=进入Flink的最大的事件时间(mxtEventTime)-指定的延迟时间(t)-1ms
在有序流中指定的延迟时间就相当于是0,在乱序流中需要我们根据实际情况来指定延迟时间,总之,当计算得到watermark时就代表Flink的事件时间推进到了该时刻,我们就可以认为watermark时刻之前的数据全部已经到达Flink,不会再有延迟数据到达。
通过了解以上watermark的生成原理,我们也能得到watermark具备如下特点:
-
watermark是衡量事件时间进展的机制,watermark值代表了当前事件时间的进展,Flink处理乱序事件时,以watermark为标准,只要计算出watermark的值t,那么就认为当期t时刻之前的所有数据全部达到,之后流中不会有小于t时刻的事件时间数据。
-
watermark本质是一个时间,基于事件时间生成并单调递增。
-
watermark是周期性生成并插入到数据流中,默认每隔200ms计算一次watermark,这里的200ms时间是以系统时间为基准。
-
watermark计算公式为:进入Flink的最大的事件时间(mxtEventTime)-指定的延迟时间(t),可以指定延迟时间确保正确处理乱序问题。
watermark传递与对齐机制
并行流中的watermark传递
在watermark原理小节中,我们了解到watermark的生成在有序流和无序流中生成的方式,并且watermark会随着数据流向下游移动,前面我们讨论的情况更多的是针对单并行度下watemark生成方式。
在实际Flink使用场景中,Flink一个流中会有多个并行度,按照单并行度watermark生成方式每个并行度都可以计算得到对应的watermark,由于每个并行度由于处理数据的速度不同得到的watermark的值也不同,回归到watermark的本质------标志事件时间进展,这里说的事件时间进展是Flink全局对应事件时间的进展并非针某个并行度下的事件时间进展,所以多并行度下就涉及到如何确定watermark的问题。在Flink多并行度下,全局watermark是每个并行度中watermark最小的值。
如下图示例,Flink读取源数据的并行度为4,根据每个并行度中处理的事件时间得到每个并行度中的watermark值分别为2,4,3,6,那么此刻Watermark取最小值2,并将该watermark广播给Flink下游,从而下游不必再基于原始事件时间处理就能知道当前的全局watermark。

当每个并行度中的watermark更新时,同样会选择每个并行度最小的watermark当做全局的watermark,依次往后推进watermark的值,如下图所示:

综上来看,Flink在多并行度情况下会选择所有并行度中最小的watermark当做全局Watermark并广播给下游。此外,在Flink多个Source流进行关联时,每个source流都可以单独设置watermark,多Source进行关联后,Flink全局watermark值选择原理与上面相同,会选择多Source流中最小的watermark值当做Flink全局watermark。
watermark alignment对齐机制
watermark作为处理事件时间的核心很多操作依赖于watermark来触发计算,如:window窗口、CEP操作等,Flink多并行度或Flink多souce情况下,Flink默认会选择并行度或者多Source中watermark最小值作为全局watermark。如果某个并行度或者Source读取数据的速度过快,对应的watermark前进速度也会过快,默认这种选择watermark机制下,超前于较慢一侧的数据就会被存储在Operator的状态中,两个并行/Source之间读取速度差距越大则状态中存储的数据也越大,这对存储空间及状态恢复都会产生很大的挑战。
为了解决以上问题,Flink在1.15版本引入了watermark对齐机制(watermark alignment),如果某个并发或Source读取速度过快,watermark对齐机制就会暂停这个并发/source的读取,等待较慢一侧追赶上来之后再恢复读取,watermark对齐机制原理如下:

Flink Source由两部分组成,一部分是存在于JobManager中的Source Coordinator ,另一部分是存在于各TaskManager上的Source Operator,其中Source Coordinator属于协调者角色,Coordinator会周期性检查各Source Operator所汇报的watermark值是否达到当前允许的watermark最大偏移,如果某个Operator超过了watermark最大偏移,那么就会暂停读取Source数据,直到下个watermark检查周期。
Flink 多Source之间watermark对齐机制在JobManager上引入了CoordinatorStore来让各Source Coordinator之间交换信息,在watermark对齐机制下,各个Source Coordinator 通过CoordinatorStore来协调最大的允许watermark。
在编写Flink代码时,可以通过以下方式指定watermark对齐机制:
WatermarkStrategy
.<Tuple2<Long,String>>forBoundedOutOfOrderness(Duration.ofSeconds(20))
.withWatermarkAlignment("alignment-group-1", Duration.ofSeconds(20), Duration.ofSeconds(1));
以上withWatermarkAlignment即是开启watermark对齐机制,其中需要传入3个参数,第一个参数是指定watermark对齐组,可以对不同的Source源设置相同的对齐组,这样就可以让不同的Source之间进行watermark对齐,只有一个Source的情况下,指定该值后,多个并发之间会进行watermark对齐;第二个参数是指定最大允许的watermark偏移值,如果读取速度过快一侧watermark较读取数据慢一侧watermark达到该值,那么读取速度快的一侧就会暂停读取数据;第三个参数是watermark检查间隔,Flink会每隔该时间检查watermark以决定是否对读取速度过快的并行/Source暂停读取数据,默认值为1秒。
watermark对齐机制代码
这里以读取Socket中基站日志数据为例,来演示watermark对齐机制使用。
- Java代码:
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStreamSource<String> sourceDS = env.socketTextStream("node5", 9999);
//将数据转换成StationLog对象
SingleOutputStreamOperator<StationLog> stationLogDS = sourceDS.map(new MapFunction<String, StationLog>() {
@Override
public StationLog map(String s) throws Exception {
String[] arr = s.split(",");
return new StationLog(arr[0].trim(),
arr[1].trim(),
arr[2].trim(),
arr[3].trim(),
Long.valueOf(arr[4]),
Long.valueOf(arr[5]));
}
});
//给 stationLogDS 流设置Watermark
SingleOutputStreamOperator<StationLog> dsWithWatermark = stationLogDS.assignTimestampsAndWatermarks(
//设置Watermark,最大延迟时间为5s
WatermarkStrategy.<StationLog>forMonotonousTimestamps()
//设置EventTime对应字段
.withTimestampAssigner(new SerializableTimestampAssigner<StationLog>() {
@Override
public long extractTimestamp(StationLog element, long recordTimestamp) {
return element.callTime;
}
})
//设置Watermark对齐,对齐组为socket-source-group,watermark最大偏移值为5s,检查周期是2s
.withWatermarkAlignment("socket-source-group",Duration.ofSeconds(5),Duration.ofSeconds(2))
);
dsWithWatermark.print();
env.execute();
- Scala代码:
val env = StreamExecutionEnvironment.getExecutionEnvironment
//导入隐式转换
import org.apache.flink.streaming.api.scala._
/**
* Socket中输入数据格式如下:
* 001,181,182,busy,1000,10
* 002,182,183,fail,2000,20
* 003,183,184,busy,3000,30
* 004,184,185,busy,4000,40
* 005,181,183,busy,5000,50
*/
val sourceDS: DataStream[String] = env.socketTextStream("node5", 9999)
//将数据转换成StationLog对象
val stationLogDS: DataStream[StationLog] = sourceDS.map(line => {
val arr = line.split(",")
StationLog(arr(0), arr(1), arr(2), arr(3), arr(4).toLong, arr(5).toLong)
})
//给 stationLogDS 设置水位线
val dsWithWatermark: DataStream[StationLog] = stationLogDS.assignTimestampsAndWatermarks(
//设置水位线策略
WatermarkStrategy.forMonotonousTimestamps[StationLog]()
//设置事件时间抽取器
.withTimestampAssigner(new SerializableTimestampAssigner[StationLog] {
override def extractTimestamp(element: StationLog, recordTimestamp: Long): Long = {
element.callTime
}
})
//设置Watermark对齐,对齐组为socket-source-group,watermark最大偏移值为5s,检查周期是2s
.withWatermarkAlignment("socket-source-group",Duration.ofSeconds(5 ),Duration.ofSeconds(2))
)
dsWithWatermark.print()
env.execute()