3. Watermark生成策略:周期性生成器 vs 标点式生成器
Watermark 怎么生成?说白了就两种路子:一种是每隔几秒看一眼,另一种是每来一条数据就判断一下。我刚开始接触 Flink 时,总觉得这两种方式差不多,直到在生产环境踩了坑,才发现选错了策略,后果可能很严重。
这一节,我们就来拆解这两种生成策略,以及 Flink 内置的那个最常用的 BoundedOutOfOrderness 策略。
3.1 周期性生成器(Periodic)
这是最常用的方式。它的核心逻辑是:系统会周期性地发射一个 Watermark。这个周期由 ExecutionConfig.setAutoWatermarkInterval() 控制,默认是 200 毫秒。
你想想看,如果数据流很平稳,乱序程度也固定,周期性生成器是最省心的。它不关心每一条数据,只在每个周期结束时,根据当前已收到的最大事件时间,减去一个固定的延迟量,然后发射 Watermark。
核心公式:
Watermark = 当前观察到的最大事件时间 - 最大允许乱序时间
举个例子。假设你设置最大乱序时间为 5 秒,周期为 1 秒。那么每过 1 秒,Flink 就会检查一下当前所有分区中最大的事件时间戳,减去 5 秒,然后广播出去。
我个人习惯用这种方式处理大部分业务场景。它开销小,实现简单,而且足够稳定。
3.1.1 实现方式
你需要实现 WatermarkGenerator 接口中的 onPeriodicEmit() 方法。Flink 会周期性地调用这个方法。
// 伪代码示例:周期性生成器
public class MyPeriodicGenerator implements WatermarkGenerator<MyEvent> {
private long maxTimestamp;
private long maxOutOfOrderness = 5000; // 5秒
@Override
public void onEvent(MyEvent event, long eventTimestamp, WatermarkOutput output) {
// 每次来事件,更新最大时间戳
maxTimestamp = Math.max(maxTimestamp, eventTimestamp);
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 周期性地发射 Watermark
output.emitWatermark(new Watermark(maxTimestamp - maxOutOfOrderness));
}
}
避坑指南: 我曾经遇到过一个场景,数据源偶尔会断流几分钟。周期性生成器在断流期间,仍然会基于最后一条数据的时间戳发射 Watermark。这会导致下游窗口被提前触发,丢数据。解决办法是:在 onPeriodicEmit() 里加一个判断,如果长时间没有新数据,就暂停发射 Watermark。
3.2 标点式生成器(Punctuated)
标点式生成器就不一样了。它每处理一条数据,都会判断是否要发射 Watermark。也就是说,Watermark 的生成是事件驱动的,而不是时间驱动的。
什么时候用这个?我记得有一次做金融交易风控,数据里本身就携带了水位标记字段。比如某条数据里有个 isWatermark = true 的标记,或者事件类型是 END_OF_BATCH。这时候,标点式生成器就派上用场了。
3.2.1 实现方式
你需要实现 onEvent() 方法,并在里面直接决定是否发射 Watermark。
// 伪代码示例:标点式生成器
public class MyPunctuatedGenerator implements WatermarkGenerator<MyEvent> {
@Override
public void onEvent(MyEvent event, long eventTimestamp, WatermarkOutput output) {
// 如果事件本身携带了水位标记
if (event.isWatermark()) {
output.emitWatermark(new Watermark(eventTimestamp));
}
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 标点式生成器,这个方法通常什么都不做
}
}
注意: 标点式生成器如果使用不当,可能会产生大量的 Watermark,给系统带来压力。每来一条数据都发射 Watermark,在高峰期可能每秒产生数万个 Watermark 事件。这会增加网络和状态后端的负担。所以,除非业务有明确需求,否则我建议优先考虑周期性生成器。
3.3 两种策略对比
为了让你看得更清楚,我整理了一张对比表:
| 对比维度 | 周期性生成器 | 标点式生成器 |
|---|---|---|
| 触发方式 | 基于时间周期(如每200ms) | 基于事件特征(如特定字段) |
| 生成频率 | 固定频率,可控 | 不固定,取决于数据流 |
| 适用场景 | 通用场景,乱序程度稳定 | 数据自带水位标记,或需要精确控制 |
| 实现复杂度 | 低 | 中 |
| 系统开销 | 低 | 高(可能产生大量Watermark) |
3.4 内置的 BoundedOutOfOrderness 策略
Flink 给我们提供了一个现成的周期性生成器实现:BoundedOutOfOrdernessTimestampExtractor(在 Flink 1.12 之后,推荐使用 WatermarkStrategy.forBoundedOutOfOrderness())。
这个策略说白了就是:假设数据最多乱序 N 秒。你只需要告诉 Flink 这个 N 是多少,剩下的它帮你搞定。
3.4.1 怎么用?
// Flink 1.12+ 推荐用法
DataStream<MyEvent> stream = env
.addSource(new MySource())
.assignTimestampsAndWatermarks(
WatermarkStrategy
.<MyEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.getEventTime())
);
就这么简单。你传一个 Duration.ofSeconds(5),Flink 就知道:允许数据最多乱序 5 秒。它内部实现的就是我们刚才讲的周期性生成器逻辑。
3.4.2 内部原理
它的实现其实不复杂。我拆解给你看:
- 维护一个
currentMaxTimestamp:每来一条数据,就更新这个值。 - 周期性发射:每 200ms(默认),发射一个
currentMaxTimestamp - maxOutOfOrderness的 Watermark。 - 多分区处理:如果有多个输入分区,Flink 会取所有分区中 最小的 Watermark 作为全局 Watermark。
关键点: 这个策略假设数据是“有界乱序”的。也就是说,你设置的 5 秒,意味着你相信不会有任何一条数据比当前最大时间戳晚超过 5 秒。如果真的有数据晚了 10 秒,那它就会被当作迟到数据处理(后面章节会讲迟到数据的处理)。
3.5 核心逻辑流程图
下面这张图,展示了周期性生成器和标点式生成器的核心区别:
3.6 如何选择?
我个人的经验是:
- 90% 的场景用
forBoundedOutOfOrderness:它简单、可靠、性能好。你只需要根据业务容忍度,设置一个合理的乱序时间就行。 - 数据自带水位标记时用标点式:比如日志系统里,每条日志都带一个
batchId,当batchId变化时,就代表上一个批次结束了。这时候标点式生成器最合适。 - 对延迟极度敏感时考虑标点式:如果你希望 Watermark 能立即反映最新数据的时间戳,而不是等几百毫秒的周期,标点式可以做到。
一个小建议: 如果你不确定选哪个,先用 forBoundedOutOfOrderness。它是最安全的选择。等业务跑起来后,通过监控 Watermark 的生成频率和窗口触发延迟,再决定是否需要优化。