应避免直接对高基数字段组合使用groupBy,易引发shuffle倾斜导致OOM;需估算键总数、加盐处理、启用AQE、用GROUPING SETS替代UNION ALL、合理使用cache或checkpoint、优选聚合函数并监控Shuffle数据分布。

避免直接用 groupBy 处理高基数字段组合
高基数(如用户ID、订单号、URL)多列组合分组会触发大量 shuffle,极易 OOM 或拖慢整个 stage。Spark 不会自动拆解或采样,它老老实实把所有键哈希到分区里——一旦某几个 key 占据 80% 数据量,就会出现严重倾斜。
实操建议:
- 先用
df.select("col1", "col2").distinct().count()估算组合键总数,超千万级就要警惕 - 对必须分组的高基数字段,提前加盐(salting):比如给
user_id随机拼接 0–9 后缀,分组时用concat(user_id, salt),聚合后再去盐汇总 - 用
spark.sql.adaptive.enabled=true开启自适应查询执行(AQE),它能在运行时合并小分区、优化 join 策略,对 groupBy 后的 reduce 阶段有明显改善
GROUPING SETS 比多个 UNION ALL 更省资源
需要同时统计「按年」、「按年+月」、「全局总计」三类结果?别写三个 GROUP BY 再 UNION ALL —— 这会强制三次全量 shuffle。而 GROUPING SETS 让 Spark 在一次 shuffle 中完成所有分组键的哈希与聚合,底层复用中间数据结构。
示例对比:
SELECT year, month, SUM(sales) FROM sales GROUP BY GROUPING SETS ((year), (year, month), ())
比下面三句拼接快得多:
SELECT year, NULL AS month, SUM(sales) FROM sales GROUP BY year UNION ALL SELECT year, month, SUM(sales) FROM sales GROUP BY year, month UNION ALL SELECT NULL, NULL, SUM(sales) FROM sales
注意:GROUPING SETS 要求 Spark 3.0+,且字段为 NULL 表示该维度未参与分组(可用 GROUPING() 函数识别)
用 cache() 前先确认数据是否真的“可复用”
很多人习惯在 groupBy 前对源 DataFrame 调用 cache(),但若只用一次、或后续还有 filter / projection,缓存反而增加 GC 压力和内存开销。
判断依据:
- 该 DataFrame 是否被多个 action(如
count()+show()+write)重复触发?是 → 缓存有意义 - 是否已做过强过滤(如
where date >= '2025-01-01')?是 → 缓存裁剪后的小数据集更划算 - 是否含大量 null 或重复值?考虑先
dropDuplicates()再缓存,减少序列化体积
更轻量的选择是 checkpoint()(需设置检查点目录),它把 lineage 截断,适合长链路中防重算,但不占内存。
聚合函数选型直接影响 shuffle 数据量
COUNT(*) 和 COUNT(col) 行为不同:前者统计行数,不关心内容;后者会过滤 NULL 值,且若 col 是复杂类型(如 struct),序列化开销翻倍。同理,collect_list() 易爆内存,approx_count_distinct() 在误差允许时更稳。
关键差异:
-
size(collect_list(x))≈count(x),但前者必须把全部值拉到 driver,绝对禁止用于大分组 -
first(x)和last(x)不保证顺序,除非配合orderBy窗口;真要取首行,用row_number() OVER (PARTITION BY k ORDER BY t) = 1 - 字符串聚合慎用
concat_ws(',', collect_list(str)),长度不可控;优先考虑string_agg(str, ',')(Spark 3.4+),它支持截断和去重
Spark UI → Stage Tab → Shuffle Write Size / Records Written,如果某 task 写出的数据是平均值的 10 倍以上,就得回溯分组键设计,而不是调大 executor memory。

















