Spark 数据倾斜:原因、定位与处理方法

前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站:https://www.captainai.net/dongkelun

什么是数据倾斜

数据倾斜是指在 Spark 任务中,个别 Task 处理的数据量远大于其他 Task,导致整体任务被拖慢,甚至出现 OOM。

常见场景:

  1. Join 场景:某张表中某个 key 关联了大量记录,导致这个 key 对应的 Task 特别慢
  2. 聚合场景:按维度做 count,某个维度的数据量是其他维度的几十倍

产生原因

Key 分布本身不均

业务数据本身就存在冷热不均,比如:

  • 按用户 ID 聚合,活跃用户和沉默用户差异巨大
  • 按时间维度,节假日数据量暴增
  • 存在大量 null、空字符串等,占据同一个分区

这是数据倾斜的根本原因。

Shuffle 机制暴露倾斜

业务数据的冷热不均一直存在,但 Shuffle 之前按文件大小/行组切分,数据不按 key 哈希分布,不会出现某个 Task 数据量远大于其他的现象。Shuffle 阶段按 key 做 HashPartitioningkey.hashCode() % numPartitions),同一个 key 的所有数据路由到同一个分区,冷热不均才被暴露为 Task 级别的瓶颈:

  • 某个 key 有 1 亿条数据 → 这 1 亿条全给一个 Task
  • 其他 key 各 100 条 → 每个 Task 数据很少

数据倾斜的本质不是数据量大,而是 Shuffle 按 key 哈希后,某个分区的数据量远高于其他分区。

因此,一切解决数据倾斜的方案,核心思路都是绕过或打破 HashPartitioning 的扎堆效应------要么不做 Shuffle(Broadcast Join),要么让同一个 key 分散到多个分区(加盐打散)。

如何定位数据倾斜

看 Web UI

在 Spark UI 的 Stages 页面,查看各 Task 的数据量(Input Size / Records)。如果某个 Task 数据量是其他的 10 倍甚至 100 倍,就是明显的数据倾斜。新版本 Spark UI 还会直接在 Stage 页面提示 Skewness 指标。

看日志

如果任务特别慢,看 Driver 日志或者 Executor 日志,看是不是某个 Task 一直在运行,或者出现 OOM。

加日志定位具体 key

在代码里对可能导致倾斜的 Key 打印数据分布,比如:

scala 复制代码
df.groupBy("province").count().orderBy(desc("count")).show(50)

这样能快速定位到是哪个 key 数据量最大。

解决方案

方案选择速查表

场景 适用方案
聚合倾斜 加盐打散、大 key 单独处理
Join 倾斜(可广播) Broadcast Join
Join 倾斜(不可广播) 两边加盐打散
空值倾斜 过滤或加盐打散

一、聚合倾斜方案

1.1 加盐打散(两阶段聚合)

核心思路:先把 key 随机加个前缀打散,处理后再去掉前缀聚合回来。

scala 复制代码
// 第一阶段:打散
val salt = 1000
val dfSalted = df.withColumn("key_salt",
  concat(col("key"), lit("_"), (rand() * salt).cast(IntegerType)))

// 按打散后的 key 先做局部聚合
val partial = dfSalted.groupBy("key_salt").agg(count("*").as("cnt"))

// 第二阶段:去盐聚合
val result = partial
  .withColumn("key", split(col("key_salt"), "_").getItem(0))
  .groupBy("key").agg(sum("cnt").as("total"))

仅适用于聚合场景(Count、Sum 等可分解操作)。

1.2 大 key 单独处理

如果只有 1-2 个特别大的 key,单独拎出来处理:

scala 复制代码
val bigKeys = df.groupBy("province").count()
  .filter(col("count") > 1000000).collect().map(_.getString(0))

val dfBig = df.filter(col("province").isin(bigKeys:_*))
val dfNormal = df.filter(!col("province").isin(bigKeys:_*))

val resultBig = dfBig.groupBy("province").count()
val resultNormal = dfNormal.groupBy("province").count()

resultBig.union(resultNormal)

二、Join 倾斜方案

2.1 Broadcast Join
scala 复制代码
df1.join(broadcast(df2), "key")

把一侧数据全量复制到所有 Executor,在每个 Executor 内存中本地完成 Join,彻底避免 Shuffle。适用条件:被广播的表 < 10MB(默认 spark.sql.autoBroadcastJoinThreshold)。

为何能避免倾斜:Broadcast Join 没有 Shuffle 阶段(DAG 中无 Exchange 节点),df1 保持上游的物理分区方式,每个 Task 在本地拿着完整 df2 副本逐条匹配,数据不按 key 哈希分区,不存在某个 key 扎堆到单个 Task 的问题。本质上是用内存换 Shuffle。

当一侧表足够小时,优先使用此方案。

大 key 拆分变种

当只有倾斜 key 在另一侧对应的数据量较小时,可以只 broadcast 这部分数据:

scala 复制代码
val bigKeys = df1.groupBy("key").count()
  .filter(col("count") > 100000).collect().map(_.getString(0))

val dfSkew = df1.filter(col("key").isin(bigKeys: _*))
val dfNormal = df1.filter(!col("key").isin(bigKeys: _*))

val df2SkewKeys = df2.filter(col("key").isin(bigKeys: _*))
val resultSkew = dfSkew.join(broadcast(df2SkewKeys), "key")
val resultNormal = dfNormal.join(df2, "key")

resultSkew.union(resultNormal)

局限:如果倾斜 key 在 df2 中对应的数据量也大,broadcast 会 OOM,此方案失效。

2.2 两边加盐打散

Join 场景下两侧数据都大且无法 broadcast 时,参考聚合倾斜的加盐思路,两边分别加随机盐后 Join:

scala 复制代码
val N = 10
val df1Salted = df1.withColumn("salted_key",
  concat(col("key"), lit("_"), (rand() * N).cast("int")))

val df2Salted = df2.withColumn("salted_key",
  concat(col("key"), lit("_"), (rand() * N).cast("int")))

df1Salted.join(df2Salted, "salted_key").drop("salted_key")

原理:两边对同一条记录各自取 rand() * N,当两边随机到同一个盐值时匹配上(概率 1/N)。整体上 key=a 的 1 亿条分散到 a_0~a_9 各约 1000 万条,单 Task 处理量降到 1/N。


三、通用方案

3.1 过滤无效数据
scala 复制代码
df.filter(col("user_id").isNotNull)

适用于 null、空值等无效 key 导致的倾斜。

3.2 调整并行度
scala 复制代码
spark.conf.set("spark.sql.shuffle.partitions", 200)

稀释每个 Task 的数据量,治标不治本,倾斜严重时效果有限。

3.3 AQE 自动优化
scala 复制代码
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")

Spark 3.0+ 自动检测并处理倾斜,对 SQL 用户最友好。

3.4 自定义分区器

RDD 场景可以自定义 Partitioner:

scala 复制代码
class CustomPartitioner(numPartitions: Int) extends Partitioner {
  override def numPartitions: Int = numPartitions
  override def getPartition(key: Any): Int = {
    // 自定义分区逻辑
  }
}

实践建议

  1. 优先用 Broadcast:能 broadcast 就不要 shuffle,这是防止倾斜的最优解
  2. AQE 打开:Spark 3.0+ 强烈建议开启 AQE,很多倾斜场景能自动处理
  3. 加盐打散是兜底方案:当其他方案都不行时,加盐打散几乎一定能解决,但代码会复杂些
  4. 先定位再动手:不要盲目调参,先用 Web UI 定位是哪个 stage、哪个 key 倾斜
  5. 预防大于治疗:从源头避免倾斜 key 的产生,比如数据清洗时过滤掉异常值
相关推荐
ZKKLLY1 小时前
生成式引擎优化赛道崛起,优质GEO服务商该如何筛选评估
大数据·人工智能
IT古董2 小时前
FDE(Forward Deployed Engineer)详解:AI时代正在崛起的新型工程师
大数据·人工智能·数据挖掘
大大大大晴天3 小时前
大数据K8S基础:从 Pod 到 Service,看懂数据负载如何在云原生上运行
大数据·kubernetes
whcyhhh3 小时前
头歌实践教学平台:数据科学与大数据技术导论(七上)
大数据·数据库·python
夕除4 小时前
redis--007
数据库·分布式
东方护航数据恢复(深圳)4 小时前
国产信创数据库(达梦DM8/人大金仓)损坏恢复技术:从DMF文件解析到数据字典重建
大数据·数据库
starzy19904 小时前
SparkStreaming 之 foreachRDD 算子详解及代码实现
大数据·spark
用户594404103564 小时前
Redis 高并发高可用实战:从主从哨兵到集群分片,附完整 Java 代码
大数据
ApacheSeaTunnel5 小时前
一个关于数据集成的真相:链路 Success 不等于数据真实可靠
大数据·开源·数据集成·seatunnel·技术分享·数据同步