Flink中的时间和窗口

一、窗口(Window)

1、窗口的概念

 Flink是一种流式计算引擎,主要是来处理无界数据流的,数据源源不断、无穷无尽。想要更加方便高效地处理无界流,一种方式就是将无限数据切割成有限的“数据块”进行处理,这就是所谓的“窗口”(Window)。

在Flink中,窗口其实并不是一个“框”,应该把窗口理解成一个“桶”。在Flink中,窗口可以把流切割成有限大小的多个“存储桶”(bucket);每个数据都会分发到对应的桶中,当到达窗口结束时间时,就对每个桶中收集的数据进行计算处理。

Flink中窗口并不是静态准备好的,而是动态创建——当有落在这个窗口区间范围的数据达到时,才创建对应的窗口,另外,这里我们认为到达窗口结束时间时,窗口就触发计算并关闭,事实上“触发计算”和“窗口关闭”两个行为也可以分开

2、窗口的分类

2.1、按照驱动类型分

窗口本身是截取有界数据的一种方式,所以窗口一个非常重要的信息其实就是“怎样截取数据”。换句话说,就是以什么标准来开始和结束数据的截取,我们把它叫作窗口的“驱动类型”

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

计数窗口(Count Window):计数窗口基于元素的个数来截取数据,到达固定的个数时就触发计算并关闭窗口。每个窗口截取数据的个数,就是窗口的大小。基本思路是“人齐发车

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

滚动窗口(Tumbling Window):

1)滚动窗口有固定的大小,是一种对数据进行“均匀切片”的划分方式。窗口之间没有重叠,也不会有间隔,是“首尾相接”的状态。注意这里所说的间隔,指的是逻辑上数据的间隔,而非实际的间隔

2)这是最简单的窗口形式,每个数据都会被分配到一个窗口,而且只会属于一个窗口。

3)滚动窗口应用非常广泛,它可以对每个时间段做聚合统计,很多BI分析指标都可以用它来实现

滑动窗口(Sliding Window):

1)滑动窗口的大小也是固定的。但是窗口之间并不是首尾相接的,而是可以“错开”一定的位置。

2)定义滑动窗口的参数有两个:除去窗口大小(window size)之外,还有一个“滑动步长”(window slide),它其实就代表了窗口计算的频率。窗口在结束时间触发计算输出结果,那么滑动步长就代表了计算频率。

3)当滑动步长小于窗口大小时,滑动窗口就会出现重叠,这时数据也可能会被同时分配到多个窗口中。滚动窗口也可以看作是一种特殊的滑动窗口——窗口大小等于滑动步长(size = slide)

4)滑动窗口适合计算结果更新频率非常高的场景

会话窗口(Session Window):

1)会话窗口,是基于“会话”(session)来来对数据进行分组的。会话窗口只能基于时间来定义

2)会话窗口中,最重要的参数就是会话的超时时间,也就是两个会话窗口之间的最小距离。如果相邻两个数据到来的时间间隔(Gap)小于指定的大小(size),那说明还在保持会话,它们就属于同一个窗口;如果gap大于size,那么新来的数据就应该属于新的会话窗口,而前一个窗口就应该关闭了。

3)会话窗口的长度不固定,起始和结束时间也是不确定的,各个分区之间窗口没有任何关联。会话窗口之间一定是不会重叠的,而且会留有至少为size的间隔(session gap)

4)在一些类似保持会话的场景下,可以使用会话窗口来进行数据的处理统计。

全局窗口(Global Window):

1)这种窗口全局有效,会把相同key的所有数据都分配到同一个窗口中。这种窗口没有结束的时候,默认是不会做触发计算的。如果希望它能对数据进行计算处理,还需要自定义“触发器”(Trigger)

2)全局窗口没有结束的时间点,所以一般在希望做更加灵活的窗口处理时自定义使用。Flink中的计数窗口(Count Window),底层就是用全局窗口实现的。

3、窗口API概览

在定义窗口操作之前,首先需要确定,到底是基于按键分区(Keyed)的数据流KeyedStream来开窗,还是直接在没有按键分区的DataStream上开窗。也就是说,在调用窗口算子之前,是否有keyBy操作

按键分区窗口(Keyed Windows):

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

2)此时由keyBy触发Hash重分区

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

非按键分区(Non-Keyed Windows):

1)如果没有进行keyBy,那么原始的DataStream就不会分成多条逻辑流。这时窗口逻辑只能在一个任务(task)上执行,就相当于并行度变成了1。

2)此时,由窗口函数自身触发重分区,就算原本并行度不为1也会转为1

3)在代码中,直接基于DataStream调用.windowAll()定义窗口。对于非按键分区的窗口操作手动调大窗口算子的并行度也是无效的,windowAll本身就是一个非并行的操作

代码中窗口API的调用:

1)窗口操作主要有两个部分:窗口分配器(Window Assigners)和窗口函数(Window Functions)。

2)其中.window()方法需要传入一个窗口分配器,它指明了窗口的类型;而后面的.aggregate()方法传入一个窗口函数作为参数,它用来定义窗口具体的处理逻辑。窗口分配器有各种形式,而窗口函数的调用方法也不只.aggregate()一种

函数的调用方法可见与keyby后的操作一样,最终都是要经过处理重新变为DataStream

4、窗口分配器

定义窗口分配器(Window Assigners)是构建窗口算子的第一步,它的作用就是定义数据应该被“分配”到哪个窗口。所以可以说,窗口分配器其实就是在指定窗口的类型以及窗口的控制参数。

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

4.1、时间窗口

长度为5秒的滚动窗口:

window(TumblingProcessingTimeWindows.of(Time.seconds(5)))

长度为10秒、滑动步长为5秒的滑动窗口:

window(SlidingProcessingTimeWindows.of(Time.seconds(10)Time.seconds(5)))

会话超时时间为10秒的会话窗口:

window(ProcessingTimeSessionWindows.withGap(Time.seconds(10)))

会话超时时长动态变化的会话窗口:需要传一个提取器方法

.window(ProcessingTimeSessionWindows.withDynamicGap(
        new SessionWindowTimeGapExtractor<WaterSensor>() {
            @Override
            public long extract(WaterSensor element) {
                // 从数据中提取ts,作为间隔,单位ms
                return element.getTs() * 1000L;
            }
        }
));// 会话窗口,动态间隔,每条来的数据都会更新 间隔时间
4.2、计数窗口

长度为10的滚动计数窗口:countWindow(10)

长度为10、滑动步长为3的滑动计数窗口:countWindow(10,3)

4.3、全局窗口

全局窗口是计数窗口的底层实现,一般在需要自定义窗口时使用,而且必须自行定义触发器才能实现窗口计算,否则起不到任何作用

window(GlobalWindows.create())

5、窗口函数

定义了窗口分配器,我们只是知道了数据属于哪个窗口,可以将数据收集起来了;至于收集起来到底要做什么,其实还完全没有头绪。所以在窗口分配器之后,必须再接上一个定义窗口如何进行计算的操作,这就是所谓的“窗口函数”(window functions)。

5.1、增量聚合函数(ReduceFunction / AggregateFunction

窗口将数据收集起来,最基本的处理操作当然就是进行聚合。我们可以每来一个数据就在之前结果上聚合一次,这就是“增量聚合”。

归约函数(ReduceFunction):

聚合状态的类型、输出结果的类型都必须和输入数据类型一样

* 1、相同key的第一条数据来的时候,不会调用reduce方法
* 2、增量聚合: 来一条数据,就会计算一次,但是不会输出
* 3、在窗口触发的时候,才会输出窗口的最终计算结果,如果某窗口内只有一条数据,则直接输出这条数据,而不是聚合结果

聚合函数(AggregateFunction):

new AggregateFunction<WaterSensor, Integer, String>()

传入参数有三种类型:输入类型(IN)、累加器类型(ACC)和输出类型(OUT)

AggregateFunction的工作原理是:首先调用createAccumulator()为任务初始化一个状态(累加器),每个聚合任务(即每个相同key的单次窗口任务)只会调用一次;而后每来一个数据就调用一次add()方法,对数据进行聚合,得到的结果保存在状态中;等到了窗口需要输出时,再调用getResult()方法得到计算结果。很明显,与ReduceFunction相同,AggregateFunction也是增量式的聚合;而由于输入、中间状态、输出的类型可以不同,使得应用更加灵活方便。

* 1、相同key的第一条数据来,创建窗口,创建累加器
* 2、增量聚合: 来一条计算一条, 调用一次add方法,首次也调用,和累加器初始值聚合
* 3、窗口输出时调用一次getresult方法
* 4、输入、中间累加器、输出 类型可以不一样,非常灵活

Flink也为窗口的聚合提供了一系列预定义的简单聚合方法,可以直接基于WindowedStream调用。主要包括.sum()/max()/maxBy()/min()/minBy(),与KeyedStream的简单聚合非常相似。它们的底层,其实都是通过AggregateFunction来实现的

5.2、全窗口函数(full window functions)

有些场景下,我们要做的计算必须基于全部的数据才有效,例如要求平均值等,这时如果先一条条来分别做平均必定会有精度损失,这时做增量聚合就没什么意义了;另外,输出的结果有可能要包含上下文中的一些信息(比如窗口的起始时间),这是增量聚合函数做不到的。

所以,我们还需要有更丰富的窗口计算方式。窗口操作中的另一大类就是全窗口函数。与增量聚合函数不同,全窗口函数需要先收集窗口中的数据,并在内部缓存起来,等到窗口要输出结果的时候再取出数据进行计算。

窗口函数(WindowFunction):

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

不过WindowFunction能提供的上下文信息较少,也没有更高级的功能。事实上,它的作用可以被ProcessWindowFunction全覆盖,所以之后可能会逐渐弃用。

处理窗口函数(ProcessWindowFunction):

ProcessWindowFunction是Window API中最底层的通用窗口函数接口。之所以说它“最底层”,是因为除了可以拿到窗口中的所有数据之外,ProcessWindowFunction还可以获取到一个“上下文对象”(Context),其实就是一个增强版的WindowFunction

SingleOutputStreamOperator<String> process = sensorWS
        .process(
                new ProcessWindowFunction<WaterSensor, String, String, TimeWindow>() {
                    /**
                     * 全窗口函数计算逻辑:  窗口触发时才会调用一次,统一计算窗口的所有数据
                     * @param s   分组的key
                     * @param context  上下文
                     * @param elements 存的数据
                     * @param out      采集器
                     * @throws Exception
                     */
                    @Override
                    public void process(String s, Context context, Iterable<WaterSensor> elements, Collector<String> out) throws Exception {
                        // 上下文可以拿到window对象,还有其他东西:侧输出流 等等
                        long startTs = context.window().getStart();
                        long endTs = context.window().getEnd();
                        String windowStart = DateFormatUtils.format(startTs, "yyyy-MM-dd HH:mm:ss.SSS");
                        String windowEnd = DateFormatUtils.format(endTs, "yyyy-MM-dd HH:mm:ss.SSS");

                        long count = elements.spliterator().estimateSize();

                        out.collect("key=" + s + "的窗口[" + windowStart + "," + windowEnd + ")包含" + count + "条数据===>" + elements.toString());

                    }
                }
        );

用一个很形象的比喻,增量函数类似于做了预聚合,而全窗口函数相当于关闭了预聚合

5.3、增量聚合和全窗口函数的结合使用

我们之前在调用WindowedStream的.reduce()和.aggregate()方法时,只是简单地直接传入了一个ReduceFunction或AggregateFunction进行增量聚合。除此之外,其实还可以传入第二个参数:一个全窗口函数,可以是WindowFunction或者ProcessWindowFunction。

* 增量聚合 Aggregate + 全窗口 process
* 1、增量聚合函数处理数据: 来一条计算一条
* 2、窗口触发时, 增量聚合的结果(只有一条) 传递给 全窗口函数,即全窗口函数的输入类型 = 增量聚合函数的输出类型
* 3、经过全窗口函数的处理包装后,输出
* 结合两者的优点:
* 1、增量聚合: 来一条计算一条,存储中间的计算结果,占用的空间少
* 2、全窗口函数: 可以通过 上下文 实现灵活的功能

5.4、Flink中开窗函数与Hive开窗函数的联系与区别

类似于Hive中的开窗函数sum等,都是对相同分组且同一个窗口范围的聚合

但不同的是:

1)Hive中的先groupby分组再开窗,分组充当去重(行列过滤)的功能,只能在分组后数据的基础上进行开窗,开窗函数中不能使用分组后没有的列,例如一个表有班级,成绩,对班级groupby后想要开窗max(成绩),是不被允许的;但Flink这里的窗口,使用keyby分组,只是一种逻辑上的分组,只是打上标记,让相同key的数据发往同一个分区,但数据的非key列,仍然可以放在聚合函数中使用

2)Flink里的窗口不同于partition by,partition by后每个数据只会属于同一个窗口,而Flink中的每个数据可以属于多个窗口,如滑动窗口中的重复值

3)Hive开窗后的数据最后是放在基础数据后的,如开窗打上row_number,而flink开窗后的数据,是直接统计结果的,丢失了其原始值,只有开窗值

6、其他API

触发器(Trigger)

触发器主要是用来控制窗口什么时候触发计算。所谓的“触发计算”,本质上就是执行窗口函数,所以可以认为是计算得到结果并输出的过程。

基于WindowedStream调用.trigger()方法,就可以传入一个自定义的窗口触发器(Trigger)。

移除器(Evictor):

移除器主要用来定义移除某些数据的逻辑。基于WindowedStream调用.evictor()方法,就可以传入一个自定义的移除器(Evictor)。

无论是触发器还是移除器,现成的几个窗口,都有默认的实现,一般不需要自定义

7、窗口函数底层源码分析

7.1、窗口是怎么划分的?

对于滑动窗口:

public Collection<TimeWindow> assignWindows(
        Object element, long timestamp, WindowAssignerContext context) {
    .....
    long start =
            TimeWindow.getWindowStartWithOffset(
                    now, (globalOffset + staggerOffset) % size, size);
    return Collections.singletonList(new TimeWindow(start, start + size));
}

public static long getWindowStartWithOffset(long timestamp, long offset, long windowSize) {
    final long remainder = (timestamp - offset) % windowSize;
    // handle both positive and negative cases
    if (remainder < 0) {
        return timestamp - (remainder + windowSize);
    } else {
        return timestamp - remainder;
    }
}

默认情况offset为0,则remainder = 当前时间 % 窗口大小,走else,返回当前时间 - remainder,
即start= 向下取整,取窗口长度的整数倍,end = start + 窗口长度,new TimeWindow会生成window对象给触发器触发执行使用,而该对象会放在一个单例集合singletonList中,该集合不允许增加删除修改元素,也就保证了单例,不会每次一条数据来都触发新增窗口

比如当前时间是23s,窗口大小为10s,则remainder为23%10=3s,start为23s-3s=10s,end为10+10=20s,因此该窗口范围即[10s,20s)

对于滚动窗口:

public Collection<TimeWindow> assignWindows(
        Object element, long timestamp, WindowAssignerContext context) {
    timestamp = context.getCurrentProcessingTime();
    List<TimeWindow> windows = new ArrayList<>((int) (size / slide));
    long lastStart = TimeWindow.getWindowStartWithOffset(timestamp, offset, slide);
    for (long start = lastStart; start > timestamp - size; start -= slide) {
        windows.add(new TimeWindow(start, start + size));
    }
    return windows;
}

首先lastStart还是和滑动窗口看似相同,但这里传入的不在是窗口大小,而是滑动步长slide,即当前时间向下取整,取步长的整数倍,但是要知道滑动窗口的数据是可能同时属于多个窗口的,所以这个lastStart起的名字就很巧妙,只代表该时间戳的最后一个窗口开始时间,具体前面还有没有窗口,需要for循环去获取,而后面还有没有窗口,是之后再来数据的时候,创建的,确定窗口是否要包含本次数据

循环条件:start > timestamp - size => 保证上一个窗口的结束界限一定包含timestamp
循环变量:start -= slide => 在此范围内有几个窗口通过步长错位创建几个

至于后面往前推创建了一个已经存在的窗口,也不用担心,因为框架内会进行合并处理

7.2、窗口什么时候触发输出,什么时候销毁

 想必到这里对窗口范围左闭右开不太理解,为什么右开呢?

public TriggerResult onElement(
        Object element, long timestamp, TimeWindow window, TriggerContext ctx) {
    ctx.registerProcessingTimeTimer(window.maxTimestamp());
    return TriggerResult.CONTINUE;
}

public void clear(TimeWindow window, TriggerContext ctx) throws Exception {
    ctx.deleteProcessingTimeTimer(window.maxTimestamp());
}

public long maxTimestamp() {
    return end - 1;
}

在触发器触发窗口执行,触发器的onElement方法描述了触发执行的时间因子,当等于maxTimestamp时,也就是时间进展大于等于时触发,而该值等于end-1,窗口触发执行只能窗口数据收集完成后在执行,而这个收集完成的标准是end-1ms,即无论如何都到不了end那一毫秒,而下一个窗口开始时间是可以等于end这一毫秒的,因此窗口左闭右开,而clear方法执行对窗口使用完毕后的销毁工作,也是该时间戳

创建: 属于本窗口的第一条数据来的时候,现new的,事件驱动
销毁(关窗): 时间进展 >=  窗口的最大时间戳(end - 1ms) + 允许迟到的时间(默认0)

二、时间语义

事件时间:一个是数据产生的时间(时间戳Timestamp)

处理时间:数据真正被处理的时刻

时间语义:到底是以那种时间作为衡量标准

在实际应用中,事件时间语义会更为常见。一般情况下,业务日志数据中都会记录数据生成的时间戳(timestamp),它就可以作为事件时间的判断基础。

在Flink中,由于处理时间比较简单,早期版本默认的时间语义是处理时间;而考虑到事件时间在实际应用中更为广泛,从Flink1.12版本开始,Flink已经将事件时间作为默认的时间语义了。

三、水位线(Watermark)

1、事件时间和窗口

在窗口的处理过程中,我们可以基于数据的时间戳,自定义一个“逻辑时钟”。这个时钟的时间不会自动流逝;它的时间进展,就是靠着新到数据的时间戳来推动的。这样的好处在于,计算的过程可以完全不依赖处理时间(系统时间),不论什么时候进行统计处理,得到的结果都是正确的

思考一下为什么只有窗口处理依赖这个逻辑时钟?因为窗口处理是处理规定桶内的数据,处理完后桶就关闭了,就不会二次处理了,一定要保持这一次处理数据的准确性;而非窗口的处理,就算数据延时了,也总有处理到的时候,因此不用关心延时问题

2、什么是水位线

在Flink中,用来衡量事件时间进展的标记,就被称作“水位线”(Watermark)

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

2.1、有序流中的水位线

理想状态(数据量小),数据应该按照生成的先后顺序进入流中,每条数据产生一个水位线

实际应用中,如果当前数据量非常大,且同时涌来的数据时间差会非常小(比如几毫秒),往往对处理计算也没什么影响。所以为了提高效率,一般会每隔一段时间生成一个水位线。

2.2、有序 + 迟到数据

有序流由于数据时有序的,就算数据发生了迟到依然是有序的,因此我们只需要根据其水位线也就是事件时间去处理即可

2.3、乱序流中的水位线

在分布式系统中,数据在节点间传输,会因为网络传输延迟的不确定性,导致顺序发生改变,这就是所谓的“乱序数据”

乱序 + 数据量小时,我们还是靠数据来驱动,每来一个数据就提取它的时间戳、插入一个水位线。不过现在的情况是数据乱序,所以插入新的水位线时,要先判断一下时间戳是否比之前的大,否则就不再生成新的水位线。也就是说,只有数据的时间戳比当前时钟大,才能推动时钟前进,这时才插入水位线。

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

2.4、乱序 + 迟到数据

因为数据是无序的,而无序就一定会伴随着延迟,因为正是因为迟到才导致的无序,如果我们还像有序流去处理,必定会丢失数据

为了让窗口能够正确收集到迟到的数据,我们也可以等上一段时间,比如2秒;也就是用当前已有数据的最大时间戳减去2秒,就是要插入的水位线的时间戳。这样的话,9秒的数据到来之后,事件时钟不会直接推进到9秒,而是进展到了7秒;必须等到11秒的数据到来之后,事件时钟才会进展到9秒,这时迟到数据也都已收集齐,0~9秒的窗口[0,10)就可以正确计算结果了。

可以在数据的时间戳基础上加一些合适的延迟来保证不丢数据,这样就很好的处理了乱序流中的迟到数据,就算这个延迟多了一点也无所谓,只是晚一点出结果而已,我们首要保证的是数据准确性

2.5、水位线特性

水位线是基于数据的时间戳生成的,是插入到数据流中的一个标记,可以认为是一个特殊的数据,用来表示当前事件时间的进展

水位线的时间戳必须单调递增,以确保任务的事件时间时钟一直向前推进,试想一下如果不递增,会回退的话,那么窗口可能永远无法结束,因为他的水位线在被刷新,当刷新到一个较小的值,但没有新的数据,新的较大值过来的时候,该关闭的窗口将永远陷入等待状态

水位线可以通过设置延迟,来保证正确处理乱序数据,一个水位线Watermark(t),表示在当前流中事件时间已经达到了时间戳t,这代表t之前的所有数据都到齐了,之后流中不会出现时间戳t’ ≤ t的数据

对于有序留,

3、水位线和窗口的工作原理

水位线是Flink流处理中保证结果正确性的核心机制,它往往会跟窗口一起配合,完成对乱序数据的正确处理。

窗口的误解:在Flink中,窗口就是用来处理无界流的核心。我们很容易把窗口想象成一个固定位置的“框”,数据源源不断地流过来,到某个时间点窗口该关闭了,就停止收集数据、触发计算并输出结果,然后清空窗口继续收集数据

对于处理时间下的窗口而言,这样理解似乎没什么问题。然而如果我们采用事件时间语义,就会有些费解了。由于有乱序数据,我们需要设置一个延迟时间来等所有数据到齐。比如上面的例子中,我们可以设置延迟时间为2秒,这样0~10秒的窗口会在时间戳为12的数据到来之后,才真正关闭计算输出结果,这样就可以正常包含迟到的9秒数据了。但是这样一来,0~10秒的窗口不光包含了迟到的9秒数据,连11秒和12秒的数据也包含进去了。我们为了正确处理迟到数据,结果把早到的数据划分到了错误的窗口——最终结果都是错误的。

正确理解:在Flink中,窗口其实并不是一个“框”,应该把窗口理解成一个“桶”。在Flink中,窗口可以把流切割成有限大小的多个“存储桶”(bucket);每个数据都会分发到对应的桶中,当到达窗口结束时间时,就对每个桶中收集的数据进行计算处理。

问题:水位线会影响滑动窗口和滚动窗口的窗口范围吗?会影响其中的数据吗?

不会,水位线只是用来处理延时数据的,无论是基于数量的,还是基于时间的,无论是滑动窗口,还是滚动窗口,水位线既不会影响窗口范围,也不会影响其中数据,因为窗口范围是由源码自己控制的,而数据是由相应的桶自己装载的,和水位线这个延迟时间的设置没有任何关系

4、生成水位线

4.1、生成水位线的总体原则

完美的水位线是“绝对正确”的 => 就必须等足够长的时间,这会带来更高的延迟

对准确性完全不考虑 => 可以直接使用处理时间语义,这在理论上可以得到最低的延迟

以上都是两种极端情况,一般不会采用,Flink中的水位线,其实是流处理中对低延迟和结果正确性的一个权衡机制,控制的权力交给了程序员,我们可以在代码中定义水位线的生成策略,我们既希望处理得更快、也希望能容忍一定的遗漏数据

4.2、水位线生成策略

在Flink的DataStream API中,有一个单独用于生成水位线的方法:.assignTimestampsAndWatermarks(<watermark strategy>),它主要用来为流中的数据分配时间戳,并生成水位线来指示事件时间,也就是说如果使用执行时间比如执行时间窗口,是不需要定义水位线的,而如果使用事件窗口,则是需要定义水位线生成策略的

WatermarkStrategy作为参数,这就是所谓的“水位线生成策略”。WatermarkStrategy是一个接口,该接口中包含了

水位线生成器WatermarkGenerator:主要负责按照既定的方式,基于时间戳生成水位线,可以使用内置的,也可以自定义

时间戳分配器TimestampAssigner:负责从流中数据元素的某个字段中提取时间戳,并分配给元素。时间戳的分配是生成水位线的基础

4.3、Flink内置水位线

有序流中内置水位线设置:对于有序流,主要特点就是时间戳单调增长,所以永远不会出现迟到数据的问题,直接调用WatermarkStrategy.forMonotonousTimestamps()方法就可以实现,这个就是无延时的水位线生成器,尽管用不上延时处理,但依然需要重写时间戳分配器,因为你不知道他是从事件中哪个字段拿来做的事件时间,因此需要重写整个水位线生成策略

乱序流中内置水位线设置:由于乱序流中需要等待迟到数据到齐,所以必须设置一个固定量的延迟时间,这时生成水位线的时间戳,就是当前数据流中最大的时间戳减去延迟的结果。调用WatermarkStrategy.forBoundedOutOfOrderness()方法就可以实现,这个方法需要传入一个maxOutOfOrderness参数,表示“最大乱序程度”,也就是允许的最大延迟时间,例如<WaterSensor>forBoundedOutOfOrderness(Duration.ofSeconds(3))就代表延时等待3s,由于该方法底层是一个泛型方法,因此前面尖括号里需要定义好对什么样的类进行延时处理

其实底层源码去看的话,会发现单调递增的生成器,也是走了乱序等待生成器的底层逻辑,只不过等待时间为0而已,super(Duration.ofMillis(0));

4.4、自定义水位线生成器

周期性水位线生成器(Periodic Generator):

首先上面的两个内置水位线均为周期性水位线生成器,因为他们都需要周期性监听最大值,来发射水位线

周期性生成器一般是通过onEvent()观察判断输入的事件,而在onPeriodicEmit()里发出水位线,我们在onPeriodicEmit()里调用output.emitWatermark(),就可以发出水位线了;这个方法由系统框架周期性地调用,默认200ms一次。

 WatermarkStrategy<WaterSensor> watermarkStrategy = WatermarkStrategy
                // TODO 指定 自定义的 生成器
                // 1.自定义的 周期性生成
//                .<WaterSensor>forGenerator(ctx -> new MyPeriodWatermarkGenerator<>(3000L))
                // 2.自定义的 断点式生成
                .<WaterSensor>forGenerator(ctx -> new MyPuntuatedWatermarkGenerator<>(3000L))
                .withTimestampAssigner(
                        (element, recordTimestamp) -> {
                            return element.getTs() * 1000L;
                        });

        SingleOutputStreamOperator<WaterSensor> sensorDSwithWatermark = sensorDS.assignTimestampsAndWatermarks(watermarkStrategy);


public class MyPeriodWatermarkGenerator<T> implements WatermarkGenerator<T> {
    // 乱序等待时间
    private long delayTs;
    // 用来保存 当前为止 最大的事件时间
    private long maxTs;

    public MyPeriodWatermarkGenerator(long delayTs) {
        this.delayTs = delayTs;
        this.maxTs = Long.MIN_VALUE + this.delayTs + 1;
    }

    /**
     * 每条数据来,都会调用一次: 用来提取最大的事件时间,保存下来
     *
     * @param event
     * @param eventTimestamp 提取到的数据的 事件时间
     * @param output
     */
    @Override
    public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
        maxTs = Math.max(maxTs, eventTimestamp);
        System.out.println("调用onEvent方法,获取目前为止的最大时间戳=" + maxTs);
    }

    /**
     * 周期性调用: 发射 watermark
     *
     * @param output
     */
    @Override
    public void onPeriodicEmit(WatermarkOutput output) {
        output.emitWatermark(new Watermark(maxTs - delayTs - 1));
        System.out.println("调用onPeriodicEmit方法,生成watermark=" + (maxTs - delayTs - 1));
    }
}

这样自定义的周期性水位线策略,就可以像forBoundedOutOfOrderness一样设置延时,生成水位线了,如果我想让他像forMonotonousTimestamps一样不设置延时,只需传入0或者我在生成一个无参构造器即可

周期默认200ms可以随意设置吗?修改方式env.getConfig().setAutoWatermarkInterval(400L);要综合考虑,设置过小,存储水位线数据浪费状态资源,适合在数据更新频率快时设置减少延时;设置过大,这又是一种自己创造的延时,设想一下如果一个数据在1ms时就已经拿到了符合窗口关闭的maxTs条件,而因为周期性为1s迟迟不发射,是不是又延时了1s,适合在数据更新慢时设置用以节省资源;一般情况下不进行设置

水位线为什么要maxTs - delayTs - 1?首先需要减去延迟等待时间才是真正的水位线这毋庸置疑,其次再减1ms是为了保证不丢数据,设想一下如果来了一条数据已经满足maxTs的条件,但如果下一条数据也满足该条件呢?那窗口就会遗漏掉该数据,该条数据将不属于任何一个窗口,则显然时不合理的,因此我们宁可让其在设置延时的时候,多延时1ms,来保证不出现这种问题

断点式水位线生成器:

断点式生成器会不停地检测onEvent()中的事件,当发现带有水位线信息的事件时,就立即发出水位线。我们把发射水位线的逻辑写在onEvent方法当中即可,即周期性方法还是按一定事件调用,但不进行任何out,也就没有状态积累

之前我们周期性发送水位线无论如何设置发射延时,总归还是会有一定延时,也会有一定的资源浪费,而且你很难提前知道到底什么时候数据快与慢;而如果我们采用断点方式,即数据去驱动,那么这时候我们发射水位线既不会有延时,而且不会平白无故发送大量水位线,节省资源

在数据源中发送水位线:

我们也可以在自定义的数据源中抽取事件时间,然后发送水位线。这里要注意的是,在自定义数据源中发送了水位线以后,就不能再在程序中使用assignTimestampsAndWatermarks方法来生成水位线了

env.fromSource(
kafkaSource, WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(3)), "kafkasource"
)

而该种写法并没有声明时间戳如何抽取,是因为kafkaSource比较特殊,其时间戳一定是数据在kafka中的那个时间戳,所以其内置了抽取逻辑,不用我们手写

5、水位线的传递

首先水位线为什么要传递?因为水位线也是一种数据,他需要同步给后面的算子,以此判断数据是否迟到,是否要进行处理

在流处理中,上游任务处理完水位线、时钟改变之后,要把当前的水位线再次发出,广播给所有的下游子任务。而当一个任务接收到多个上游并行任务传递来的水位线时,应该以最小的那个作为当前任务的事件时钟

水位线在上下游任务之间的传递,非常巧妙地避免了分布式系统中没有统一时钟的问题,每个任务都以“处理完之前所有数据”为标准来确定自己的时钟。

在多个上游并行任务中,如果有其中一个没有数据,由于当前Task是以最小的那个作为当前任务的事件时钟,就会导致当前Task的水位线无法推进,就可能导致窗口无法触发。这时候可以设置空闲等待。

WatermarkStrategy
        .<Integer>forMonotonousTimestamps()
        .withTimestampAssigner((r, ts) -> r * 1000L)
        .withIdleness(Duration.ofSeconds(5))  //空闲等待5s

6、迟到数据的处理

6.1、推迟水印推进

在水印产生时,设置一个乱序容忍度,推迟系统时间的推进,保证窗口计算被延迟执行,为乱序的数据争取更多的时间进入窗口

WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(10));

6.2、设置窗口延迟关闭

Flink的窗口,也允许迟到数据。当触发了窗口计算后,会先计算当前的结果,但是此时并不会关闭窗口。

以后每来一条迟到数据,就触发一次这条数据所在窗口计算(增量计算)。直到wartermark 超过了窗口结束时间+推迟时间,此时窗口会真正关闭。允许迟到只能运用在event time上

.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.
allowedLateness(Time.seconds(3))

6.3、使用侧流接收迟到的数据

.window(TumblingEventTimeWindows.of(Time.seconds(10)))

                .allowedLateness(Time.seconds(2)) // 推迟2s关窗

                .sideOutputLateData(lateTag) // 关窗后的迟到数据,放入侧输出流

process.getSideOutput(lateTag).printToErr("关窗后的迟到数据");

6.4、总结

* 1、乱序与迟到的区别
 *      乱序: 数据的顺序乱了, 时间小的比时间大的晚来
 *      迟到: 数据的时间戳 < 当前的watermark
 * 2、乱序、迟到数据的处理
 * 1) watermark中指定 乱序等待时间
 * 2) 如果开窗,设置窗口允许迟到
 *      =》 推迟关窗时间,在关窗之前,迟到数据来了,还能被窗口计算,来一条迟到数据触发一次计算
 *      =》 关窗后,迟到数据不会被计算
 * 3) 关窗后的迟到数据,放入侧输出流

 * 如果 watermark等待3s,窗口允许迟到2s, 为什么不直接 watermark等待5s 或者 窗口允许迟到5s?
 *  =》 watermark等待时间不会设太大 ===》 影响的计算延迟
 *          如果3s ==》 窗口第一次触发计算和输出,  13s的数据来 。  13-3=10s
 *          如果5s ==》 窗口第一次触发计算和输出,  15s的数据来 。  15-5=10s
 *  =》 窗口允许迟到,是对 大部分迟到数据的 处理, 尽量让结果准确
 *          如果只设置 允许迟到5s, 那么 就会导致 频繁 重新输出

 *  TODO 设置经验
 *  1、watermark等待时间,设置一个不算特别大的,一般是秒级,在 乱序和 延迟 取舍,保证大部分数据的及时处理以及乱序容忍程度
 *  2、设置一定的窗口允许迟到,只考虑大部分的迟到数据,极端小部分迟到很久的数据,不管
 *  3、极端小部分迟到很久的数据, 放到侧输出流。 获取到之后可以做各种处理,比如再合流

四、基于时间的合流——双流联结(Join)

可以发现,根据某个key合并两条流,与关系型数据库中表的join操作非常相近。事实上,Flink中两条流的connect操作,就可以通过keyBy指定键进行分组后合并,实现了类似于SQL中的join操作;另外connect支持处理函数,可以使用自定义实现各种需求,其实已经能够处理双流join的大多数场景。

不过处理函数是底层接口,所以尽管connect能做的事情多,但在一些具体应用场景下还是显得太过抽象了。比如,如果我们希望统计固定时间内两条流数据的匹配情况,那就需要自定义来实现——其实这完全可以用窗口(window)来表示。为了更方便地实现基于时间的合流操作,Flink的DataStrema API提供了内置的join算子。

1、窗口联结(Window Join)

Flink为基于一段时间的双流合并专门提供了一个窗口联结算子,可以定义时间窗口,并将两条流中共享一个公共键(key)的数据放在窗口中进行配对处理。

// TODO window join
// 1. 落在同一个时间窗口范围内才能匹配,相当于where过滤
// 2. 根据keyby的key,来进行匹配关联,相当于on里的条件
// 3. 只能拿到匹配上的数据,类似有固定时间范围的inner join
DataStream<String> join = ds1.join(ds2)
        .where(r1 -> r1.f0)  // ds1的keyby
        .equalTo(r2 -> r2.f0) // ds2的keyby
        .window(TumblingEventTimeWindows.of(Time.seconds(10)))
        .apply(new JoinFunction<Tuple2<String, Integer>, Tuple3<String, Integer, Integer>, String>() {
            /**
             * 关联上的数据,调用join方法
             * @param first  ds1的数据
             * @param second ds2的数据
             * @return
             * @throws Exception
             */
            @Override
            public String join(Tuple2<String, Integer> first, Tuple3<String, Integer, Integer> second) throws Exception {
                return first + "<----->" + second;
            }
        });

2、间隔联结(Interval Join)

在有些场景下,我们要处理的时间间隔可能并不是固定的。这时显然不应该用滚动窗口或滑动窗口来处理——因为匹配的两个数据有可能刚好“卡在”窗口边缘两侧,于是窗口内就都没有匹配了;会话窗口虽然时间不固定,但也明显不适合这个场景。基于时间的窗口联结已经无能为力了。

为了应对这样的需求,Flink提供了一种叫作“间隔联结”(interval join)的合流操作。顾名思义,间隔联结的思路就是针对一条流的每个数据,开辟出其时间戳前后的一段时间间隔,看这期间是否有来自另一条流的数据匹配。

2.1、间隔联结的原理

间隔联结具体的定义方式是,我们给定两个时间点,分别叫作间隔的“上界”(upperBound)和“下界”(lowerBound);于是对于一条流(不妨叫作A)中的任意一个数据元素a,就可以开辟一段时间间隔:[a.timestamp + lowerBound, a.timestamp + upperBound],即以a的时间戳为中心,下至下界点、上至上界点的一个闭区间:我们就把这段时间作为可以匹配另一条流数据的“窗口”范围。所以对于另一条流(不妨叫B)中的数据元素b,如果它的时间戳落在了这个区间范围内,a和b就可以成功配对,进而进行计算输出结果。所以匹配的条件为:

a.timestamp + lowerBound <= b.timestamp <= a.timestamp + upperBound

这里需要注意,做间隔联结的两条流A和B,也必须基于相同的key;下界lowerBound应该小于等于上界upperBound,两者都可正可负;间隔联结目前只支持事件时间语义

2.2、间隔联结的实现

fromElements方法产生的数据,不会产生迟到或乱序,无需考虑乱序及测流处理

/**
 * TODO Interval join
 * 1、只支持事件时间
 * 2、指定上界、下界的偏移,负号代表时间往前,正号代表时间往后
 * 3、process中,只能处理 join上的数据
 * 4、两条流关联后的watermark,以两条流中最小的为准
 * 5、如果 当前数据的事件时间 < 当前的watermark,就是迟到数据, 主流的process不处理
 *  => between后,可以指定将 左流 或 右流 的迟到数据 放入侧输出流
 */

//1. 分别做keyby,key其实就是关联条件
KeyedStream<Tuple2<String, Integer>, String> ks1 = ds1.keyBy(r1 -> r1.f0);
KeyedStream<Tuple3<String, Integer, Integer>, String> ks2 = ds2.keyBy(r2 -> r2.f0);

//2. 调用 interval join
OutputTag<Tuple2<String, Integer>> ks1LateTag = new OutputTag<>("ks1-late", Types.TUPLE(Types.STRING, Types.INT));
OutputTag<Tuple3<String, Integer, Integer>> ks2LateTag = new OutputTag<>("ks2-late", Types.TUPLE(Types.STRING, Types.INT, Types.INT));
SingleOutputStreamOperator<String> process = ks1.intervalJoin(ks2)
        .between(Time.seconds(-2), Time.seconds(2))
        .sideOutputLeftLateData(ks1LateTag)  // 将 ks1的迟到数据,放入侧输出流
        .sideOutputRightLateData(ks2LateTag) // 将 ks2的迟到数据,放入侧输出流
        .process(
                new ProcessJoinFunction<Tuple2<String, Integer>, Tuple3<String, Integer, Integer>, String>() {
                    /**
                     * 两条流的数据匹配上,才会调用这个方法
                     * @param left  ks1的数据
                     * @param right ks2的数据
                     * @param ctx   上下文
                     * @param out   采集器
                     * @throws Exception
                     */
                    @Override
                    public void processElement(Tuple2<String, Integer> left, Tuple3<String, Integer, Integer> right, Context ctx, Collector<String> out) throws Exception {
                        // 进入这个方法,是关联上的数据
                        out.collect(left + "<------>" + right);
                    }
                });

process.print("主流");
process.getSideOutput(ks1LateTag).printToErr("ks1迟到数据");
process.getSideOutput(ks2LateTag).printToErr("ks2迟到数据");
2.3、间隔联结的特点

间隔联结同样是一种内连接(inner join)

与窗口联结不同的是,interval join做匹配的时间段是基于流中数据的,所以并不确定

流B中的数据可以不只在一个区间内被匹配,类似于滚动窗口

上下界的偏移可以都为正,也可以都为负,甚至可以相等

上下界的偏移计算,是针对事件时间的,而不是事件时间对应的水位线,窗口也是这样,水位线只是有界区间的等待延迟,而且水位线并不是window窗口特有

A对B取between(-2,1),则B对A取between(-1,2),是相对的

  • 25
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值