1. 为什么事件驱动?为什么不是只用窗口?
很多需求用内置时间窗口 就能搞定(Tumbling/Sliding/Session)。但当你遇到这些场景,ProcessFunction 更合适:
- 非标准窗口逻辑:窗口边界、触发时机、跨窗对比、部分计算/回补等;
- 与外部系统互动:异步校验、限流、熔断、重试、超时告警;
- 精细化迟到处理:同一 key 内"先快后准",可多次更正;
- 状态寿命管理:空闲 key 清理、最长保留时间、按业务时序过期。
一句话:窗口能覆盖 80% 场景,剩下 20% 用 ProcessFunction 做精细控制。
2. 正确时间语义:事件时间 + 水位线
Flink 有三种时间:事件时间(发生)、摄取时间(进入 Flink)、处理时间(算子机器时钟)。
可复现、能重算、能正确处理乱序/迟到 → 选 事件时间。
水位线(Watermark)定义何时停止等待更早事件 :Watermark(t)
断言"≤ t 大致到齐"。
常用策略:有界乱序(BoundedOutOfOrderness),例如最大乱序 20 秒:
java
WatermarkStrategy<Event> wm = WatermarkStrategy
.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(20))
.withTimestampAssigner((e, ts) -> e.getEventTime());
DataStream<Event> stream = raw.assignTimestampsAndWatermarks(wm);
取舍:延迟 vs 完备性。可先"快产出,再用迟到修正"。
3. 无状态到有状态:map/flatMap → keyBy/聚合 → 有状态与定时器
- map/flatMap:清洗/富化/拆分(1→1 或 1→N)
- keyBy:同 key 进同一个并行子任务,才能"按账号/司机/设备"维护独立状态
- 增量聚合 优先:
reduce/aggregate
胜于全量process
(省内存) - 状态 + 定时器(ProcessFunction):做窗口外的时序/过期/限流等逻辑
4. 用 KeyedProcessFunction 实现"伪窗口"(每小时汇总小费)
目标:与 TumblingEventTimeWindow(1h)
等价,但用 ProcessFunction 自定义触发与迟到策略。
4.1 伪窗口骨架
java
public static class PseudoWindow
extends KeyedProcessFunction<Long, TaxiFare, Tuple3<Long, Long, Float>> {
private final long durationMs; // 窗口长度
private transient MapState<Long, Float> sum; // <窗口结束时间, 小费和>
public PseudoWindow(Duration duration) {
this.durationMs = duration.toMillis();
}
@Override
public void open(OpenContext ctx) {
MapStateDescriptor<Long, Float> desc =
new MapStateDescriptor<>("sumOfTips", Long.class, Float.class);
sum = getRuntimeContext().getMapState(desc);
}
@Override
public void processElement(TaxiFare fare, Context ctx,
Collector<Tuple3<Long, Long, Float>> out) throws Exception {
long et = fare.getEventTime();
TimerService ts = ctx.timerService();
if (et <= ts.currentWatermark()) {
// 迟到:默认丢弃,或侧输出(见 §5)
return;
}
long end = et - (et % durationMs) + durationMs - 1; // 向上对齐到窗口结束
ts.registerEventTimeTimer(end); // 注册事件时间定时器
Float s = sum.get(end);
sum.put(end, (s == null ? 0f : s) + fare.tip);
}
@Override
public void onTimer(long ts, OnTimerContext ctx,
Collector<Tuple3<Long, Long, Float>> out) throws Exception {
long driverId = ctx.getCurrentKey();
Float s = sum.get(ts);
out.collect(Tuple3.of(driverId, ts, s == null ? 0f : s));
sum.remove(ts); // 相当于 allowedLateness = 0
}
}
要点
- 用
MapState<窗口结束时间, 聚合值>
支持同时"打开"多个窗口(乱序/长延迟场景)。 - 定时器时间戳与
MapState
key 对齐 →onTimer
查找高效。 - 去掉
sum.remove(ts)
+ 引入允许迟到 & 多次触发,即可"先快后准"。
5. 迟到事件:侧输出 + 允许迟到"双保险"
侧输出把"本该丢弃"的迟到数据汇入备用流,便于监控/回补:
java
private static final OutputTag<TaxiFare> LATE = new OutputTag<TaxiFare>("late") {};
if (et <= ts.currentWatermark()) {
ctx.output(LATE, fare); // 收集迟到事件
return;
}
允许迟到(窗口 API 原生支持;ProcessFunction 需自行持久窗口状态更久并二次触发):
- 把窗口状态保留一个容忍区间(比如 10s),迟到到来再触发一次;
- 超过容忍区间仍迟到的 → 侧输出做离线回补/告警。
6. 连接流(Connected Streams):把"规则/阈值"也做成流
控制流(规则)与数据流按相同 key 连接,用 RichCoFlatMapFunction
或 KeyedCoProcessFunction
:
- 在控制流
flatMap1/2
中更新 keyed state (如blocked=true
); - 在数据流回调里检查状态,决定放行/丢弃/降级处理;
- 注意 :两条输入流竞态,必要时缓存与重放。
7. 工程化清单(Checklist)
水位线
- 起步用
forBoundedOutOfOrderness
;按迟到 P95/P99 调参; - 监控:
currentWatermark
、迟到比例、侧输出量、窗口延迟。
窗口
- 优先
reduce/aggregate
增量;需要窗口上下文再配ProcessWindowFunction
; - 滑动窗长窗短步会产生大量复制 → 评估状态/CPU;必要时改滚动窗+下游聚合。
状态
- RocksDB 后端时优先
MapState/ListState
,不要把集合塞进ValueState
; - 控制 key 基数与状态大小:TTL、空闲清理、降维聚合。
一致性
- Checkpoint + 外部化存储;Source/Sink 语义匹配(2PC、幂等)。
可观测性
- 指标:吞吐、延迟、反压、状态大小、Checkpoint 时间/失败率;
- 日志:迟到/丢弃采样、关键边界值打点。
8. 端到端示例(事件时间 + 伪窗口 + 迟到侧输出)
java
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 1) 事件时间 & 水位线
WatermarkStrategy<TaxiFare> wm = WatermarkStrategy
.<TaxiFare>forBoundedOutOfOrderness(Duration.ofSeconds(20))
.withTimestampAssigner((f, ts) -> f.getEventTime());
// 2) 主流
DataStream<TaxiFare> fares = env.addSource(new TaxiFareSource(...))
.assignTimestampsAndWatermarks(wm);
// 3) 伪窗口 + 侧输出迟到
OutputTag<TaxiFare> LATE = new OutputTag<TaxiFare>("late") {};
SingleOutputStreamOperator<Tuple3<Long, Long, Float>> hourlyTips = fares
.keyBy(f -> f.driverId)
.process(new PseudoWindow(Duration.ofHours(1)) {
private static final long serialVersionUID = 1L;
@Override
public void processElement(TaxiFare f, Context ctx,
Collector<Tuple3<Long, Long, Float>> out) throws Exception {
long et = f.getEventTime();
if (et <= ctx.timerService().currentWatermark()) {
ctx.output(LATE, f); // 迟到 → 侧输出
return;
}
super.processElement(f, ctx, out); // 调父类逻辑
}
});
hourlyTips.print("on-time");
hourlyTips.getSideOutput(LATE).print("late");
// 4) 别忘了执行
env.execute("Hourly Tips with PseudoWindow");
想要"允许迟到=10s"效果:保留窗口状态更久(不立刻 remove),在 10s 内再次触发;超过 10s 的仍进侧输出(离线补偿/审计)。
9. 性能小贴士
- 能增量就别全量 :
aggregate/reduce
>process
遍历Iterable
; - 对齐定时器与状态 key :用"窗口结束时间戳"作为
MapState
key 与定时器时间,onTimer
O(1) 命中; - 背压治理:批量 sink、异步 I/O、限速、合理并行度/算子链;
- 热键防护:热点 key 限流/分片(如加子键)、异步落库。
10. 结语
- 事件时间 + 水位线是实时计算正确性的根基;
- 窗口 API 足够强,但当触发/迟到/状态寿命/外部交互复杂时,ProcessFunction能给到你"像写后端业务一样"的掌控力;
- 用侧输出 + 允许迟到形成"快产出、可更正"的闭环;
- 把状态/一致性/可观测性一次做到位,你的流式应用才能稳健长期运行。