自己牵头维护的Spark集群已经跑了三年多,从最初的十几个节点扩到现在上百节点,大部分时间都挺稳。但今年上半年接连出了几档子事:一个团队凌晨三点跑全量同步任务,把资源全占了,白天的实时报表任务排到中午才跑完;另一个组提交了一个executor数量没设上限的任务,直接把队列资源打满,其它业务全被饿死。排查到最后,问题都出在任务调度上。这不是单点故障,而是调度策略和业务形态不匹配。所以这篇文章想把这套Spark任务调度算法的优化实践完整梳理一遍,从调度体系的基本架构,到FIFO和FAIR两种算法的选型与配置,再到并行度、动态资源分配、数据本地性这些绕不开的参数,最后附上真实案例和踩坑记录,希望能帮到同样被调度问题折腾的人。
1. 调度体系整体设计与思路拆解
1.1 一个提交上来的Spark应用,资源到底是怎么分的
想调好调度器,先得理解Spark里资源分配的完整链路。一个应用从提交到真正跑起来,大致要经历这么几层:Application → Job → Stage → Task。
Application就是你用spark-submit提交的那个任务,对应一个SparkContext实例。一个Application内部会因为多个Action操作被拆成多个Job。每个Job又按照宽依赖切分成多个Stage。Stage是调度的最小复用单位,里面的每个数据分片会对应一个Task,Task才是在Executor上真正执行的计算单元。
任务调度的核心就是回答两个问题:哪些Task先跑?每个Task跑在哪台机器上?
这两个问题分别由调度器(TaskScheduler)和分配策略(SchedulerBackend做资源Offer)来回答。Spark默认的TaskScheduler实现是TaskSchedulerImpl,而它内部又依赖一个可插拔的调度算法接口SchedulingAlgorithm,就是FIFO和Fair这两种。理解这层结构之后,你才会明白为什么改一个scheduler.mode参数能产生那么大的影响,因为它切换的是整个Task分配策略的决策核心。
1.2 TaskScheduler与SchedulerBackend,一对容易混淆的搭档
很多初学者会把这两个角色混在一起。我用一个比方来区分:SchedulerBackend是后勤处,负责拉资源;TaskScheduler是作战室,负责派任务。
具体来说,SchedulerBackend负责和Cluster Manager(YARN、K8s或者Standalone)通信,申请Executor,并且把每个Executor上可用的CPU核数和内存量汇报给TaskScheduler。TaskScheduler拿到这些资源信息后,按照调度算法,把等待队列里的Task一个个分配过去。
这里有一个容易被忽视的细节:资源Offer是批量发生的。SchedulerBackend每次会生成一批WorkerOffer,每个Executor一条,TaskScheduler再逐一决定要不要把Task放上去。如果你写过自定义TaskScheduler,会看到resourceOffers()方法返回的是一个Seq[WorkerOffer],调度算法在内部会遍历这个序列,按每个Executor的剩余核数分配任务。
在实际排障时,这个分工很关键。比如你看到任务一直处于Waiting状态,就要先判断是Executor还没申请到(SchedulerBackend的问题),还是Task已经分派出去了但没地方落(TaskScheduler或资源本地性的问题)。我遇到过好多次,业务方跑来问“为什么我的任务不跑”,点开Active Stages一看,Task全在PENDING,而Executor列表一片空白——那是资源没申请下来,不是调度算法的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心调度算法与实际配置
2.1 FIFO和FAIR,五分钟后你也能讲清区别
Spark默认的调度模式是FIFO(先进先出),这可能是最直白的调度逻辑了:谁先提交谁先跑,一个Job运行完或者阻塞了,下一个Job才有机会。它背后对应的是FIFOSchedulingAlgorithm,按Job的优先级(Priority)和提交顺序(Stage ID)排序。
FIFO的问题也很明显:没有抢占和隔离的概念。如果一个Job里有1000个Task,还有一个Job只有1个Task,后者会一直等到前者的Task全部调度完才被调度。放到多业务共享集群的场景,就是“大任务压死小任务”,某个凌晨跑批的任务能把资源占满,白天的交互式查询全部卡死。
FAIR(公平调度)解决的就是这个问题。它的核心是让多个Job之间按权重(Weight)轮转分配资源,而不是让一个Job独占。Spark的Fair调度器参考了Hadoop的公平调度器设计,支持把资源池划分成多个Pool,每个Pool里可以配置不同的调度权重和任务数限制。
配置方式是在fairscheduler.xml(在$SPARK_HOME/conf目录下)里声明的,下面这个是我这边生产环境用的简化版配置:
xml复制<?xml version="1.0"?>
<allocations>
<pool name="realtime">
<schedulingMode>FAIR</schedulingMode>
<weight>3</weight>
<minShare>4</minShare>
</pool>
<pool name="batch">
<schedulingMode>FAIR</schedulingMode>
<weight>1</weight>
<minShare>2</minShare>
</pool>
</allocations>
注意weight的含义:它控制的是该池子获得空闲资源的相对权重,不是绝对的CPU份额。比如realtime池权重是3,batch池是1,那么当两个池都有任务等待时,realtime池会以大约3:1的比例分到新空闲资源。minShare是保证该池至少能拿到的并发Task槽位数,在集群资源紧张时优先保障。
在提交任务时,通过spark.scheduler.pool指定任务归属哪个池子,比如:
bash复制spark-submit --conf spark.scheduler.pool=realtime ...
2.2 FAIR模式下如何划分业务池,一套能直接抄的模板
如果你们集群只跑一种业务,那FIFO完全够用,没必要上FAIR。但凡有多个团队、多种时效性要求的任务混跑,我建议按业务价值把池子划分成三类:
- 交互式/实时池(weight高,通常是3~5):给即时查询、报表、线上服务调用的Spark任务,特点是单个Task不大、延迟敏感、不能等太久。
- 默认池(weight中等,1~2):给没有显式指定池的任务,避免遗漏的任务饿死。
- 离线批处理池(weight低,甚至可以设成0.5):给凌晨的全量ETL、数据同步任务,单个Task多、执行时间长、扛得住等。
分配权重需要结合实际容量做估算。比如集群总共有200个executor core,实时业务高峰期需要60个核才能保证延迟在可接受范围内,那么realtime池的权重至少应该占到30%以上。权重不能拍脑袋,最好把过去两周的“任务并发数×平均核数”拉出来算一下。
另外一个很容易踩的坑是:FAIR模式下如果只配置了spark.scheduler.mode=FAIR,但应用里没设置spark.scheduler.pool,那所有任务还是会全部落进一个default池里,照样先来先服务。很多博主会说“设置了FAIR就会自动均衡”,这说法不全对,至少你要保证不同任务确实走了不同的池子。
2.3 还有哪些调度参数值得调
除了scheduler.mode外,有四个参数直接影响调度行为,值得一起讲:
spark.scheduler.maxRegisteredResourcesWaitingTime
应用在启动时,要等spark.scheduler.minRegisteredResourcesRatio比例的Executor注册完成后才开始分配Task。如果等待时间太短,Executor还没完全启动就开始调度,Task会先PENDING在本地等资源;如果太长,应用启动会变慢。生产上我一般设30s(默认30s,不用动)到60s之间,看集群规模。
spark.scheduler.minRegisteredResourcesRatio
默认是0.0,对于YARN模式来说,这意味着一个Executor都没注册就开始调度Task。对超大作业来说,这会带来一堆TASK_PENDING,没什么实际进展。我建议设成0.8,等80%的Executor就位后再开始跑Task,虽然启动慢一点,但整体执行时间反而更短。
spark.scheduler.listenerbus.eventqueue.capacity
这个是事件队列的容量,默认10000。在Task非常多(几十万个Task)的批处理作业里,事件队列被打满会导致SparkContext报错ListenerBus has been stopped。这个参数不直接是调度算法,但经常以“调度问题”的面貌出现。调到20000或50000一般能解决。
spark.scheduler.revive.interval
控制TaskScheduler多久主动向Driver要一次资源Offer,默认1s。如果你的任务都是秒级延迟的交互式查询,可以考虑调低到100ms,但会增加Driver的调度压力。批处理任务就不要动了。
3. 并行度、动态资源与数据本地性,调度效果的三驾马车
3.1 并行度不是越大越好,教你一套估算口径
很多调度问题表面上是“任务排队”,实际上是因为并行度设得不对,导致资源明明空着,Task却不够分。并行度由RDD分片数或Shuffle时的spark.sql.shuffle.partitions决定。
先说RDD分片。如果从HDFS读数据,每个分片默认是spark.sql.files.maxPartitionBytes(默认128MB)一个Task。数据量低时容易造成分区数太少,跑不满集群。
再说Shuffle。spark.sql.shuffle.partitions的默认值是200,这个默认值只适合中小规模数据。我这边见过一个很大的生产作业,每天处理几TB的数据,跑Shuffle时只用了200个分区,每个分区几十GB的溢写到磁盘,整个任务的执行时间以小时计算。把并行度调到1000之后,执行时间缩短了接近四倍。
一个简化的估算公式是这样的:
目标并行度 ≈ 集群可用的Executor总核数 × 每个核上期望的Task并发数(通常是2~3)
因为每个Task处理能力有限(通常在数百MB到数GB数据量),如果Task数据量太大,建议提高并行度。同时也要考虑Task数量不能过多,因为Driver端调度几十万个Task本身就有序列化和调度开销,我一般把单个Stage的Task数控制在5万以内,超出的话要么加数据读入的并行度,要么合并小文件。
3.2 动态资源分配的两个大坑
spark.dynamicAllocation.enabled=true在YARN上确实能省资源,但它的行为有时候会让人摸不着头脑。核心逻辑是ExecutorAllocationManager会按照两个阈值来伸缩:
spark.dynamicAllocation.schedulerBacklogTimeout(默认1s):如果存在积压Task,则申请新的Executor。spark.dynamicAllocation.executorIdleTimeout(默认60s):如果Executor空闲超过这个时间,就释放掉。
第一个坑是:动态资源分配会导致Task的本地性判定很尴尬。Executor刚申请下来的时候,Task已经根据旧的资源Offer做了决定,新Executor上并不会立刻获得Task,导致一部分Task被调到了非本地节点的Executor上。业界有一种说法是动态资源分配和数据本地性两者存在天然冲突,这个说法有一定道理。如果你对数据本地性要求很高,就把schedulerBacklogTimeout调大一点,让Executor有足够时间收到新Task的Offer。
第二个坑是:动态分配和Fair调度叠加时,资源空转现象会更严重。因为Fair调度只负责在已有Executor之间分配Task,而动态分配负责创建/销毁Executor。当某个池子权重很高但没有任务时,它会把空闲Executor释放掉,等任务来了再申请,一来一回之间应用性能会受影响。
一个稳妥的配置建议是:
bash复制spark.dynamicAllocation.enabled=true
spark.dynamicAllocation.initialExecutors=10
spark.dynamicAllocation.minExecutors=5
spark.dynamicAllocation.maxExecutors=50
spark.dynamicAllocation.executorIdleTimeout=120s
关键是minExecutors和maxExecutors要收敛,不要设一个“无上限”的maxExecutors。我见过有人在maxExecutors里填了1000,结果一个任务把整个集群的executor都申请走了——问题不是动态分配有问题,而是限制没做对。
3.3 数据本地性,调度中默默牺牲的一环
Spark的调度算法会按照“数据在哪里,就把计算调到那里”的原则来分配Task,这种策略叫数据本地性(Locality)。本地性分为五个等级,从高到低:
| 级别 | 含义 |
|---|---|
| PROCESS_LOCAL | Task和数据在同一个Executor进程内,速度最快 |
| NODE_LOCAL | Task和数据在同一个节点,但可能不在同一个Executor |
| RACK_LOCAL | Task和数据在同一个机架 |
| ANY | 任意位置 |
调度器分配Task时,会先尝试最高级别的PROCESS_LOCAL,如果暂时没有空闲的Executor满足条件,会等待一段时间再降级。这个等待时间由spark.locality.wait(默认3秒)控制。
在实际场景里,数据本地性的影响取决于“数据读取成本”和“计算成本”的比例。如果Task的计算量很小,而数据体积很大,那么本地性对性能影响显著;反之,计算密集型的Task即使跑了远程数据,耗时占比也不高。
一个比较实用的本地性调优思路是:检查Spark UI上每个Stage的Locality Level分布,如果大量Task是NODE_LOCAL甚至RACK_LOCAL,说明本地性优化不够。可能的原因包括Executor数量太少、Task数远大于Executor槽位数、或者动态资源分配初期Executor不足。可以先通过调大spark.locality.wait到5~10秒观察效果,如果没有明显改善,就要考虑增加Executor、或者使用spark.executor.cores调小核数让更多Executor并行运行。
但我必须说句公道话:本地性不是万能的,它只影响调度的物理分布,不影响调度优先级。有些任务瓶颈在CPU或Shuffle,本地性优化效果可能不够明显。在动手调之前,先看Task的耗时分布,别把时间浪费在本不是瓶颈的地方。
4. 一次完整优化案例:一个凌晨跑批任务引发的“血案”
4.1 场景描述与问题表象
我们集群的典型业务形态是:白天以实时报表和交互式查询为主,深夜以全量ETL和算法训练为主。由于我最初把所有任务都扔在FIFO模式下,集群里就出现了这种矛盾:一个凌晨3点启动的全量同步任务,Stage数很多、每个Stage的Task数大概在3000左右,它一跑起来就把所有Executor的CPU都抢走了。另一个团队上午9点上班后提交的实时查询任务,提交后一直处于WAITING状态,直到跑批任务结束(大约40分钟)才拿到资源。
业务方投诉后,我先做了两件事:第一,打开Spark UI看Active Stages的任务分布;第二,查看YARN资源池的实际使用情况。确认问题本质是:FIFO下大Job排队阻塞了小Job,资源分配严重倾向了“先来者”。
4.2 优化方案:不换引擎,先把调度策略换成FAIR
我没有立刻去改代码或调并行度,因为这种任务形态的冲突,最直接的解法就是切换调度策略。FAIR模式允许多个Job并行执行,Small Task可以插队,大Job也不会被完全阻塞。另外我还顺手做了三件事:
- 划分了三个池子:
realtime、batch、default。权重分别为4、1、1。 - 应用侧只对
realtime池的任务显式设置spark.scheduler.pool=realtime,其它任务不管,它们会自动落入default池。 - 调整了动态资源分配的
minExecutors和maxExecutors,限制单个应用最多使用集群总核数的40%,防止“跑批任务把资源全占用”。
为了给大家一个更直观的对比,优化前后的配置差异大致是这样:
bash复制# 优化前
spark.scheduler.mode=FIFO
spark.dynamicAllocation.enabled=true
spark.dynamicAllocation.maxExecutors=100
# 优化后
spark.scheduler.mode=FAIR
spark.scheduler.allocation.file=/etc/spark/conf/fairscheduler.xml
spark.dynamicAllocation.enabled=true
spark.dynamicAllocation.initialExecutors=10
spark.dynamicAllocation.minExecutors=5
spark.dynamicAllocation.maxExecutors=40
spark.scheduler.minRegisteredResourcesRatio=0.8
spark.locality.wait=5s
4.3 调优后的量化对比
为了效果更直观,我挑了一个典型的“白天查询任务+夜间批处理任务”组合来对比:
- 优化前:实时查询任务在忙时平均提交到运行完成耗时约43分钟(主要时间花在等待资源)。
- 优化后:实时查询任务的平均耗时降到约6分钟,其中真正执行的时间不到2分钟,剩余时间主要是SparkContext初始化、Executor申请和Shuffle调度。
- 批处理任务:执行时间从原来的40分钟增加到52分钟。这是可以预期的代价,因为Fair调度会分一部分资源给其它Job,但总体吞吐能力反而提升了——多个Job可以“同时”跑,而不是一个个排队。
大数据领域Spark的任务调度算法优化实践,本质是在“公平性”和“资源利用率”之间找平衡,没有银弹。但如果你的集群里有明显的长短任务混跑,把FIFO换成FAIR,再加上合理的池子配置和动态资源限制,十有八九能解决大部分排队问题。
4.4 一个小技巧:通过Spark UI快速定位调度瓶颈
调优过程中,我习惯在Spark UI上关注三个指标:
- Active Stages 里的Task分布:如果Task长时间处于
PENDING,说明资源不够或本地性等待。 - Executors 页签里的“Task Time”和“Idle Time”:如果某Executor的Idle Time特别高,说明Task分配不均,可能需要检查并行度或Shuffle分区数。
- GC Time和Shuffle Spill:如果Shuffle Spill非常高,说明并行度不够,或者内存配置不合理。
这三个指标能帮你快速判断“瓶颈在调度、在资源、还是在代码”,避免一上来就盲目改调度参数。
5. 常见问题与排查技巧实录
5.1 容易被误判的调度问题速查表
在群里答疑这几年,我把大家问得最多、也最容易误判的“调度问题”整理成了一个速查表,按症状、原因、排查方式三列来写:
| 症状 | 常见原因 | 排查方向 |
|---|---|---|
| 任务提交后长时间WAITING,Executor为0 | 资源不足或动态分配未触发 | 看Resource Manager队列,检查maxExecutors |
| Executor正常运行,但Task长时间PENDING | 数据本地性等待,或Task数远大于槽位 | 看Locality Level,调spark.locality.wait |
| 多个Job同时跑,个别Job永远不跑 | FIFO + 大Job排队 | 切FAIR,检查pool配置 |
| Task数量巨大,Driver端OOM | Shuffle分区数过大、事件队列溢出 | 调spark.sql.shuffle.partitions、事件队列容量 |
| 一个Executor上的Task耗时明显比其它长 | 数据倾斜或本地性差 | 看每个Task的Shuffle Read Size,做数据预聚合 |
| 看似设置了Fair,但所有任务仍进default池 | 没设置spark.scheduler.pool |
确认应用侧是否显式指定池子 |
5.2 我把一个坑踩了三遍才明白的事
第一遍,我以为设置spark.scheduler.mode=FAIR就能自动均衡,结果任务还是串行跑。原因就是上面表格里写的:应用提交时没指定spark.scheduler.pool,所有任务都进了默认池,而默认池内部的调度算法也只有FIFO一种选择。
第二遍,我发现FAIR模式下,多个Job一起跑了,但总吞吐并没有提升。看Spark UI才发现,两个Job在争抢同一批Executor,而每个Executor上的核数被设成了spark.executor.cores=8,Task分配是按核数来的,并非真正并行在跑。后来我把spark.executor.cores调到了4,增加了Executor数量,反而让整体并行度上去了。
第三遍,是动态资源分配和Fair调度叠加时的坑:批处理任务大,一来就申请了一堆Executor,然后Fair调度把Task分散到这些Executor上,但大多数Executor只跑了几个Task就空闲了,等executorIdleTimeout到了才被释放,释放后又重新申请。解决办法是适当减小maxExecutors,让动态分配更灵敏一些,同时保证空闲Executor能在短时间内复用。
这三遍下来我得到的核心经验其实是:调度参数不能孤立去看,Executor数量、核数、并行度、动态资源、本地性等待,是同一套资源池里互相牵连的变量。改任何一个最好都过一遍“这样改会不会影响其它环节”,而不是按下葫芦浮起瓢。
5.3 三个能让排查效率翻倍的习惯
第一,提交任务时加上--conf spark.eventLog.enabled=true --conf spark.eventLog.dir=hdfs:///spark-logs,这样任务结束后还能查看历史UI,回放当时的调度过程。很多问题不抓到现场,事后根本说不清楚。
第二,给每个业务池一个命名规范。比如realtime_${appName}、batch_${appName},这样在Spark UI的“Active Stages”和YARN的Application列表中,一眼就能分辨出哪个任务属于哪个优先级。
第三,重要任务一定要设置“资源上限”和“应用超时”。比如动态资源的maxExecutors、以及spark.sql.autoBroadcastJoinThreshold这类参数。别把希望寄托在“所有人都按照规范提交任务”上,真正稳定的调度体系,是让“不规范的任务也不会造成灾难”。
一些写在最后的个人体会
从最初盲目地把所有任务丢进FIFO,到后来按业务价值划分调度池、控制动态资源上限、调优数据本地性等待时间,这套优化做下来,集群的整体吞吐和稳定性都有了明显改善。Spark的任务调度算法本身并不复杂,复杂的是“调度器只负责决策,而决定决策效果的是整个资源池的形状”。所以下次再遇到任务排队、资源争抢,别急着加机器,先静下心看一下Task的状态分布、Executor的空闲情况、池子的权重配比——问题可能就在这些不起眼的参数组合里。
