先引申出Flink中的时间语义:处理时间、事件时间
在事件时间的语义下,不依赖系统时间,而是基于数据自带的时间戳去定义一个时钟,用来表示当前时间的进展。
在数据流中加入一个时钟标记,记录当前的事件时间,这个标记可以直接广播到下游,当下游任务收到这个标记,就可以更新自己的时钟了,这种类似于水流中用来做标志的记号,在Flink中被称为水位线
在理想状态下,数据应该按照它们生成的先后顺序、排好队进入流中。在实际应用中,如果当前数据量非常大,可能会有很多数据的时间戳是相同的,这时每来一条时间就提取时间戳、插入水位线就做了大量的无用功。而且即使时间戳不同,同时涌来的时间差会非常小(比如几毫秒),往往对处理计算没什么影响,故为了提高效率,一般会采取每隔一段时间生成一个水位线(对应于时间戳)。这个每隔的时间周期指的是处理时间(系统时间)
在分布式系统中,数据在节点间的传输,会因为网络传输延迟的不确定性,导致顺序发生改变,这就是所谓的乱序。为了从乱序流中插入水位线,我们就需要定义一个规则:插入新的水位线时,先判断一下时间戳是否比之前的大,否则就不再生成新的水位线。
如果考虑到大量数据同时到来的处理效率,我们同样可以周期性地生成水位线,这时只需要保存一下之前所有数据中最大时间戳,需要插入水位线时,就直接以它作为时间戳生成新的水位线。
但也有一个问题,我们无法正确处理“迟到”的数据。为了让窗口能正确收集到迟到的数据,我们可以等上几秒,也就是用当前已有数据的最大时间戳减去几秒,就是要插入的水位线的时间戳
水位线是Flink流处理中保证结果正确性的核心机制,它往往会跟窗口一起配合,完成堆乱序数据的正确处理
计算处理更快、实时性更强、计算准确性尽可能得到保障,我们就需要设置合理的水位线。
在Flink的DataStream API中,有一个单独用于生成水位线的方法:assignTimestampsAndWatermarks(),它主要用来为流中的数据分配时间戳,并生成水位线来指示事件时间:
public SingleOutputStreamOperator<T> assignTimestampsAndWatermarks(
WatermarkStrategy<T> watermarkStrategy)
上述方法需要传入一个watermarkStrategy参数,这就是所谓的水位线生成策略
public interface WatermarkStrategy<T> extends TimestampAssignerSupplier<T>,
WatermarkGeneratorSupplier<T>{
@Override
TimestampAssigner<T> createTimestampAssigner(TimestampAssignerSupplier.Context context);
@Override
WatermarkGenerator<T> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context);
}
public interface WatermarkGenerator<T> {
void onEvent(T event, long eventTimestamp, WatermarkOutput output);
void onPeriodicEmit(WatermarkOutput output);
}
WatermarkStrategy这个接口是一个生成水位线策略的抽象,让我们可以灵活地实现自己的需求,如果想要自己实现还是比较麻烦的。Flink提供了内置的水位线生成器WatermarkGenerator,不仅开箱即用简化了编程,而且也为我们自定义水位线策略提供了模板。
这两个生成器可以通过调用WatermarkStrategy 的静态辅助方法来创建。它们都是周期性 生成水位线的,分别对应着处理有序流和乱序流的场景
import com.yingzi.chapter05.Source.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 java.time.Duration;
public class WatermarkTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.getConfig().setAutoWatermarkInterval(100);
//从元素中读取数据
SingleOutputStreamOperator<Event> stream = env.fromElements(
new Event("Mary", "./home", 1000L),
new Event("Bob", "./cart", 2000L),
new Event("Alice", "./prod?id=100", 3000L),
new Event("Bob", "./prod?id=1", 3300L),
new Event("Bob", "./home", 3500L),
new Event("Alice", "./prod?id=200", 3200L),
new Event("Bob", "./prod?id=2", 3800L),
new Event("Bob", "./prod?id=3", 4200L))
//有序流的watermark生成
// .assignTimestampsAndWatermarks(WatermarkStrategy
// .forMonotonousTimestamps()
// .withTimestampAssigner(new SerializableTimestampAssigner() {
// @Override
// public long extractTimestamp(Event element, long recordTimestamp) {
// return element.timestamp;
// }
// }))
//乱序流的watermark生成
.assignTimestampsAndWatermarks(WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(2))
.withTimestampAssigner(new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp;
}
}));
env.execute();
}
}
事实上,有序流的水位线生成器本质上和乱序流式一样的,相当于延迟设为0的乱序流水位线生成器,两者完全相同:
WatermarkStrategy.forMonotonousTimestamps()
WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(0))
注意:乱序流中生成的水位线真正的时间戳,其实是当前最大时间戳 - 延迟时间 - 1,这里单位是毫秒。
public void onPeriodicEmit(WatermarkOutput output) {
output.emitWatermark(new Watermark(maxTimestamp - outOfOrdernessMillis - 1));
}
一般来说,Flink内置的水位线生成器就可以满足应用需求了。不过有时我们得业务逻辑可能非常复杂,这就必须自定义实现水位线策略WatermarkStrategy。WatermarkGenerator接口中有两个方法:onEvent()、onPeriodicEmit(),前者是在每个时间到来时调用,后者由框架周期性调用。周期性调用的方法中发出水位线,自然就是周期性生成水位线;而在事件触发的方法中发出水位线,自然就是断点式生成了。两种方式的不同就集中体现在这两个方法的实现上
周期性生成器一般是通过onEvent()观察判断输入的事件,而在onPeriodicEmit()里发出水位线
import com.yingzi.chapter05.Source.ClickSource;
import com.yingzi.chapter05.Source.Event;
import org.apache.flink.api.common.eventtime.*;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
public class CustomWatermarkTest {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.addSource(new ClickSource())
.assignTimestampsAndWatermarks(new CustomWatermarkStrategy()).print();
env.execute();
}
public static class CustomWatermarkStrategy implements WatermarkStrategy<Event> {
@Override
public TimestampAssigner<Event> createTimestampAssigner(TimestampAssignerSupplier.Context context) {
return new SerializableTimestampAssigner<Event>() {
@Override
public long extractTimestamp(Event element, long recordTimestamp) {
return element.timestamp; // 告诉程序数据源里的时间戳是哪一个字段
}
};
}
@Override
public WatermarkGenerator<Event>
createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) {
return new CustomPeriodicGenerator();
}
}
public static class CustomPeriodicGenerator implements WatermarkGenerator<Event> {
private Long delayTime = 5000L; // 延迟时间
private Long maxTs = Long.MIN_VALUE + delayTime + 1L; // 观察到的最大时间戳
@Override
public void onEvent(Event event, long eventTimestamp, WatermarkOutput
output) {
// 每来一条数据就调用一次
maxTs = Math.max(event.timestamp, maxTs); // 更新最大时间戳
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 发射水位线,默认 200ms 调用一次
output.emitWatermark(new Watermark(maxTs - delayTime - 1L));
}
}
}
断点式生成器会不停地检测onEvent()中的事件,当发现带有水位线信息的特殊事件时,立即发出水位线。一般来说,断点式生成器不会通过onPeriodicEmit()发出水位线
public class CustomPunctuatedGenerator implements WatermarkGenerator<Event> {
@Override
public void onEvent(Event r, long eventTimestamp, WatermarkOutput output) {
// 只有在遇到特定的 itemId 时,才发出水位线
if (r.user.equals("Mary")) {
output.emitWatermark(new Watermark(r.timestamp - 1));
}
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 不需要做任何事情,因为我们在 onEvent 方法中发射了水位线
}
}
我们在 onEvent()中判断当前事件的 user 字段,只有遇到“Mary”这个特殊的值时,才调用 output.emitWatermark()发出水位线。这个过程是完全依靠事件来触发的,所以水位线的生成一 定在某个数据到来之后
我们也可以在自定义的数据源中抽取事件时间,然后发送水位线。这里要注意的是,在自 定义数据源中发送了水位线以后,就不能再在程序中使用 assignTimestampsAndWatermarks 方 法 来 生 成 水 位 线 了 。 在 自 定 义 数 据 源 中 生 成 水 位 线 和 在 程 序 中 使 用 assignTimestampsAndWatermarks 方法生成水位线二者只能取其一。
在自定义水位线中生成水位线相比 assignTimestampsAndWatermarks 方法更加灵活,可以 任意的产生周期性的、非周期性的水位线,以及水位线的大小也完全由我们自定义。所以非常 适合用来编写 Flink 的测试程序,测试 Flink 的各种各样的特性
import com.yingzi.chapter05.Source.Event;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.source.SourceFunction;
import org.apache.flink.streaming.api.watermark.Watermark;
import java.util.Calendar;
import java.util.Random;
public class EmitWatermarkInSourceFunction {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
env.addSource(new ClickSourceWithWatermark()).print();
env.execute();
}
// 泛型是数据源中的类型
public static class ClickSourceWithWatermark implements SourceFunction<Event> {
private boolean running = true;
@Override
public void run(SourceContext<Event> sourceContext) throws Exception {
Random random = new Random();
String[] userArr = {"Mary", "Bob", "Alice"};
String[] urlArr = {"./home", "./cart", "./prod?id=1"};
while (running) {
long currTs = Calendar.getInstance().getTimeInMillis(); // 毫秒时间戳
String username = userArr[random.nextInt(userArr.length)];
String url = urlArr[random.nextInt(urlArr.length)];
Event event = new Event(username, url, currTs);
// 使用 collectWithTimestamp 方法将数据发送出去,并指明数据中的时间戳的字段
sourceContext.collectWithTimestamp(event, event.timestamp);
// 发送水位线
sourceContext.emitWatermark(new Watermark(event.timestamp - 1L));
Thread.sleep(1000L);
}
}
@Override
public void cancel() {
running = false;
}
}
}
在实际应用中往往上下游都有多个并行子任务,为了统一推进事件时间的进展,我们要求上游任务处理完水位线、时钟改变之后,要把当前的水位线广播给所有的下游任务。这样,后续任务就不需要依赖原始数据中的时间戳,也可以知道当前事件时间了。
上游并行子任务发来不同的水位线,当前任务会为每一个分区设置一个“分区水位线” (Partition Watermark),这是一个分区时钟;而当前任务自己的时钟,就是所有分区时钟里最小的那个。
水位线在事件时间的世界里面,承担了时钟的角色,是唯一的时间尺度。
水位线的默认计算公式:水位线 = 观察到的最大事件时间 – 最大延迟时间 – 1 毫秒
在数据流开始之前,Flink 会插入一个大小是负无穷大的水位线,而在数据流结束时,Flink 会插入一个正无穷大)的水位线,保 证所有的窗口闭合以及所有的定时器都被触发。
对于离线数据集,Flink 也会将其作为流读入,也就是一条数据一条数据的读取。在这种 情况下,Flink 对于离线数据集,只会插入两次水位线,也就是在最开始处插入负无穷大的水 位线,在结束位置插入一个正无穷大的水位线。因为只需要插入两次水位线,就可以保证计算的正确,无需在数据流的中间插入水位线了