数据倾斜是 Spark 作业中最常见、最棘手的性能问题之一,它会导致个别 Task 处理的数据量远大于其他 Task,使得整个作业的完成时间被严重拖长,甚至引发 OOM 错误。
一、 什么是数据倾斜?
- 现象: 在 Spark 作业执行过程中(尤其是 shuffle 操作后),观察到绝大多数 Task 执行都非常快,但个别 Task 执行极慢(卡在 99% 或 100%),或者直接失败(OOM)。
- 本质: 数据在集群节点间进行分区(Partitioning)或分组(Grouping)时,分布极度不均匀。某个或某几个 Key 对应的数据量远远超过其他 Key(例如,某个 Key 有百万条记录,其他 Key 只有几十条)。导致处理这些“热点” Key 的 Task 需要处理的数据量巨大,成为整个作业的瓶颈。
- 危害:
- 作业运行时间显著延长: 木桶效应,作业完成时间取决于最慢的 Task。
- 资源浪费: 其他计算资源空闲或低负载,而少数资源过载。
- Executor OOM: 处理大量数据的 Task 消耗过多内存,导致 Executor 崩溃。
- Task 失败重试: OOM 导致 Task 失败,重试可能再次失败,最终导致 Stage 或 Job 失败。
二、 如何识别数据倾斜?
- 查看 Spark Web UI / History Server:
- Stages 页面: 关注 Shuffle Read Size / Records。如果某个 Stage 的 Summary Metrics 中
Shuffle Read Size或Shuffle Read Records的Max值远高于Median或75th percentile值(比如大 10 倍、100 倍甚至更多),极有可能存在倾斜。 - Stage Detail 页面: 查看该 Stage 所有 Task 的
Shuffle Read Size或Duration直方图。如果存在一个或少数几个 Task 的指标值(柱状)远高于其他绝大多数 Task,基本可以确认倾斜发生在该 Stage。 - Executor 页面: 观察是否有 Executor 的 GC 时间异常长或失败次数多,可能由处理倾斜数据的 Task 引起。
- Stages 页面: 关注 Shuffle Read Size / Records。如果某个 Stage 的 Summary Metrics 中
- 查看日志:
- 在 Driver 或 Executor 日志中搜索
OOM(OutOfMemoryError) 错误信息。 - 查看 Task 失败重试的日志。
- 在 Driver 或 Executor 日志中搜索
- 代码分析:
- 关注作业中的
groupByKey,reduceByKey,join(特别是当join的某一方是小表时,使用BroadcastHashJoin策略则不会倾斜,但如果是SortMergeJoin或ShuffleHashJoin就可能倾斜),count(distinct),distinct,repartition等涉及 Shuffle 的操作。 - 思考 Key 的分布:是否存在业务上明显可能成为“热点”的 Key(如 null/空值、默认值、特定枚举值、异常值等)?
- 关注作业中的
- 数据探查:
- 采样计数:
df.select("key").sample(false, 0.1).groupBy("key").count().orderBy($"count".desc).show()查看 Key 的分布频率。 - 近似统计: 使用
approx_count_distinct结合groupBy快速估算 Key 的基数(不同值的数量)和分布。 - 直方图: 对 Key 列计算直方图(Spark SQL 支持)。
- 采样计数:
三、 数据倾斜解决方案详解
解决数据倾斜的核心思路是:打散热点 Key 或 避免对热点 Key 进行代价高昂的操作。没有一劳永逸的方案,需要根据倾斜的严重程度、数据特点、业务逻辑和资源情况选择或组合使用。
解决方案 1:过滤倾斜 Key(适用于可丢弃的异常数据)
- 场景: 引起倾斜的 Key 是异常数据(如 null、测试数据、错误数据),且业务上允许直接过滤掉这些数据。
- 方法: 在 Shuffle 操作前,使用
filter将这些异常的 Key 过滤掉。// 示例:过滤掉 key 为 null 或空字符串的数据 val filteredData = originalData.filter(col("key").isNotNull && col("key") =!= "") // 然后在 filteredData 上进行后续的 groupBy/reduceByKey/join 等操作 - 优点: 简单直接,效果立竿见影。
- 缺点: 仅适用于可丢弃的异常数据。如果这些 Key 包含重要业务信息则不可行。
解决方案 2:提高 Shuffle 并行度(适用于轻度倾斜)
- 场景: 数据倾斜不是特别严重,增加分区数可以一定程度上分散热点 Key。
- 方法:
- 在 Spark 配置中设置全局默认值:
spark.sql.shuffle.partitions(默认 200)。适用于 Spark SQL。 - 在 RDD 操作中显式指定:
reduceByKey(_ + _, 1000)或repartition(1000)。适用于 RDD API。 - 在 DataFrame/Dataset 操作中使用
.repartition(numPartitions, $"key")或.coalesce()(注意coalesce只能减少分区)。
- 在 Spark 配置中设置全局默认值:
- 原理: 增加下游 Stage 的 Task 数量,让原本一个 Task 处理的热点 Key 的数据,被分配到更多的 Task 上去处理。
- 优点: 配置简单,对代码侵入性小。
- 缺点:
- 对于极度倾斜(如某个 Key 的数据量占总量 50% 以上)效果有限,即使增加很多分区,处理该 Key 的 Task 仍然可能很慢或 OOM。
- 增加了 Shuffle 的开销(网络传输、小文件)。
- 需要根据数据量和集群资源合理设置,分区数过多或过少都可能降低性能。
解决方案 3:两阶段聚合(局部聚合 + 全局聚合)(适用于 reduceByKey / groupByKey 等聚合类倾斜)
- 场景: 聚合操作(如
sum,count,avg,max,min)时发生倾斜。 - 方法:
- 局部聚合(打散): 给 Key 加上随机前缀(0~N),进行第一次聚合。这样同一个原始 Key 的数据会被分散到多个不同的新 Key 上。
- 全局聚合(还原): 去掉随机前缀,对局部聚合的结果进行第二次聚合,得到最终结果。
- 代码示例 (RDD):
val prefixNum = 10 // 随机前缀的数量,根据倾斜程度调整 // 1. 局部聚合 val localAggRdd = originalRdd.map { case (key, value) => val prefix = new Random().nextInt(prefixNum) // 随机前缀 (s"${prefix}_${key}", value) // 加盐后的key }.reduceByKey(_ + _) // 第一次聚合(局部) // 2. 全局聚合 val globalAggRdd = localAggRdd.map { case (prefixedKey, sumValue) => val originalKey = prefixedKey.split("_", 2)(1) // 去掉随机前缀,还原原始key (originalKey, sumValue) }.reduceByKey(_ + _) // 第二次聚合(全局) - 代码示例 (Spark SQL): 实现相对复杂,通常需要借助
UDAF(用户自定义聚合函数)或在代码中分步操作 DataFrame。 - 优点: 能有效打散热点 Key,显著缓解聚合操作的倾斜问题。
- 缺点:
- 需要进行两次 Shuffle,增加了计算和网络开销。
- 代码比直接聚合复杂。
- 对于求平均值(
avg)等操作,需要额外记录次数,在全局聚合时做除法。 - 随机前缀数量
prefixNum的选择需要权衡(太小可能打散不够,太大则开销增大)。
解决方案 4:广播小表 + MAPJOIN(适用于大表 Join 小表,且小表倾斜)
- 场景: 一个大表和一个相对小的表进行
Join,且小表中存在热点 Key 导致 Join 倾斜。注意:这里的“小”是指表的大小可以被广播到所有 Executor。 - 方法:
- 识别倾斜 Key: 通过探查找到小表中的热点 Key。
- 拆分:
- 将小表拆分成两部分:
broadcastSmallTable:包含非热点 Key 的数据。hotKeysSmallTable:只包含热点 Key 的数据。
- 将大表也拆分成两部分:
normalBigTable:与大表broadcastSmallTable关联的 Key 对应的数据(即非热点 Key)。hotKeyBigTable:与大表hotKeysSmallTable关联的 Key 对应的数据(即热点 Key)。
- 将小表拆分成两部分:
- 分别处理:
- 将
broadcastSmallTable广播出去。 - 对
normalBigTable与广播出去的broadcastSmallTable进行MAPJOIN(避免了 Shuffle,高效且不会倾斜)。 - 对
hotKeyBigTable和hotKeysSmallTable进行普通的Shuffle Join。因为hotKeysSmallTable只包含热点 Key 且数据量小,而hotKeyBigTable虽然数据量大(热点 Key 的所有记录),但通过拆解,这个 Shuffle Join 只处理热点 Key,避免了非热点 Key 数据的干扰。关键点: 即使hotKeyBigTable数据量大,但因为它只包含热点 Key 本身的数据,在 Join 时不会造成跨 Key 的倾斜(热点 Key 内部的数据会被分配到多个 Task 处理)。如果hotKeyBigTable本身在热点 Key 上分布不均(不太常见),可能需要结合加盐进一步处理。
- 将
- 合并结果: 将
MAPJOIN的结果和Shuffle Join的结果union起来。
- 优点: 避免了热点 Key 对整体 Join 的拖累,非热点部分使用高效的 MAPJOIN。
- 缺点:
- 实现非常复杂,需要精确识别热点 Key 并拆分表。
- 需要多次操作和 Shuffle。
- 要求小表足够小以进行广播。
- 热点 Key 部分仍需 Shuffle Join,如果这部分数据量巨大且热点 Key 本身内部分布也不均,可能还需要其他优化。
解决方案 5:倾斜 Key 加盐 + Join(适用于大表 Join 大表,且某表存在热点 Key)
- 场景: 两个大表进行
Join,且其中一个表(假设是左表leftTable)存在热点 Key。 - 方法:
- 识别倾斜 Key: 找到左表中的热点 Key 列表。
- 对左表加盐(扩容):
- 将左表拆分成两部分:
normalLeft:不包含热点 Key 的数据。skewedLeft:只包含热点 Key 的数据。
- 对
skewedLeft中的每条热点 Key 数据,添加一个随机前缀(Salt, 0~N)。例如,热点 KeyA变成了[0_A, 1_A, ..., N_A]。
- 将左表拆分成两部分:
- 对右表扩容:
- 将右表也拆分成两部分:
normalRight:与normalLeft关联的 Key 对应的数据(非热点 Key)。skewedRight:与skewedLeft关联的 Key 对应的数据(热点 Key)。
- 对
skewedRight进行笛卡尔积扩容:将其中的每条热点 Key 数据复制 N 份,并为每一份添加不同的后缀(0~N),使其能与左表加盐后的所有前缀匹配。例如,热点 KeyA变成了[A_0, A_1, ..., A_N]。
- 将右表也拆分成两部分:
- 分别 Join:
- 对
normalLeft和normalRight进行普通的Shuffle Join(或如果满足条件用SortMergeJoin)。 - 对加盐后的
skewedLeft(Key 形如prefix_originalKey) 和扩容后的skewedRight(Key 形如originalKey_suffix) 进行 Join。Join 条件需要匹配:skewedLeft.prefix = skewedRight.suffixANDskewedLeft.originalKey = skewedRight.originalKey。这样,原本集中在单个热点 KeyA上的大量数据,被分散到了N+1个新 Key 组合(0, A),(1, A), …,(N, A)上,每个组合对应的数据量大大减少。
- 对
- 合并结果: 将两次 Join 的结果
union起来。
- 优点: 能有效打散大表 Join 大表时的热点 Key,显著提升性能。
- 缺点:
- 实现极其复杂: 需要精确识别热点 Key、拆分表、加盐、扩容、编写复杂的 Join 条件。
- 数据膨胀: 对右表的
skewedRight部分进行笛卡尔积扩容会导致该部分数据量膨胀 N 倍(N 是随机前缀的数量),消耗大量内存和计算资源。N 的选择至关重要(太小打散不够,太大膨胀严重)。 - 多次 Shuffle: 至少两次 Shuffle。
- 仅适用于处理已知的、有限数量的热点 Key。如果热点 Key 非常多,扩容的代价会变得无法承受。
解决方案 6:使用 Skew Join Hint (Spark 3.0+)
- 场景: Spark 3.0 及以上版本,在 Spark SQL 中优化
SortMergeJoin或ShuffleHashJoin的倾斜问题。 - 方法: 在 SQL 中使用
/*+ SKEWED_JOIN(table_name, key_column_name, skewed_value_list) */提示优化器。SELECT /*+ SKEWED_JOIN(left_table, join_key, (skewed_value1, skewed_value2)) */ ... FROM left_table JOIN right_table ON left_table.join_key = right_table.join_key - 原理: 优化器识别到 Hint 后,会尝试:
- 将 Hint 中指定的
skewed_value_list对应的数据从left_table中分离出来。 - 将这些热点 Key 的数据广播到所有 Executor(类似于 MAPJOIN 处理小表的方式)。
- 将
right_table中对应这些热点 Key 的数据复制并分发到各个 Executor。 - 在 Executor 本地完成热点 Key 数据的 Join。
- 非热点 Key 的数据仍采用常规的 Shuffle Join。
- 合并两部分结果。
- 将 Hint 中指定的
- 优点:
- 声明式: 只需在 SQL 中添加 Hint,无需修改复杂的数据处理逻辑。
- 自动化: Spark 引擎自动完成识别、拆分、广播、分发、Join 和合并过程。
- 避免了手动加盐/扩容的复杂性和数据膨胀问题(广播热点 Key 的小表数据)。
- 缺点:
- 仅适用于 Spark SQL。
- 需要 Spark 3.0+。
- 需要预先知道具体的倾斜 Key (
skewed_value_list)。虽然 Spark 3.2+ 支持SKEWED_JOIN不带值列表(优化器自动检测),但效果可能不如明确指定好。 - 广播热点 Key 对应的
left_table数据时,要求这部分数据足够小,能够被广播。如果热点 Key 本身对应的数据量也巨大,广播可能失效或导致 Driver OOM。 - 复制
right_table中热点 Key 数据到所有 Executor 仍会造成一定网络和内存开销。
其他辅助或特定场景方案
- 优化数据源:
- 如果数据源支持(如 Hive),预先对经常作为 Join Key 或 Group By Key 的字段进行分桶(
CLUSTERED BY key INTO N BUCKETS)。Spark 读取分桶表时,相同 Key 的数据默认在同一个分区,可以避免 Shuffle,减少倾斜发生的环节。
- 如果数据源支持(如 Hive),预先对经常作为 Join Key 或 Group By Key 的字段进行分桶(
- 避免不必要的 Shuffle:
- 优先使用
reduceByKey/aggregateByKey代替groupByKey(前者在 Map 端有预聚合,能显著减少 Shuffle 数据量)。 - 使用
broadcast join代替shuffle join(当小表足够小时)。 - 使用
coalesce代替repartition减少分区(如果目的是减少分区数且不要求均匀)。
- 优先使用
- 增大 Executor 资源:
- 对处理热点 Key 的 Task,增大其 Executor 的堆内存 (
spark.executor.memory)、堆外内存 (spark.executor.memoryOverhead,spark.memory.offHeap.size) 或 Core 数 (spark.executor.cores)。这不能解决倾斜本身,但可能让处理热点 Key 的 Task 避免 OOM 或稍微快一点,属于“硬扛”。仅适用于轻度倾斜且资源充足的情况。
- 对处理热点 Key 的 Task,增大其 Executor 的堆内存 (
- 自定义分区器: 对于非常特殊的 Key 分布,可以继承
Partitioner编写自定义分区逻辑,将热点 Key 更均匀地分配到不同分区。这需要对数据和业务有深刻理解,且实现复杂,较少用。
四、 选择策略与建议
- 诊断先行: 务必先通过 UI、日志、数据探查确认倾斜的存在、发生在哪个 Stage、哪个 Key(或哪些 Key)。
- 评估倾斜程度: 是轻度(个别 Key 稍多)、中度(少数 Key 占比显著)还是重度(一两个 Key 占绝大部分)?
- 考虑业务逻辑: 倾斜 Key 是否可以过滤?最终结果是否需要精确包含这些 Key?
- 评估数据大小: 小表可以广播吗?热点 Key 本身对应的数据量有多大?
- 优先简单方案:
- 能过滤就过滤(方案1)。
- 轻度倾斜优先尝试增加并行度(方案2)。
- 聚合操作倾斜优先尝试两阶段聚合(方案3)。
- 大表 Join 小表且小表倾斜,优先尝试广播小表 + MAPJOIN(方案4 - 注意这里是广播非倾斜部分的小表)。
- 复杂方案应对重度倾斜:
- 大表 Join 大表且存在热点 Key,方案5(加盐/扩容)或方案6(Skew Join Hint)是主要选择。Spark 3.0+ 优先尝试方案6(Hint),更简洁。否则选择方案5。
- 组合使用: 现实问题往往复杂,可能需要组合多种方案。例如:
- 先用方案1过滤掉 null Key。
- 对剩余数据,识别出主要热点 Key
A和B。 - 使用方案6 (Hint) 指定
(A, B)进行 Skew Join。 - 或者手动拆分:对
A和B使用方案5(加盐/扩容),对其他非热点 Key 使用普通的 Shuffle Join。
- 监控与迭代: 应用解决方案后,务必再次运行作业并通过 Spark UI 监控效果,确认倾斜是否缓解或消除。可能需要调整参数(如并行度、随机前缀数
N)或尝试其他方案。 - 预防优于治疗:
- 在设计数据管道时考虑 Key 分布。
- 对关键字段进行适当的数据清洗(处理 null/异常值)。
- 考虑使用支持更好分布的数据存储或格式(如分桶表)。
- 在开发阶段加入对倾斜的监控和检测逻辑。
总结
Spark 数据倾斜是性能优化的重点和难点。理解其本质(数据分布不均)和危害是基础。掌握多种诊断方法(UI、日志、探查)和解决方案(从简单的过滤、增加并行度到复杂的两阶段聚合、加盐 Join、Skew Hint),并能根据具体场景灵活选择、组合应用,是高效解决数据倾斜问题的关键。记住,没有银弹,实践、监控和迭代是成功的关键。优先使用 Spark 内置的高级特性(如 Skew Join Hint)往往能简化开发。
1450

被折叠的 条评论
为什么被折叠?



