4. Spark 作业调优:内存与CPU的平衡,避免数据倾斜,合理设置并行度与shuffle分区数

Spark 作业的成本,说白了就是资源消耗除以产出效率。很多团队一上来就堆资源,内存给 64G、CPU 开 64 核,结果跑得慢还贵。我见过太多这样的案例了。

调优的核心就三件事:让 CPU 别闲着,让内存别爆掉,让数据别歪着跑。下面我一个个说。

4.1 内存与CPU的平衡:别让任何一方空转

Spark 的 Executor 里,CPU 和内存是共生的。你给 8 核配 32G 内存,和给 8 核配 8G 内存,跑出来的效果天差地别。

我个人习惯先看一个指标:GC 时间占比。如果 Full GC 超过 10%,说明内存给少了,CPU 在等内存回收。反过来,如果 CPU 利用率不到 70%,内存却用了 90%,那就是内存给多了,浪费钱。

我的经验公式:
单 Executor 内存 = 单核内存 × 核数 × 1.5
单核内存通常取 2G~4G。比如 4 核 Executor,给 12G~24G 比较合理。

举个例子。我之前优化过一个 ETL 作业,原来配置是 10 个 Executor,每个 8 核 64G。GC 时间占 18%,CPU 平均 45%。我改成 20 个 Executor,每个 4 核 16G。结果呢?

配置 Executor 数 单 Executor 资源 GC 时间占比 CPU 利用率 总耗时 成本(按小时)
原配置 10 8核 / 64G 18% 45% 2.1h 高
优化后 20 4核 / 16G 4% 82% 1.2h 降低 43%

你看,资源总量没变,但跑得快了,成本反而降了。为什么?因为小 Executor 的 GC 压力小,CPU 不用等内存,任务调度也更均匀。

注意:Executor 不是越小越好。单 Executor 少于 2 核时,任务并发度太低,反而会引入调度开销。我一般控制在 2~8 核之间。

4.2 数据倾斜:成本黑洞的元凶

数据倾斜是什么?就是某个分区数据量特别大,其他分区很小。结果一个 task 跑 30 分钟,其他 task 几秒就跑完了。你想想看,集群资源被一个 task 占着,其他资源空转,成本能不爆炸吗?

我曾经遇到过一个极端案例:一个 join 操作,因为 key 分布不均,导致一个分区有 2 亿条记录,其他分区平均只有 10 万条。那个 task 跑了 40 分钟,整个作业花了 1 小时。优化后只用了 8 分钟。

怎么发现数据倾斜?看 Spark UI 的 Stage 页面。如果某个 task 的 Shuffle Read 量是其他 task 的 10 倍以上,基本就是倾斜了。

4.2.1 常见解决方案

  • 加盐(Salting):给倾斜的 key 加随机前缀,打散到更多分区。适合 join 场景。
  • 调整并行度:增大 spark.sql.shuffle.partitions,让每个分区数据量变小。
  • 广播小表:如果一张表很小(比如几百 MB),用 broadcast join 避免 shuffle。
  • 两阶段聚合:先局部聚合,再全局聚合。适合 groupBy 场景。
加盐示例(Scala):
// 第一步:给倾斜 key 加随机前缀(0~9)
val saltedDF = skewedDF.withColumn(
  "salted_key",
  concat(col("key"), lit("_"), (rand() * 10).cast("int"))
)

// 第二步:按加盐 key 聚合
val partialResult = saltedDF.groupBy("salted_key").agg(sum("value"))

// 第三步:去掉前缀,再聚合
val finalResult = partialResult.withColumn(
  "original_key",
  substring_index(col("salted_key"), "_", 1)
).groupBy("original_key").agg(sum("value"))

嗯,这里要注意:加盐的粒度要控制好。我一般加 10~100 个盐值,太多会导致小文件问题。

4.3 合理设置并行度与shuffle分区数

并行度决定了 Spark 同时跑多少个 task。设置不合理,要么资源浪费,要么任务排队。

核心原则:并行度 = 集群可用 CPU 核数 × 2~3 倍。比如你有 100 个核,并行度设 200~300 比较合适。为什么留余量?因为有些 task 跑得快,有些跑得慢,多点 task 可以避免最后几个 task 拖慢整体。

4.3.1 shuffle 分区数

shuffle 分区数由 spark.sql.shuffle.partitions 控制,默认 200。这个值不是越大越好。

场景 建议分区数 原因
小数据量(< 1GB) 50~100 分区太多会产生大量小文件,增加调度开销
中等数据量(1GB~100GB) 200~500 平衡并行度与文件大小
大数据量(> 100GB) 500~2000 保证每个分区数据量在 100MB~200MB 之间

我个人的习惯是:每个分区输出 100MB~200MB。这样下游读取时,文件大小适中,既不会产生太多小文件,也不会因为文件太大导致读取慢。

快速估算公式:
分区数 = 输入数据量 / 150MB
比如输入 30GB,分区数 ≈ 30 × 1024 / 150 ≈ 200 个。

4.4 核心调优参数一览

下面是我常用的参数配置,你可以直接参考:

参数 推荐值 说明
spark.executor.cores 2~8 单 Executor 核数,建议 4 或 8
spark.executor.memory 4G~32G 按 2G~4G/核 计算
spark.sql.shuffle.partitions 200~2000 根据数据量调整,默认 200 通常不够
spark.default.parallelism 集群总核数 × 2~3 RDD 操作的默认并行度
spark.shuffle.file.buffer 64k shuffle 写缓冲区,增大可减少磁盘 IO
spark.reducer.maxSizeInFlight 96m shuffle 读缓冲区,增大可减少网络请求

4.5 本章知识体系

下面这张图总结了 Spark 作业调优的核心逻辑:

Spark 作业调优知识体系 Spark 作业调优核心逻辑 内存与CPU平衡 避免数据倾斜 并行度与分区数 GC 时间占比 < 10% CPU 利用率 > 70% 单 Executor 2~8 核 加盐(Salting) 广播小表(Broadcast Join) 两阶段聚合 并行度 = 核数 × 2~3 分区输出 100MB~200MB shuffle.partitions 动态调整 成本降低 + 性能提升 资源利用率最大化,作业耗时最小化

4.6 避坑指南

最后分享几个我踩过的坑:

  • 别盲目增大 shuffle 分区数。我曾经把分区数从 200 调到 2000,结果产生了大量小文件,下游读取反而更慢了。分区数不是越大越好,要跟数据量匹配。
  • 注意动态资源分配。开启 spark.dynamicAllocation.enabled 后,Spark 会根据负载自动调整 Executor 数量。但如果你设置了 spark.executor.cores 为 1,动态分配反而会频繁启停 Executor,增加开销。我建议至少设 2 核。
  • 数据倾斜别只靠加盐。加盐虽然好用,但会引入额外的聚合步骤。如果倾斜不严重,先试试增大分区数,成本更低。
一句话总结:
Spark 调优不是堆资源,而是让每一份资源都干最合适的活。内存和 CPU 要匹配,数据要均匀,并行度要合理。做到这三点,成本自然就降下来了。