4. Apache Flink基础:Flink架构与运行模式、DataStream API入门、事件时间与处理时间
好,咱们进入Flink的世界。说实话,在数据湖仓的增量处理场景里,Flink几乎成了标配。为什么?因为它天生就是为「流」而生的。批处理它能干,流处理它更擅长。今天这一章,我带你把Flink的骨架摸清楚。
4.1 Flink架构与运行模式
先看架构。Flink是个典型的Master-Worker架构。你想想看,一个集群总得有个「大脑」来调度任务,也得有「工人」来干活。
- JobManager:这就是大脑。负责接收作业、调度任务、做Checkpoint协调。它挂了,整个集群就瘫痪了。所以生产环境一定要配高可用。
- TaskManager:这是工人。真正干活的地方,每个TaskManager里有多个Task Slot,Slot就是执行任务的最小单元。
我记得刚接触Flink时,有个概念特别容易搞混——并行度和Slot数量的关系。简单说:Slot是资源上限,并行度是你想用多少资源。并行度不能超过Slot总数,否则任务会排队等资源。
核心要点:一个TaskManager可以配置多个Slot,每个Slot独占一部分内存。但Slot之间共享CPU和网络带宽。所以别把Slot数设得太大,否则资源争抢会很严重。
再来看运行模式。Flink支持三种部署方式:
| 模式 | 适用场景 | 特点 |
|---|---|---|
| Session Mode | 短作业、开发测试 | 共享JobManager,资源复用,但作业间会互相影响 |
| Per-Job Mode | 生产环境长作业 | 每个作业独享JobManager,隔离性好,资源用完即释放 |
| Application Mode | 大规模生产集群 | Main方法在集群端执行,适合镜像化部署,启动快 |
我个人习惯在生产环境用Application Mode。为什么?因为Per-Job Mode虽然隔离性好,但每次提交作业都要在客户端执行Main方法,如果客户端挂了,作业就提交失败了。Application Mode把Main方法挪到集群里跑,稳得多。
避坑指南:我曾经在Session Mode下跑多个实时ETL作业,结果一个作业的背压把整个集群拖垮了。从那以后,生产环境我再也不敢用Session Mode跑核心作业。
下面这张图帮你理清Flink的运行时架构:
4.2 DataStream API入门
好,架构看完了,咱们上手写代码。DataStream API是Flink最核心的编程接口。说白了,就是把数据流想象成一条河,你在河上装各种「处理站」——过滤、转换、聚合、分流。
先看一个最简单的例子:从Socket读取数据,做词频统计。
// 1. 创建执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 2. 从Socket读取数据流
DataStream<String> textStream = env.socketTextStream("localhost", 9999);
// 3. 转换操作:切分、映射、分组、聚合
DataStream<Tuple2<String, Integer>> wordCount = textStream
.flatMap((String line, Collector<String> out) -> {
for (String word : line.split(" ")) {
out.collect(word);
}
})
.returns(Types.STRING)
.map(word -> Tuple2.of(word, 1))
.returns(Types.TUPLE(Types.STRING, Types.INT))
.keyBy(value -> value.f0)
.sum(1);
// 4. 打印结果
wordCount.print();
// 5. 启动任务
env.execute("Socket WordCount");
这段代码里,有几点我想强调一下:
- returns() 方法:Flink的类型系统在Java里会泛型擦除,你不显式指定类型,它可能给你报错。我刚开始写的时候经常漏掉,结果运行时抛异常。
- keyBy 和 sum:这是Flink最经典的滚动聚合。注意,keyBy之后的数据是逻辑分区,不是全局排序。
小技巧:如果你用Lambda表达式,记得用 .returns() 告诉Flink返回类型。或者直接用匿名内部类,省得类型擦除搞鬼。
DataStream API的算子可以分为三大类:
| 类别 | 算子 | 说明 |
|---|---|---|
| Source | fromCollection / socketTextStream / addSource | 数据入口,Kafka Connector是最常用的 |
| Transformation | map / flatMap / filter / keyBy / window | 核心处理逻辑,链式调用 |
| Sink | print / writeAsText / addSink | 数据出口,写入外部系统 |
我个人建议,刚开始学的时候,多写几个map和flatMap的例子。这两个算子几乎覆盖了80%的数据转换场景。map是一对一,flatMap是一对多,搞懂它们,后面学窗口和状态就轻松了。
4.3 事件时间与处理时间
这是Flink里最容易踩坑的地方,没有之一。我见过太多人因为时间语义没选对,导致数据对不上账。
Flink支持三种时间语义:
- Processing Time:处理时间,就是机器当前的时间。简单、延迟低,但结果不确定。比如你8点处理一条7点的数据,时间戳就是8点。
- Event Time:事件时间,数据本身携带的时间戳。比如IoT设备上报时带的采集时间。结果确定,但需要处理乱序。
- Ingestion Time:摄入时间,数据进入Flink的时间。介于两者之间,用得不多。
为什么说Event Time重要?你想想看,在数据湖仓场景里,我们经常要回溯历史数据。如果用Processing Time,今天重跑昨天的数据,时间戳全变成今天了,聚合结果完全不对。所以,生产环境几乎都用Event Time。
我曾经接手过一个实时报表项目,上游数据经常延迟5-10分钟。开发同学用了Processing Time,结果每天凌晨的报表数据总是对不上。后来改成Event Time + Watermark,问题才解决。记住:只要数据可能乱序,就别用Processing Time做聚合。
用Event Time必须配合Watermark。Watermark是Flink用来衡量事件时间进度的机制。它本质上是一个时间戳,告诉Flink:「在这个时间戳之前的数据,我已经收得差不多了,你可以触发计算了」。
看个例子:
// 设置事件时间语义
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
// 自定义Watermark生成器
DataStream<MyEvent> streamWithWatermarks = inputStream
.assignTimestampsAndWatermarks(
WatermarkStrategy.<MyEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.getEventTime())
);
// 滚动窗口,每10秒统计一次
streamWithWatermarks
.keyBy(event -> event.getUserId())
.window(TumblingEventTimeWindows.of(Time.seconds(10)))
.sum("amount")
.print();
这里forBoundedOutOfOrderness(Duration.ofSeconds(5))的意思是:允许数据最多乱序5秒。也就是说,Watermark = 当前收到的事件时间最大值 - 5秒。这样即使有些数据来晚了,只要不超过5秒,都能被正确归入对应的窗口。
核心原则:Watermark的延迟时间设置,取决于你的业务容忍度。设得太小,乱序数据会被丢弃;设得太大,窗口结果出得慢。我一般建议先设成预估延迟的2倍,上线后再根据实际数据调整。
最后,用一张图总结时间语义和Watermark的关系: