day3_Flink基础

文章目录

Flink基础

今日课程内容目标

  • Flink中的一些重要概念
  • Flink的时间语义
  • 事件时间语义中的watermark
  • Flink的窗口(Window)

Flink中的一些重要概念

Flink程序提交流程图解:

Flink的数据流图


  • DataFlow Graph(数据流图)

    是代码写完之后,根据API就生成了。

  • Job Graph(任务图)

    是客户端根据数据流图优化后的图。

  • Execution Graph(执行图)

    是客户端把任务图(Job Graph)提交给集群后,集群的JobManager根据任务图解析,转换成了Execution Graph。

  • Physical Graph(物理图)

    是JobManager把Executor Graph调度给TaskManager执行,TaskManager收到Execution Graph后,把执行图解析,转换成了能够具体执行任务的物理图。

总结:

  • 用户通过算子api所开发的代码,会被flink任务提交客户端解析成jobGraph

  • 然后,jobGraph提交到集群JobManager,转化成ExecutionGraph(并行化后的执行图)

  • 然后,ExecutionGraph中的各个task会以多并行实例(subTask)部署到taskmanager上执行

  • subTask运行的位置是taskmanager所提供的槽位(task slot),槽位简单理解就是线程;

并行度(Parallelism)

当前数据流中有 source、map、window、sink 四个算子,除最后 sink,其他算子的并行度都为 2。整个程序包含了 7 个子任务,至少需要 2 个分区来并行执行。我们可以说,这段流处理程序的并行度就是 2。

  • flink程序中,每一个算子都可以成为一个独立任务(task)
  • 每个任务在运行时都可拥有多个并行的运行实例(subTask)
  • 且每个算子任务的并行度都可以在代码中显式设置;

Flink提供了四种设置并行度的方式

  • 配置文件(默认的,优先级最低)
  • 任务提交(-p 3)
  • 代码全局层面(env.setParallelism(3))
  • 算子层面(map.setParallelism(3),优先级最高)

优先级的高低顺序如下:

  • 算子层面 -> 代码全局层面 -> 任务提交 -> 配置文件

算子&算子链

算子间的数据传输

如上图所示,一个数据流在算子之间传输数据的形式可以是一对一(one-to-one)的直通 (forwarding)模式,也可以是打乱的重分区(redistributing)模式,具体是哪一种形式,取决于算子的种类。

  • 一对一(One-to-one,forwarding)

    数据流维护着分区以及元素的顺序。比如图中的 source 和 map 算子,source算子读取数据之后,可以直接发送给 map 算子做处理,它们之间不需要重新分区,也不需要调整数据的顺序。这就意味着 map 算子的子任务,看到的元素个数和顺序跟 source 算子的子任务产生的完全一样,保证着"一对一"的关系。map、filter、flatMap 等算子都是这种 one-to-one的对应关系。这种关系类似于 Spark 中的窄依赖

  • 重分区(Redistributing)

    数据流的分区会发生改变。比图中的 map 和后面的 keyBy/window 算子之间(这里的 keyBy 是数据传输算子,后面的 window、apply 方法共同构成了 window 算子),以及 keyBy/window 算子和 Sink 算子之间,都是这样的关系。

    每一个算子的子任务,会根据数据传输的策略,把数据发送到不同的下游目标任务。例如,keyBy()是分组操作,本质上基于键(key)的哈希值(hashCode)进行了重分区;而当并行度改变时,比如从并行度为 2 的 window 算子,要传递到并行度为 1 的 Sink 算子,这时的数据传输方式是再平衡(rebalance),会把数据均匀地向下游子任务分发出去。这些传输方式都会引起重分区(redistribute)的过程,这一过程类似于 Spark 中的 shuffle。

    总体说来,这种算子间的关系类似于 Spark 中的宽依赖

合并算子链

上下游算子,能否chain在一起,放在一个Task中,取决于如下3个条件:

  • 上下游算子实例间是oneToOne数据传输(forward);
  • 上下游算子并行度相同;
  • 上下游算子属于相同的slotSharingGroup(槽位共享组);

3个条件都满足,才能合并为一个task;否则不能合并成一个task;

当然,即使满足上述3个条件,也不一定就非要把上下游算子绑定成算子链;

flink提供了相关的api,来让用户可以根据自己的需求,进行灵活的算子链合并或拆分;

  • setParallelism 设置算子的并行度
  • slotSharingGroup 设置算子的槽位共享组
  • disableChaining 对算子禁用前后链合并
  • startNewChain 对算子开启新链(即禁用算子前链合并)

任务(Tasks)和任务槽(Task Slots)

任务槽(Task Slots)

每个任务槽(task slot)其实表示了 TaskManager 拥有计算资源的一个固定大小的子集。这些资源就是用来独立执行一个子任务的。

假如一个 TaskManager 有三个 slot,那么它会将管理的内存平均分成三份,每个 slot 独自占据一份。这样一来,我们在 slot 上执行一个子任务时,相当于划定了一块内存"专款专用",就不需要跟来自其他作业的任务去竞争内存资源了。所以现在我们只要 2 个 TaskManager,就可以并行处理分配好的 5 个任务了,如上图所示。

任务槽数量的设置

通过集群的配置文件来设定 TaskManager 的 slot 数量:

xml 复制代码
taskmanager.numberOfTaskSlots: 8

注意:

slot 目前仅仅用来隔离内存,不会涉及 CPU 的隔离。在具体应用时,可以将 slot 数量配置为机器的 CPU 核心数,尽量避免不同任务之间对 CPU 的竞争。这也是开发环境默认并行度设为机器 CPU 数量的原因。

任务对任务槽的共享

如上图所示,只要属于同一个作业,那么对于不同任务节点的并行子任务,就可以放到同一个 slot 上执行。所以对于第一个任务节点 source→map,它的 6 个并行子任务必须分到不同的 slot 上(如果在同一 slot 就没法数据并行了),而第二个任务节点 keyBy/window/apply 的并行子任务却可以和第一个任务节点共享 slot。

任务槽和并行度的关系

Slot 和并行度确实都跟程序的并行执行有关,但两者是完全不同的概念。简单来说,taskslot 是 静 态 的 概 念 , 是 指 TaskManager 具 有 的最大 并 发 执 行 能 力 , 可 以 通 过 参 数taskmanager.numberOfTaskSlots 进行配置;而并行度(parallelism)是动态概念,也就是TaskManager 运行程序时实际使用的并发能力,可以通过参数 parallelism.default 进行配置。换句话说,并行度如果小于等于集群中可用 slot 的总数,程序是可以正常执行的,因为 slot 不一定要全部占用,有十分力气可以只用八分;而如果并行度大于可用 slot 总数,导致超出了并行能力上限,那么心有余力不足,程序就只好等待资源管理器分配更多的资源了。

下面我们再举一个具体的例子。假设一共有 3 个 TaskManager,每一个TaskManager 中的slot 数量设置为 3 个,那么一共有 9 个 task slot,如下图所示,表示集群最多能并行执行 9个任务。

定义 WordCount 程序的处理操作是四个转换算子:source→ flatMap→ reduce→ sink。

当所有算子并行度相同时,容易看出 source 和 flatMap 可以合并算子链,于是最终有三个任务节点。

如果我们没有任何并行度设置,而配置文件中默认 parallelism.default=1,那么程序运行的默认并行度为 1,总共有 3 个任务。由于不同算子的任务可以共享任务槽,所以最终占用的 slot

只有 1 个。9 个 slot只用了 1 个,有 8 个空闲,如下图所示。




如果我们更改默认参数,或者提交作业时设置并行度为 2,那么总共有 6 个任务,共享任务槽之后会占用 2 个 slot,如图****示例二****所示。同样,就有 7 个 slot 空闲,计算资源没有充分利用。所以可以看到,设置合适的并行度才能提高效率。

那对于这个例子,怎样设置并行度效率最高呢?当然是需要把所有的 slot 都利用起来。考虑到 slot 共享,我们可以直接把并行度设置为 9,这样所有 27 个任务就会完全占用 9 个 slot。这是当前集群资源下能执行的最大并行度,计算资源得到了充分的利用,如图示例三所示。

另外再考虑对于某个算子单独设置并行度的场景。例如,如果我们考虑到输出可能是写入文件,那会希望不要并行写入多个文件,就需要设置 sink 算子的并行度为 1。这时其他的算子并行度依然为 9,所以总共会有 19 个子任务。根据 slot 共享的原则,它们最终还是会占用全部的 9 个 slot,而 sink 任务只在其中一个 slot 上执行,如图示例四所示。通过这个例子也可以明确地看到,整个流处理程序的并行度,就应该是所有算子并行度中最大的那个,这代表了运行程序需要的 slot 数量。

总结

Flink集群 -> Job(作业) -> Task(任务,根据宽依赖算子) -> SubTask(子任务,并行度)

备注:

Slot(槽) 固定的静态资源

并行度:动态的任务执行参数

并行度 <= 可用槽数量

比如说:slot,只有4个

但是,并行度设置为5

Standalone集群,执行失败的。

Yarn集群,可以正常执行。因为Yarn可以动态开启Container(TaskManager)

Flink的时间语义

三种时间概念

在实时流式计算中,时间是一个能影响计算结果的非常重要因素!

试想场景:每隔1分钟计算一次最近10分钟的活跃用户量:

①假设此刻的时间是13:10,要计算的活跃用户量时间段为:[ 13:00,13:10 );

②有一条行为日志中记录的用户的行为时间是12:59,但到达flink计算程序时已是13:02;

那么,这个用户是否要纳入本次计算的结果中呢?看如何定义:

①如果时段 [13:00 , 13:10 )定义的是用户行为的发生时间(数据中的业务时间),则不应纳入;

②如果时段 [13:00 , 13:10 )定义的是计算时的时间,则应该纳入;

flink内部为了直观地统一计算时所用的时间标准,特制定了三种时间语义:

  • processing time 处理时间
  • event time 事件时间
  • Ingestion time 注入时间

时间语义主要影响 "窗口计算"

两种时间语义

时间语义,是flink中用于时间推进和时间判断的机制;

时间推进和时间判断,以什么为标准,就产出了两种不同的时间语义;

  • 以 processing time为依据,则叫做处理时间语义
    • Processing Time是指数据被Operator处理时所在机器的系统时间。
    • 处理时间遵循客观世界中时间的特性:单调递增,恒定速度,永不停滞,永不回退;
  • 以 event time为依据,则叫做事件时间语义
    • Event Time是指在数据本身的业务时间(如用户行为日志中的用户行为时间戳);
    • Event Time语义中,时间的推进完全由流入flink系统的数据来驱动
    • 数据中的业务时间推进到哪,flink就认为自己的时间推进到了哪;它可能停滞,也可能速度不恒定,但也一定是单调递增不可回退

事件时间(Event Time)的意义

  • 准确性处理乱序数据:事件时间是数据记录中实际事件发生的时间。在实际应用中,数据往往由于网络延迟、分布式系统的不同步等原因而出现乱序。例如,在一个日志收集系统中,由于网络拥塞,某条日志可能会延迟到达处理节点。通过使用事件时间语义,Flink 可以根据数据本身携带的事件发生时间来进行窗口计算等操作,而不是根据数据到达系统的时间。这样可以准确地计算出在真实世界的某个时间范围内(如每小时的网站访问量)的数据聚合结果,避免了因乱序数据导致的计算错误。
  • 符合业务逻辑:从业务角度看,很多业务规则是基于事件实际发生的时间来定义的。以电商订单处理为例,计算某一天的订单总额应该是基于订单创建时间(事件时间),而不是订单数据被 Flink 系统接收的时间。这种基于事件时间的处理方式能够更好地满足业务需求,保证数据处理结果的准确性和业务价值。

处理时间(Processing Time)的意义

  • 简单高效的处理方式:处理时间是数据进入 Flink 系统后,系统对其进行操作的时间。它相对事件时间来说,更容易获取和处理。在一些对时间精度要求不是特别高,或者数据基本是有序到达的场景中,使用处理时间可以简化系统的设计和实现。例如,在一个简单的实时监控系统中,只是实时统计当前一分钟内进入系统的数据量,此时使用处理时间就可以快速实现这个功能,不需要考虑数据的乱序问题。
  • 低延迟快速响应:处理时间语义能够提供较低的延迟响应。因为它是基于系统处理数据的时间,不需要等待数据的时间戳对齐或者处理乱序数据。对于一些需要快速做出反应的场景,如实时广告投放系统,根据处理时间来决定是否投放广告可以更快地响应市场变化和用户行为,提高广告投放的时效性。

时间语义的设置

在需要指定时间语义的相关操作(如时间窗口)时,可以通过显式的api来使用特定的时间语义

java 复制代码
//基于事件时间的滑动窗口
keyedStream.window(SlidingEventTimeWindows.of(Time.seconds(5),Time.seconds(1)));
//基于处理时间的滑动窗口
keyedStream.window(SlidingProcessingTimeWindows.of(Time.seconds(5),Time.seconds(1)));
//基于事件时间的滚动窗口
keyedStream.window(TumblingEventTimeWindows.of(Time.seconds(5)));
//基于处理时间的滚动窗口
keyedStream.window(TumblingProcessingTimeWindows.of(Time.seconds(5)));

事件时间语义中的watermark

事件时间推进的困难

由于在事件时间语义的世界观中,时间是由流入系统的数据(事件)而推进的;

而事件时间,并不能像处理时间那样,由宇宙客观规律以恒定速度,不可停滞地推进;

从而,在事件时间语义的世界观中,时间的推进,并不是一件显而易见的事情;

  • 场景一

​ 数据时间存在乱序的可能性,但时光不能倒流啊!

  • 场景二

​ 下游分区接收上游多个分区的数据,数据时间错落有致,那以谁为准?!

watermark来推进时间

"水位线 "(Watermark)就是类似于水流中用来做标志的记号,在 Flink 中,这种用来衡量事件时间(Event Time)进展的标记。

具体实现上,水位线可以看作一条特殊的数据记录,它是插入到数据流中的一个标记点,主要内容就是一个时间戳,用来指示当前的事件时间。而它插入流中的位置,就应该是在某个数据到来之后;这样就可以从这个数据中提取时间戳,作为当前水位线的时间戳了

每个事件产生的数据,都包含了一个时间戳,我们直接用一个整数表示

这里没有指定单位,可以理解为秒。当产生于 2 秒的数据到来之后,当前的事件时间就是 2 秒;在后面插入一个时间戳也为 2 秒的水位线,随着数据一起向下游流动。而当 5 秒产生的数据到来之后,同样在后面插入一个水位线,时间戳也为 5,当前最大的水位线时间戳为 5 秒。

水位线就像它的名字所表达的,是数据流中的一部分,随着数据一起流动,在不同任务之间传输。这看起来非常简单;接下来我们就进一步探讨一些复杂的状况。

有序流中的水位线

需要注意的是,水位线插入的"周期",本身也是一个时间概念。周期时间是指处理时间(系统时间),而不是事件时间,如每隔200ms生成一次水印线

乱序流中的水位线

这里的乱序指的是数据到达的先后顺序,未必与数据实际产生的时间顺序保持一致 。如:

如图所示,一个 7 秒时产生的数据,生成时间自然要比 9 秒的数据早;但是经过数据缓存和传输之后,处理任务可能先收到了 9 秒的数据,之后 7 秒的数据才姗姗来迟。这时如果我们希望插入水位线,来指示当前的事件时间进展,又该怎么做呢?

解决思路也很简单:我们插入新的水位线时,要先判断一下时间戳是否比之前的大,否则就不再生成新的水位线,如图所示。也就是说,只有数据的时间戳比历史的时间戳更大,才能推动时间戳前进,这时才插入水位线

如果考虑到大量数据同时到来的处理效率,我们同样可以周期性地生成水位线。这时只需要保存一下之前所有数据中的最大时间戳,需要插入水位线时,就直接以它作为时间戳生成新的水位线,如图所示。

水位线并不能彻底解决乱序数据的处理问题,比如网络原因、背压等因素导致的长时间的"数据迟到",往往生产中水位线时间设置为秒级,对于小时、天级别的迟到需要结合allowedLateness和SideOutput来实现,后面会进行详细讲解。

总结
  • 水位线是插入到数据流中的一个标记,可以认为是一个特殊的数据
  • 水位线主要的内容是一个时间戳,用来表示当前事件时间的进展
  • 水位线是基于数据的时间戳生成的
  • 水位线的时间戳必须单调递增,以确保任务的事件时间时钟一直向前推进
  • 水位线可以通过设置延迟,来保证正确处理乱序数据
  • 一个水位线 Watermark(t),表示在当前流中事件时间已经达到了时间戳 t, 这代表 t 之前的所有数据都到齐了,之后流中不会出现时间戳 t' ≤ t 的数据
  • 水位线是 Flink 流处理中保证结果正确性的核心机制,它往往会跟窗口一起配合,完成对乱序数据的正确处理。

内置watermark生成策略

watermark的生成值算法策略

  • 紧跟最大事件时间的watermark生成策略(完全不容忍乱序)

    java 复制代码
    WatermarkStrategy.forMonotonousTimestamps();
  • 允许乱序的watermark生成策略(最大事件时间-容错时间)

    java 复制代码
    WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(10)); // 根据实际数据的最大乱序情况来设置

    BoundedOutOfOrderness策略的时间推进示意图 watermark = MaxEventTime - 5)

  • 自定义watermark生成策略

    java 复制代码
    WatermarkStrategy.forGenerator(new WatermarkGenerator(){ ... } );

设置watermark策略的代码模板

java 复制代码
SingleOutputStreamOperator<Score> stream1 = source.map(line -> {
    String[] arr = line.split(",");
    return new Score(....);
}).assignTimestampsAndWatermarks(
        WatermarkStrategy.<Score>forBoundedOutOfOrderness(Duration.ZERO)
                .withTimestampAssigner((element, recordTimestamp) -> element.getTimeStamp())
                // 防止上游某些分区的水位线不推进导致下游的窗口一直不触发(这个分区很久都没数据)
                .withIdleness(Duration.ofMillis(2000)) 
);

WaterMark生成算子工作机制

演示示例:内置水位线生成器

java 复制代码
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.ProcessFunction;
import org.apache.flink.util.Collector;

/**
 * @author : www.itcast.cn
 * @date : 22.10.24 17:56
 * @desc : 单并行度情况下的watermark测试
 *   ==> 在socket端口依次输入如下两条数据:
 *   1,hadoop,2000
 *   1,hadoop,3000
 *
 *   ==> 程序的控制台上会依次输出如下信息:
 *      本次收到的数据EventBean(guid=1, eventId=hadoop, timeStamp=2000)
 *      此刻的watermark: -9223372036854775808
 *      此刻的处理时间(processing time): 1666662299181
 * =============================================
 *     EventBean(guid=1, eventId=hadoop, timeStamp=2000)
 *     本次收到的数据EventBean(guid=1, eventId=hadoop, timeStamp=3000)
 *     此刻的watermark: 1999
 *     此刻的处理时间(processing time): 1666662341204
 * =============================================
 *
 *  1,hadoop,4000
 *  1,hadoop,5000
 *  1,hadoop,6000
 **/
public class WaterMark_Demo {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        // 1,hadoop,2000
        DataStreamSource<String> socketTextStream = env.socketTextStream("node1", 9999);

        SingleOutputStreamOperator<EventBean> mapStream = socketTextStream.map(s -> {
                    String[] split = s.split(",");
                    return new EventBean(Long.parseLong(split[0]), split[1], Long.parseLong(split[2]));
                }).returns(EventBean.class)
                    .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<EventBean>forMonotonousTimestamps()
                                .withTimestampAssigner(new SerializableTimestampAssigner<EventBean>() {
                                    @Override
                                    public long extractTimestamp(EventBean eventBean, long recordTimestamp) {
                                        return eventBean.getTimeStamp();
                                    }
                                })
                );

        mapStream.process(new ProcessFunction<EventBean, EventBean>() {
            @Override
            public void processElement(EventBean eventBean, ProcessFunction<EventBean, EventBean>.Context ctx, Collector<EventBean> out) throws Exception {
                System.out.println("=============================================");

                // 打印此刻的 watermark
                long processTime = ctx.timerService().currentProcessingTime();
                long watermark = ctx.timerService().currentWatermark();

                System.out.println("本次收到的数据" + eventBean);
                System.out.println("此刻的watermark: " + watermark);
                System.out.println("此刻的处理时间(processing time): " + processTime );
                System.out.println("=============================================");
                out.collect(eventBean);
            }
        }).printToErr();

        env.execute();
    }
}

水印时间是根据事件时间产生的,在事件时间基础上-1毫秒生成的

演示示例:自定义周期性水位线生成器(Periodic Generator)

java 复制代码
import org.apache.flink.api.common.eventtime.*;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.ProcessFunction;
import org.apache.flink.util.Collector;

import java.time.Duration;

/**
 * @author : www.itcast.cn
 * @date : 22.10.25 10:20
 * @desc : 自定义周期性水位线生成器
 *   ==> 在socket端口依次输入如下两条数据:
 *   1,hadoop,2000
 *   1,hadoop,3000
 *
 *   ==> 程序的控制台上会依次输出如下信息:
 *      本次收到的数据EventBean(guid=1, eventId=hadoop, timeStamp=2000)
 *      此刻的watermark: -9223372036854775808
 *      此刻的处理时间(processing time): 1666662299181
 *  =============================================
 *     EventBean(guid=1, eventId=hadoop, timeStamp=2000)
 *     本次收到的数据EventBean(guid=1, eventId=hadoop, timeStamp=3000)
 *     此刻的watermark: 1999
 *     此刻的处理时间(processing time): 1666662341204
 *  =============================================
 *
 *   1,hadoop,4000
 *   1,hadoop,5000
 *   1,hadoop,6000
 **/
public class CustomWatermark_Demo {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        //生成watermark的周期
        env.getConfig().setAutoWatermarkInterval(1000l);

        DataStreamSource<String> socketTextStream = env.socketTextStream("node1", 9999);

        SingleOutputStreamOperator<EventBean> mapStream = socketTextStream.map(s -> {
                    String[] split = s.split(",");
                    return new EventBean(Long.parseLong(split[0]), split[1], Long.parseLong(split[2]));
                }).returns(EventBean.class);

        mapStream
                .assignTimestampsAndWatermarks(new CustomWatermarkStrategy(Duration.ZERO))
                .process(new ProcessFunction<EventBean, EventBean>() {
                    @Override
                    public void processElement(EventBean eventBean, ProcessFunction<EventBean, EventBean>.Context ctx, Collector<EventBean> out) throws Exception {
                        System.out.println("=============================================");

                        // 打印此刻的 watermark
                        long processTime = ctx.timerService().currentProcessingTime();
                        long watermark = ctx.timerService().currentWatermark();

                        System.out.println("本次收到的数据" + eventBean);
                        System.out.println("此刻的watermark: " + watermark);
                        System.out.println("此刻的处理时间(processing time): " + processTime );
                        System.out.println("=============================================");
                        out.collect(eventBean);
                    }
                }).printToErr();

        env.execute();
    }

    /**
     * 自定义周期性水位线生成器
     */
    public static class CustomWatermarkStrategy implements WatermarkStrategy<EventBean> {
        private Duration maxOutOfOrderness; // 延迟时间
        public CustomWatermarkStrategy(Duration maxOutOfOrderness) {
            this.maxOutOfOrderness = maxOutOfOrderness;
        }
        @Override
        public TimestampAssigner<EventBean> createTimestampAssigner(TimestampAssignerSupplier.Context context) {
            return new SerializableTimestampAssigner<EventBean>() {
                @Override
                public long extractTimestamp(EventBean element, long recordTimestamp) {
                    return element.getTimeStamp(); // 告诉程序数据源里的时间戳是哪一个字段
                }
            };
        }

        @Override
        public WatermarkGenerator<EventBean> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
            return new CustomPeriodicGenerator(this.maxOutOfOrderness);
        }
    }

    public static class CustomPeriodicGenerator implements WatermarkGenerator<EventBean> {
        private final long outOfOrdernessMillis; // 延迟时间
        private long maxTimestamp; // 观察到的最大时间戳

        public CustomPeriodicGenerator(Duration maxOutOfOrderness){
            this.outOfOrdernessMillis = maxOutOfOrderness.toMillis();

            // start so that our lowest watermark would be Long.MIN_VALUE.
            this.maxTimestamp = Long.MIN_VALUE + outOfOrdernessMillis + 1;
        }

        /**
         * 每条数据都会触发该方法
         * @param event
         * @param eventTimestamp
         * @param output
         */
        @Override
        public void onEvent(EventBean event, long eventTimestamp, WatermarkOutput output) {
            System.out.println("onEvent....");
            // 每来一条数据就调用一次
            maxTimestamp = Math.max(maxTimestamp, eventTimestamp); // 更新最大时间戳
        }

        /**
         * 默认情况下,每隔200ms发射一个水印,如果水印的时间大于等于窗口的结束时间,则触发窗口计算
         * @param output
         */
        @Override
        public void onPeriodicEmit(WatermarkOutput output) {
            System.out.println("onPeriodicEmit....");
            // 发射水位线,默认200ms调用一次
            output.emitWatermark(new Watermark(maxTimestamp - outOfOrdernessMillis - 1));
        }
    }
}

默认情况下,周期性水印是每隔200毫秒生成一次。固定不变的。

演示示例:定点式水位线生成器(Punctuated Generator)

java 复制代码
public static class CustomPunctuatedGenerator implements WatermarkGenerator<EventBean> {
    /**
     * 每条数据都会触发该方法
     * @param event
     * @param eventTimestamp
     * @param output
     */
    @Override
    public void onEvent(EventBean event, long eventTimestamp, WatermarkOutput output) {
        System.out.println("onEvent....");
        // 只有在遇到特定的 itemId 时,才发出水位线
        if (event.getEventId().equals("spark")) {
            output.emitWatermark(new Watermark(event.getTimeStamp() - 1));
        }
    }

    /**
     * 默认情况下,每隔200ms发射一个水印,如果水印的时间大于等于窗口的结束时间,则触发窗口计算
     * @param output
     */
    @Override
    public void onPeriodicEmit(WatermarkOutput output) {
        System.out.println("onPeriodicEmit....");
        // 不需要做任何事情,因为我们在 onEvent 方法中发射了水位线
    }
}

Flink的窗口(Window)

窗口(window)概念

窗口,就是把无界的数据流,依据一定规则划分成一段一段的有界数据流来计算;

既然划分成有界数据段,通常都是为了"聚合";

Keyedwindow重要特性:任何一个窗口,都绑定在自己所属的key上;不同key的数据肯定不会划分到相同窗口中去!

所以可以认为窗口是一个将"无界流"转"有界集"然后进行计算的一个数据切割方式

窗口细分类型

按照驱动类型分类

窗口本身是截取无界数据流到有界数据集的一种方式,因此在Flink中可以有两种截取数据的方式:

  • 一种是按照时间截取数据,这种窗口称之为"时间窗口 "( Time Window),也是使用最多且最常见的数据驱动类型

  • 另一种可以按照数据驱动,也就是说按照固定的个数,来截取一段数据集,这种窗口叫作"*计数窗口*"(Count Window)

如图所示:

(一)时间窗口(Time Window)

时间窗口以时间点来定义窗口的开始(start)和结束(end) ,所以截取出的就是某一时间段的数据。到达结束时间时,窗口不再收集数据,触发计算输出结果,并将窗口关闭销毁

用结束时间减去开始时间,得到这段时间的长度,就是窗口的大小(window size) 。这里的时间可以是不同的语义,所以我们可以定义处理时间窗口和事件时间窗口

Flink 中有一个专门的类来表示时间窗口,名称就叫作 TimeWindow。这个类只有两个私有属性:start 和 end ,表示窗口的开始和结束的时间戳,单位为毫秒

java 复制代码
public class TimeWindow extends Window {

    private final long start;
    private final long end;

    public TimeWindow(long start, long end) {
        this.start = start;
        this.end = end;
    }

    public long getStart() {
        return start;
    }

    public long getEnd() {
        return end;
    }

    @Override
    public long maxTimestamp() {
        return end - 1;
    }
...略
}

很明显,窗口中的数据,最大允许的时间戳就是 end - 1 ,这也就代表了我们定义的窗口时间范围都是左闭右开的区间[start,end)

(二)计数窗口(Count Window)

计数窗口基于元素的个数来截取数据,到达固定的个数时就触发计算并关闭窗口。每个窗口截取数据的个数,就是窗口的大小。

计数窗口相比时间窗口就更加简单,只需指定窗口大小,就可以把数据分配到对应的窗口中了。在 Flink 内部也并没有对应的类来表示计数窗口,底层是通过"全局窗口"(Global Window)来实现的。关于全局窗口,稍后讲解。

按照窗口分配数据的规则分类

时间窗口和计数窗口,只是对数据的一个大致划分;在具体使用时,还需要定义更加精细的规则,来控制数据应该划分到哪个窗口中去。

根据分配数据的规则,窗口的具体实现可以分为 4 类:滚动窗口(Tumbling Window)、滑动窗口(Sliding Window)、会话窗口(Session Window),以及全局窗口(Global Window)

  • 滚动窗口(Tumbling Window)

    滚动窗口有固定的大小,是一种对数据进行"均匀切片"的划分方式。窗口之间没有重叠, 也不会有间隔,是"首尾相接"的状态 。也正是因为滚动窗口是"无缝衔接",所以每个数据都会被分配到一个窗口,而且只会属于一个窗口。

  • 滑动窗口(Sliding Window)

    与滚动窗口类似,滑动窗口的大小也是固定的。区别在于,窗口之间并不是首尾相接的,而是可以"错开"一定的位置

  • 会话窗口(Session Window)

    会话窗口顾名思义,是基于"会话"(session)来对数据进行分组的。简单来说,就是数据来了之后就开启一个会话窗口,如果接下来还有数据陆续到来, 那么就一直保持会话;如果一段时间一直没收到数据,那就认为会话超时失效,窗口自动关闭

  • 全局窗口(Global Windows )

    还有一类窗口,就是"全局窗口 "。这种窗口全局有效,会把相同 key 的所有数据都分配到同一个窗口中;说直白一点,就跟没分窗口一样。无界流的数据永无止尽,所以这种窗口也没有结束的时候,默认是不会做触发计算的。如果希望它能对数据进行计算处理, 还需要自定义"触发器"(Trigger)

窗口计算API模板

  • KeyedWindows

    java 复制代码
    stream
         .keyBy(...)               <-  keyed versus non-keyed windows
         .window(...)              <-  required: "assigner"
         [.trigger(...)]            <-  optional: "trigger" (else default trigger)
         [.evictor(...)]            <-  optional: "evictor" (else no evictor)
         [.allowedLateness(...)]    	<-  optional: "lateness" (else zero)
         [.sideOutputLateData(...)]   	 <-  optional: "output tag" (else no side output for late data)
          .reduce/aggregate/apply()/process    <-  required: "function"
         [.getSideOutput(...)]        <-  optional: "output tag"

    经过按键分区keyBy 操作后,数据流会按照key 被分为多条逻辑流(logical streams),这就是 KeyedStream。基于 KeyedStream 进行窗口操作时, 窗口计算会在多个并行子任务上同时执行。相同 key 的数据会被发送到同一个并行子任务,而窗口操作会基于每个 key 进行单独的处理。所以可以认为,每个 key 上都定义了一组窗口,各自独立地进行统计计算。

    在代码实现上,我们需要先对 DataStream 调用.keyBy() 进行按键分区, 然后再调用****.window()****定义窗口。

  • NonKeyedWindows

    java 复制代码
    stream
          .windowAll(...)           <-  required: "assigner"
          [.trigger(...)]            <-  optional: "trigger" (else default trigger)
          [.evictor(...)]            <-  optional: "evictor" (else no evictor)
          [.allowedLateness(...)]    <-  optional: "lateness" (else zero)
          [.sideOutputLateData(...)] <-  optional: "output tag" (else no side output for late data)
           .reduce/aggregate/apply()/process      <-  required: "function"
          [.getSideOutput(...)]      <-  optional: "output tag"

    如果没有进行 keyBy,那么原始的 DataStream 就不会分成多条逻辑流。这时窗口逻辑只能在一个任务(task)上执行,就相当于并行度变成了 1。所以在实际应用中一般不推荐使用这种方式。在代码中,直接基于 DataStream 调用**.windowAll()**定义窗口。

    对于非按键分区的窗口操作,手动调大窗口算子的并行度也是无效的,windowAll 本身就是一个非并行的操作。

窗口分配器(Window Assigners)

窗口分配器最通用的定义方式,就是调用**.window()方法。这个方法需要传入一个 WindowAssigner** 作为参数,返回 WindowedStream 。如果是非按键分区窗口,那么直接调用**.windowAll()**方法,同样传入一个 WindowAssigner ,返回的是 AllWindowedStream

窗口按照驱动类型可以分成时间窗口和计数窗口,而按照具体的分配规则,又有滚动窗口、滑动窗口、会话窗口、全局窗口四种。除去需要自定义的全局窗口外,其他常用的类型 Flink中都给出了内置的分配器实现,可以方便地调用实现各种需求。

如:

java 复制代码
stream
     .keyBy(...)               <-  keyed versus non-keyed windows
     .window(...)              <-  required: "assigner"								<-这里
     [.trigger(...)]            <-  optional: "trigger" (else default trigger)
     [.evictor(...)]            <-  optional: "evictor" (else no evictor)
     [.allowedLateness(...)]    <-  optional: "lateness" (else zero)
     [.sideOutputLateData(...)] <-  optional: "output tag" (else no side output for late data)
      .reduce/aggregate/apply()      <-  required: "function"
     [.getSideOutput(...)]      <-  optional: "output tag"

示例如下:

java 复制代码
/**
 * NonKeyed窗口,全局窗口
 */
//  处理时间语义,滚动窗口
source.windowAll(TumblingProcessingTimeWindows.of(Time.seconds(5)));
// 处理时间语义,滑动窗口
source.windowAll(SlidingProcessingTimeWindows.of(Time.seconds(5),Time.seconds(1)));
// 事件时间语义,滚动窗口
source.windowAll(TumblingEventTimeWindows.of(Time.seconds(5)));
// 事件时间语义,滑动窗口
source.windowAll(SlidingEventTimeWindows.of(Time.seconds(5),Time.seconds(1)));
// 计数滚动窗口
source.countWindowAll(100);
// 计数滑动窗口
source.countWindowAll(100,20);
​
​
/**
 * Keyed窗口
 */
KeyedStream<String, String> keyedStream = source.keyBy(s -> s);
// 处理时间语义,滚动窗口
keyedStream.window(TumblingProcessingTimeWindows.of(Time.seconds(5)));
// 处理时间语义,滑动窗口
keyedStream.window(SlidingProcessingTimeWindows.of(Time.seconds(5),Time.seconds(1)));
// 事件时间语义,滚动窗口
keyedStream.window(TumblingEventTimeWindows.of(Time.seconds(5)));
// 事件时间语义,滑动窗口
keyedStream.window(SlidingEventTimeWindows.of(Time.seconds(5),Time.seconds(1)));
// 计数滚动窗口
keyedStream.countWindow(1000);
// 计数滑动窗口
keyedStream.countWindow(1000,100);

窗口函数(Window Functions)

经窗口分配器处理之后,数据可以分配到对应的窗口中,可以将数据收集起来;窗口分配器之后,必须 再接上一个定义窗口如何进行计算的操作,这就是所谓的"窗口函数"(window functions)。如:

java 复制代码
stream
     .keyBy(...)               <-  keyed versus non-keyed windows
     .window(...)              <-  required: "assigner"
     [.trigger(...)]            <-  optional: "trigger" (else default trigger)
     [.evictor(...)]            <-  optional: "evictor" (else no evictor)
     [.allowedLateness(...)]    <-  optional: "lateness" (else zero)
     [.sideOutputLateData(...)] <-  optional: "output tag" (else no side output for late data)
      .reduce/aggregate/apply()/process      <-  required: "function"									<- 这里
     [.getSideOutput(...)]      <-  optional: "output tag"

窗口函数定义了要对窗口中收集的数据做的计算操作,根据处理的方式可以分为两类:增量聚合函数和全窗口函数。

  • 增量聚合算子,如min、max、minBy、maxBy、sum、reduce、aggregate
  • 全量聚合算子,如apply、process

两类聚合算子的底层区别

  • 增量聚合:一次取一条数据,用聚合函数对中间累加器更新;窗口触发时,取累加器输出结果;

  • 全量聚合:数据"攒"在状态容器中,窗口触发时,把整个窗口的数据交给聚合函数;

增量聚合函数(incremental aggregation functions)

典型的增量聚合函数有两个:ReduceFunction 和 AggregateFunction

(一)归约函数(ReduceFunction )

ReduceFunction 中需要重写一个 reduce 方法,它的两个参数代表输入的两个元素,而归约最终输出结果的数据类型,与输入的数据类型必须保持一致。也就是说,中间聚合的状态和输出的结果,都和输入的数据类型是一样的。

需求一:每隔10s,统计最近 30s 的数据中,每个用户的订单总数量

java 复制代码
/** 
 *
 * 测试数据 :
 * 1,1,p01,10,10000
 * 2,1,p02,20,11000
 * 3,1,p03,40,12000
 * 4,1,p02,10,20000
 * 5,1,p03,50,21000
 * 6,1,p04,10,22000
 * 7,1,p05,60,28000
 * 8,1,p02,10,30000
 **/
public class WindowApi_Demo {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        DataStreamSource<String> socketTextStream = env.socketTextStream("node1", 9999);

        SingleOutputStreamOperator<OrderInfo> mapStream = socketTextStream.map(s -> {
            String[] split = s.split(",");
            return new OrderInfo(Long.parseLong(split[0]), Integer.parseInt(split[1]), split[2], Double.parseDouble(split[3]), Long.parseLong(split[4]));
        }).returns(OrderInfo.class);

        // 分配 watermark ,以推进事件时间
        SingleOutputStreamOperator<OrderInfo> watermarkedBeanStream = mapStream.assignTimestampsAndWatermarks(
                WatermarkStrategy.<OrderInfo>forBoundedOutOfOrderness(Duration.ofMillis(0))
                        .withTimestampAssigner(new SerializableTimestampAssigner<OrderInfo>() {
                            @Override
                            public long extractTimestamp(OrderInfo orderInfo, long recordTimestamp) {
                                return orderInfo.getCtime();
                            }
                        })
        );

        /**
         * 滚动聚合api使用示例
         * 需求 一 :  每隔10s,统计最近 30s 的数据中,每个用户的订单总数量
         * 使用aggregate算子来实现
         */
        final SingleOutputStreamOperator<Tuple2<Integer, Long>> resultStream = watermarkedBeanStream.map(new MapFunction<OrderInfo, Tuple2<Integer, Long>>() {
                    @Override
                    public Tuple2<Integer, Long> map(OrderInfo orderInfo) throws Exception {
                        return Tuple2.of(orderInfo.getUid(), 1l);
                    }
                })
                .keyBy(t -> t.f0)
                // 参数1: 窗口长度 ; 参数2:滑动步长
                .window(SlidingEventTimeWindows.of(Time.seconds(30), Time.seconds(10)))
                // reduce :滚动聚合算子,它有个限制 ,聚合结果的数据类型 与  数据源中的数据类型 ,是一致
                .reduce(new ReduceFunction<Tuple2<Integer, Long>>() {
                    @Override
                    public Tuple2<Integer, Long> reduce(Tuple2<Integer, Long> value1, Tuple2<Integer, Long> value2) throws Exception {
                        return Tuple2.of(value1.f0, value1.f1 + value2.f1);
                    }
                });
        resultStream.print();

        env.execute();
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class OrderInfo {
        //订单id
        private Long oid;
        //用户id
        private Integer uid;
        //商品id
        private String pid;
        //订单总金额
        private Double tmoney;
        //订单时间戳
        private Long ctime;
    }
}

(二)聚合函数(AggregateFunction)

输入的数据类型和输出的数据类型可以不一致。

需求二:每隔10s,统计最近 30s 的数据中,每个用户的平均消费金额

java 复制代码
/**
 * 需求 二 :  每隔10s,统计最近 30s 的数据中,每个用户的平均消费金额
 * 要求用 aggregate 算子来做聚合
 * 滚动聚合api使用示例
 */
SingleOutputStreamOperator<Double> resultStream2 = watermarkedBeanStream
        .keyBy(OrderInfo::getUid)
        .window(SlidingEventTimeWindows.of(Time.seconds(30), Time.seconds(10)))
        // 泛型1: 输入的数据的类型  ;  泛型2: 累加器的数据类型  ;  泛型3: 最终结果的类型
        .aggregate(new AggregateFunction<OrderInfo, Tuple2<Integer, Double>, Double>() {
            @Override
            public Tuple2<Integer, Double> createAccumulator() {
                return Tuple2.of(0, 0d);
            }

            @Override
            public Tuple2<Integer, Double> add(OrderInfo orderInfo, Tuple2<Integer, Double> accumulator) {
                return Tuple2.of(accumulator.f0 + 1, accumulator.f1 + orderInfo.getTmoney());
            }

            @Override
            public Double getResult(Tuple2<Integer, Double> accumulator) {
                return accumulator.f1 / accumulator.f0;
            }

            /**
             * 在批计算模式中,shuffle的上游可以做局部聚合,然后会把局部聚合结果交给下游去做全局聚合
             * 因此,就需要提供  两个局部聚合结果进行合并的逻辑
             *
             * 在流式计算中,不存在这种 上游局部聚合和交给下游全局聚合的机制!
             * 所以,在流式计算模式下,不用实现下面的方法
             * @param a An accumulator to merge
             * @param b Another accumulator to merge
             * @return
             */
            @Override
            public Tuple2<Integer, Double> merge(Tuple2<Integer, Double> a, Tuple2<Integer, Double> b) {
                return Tuple2.of(a.f0 + b.f0, a.f1 + b.f1);
            }
        });
resultStream2.print();
全量窗口函数(full window functions)

与增量聚合函数不同,全窗口函数需要先收集窗口中的数据,并在内部缓存起来,等到窗口要输出结果的时候再取出数据进行计算。

这样做毫无疑问是低效的:因为窗口全部的计算任务都积压在了要输出结果的那一瞬间,而在之前收集数据的漫长过程中却无所事事。

那为什么还需要有全量窗口函数呢?这是因为有些场景下,要做的计算必须基于全部的数据才有效,这时做增量聚合就没什么意义了;另外,输出的结果有可能要包含上下文中的一些信息(比如窗口的起始时间),这是增量聚合函数做不到的。所以,我们还需要有更丰富的窗口计算方式,这就可以用全量窗口函数来实现。

在 Flink 中,全量窗口函数也有两种:WindowFunctionProcessWindowFunction

(一)窗口函数(WindowFunction )

WindowFunction 字面上就是"窗口函数",它其实是老版本的通用窗口函数接口。可以基于 WindowedStream 调用.apply()方法,传入一个 WindowFunction 的实现类。

需求三:每隔10s,统计最近 30s 的数据中,每个用户的订单记录中,订单金额最大的前2条记录(使用apply算子来实现)

java 复制代码
/**
 * 全窗口计算api使用示例
 * 需求 三:每隔10s,统计最近 30s 的数据中,每个用户的订单记录中,订单金额最大的前2条记录
 * 要求用 apply 或者  process 算子来实现
 */
// 1. 用apply算子来实现需求
SingleOutputStreamOperator<OrderInfo> resultStream3 = watermarkedBeanStream.keyBy(OrderInfo::getUid)
        .window(SlidingEventTimeWindows.of(Time.seconds(30), Time.seconds(10)))

        // 泛型1: 输入数据类型;  泛型2:输出结果类型;  泛型3: key的类型, 泛型4:窗口类型
        .apply(new WindowFunction<OrderInfo, OrderInfo, Integer, TimeWindow>() {
            /**
             *
             * @param key 本次传给咱们的窗口是属于哪个key的
             * @param window 本次传给咱们的窗口的各种元信息(比如本窗口的起始时间,结束时间)
             * @param input 本次传给咱们的窗口中所有数据的迭代器
             * @param out 结果数据输出器
             * @throws Exception
             */
            @Override
            public void apply(Integer key, TimeWindow window, Iterable<OrderInfo> input, Collector<OrderInfo> out) throws Exception {

                // low bi写法: 从迭代器中迭代出数据,放入一个arraylist,然后排序,输出前2条
                ArrayList<OrderInfo> tmpList = new ArrayList<>();

                // 迭代数据,存入list
                for (OrderInfo orderInfo : input) {
                    tmpList.add(orderInfo);
                }
                // 排序
                Collections.sort(tmpList, new Comparator<OrderInfo>() {
                    @Override
                    public int compare(OrderInfo o1, OrderInfo o2) {
                        return (int)(o2.getTmoney() - o1.getTmoney());
                    }
                });

                // 输出前2条
                for (int i = 0; i < Math.min(tmpList.size(), 2); i++) {
                    out.collect(tmpList.get(i));
                }
            }
        });
resultStream3.print();

事实上,它的作用可以被 ProcessWindowFunction 全覆盖,所以之后可能会逐渐弃用。一般在实际应用,直接使用 ProcessWindowFunction 就可以了。

(二)处理窗口函数(ProcessWindowFunction )

ProcessWindowFunction 是 Window API 中最底层的通用窗口函数接口。之所以说它"最底层 ",是因为除了可以拿到窗口中的所有数据之外,ProcessWindowFunction 还可以获取到一个"上下文对象"(Context)。不仅能够获取窗口信息,还可以访问当前的时间和状态信息。这里的时间就包括了处理时间(processing time)和事件时间水位线(event time watermark)。因此ProcessWindowFunction 更加灵活、功能更加丰富。

同时:ProcessWindowFunction 是 Flink 底层 API------处理函数(process function)中的一员,关于处理函数我们会在后续章节展开讲解。

当然, 这些好处是以牺牲性能和资源为代价的 。 作 为一个全窗口函数,ProcessWindowFunction 同样需要将所有数据缓存下来、等到窗口触发计算时才使用。它其实就是一个增强版的 WindowFunction。

具体使用跟 WindowFunction 非常类似,我们可以基于 WindowedStream 调用**.process()**方法,传入一个 ProcessWindowFunction 的实现类。

需求四:每隔10s,统计最近 30s 的数据中,每个用户的订单记录中,订单金额最大的前2条记录(使用process 算子来实现

java 复制代码
// 2. 用process算子来实现需求
SingleOutputStreamOperator<String> resultStream4 = watermarkedBeanStream.keyBy(OrderInfo::getUid)
        .window(SlidingEventTimeWindows.of(Time.seconds(30), Time.seconds(10)))
        .process(new ProcessWindowFunction<OrderInfo, String, Integer, TimeWindow>() {
            @Override
            public void process(Integer aLong, ProcessWindowFunction<OrderInfo, String, Integer, TimeWindow>.Context context, Iterable<OrderInfo> input, Collector<String> out) throws Exception {

                // 本次窗口的元信息
                TimeWindow window = context.window();
                long maxTimestamp = window.maxTimestamp();// 本窗口允许的最大时间戳  [1000,2000) ,其中 1999就是允许的最大时间戳; 2000就是窗口的end
                long windowStart = window.getStart();
                long windowEnd = window.getEnd();


                // low bi写法: 从迭代器中迭代出数据,放入一个arraylist,然后排序,输出前2条
                ArrayList<OrderInfo> tmpList = new ArrayList<>();

                // 迭代数据,存入list
                for (OrderInfo eventBean2 : input) {
                    tmpList.add(eventBean2);
                }
                // 排序
                Collections.sort(tmpList, new Comparator<OrderInfo>() {
                    @Override
                    public int compare(OrderInfo o1, OrderInfo o2) {
                        return (int) (o2.getTmoney() - o1.getTmoney());
                    }
                });

                // 输出前2条
                for (int i = 0; i < Math.min(tmpList.size(), 2); i++) {
                    OrderInfo bean = tmpList.get(i);
                    out.collect( "窗口start:"+windowStart + "," +"窗口end:"+ windowEnd + "," + JSON.toJSONString(bean));
                }
            }

        });
resultStream4.print();
水位线和窗口结合的使用

前面提到,当水位线到达窗口结束时间时,窗口就会闭合不再接收迟到的数据,因为根据水位线的定义,所有小于等于水位线的数据都已经到达,所以显然 Flink 会认为窗口中的数据都到达了(尽管可能存在迟到数据,也就是时间戳小于当前水位线的数据)。可以在之前生成水位线代码 WatermarkTest 的基础上,增加窗口应用做一下测试:

java 复制代码
/**
 * @author : www.itcast.cn
 * @date : 22.10.24 17:56
 * @desc :
 *   ==> 在socket端口依次输入如下两条数据:
 *   1,hadoop,2000
 *   1,hadoop,3000
 *
 *   ==> 程序的控制台上会依次输出如下信息:
 *      本次收到的数据EventBean(guid=1, eventId=hadoop, timeStamp=2000)
 *      此刻的watermark: -9223372036854775808
 *      此刻的处理时间(processing time): 1666662299181
 * =============================================
 *     EventBean(guid=1, eventId=hadoop, timeStamp=2000)
 *     本次收到的数据EventBean(guid=1, eventId=hadoop, timeStamp=3000)
 *     此刻的watermark: 1999
 *     此刻的处理时间(processing time): 1666662341204
 * =============================================
 *
 *  1,hadoop,4000
 *  1,hadoop,10000
 *  1,hadoop,8000
 *  1,hadoop,15000
 *  1,hadoop,9000
 *  1,hadoop,25000
 **/
public class WaterMark_Demo2 {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        // 1,hadoop,2000
        DataStreamSource<String> socketTextStream = env.socketTextStream("node1", 9999);

        SingleOutputStreamOperator<EventBean> mapStream = socketTextStream.map(s -> {
                    String[] split = s.split(",");
                    return new EventBean(Long.parseLong(split[0]), split[1], Long.parseLong(split[2]));
                }).returns(EventBean.class)
                    .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<EventBean>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                                .withTimestampAssigner(new SerializableTimestampAssigner<EventBean>() {
                                    @Override
                                    public long extractTimestamp(EventBean eventBean, long recordTimestamp) {
                                        return eventBean.getTimeStamp();
                                    }
                                })
                );

        // 根据 user 分组,开窗统计
        mapStream.keyBy(data -> data.getGuid())
                .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                        .process(new ProcessWindowFunction<EventBean, String, Long, TimeWindow>() {
                            @Override
                            public void process(Long aLong, ProcessWindowFunction<EventBean, String, Long, TimeWindow>.Context context, Iterable<EventBean> elements, Collector<String> out) throws Exception {
                                Long currentWatermark = context.currentWatermark();
                                out.collect("key=" + aLong + "\n" +
                                        "数据为:" + elements + "\n" +
                                        "数量条数:" + elements.spliterator().estimateSize() + "\n" +
                                        "窗口为:[" + context.window().getStart() + "," + context.window().getEnd() + "]\n" +
                                        "水位线处于:" + currentWatermark + "\n" +
                                        "=======================================================================\n\n");
                            }
                        }).printToErr();
         env.execute();
    }
}

这里设置的最大延迟时间是 5 秒,所以当我们在终端启动 nc 程序,也就是 nc --lk 9999然后输入如下数据时:

复制代码
1,hadoop,2000
1,hadoop,3000
1,hadoop,4000
1,hadoop,10000
1,hadoop,8000
1,hadoop,15000
1,hadoop,9000
1,hadoop,25000

看到如下结果:

复制代码
key=1
数据为:[EventBean(guid=1, eventId=hadoop, timeStamp=4000), EventBean(guid=1, eventId=hadoop, timeStamp=8000)]
数量条数:2
窗口为:[0,10000]
水位线处于:9999
=========================================================
key=1
数据为:[EventBean(guid=1, eventId=hadoop, timeStamp=10000), EventBean(guid=1, eventId=hadoop, timeStamp=15000)]
数量条数:2
窗口为:[10000,20000]
水位线处于:19999
==========================================================

当输入【1,hadoop,15000】时,流中会周期性地(默认 200毫秒)插入一个时间戳为 15000 -- 5 * 1000L -- 1L = 9999 毫秒的水位线,已经到达了窗口[0,10000)的结束时间,所以会触发窗口的闭合计算。

而后面再输入一条【1,hadoop,9000】时,将不会有任何结果;因为这是一条迟到数据,它所属于的窗口已经触发计算然后销毁了(窗口默认被销毁),所以无法再进入到窗口中,自然也就无法更新计算结果了。窗口中的迟到数据默认会被丢弃,这会导致计算结果不够准确。Flink 提供了有效处理迟到数据的手段,后续会详细介绍。

复制代码
在多并行度的场景下:当所有的线程都满足了窗口的结束时间才会触发窗口计算操作
数据延迟处理

延迟处理的方案:

  • 小延迟(乱序),用watermark容错 (减慢时间的推进,让本已迟到的数据被认为没有迟到)

  • 中延迟(乱序),用allowedLateness (允许一定限度内的迟到,并对迟到数据重新触发窗口计算)

  • 大延迟(乱序),用sideOutputLateData (将超出allowedLateness的迟到数据输出到一个侧流中)

代码示例:

java 复制代码
SingleOutputStreamOperator<String> stream4 =
        stream3.windowAll(SlidingEventTimeWindows.of(Time.seconds(5), Time.seconds(5)))
        .allowedLateness(Time.milliseconds(2000))   // 允许迟到2秒; 默认是0
        .sideOutputLateData(lateTag)   // 超过迟到最大允许时间的数据,收集到侧输出流
        .apply(new Ex3_WindowFunc1());
​
// 获取侧流,做一些自己的补救逻辑
stream4.getSideOutput(lateTag).print();

注意正确理解延迟时间!

如:allowedLateness(2s),表示:

如果watermark(此刻的事件时间)推进到了 A窗口结束点后2s,如果还来A窗口的数据,就算迟到,就不会再去触发A窗口的计算,而是输出到迟到测流了

需求:从socket接受数据,进行转换,使用水印时间和迟到数据处理机制触发窗口操作

java 复制代码
/** 
 * @desc : 使用watermark处理迟到数据
 *
 * 需求:从socket接受数据,进行转换,使用水印时间和迟到数据处理机制触发窗口操作
 *
 * 数据样本:
 * hadoop,1641783602000  -> 2022-01-10 11:00:02
 * hadoop,1641783607000  -> 2022-01-10 11:00:07
 * hadoop,1641783602000  -> 2022-01-10 11:00:02
 * hadoop,1641783603000  -> 2022-01-10 11:00:03
 * hadoop,1641783608000  -> 2022-01-10 11:00:08
 * hadoop,1641783602000  -> 2022-01-10 11:00:02
 * hadoop,1641783603000  -> 2022-01-10 11:00:03
 * hadoop,1641783609000  -> 2022-01-10 11:00:09 ->窗口销毁
 * hadoop,1641783602000  -> 2022-01-10 11:00:02
 **/
public class LatenessDataDemo {
    public static void main(String[] args) throws Exception {
        /**
         * 实现步骤:
         * 1:初始化flink流处理的运行环境
         * 2:接入数据源:socket接收数据
         * 3:转换数据
         *   3.1)对数据进行拆分操作:map
         *   3.2)定义水印
         *   3.3)对相同的单词进行分组
         *   3.4)对分组后的数据进行窗口操作
         *   3.5)对窗口的数据添加窗口函数
         *      3.5.1)设置迟到数据延迟时间
         *      3.5.2)收集迟到的数据
         *      3.5.3)获取到迟到的数据
         *  4:输出操作:打印测试
         *  5:启动作业运行任务
         */
        //todo 1:初始化flink流处理的运行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        //设置单并行度
        env.setParallelism(1);

        //todo 2:接入数据源:socket接收数据
        DataStreamSource<String> socketTextStream = env.socketTextStream("node1", 9999);

        //todo 3:转换数据
        //todo 3.1)对数据进行拆分操作:map
        SingleOutputStreamOperator<Tuple2<String, Long>> mapDataStream = socketTextStream.map(new MapFunction<String, Tuple2<String, Long>>() {
            @Override
            public Tuple2<String, Long> map(String line) throws Exception {
                //对一行数据进行拆分操作
                String[] dataArray = line.split(",");
                return Tuple2.of(dataArray[0], Long.parseLong(dataArray[1]));
            }
        });
        //todo 3.2)定义水印
        SingleOutputStreamOperator<Tuple2<String, Long>> watermarkDataStream = mapDataStream.assignTimestampsAndWatermarks(
                WatermarkStrategy.<Tuple2<String, Long>>forBoundedOutOfOrderness(Duration.ofSeconds(2))
                        .withTimestampAssigner(new SerializableTimestampAssigner<Tuple2<String, Long>>() {
                            @Override
                            public long extractTimestamp(Tuple2<String, Long> element, long recordTimestamp) {
                                return element.f1;
                            }
                        })
        );
        //todo 3.3)对相同的单词进行分组
        KeyedStream<Tuple2<String, Long>, String> keyedStream = watermarkDataStream.keyBy(t -> t.f0);
        //todo 3.4)对分组后的数据进行窗口操作
        WindowedStream<Tuple2<String, Long>, String, TimeWindow> windowDataStream = keyedStream
                .window(TumblingEventTimeWindows.of(Time.seconds(5)));//添加五秒钟的事件时间的翻滚窗口
        //todo 3.5)对窗口的数据添加窗口函数
        //todo 3.5.1)设置迟到数据延迟时间
        WindowedStream<Tuple2<String, Long>, String, TimeWindow> allowedLateness = windowDataStream.allowedLateness(Time.seconds(2));//设置窗口延迟2秒钟
        //todo 3.5.2)收集迟到的数据
        //设置测输出机制,处理水印和延迟处理后依然迟到的数据
        OutputTag<Tuple2<String, Long>> outputTag = new OutputTag<>("sideOutput", TypeInformation.of(new TypeHint<Tuple2<String, Long>>() { }));
        WindowedStream<Tuple2<String, Long>, String, TimeWindow> sideOutputLateData = allowedLateness.sideOutputLateData(outputTag);
        SingleOutputStreamOperator<String> result = sideOutputLateData.process(new ProcessWindowFunction<Tuple2<String, Long>, String, String, TimeWindow>() {
            //定义时间格式的工具类
            private final SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss.SSS");

            /**
             * 对分组后窗口内所有的数据进行处理
             * @param key The key for which this window is evaluated.
             * @param context The context in which the window is being evaluated.
             * @param elements The elements in the window being evaluated.
             * @param out A collector for emitting elements.
             * @throws Exception
             */
            @Override
            public void process(String key, ProcessWindowFunction<Tuple2<String, Long>, String, String, TimeWindow>.Context context,
                                Iterable<Tuple2<String, Long>> elements, Collector<String> out) throws Exception {
                //对窗口内的数据进行排序操作
                //定义当前窗口的时间字段的集合列表
                List<Long> timeArr = new ArrayList<>();
                //循环遍历迭代器中的每条数据
                Iterator<Tuple2<String, Long>> iterator = elements.iterator();
                while (iterator.hasNext()) {
                    Tuple2<String, Long> next = iterator.next();
                    timeArr.add(next.f1);
                }
                //对集合中的数据进行排序
                Collections.sort(timeArr);

                out.collect("key=" + key + "\n" +
                        " 触发窗口计算的数据个数:" + timeArr.size() + "\n" +
                        " 触发窗口的数据:" + sdf.format(new Date(timeArr.get(timeArr.size() - 1))) + "\n" +
                        " 窗口计算的开始时间和结束时间为:【" + sdf.format(new Date(context.window().getStart())) + " - " +
                        sdf.format(new Date(context.window().getEnd())) + "】\n" +
                        "==============================\t\t");
            }
        });
        //todo 3.5.3)获取到迟到的数据
        DataStream<Tuple2<String, Long>> sideOutPut = result.getSideOutput(outputTag);
        sideOutPut.printToErr("迟到的数据>>>");
        //todo 4:输出操作:打印测试
        result.print("正常计算出来的结果>>>");
        //todo 5:启动作业运行任务
        env.execute();
    }
}
处理空闲数据源

在某些情况下,由于数据产生的比较少,导致一段时间内没有数据产生,进而就没有水印的生成,导致下游依赖水印的一些操作就会出现问题,比如某一个算子的上游有多个算子,这种情况下,水印是取其上游两个算子的较小值,如果上游某一个算子因为缺少数据迟迟没有生成水印,就会出现eventtime倾斜问题,导致下游没法触发计算。

所以filnk通过**WatermarkStrategy.withIdleness()**方法允许用户在配置的时间内(即超时时间内)没有记录到达时将一个流标记为空闲。这样就意味着下游的数据不需要等待水印的到来。

当下次有水印生成并发射到下游的时候,这个数据流重新变成活跃状态。

通过下面的代码来实现对于空闲数据流的处理

java 复制代码
WatermarkStrategy
        .<Tuple2<Long, String>>forBoundedOutOfOrderness(Duration.ofSeconds(20))
        .withIdleness(Duration.ofMinutes(1));

参考代码

java 复制代码
/** 
 * @desc :
 *  如果数据源中的某一个分区/分片在一段时间内未发送事件数据,
 *  则意味着 WatermarkGenerator 也不会获得任何新数据去生成 watermark。
 *  我们称这类数据源为空闲输入或空闲源。
 *  在这种情况下,当某些其他分区仍然发送事件数据的时候就会出现问题。
 *  由于下游算子 watermark 的计算方式是取所有不同的上游并行数据源 watermark 的最小值,则其 watermark 将不会发生变化。
 *  为了解决这个问题,
 *  你可以使用 WatermarkStrategy 来检测空闲输入并将其标记为空闲状态。
 *
 *  WatermarkStrategy 为此提供了一个工具接口
 *  WatermarkStrategy
 *      .forBoundedOutOfOrderness[(Long, String)](Duration.ofSeconds(20))
 *      .withIdleness(Duration.ofMinutes(1))
 **
 *   ==> 在socket端口依次输入如下两条数据:
 *   1,hadoop,2000
 *   1,hadoop,3000
 *
 *   ==> 程序的控制台上会依次输出如下信息:
 *      本次收到的数据EventBean(guid=1, eventId=hadoop, timeStamp=2000)
 *      此刻的watermark: -9223372036854775808
 *      此刻的处理时间(processing time): 1666662299181
 * =============================================
 *     EventBean(guid=1, eventId=hadoop, timeStamp=2000)
 *     本次收到的数据EventBean(guid=1, eventId=hadoop, timeStamp=3000)
 *     此刻的watermark: 1999
 *     此刻的处理时间(processing time): 1666662341204
 * =============================================
 *
 *  1,hadoop,4000
 *  1,hadoop,10000
 *  1,hadoop,10000
 *  1,hadoop,8000
 *  1,hadoop,15000  --该条数据满足了触发窗口计算的条件,但是因为设置了两个并行度,只有一个并行度满足了窗口计算的条件,此时设置了
 *      .withIdleness(Duration.ofSeconds(30)),因此等待30秒后依然没有满足另一个并行度窗口计算的触发条件,则不会已知等待,直接触发计算
 **/
public class FreeDataSourceWaterMark {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(2);
        
        // 1,hadoop,2000
        DataStreamSource<String> socketTextStream = env.socketTextStream("node1", 9999);

        SingleOutputStreamOperator<EventBean> mapStream = socketTextStream.map(s -> {
                    String[] split = s.split(",");
                    return new EventBean(Long.parseLong(split[0]), split[1], Long.parseLong(split[2]));
                }).returns(EventBean.class)
                .assignTimestampsAndWatermarks(
                        WatermarkStrategy
                                .<EventBean>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                                .withTimestampAssigner(new SerializableTimestampAssigner<EventBean>() {
                                    @Override
                                    public long extractTimestamp(EventBean eventBean, long recordTimestamp) {
                                        return eventBean.getTimeStamp();
                                    }
                                }).withIdleness(Duration.ofSeconds(30))
                );

        // 根据 user 分组,开窗统计
        mapStream.keyBy(data -> data.getGuid())
                .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                .process(new ProcessWindowFunction<EventBean, String, Long, TimeWindow>() {
                    @Override
                    public void process(Long aLong, ProcessWindowFunction<EventBean, String, Long, TimeWindow>.Context context, Iterable<EventBean> elements, Collector<String> out) throws Exception {
                        Long currentWatermark = context.currentWatermark();
                        out.collect("key=" + aLong + "\n" +
                                "数据为:" + elements + "\n" +
                                "数量条数:" + elements.spliterator().estimateSize() + "\n" +
                                "窗口为:[" + context.window().getStart() + "," + context.window().getEnd() + "]\n" +
                                "水位线处于:" + currentWatermark + "\n" +
                                "=======================================================================\n\n");
                    }
                }).printToErr();

        env.execute();
    }
}

本章总结

在流处理中,由于对实时性的要求非常高,同时又要求能够保证窗口操作结果的正确,所以必须引入水位线来描述事件时间。而窗口正是时间相关的最佳应用场景,所以 Flink 提供了丰富的窗口类型和处理操作;与此同时,在实际应用中很难对乱序流给出一个最佳延迟时间,单独依赖水位线去保证结果正确性是不够的,所以需要结合窗口(Window)处理迟到数据的相关 API。

本章详细了解了 Flink 中时间语义和水位线的概念、窗口 API 的用法以及处理迟到数据的相关知识,这些内容对于实时流处理来说非常重要。

相关推荐
不辉放弃19 分钟前
java连数据库
java·mysql
jiet_h24 分钟前
Android锁
android
熊大如如8 小时前
Java 反射
java·开发语言
猿来入此小猿8 小时前
基于SSM实现的健身房系统功能实现十六
java·毕业设计·ssm·毕业源码·免费学习·猿来入此·健身平台
teacher伟大光荣且正确9 小时前
Qt Creator 配置 Android 编译环境
android·开发语言·qt
goTsHgo9 小时前
Spring Boot 自动装配原理详解
java·spring boot
卑微的Coder9 小时前
JMeter同步定时器 模拟多用户并发访问场景
java·jmeter·压力测试
pjx9879 小时前
微服务的“导航系统”:使用Spring Cloud Eureka实现服务注册与发现
java·spring cloud·微服务·eureka
多多*10 小时前
算法竞赛相关 Java 二分模版
java·开发语言·数据结构·数据库·sql·算法·oracle
爱喝酸奶的桃酥10 小时前
MYSQL数据库集群高可用和数据监控平台
java·数据库·mysql