目录

设置水位线延迟时间

允许窗口处理迟到数据

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

总结:


 


        我们知道,所谓的“迟到数据”( late data ),是指某个水位线之后到来的数据,它的时


间戳其实是在水位线之前的。所以只有在事件时间语义下,讨论迟到数据的处理才是有意义的。



        事件时间里用来表示时钟进展的就是水位线(watermark )。对于乱序流,水位线本身就可


以设置一个延迟时间;而做窗口计算时,我们又可以设置窗口的允许延迟时间;另外窗口还有


将迟到数据输出到测输出流的用法。所有的这些方法,它们之间有什么关系,我们又该怎样合


理利用呢?这一节我们就来讨论这个问题。



设置水位线延迟时间



        水位线是事件时间的进展,它是我们整个应用的全局逻辑时钟。水位线生成之后,会随着


数据在任务间流动,从而给每个任务指明当前的事件时间。所以从这个意义上讲,水位线是一


个覆盖万物的存在,它并不只针对事件时间窗口有效。



        之前我们讲到触发器时曾提到过“定时器”,时间窗口的操作底层就是靠定时器来控制触


发的。既然是底层机制,定时器自然就不可能是窗口的专利了;事实上它是 Flink 底层 API —


—处理函数( process function )的重要部分。



        所以水位线其实是所有事件时间定时器触发的判断标准。那么水位线的延迟,当然也就是


全局时钟的滞后,相当于是上帝拨动了琴弦,所有人的表都变慢了。



        既然水位线这么重要,那一般情况就不应该把它的延迟设置得太大,否则流处理的实时性


就会大大降低。因为水位线的延迟主要是用来对付分布式网络传输导致的数据乱序,而网络传


输的乱序程度一般并不会很大,大多集中在几毫秒至几百毫秒。所以实际应用中,我们往往会


给水位线设置一个“能够处理大多数乱序数据的小延迟”,视需求一般设在毫秒 ~ 秒级。



        当我们设置了水位线延迟时间后,所有定时器就都会按照延迟后的水位线来触发。如果一


个数据所包含的时间戳,小于当前的水位线,那么它就是所谓的“迟到数据”。



允许窗口处理迟到数据



        水位线延迟设置的比较小,那之后如果仍有数据迟到该怎么办?对于窗口计算而言,如果


水位线已经到了窗口结束时间,默认窗口就会关闭,那么之后再来的数据就要被丢弃了。


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



这种情况下,由于大部分乱序数据已经被水位线的延迟等到了,所以往往迟到的数据不会


太多。这样,我们会在水位线到达窗口结束时间时,先快速地输出一个近似正确的计算结果;


然后保持窗口继续等到延迟数据,每来一条数据,窗口就会再次计算,并将更新后的结果输出。


这样就可以逐步修正计算结果,最终得到准确的统计值了。



        类比班车的例子,我们可以这样理解:大多数人是在发车时刻前后到达的,所以我们只要


把表调慢,稍微等一会儿,绝大部分人就都上车了,这个把表调慢的时间就是水位线的延迟;


到点之后,班车就准时出发了,不过可能还有该来的人没赶上。于是我们就先慢慢往前开,这


段时间内,如果迟到的人抓点紧还是可以追上的;如果有人追上来了,就停车开门让他上来,


然后车继续向前开。当然我们的车不能一直慢慢开,需要有一个时间限制,这就是窗口的允许


延迟时间。一旦超过了这个时间,班车就不再停留,开上高速疾驰而去了。



 所以我们将水位线的延迟和窗口的允许延迟数据结合起来,最后的效果就是先快速实时地


输出一个近似的结果,而后再不断调整,最终得到正确的计算结果。回想流处理的发展过程,


这不就是著名的 Lambda 架构吗?原先需要两套独立的系统来同时保证实时性和结果的最终


正确性,如今 Flink 一套系统就全部搞定了。



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




        即使我们有了前面的双重保证,可窗口不能一直等下去,最后总要真正关闭。窗口一旦关



闭,后续的数据就都要被丢弃了。那如果真的还有漏网之鱼又该怎么办呢?




        那就要用到最后一招了:用窗口的侧输出流来收集关窗以后的迟到数据。这种方式是最后



“兜底”的方法,只能保证数据不丢失;因为窗口已经真正关闭,所以是无法基于之前窗口的



结果直接做更新的。我们只能将之前的窗口计算结果保存下来,然后获取侧输出流中的迟到数



据,判断数据所属的窗口,手动对结果进行合并更新。尽管有些烦琐,实时性也不够强,但能



够保证最终结果一定是正确的。




        如果还用赶班车来类比,那就是车已经上高速开走了,这班车是肯定赶不上了。不过我们



还留下了行进路线和联系方式,迟到的人如果想办法辗转到了目的地,还是可以和大部队会合



的。最终,所有该到的人都会在目的地出现。




         所以总结起来,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();
 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))
 // 方式三:将最后的迟到数据输出到侧输出流
 .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;
 }
 }
 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));
 }
 }
}



我们还是先启动 nc –lk 7777 ,然后依次输入以下数据:




Alice, ./home, 1000

Alice, ./home, 2000

Alice, ./home, 10000

Alice, ./home, 9000

Alice, ./cart, 12000

Alice, ./prod?id=100, 15000

Alice, ./home, 9000

Alice, ./home, 8000

Alice, ./prod?id=200, 70000

Alice, ./home, 8000

Alice, ./prod?id=300, 72000

Alice, ./home, 8000




        下面我们来分析一下程序的运行过程。当输入数据[Alice, ./home, 10000] 时,时间戳为



10000 ,由于设置了 2 秒钟的水位线延迟时间,所以此时水位线到达了 8 秒(事实上是 7999



毫秒,这里不再追究减 1 的细节),并没有触发 [0, 10s) 窗口的计算;所以接下来时间戳为 9000



的数据到来,同样可以直接进入窗口做增量聚合。当时间戳为 12000 的数据到来时(无所谓



url 是什么,所有数据都可以推动水位线前进),水位线到达了 12000 – 2 * 1000 = 10000 ,所以



触发了 [0, 10s) 窗口的计算,第一次输出了窗口统计结果,如下所示:




result> UrlViewCount{url='./home,', count=3, windowStart=1970-01-01 08:00:00.0,

windowEnd=1970-01-01 08:00:10.0}




        这里 count 值为 3 ,就包括了之前输入的时间戳为 1000 、 2000 、 9000 的三条数据。



        不过窗口触发计算之后并没有关闭销毁,而是继续等待迟到数据。之后时间戳为 15000



的数据继续推进水位线,此时时钟已经进展到了 13000ms ;此时再来一条时间戳为 9000 的数



据,我们会发现立即输出了一条统计结果:




result> UrlViewCount{url='./home,', count=4, windowStart=1970-01-01

08:00:00.0, windowEnd=1970-01-01 08:00:10.0}




        很明显,这仍然是[0, 10s) 的窗口,在之前计数值 3 的基础上继续叠加,更新统计结果为



4 。所以允许窗口处理迟到数据之后,相当于窗口有了一段等待时间,在这期间所有的迟到数



据都会立即触发窗口计算,更新之前的结果。




        因此,之后时间戳为 8000 的数据到来,同样会立即输出:




result> UrlViewCount{url='./home,', count=5, windowStart=1970-01-01

08:00:00.0, windowEnd=1970-01-01 08:00:10.0}




·        我们设置窗口等待的时间为 1 分钟,所以当时间推进到 10000 + 60 * 1000 = 70000 时,窗



口就会真正被销毁。此前的所有迟到数据可以直接更新窗口的计算结果,而之后的迟到数据已



经无法整合进窗口,就只能用侧输出流来捕获了。需要注意的是,这里的“时间”依然是由水



位线来指示的,所以时间戳为 70000 的数据到来,并不会触发窗口的销毁;当时间戳为 72000



的数据到来,水位线推进到了 72000 – 2 * 1000 = 70000 ,此时窗口真正销毁关闭,之后再来的



迟到数据就会输出到侧输出流了:




late> Event{user='Alice,', url='./home,', timestamp=1970-01-01 08:00:08.0}




总结:




        在流处理中,由于对实时性的要求非常高,同时又要求能够保证窗口操作结果的正确,所



以必须引入水位线来描述事件时间。而窗口正是时间相关的最佳应用场景,所以 Flink 为我们



提供了丰富的窗口类型和处理操作;与此同时,在实际应用中很难对乱序流给出一个最佳延迟



时间,单独依赖水位线去保证结果正确性是不够的,所以需要结合窗口( Window )处理迟到



数据的相关 API 。本章我们详细了解了 Flink 中时间语义和水位线的概念、窗口 API 的用法以



及处理迟到数据的相关知识,这些内容对于实时流处理来说非常重要。




        Flink 的时间语义和窗口,主要就是为了处理大规模的乱序数据流时,同时保证低延迟、



高吞吐和结果的正确性。这部分设计基本上是对谷歌( Google )著名论文《数据流模型:一种



在大规模、无界、无序数据处理中平衡正确性、延迟和性能的实用方法》( The Dataflow Model:



A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded,



Out-of-Order Data Processing )的具体实现,如果读者有兴趣可以读一下原始论文,会对流处



理有更加深刻的理解。