处理迟到数据
有了事件时间、水位线和窗口的相关知识,现在就可以系统性地讨论一下怎样处理迟到数据了。所谓的“迟到数据”(late data),是指某个水位线之后到来的数据,它的时间戳其实是在水位线之前的。所以只有在事件时间语义下,讨论迟到数据的处理才是有意义的。事件时间里用来表示时钟进展的就是水位线(watermark)。对于乱序流,水位线本身就可以设置一个延迟时间;而做窗口计算时,又可以设置窗口的允许延迟时间;另外窗口还有将迟到数据输出到测输出流的用法。
1、设置水位线延迟时间
水位线是事件时间的进展,它是整个应用的全局逻辑时钟。水位线生成之后,会随着数据在任务间流动,从而给每个任务指明当前的事件时间。所以从这个意义上讲,水位线是一个覆盖万物的存在,它并不只针对事件时间窗口有效。之前讲到触发器时曾提到过“定时器”,时间窗口的操作底层就是靠定时器来控制触发的。既然是底层机制,定时器自然就不可能是窗口的专利了;事实上它是Flink底层API——处理函数(process function)的重要部分。所以水位线其实是所有事件时间定时器触发的判断标准。那么水位线的延迟,当然也就是全局时钟的滞后,相当于是上帝拨动了琴弦,所有人的表都变慢了。既然水位线这么重要,那一般情况就不应该把它的延迟设置得太大,否则流处理的实时性就会大大降低。因为水位线的延迟主要是用来对付分布式网络传输导致的数据乱序,而网络传输的乱序程度一般并不会很大,大多集中在几毫秒至几百毫秒。所以实际应用中,往往会给水位线设置一个“能够处理大多数乱序数据的小延迟”,视需求一般设在毫秒~秒级。当设置了水位线延迟时间后,所有定时器就都会按照延迟后的水位线来触发。如果一个数据所包含的时间戳,小于当前的水位线,那么它就是所谓的“迟到数据”。
2、允许窗口处理迟到数据
水位线延迟设置的比较小,那之后如果仍有数据迟到该怎么办?对于窗口计算而言,如果水位线已经到了窗口结束时间,默认窗口就会关闭,那么之后再来的数据就要被丢弃了。自然想到,Flink的窗口也是可以设置延迟时间,允许继续处理迟到数据的。这种情况下,由于大部分乱序数据已经被水位线的延迟等到了,所以往往迟到的数据不会太多。这样,会在水位线到达窗口结束时间时,先快速地输出一个近似正确的计算结果;然后保持窗口继续等到延迟数据,每来一条数据,窗口就会再次计算,并将更新后的结果输出。这样就可以逐步修正计算结果,最终得到准确的统计值了。类比班车的例子,可以这样理解:大多数人是在发车时刻前后到达的,所以只要把表调慢,稍微等一会儿,绝大部分人就都上车了,这个把表调慢的时间就是水位线的延迟;到点之后,班车就准时出发了,不过可能还有该来的人没赶上。于是我们就先慢慢往前开,这段时间内,如果迟到的人抓点紧还是可以追上的;如果有人追上来了,就停车开门让他上来,然后车继续向前开。当然我们的车不能一直慢慢开,需要有一个时间限制,这就是窗口的允许延迟时间。一旦超过了这个时间,班车就不再停留,开上高速疾驰而去了。所以我们将水位线的延迟和窗口的允许延迟数据结合起来,最后的效果就是先快速实时地输出一个近似的结果,而后再不断调整,最终得到正确的计算结果。回想流处理的发展过程,这不就是著名的Lambda架构吗?原先需要两套独立的系统来同时保证实时性和结果的最终正确性,如今Flink一套系统就全部搞定了。
3、将迟到数据放入窗口侧输出流
即使有了前面的双重保证,可窗口不能一直等下去,最后总要真正关闭。窗口一旦关闭,后续的数据就都要被丢弃了。那如果真的还有漏网之鱼又该怎么办呢?那就要用到最后一招了:用窗口的侧输出流来收集关窗以后的迟到数据。这种方式是最后“兜底”的方法,只能保证数据不丢失;因为窗口已经真正关闭,所以是无法基于之前窗口的结果直接做更新的。只能将之前的窗口计算结果保存下来,然后获取侧输出流中的迟到数据,判断数据所属的窗口,手动对结果进行合并更新。尽管有些烦琐,实时性也不够强,但能够保证最终结果一定是正确的。如果还用赶班车来类比,那就是车已经上高速开走了,这班车是肯定赶不上了。不过我们还留下了行进路线和联系方式,迟到的人如果想办法辗转到了目的地,还是可以和大部队会合的。最终,所有该到的人都会在目的地出现。所以总结起来,Flink处理迟到数据,对于结果的正确性有三重保障:水位线的延迟,窗口允许迟到数据,以及将迟到数据放入窗口侧输出流。我们可以回忆一下之前6.3.5小节统计每个url浏览次数的代码UrlViewCountExample,稍作改进,增加处理迟到数据的功能。具体代码如下。
public class LaterDataTest { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); //设置全局并行度 env.setParallelism(1); //设置水位线生成间隔 env.getConfig().setAutoWatermarkInterval(100); SingleOutputStreamOperator<Event> eventStream = env.socketTextStream("hadoop103", 9999).map( new MapFunction<String, Event>() { @Override public Event map(String value) throws Exception { String[] split = value.split(","); return new Event(split[0].trim(), split[1].trim(), Long.valueOf(split[2].trim())); } } ).returns(new TypeHint<Event>() { }).assignTimestampsAndWatermarks(WatermarkStrategy .<Event>forBoundedOutOfOrderness(Duration.ofSeconds(2)) .withTimestampAssigner(new SerializableTimestampAssigner<Event>() { @Override public long extractTimestamp(Event element, long recordTimestamp) { return element.timestamp; } })); eventStream.print(" input "); //定义输出标签 OutputTag<Event> later = new OutputTag<Event>("later") { }; //统计 url 访问量 SingleOutputStreamOperator<UrlViewCount> result = eventStream.keyBy(data -> data.url) .window(TumblingEventTimeWindows.of(Time.seconds(10))) .allowedLateness(Time.minutes(1)) //1 min 延迟 //迟到数据输出到册数出列 .sideOutputLateData(later) .aggregate(new UrlCountViewExample.UrlViewCountAgg(), new UrlCountViewExample.UrlViewCountResult()); result.print(" result "); //侧输出流 result.getSideOutput(later).print("later datas"); env.execute(); }