1. 项目概述:从一张图读懂Spark执行的“心跳节奏”
你有没有在调试一个Spark作业时,看着Web UI里Stage跳来跳去、Task忽增忽减,却始终搞不清“到底哪一步在真正干活”?或者刚学完RDD的Transformation和Action,一写collect()就OOM,翻遍文档也找不到“数据到底在哪儿被切分、在哪台机器上算、结果又怎么拼回来”的完整路径?这正是绝大多数人卡在Spark入门墙上的真实状态——知道概念,但看不见脉络;会写代码,但摸不到骨架。而这张标题里提到的“SparkCore:Spark原理初探(执行流程图)”,本质上不是一张静态示意图,它是一张可执行的思维导图,是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个vCore+16GB内存,然后启动了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进程启动(t=0ms)
spark-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.userClassPathFirst=true。这一帧的终点,是Driver进程中SparkContext对象的init()方法完成,此时sc.version能正确返回,但sc.parallelize(Seq(1,2,3))还不能执行——因为Executor还没上线。
3.2 第2帧:Executor注册与资源确认(t=500~2000ms)
Driver启动后,第一件事不是跑任务,而是“招兵买马”。它会向Cluster Manager(YARN/K8s)发送请求,申请N个Executor容器。以YARN为例,ApplicationMaster收到请求后,会向NodeManager申请Container,NodeManager启动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划分(t=2000~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个Stage:Stage 0(map+filter)、Stage 1(reduceByKey的ShuffleMapStage)、Stage 2(map+count的ResultStage)。这里有个经典误区:map操作一定不触发Stage划分?错!如果map里调用了SparkContext.broadcast,而广播变量很大,DAGScheduler会为广播变量传输单独建一个Stage(BroadcastStage)。流程图用虚线框标出“BroadcastStage”,并注明:“仅当broadcast变量>10MB且未启用spark.broadcast.compress时激活”。这个阈值来自Spark源码BroadcastManager的DEFAULT_BROADCAST_COMPRESS_THRESHOLD = 10 * 1024 * 1024。所以,当你发现莫名其妙多了一个Stage,先检查广播变量大小。
3.4 第4帧:TaskSet提交与Task序列化(t=3000~3500ms)
Stage划分完成后,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>观察OU(Old Gen Used)是否持续飙升。
3.5 第5帧:Executor接收Task并反序列化(t=3500~4000ms)
Executor端的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执行与内存分配(t=4000~5000ms)
Task反序列化成功后,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 Write:Map端数据落盘(t=5000~8000ms)
ShuffleMapTask执行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 Read:Reduce端拉取数据(t=8000~12000ms)
ResultTask或下一个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与聚合(t=12000~15000ms)
拉取到的多个ShuffleBlock(来自不同Map Task)被ShuffleBlockFetcherIterator合并成一个Iterator,传给reduce函数。对reduceByKey,Aggregator.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结果回传(t=15000~16000ms)
ResultTask执行完rdd.iterator(),得到最终结果(如count()的Long值),调用DirectTaskResult.serialize()序列化,通过NettyBlockTransferService发回Driver。注意,只有ResultTask的结果回传,ShuffleMapTask的结果不回传——它只写本地磁盘,供下游Task拉取。这里有个性能陷阱:collect()会把所有分区结果序列化回Driver,如果结果集太大(>1GB),Driver直接OOM。流程图在此处用红色叉号标出:“禁止在生产环境用collect()取大数据集”,替代方案是saveAsTextFile()或foreachPartition()写入外部存储。
3.11 第11帧:Driver端结果聚合(t=16000~16500ms)
Driver收到所有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帧:作业结束与资源释放(t=16500~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.enabled=true。
4. 实操验证:三步亲手绘制你的专属流程图
4.1 第一步:用spark-shell启动最小闭环
别急着跑WordCount,先搭一个能看见每帧心跳的环境。打开终端,执行:
spark-shell \ --master local[2] \ --conf spark.eventLog.enabled=true \ --conf spark.eventLog.dir=file:///tmp/spark-events \ --conf spark.ui.port=4040关键参数解读: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)这段代码足够简单,但包含了parallelize(DAG起点)、map(窄依赖)、filter(窄依赖)、count(Action),能触发完整的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帧(submit)120ms,第2帧(注册)380ms,第3帧(DAG)210ms,第4帧(TaskSet)150ms,第5帧(反序列化)80ms,第6帧(执行)450ms,第7帧(Shuffle Write)0ms(因无shuffle),第8帧(Shuffle Read)0ms,第9帧(Merge)0ms,第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-submit |
| Web UI打不开,提示Connection Refused | 第1帧 | spark.ui.port被占用,或防火墙拦截 | netstat -an | grep 4040 |
日志里反复出现Registered executor N times | 第2帧 | Executor注册后崩溃,YARN不断重启 | yarn logs -applicationId <app_id> | grep "Exception" |
Stage长时间卡在SUBMITTING状态 | 第3帧 | DAGScheduler死锁,常因自定义Partitioner的numPartitions返回负数 | 在Driver日志搜DAGScheduler: failed to submit job |
Task一直显示RUNNING但无进展 | 第6帧 | Task执行函数死循环,或GC停顿过长 | jstack <executor_pid> | grep "RUNNABLE" |
| Shuffle Write耗时突增10倍 | 第7帧 | 单个partition数据量暴增(数据倾斜) | 查看Shuffle spill (disk)指标是否激增 |
FetchFailedException: too large frame | 第8帧 | Netty接收缓冲区溢出,spark.network.maxFrameSize太小 | 检查spark.network.maxFrameSize默认128MB |
collect()返回空结果 | 第10帧 | ResultTask序列化失败,或Driver内存不足 | jstat -gc <driver_pid>观察OU是否100% |
| 作业结束后Executor进程仍在 | 第12帧 | YARNApplicationMaster未及时释放资源 | yarn application -list | grep <app_name> |
5.2 独家避坑技巧:教科书里不会写的实战经验
提示:Executor的
--executor-memory必须大于spark.executor.memoryOverhead(默认executorMemory * 0.1),否则YARN直接拒绝启动。我曾因把--executor-memory 4g和--conf spark.executor.memoryOverhead=512同时设置,导致Executor永远注册不上。正确做法是:--executor-memory 4g时,memoryOverhead自动为400MB,无需手动设。
注意:
spark.sql.adaptive.enabled=true开启后,流程图的第3帧(Stage划分)会被动态改写。ADP会在运行时检测数据倾斜,把一个大Stage拆成多个小Stage。这意味着你看到的Stage数可能和代码写的repartition()不一致。调试时,务必先关掉ADP:--conf spark.sql.adaptive.enabled=false,等流程图逻辑理顺后再开。
技巧:想快速定位哪个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 memory,90%的情况是Netty的Direct Memory爆了。解决方案不是加-XX:MaxDirectMemorySize,而是调小spark.network.blockTransferService的缓冲区:--conf spark.network.nio.directBuffers=false(强制用heap buffer),或--conf spark.network.maxReqsInFlight=10(降低并发请求数)。
教训:某次线上事故,作业随机失败,日志里只有
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. 流程图之外:如何让这张图真正长在你脑子里
这张“SparkCore:Spark原理初探(执行流程图)”的价值,不在于你把它打印出来贴在墙上,而在于它能成为你日常开发中的条件反射。我给自己定了一条铁律:每次写完一个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的脉搏。