目录

6.3.6 测试水位线和窗口的使用

6.3.7 其他 API

6.3.8 窗口的生命周期

6.4 迟到数据的处理

6.4.1 设置水位线延迟时间

6.4.2 允许窗口处理迟到数据

6.4.3 将迟到数据放入窗口侧输出流


2. 全窗口函数(full window functions)

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

很明显,这就是典型的批处理思路了——先攒数据,等一批都到齐了再正式启动处理流程。

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

在 Flink 中,全窗口函数也有两种:WindowFunction ProcessWindowFunction

(1)窗口函数(WindowFunction)

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

stream 
 .keyBy(<key selector>) 
 .window(<window assigner>) 
 .apply(new MyWindowFunction());

这个类中可以获取到包含窗口所有数据的可迭代集合(Iterable),还可以拿到窗口 (Window)本身的信息。WindowFunction 接口在源码中实现如下:

public interface WindowFunction<IN, OUT, KEY, W extends Window> extends Function, 
Serializable { 
void apply(KEY key, W window, Iterable<IN> input, Collector<OUT> out) throws 
Exception; 
}

当窗口到达结束时间需要触发计算时,就会调用这里的 apply 方法。我们可以从 input 集 合中取出窗口收集的数据,结合 key 和 window 信息,通过收集器(Collector)输出结果。这 里 Collector 的用法,与 FlatMapFunction 中相同。

不过我们也看到了,WindowFunction 能提供的上下文信息较少,也没有更高级的功能。 事实上,它的作用可以被 ProcessWindowFunction 全覆盖,所以之后可能会逐渐弃用。一般在 实际应用,直接使用 ProcessWindowFunction 就可以了。

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

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

当 然 , 这 些 好 处 是 以 牺 牲 性 能 和 资 源 为 代 价 的 。 作 为 一 个 全 窗 口 函 数 ,

ProcessWindowFunction 同样需要将所有数据缓存下来、等到窗口触发计算时才使用。它其实 就是一个增强版的 WindowFunction。

具体使用跟 WindowFunction 非常类似,我们可以基于 WindowedStream 调用.process()方 法,传入一个 ProcessWindowFunction 的实现类。下面是一个电商网站统计每小时 UV 的例子:

package com.atguigu.chapter06;

import com.atguigu.chapter05.ClickSource;
import com.atguigu.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;

import java.sql.Timestamp;
import java.time.Duration;
import java.util.HashSet;

public class WindowProcessTest {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env=StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.getConfig().setAutoWatermarkInterval(100);

        SingleOutputStreamOperator<Event> stream=env.addSource(new ClickSource())

                //1.WaterMark的生成器
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ZERO)//变成有序
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            //2.时间戳的提取器
                            public long extractTimestamp(Event event, long l) {
                                return event.timestamp;
                            }
                        })
                );

        //数据显示
        stream.print("data");

        //使用ProcessWindowsFunction计算UV
        stream.keyBy(data -> true)
                        .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                                .process(new UvCountByWindow())
                                        .print();


        env.execute();
    }
    //实现自定义的ProcessWindowFunction,输出一条统计信息
    public static class UvCountByWindow extends ProcessWindowFunction<Event,String,Boolean, TimeWindow>{

        @Override
        public void process(Boolean aBoolean, ProcessWindowFunction<Event, String, Boolean, TimeWindow>.Context context, Iterable<Event> iterable, Collector<String> collector) throws Exception {
            //用一个HashSet保存User
            HashSet<String> userSet=new HashSet<>();
            //从elements中遍历数据,放到set中去重
            for(Event event:iterable){
                userSet.add(event.user);
            }

            //独立访客数据,==》访问量(去重user)
            Integer uv=userSet.size();

            //结合窗口信息
            Long start=context.window().getStart();
            Long end=context.window().getEnd();
            collector.collect("窗口"+new Timestamp(start)+"~"+new Timestamp(end)+" UV值为:"+uv);
        }
    }
}

3. 增量聚合和全窗口函数的结合使用

增量聚合函数处理计算会更高效。举一个最简单的例子,对一组数据求和。大量的数据连续不断到来,全窗口函数只是把它们收集缓存起来,并没有处理到了窗口要关闭、输出结果 的时候,再遍历所有数据依次叠加,得到最终结果(效率低)。而如果我们采用增量聚合的方式,那么只 需要保存一个当前和的状态,每个数据到来时就会做一次加法,更新状态(效率高,但无法获得窗口信息);到了要输出结果的 时候,只要将当前状态直接拿出来就可以了。增量聚合相当于把计算量“均摊”到了窗口收集 数据的过程中,自然就会比全窗口聚合更加高效、输出更加实时。

而全窗口函数的优势在于提供了更多的信息,可以认为是更加“通用”的窗口操作。它只 负责收集数据、提供上下文相关信息,把所有的原材料都准备好,至于拿来做什么我们完全可 以任意发挥。这就使得窗口计算更加灵活,功能更加强大。

所以在实际应用中,我们往往希望兼具这两者的优点,把它们结合在一起使用。Flink 的Window API 就给我们实现了这样的用法。

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

// ReduceFunction 与 WindowFunction 结合 
public <R> SingleOutputStreamOperator<R> reduce( 
 ReduceFunction<T> reduceFunction, WindowFunction<T, R, K, W> function) 
// ReduceFunction 与 ProcessWindowFunction 结合 

public <R> SingleOutputStreamOperator<R> reduce( 
 ReduceFunction<T> reduceFunction, ProcessWindowFunction<T, R, K, W> 
function) 
// AggregateFunction 与 WindowFunction 结合 
public <ACC, V, R> SingleOutputStreamOperator<R> aggregate( 
 AggregateFunction<T, ACC, V> aggFunction, WindowFunction<V, R, K, W> 
windowFunction) 
// AggregateFunction 与 ProcessWindowFunction 结合 
public <ACC, V, R> SingleOutputStreamOperator<R> aggregate( 
 AggregateFunction<T, ACC, V> aggFunction, 
 ProcessWindowFunction<V, R, K, W> windowFunction)

这样调用的处理机制是:基于第一个参数(增量聚合函数)来处理窗口数据,每来一个数据就做一次聚合;等到窗口需要触发计算时,则调用第二个参数(全窗口函数)的处理逻辑输 出结果。需要注意的是,这里的全窗口函数就不再缓存所有数据了,而是直接将增量聚合函数 的结果拿来当作了 Iterable 类型的输入。一般情况下,这时的可迭代集合中就只有一个元素了。

代码实现:

package com.atguigu.chapter06;

import com.atguigu.chapter05.ClickSource;
import com.atguigu.chapter05.Event;
import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.AggregateFunction;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;

import java.sql.Timestamp;
import java.time.Duration;
import java.util.HashSet;

public class UVCountExample {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env=StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.getConfig().setAutoWatermarkInterval(100);

        SingleOutputStreamOperator<Event> stream=env.addSource(new ClickSource())

                //1.WaterMark的生成器
                .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ZERO)//变成有序
                        .withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
                            @Override
                            //2.时间戳的提取器
                            public long extractTimestamp(Event event, long l) {
                                return event.timestamp;
                            }
                        })
                );

        //数据显示
        stream.print("data");


        //使用AggregateFunction函数和ProcessWindowFunction集合计算uv
        stream.keyBy(data -> true)
                .window(TumblingEventTimeWindows.of(Time.seconds(10)))
                .aggregate(new UvAgg(),new UvCountResult())
                .print();

                env.execute();
    }

    //自定义实现AggregateFunction,增量聚合计算uv值
    public static class UvAgg implements AggregateFunction<Event, HashSet<String>,Long>{

        @Override
        public HashSet<String> createAccumulator() {
            return new HashSet<>();
        }

        @Override
        public HashSet<String> add(Event event, HashSet<String> strings) {
            strings.add(event.user);
            return strings;
        }

        @Override
        public Long getResult(HashSet<String> strings) {
            return (long) strings.size();
        }

        //如果不是会话窗口,就没有必要合并窗口(merge)
        @Override
        public HashSet<String> merge(HashSet<String> strings, HashSet<String> acc1) {
            return null;
        }
    }
    //自定义实现ProcessWindowFunction,包装窗口信息输出
    public static class UvCountResult extends ProcessWindowFunction<Long,String,Boolean, TimeWindow>{

        @Override
        public void process(Boolean aBoolean, ProcessWindowFunction<Long, String, Boolean, TimeWindow>.Context context, Iterable<Long> iterable, Collector<String> collector) throws Exception {
            Long start=context.window().getStart();
            Long end=context.window().getEnd();
            Long uv=iterable.iterator().next();
            collector.collect("窗口"+new Timestamp(start)+"~"+new Timestamp(end)+" UV值为:"+uv);
        }
    }
}

代码中用一个 AggregateFunction 来实现增量聚合,每来一个数据就计数加一;得到的结 果交给 ProcessWindowFunction,结合窗口信息包装成我们想要的 UrlViewCount,最终输出统 计结果。

注:ProcessWindowFunction 是处理函数中的一种,后面我们会详细讲解。这里只用它来 将增量聚合函数的输出结果包裹一层窗口信息。

窗口处理的主体还是增量聚合,而引入全窗口函数又可以获取到更多的信息包装输出,这样的结合兼具了两种窗口函数的优势,在保证处理性能和实时性的同时支持了更加丰富的应用场景。

6.3.6 测试水位线和窗口的使用

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

import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner; 
import org.apache.flink.api.common.eventtime.WatermarkStrategy; 
import org.apache.flink.api.common.functions.MapFunction;; 
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; 
import 
org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction; 
import 
org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows; 
import org.apache.flink.streaming.api.windowing.time.Time; 
import org.apache.flink.streaming.api.windowing.windows.TimeWindow; 
import org.apache.flink.util.Collector; 
 
import java.time.Duration; 
 
 
 
public class WatermarkTest { 
 public static void main(String[] args) throws Exception { 
 StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment(); 
 env.setParallelism(1); 
 
 // 将数据源改为 socket 文本流,并转换成 Event 类型 
 env.socketTextStream("localhost", 7777) 
 .map(new MapFunction<String, Event>() { 
 @Override 
 public Event map(String value) throws Exception { 
 String[] fields = value.split(","); 
 return new Event(fields[0].trim(), fields[1].trim(), 
Long.valueOf(fields[2].trim())); 
 } 
 }) 
 // 插入水位线的逻辑 
 .assignTimestampsAndWatermarks( 
 // 针对乱序流插入水位线,延迟时间设置为 5s 
 
WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5)) 
 .withTimestampAssigner(new 
SerializableTimestampAssigner<Event>() { 
 // 抽取时间戳的逻辑 
 @Override 
 public long extractTimestamp(Event element, long 
recordTimestamp) { 
 return element.timestamp; 
 } 
 }) 
 ) 
 // 根据 user 分组,开窗统计 
 .keyBy(data -> data.user) 
 .window(TumblingEventTimeWindows.of(Time.seconds(10))) 
 .process(new WatermarkTestResult()) 
 .print(); 
 
 env.execute(); 
 } 
 
 // 自定义处理窗口函数,输出当前的水位线和窗口信息 
 public static class WatermarkTestResult extends ProcessWindowFunction<Event, 
String, String, TimeWindow>{ 
 @Override 
 public void process(String s, Context context, Iterable<Event> elements, 
Collector<String> out) throws Exception { 
 Long start = context.window().getStart(); 
 Long end = context.window().getEnd(); 
 Long currentWatermark = context.currentWatermark(); 
 Long count = elements.spliterator().getExactSizeIfKnown(); 
 out.collect("窗口" + start + " ~ " + end + "中共有" + count + "个元素,
窗口闭合计算时,水位线处于:" + currentWatermark); 
 } 
 } 
}

6.3.7 其他 API

1. 触发器(Trigger)

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

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

stream.keyBy(...) 
 .window(...) 
 .trigger(new MyTrigger())

Trigger 是窗口算子的内部属性,每个窗口分配器(WindowAssigner)都会对应一个默认的触发器;对于 Flink 内置的窗口类型,它们的触发器都已经做了实现。例如,所有事件时间 窗口,默认的触发器都是 EventTimeTrigger;类似还有 ProcessingTimeTrigger 和 CountTrigger。

所以一般情况下是不需要自定义触发器的,不过我们依然有必要了解它的原理。 Trigger 是一个抽象类,自定义时必须实现下面四个抽象方法:

⚫ onElement():窗口中每到来一个元素,都会调用这个方法。

⚫ onEventTime():当注册的事件时间定时器触发时,将调用这个方法。

⚫ onProcessingTime ():当注册的处理时间定时器触发时,将调用这个方法。

⚫ clear():当窗口关闭销毁时,调用这个方法。一般用来清除自定义的状态。

2. 移除器(Evictor)

移除器主要用来定义移除某些数据的逻辑。基于 WindowedStream 调用.evictor()方法,就 可以传入一个自定义的移除器(Evictor)。Evictor 是一个接口,不同的窗口类型都有各自预实 现的移除器。

stream.keyBy(...) 
 .window(...) 
 .evictor(new MyEvictor())

Evictor 接口定义了两个方法:

⚫ evictBefore():定义执行窗口函数之前的移除数据操作

⚫ evictAfter():定义执行窗口函数之后的以处数据操作

默认情况下,预实现的移除器都是在执行窗口函数(window fucntions)之前移除数据的。

3. 允许延迟(Allowed Lateness)

在事件时间语义下,窗口中可能会出现数据迟到的情况。这是因为在乱序流中,水位线 (watermark)并不一定能保证时间戳更早的所有数据不会再来。当水位线已经到达窗口结束时 间时,窗口会触发计算并输出结果,这时一般也就要销毁窗口了;如果窗口关闭之后,又有本 属于窗口内的数据姗姗来迟,默认情况下就会被丢弃。这也很好理解:窗口触发计算就像发车, 如果要赶的车已经开走了,又不能坐其他的车(保证分配窗口的正确性),那就只好放弃坐班 车了。

不过在多数情况下,直接丢弃数据也会导致统计结果不准确,我们还是希望该上车的人都 能上来。为了解决迟到数据的问题,Flink 提供了一个特殊的接口,可以为窗口算子设置一个 “允许的最大延迟”(Allowed Lateness)。也就是说,我们可以设定允许延迟一段时间,在这段 时间内,窗口不会销毁,继续到来的数据依然可以进入窗口中并触发计算。直到水位线推进到 了 窗口结束时间 + 延迟时间,才真正将窗口的内容清空,正式关闭窗口。

基于 WindowedStream 调用.allowedLateness()方法,传入一个 Time 类型的延迟时间,就可 以表示允许这段时间内的延迟数据。

stream.keyBy(...) 
 .window(TumblingEventTimeWindows.of(Time.hours(1))) 
 .allowedLateness(Time.minutes(1))

4. 将迟到的数据放入侧输出流

我们自然会想到,即使可以设置窗口的延迟时间,终归还是有限的,后续的数据还是会被 丢弃。如果不想丢弃任何一个数据,又该怎么做呢? Flink 还提供了另外一种方式处理迟到数据。我们可以将未收入窗口的迟到数据,放入“侧 输出流”(side output)进行另外的处理。所谓的侧输出流,相当于是数据流的一个“分支”, 这个流中单独放置那些错过了该上的车、本该被丢弃的数据。

基于 WindowedStream 调用.sideOutputLateData() 方法,就可以实现这个功能。方法需要 传入一个“输出标签”(OutputTag),用来标记分支的迟到数据流。因为保存的就是流中的原 始数据,所以 OutputTag 的类型与流中数据类型相同。

DataStream<Event> stream = env.addSource(...); 
 
OutputTag<Event> outputTag = new OutputTag<Event>("late") {}; 
 
stream.keyBy(...) 
 .window(TumblingEventTimeWindows.of(Time.hours(1))) 
.sideOutputLateData(outputTag)

将迟到数据放入侧输出流之后,还应该可以将它提取出来。基于窗口处理完成之后的DataStream调用.getSideOutput()方法,传入对应的输出标签,就可以获取到迟到数据所在的 流了。

SingleOutputStreamOperator<AggResult> winAggStream = stream.keyBy(...) 
 .window(TumblingEventTimeWindows.of(Time.hours(1))) 
.sideOutputLateData(outputTag) 
.aggregate(new MyAggregateFunction()) 
DataStream<Event> lateStream = winAggStream.getSideOutput(outputTag);

这里注意,getSideOutput()是 SingleOutputStreamOperator 的方法,获取到的侧输出流数据 类型应该和 OutputTag 指定的类型一致,与窗口聚合之后流中的数据类型可以不同。

6.3.8 窗口的生命周期

1. 窗口的创建

窗口的类型和基本信息由窗口分配器(window assigners)指定,但窗口不会预先创建好, 而是由数据驱动创建。当第一个应该属于这个窗口的数据元素到达时,就会创建对应的窗口。

2. 窗口计算的触发

除了窗口分配器,每个窗口还会有自己的窗口函数(window functions)和触发器(trigger)。 窗口函数可以分为增量聚合函数和全窗口函数,主要定义了窗口中计算的逻辑;而触发器则是指定调用窗口函数的条件。

对于不同的窗口类型,触发计算的条件也会不同。例如,一个滚动事件时间窗口,应该在 水位线到达窗口结束时间的时候触发计算,属于“定点发车”;而一个计数窗口,会在窗口中 元素数量达到定义大小时触发计算,属于“人满就发车”。所以 Flink 预定义的窗口类型都有 对应内置的触发器。

对于事件时间窗口而言,除去到达结束时间的“定点发车”,还有另一种情形。当我们设 置了允许延迟,那么如果水位线超过了窗口结束时间、但还没有到达设定的最大延迟时间,这 期间内到达的迟到数据也会触发窗口计算。这类似于没有准时赶上班车的人又追上了车,这时 车要再次停靠、开门,将新的数据整合统计进来。

3. 窗口的销毁

一般情况下,当时间达到了结束点,就会直接触发计算输出结果、进而清除状态销毁窗口。 这时窗口的销毁可以认为和触发计算是同一时刻。这里需要注意,Flink 中只对时间窗口 (TimeWindow)有销毁机制;由于计数窗口(CountWindow)是基于全局窗口(GlobalWindw) 实现的,而全局窗口不会清除状态,所以就不会被销毁。

在特殊的场景下,窗口的销毁和触发计算会有所不同。事件时间语义下,如果设置了允许 延迟,那么在水位线到达窗口结束时间时,仍然不会销毁窗口;窗口真正被完全删除的时间点, 是窗口的结束时间加上用户指定的允许延迟时间。

4. 窗口 API 调用总结

flink table cdc 时间差八小时 flink时间函数_大数据

 

Window API 首先按照时候按键分区分成两类。keyBy 之后的 KeyedStream,可以调 用.window()方法声明按键分区窗口(Keyed Windows);而如果不做 keyBy,DataStream 也可 以直接调用.windowAll()声明非按键分区窗口。之后的方法调用就完全一样了。

接下来首先是通过.window()/.windowAll()方法定义窗口分配器,得到 WindowedStream; 然 后 通 过 各 种 转 换 方 法 (reduce/aggregate/apply/process ) 给 出 窗 口 函 数

(ReduceFunction/AggregateFunction/ProcessWindowFunction),定义窗口的具体计算处理逻辑, 转换之后重新得到 DataStream。这两者必不可少,是窗口算子(WindowOperator)最重要的组 成部分。

此外,在这两者之间,还可以基于 WindowedStream 调用.trigger()自定义触发器、调 用.evictor()定义移除器、调用.allowedLateness()指定允许延迟时间、调用.sideOutputLateData()将迟到数据写入侧输出流,这些都是可选的 API,一般不需要实现。而如果定义了侧输出流, 可以基于窗口聚合之后的 DataStream 调用.getSideOutput()获取侧输出流。

6.4 迟到数据的处理

我们知道,所谓的“迟到数据”(late data),是指某个水位线之后到来的数据,它的时 间戳其实是在水位线之前的。所以只有在事件时间语义下,讨论迟到数据的处理才是有意义的。

事件时间里用来表示时钟进展的就是水位线(watermark)。对于乱序流,水位线本身就可 以设置一个延迟时间;而做窗口计算时,我们又可以设置窗口的允许延迟时间;另外窗口还有 将迟到数据输出到测输出流的用法。所有的这些方法,它们之间有什么关系,我们又该怎样合 理利用呢?这一节我们就来讨论这个问题。

6.4.1 设置水位线延迟时间

水位线是事件时间的进展,它是我们整个应用的全局逻辑时钟。水位线生成之后,会随着 数据在任务间流动,从而给每个任务指明当前的事件时间。所以从这个意义上讲,水位线是一 个覆盖万物的存在,它并不只针对事件时间窗口有效。

之前我们讲到触发器时曾提到过“定时器”,时间窗口的操作底层就是靠定时器来控制触 发的。既然是底层机制,定时器自然就不可能是窗口的专利了;事实上它是 Flink 底层 API— —处理函数(process function)的重要部分。

所以水位线其实是所有事件时间定时器触发的判断标准。那么水位线的延迟,当然也就是 全局时钟的滞后,相当于是上帝拨动了琴弦,所有人的表都变慢了。

既然水位线这么重要,那一般情况就不应该把它的延迟设置得太大,否则流处理的实时性 就会大大降低。因为水位线的延迟主要是用来对付分布式网络传输导致的数据乱序,而网络传 输的乱序程度一般并不会很大,大多集中在几毫秒至几百毫秒。所以实际应用中,我们往往会 给水位线设置一个“能够处理大多数乱序数据的小延迟”,视需求一般设在毫秒~秒级。

当我们设置了水位线延迟时间后,所有定时器就都会按照延迟后的水位线来触发。如果一 个数据所包含的时间戳,小于当前的水位线,那么它就是所谓的“迟到数据”。

6.4.2 允许窗口处理迟到数据

水位线延迟设置的比较小,那之后如果仍有数据迟到该怎么办?对于窗口计算而言,如果 水位线已经到了窗口结束时间,默认窗口就会关闭,那么之后再来的数据就要被丢弃了。

自然想到,Flink 的窗口也是可以设置延迟时间,允许继续处理迟到数据的。

这种情况下,由于大部分乱序数据已经被水位线的延迟等到了,所以往往迟到的数据不会 太多。这样,我们会在水位线到达窗口结束时间时,先快速地输出一个近似正确的计算结果; 然后保持窗口继续等到延迟数据,每来一条数据,窗口就会再次计算,并将更新后的结果输出。 这样就可以逐步修正计算结果,最终得到准确的统计值了。

类比班车的例子,我们可以这样理解:大多数人是在发车时刻前后到达的,所以我们只要 把表调慢,稍微等一会儿,绝大部分人就都上车了,这个把表调慢的时间就是水位线的延迟; 到点之后,班车就准时出发了,不过可能还有该来的人没赶上。于是我们就先慢慢往前开,这 段时间内,如果迟到的人抓点紧还是可以追上的;如果有人追上来了,就停车开门让他上来, 然后车继续向前开。当然我们的车不能一直慢慢开,需要有一个时间限制,这就是窗口的允许 延迟时间。一旦超过了这个时间,班车就不再停留,开上高速疾驰而去了。

所以我们将水位线的延迟和窗口的允许延迟数据结合起来,最后的效果就是先快速实时地 输出一个近似的结果,而后再不断调整,最终得到正确的计算结果。回想流处理的发展过程, 这不就是著名的 Lambda 架构吗?原先需要两套独立的系统来同时保证实时性和结果的最终 正确性,如今 Flink 一套系统就全部搞定了。

6.4.3 将迟到数据放入窗口侧输出流

即使我们有了前面的双重保证,可窗口不能一直等下去,最后总要真正关闭。窗口一旦关 闭,后续的数据就都要被丢弃了。那如果真的还有漏网之鱼又该怎么办呢?

那就要用到最后一招了:用窗口的侧输出流来收集关窗以后的迟到数据。这种方式是最后 “兜底”的方法,只能保证数据不丢失;因为窗口已经真正关闭,所以是无法基于之前窗口的 结果直接做更新的。我们只能将之前的窗口计算结果保存下来,然后获取侧输出流中的迟到数 据,判断数据所属的窗口,手动对结果进行合并更新。尽管有些烦琐,实时性也不够强,但能 够保证最终结果一定是正确的。

如果还用赶班车来类比,那就是车已经上高速开走了,这班车是肯定赶不上了。不过我们 还留下了行进路线和联系方式,迟到的人如果想办法辗转到了目的地,还是可以和大部队会合 的。最终,所有该到的人都会在目的地出现。

所以总结起来,Flink 处理迟到数据,对于结果的正确性有三重保障:水位线的延迟,窗 口允许迟到数据,以及将迟到数据放入窗口侧输出流。我们可以回忆一下之前 6.3.5 小节统计 每个 url 浏览次数的代码 UrlViewCountExample,稍作改进,增加处理迟到数据的功能。具体 代码如下。

import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner; 
import org.apache.flink.api.common.eventtime.WatermarkStrategy; 
import org.apache.flink.api.common.functions.AggregateFunction; 
import org.apache.flink.api.common.functions.MapFunction; 
 
 
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; 
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; 
import 
org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction; 
import 
org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows; 
import org.apache.flink.streaming.api.windowing.time.Time; 
import org.apache.flink.streaming.api.windowing.windows.TimeWindow; 
import org.apache.flink.util.Collector; 
import org.apache.flink.util.OutputTag; 
 
 
import java.time.Duration; 
 
 
public class ProcessLateDataExample { 
 public static void main(String[] args) throws Exception { 
 StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment(); 
179 
 

 env.setParallelism(1); 
 
 // 读取 socket 文本流 
 SingleOutputStreamOperator<Event> stream = 
 env.socketTextStream("localhost", 7777) 
 .map(new MapFunction<String, Event>() { 
 @Override 
 public Event map(String value) throws Exception { 
 String[] fields = value.split(" "); 
 return new Event(fields[0].trim(), fields[1].trim(), 
Long.valueOf(fields[2].trim())); 
 } 
 }) 
 // 方式一:设置 watermark 延迟时间,2 秒钟 
 .assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBound
edOutOfOrderness(Duration.ofSeconds(2)) 
 .withTimestampAssigner(new 
SerializableTimestampAssigner<Event>() { 
 @Override 
 public long extractTimestamp(Event element, long 
recordTimestamp) { 
 return element.timestamp; 
 } 
 })); 
 
 // 定义侧输出流标签 
 OutputTag<Event> outputTag = new OutputTag<Event>("late"){}; 
 
 SingleOutputStreamOperator<UrlViewCount> result = stream.keyBy(data -> 
data.url) 
 .window(TumblingEventTimeWindows.of(Time.seconds(10))) 
 // 方式二:允许窗口处理迟到数据,设置 1 分钟的等待时间 
 .allowedLateness(Time.minutes(1)) 
 // 方式三:将最后的迟到数据输出到侧输出流 
180 
 

 .sideOutputLateData(outputTag) 
 .aggregate(new UrlViewCountAgg(), new UrlViewCountResult()); 
 
 result.print("result"); 
 result.getSideOutput(outputTag).print("late"); 
 
 // 为方便观察,可以将原始数据也输出 
 stream.print("input"); 
 
 env.execute(); 
 } 
 
 public static class UrlViewCountAgg implements AggregateFunction<Event, Long, 
Long> { 
 @Override 
 public Long createAccumulator() { 
 return 0L; 
 } 
 
 @Override 
 public Long add(Event value, Long accumulator) { 
 return accumulator + 1; 
 } 
 
 @Override 
 public Long getResult(Long accumulator) { 
 return accumulator; 
 } 
 
 @Override 
 public Long merge(Long a, Long b) { 
 return null; 
 } 
181 
 

 } 
 
 public static class UrlViewCountResult extends ProcessWindowFunction<Long, 
UrlViewCount, String, TimeWindow> { 
 
 @Override 
 public void process(String url, Context context, Iterable<Long> elements, 
Collector<UrlViewCount> out) throws Exception { 
 // 结合窗口信息,包装输出内容 
 Long start = context.window().getStart(); 
 Long end = context.window().getEnd(); 
 out.collect(new UrlViewCount(url, elements.iterator().next(), start, 
end)); 
 } 
 } 
}