3. Flink 基础概念:DataStream API、时间语义与 Watermark 机制
做实时特征工程,Flink 几乎是绕不开的选项。我最早接触 Flink 是在 2018 年,那时候公司要从 Storm 迁移到 Flink,说实话,刚开始挺不适应的。但用顺手之后,你会发现它在实时计算领域确实有不可替代的优势。
这一章,我们聚焦三个核心概念:DataStream API、时间语义和 Watermark 机制。这三个东西搞明白了,实时特征的计算逻辑你就掌握了八成。
3.1 DataStream API:实时数据的“流水线”
DataStream API 是 Flink 处理无界数据流的核心抽象。你可以把它想象成一条流水线——数据从源头进来,经过各种算子处理,最终输出到某个地方。
我个人习惯把 DataStream 的编程模型拆成三块:
- Source:数据从哪里来?Kafka、Kinesis、文件系统,或者自定义数据源。
- Transformation:数据怎么变?map、filter、keyBy、window 等等。
- Sink:数据到哪里去?写入数据库、发送到下游系统、或者打印到控制台。
来看一个最简单的例子。假设我们要从 Kafka 读取用户点击事件,然后过滤出有效点击:
DataStream<ClickEvent> clicks = env
.addSource(new FlinkKafkaConsumer<>("clicks", new ClickEventSchema(), props))
.filter(event -> event.getTimestamp() > 0)
.keyBy(ClickEvent::getUserId)
.timeWindow(Time.minutes(5))
.apply(new CountAggregator());
这段代码里,keyBy 之后的数据会被分配到不同的并行子任务上。嗯,这里要注意:keyBy 不是简单的分组,它决定了后续窗口计算的分区策略。我在项目中遇到过因为 key 选择不当导致数据倾斜的情况,后来改用复合 key 才解决。
env.setParallelism(1) 强制单线程运行,方便观察数据流向。生产环境再调大并行度。
3.2 时间语义:Event Time vs Processing Time
时间语义是 Flink 里最容易让人迷糊的地方。说白了,就两种时间:
| 时间类型 | 定义 | 典型场景 | 我踩过的坑 |
|---|---|---|---|
| Processing Time | 数据到达 Flink 算子的系统时间 | 实时监控、简单聚合 | 依赖机器时钟,集群时间不同步会出问题 |
| Event Time | 数据本身携带的时间戳 | 用户行为分析、金融交易 | 乱序数据会导致结果不准确 |
Processing Time 实现简单,但结果不可复现。你想想看,如果两次重跑同一个作业,因为机器负载不同,Processing Time 不同,结果可能完全不一样。这在特征回溯场景下是致命的。
Event Time 才是做特征工程的正确姿势。它依赖数据自带的时间戳,不管数据什么时候到,只要时间戳正确,结果就是确定的。我曾经在做一个用户留存特征时,因为用了 Processing Time,导致凌晨的离线回刷和白天实时结果对不上,排查了两天才发现是时间语义的问题。
3.3 Watermark 机制:解决乱序问题的关键
Event Time 虽好,但现实世界的数据总是乱序的。用户可能在 10:05 产生了一个事件,但因为网络延迟,10:08 才到达 Flink。这时候窗口已经关闭了,数据就丢了。
Watermark 就是用来解决这个问题的。它本质上是一个“时间进度信号”,告诉 Flink:“在这个时间戳之前的数据,我基本都收到了,你可以放心关闭窗口了。”
我画了一张图,帮你理解 Watermark 的工作流程:
上图展示了一个 5 分钟窗口的场景。事件 A(10:02)和事件 B(10:05)属于 [10:00, 10:05) 窗口。当 Watermark 推进到 10:07 时,Flink 认为 10:05 之前的数据都到齐了,于是触发窗口计算。事件 C(10:08)属于下一个窗口,事件 D(10:12)同理。
Watermark 的生成方式有两种:
- 周期性生成:每隔一段时间(比如 200ms)生成一次 Watermark。适合数据量大的场景。
- 逐条生成:每来一条数据就生成一次 Watermark。适合对延迟敏感的场景。
代码实现也很简单:
DataStream<ClickEvent> withWatermarks = clicks
.assignTimestampsAndWatermarks(
WatermarkStrategy.<ClickEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.getTimestamp())
);
这里 forBoundedOutOfOrderness(Duration.ofSeconds(5)) 表示允许最多 5 秒的乱序。我建议这个值不要设得太大,否则窗口会延迟触发,影响实时性。也不要设得太小,否则乱序数据会被丢弃。
3.4 三者如何协同工作?
DataStream API、时间语义和 Watermark 不是孤立的。它们共同构成了 Flink 实时计算的基础设施。
我总结了一个简单的协作流程:
- DataStream API 定义数据流的来源和转换逻辑。
- 通过
assignTimestampsAndWatermarks指定 Event Time 和 Watermark 策略。 - 窗口算子(如
timeWindow)根据 Event Time 划分窗口。 - Watermark 推进到窗口结束时间时,触发窗口计算。
- 计算结果通过 Sink 输出到外部系统。
举个例子,假设我们要计算每个用户过去 5 分钟的点击次数:
DataStream<ClickEvent> stream = env
.addSource(kafkaSource)
.assignTimestampsAndWatermarks(
WatermarkStrategy.<ClickEvent>forBoundedOutOfOrderness(Duration.ofSeconds(3))
.withTimestampAssigner((e, ts) -> e.getEventTime())
)
.keyBy(ClickEvent::getUserId)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.aggregate(new CountAggregate())
.addSink(redisSink);
这段代码里,TumblingEventTimeWindows 表示滚动窗口,窗口大小 5 分钟。Watermark 允许 3 秒乱序。当 Watermark 超过窗口结束时间时,CountAggregate 就会执行。
好了,这一章的内容就到这里。DataStream API 是骨架,时间语义是灵魂,Watermark 是保障。三者配合好了,实时特征的计算才能既准确又高效。