Flink 作业从提交到运行的完整生命周期(源码级解析)

7 阅读9分钟

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逻辑拓扑构建
阶段 2StreamGraphJobGraph算子链合并优化
阶段 3JobGraphExecutionGraph物理执行计划生成
阶段 4ExecutionGraphTask 实例任务调度与执行

下面逐一展开每个阶段的细节。


二、阶段一:用户代码 → 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");  // 从这里开始

执行步骤:

  1. 备份 Transformations
    execute() 方法首先将当前环境中的 transformations 列表进行备份,防止重复执行时出现问题。

  2. 调用 getStreamGraph()
    创建一个空的 StreamGraph 对象,并清空 transformations 列表。

  3. 缓存检查
    检查是否已有缓存的 StreamGraph,避免重复构建。

  4. 调用 transform() 方法
    这是核心转换逻辑,会递归遍历所有 Transformation:

    • 已转换检查:如果一个 Transformation 有多个下游算子,确保只转换一次
    • 设置最大并行度:根据配置或默认值设置
    • 配置 Slot Sharing:决定哪些算子可以共享 Slot
    • 创建 StreamNode:每个 Transformation 对应一个 StreamNode
    • 添加 StreamEdge:连接上下游节点
  5. 存储到 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() 返回后,继续执行:

  1. 调用 executeAsync()
    获取执行器(Executor),判断是本地执行还是集群提交。

  2. StreamGraph 交给执行器
    执行器会将 StreamGraph 传递给 FlinkPipelineExecutorUtils

  3. 获取 Translator
    通过 SPI 机制加载对应的 StreamGraphTranslator

  4. 执行转换
    调用 StreamGraphTranslator.translate() 方法。

  5. 进入 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: SourceMapFilterSink (4个节点)
JobGraph:    [SourceMapFilterSink] (1JobVertex)

所有算子满足 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,说明已链接
  • 如果看到 SourceMapFilter 三个独立 Task,说明未链接

3.6 关键源码位置

  • StreamGraphTranslator.translate() - 转换入口
  • StreamingJobGraphGenerator.setChaining() - 链接核心
  • StreamingJobGraphGenerator.isChainable() - 判断条件

四、阶段三:JobGraph → ExecutionGraph

4.1 从逻辑图到物理执行图

JobGraph 描述了优化后的作业结构,但还缺少:

  • 具体的并行度实例
  • Slot 分配信息
  • 数据分区的细节

ExecutionGraph 就是将 JobGraph 实例化 为可执行的物理计划。

4.2 核心流程

  1. 提交 JobGraph
    getJobGraph() 返回后,创建 MiniCluster(本地模式)或连接远程集群。

  2. 调用 submitJob()
    将 JobGraph 提交给 Dispatcher。

  3. 持久化 JobGraph
    在高可用模式下,JobGraph 会被写入 Zookeeper 或文件系统。

  4. 创建 JobMaster
    Dispatcher 为这个作业创建一个 JobMaster,负责调度和协调。

  5. JobMaster 构造 SchedulerNG
    JobMaster 的构造方法会创建 SchedulerNG 对象(默认是 DefaultScheduler)。

  6. 创建 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 核心流程

  1. 注册 JobManagerRunner
    ExecutionGraph 作为参数传递给 runJob() 方法。

  2. 启动 JobManagerRunner
    执行以下操作:

    • 确定调度策略(Eager/Lazy/Region)
    • 识别 Failover Region(故障恢复单元)
    • 分配 Slot
    • 部署 Task
    • 执行 Task
  3. 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
  1. Task 部署流程
// 创建 TaskDeploymentDescriptor
TaskDeploymentDescriptor tdd = new TaskDeploymentDescriptor(
    executionAttemptId,
    serializedJobInformation,
    serializedTaskInformation,
    inputGates,
    resultPartitions
);

// RPC 调用 TaskManager
taskManager.submitTask(tdd);

TaskManager 收到部署请求后:

  • 创建 Task 对象
  • 启动 Task 线程
  • 初始化算子(open() 方法)
  • 开始处理数据
  1. Task 生命周期
CREATED → DEPLOYING → RUNNING → FINISHED
                ↓
            CANCELINGCANCELED
                ↓
            FAILING → FAILED
  1. 返回 JobClient
    回到 execute() 方法,返回 JobClient 对象。

  2. 阻塞等待结果
    调用 JobClient.getJobExecutionResult() 会阻塞,直到作业完成。

  3. 通知监听器
    作业完成后,通知所有注册的监听器(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]

关键差异

  1. JobGraph 通过文件传递
    客户端生成 JobGraph 后,序列化成文件并上传到 HDFS,而不是直接通过 RPC 传递。

  2. ApplicationMaster 启动 JobManager
    YARN 分配容器后,ApplicationMaster 会在容器中启动 JobManager 进程。

  3. 资源申请委托给 YARN
    Slot 分配不再是 Flink 内部的 ResourceManager,而是通过 YARN 申请容器。

  4. 核心转换链路不变
    StreamGraph → JobGraph → ExecutionGraph → Task 这条链路保持完全一致。

6.2 总结一句话

YARN Per-Job 模式的本质是把 JobGraph 通过文件传递给 YARN 拉起的 JobManager,资源申请交给了 YARN,但引擎层的 StreamGraph → JobGraph → ExecutionGraph → Task 这条链路保持不变。


七、常见问题与调优建议

7.1 为什么我的算子没有链接?

排查步骤

  1. 检查并行度是否一致
  2. 确认中间是否有 keyBy()rebalance() 等改变分区的操作
  3. 查看是否手动设置了不同的 Slot Sharing Group
  4. 确认没有禁用全局 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 SharingSlot 数 = 所有算子的最大并行度之和
  • 使用 Slot SharingSlot 数 = 最大并行度

示例

Source(4) -> Map(4) -> Reduce(8) -> Sink(2)
  • 不共享:4 + 4 + 8 + 2 = 18 个 Slot
  • 共享:max(4, 4, 8, 2) = 8 个 Slot

7.4 性能优化建议

  1. 合理设置并行度

    • CPU 密集型:并行度 = CPU 核心数
    • I/O 密集型:并行度 = CPU 核心数 * 2
  2. 善用算子链

    • 减少序列化/反序列化开销
    • 降低线程上下文切换
  3. 避免过度 Shuffle

    • 尽量减少 keyBy()rebalance() 的使用
    • 考虑使用 forward 分区
  4. Slot Sharing 权衡

    • 优点:减少资源占用
    • 缺点:可能导致资源竞争

八、总结

8.1 四阶段核心要点

阶段输入输出关键类核心操作
1User CodeStreamGraphStreamGraphGenerator构建逻辑拓扑
2StreamGraphJobGraphStreamingJobGraphGenerator算子链合并
3JobGraphExecutionGraphExecutionGraphBuilder生成物理执行计划
4ExecutionGraphTaskDefaultScheduler调度和执行

8.2 关键设计思想

  1. 分层抽象:用户代码 → 逻辑图 → 优化图 → 物理图,逐层细化
  2. 延迟执行:调用 execute() 前不会真正提交作业
  3. 算子融合:通过 Chaining 减少通信开销
  4. 资源共享:Slot Sharing 提高资源利用率
  5. 容错机制:ExecutionGraph 支持 Checkpoint 和故障恢复

8.3 扩展思考

  • Flink SQL 的执行流程有何不同?
    SQL → AST → Logical Plan → Optimized Plan → JobGraph(跳过 StreamGraph)

  • Batch 模式的特殊处理?
    使用 Lazy 调度策略,支持算子重排序优化

  • Adaptive Scheduler(1.13+)的改进?
    动态调整并行度,无需预先设定


参考资料

  1. Apache Flink 官方文档
  2. Flink 源码(1.17 分支)