1. 为什么需要窗口?
- 把无限变有限:窗口把源源不断的数据,按时间或会话切分为有限"桶",从而能做聚合、排序、统计等计算。
- 契合业务口径:诸如"每小时销量""近 10 分钟活跃用户""按会话统计停留时长"等,天然就是窗口语义。
- 状态可控:窗口随生命周期结束(+ 允许迟到宽限)自动清理状态,避免状态无限膨胀。
2. 选择合适的窗口类型
2.1 三类内置窗口
- Time Window(时间窗口)
按时间范围切片,支持事件时间 或处理时间:
- Tumbling(滚动):固定大小、不重叠。
- Sliding(滑动):固定大小+滑动步长,可能重叠。
仅支持 Keyed Partition Stream。
- Session Window(会话窗口)
按"静默间隔(session gap)"切分,一段时间未收到数据就"闭合"一个会话窗口。
支持 Global Stream 与 Keyed Partition Stream。
- Global Window(全局窗口)
所有元素进入同一个窗口。常用于有界流,在输入结束时统一触发。
支持 Global / Keyed / Non-Keyed。
2.2 速查表(附 API 片段)
| 场景 | 窗口类型 | 时间语义 | 典型配置 |
|---|---|---|---|
| 整点统计每小时销量 | Tumbling | Event Time | WindowStrategy.tumbling(Duration.ofHours(1), EVENT_TIME) |
| 近 10 分钟滚动,每 5 分钟出一次 | Sliding | Processing/Event Time | WindowStrategy.sliding(Duration.ofMinutes(10), Duration.ofMinutes(5), PROCESSING_TIME) |
| 用户会话(5 分钟无操作即切窗) | Session | Event Time | WindowStrategy.session(Duration.ofMinutes(5), EVENT_TIME) |
| 有界批流一次性汇总 | Global | - | WindowStrategy.global() |
3. 时间语义与迟到数据(Allowed Lateness)
- 事件时间(Event Time):以数据里携带的时间戳为准。触发时机由**水位线(Watermark)**决定:当水位线越过窗口结束时间,窗口触发。
- 处理时间(Processing Time):以算子机器的系统时间为准,延迟可控但受抖动影响。
迟到数据:当水位线已经超过窗口结束时间后才到达的记录。
- 默认 丢弃 (
allowedLateness=0)。 - 若设置了 Allowed Lateness ,在"结束时间 + 宽限期"内到达的元素,仍会加入窗口并导致窗口再次触发 (re-fire);超出宽限期的元素会进入
onLateRecord()回调。
例:60s 窗口、30s 滑动、10s 允许迟到
java
WindowStrategy ws = WindowStrategy.sliding(
Duration.ofSeconds(60),
Duration.ofSeconds(30),
WindowStrategy.PROCESSING_TIME, // 或 EVENT_TIME
Duration.ofSeconds(10) // allowed lateness
);
4. WindowProcessFunction:把生命周期钩子用起来
单输入窗口处理接口(关键方法):
onRecord(IN record, ..., windowContext)
默认把记录写入窗口内置状态 (putRecord)。你也可以重写用于预聚合,以减少缓存全量记录的成本。onTrigger(..., windowContext)
窗口被触发时回调。若沿用内置状态,可用getAllRecords()获取窗口内所有记录;或读取你在窗口状态里维护的预聚合结果。onClear(..., windowContext)
窗口即将清理前回调。适合清理分区状态等你自维护的状态。onLateRecord(record, ...)
窗口已清理后到达的极端迟到数据,拿不到窗口状态。一般用于旁路告警或重处理通道。
小结:
- 轻计算 + 小数据量 → 直接在
onTrigger聚合getAllRecords();- 高 QPS / 大窗口 / 大记录 → 在
onRecord做预聚合 (窗口状态),onTrigger只读聚合值。
5. 状态管理:分区状态 vs 窗口状态
- Partitioned State(分区状态) :与 key(或 Non-Keyed 的任务实例)绑定,不随窗口切换 。需要你在合适时机(如
onClear)手动清理。 - Window State(窗口状态) :与具体窗口实例绑定,窗口清理时由框架统一清除,适合存放累加器、HLL、Bloom 等。
6. 从 0 到 1:每小时统计每个商品销量
6.1 基础版:沿用窗口内置状态,触发时聚合
java
public class CountProductSalesEveryHour {
public static class Order {
public long orderId;
public long productId;
public long salesQuantity;
public long orderTime; // 事件时间毫秒
}
public static void main(String[] args) throws Exception {
ExecutionEnvironment env = ExecutionEnvironment.getInstance();
// 1) 构造源流
NonKeyedPartitionStream<Order> orderSource = ...;
// 2) 抽取事件时间 + 生成水位线(200ms 周期)
NonKeyedPartitionStream<Order> orderStream = orderSource.process(
EventTimeExtension
.<Order>newWatermarkGeneratorBuilder(o -> o.orderTime)
.periodicWatermark(Duration.ofMillis(200))
.buildAsProcessFunction()
);
// 3) 按 productId 分区 + 1h 滚动窗口 + 触发时汇总
NonKeyedPartitionStream<Tuple2<Long, Long>> productSales = orderStream
.keyBy(o -> o.productId)
.process(BuiltinFuncs.window(
WindowStrategy.tumbling(Duration.ofHours(1), WindowStrategy.EVENT_TIME),
new CountSalesQuantity()
));
productSales.toSink(new WrappedSink<>(new PrintSink<>()));
env.execute("CountSalesQuantifyOfEachProductEveryHour");
}
// 触发时遍历窗口内全部订单求和
public static class CountSalesQuantity
implements OneInputWindowStreamProcessFunction<Order, Tuple2<Long, Long>> {
@Override
public void onTrigger(
Collector<Tuple2<Long, Long>> out,
PartitionedContext<Tuple2<Long, Long>> ctx,
OneInputWindowContext<Order> win) throws Exception {
long productId = ctx.getStateManager().getCurrentKey();
long total = 0L;
for (Order o : win.getAllRecords()) {
total += o.salesQuantity;
}
out.collect(Tuple2.of(productId, total));
}
}
}
适用 :窗口内数据量可控、逻辑简单。
代价 :需要缓存窗口内全部记录。
6.2 进阶版:窗口状态预聚合(降本增效)
java
public static class CountSalesQuantityWithPreAggregation
implements OneInputWindowStreamProcessFunction<Order, Tuple2<Long, Long>> {
private final ValueStateDeclaration<Long> sumDecl =
StateDeclarations.valueState("totalSalesQuantity", TypeDescriptors.LONG);
@Override
public Set<StateDeclaration> useWindowStates() {
return Set.of(sumDecl);
}
@Override
public void onRecord(
Order record,
Collector<Tuple2<Long, Long>> out,
PartitionedContext<Tuple2<Long, Long>> ctx,
OneInputWindowContext<Order> win) throws Exception {
ValueState<Long> sumState = win.getWindowState(sumDecl).get();
long sum = sumState.value() == null ? 0L : sumState.value();
sum += record.salesQuantity;
sumState.update(sum);
// 不再写入内置窗口状态,避免缓存全量记录
}
@Override
public void onTrigger(
Collector<Tuple2<Long, Long>> out,
PartitionedContext<Tuple2<Long, Long>> ctx,
OneInputWindowContext<Order> win) throws Exception {
long productId = ctx.getStateManager().getCurrentKey();
ValueState<Long> sumState = win.getWindowState(sumDecl).get();
long sum = sumState.value() == null ? 0L : sumState.value();
out.collect(Tuple2.of(productId, sum));
}
@Override
public void onLateRecord(
Order record,
Collector<Tuple2<Long, Long>> out,
PartitionedContext<Tuple2<Long, Long>> ctx) throws Exception {
// 窗口已清理后到达的极端迟到数据:通常旁路告警或沉到重处理队列
}
}
优势:
- 窗口不缓存全量记录,状态体积与网络开销显著下降;
- 适合高并发/大窗口/大体量记录的生产环境。
7. 生产最佳实践清单
-
时间窗口仅对 Keyed Stream 生效 :务必
keyBy之后再声明时间类窗口。 -
水位线推进策略:
- 确认时间戳提取字段正确;
- 注意分区空闲检测(idleness),避免少数空闲分区卡住全局水位线;
- 调整 watermark 周期与对齐策略,确保触发及时。
-
Allowed Lateness 会引发多次触发 :下游要么幂等 ,要么设计去重键(如窗口结束时间 + 业务 ID)。
-
onLateRecord 无法访问窗口状态:因为窗口已清理。把超迟到数据送旁路指标或死信队列。
-
Session gap:过小会"碎窗",过大会拖尾。以真实业务静默阈值为准。
-
Global Window:更适合有界输入;无界场景需自定义触发器与资源保护。
-
状态与容错:
- 大状态建议用 RocksDB;
- 为分区状态配置 TTL;窗口状态由框架清理。
- 合理设置 checkpoint 间隔、重启策略、并行度。
-
性能权衡:
- 滑动步长越小 → 产出越频繁 → 成本越高;
- 能预聚合就预聚合,减少
onTrigger的一次性压力。
-
测试与可观测:
- 用 MiniCluster/UDF 测试基架覆盖
onRecord/onTrigger/onLateRecord时序; - 关注"窗口元素数""迟到率""re-fire 次数""状态大小"等指标。
- 用 MiniCluster/UDF 测试基架覆盖
8. 常见需求拼装示例
"近 10 分钟去重 UV,每 1 分钟产出;允许 30 秒迟到;超迟到旁路告警"
java
WindowStrategy ws = WindowStrategy.sliding(
Duration.ofMinutes(10),
Duration.ofMinutes(1),
WindowStrategy.EVENT_TIME,
Duration.ofSeconds(30)
);
keyedUserIdStream.process(
BuiltinFuncs.window(ws, new UniqueUserCountWithLateSideOutput())
);
要点:
onRecord使用窗口状态里的HyperLogLog / Bloom / BitMap去重累加;onTrigger输出 UV;onLateRecord旁路上报。
9. API 复用与工程化封装
把"窗口策略"和"处理逻辑"分离,使其可配置 + 可复用:
java
KeyedPartitionStream<Order> keyed = orderStream.keyBy(o -> o.productId);
OneInputStreamProcessFunction fn = BuiltinFuncs.window(
WindowStrategy.tumbling(Duration.ofMinutes(5), WindowStrategy.EVENT_TIME),
new CountSalesQuantityWithPreAggregation()
);
keyed.process(fn).process(...);
- 策略层面:窗口大小、滑动步长、时间语义、允许迟到,都能通过配置切换。
- 逻辑层面:处理函数聚合/清理/旁路输出可单测与复用。
10. 结语
- 先选对窗口策略(WindowStrategy) ,再用 WindowProcessFunction 把生命周期钩子用好;
- 能预聚合就别存全量,成本与延迟同步下降;
- 把 Allowed Lateness 与下游幂等/去重一起设计;
- 用指标与测试守住正确性与成本边界。