目录
设置水位线延迟时间
允许窗口处理迟到数据
将迟到数据放入窗口侧输出流
总结:
我们知道,所谓的“迟到数据”( 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 )的具体实现,如果读者有兴趣可以读一下原始论文,会对流处
理有更加深刻的理解。