资讯动态

Spark执行流程图:从spark-submit到Task执行的全链路解析

发布时间:2026/10/15 17:04:20 来源:尧图企业网站定制
1. 为什么一张执行流程图成了Spark入门者最该死磕的“第一道墙”刚带完某高校大数据实训课有个现象特别扎眼十个人里有七个人能背出“RDD是弹性分布式数据集”“宽依赖窄依赖”这些定义但一问“提交一个wordcount任务后代码到底在集群上经历了什么”立刻卡壳。有人掏出手机翻笔记有人盯着IDE里那行spark-submit --master yarn --class WordCount ...发愣还有人脱口而出“不就是Driver分发Task到Executor嘛”——可Driver怎么知道该分发多少TaskTask又凭什么被调度到某台机器上Shuffle阶段的数据到底存在哪谁负责清理这些问题光靠背概念根本答不上来。这背后不是记性差而是缺一张真正“能动起来”的执行流程图。市面上很多资料画的图要么是抽象的UML时序图满屏箭头指向一堆叫“SchedulerBackend”“DAGScheduler”的黑盒子要么是静态的组件框图把Driver、Executor、Cluster Manager并排一摆仿佛它们天生就该待在那里。结果就是学完像雾里看花知道有这些角色但不知道它们在真实任务中如何咬合、协作、甚至互相拉扯。我试过让学员自己手动画流程结果交上来的图里80%把Stage划分逻辑画错了——把map()和filter()这种窄依赖操作硬生生切成了两个Stage或者把reduceByKey()之后的Shuffle Write和Shuffle Read画成两段完全独立的线中间断开。这说明问题不在绘图工具而在对“物理执行单元如何由逻辑DAG生成”这个核心链条的理解是断裂的。这张图之所以难是因为它横跨三层最上层是开发者写的Scala/Python代码逻辑层中间是Spark Core内部的调度决策调度层最下层是JVM进程、线程、网络IO、磁盘文件的真实运作物理层。而绝大多数教程只讲第一层和第三层把第二层这个最关键的“翻译官”给跳过去了。今天这篇我们就从spark-submit敲下回车的那一刻开始一帧一帧拆解这张图里每一个箭头背后的血肉——不是告诉你“它叫什么”而是告诉你“它为什么必须这么走”。提示本文所有流程描述均基于Spark 3.5稳定版源码逻辑不涉及YARN/K8s等资源管理器的具体实现细节聚焦Spark Core自身调度与执行机制。你不需要会看源码但需要愿意跟着节奏把每个组件的动作想成“一个活人在干活”。2. 从submit到Driver启动那个被忽略的“预热三秒”很多人以为spark-submit命令一执行Driver进程就立刻开始解析代码了。其实在Driver JVM真正加载用户类之前Spark已经默默完成了三件关键的事而这三件事直接决定了后续整个流程的走向。2.1 第一步参数解析与部署模式绑定spark-submit本质是个Shell脚本封装器。它首先做的是把命令行参数--master yarn、--deploy-mode client、--driver-memory 4g解析成一个SparkConf对象。这个对象不是简单的键值对集合而是一个带优先级的配置仓库。比如如果你在代码里写了conf.set(spark.serializer, org.apache.spark.serializer.KryoSerializer)但同时又在命令行加了--conf spark.serializerorg.apache.spark.serializer.JavaSerializer那么后者会覆盖前者——因为命令行参数的优先级高于代码内设置。更关键的是--master参数。它在这里被转换成一个SparkDeployMode枚举值并触发MasterURLParser去识别部署环境。yarn、k8s、local[*]这些字符串会被映射到不同的ClusterManager实现类。比如yarn对应YarnClusterManager而local[*]则直接走LocalClusterManager。这个选择不是终点而是起点它决定了接下来Driver要跟谁“打招呼”也决定了后续Task最终跑在哪种容器里。2.2 第二步Driver进程的“双面人格”初始化当JVM启动Main类通常是org.apache.spark.deploy.SparkSubmit被加载后真正的Driver进程才诞生。但此时的Driver身兼两职用户代码执行者加载你写的WordCount.scala运行main()方法构建RDD lineage。集群协调者创建SparkContext进而初始化DAGScheduler、TaskScheduler、HeartbeatReceiver等一系列核心调度组件。这两重身份在启动瞬间就产生了张力。比如SparkContext构造函数里有一行关键代码this.scheduler TaskSchedulerImpl.createTaskScheduler(this, master)。注意这里的master参数正是前面解析出来的yarn或local。TaskSchedulerImpl会根据这个值决定自己该用哪个SchedulerBackend——如果是local就用LocalSchedulerBackend它内部直接用线程池模拟Executor如果是yarn就用YarnClientSchedulerBackend它会立刻向YARN ResourceManager发起RPC请求申请一个Container来启动真正的Executor进程。这个初始化过程耗时极短但却是整个流程的“奇点”。一旦TaskScheduler和SchedulerBackend握手成功Driver就从“单机程序”正式蜕变为“集群大脑”。2.3 第三步Application注册与心跳通道建立SchedulerBackend启动后做的第一件事不是分配Task而是向集群管理器注册当前Application。以YARN为例YarnClientSchedulerBackend会调用YarnClient的submitApplication()方法提交一个ApplicationSubmissionContext。这个Context里包含了Application Name、队列名、AMApplicationMaster的启动命令其实就是Driver进程的完整JVM参数、所需资源vCores、Memory等。注册成功后YARN会为这个Application分配一个Container并在其中启动AM进程——而这个AM就是Driver本身。此时Driver除了运行你的业务代码还多了一个新任务定期向ResourceManager发送心跳Heartbeat。这个心跳包里不仅包含“我还活着”的信号还携带了当前已分配的Executor列表、各Executor的资源使用率、以及等待调度的Task数量。ResourceManager正是靠这个心跳动态调整资源分配策略。注意在client模式下Driver进程运行在提交命令的客户端机器上AM只是个轻量级代理而在cluster模式下Driver进程和AM合二为一都运行在YARN分配的Container里。这个区别直接影响日志查看方式和故障排查路径——client模式的日志在本地终端输出cluster模式则必须通过YARN Web UI或yarn logs -applicationId命令获取。3. DAG生成与Stage划分逻辑图如何变成物理执行单元当你的main()方法执行到sc.textFile(hdfs://...).flatMap(...).map(...).reduceByKey(...).collect()这一行时Spark并没有立刻去读HDFS。它只是在内存里用RDD对象链记录下这一系列转换操作Transformation的依赖关系。这个链就是DAGDirected Acyclic Graph的雏形。但此时的DAG还只是“纸面上的蓝图”。让它落地为可执行的物理单元需要一次关键的“编译”动作。3.1 从Action触发为什么collect()是真正的“发令枪”textFile()、map()、filter()这些操作都是惰性求值Lazy Evaluation。它们只是在构建RDD的dependencies和partitioner字段不触发任何实际计算。只有当你调用collect()、count()、saveAsTextFile()这类Action操作时DAGScheduler才会被真正唤醒。collect()的源码里核心逻辑是runJob(rdd, (iter: Iterator[T]) iter.toArray)。这个runJob方法就是DAG调度的总入口。它接收一个RDD和一个函数但并不立即执行函数而是先调用DAGScheduler.submitJob()把任务“扔”给调度器。这里有个精妙的设计submitJob()方法内部会先调用getMissingParentStages()递归地找出当前RDD的所有上游Stage。为什么是“上游”因为Spark的执行是自底向上的。比如reduceByKey()的结果RDD它的上游Stage就是产生其输入数据的那个Stage。getMissingParentStages()会一直向上追溯直到找到那些没有父Stage的RDD——通常是HadoopRDD对应HDFS文件或ParallelCollectionRDD对应sc.parallelize()。这些“源头RDD”所在的Stage就是整个DAG的叶子节点也是最先被执行的。3.2 Stage划分的核心规则窄依赖与宽依赖的生死线getMissingParentStages()的递归过程本质上是在做一件大事按Shuffle边界切分Stage。而判断是否需要Shuffle的唯一标准就是RDD之间的依赖类型。窄依赖Narrow Dependency父RDD的一个分区只被子RDD的一个分区所依赖。典型如map()、filter()、union()。这种依赖可以链式执行无需跨节点传输数据因此属于同一个Stage。宽依赖Wide Dependency父RDD的一个分区可能被子RDD的多个分区所依赖。典型如groupByKey()、reduceByKey()、join()。这种依赖必然引发Shuffle数据需要重新分区、网络传输、磁盘落盘因此是Stage的天然分界线。我们用一个具体例子说明。假设代码是val lines sc.textFile(hdfs://data.txt) val words lines.flatMap(_.split( )) val pairs words.map(word (word, 1)) val counts pairs.reduceByKey(_ _) counts.collect()DAGScheduler会这样切分lines-wordsflatMap是窄依赖同Stage。words-pairsmap是窄依赖同Stage。pairs-countsreduceByKey是宽依赖此处切一刀pairs及其上游构成Stage 0counts构成Stage 1。Stage 0的Task会在Executor上直接执行textFile-flatMap-map这一整条链Stage 1的Task则负责从Stage 0的输出中拉取属于自己分区的数据再做reduce。这个切分逻辑是Spark能实现“流水线优化”Pipeline Optimization的基础——Stage内部的窄依赖操作会被合并成一个Task避免了中间结果的序列化/反序列化开销。3.3 ShuffleMapStage与ResultStage两种Stage的本质差异切分出来的Stage分为两类ShuffleMapStage所有非最后一个Stage。它的核心任务是执行计算并将结果按Key进行分区Partition写入本地磁盘或内存取决于spark.shuffle.spill配置生成MapStatus。MapStatus里记录了该Stage每个Task输出的分区数据存放在哪台Executor的哪个BlockManager上大小是多少。这些信息会被DAGScheduler收集起来作为下一个Stage的输入依据。ResultStageDAG的最后一个Stage对应最终的Action操作如collect()。它的Task不产生Shuffle输出而是直接将计算结果返回给Driver。比如collect()的ResultStage其Task会把本分区的计算结果序列化通过网络发回DriverDriver再在内存里把所有分区的结果聚合起来返回给用户代码。理解这个差异至关重要。很多性能问题源于混淆了二者职责。例如有人试图在ResultStage里做大量计算期望“快点拿到结果”却忘了ResultStage的Task是单线程执行的且结果要全量回传极易成为瓶颈。正确的做法是把尽可能多的计算压到ShuffleMapStage里完成让ResultStage只做轻量聚合。实操心得用spark.ui.enabledtrue打开Web UI在Stages页面你可以清晰看到每个Stage的类型ShuffleMap or Result、Task数量、输入/输出数据量。如果发现某个ShuffleMapStage的“Shuffle Write”数据量远大于“Shuffle Read”说明上游数据倾斜严重如果ResultStage的“Duration”异常长大概率是collect()把海量数据拉回了Driver内存应该改用take(n)或saveAsTextFile()。4. Task调度与执行从“派单”到“干活”的微观现场当DAGScheduler确认所有上游Stage都已完成它会为当前Stage比如上面的Stage 1创建一批Task对象并调用TaskScheduler.submitTasks()把这批Task“派单”出去。但这仅仅是调度的开始真正的“干活”发生在Executor进程内部。这个过程充满了资源竞争、状态同步和容错重试。4.1 TaskSetManagerDriver端的“车间主任”TaskSchedulerImpl收到submitTasks()请求后会创建一个TaskSetManager实例来专门管理这一批属于同一个Stage的Task。TaskSetManager不是简单地把Task塞进队列它要干三件关键的事Locality Level匹配Spark追求“数据本地性”Data Locality即Task尽量在数据所在的节点上执行避免网络传输。TaskSetManager会根据每个Task需要处理的Split如HDFS的Block位置查询BlockManagerMaster获取该Split所在节点的IP。然后它会按优先级尝试将Task调度到PROCESS_LOCAL同一JVM仅local模式NODE_LOCAL同一节点最理想NO_PREF无偏好RACK_LOCAL同一机架ANY任意节点最后选择Task失败重试如果一个Task在Executor上执行失败比如OOM、网络超时TaskSetManager不会立刻放弃。它会根据spark.task.maxFailures默认4的配置对该Task进行重试。重试时会降级Locality Level比如从NODE_LOCAL降到RACK_LOCAL并尝试调度到其他可用的Executor上。Speculative Execution推测执行对于拖慢整体进度的“慢Task”TaskSetManager会启动一个“影子Task”在另一台空闲Executor上并行执行同一个Task。哪个先完成就采用哪个的结果后完成的会被Kill掉。这需要开启spark.speculationtrue并配置合理的阈值如spark.speculation.interval100ms。4.2 Executor端的“流水线工人”CoarseGrainedExecutorBackend当TaskScheduler通过SchedulerBackend如YarnSchedulerBackend把Task发给某个Executor时真正接收并执行它的是CoarseGrainedExecutorBackend。这个名字里的“CoarseGrained”粗粒度很关键它意味着Executor不是为单个Task服务的而是作为一个长期运行的JVM进程持续接收、执行、汇报多个Task。CoarseGrainedExecutorBackend内部有一个Executor对象注意大小写这是另一个类它维护着一个ThreadPool线程池。每当收到一个LaunchTask消息Executor就会从线程池里捞一个空闲线程用这个线程去执行Task。Task的执行逻辑被封装在一个TaskRunner对象里。TaskRunner.run()方法是整个计算的“心脏”。它会反序列化Task对象包括闭包、广播变量。调用Task.execute()进入具体的计算逻辑如ShuffleMapTask.runTask()或ResultTask.runTask()。将执行结果TaskResult序列化并通过NettyBlockTransferService发回Driver。这个设计带来了巨大优势Task的启动开销JVM进程创建被摊薄到无数次Task执行上线程复用避免了频繁创建销毁线程的代价。但这也意味着一个Task的Bug比如死循环、内存泄漏可能拖垮整个Executor进程影响其上所有Task。4.3 Shuffle的“暗流涌动”MapOutputTracker与BlockManagerShuffle是Spark最复杂的环节而MapOutputTracker和BlockManager是它的两大支柱。MapOutputTracker它是一个分布式元数据服务。当ShuffleMapStage的Task执行完毕会向MapOutputTrackerMasterEndpoint运行在Driver上注册自己的MapStatus。MapStatus里最关键的信息是blockManagerId本Task所在Executor的ID和mapStatus数组每个分区的Block ID和大小。下游Stage的Task在执行前会先向MapOutputTracker查询“我要的第3号分区的数据现在在谁那里”。MapOutputTracker会返回一个MapStatus列表告诉它去哪几个Executor上拉取数据。BlockManager它是Executor和Driver上都存在的“本地缓存管家”。每个Executor的BlockManager管理着三块内存Storage Memory用于缓存RDDpersist()。Execution Memory用于Shuffle、Join、Aggregation等计算过程中的临时数据。Unroll Memory用于反序列化大对象。当ShuffleMapTask写入数据时它会调用BlockManager.putBytes()把分区数据写入Execution Memory。如果内存不够就会触发spill把数据溢写Spill到本地磁盘的shuffle目录下。下游Task来拉取时BlockManager会智能地从内存或磁盘读取再通过Netty网络服务发送出去。踩坑实录某次线上任务Shuffle阶段大量Task卡在fetching shuffle data状态。查日志发现BlockManager报错Failed to fetch block from ... due to java.io.IOException: Connection reset by peer。排查后发现是Executor的Execution Memory配置过小spark.executor.memory2g而Shuffle数据量巨大导致频繁Spill磁盘IO打满网络连接超时。解决方案是将spark.executor.memory调至8g并显式设置spark.shuffle.memoryFraction0.6确保Shuffle有足够内存缓冲。5. 容错与血统当一个Executor宕机Spark如何“起死回生”分布式系统最大的敌人不是性能而是不确定性。网络抖动、磁盘损坏、进程崩溃这些在单机上罕见的问题在集群里每天都在发生。Spark的容错机制不是靠“永不宕机”而是靠“宕机了也能续上”。其核心就是RDD的不可变性和Lineage血统。5.1 血统Lineage一份永不丢失的“施工图纸”每个RDD对象都有一个dependencies字段它是一个Seq[Dependency[_]]。这个序列精确地记录了该RDD是如何从上游RDD一步步转换Transformation而来的。比如pairsRDD的dependencies会指向wordsRDDwords的dependencies会指向linesRDD。而linesRDD的dependencies则为空Nil因为它直接读取外部存储HDFS。这份Lineage是只读的、不可变的。它不存储任何实际数据只存储“怎么做”的指令。因此它极其轻量可以安全地在Driver内存里长久保存。当一个Executor宕机它上面正在运行的所有Task都会失败。TaskSetManager检测到失败后会立刻向DAGScheduler报告。DAGScheduler的反应是不慌我有图纸。它会检查失败的Task属于哪个Stage然后检查该Stage的上游Stage是否都已完成。如果上游Stage都OK那么它只需要重新提交这个失败的Task或整个Stage让TaskScheduler把它调度到其他健康的Executor上重新执行Lineage里记录的那一系列操作即可。5.2 Checkpoint给血统链装上“保险丝”Lineage虽然轻量但也有弱点如果DAG太长Lineage链就会非常深。比如一个ETL任务经过了50次map()、filter()、join()那么任何一个中间RDD的丢失都可能导致从头开始计算50步。这在生产环境中是不可接受的。Checkpoint机制就是为了解决这个问题。当你调用rdd.checkpoint()时Spark会做两件事将该RDD的计算结果强制物化Materialize到一个可靠的、容错的存储系统中如HDFS、S3。将该RDD的dependencies置为空Nil并将其isCheckpointed标志设为true。从此以后这个RDD就不再依赖上游它自己就是一个新的“数据源头”。如果后续计算中它丢失了Spark不再回溯Lineage而是直接从HDFS上读取checkpoint文件。关键细节checkpoint()方法本身是惰性的它只是标记。真正的物化动作发生在下一个Action触发时。而且checkpoint目录必须是全局可访问的如hdfs://namenode:8020/checkpoint/不能是本地路径。否则其他Executor无法读取。5.3 检查点与血统的协同一个真实的恢复场景我们用一个具体场景把Lineage和Checkpoint串起来假设一个风控模型训练任务流程是rawData sc.textFile(hdfs://raw/)cleaned rawData.map(parse).filter(notNull)features cleaned.map(extractFeatures)features.checkpoint()// 在这里打一个检查点model train(features)正常情况下features.checkpoint()会在train()触发时把featuresRDD写入HDFS。此时features的dependencies被清空。但如果在train()执行到一半时Driver进程意外退出了。当用户重启Driver重新运行代码sc.textFile()会重新读取原始数据map和filter会重新执行但当执行到features.checkpoint()时Spark会发现HDFS上已经存在对应的checkpoint文件。于是它会直接从HDFS加载features跳过前面所有计算步骤直接进入train()。这就是Checkpoint带来的“断点续训”能力。而如果没有这个checkpointDriver重启后就必须从sc.textFile()开始重新走一遍全部50步计算耗时可能从几分钟变成几小时。经验技巧Checkpoint不是越多越好。它会产生额外的HDFS写入开销和存储成本。最佳实践是在DAG的关键分叉点、或计算代价极高的Stage之后设置一个checkpoint。比如在做完join代价高之后或在特征工程完成之后数据量大再调用checkpoint()。6. 执行流程图的终极形态一张图三个视角现在我们可以把前面所有环节浓缩成一张真正“能动起来”的执行流程图。但请注意这张图的价值不在于它画得有多漂亮而在于它能从三个不同视角帮你定位问题。6.1 视角一时间轴视角What Happens When这是最基础的视角按时间顺序排列关键事件spark-submit启动 Driver JVM。Driver 初始化SparkContext连接集群管理器注册 Application。用户代码执行构建 RDD Lineage。Action如collect()触发DAGScheduler生成 Stage。TaskScheduler为每个 Stage 创建TaskSetManager开始调度 Task。SchedulerBackend将 Task 发送给 Executor。Executor 的CoarseGrainedExecutorBackend启动线程执行TaskRunner。ShuffleMapTask 写入数据到BlockManager注册MapStatus到MapOutputTracker。ResultTask 从MapOutputTracker查询位置从各 Executor 的BlockManager拉取数据。ResultTask 将结果发回 DriverDriver 聚合后返回给用户。这个时间轴是排查“任务卡在哪儿了”的第一把尺子。比如如果Web UI显示Stage一直在“Pending”问题大概率出在1-5步资源申请、DAG生成如果Stage显示“Running”但Task数量始终是0问题可能在6-7步网络不通、Executor未启动。6.2 视角二组件交互视角Who Talks To Whom这张图聚焦于核心组件间的通信Driver ↔ Cluster Manager通过SchedulerBackend进行Application注册、资源申请、心跳。Driver ↔ Executor通过CoarseGrainedSchedulerBackend和CoarseGrainedExecutorBackend进行Task分发、状态汇报、结果接收。Driver ↔ MapOutputTrackerDAGScheduler向MapOutputTrackerMasterEndpoint注册MapStatusResultTask向MapOutputTracker查询数据位置。Executor ↔ BlockManager每个Executor内部TaskRunner与本地BlockManager交互进行数据读写。Executor ↔ ExecutorResultTask通过NettyBlockTransferService直接与其他Executor的BlockManager通信拉取Shuffle数据。这个视角是排查“为什么连不上”、“为什么找不到数据”的利器。比如java.net.ConnectException: Connection refused错误结合此图就能快速锁定是Driver连不上YARN RMDriver↔CM还是Executor连不上Driver的MapOutputTrackerExecutor↔Driver或是两个Executor之间网络不通Executor↔Executor。6.3 视角三数据流视角Where Does Data Flow这是最贴近物理现实的视角追踪数据的实体移动Input Data从HDFS/S3等外部存储通过HadoopRDD.compute()被读入Executor内存。Intermediate Data在Stage内部通过窄依赖链式传递不落盘如map的输出直接喂给filter。Shuffle Data在Stage边界由ShuffleMapTask写入本地磁盘/tmp/spark-*/shuffle/再由ResultTask通过网络拉取。Output DataResultTask的最终结果序列化后通过网络发回Driver内存或由saveAsTextFile()等Action写入HDFS。这个视角是优化性能的黄金法则。比如Shuffle Data量过大就要考虑repartition()减少分区数或用mapPartitions()替代map()减少序列化开销Output Data回传Driver导致OOM就要禁用collect()改用foreachPartition()做分布式写入。最后分享一个小技巧在开发调试阶段不要迷信Web UI的“Summary”页。一定要点开每个Stage看它的“Details”页重点关注“Input Size / Records”、“Shuffle Read / Write”、“GC Time”这三列。它们比任何文字描述都更能告诉你你的代码到底在干什么、瓶颈在哪里。我见过太多人只看“Duration”一个数字就武断地说“Spark慢”结果点开一看“Shuffle Write”只有1MB而“GC Time”占了Duration的90%——问题根本不在Spark而在你的map()函数里new了上万个大对象。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑