4、实时计算引擎核心:Flink 的架构、JobManager/TaskManager、数据流与算子

聊到实时计算,Flink 基本是绕不开的选项。我最早接触流计算还是用 Storm,后来转到 Spark Streaming,直到 Flink 出来,我才觉得「嗯,这才是真正的流处理」。说白了,Flink 的核心思想就一句话:一切皆流,批是流的特例。

这一章,我带你把 Flink 的骨架拆开看看。不扯虚的,直接看它到底怎么跑起来的。

4.1 Flink 的整体架构

Flink 的架构,我习惯把它分成两层:运行时层和部署层。运行时层是它的心脏,部署层只是外壳。

先看一张整体架构图,后面我们逐个拆解。

Flink 运行时架构 部署层(Deployment Layer) Local | Standalone | YARN | Kubernetes | Mesos 运行时层(Runtime Layer) JobManager + TaskManager + Dispatcher + ResourceManager API 层(API Layer) DataStream API | Table API | SQL | ProcessFunction 核心能力层(Core Capabilities) State(状态管理)| Time(事件时间/处理时间)| Checkpoint(容错) 核心:运行时层是 Flink 的心脏,JobManager 管调度,TaskManager 管执行

你看,从下往上,能力越来越抽象。部署层解决「跑在哪」,运行时层解决「怎么跑」,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 突然挂了,那恢复起来可够呛。

我的建议:生产环境一定要配 HA。JobManager 至少两个节点,状态后端用 ZooKeeper 或 etcd。别省这个成本,血的教训。

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 里。为什么重要?因为后续的聚合、窗口操作,都依赖这个分区保证正确性。

注意:keyBy 会导致数据重分区,产生网络 shuffle。如果你的 key 分布不均匀(比如热点 key),某些 TaskManager 会负载过高。我曾经遇到过某个 key 的数据量是其他 key 的 100 倍,结果那个 slot 直接 OOM。解决方案是加一层「二次散列」或「随机前缀」。

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 真正拉开差距的地方。