大数据架构里的并行度调优,说到底就是一件事:在有限的计算资源下,把任务拆分得刚刚好,让每一份资源都被高效利用,而不是互相等待或抢来抢去。
很多刚接触分布式计算的人,一上来就被“并行度”“分区数”“并发度”“资源规格”这些词绕晕,以为把它们都调大就是优化。结果呢?资源翻倍,任务不但没变快,反而更慢了——调度开销变大,网络传输变多,小任务排队时间甚至比计算时间还长。我见过不少因为这个踩坑的案例,集群里CPU飙得老高,但Spark作业的进度条就是纹丝不动。本文就围绕大数据架构中的任务并行与并发控制,把并行度背后的原理、常见的调优思路、实操中会遇到的问题一次性说清楚。适合正在做离线数仓、实时计算,或者刚接触分布式计算、准备好好调一调自己作业的人。
1. 并行度到底调的是什么
1.1 并行度、并发度、分区数的区别
这三个概念容易混淆,但本质完全不同。
并行度(Parallelism),指的是在同一时刻能够同时执行的计算任务数量。比如一个Spark作业,你能同时跑多少个task,这个数值就等于执行器(executor)的资源槽位总和。并行度决定了计算的“宽度”。
并发度(Concurrency),在大数据语境下更侧重于指同时处于运行状态的作业(job)或任务(task)数量。它关注的是有多少任务在同时推进,而实际是否能同时执行受限于并行度。
分区数(Partitions),是数据在计算框架里的物理划分。RDD的分区数直接决定了task的数量,一个分区对应一个task。所以很多时候我们所说的“提高并行度”,实际上是在调整分区数。
从机制上看,它们的关系可以这样理解:你在任务层面设置了高并发,但如果机器的CPU核数和executor数量不够,任务就会排队;你增加了分区数,但如果运行资源没跟上,同样会在调度器那里卡住。调优的本质,是在这几个变量之间找到那个平衡点。
1.2 一个任务的完整执行链路
要理解并行度调优,得先看清楚一个任务从提交到运行经历了什么。
以Spark为例,作业提交后,Driver会将作业拆分为多个job,每个job再按shuffle或stage边界拆分为stage,每个stage内部进一步拆分为task。这些task由Driver分配到各个节点的executor上执行。
关键点是,task并不是提交了就能马上运行的。executor里有可用的计算槽位(core),每个槽位同一时刻只能运行一个task。如果executor数量少,或者槽位被占用,剩下的task只能排队等调度。
所以并行度的本质是:你的数据切分粒度 × 你的计算资源可用性 = 实际运行效率。
实际调优时,我习惯先把两个问题分开看:
- 数据层面:分区数够不够,有没有数据倾斜;
- 资源层面:executor数量、核数、内存是否匹配分区数。
资源够了再调分区数,分区数合理了再看倾斜,很多看似“并行度不够”的问题,其实出在资源与分区的匹配度上。
2. 核心参数配置与原理
2.1 Spark作业并行度的参数层级
Spark的并行度配置涉及多层,而且每一步都有自己的独立参数。很多人调的并行度不生效,就是因为参数层级用错了。
第一层是配置全局默认并行度,spark.default.parallelism。如果不显式设置,对于RDD操作,它就是所有executor上总核数的2倍(旧版本)或者根据具体操作自动推断(新版本)。
第二层是应用级别。每个RDD本身的分区数由数据源和操作决定。从HDFS读文件时,分区数取决于文件块数与InputFormat的切片逻辑;调用repartition(n)、coalesce(n)、groupBy、join这类宽依赖算子时,分区数会发生变化,不同的算子对应的shuffle分区数参数也不同,最常见的是spark.sql.shuffle.partitions(默认200)。
第三层是执行阶段。每个stage的task数量由最终RDD分区数决定,一个stage的task上限受限于executor的资源槽位总数。并行度设置得再高,如果资源总数不足,task也会排队等待。
所以,调并行度的起点不是去改任一参数,而是先想清楚:我的数据量有多大?executor能提供的总并发执行能力是多少?
2.2 不同计算引擎的参数对比
大数据架构里不只有Spark,Flink、MapReduce、Hive都会有类似的并行度设置,其实质同样是控制资源与任务的匹配。它们的核心参数如下。
| 引擎 | 核心参数 | 作用范围 | 备注 |
|---|---|---|---|
| Spark SQL | spark.sql.shuffle.partitions | 每个shuffle阶段的默认分区数 | 默认200,大作业通常不够 |
| Spark RDD | spark.default.parallelism | 无shuffle操作的默认并行度 | 小文件或纯转换场景适用 |
| Spark 动态资源 | spark.dynamicAllocation.enabled | 根据负载动态增减executor | 开启后需配套设置单executor核数 |
| Flink | parallelism.default | 算子的全局默认并行度 | 可在算子链关键节点精细设置并控制 |
| Flink | execution.buffer-timeout | 并发任务间的数据传输超时 | 微批时增大可降低交互瓶颈 |
| Hive/MapReduce | mapreduce.job.reduces | 控制reduce阶段并发 | 通常设为核心数的1~2倍 |
| Hive | hive.exec.parallel | 并行执行stage | 需谨慎,会挤占资源 |
2.3 动态分配与静态分配的选择
这里有一个很实际的分歧:是预先分配好固定资源,还是让框架按负载动态调整?
静态分配的优点是稳定、可预测。适合周期性ETL任务,数据量波动不大时,固定资源加上合理的并行度设置,每个批次的耗时都很平稳,方便做SLA保障。
动态分配的优点则是资源利用率高。适合数据量波动明显的场景,数据高峰时自动增加executor,低谷时减少executor,避免占用闲置资源。
我的做法是分场景:数仓核心链路、需要稳定产出时间的任务,用静态分配;探索性分析、临时脚本、报表类任务,开启动态分配。
动态分配里有几个细节值得注意:
- 设置最小和最大executor数量时,通常我会把最大值设为所需峰值的120%;
- shuffle服务的相关参数需要额外配置,否则在动态调整executor时可能因中间文件无法访问而报错;
- 队列资源的限制必须提前确认,否则动态分配无法扩展。
3. 实操调优:从环境到参数
3.1 确认集群资源与任务上限
调优前,先摸清家底,否则调来调去只是隔靴搔痒。
我做过一个小实验来理解“核数”与“并行度上限”之间的关系:一台8核的机器上启动两个executor,每个executor是2核,意味着每个executor可以同时运行2个task,总计并发能力是4个task。然而一旦task数量到达5个以上,就有task在排队等待空闲core了。
这里要注意几个常见的资源参数匹配陷阱:
--num-executors:总的executor数量。如果设得过多,超过了队列最大资源配额,任务根本提交不上;--executor-cores:每个executor的核数。这个值设置太高,会导致碎片化资源,难以分配到整节点;--executor-memory:过大时容易触发OOM,过小时任务处理能力弱;- 集群通常还有单用户的最大资源限制,超过后同样会被拒绝或阻塞。
我的建议是先用集群监控页面,查看当前队列的可用资源、已被占用的资源。比如一个队列总共有100个vcore,已被占用60个,那么你最多只能用40个。在这个基础上,并行度要与此对齐。
3.2 估算合理的任务并发上限
一个可以直接参考的计算思路是:
可用执行槽位(核数)= 单任务可申请的总核数上限在Spark中,排队的task数量,往往会积压在executor的调度队列里。通过Spark UI的“Active Stages”页面,可以直观看到每个stage的task数量和已完成task数量。如果你发现一个stage里90%的task都完成了,还有10%的task一直没跑完,大概率不是并行度问题,而是数据倾斜问题——少数key对应的数据量远超其他key,导致个别task负载过重。
如果想要单纯估算并行度的“合理上限”,可以先这样算:
- 您的核心executor核数总和,比如10个executor,每个3核,那么并行执行上限就是30个task;
- 对于数据量大、无shuffle的纯扫描任务,我习惯让task数 = 资源的2~3倍,这样做是为了让CPU在task切换时能更好的隐藏IO等待;
- 对于有shuffle的任务,shuffle分区数则是多少,就会决定下一个stage的task数量,因此结合下游的处理逻辑一起推敲,上下游才能得到均衡。
3.3 一个示例:从240个分区到120个分区的调整
以前处理过一个大表join场景,数据量约2TB,Spark SQL默认shuffle分区数200,运行时长一直稳定在55分钟左右。
任务配置是executor 20个,每个5核,总并行槽位100个。问题很明显:200个task却只有100个槽位,等于一半的task在排队,而且由于每个task处理的数据量较大,GC时间不短。
调整思路很简单:把spark.sql.shuffle.partitions从200降到120,让task数和槽位数匹配,同时每个task处理的数据量略有增加。结果呢?GC时间从13分钟降到了7分钟,总时长从55分钟降到约32分钟。
原因在于:分区数从200降到120后,每个task处理的数据量更大了,但并没有超过单task的合理处理范围,反而减少了task调度与序列化的开销。这说明并行度调参时,并非越大越好,而是要让每个task的工作量与它的运行资源相匹配。
3.4 SQL层面控制并行度
部分参数主要影响执行计划。拿Spark SQL来说,除了调spark.sql.shuffle.partitions,SQL本身的写法也会改变并行度表现。
- 子查询与CTE:如果一段SQL里,一个CTE被引用多次,Spark会重复计算。在某些情况下,可以增加一个中间表并写缓存(cache),可以让后续读取并行化,从而减少重复计算带来的等待;
- 小表与大表join:如果不小心关掉了自动广播,会发生完整shuffle。手动加
/*+ BROADCAST */提示,能避免放大下游shuffle的task数量; - group by 与 distinct:都会触发shuffle,如果group by的key区分度不高,就会遇到单个task处理量过高,也就是倾斜。可以在SQL里给key加随机前缀,做两阶段聚合,一定程度上缓解倾斜。
SQL改写带来的效果非常直接,也是并行度调优中“不需要改配置就能提升性能”的高性价比路径。
4. 资源竞争与任务并发冲突
4.1 多任务同时跑时的资源分配问题
在真实的生产集群里,不太可能只有你一个人的任务在跑。同一个队列中的多个作业并行执行时,相互之间的资源竞争才是常态。这也正是“任务并发控制”中容易被忽视但又非常影响稳定性的部分。
两个任务同时跑,每个都申请最大资源,结果导致谁都无法拿到足够的资源,双双阻塞,这种情况在共享集群里非常常见。
针对这类场景,比较有用的调控方式有以下几种:
- 配置任务优先级:给关键链路任务设置较高的优先级,让调度器在资源紧张时优先保障;
- 限制单个作业的资源上限,而不是放任它去无限申请,相当于给并发控制设置了边界;
- 用队列隔离:不同团队、不同业务设计不同的资源队列,避免相互挤兑;
- 开启公平调度:这类调度模式下多个作业可均匀获得资源,而不是第一个作业占满全部资源后第二个再等待。
4.2 控制并发度的常用手段
设置并行度参数是控制单个作业内并发度的方式,控制整个集群中的任务并发程度则要考虑更多:
限制同一时间提交的作业数量。很多平台支持全局调度限制,同一队列下同时运行的作业数上限,设得太大会导致资源争抢,设得太小则导致资源闲置。
控制每个作业的并行度上限。同一队列总资源固定,每个作业的并发度之和不可能无限增长,提前规划好各作业的并行度上限是避免资源超卖的唯一方式。
错峰执行主要作业。将不同团队的跑批时间错开,避免高峰期全部挤在一起。这是最简单并且见效快不过也是执行成本高的做法。
4.3 动态调整的时机与策略
生产环境中的资源常常随时间变化,比如早晨的报表任务少,午后的数据同步任务多,夜间则集中跑批。一成不变的并行度配置很难适应这种波动,因此很多团队会选择动态资源机制。
我在实际使用中有几个判断原则:
- 平均CPU使用率长时间低于20%时,可以适当调小资源,节约成本;
- 当任务的平均排队时间超过单任务本身的执行时间时,说明并行度已经严重超过资源承载量,需要主动降并行度或扩容;
- 动态调整要留有缓冲,避免频繁上下波动造成不必要的资源浪费。
5. 常见问题与排查技巧
5.1 并行度明明调高了,任务反而更慢
这个现象第一次遇到时可能需要花点时间才能想明白:应该是更快的呀?
从原理上解释,并行度提高意味着task数量增多,每个task处理的数据量变小,但task调度、序列化、shuffle读数据的开销都是固定成本。一个100MB的数据块,切成2块和切成10块,后者的总耗时未必缩短,因为会有更多的网络与磁盘IO来传递那些小的分片。
所以遇到“并行度调高但更慢”的情况,先做几件事:
- 在Spark UI里看stage的“Shuffle Read Size/Records”,确认单个task的shuffle数据量是不是太小;
- 看“Scheduler Delay”,如果明显增大,说明是调度瓶颈;
- 看“GC Time”,如果GC时间占比增长,说明内存压力变大。
这三种情况都指向了并行度调过头——单个task的数据量过小,固定开销占比过高。解决方法也很直接:适度降低并行度,或者增加单task的处理数据量。
还有一个背锅重灾区是小文件问题。如果上游每天的产出物是几千个小文件,你读入时分区数天然就很大,再强行调低并行度也执行不下去,因为HDFS切片逻辑会生成很多空分区,白白浪费计算资源。这种问题要从上游治理,用repartition或coalesce及时合并小文件。
5.2 数据倾斜导致的“长尾task”
并行度调优遇到的另一个大坑是数据倾斜,即个别task处理的数据量远大于其他task。表现很典型:一个stage里绝大部分task几十秒就完成了,只有个别task跑了十几分钟甚至更久。
排查方法:
- 观察Spark UI中task的数据分布,找到运行时间最长的那几个task;
- 确认这几个task的输入数据量是否为其他task的数倍以上;
- 如果确认是倾斜,再对key的分布情况做具体的分析(最常见的是key为null,或者某个热点key的值)。
常用的解决手段:
- 加盐(salting):给倾斜的key加随机前缀,将大key拆成多个子key;
- 两阶段聚合:先局部聚合,再全局聚合,适用于group by类型的倾斜;
- 广播小表:如果join的一端很小,直接触发broadcast join,绕过shuffle阶段的倾斜;
- 过滤空key:如果是业务上的无效数据,直接清理掉。
5.3 任务排队与资源不足的排查路径
当你提交任务后,发现作业一直处于“Accepted”或“Pending”状态,而不是正常执行,大概率是资源问题。
排查路径:
- 查看队列的资源使用情况,确认是否有可用资源;
- 查看你的作业申请的资源量是否超过了队列配额;
- 检查是否同时跑的作业太多,把队列占满了;
- 如果上面三步都正常,再看调度器配置,确认优先级或超时相关设定。
资源不足时,我一般会做一件事:从YARN的“Running”和“Pending”页面对比,看是vcore不足还是内存不足。很多时候队列内存耗尽,但vcore还有很多空闲,说明有人内存申请过大了。这类问题用资源隔离或限制单个任务内存上限来控制,可以在一定程度上规避。
5.4 一个印象深刻的参数调优案例
有一次处理一个明细数据统计任务,输入数据约500GB,Hive里跑全量聚合,执行时间一度超过2小时,一直查不出原因。
查看执行计划后发现,有几处join用到了大表join大表,默认触发全shuffle,产生了几千个task。但executor资源只有30个vcore,导致一个stage里同时运行的任务也就只有30个,剩余tasks全部排队。
我没有急着调整并行度,而是先分析了两张大表的关联字段,发现可以先将其中一张大表的数据提前聚合过滤,让数据量减少到原来的五分之一,再做join。同时针对性设置并行度到90,使得每个task的负载与可用核心数在合理比例上。
改动后的作业运行时间下降到了约40分钟。这个案例给我们的经验是:并行度调优,首先从减少数据量开始,再考虑增加资源或task数量。
6. 经验心得与避坑建议
6.1 调优的层次化思维
并行度调优不是单纯调一个参数。我在实操中逐渐形成了一套顺序,基本能覆盖大多数场景:
- 先看数据规模:每天增量多少,总数据量多少,是否分布均匀;
- 再看资源情况:集群空闲情况、分配配额、同一时间并行的其他任务情况;
- 然后调并行度:根据资源和数据规模综合设定task数与并发度;
- 最后验证和观测:运行过程中的稳定性、有无倾斜和长尾,再回去微调。
把这个顺序固定下来,比拿到参数就“调大试一下”要靠谱得多,也更好排查问题。
6.2 逆经验——过度调优的伤害
调整并行度时,“过度调优”是一种很隐蔽但客观存在的问题。常见做法是把并行度设成很大,觉得这样总能利用更多资源,但在某些类型任务中反而会适得其反。尤其是迭代计算、频繁切换状态的作业,并行度过大时,通信和状态管理的开销会盖过计算收益。
在这种场景,我反而会把并行度下调,让计算更集中,减少数据在节点之间的传输,往往效果更稳定。
6.3 监控指标要盯哪些
最后分享几个我在调优时重点盯的监控指标,新上手的朋友建议先熟悉它们:
- 输入数据量:确认每个task处理的数据量是否在合理范围,一般在100MB~1GB之间比较合适;
- Shuffle Read Size:shuffle读的数据量过大,说明分区数或数据分布存在不合理;
- GC时间:GC占执行时间的比例超过10%,就要考虑增加内存或者降低并行度;
- 调度延迟:如果task运行时长期处于等待调度状态,并行度超过资源承受能力了;
- 执行时间分布:看是不是有长尾,长尾则是倾斜,不是并行度调整能直接解决的。
做并行度调优,别老想着“越大越好”或“越多越好”,核心逻辑是匹配——数据量、资源槽位、task数量、单task负载,这几个维度匹配上了,任务自然又快又稳。我自己的习惯是把每次调优的参数、效果、原因记录下来,前几次可能要靠经验和大量试验,但积累多了之后,基本看一眼任务规模,就能大致判断出合理的并行度区间了。