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时,会优先尝试PROCESS_LOCAL级别,如果找不到满足条件的Executor,它不会立刻降级,而是等一段时间,这个时间就是spark.locality.wait控制的。默认值在Spark 2.x之后是0s?其实不对,默认是3s。之前的版本是3秒,有些发行版或配置下还会被调大。等待时间到了还找不到,才逐级降级到NODE_LOCAL、RACK_LOCAL甚至ANY。
这个“等待”机制的初衷是:为了数据本地性,多等几秒也能接受,因为真正执行时省掉了大量网络IO时间。但如果在动态分配资源的集群上,等待时间设得过大,反而会拖慢整体进度。比如一个作业的许多task无法在PROCESS_LOCAL下找到Executor,调度器就白白等了好几秒,然后才降级执行。这是很多Spark作业“调度慢、执行快”现象的直接原因之一。
2.4 用生活类比把全链路串起来
如果把Spark作业比作一家餐厅的后厨:DAGScheduler是厨师长,他看菜单(DAG)后确定先做哪几道菜(Stage),每道菜要分成多少份(task),然后告诉传菜员(TaskScheduler)上菜顺序;SchedulerBackend是餐厅经理,他掌握着有多少张桌子(Executor)、多少个灶台(CPU核心);传菜员每看到有空灶台,就把合适的菜分过去,但要是菜需要的食材在某个特定位置的冰箱里,他宁愿等一等,也不愿意让菜先从远处冰箱调过来再炒——宁可晚几秒钟上菜,也不想让菜变凉。
这就能解释为什么某些stage的task明明没有在执行,Executor也显示有空闲核心,但作业就是卡着不动。它可能正在等PROCESS_LOCAL的机会,或者等下一批TaskSet被提交。
3. FIFO和FAIR的取舍,以及真正决定任务被谁执行的调度规则
3.1 两种调度模式的工作逻辑
TaskScheduler内部有调度池(Scheduling Pool)的概念。默认情况下,一个Spark应用只有一个池,池内按FIFO(先入先出)调度。FIFO的意思是:先提交的Job先生效,同一个Job内部的Stage按提交顺序排队,前一个Stage没有完成,后一个Stage不会被提交。这种方式实现简单,短作业和长作业混跑时容易发生“长作业霸占资源”的问题。
公平调度模式(FAIR)就针对这个问题做了改进。它允许多个调度池并存,每个池可以设置不同的权重(weight)和最小任务数(minShare),调度器按照权重和当前活动任务数计算每个池的“公平份额”。在同一个Spark应用内部,不同线程提交的Job如果分别属于不同池,就能实现资源分配上的隔离和抢占。
配置上主要是两步:先把spark.scheduler.mode设为FAIR,再提供一个spark.scheduler.allocation.file指向一个XML文件,定义各调度池的权重、最小任务数、调度模式。如果你不指定这个文件,Spark会使用默认的fairscheduler.xml,里面定义了名为default的池。
一个常见的公平调度池配置长这样:
xml复制<?xml version="1.0"?>
<allocations>
<pool name="etl_pool">
<schedulingMode>FAIR</schedulingMode>
<weight>3</weight>
<minShare>10</minShare>
</pool>
<pool name="ad-hoc_pool">
<schedulingMode>FIFO</schedulingMode>
<weight>1</weight>
<minShare>2</minShare>
</pool>
</allocations>
这里etl_pool的权重是ad-hoc_pool的3倍,意味着任务数量相同的前提下,etl_pool里的任务能拿到更多资源。minShare表示该池最少保留多少个task的并发额度,避免高权重池把资源全抢走后,低权重池饿死。
3.2 调度池内部的task选择逻辑
调度池决定的是“哪个Job/Stage优先”,而每个Stage内部的task执行顺序,则由TaskSetManager进一步决定。TaskSetManager会为每个task计算一个优先值,主要参考这几点:data locality(本地性级别更高优先)、stage内部序号(序号越靠前越优先)、失败重试次数(重试次数多会提高优先级)。整体上Spark不是简单地按task编号顺序执行,而是尽可能先把数据本地性好的任务发出去——因为这类任务执行快,能尽早释放资源给后续任务。
注意,这里还有一个容易误解的点:FIFO和FAIR都是Job/Stage级别的调度,不是task级别的。在同一个Stage内,task之间不存在“FIFO还是FAIR”的区别,它们内部的调度逻辑由TaskSetManager统一管理。这一点在面试时经常被问到,如果只说“FAIR能做到task级公平”,那就是概念理解错了。
3.3 不同场景下调度模式选择
从我的实践看,选调度模式没有绝对的标准,但可以按场景判断:
- 单一团队、单一作业类型,用FIFO就够了。FIFO没有额外配置成本,行为也容易预测。
- 多个业务共用一个Spark应用(比如同一个Streaming streaming job里多个业务逻辑),或用Spark SQL做多租户服务,FAIR更合适。
- 集群本身由YARN管理,且YARN层面已经配置了容量调度器,此时Spark内部的调度模式主要影响的是单个Spark应用内部的阶段调度,跨应用资源分配主要是YARN的责任。
我之前在一个数据平台上遇到过这样的情况:同一个Spark Thrift Server,跑着报表组的若干SQL,也跑着临时分析SQL,结果临时分析任务经常把资源占满,报表SQL排队排到天荒地老。后来在Spark应用内配置了FAIR调度池,把报表组的SQL映射到高权重池,临时分析映射到低权重池,排队现象才缓解。
3.4 集群资源不足时的调度行为
调度器还有一个关键行为:当Executor上的可用资源不足以执行某个task时,这个task会进入“等待分配资源”的状态。TaskScheduler会维护一个待分配队列,每次收到Executor心跳时尝试搭配。资源不足和本地性等待不同:本地性等待是“有资源但不是最优位置”,资源不足是“根本没有足够空闲核心”。
判断这两种情况,可以看Spark UI的Executor页面。如果多个Executor的“Active Tasks”列等于可用核心数,说明Executor都满了;如果显示空闲但task堆积在等待队列,那就是调度或者本地性问题。这个区分在定位性能瓶颈时非常重要,我每次排查都会先看一眼这个指标,能省下大把时间。
4. 调度瓶颈定位与参数优化实操
4.1 用Spark UI快速锁定调度等待
直接说结论:Spark UI的Stage详情页是观察调度行为的第一现场。点进任意一个耗时较长的Stage,展开“Event Timeline”,可以看到每个task从“创建(Created)”到“调度(Scheduled)”、“执行(Running)”再到“完成(Finished)”的时间线。如果一个task在Created阶段停留很久才进入Running,那就说明卡在了调度环节。
再叠加两个指标一起看:一个是任务本地性级别的分布,页面底部会列出每个本地性级别下的任务数量;另一个是“Task Deserialization Time”和“Result Serialization Time”——这两个时间极短,不是瓶颈,但如果调度等待时间占据了总耗时的一半以上,基本可以断定调度环节出了问题。
我一般会在作业运行前打开“Spark Jobs”页面,点中耗时最长的Job,然后逐个看它的Stage耗时。把每个Stage的“Scheduler Delay”这一列调出来(新版Spark UI有这个字段),如果某Stage的Scheduler Delay占Stage总耗时的30%以上,就值得继续深挖。
4.2 核心参数:从默认值调到适合你的集群
Spark关于调度可调的参数非常多,但真正值得反复调整的核心集中在下面这张表里:
| 参数名 | 默认值 | 作用 | 建议调整方向 |
|---|---|---|---|
spark.locality.wait |
3s | 本地性降级前的总等待时间 | 集群网络快、数据本地性差时调小到1s或0s |
spark.locality.wait.process |
3s | PROCESS_LOCAL的等待时间 | 可单独设置,比总等待时间优先 |
spark.locality.wait.node |
3s | NODE_LOCAL的等待时间 | 可单独设置 |
spark.locality.wait.rack |
3s | RACK_LOCAL的等待时间 | 机架感知网络延迟高时设置 |
spark.scheduler.maxRegisteredResourcesWaitingTime |
30s | 等待所有Executor注册的最长时间 | 作业Executor数量多时适当调大 |
spark.scheduler.minRegisteredResourcesRatio |
0.8(YARN上) | 已注册资源占期望资源的比例,达到比例才会启动调度 | Executor数量大时建议降低到0.6左右 |
spark.task.maxFailures |
4 | 单个task最大失败次数 | 调度频繁失败时检查网络、资源问题,不要盲目调大 |
spark.executor.heartbeatInterval |
10s | Executor心跳间隔 | 调度器靠心跳感知空闲资源,间隔太长调度延迟会变高 |
spark.scheduler.mode |
FIFO | 调度模式 | 多业务共用时改FAIR |
以spark.scheduler.minRegisteredResourcesRatio为例:YARN模式下,Spark要先等Executor注册完毕才开始调度,默认达到80%注册率就开始跑。如果一个作业申请了200个Executor,启动速度又慢,你可能会发现前面几分钟作业完全没动静。把比例调到0.6,可以让作业在120个Executor注册后就开始调度,前期的资源利用率会明显提升。
但这并不意味着所有参数都调小就是好。spark.locality.wait如果设成0,调度器会立刻放弃数据本地性,把task随便扔到任意节点执行,虽然task启动快了,但shuffle数据的网络传输量会暴增,总耗时反而可能变长。我见过一个例子:把spark.locality.wait从3s改成0s后,某个Stage的Shuffle Read时间从12秒涨到45秒,整体得不偿失。所以调整前必须观察任务的Input/Output数据位置和数据量,数据量大且本地性强的,反而要保留甚至增大等待时间。
4.3 并行度设置对调度压力的影响
并行度直接决定了task数量。spark.default.parallelism控制RDD默认分区数(集群所有Executor核心总数的2倍,这是默认逻辑),spark.sql.shuffle.partitions控制SQL阶段shuffle后的分区数,默认200。这两个参数如果设置不合理,调度器就会面对过多或过少的task。
我在实际业务里有个经验公式:单stage的task数量,建议控制在集群可用核心总数的2~3倍左右。比如你申请了50个Executor,每个4核,总共200个核心,那一个Stage的task数量在400~600之间比较合理。如果task只有50个,每个任务要处理的数据量太大,JVM压力也大;如果task有2000个,调度器每次心跳分配任务、每个task都要做序列化和网络通信,调度的开销就会吞噬掉并行度带来的收益。
4.4 动态资源分配:省资源但要防调度抖动
spark.dynamicAllocation.enabled开启后,Spark会根据任务积压情况动态增加或缩减Executor。这样做能避免作业高峰期资源不够、低峰期资源浪费,但有个副作用:Executor的动态增减会频繁触发task的重新调度和shuffle文件的重新计算。
动态资源分配开启时,建议一并设置spark.dynamicAllocation.executorIdleTimeout和spark.dynamicAllocation.cachedExecutorIdleTimeout,前者控制无任务空闲多久后释放Executor,后者控制缓存了RDD数据的Executor的空闲时间。默认值分别是60s和60s(Spark 3.x),对于频繁读写缓存数据的作业,可以调大到120s以上,避免Executor刚把数据缓存好就被回收,下次又得重新拉取。
另一个注意点:动态资源分配和本地性等待会互相影响。Executor数量变化时,原本满足PROCESS_LOCAL的Executor可能已经被回收了,task就只能从NODE_LOCAL甚至ANY级别执行。如果你发现开启动态分配后作业反而变慢了,先打开Executors页面看Executor数量曲线——如果曲线呈锯齿状频繁升降,那就需要把executorIdleTimeout调大,或者干脆关闭动态分配。
5. 一次真实调度优化的完整复盘:参数调整前后对比
5.1 现象与初判
这个作业是每天凌晨跑的聚合ETL,SQL逻辑不复杂,但涉及好几张大表,最后的写入目标是一个结果表。跑完要12分钟左右,业务方一直在催。从Spark UI看,Job一共有5个Stage,前面2个Stage很快,第3个Stage耗了接近8分钟,明显是瓶颈。点进去看,1500个task的“Scheduler Delay”平均达到6.5秒/任务,而且task主要在NODE_LOCAL级别执行,PROCESS_LOCAL的数量很少。
我当时判断有两点:一是task总数偏多,1500个task,但集群可用核心只有160个,明显不符合2~3倍核心数的经验值;二是PROCESS_LOCAL命中率太低,导致调度器频繁等待降级,积累了大量的调度延迟。再配合Executor数量看,申请的是40个Executor,每个4核,160核,1500个task意味着每个task处理的平均数据量很小,按数据总量算,每个task可能只需要跑0.5秒,但调度等待就要好几秒,效率自然上不去。
5.2 调整过程与明细
我先从减少task数量入手:把SQL里的repartition(2000)改成coalesce(600),这一步将第3个Stage的task数量从1500压到600。原先那个repartition的用途是防止数据倾斜,但实际看数据分布并没有那么极端,600个分区也够用。
第二步调spark.locality.wait。集群节点之间的网络是万兆交换机,跨节点传输数据的时间可以接受,原先3秒的等待对本地性收益影响不大,反而让任务迟迟无法降级。我把总等待时间调成1s,process和node的等待时间都设成1s,让调度器更快降级到可执行位置。
第三步调spark.scheduler.minRegisteredResourcesRatio。这个作业申请了40个Executor,资源管理器启动它们需要时间,默认0.8意味着要等32个Executor注册完毕才开始调度。我把它降到0.7,让作业在28个Executor注册后就开始跑,前期空转时间少了将近1分钟。
配置最终如下:
bash复制spark-submit \
--class com.example.etl.DailyAggJob \
--master yarn \
--deploy-mode cluster \
--executor-memory 8g \
--executor-cores 4 \
--num-executors 40 \
--conf spark.locality.wait=1s \
--conf spark.locality.wait.process=1s \
--conf spark.locality.wait.node=1s \
--conf spark.scheduler.minRegisteredResourcesRatio=0.7 \
--conf spark.sql.shuffle.partitions=600 \
--conf spark.default.parallelism=600 \
/path/to/etl-job.jar
5.3 效果数据对比
调整后我记录了同一份数据、同一集群规模下优化前后的关键指标:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 总耗时 | 12分20秒 | 4分38秒 | 降低62% |
| 第3个Stage耗时 | 8分10秒 | 2分05秒 | 降低74% |
| 第3个Stage Scheduler Delay均值 | 6.5s | 0.8s | 降低88% |
| PROCESS_LOCAL任务数 | 120 | 180 | 提升50% |
| NODE_LOCAL任务数 | 1200 | 360 | 降低70% |
| 总Shuffle Read数据量 | 约820GB | 约790GB | 基本持平 |
有意思的是,把并行度降低后,每个task处理的数据量变大,task本身执行时间从0.5秒涨到1.4秒,但调度延迟降得更猛,整体反而快了很多。这印证了一个结论:调度开销在task粒度过小的时候,会成为最大的隐藏瓶颈,优化调度往往比单纯堆资源更有效。
5.4 这次复盘给到我的三个通用经验
第一个经验:task粒度过小时,不要盲目堆并行度。判断粒度是否合理,看单个task的执行时间,如果大量task的执行时间低于1秒,说明切分太碎,合并分区带来的收益往往大于继续细分。
第二个经验:本地性等待时间要结合集群网络带宽来判断,不要照抄默认值。万兆网卡下,跨节点传输1GB数据的成本远低于等待3秒调度降级的成本,所以等待时间可以大胆往下调;但如果是千兆网甚至跨地域机房的场景,反而该调大等待时间保住数据本地性。
第三个经验:Scheduler Delay这个指标很有价值,但不要只看平均值。建议看P95甚至P99,有时候平均值不高,但尾部任务延迟很严重,作业的总耗时就被这些长尾task拖住了。尾部延迟高往往对应个别Executor负载不均衡或者本地性极差的任务,这时要单独处理那几个task对应的分区。
6. 容易被误判为调度问题的坑
6.1 数据倾斜的“假调度等待”
调度慢和数据倾斜的表现很像:都是一批task迟迟不结束,看起来像某些task“抢不到资源”。但本质上,数据倾斜是一个task要处理的数据量远大于其他task,执行时间自然被拉长,跟调度器没有关系。
区分方法也很简单:看Spark UI里每个task的“Duration”列。如果大部分task已经完成,剩下几个task的执行时间特别长,那基本可以断定是数据倾斜。如果在Event Timeline里看到大量task都在Created/Scheduled状态,而真正执行的task很少,那才是调度问题。数据倾斜的解法是加盐、两阶段聚合或调整分区键,跟调spark.locality.wait没有关系。
6.2 Executor心跳丢失导致的重复调度
生产集群偶尔会出现Executor失联的情况,可能是节点网络抖动、内存溢出后进程被杀,也可能是心跳间隔太长被集群误判为超时。TaskScheduler收到超时通知后,会把该Executor上尚未完成的任务重新标记为待调度,再发给其他Executor。如果同一批task连续被重发多次,就会造成“task执行了一会儿,又被重新调度”的诡异现象。
我在一次排查中发现某作业的Shuffle Read量特别大,数据量明明没变,但读了好几次。后来看日志才发现是部分Executor心跳超时,触发task重试,重试后的task重新拉取shuffle数据,产生了重复IO。这类问题靠调整调度参数解决不了,得从资源稳定性上着手,比如加大spark.executor.heartbeatInterval、给Executor配置足够的内存、监控节点负载。
6.3 小文件过多引发的调度风暴
Spark SQL从HDFS读小文件时,有多少个小文件读进来就会产生多少个分区,进而产生多少个task。如果上游产生了几十万个小文件,哪怕每个文件只有几百KB,Spark也会生成几十万个task。此时调度器频繁分配任务、Executor频繁启动和停止任务,CPU和网络都被调度事件本身消耗掉了。
优化思路不是在调度层面,而是从数据写入侧做合并:写Hive表时用hive.merge.mapfiles=true、hive.merge.size.per.task=256000000,或者Spark写文件时控制输出分区数。小文件问题不解决,任何调度参数都治标不治本。
6.4 多租户共享集群的资源配置冲突
多租户场景下,另一个团队的作业如果一直在抢占资源,你的Spark作业申请到的Executor可能反复变化。此时你的作业会频繁进入资源等待状态,看起来像TaskScheduler不干活,实际是资源根本拿不满。这种问题要去看YARN的资源队列使用率,而不是盯着Spark参数调。
我的建议是:先确认你自己作业的资源是否达到期望值,再判断调度参数是否有问题。顺序不能反。排障顺序应该是:资源申请是否满足 → Executor是否健康 → 调度延迟是否高 → 执行时间是否合理 → 数据倾斜是否存在。如果一上来就调调度参数,很容易在错误的方向上折腾很久。
6.5 动态资源分配与调度延迟的叠加效应
单独开启动态资源分配,或者单独调低本地性等待,都可能不产生明显问题。两个配置叠加后,协调不好就会出现“调度器想等本地性,但动态资源管理器因为任务积压量下降而回收Executor,导致本地性命中率进一步变差”的恶性循环。最终表现为作业越跑越慢,资源越用越少。
遇到这种叠加效应,我一般会先把动态资源分配关闭,压测纯调度参数的优化效果,再重新开出动态资源分配,对比两者叠加后的表现。不要同时修改太多参数,一次改一个,用控制变量法确认每一项的贡献,这是最快也最不容易翻车的做法。
最后分享两个实用建议
如果你刚接手一个性能不佳的Spark作业,别急着调参数。先花十分钟看Spark UI,确认瓶颈在哪个Stage,锁定Stage后再看Scheduler Delay、本地性分布和task执行时长,三分钟就能判断问题在调度、资源、还是数据本身。这个排查路径我重复使用过无数次,比拍脑袋堆参数靠谱得多。另一个建议是:调度优化不是一次性动作,而是一个持续观察的过程。作业的数据量、分区数、集群规模一变,之前的参数就可能不再合适,建议把关键调度参数纳入作业的版本管理,每次调整都记录前后效果,慢慢地你就能形成一套适合自己集群的基线配置。
