数据倾斜(Data Skew)是 Spark 生产环境中遇到的最棘手的性能问题之一。当某个分区的数据量远大于其他分区时,整个作业的执行时间会被这个慢任务拉长,导致集群资源利用率低下,甚至引发 OOM 异常。本文将深入剖析数据倾斜的底层原理,系统性地介绍排查方法,并给出经过生产验证的多种解决方案。
一、数据倾斜的本质与危害

数据倾斜的根本原因是 Shuffle 过程中,Partitioner 将数据分配到不同分区时,某些分区聚集了远多于平均量的数据。在 Spark 中,Shuffle 操作是分区数据重新分布的过程,而 HashPartitioner 或 RangePartitioner 的哈希函数可能导致大量 Key 落到同一个分区。
数据倾斜的具体表现包括:
- 任务执行时间差异巨大:某个 Task 运行数小时,而其他 Task 在几分钟内完成
- OOM 错误:单个 Task 的数据量超过 Executor 内存限制,导致 java.lang.OutOfMemoryError
- Shuffle 阶段异常缓慢:Shuffle Read/Write 的数据量在各 Executor 间严重不均衡
- GC 频繁:倾斜的分区导致频繁的 Full GC,拖慢整个 Stage
从 Web UI 的 Stage 页面可以直观地看到数据倾斜:在 Tasks 列表中,如果某个 Task 的 Shuffle Read 或 Shuffle Write 数据量是其他 Task 的数十倍甚至上百倍,基本可以确定存在数据倾斜问题。
二、数据倾斜的常见原因
2.1 业务数据本身的分布不均
这是最常见的原因。例如:
- 空值 Key 聚集:大量记录的 Key 为 null,所有 null 值被 Hash 到同一个分区
- 热点 Key 问题:某些 Key 的记录数远多于其他 Key,如电商中的头部商品、搜索引擎中的热门关键词
- 维度倾斜:事实表与维度表关联时,维度表中的某些 Key 对应的记录数极多
2.2 Spark 分区策略导致
Spark 默认使用 HashPartitioner,其分区逻辑为 key.hashCode % numPartitions。当数据分布不均匀时,哈希函数无法保证均匀分布。此外,Spark SQL 的自动分区推断也可能在某些情况下导致不均衡。
2.3 数据预处理阶段引入
数据在 ETL 阶段被压缩或聚合时,某些特殊值(如默认值、占位符)可能积累大量记录,在后续的 Join 或 GroupBy 操作中暴露出来。
三、数据倾斜的排查方法
3.1 通过 Spark Web UI 定位
Spark Web UI 是最直接的排查工具。进入 Application 的 Stages 页面,关注以下指标:
| 指标 | 正常值 | 异常值(倾斜) |
|---|---|---|
| Shuffle Read Size / Record | 各 Task 基本一致 | 某个 Task 高出其他 10x+ |
| Task Duration | 各 Task 完成时间相近 | 某个 Task 耗时远超中位数 |
| Shuffle Spill (Memory/Disk) | 少量或没有 | 大量磁盘溢写 |
| GC Time | < 5% 的 Task 时间 | GC 占比 > 20% |
3.2 代码层面排查技巧
在代码中添加采样分析,可以精确找出倾斜的 Key:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 // 方法1:统计每个 Key 的记录数,找出 Top N 热点 Key
val topKeys = df.groupBy("key_col")
.count()
.orderBy(desc("count"))
.limit(10)
.collect()
topKeys.foreach(println)
// 方法2:计算数据分布统计量
val stats = df.groupBy("key_col")
.count()
.agg(
max("count"),
avg("count"),
stddev("count")
)
.collect()
如果最大值远大于平均值(例如 max > 10 * avg),说明存在严重的数据倾斜。
3.3 日志分析法
在 YARN 或 Spark Standalone 模式下,查看 Executor 日志中的异常信息:
1
2
3
4
5
6
7 # 在 YARN 上获取失败的 Task 日志
$ yarn logs -applicationId application_xxx_xxx -containerId container_xxx_xxx
# 重点关注的关键字:
# - "FetchFailedException":Shuffle 读取失败
# - "OOM" 或 "OutOfMemoryError":内存不足
# - "GC overhead limit exceeded":GC 问题
四、生产级解决方案
4.1 方案一:加盐(Salting)预处理

加盐是解决数据倾斜最经典也最有效的方法之一。核心思路是给倾斜的 Key 加上随机前缀,将原本集中在一个分区的数据打散到多个分区中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 import org.apache.spark.sql.functions._
import scala.util.Random
// 1. 识别热点 Key(假设 topKeys 是倾斜 Key 的集合)
val topKeysSet = topKeys.map(_.getString(0)).toSet
// 2. 广播热点 Key 集合
val broadcastKeys = spark.sparkContext.broadcast(topKeysSet)
// 3. 对热点 Key 加盐,非热点 Key 保持不变
val saltedDf = df.withColumn("salted_key",
when(col("key_col").isin(topKeysSet.toSeq: _*),
concat(
col("key_col"),
lit("_"),
(rand() * 10).cast("int").cast("string")
)
).otherwise(col("key_col"))
)
// 4. 使用加盐后的 Key 进行聚合
val result = saltedDf.groupBy("salted_key").agg(sum("value").as("sum_value"))
// 5. 如果需要在原始 Key 维度上聚合,去除盐值后缀
val finalResult = result.withColumn("original_key",
when(col("salted_key").contains("_"),
split(col("salted_key"), "_").getItem(0)
).otherwise(col("salted_key"))
).groupBy("original_key").agg(sum("sum_value").as("total_value"))
加盐的核心要点:
- 盐值范围(random 的倍数)需要根据数据量合理设置,通常取 10~100
- 盐值越大,打散效果越好,但后续去盐聚合的代价也越大
- 定范围的盐值可以用随机数,也可以用 Key 的哈希值取模
4.2 方案二:两阶段聚合(Partial + Final Aggregation)
两阶段聚合将聚合操作拆分为局部聚合和全局聚合两步。先对加盐后的 Key 做局部聚合,再去除盐值做全局聚合:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 import org.apache.spark.sql.functions._
// 第一阶段:加盐局部聚合
val saltRange = 10
val partialAgg = df.withColumn("salt", (rand() * saltRange).cast("int"))
.withColumn("salted_key", concat(col("key_col"), lit("_"), col("salt")))
.groupBy("salted_key")
.agg(
sum("value").as("partial_sum"),
count("value").as("partial_count")
)
// 第二阶段:去除盐值,全局聚合
val finalAgg = partialAgg.withColumn("original_key",
split(col("salted_key"), "_").getItem(0)
)
.groupBy("original_key")
.agg(
sum("partial_sum").as("total_sum"),
sum("partial_count").as("total_count")
)
这种方法适用于
1 | count |
、
1 | sum |
等可分解的聚合函数。对于
1 | avg |
,可以先计算 sum 和 count,最后再相除。对于
1 | max |
/
1 | min |
,第一阶段取局部极值,第二阶段取全局极值。
4.3 方案三:广播变量优化 Join
当一个大表与一个小表 Join 时,如果小表足够小(默认小于 10MB),可以将其广播到所有 Executor 上,避免 Shuffle 操作,从而彻底消除数据倾斜:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 import org.apache.spark.sql.functions._
// 方法1:使用 Broadcast Hint(适用于 Spark 2.2+)
val result = largeDf.join(
broadcast(smallDf),
largeDf("key") === smallDf("key"),
"inner"
)
// 方法2:手动广播
val smallMap = smallDf.collect().map(row =>
row.getString(0) -> row.getLong(1)
).toMap
val broadcastMap = spark.sparkContext.broadcast(smallMap)
val result = largeDf.mapPartitions(iter => {
val map = broadcastMap.value
iter.filter(row => map.contains(row.getString(0)))
})
调整广播阈值:
1 spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "100MB")
4.4 方案四:调整并行度与分区策略
适当增加分区数可以缓解数据倾斜,但无法根除:
1
2
3
4
5
6
7
8 // 调整 Shuffle 分区数(默认 200)
spark.conf.set("spark.sql.shuffle.partitions", "1000")
// 使用 repartition 重新分区
val repartitionedDf = df.repartition(1000, col("key_col"))
// 使用 range partition 替代 hash partition
val rangePartitionedDf = df.repartitionByRange(1000, col("key_col"))
4.5 方案五:拆分倾斜 Key 单独处理
将倾斜 Key 和非倾斜 Key 拆分为两个数据集,分别处理后再合并:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 // 1. 定义倾斜 Key 阈值
val skewThreshold = 1000000
// 2. 统计每个 Key 的记录数
val keyCounts = df.groupBy("key_col").count().cache()
// 3. 分离倾斜 Key 和非倾斜 Key
val skewKeys = keyCounts.filter(col("count") > skewThreshold)
.select("key_col")
val normalKeys = keyCounts.filter(col("count") <= skewThreshold)
.select("key_col")
// 4. 分别处理
val skewDf = df.join(broadcast(skewKeys), Seq("key_col"))
val normalDf = df.join(broadcast(normalKeys), Seq("key_col"))
// 5. 对倾斜数据加盐处理
val processedSkew = processSkewData(skewDf)
// 6. 正常数据正常聚合
val processedNormal = normalDf.groupBy("key_col").agg(/* ... */)
// 7. 合并结果
val finalResult = processedSkew.union(processedNormal)
五、实战案例:电商订单数据倾斜调优
假设我们有一个电商订单表,需要按商品类目统计近 30 天的销售额。由于某些爆款商品的订单量是普通商品的 1000 倍以上,导致数据严重倾斜。
5.1 问题排查
通过 Web UI 发现某个 Task 的 Shuffle Read 数据量达到 50GB,而其他 Task 平均只有 200MB,差距达 250 倍。该 Task 执行时间超过 2 小时,作业整体阻塞。
5.2 解决方案实施
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36 // 步骤1:找出 Top 10 热点商品类目
val topCategories = orders
.filter($"order_date" >= date_sub(current_date(), 30))
.groupBy("category_id")
.count()
.orderBy(desc("count"))
.limit(10)
.collect()
// 步骤2:对热点类目加盐
val hotIds = topCategories.map(_.getInt(0)).toSet
val broadcastHotIds = spark.sparkContext.broadcast(hotIds)
val saltedOrders = orders.withColumn("salt",
when(col("category_id").isin(hotIds.toSeq: _*),
(rand() * 20).cast("int")
).otherwise(lit(0))
).withColumn("salted_category",
concat(col("category_id"), lit("_"), col("salt"))
)
// 步骤3:加盐后聚合
val partialAgg = saltedOrders
.groupBy("salted_category")
.agg(sum("amount").as("partial_sales"))
// 步骤4:去盐后全局聚合
val finalResult = partialAgg
.withColumn("category_id",
split(col("salted_category"), "_").getItem(0).cast("int")
)
.groupBy("category_id")
.agg(sum("partial_sales").as("total_sales"))
.orderBy(desc("total_sales"))
finalResult.show()
5.3 优化效果
加盐后,最慢的 Task 从 2 小时 15 分钟降至 12 分钟,整个作业从 2.5 小时缩短至 18 分钟,性能提升约 8 倍。集群资源利用率从 15% 提升至 85%。
当然,加盐方案引入了一个代价:去盐步骤需要第二次 Shuffle。因此实际应用中需要权衡加盐倍数和去盐开销。对于我们的场景,20 倍盐值是最优选择。
六、数据倾斜预防最佳实践
6.1 设计阶段的预防
- 合理设计分区键:选择分布均匀的列作为分区键,避免使用高基数但分布不均的列
- 使用 Bucketing 表:对于频繁 Join 的字段,使用 Bucketing 可以预先将数据均匀分布,避免 Shuffle
- 预处理空值:将空值替换为随机值或单独处理,避免空值聚集
6.2 开发阶段的检查
- 对大规模数据 Always 执行数据分布采样分析
- 设置 Spark 作业的监控告警,当 Task 执行时间标准差超过阈值时自动告警
- 在 CI/CD 中集成数据倾斜检测脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 // 开发阶段的数据倾斜检测工具函数
def detectSkew(df: DataFrame, keyCol: String, threshold: Double = 10.0): Boolean = {
val stats = df.groupBy(keyCol).count().agg(
max("count").as("max_count"),
avg("count").as("avg_count")
).collect()(0)
val maxCount = stats.getLong(0)
val avgCount = stats.getDouble(1)
if (avgCount > 0 && maxCount / avgCount > threshold) {
println(s"[WARN] 数据倾斜检测:最大分区数($maxCount) 是平均值($avgCount) 的 ${maxCount / avgCount} 倍")
true
} else {
println(s"[OK] 数据分布正常")
false
}
}
6.3 监控与自动修复
- 使用 Spark 的监听器接口(SparkListener)实时监控 Task 执行时间分布
- 在调度系统(如 Airflow、DolphinScheduler)中集成数据倾斜检测步骤
- 对于已知的周期性热点数据,建立自动加盐处理流程
七、总结
数据倾斜是 Spark 生产环境中最常见的性能杀手,但好在有成熟的解决方案可以应对。本文总结的核心要点如下:
- 定位优先:通过 Web UI 的 Task 耗时和 Shuffle 数据量分布,可以快速确认是否存在数据倾斜
- 检测手段多样:采样分析、日志分析、监控仪表盘等多种手段配合使用
- 加盐是通用方案:适用于 GroupBy 和 Join 场景,但需要合理选择盐值倍数
- 广播 Join 最佳:小表广播可以彻底避免 Shuffle,是最优解
- 拆分处理针对性强:适合倾斜 Key 数量少但数据量极大的场景
- 预防胜于治疗:合理的数据模型设计、分区策略和开发阶段检查,可以减少 80% 的倾斜问题
建议在实际项目中,将数据倾斜检测工具函数打包为公共工具库,在每个 Spark 作业中默认启用。同时,建立生产环境的数据倾斜告警机制,确保在问题出现的早期就能及时发现和干预。
如果你有更复杂的数据倾斜场景,比如多层嵌套的 Join 操作、多个倾斜 Key 同时存在,或者需要毫秒级的实时处理,欢迎在评论区讨论交流。
汤不热吧