4、实时计算引擎核心:Flink 的架构、JobManager/TaskManager、数据流与算子
聊到实时计算,Flink 基本是绕不开的选项。我最早接触流计算还是用 Storm,后来转到 Spark Streaming,直到 Flink 出来,我才觉得「嗯,这才是真正的流处理」。说白了,Flink 的核心思想就一句话:一切皆流,批是流的特例。
这一章,我带你把 Flink 的骨架拆开看看。不扯虚的,直接看它到底怎么跑起来的。
4.1 Flink 的整体架构
Flink 的架构,我习惯把它分成两层:运行时层和部署层。运行时层是它的心脏,部署层只是外壳。
先看一张整体架构图,后面我们逐个拆解。
你看,从下往上,能力越来越抽象。部署层解决「跑在哪」,运行时层解决「怎么跑」,API 层解决「怎么写」,核心能力层解决「跑得稳不稳」。我个人习惯把关注点放在运行时层和 API 层,因为这两层直接决定了你的作业能不能稳定跑、好不好写。
4.2 JobManager 与 TaskManager
这两个家伙,是 Flink 的「大脑」和「手脚」。我刚开始学 Flink 时,总觉得它们的关系有点像 Hadoop 的 JobTracker 和 TaskTracker,但细看差别很大。
4.2.1 JobManager:大脑
JobManager 是集群的「主节点」。它负责三件事:
- 调度任务:把用户提交的 JobGraph 转成 ExecutionGraph,然后分发给 TaskManager
- 协调 Checkpoint:定期触发快照,保证故障恢复
- 资源管理:通过 ResourceManager 申请和释放 slot
我在项目中遇到过一个问题:JobManager 挂了,整个集群就瘫痪了。后来我们上了 HA(高可用),用 ZooKeeper 做 leader 选举,才算踏实。你想想看,如果线上作业跑得好好的,JobManager 突然挂了,那恢复起来可够呛。
4.2.2 TaskManager:手脚
TaskManager 是「工作节点」。每个 TaskManager 有多个 Task Slot,每个 slot 跑一个线程。Slot 的数量决定了 TaskManager 的并行度。
这里有个关键点:Slot 是资源隔离的单位,但不是线程隔离的单位。同一个 TaskManager 里的不同 slot,共享 JVM 进程,但各自有独立的资源(CPU、内存)。
| 角色 | 职责 | 数量 | 高可用 |
|---|---|---|---|
| JobManager | 调度、协调、资源管理 | 1 个 active + N 个 standby | ZooKeeper / etcd |
| TaskManager | 执行任务、维护状态 | N 个(按需扩展) | 无状态,可随时重启 |
| ResourceManager | 管理 slot 资源 | 1 个 | 随 JobManager HA |
| Dispatcher | 接收作业、启动 JobManager | 1 个 | 随 JobManager HA |
我曾经踩过一个坑:给 TaskManager 分配的内存太小,结果作业跑起来频繁 GC,延迟飙升。后来我学乖了,TaskManager 的内存至少给 4GB,堆外内存也要留足。
4.3 数据流与算子
数据流是 Flink 的「血液」,算子是「器官」。血液流过不同的器官,被加工、转换、聚合。
4.3.1 数据流:有界 vs 无界
Flink 把数据流分成两种:
- 有界流(Bounded):数据有起点和终点,比如读一个文件。说白了就是批处理。
- 无界流(Unbounded):数据源源不断,没有终点,比如 Kafka 里的实时日志。这才是真正的流处理。
Flink 牛在哪?它用同一套引擎处理这两种流。你写一个 DataStream API 的作业,换一下数据源,就能从批处理切到流处理。我当年从 Spark Streaming 转过来时,觉得这个设计太优雅了。
4.3.2 算子:数据流的加工厂
算子是 Flink 对数据做操作的基本单元。我按功能把它们分成几类:
| 类别 | 算子 | 作用 | 示例 |
|---|---|---|---|
| 转换 | map / flatMap / filter | 一对一或一对多转换 | map(x -> x * 2) |
| 聚合 | keyBy / sum / reduce | 按 key 分组后聚合 | keyBy(0).sum(1) |
| 窗口 | window / windowAll | 按时间或数量切分数据 | timeWindow(Time.seconds(5)) |
| 连接 | join / coGroup / union | 合并多个流 | stream1.join(stream2) |
| 状态 | process / keyedState | 带状态的处理 | process(new KeyedProcessFunction) |
这里我要重点说一下 keyBy。它是个「物理分区」操作,数据经过 keyBy 后,相同 key 的数据会被分到同一个 slot 里。为什么重要?因为后续的聚合、窗口操作,都依赖这个分区保证正确性。
4.3.3 一个完整的算子链示例
光说不练假把式。我写一个简单的 Flink 作业,你看算子是怎么串起来的:
// 从 Kafka 读取实时日志
DataStream<String> source = env.addSource(
new FlinkKafkaConsumer<>("topic", new SimpleStringSchema(), props)
);
// 解析 JSON
DataStream<LogEvent> parsed = source
.map(line -> JsonUtil.parse(line, LogEvent.class))
.name("parse-json");
// 按用户 ID 分组,统计每分钟的访问次数
DataStream<UserCount> result = parsed
.keyBy(LogEvent::getUserId)
.timeWindow(Time.minutes(1))
.aggregate(new CountAggregate(), new WindowResultFunction())
.name("window-count");
// 输出到 Elasticsearch
result.addSink(new ElasticsearchSink<>(config, transportAddresses, esSinkFunction));
你看,这个作业的算子链是:source → map → keyBy → window → aggregate → sink。每个算子都是独立的逻辑单元,Flink 会在运行时把它们优化成 Task,分配到不同的 TaskManager 上执行。
我习惯在写作业时,给每个算子加 .name()。为什么?因为 Flink Web UI 上会显示算子名称,方便你定位瓶颈。有一次线上作业延迟高,我一看 Web UI,发现某个 map 算子的反压水位很高,赶紧优化了那个算子的逻辑。
4.4 小结
这一章我们拆了 Flink 的骨架:
- 架构:部署层 → 运行时层 → API 层 → 核心能力层,层层递进
- JobManager:大脑,管调度、协调、资源
- TaskManager:手脚,管执行、状态、slot
- 数据流:有界 vs 无界,Flink 统一处理
- 算子:map、keyBy、window、process 等,数据流的加工单元
嗯,这些是 Flink 的「硬知识」。但光知道这些还不够,下一层我们要深入 状态与容错——这才是 Flink 真正拉开差距的地方。