水位线生成

1、水位线生成原则

完美的水位线是“绝对正确”的,也就是一个水位线一旦出现,就表示这个时间之前的数据已经全部到齐、之后再也不会出现了。而完美的东西总是可望不可即,我们只能尽量去保证水位线的正确。如果对结果正确性要求很高、想要让窗口收集到所有数据,那么只能等了。由于网络传输的延迟不确定,为了获取所有迟到数据,我们只能等待更长的时间。作为Flink使用人员,我们当然不会傻傻地一直等下去。那到底等多久呢?这就需要对相关领域有一定的了解了。比如,如果我们知道当前业务中事件的迟到时间不会超过5秒,那就可以将水位线的时间戳设为当前已有数据的最大时间戳减去5秒,相当于设置了5秒的延迟等待。更多的情况下,我们或许没那么大把握。毕竟未来是没有人能说得准的,我们怎么能确信未来不会出现一个超级迟到数据呢?所以另一种做法是,可以单独创建一个Flink作业来监控事件流,建立概率分布或者机器学习模型,学习事件的迟到规律。得到分布规律之后,就可以选择置信区间来确定延迟,作为水位线的生成策略了。例如,如果得到数据的迟到时间服从μ=1,σ=1的正态分布,那么设置水位线延迟为3秒,就可以保证至少97.7%的数据可以正确处理。如果我们希望计算结果能更加准确,那可以将水位线的延迟设置得更高一些,等待的时间越长,自然也就越不容易漏掉数据。不过这样做的代价是处理的实时性降低了,我们可能为极少数的迟到数据增加了很多不必要的延迟。如果我们希望处理得更快、实时性更强,那么可以将水位线延迟设得低一些。这种情况下,可能很多迟到数据会在水位线之后才到达,就会导致窗口遗漏数据,计算结果不准确。对于这些“漏网之鱼”,Flink另外提供了窗口处理迟到数据的方法,我们会在后面介绍。当然,如果我们对准确性完全不考虑、一味地追求处理速度,可以直接使用处理时间语义,这在理论上可以得到最低的延迟。所以Flink中的水位线,其实是流处理中对低延迟和结果正确性的一个权衡机制,而且把控制的权力交给了程序员,我们可以在代码中定义水位线的生成策略。接下来我们就具体了解一下水位线在代码中的使用。

2、水位线生成策略(Watermark Strategies)

在Flink的DataStream API中 , 有 一 个 单 独 用 于 生 成 水 位 线 的 方法:.assignTimestampsAndWatermarks(),它主要用来为流中的数据分配时间戳,并生成水位线来指示事件时间:

    // ------------------------------------------------------------------------
    //  Timestamps and watermarks
    // ------------------------------------------------------------------------

    /**
     * Assigns timestamps to the elements in the data stream and generates watermarks to signal
     * event time progress. The given {@link WatermarkStrategy} is used to create a {@link
     * TimestampAssigner} and {@link WatermarkGenerator}.
     *
     * <p>For each event in the data stream, the {@link TimestampAssigner#extractTimestamp(Object,
     * long)} method is called to assign an event timestamp.
     *
     * <p>For each event in the data stream, the {@link WatermarkGenerator#onEvent(Object, long,
     * WatermarkOutput)} will be called.
     *
     * <p>Periodically (defined by the {@link ExecutionConfig#getAutoWatermarkInterval()}), the
     * {@link WatermarkGenerator#onPeriodicEmit(WatermarkOutput)} method will be called.
     *
     * <p>Common watermark generation patterns can be found as static methods in the {@link
     * org.apache.flink.api.common.eventtime.WatermarkStrategy} class.
     *
     * @param watermarkStrategy The strategy to generate watermarks based on event timestamps.
     * @return The stream after the transformation, with assigned timestamps and watermarks.
     */
    public SingleOutputStreamOperator<T> assignTimestampsAndWatermarks(
            WatermarkStrategy<T> watermarkStrategy) {
        final WatermarkStrategy<T> cleanedStrategy = clean(watermarkStrategy);
        // match parallelism to input, to have a 1:1 source -> timestamps/watermarks relationship
        // and chain
        final int inputParallelism = getTransformation().getParallelism();
        final TimestampsAndWatermarksTransformation<T> transformation =
                new TimestampsAndWatermarksTransformation<>(
                        "Timestamps/Watermarks",
                        inputParallelism,
                        getTransformation(),
                        cleanedStrategy);
        getExecutionEnvironment().addOperator(transformation);
        return new SingleOutputStreamOperator<>(getExecutionEnvironment(), transformation);
    }

具体使用时,直接用DataStream调用该方法即可,与普通的transform方法完全一样。

 DataStream<Event> eventDStream = eventDataStream.assignTimestampsAndWatermarks(
                WatermarkStrategy
                        .<Event>forMonotonousTimestamps() //有序流 Watermark 生成策略
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override//指定时间戳列
                            public long extractTimestamp(Event element, long recordTimestamp) {
                                return element.timestamp;//recordTimestamp 是毫秒,如果 Event timestamp 是秒 需要*1000
                            }
                        }));

注意:原始的时间戳只是写入日志数据的一个字段,如果不提取出来并明确把它分配给数据,Flink是无法知道数据真正产生的时间的。当然,有些时候数据源本身就提供了时间戳信息,比如读取Kafka时,我们就可以从Kafka数据中直接获取时间戳,而不需要单独提取字段分配了。.assignTimestampsAndWatermarks()方法需要传入一个WatermarkStrategy作为参数,这就是所谓 的“水位 线生成策略”。WatermarkStrategy中包含 了一个“时间戳 分配器”TimestampAssigner和一个“水位线生成器”WatermarkGenerator。

@Public
public interface WatermarkStrategy<T>
        extends TimestampAssignerSupplier<T>, WatermarkGeneratorSupplier<T> {

    // ------------------------------------------------------------------------
    //  Methods that implementors need to implement.
    // ------------------------------------------------------------------------

    /** Instantiates a WatermarkGenerator that generates watermarks according to this strategy. */
    @Override
    WatermarkGenerator<T> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context);

    /**
     * Instantiates a {@link TimestampAssigner} for assigning timestamps according to this strategy.
     */
    @Override
    default TimestampAssigner<T> createTimestampAssigner(
            TimestampAssignerSupplier.Context context) {
        // By default, this is {@link RecordTimestampAssigner},
        // for cases where records come out of a source with valid timestamps, for example from
        // Kafka.
        return new RecordTimestampAssigner<>();
    }

3、Flink内置水位线生成器

WatermarkStrategy这个接口是一个生成水位线策略的抽象,让我们可以灵活地实现自己的需求;但看起来有些复杂,如果想要自己实现应该还是比较麻烦的。好在Flink充分考虑到了我们的痛苦,提供了内置的水位线生成器(WatermarkGenerator),不仅开箱即用简化了编程,而且也为我们自定义水位线策略提供了模板。这两个生成器可以通过调用WatermarkStrategy的静态辅助方法来创建。它们都是周期性生成水位线的,分别对应着处理有序流和乱序流的场景。

有序流策略

对于有序流,主要特点就是时间戳单调增长(Monotonously Increasing Timestamps),所以永远不会出现迟到数据的问题。这是周期性生成水位线的最简单的场景,直接调用WatermarkStrategy.forMonotonousTimestamps()方法就可以实现。简单来说,就是直接拿当前最大的时间戳作为水位线就可以了。

    //有序流 Watermark 生成策略  forMonotonousTimestamps()
        DataStream<Event> eventDStream = eventDataStream.assignTimestampsAndWatermarks(
                WatermarkStrategy
                        .<Event>forMonotonousTimestamps() //有序流 Watermark 生成策略
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override//指定时间戳列
                            public long extractTimestamp(Event element, long recordTimestamp) {
                                return element.timestamp;//recordTimestamp 是毫秒,如果 Event timestamp 是秒 需要*1000
                            }
                        }));

上面代码中我们调用.withTimestampAssigner()方法,将数据中的timestamp字段提取出来,作为时间戳分配给数据元素;然后用内置的有序流水位线生成器构造出了生成策略。这样,提取出的数据时间戳,就是我们处理计算的事件时间。这里需要注意的是,时间戳和水位线的单位,必须都是毫秒。

乱序流

由于乱序流中需要等待迟到数据到齐,所以必须设置一个固定量的延迟时间(Fixed Amount of Lateness)。这时生成水位线的时间戳,就是当前数据流中最大的时间戳减去延迟的结果,相当于把表调慢,当前时钟会滞后于数据的最大时间戳。调用WatermarkStrategy. forBoundedOutOfOrderness()方法就可以实现。这个方法需要传入一个maxOutOfOrderness参数,表示“最大乱序程度”,它表示数据流中乱序数据时间戳的最大差值;如果我们能确定乱序程度,那么设置对应时间长度的延迟,就可以等到所有的乱序数据了。

  //无序流 Watermark 生成,forBoundedOutOfOrderness(Duration.ofSeconds(2) 等2s
        DataStream<Event> eventDStream1 = eventDataStream.//无序流 Watermark 生成
                assignTimestampsAndWatermarks(
                WatermarkStrategy
                        .<Event>forBoundedOutOfOrderness(Duration.ofSeconds(2)) //无序流 Watermark 生成 2s延迟
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            public long extractTimestamp(Event element, long recordTimestamp) {
                                //指定时间戳列
                                return element.timestamp;
                            }
                        }));

上面代码中,我们同样提取了timestamp字段作为时间戳,并且以2秒的延迟时间创建了处理乱序流的水位线生成器。事实上,有序流的水位线生成器本质上和乱序流是一样的,相当于延迟设为0的乱序流水位线生成器,两者完全等同:

WatermarkStrategy.forMonotonousTimestamps()
WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(0))

这里需要注意的是,乱序流中生成的水位线真正的时间戳,其实是当前最大时间戳–延迟时间–1,这里的单位是毫秒。为什么要减1毫秒呢?我们可以回想一下水位线的特点:时间戳为t的水位线,表示时间戳≤t的数据全部到齐,不会再来了。如果考虑有序流,也就是延迟时间为0的情况,那么时间戳为7秒的数据到来时,之后其实是还有可能继续来7秒的数据的;所以生成的水位线不是7秒,而是6秒999毫秒,7秒的数据还可以继续来。这一点可以在BoundedOutOfOrdernessWatermarks的源码中明显地看到:

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

水位线在事件时间的世界里面,承担了时钟的角色。也就是说在事件时间的流中,水位线是唯一的时间尺度。如果想要知道现在几点,就要看水位线的大小。后面讲到的窗口的闭合,以及定时器的触发都要通过判断水位线的大小来决定是否触发。水位线是一种特殊的事件,由程序员通过编程插入的数据流里面,然后跟随数据流向下游流动。水位线的默认计算公式:水位线=观察到的最大事件时间–最大延迟时间–1毫秒。所以这里涉及到一个问题,就是不同的算子看到的水位线的大小可能是不一样的。因为下游的算子可能并未接收到来自上游算子的水位线,导致下游算子的时钟要落后于上游算子的时钟。比如map->reduce这样的操作,如果在map中编写了非常耗时间的代码,将会阻塞水位线的向下传播,因为水位线也是数据流中的一个事件,位于水位线前面的数据如果没有处理完毕,那么水位线不可能弯道超车绕过前面的数据向下游传播,也就是说会被前面的数据阻塞。这样就会影响到下游算子的聚合计算,因为下游算子中无论由窗口聚合还是定时器的操作,都需要水位线才能触发执行。这也就告诉了我们,在编写Flink程序时,一定要谨慎的编写每一个算子的计算逻辑,尽量避免大量计算或者是大量的IO操作,这样才不会阻塞水位线的向下传递。在数据流开始之前,Flink会插入一个大小是负无穷大(在Java中是-Long.MAX_VALUE)的水位线,而在数据流结束时,Flink会插入一个正无穷大(Long.MAX_VALUE)的水位线,保证所有的窗口闭合以及所有的定时器都被触发。对于离线数据集,Flink也会将其作为流读入,也就是一条数据一条数据的读取。在这种情况下,Flink对于离线数据集,只会插入两次水位线,也就是在最开始处插入负无穷大的水位线,在结束位置插入一个正无穷大的水位线。因为只需要插入两次水位线,就可以保证计算的正确,无需在数据流的中间插入水位线了。水位线的重要性在于它的逻辑时钟特性,而逻辑时钟这个概念可以说是分布式系统里面最为重要的概念之一了,理解透彻了对理解各种分布式系统非常有帮助。具体可以参考LeslieLamport的论文。

posted @ 2022-06-20 09:36  晓枫的春天  阅读(418)  评论(0编辑  收藏  举报