Flink中Time&Window

542 阅读16分钟

Time

更多代码案例可参考:github.com/perkinls/fl…

Fink支持的Time类型

Flink在流式传输程序中支持不同的时间概念。

处理时间(Processing time)

处理时间是指正在执行相应操作的机器的系统时间。当流式程序按处理时间运行时,所有基于时间的操作(如时间窗口)都将使用运行相应操作员的计算机的系统时钟。每小时处理时间窗口将包括系统时钟指示整小时的时间之间到达特定操作员的所有记录。

例如,如果一个应用程序在9:15 am开始运行,则第一个每小时处理时间窗口将包括在9:15 am和10:00 am之间处理的事件,下一个窗口将包括在10:00 am和11:00 am之间处理的事件,依此类推。

事件时间(Event time)

事件时间是每个事件在设备上产生的时间。这个时间通常是嵌入在事件中。使用事件时间,时间进度取决于数据,而不是算子所在的系统时间。采用事件时间必须指定如何生成事件时间Watermark。watermark是基于事件时间处理时表示进度的机制。

在理想情况下,事件时间处理将产生完全一致且确定的结果,而不管事件何时到达或它们的顺序如何。但是,除非已知事件是按时间戳(按时间戳)到达的,否则事件时间处理会在等待无序事件时产生一定的延迟。由于只能等待有限的时间,因此这限制了确定性事件时间应用程序的可用性。

假设所有数据都已到达,事件时间操作将按预期方式运行,即使在处理无序或迟到的事件或重新处理历史数据时,也会产生正确且一致的结果。例如,每小时事件时间窗口将包含所有带有落入该小时事件时间戳的记录,无论它们到达的顺序或处理的时间。

事件时间处理通常会产生一定的延迟,这是因为它需要等待滞后事件和无序事件的一定时间。

注入时间(Ingestion time)

注入时间是指事件进入flink的时间。在Sources操作符中每个事件都会获取Sources的当前时间作为时间戳,基于时间的操作(比如windows)会依据这个时间戳。

在概念上注入时间在事件时间和处理时间之间。与处理时间相比,稍微更消耗性能些,但是却提供了可预测的结果。因为注入时间使用固定的时间戳(在Sources处一次分配),不同的窗口操作都会使用相同的时间,而使用处理时间每个窗口操作,都可能分配给消息不同的时间窗口(基于操作本地系统时间)。

与事件时间(event time)相比,注入时间程序不能处理任何无需事件或者滞后数据,但是程序不需要指定如何生成watermark。

在内部,注入时间和事件时间非常相似,但是注入时间有自动时间戳分配和自动生成watermark的功能。

在这里插入图片描述

设置时间特性

Flink DataStream程序的第一部分通常设置基准时间特征。该设置定义数据流源的行为方式(例如,它们是否将分配时间戳),以及诸如KeyedStream.timeWindow(Time.seconds(30))之类的窗口操作应使用什么时间概念。

final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);
-- 不设置,默认是 ProcessTime

注意,为了使用事件时间来运行这个例子,要求:

  1. 要么使用Sources来直接定义数据的事件时间并生成发射watermark。

  2. 要么程序在Sources后必须注入一个Timestamp Assigner & Watermark Generator。

这些功能主要描述了如何去使用事件时间戳和事件流展示出来的无序程度。

事件时间和watermark

支持事件时间的流处理器需要一种方法来测量事件时间的进度。例如,windows窗口为一小时,当事件时间已经超过窗口的结束时间时需要通知该操作算子,以便操作算子可以关闭正在进行中的窗口。

处理时间和事件时间时相互独立的。例如在一个程序中,操作算子的当前事件时间可能稍微落后于处理时间(事件延迟导致)。Flink中使用watermark去测量基于事件时间的处理进度,Watermark 作为数据流的一部分,携带一个时间戳t。Watermark(t) 声明事件时间已经到达时间t,意味着已经没有事件时间t1<=t的元素在流中存在。 在这里插入图片描述

Watermark对于无序流是至关重要的,如下图所示,事件不是根据时间戳排序。一般来说,watermark是一个声明,所有watermark代表时间之前事件应该已经到达。一旦watermark到达操作算子,操作算子就可以提升内部时间到watermark所指定的值。 在这里插入图片描述 请注意,事件时间是由新创建的一个(或多个)流元素从产生它们的事件或触发了创建这些元素的Watermark中继承的。

在并行流中的watermark

Watermark是在Source函数中直接或者在其后指定生成。一个source算子的每个并行子任务通常独立的产生watermark,这些watermark定义了特定并行源的事件时间。当watermark流经流程序时,会调整操作算子中的事件时间至watermark到达的时间。每当操作算子调整它自己的事件时间时,它也会为后继的操作算子生成一个新watermark。

一些操作算子会有多个输入流。例如,union操作或者keyBy(...)或partition(...)之后的操作.这些操作算子的当前事件时间是所有输入流最小的事件时间。当输入流更新它们的事件时间时,操作算子也会更新。

下图显示了流过并行流的事件和watermark的示例,以及跟踪事件时间的运算符。 在这里插入图片描述

  • 通常情况下,watermark在source函数中生成,但是也可以在source后任何阶段,如果指定多次后面指定的会覆盖前面的值。source的每个sub task独立生成水印
  • watermark通过operator时会推进operators初的event time,同时operators会为下游生成一个新的watermark
  • 多输入operator(union、keyBy、partition)的当前event time是其输入流event time 的最小值

迟滞元素

某些元素可能会违反watermark条件,这意味着即使在发生watermark(t)之后,也会出现更多时间戳为t'<= t的元素。事实上,在现实中某些元素可能会有任意的延迟,使得所有元素在watermark之前准时到达变得不是很可能。即使延迟是有界的,延迟watermark太大也是不可取的,因为会导致事件时间窗口计算延迟很大。

由于这个原因,流式传输程序可能会明确抛弃watermark出现之后的迟滞元素。

在这里插入图片描述

Window窗口

实时处理领域比较常见的处理模式就是事件驱动处理和窗口处理,flink是支持事件驱动处理,也支持窗口处理,常见的窗口处理主要是有两种:滑动窗口和会话窗口。

  • Flink认为Batch是Streaming的一个特例,因为Flink底层引擎是流式引擎,在此上实现了流处理和批处理。而窗口(Window)到Batch的桥梁,Flink提供了非常完善的窗口机制。
  • Window是一种切割无线数据集为有限块并进行相应计算的处理手段
  • 在流处理应用中,数据是连续不断的,因此我们不可能等到所有数据都到了才开始处理。当我们可以每来一条消息就处理一次,但是有时我们需要做一些聚合类的处理,例如:在过去的一分钟内有多少用户点击了我们的网页。在这种情况下,我们必须定义一个窗口,用来收集最近一分钟内的数据,并对这个窗口内的数据进行计算。

Window分配器(Window Assigner)

Flink的窗口从API使用上是有一点区别的,分为keyed和non-keyed 窗口,也即是有没有对流使用keyBy函数。可以看到,唯一的区别是对键控流的keyBy(...)调用和对non-keyed流的window(...)变为windowAll(...)。

在这里插入图片描述

WindowAssigner决定着进入flink 的元素会被分配到哪些窗口里。Flink带有针对最常见用例的预定义窗口分配器,即滚动窗口,滑动窗口,会话窗口和全局窗口。当然,我们也可以通过继承WindowAssigner类来实现一个自定义的WindowAssigner。内置的WindowAssinger(除了全局窗口)都是根据时间分配元素到窗口的,可以是事件时间或者处理时间。

基于时间的窗口都有一个开始时间(包括)和一个结束时间(不包括)来描述窗口大小,它们共同描述了窗口的大小。在代码中,Flink在使用基于时间的窗口时使用TimeWindow,该窗口具有用于查询开始和结束时间戳记的方法,以及用于返回给定窗口允许的最大时间戳的附加方法maxTimestamp()

滚动窗口(Tumbling Windows)

滚动窗口分配器将每个元素分配给指定窗口大小的窗口。滚动窗口具有固定的大小,并且不重叠。例如,如果您指定大小为5分钟的翻滚窗口,则将评估当前窗口,并且每五分钟将启动一个新窗口,如下图所示。

在这里插入图片描述

以下代码段显示了如何使用滚动窗口。

val input: DataStream[T] = ...

// tumbling event-time windows
input
    .keyBy(<key selector>)
    .window(TumblingEventTimeWindows.of(Time.seconds(5)))
    .<windowed transformation>(<window function>)

// tumbling processing-time windows
input
    .keyBy(<key selector>)
    .window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
    .<windowed transformation>(<window function>)

// daily tumbling event-time windows offset by -8 hours.
input
    .keyBy(<key selector>)
    .window(TumblingEventTimeWindows.of(Time.days(1), Time.hours(-8)))
    .<windowed transformation>(<window function>)

**特点:**时间对齐、窗口长度固定、event无重叠

**适用场景:**BI统计(计算各个时间段的指标)

**对齐方式:**默认是aligned with epoch(整点、整分、整秒),可以通过offset参数改变对齐方式

滑动窗口(Sliding Windows)

滑动窗口分配器将元素分配给固定长度的窗口。类似于滚动窗口分配器,窗口的大小由窗口大小参数配置。附加的窗口滑动参数控制滑动窗口启动的频率。因此,如果滑动时间小于窗口大小,则滑动窗口可能会重叠。在这种情况下,元素被分配给多个窗口。

例如,您可以将大小为10分钟的窗口滑动5分钟。这样,您每隔5分钟就会得到一个窗口,其中包含最近10分钟内到达的事件,如下图所示。 在这里插入图片描述 以下代码段显示了如何使用滑动窗口。

val input: DataStream[T] = ...

// sliding event-time windows
input
    .keyBy(<key selector>)
    .window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5)))
    .<windowed transformation>(<window function>)

// sliding processing-time windows
input
    .keyBy(<key selector>)
    .window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5)))
    .<windowed transformation>(<window function>)

// sliding processing-time windows offset by -8 hours
input
    .keyBy(<key selector>)
    .window(SlidingProcessingTimeWindows.of(Time.hours(12), Time.hours(1), Time.hours(-8)))
    .<windowed transformation>(<window function>)

可以使用Time.milliseconds(x),Time.seconds(x),Time.minutes(x)来指定时间间隔。如最后一个示例所示,滑动窗口分配器还采用可选的offset参数,该参数可用于更改窗口的对齐方式。例如,在没有偏移的情况下,每小时滑动30分钟的窗口与纪元对齐,即您将获得1:00:00.000-1:59:59.999、1:30:00.000-2:29:59.999之类的窗口,依此类推。如果要更改,可以提供一个偏移量。

例如,使用15分钟的偏移量,您将获得1:15:00.000-2:14:59.999、1:45:00.000-2:44:59.999等。偏移量的重要用例是将窗口调整为时区除了UTC-0。例如,在中国,您将必须指定Time.hours(-8)的偏移量。

  1. 窗口大小等于滑动间隔:这个就是滚动窗口,数据不会重叠,也不会丢失数据。
  2. 窗口大小大于滑动间隔:这个时候会有数据重叠,也即是数据会被重复处理。
  3. 窗口大小小于滑动间隔:必然是会导致数据丢失,不可取。

**特点:**时间对齐、窗口长度固定、event有重叠

**适用场景:**监控场景,对过去一个时间段内的统计(如:求某接口最近5min的失败率来决定是否要报警)

**对齐方式:**默认是aligned with epoch(整点、整分、整秒等),可以通过offset参数改变对齐方式

会话窗口(Session Windows)

会话窗口分配器按活动会话对元素进行分组。与滚动窗口和滑动窗口相比,会话窗口不重叠且没有固定的开始和结束时间。相反,当会话窗口在一定时间段内未接收到元素时,即在发生不活动间隙时,它将关闭。会话窗口分配器可以配置有静态会话间隔,也可以配置有会话间隔提取器功能,该功能定义不活动的时间长度。当此时间段到期时,当前会话将关闭,随后的元素将分配给新的会话窗口。

在这里插入图片描述

以下代码段显示了如何使用会话窗口。

val input: DataStream[T] = ...

// event-time session windows with static gap
input
    .keyBy(<key selector>)
    .window(EventTimeSessionWindows.withGap(Time.minutes(10)))
    .<windowed transformation>(<window function>)

// event-time session windows with dynamic gap
input
    .keyBy(<key selector>)
    .window(EventTimeSessionWindows.withDynamicGap(new SessionWindowTimeGapExtractor[String] {
      override def extract(element: String): Long = {
        // determine and return session gap
      }
    }))
    .<windowed transformation>(<window function>)

// processing-time session windows with static gap
input
    .keyBy(<key selector>)
    .window(ProcessingTimeSessionWindows.withGap(Time.minutes(10)))
    .<windowed transformation>(<window function>)


// processing-time session windows with dynamic gap
input
    .keyBy(<key selector>)
    .window(DynamicProcessingTimeSessionWindows.withDynamicGap(new SessionWindowTimeGapExtractor[String] {
      override def extract(element: String): Long = {
        // determine and return session gap
      }
    }))
    .<windowed transformation>(<window function>)

可以使用Time.milliseconds(x),Time.seconds(x),Time.minutes(x)等之一指定静态间隙(固定gap)。通过实现SessionWindowTimeGapExtractor接口来指定动态间隙(动态gap)。

注意:
由于会话窗口没有固定的开始和结束,因此对它们的评估不同于滚动窗口和滑动窗口。在内部,会话窗口运算符会为每个到达的记录创建一个新窗口,如果窗口彼此之间的距离比已定义的间隔小,则将它们合并在一起。为了可合并,会话窗口运算符需要合并触发器和合并窗口函数,例如ReduceFunction,AggregateFunction或ProcessWindowFunction(FoldFunction无法合并)。

**特点:**时间无对齐、event不重叠、没有固定开始河结束时间

**适用场景:**线上用户行为分析

全局窗口(Global Windows)

全局窗口分配器将具有相同键的所有元素分配给同一单个全局窗口。仅当指定自定义触发器时,此窗口方案才有用。否则,将不会执行任何计算,因为全局窗口没有可以处理聚合元素的自然端。

在这里插入图片描述

以下代码段显示了如何使用全局窗口。

val input: DataStream[T] = ...

input
    .keyBy(<key selector>)
    .window(GlobalWindows.create())
    .<windowed transformation>(<window function>)
注意:
必须指定自定义触发器,否则无任何意义

窗口生命周期

简单来说,窗口创建于属于该窗口的第一个元素到达,结束于事件时间或者处理时间达到了窗口的结尾时间加上用户自定义的允许延迟时间。

举一个简单的例子,比如一个滚动窗口,窗口大小为5min,允许延迟1min,当有12:00到12:05之间的第一个元素进入flink的时候,flink会开启一个12:00和12:05之间的窗口,当watermark到达12:06的时候就会删除该窗口。

此外,每个窗口都会有一个触发器和一些处理函数。触发器主要是用来在窗口创建之后消亡之前触发窗口计算。

Triggers

触发器

Evictors

Flink的窗口模型除了WindowAssigner和Trigger外,还可以指定一个可选的Evictor。可以使用evictor(…)方法来完成此操作。evictor可以在触发触发器之后以及应用窗口功能之前和/或之后从窗口中删除元素。为此,Evictor有两种方法:

/**
 * Optionally evicts elements. Called before windowing function.
 *
 * @param elements The elements currently in the pane.
 * @param size The current number of elements in the pane.
 * @param window The {@link Window}
 * @param evictorContext The context for the Evictor
 */
void evictBefore(Iterable<TimestampedValue<T>> elements, int size, W window, EvictorContext evictorContext);

/**
 * Optionally evicts elements. Called after windowing function.
 *
 * @param elements The elements currently in the pane.
 * @param size The current number of elements in the pane.
 * @param window The {@link Window}
 * @param evictorContext The context for the Evictor
 */
void evictAfter(Iterable<TimestampedValue<T>> elements, int size, W window, EvictorContext evictorContext);

evictBefore()包含要在窗口函数之前应用的逐出逻辑,而evictAfter()包含要在窗口函数之后应用的逐出逻辑,应用窗口功能之前逐出的元素将不会被其处理。

Flink附带了三个预先实施的驱逐程序。这些是:

  • CountEvictor:从窗口中保留用户指定数量的元素,并从窗口缓冲区的开头丢弃其余的元素。
  • DeltaEvictor:采用DeltaFunction和阈值,计算窗口缓冲区中最后一个元素与其余每个元素之间的增量,并删除增量大于或等于阈值的元素。
  • TimeEvictor:以一个以毫秒为单位的间隔作为参数,对于给定的窗口,它将在其元素中找到最大时间戳max_ts,并删除所有时间戳小于max_ts-interval的元素。

默认情况下,所有预先实现的驱逐程序均在窗口函数之前应用其逻辑。

注意:

  1. 指定evictor可防止任何预聚集,因为在应用计算之前必须将窗口的所有元素传递给evictor。
  2. Flink不保证窗口中元素的顺序。这意味着,尽管evictor可以从窗口的开头删除元素,但不一定是最先到达的元素。

允许延迟

当使用事件时间窗口时,可能会发生元素到达较晚的情况,即Flink用于跟踪事件时间进度的watermark已经超过了元素所属窗口的结束时间戳。默认情况下,当watermark超过窗口末端时,将删除晚期元素。

设置延迟时间

Flink允许为窗口运算符指定最大允许延迟。允许延迟指定元素删除之前可以延迟的时间,其默认值为0。在水印通过窗口末端之后但在通过窗口末端之前到达的元素加上允许延迟,仍添加到窗口中。根据使用的触发器,延迟但未掉落的元素可能会导致窗口再次触发。EventTimeTrigger就是这种情况。

为了使此工作正常进行,Flink保持窗口的状态,直到允许的延迟过期为止。一旦发生这种情况,Flink将删除该窗口并删除其状态,如“窗口生命周期”部分中所述。

默认情况下,允许的延迟设置为0。也就是说,到达水印后的元素将被丢弃。

您可以这样指定允许的延迟:

val input: DataStream[T] = ...

input
    .keyBy(<key selector>)
    .window(<window assigner>)
    .allowedLateness(<time>)
    .<windowed transformation>(<window function>)

使用GlobalWindows窗口分配器时,永远不会考虑任何数据,因为全局窗口的结束时间戳是Long.MAX_VALUE。

数据侧输出

使用Flink的侧面输出功能,可以获得被丢弃的数据流。首先,您需要使用窗口流上的sideOutputLateData(OutputTag)指定要获取最新数据。然后,您可以根据窗口化操作的结果获取侧面输出流:

val lateOutputTag = OutputTag[T]("late-data")

val input: DataStream[T] = ...

val result = input
    .keyBy(<key selector>)
    .window(<window assigner>)
    .allowedLateness(<time>)
    .sideOutputLateData(lateOutputTag)
    .<windowed transformation>(<window function>)

val lateStream = result.getSideOutput(lateOutputTag)

当指定的允许延迟大于0时,在watermark通过窗口末尾之后,将保留窗口及其内容。在这些情况下,当延迟但未丢弃的元素到达时,可能会触发该窗口的另一次触发。这些触发称为延迟触发,因为它们是由延迟事件触发的,与主触发(即窗口的第一次触发)相反。在会话窗口的情况下,后期触发会进一步导致窗口合并,因为它们可能“弥合”两个预先存在的未合并窗口之间的间隙。

后期触发计算的元素应被视为先前计算的更新结果,即,您的数据流将包含同一计算的多个结果。根据您的应用程序,您需要考虑这些重复的结果或对它们进行重复数据删除。

处理窗口结果

窗口操作的结果还是DataStream,结果操作元素中没有保留任何有关窗口操作的信息,因此,如果要保留有关窗口的元信息,则必须在ProcessWindowFunction中的结果元素中手动编码该信息。在结果元素上设置的唯一相关信息是元素时间戳。由于窗口结束时间戳是唯一的,因此将其设置为已处理窗口的最大允许时间戳,即end timestamp - 1。请注意,对于事件时间窗口和处理时间窗口都是如此。即,在窗口操作元素之后始终具有时间戳,但这可以是事件时间时间戳或处理时间时间戳。对于处理时间窗口,这没有特殊的含义,但是对于事件时间窗口,连同watermark与窗口的交互方式一起,可以以相同的窗口大小进行连续的窗口操作。在查看watermark如何与窗口交互之后,我们将进行介绍。

watermark和windows的相互作用

当watermark到达窗口运算符时,将触发两件事:

  • watermark会触发所有最大时间戳(即end timestamp - 1)小于新watermark的所有窗口的计算
  • watermark被(按原样)转发到下游操作

直观地,一旦下游操作收到watermark后,watermark就会“flushes”所有在下游操作中被认为是后期的窗口。

连续窗口操作

如前所述,计算开窗结果的时间戳的方式以及watermark与窗口的交互方式允许将连续的开窗操作组合在一起。当您要执行两个连续的窗口化操作时,如果您想使用不同的keys,但仍希望来自同一上游窗口的元素最终位于同一下游窗口中,此功能将非常有用。考虑以下示例:

val input: DataStream[Int] = ...

val resultsPerKey = input
    .keyBy(<key selector>)
    .window(TumblingEventTimeWindows.of(Time.seconds(5)))
    .reduce(new Summer())

val globalResults = resultsPerKey
    .windowAll(TumblingEventTimeWindows.of(Time.seconds(5)))
    .process(new TopKWindowFunction())

在此示例中,第一个操作的时间窗口[0,5)的结果也将在随后的窗口操作中的时间窗口[0,5)中结束。这允许计算每个键的总和,然后在第二个操作中计算同一窗口内top-k个元素。

有用的状态规模考虑

Windows可以定义很长时间(例如几天,几周或几个月),因此会积累很大的状态。在估算窗口计算的存储需求时,需要牢记一些规则:

  1. Flink为每个元素所属的窗口创建一个副本。鉴于此,滚动窗口将保留每个元素的一个副本(一个元素恰好属于一个窗口,除非它被延迟放置)。相反,滑动窗口会为每个元素创建多个。因此,大小为1天的滑动窗口和滑动1秒的滑动窗口可能不是一个好主意。
  2. ReduceFunction,AggregateFunction和FoldFunction可以极大地减少存储需求,因为它们渴望聚合元素并且每个窗口仅存储一个值。相反,仅使用ProcessWindowFunction需要累积所有元素。
  3. 使用Evictor可防止任何预聚合,因为在应用计算之前必须将窗口的所有元素传递通过evictor。