Flink基础学习笔记(五)time+watermark+window

写在前面

本章主要总结了Flink中time、watermark、window的相关知识点,对于相关的实现在这里弱化了,原因在于之前的学习中单纯了解了这些API的写法,但是对于为什么这么做似乎总是get不到点。总的来说,time本质上是由于Flink在流处理中需要进行顺序的一种标记方式,watermark则是为了缓解乱序所带来的问题,window则是为了在流中进行一些简单的聚合操作。
至此,Flink中的基本概念已经了解完毕,后续将会从源码和实际应用的案例两个角度对Flink进行学习。

1、time

1.1、time在Flink中的地位

Flink 的 API 大体上可以划分为三个层次:处于最底层的 ProcessFunction、中间一层的 DataStream API 和最上层的 SQL/Table API,这三层中的每一层都非常依赖于时间属性,由上到下,表达能力逐渐增强,便捷性逐渐减弱。时间属性是流处理中最重要的一个方面,是流处理系统的基石之一,贯穿这三层 API。在 DataStream API 这一层中因为封装方面的原因,我们能够接触到时间的地方不是很多,所以我们将重点放在底层的。
在这里插入图片描述

1.2、time的概述及分类

时间不是绝对的,不同“世界”中的时间语义各不相同。
本流处理中的时间本质上可以是一个普通的递增字段,不一定表示真实的时间。


在这里插入图片描述
针对Stream数据中的时间,Flink可以分为以下三种:

  • Ingestion Time:事件-数据进入Flink的时间,一般不用。
  • Processing Time:表示的是真实世界的时间,执行操作算子的本地系统时间,与机器相关。Processing Time在使用时是直接调取系统的时间,考虑到多线程或分布式系统的不确定性,所以它每次运行的结果可能是不确定的
  • Event Time:是数据当中包含的时间。因为Event Time时间戳是被写入每一条数据里的,所以在重放某个数据进行多次处理的时候,携带的这些时间戳不会改变,如果处理逻辑没有改变的话,最后的结果也是比较确定的。

2、watermark

2.1、概述

watermark本质上是一种处理时间乱序的方法之一,大多是为启发式的(猜测的),在延迟和完整性之间的平衡。

在这里插入图片描述

以上图的数据为例,按照1~7的时间来排列的。对于机器时间而言,每个机器的时间会单调增加。

  • 在这种情况下,用Processing Time获得的时间是完美的按照时间从小到大排序的数据
  • 对于Event Time而言,由于延迟或分布式的一些原因,数据到来的顺序可能和它们真实产生的顺序有一定的出入,数据可能存在着一定程度的乱序。这时就要充分利用数据里边携带的时间戳,对数据进行一个粗粒度的划分。例如可以把数据分为三组,第一组里最小的时间是1,第二组最小的时间是4,第三组最小的时间是7。这样划分之后,数据在组和组之间就是按从小到大的顺序排列好的。

怎样充分的把一定程度的乱序化解掉,让整个的系统看上去数据进来基本上是有顺序的?
一种解决方案是在数据中间插入被称为Watermark的meta数据。在上图的例子中,前三个数据到来之后,假设再没有小于等于3的数据进来了,这时就可以插入一条Watermark 3到整个数据里,系统在看到Watermark 3时就知道,以后都不会有小于或等于3的数据过来了,这时它就可以放心大胆地进行自己的一些处理逻辑。

2.2、timestamp分配与watermark的生成

  • Source:在SourceFunction中产生
    collectWithTimestamp(T element,long timestamp)
    emitWatermark(Watermark mark)
    通过 collectWithTimestamp 方法发送一条数据,其中第一个参数就是我们要发送的数据,第二个参数就是这个数据所对应的时间戳;也可以调用 emitWatermark 方法去产生一条 watermark,表示接下来不会再有时间戳小于等于这个数值记录。
  • Process:在流中指定
    DataStream.assignTimestampAndWatermarks(...),使用 DataStream API 的时候指定,调用的 DataStream.assignTimestampsAndWatermarks 这个方法,能够接收不同的 timestamp 和 watermark 的生成器。

对于watermark的生成,主要分为以下两种:

定期生成根据特殊记录生成
实现时间驱动数据驱动
每隔一段时间调用生成方法每一次分配Timestamp都会调用生成方法
实现AssignerWithPeriodicWatermarks实现AssignerWithPunctuatedWatermarks

两者的区别主要有三个方面,首先定期生成是现实时间驱动的,这里的“定期生成”主要是指 watermark(因为 timestamp 是每一条数据都需要有的),即定期会调用生成逻辑去产生一个 watermark。而根据特殊记录生成是数据驱动的,即是否生成 watermark 不是由现实时间来决定,而是当看到一些特殊的记录就表示接下来可能不会有符合条件的数据再发过来了,这个时候相当于每一次分配 Timestamp 之后都会调用用户实现的 watermark 生成方法,用户需要在生成方法中去实现 watermark 的生成逻辑。

在分配 timestamp 和生成 watermark 的过程,虽然在 SourceFunction 和 DataStream 中都可以指定,但是还是建议生成的工作越靠近 DataSource 越好。这样会方便让程序逻辑里面更多的 operator 去判断某些数据是否乱序。Flink 内部提供了很好的机制去保证这些 timestamp 和 watermark 被正确地传递到下游的节点。

2.3、watermark的传播

watermark的传播遵循以下原则:

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

watermark 在传播的时候有一个特点是,它的传播是幂等的。多次收到相同的 watermark,甚至收到之前的 watermark 都不会对最后的数值产生影响,因为对于单个输入永远是取最大的,而对于整个任务永远是取一个最小的

多输入取小,类似于木桶效应,以下图为例:

  1. 下游有3个箭头即3个并行子任务,上游有4个箭头即4个并行子任务,上游给每个分区保持一个watermark2 、4、3、6,最小的为2,所以自己当前的时钟为2,把2广播到下游;
  2. 第一个分区来了个数据4,更新watermark为4,再比较得到最小值为3,所以自己的时钟变成3,把3广播出去(表3之前的数据都处理完了);
  3. 又来了一个7,把第二个分区的watermark改了,发现最小值没有变还是3,把3往下游传递;
  4. 第三个分区的数据6来了,把第三个分区的watermark变成6,再比较最小值得到4,最后把4广播出去。
    在这里插入图片描述

这种设计传播方式存在一种缺陷,没有区分逻辑上的单流和多流,强制同步时钟
对于同一个流的不同 partition,我们对他做这种强制的时钟同步是没有问题的,因为一开始就是把一条流拆散成不同的部分,但每一个部分之间共享相同的时钟。但是如果算子的任务是在做类似于 JOIN 操作,那么要求你两个输入的时钟强制同步其实没有什么道理的,因为完全有可能是把一条离现在时间很近的数据流和一个离当前时间很远的数据流进行 JOIN,这个时候对于快的那条流,因为它要等慢的那条流,所以说它可能就要在状态中去缓存非常多的数据,这对于整个集群来说是一个很大的性能开销。

2.4、watermark的处理

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

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

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

一个简单的应用是,我们在做一些时间相关的处理的时候,可能需要缓存一部分数据,但这些数据不能一直去缓存下去,所以需要有一些过期的机制,我们可以通过 timer 去设定这么一个时间,指定某一些数据可能在将来的某一个时间点过期,从而把它从状态里删除掉。所有的这些和时间相关的逻辑在 Flink 内部都是由自己的 Time Service(时间服务)完成的。

watermark的处理过程主要分为以下三步:

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

2.5、table API中的时间

2.5.1、table中指定时间列

Flink在流中,将table视为动态table。

其实社区就怎么在 Table/SQL 中去使用时间这个问题做过一定的讨论,是把获取当前 Processing Time 的方法是作为一个特殊的 UDF,还是把这一个列物化到整个的 schema 里面,最终采用了后者。我们这里就分开来讲一讲 Processing Time 和 Event Time 在使用的时候怎么在 Table 中指定。

在这里插入图片描述
对于 Processing Time,我们知道要得到一个 Table 对象(或者注册一个 Table)有两种手段:

  • 可以从一个 DataStream 转化成一个 Table;
  • 直接通过 TableSource 去生成这么一个 Table;

对于第一种方法而言,我们只需要在你已有的这些列中(例子中 f1 和 f2 就是两个已有的列),在最后用“列名.proctime”这种写法就可以把最后的这一列注册为一个 Processing Time,以后在写查询的时候就可以去直接使用这一列。如果 Table 是通过 TableSource 生成的,就可以通过实现这一个 DefinedRowtimeAttributes 接口,然后就会自动根据你提供的逻辑去生成对应的 Processing Time。

相对而言,在使用 Event Time 时则有一个限制,因为 Event Time 不像 Processing Time 那样是随拿随用。如果你要从 DataStream 去转化得到一个 Table,必须要提前保证原始的 DataStream 里面已经存在了 Record Timestamp 和 watermark。如果你想通过 TableSource 生成的,也一定要保证你要接入的一个数据里面存在一个类型为 long 或者 timestamp 的这么一个时间字段。

具体来说,如果你要从 DataStream 去注册一个表,和 proctime 类似,你只需要加上“列名.rowtime”就可以。需要注意的是,如果你要用 Processing Time,必须保证你要新加的字段是整个 schema 中的最后一个字段,而 Event Time 的时候你其实可以去替换某一个已有的列,然后 Flink 会自动的把这一列转化成需要的 rowtime 这个类型。 如果是通过 TableSource 生成的,只需要实现 DefinedRowtimeAttributes 接口就可以了。需要说明的一点是,在 DataStream API 这一侧其实不支持同时存在多个 Event Time(rowtime),但是在 Table 这一层理论上可以同时存在多个 rowtime。因为 DefinedRowtimeAttributes 接口的返回值是一个对于 rowtime 描述的 List,即其实可以同时存在多个 rowtime 列,在将来可能会进行一些其他的改进,或者基于去做一些相应的优化

2.5.1、时间列和table操作

为了限制状态的无限增长,下面一些操作必须在时间列上进行。

  • over窗口聚合(over window aggregation)
  • group by窗口聚合(group by window aggregation)
  • 时间窗口连接(Time-windowed join)
  • 排序(order by):一个无尽的数据流上对数据做排序几乎是不可能的事情,但因为这个数据本身到来的顺序已经是按照时间属性来进行排序,所以说我们如果要对一个 DataStream 转化成 Table 进行排序的话,你只能是按照时间列进行排序,当然同时你也可以指定一些其他的列,但是时间列这个是必须的,并且必须放在第一位

为什么说这些操作只能在时间列上进行
因为我们有的时候可以把到来的数据流就看成是一张按照时间排列好的一张表而我们任何对于表的操作,其实都是必须在对它进行一次顺序扫描的前提下完成的。因为大家都知道数据流的特性之一就是一过性,某一条数据处理过去之后,将来其实不太好去访问它。当然因为 Flink 中内部提供了一些状态机制,我们可以在一定程度上去弱化这个特性,但是最终还是不能超越的限制状态不能太大。所有这些操作为什么只能在时间列上进行,因为这个时间列能够保证我们内部产生的状态不会无限的增长下去,这是一个最终的前提。

3、window

3.1、window的应用场景

网上的大部分参考资料说的都是窗口是流处理进行切分得到有界流…,其实这种说法本身在将Flink的设计思想替换为了Spark的设计思想,个人感觉这种理解并没有理解Flink的核心,感觉还是从实际应用场景中的需求出发,似乎更能说明为什么需要窗口。

下图显示了窗口的应用场景,包括aggregate、merge、join等操作,其实这些操作可以看出,需要对流中的数据进行局部聚合
在这里插入图片描述

3.2、window抽象概念

下图从编程API角度显示了窗口使用的整个流程,红色部分表示可以自定义模块,通过自定义这些模块的组合,我们可以实现高级的窗口应用。同时 Flink 也提供了一些内置的实现,可以用来做一些简单应用。

  • TimestampAssigner: 时间戳分配器,假如我们使用的是 EventTime 时间语义,就需要通过 TimestampAssigner 来告诉 Flink 框架,元素的哪个字段是事件时间,用于后面的窗口计算。
  • KeySelector:Key 选择器,用来告诉 Flink 框架做聚合的维度有哪些。
  • WindowAssigner:窗口分配器,用来确定哪些数据被分配到哪些窗口。
  • State:状态,用来存储窗口内的元素,如果有 AggregateFunction,则存储的是增量聚合的中间结果。
  • AggregateFunction(可选):增量聚合函数,主要用来做窗口的增量计算,减轻窗口内 State 的存储压力。
  • Trigger:触发器,用来确定何时触发窗口的计算。
  • Evictor(可选):驱逐器,用于在窗口函数计算之前(后)对满足驱逐条件的数据做过滤。
  • WindowFunction:窗口函数,用来对窗口内的数据做计算。
  • Collector:收集器,用来将窗口的计算结果发送到下游。

在这里插入图片描述

3.3、window编程接口

下面给出了一个关于窗口的示例代码,相关API和抽象概念的对应关系见注释。

SingleOutputStreamOperator<Long> result = dataStreamSource
        // TimestampAssigner
        .assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<Tuple2<String, Long>>(Time.seconds(5)) {
            @Override
            public long extractTimestamp(Tuple2<String, Long> element) {
                return element.f1;
            }
        })
        // KeySelector
        .keyBy(new KeySelector<Tuple2<String, Long>, String>() {
            @Override
            public String getKey(Tuple2<String, Long> value) throws Exception {
                return value.f0;
            }
        })
        // WindowAssigner
        .window(TumblingEventTimeWindows.of(Time.minutes(5)))
        // Trigger
        .trigger(CountTrigger.of(1))
        // Aggregate/ndow Function
        .aggregate(new AggregateFunction<Tuple2<String, Long>, Long, Long>() {
            @Override
            public Long createAccumulator() {
                return null;
            }

            @Override
            public Long add(Tuple2<String, Long> value, Long accumulator) {
                return null;
            }

            @Override
            public Long getResult(Long accumulator) {
                return null;
            }

            @Override
            public Long merge(Long a, Long b) {
                return null;
            }
        });

3.4、Window Assigner

Window Assigner定义了窗口的类型。

下图给出了Flink中常见的窗口示意图。整体上,窗口分为时间窗口、计数窗口、用户自定义窗口。

  • 时间窗口TimeWindow:滚动时间窗口(Tumbling Window)、滑动时间窗口(Sliding Window)、会话时间窗口(Session Window)。

  • 计数窗口CountWindow:按照指定的数据条数生成一个Window,与时间无关。分为滚动计数窗口、滑动计数窗口。CountWindow根据窗口中相同key元素的数量来触发执行,执行时只计算元素数量达到窗口大小的key对应的结果。
    注意:CountWindow的window_size指的是相同Key的元素的个数,不是输入的所有元素的总数。
    在这里插入图片描述
    具体来说,常见的窗口包括:

  • 滚动窗口(Tumbling Windows):将数据依据固定的窗口长度对数据进行切片。
    特点:时间对齐,窗口长度固定,没有重叠。 它是步长 = site的滑动窗口;
    使用场景:商业BI分析统计(关注的商业指标往往是某个时间段的指标,如一天或一周的销售额,每个时间段的聚合操作)

  • 滑动窗口(Sliding Windows):滑动窗口是滚动窗口的更广义的一种形式,滑动窗口由固定的窗口长度和滑动间隔组成。
    特点:时间对齐,窗口长度固定,可以有重叠。
    适用场景:对最近一个时间段内的统计(求某接口最近5min的失败率来决定是否要报警);
    灵活;连续的波浪;比如股票交易所它是最近24小时的涨跌幅度,随时往后算随时往后划;

  • 会话窗口(Session Windows):由一系列事件组合一个指定时间长度的timeout间隙组成,类似于web应用的session,也就是一段时间没有接收到新数据就会生成新的窗口。
    特点:时间无对齐。
    session窗口分配器通过session活动来对元素进行分组,session窗口跟滚动窗口和滑动窗口相比,不会有重叠和固定的开始时间和结束时间的情况,相反,当它在一个固定的时间周期内不再收到元素,即非活动间隔产生,那个这个窗口就会关闭。一个session窗口通过一个session间隔来配置,这个session间隔定义了非活跃周期的长度,当这个非活跃周期产生,那么当前的session将关闭并且后续的元素将被分配到新的session窗口中去。

  • 滚动计数窗口:默认的CountWindow是一个滚动窗口,只需要指定窗口大小即可,当元素数量达到窗口大小时,就会触发窗口的执行。

  • 滑动计数窗口:滑动窗口和滚动窗口的函数名是完全一致的,只是在传参数时需要传入两个参数,一个是window_size,一个是sliding_size。

3.5、Window Trigger

Window Trigger决定了窗口什么时候触发计算。

Flink内置的Trigger如下:
在这里插入图片描述

对于Trigger和Evictor的详细使用案例,参考连接:https://files.alicdn.com/tpsservice/73a1f1c404d2a658585cf4f4d86ef776.pdf

3.6、Window Evictor

Window Evictor主要用于移除一些在进行窗口计算之前的事件。

下面是Flink中内置的一些Evictor 。
在这里插入图片描述

3.7、Window Function

window function定义了要对窗口中收集的数据做的计算操作,可以分为两类:

  • 增量聚合函数(incremental aggregation functions):每条数据到来就进行计算,保持一个简单的状态。如:ReduceFunction, AggregateFunction。
  • 全窗口函数(full window functions):先把窗口所有数据都收集起来,等到计算的时候会遍历所有数据。如:ProcessWindowFunction。

AggregateFunction,它是高级别的抽象,主要用来做增量聚合,每来一条元素都做一次聚合,这样状态里只需要存最新的聚合值。
优点:增量聚合,实现简单。
缺点:输出只有一个聚合值,使用场景比较局限。
在这里插入图片描述

ProcessWindowFunction,它是低级别的抽象用来做全量聚合,每来一条元素都存在状态里面,只有当窗口触发计算时才会调用这个函数。
优点:可以获取到窗口内所有数据的迭代器,实现起来比较灵活;可以获取到聚合的 Key 以及可以从上下文 Context 中获取窗口的相关信息。
缺点:需要存储窗口内的全量数据,State 的压力较大。
在这里插入图片描述

其他,这两种窗口并不是完全隔离的,通过一定条件可以进行相互转换。我们可以把这两种方式结合起来使用,通过 AggregateFunction 做增量聚合,减少中间状态的压力。通过ProcessWindowFunction 来输出我们想要的信息,比如聚合的 Key 以及窗口的信息
在这里插入图片描述

参考资料

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值