Flink 中的事件时间处理
- 1.时间戳
- 2.水位线
- 3.水位线传播和事件时间
- 4.时间戳分配和水位线生成
在之前的博客中,我们强调了时间语义对于流处理应用的重要性并解释了 处理时间 和 事件时间 的差异。虽然处理时间是基于处理机器的本地时间,相对容易理解,但它会产生一些较为随意、不一致且无法重现的结果。相反,事件时间语义会生成可重现且一致性的结果,这也是很多流处理用例的刚性需求。但和基于处理时间语义的应用相比,基于事件时间的应用需要一些额外的配置。此外,相比纯粹使用处理时间的引擎,支持事件时间的流处理引擎内部要更加复杂。
Flink 不仅针对常见的事件时间操作提供了直观易用的原语,还支持一些表达能力很强的 API,允许使用者以自定义算子的方式实现更高级的事件时间处理应用。在面对这些高级应用时,充分理解 Flink 内部事件处理机制通常会有所帮助,有时候更是必要的。在《流处理基础概念(二):时间语义(处理时间、事件时间、水位线)》一文中,我们介绍了 Flink 在提供处理时间语义时所采用的两个概念:记录时间戳 和 水位线。接下面我们会介绍 Flink 内部如何实现和处理时间戳及水位线以支持事件时间语义的流式应用。
1.时间戳
在事件时间模式下,Flink 流式应用处理的所有记录都必须包含时间戳。时间戳将记录和特定时间点进行关联,这些时间点通常是记录所对应事件的发生时间。但实际上应用可以自由选择时间戳的含义,只要保证流记录的时间戳会随着数据流的前进大致递增即可。正如前文所述,基本上所有现实应用场景都会出现一定程度的时间戳乱序。
当 Flink 以事件时间模式处理数据流时,会根据记录的时间戳触发时间相关算子的计算。例如,时间窗口算子会根据记录关联的时间戳将其分配到窗口中。Flink 内部采用 8 字节的 Long
值对时间戳进行编码,并将它们以元数据(metadata
)的形式附加在记录上。内置算子会将这个 Long
值解析为毫秒精度的 Unix
时间戳(自 1970-01-01-00:00:00.000
以来的毫秒数)。但自定义算子可以有自己的时间戳解析机制,如将精度调整为微秒。
2.水位线
除了记录的时间戳,Flink 基于事件时间的应用还必须提供 水位线(watermark
)。水位线用于在事件时间应用中推断每个任务当前的事件时间。基于时间的算子会使用这个时间来触发计算并推动进度前进。例如:基于时间窗口的任务会在其事件时间超过窗口结束边界时进行最终的窗口计算并发出结果。
当一个算子接收到时间为 T 的水位线,就可以认为不会再收到任何时间戳小于或等于 T 的事件了。水位线无论对于事件时间窗口还是处理乱序事件的算子都很关键。算子一旦收到某个水位线,就相当于接收到信号:某个特定时间区间的时间戳已经到齐,可以触发窗口计算或对接收的数据进行排序了。
在 Flink 中,水位线是利用一些包含 Long
值时间戳的特殊记录来实现的。如上图所示,它们像带有额外时间戳的常规记录一样在数据流中移动。
水位线拥有两个基本属性:
- 必须单调递增。这是为了确保任务中的事件时间时钟正确前进,不会倒退。
- 和记录的时间戳存在联系。一个时间戳为 T 的水位线表示,接下来所有记录的时间戳一定都大于 T。
第二个属性可用来处理数据流中时间戳乱序的记录,例如上图中的时间戳为 3 和 5 的记录。对基于时间的算子任务而言,其收集和处理的记录可能会包含乱序的时间戳。这些算子只有当自己的事件时间时钟(由接收的水位线驱动)指示不必再等那些包含相关时间戳的记录时,才会最终触发计算。当任务收到一个违反水位线属性,即时间戳小于或等于前一个水位线的记录时,该记录本应参与的计算可能已经完成。我们称此类记录为 迟到记录(late record
)。为了处理迟到记录,Flink 提供了不同的机制,我们将在后续讨论它们。
水位线的意义之一在于它允许应用控制结果的完整性和延迟。如果水位线和记录的时间戳非常接近,那结果的处理延迟就会很低,因为任务无须等待过多记录就可以触发最终计算。但同时结果的完整性可能会受影响,因为可能有部分相关记录被视为迟到记录,没能参与运算。相反,非常 “保守” 的水位线会增加处理延迟,但同时结果的完整性也会有所提升。
3.水位线传播和事件时间
Flink 内部将水位线实现为特殊的记录,它们可以通过算子任务进行接收和发送。任务内部的 时间服务(time service
)会维护一些 计时器(timer
),它们依靠接收到水位线来激活。这些计时器是由任务在时间服务内注册,并在将来的某个时间点执行计算。例如:窗口算子会为每个活动窗口注册一个计时器,它们会在事件时间超过窗口的结束时间时清理窗口状态。
当任务接收到一个水位线时会执行以下操作:
- 基于水位线记录的时间戳更新内部事件时间时钟。
- 任务的时间服务会找出所有触发时间小于更新后事件时间的计时器。对于每个到期的计时器,调用回调函数,利用它来执行计算或发出记录。
- 任务根据更新后的事件时间将水位线发出。
Flink 对通过 DataStream API 访问时间戳和水位线有一定限制。普通函数无法读写记录的时间戳或水位线,但一系列处理函数(
process function
)除外。它们可以读取当前正在处理记录的时间戳,获得当前算子的事件时间,还能注册计时器。所有函数的 API 都无法支持设置发出记录的时间戳、调整任务的事件时间时钟或发出水位线。为发出记录配置时间戳的工作需要由基于时间的 DataStream 算子任务来完成,这样才能确保时间戳和发出的水位线对齐。举例而言,时间窗口算子任务会在发送触发窗口计算的水位线时间戳之前,将所有经过窗口计算所得结果的时间戳设为窗口的结束时间。
接下来我们详细解释一下任务在收到一个新的水位线之后,将如何发送水位线和更新其内部事件时间时钟。Flink 会将数据流划分为不同的分区,并将它们交由不同的算子任务来并行执行。每个分区作为一个数据流,都会包含带有时间戳的记录以及水位线。根据算子的上下游连接情况,其任务可能需要同时接收来自多个输入分区的记录和水位线,也可能需要将它们发送到多个输出分区。下面我们将详细介绍一个任务如何将水位线发送至多个输出任务,以及它从多个输入任务获取水位线后如何推动事件时间时钟前进。
一个任务会为它的每个输入分区都维护一个 分区水位线(partition watermark
)。当收到某个分区传来的水位线后,任务会以接收值和当前值中较大的那个去更新对应分区水位线的值。随后,任务会把事件时间时钟调整为所有分区水位线中最小的那个值。如果事件时间时钟向前推动,任务会先处理因此而触发的所有计时器,之后才会把对应的水位线发往所有连接的输出分区,以实现事件时间到全部下游任务的广播。
下图展示了一个有 4 个输入分区和 3 个输出分区的任务在接收到水位线后,是如何更新它的分区水位线和事件时间时钟,并将水位线发出的。
对于那些有着两条或多条输入数据流的算子,如 Union
或 CoFlatMap
,它们的任务同样是利用全部分区水位线中的最小值来计算事件时间时钟,并没有考虑分区是否来自不同的输入流。这就导致所有输入的记录都必须基于同一个事件时间时钟来处理。如果不同输入流的事件时间没有对齐,那么该行为就会导致一些问题。
Flink 的水位线处理和传播算法保证了算子任务所发出的记录时间戳和水位线一定会对齐。然而,这依赖于一个事实:所有分区都会持续提供自增的水位线。只要有一个分区的水位线没有前进,或分区完全空闲下来不再发送任何记录或水位线,任务的事件时间时钟就不会前进,继而导致计时器无法触发。这种情形会给那些靠时钟前进来执行计算或清除状态的时间相关算子带来麻烦。因此,如果一个任务没有从全部输入任务以常规间隔接收新的水位线,就会导致时间相关算子的处理延迟或状态大小激增。
当算子两个输入流的水位线差距很大时,也会产生类似影响。对于一个有两个输入流的任务而言,其事件时间时钟会受制于那个相对较慢的流,而较快流的记录或中间结果会在状态中缓冲,直到事件时间时钟到达允许处理它们的那个点。
4.时间戳分配和水位线生成
到目前为止,我们已经解释了时间戳和水位线的含义以及它们在 Flink 内部的处理逻辑,但一直没涉及它们的来源。时间戳和水位线通常都是在数据流刚刚进入流处理应用的时候分配和生成的。由于不同的应用会选择不同的时间戳,而水位线依赖于时间戳和数据流本身的特征,所以应用必须显式地分配时间戳和生成水位线。Flink DataStream 应用可以通过三种方式完成该工作:
- 在数据源完成:我们可以利用 SourceFunction 在应用读入数据流的时候分配时间戳和生成水位线。源函数会发出一条记录流。每个发出的记录都可以附加一个时间戳,水位线可以作为特殊记录在任何时间点发出。如果源函数(临时性地)不再发出水位线,可以把自己声明成空闲。Flink 会在后续算子计算水位线的时候把那些来自于空闲源函数的流分区排除在外。数据源空闲声明机制 可以用来解决上面提到的水位线不向前推进的问题。我们会在后续详细讨论数据源函数。
- 周期分配器(
periodic assigner
):DataStream API 提供了一个名为AssignerWithPeriodicWatermarks
的用户自定义函数,它可以用来从每条记录提取时间戳,并周期性地响应获取当前水位线的查询请求。提取出来的时间戳会附加到各自的记录上,查询得到的水位线会注入到数据流中。这个函数会在后续介绍。 - 定点分配器(
punctuated assigner
):另一个支持从记录中提取时间戳的用户自定义函数叫作AssignerWithPunctuatedWatermarks
。它可用于需要根据特殊输入记录生成水位线的情况。和AssignerWithPeriodicWatermarks
函数不同,这个函数不会强制你从每条记录中都提取一个时间戳(虽然这样也行)。这个函数也会在后续介绍。
用户自定义的时间戳分配函数通常都会尽可能地靠近数据源算子,因为在经过其他算子处理后,记录顺序和它们的时间戳会变得难以推断。这也是为什么不建议在流式应用中途覆盖已有的时间戳和水位线(虽然这可以通过用户自定义函数实现)。