Bootstrap

Flink事件时间和Watermark

前言

Flink 提供了三种时间语义,分别是 事件时间、摄入时间和处理时间。其中,只有事件时间需要配合 Watermark 使用,这是为什么呢?Watermark 又是个什么东西呢?

诞生背景

时间窗口计算模型的三大属性,分别是:窗口的大小、计算的频次、窗口数据的处理逻辑。其中,计算的频次 Flink 要如何确定呢?

举个例子,现在要每隔1分钟计算某个商品过去1分钟的销量。

这是一个典型的滚动窗口计算场景,窗口大小和计算频次都是1分钟。在摄入时间或处理时间语义下,Flink 很容易处理,因为这俩时间语义都是采用的 subTask 本地机器时间来分配窗口的,本地机器时间有个特点,那就是单调递增,对于窗口算子来说,数据都是顺序到达的,subTask 无须处理乱序数据。窗口的分配和关闭计算全凭本地机器时间推动,Flink 只需要启动一个单独的线程,在窗口关闭时间到达的时候触发计算即可。

可是,在事件时间语义下,因为数据是乱序的,窗口算子必须要考虑如何处理“迟到”的数据,这件事就变得复杂起来了。

举个例子,窗口算子 subTask 分别接收到了下单时间为 10:00:20、10:00:40 的两条订单数据,于是将其分配到 [10:00,10:01] 窗口并缓存起来,接着又收到了下单事件为 10:01:30 的订单数据,此时,Flink 可以将 [10:00,10:01] 窗口关闭并触发计算吗???

此时,Flink 面临两个选择:1、关闭窗口并计算,数据处理的效率和实时性更好,但是面对后续到达的迟到数据,只能选择丢弃,结果的准确性会降低;2、不关闭窗口,继续等待迟到数据,结果的准确性会更高,但是效率和实时性会降低。

Flink 面对两难的抉择,其实不知道如何选择,索性把这个选择权交给开发者。于是,Watermark 诞生了,它其实就是 事件时间时钟,发送 Watermark 其实就是推进下游算子 subTask 的事件时间时钟,告诉下游算子,Watermark 之前的数据已经全部到达,对应的窗口可以关闭并触发计算了。

如果业务对实时性要求更高,那么我们就可以把 Watermark 设置的激进些;如果业务对结果准确性更高,那么就可以把 Watermark 设置的保守些。

Watermark 是什么

Watermark 是一个单位为毫秒的Unix时间戳,用于维护事件时间时钟。如下示例,代表创建了一个时间为2024-01-01 00:00:00的Watermark:

Watermark watermark = new Watermark(1704038400L);

需要注意的是,Watermark 的生成规则应该遵循单调递增原则,就像时间一样不能回退,否则可能会导致数据结果不正确。

在 Flink 中,在所有算子间传输的数据都被抽象成了 StreamElement 类。普通数据被封装成子类 StreamRecord,Watermark 是一种可以在算子间传输的特殊数据,它只携带一个时间戳,所以被封装成了子类org.apache.flink.streaming.api.watermark.Watermark

public class Watermark extends StreamElement {
    public static final Watermark MAX_WATERMARK = new Watermark(Long.MAX_VALUE);
    public static final Watermark UNINITIALIZED = new Watermark(Long.MIN_VALUE);
    protected final long timestamp;
}

Watermark生成策略

Watermark 生成策略被 Flink 抽象成了 WatermarkStrategy 接口

public interface WatermarkStrategy<T> extends TimestampAssignerSupplier<T>, WatermarkGeneratorSupplier<T> {
    
    WatermarkGenerator<T> createWatermarkGenerator(WatermarkGeneratorSupplier.Context var1);

    default TimestampAssigner<T> createTimestampAssigner(TimestampAssignerSupplier.Context context) {
        return new RecordTimestampAssigner();
    }
}

它包含两个组件,WatermarkGenerator 用于生成 Watermark,TimestampAssigner 用于提取数据中的事件时间。

WatermarkGenerator 接口中,onEvent() 会在每次处理数据时调用,适用于数据本身携带 Watermark 的场景;onPeriodicEmit() 会按照一定的频率周期性的调用,适用于数据本身没有 Watermark 标志的场景。

public interface WatermarkGenerator<T> {
    void onEvent(T var1, long var2, WatermarkOutput var4);

    void onPeriodicEmit(WatermarkOutput var1);
}

TimestampAssigner 默认会使用子类 RecordTimestampAssigner,它直接使用 Source 算子采集数据时指定的时间戳,即通过以下方式采集数据时使用:

public void run(SourceContext<String> sourceContext) throws Exception {
    sourceContext.collectWithTimestamp("element", System.currentTimeMillis());
}

如果 Source 算子没有指定时间戳,就需要我们实现 TimestampAssigner 接口自己从数据中提取时间戳了。

内置的WatermarkGenerator

Flink 内置了一些常用的Watermark生成策略,如果满足业务需求,可以拿来即用。

1、BoundedOutOfOrdernessWatermarks

你能接受的最大乱序程度是多少?假如说,你认为数据延迟最多不超过10秒,即到达 10:00:10时,10点之前的数据肯定全部到达,那么就可以直接使用 BoundedOutOfOrdernessWatermarks。

public class BoundedOutOfOrdernessWatermarks<T> implements WatermarkGenerator<T> {
    private long maxTimestamp;
    private final long outOfOrdernessMillis;

    // 最大乱序程度 时间
    public BoundedOutOfOrdernessWatermarks(Duration maxOutOfOrderness) {
        Preconditions.checkNotNull(maxOutOfOrderness, "maxOutOfOrderness");
        Preconditions.checkArgument(!maxOutOfOrderness.isNegative(), "maxOutOfOrderness cannot be negative");
        this.outOfOrdernessMillis = maxOutOfOrderness.toMillis();
        this.maxTimestamp = Long.MIN_VALUE + this.outOfOrdernessMillis + 1L;
    }

    public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
        this.maxTimestamp = Math.max(this.maxTimestamp, eventTimestamp);
    }

    public void onPeriodicEmit(WatermarkOutput output) {
        output.emitWatermark(new Watermark(this.maxTimestamp - this.outOfOrdernessMillis - 1L));
    }
}

实例化 BoundedOutOfOrdernessWatermarks 需要传入一个你能接受的最大乱序程度时间,Watermark 会周期性的发送,但是发送的时间戳会比当前最大的事件时间减去一个 maxOutOfOrderness。

2、AscendingTimestampsWatermarks

如果你确定数据流中永远也不会有迟到数据,即你处理的是一条有序数据流,那么直接使用 AscendingTimestampsWatermarks,它继承自 BoundedOutOfOrdernessWatermarks,只不过不接收乱序。

public class AscendingTimestampsWatermarks<T> extends BoundedOutOfOrdernessWatermarks<T> {
    public AscendingTimestampsWatermarks() {
        super(Duration.ofMillis(0L));
    }
}

自定义WatermarkGenerator

内置 WatermarkGenerator 不满足需求时,也可以自定义Watermark生成策略。

如果数据本身携带 Watermark 标志,那么可以重写 onEvent() 来发送 Watermark。

public class MyWatermarkGenerator implements WatermarkGenerator<Tuple2<String, Long>> {

    @Override
    public void onEvent(Tuple2<String, Long> tuple2, long l, WatermarkOutput watermarkOutput) {
        // 假设数据格式中 字符串W开头是Watermark,其中f1是时间戳
        if (tuple2.f0.startsWith("W")) {
            watermarkOutput.emitWatermark(new Watermark(tuple2.f1));
        }
    }

    @Override
    public void onPeriodicEmit(WatermarkOutput watermarkOutput) {
        // NOOP
    }
}

如果数据本身没有Watermark标志,我们也可以直接根据系统时钟,周期性的发送 Watermark。先通过 StreamExecutionEnvironment 设置周期性发送 Watermark 的频率

StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment();
environment.getConfig().setAutoWatermarkInterval(500L);

再重写 onPeriodicEmit()

public class MyWatermarkGenerator implements WatermarkGenerator<Tuple2<String, Long>> {

    @Override
    public void onEvent(Tuple2<String, Long> tuple2, long l, WatermarkOutput watermarkOutput) {
        // NOOP
    }

    @Override
    public void onPeriodicEmit(WatermarkOutput watermarkOutput) {
        watermarkOutput.emitWatermark(new Watermark(System.currentTimeMillis()));
    }
}

迟到数据的处理

针对迟到数据的处理,Flink 提供了三种方案。

1、推送延迟的 Watermark

通过设置最大乱序容忍度,让 Flink 发送 Watermark 时主动把最大事件时间减去一个延迟时间,为乱序数据争取更多的时间进入窗口。

WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(5));

2、窗口延迟关闭

如果延迟后的 Watermark 还是没能等到迟到的数据,我们可以用第二招:窗口延迟关闭。

Watermark 到了窗口的关闭时间,先触发计算,但是不关闭窗口,窗口内缓存的数据会保存一段时间,这段时间内如果有迟到的数据,Flink 会重新计算。直到 Watermark 超过了 窗口关闭时间+延迟时间,窗口才会真的关闭。注意,延迟的时间不宜过长,会增加 Flink 作业的负担。

.windowAll(TumblingEventTimeWindows.of(Duration.ofSeconds(10L)))
.allowedLateness(Duration.ofSeconds(3L))

3、迟到数据输出到另外一个数据流

如果延迟的窗口还是没能等到迟到的数据,Flink 还给我们准备了最后一招。

通过Flink提供的侧输出流能力,将迟到数据输出到另外一个数据流里,迟到数据流再对计算结果做一定的修正。

.windowAll(TumblingEventTimeWindows.of(Duration.ofSeconds(10L)))
.allowedLateness(Duration.ofSeconds(3L))
.sideOutputLateData(new OutputTag<>("late-data"))

尾巴

Flink 在事件时间语义下需要搭配 Watermark 使用,Watermark 是一种特殊的数据类型,它可以在算子间传输。它本身就是一个时间戳,本质上是一个衡量事件时间进度的标记,subTask 收到 Watermark 会认为早于该时间戳的数据都收到了,相应的窗口可以关闭并触发计算了。

Watermark 是为了平衡数据处理的时效性和结果准确性的一个手段,如果业务对时效性敏感,Watermark就可以设置的激进些;如果业务对结果准确性更敏感,Watermark 就可以设置的保守些。另外需要注意,Watermark 生成要遵循单调递增的原则,不能发生时钟回退。Watermark 生成也不宜太频繁,以免影响 Flink 作业的性能。

;