Flink运行时架构详解:从任务提交到集群调度与执行
带着问题去理解架构是一种很笨但很实用的学习方法。早期我学 Flink 的时候上来就啃源码结果反复在各种术语里打转。后来换了个思路先不管代码里的细节只问一个问题——一个用户写好的 Flink 作业从提交到集群启动运行中间到底发生了什么把这条链路捋清楚再回去看架构图每个组件的角色就都站对位置了。这篇文章是这个系列的第一篇把整体架构、基本组件、任务提交流程拉通讲一遍。不要指望看完就能写出分布式调度器但至少你再遇到“TaskManager 挂了”“Slot 不够”这类报错时心里有个底。适合刚入门 Flink 的数据工程师也适合准备面试时把基础概念串一遍的人。1. 先把整体架构放在眼前Flink 运行时到底在解决什么分布式流处理的本质是把一个连续的计算拆到多台机器上还要让它们看起来像在一台机器上一样协作处理。这里最头疼的不是计算本身而是编排谁来接收数据谁来转换谁来汇总网络抖动时怎么办某台机器挂了怎么接续。一个靠谱的运行时架构本质就是一套把这些问题全部标准化的分工机制。我习惯用餐厅后厨来类比。一个夫妻店厨师既要洗菜又要切菜又要炒菜订单小的时候没问题但一旦客流上来就完全乱套。连锁餐厅的后厨是分层协作主厨定菜谱、管出品切配师傅负责切菜炒锅师傅负责炒菜打荷的负责传菜大家只需要通过固定的流程和窗口协作各自关注自己的环节。数据流处理也是这么回事一条数据从入口到出口会经过很多算子这些算子可能分布在不同的机器上机器之间靠网络传送数据。Flink 的运行时架构可以抽象成三个核心角色。客户端是把用户写的代码翻译成作业描述的角色相当于把菜谱写成标准流程的人JobManager 是整个作业的大脑负责调度和协调相当于主厨TaskManager 是真正干活的节点里面跑着各类计算任务相当于各位师傅。三个角色的职责边界很清楚所以定位问题时也很方便作业起不来先看客户端和 JobManager运行中性能差、数据倾斜那基本是 TaskManager 这一端。为什么必须把职责拆得这么清楚而不是让每个节点各自为政因为分布式系统里最危险的就是没有统一领导的资源竞争。如果每个节点都自己决定跑什么任务出现故障时就会出现谁都不管或者谁都在管的混乱状态。JobManager 做统一调度目的就在于用全局视角做决策谁该休息、谁能接管、资源怎么重新分配。这是所有分布式计算框架的共同思路只不过 Flink 把执行细节做得更细。2. 基本组件拆解各自的位置、职责与背后的设计逻辑2.1 客户端作业的入口与翻译官客户端是启动一切的地方。你写的 DataStream 程序或者 Table/SQL 程序最终都是在客户端完成解析和优化的。它会把用户代码转换成一张逻辑的数据流图再对这个图做一些优化最后打包成一个可提交的 JobGraph 发给集群。这个阶段有个容易被忽略的细节客户端本身不参与计算它只是一个入口。生产环境里常见一个现象用交互式提交脚本启动作业然后终端一关客户端退出有人以为作业也挂了。其实作业是否继续运行取决于提交时使用的模式以及是否需要保持客户端连接来接收状态。这个问题后面说提交流程时再展开。客户端具体做的事情包括校验作业配置生成逻辑计划做一部分静态优化比如算子合并的判断。这些工作如果放到 JobManager 上做也不是不行但会白白消耗集群 CPU更重要的是会让提交作业的速度变慢因为 JobManager 要处理的请求很多。把解析和优化放在客户端是一个很明智的分工集群只负责调度和执行不负责翻译菜谱。2.2 JobManager集群的大脑与调度中心JobManager 是集群中真正的领导角色。它在整个作业生命周期里做三件主要的事情调度执行、协调检查点、触发故障恢复。调度执行指的是把客户端提交上来的 JobGraph 转换成可并行执行的 ExecutionGraph然后为每个并行任务寻找合适的 TaskManager 和 Slot协调检查点是定期通知各个 TaskManager 做状态快照并收集快照完成情况故障恢复是某个任务失败之后由 JobManager 决定到底是局部重试还是整个作业重新调度。实际运行时JobManager 内部还会分成几个小的组件例如负责作业调度的 JobMaster、负责资源管理的 ResourceManager、负责检查点的 CheckpointCoordinator。这些概念在不同版本里会有细微差别但从学习的角度你只要记住 JobManager 是一个管理集合就足够了。高可用场景下JobManager 通常不止一个实例。多个实例之间通过某种协调服务做领导者选举只有一个是活跃的其余待命。一旦活跃实例宕机其它实例会接管职责。为什么这么设计因为单点是分布式系统最忌讳的事。流式作业可能要跑几个月一个节点长时间不宕机的概率其实并不高。如果 JobManager 挂了整个作业就完蛋流式作业的可用性就无从谈起。2.3 TaskManager真正干活的执行者TaskManager 是集群里真正执行数据计算的进程。每个 TaskManager 会被划分成若干个 SlotSlot 是资源分配的最小单位可以理解为 TaskManager 里的一块固定大小的资源租约包含一定的 CPU 配额和内存配额。一个 Slot 可以运行一个或多个任务子任务具体取决于是否启用了 Slot 共享。任务线程与 Slot 的对应关系是初学者最容易搞糊涂的地方。Slot 并不是线程它只是一个资源容器真正执行算子逻辑的是 JVM 里运行的线程。一个 Slot 里可能有好几个子任务在排队执行。为什么这样设计如果每个子任务都占用一个独立进程整个集群会被进程数撑爆内存也会被大量重复浪费。共享 Slot 之后相同资源池里的任务可以共享内存中的一部分数据结构减少序列化和网络开销。从运维角度看Slot 数量决定了一个 TaskManager 最多能同时承载多少个并行子任务。假设一个 TaskManager 配置了两个 Slot另一个配置了四个 Slot集群总可用 Slot 就是六个。作业并行度为五的话会占用其中五个 Slot剩下一个留给其它作业。如果作业的并行度大于集群总 Slot 数调度器就只能排队等待资源这时候你会看到作业一直在启动中或者 Restarting。2.4 高可用集群的外围组件除了三个核心角色一个在生产环境真正可靠的集群还需要外围组件支撑例如用于领导者选举和元数据存储的协调服务以及用于保存检查点和作业元数据的持久化存储。协调服务的角色很多时候被忽略但它才是保证 JobManager 多实例能正常配合的基础。持久化存储的作用也很直接JobManager 需要把 JobGraph、作业的配置信息、检查点元数据保存下来一旦出现故障新的 JobManager 可以从存储中恢复这些信息继续之前的调度和恢复。如果没有持久化宕机之后所有状态都是内存里的临时数据一旦重启就全没了。这些外围组件和运行时架构有什么关系其实它们是从单机可用到生产可用的关键区别。单机环境下你可以把所有角色都塞进一个 JVM 里生产环境里只要任何一个角色没有高可用保障整个集群的可用性就会大打折扣。3. 任务提交流程从代码到分布式集群运行的完整链路3.1 四层图的转换逻辑理解 Flink 提交流程的核心是搞懂四张图StreamGraph、JobGraph、ExecutionGraph以及最终的物理执行图。这是一个逐步细化的过程。StreamGraph 是最接近用户代码的一层节点就是用户对数据流的各种操作比如 map、filter、keyBy、window边就是数据在这些操作之间的流动关系。它基本上是按照用户书写顺序生成的还没有做复杂优化。JobGraph 是客户端做过一轮优化之后的结果主要变化是把可以合并的算子链融合成一个节点同时把数据传输的具体方式比如分区器、序列化器都固化下来。ExecutionGraph 是 JobManager 收到 JobGraph 之后结合并行度生成的并行化视图每个逻辑节点会被复制成与并行度一致的并行子任务。最后的物理执行图就是这些子任务被实际调度到具体 TaskManager 的 Slot 上时形成的部署视图。为什么设计这么多层一个原因是职责分层用户关心的是逻辑语义客户端负责把逻辑语义翻译成可调度的计划JobManager 负责把计划变成可并行的执行计划TaskManager 负责把执行计划落地。另一个原因是便于优化不同层级有不同的优化手段比如算子链优化在 JobGraph 阶段做最合适而并行度的分组调度在 ExecutionGraph 阶段才能做。举个例子。你的代码是 source → map → keyBy → window → sink。StreamGraph 里这五个节点是分开的。做算子链优化时source 和 map 如果能串在一起就会变成一个 JobGraph 节点但 keyBy 是重分区操作所以 keyBy 之后的 window 和 sink 也可能在另一个链里。最终 JobGraph 可能只有两三个节点。到了 ExecutionGraph根据并行度再展开成多个子任务。3.2 算子链减少开销的关键优化算子链是 Flink 里非常重要的优化手段。简单说如果两个相邻算子之间是窄依赖也就是数据不需要跨分区重新打散那么它们可以合并成一个任务在同一个线程里执行。比如 source 之后接一个 map又接一个 filter这三个算子往往可以合并成一个任务链。为什么合并能带来性能提升因为不合并的话每个算子都是独立任务数据从一个任务到下一个任务需要经过序列化、网络传输、反序列化或者线程间队列传递。合并之后数据就在同一个线程的函数调用栈里直接传递省掉了大量内存拷贝和线程切换开销。在高吞吐场景下这个优化往往能带来成倍的效果差异。什么时候算子链会断开常见的是 keyBy、rebalance、broadcast 这类需要重新分区的操作数据必须从一个子任务路由到另一个子任务链路自然就断了。另一种是你手动关闭了算子链通过配置强制每个算子单独成为一个任务。一般是为了定位某个算子的性能瓶颈或者需要单独设置某个算子的并行度才会这么做。在 UI 上看到的 Task很多时候是一个算子链而不是单个算子新手排查问题时要注意这一点。3.3 JobManager 的调度决策接下来看调度阶段。JobManager 收到 JobGraph 之后会为每个任务计算所需 Slot 数量。这里有一个常见误区所需 Slot 数量不是所有算子并行度的简单加法而是取最大并行度因为默认开启了 Slot 共享。假设一个作业有三个算子并行度分别是 2、2、6那么整个作业只需要 6 个 Slot 就够。每个 Slot 里会放若干个属于不同算子的子任务这样资源利用率更高启动速度也更快。调度时 JobManager 会考虑数据本地性尽量把子任务调度到输入数据所在的机器减少跨机网络传输。流式作业里数据是不间断的所以这个考量对批式作业更明显但架构理解上是统一的。然后它会向对应的 TaskManager 发送部署请求TaskManager 在本地创建任务线程连接上游和下游的数据通道开始数据处理。这里必须提一下检查点协调的时间点。调度完成、任务全部进入 RUNNING 状态之后CheckpointCoordinator 才会开始周期性触发检查点。很多作业一启动就在做检查点而状态很大导致检查点超时看起来就是作业没报错但一直在恢复重启。这种问题往往不是调度架构的锅而是检查点参数没调好。3.4 不同提交模式的差异从任务提交的角度Flink 还有一个概念必须讲提交模式也就是会话模式、单作业模式、应用模式。会话模式是集群常驻一个集群同时提交多个作业优势是资源共享、提交快短板是作业之间会互相影响一个作业把内存打爆可能带崩其它作业。单作业模式是集群跟着作业走作业结束集群就释放隔离性最好但每次提交都要重新启动集群开销大。应用模式类似单作业但用户的主方法是在集群的某个节点上执行客户端提交完就可以退出特别适合打包好的作业包。这三种模式影响的正是架构里客户端、JobManager、TaskManager 的生命周期。会话模式下客户端只是一个发起者集群常驻单作业模式下客户端短暂存在集群动态创建应用模式下甚至连客户端的很多工作都移到了集群内部。理解这个差异对排障很有帮助。比如会话模式下作业挂了但集群还在可以立刻再提交单作业模式下要重新排队启动集群时间成本完全不同。4. 实践中的几个大坑槽位、并行度与内存的纠缠4.1 槽位不是线程也不是并发度很多人把 Slot 数和并行度划等号这是一个很危险的直觉。Slot 是 TaskManager 对外承诺的资源容量类似餐厅里的一张桌子可以坐不同的客人但桌子本身不是厨师也不代表厨师数量。并行度是算子层面的并发概念决定一个算子被拆成多少个子任务。两者之间的关系是通过调度器的分配规则连接起来的。举个具体例子。一个集群有两个 TaskManager每个配置两个 Slot所以总共四个 Slot。一个作业的并行度是三另一个作业的并行度也是三两个作业都想跑的话第二个作业会一直等待因为剩余 Slot 不够。但第二个作业的并行度如果是 1它就能立刻跑起来。这里体现的是资源不是按作业数算而是按 Slot 使用量算。生产环境调并行度时一定要把集群总 Slot 数放在心里不要把作业并行度调到比集群剩余 Slot 数还大。还有一点值得提醒Slot 共享虽然能提高利用率但也会带来故障域扩大。如果一个 Slot 里塞了多个算子的子任务其中某个子任务异常导致 Slot 被释放同 Slot 的其他子任务也会受影响。排查问题时可以先从 UI 上看一个 TaskManager 的 Slot 里到底跑了哪些任务再判断是单点问题还是资源争抢问题。4.2 并行度该调多少才能兼顾吞吐和资源并行度的设置是一门艺术。设大了资源浪费作业启动慢状态因为分片太多而膨胀设小了数据倾斜和积压就会出现。经验上流式作业建议并行度不超过集群 Slot 总数的 70%留出一部分余量给后续的恢复、扩容或者其它作业。另外不同算子差异很大source 和 sink 的并行度可以跟核心业务算子不同。如果你的 source 是从消息中间件拉数据并行度一般受分区数限制设得再大也只会有部分子任务真正在拉数据。还有一个值得注意的细节状态后端的存储会与并行度强相关。并行度调高状态分片更多检查点压力也更大并行度调小单个子任务的状态可能暴涨容易出现内存压力。所以改并行度的时候最好顺带观察检查点的高峰时延如果出现明显恶化就要回头考虑是不是并行度变化带来的连锁反应。4.3 内存堆以外还有一笔账Flink 的内存管理在运行时架构中非常特殊。TaskManager 的内存配额里除了 JVM 堆还包括堆外直接内存、网络缓冲内存、托管内存以及 JVM 自身开销。托管内存是 Flink 专门用于排序、哈希表、状态后端等操作的内存它可以配置在堆内或堆外不同版本默认值有差异。网络缓冲内存则用于数据在任务之间传输时的缓冲。生产环境里最典型的坑是容器内存限制和 JVM 参数不匹配。比如你给 TaskManager 配了 4 GB 堆但没有给堆外和 JVM 开销留余量在资源管理框架里申请的容器内存也是 4 GB那很容易因为堆外内存超出限制被强制杀掉。处理方法很简单给容器内存至少留出几百 MB 甚至 1 GB 的缓冲同时把 Flink 的各类内存配比调得和容器规格一致。排查时看到 Container killed by memory 这类关键词先去看各类内存的使用率而不是直接调高堆大小。5. 常见问题排查与速查表5.1 先从日志的位置开始定位排障的第一步不是看报错文本而是先确认是哪个角色的日志。客户端日志通常在提交机器上JobManager 日志在跑着 JobManager 进程的节点上TaskManager 日志在执行节点上。很多新手拿着客户端报错去找 TaskManager 的问题折腾半天才发现作业根本没有提交成功。遇到作业起不来我的习惯是按这个顺序查先看客户端有没有把 JobGraph 提交给 JobManager再看 JobManager 有没有把任务调度到 TaskManager最后看 TaskManager 有没有正常启动任务线程。三个环节对应三类日志定位起来非常快。客户端日志里如果看到类似无法连接 JobManager 的报错大概率是地址配置有问题或者 JobManager 进程还没起来。JobManager 日志里如果看到资源不足的提示再去算集群剩余 Slot。TaskManager 日志里如果出现任务初始化异常那就是作业本身的问题比如序列化器配置不对或者状态后端初始化失败。分清楚这三层很多问题其实不需要深挖源码就能解决。5.2 高频问题的具体表现作业一直 Restarting看起来是任务执行失败被反复重试但很多时候根因在检查点或者背压。检查点超时会导致作业自动回滚表现就是周期性重启背压严重会导致上游数据积压下游处理不过来但作业状态依然是 RUNNING。这两种情况的处理思路完全不同一个要调整检查点参数或检查状态后端一个要调并发或优化算子逻辑。Slot 不够的报错通常写得很明确类似 not enough free slots但也有些场景是 JobManager 在等待资源管理器分配新的 TaskManager这时候会出现超时类日志。如果是会话模式可以临时扩容集群解决如果是单作业模式扩容和作业启动是一体的要在申请资源前把参数调好。还有一种容易被忽略的情况作业提交成功了但个别并行子任务长时间没有数据。这往往不是架构问题而是数据源的分区策略导致的。比如外部系统的数据分布不均匀某些分区数据量极少对应子任务自然没有数据。这时候调整分发策略或者给 source 设置合适的并行度比反复重启作业更有效。5.3 速查表症状、可能原因与检查点下面这张表整理了我运维中遇到频率最高的几个问题希望对你有用。症状可能原因第一个检查点作业提交后一直 PENDING/启动中Slot 不足查看集群剩余 Slot 与作业并行度任务反复失败重启代码异常或检查点超时看 TaskManager 异常堆栈确认是否与状态相关检查点一直失败状态太大或持久化存储慢观察状态大小和存储写入延迟某些子任务数据量明显偏高数据倾斜在 UI 上看子任务的记录数和背压状态容器被强杀内存超限核对容器规格与 Flink 内存相关参数延迟突然飙升背压或 GC查 GC 日志和算子的背压指示这张表不能覆盖所有场景但能帮你快速建立一个排查框架。遇到表里没有的问题也可以套用同样的思路先分层看是哪个角色的问题再缩小到具体报错最后结合指标验证猜测。6. 最后分享一点个人经验从不懂架构的新手到能够独立处置线上问题我走过不少弯路。最大的一条教训是遇到问题就去查具体参数而不先想清楚问题出在哪一层。架构知识的价值就在这里它让你有一个坐标系遇到报错能快速定位是客户端、调度层还是执行层的问题。另一个习惯是多看作业的执行图在 UI 里把算子链、子任务、分区数和实际日志对应起来比死记硬背任何文档都管用。