1. 先说一个让我印象深刻的调优场景
前两年接手了一个业务团队的Spark批处理任务,跑一次全量ETL要12分钟左右。业务方一开始怀疑是数据量大、集群资源不够,申请了更多executor,结果跑完发现时间只少了不到1分钟。后来我翻了Spark UI的Stage页面,发现一个奇怪的现象:某个Stage有1200个task,但大部分时间都在等待状态,真正执行的时间加起来不到总耗时的三分之一。也就是说,资源早就申请下来了,Executor的核也空闲着,但TaskScheduler就是没有及时把任务分发出去。问题根本不在算力,而在任务调度这个看不见的环节。
那段时间我陆续调了spark.locality.wait、spark.scheduler.mode、并行度设置这些参数,才把整个作业从12分钟压到了4分半。说实话,任务调度这块平时很少有人愿意深挖,因为Spark SQL写得好不好、数据倾斜处理到不到位,往往掩盖了调度层面的低效。但只要你跑的是大作业、集群资源紧张、或者跟其他团队共享一个集群,调度算法和参数配置的影响就会非常明显。这也是我想把这段经验整理出来的原因。
下文我会从Spark任务调度的完整链路讲起,再深入FIFO与FAIR两种调度算法的取舍、延迟调度与本地性机制,接着给出一套可落地的参数优化清单和真实案例复盘,最后聊聊我在实践中踩过的几个容易误判的坑。适合那些已经能跑通Spark作业,但发现作业性能始终上不去的读者,也适合准备大数据面试时想把调度机制讲透彻的人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 调度链路拆解:从DAGScheduler到Executor上的任务执行
2.1 Stage划分:宽依赖是切分边界
要理解任务调度,首先要搞清楚Spark里的Stage是怎么来的。DAGScheduler会根据RDD的依赖关系构建DAG,遇到宽依赖(shuffle依赖)就切分出新的Stage。窄依赖(如map、filter)不需要跨节点传输数据,可以合并到同一个Stage里流水线执行;而宽依赖(如groupByKey、reduceByKey、join)需要把同一个key的数据汇聚到同一个节点,这一汇聚过程的边界就是Stage之间的分水岭。
从调度的角度看,Stage决定了任务集合的大小:一个Stage内有多少个partition,就会产生多少个task。Stage又分两种:ShuffleMapStage负责输出shuffle数据供下游Stage读取,ResultStage则是作业的最终计算阶段,直接产出结果。DAGScheduler一次只提交一组无依赖的Stage,等它们全部完成后,再提交依赖它们的下一批Stage。这就是为什么Spark UI上的Stage经常是分批出现的,而不是一次性全部提交。
这里有个很多人容易忽略的点:Stage的切分策略直接决定了task数量。如果你把数据从HDFS读进来后,立刻做了一次repartition(2000),那么这个宽依赖就会把后续所有计算都拆成2000个task。哪怕这只是中间结果,也会给调度器带来很大的任务分发压力。我自己见过一个作业,只是做了两三次repartition,任务数从几百直接飙到几千,调度器的开销成倍增长。
2.2 TaskScheduler和SchedulerBackend的协作方式
Stage切分完成后,DAGScheduler会把Stage里的所有task打包成一个TaskSet,交给TaskScheduler。TaskScheduler需要做几件事:维护每个TaskSet的任务状态、决定哪些task先执行、把每个task分配到具体的Executor上、处理失败重试。而真正跟集群资源打交道的是SchedulerBackend,它负责向资源管理器(YARN、Standalone或K8s)申请Executor,再把Executor上的可用CPU核心和内存反馈给TaskScheduler。
简单理解:SchedulerBackend管“我有多少台机器、多少个核心可以用”,TaskScheduler管“这些核心先让哪个任务用,任务放在哪个核上跑最划算”。两者通过一套回调机制保持通信,每次Executor心跳上报可用资源,TaskScheduler就趁机把符合条件的task推过去。
这套机制还有一个隐藏的逻辑:task并不是一提交就能全部发出去。每个task需要占用一个CPU核心,如果你设置了spark.task.cpus=1,而某个Executor有4个核心,那这个Executor最多同时跑4个task。如果Executor不断心跳上报可用资源,TaskScheduler就会在每次心跳时尝试补发任务。这就是为什么调度延迟往往表现为“Executor有空闲核心,但task停在等待状态”——要么是本地性等待,要么是TaskSet还没轮到被调度。
2.3 任务本地性:调度器为什么要“等待”
Spark调度器在分配任务时,不是随便找个空闲Executor就发。它会优先看数据本地性,目标是“把计算搬到数据所在节点”。这个机制叫延迟调度(Delay Scheduling),也是很多调度问题的根源。
Spark定义了五个本地性级别,从高到低分别是:
| 级别 | 含义 | 说明 |
|---|---|---|
| PROCESS_LOCAL | 进程本地 | task要处理的数据已经缓存在该Executor的BlockManager里,同进程直接读,速度最快 |
| NODE_LOCAL | 节点本地 | 数据在同节点的其他Executor或磁盘上,需要同节点内网络传输或读本地磁盘 |
| RACK_LOCAL | 机架本地 | 数据在同一个机架的其他节点上,需要跨节点、但不出机架 |
| ANY | 任意位置 | 数据在哪都行,跨机架传输,速度最慢 |
调度器在分发task时,
