我接触Spark这几年,印象最深的一次排查经历,是一个跑得好好的ETL作业突然卡了大半天,所有任务状态都显示“等待”,Spark UI里节点列表一片飘红。折腾了许久才定位到,问题出在Scheduler和BlockManager之间的元数据会话上。从那以后我看Spark作业的角度就不一样了:以前只关心算子写得对不对,后来更关心调度器和块管理器到底是怎样配合的。今天这篇,我就把Scheduler与BlockManager的交互流程完整拆开,从阶段划分、任务下发、数据定位,到Shuffle读写链路上的底层细节,全部过一遍。适合正在做Spark应用开发、搞数据平台运维,或者想对分布式计算框架做二次扩展的工程师,看完你至少能明白一个任务从提交到结果返回,走的每一步到底是谁在帮谁。
1. 两个组件到底管到什么边界:调优前必须厘清的职责
很多人一开始对Scheduler和BlockManager的理解是割裂的,一个管“任务怎么排”,一个管“数据存哪”。这个认知不算错,但如果只停留在这一层,你会很难解释很多Spark的诡异现象,比如:为什么任务总是调度到没有数据的节点?为什么ShuffleRead偶尔卡到令人抓狂?为什么同一个作业换个executor数量,性能天差地别?
这些问题背后,本质上是两个角色在流程上的深度耦合。所以先别急看代码,我们先把职责边界划清楚。
1.1 调度器内部其实还有两层
Scheduler这个名字,严格来说应该拆成两层:DAGScheduler和TaskScheduler。DAGScheduler面向的是Job级调度,它把RDD的依赖关系图切割成一串Stage,每个Stage内部生成一批任务,这批任务会交给TaskScheduler。TaskScheduler面向的是Task级调度,它负责把这些任务真正丢到某个Executor上执行。
在整个调度链路里,DAGScheduler关心的是“这个任务需要的数据在哪”,TaskScheduler关心的是“哪个Executor适合执行这个任务”。后者往往是前者的执行结果。也就是说,DAGScheduler基于数据分布做出候选节点列表,TaskScheduler根据Executor的资源情况、可用性、白名单等做最终裁决。
这套分工直接影响了你看到的现象:如果你在Spark UI里看到某个Stage有任务一直处于“调度等待”状态,不一定就是资源不足,更可能是DAGScheduler给出了一个“首选位置列表”,但TaskScheduler发现这些节点已经不可用或没有资源,于是只能退而求其次等待或换节点。
1.2 BlockManager不是简单缓存
BlockManager是Spark存储层的核心组件,但它远不止“缓存”这么简单。每个Executor上都会有一个BlockManager实例,负责该Executor进程内的数据块写入、读取、复制和淘汰。Driver端还有一个BlockManagerMaster,专门维护所有Executor的Block元数据信息,包括哪个Block在哪个Executor上、块大小、存储级别、最后更新时间等。
很多人在定位OOM问题时只盯住堆内存,容易忽略BlockManager在其中的角色。BlockManager本身管理的不只是RDD缓存数据,还包括Shuffle过程中的中间文件、广播变量、任务结果数据等。它底层可能用内存、磁盘,也可能两者都用。关键点在于,BlockManager是个跨组件的数据中枢,而不是简单的“cache层”。
1.3 为什么交互如此关键
Scheduler要做出合理的调度决策,就必须知道数据在哪;而数据在哪这件事,只有BlockManager最清楚。所以Scheduler在生成任务阶段会有意识地调用BlockManagerMaster来查询依赖数据的位置,再结合RDD本身的优先位置定义,产出最终的本地位信息。
反过来,BlockManager也依赖Scheduler把任务调度到对的位置,否则它辛辛苦苦缓存好的块就没法被“就近消费”。如果任务被调度到了离数据很远的节点,一次Fetch就是一次网络跨机架甚至跨机房传输,成本极大。
所以这两者是一条绳子上的两个蚂蚱:Scheduler拿BlockManager的数据分布信息做决策,BlockManager靠Scheduler把计算推到数据旁边去。你说到底是调度决定存储,还是存储决定调度?我觉得这是一个螺旋上升的耦合关系,谁先下手,取决于当前阶段的执行状态和触发源。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 任务从DAG变成可执行单元:Scheduler主动问BlockManager要位置的完整过程
2.1 RDD分区如何映射到Block
在Spark里,RDD的每个分区并不是直接对应一个物理数据块。但有趣的是,Spark内部很多模块确实会以“Block”的概念来衡量数据位置。比如HadoopRDD的每个分区会对应一个InputSplit,这个Split至少包含一个HDFS Block的位置列表。而在CacheManager里,RDD分区一旦被缓存,就会以“rdd_分区ID”的形式注册成BlockManager中的Block。
这层映射关系非常关键,因为Scheduler的分区本地性判断,本质上是在问:“这个RDD分区如果在BlockManager里有缓存,缓存块在哪些Executor节点?”如果再往下钻一层,即使没有缓存,底层InputSplit提供的location信息也足够让Scheduler判断数据的大致物理分布。
2.2 Scheduler如何向BlockManager打听数据位置
DAGScheduler在提交一个任务集之前,会调用一个非常关键的内部方法——getPreferredLocs。这个方法的职责是,为指定的RDD分区计算一个候选节点列表。
它的计算逻辑是带缓存和递归的:
- 如果这个RDD分区已经有缓存,直接从BlockManagerMaster获取Block所在节点列表。
- 如果没有缓存,但该RDD实现了getPreferredLocations方法,就调用这个方法拿到位置信息。
- 如果还没拿到,则通过RDD的依赖关系向上回溯,看看父RDD对应分区的位置信息是否可以继承。
这里有个值得注意的点:getPreferredLocs只会返回一个节点列表,它不一定保证该节点上真的有数据。比如某个RDD分区尚未计算,也没有缓存,它回溯到父RDD时,父RDD同样没有计算。这时Spark会返回一个空的候选列表,调度器就只能退化为“随机调度”。
我实际跟踪过很多作业,发现大部分Stage的第一个任务,候选位置其实都是空的。这不是Spark有问题,而是Spark的设计如此:对于尚未物化的分区,位置信息本身就不可知,只能等第一步计算完成后再逐步收敛。理解了这一点,你再看Spark UI里的调度延迟,就不会误以为纯粹是负载问题了。
2.3 任务生成时如何携带位置信息
当DAGScheduler确定了每个分区的首选位置后,会为这个分区生成一个Task对象。Task对象内部携带一个preferredLocations属性,这个属性就是前面getPreferredLocs推导出的候选节点。
接下来,TaskScheduler的接口实现(比如TaskSchedulerImpl)会接收一批任务,并以TaskSet的形式落到调度池中。挂在Task上的preferredLocations,在调度器决策时会被拿出来使用。具体调度逻辑根据不同的调度模式(FIFO、FAIR)和可满足性判断,最终决定是把任务分配到候选节点,还是等待候选节点释放资源,或者直接放到其他节点执行。
这个过程中,Scheduler不会反复向BlockManagerMaster发请求去实时查位置,而是会尽量复用Task上的preferredLocations。所以在同一个Stage内部,如果某个块的元数据出现了变化(比如缓存块被驱逐),Scheduler依然会照旧尝试原优先位置,直到任务实际运行时报出FetchFailed再做降级重试。
这也给了一个提升性能的思路:如果你事先知道某些RDD会被多个Stage重复使用,主动用persist把数据缓存下来。这样Stage首次计算之后,后续Stage在getPreferredLocs阶段就能大概率命中BlockManagerMaster的缓存信息,任务本地性会明显提高,调度延迟也能减少。
3. 任务结果不是直接回Driver:绕道BlockManager的真实原因和传输细节
3.1 任务结果为什么要绕道BlockManager
很多人初学Spark时会有一个误解:任务执行完,直接把结果通过网络传回Driver不就行了吗?实际却不是这样。出于容错、内存控制、网络传输效率等方面的考虑,Spark采用了一套“结果可能绕道BlockManager”的机制。
任务执行结束后,Executor会先把结果封装为任务结果对象。如果结果数据量较小(默认小于1GB,Spark开发版还提供“直接任务结果”的优化通道),结果可以直接序列化后由Executor的通信层送回Driver,Driver端不需要额外发起数据拉取。
但问题在于,一个任务的结果有可能很大。特别是Reduce端聚合结果、Collect算子拉回的数据、或者某些不落地的中间计算结果,动辄几百MB甚至上GB。如果所有结果都直接塞给Driver,Driver的JVM内存会立刻被冲爆,同时网络并发也会受严重影响。
所以Spark的设计是:当结果数据较大或无法直接序列化时,Executor端会把结果封装成Block,先写入当前的BlockManager,然后向Driver发送一个IndirectTaskResult消息。Driver收到这个消息后,再向对应Executor的BlockManager发起一次远程Block拉取。这个设计让“大结果”不会直接占用Driver的堆内存,而是按需获取、按需释放。
3.2 从本地Block到远程拉取
我们具体看一下IndirectTaskResult这条链路。
当Executor完成一个任务后,BlockManager会调用putBytes或者putIterator方法,把任务输出存成一个Block。这个过程的实现细节非常有意思:结果Block并不一定需要全量序列化成一个字节数组,可能是一个迭代器,也可能是一个可以按块读的字节缓冲。BlockManager会根据存储级别、内存压力和块大小选择放在内存还是落盘。
紧接着,Executor的TaskRunner会封装一条状态消息,告诉Driver“任务成功了,结果在某个Executor上的某个Block里”。这条消息包含的关键索引就是BlockManagerId和BlockId。
Driver端收到成功消息后,会判断结果类型是否为IndirectTaskResult。如果是,说明结果数据在远端,Driver调度层面的TaskResultGetter组件会远程调用BlockManager的getRemoteBytes方法,根据BlockId向对应的BlockManager发起网络请求读取数据。数据拿回来后再做反序列化,进入DAGScheduler的处理流程。
如果你的作业经常出现Driver端GC时间飙升或网络传输占用过高,可以重点观察这两个指标:任务结果大小,和IndirectTaskResult的比例。当大量任务处理结果超过直接结果阈值时,Driver就会频繁地远程拉取Block,网络模型和内存模型都会压力山大。
3.3 清理时机与生命周期
结果Block并不是永久存在的,它有一个很短暂的生命周期。正常情况下,任务结果Block在Driver端成功拉取后,或者任务本身被判断为失败/冗余时,会被清理掉。
Executor端清理结果Block的机制由BlockManager统一管理。BlockManager会维护每个Block的引用信息。如果结果Block是临时的,Driver或其他节点在获取完成后会发送一个释放请求。BlockManager收到请求后,如果该Block未被其他消费者引用,就会被删除。
实际运维中,我见到的典型问题之一是:任务重试率很高时,结果Block的清理不够及时,Executor上会堆积大量临时结果Block,最终导致内存压力或磁盘压力。这时候不要急着调整Executor内存大小,先看是不是任务失败率过高、Driver端拉取超时导致的垃圾Block堆积。
4. 本地性判定:为什么Scheduler非要依赖BlockManager的元数据
4.1 本地性级别和判定
Spark定义了多种本地性级别,从高到低大致是:
- PROCESS_LOCAL:任务和数据在同一JVM进程内,性能最佳。
- NODE_LOCAL:任务和数据在同一节点,但跨进程。
- RACK_LOCAL:任务和数据在同一机架,跨节点。
- ANY:跨机架或未知位置。
Scheduler在做任务分发时,会根据Task上的preferredLocations和当前Executor的位置做匹配。Spark会在每个Executor的空闲状态下尝试提升本地性级别,直到达到一个能满足当前等待条件的水平。
这里最关键的一个点是:preferredLocations列表来源就是BlockManagerMaster。如果BlockManagerMaster提供的元数据不准确,本地性判断就会失真。
4.2 getPreferredLocs的计算过程
为更直观地说明,我描述一下getPreferredLocs的工作过程,这个逻辑在整个Spark调度链路中极具代表性。
假设你执行一个:rdd.map(...).join(otherRdd).filter(...).count()。
DAGScheduler在生成最终Stage时,会对最后的RDD(filter后的那个RDD)的每个分区调用getPreferredLocs。由于这个RDD是map/join后产生的,可能没有显式的getPreferredLocations实现,所以Spark会顺着RDD的血缘关系向上回溯。如果父RDD是join产生的,它会继续回溯到两个父RDD,再追踪到最初的rdd和otherRdd。如果这些源RDD有明确的location信息(比如从HDFS读数据),位置信息就会一路汇总上来。
这个计算过程为什么说是“依赖BlockManager元数据”?因为在整个RDD世系中,只要有一个父RDD分区已经成功缓存到Executor的BlockManager中,getPreferredLocs在递归过程中就会提前命中缓存块的位置,返回该块所在节点列表,不再继续向下回溯。这大大提升了本地位判定的效率和准确度。
4.3 本地性等待和退让策略
本地性并非“永远强求”。Scheduler在找到一个候选节点并尝试调度任务时,如果该节点上的Executor没有空闲slot,它不会无限等待。Spark引入了本地性等待时间概念,每个级别有一个等待时长,超过等待时间后,Scheduler会降级到下一个本地性级别执行。
这种设计很现实:PROCESS_LOCAL性能再好,如果唯一的候选节点忙不过来,硬等会让作业整体延迟不可接受。降级到NODE_LOCAL甚至RACK_LOCAL,虽然多了网络IO,但总比卡死强。
但从另一个角度看,如果等待时间配置过短,任务会被频繁调度到非本地节点,导致大量Shuffle读取和数据拉取,反而让性能更差。你需要根据集群的网络带宽和任务运行时长来平衡这个参数。
我在集群上做过对比实验:把spark.locality.wait从默认3秒调整到10秒后,某些重Shuffle作业的ShuffleRead量下降了约35%,但调度延迟略微上升。如果你遇到“数据本地性良好但调度慢”和“调度快但数据拉取量大”两种矛盾症状,调这个等待时间往往是第一选择。
5. Shuffle场景里的高并发交互:ShuffleMapTask输出和ResultTask拉取的底层联动
5.1 Shuffle Write:map端数据怎么进BlockManager
Shuffle过程是Scheduler和BlockManager交互最频繁的场景之一。我们先从Shuffle Write说起。
一个ShuffleMapStage里,每个ShuffleMapTask会处理上游数据,并根据Partitioner确定每条记录应该进入哪个reduce分区。Spark不会把所有数据全量缓存进内存,它默认通过ExternalSorter做溢写合并,最终在每个任务执行目录下生成两个文件:数据文件和索引文件。
这里有个容易被忽略的细节:在旧版Spark中,ShuffleMapTask的输出Block会被注册到BlockManager,后续reduce端获取时需要通过BlockManagerMaster查询位置。但实际物理文件并不是BlockManager直接管理的普通Block,而是通过IndexShuffleBlockResolver这样的组件建立文件片段到BlockId的映射。所以严格来说,Shuffle Write阶段,BlockManager既参与了元数据注册,也参与了块寻址,但真实的文件IO由ShuffleManager负责。
用一张表格总结Shuffle Write阶段两者分工:
| 维度 | Scheduler/ShuffleMapTask | BlockManager |
|---|---|---|
| 数据写文件 | 调用ExternalSorter写数据文件和索引文件 | 不直接负责文件内容写入 |
| 元数据注册 | 把MapStatus上报给MapOutputTracker | 注册ShuffleBlockId到文件位置的映射 |
| 本地位判定 | 根据MapStatus知道数据在哪个节点 | 提供块是否存在、所在节点等信息 |
所以在Shuffle Write完成后,reduce端需要拿到“每个上游分区数据的物理位置列表”,这个列表就是MapStatus。MapStatus由MapOutputTracker维护,它像是Scheduler和BlockManager之间的一个“中间地图”。如果把BlockManagerMaster比作“点位置注册中心”,那MapOutputTracker就是“分区数据位置索引”。
5.2 Shuffle Read:reduce端拿数据的完整链路
reduce端的ResultTask启动后,需要读取所有与它有依赖关系的map输出数据。这个读取流程可以拆成几大步:
第一步,reduce任务向Driver端的MapOutputTracker获取MapStatus列表。每个MapStatus里面包含了每个map任务输出的数据文件位置、文件长度等关键信息。
第二步,reduce任务根据MapStatus上的位置信息,把每个需要读取的数据分片映射到对应Executor节点的BlockManager。
第三步,如果读取的块在本地节点,直接由本地BlockManager或ShuffleBlockFetcherIterator发起本地文件读取;如果在远端节点,则通过BlockTransferService发起远程fetch。
第四步是细节较多的地方:远端fetch到的数据不是一次性全部拉回,而是按批次读取。每个Batch通常由多个连续的Block组成,这样能减少网络请求次数。
我经常和团队强调一个概念:Shuffle Read的瓶颈往往不在于数据总量,而在于请求数量和单次传输的块大小。如果你看到ShuffleRead里FetchWait时间很长,优先检查是不是map任务数量过多,导致每一个reduce任务都要面对几百个上游块请求,请求风暴会让网络连接和线程池瞬间打满。
5.3 磁盘与内存的取舍
Shuffle Read的过程中,BlockManager的作用还体现在对shuffle数据的读缓存处理上。当某个shuffle块被反复读取时(比如同一个Executor上有多个下游任务需要相同数据),BlockManager可以缓存一小部分shuffle块到内存,避免重复磁盘IO。
但注意,Shuffle数据常规上不会被BlockManager主动缓存,它是通过“请求-读取-释放”的方式流转的。如果你看到某个Executor堆内存里shuffle read数据占用了大量空间,那不是BlockManager缓存了它,而是最终的聚合操作(比如reduceByKey在内存中做combine)还没来得及释放。
在实际调优时,我习惯把Executor内存分为三块来看:执行内存、存储内存、预留内存。其中存储内存就是给BlockManager使用的。Shuffle读取的临时数据主要走执行内存,但如果执行内存不足,Spark可能把它溢写到磁盘,并且通过BlockManager进行临时块管理。这就是为什么有时候我们在BlockManager的UI界面里也能看到一些shuffle相关的Block条目。
理解这个交互后,你就能更理性地设置spark.memory.fraction和spark.memory.storageFraction了。Storage fraction设得太大,Shuffle计算内存不够,容易频繁溢写;设得太小,缓存RDD的空间不足,会失去缓存本地位优势。建议结合你的缓存量、Shuffle比例和作业运行时长综合权衡。
6. 故障排查:交互链路中我实际踩过的坑和调整参数顺序
6.1 “任务永远跑在非本地节点”的排查思路
有一种非常常见的性能问题:数据明明在某几个节点上,但任务总是被调度到其他节点执行。很多人的第一反应是“本地性等待时间不够”,但我实际排查下来,至少还有三个原因值得优先检查。
- 数据节点的Executor没有申请到,比如Executor总数比数据节点数少,导致任务只能落到其它节点。
- BlockManagerMaster的元数据很久没刷新,新增或移除Executor时,元数据更新延迟导致Scheduler以为某个节点不可用。
- 上游分区尚未物化,getPreferredLocs在回溯时拿不到任何有效位置。
我在自己的集群上遇到过一次:某节点网络抖动导致Executor被反复标记为Dead和Alive,BlockManagerMaster中的Executor列表频繁变化,部分任务拿到过一个已失效的Executor ID,任务一直等不到该节点响应。最终通过调整Executor心跳超时和黑名单机制解决。
6.2 Executor失联导致块元数据不可达
Scheduler和BlockManager交互中,最让人头疼的一类问题是Executor失联。
当某个Executor崩溃,BlockManagerMaster会收到ExecutorRemoved事件,并且把所有与该Executor相关的Block标记为不可用。这时如果某个Task尝试从该Executor拉取shuffle数据或结果块,就会触发FetchFailure。Spark会对FetchFailure做特定处理:它不会像一般任务异常那样直接失败,而是会尝试重新提交父Stage或上游的任务,来重建丢失的shuffle数据。
这个机制是容错的重要支撑,但也带来一个坏消息:如果Executor频繁失联,Stage会被反复重新提交,调度器会产生大量新任务,BlockManager上的临时块也会猛增。整个集群看上去就像“卡死”了,但实际上是在反复重算。
我处理过最极端的一次是Spark节点磁盘故障,导致多个Executor发生OOM异常,结果一个只有20个Stage的作业跑了三天没完成。后来加上了健康检查和磁盘预留空间监控,才把问题根除。
6.3 结合Spark UI和日志判断交互瓶颈
判断Scheduler和BlockManager交互是否出了问题,有一个比较实用的方法:不只看任务执行时间,也要看“调度延迟”和“获取数据时间”。
在Spark UI的Stage页面里,重点关注:
- Scheduled Delay:任务从提交到真正开始执行的时间。如果这个值很高,说明任务卡在Scheduler等待资源或本地位条件上。
- Fetch Wait Time:Shuffle读取时等待数据的时间。如果这个值很高,说明上游数据位置不可达或网络拉取太慢。
- Getting Result Time:Driver拉取任务结果的时间。如果这个值很高,说明结果Block很大或网络传输压力很大。
在日志层面,BlockManager的日志有非常多的INFO级别输出,包括块注册、块删除、块获取请求等。如果你开启了Details级别的日志,可以观察到每一次getRemoteBytes的记录。当你怀疑某台节点上有安全问题时,这些日志是定位关键路径的第一手证据。
6.4 参数调优的优先级建议
如果你需要优化Scheduler与BlockManager交互带来的性能问题,我给的优先级顺序是这样的,亲测有效:
第一梯队,先解决资源容量和稳定性。确保Executor数量、每个Executor的内存和CPU核数满足数据规模的并行度要求。如果节点本身都在频繁失联,后面所有参数调整都没有意义。
第二梯队,调整本地性等待时间。观察任务本地位级别分布,如果PROCESS_LOCAL和NODE_LOCAL比例过低,适当增加spark.locality.wait,但要控制在一个能接受的调度延迟范围内。
第三梯队,调整存储和内存比例。确认缓存数据量和Shuffle数据量之间的平衡,再逐步调整spark.memory.storageFraction。
第四梯队,再考虑网络层面的参数,比如spark.shuffle.io.numConnectionsPerPeer、spark.shuffle.io.retryWait等。这些参数在集群网络拥塞或连接数过高时很有用,但不要在基础不稳的情况下去调,容易掩盖真正的问题。
很多人在调优时喜欢一上来就改内存参数,把Executor搞到几十GB,其实这未必优化了Scheduler和BlockManager之间的交互。更大的内存只是让BlockManager能缓存更多块,但调度决策本身可能并没有受益。我这里还有一个个人经验:在大促或数据高峰之前,最好先把一次全量作业跑一遍,记录下本地位命中率、ShuffleRead量和Driver拉取任务结果的时间。有了这套基线,你后续再调任何参数都能用数据说话,而不是凭感觉。
最后再分享一个小技巧:在排查Scheduler和BlockManager交互问题时,不要只看某一个Executor的信息,把同一Stage在不同Executor上的执行情况并排对比。如果某个Executor上的任务总是比其他节点慢好几倍,大概率是这个节点的BlockManager本地磁盘IO出了问题,或者它离数据源头特别远。这时候在该节点上单独压测磁盘读写,往往能找到根因,比死磕代码更有用。
