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 不用等内存,任务调度也更均匀。
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 场景。
// 第一步:给倾斜 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 作业调优的核心逻辑:
4.6 避坑指南
最后分享几个我踩过的坑:
- 别盲目增大 shuffle 分区数。我曾经把分区数从 200 调到 2000,结果产生了大量小文件,下游读取反而更慢了。分区数不是越大越好,要跟数据量匹配。
- 注意动态资源分配。开启
spark.dynamicAllocation.enabled后,Spark 会根据负载自动调整 Executor 数量。但如果你设置了spark.executor.cores为 1,动态分配反而会频繁启停 Executor,增加开销。我建议至少设 2 核。 - 数据倾斜别只靠加盐。加盐虽然好用,但会引入额外的聚合步骤。如果倾斜不严重,先试试增大分区数,成本更低。
Spark 调优不是堆资源,而是让每一份资源都干最合适的活。内存和 CPU 要匹配,数据要均匀,并行度要合理。做到这三点,成本自然就降下来了。