前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站:https://www.captainai.net/dongkelun
什么是数据倾斜
数据倾斜是指在 Spark 任务中,个别 Task 处理的数据量远大于其他 Task,导致整体任务被拖慢,甚至出现 OOM。
常见场景:
- Join 场景:某张表中某个 key 关联了大量记录,导致这个 key 对应的 Task 特别慢
- 聚合场景:按维度做 count,某个维度的数据量是其他维度的几十倍
产生原因
Key 分布本身不均
业务数据本身就存在冷热不均,比如:
- 按用户 ID 聚合,活跃用户和沉默用户差异巨大
- 按时间维度,节假日数据量暴增
- 存在大量 null、空字符串等,占据同一个分区
这是数据倾斜的根本原因。
Shuffle 机制暴露倾斜
业务数据的冷热不均一直存在,但 Shuffle 之前按文件大小/行组切分,数据不按 key 哈希分布,不会出现某个 Task 数据量远大于其他的现象。Shuffle 阶段按 key 做 HashPartitioning(key.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 = {
// 自定义分区逻辑
}
}
实践建议
- 优先用 Broadcast:能 broadcast 就不要 shuffle,这是防止倾斜的最优解
- AQE 打开:Spark 3.0+ 强烈建议开启 AQE,很多倾斜场景能自动处理
- 加盐打散是兜底方案:当其他方案都不行时,加盐打散几乎一定能解决,但代码会复杂些
- 先定位再动手:不要盲目调参,先用 Web UI 定位是哪个 stage、哪个 key 倾斜
- 预防大于治疗:从源头避免倾斜 key 的产生,比如数据清洗时过滤掉异常值