Flink 作业从提交到运行的完整生命周期(源码级解析)
基于 Flink 1.17,深入剖析 StreamGraph → JobGraph → ExecutionGraph → Task 的完整转换链路
前言
当你在 Flink 程序中写下 env.execute() 的那一刻,你是否好奇过这背后发生了什么?一个简单的方法调用,是如何触发整个分布式任务的调度、部署和执行的?
本文将基于 Flink 1.17 源码,带你完整走一遍这个生命周期。
一、整体流程概览
Flink 作业的执行可以分为 4 个核心阶段:
graph LR
A[用户代码] --> B[StreamGraph]
B --> C[JobGraph]
C --> D[ExecutionGraph]
D --> E[Task 执行]
style A fill:#e1f5ff
style B fill:#fff4e1
style C fill:#ffe1f5
style D fill:#e1ffe1
style E fill:#f5e1ff
| 阶段 | 输入 | 输出 | 核心职责 |
|---|---|---|---|
| 阶段 1 | 用户代码(DataStream API) | StreamGraph | 逻辑拓扑构建 |
| 阶段 2 | StreamGraph | JobGraph | 算子链合并优化 |
| 阶段 3 | JobGraph | ExecutionGraph | 物理执行计划生成 |
| 阶段 4 | ExecutionGraph | Task 实例 | 任务调度与执行 |
下面逐一展开每个阶段的细节。
二、阶段一:用户代码 → StreamGraph
2.1 核心流程
当你调用 env.execute() 时,Flink 会启动第一阶段的转换:
// 用户代码示例
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.socketTextStream("localhost", 9999)
.flatMap(new Tokenizer())
.keyBy(value -> value.f0)
.sum(1)
.print();
env.execute("WordCount"); // 从这里开始
执行步骤:
-
备份 Transformations
execute()方法首先将当前环境中的transformations列表进行备份,防止重复执行时出现问题。 -
调用
getStreamGraph()
创建一个空的StreamGraph对象,并清空transformations列表。 -
缓存检查
检查是否已有缓存的 StreamGraph,避免重复构建。 -
调用
transform()方法
这是核心转换逻辑,会递归遍历所有 Transformation:- 已转换检查:如果一个 Transformation 有多个下游算子,确保只转换一次
- 设置最大并行度:根据配置或默认值设置
- 配置 Slot Sharing:决定哪些算子可以共享 Slot
- 创建 StreamNode:每个 Transformation 对应一个 StreamNode
- 添加 StreamEdge:连接上下游节点
-
存储到 StreamGraph
最终所有节点和边都存储在 StreamGraph 对象中。
2.2 StreamGraph 的结构
StreamGraph
├── StreamNode(算子节点)
│ ├── ID
│ ├── 并行度
│ ├── Slot Sharing Group
│ └── Operator(UDF 逻辑)
└── StreamEdge(数据流向)
├── 源节点
├── 目标节点
├── 分区器(Partitioner)
└── 数据类型
2.3 关键源码位置
StreamExecutionEnvironment.execute()- 入口方法StreamGraphGenerator.generate()- 转换核心StreamGraphGenerator.transform()- 递归转换逻辑
三、阶段二:StreamGraph → JobGraph
3.1 为什么需要 JobGraph?
StreamGraph 只是逻辑拓扑,包含了所有算子的原始关系。但在实际执行时,Flink 会进行 算子链合并(Operator Chaining) 优化,将满足条件的多个算子合并成一个 Task,减少线程切换和网络开销。
JobGraph 就是经过优化后的物理执行图。
3.2 核心流程
从 getStreamGraph() 返回后,继续执行:
-
调用
executeAsync()
获取执行器(Executor),判断是本地执行还是集群提交。 -
StreamGraph 交给执行器
执行器会将 StreamGraph 传递给FlinkPipelineExecutorUtils。 -
获取 Translator
通过 SPI 机制加载对应的StreamGraphTranslator。 -
执行转换
调用StreamGraphTranslator.translate()方法。 -
进入
setChaining()方法
这是算子链合并的核心逻辑:- 遍历所有 StreamNode
- 根据条件判断能否与下游合并
- 创建 JobVertex(合并后的节点)
- 创建 JobEdge(JobVertex 之间的连接)
3.3 算子链合并的 6 个条件
Flink 通过 isChainable() 方法判断两个算子能否合并,必须同时满足以下 6 个条件:
条件 1:下游节点只有一个输入
// 能链接
source.map(...).filter(...) // filter 只有 map 一个输入
// 不能链接
stream1.union(stream2).map(...) // map 有两个输入(union 的结果)
原因:如果下游有多个输入,合并后无法清晰界定数据来源。
条件 2:在同一个 Slot Sharing Group
// 默认所有算子在同一个 "default" 组,可以链接
source.map(...).filter(...)
// 手动设置不同组,无法链接
source.map(...).slotSharingGroup("group1")
.filter(...).slotSharingGroup("group2")
条件 3:算子本身支持链接
某些算子由于实现原因不支持链接,例如:
- 异步 I/O 算子
- 迭代算子的头尾节点
可以通过 setChainingStrategy() 设置:
ChainingStrategy.ALWAYS:总是尝试链接ChainingStrategy.NEVER:禁止链接ChainingStrategy.HEAD:只能作为链的起点
条件 4:分区器支持链接
只有 ForwardPartitioner 支持链接,其他分区器都会打破一对一映射:
// 能链接(Forward)
stream.map(...).filter(...)
// 不能链接(Rebalance)
stream.map(...).rebalance().filter(...)
// 不能链接(KeyBy 改变分区)
stream.map(...).keyBy(x -> x).reduce(...)
原因:链接的前提是数据流向是确定的、一对一的。一旦引入 shuffle,数据会被重新分配到不同的并行实例。
条件 5:并行度相同
// 能链接(并行度都是默认)
stream.map(...).filter(...)
// 不能链接(并行度不同)
stream.map(...).setParallelism(2)
.filter(...).setParallelism(4)
条件 6:全局启用了 Chaining
// 禁用全局链接
env.disableOperatorChaining();
// 或者配置项
pipeline.operator-chaining.enabled: false
3.4 算子链合并示例
示例 1:完全链接
env.socketTextStream("localhost", 9999) // Source
.map(String::toUpperCase) // Map
.filter(s -> s.length() > 5) // Filter
.print(); // Sink
转换结果:
StreamGraph: Source → Map → Filter → Sink (4个节点)
JobGraph: [Source → Map → Filter → Sink] (1个 JobVertex)
所有算子满足 6 个条件,合并成一个 Task。
示例 2:KeyBy 打断链接
env.socketTextStream("localhost", 9999) // Source
.flatMap(new Tokenizer()) // FlatMap
.keyBy(value -> value.f0) // KeyBy(改变分区)
.sum(1) // Sum
.print(); // Sink
转换结果:
StreamGraph: Source → FlatMap → KeyBy → Sum → Sink (5个节点)
JobGraph: [Source → FlatMap] → [Sum → Sink] (2个 JobVertex)
KeyBy 引入 Hash 分区,打断链接。
3.5 如何验证算子链?
在 Flink Web UI 中查看任务:
- 如果看到
Source -> Map -> Filter,说明已链接 - 如果看到
Source、Map、Filter三个独立 Task,说明未链接
3.6 关键源码位置
StreamGraphTranslator.translate()- 转换入口StreamingJobGraphGenerator.setChaining()- 链接核心StreamingJobGraphGenerator.isChainable()- 判断条件
四、阶段三:JobGraph → ExecutionGraph
4.1 从逻辑图到物理执行图
JobGraph 描述了优化后的作业结构,但还缺少:
- 具体的并行度实例
- Slot 分配信息
- 数据分区的细节
ExecutionGraph 就是将 JobGraph 实例化 为可执行的物理计划。
4.2 核心流程
-
提交 JobGraph
从getJobGraph()返回后,创建 MiniCluster(本地模式)或连接远程集群。 -
调用
submitJob()
将 JobGraph 提交给 Dispatcher。 -
持久化 JobGraph
在高可用模式下,JobGraph 会被写入 Zookeeper 或文件系统。 -
创建 JobMaster
Dispatcher 为这个作业创建一个 JobMaster,负责调度和协调。 -
JobMaster 构造 SchedulerNG
JobMaster 的构造方法会创建SchedulerNG对象(默认是DefaultScheduler)。 -
创建 ExecutionGraph 工厂
SchedulerNG 内部会调用ExecutionGraphBuilder.buildGraph(),将 JobGraph 转换为 ExecutionGraph。
4.3 ExecutionGraph 的结构
ExecutionGraph
├── ExecutionJobVertex(JobVertex 的执行版本)
│ ├── 并行度:4
│ └── ExecutionVertex[](并行实例数组)
│ ├── ExecutionVertex-0
│ ├── ExecutionVertex-1
│ ├── ExecutionVertex-2
│ └── ExecutionVertex-3
└── IntermediateResult(中间结果)
└── IntermediateResultPartition[](分区数组)
示例:一个并行度为 4 的 Map 算子,会生成:
- 1 个 ExecutionJobVertex
- 4 个 ExecutionVertex(每个对应一个 Task 实例)
4.4 调度策略
SchedulerNG 会根据配置选择调度策略:
| 策略 | 适用场景 | 行为 |
|---|---|---|
| Eager | 流处理(默认) | 一次性调度所有 Task |
| Lazy | 批处理 | 按依赖关系逐步调度 |
| Pipelined Region | 混合模式 | 按 Region 调度 |
4.5 关键源码位置
Dispatcher.submitJob()- 提交入口JobMaster构造方法 - 创建调度器ExecutionGraphBuilder.buildGraph()- 构建核心
五、阶段四:ExecutionGraph → Task 执行
5.1 从计划到实际运行
ExecutionGraph 构建完成后,就可以开始真正的任务调度和执行了。
5.2 核心流程
-
注册 JobManagerRunner
ExecutionGraph 作为参数传递给runJob()方法。 -
启动 JobManagerRunner
执行以下操作:- 确定调度策略(Eager/Lazy/Region)
- 识别 Failover Region(故障恢复单元)
- 分配 Slot
- 部署 Task
- 执行 Task
-
Slot 分配流程
sequenceDiagram
participant JM as JobMaster
participant SP as SlotPool
participant RM as ResourceManager
participant TM as TaskManager
JM->>SP: 请求 Slot
SP->>RM: 申请资源
RM->>TM: 分配 Slot
TM->>SP: Offer Slot
SP->>JM: 返回 SlotID
详细步骤:
- JobMaster 向 SlotPool 请求 Slot
- 如果 SlotPool 没有空闲 Slot,向 ResourceManager 申请
- ResourceManager 选择合适的 TaskManager
- TaskManager 分配 Slot 并返回 SlotID
- Task 部署流程
// 创建 TaskDeploymentDescriptor
TaskDeploymentDescriptor tdd = new TaskDeploymentDescriptor(
executionAttemptId,
serializedJobInformation,
serializedTaskInformation,
inputGates,
resultPartitions
);
// RPC 调用 TaskManager
taskManager.submitTask(tdd);
TaskManager 收到部署请求后:
- 创建 Task 对象
- 启动 Task 线程
- 初始化算子(open() 方法)
- 开始处理数据
- Task 生命周期
CREATED → DEPLOYING → RUNNING → FINISHED
↓
CANCELING → CANCELED
↓
FAILING → FAILED
-
返回 JobClient
回到execute()方法,返回JobClient对象。 -
阻塞等待结果
调用JobClient.getJobExecutionResult()会阻塞,直到作业完成。 -
通知监听器
作业完成后,通知所有注册的监听器(JobListener)。
5.3 Slot Sharing 机制
Flink 允许不同算子的 SubTask 共享同一个 Slot,前提是:
- 在同一个 Slot Sharing Group
- 不属于同一个 JobVertex
示例:
// 假设并行度都是 4
Source -> Map -> KeyBy -> Reduce -> Sink
不使用 Slot Sharing:需要 4 * 5 = 20 个 Slot
使用 Slot Sharing:只需要 4 个 Slot(每个 Slot 运行一个完整的 Pipeline)
5.4 关键源码位置
JobManagerRunner.start()- 启动入口DefaultScheduler.startScheduling()- 调度核心ExecutionVertex.deploy()- 部署逻辑Task.run()- 执行线程
六、特殊场景:Flink on YARN
6.1 Per-Job 模式的特殊之处
在 YARN Per-Job 模式下,流程有所不同:
graph TD
A[Client] -->|1. 生成 JobGraph| B[JobGraph]
B -->|2. 序列化| C[JobGraph 文件]
C -->|3. 上传| D[HDFS]
A -->|4. 提交 Application| E[YARN ResourceManager]
E -->|5. 分配容器| F[ApplicationMaster]
F -->|6. 读取 JobGraph| D
F -->|7. 启动| G[JobManager]
G -->|8. 转换| H[ExecutionGraph]
H -->|9. 调度| I[TaskManager]
关键差异:
-
JobGraph 通过文件传递
客户端生成 JobGraph 后,序列化成文件并上传到 HDFS,而不是直接通过 RPC 传递。 -
ApplicationMaster 启动 JobManager
YARN 分配容器后,ApplicationMaster 会在容器中启动 JobManager 进程。 -
资源申请委托给 YARN
Slot 分配不再是 Flink 内部的 ResourceManager,而是通过 YARN 申请容器。 -
核心转换链路不变
StreamGraph → JobGraph → ExecutionGraph → Task 这条链路保持完全一致。
6.2 总结一句话
YARN Per-Job 模式的本质是把 JobGraph 通过文件传递给 YARN 拉起的 JobManager,资源申请交给了 YARN,但引擎层的 StreamGraph → JobGraph → ExecutionGraph → Task 这条链路保持不变。
七、常见问题与调优建议
7.1 为什么我的算子没有链接?
排查步骤:
- 检查并行度是否一致
- 确认中间是否有
keyBy()、rebalance()等改变分区的操作 - 查看是否手动设置了不同的 Slot Sharing Group
- 确认没有禁用全局 Chaining
调试技巧:
// 打印 JobGraph
System.out.println(env.getStreamGraph().getJobGraph());
7.2 如何手动控制算子链?
// 禁用全局链接
env.disableOperatorChaining();
// 禁用单个算子的链接
stream.map(...).disableChaining();
// 强制开始新的链
stream.map(...).startNewChain();
// 设置 Slot Sharing Group
stream.map(...).slotSharingGroup("custom-group");
7.3 Slot 数量怎么设置?
基本原则:
- 不使用 Slot Sharing:
Slot 数 = 所有算子的最大并行度之和 - 使用 Slot Sharing:
Slot 数 = 最大并行度
示例:
Source(4) -> Map(4) -> Reduce(8) -> Sink(2)
- 不共享:4 + 4 + 8 + 2 = 18 个 Slot
- 共享:max(4, 4, 8, 2) = 8 个 Slot
7.4 性能优化建议
-
合理设置并行度
- CPU 密集型:并行度 = CPU 核心数
- I/O 密集型:并行度 = CPU 核心数 * 2
-
善用算子链
- 减少序列化/反序列化开销
- 降低线程上下文切换
-
避免过度 Shuffle
- 尽量减少
keyBy()、rebalance()的使用 - 考虑使用
forward分区
- 尽量减少
-
Slot Sharing 权衡
- 优点:减少资源占用
- 缺点:可能导致资源竞争
八、总结
8.1 四阶段核心要点
| 阶段 | 输入 | 输出 | 关键类 | 核心操作 |
|---|---|---|---|---|
| 1 | User Code | StreamGraph | StreamGraphGenerator | 构建逻辑拓扑 |
| 2 | StreamGraph | JobGraph | StreamingJobGraphGenerator | 算子链合并 |
| 3 | JobGraph | ExecutionGraph | ExecutionGraphBuilder | 生成物理执行计划 |
| 4 | ExecutionGraph | Task | DefaultScheduler | 调度和执行 |
8.2 关键设计思想
- 分层抽象:用户代码 → 逻辑图 → 优化图 → 物理图,逐层细化
- 延迟执行:调用
execute()前不会真正提交作业 - 算子融合:通过 Chaining 减少通信开销
- 资源共享:Slot Sharing 提高资源利用率
- 容错机制:ExecutionGraph 支持 Checkpoint 和故障恢复
8.3 扩展思考
-
Flink SQL 的执行流程有何不同?
SQL → AST → Logical Plan → Optimized Plan → JobGraph(跳过 StreamGraph) -
Batch 模式的特殊处理?
使用 Lazy 调度策略,支持算子重排序优化 -
Adaptive Scheduler(1.13+)的改进?
动态调整并行度,无需预先设定