一文搞懂 Flink Watermark 设计理念(附源码
1. Watermark 的核心设计理念
1.1 为什么需要 Watermark?
问题背景:Event Time vs Processing Time
场景:统计每小时的交易金额
Processing Time 方式:
系统时间 10:00-11:00 的所有事件 → 10点窗口
问题:网络延迟、重放历史数据时结果不确定
Event Time 方式:
事件时间戳 10:00-11:00 的所有事件 → 10点窗口
问题:如何知道"10点的所有事件都到齐了"?
↓
需要 Watermark!
1.2 Watermark 的语义定义
// flink-core/src/main/java/org/apache/flink/api/common/eventtime/Watermark.java
/**
* Watermark(t) 声明:
* 事件时间已经到达时间 t,意味着不应该再有时间戳 <= t 的事件到来
*
* 如果后续还有 timestamp <= t 的事件,则为"迟到事件"(Late Event)
*/
public final class Watermark implements Serializable {
// 本质就是一个时间戳
private final long timestamp; // 毫秒时间戳
public static final Watermark MAX_WATERMARK = new Watermark(Long.MAX_VALUE);
}
关键概念:
Watermark(T) 的含义:
┌─────────────────────────────────────────────────┐
│ 时间轴: ... ─────|─────|─────|─────────> │
│ T-2 T-1 T 当前Watermark│
│ │
│ 保证:timestamp <= T 的事件"应该"都已到达 │
│ 允许:后续可能有迟到事件 (Late Events) │
└─────────────────────────────────────────────────┘
2. Watermark 核心接口设计
2.1 设计的三层抽象
// 层次1: WatermarkStrategy - 策略工厂
@Public
public interface WatermarkStrategy<T> {
// 创建 Watermark 生成器
WatermarkGenerator<T> createWatermarkGenerator(Context context);
// 创建时间戳提取器
TimestampAssigner<T> createTimestampAssigner(Context context);
}
// 层次2: WatermarkGenerator - 生成逻辑
@Public
public interface WatermarkGenerator<T> {
// 每个事件到来时调用
void onEvent(T event, long eventTimestamp, WatermarkOutput output);
// 周期性调用(默认200ms)
void onPeriodicEmit(WatermarkOutput output);
}
// 层次3: WatermarkOutput - 输出接口
public interface WatermarkOutput {
void emitWatermark(Watermark watermark);
void markIdle(); // 标记空闲
void markActive(); // 标记活跃
}
设计优势:
- 分离关注点:时间戳提取 vs Watermark 生成
- 灵活性:支持 Punctuated 和 Periodic 两种模式的统一
- 可组合:通过装饰器模式扩展功能(Idleness、Alignment)
3. Watermark 生成策略(源码实现)
3.1 策略一:单调递增时间戳
// 适用场景:事件严格按时间顺序到达(如日志文件)
@Public
public class AscendingTimestampsWatermarks<T> implements WatermarkGenerator<T> {
private long maxTimestamp = Long.MIN_VALUE + 1;
@Override
public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
// 每个事件到达,更新最大时间戳
maxTimestamp = Math.max(maxTimestamp, eventTimestamp);
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 周期性发送 Watermark = 当前最大时间戳 - 1
output.emitWatermark(new Watermark(maxTimestamp - 1));
}
}
核心思想:
事件流: [ts=100] [ts=105] [ts=110] [ts=115] ...
↓ ↓ ↓ ↓
Watermark: 99 104 109 114
(紧跟最大时间戳)
3.2 策略二:有界乱序(最常用)
// flink-core/.../BoundedOutOfOrdernessWatermarks.java
@Public
public class BoundedOutOfOrdernessWatermarks<T> implements WatermarkGenerator<T> {
private long maxTimestamp; // 见过的最大时间戳
private final long outOfOrdernessMillis; // 允许的最大乱序时间
public BoundedOutOfOrdernessWatermarks(Duration maxOutOfOrderness) {
this.outOfOrdernessMillis = maxOutOfOrderness.toMillis();
// 初始化为最小值 + 乱序时间 + 1
this.maxTimestamp = Long.MIN_VALUE + outOfOrdernessMillis + 1;
}
@Override
public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
// 持续跟踪最大时间戳
maxTimestamp = Math.max(maxTimestamp, eventTimestamp);
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// Watermark = 最大时间戳 - 允许乱序时间 - 1
output.emitWatermark(new Watermark(maxTimestamp - outOfOrdernessMillis - 1));
}
}
实际案例:
// 允许 3 秒乱序
WatermarkStrategy<Event> strategy = WatermarkStrategy
.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(3))
.withTimestampAssigner((event, ts) -> event.timestamp);
事件序列(乱序到达):
Time Event-TS MaxTS Watermark (MaxTS - 3000 - 1)
---- -------- ----- -------------------------
0ms 5000 5000 999 (5000-3000-1)
100ms 4000 5000 999 (不变,4000 < 5000)
200ms 8000 8000 4999 (8000-3000-1)
300ms 6000 8000 4999 (不变,6000 < 8000)
400ms 10000 10000 6999 (10000-3000-1)
解释:
- Watermark=4999 时,保证 <= 4999 的事件都到了
- Event(6000) 在 Watermark=4999 后到达 → OK(在窗口内)
- 如果 Event(3000) 在 Watermark=4999 后到达 → Late Event!
4. Watermark 在运行时的传播机制
4.1 Source 算子中的 Watermark 生成
// flink-runtime/.../TimestampsAndWatermarksOperator.java
public class TimestampsAndWatermarksOperator<T> extends AbstractStreamOperator<T> {
private WatermarkGenerator<T> watermarkGenerator;
private TimestampAssigner<T> timestampAssigner;
private long watermarkInterval; // 默认 200ms
@Override
public void open() throws Exception {
// 1. 创建时间戳提取器和 Watermark 生成器
timestampAssigner = watermarkStrategy.createTimestampAssigner(...);
watermarkGenerator = watermarkStrategy.createWatermarkGenerator(...);
// 2. 注册周期性 Watermark 发送定时器
watermarkInterval = getExecutionConfig().getAutoWatermarkInterval();
if (watermarkInterval > 0) {
long now = getProcessingTimeService().getCurrentProcessingTime();
getProcessingTimeService().registerTimer(now + watermarkInterval, this);
}
}
@Override
public void processElement(StreamRecord<T> element) throws Exception {
// 1. 提取时间戳
long newTimestamp = timestampAssigner.extractTimestamp(
element.getValue(),
element.getTimestamp()
);
element.setTimestamp(newTimestamp);
// 2. 转发元素到下游
output.collect(element);
// 3. 通知 Watermark 生成器(可能立即发送 Punctuated Watermark)
watermarkGenerator.onEvent(element.getValue(), newTimestamp, wmOutput);
}
@Override
public void onProcessingTime(long timestamp) throws Exception {
// 周期性触发(每 200ms)
watermarkGenerator.onPeriodicEmit(wmOutput);
// 注册下一次触发
long now = getProcessingTimeService().getCurrentProcessingTime();
getProcessingTimeService().registerTimer(now + watermarkInterval, this);
}
}
执行流程:
Source 产生数据:
┌─────────────────────────────────────────────────┐
│ [Record] → processElement() │
│ ↓ │
│ 1. 提取时间戳 (TimestampAssigner) │
│ 2. 设置到 StreamRecord │
│ 3. 转发到下游 │
│ 4. onEvent() - 通知 WatermarkGenerator │
│ │
│ [Timer 200ms] → onProcessingTime() │
│ ↓ │
│ onPeriodicEmit() - 周期性发送 Watermark │
└─────────────────────────────────────────────────┘
4.2 多输入算子的 Watermark 合并
// flink-runtime/.../StatusWatermarkValve.java
public class StatusWatermarkValve {
// 每个输入通道的状态
private final List<Map<Integer, SubpartitionStatus>> subpartitionStatuses;
// 使用优先队列找最小 Watermark
private final HeapPriorityQueue<SubpartitionStatus> alignedSubpartitionStatuses;
// 上次输出的 Watermark
private long lastOutputWatermark;
public void inputWatermark(Watermark watermark, int channelIndex, DataOutput<?> output) {
SubpartitionStatus status = subpartitionStatuses.get(channelIndex).get(...);
// 1. 更新该通道的 Watermark
if (watermark.getTimestamp() > status.watermark) {
status.watermark = watermark.getTimestamp();
// 2. 调整优先队列
if (status.isWatermarkAligned) {
adjustAlignedSubpartitionStatuses(status);
}
// 3. 计算新的最小 Watermark 并输出
findAndOutputNewMinWatermarkAcrossAlignedSubpartitions(output);
}
}
private void findAndOutputNewMinWatermarkAcrossAlignedSubpartitions(DataOutput<?> output) {
// 从优先队列取最小值
SubpartitionStatus minStatus = alignedSubpartitionStatuses.peek();
if (minStatus != null && minStatus.watermark > lastOutputWatermark) {
lastOutputWatermark = minStatus.watermark;
// 向下游发送新的 Watermark
output.emitWatermark(new Watermark(lastOutputWatermark));
}
}
}
多输入合并规则:
算子有 2 个输入流:
Input 1: ─── W(100) ─── W(150) ─── W(200) ───>
Input 2: ─── W(80) ──── W(120) ─── W(180) ───>
↓ ↓ ↓
合并输出: W(80) W(120) W(180)
(取最小) (取最小) (取最小)
原因:确保所有输入的 Watermark 都推进到某个时间点
才能保证该时间点前的事件都已到达
5. 空闲源(Idle Source)处理
5.1 为什么需要 Idle 检测?
问题场景:
┌────────────────────────────────────────────────┐
│ Kafka Partition 1: 高流量,Watermark 快速推进 │
│ [W(1000)] [W(2000)] [W(3000)] ... │
│ │
│ Kafka Partition 2: 低流量,长时间无数据 │
│ [W(100)] ... (空闲 5分钟) ... │
│ │
│ 合并结果: │
│ min(3000, 100) = 100 ← 被 Partition 2 阻塞! │
│ │
│ 后果:下游窗口无法触发,整个作业停滞! │
└────────────────────────────────────────────────┘
解决方案:将 Partition 2 标记为 Idle,合并时忽略它
5.2 Idle 检测源码实现
// flink-core/.../WatermarksWithIdleness.java
@Public
public class WatermarksWithIdleness<T> implements WatermarkGenerator<T> {
private final WatermarkGenerator<T> watermarks; // 包装的原始生成器
private final IdlenessTimer idlenessTimer; // 空闲检测定时器
private boolean isIdleNow = false;
@Override
public void onEvent(T event, long eventTimestamp, WatermarkOutput output) {
// 1. 委托给原始生成器
watermarks.onEvent(event, eventTimestamp, output);
// 2. 记录活动,重置空闲计时器
idlenessTimer.activity();
isIdleNow = false;
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 检查是否空闲
if (idlenessTimer.checkIfIdle()) {
if (!isIdleNow) {
output.markIdle(); // 标记为空闲
isIdleNow = true;
}
} else {
watermarks.onPeriodicEmit(output); // 正常发送 Watermark
}
}
static class IdlenessTimer {
private long counter; // 活动计数器
private long lastCounter; // 上次检查的计数
private long startOfInactivityNanos; // 不活跃开始时间
private final long maxIdleTimeNanos; // 空闲超时
public void activity() {
counter++; // 每次事件到来,计数器+1
}
public boolean checkIfIdle() {
if (counter != lastCounter) {
// 有新活动,重置
lastCounter = counter;
startOfInactivityNanos = 0L;
return false;
} else if (startOfInactivityNanos == 0L) {
// 首次检测到无活动,开始计时
startOfInactivityNanos = clock.relativeTimeNanos();
return false;
} else {
// 检查是否超时
return clock.relativeTimeNanos() - startOfInactivityNanos > maxIdleTimeNanos;
}
}
}
}
使用方式:
WatermarkStrategy<Event> strategy = WatermarkStrategy
.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(3))
.withIdleness(Duration.ofMinutes(1)) // 1分钟无数据则标记为空闲
.withTimestampAssigner((event, ts) -> event.timestamp);
空闲状态传播:
Partition 1: [Active, W(1000)]
Partition 2: [Idle] ← 被忽略
↓
合并输出: W(1000) (只考虑 Active 分区)
当 Partition 2 恢复数据:
Partition 2: [Active, W(500)] ← 重新参与合并
↓
合并输出: min(1000, 500) = W(500)
6. Watermark Alignment(对齐)
6.1 解决的问题
问题:多个 Source 速度不一致导致数据倾斜
Source A (快): W(10000) ─────────> 大量数据被处理
Source B (慢): W(100) ───────────> 阻塞整体进度
Watermark Alignment 目标:
- 限制快的 Source,使其不超过慢的 Source 太多
- 避免过多的状态积压和内存压力
6.2 对齐机制
// flink-core/.../WatermarksWithWatermarkAlignment.java
final class WatermarksWithWatermarkAlignment<T> implements WatermarkStrategy<T> {
private final String watermarkGroup; // 对齐组名称
private final Duration maxAllowedWatermarkDrift; // 最大允许漂移
private final Duration updateInterval; // 更新间隔
// 工作原理:
// 1. 同组的所有 Source 定期向 JobManager 报告当前 Watermark
// 2. JobManager 计算组内最小 Watermark (minWatermark)
// 3. 如果某个 Source 的 Watermark > minWatermark + maxDrift
// 则暂停该 Source 的数据消费
// 4. 等待其他 Source 追上后再恢复
}
配置示例:
WatermarkStrategy<Event> strategy = WatermarkStrategy
.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(3))
.withWatermarkAlignment(
"my-watermark-group", // 组名
Duration.ofSeconds(20) // 最大漂移 20 秒
);
对齐效果:
不对齐:
Source A: W(0) ─ W(1000) ─ W(2000) ─ W(3000) ─ ...
Source B: W(0) ─ W(100) ── W(200) ── W(300) ── ...
产生大量中间状态!
对齐(maxDrift=500):
Source A: W(0) ─ W(500) ─ [暂停] ─ W(700) ─ W(1200) ─ [暂停] ...
Source B: W(0) ─ W(100) ─ W(200) ─ W(300) ─ W(700) ─ ...
Source A 被限速,等待 B 追上
7. Watermark 与窗口的交互
7.1 窗口触发条件
// 窗口触发逻辑(伪代码)
class WindowOperator {
@Override
public void processWatermark(Watermark mark) {
// 遍历所有活跃窗口
for (Window window : activeWindows) {
// 当 Watermark >= 窗口结束时间,触发窗口
if (mark.getTimestamp() >= window.maxTimestamp()) {
// 1. 触发窗口计算
triggerWindow(window);
// 2. 清理窗口状态
if (!allowedLateness) {
cleanupWindow(window);
}
}
}
// 转发 Watermark 到下游
output.emitWatermark(mark);
}
}
示例:
窗口: [10:00:00, 10:00:10) (10秒滚动窗口)
事件流:
t=10:00:02 Event(user1, 100) ─┐
t=10:00:05 Event(user2, 200) ├─> 进入窗口
t=10:00:08 Event(user1, 150) ─┘
Watermark 推进:
W(10:00:03) → 窗口未触发
W(10:00:07) → 窗口未触发
W(10:00:10) → 窗口触发! (Watermark >= 10:00:10)
→ 输出: sum = 450
7.2 迟到数据处理
// 允许 1 分钟延迟
stream
.keyBy(...)
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.allowedLateness(Time.minutes(1)) // 允许迟到
.sideOutputLateData(lateDataTag) // 迟到数据输出到侧输出流
.sum("value");
时间线:
Window: [10:00, 10:01)
W(10:01) 到达 → 触发窗口,但不删除状态
W(10:02) 到达 → 清理窗口状态
10:00:30 的事件在 W(10:01:30) 前到达 → 更新窗口结果
10:00:30 的事件在 W(10:02) 后到达 → 发送到侧输出流
8. 总结:Watermark 设计精髓
核心设计原则
- 渐进性保证:Watermark 提供的是"尽力而为"的保证,允许迟到事件
- 分布式协调:通过取最小值合并,确保全局一致性
- 灵活性:支持多种生成策略和扩展机制
- 性能优化:
- 周期性生成减少开销
- Idle 检测避免阻塞
- Alignment 控制状态膨胀
关键实现技巧
// 1. 分层设计
WatermarkStrategy → 策略定义(用户层)
WatermarkGenerator → 生成逻辑(实现层)
StatusWatermarkValve → 多流合并(运行时层)
// 2. 装饰器模式
baseStrategy
.withIdleness(...) // 添加空闲检测
.withAlignment(...) // 添加对齐
.withTimestampAssigner() // 添加时间戳提取
// 3. 时间语义隔离
Watermark(事件时间) + ProcessingTime(系统时间)
→ 混合使用,各司其职
69

被折叠的 条评论
为什么被折叠?



