Apache Flink 中的窗口处理机制

窗口(Windows)

窗口是处理无界流的核心。窗口将流拆分成有限大小的“桶”,我们可以在桶上进行计算。本文重点介绍Flink中的窗口机制,以及程序员如何最大限度地利用Flink提供的窗口功能。

窗口化Flink程序的一般结构如下。第一段代码针对是分组键控流,第二段代码针对的是非键控流。可以看到,二者的唯一区别就是键控流有keyBy(...)调用,以及非键控流的window(...)变成了windowAll(...)。这也将作为本文其余部分的路线图。

键控窗口(Keyed Windows)

键控窗口是应用于 keyed streams 的窗口。键控流在逻辑上是按照键进行分区,每个分区上的窗口计算相互独立。

键控窗口有以下特点:

  • 每个键的元素会按照窗口分配器分别分到不同的窗口中。

  • 窗口函数(如Reduce、Aggregate等)会对每个键及其对应的窗口分别进行计算。

  • 窗口生命周期也是按键维护的。可以针对每个键的窗口做触发计算。

基于这些特点,我们可以在流上定义丰富的按键分组的窗口计算。例如按用户维度计算最近1小时的访问量等。

stream
       .key_by(...)
       .window(...)                 <-  required: "assigner"
      [.trigger(...)]               <-  optional: "trigger" (else default trigger)
      [.allowed_lateness(...)]      <-  optional: "lateness" (else zero)
      [.side_output_late_data(...)] <-  optional: "output tag" (else no side output for late data)
       .reduce/aggregate/apply()    <-  required: "function"
      [.get_side_output(...)]       <-  optional: "output tag"

键控窗口是Flink中最常见和重要的窗口类型。但在有必要时,Flink也支持全局窗口(未分组的窗口计算)。

非键控窗口(Non-Keyed Windows)

非键控窗口是应用于非键控流的窗口。非键控流没有逻辑上的分区,窗口计算作用于整个流。

非键控窗口的主要特点是:

  • 所有元素都会被分配到指定策略(时间或计数)的窗口中。

  • 窗口函数作用于完整的流,不会按键进行分组。

  • 窗口的生命周期也是在全局维护。

例如,可以对整个流计算过去1小时的统计信息等。

非键控窗口在某些场景下也很有用。在Flink中,它对应着未进行keyBy分组的DataStream上的windowAll()调用。

当需要按窗口对整体流进行汇总计算时,非键控窗口是一个很好的选择。但大部分场景还是需要按键分组的键控窗口。

在上面的内容中,方括号([…])中的命令是可选的。这揭示了 Flink 允许您以许多不同的方式自定义窗口逻辑,以使其最符合您的需求。

注意:在 Python DataStream API 中仍不支持 Evictor。

窗口生命周期(Window Lifecycle)

窗口在应该属于该窗口的第一个元素到达时被创建,当时间(事件时间或处理时间)超过其结束时间戳加上用户指定的允许迟到时间(见允许迟到时间)时,窗口将被完全移除。

Flink 仅对基于时间的窗口进行移除保证,而不适用于其他类型,例如全局窗口(见窗口分配器)。例如,对于一个基于事件时间的窗口策略,它每5分钟创建一个不重叠的窗口(或滚动窗口),并允许迟到1分钟,在第一个具有落入该时间间隔的时间戳的元素到达时,Flink 将为12:00到12:05之间的间隔创建一个新窗口,并在水印超过12:06时间戳时将其移除。

此外,每个窗口都将有一个触发器(请参阅触发器)和一个函数(ProcessWindowFunction、ReduceFunction或AggregateFunction)(请参阅窗口函数)。函数将包含要应用于窗口内容的计算,而触发器则指定了在何种条件下窗口被认为已准备好应用函数。触发策略可能是诸如“当窗口内的元素数量超过4个”或“当水印超过窗口的结束时间”。触发器还可以决定在窗口创建和移除之间的任何时间清除窗口的内容。在这种情况下,清除只指窗口中的元素,而不包括窗口的元数据。这意味着仍然可以向该窗口添加新数据

使用ProcessWindowFunction的窗口化转换不能像其他情况那样高效地执行,因为Flink在调用函数之前必须在内部缓冲窗口的所有元素。这可以通过将ProcessWindowFunctionReduceFunctionAggregateFunction组合来减轻,以获得窗口元素的增量聚合和ProcessWindowFunction接收的附加窗口元数据。

窗口分配器(Window Assigners)

在指定你的流是否具有键时,下一步是定义窗口分配器。窗口分配器定义了如何将元素分配到窗口中。通过在 window(…)(对于带键的流)或 windowAll()(对于非带键的流)调用中指定您选择的 WindowAssigner 来完成此操作。

窗口分配器负责将每个传入的元素分配到一个或多个窗口中。Flink 预定义了用于最常见用例的窗口分配器,包括滚动窗口(tumbling windows)滑动窗口(sliding windows会话窗口(session windows)全局窗口(global windows)

此外,你还可以通过扩展 WindowAssigner 类来实现自定义窗口分配器。所有内置的窗口分配器(除全局窗口外)都根据时间将元素分配给窗口,时间可以是处理时间或事件时间。

基于时间的窗口具有起始时间戳(包含在内)和结束时间戳(排除在外),二者共同描述了窗口的大小。在代码中,Flink 在处理基于时间的窗口时使用 TimeWindow,它具有用于查询起始时间戳和结束时间戳的方法,还有一个额外的 maxTimestamp() 方法,返回给定窗口的最大允许时间戳。

以下将展示 Flink 预定义的窗口分配器的工作方式,以及它们在 DataStream 程序中的使用。以下图形可视化了每个分配器的工作原理。紫色圆圈表示流的元素,这些元素根据某个键进行分区(在本例中为用户 1、用户 2 和用户 3)。x 轴显示了时间的进展。

1、滚动窗口(Tumbling Windows)

滚动窗口分配器将每个元素分配给一个指定窗口大小的窗口。滚动窗口具有固定的大小,并且不会重叠

例如,如果指定了一个大小为5分钟的滚动窗口,则将评估当前窗口,并每五分钟启动一个新窗口,如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oKU0mX5F-1693240903176)(https://files.mdnice.com/user/3948/d96ec417-f9d4-4766-b54b-c5b7dd9e4045.svg)]
以下代码片段展示了如何使用滚动窗口:

input = ...  # 类型: DataStream

# 滚动事件时间窗口
input \
    .key_by(<key selector>) \
    .window(TumblingEventTimeWindows.of(Time.seconds(5))) \
    .<windowed transformation>(<window function>)

# 滚动处理时间窗口
input \
    .key_by(<key selector>) \
    .window(TumblingProcessingTimeWindows.of(Time.seconds(5))) \
    .<windowed transformation>(<window function>)

# 偏移 -8 小时的每日滚动事件时间窗口
input \
    .key_by(<key selector>) \
    .window(TumblingEventTimeWindows.of(Time.days(1), Time.hours(-8))) \
    .<windowed transformation>(<window function>)

时间间隔可以通过使用 Time.milliseconds(x)、Time.seconds(x)、Time.minutes(x) 等方法来指定。

如最后一个示例所示,滚动窗口分配器还可以使用一个可选的 offset 参数来改变窗口的对齐方式。例如,在没有偏移的情况下,每小时滚动窗口与epoch对齐,这意味着你会得到如 1:00:00.000 - 1:59:59.999、2:00:00.000 - 2:59:59.999 等窗口。如果你想更改这一点,可以提供一个偏移值。例如,使用 15 分钟的偏移,您会得到如 1:15:00.000 - 2:14:59.999、2:15:00.000 - 3:14:59.999 等窗口。偏移的一个重要用途是将窗口调整到 UTC-0 之外的时区。例如,在中国,你需要指定一个偏移量 Time.hours(-8)。

这些参数的使用使用户能够根据需求灵活地定义和调整窗口的大小、对齐和时区等属性。

2、滑动窗口(Sliding Windows)

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

例如,您可以有大小为 10 分钟的窗口,每隔 5 分钟滑动一次。通过这种方式,您可以每隔 5 分钟获得一个窗口,其中包含了过去 10 分钟内到达的事件,如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u6d0pOXK-1693240903176)(https://files.mdnice.com/user/3948/98a12aae-acc6-42ab-8e12-edebcee05a4a.svg)]
以下代码片段展示了如何使用滑动窗口。

input = ...  # type: DataStream

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

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

# sliding processing-time windows offset by -8 hours
input \
    .key_by(<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) 等方式进行指定。

正如上一个示例所示,滑动窗口分配器还可以使用可选的偏移参数来改变窗口的对齐方式。例如,如果不使用偏移,滑动窗口大小为 1 小时,滑动为 30 分钟的窗口将与epoch对齐,这意味着您将获得诸如 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)。

3、会话窗口(Session Windows)

会话窗口分配器将元素按活动会话分组。会话窗口不重叠,也没有固定的开始和结束时间,与滚动窗口和滑动窗口不同。相反,会话窗口在一定的时间内不接收元素时关闭,即发生不活动间隙时。会话窗口分配器可以配置为具有静态会话间隙或会话间隙提取函数,后者定义了不活动时期的持续时间。当此时间到期时,当前会话关闭,随后的元素将分配给新的会话窗口。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bwpgDV7S-1693240903176)(https://files.mdnice.com/user/3948/c43aae68-42c3-44f1-855a-a7a6fe12056e.svg)]
以下代码段显示了如何使用会话窗口。

input = ...  # type: DataStream

class MySessionWindowTimeGapExtractor(SessionWindowTimeGapExtractor):

    def extract(self, element: tuple) -> int:
        # determine and return session gap

# event-time session windows with static gap
input \
    .key_by(<key selector>) \
    .window(EventTimeSessionWindows.with_gap(Time.minutes(10))) \
    .<windowed transformation>(<window function>)

# event-time session windows with dynamic gap
input \
    .key_by(<key selector>) \
    .window(EventTimeSessionWindows.with_dynamic_gap(MySessionWindowTimeGapExtractor())) \
    .<windowed transformation>(<window function>)

# processing-time session windows with static gap
input \
    .key_by(<key selector>) \
    .window(ProcessingTimeSessionWindows.with_gap(Time.minutes(10))) \
    .<windowed transformation>(<window function>)

# processing-time session windows with dynamic gap
input \
    .key_by(<key selector>) \
    .window(DynamicProcessingTimeSessionWindows.with_dynamic_gap(MySessionWindowTimeGapExtractor())) \
    .<windowed transformation>(<window function>)
Static gaps can be specified by using o

静态间隙可以通过使用 Time.milliseconds(x)、Time.seconds(x)、Time.minutes(x) 等来指定。

动态间隙是通过实现 SessionWindowTimeGapExtractor 接口来指定的。

由于会话窗口没有固定的开始和结束,因此它们的评估方式与滚动窗口和滑动窗口不同。在内部,会话窗口运算符为每个到达的记录创建一个新窗口,并在它们彼此之间的距离小于定义的间隙时将窗口合并在一起。为了能够合并,会话窗口运算符需要一个合并触发器和一个合并窗口函数,例如 ReduceFunction、AggregateFunction 或 ProcessWindowFunction。

4、全局窗口(Global Windows)

全局窗口分配器将具有相同键的所有元素分配给同一个全局窗口。只有在你指定了自定义触发器时,这种窗口方案才有用。否则,不会执行任何计算,因为全局窗口没有自然的结束时间,我们无法在其中处理聚合元素。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-99xoIieg-1693240903177)(https://files.mdnice.com/user/3948/0346c759-ffd6-4269-945c-514907db11e5.svg)]

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

input = ...  # type: DataStream

input \
    .key_by(<key selector>) \
    .window(GlobalWindows.create()) \
    .<windowed transformation>(<window function>)

ReduceFunction

ReduceFunction(指定了如何将输入的两个元素合并以产生相同类型的输出元素。Flink 使用 ReduceFunction 来逐步聚合窗口中的元素。

可以像这样定义和使用 ReduceFunction:

# 示例对窗口中所有元素的元组的第二个字段进行求和。
input = ...  # type: DataStream

input \
    .key_by(<key selector>) \
    .window(<window assigner>) \
    .reduce(lambda v1, v2: (v1[0], v1[1] + v2[1]),
            output_type=Types.TUPLE([Types.STRING(), Types.LONG()]))

AggregateFunction

AggregateFunction(聚合函数)是 ReduceFunction 的一种通用版本,它具有三种类型:输入类型(IN)、累加器类型(ACC)和输出类型(OUT)

  • 输入类型是输入流中元素的类型,AggregateFunction 具有将一个输入元素添加到累加器的方法。
  • 该接口还具有创建初始累加器的方法,将两个累加器合并为一个累加器的方法,
  • 以及从累加器中提取输出(类型为 OUT)的方法。

与 ReduceFunction 一样,Flink 将在输入元素到达时逐步聚合窗口的输入元素。

可以像这样定义和使用 AggregateFunction:

class AverageAggregate(AggregateFunction):
 
    def create_accumulator(self) -> Tuple[int, int]:
        return 0, 0

    def add(self, value: Tuple[str, int], accumulator: Tuple[int, int]) -> Tuple[int, int]:
        return accumulator[0] + value[1], accumulator[1] + 1

    def get_result(self, accumulator: Tuple[int, int]) -> float:
        return accumulator[0] / accumulator[1]

    def merge(self, a: Tuple[int, int], b: Tuple[int, int]) -> Tuple[int, int]:
        return a[0] + b[0], a[1] + b[1]

input = ...  # type: DataStream
# 示例计算了窗口中元素的第二个字段的平均值。
input \
    .key_by(<key selector>) \
    .window(<window assigner>) \
    .aggregate(AverageAggregate(),
               accumulator_type=Types.TUPLE([Types.LONG(), Types.LONG()]),
               output_type=Types.DOUBLE())

ProcessWindowFunction

ProcessWindowFunction(处理窗口函数)获取一个包含窗口中所有元素的 Iterable,以及一个带有访问时间和状态信息的 Context 对象,使其能够比其他窗口函数提供更大的灵活性。这是以性能和资源消耗为代价的,因为元素无法逐步聚合,而是需要在内部缓冲,直到窗口被认为已准备好进行处理。

ProcessWindowFunction 的PyFlink源码如下所示:

class ProcessWindowFunction(Function, Generic[IN, OUT, KEY, W]):

    @abstractmethod
    def process(self,
                key: KEY,
                context: 'ProcessWindowFunction.Context',
                elements: Iterable[IN]) -> Iterable[OUT]:
        """
        Evaluates the window and outputs none or several elements.
    
        :param key: The key for which this window is evaluated.
        :param context: The context in which the window is being evaluated.
        :param elements: The elements in the window being evaluated.
        :return: The iterable object which produces the elements to emit.
        """
        pass

    @abstractmethod
    def clear(self, context: 'ProcessWindowFunction.Context') -> None:
        """
        Deletes any state in the :class:`Context` when the Window expires (the watermark passes its
        max_timestamp + allowed_lateness).
    
        :param context: The context to which the window is being evaluated.
        """
        pass

    class Context(ABC, Generic[W2]):
        """
        The context holding window metadata.
        """
    
        @abstractmethod
        def window(self) -> W2:
            """
            :return: The window that is being evaluated.
            """
            pass
    
        @abstractmethod
        def current_processing_time(self) -> int:
            """
            :return: The current processing time.
            """
            pass
    
        @abstractmethod
        def current_watermark(self) -> int:
            """
            :return: The current event-time watermark.
            """
            pass
    
        @abstractmethod
        def window_state(self) -> KeyedStateStore:
            """
            State accessor for per-key and per-window state.
      
            .. note::
                If you use per-window state you have to ensure that you clean it up by implementing
                :func:`~ProcessWindowFunction.clear`.
      
            :return: The :class:`KeyedStateStore` used to access per-key and per-window states.
            """
            pass
    
        @abstractmethod
        def global_state(self) -> KeyedStateStore:
            """
            State accessor for per-key global state.
            """
            pass

关键参数是通过 keyBy() 调用指定的 KeySelector 提取的键。在元组索引键或字符串字段引用的情况下,这个键的类型总是 Tuple,必须手动将其转换为精确大小的元组以提取键字段。

可以像这样定义和使用 ProcessWindowFunction:

# 示例展示了一个计算窗口中元素数量的 ProcessWindowFunction。此外,窗口函数还将有关窗口的信息添加到输出中。
input = ...  # type: DataStream

input \
    .key_by(lambda v: v[0]) \
    .window(TumblingEventTimeWindows.of(Time.minutes(5))) \
    .process(MyProcessWindowFunction())

# ...

class MyProcessWindowFunction(ProcessWindowFunction):

    def process(self, key: str, context: ProcessWindowFunction.Context,
                elements: Iterable[Tuple[str, int]]) -> Iterable[str]:
        count = 0
        for _ in elements:
            count += 1
        yield "Window: {} count: {}".format(context.window(), count)

请注意,对于像计数这样的简单聚合,使用 ProcessWindowFunction 效率相当低下。下一节将展示如何将 ReduceFunctionAggregateFunctionProcessWindowFunction 结合使用,以实现增量聚合和 ProcessWindowFunction 的附加信息。

带增量聚合的ProcessWindowFunction

一个 ProcessWindowFunction 可以与 ReduceFunctionAggregateFunction 结合使用,以在窗口中的元素到达时进行增量聚合。当窗口关闭时,ProcessWindowFunction 将获得聚合结果。这使其能够在具有 ProcessWindowFunction 的附加窗口元信息的情况下逐步计算窗口。

你还可以使用传统的 WindowFunction,而不是 ProcessWindowFunction 来进行增量窗口聚合。

1、使用 ReduceFunction 进行增量窗口聚合

以下示例展示了如何将增量的 ReduceFunctionProcessWindowFunction 结合使用,以返回窗口中最小的事件及窗口的开始时间。

input = ...  # type: DataStream

input \
    .key_by(<key selector>) \
    .window(<window assigner>) \
    .reduce(lambda r1, r2: r2 if r1.value > r2.value else r1,
            window_function=MyProcessWindowFunction(),
            output_type=Types.TUPLE([Types.STRING(), Types.LONG()]))

# Function definition

class MyProcessWindowFunction(ProcessWindowFunction):

    def process(self, key: str, context: ProcessWindowFunction.Context,
                min_readings: Iterable[SensorReading]) -> Iterable[Tuple[int, SensorReading]]:
        min = next(iter(min_readings))
        yield context.window().start, min

2、使用 AggregateFunction 进行增量窗口聚合

以下示例展示了如何将增量的 AggregateFunctionProcessWindowFunction 结合使用,以计算平均值,并在输出中同时包含键、窗口和平均值。

input = ...  # type: DataStream

input
    .key_by(<key selector>) \
    .window(<window assigner>) \
    .aggregate(AverageAggregate(),
               window_function=MyProcessWindowFunction(),
               accumulator_type=Types.TUPLE([Types.LONG(), Types.LONG()]),
               output_type=Types.TUPLE([Types.STRING(), Types.DOUBLE()]))

# Function definitions

class AverageAggregate(AggregateFunction):
    """
    The accumulator is used to keep a running sum and a count. The :func:`get_result` method
    computes the average.
    """

    def create_accumulator(self) -> Tuple[int, int]:
        return 0, 0

    def add(self, value: Tuple[str, int], accumulator: Tuple[int, int]) -> Tuple[int, int]:
        return accumulator[0] + value[1], accumulator[1] + 1

    def get_result(self, accumulator: Tuple[int, int]) -> float:
        return accumulator[0] / accumulator[1]

    def merge(self, a: Tuple[int, int], b: Tuple[int, int]) -> Tuple[int, int]:
        return a[0] + b[0], a[1] + b[1]

class MyProcessWindowFunction(ProcessWindowFunction):

    def process(self, key: str, context: ProcessWindowFunction.Context,
                averages: Iterable[float]) -> Iterable[Tuple[str, float]]:
        average = next(iter(averages))
        yield key, average

3、在 ProcessWindowFunction 中使用窗口级状态

除了访问键控状态(就像任何富函数一样),ProcessWindowFunction 还可以使用作用于函数当前处理的窗口的键控状态。在这个背景下,理解“窗口”是指的哪个窗口很重要。涉及不同的“窗口”:

  1. 在指定窗口操作时定义的窗口:这可能是 1 小时的滚动窗口或滑动窗口,每个窗口滑动 1 小时。

  2. 给定键的实际定义窗口实例:这可能是用户ID为 xyz 的从 12:00 到 13:00 的时间窗口。这是基于窗口定义的,根据作业当前处理的键的数量以及事件落入的时间槽来决定窗口数量。

窗口级状态与上述两者中的后者相关。这意味着,如果我们为 1000 个不同的键处理事件,并且所有这些事件都在当前落入 [12:00, 13:00) 时间窗口,那么将会有 1000 个窗口实例,每个实例都有自己的键控窗口级状态。

Context 对象上有两种方法,可以让 process() 调用接收到这两种类型的状态:

  • globalState():允许访问未作用于窗口的键控状态。
  • windowState():允许访问也作用于窗口的键控状态。

如果预期同一窗口会多次触发,这个特性就会很有帮助。比如当数据迟到时会有迟到触发,或者当有自定义触发器进行推测性早期触发时。在这种情况下,您可以将有关先前触发或触发次数的信息存储在窗口级状态中。

在使用窗口状态时,清理状态也是很重要的,当一个窗口被清除时应该在 clear() 方法中进行清理。

4、WindowFunction(传统版本)

在某些可以使用 ProcessWindowFunction 的地方,你也可以使用 WindowFunction。这是 ProcessWindowFunction 的较旧版本,提供较少的上下文信息,并且没有一些先进的功能,比如窗口级键控状态。这个接口在未来的某个时间点将会被弃用。

WindowFunction 的PyFlink源码如下所示:

class WindowFunction(Function, Generic[IN, OUT, KEY, W]):

    @abstractmethod
    def apply(self, key: KEY, window: W, inputs: Iterable[IN]) -> Iterable[OUT]:
        """
        Evaluates the window and outputs none or several elements.
    
        :param key: The key for which this window is evaluated.
        :param window: The window that is being evaluated.
        :param inputs: The elements in the window being evaluated.
        """
        pass

它可以这样使用:

input = ...  # type: DataStream

input \
    .key_by(<key selector>) \
    .window(<window assigner>) \
    .apply(MyWindowFunction())

触发器(Triggers)

触发器决定窗口(由窗口分配器形成的)何时准备好由窗口函数处理。每个 WindowAssigner 都附带一个默认触发器。如果默认触发器不符合您的需求,您可以使用 trigger(...) 来指定自定义触发器。

触发器接口有五种方法,允许触发器对不同的事件做出反应:

  1. onElement() 方法会在将元素添加到窗口时调用。
  2. onEventTime() 方法在注册的事件时间定时器触发时调用。
  3. onProcessingTime() 方法在注册的处理时间定时器触发时调用。
  4. onMerge() 方法适用于具有状态的触发器,在相应窗口合并时合并两个触发器的状态,例如在使用会话窗口时。
  5. 最后,clear() 方法在相应窗口被移除时执行任何所需的操作。

关于上述方法需要注意的两件事情是:

  1. 前三种方法通过返回 TriggerResult 来决定如何在其调用事件上执行操作。操作可以是以下之一:

    • CONTINUE:不执行任何操作,
    • FIRE:触发计算,
    • PURGE:清除窗口中的元素,以及
    • FIRE_AND_PURGE:触发计算,然后清除窗口中的元素。
  2. 这些方法中的任何一个都可以用于注册将来的处理或事件时间定时器的操作。

触发计算与清除元素

一旦触发器确定窗口已准备好进行处理,它将触发计算,即返回 FIREFIRE_AND_PURGE。这是窗口操作符发出当前窗口结果的信号。对于带有 ProcessWindowFunction 的窗口,所有元素都会传递给 ProcessWindowFunction(可能在传递给收回者之后)。带有 ReduceFunctionAggregateFunction 的窗口只需发出其及早聚合的结果。

当触发器触发计算时,它可以选择 FIREFIRE_AND_PURGEFIRE 会保留窗口的内容,而 FIRE_AND_PURGE 则会删除其内容。默认情况下,预先实现的触发器只会触发计算,而不清除窗口状态。

清除会简单地删除窗口的内容,并保留窗口的任何潜在元信息以及任何触发器状态。

WindowAssigners 的默认触发器

WindowAssigner 的默认触发器适用于许多用例。例如,所有事件时间窗口分配器都有一个 EventTimeTrigger 作为默认触发器。此触发器会在水印通过窗口的结束时间时触发。

全局窗口的默认触发器是 NeverTrigger,它永远不会触发。因此,在使用全局窗口时,您总是需要定义一个自定义触发器。

通过使用 trigger() 来指定触发器,将覆盖 WindowAssigner 的默认触发器。例如,如果为 TumblingEventTimeWindows 指定了 CountTrigger,那么窗口触发将不再基于时间的进度触发,而只会基于计数触发。目前,如果您想要根据时间和计数两者来触发,那么您必须编写自己的自定义触发器。

内置和自定义触发器

Flink 提供了一些内置的触发器。

  1. (前面已提到的)EventTimeTrigger 基于事件时间的进度触发,由水印来衡量。
  2. ProcessingTimeTrigger 基于处理时间触发。
  3. CountTrigger 一旦窗口中的元素数量超过给定的限制,就会触发。
  4. PurgingTrigger 以另一个触发器作为参数,将其转换为清除触发器。

如果需要实现自定义触发器,应该查看抽象的 Trigger 类。请注意,API 仍在不断发展,可能会在未来的 Flink 版本中发生变化。

剔除器(Evictors)

Flink 的窗口模型允许在窗口分配器和触发器之外,通过使用 evictor(...) 方法来指定一个可选的剔除器。剔除器具有在触发器触发后、窗口函数应用之前和/或之后从窗口中移除元素的能力。为此,剔除器接口包含两个方法:

/**
 * 在窗口函数之前可选择进行剔除。被调用。
 *
 * @param elements 窗格中当前的元素。
 * @param size 窗格中当前的元素数量。
 * @param window {@link Window}
 * @param evictorContext 剔除器的上下文
 */
void evictBefore(Iterable<TimestampedValue<T>> elements, int size, W window, EvictorContext evictorContext);

/**
 * 在窗口函数之后可选择进行剔除。被调用。
 *
 * @param elements 窗格中当前的元素。
 * @param size 窗格中当前的元素数量。
 * @param window {@link Window}
 * @param evictorContext 剔除器的上下文
 */
void evictAfter(Iterable<TimestampedValue<T>> elements, int size, W window, EvictorContext evictorContext);
  • evictBefore() 包含在应用窗口函数之前要执行的剔除逻辑。
  • evictAfter() 包含在应用窗口函数之后要执行的剔除逻辑。

在应用窗口函数之前剔除的元素将不会被窗口函数处理。

Flink 预先提供了三种实现的剔除器:

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

默认情况下,所有预先实现的剔除器都会在窗口函数之前应用其逻辑。

通过指定剔除器,将阻止任何预先聚合,因为在应用计算之前,窗口中的所有元素都必须传递给剔除器。这意味着具有剔除器的窗口将创建更多的状态。

注意:剔除器在 Python DataStream API 中仍不受支持。

Flink 对窗口内元素的顺序没有提供任何保证。这意味着尽管剔除器可能从窗口的开头剔除元素,但这些元素不一定是最先到达或最后到达的元素。

允许的延迟时间(Allowed Lateness)

在使用事件时间窗口进行工作时,可能会出现元素迟到的情况,即 Flink 用于跟踪事件时间进度的水印已经超过了元素所属窗口的结束时间戳。

默认情况下,当水印已经超过窗口结束时间时,迟到的元素将会被丢弃。然而,Flink 允许为窗口操作符指定最大允许的延迟时间。允许的延迟时间指定了元素在被丢弃之前可以迟到多长时间,其默认值为 0。在水印超过窗口结束时间但在水印超过窗口结束时间加上允许的延迟时间之前到达的元素仍然会被添加到窗口中。根据所使用的触发器,一个迟到但未被丢弃的元素可能会导致窗口再次触发。这适用于 EventTimeTrigger。

为了使这个机制生效,Flink 会保留窗口的状态,直到它们的允许延迟时间过期。一旦发生这种情况,Flink 将删除窗口并清除其状态,这也在“窗口生命周期”部分中有描述。

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

可以像这样指定允许的延迟时间:

late_output_tag = OutputTag("late-data", type_info)

input = ...  # type: DataStream

result = input \
    .key_by(<key selector>) \
    .window(<window assigner>) \
    .allowed_lateness(<time>) \
    .side_output_late_data(late_output_tag) \
    .<windowed transformation>(<window function>)

late_stream = result.get_side_output(late_output_tag)

迟到元素的注意事项

在指定大于 0 的允许的延迟时间时,窗口连同其内容会在水印超过窗口结束时间后保留。在这些情况下,当迟到但未被丢弃的元素到达时,它可能会触发窗口的另一个触发。这些触发称为迟到触发,因为它们是由迟到事件触发的,与主要触发相对应,主要触发是窗口的第一个触发。对于会话窗口,迟到触发还可能导致窗口合并,因为它们可能“连接”了两个预先存在但尚未合并的窗口之间的间隙。

由迟到触发发出的元素应被视为前一个计算的更新结果,即你的数据流将包含同一计算的多个结果。根据你的应用程序,你需要考虑这些重复的结果或对其进行去重处理。

处理窗口结果

窗口操作的结果还是一个 DataStream,结果元素中不会保留有关窗口操作的信息,因此如果想要保留有关窗口的元信息,必须在你的 ProcessWindowFunction 中手动将该信息编码到结果元素中。结果元素上设置的唯一相关信息是元素的时间戳。该时间戳被设置为已处理窗口的最大允许时间戳,即结束时间戳 - 1,因为窗口结束时间戳是排他的。

请注意,这对事件时间窗口和处理时间窗口都适用。也就是说,在窗口操作之后,元素始终具有时间戳,但这可以是事件时间时间戳或处理时间时间戳。对于处理时间窗口,这没有特殊的影响,但对于事件时间窗口,这与水印如何与窗口交互有关,使得可以使用相同的窗口大小进行连续的窗口操作。

1、水印与窗口的交互方式

当水印到达窗口算子时,会触发两件事情:

  1. 水印会触发所有窗口的计算,其中最大时间戳(即结束时间戳 - 1)小于新的水印
  2. 水印会原样转发给下游操作

直观地说,水印会“清理”掉在下游操作中被视为迟到的窗口,一旦它们接收到该水印。

2、连续的窗口操作

如前所述,窗口结果的时间戳计算方式以及水印如何与窗口交互,使得可以将连续的窗口操作串联在一起。当你希望执行两个连续的窗口操作,并且希望使用不同的键,但仍希望来自同一上游窗口的元素最终出现在同一下游窗口中时,这可能非常有用。考虑以下示例:

input = ...  # type: DataStream

results_per_key = input \
    .key_by(<key selector>) \
    .window(TumblingEventTimeWindows.of(Time.seconds(5))) \
    .reduce(Summer())

global_results = results_per_key \
    .window_all(TumblingProcessingTimeWindows.of(Time.seconds(5))) \
    .process(TopKWindowFunction())

在此示例中,第一个操作的时间窗口 [0, 5) 的结果也将出现在后续窗口化操作中的时间窗口 [0, 5) 中。这使得可以在第一个操作中按键计算总和,然后在第二个操作中在相同窗口内计算前 k 个元素。

有关状态大小的考虑事项

窗口可以在很长的时间范围内进行定义(如几天、几周或几个月),因此可能会积累非常大的状态。在估算窗口计算的存储需求时,需要记住一些规则:

  1. Flink 会为每个属于窗口的元素创建一个副本。基于此,滚动窗口会保留每个元素的一个副本(除非它被迟到丢弃,一个元素属于且仅属于一个窗口)。相比之下,滑动窗口会创建多个元素的副本,如在“窗口分配器”部分中所述。因此,大小为 1 天、滑动大小为 1 秒的滑动窗口可能不是一个好主意。

  2. ReduceFunction 和 AggregateFunction 可以显著减少存储需求,因为它们会立即对元素进行聚合,并仅存储每个窗口的一个值。相比之下,仅使用 ProcessWindowFunction 需要累积所有元素。

  3. 使用剔除器会阻止任何预先聚合,因为窗口的所有元素都必须在应用计算之前通过剔除器。

PyFlink 入门资料关注公众号
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值