Flink中的时间语义和Watermarks

在流处理中,时间是一个非常核心的概念,是整个系统的基石。我们经常会遇到这样的需求:给定一个时间窗口,比如一个小时,统计时间窗口内的数据指标。那如何界定哪些数据将进入这个窗口呢?在窗口的定义之前,首先需要确定一个作业使用什么样的时间语义。

本文将介绍Flink的Event Time、Processing Time和Ingestion Time三种时间语义,接着会详细介绍Event Time和Watermark的工作机制,以及如何对数据流设置Event Time并生成Watermark。

一、Flink的三种时间语义

在 Flink 的流式处理中,明确支持三种不同的时间,会涉及到时间的不同概念, 如下图所示:

在这里插入图片描述

Event Time:是事件创建的时间。它通常由事件中的时间戳描述,例如采集的日志数据中,每一条日志都会记录自己的生成时间,Flink 通过时间戳分配器访问事件时间戳。

由于事件从发生到进入Flink时间算子之间有很多环节,一个较早发生的事件因为延迟可能较晚到达,因此使用Event Time意味着事件到达有可能是乱序的。

Ingestion Time:是数据进入 Flink Source 的时间。 从Source到下游各个算子中间可能有很多计算环节,任何一个算子的处理速度快慢可能影响到下游算子的Processing Time。而Ingestion Time定义的是数据流最早进入Flink的时间,因此不会被算子处理速度影响。

Processing Time:是每一个执行基于时间操作的算子的本地系统时间,与机器相关,默认的时间属性就是 Processing Time。 在Processing Time的时间窗口场景下,无论事件什么时候发生,只要该事件在某个时间段到达了某个算子,就会被归结到该窗口下,不需要Watermark机制。

二、设置时间语义

在 Flink 的流式处理中, 绝大部分的业务都会使用 eventTime, 一般只在eventTime 无法使用时,才会被迫使用ProcessingTime 或者 IngestionTime

在Flink中,我们需要在执行环境层面设置使用哪种时间语义。下面的代码使用Event Time:

env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);

如果想用另外两种时间语义,需要替换为:TimeCharacteristic.ProcessingTimeTimeCharacteristic.IngestionTime

三、Watermark的核心原理

Flink的三种时间语义中,Processing Time和Ingestion Time都可以不用设置Watermark。

如果我们要使用Event Time语义,以下两项配置缺一不可:

第一,使用一个时间戳为数据流中每个事件的Event Time赋值;
第二,生成Watermark

实际上,Event Time是每个事件的元数据,如果不设置,Flink并不知道每个事件的发生时间,我们必须要为每个事件的Event Time赋值一个时间戳。关于时间戳,包括Flink在内的绝大多数系统都使用Unix时间戳系统(Unix time或Unix epoch)。Unix时间戳系统以1970-01-01 00:00:00.000 为起始点,其他时间记为距离该起始时间的整数差值,一般是毫秒(millisecond)精度。

有了Event Time时间戳,我们还必须生成Watermark。Watermark是Flink插入到数据流中的一种特殊的数据结构,它包含一个时间戳,并假设后续不会有小于该时间戳的数据。

下图展示了一个乱序数据流,其中方框是单个事件,方框中的数字是其对应的Event Time时间戳,圆圈为Watermark,圆圈中的数字为Watermark对应的时间戳。

在这里插入图片描述

Watermark的核心本质可以理解成一个延迟触发机制。

我们知道,流处理从事件产生,到流经 source,再到 operator,中间是有一个过程和时间的,虽然大部分情况下,流到 operator 的数据都是按照事件产生的时间顺序来的,但是也不排除由于网络、分布式等原因,导致乱序的产生,所谓乱序,就是指 Flink 接收到的事件的先后顺序不是严格按照事件的 Event Time 顺序排列的。

那么此时出现一个问题,一旦出现乱序,如果只根据 eventTime 决定 window 的运行,我们不能明确数据是否全部到位,但又不能无限期的等下去,此时必须要有个机制来保证一个特定的时间后,必须触发 window 去进行计算了,这个特别的机制,就是 Watermark。

1、Watermark 是一种衡量 Event Time 进展的机制。
2、Watermark 是用于处理乱序事件的
3、Watermark 机制结合 window 来实现。

数据流中的 Watermark 用于表示 timestamp 小于 Watermark 的数据,都已经到达了,因此, window 的执行也是由 Watermark 触发的。

当 Flink 接收到数据时,会按照一定的规则去生成 Watermark,这条 Watermark 就等于当前所有到达数据中的 maxEventTime - 延迟时长,也就是说,Watermark 是由数据携带的,一旦数据携带的 Watermark 比当前未触发的窗口的停止时间要晚, 那么就会触发相应窗口的执行。由于 Watermark 是由数据携带的,因此,如果运行过程中无法获取新的数据,那么没有被触发的窗口将永远都不被触发。

四、抽取时间戳及生成Watermark

我们已经了解了Flink的Event Time和Watermark机制的大致工作原理,接下来我们将展示如何在代码层面设置时间戳并生成Watermark。因为时间在后续处理中都会用到,时间的设置要在任何时间窗口操作之前。

Source

我们可以在Source阶段完成时间戳抽取和Watermark生成的工作。Flink 1.11开始推出了新的Source接口,并计划逐步替代老的Source接口,这里暂时以老的Source接口来展示时间戳抽取和Watermark生成的过程。

在老的Source接口中,通过自定义SourceFunctionRichSourceFunction,在SourceContext里重写

void collectWithTimestamp(T element, long timestamp)

void emitWatermark(Watermark mark)

两个方法,其中,collectWithTimestamp()给数据流中的每个元素T赋值一个timestamp作为Event Time,emitWatermark()生成Watermark。

下面的代码展示了调用这两个方法抽取时间戳并生成Watermark。

class MyType {
  public double data;
  public long eventTime;
  public boolean hasWatermark;
  public long watermarkTime;
  
  ...
}

class MySource extends RichSourceFunction[MyType] {
  @Override
  public void run(SourceContext<MyType> ctx) throws Exception {
    while (/* condition */) {
      MyType next = getNext();
      ctx.collectWithTimestamp(next, next.eventTime);

      if (next.hasWatermarkTime()) {
        ctx.emitWatermark(new Watermark(next.watermarkTime));
      }
    }
  }
}

Source之后

如果我们不想修改Source,也可以在Source之后,通过assignTimestampsAndWatermarks()方法来设置。与Source接口一样,Flink 1.11重构了assignTimestampsAndWatermarks()方法,重构后的assignTimestampsAndWatermarks()方法和新的Source接口结合更好、表达能力更强,这里将分别介绍一下不同版本Flink的assignTimestampsAndWatermarks()`方法。

Flink1.11

新的assignTimestampsAndWatermarks()方法主要依赖WatermarkStrategy,通过WatermarkStrategy我们可以为每个元素抽取时间戳并生成Watermark。assignTimestampsAndWatermarks()方法结合WatermarkStrategy的大致使用方式为:

DataStream<MyType> stream = ...

DataStream<MyType> withTimestampsAndWatermarks = stream
        .assignTimestampsAndWatermarks(
            WatermarkStrategy
                .forGenerator(...)
                .withTimestampAssigner(...)
        );

可以看到WatermarkStrategy.forGenerator(...).withTimestampAssigner(...)链式调用了两个方法,forGenerator()方法用来生成Watermark,withTimestampAssigner()方法用来为数据流的每个元素设置时间戳。

withTimestampAssigner()方法相对更好理解,它抽取数据流中的每个元素的时间戳,一般是告知Flink具体哪个字段为时间戳字段。例如,一个MyType数据流中eventTime字段为时间戳,数据流的每个元素为event,使用Lambda表达式来抽取时间戳,可以写成:.withTimestampAssigner((event, timestamp) -> event.eventTime)。这个Lambda表达式可以帮我们抽取数据流元素中的时间戳eventTime,我们暂且可以不用关注第二个参数timestamp

基于Event Time时间戳,我们还要设置Watermark生成策略,一种方法是自己实现一些Watermark策略类,并使用forGenerator()方法调用这些Watermark策略类。

Watermark是一种插入到数据流中的特殊元素,Watermark元素包含一个时间戳,当某个算子接收到一个Watermark元素时,算子会假设早于这条Watermark的数据流元素都已经到达。那么如何向数据流中插入Watermark呢?

Flink提供了两种方式,一种是周期性地(Periodic)生成Watermark,一种是逐个式地(Punctuated)生成Watermark。无论是Periodic方式还是Punctuated方式,都需要实现WatermarkGenerator接口类,如下所示,T为数据流元素类型。

// Flink源码
// 生成Watermark的接口类
@Public
public interface WatermarkGenerator<T> {
    // 数据流中的每个元素流入后都会调用onEvent()方法
    // Punctunated方式下,一般根据数据流中的元素是否有特殊标记来判断是否需要生成Watermark
    // Periodic方式下,一般用于记录各元素的Event Time时间戳
    void onEvent(T event, long eventTimestamp, WatermarkOutput output);

    // 每隔固定周期调用onPeriodicEmit()方法
    // 一般主要用于Periodic方式
    // 固定周期用 ExecutionConfig#setAutoWatermarkInterval() 方法设置
    void onPeriodicEmit(WatermarkOutput output);
}
Periodic

周期性的生成 watermark:系统会周期性的将 watermark 插入到流中(水位线也是一种特殊的事件)。默认周期是 200 毫秒。可以使用ExecutionConfig.setAutoWatermarkInterval() 方法进行设置 :

// 每隔 5 秒产生一个 watermark
env.getConfig.setAutoWatermarkInterval(5000)

产生 watermark 的逻辑: 每隔 5 秒钟, Flink 会调用AssignerWithPeriodicWatermarks 的 getCurrentWatermark()方法 。如果方法返回一个时间戳大于之前水位的时间戳, 新的 watermark 会被插入到流中。这个检查保证了水位线是单调递增的。如果方法返回的时间戳小于等于之前水位的时间戳, 则不会产生新的 watermark。

下面的代码定期生成Watermark,数据流元素是一个Tuple2,第二个字段Long是Event Time时间戳。

// 定期生成Watermark
// 数据流元素 Tuple2<String, Long> 共两个字段
// 第一个字段为数据本身
// 第二个字段是时间戳
public static class MyPeriodicGenerator implements WatermarkGenerator<Tuple2<String, Long>> {

    private final long maxOutOfOrderness = 60 * 1000; // 1分钟
    private long currentMaxTimestamp;                 // 已抽取的Timestamp最大值

    @Override
    public void onEvent(Tuple2<String, Long> event, long eventTimestamp, WatermarkOutput output) {
        // 更新currentMaxTimestamp为当前遇到的最大值
        currentMaxTimestamp = Math.max(currentMaxTimestamp, eventTimestamp);
    }

    @Override
    public void onPeriodicEmit(WatermarkOutput output) {
        // Watermark比currentMaxTimestamp最大值慢1分钟
        output.emitWatermark(new Watermark(currentMaxTimestamp - maxOutOfOrderness));
    }

}

用变量currentMaxTimestamp记录已抽取的时间戳最大值,每个元素到达后都会调用onEvent()方法,更新currentMaxTimestamp时间戳最大值。

当需要发射Watermark时,以时间戳最大值减1分钟作为Watermark发送出去。这种Watermark策略假设Watermark比已流入数据的最大时间戳慢1分钟,超过1分钟的将被视为迟到数据。

实现好MyPeriodicGenerator后,我们要用forGenerator()方法调用这个类:

// 第二个字段是时间戳
DataStream<Tuple2<String, Long>> watermark = input.assignTimestampsAndWatermarks(
    WatermarkStrategy
        .forGenerator((context -> new MyPeriodicGenerator()))
        .withTimestampAssigner((event, recordTimestamp) -> event.f1));

考虑到这种基于时间戳最大值的场景比较普遍,Flink已经帮我们封装好了这样的代码,名为BoundedOutOfOrdernessWatermarks,其内部实现与上面的代码几乎一致,我们只需要将最大的延迟时间作为参数传入:

// 第二个字段是时间戳
DataStream<Tuple2<String, Long>> input = env
    .addSource(new MySource())
    .assignTimestampsAndWatermarks(
        WatermarkStrategy
            .<Tuple2<String, Long>>forBoundedOutOfOrderness(Duration.ofSeconds(5))
            .withTimestampAssigner((event, timestamp) -> event.f1)
);

除了BoundedOutOfOrdernessWatermarks,另外一种预置的Watermark策略为AscendingTimestampsWatermarks

AscendingTimestampsWatermarks其实是继承了BoundedOutOfOrdernessWatermarks,只不过AscendingTimestampsWatermarks会假设Event Time时间戳单调递增,从内部代码实现上来说,Watermark的发射时间为时间戳最大值,不添加任何延迟。使用时,可以参照下面的方式:

// 第二个字段是时间戳
DataStream<Tuple2<String, Long>> input = env
    .addSource(new MySource())
    .assignTimestampsAndWatermarks(
        WatermarkStrategy
            .<Tuple2<String, Long>>forMonotonousTimestamps()
            .withTimestampAssigner((event, timestamp) -> event.f1)
);
Punctuated

假如数据流元素有一些特殊标记,标记了某些元素为Watermark,我们可以逐个检查数据流各元素,根据是否有特殊标记判断是否要生成Watermark。

下面的代码以一个Tuple3为例,其中第二个字段是时间戳,第三个字段标记了是否为Watermark。我们只需要在onEvent()方法中根据第三个字段来决定是否生成一条新的Watermark,由于这里不需要周期性的操作,因此onPeriodicEmit()方法里不需要做任何事情。

// 逐个检查数据流中的元素,根据元素中的特殊字段,判断是否要生成Watermark
// 数据流元素 Tuple3<String, Long, Boolean> 共三个字段
// 第一个字段为数据本身
// 第二个字段是时间戳
// 第三个字段判断是否为Watermark的标记
public static class MyPunctuatedGenerator implements WatermarkGenerator<Tuple3<String, Long, Boolean>> {

    @Override
    public void onEvent(Tuple3<String, Long, Boolean> event, long eventTimestamp, WatermarkOutput output) {
        if (event.f2) {
          output.emitWatermark(new Watermark(event.f1));
        }
    }

    @Override
    public void onPeriodicEmit(WatermarkOutput output) {
      	// 这里不需要做任何事情,因为我们在 onEvent() 方法中生成了Watermark
    }

}

Flink1.11之前

watermark 的引入很简单,对于乱序数据,最常见的引用方式如下 :

assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<SensorReading>             (Time.milliseconds(1000)) {
                    @Override
                    public long extractTimestamp(SensorReading> element) {
                        return element.timestamp ;
                    }
                });

Event Time 的使用一定要指定数据源中的时间戳 ,否则程序无法知道事件的事件时间是什么(数据源里的数据没有时间戳的话, 就只能使用 Processing Time 了)

我们看到上面的例子中创建了一个看起来有点复杂的类, 这个类实现的其实就是分配时间戳的接口。

Flink 暴露了 TimestampAssigner 接口供我们实现,使我们可以自定义如何从事件数据中抽取时间戳 。

val env = StreamExecutionEnvironment.getExecutionEnvironment

// 从调用时刻开始给 env 创建的每一个 stream 追加时间特性
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)

val readings: DataStream[SensorReading] = env

.addSource(new SensorSource)

.assignTimestampsAndWatermarks(new MyAssigner())

MyAssigner 有两种类型

  • AssignerWithPeriodicWatermarks

  • AssignerWithPunctuatedWatermarks

以上两个接口都继承自 TimestampAssigner。

AssignerWithPeriodicWatermarks

周期性的生成 watermark:系统会周期性的将 watermark 插入到流中(水位线也是一种特殊的事件!)。默认周期是 200 毫秒。可以使用ExecutionConfig.setAutoWatermarkInterval() 方法进行设置。

例子,自定义一个周期性的时间戳抽取:

class PeriodicAssigner extends AssignerWithPeriodicWatermarks[SensorReading] {
val bound: Long = 60 * 1000 // 延时为 1 分钟

var maxTs: Long = Long.MinValue // 观察到的最大时间戳

override def getCurrentWatermark: Watermark = {

new Watermark(maxTs - bound)

}

override def extractTimestamp(r: SensorReading, previousTS: Long) = {
    maxTs = maxTs.max(r.timestamp)
    r.timestamp
}
}

一种简单的特殊情况是, 如果我们事先得知数据流的时间戳是单调递增的,也就是说没有乱序, 那我们可以使用 assignAscendingTimestamps, 这个方法会直接使用数据的时间戳生成 watermark 。

val stream: DataStream[SensorReading] = ...
val withTimestampsAndWatermarks = stream
.assignAscendingTimestamps(e => e.timestamp)

>> result: E(1), W(1), E(2), W(2), ...

而对于乱序数据流,如果我们能大致估算出数据流中的事件的最大延迟时间, 就可以使用如下代码 :

val stream: DataStream[SensorReading] = ...
val withTimestampsAndWatermarks = stream.assignTimestampsAndWatermarks(
new SensorTimeAssigner
)

class SensorTimeAssigner extends BoundedOutOfOrdernessTimestampExtractor[SensorReading](Time.seconds(5)) {
// 抽取时间戳
override def extractTimestamp(r: SensorReading): Long = r.timestamp
}

>> relust: E(10), W(0), E(8), E(7), E(11), W(1), ...

AssignerWithPunctuatedWatermarks

间断式地生成 watermark。和周期性生成的方式不同,这种方式不是固定时间的, 而是可以根据需要对每条数据进行筛选和处理。直接上代码来举个例子, 我们只给sensor_1 的传感器的数据流插入 watermark :

class PunctuatedAssigner extends AssignerWithPunctuatedWatermarks[SensorReading] {
val bound: Long = 60 * 1000
override def checkAndGetNextWatermark(r: SensorReading, extractedTS: Long): Watermark = {
if (r.id == "sensor_1") {
new Watermark(extractedTS - bound)

} else {null}
}

override def extractTimestamp(r: SensorReading, previousTS: Long): Long= {
r.timestamp}
}

Watermark的设定

我们已经了解了Flink的Event Time和Watermark生成方法,那么具体如何操作呢?

实际上,这个问题可能并没有一个标准答案。批处理中,数据都已经准备好了,不需要考虑未来新流入的数据,而流处理中,我们无法完全预知有多少迟到数据,数据的流入依赖业务的场景、数据的输入、网络的传输、集群的性能等等。

Watermark是一种在延迟和准确性之间平衡的策略:

  • 在Flink 中watermark由应用程序开发人员生成,对待具体的业务场景,我们可能需要反复尝试,不断迭代和调整时间戳和Watermark策略
  • Watermark设置的延迟太久,一些重要数据有可能被当成迟到数据,影响计算结果的准确性
  • Watermark设置的延迟太早,则可能收到错误的结果,不过Flink出来迟到数据的机制可以解决这个问题
  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值