作业提交之后 UI 上刷出一串方块,有人在等它跑完,有人在等它报错。我见过不少团队在这个环节卡上好几天,日志翻了几百行,最后发现问题既不在业务代码也不在集群资源,而是压根没搞清楚 Flink 从拿到你的 DataStream 代码到把 Task 跑起来,中间到底画了几张图、每张图又各自改了什么。Flink 的执行图演变链路——StreamGraph、JobGraph、ExecutionGraph 再到物理执行图——是这个框架最容易被跳过、又最躲不开的一段内核知识。它决定了你的算子会不会被 chain 到一起、并行度在哪个阶段被真正展开、故障恢复时重算的粒度有多大、反压到底该去哪个面板看。这篇内容面向已经能写 Flink 作业、但一遇到调度异常和性能问题就只会调并行度的同学,我会把四张图逐层拆开,讲清楚每一层存在的理由、转换时的判定规则,以及在真实排查里怎么拿这些图当工具用,顺带聊一下 Flink 里另一条几乎被人遗忘的图计算支线。
1. 先把四张图的位置摆正:一次 execute 之后发生了什么
很多人对这几个名词的印象停留在面试题层面,能背出顺序,但一旦线上出问题就完全对不上号。原因在于这些图不是并列的四个概念,而是一条单向的、逐层收敛的翻译链。你不把它们的先后关系和职责边界理清楚,后面所有的排查都是盲猜。
1.1 从 execute() 到 TaskManager 的一条单向链路
当你在 main 方法里调用env.execute("job-name"),Flink 客户端开始干活。它手里握着的是你通过 DataStream API 构造出来的一棵StreamTransformation树,每个map、filter、keyBy、window都在树上挂了一个节点。客户端第一步把这棵树翻译成StreamGraph,第二步做算子链合并产出JobGraph,第三步把JobGraph连同依赖的 jar 一起提交给集群。到了 JobManager 侧,JobMaster拿到JobGraph后再展开成ExecutionGraph,最后由调度器根据ExecutionGraph里的并行子任务,向 TaskManager 申请槽位、部署Task,这些Task之间通过ResultPartition和InputGate连起来,才形成了物理意义上真正跑起来的那张图。
这条链有个关键特性:单向且不可逆。你没法从ExecutionGraph反推出原始StreamGraph长什么样,因为中间经过了 chain 合并和并行度展开,信息已经丢失。这也解释了一个常见现象——为什么你在 UI 上看到的算子数量和代码里的算子数量经常对不上,UI 展示的是JobGraph视图,已经做过 chain 合并了。
还有一个容易被忽略的点:StreamGraph的生成发生在客户端,而ExecutionGraph的生成发生在 JobManager。这意味着客户端和集群之间如果版本不一致、或者用户代码里用了客户端侧才能解析的 lambda,问题会在翻译阶段就爆出来,报错位置却指向一个跟业务毫无关系的地方。
1.2 四张图各自守着什么边界
把这四张图放在一张表里对比,职责差异会非常明显:
| 图类型 | 生成位置 | 核心元素 | 是否含并行度展开 | 主要用途 |
|---|---|---|---|---|
| StreamGraph | 客户端 | StreamNode / StreamEdge | 有并行度数值,但未展开成实例 | 逻辑拓扑的首次固化 |
| JobGraph | 客户端 | JobVertex / IntermediateDataSet / JobEdge | 已完成 chain 合并 | 提交给集群的序列化产物 |
| ExecutionGraph | JobManager | ExecutionJobVertex / ExecutionVertex | 已按并行度展开成实例 | 调度、容错、状态追踪 |
| 物理执行图 | TaskManager | Task / ResultPartition / InputGate | 每个实例对应一个 Task | 实际数据流转 |
这张表最值得盯的是第三列。StreamGraph里每个算子已经带了并行度这个属性,但它还是"一个节点",没被复制成 N 份;到了ExecutionGraph,一个并行度为 4 的算子会变成 4 个ExecutionVertex,每个都有独立的运行状态、独立的 failover 计数。这个从 1 到 N 的展开动作,是理解容错粒度和资源分配的关键分界。
物理执行图严格来说在 Flink 源码里没有对应的类叫这个名字,它是Task部署后通过ResultPartition与InputGate形成的实际连接关系。之所以还要单独拎出来说,是因为排查网络层问题时,你面对的确实是这张"不存在于代码里但存在于运行时"的图。
2. 为什么不能一张图走到底:分层设计背后的取舍
我第一次看这段源码时的疑问是:直接搞一张图不就完了,为什么要翻译四次?后来在处理一个跨版本升级的兼容性问题时才想明白,每多一层,都是为了解决一类特定的矛盾,而不是为了架构好看。
2.1 API 层与运行时的解耦是硬需求
DataStream API 是面向用户的,它的抽象必须足够舒服——keyBy之后能接window,window之后能接aggregate,这些语义要表达得自然。但运行时关心的完全是另一套东西:谁跟谁之间要走网络、哪个槽位能放几个 Task、checkpoint 的 barrier 怎么在链路上传播。这两套关注点硬塞进一张图里,必然导致要么 API 难用,要么运行时难优化。
分层的本质是把"用户想表达什么"和"机器要怎么执行"切开。StreamGraph承接前者,ExecutionGraph承接后者,JobGraph夹在中间做一次关键的工程折中——它既要能被序列化后跨进程传输,又要已经做完一部分优化(比如 chain),让 JobManager 少干活。
这个设计还有一个副作用值得说:Flink 的 Table API / SQL 最终也会翻译成StreamGraph或JobGraph。也就是说,不管你从哪个入口进来,最后都收敛到同一套执行图体系。这解释了为什么 SQL 作业和 DataStream 作业在 UI 上的呈现形式几乎一样,两者的性能调优手段也高度重合。
2.2 每一层都在丢掉一些信息、补上另一些信息
有个说法很贴切:这四层翻译不是简单的格式转换,而是有损压缩加有损展开的组合。StreamGraph里记录的是原始的算子语义和用户指定的并行度,到了JobGraph,多个StreamNode被合并成一个JobVertex,链内部的边被抹掉了——你在JobGraph上看不到map和filter之间的那条边,因为它们已经变成同一个 Task 里的方法调用。这是丢信息。
反过来,ExecutionGraph补上了StreamGraph里根本没有的东西:每个并行子任务的当前状态(CREATED、DEPLOYING、RUNNING、FAILED)、每个中间结果的分区状态、failover 区域划分、以及每个顶点已经失败过几次。这些是调度器做决策的依据,在客户端阶段完全不需要存在。
理解这个"丢什么、补什么"的规律,能帮你在排查时快速判断该看哪一层。比如你怀疑两个算子之间的数据分布有问题,那必然要去StreamGraph或者 UI 的算子视图看,JobGraph上这两个算子如果被 chain 了,你是看不出边界的。
3. StreamGraph:算子视角的第一版草图
StreamGraph是整条链上最接近你写的那段代码的一层,但它已经做了一些你不能忽略的加工。搞清楚这些加工动作,能解释很多"我明明没这么写"的困惑。
3.1 它记录了什么、又刻意不关心什么
StreamGraphGenerator遍历StreamTransformation树,为每个 transformation 生成一个StreamNode。节点上挂的信息包括:算子的工厂类(StreamOperatorFactory)、并行度、输入输出序列化器、槽位共享组、以及各类资源配置。边(StreamEdge)上挂的是分区策略——ForwardPartitioner、HashPartitioner、RebalancePartitioner、BroadcastPartitioner这些。
它刻意不关心的东西同样重要:不关心算子之间能不能合并成一条链,不关心物理上部署到哪台机器,不关心 checkpoint 怎么做。它只回答一个问题——这个作业的逻辑拓扑长什么样。
这里有个实操中经常踩的点:keyBy产生的HashPartitioner在StreamGraph上是明确存在的边。如果你在代码里写了keyBy之后又rebalance,那这条链上会出现两次分区动作,StreamGraph上看得一清二楚,但 UI 上因为 chain 的关系可能已经模糊了。所以定位"多了一次 shuffle"这类问题时,把StreamGraph打出来看是最直接的。
3.2 从 DataStream API 到 StreamNode 的转换细节
这个过程里有个细节很多人不知道:StreamGraph生成阶段会对一些算子做隐式改写。最典型的是 watermark 相关——你在代码里调assignTimestampsAndWatermarks,生成的是一个独立的StreamNode,但在 chain 策略允许的情况下它会和下游合并。这意味着 watermark 的产生位置在StreamGraph上是一个明确的节点,到JobGraph上可能就消失了。
另一个隐式改写是 slot sharing group 的继承。如果你给某个算子单独设了slotSharingGroup("my-group"),那么它下游的算子默认会继承这个组,除非显式再设置一次。这个继承规则在StreamGraph阶段就固化了,后面几层只是照着执行。我见过一个案例:有人在某个 sink 上设了独立的槽位组,结果整条链路下游全部跑到独立组里,资源占用翻倍,排查了很久才发现是继承机制。
想直观看到这一层,最省事的办法是在客户端侧打日志,把StreamGraph的 JSON 表示输出出来。Flink 提供了StreamGraph#getJobGraph之前的中间态访问能力,通过配置也能让 UI 展示未经 chain 的原始拓扑,具体手段是关掉 chain(后面会讲),这时 UI 上呈现的接近StreamGraph视图。
4. JobGraph:分布式化的第一次收敛
JobGraph是客户端交付给集群的最终产物,也是 Flink 优化动作最密集的一层。它的核心任务就一个:把逻辑节点合并成可以独立调度的执行单元,同时保证合并之后语义不变。这个"保证语义不变"说起来轻松,实际判定条件相当苛刻。
4.1 Operator Chain 的判定条件与实测影响
两个相邻节点能被 chain 到一起,必须同时满足下面这些条件,少一条都不行:
| 判定条件 | 说明 | 不满足时的表现 |
|---|---|---|
| 下游只有一个输入 | 下游节点入边数量为 1 | 多输入节点(如 union 后)必然断开 |
| 分区策略为 forward | 上游到下游是ForwardPartitioner | hash / rebalance / broadcast 边必然断开 |
| 上下游并行度一致 | 并行度数值相等 | 并行度不同则无法一对一传递 |
| 同属一个槽位共享组 | slotSharingGroup 相同 | 组不同则无法部署到同一槽位 |
| chain 策略允许 | 未被disableChaining阻断 | 显式禁用则断开 |
| 无特殊算子阻断 | 如迭代头、某些 source | 特定算子强制断开 |
这张表我自己在排查性能问题时反复用过。有一次一个作业的吞吐上不去,UI 上看到keyBy之后紧接着一个map是分开的两个方块,第一反应是 chain 失效了。对照表格逐条排查,发现是keyBy引入了HashPartitioner,第二条就卡住了——这是设计如此,不是 bug。真正的问题是keyBy后面那个map里做了很重的计算,本应该在keyBy之前就做掉。
disableChaining()和startNewChain()这两个 API 值得单独说一下。前者把当前算子的上下游链全部断开,后者从当前算子开始新起一条链。这两个方法的作用域是"当前算子",不是全局,很多人误以为调一次就全局生效。
4.2 为什么 chain 会悄悄改变你的并行语义
这一节是我认为最需要强调的部分。chain 合并之后,多个算子变成了同一个Task里的多个 operator,它们在同一个线程里串行执行。这带来两个容易被忽视的后果。
第一是线程模型的改变。被 chain 的算子共享同一个线程,意味着如果其中一个算子阻塞了(比如同步 IO、长时间 GC、锁等待),整条链上的所有算子都会被拖住。我遇到过一种典型情况:某个map里做了一次同步的远程调用,超时设置是默认值,结果整条链的吞吐被打到个位数,而 UI 上反压显示的是最上游的 source,完全误导了排查方向。
第二是checkpoint barrier 的对齐行为改变。chain 内部的算子之间不涉及网络传输,barrier 直接传递,不存在对齐等待;跨 chain 的边才需要对齐。这直接影响 checkpoint 的耗时。当你把一条长链拆成两条短链时,可能会发现 checkpoint 时间变长了,原因就是多了一次网络对齐。反过来,如果 checkpoint 超时,把某些算子拆开独立成链、单独设置并行度,有时反而能缓解,因为瓶颈算子不再拖累整条链。
还有一点:chain 之后,同一个JobVertex里的算子共享并行度。StreamGraph上每个算子可以有自己的并行度,但一旦被 chain 合并,就必须统一成相同的值,否则第二条判定条件不满足直接断开。这就解释了为什么调大某个算子的并行度有时完全不生效——它被 chain 在了上游,改的是自己但真正的瓶颈在链上的另一个算子。
5. ExecutionGraph:并行化之后的调度蓝图
JobGraph到了 JobMaster 手上,第一件事就是展开成ExecutionGraph。这是四层里结构最复杂的一层,也是唯一会随着运行不断变化的一层。
5.1 ExecutionJobVertex 与 ExecutionVertex 的展开方式
一个JobVertex如果并行度是 8,展开后会产生 1 个ExecutionJobVertex和 8 个ExecutionVertex。每个ExecutionVertex可以理解为一个待调度的任务实例,有自己的状态机:CREATED → SCHEDULED → DEPLOYING → RUNNING → FINISHED,失败时进入 FAILED 并可能重试。ExecutionJobVertex更像是一个容器,记录这 8 个兄弟实例的整体状态和失败计数。
边的情况类似:JobGraph上的一条JobEdge,展开后变成一组ExecutionEdge,连接上游的 8 个ExecutionVertex和下游的对应实例。中间结果从IntermediateDataSet展开成IntermediateResult,再细分成IntermediateResultPartition——上游一个并行子任务的输出就是一个 partition。
这个展开过程最有价值的信息是failover 区域划分。Flink 会把互相连接的ExecutionVertex划分成区域,一个区域内的任务失败时,整个区域需要一起重启。这个区域划分直接依赖 chain 的结果和分区策略:被 chain 到同一个JobVertex的算子天然在同一个区域,用ForwardPartitioner连接的两条链也可能被划到一起。所以你在 UI 上看到"重启了整个 pipeline"而不是只重启一个算子,根源往往在这一层的区域划分上。
5.2 故障恢复时这张图是怎么被复用的
一个ExecutionVertex失败后,ExecutionGraph不会被丢弃重建,而是就地更新状态:失败的实例状态置为 FAILED,其所在 failover 区域内的所有实例一并标记为需要重启,然后从最近一次成功的 checkpoint 恢复状态,重新部署。
这里有个反直觉的现象:重启次数是会累计的。ExecutionJobVertex上记录了numRestarts,配合restart-strategy配置的failure-rate或fixed-delay策略,超过阈值作业就会彻底失败。排查"作业反复重启最后挂掉"这类问题时,看这个计数比看日志更快——如果重启间隔越来越短、计数持续上涨,基本可以确定是一个稳定复现的故障,而不是偶发的环境抖动。
另外一个细节:从 checkpoint 恢复时,状态会重新分配给ExecutionVertex。如果并行度变了(比如从 4 改到 8),状态需要重新切分,keyBy之后的状态可以按 key group 均匀重分,但union之类的无 key 状态就比较麻烦,这也是 Flink 对改并行度支持程度不一致的原因。
6. 物理执行图:真正落在 TaskManager 上的那一层
前面几层你都可以通过代码或者 UI 看到,这一层没有对应的数据结构,只能通过运行时指标和日志反推。但恰恰是这一层决定了你的网络缓冲够不够、反压从哪来、数据倾斜发生在哪个 partition 上。
6.1 Task、ResultPartition 与 InputGate 的对应关系
每个ExecutionVertex部署到 TaskManager 上就是一个Task,占一个 slot 的一部分线程资源。每个Task的输出按下游消费者数量切分成若干个ResultPartition,每个ResultPartition又可以细分成多个 subpartition(对应下游的每个并行子任务)。下游的Task通过InputGate拉取数据,一个InputGate对应一个上游JobVertex,里面包含多个InputChannel,每个 channel 对应一个上游的 subpartition。
这个层级结构解释了一个常见配置问题:taskmanager.network.memory.fraction控制的是网络缓冲池大小,而缓冲是按InputChannel分配的。如果你的作业有大量的 shuffle 边(比如多次keyBy、rebalance),channel 数量会成倍增长,缓冲区被切得很碎,每个 channel 分到的容量变小,反压阈值(默认 0.5 上下水位)就更容易被触发。这也是为什么"减少不必要的 shuffle"不只是减少网络传输,还能间接改善内存压力。
6.2 用 UI 和日志反推物理图的实际形态
UI 上的 SubTask 列表其实就对应着物理图上的Task实例。点进某个算子,看到多行 SubTask,每行的Bytes Sent、Records Sent、In Pool Usage、Out Pool Usage这些指标,反映的是这个Task的ResultPartition和InputGate的实时状态。
我常用的一个判断方法是看Out Pool Usage的分布。如果某个 SubTask 的 outPoolUsage 长期贴着 1.0,说明它的下游消费不过来,缓冲区写满了,这是反压的源头方向。反过来In Pool Usage长期为 0,说明它在等数据。把两端的值对着看,反压的传播路径就出来了:从上游往下游走,第一个 outPoolUsage 打满的地方,就是真正的瓶颈所在。
日志侧也有线索。TaskManager 的日志里,如果出现Flink's network buffers are exhausted或者InputGate has no more buffers之类的告警,基本可以确认网络层已经饱和,这时候调大内存或者减少 shuffle 才是正解,单纯调并行度只会让 channel 更多、情况更糟。
7. Gelly 那条支线:图计算库在 Flink 里的位置变化
说到 Flink 和"图",还有一条几乎被遗忘的支线:Flink 内置的图计算库 Gelly。它曾经是 Flink 批处理能力的重要门面,后来逐渐淡出主线,这个演变过程本身就挺能说明问题的。
7.1 为什么批式图计算最终让位
Gelly 提供的是一套基于批处理的图算法抽象:Graph数据结构、vertex-centric 的VertexCentricIteration、scatter-gather 模型、gather-sum-apply 模型,以及一组开箱即用的算法(PageRank、连通分量、三角计数之类)。它的实现思路是把每一轮迭代映射成 Flink 作业里的一个迭代步骤,靠批处理引擎的循环能力跑起来。
这套设计在 2015 到 2018 年前后是合理的——那时候流处理引擎做迭代还不成熟,批处理是图计算的现实选择。但随着 Flink 重心全面转向流处理,Gelly 的维护投入持续下降。从 1.12 之后就基本没有新特性合入了,后续版本里被标记为不再推荐,新的大版本里干脆移除了。原因不复杂:图计算对迭代效率、点切分/边切分、内存驻留的要求,和通用数据流引擎的设计目标偏差太大,与其在通用引擎上做特化,不如用专门的图计算方案。
这个演变对普通开发者的直接意义是:如果你在旧项目里还在用 Gelly,升级大版本前必须先确认这套 API 是否还在。我见过有人升级到新版本后编译直接挂掉,才发现依赖的图算法类已经被删了。
7.2 今天做图计算该怎么选
现实里的选型大致分三档。数据量在单机内存可容纳的范围(千万级点、亿级边以内),NetworkX 这类图库配合内存优化足够用,开发效率最高。数据量再大一个数量级,需要分布式图算法,就得看具体是偏 OLAP 的批量图分析还是偏 OLTP 的实时点查询,前者用专门的分布式图计算引擎更合适,后者通常会用图数据库。
如果场景是"流式图更新 + 增量计算",比如实时风控里的关系圈检测,那反而更适合用 Flink 的流处理能力自己实现:把图的边当成流,用keyBy按顶点 ID 分区,用KeyedState保存邻接表,用定时器或者窗口触发一轮局部更新。这套方案没有通用图算法的抽象,但胜在可控、可增量、延迟低。我在一个关系网络实时扩展的场景里用过这种写法,几百毫秒的延迟能做到秒级以内的结果更新,代价是要自己处理消息乱序和状态膨胀。
8. 拿图去排查问题:三个场景的定位路径
理论讲完,最有价值的部分是怎么把这几张图当工具用。下面三个场景是我自己反复遇到的,每一个都能对应到具体的图层级。
8.1 反压定位:从 UI 到 chain 断点的排查顺序
反压的表象是上游变慢,但根因几乎总在下游。我的排查顺序固定是三步。
第一步看 UI 的 Backpressure 面板,它给出的是每个算子的反压等级(OK / LOW / HIGH)。从下游往上找第一个出现 HIGH 的算子,这个算子的下游就是嫌疑区。注意这一步只能定位到JobVertex粒度,也就是 chain 之后的整体。
第二步看 SubTask 级别的指标分布。如果同一个JobVertex的所有 SubTask 都 HIGH,说明是整体性问题(比如下游算子是单并行度的 sink);如果只有个别 SubTask HIGH,那基本确定是数据倾斜,去看这些 SubTask 的Records Sent是否显著高于其他兄弟。
第三步回到StreamGraph层面,审视 chain 结构。如果瓶颈算子和它的上游被 chain 在一起,那上游的所有算子都被拖累,UI 上看到的反压点会严重偏上游。这时候需要临时disableChaining()重新跑一次,把链拆开,反压的真实位置才会暴露出来。这个动作我在排查线上问题时做过很多次,拆与不拆看到的反压分布可以是完全不同的两个样子。
8.2 数据倾斜:为什么调并行度经常无效
倾斜的经典症状是:某个 SubTask 的输入记录数是其他兄弟的几倍到几十倍。原因通常落在keyBy的 key 分布上——某个热点 key 的哈希值固定落在同一个分区,不管你并行度调到多少,它只会在新分区里换个位置。
这种情况下调并行度无效的机制,可以从ExecutionGraph上看得很清楚:并行度从 4 调到 8,ExecutionVertex从 4 个变成 8 个,但热点 key 依然哈希到其中一个,那个实例的负载不会下降。真正有效的手段是打散热点——给 key 加随机前缀做两阶段聚合,或者对热点数据走单独的旁路逻辑。
还有一类倾斜来自rebalance的下游消费能力不均。rebalance是轮询分发,理论上均匀,但如果下游算子的处理耗时随数据内容波动很大(比如有些记录要查外部存储),就会形成事实上的倾斜。这种倾斜在JobGraph上看不出来,因为边是均匀的,得看ExecutionGraph上各实例的实际处理速率才能发现。
8.3 checkpoint 超时:从 barrier 对齐反推图结构
checkpoint 超时排查,第一步是确认是否卡在 barrier 对齐。UI 的 Checkpoints 面板会显示最近若干次 checkpoint 的详细耗时,其中Alignment Duration这一项如果占比很高,问题就出在多输入算子等待所有输入的 barrier 到齐。
这时候要看图的连接结构。一个有两路输入的算子(比如union之后接的process、或者connect的两条流),如果两路数据速率差异巨大,对齐就会非常慢——快的一路 barrier 到了,得等慢的那一路。降低对齐开销的手段包括:把慢的那一路上游单独提并行度、检查是否有输入长时间无数据(空闲分区会拖慢对齐,需要开启withIdleness配置)、或者干脆用 unaligned checkpoint 跳过对齐。
Alignment Duration不高但总耗时高的情况,通常是同步阶段的问题,得去看状态后端和存储侧的指标,跟图结构关系不大。这两类问题的区分点很明确:对齐时间长 → 图结构和数据速率问题;同步时间长 → 状态和存储问题。我在实际排查里靠这一个判断就能省掉大半的试错。
四张图这条链路我看了不少遍,最大的收获不是记住顺序,而是形成了一个条件反射:碰到任何调度或性能问题,先想清楚它发生在哪一层,再去对应的图上找证据,而不是上来就调参数。这个习惯帮我砍掉了大量无效的试错时间。
最后分享一个我常用的笨办法:在本地用一个小数据集(几千条记录)跑一遍作业,把并行度设成 2 或者 3,打开 Web UI 把JobGraph视图截图存下来,同时打印一份StreamGraph的 JSON 做对照。两相对比,哪几个算子被 chain 了、哪些边被合并了、并行度是怎么继承的,一目了然。这个动作花十分钟,能省掉后面在集群上瞎猜的几个小时。线上环境因为数据量大、结构复杂,很多问题被掩盖了,反倒是这个小规模的本地说验最能暴露图结构层面的问题。