Flink 时间属性深度解析

前言

Flink 的 API 大体上可以划分为三个层次:最底层的 ProcessFunction、中间一层的 DataStream API 和最上层的 SQL / Table API,这三层的每一层都非常依赖于时间属性。
在这里插入图片描述

Flink 时间语义

Flink 支持的核心时间语义是 Processing TimeEvent Time。两者的不同点如下表所示:

Processing Time(处理时间)Event Time(事件时间)
真实世界的时间数据世界的时间
处理数据节点的本地时间数据本身携带的TimeStamp
处理简单处理复杂
结果不确定(无法重现)结果确定(可重现)

在判断应该使用 Processing Time 还是 Event Time 时,可以遵循一个原则:当你的程序遇到某个问题要从上一个 checkpoint 或者 savepoint 进行重启时,是否希望结果完全相同

  • 希望结果完全相同,就只能用 Event Time。
  • 如果接受结果不同,则可以用 Processing Time。

Processing Time 的一个常见的用途是,根据现实时间来统计整个系统的吞吐,比如要计算现实时间一个小时处理了多少条数据,这种情况只能使用 Processing Time。

注意:当使用每小时 Processing Time 时间窗口时,窗口包括在系统时钟指示整点之间的数据。例如:一个程序在 9:15 开始运行,则第一个每小时处理时间窗口将包括在 9:15 和 10:00 之间处理的事件,下一个窗口将包括在 10:00 和 11:00 之间处理的事件。

时间的特性

对于 Processing Time:因为使用的是本地机器节点的时间,所以每一次取到的 Processing Time 肯定都是递增的,相当于是一个有序的数据流。

对于 Event Time:因为时间是绑定在每一条数据上,由于网络延迟、程序内部逻辑等等原因,数据的时间可能会存在乱序的情况。
在这里插入图片描述
解决 Event Time 乱序的问题就需要使用 Watermark。
一个 Watermark 本质上就是一个 timestamp 数值,表示以后到来的数据已经再也没有小于或等于这个时间的了。

Watermark(t) 表示数据流中 “ event time =< t ” 的数据都已到达,流中不应该有 “ 时间戳 <= t ” 的数据。

事实上,Watermark(t) 发生之后,还会出现很多 “ 时间戳 <= t ” 的数据。这些数据称为 Lateness(迟到数据)。

Timestamp 和 Watermark 行为概览

在这里插入图片描述

1、Timestamp 分配和 Watermark 生成

WatermarkGenerator 是 Watermark 的生成器,接口代码如下:

// 可以基于事件或者周期性的生成 watermark
@Public
public interface WatermarkGenerator<T> {
    // 每来一条数据调用一次,可检查或记录事件的时间戳,或基于事件本身去生成watermark
    void onEvent(T event, long eventTimestamp, WatermarkOutput output);
    // 周期性调用,可能会生成新的watermark,有可能不会。
    // 不会生成watermark是因为上一个watermark还没有触发。
    // 调用此方法生成watermark的间隔时间由ExecutionConfig.getAutoWatermarkInterval()决定。
    void onPeriodicEmit(WatermarkOutput output);
}

1.1 Watemark 的生成方式有两种:

1、周期性生成
通常通过 onEvent() 观察传入的事件数据,然后定期调用 onPeriodicEmit() 发出 Watemark。
调用此方法生成 watermark 的间隔时间由 ExecutionConfig.getAutoWatermarkInterval() 决定。
当调用 onPeriodicEmit() 方法时,如果返回的 watermark 非空并值大于前一个 watermark,则将发出新的 watermark。

// 该 watermark 生成器可以覆盖的场景是:数据源在一定程度上乱序。
public class BoundedOutOfOrdernessGenerator implements WatermarkGenerator<MyEvent> {
    private final long maxOutOfOrderness = 3500; // 3.5 秒
    private long currentMaxTimestamp;

    @Override
    public void onEvent(MyEvent event, long eventTimestamp, WatermarkOutput output) {
        currentMaxTimestamp = Math.max(currentMaxTimestamp, eventTimestamp);
    }

    @Override
    public void onPeriodicEmit(WatermarkOutput output) {
        // 发出的 watermark = 当前最大时间戳 - 最大乱序时间
        output.emitWatermark(new Watermark(currentMaxTimestamp - maxOutOfOrderness - 1));
    }
}

2、标记生成
查看 onEvent() 中的事件数据,并等待流中携带 Watermark 的特殊标记事件或打点数据。当获取到这些数据时,它就会发出 Watermark。

通常情况下,标记生成器不会通过 onPeriodicEmit() 方法发出 watermark。

public class PunctuatedAssigner implements WatermarkGenerator<MyEvent> {
    @Override
    public void onEvent(MyEvent event, long eventTimestamp, WatermarkOutput output) {
        if (event.hasWatermarkMarker()) {
            output.emitWatermark(new Watermark(event.getWatermarkTimestamp()));
        }
    }

    @Override
    public void onPeriodicEmit(WatermarkOutput output) {
        // onEvent 中已经实现
    }
}

1.2 Watemark 的使用方式:

	dataStream.assignTimestampsAndWatermarks(<watermark strategy>);

2、Watemark 传播

在这里插入图片描述
具体的传播策略基本上遵循这三点。

  • 第一,watermark 会以广播的形式在算子之间进行传播。比如说上游的算子连接了三个下游的任务,它会把自己当前收到的 watermark 以广播的形式传到各个下游。
  • 第二,如果在程序里面收到一个 Long.MAX_VALUE 这个数值的 watermark,就表示对应的那一条流的一个部分不会再有数据发过来了,相当于是一个终止的标志。
  • 第三,单输入取其大,多输入取其小。

这种设计有一个局限,具体体现在它没有区分你输入的是一条流多个 partition 还是来自于不同逻辑上的流的 JOIN。

  • 对于同一个流的不同 partition,我们对他做这种强制的时钟同步是没有问题的,因为一开始就把一条流拆散成不同的部分,但每一个部分之间共享相同的时钟。
  • 对于算子做类似于 JOIN 操作,那么要求两个输入的时钟强制同步其实是没有必要的,因为完全有可能是把一条离现在时间很近的数据流和一个离当前时间很远的数据流进行 JOIN,这时对于快的流要等慢点流,所所以说它可能就要在状态中去缓存非常多的数据,这对于整个集群是一个很大的性能开销。

3、ProcessFunction

在正式介绍 watermark 的处理之前,先简单介绍 ProcessFunction,因为 watermark 在任务里的处理逻辑分为内部逻辑和外部逻辑。外部逻辑其实就是通过 ProcessFunction 来体现的,如果你需要使用 Flink 提供的时间相关的 API 的话就只能写在 ProcessFunction 里。

ProcessFunction 和时间相关的功能主要有三点:

  • 第一点,根据你当前系统使用的时间语义不同,你可以去获取当前你正在处理这条数据自带的 Timestamp,或者当前的 Processing Time。
  • 第二点,它可以获取当前算子的时间,可以把它理解成当前的 watermark。
  • 第三点,为了在 ProcessFunction 中去实现一些复杂的功能,允许注册一些 timer(定时器)。比如说在 watermark 达到某一个时间点时就触发定时器,所有的这些回调逻辑也都是由用户提供的,涉及到如下三个方法,registerEventTimeTimer、registerProcessingTimeTimer 和 onTimer。在 onTimer 方法中就需要去实现自己的回调逻辑,当条件满足时回调逻辑就会被触发。

一个简单的应用是,我们在做一些时间相关的处理的时候,可能需要缓存一部分数据,但这些数据不能一直去缓存下去,所以需要有一些过期的机制,我们可以通过 timer 去设定这么一个时间,指定某一些数据可能在将来的某一个时间点过期,从而把它从状态里删除掉。


4、Watermark 处理

在这里插入图片描述
一个算子的实例在收到 watermark 时:

  1. 首先要更新当前的算子时间,这样的话在 ProcessFunction 里方法查询这个算子时间的时候,就能获取到最新的时间。
  2. 第二步它会遍历计时器(timer)队列,你可以注册很多 timer,Flink 会把这些 Timer 按照触发时间放到一个优先队列中。
  3. 第三步 flink 得到一个时间之后就会遍历计时器的队列,然后逐一触发用户的回调逻辑。通过这种方式,Flink 的某一个任务就会将当前的 watermark 发送到下游的其他任务实例上,从而完成整个 watermark 的传播,从而形成一个闭环。

Table API 中的时间

为了让时间参与到 Table/SQL 这一层的运算中,我们需要提前把时间属性放到表的 schema 中,这样的话我们才能够在 SQL 语句或者 Table 的逻辑表达式里面使用时间去完成需求。

1、Table 中指定时间列

我们分开讲一讲 Processing Time 和 Event Time 在使用时怎么在 Table 中指定。
在这里插入图片描述

1.1 Processing Time 在 Table 中的指定

对于 Processing Time,我们知道要得到一个 Table 对象(或者注册一个 Table)有两种方法:

  • 方法1:可以从一个 DataStream 转化成一个 Table
  • 方法2:直接通过 TableSource 去生成这么一个 Table

对于方法1,我们只需要在已有的列中(如图上所示 f1 和 f2 就是两个已有的列),在最后用“列名.proctime” 这种写法就可以把最后这一列注册为一个 Processing Time,以后在写查询时就可以去直接使用这一列。

DataStream<Tuple2<String, String>> stream = ...;

Table table = tEnv.fromDataStream(stream, "Username, Data, UserActionTime.proctime");

WindowedTable windowedTable = table.window(Tumble.over("10.minutes").on("UserActionTime").as("userActionWindow"));

对于方法2,需要通过实现一个 DefinedRowtimeAttributes 接口,然后就会自动根据你提供的逻辑去生成对应的 Processing Time。

public class UserActionSource implements StreamTableSource<Row>, DefinedProctimeAttribute {

    @Override
    public TypeInformation<Row> getReturnType() {
        String[] names = new String[] {"Username" , "Data"};
        TypeInformation[] types = new TypeInformation[] {Types.STRING(), Types.STRING()};
        return Types.ROW(names, types);
    }

    @Override
    public DataStream<Row> getDataStream(StreamExecutionEnvironment execEnv) {
        // create stream
        DataStream<Row> stream = ...;
        return stream;
    }

    @Override
    public String getProctimeAttribute() {
        // 此字段追加最后一个字段
        return "UserActionTime";
    }
}

// 注册表源
tEnv.registerTableSource("UserActions", new UserActionSource());

WindowedTable windowedTable = tEnv
    .scan("UserActions")
    .window(Tumble.over("10.minutes").on("UserActionTime").as("userActionWindow"));

1.2 Event Time 在 Table 中的指定

相对而言,在使用 Event Time 时则有一 限制,因为 Event Time 不像 Processing Time 那样是随拿随用。

我们知道要得到一个 Table 对象(或者注册一个 Table)有两种方法:

  • 方法1:可以从一个 DataStream 转化成一个 Table
  • 方法2:直接通过 TableSource 去生成这么一个 Table

对于方法1,要从 DataStream 转化得到一个 Table,必须要提前保证原始的 DataStream 里面已经存在了 Timestamp 和 watermark

具体方式和 procatime 类似,只需要加上 “列名.rowtime” 就可以。

注意:
—— 要用 Processing Time,必须保证要新加的字段是整个 schema 中的最后一个字段
—— 要用 Event Time,其实可以去替换某一个已有的列或者额外定义一个字段,然后 Flink 会自动的把这一列转化成需要的 Event Time 这个类型。

// Option 1:
// 从流中提取时间戳并分配水印
DataStream<Tuple2<String, String>> stream = inputStream.assignTimestampsAndWatermarks(...);

// 添加一个字段作为 Event Time 字段
Table table = tEnv.fromDataStream(stream, "Username, Data, UserActionTime.rowtime");


// Option 2:
// 根据第一个字段提取时间戳,从流中分配水印
DataStream<Tuple3<Long, String, String>> stream = inputStream.assignTimestampsAndWatermarks(...);

// 第一个字段替换为 Event Time 字段
Table table = tEnv.fromDataStream(stream, "UserActionTime.rowtime, Username, Data");

// 使用:
WindowedTable windowedTable = table.window(Tumble.over("10.minutes").on("UserActionTime").as("userActionWindow"));

对于方法2,要通过 TableSource 生成一个 Table,必须要保证接入的数据里面存在一个类型为 long 或者 timestamp 的时间字段。同时要实现 DefinedRowtimeAttributes 接口。

注意:在 DataStream API 这一层其实不支持同时存在多个 Event Time,但是在 Table 这一层理论上可以同时存在多个 Event Time。因为 DefinedRowtimeAttributes 接口的返回值是一个对于 Event Time 描述的 List,即可以同时存在多个 Event Time 列。

// 定义具有 Event Time 属性的表源
public class UserActionSource implements StreamTableSource<Row>, DefinedRowtimeAttributes {

    @Override
    public TypeInformation<Row> getReturnType() {
        String[] names = new String[] {"Username", "Data", "UserActionTime"};
        TypeInformation[] types =
            new TypeInformation[] {Types.STRING(), Types.STRING(), Types.LONG()};
        return Types.ROW(names, types);
    }

    @Override
    public DataStream<Row> getDataStream(StreamExecutionEnvironment execEnv) {
        // create stream
        DataStream<Row> inputStream = ...;
        // 基于 "UserActionTime" 属性分配 Watermark
        DataStream<Row> stream = inputStream.assignTimestampsAndWatermarks(...);
        return stream;
    }

    @Override
    public List<RowtimeAttributeDescriptor> getRowtimeAttributeDescriptors() {
        // 将 "UserActionTime" 属性标记为 event-time 属性
        // 创建 "UserActionTime" 描述符,用来标识时间属性字段
        RowtimeAttributeDescriptor rowtimeAttrDescr = new RowtimeAttributeDescriptor(
            "UserActionTime",
            new ExistingField("UserActionTime"),
            new AscendingTimestamps());
        List<RowtimeAttributeDescriptor> listRowtimeAttrDescr = Collections.singletonList(rowtimeAttrDescr);
        return listRowtimeAttrDescr;
    }
}

// 注册表源
tEnv.registerTableSource("UserActions", new UserActionSource());

WindowedTable windowedTable = tEnv
    .scan("UserActions")
    .window(Tumble.over("10.minutes").on("UserActionTime").as("userActionWindow"));

2、时间列和 Table 操作

在这里插入图片描述
指定完时间列之后,我们要真正去查询时就会涉及一些具体的操作。

  • “ Over 窗口聚合” 和 “ Group by 窗口聚合” 这两种窗口聚合:在写 SQL 提供参数的时候只能允许在时间列上进行这种聚合操作。
  • 时间窗口连接:写条件时只支持对应的时间列。
  • 排序:我们不可能在一个无边界的数据流上做排序,但是因为这个数据本身到来的顺序已经是按照时间属性来进行排序,所以说如果要对一个 DataStream 转化成 Table 进行排序的话,只能是按照时间列进行排序,当然同时也可以指定一些其他的列,但是时间列是必须指定的,并且必须放在第一位

为什么说上面操作只能在时间列上进行?

因为我们有时候可以把到来的数据流看成是一张按照时间排列好的一张表,而我们任何对于表的操作,其实都是必须在对表进行一次顺序扫描对前提下完成的

数据流上某条数据处理过去之后,将来其实不太容易去访问它。但是因为 Flink 内部提供了一些状态机制,我们在一定程度上是可以访问某些数据,但是状态存储会被限制,存储状态不能太大。所以这些操作为什么只能在时间列上进行,因为这个时间列能够保证我们内部产生的状态不会无限的增长下去,到达某个时间点存储的状态就会被删除,这是一个最终的前提

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值