3. Watermark生成策略:周期性生成器 vs 标点式生成器

Watermark 怎么生成?说白了就两种路子:一种是每隔几秒看一眼,另一种是每来一条数据就判断一下。我刚开始接触 Flink 时,总觉得这两种方式差不多,直到在生产环境踩了坑,才发现选错了策略,后果可能很严重。

这一节,我们就来拆解这两种生成策略,以及 Flink 内置的那个最常用的 BoundedOutOfOrderness 策略。

3.1 周期性生成器(Periodic)

这是最常用的方式。它的核心逻辑是:系统会周期性地发射一个 Watermark。这个周期由 ExecutionConfig.setAutoWatermarkInterval() 控制,默认是 200 毫秒。

你想想看,如果数据流很平稳,乱序程度也固定,周期性生成器是最省心的。它不关心每一条数据,只在每个周期结束时,根据当前已收到的最大事件时间,减去一个固定的延迟量,然后发射 Watermark。

核心公式:

Watermark = 当前观察到的最大事件时间 - 最大允许乱序时间

举个例子。假设你设置最大乱序时间为 5 秒,周期为 1 秒。那么每过 1 秒,Flink 就会检查一下当前所有分区中最大的事件时间戳,减去 5 秒,然后广播出去。

我个人习惯用这种方式处理大部分业务场景。它开销小,实现简单,而且足够稳定。

3.1.1 实现方式

你需要实现 WatermarkGenerator 接口中的 onPeriodicEmit() 方法。Flink 会周期性地调用这个方法。

// 伪代码示例:周期性生成器
public class MyPeriodicGenerator implements WatermarkGenerator<MyEvent> {
    private long maxTimestamp;
    private long maxOutOfOrderness = 5000; // 5秒

    @Override
    public void onEvent(MyEvent event, long eventTimestamp, WatermarkOutput output) {
        // 每次来事件,更新最大时间戳
        maxTimestamp = Math.max(maxTimestamp, eventTimestamp);
    }

    @Override
    public void onPeriodicEmit(WatermarkOutput output) {
        // 周期性地发射 Watermark
        output.emitWatermark(new Watermark(maxTimestamp - maxOutOfOrderness));
    }
}

避坑指南: 我曾经遇到过一个场景,数据源偶尔会断流几分钟。周期性生成器在断流期间,仍然会基于最后一条数据的时间戳发射 Watermark。这会导致下游窗口被提前触发,丢数据。解决办法是:在 onPeriodicEmit() 里加一个判断,如果长时间没有新数据,就暂停发射 Watermark。

3.2 标点式生成器(Punctuated)

标点式生成器就不一样了。它每处理一条数据,都会判断是否要发射 Watermark。也就是说,Watermark 的生成是事件驱动的,而不是时间驱动的。

什么时候用这个?我记得有一次做金融交易风控,数据里本身就携带了水位标记字段。比如某条数据里有个 isWatermark = true 的标记,或者事件类型是 END_OF_BATCH。这时候,标点式生成器就派上用场了。

3.2.1 实现方式

你需要实现 onEvent() 方法,并在里面直接决定是否发射 Watermark。

// 伪代码示例:标点式生成器
public class MyPunctuatedGenerator implements WatermarkGenerator<MyEvent> {
    @Override
    public void onEvent(MyEvent event, long eventTimestamp, WatermarkOutput output) {
        // 如果事件本身携带了水位标记
        if (event.isWatermark()) {
            output.emitWatermark(new Watermark(eventTimestamp));
        }
    }

    @Override
    public void onPeriodicEmit(WatermarkOutput output) {
        // 标点式生成器,这个方法通常什么都不做
    }
}

注意: 标点式生成器如果使用不当,可能会产生大量的 Watermark,给系统带来压力。每来一条数据都发射 Watermark,在高峰期可能每秒产生数万个 Watermark 事件。这会增加网络和状态后端的负担。所以,除非业务有明确需求,否则我建议优先考虑周期性生成器。

3.3 两种策略对比

为了让你看得更清楚,我整理了一张对比表:

对比维度 周期性生成器 标点式生成器
触发方式 基于时间周期(如每200ms) 基于事件特征(如特定字段)
生成频率 固定频率,可控 不固定,取决于数据流
适用场景 通用场景,乱序程度稳定 数据自带水位标记,或需要精确控制
实现复杂度 低 中
系统开销 低 高(可能产生大量Watermark)

3.4 内置的 BoundedOutOfOrderness 策略

Flink 给我们提供了一个现成的周期性生成器实现:BoundedOutOfOrdernessTimestampExtractor(在 Flink 1.12 之后,推荐使用 WatermarkStrategy.forBoundedOutOfOrderness())。

这个策略说白了就是:假设数据最多乱序 N 秒。你只需要告诉 Flink 这个 N 是多少,剩下的它帮你搞定。

3.4.1 怎么用?

// Flink 1.12+ 推荐用法
DataStream<MyEvent> stream = env
    .addSource(new MySource())
    .assignTimestampsAndWatermarks(
        WatermarkStrategy
            .<MyEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
            .withTimestampAssigner((event, timestamp) -> event.getEventTime())
    );

就这么简单。你传一个 Duration.ofSeconds(5),Flink 就知道:允许数据最多乱序 5 秒。它内部实现的就是我们刚才讲的周期性生成器逻辑。

3.4.2 内部原理

它的实现其实不复杂。我拆解给你看:

  1. 维护一个 currentMaxTimestamp:每来一条数据,就更新这个值。
  2. 周期性发射:每 200ms(默认),发射一个 currentMaxTimestamp - maxOutOfOrderness 的 Watermark。
  3. 多分区处理:如果有多个输入分区,Flink 会取所有分区中 最小的 Watermark 作为全局 Watermark。

关键点: 这个策略假设数据是“有界乱序”的。也就是说,你设置的 5 秒,意味着你相信不会有任何一条数据比当前最大时间戳晚超过 5 秒。如果真的有数据晚了 10 秒,那它就会被当作迟到数据处理(后面章节会讲迟到数据的处理)。

3.5 核心逻辑流程图

下面这张图,展示了周期性生成器和标点式生成器的核心区别:

Watermark 生成策略核心流程对比 周期性生成器 每 200ms 触发一次 检查所有分区最大时间戳 发射 Watermark maxTimestamp - delay 标点式生成器 每条数据到达时触发 判断是否携带水位标记 是/否 发射 Watermark 使用事件自带时间戳 核心区别:周期性生成器由时间驱动,标点式生成器由事件驱动

3.6 如何选择?

我个人的经验是:

  • 90% 的场景用 forBoundedOutOfOrderness:它简单、可靠、性能好。你只需要根据业务容忍度,设置一个合理的乱序时间就行。
  • 数据自带水位标记时用标点式:比如日志系统里,每条日志都带一个 batchId,当 batchId 变化时,就代表上一个批次结束了。这时候标点式生成器最合适。
  • 对延迟极度敏感时考虑标点式:如果你希望 Watermark 能立即反映最新数据的时间戳,而不是等几百毫秒的周期,标点式可以做到。

一个小建议: 如果你不确定选哪个,先用 forBoundedOutOfOrderness。它是最安全的选择。等业务跑起来后,通过监控 Watermark 的生成频率和窗口触发延迟,再决定是否需要优化。