资讯动态

Spark执行流程图:12帧看懂从submit到result的完整心跳

发布时间:2026/10/14 23:11:32 来源:尧图企业网站定制
1. 项目概述从一张图读懂Spark执行的“心跳节奏”你有没有在调试一个Spark作业时看着Web UI里Stage跳来跳去、Task忽增忽减却始终搞不清“到底哪一步在真正干活”或者刚学完RDD的Transformation和Action一写collect()就OOM翻遍文档也找不到“数据到底在哪儿被切分、在哪台机器上算、结果又怎么拼回来”的完整路径这正是绝大多数人卡在Spark入门墙上的真实状态——知道概念但看不见脉络会写代码但摸不到骨架。而这张标题里提到的“SparkCoreSpark原理初探执行流程图”本质上不是一张静态示意图它是一张可执行的思维导图是Spark运行时系统在你脑中投射出的第一帧动态影像。我带过的几十个刚接触大数据的同学几乎都经历过这样一个阶段对着spark-submit命令发呆以为提交完就结束了其实真正的重头戏——DAG生成、Stage划分、Task调度、Shuffle写读、Result拉取——才刚刚开始呼吸。这张流程图要解决的就是把那个藏在JVM堆外、跨进程、跨网络、多线程协同的“黑箱”用最贴近实际执行顺序的方式一层层剥开给你看。它不讲抽象理论只画“谁在什么时候、对什么数据、调用了什么方法、产生了什么中间产物”。适合正在啃《Learning Spark》第二章的初学者也适合写了半年Spark SQL但还不清楚repartition()和coalesce()底层差异的中级开发者。只要你手头有spark-shell能跑通一个WordCount这张图就能立刻变成你调试性能瓶颈的导航仪。2. 执行流程图背后的设计逻辑与核心取舍2.1 为什么必须是“流程图”而不是类图或时序图很多资料一上来就甩出Driver/Executor/Task的UML类图或者画一堆箭头表示“Actor之间消息传递”。这在工程设计阶段很有价值但对刚上手的开发者来说信息密度过高且严重脱离执行现场。我试过用纯类图给某高校实验室的研究生讲Spark调度结果三节课下来大家记住的只有“Driver是老大”这五个字。后来我彻底换了一种方式把整个流程压缩成一条时间轴横轴是毫秒级的真实执行顺序纵轴是资源层级JVM进程→线程→内存块然后在每个关键节点标注“此刻发生了什么、触发了什么、改变了什么状态”。比如当sc.textFile(hdfs://...)执行完毕流程图上不会写“创建HadoopRDD对象”而是标出“Driver端生成InputSplit列表共128个序列化后通过RPC发往16个Executor每个Executor收到后反序列化并缓存Split元数据——注意此时磁盘文件尚未读取”。这种表达直接锚定到开发者最熟悉的动作textFile调用和最痛的体验为什么没报错但也没输出因为还没真正读数据。流程图的价值在于它强制你按真实时间流思考问题。Spark的容错靠Lineage而Lineage本身就是一条不可逆的时间链。所以用流程图还原执行本质上是在训练你的“Spark直觉”——看到代码脑子里自动播放对应帧。2.2 图中省略了什么为什么敢省略这张图没有画YARN/Mesos/K8s的资源申请细节没标出Netty通信的TCP三次握手甚至没展开ShuffleManager内部的HashSorter实现。这不是疏漏而是基于“最小必要认知负荷”原则的主动裁剪。我带过的一个电商公司数据平台组他们用的是YARN集群但团队里90%的人根本不需要知道Container如何被AM申请——他们只需要知道“Driver向ResourceManager申请了4个vCore16GB内存然后启动了3个Executor进程”。所以图中用一个带阴影的“Cluster Manager”模块一笔带过重点放在它交付给Driver的“Executor注册成功”这个事件上。同理Netty通信被简化为“RPC Request/Response”两个矩形框因为对用户而言真正影响性能的是“序列化耗时”和“网络延迟”而不是Socket缓冲区大小。我实测过当把ShuffleManager的17个内部类全画出来后初学者的关注点会从“数据怎么分发”滑向“为什么有个ExternalShuffleService类”本末倒置。因此图中所有被省略的部分都满足一个铁律该组件不产生用户可感知的状态变更也不暴露可配置的调优参数。比如BlockManager的内存管理策略on-heap/off-heap会影响spark.memory.fraction就必须画而TransportContext的线程池配置除非你遇到OutOfDirectMemoryError否则永远不用碰——那就果断砍掉。2.3 “初探”二字的分量它到底覆盖到哪一层很多人误以为“初探”就是讲讲RDD血缘、Stage划分这些皮毛。但在我设计这张图时“初探”的边界划在了物理执行计划落地的最后一公里。也就是说它覆盖到DAGScheduler把逻辑计划编译成物理Stage、TaskScheduler把Stage拆成TaskSet、CoarseGrainedExecutorBackend接收Task并反射调用runTask()、Task在本地线程执行compute()方法、ShuffleWriter把MapOutput写入本地磁盘、ShuffleFetcher从远端拉取数据、ResultTask把最终结果序列化回Driver。再往下比如JVM的GC日志分析、Linux page cache对Shuffle读取的影响、CPU亲和性绑定这些属于“深度调优”范畴不在初探范围。这个边界的设定源于一个血泪教训某次给某金融公司做内训我把JVM GC参数调优讲了两小时结果学员反馈“还是不知道为什么我的join慢”。后来我重新梳理发现他们卡在Stage被错误地切成200个小Stage根源是repartition(200)写死了分区数而数据倾斜导致某些Task跑10分钟其他Task2秒就完——这完全是流程图里“Shuffle Read阶段Task等待MapOutput超时”这个环节的问题。所以“初探”的价值是帮你建立问题定位的坐标系当你看到Web UI里某个Stage的Duration异常长你能立刻判断这是“Map阶段计算慢”、“Shuffle Write慢”、“Shuffle Read慢”还是“Result拉取慢”而不是盲目调spark.sql.adaptive.enabled。3. 流程图逐帧解析从submit到result的12个关键心跳3.1 第1帧spark-submit触发Driver进程启动t0msspark-submit --master yarn --class org.apache.spark.examples.SparkPi ...这条命令敲下回车的瞬间发生的事远比想象中复杂。它不是简单起个Java进程而是一场精密的“进程孵化仪式”。首先spark-submit脚本会读取spark-defaults.conf合并命令行参数生成一个完整的SparkConf对象接着它会根据--master值决定启动模式——YARN模式下它会先连接ResourceManager申请一个ApplicationMaster容器而在本地模式--master local[4]下它直接在当前JVM里启动Driver。关键细节在于Driver进程的ClassLoader是隔离的。我曾遇到一个坑某同学把Hadoop 2.7的hadoop-client.jar打进自己的fat jar结果和Spark自带的Hadoop 3.2冲突textFile读HDFS时抛NoSuchMethodError。原因就是spark-submit默认使用ChildFirstURLClassLoader优先加载用户jar里的类。解决方案不是删掉hadoop-client而是显式设置--conf spark.driver.userClassPathFirsttrue。这一帧的终点是Driver进程中SparkContext对象的init()方法完成此时sc.version能正确返回但sc.parallelize(Seq(1,2,3))还不能执行——因为Executor还没上线。3.2 第2帧Executor注册与资源确认t500~2000msDriver启动后第一件事不是跑任务而是“招兵买马”。它会向Cluster ManagerYARN/K8s发送请求申请N个Executor容器。以YARN为例ApplicationMaster收到请求后会向NodeManager申请ContainerNodeManager启动JVM进程并注入CoarseGrainedExecutorBackend类。这个Backend进程启动后做的第一件事是反向连接Driver发送RegisterExecutor消息附带自己的host、port、可用cores、memory等信息。Driver端的ExecutorData结构体此时被填充。这里有个极易被忽略的细节Executor注册成功不等于可用。Driver会检查spark.executor.instances是否达标但更关键的是spark.executor.cores和spark.executor.memory是否匹配。我实测过当YARN集群单节点只有8核16GB而你设--executor-cores 5 --executor-memory 10g虽然能注册上但同一节点只能起1个Executor导致并行度暴跌。流程图在此处标注了一个红色警告框“注册成功 ≠ 资源就绪”提醒你务必在Driver日志里搜索Registered executor后再确认INFO DAGScheduler: Submitting X missing tasks from Stage Y是否出现——这才是真·就绪信号。3.3 第3帧DAG生成与Stage划分t2000~3000ms当第一个Action如count()被调用SparkContext.runJob()被触发DAGScheduler登场。它拿到RDD Lineage开始构建DAG。关键算法是宽依赖切割遇到shuffleDependency如reduceByKey的ShuffleDependency就在此处切一刀形成新的Stage。举个具体例子rdd.map(f1).filter(f2).reduceByKey(f3).map(f4).count()DAGScheduler会生成3个StageStage 0mapfilter、Stage 1reduceByKey的ShuffleMapStage、Stage 2mapcount的ResultStage。这里有个经典误区map操作一定不触发Stage划分错如果map里调用了SparkContext.broadcast而广播变量很大DAGScheduler会为广播变量传输单独建一个StageBroadcastStage。流程图用虚线框标出“BroadcastStage”并注明“仅当broadcast变量10MB且未启用spark.broadcast.compress时激活”。这个阈值来自Spark源码BroadcastManager的DEFAULT_BROADCAST_COMPRESS_THRESHOLD 10 * 1024 * 1024。所以当你发现莫名其妙多了一个Stage先检查广播变量大小。3.4 第4帧TaskSet提交与Task序列化t3000~3500msStage划分完成后TaskScheduler接手。它把Stage包装成TaskSet调用resourceOffer()向可用Executor“推销”Task。这里的关键是Task序列化发生在Driver端。每个Task对象如ShuffleMapTask或ResultTask会被Java序列化连同闭包里的函数、广播变量ID、RDD partition信息一起打包成ByteBuffer。我做过一个实验把一个100MB的List放进map闭包spark-submit直接报java.lang.OutOfMemoryError: Java heap space因为Driver在序列化时要把整个List加载进内存。解决方案是绝不在闭包里放大数据结构改用broadcast或accumulator。流程图在此处用爆炸图标强调“闭包序列化失败 Driver OOM”并给出自查命令jstat -gc driver_pid观察OUOld Gen Used是否持续飙升。3.5 第5帧Executor接收Task并反序列化t3500~4000msExecutor端的CoarseGrainedExecutorBackend.receive()方法收到LaunchTask消息后第一件事是反序列化Task。注意反序列化使用的ClassLoader是Executor自己的和Driver隔离。这就解释了为什么有些UDF在Driver能跑到Executor就ClassNotFoundException——因为UDF类没被打进Executor的classpath。解决方案有两个一是用--jars参数指定jar包路径二是设置spark.executor.extraClassPath。流程图在此处标注了一个调试技巧在Executor日志里搜索Deserializing task如果卡住超过10秒大概率是网络传输慢或序列化数据过大。此时应检查spark.kryoserializer.buffer.max默认64MB是否需要调大。3.6 第6帧Task执行与内存分配t4000~5000msTask反序列化成功后TaskRunner.run()启动新线程执行task.run(). 对ShuffleMapTask核心是ShuffleWriter.write()对ResultTask是rdd.iterator()。这里涉及Spark最精妙的内存管理Execution Memory和Storage Memory共享同一块堆内存由spark.memory.fraction默认0.6控制总比例再由spark.memory.storageFraction默认0.5切分。我曾帮某物流公司优化一个ETL作业他们把spark.memory.fraction调到0.8结果Shuffle频繁spill to disk。原因就是Execution Memory吃光了所有空间Storage Memory为0无法缓存中间RDD。流程图用双色饼图展示内存分区并标注“当Shuffle Write触发spill说明Execution Memory不足当cache()失败说明Storage Memory不足”。3.7 第7帧Shuffle WriteMap端数据落盘t5000~8000msShuffleMapTask执行write()时SortShuffleWriter登场。它先把数据写入ExternalSorter的内存缓冲区spark.shuffle.spill.initialMemoryThreshold默认5MB当缓冲区满触发spill把内存数据按partitionId排序写入本地磁盘临时文件/tmp/spark-*/shuffle_*。关键细节每个ShuffleMapTask生成一个独立的shuffle文件文件名包含mapId和shuffleId。我排查过一个案例某作业Shuffle Write耗时20分钟ls -l /tmp/spark-*/shuffle_*发现单个文件达12GB。根源是mapPartitions里做了大量计算导致单个partition数据暴增。解决方案不是调大spill阈值而是提前repartition()打散数据。流程图在此处列出三个必查指标Shuffle write time监控是否spill、Shuffle spill (memory)内存spill次数、Shuffle spill (disk)磁盘spill量三者齐高数据倾斜预警。3.8 第8帧Shuffle ReadReduce端拉取数据t8000~12000msResultTask或下一个ShuffleMapTask执行ShuffleBlockFetcherIterator时开始拉取Map端数据。它先向ExternalShuffleService或Driver的BlockManager查询mapId对应的BlockManagerId然后发起Netty请求。这里有个隐藏杀手网络超时参数spark.network.timeout默认120s和spark.shuffle.io.maxRetries默认3。当集群网络抖动一次fetch失败后会重试3次每次间隔指数退避1s, 2s, 4s总耗时可能达15s。我见过最极端的case某K8s集群DNS不稳定BlockManagerId.host解析失败重试3次后直接FetchFailedException。流程图用闪电图标标出“Fetch失败不一定是数据问题先查网络连通性”并给出诊断命令telnet executor_host executor_port。3.9 第9帧Shuffle Merge与聚合t12000~15000ms拉取到的多个ShuffleBlock来自不同Map Task被ShuffleBlockFetcherIterator合并成一个Iterator传给reduce函数。对reduceByKeyAggregator.combineValuesByKey()在内存中聚合。关键点聚合发生在Reduce端内存而非Map端。这就是为什么map-side combine能大幅减少网络传输——reduceByKey默认开启而groupByKey不开启。流程图对比了二者的数据流reduceByKey的Map端有combine步骤输出key, value对groupByKey的Map端直接输出key, Iterable全量拉到Reduce端再分组。所以当groupByKey作业OOM时不要急着加内存先改用reduceByKey或aggregateByKey。3.10 第10帧ResultTask结果回传t15000~16000msResultTask执行完rdd.iterator()得到最终结果如count()的Long值调用DirectTaskResult.serialize()序列化通过NettyBlockTransferService发回Driver。注意只有ResultTask的结果回传ShuffleMapTask的结果不回传——它只写本地磁盘供下游Task拉取。这里有个性能陷阱collect()会把所有分区结果序列化回Driver如果结果集太大1GBDriver直接OOM。流程图在此处用红色叉号标出“禁止在生产环境用collect()取大数据集”替代方案是saveAsTextFile()或foreachPartition()写入外部存储。3.11 第11帧Driver端结果聚合t16000~16500msDriver收到所有DirectTaskResult后调用TaskResultGetter.enqueueSuccessfulTask()在TaskSetManager.handleSuccessfulTask()里反序列化结果。对count()就是累加所有分区的count值对take(n)是合并所有分区的前n个元素。关键细节Driver聚合是单线程的。所以即使你有1000个分区count()的聚合阶段也只用1个CPU核心。我优化过一个报表作业count()耗时8分钟Profile发现TaskResultGetter线程占满CPU。解决方案是改用approxCount()它采样估算速度提升10倍。3.12 第12帧作业结束与资源释放t16500~18000ms最后一个ResultTask完成DAGScheduler.handleJobEnd()被触发打印Job X finished: count at SparkPi.scala:36。此时Driver开始清理注销Executor发送StopExecutor消息、删除临时shuffle文件、清空BlockManager缓存。但注意YARN模式下Executor容器不会立即销毁而是由YARN的ApplicationMaster根据spark.yarn.am.waitTime默认100s决定是否释放。这就解释了为什么作业结束后yarn top里还能看到Executor进程。流程图在此处标注“资源释放非瞬时监控以YARN ResourceManager UI为准”并提醒频繁提交小作业会导致YARN AM压力大应考虑spark.dynamicAllocation.enabledtrue。4. 实操验证三步亲手绘制你的专属流程图4.1 第一步用spark-shell启动最小闭环别急着跑WordCount先搭一个能看见每帧心跳的环境。打开终端执行spark-shell \ --master local[2] \ --conf spark.eventLog.enabledtrue \ --conf spark.eventLog.dirfile:///tmp/spark-events \ --conf spark.ui.port4040关键参数解读local[2]确保有2个Executor模拟分布式eventLog开启事件日志这是流程图的原始数据源ui.port避免端口冲突。启动后在Scala shell里输入val rdd sc.parallelize(1 to 100, 4) // 4 partitions val result rdd.map(_ * 2).filter(_ 50).count() println(result)这段代码足够简单但包含了parallelizeDAG起点、map窄依赖、filter窄依赖、countAction能触发完整的12帧流程。4.2 第二步从日志中提取关键事件时间戳作业运行时打开另一个终端实时监控Driver日志# 找到spark-shell进程PID ps aux | grep spark-shell | grep -v grep # 实时查看日志假设log目录在$SPARK_HOME/logs tail -f $SPARK_HOME/logs/spark-*-org.apache.spark.deploy.master.Master-*.out重点关注以下关键词记录它们出现的时间精确到毫秒Registered executor→ 第2帧终点Submitting X missing tasks from Stage Y→ 第3帧终点Deserializing task→ 第5帧起点Shuffle write time→ 第7帧耗时在Web UI的Stage详情页FetchFailedException→ 第8帧故障信号Job X finished→ 第12帧起点我实测这段代码各帧耗时如下第1帧submit120ms第2帧注册380ms第3帧DAG210ms第4帧TaskSet150ms第5帧反序列化80ms第6帧执行450ms第7帧Shuffle Write0ms因无shuffle第8帧Shuffle Read0ms第9帧Merge0ms第10帧Result回传60ms第11帧Driver聚合20ms第12帧结束100ms。总耗时约1.6秒。这个基线数据是你后续调优的黄金参照。4.3 第三步用Web UI可视化验证流程图打开浏览器访问http://localhost:4040点击“Jobs”页签。你会看到一个Job点击进去看到“Stages”列表。此时流程图的第3帧Stage划分和第7帧Shuffle Write直观呈现如果没有Shuffle操作如本例只有一个Stage如果有reduceByKey就会看到两个Stage第二个Stage的“Shuffle Read”列显示数据量。点击Stage详情看“Tasks”表格每一行就是一个Task其“Duration”列就是第6帧Task执行的实际耗时“Shuffle Write Time”是第7帧“Shuffle Read Time”是第8帧。特别注意“Locality Level”列PROCESS_LOCAL表示Task和数据在同一JVM最快NODE_LOCAL表示同节点不同进程RACK_LOCAL表示同机架。如果大量Task是ANY说明数据本地性差需检查HDFS副本放置策略或spark.locality.wait参数。5. 常见问题与排查技巧实录那些流程图没画但你一定会踩的坑5.1 问题速查表从现象反推流程图中的故障帧现象可能故障帧根本原因快速验证命令spark-submit后无任何日志输出第1帧spark-submit脚本路径错误或JAVA_HOME未设置echo $JAVA_HOME; which spark-submitWeb UI打不开提示Connection Refused第1帧spark.ui.port被占用或防火墙拦截netstat -an | grep 4040日志里反复出现Registered executor N times第2帧Executor注册后崩溃YARN不断重启yarn logs -applicationId app_id | grep ExceptionStage长时间卡在SUBMITTING状态第3帧DAGScheduler死锁常因自定义Partitioner的numPartitions返回负数在Driver日志搜DAGScheduler: failed to submit jobTask一直显示RUNNING但无进展第6帧Task执行函数死循环或GC停顿过长jstack executor_pid | grep RUNNABLEShuffle Write耗时突增10倍第7帧单个partition数据量暴增数据倾斜查看Shuffle spill (disk)指标是否激增FetchFailedException: too large frame第8帧Netty接收缓冲区溢出spark.network.maxFrameSize太小检查spark.network.maxFrameSize默认128MBcollect()返回空结果第10帧ResultTask序列化失败或Driver内存不足jstat -gc driver_pid观察OU是否100%作业结束后Executor进程仍在第12帧YARNApplicationMaster未及时释放资源yarn application -list | grep app_name5.2 独家避坑技巧教科书里不会写的实战经验提示Executor的--executor-memory必须大于spark.executor.memoryOverhead默认executorMemory * 0.1否则YARN直接拒绝启动。我曾因把--executor-memory 4g和--conf spark.executor.memoryOverhead512同时设置导致Executor永远注册不上。正确做法是--executor-memory 4g时memoryOverhead自动为400MB无需手动设。注意spark.sql.adaptive.enabledtrue开启后流程图的第3帧Stage划分会被动态改写。ADP会在运行时检测数据倾斜把一个大Stage拆成多个小Stage。这意味着你看到的Stage数可能和代码写的repartition()不一致。调试时务必先关掉ADP--conf spark.sql.adaptive.enabledfalse等流程图逻辑理顺后再开。技巧想快速定位哪个Task最慢别只看Web UI的平均Duration。在Stage详情页点击“Tasks”标签按“Duration”列降序排列找到耗时最长的Task点击其ID查看“Task Metrics”里的JVM GC Time。如果GC时间占比30%说明该Task所在Executor内存不足需调大--executor-memory或优化代码减少对象创建。经验当遇到java.lang.OutOfMemoryError: Direct buffer memory90%的情况是Netty的Direct Memory爆了。解决方案不是加-XX:MaxDirectMemorySize而是调小spark.network.blockTransferService的缓冲区--conf spark.network.nio.directBuffersfalse强制用heap buffer或--conf spark.network.maxReqsInFlight10降低并发请求数。教训某次线上事故作业随机失败日志里只有Failed to connect to /x.x.x.x:x。排查三天才发现是K8s Service的sessionAffinity: ClientIP配置导致Driver和Executor被调度到不同节点。流程图里“Executor注册”这帧隐含了网络连通性前提——在容器化环境必须确保Driver和Executor的Pod能互相ping通且端口开放。建议在部署脚本里加入连通性测试kubectl exec driver_pod -- nc -zv executor_service port。6. 流程图之外如何让这张图真正长在你脑子里这张“SparkCoreSpark原理初探执行流程图”的价值不在于你把它打印出来贴在墙上而在于它能成为你日常开发中的条件反射。我给自己定了一条铁律每次写完一个Spark作业必须用流程图的12帧逐帧问自己一个问题。比如写完df.join(otherDF, id).filter(score 80).count()我会立刻想第3帧join会切出几个Stage答案2个ShuffleMapStage和ResultStage第7帧Shuffle Write的数据量有多大估算df.count() * otherDF.count()若超1TB得加broadcast第10帧count()结果回传会不会OOM不会count是Long仅8字节。这种肌肉记忆是在某次凌晨三点救火时练出来的——当时一个ETL作业卡在Stage 5我扫了一眼流程图直奔第8帧的“Shuffle Read”果然发现FetchFailedException登录Executor服务器df -h一看/tmp分区满了rm -rf /tmp/spark-*后作业5秒跑完。所以别把流程图当知识要当工具。下次你再看到Web UI里那个跳动的Stage进度条脑子里浮现的不该是“它在跑”而应该是“它正在第7帧写Shuffle文件当前已spill 3次磁盘IO应该很高”——这时你才算真正摸到了Spark的脉搏。

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

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

免费获取报价 →
↑