写这篇文章的缘由很简单:我在实际项目里被 Watermark 坑过好几次,窗口该触发的时候没触发,数据该算进去的时候被丢了,追查半天发现是对时间语义的理解有盲区。
本文基于 Flink 1.17+(目前生产环境主流版本),代码示例用 Java,道理是通的。
一、先搞明白:Flink 为什么要搞三种时间?
在批处理里,数据一批全到了,算完就完事,时间就是个字段。但流处理不一样,数据是源源不断来的,没有"全部到齐"这一说。问题来了:窗口到底什么时候该关上?一条迟到很久的数据还要不要算进去?
Flink 给了三种时间来回答这个问题:
| 时间类型 | 英文 | 到底是什么 | 由谁决定 | 数据乱序时表现 |
|---|---|---|---|---|
| 事件时间 | Event Time | 数据本身产生的时间,比如埋点日志里的 timestamp 字段 |
业务数据自带 | 能处理乱序,保证结果准确 |
| 处理时间 | Processing Time | 数据到达 Flink 算子、被处理的那一刻 | Flink 机器的系统时间 | 简单高效,但乱序时结果不对 |
| 摄入时间 | Ingestion Time | 数据进入 Flink Source 的那一刻 | Source 算子生成 | 比处理时间稳定一点,但也不能处理乱序 |
1.1 为什么生产环境首选事件时间?
一句话:事件时间是唯一对业务结果负责的时间。
举个例子:你在做一个实时统计,算"每小时订单金额"。一条订单是 10:59 产生的,但因为网络抖动,11:05 才传到 Flink。如果用处理时间,这条订单会被算到 11 点那一小时,你的 10 点整的统计就少了一条,11 点的统计就多了一条。对账的时候对不上,老板找你,你说"这不是我的问题,是网络的问题"------行不通的。
事件时间直接取订单里的 order_time 字段,不管它什么时候到,10:59 的订单就算在 10 点那个窗口里,结果是对的。
java
// 设置事件时间语义
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
// 1.12+ 版本默认就是 EventTime,显式写一下更清楚
// 从数据流里提取时间戳并分配 Watermark
dataStream.assignTimestampsAndWatermarks(
WatermarkStrategy.<MyEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withIdleness(Duration.ofMinutes(1)) // 空闲分区处理,后面会讲
.withTimestampAssigner((event, timestamp) -> event.getEventTime())
);
1.2 三种时间对比图
事件时间轴(业务真实时间): t1 ---- t2 ---- t3 ---- t4 ---- t5
| | | | |
数据产生: [D1] [D2] [D3] [D4] [D5]
| | | | |
网络传输(乱序到达): | | | | |
v v v v v
实际到达 Flink 的顺序: [D1] [D3] [D2] [D5] [D4] ← D2、D4 迟到了
↑
处理时间只管到达的顺序,不管业务时间
1.3 摄入时间为什么几乎没人用?
摄入时间比处理时间稳定一点(Source 统一打时间戳,不会因为各个算子处理速度不同而不同),但它依然不能处理乱序。现在生产环境要么选事件时间(要求结果准确),要么选处理时间(要求简单、允许近似结果),摄入时间夹在中间两边不讨好,基本处于"知道有这个东西就行"的状态。
二、Watermark:事件时间的"进度条"
2.1 Watermark 到底是个啥?
前面说了事件时间能解决乱序问题,但窗口什么时候关?你不能无限等下去啊。Watermark 就是来解决这个问题的。
Watermark 是一个特殊标记,它告诉 Flink:所有时间戳小于等于 Watermark 的数据都已经到了(至少我认为都到了),可以触发窗口计算了。
Watermark 的本质是 "我赌后面不会有更晚的数据了"。你敢不敢赌,赌多大,这就是 Watermark 策略要决定的。
数据流时间轴:
事件时间: 1s 2s 3s 4s 5s 6s 7s 8s 9s 10s
数据到达: D1 D3 D2 D5 D4 D7 D6 D9 D8 D10
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
Watermark: W(1) W(3) W(3) W(5) W(5) W(7) W(7) W(9) W(9) W(11)
↑
假设乱序容忍 2 秒,Watermark = 当前最大事件时间 - 2s
当 Watermark >= 窗口结束时间时,窗口触发计算
2.2 Watermark 的两种生成策略
| 策略 | 英文名 | 原理 | 适用场景 | 代码 |
|---|---|---|---|---|
| 周期性生成 | Periodic | 每隔一段时间(默认 200ms)生成一个 Watermark | 乱序程度相对稳定,比如内网传输 | forBoundedOutOfOrderness() |
| 标点式生成 | Punctuated | 每条数据都可以决定是否生成 Watermark | 数据中有明确的"结束标记",比如 MQTT 的某个标志位 | forGenerator() 自定义 |
生产环境 99% 用的是周期性生成,因为标点式每条数据都要判断,性能开销大,而且大多数数据源没有这种"结束标记"。
java
// 周期性 Watermark:乱序容忍 5 秒
WatermarkStrategy.<MyEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
// 周期性 Watermark:严格递增,不允许乱序(几乎没有场景用这个)
WatermarkStrategy.<MyEvent>forMonotonousTimestamps()
// 自定义标点式 Watermark(极少用,了解即可)
WatermarkStrategy.forGenerator(ctx -> new WatermarkGenerator<MyEvent>() {
@Override
public void onEvent(MyEvent event, long eventTimestamp, WatermarkOutput output) {
// 如果数据带有 end-of-stream 标记,发出 Watermark
if (event.isEndMarker()) {
output.emitWatermark(new Watermark(eventTimestamp));
}
}
@Override
public void onPeriodicEmit(WatermarkOutput output) {
// 周期性也可以发,一般不在这里发
}
});
2.3 Watermark 设置多大?这是个生产大坑
Watermark 的延迟设置(也就是乱序容忍时间)直接影响三件事:
| 设置 | 结果 | 延迟 | 准确性 |
|---|---|---|---|
| 太小(1s) | 迟到数据被丢弃,结果不准 | 低 | 差 |
| 太大(10min) | 窗口迟迟不触发,延迟高,State 爆炸 | 高 | 好 |
| 适中(5~30s) | 平衡延迟和准确性 | 可接受 | 够用 |
生产环境建议:
- 先看你数据源的网络环境,内网 Kafka 一般 5~10 秒够了
- 跨机房、公网传输,可能 30 秒甚至更久
- 配合
allowedLateness做二次补救(后面讲) - 别上来就设 10 分钟,你的 State 会炸,Checkpoint 会超时,TM 会挂
java
// 生产环境推荐配置
dataStream.assignTimestampsAndWatermarks(
WatermarkStrategy.<MyEvent>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withIdleness(Duration.ofMinutes(5)) // Kafka 空闲分区处理
);
三、四种窗口类型:什么时候用哪个?
3.1 四种窗口对比
| 窗口类型 | 英文名 | 特点 | 适用场景 | 生命周期 |
|---|---|---|---|---|
| 滚动窗口 | Tumbling Window | 固定大小,不重叠,一个接一个 | 每 5 分钟统计一次 PV | 窗口结束 + Watermark 到达后关闭 |
| 滑动窗口 | Sliding Window | 固定大小,有步长,窗口之间可能重叠 | 每 5 分钟统计一次最近 10 分钟的数据(重叠) | 同滚动窗口 |
| 会话窗口 | Session Window | 动态大小,根据数据间隔(gap)自动切分 | 用户行为分析,一个会话内的点击 | gap 时间内无数据则关闭 |
| 全局窗口 | Global Window | 只有一个窗口,不会自动关闭 | 需要自定义 Trigger 触发 | 手动控制,不会自动关闭 |
java
// 滚动窗口:每 5 分钟统计一次
dataStream.keyBy(MyEvent::getUserId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.aggregate(new CountAggregate());
// 滑动窗口:每 1 分钟滑动一次,窗口大小 5 分钟,数据会参与 5 个窗口的计算
dataStream.keyBy(MyEvent::getUserId)
.window(SlidingEventTimeWindows.of(Time.minutes(5), Time.minutes(1)))
.aggregate(new CountAggregate());
// 会话窗口:5 分钟无数据则窗口关闭
dataStream.keyBy(MyEvent::getUserId)
.window(EventTimeSessionWindows.withDynamicGap(
(element) -> Time.minutes(5)
))
.aggregate(new CountAggregate());
// 全局窗口 + 自定义 Trigger
dataStream.keyBy(MyEvent::getUserId)
.window(GlobalWindows.create())
.trigger(CountTrigger.of(100)) // 每 100 条触发一次
.aggregate(new CountAggregate());
3.2 滚动窗口 vs 滑动窗口的关键区别
滚动窗口就像切黄瓜,一刀切下去,段与段之间不重叠。滑动窗口就像把一个 5cm 的尺子每隔 1cm 量一次,每次量的区域有 4cm 是重叠的。
滑动窗口的坑:窗口大小 1 小时,滑动步长 1 分钟,每条数据要参与 60 个窗口的计算!State 和计算量都翻 60 倍,这是生产环境最容易被忽视的隐患。
3.3 会话窗口的坑
会话窗口看着很美好------"用户多久没操作算会话结束",但实际用起来有几个问题:
- 同一个 key 的数据如果跨 session,窗口合并开销大
- 迟到数据可能导致已经关闭的窗口重新合并,状态管理复杂
- 会话窗口的 State 不像滚动/滑动窗口那样可以预估,可能突然膨胀
生产环境用会话窗口之前,先确认你的数据量扛得住动态窗口合并的开销。
四、三层兜底方案:Watermark + Allowed Lateness + SideOutput
这是生产环境处理迟到数据的黄金组合,面试必考。
4.1 为什么需要三层?
想象你在等快递:
- 第一层(Watermark):你说"等到下午 3 点,3 点前的快递我都签收"------这是正常流程
- 第二层(Allowed Lateness):你说"3 点后的快递,晚到 10 分钟的我也收"------这是宽限期
- 第三层(SideOutput):"10 分钟后到的快递,放到隔壁代收点,我晚点自己去取"------最后的兜底
java
// 侧输出流标签
OutputTag<MyEvent> lateDataTag = new OutputTag<MyEvent>("late-data"){};
SingleOutputStreamOperator<Result> result = dataStream
.assignTimestampsAndWatermarks(
WatermarkStrategy.<MyEvent>forBoundedOutOfOrderness(Duration.ofSeconds(10))
)
.keyBy(MyEvent::getUserId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.allowedLateness(Time.minutes(2)) // 第二层:允许迟到 2 分钟
.sideOutputLateData(lateDataTag) // 第三层:再迟的放侧输出流
.aggregate(new CountAggregate());
// 获取迟到数据流
DataStream<MyEvent> lateData = result.getSideOutput(lateDataTag);
lateData.addSink(new LateDataLogger()); // 打上日志,后续人工或离线处理
4.2 三层方案的数据流向
正常数据到达
│
▼
┌─────────────────┐
│ Watermark 判断 │ ← 第一层:Watermark 已推进过窗口结束时间?
│ (正常窗口触发) │
└─────────────────┘
│ │
│ 在Watermark内 │ 迟到但还在 allowedLateness 内
▼ ▼
触发窗口计算 ┌─────────────────┐
输出结果 │ AllowedLateness │ ← 第二层:更新窗口结果
│ (窗口重新触发) │
└─────────────────┘
│
│ 超过 allowedLateness
▼
┌─────────────────┐
│ SideOutput │ ← 第三层:侧输出流
│ (日志/离线处理) │
└─────────────────┘
4.3 生产环境配置建议
| 层级 | 配置 | 建议值 | 作用 |
|---|---|---|---|
| Watermark | forBoundedOutOfOrderness(delay) |
5~30s | 覆盖正常乱序 |
| AllowedLateness | .allowedLateness(lateness) |
1~5min | 覆盖网络抖动 |
| SideOutput | .sideOutputLateData(tag) |
必须配 | 数据不丢,可追溯 |
面试常问 :如果不配置 allowedLateness,Watermark 过了窗口结束时间后,后续到达的数据(哪怕只晚到 1 毫秒)直接丢弃。配置之后,窗口会重新触发,更新之前的结果(输出新结果或增量更新,取决于你的 AggregateFunction)。
五、增量聚合 vs 全量聚合:性能差距能有多大?
5.1 核心区别
| 特性 | 增量聚合 | 全量聚合 |
|---|---|---|
| 接口 | ReduceFunction / AggregateFunction |
WindowFunction / ProcessWindowFunction |
| 内存占用 | 只存中间结果(一个值) | 存窗口内所有数据(List) |
| 触发时计算量 | 几乎为零,结果已经算好了 | 遍历所有数据重新算 |
| 能获取的信息 | 只能拿到中间结果 | 能拿到窗口内所有数据、上下文信息 |
| 适用场景 | Sum、Count、Avg 等可增量计算 | 去重、排序、复杂逻辑 |
java
// ===== 增量聚合:性能最好,只存一个累加值 =====
// AggregateFunction<输入类型, 累加器类型, 输出类型>
class AvgAggregate implements AggregateFunction<MyEvent, Tuple2<Long, Long>, Double> {
@Override
public Tuple2<Long, Long> createAccumulator() {
return Tuple2.of(0L, 0L); // (sum, count)
}
@Override
public Tuple2<Long, Long> add(MyEvent value, Tuple2<Long, Long> accumulator) {
return Tuple2.of(accumulator.f0 + value.getAmount(), accumulator.f1 + 1);
}
@Override
public Double getResult(Tuple2<Long, Long> accumulator) {
return accumulator.f0 / (double) accumulator.f1;
}
@Override
public Tuple2<Long, Long> merge(Tuple2<Long, Long> a, Tuple2<Long, Long> b) {
return Tuple2.of(a.f0 + b.f0, a.f1 + b.f1);
}
}
// ===== 全量聚合:能拿到完整数据,但 State 大 =====
class TopNWindowFunction implements ProcessWindowFunction<
MyEvent, // 输入类型
List<MyEvent>, // 输出类型
String, // Key 类型
TimeWindow // 窗口类型
> {
@Override
public void process(String key, Context ctx, Iterable<MyEvent> elements, Collector<List<MyEvent>> out) {
List<MyEvent> list = new ArrayList<>();
elements.forEach(list::add); // 这里!所有数据都加载到内存
list.sort(Comparator.comparing(MyEvent::getAmount).reversed());
out.collect(list.subList(0, Math.min(10, list.size()))); // Top 10
}
}
5.2 生产环境的最佳实践
能用增量就用增量。窗口大小 1 小时,QPS 10 万,增量聚合只存一个累加值,全量聚合要存 3.6 亿条数据------State 直接爆炸。
如果既需要增量聚合的性能,又需要窗口上下文(比如想知道窗口的起止时间),可以用 增量 + 全量结合:
java
// AggregateFunction 做增量计算,ProcessWindowFunction 补充上下文
dataStream.keyBy(MyEvent::getUserId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.aggregate(new AvgAggregate(), new WindowResultFunction());
// WindowResultFunction 拿到增量结果 + 窗口元数据
class WindowResultFunction extends ProcessWindowFunction<
Double, Result, String, TimeWindow
> {
@Override
public void process(String key, Context ctx, Iterable<Double> elements, Collector<Result> out) {
Double avg = elements.iterator().next(); // 增量结果
long windowStart = ctx.window().getStart(); // 窗口开始时间
long windowEnd = ctx.window().getEnd(); // 窗口结束时间
out.collect(new Result(key, avg, windowStart, windowEnd));
}
}
六、滑动窗口重叠导致计算翻倍?三种优化方案
6.1 问题场景
滑动窗口 of(Time.minutes(10), Time.minutes(1)),窗口大小 10 分钟,每 1 分钟滑动一次。一条数据要同时属于 10 个窗口,计算量翻 10 倍。
6.2 优化方案对比
| 方案 | 原理 | 复杂度 | 效果 |
|---|---|---|---|
| 增量聚合 | 每个窗口用 AggregateFunction,只存中间结果 |
低 | State 减小,但计算量还是 10 倍 |
| 自定义窗口 + Evictor | 只保留窗口中最新/最旧的部分数据 | 中 | 减少每条窗口的计算量 |
| 把滑动窗口拆成滚动窗口 + 后处理 | 先用滚动窗口算细粒度,外部再聚合 | 高 | 最优,但架构复杂 |
6.3 方案一:增量聚合(最常用)
java
// 虽然还是 10 个窗口,但每个窗口只存一个累加值
dataStream.keyBy(MyEvent::getUserId)
.window(SlidingEventTimeWindows.of(Time.minutes(10), Time.minutes(1)))
.aggregate(new CountAggregate()); // 只存一个 Long,不是 List
6.4 方案二:Evictor 减少窗口内数据
java
// 只保留窗口内最近 100 条数据参与计算
dataStream.keyBy(MyEvent::getUserId)
.window(SlidingEventTimeWindows.of(Time.minutes(10), Time.minutes(1)))
.evictor(CountEvictor.of(100)) // 窗口内只保留 100 条
.process(new MyWindowFunction());
6.5 方案三:自定义窗口(进阶)
如果业务允许近似结果,可以自定义窗口逻辑,比如"只维护一个累加值,滑动时增量更新"。这个需要根据具体业务写,没有通用代码。
面试常问:滑动窗口和滚动窗口的性能差异主要来自数据重叠度。滚动窗口不重叠,每条数据只算一次;滑动窗口重叠度越高(窗口大小 / 滑动步长),计算量越大。
七、多并行度下的 Watermark 对齐
7.1 问题:多个并行实例,Watermark 怎么统一?
Flink 的一个 Source 可能有多个并行实例(比如 Kafka 的多个 Partition)。每个实例的数据到达速度不一样,Watermark 推进速度也不一样。Flink 取所有并行实例 Watermark 的最小值作为全局 Watermark------这是保守策略,保证不丢数据。
Partition 1: Watermark = 100s (正常消费)
Partition 2: Watermark = 95s (稍微慢一点)
Partition 3: Watermark = 80s (卡住了!)
↓
全局 Watermark = min(100, 95, 80) = 80s
↑
一个分区卡住,所有窗口都不能触发!
7.2 Kafka 空闲分区(Idle Partition)的处理
这是生产环境最经典的问题:某个 Kafka Partition 暂时没有数据(比如业务低峰期,或者 Partition 分配不均匀),这个分区的 Watermark 就不推进,整个作业的 Watermark 卡住,窗口永远不触发。
Flink 1.11+ 的解决方案 :withIdleness()
java
dataStream.assignTimestampsAndWatermarks(
WatermarkStrategy.<MyEvent>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withIdleness(Duration.ofMinutes(5)) // 5 分钟没数据,这个分区不参与 Watermark 计算
);
| 参数 | 含义 | 建议值 |
|---|---|---|
idlenessTimeout |
多久没数据视为"空闲" | 根据业务低谷期设置,一般 1~10 分钟 |
| 空闲分区行为 | 不参与全局 Watermark 的 min 计算 | 自动,无需额外配置 |
注意 :withIdleness 只在 WatermarkStrategy 里配,不需要改 Kafka Consumer 的配置。
八、自定义 Trigger:窗口什么时候触发,你说了算
8.1 Trigger 的作用
默认情况下,窗口在 Watermark >= 窗口结束时间时触发。但有时候你想:
- 每来 100 条数据就触发一次(提前看到结果)
- 窗口关闭时把 State 清空(默认不清空,会重复输出)
- 第一个数据来就开窗,最后一个数据走后关窗
8.2 常用 Trigger 类型
| Trigger | 作用 | 场景 |
|---|---|---|
EventTimeTrigger |
默认,Watermark >= 窗口结束时间触发 | 一般不需要改 |
ProcessingTimeTrigger |
处理时间到达触发 | 对延迟敏感,允许近似结果 |
CountTrigger |
数据条数达到阈值触发 | 提前看到结果 |
ContinuousEventTimeTrigger |
每隔一段时间触发一次 | 滑动窗口实时刷新 |
8.3 连续窗口触发(面试常考)
java
// 每 1 分钟触发一次窗口计算,不管 Watermark 到没到
dataStream.keyBy(MyEvent::getUserId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.trigger(ContinuousEventTimeTrigger.of(Time.minutes(1)))
.aggregate(new CountAggregate());
// 效果:5 分钟的窗口,每 1 分钟输出一次中间结果
// 注意:这会输出多次,下游需要能处理重复或更新逻辑
8.4 FIRE_AND_PURGE:触发后清空 State
默认的 Trigger 触发后窗口 State 还在(为了处理 allowedLateness),如果你确定不需要后续更新,可以自定义 Trigger 在触发后清空 State:
java
class FireAndPurgeTrigger extends Trigger<MyEvent, TimeWindow> {
@Override
public TriggerResult onElement(MyEvent element, long timestamp, TimeWindow window, TriggerContext ctx) {
if (window.maxTimestamp() <= ctx.getCurrentWatermark()) {
return TriggerResult.FIRE_AND_PURGE; // 触发 + 清空
}
ctx.registerEventTimeTimer(window.maxTimestamp());
return TriggerResult.CONTINUE;
}
@Override
public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) {
return time == window.maxTimestamp() ? TriggerResult.FIRE_AND_PURGE : TriggerResult.CONTINUE;
}
// ... 其他方法省略
}
使用场景:日志统计,窗口触发后不需要再更新,State 立刻释放,减少内存压力。
九、完整实战案例:实时订单统计的完整配置
9.1 背景
做一个"每 5 分钟统计各品类订单金额和订单数"的实时作业,数据源是 Kafka,要求:
- 结果准确(用事件时间)
- 容忍一定的迟到数据
- 迟到太久的数据不能丢,要记录下来
- 滑动窗口,每 1 分钟输出一次最近 5 分钟的统计
9.2 完整代码
java
public class OrderStatisticsJob {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4);
// 侧输出流标签
OutputTag<OrderEvent> lateDataTag = new OutputTag<OrderEvent>("late-orders"){};
// 1. 读取 Kafka
KafkaSource<OrderEvent> source = KafkaSource.<OrderEvent>builder()
.setBootstrapServers("kafka:9092")
.setTopics("order_events")
.setGroupId("flink-order-stats")
.setStartingOffsets(OffsetsInitializer.latest())
.setValueOnlyDeserializer(new OrderEventDeserializationSchema())
.build();
SingleOutputStreamOperator<OrderResult> result = env.fromSource(
source,
WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withIdleness(Duration.ofMinutes(5)) // 空闲分区处理
.withTimestampAssigner((event, ts) -> event.getOrderTime()),
"Kafka Source"
)
// 2. 按品类分组
.keyBy(OrderEvent::getCategory)
// 3. 滑动窗口:大小 5 分钟,步长 1 分钟
.window(SlidingEventTimeWindows.of(Time.minutes(5), Time.minutes(1)))
.allowedLateness(Time.minutes(2)) // 允许迟到 2 分钟
.sideOutputLateData(lateDataTag) // 超时的数据放侧输出
// 4. 增量聚合(Sum + Count 一起算)
.aggregate(new OrderAggregate(), new OrderResultFunction());
// 5. 结果写入 Doris
result.addSink(new DorisSink<>());
// 6. 迟到数据写入日志或另一个 Topic
result.getSideOutput(lateDataTag)
.addSink(KafkaSink.<OrderEvent>builder()
.setBootstrapServers("kafka:9092")
.setRecordSerializer(KafkaRecordSerializationSchema.builder()
.setTopic("late_orders")
.setValueSerializationSchema(new SimpleStringSchema())
.build())
.build());
env.execute("Order Statistics");
}
}
// 增量聚合:同时算总金额和总订单数
class OrderAggregate implements AggregateFunction<
OrderEvent, // 输入
Tuple2<Long, Long>, // 累加器: (总金额, 总单数)
Tuple2<Long, Long> // 输出: (总金额, 总单数)
> {
@Override
public Tuple2<Long, Long> createAccumulator() {
return Tuple2.of(0L, 0L);
}
@Override
public Tuple2<Long, Long> add(OrderEvent value, Tuple2<Long, Long> acc) {
return Tuple2.of(acc.f0 + value.getAmount(), acc.f1 + 1);
}
@Override
public Tuple2<Long, Long> getResult(Tuple2<Long, Long> acc) {
return acc;
}
@Override
public Tuple2<Long, Long> merge(Tuple2<Long, Long> a, Tuple2<Long, Long> b) {
return Tuple2.of(a.f0 + b.f0, a.f1 + b.f1);
}
}
// 补充窗口上下文信息
class OrderResultFunction extends ProcessWindowFunction<
Tuple2<Long, Long>, OrderResult, String, TimeWindow
> {
@Override
public void process(String category, Context ctx,
Iterable<Tuple2<Long, Long>> elements, Collector<OrderResult> out) {
Tuple2<Long, Long> result = elements.iterator().next();
out.collect(new OrderResult(
category,
result.f0, // 总金额
result.f1, // 总单数
ctx.window().getStart(), // 窗口开始
ctx.window().getEnd(), // 窗口结束
ctx.currentWatermark() // 当前 Watermark
));
}
}
9.3 配置参数说明
| 配置项 | 值 | 理由 |
|---|---|---|
| Watermark 延迟 | 10s | 内网 Kafka,5~10s 足够覆盖乱序 |
withIdleness |
5min | 业务有低峰期,部分 Partition 可能没数据 |
allowedLateness |
2min | 覆盖偶发的网络抖动 |
| 窗口大小 | 5min | 业务要求"最近 5 分钟" |
| 滑动步长 | 1min | 每 1 分钟刷新一次结果 |
十、生产踩坑汇总
这些坑都是我自己或者同事在实际项目上踩过的,血泪教训。
| 坑 | 现象 | 根因 | 解决办法 |
|---|---|---|---|
| Watermark 设太大 | State 爆炸,Checkpoint 超时,TM OOM | 窗口迟迟不关闭,数据积压 | 5~30s 够用就好,配合 allowedLateness |
没配 withIdleness |
凌晨低峰期窗口不触发,早上突然一起爆发 | 某个 Partition 没数据,Watermark 卡住 | 一定配 withIdleness |
| 滑动窗口重叠度太高 | CPU 飙高,延迟增大 | 窗口大小 1h,步长 1s,每条数据算 3600 次 | 评估重叠度,或换方案 |
| 用全量聚合算大数据量窗口 | RocksDB State 几十 GB,Checkpoint 几分钟 | 窗口内所有数据存 List | 改用 AggregateFunction |
allowedLateness 太大 |
窗口反复触发,结果重复输出 | 迟到数据不断来,窗口一直重新算 | allowedLateness 控制在 5min 内 |
| 没配 SideOutput | 迟到数据静默丢弃,对账对不上 | 超时的数据直接丢了,没任何记录 | 一定要配 SideOutput,至少打日志 |
| 事件时间字段取错 | 窗口触发时机完全不对 | 取了 process_time 或 Kafka 的 ingestion time |
仔细检查 timestamp 字段 |
| 会话窗口 State 不可控 | 内存突然飙高,然后 OOM | 某个用户数据特别多,会话窗口一直不关闭 | 给会话窗口设最大长度限制,或换滚动窗口 |
十一、一张图记住完整流程
#mermaid-svg-Vtlm6DnyZh53dLBH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Vtlm6DnyZh53dLBH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Vtlm6DnyZh53dLBH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Vtlm6DnyZh53dLBH .error-icon{fill:#552222;}#mermaid-svg-Vtlm6DnyZh53dLBH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Vtlm6DnyZh53dLBH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Vtlm6DnyZh53dLBH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Vtlm6DnyZh53dLBH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Vtlm6DnyZh53dLBH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Vtlm6DnyZh53dLBH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Vtlm6DnyZh53dLBH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Vtlm6DnyZh53dLBH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Vtlm6DnyZh53dLBH .marker.cross{stroke:#333333;}#mermaid-svg-Vtlm6DnyZh53dLBH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Vtlm6DnyZh53dLBH p{margin:0;}#mermaid-svg-Vtlm6DnyZh53dLBH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Vtlm6DnyZh53dLBH .cluster-label text{fill:#333;}#mermaid-svg-Vtlm6DnyZh53dLBH .cluster-label span{color:#333;}#mermaid-svg-Vtlm6DnyZh53dLBH .cluster-label span p{background-color:transparent;}#mermaid-svg-Vtlm6DnyZh53dLBH .label text,#mermaid-svg-Vtlm6DnyZh53dLBH span{fill:#333;color:#333;}#mermaid-svg-Vtlm6DnyZh53dLBH .node rect,#mermaid-svg-Vtlm6DnyZh53dLBH .node circle,#mermaid-svg-Vtlm6DnyZh53dLBH .node ellipse,#mermaid-svg-Vtlm6DnyZh53dLBH .node polygon,#mermaid-svg-Vtlm6DnyZh53dLBH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Vtlm6DnyZh53dLBH .rough-node .label text,#mermaid-svg-Vtlm6DnyZh53dLBH .node .label text,#mermaid-svg-Vtlm6DnyZh53dLBH .image-shape .label,#mermaid-svg-Vtlm6DnyZh53dLBH .icon-shape .label{text-anchor:middle;}#mermaid-svg-Vtlm6DnyZh53dLBH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Vtlm6DnyZh53dLBH .rough-node .label,#mermaid-svg-Vtlm6DnyZh53dLBH .node .label,#mermaid-svg-Vtlm6DnyZh53dLBH .image-shape .label,#mermaid-svg-Vtlm6DnyZh53dLBH .icon-shape .label{text-align:center;}#mermaid-svg-Vtlm6DnyZh53dLBH .node.clickable{cursor:pointer;}#mermaid-svg-Vtlm6DnyZh53dLBH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Vtlm6DnyZh53dLBH .arrowheadPath{fill:#333333;}#mermaid-svg-Vtlm6DnyZh53dLBH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Vtlm6DnyZh53dLBH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Vtlm6DnyZh53dLBH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vtlm6DnyZh53dLBH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Vtlm6DnyZh53dLBH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vtlm6DnyZh53dLBH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Vtlm6DnyZh53dLBH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Vtlm6DnyZh53dLBH .cluster text{fill:#333;}#mermaid-svg-Vtlm6DnyZh53dLBH .cluster span{color:#333;}#mermaid-svg-Vtlm6DnyZh53dLBH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Vtlm6DnyZh53dLBH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Vtlm6DnyZh53dLBH rect.text{fill:none;stroke-width:0;}#mermaid-svg-Vtlm6DnyZh53dLBH .icon-shape,#mermaid-svg-Vtlm6DnyZh53dLBH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vtlm6DnyZh53dLBH .icon-shape p,#mermaid-svg-Vtlm6DnyZh53dLBH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Vtlm6DnyZh53dLBH .icon-shape .label rect,#mermaid-svg-Vtlm6DnyZh53dLBH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vtlm6DnyZh53dLBH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Vtlm6DnyZh53dLBH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Vtlm6DnyZh53dLBH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ProcessingTime
EventTime
IngestionTime
是
否
固定周期
重叠统计
用户行为
完全自定义
是
否
可增量
需全量
都要
SideOutput 数据
正常
数据进入 Flink
选择时间语义
简单高效
允许近似结果
提取数据自带时间戳
几乎不用
分配 Watermark
forBoundedOutOfOrderness
Kafka 多分区?
配置 withIdleness
防止空闲分区卡死 Watermark
继续
选择窗口类型
滚动窗口
TumblingWindow
滑动窗口
SlidingWindow
会话窗口
SessionWindow
全局窗口 + Trigger
GlobalWindow
重叠度高?
用 AggregateFunction
增量聚合减 State
继续
配置三层兜底
第一层: Watermark
正常乱序处理
第二层: allowedLateness
宽限期补救
第三层: SideOutput
超时不丢数据
聚合方式?
AggregateFunction
只存中间结果
ProcessWindowFunction
注意 State 大小
Aggregate + Process
结合使用
窗口触发
输出结果
还要处理迟到?
写入日志/死信队列
离线补偿或人工处理
结束
核心口诀:事件时间保准确,Watermark 当进度条,三层兜底防丢数,增量聚合省内存。空闲分区配 Idleness,滑动窗口算重叠,全量聚合要谨慎。
十二、面试速答
整理了一些常见面试问题的精简回答,背下来直接用。
Q:事件时间 vs 处理时间 vs 摄入时间区别?为什么用事件时间?
事件时间取数据自带的时间戳,能处理乱序,结果准;处理时间取机器时间,简单但乱序时结果不对;摄入时间取进入 Source 的时间,比处理时间稳定但也不能处理乱序。生产用事件时间,因为对账要对得上。
Q:Watermark 是什么?多并行度下怎么对齐?
Watermark 是事件时间的进度标记,告诉 Flink 多少时间点之前的数据都到了。多并行度下 Flink 取所有并行实例 Watermark 的最小值作为全局 Watermark,保守策略。Kafka 空闲分区用
withIdleness处理,超时不推进的分器不参与最小值计算。
Q:Watermark + Allowed Lateness + SideOutput 三层兜底?
Watermark 是正常触发线,到了就关窗口;Allowed Lateness 是宽限期,关了的窗口有迟到数据来还能更新;SideOutput 是最后兜底,超过宽限期的数据不丢,打到侧输出流后续处理。三层保证数据不丢、结果准。
Q:滑动窗口数据重叠导致计算翻倍怎么优化?
三个方案:一是用 AggregateFunction 做增量聚合,每个窗口只存中间结果不存全量数据;二是用 Evictor 限制窗口内数据条数;三是架构上把滑动窗口拆成滚动窗口算细粒度,外部再聚合。最常用的是增量聚合。
Q:滚动/滑动/会话/全局窗口区别?
滚动窗口固定大小不重叠,统计固定周期;滑动窗口固定大小有重叠,统计滑动区间;会话窗口按数据间隔动态切分,适合用户行为;全局窗口只有一个,不会自动触发,需自定义 Trigger。生命周期方面,前三种由 Watermark + Trigger 控制,全局窗口完全由 Trigger 控制。
Q:增量聚合和全量聚合区别?性能差异?
增量聚合用 ReduceFunction/AggregateFunction,只存中间结果(比如一个累加值),内存占用 O(1);全量聚合用 WindowFunction/ProcessWindowFunction,存窗口内所有数据(List),内存占用 O(N)。大数据量窗口用增量,需要排序/去重等复杂逻辑才用全量。
Q:Watermark 生成策略?
周期性(Periodic)默认 200ms 生成一次,生产环境 99% 用这个,接口是
forBoundedOutOfOrderness;标点式(Punctuated)每条数据判断要不要生成,性能差,只有数据源有明确结束标记时才用。
Q:自定义 Trigger 场景?
两个常见场景:一是用
ContinuousEventTimeTrigger做连续窗口触发,比如 5 分钟窗口每 1 分钟输出一次中间结果;二是用FIRE_AND_PURGE触发后清空 State,减少内存压力,适合不需要更新的统计场景。
写在最后
这篇文章把我这几年用 Flink 做实时计算时关于时间语义和窗口的心得都倒出来了。最核心的建议就几条:
- 事件时间 + Watermark 是标配,处理时间只在允许近似结果时用
- 三层兜底(Watermark + AllowedLateness + SideOutput)必须配齐,数据丢了追查起来要命
- 滑动窗口先算重叠度,重叠太高一定要做优化
- Kafka 数据源一定配
withIdleness,不然凌晨低峰期窗口不触发的问题够你查一整晚 - 能用增量聚合就别用全量,State 大小直接决定作业稳不稳定
觉得有用就点个赞吧~