凌晨两点零三分,调度平台的告警电话打到我手机上。一个跑了大半年的 Spark 日活统计任务,突然在 Shuffle 阶段卡住,已经运行了近两个小时,YARN 页面上堆满了红色告警。打开 Spark UI 的 Stages 面板,我看到一个非常标准的“数据倾斜现场”:某个 Stage 生成了 200 个 Task,其中 198 个在 40 秒内跑完,剩下的两个一直处于运行状态,磁盘 IO 持续报警,但 CPU 占用并不高。大数据场景下的 Spark 数据倾斜问题就是这样,平时安安静静,一旦热点 key 的分布出现波动,整个作业就会被极少数“倒霉”的 Task 拖到天荒地老。
这篇文章不是教科书式的原理科普,而是基于我多年处理生产环境 Spark 任务的经验总结。我会从识别倾斜、定位根因、动手改造、参数兜底这几个维度展开,最后用一个真实的日活指标任务案例,完整复盘一次从 40 分钟优化到 6 分钟的排查过程。无论你是刚接触 Spark 的数据开发,还是正在被集群里某个夜间任务折腾的老手,这套方法都能直接拿过去用。
1. 数据倾斜不是玄学:从“任务卡死”到“磁盘溢写”的完整识别链路
1.1 倾斜的第一现场:任务和日志长什么样
很多人判断数据倾斜靠“感觉”,感觉某个任务变慢了就说是倾斜。但生产环境里,变慢的原因太多了,可能是上游 Hive 表数据量翻倍,可能是集群资源被其他任务抢占,也可能是某个外部接口超时拖慢了整个 Stage。真正稳定的倾斜判断指标,藏在 Spark UI 的 “Tasks” 面板里。
当你点开一个运行缓慢的 Stage,按 Duration 排序,如果看到绝大多数 Task 的耗时都在 1 分钟以内,但排名靠前的几个 Task 耗时超过 20 分钟,且它们的 “Shuffle Read Size / Records” 明显高于均值一个数量级,这就是数据倾斜。我见过不少同学在这一步误判,原因是他们只看整个 Stage 的执行时间,不点进 Task 细节。Stage 总耗时长,可能是所有 Task 都慢,也可能是少数 Task 慢,前者是资源不足,后者才是倾斜。
另一个非常直观的信号是磁盘溢写。热点 Task 处理的数据量远超 executor 内存的承载能力,Spark 不得不把部分数据写入本地磁盘,Spark UI 的 “Spill (memory+disk)” 列会显示具体数值。当某个 Task 的 disk spill 量很大时,意味着它已经从内存计算退化成了磁盘读写,速度自然惨不忍睹。我一般会同时看 memory spill 和 disk spill:如果 disk spill 远远大于 memory spill,说明内存完全装不下,单靠调内存参数很难解决问题。
1.2 区分“数据量大”和“数据倾斜”的判断技巧
我经常在排查任务时听到一句话:“这个任务数据量太大了,跑得慢正常。”但数据量大和数据倾斜完全是两回事。数据量大是整体负载高,所有节点都在正常工作,任务慢但可控。数据倾斜是局部过载,少数节点累死,多数节点闲着,资源利用率极端不均衡。
判断方法很简单:看 Spark UI 里 Task 的耗时分布。把 200 个 Task 的耗时从大到小排序,如果前 5 个 Task 的耗时之和超过整个 Stage 的 80%,那大概率不是数据量大,而是倾斜。有个细节值得注意,如果任务用的是 Spark SQL,可以顺手看下执行计划里的 “Exchange” 节点。Exchange 对应的就是 shuffle 操作,倾斜通常发生在 Exchange 之后的下游 Task 上。执行计划里会标注 shuffle 的分区表达式,也就是你 SQL 里的 GROUP BY 字段或 JOIN 字段,后续优化就是围绕这些字段展开。
1.3 倾斜的代价为什么被低估
数据倾斜最让人头疼的地方不是“慢”,而是“间歇性”。同一个任务,数据平峰期 20 分钟跑完,每逢渠道做活动、月底结算、上游数据回刷时,就会突然卡上几个小时。这种偶发性让很多人第一反应是去排查外部系统,折腾半天才发现是某个热点 key 导致的。
倾斜的隐性代价还包括集群资源的整体劣化。一个卡住的 Task 会一直占用 executor 的计算 slot,导致同一个队列里其他任务排队等待。更糟的是,热点 key 的数据量可能随着业务增长只会越来越极端,如果不在早期处理,后面每一次优化都要付出更大的改造成本。所以我在团队里反复强调一件事:所有在生产环境运行超过两周的 Spark 任务,都应该把“数据倾斜排查”纳入日常巡检,而不是等它半夜报警。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从执行计划反推数据源头:不背锅也不漏锅的关键链路
2.1 用 Spark UI 定位 Stage 内的问题不是靠猜
定位倾斜之前,先搞清楚你的作业到底慢在哪。Spark UI 的 Stages 页面会展示每个 Stage 的耗时和输入数据量,我习惯先看耗时最长的 Stage,再点进去看 Task 列表。如果 Task 数量很少,比如只有 20 个,但每个 Task 处理的数据量差距很大,那问题很可能出在数据读取阶段;如果 Task 数量很多,比如 2000 个,但只有个别 Task 耗时极高,那问题基本可以锁定在 shuffle 后的某个分区。
接下来要看执行计划。Spark SQL 的执行计划可以通过 df.explain(true) 或 SQL 命令 EXPLAIN 查看。重点不是看逻辑计划,而是看物理计划中的 Exchange 节点和 HashPartitioning。比如下面这个计划片段:
sql复制+- Exchange hashpartitioning(biz_key#123, 200)
+- HashAggregate(keys=[biz_key#123], functions=[partial_count(...)])
这里的 hashpartitioning(biz_key, 200) 说明 Spark 把 biz_key 作为 shuffle 的 key,分成 200 个分区。如果 biz_key 的取值分布严重不均,倾斜就产生在这个 Exchange 上。定位到这一步,你至少知道该往哪个字段下手,而不是盲目地重写整个 SQL。
2.2 高发场景:聚合、关联和去重为什么容易出事
数据倾斜的高发场景,我在生产环境里见到的无非三类。
第一类是 GROUP BY 聚合。假设要统计每个渠道的活跃用户数,某个渠道的日志量占全量 80%,聚合时该 key 的所有数据都会落到同一个 Task 上做 count(distinct user_id),这个 Task 必然被压垮。第二类是 JOIN。两张表关联,如果关联字段的基数分布极度不均,比如日志表里某个 user_id 出现了几千万次,但用户表里它只有一条记录,SortMergeJoin 阶段所有匹配尝试都会集中到同一个 Task。第三类是 DISTINCT 去重。去重本质上也是一种聚合,热点 key 的大量重复值集中在单点处理,同样会造成局部过载。
这三种场景有一个共同特征:它们都引入了 shuffle 和哈希分区。哈希分区按 key 的哈希值取模,如果某个 key 的数据量巨大,取模后它依然只落在少数几个分区上。理解了这个机制,你就知道为什么“加几台机器”治标不治本了——除非把热点 key 本身打散,否则再多的节点也只是旁观者。
2.3 伪倾斜:空值和过滤条件带来的“假热点”
有一种情况会让加班排查变得特别冤,就是“伪倾斜”。比如字段中存在大量 NULL,Spark 的哈希分区会把所有 NULL 分到同一个分区。从 Spark UI 看,最后一个 Task 的 Records 数远高于其他 Task,但实际上我们处理的不是业务热点,而是空值堆积。这种场景下加盐有效,但更优雅的做法是在聚合前先过滤掉空值,或者在 SQL 里用 WHERE 条件提前排除。
还有一类伪倾斜和过滤条件下推有关。如果你的 SQL 里对某张表加了 WHERE 条件,但 Spark 优化器没能把过滤条件下推到扫描层,那么大量无效记录会进入 shuffle,制造出一个并不是由 key 分布导致的倾斜。排查时我会把 EXPLAIN 计划的 Filter 节点位置和逻辑计划里的扫描行数对照检查,确认过滤是否发生在最上游。这一点在优化“突然变慢”的任务时尤其重要,因为很多时候倾斜的根源其实在 SQL 执行业务逻辑之前的读取阶段。
3. 对症下药的改良策略:加盐、两阶段聚合和动态拆分
3.1 随机前缀为什么能解决热点 key,却解决不了所有问题
数据倾斜的经典解法是加盐,也就是在 shuffle key 上拼接一个随机数或者随机前缀。核心思路是把同一个 key 的数据随机打散到多个分区,消除单点热点。以 GROUP BY 为例,典型的两阶段聚合写法如下:
python复制from pyspark.sql import functions as F
# 第一阶段:给 key 加上 0~9 的随机后缀,分 10 组聚合
df_salted = df.withColumn("salt", (F.rand() * 10).cast("int"))
stage1 = (
df_salted
.groupBy("biz_key", "salt")
.agg(F.sum("amt").alias("part_sum"))
)
# 第二阶段:去掉 salt,合并结果
result = (
stage1
.groupBy("biz_key")
.agg(F.sum("part_sum").alias("amt"))
)
第一阶段把原本 1 亿条数据按 10 个随机组拆开,每组只处理 1000 万条;第二阶段再把 10 个组的预聚合结果合并,得到最终值。这个方案对 sum、count、max、min 这类可加性聚合非常有效。但如果你是计算 count(distinct user_id) 或者中位数,不能简单地靠两阶段聚合,因为 distinct 结果无法通过分组合并来等价还原。
需要注意,加盐不是万能药。在 JOIN 场景里,如果只给大表加盐,小表不加,那么 join 时两边的 key 对不上,结果完全错乱。加盐必须确保两张表对同类热点 key 使用一致的盐值范围。关于这一点,我在 3.2 节会给出更完整的方案。
3.2 广播小表与热点 key 拆分 join:先改写执行计划
处理 JOIN 倾斜,我的第一优先级永远是“能不能用广播”。当小表足够小时,Spark 会把小表复制到每个 executor 的内存里,直接跳过 shuffle。很多看似严重的 join 倾斜,在打开广播后整个 Stage 直接消失。但广播阈值 spark.sql.autoBroadcastJoinThreshold 默认只有 10MB,如果小表实际是几十 GB,强行调大这个参数会让每个 executor 都缓存整张小表,内存开销巨大,反而更容易 OOM。所以广播只能作为“小维度表”场景的解法。
当广播不可行时,我推荐“热点 key 拆分 join”。思路是把关联表拆成热点部分和普通部分:普通部分走常规 join,热点部分先给大表加盐、给小表加盐,再做 join,最后把结果 union 起来。这里给一个 PySpark 示例:
python复制from pyspark.sql import functions as F
hot_keys = ["hot_key_1", "hot_key_2"]
salt_range = 20
# 大表中的热点 key 加盐
large_hot = large_df.filter(F.col("key").isin(hot_keys))
large_hot = large_hot.withColumn("salt", (F.rand() * salt_range).cast("int"))
large_hot = large_hot.withColumn(
"join_key", F.concat(F.col("key"), F.lit("_"), F.col("salt"))
)
# 大表中的普通 key
large_normal = large_df.filter(~F.col("key").isin(hot_keys))
large_normal = large_normal.withColumn("join_key", F.col("key"))
# 小表:热点 key 复制成 salt_range 份
dim_hot = dim_df.filter(F.col("key").isin(hot_keys))
dim_hot = dim_hot.crossJoin(
spark.range(salt_range).withColumnRenamed("id", "salt")
).withColumn("join_key", F.concat(F.col("key"), F.lit("_"), F.col("salt")))
dim_normal = dim_df.filter(~F.col("key").isin(hot_keys))
dim_normal = dim_normal.withColumn("join_key", F.col("key"))
# 合并并 join
large_all = large_hot.unionByName(large_normal)
dim_all = dim_hot.unionByName(dim_normal)
result = large_all.join(dim_all, "join_key", "left")
这个方案的关键在于准确识别热点 key。我会先跑一个抽样统计,比如 df.groupBy("key").count().orderBy(col("count").desc()).limit(10),找出占比最大的几个 key,再写进代码。盐份数也不用太大,20~50 一般就够,太大反而会增加广播和 shuffle 的数据量。
3.3 动态重分区:什么时候用 repartition,什么时候用 coalesce
有些倾斜不在业务 SQL 里,而在数据读取阶段。当上游 Hive 表或数据文件本身分布不均匀时,Spark 读取后天然会形成大小不一的分区。比如数据表有 3 个大文件、100 个小文件,读取后几个大文件对应的分区会明显偏大。此时再好的下游优化都受限,因为数据在源头就已经歪了。
这时候我会在读取后加一个 repartition,按某个分布均匀的字段重新分区,或者单纯按 RoundRobin 均匀打散。repartition 会触发一次完整的 shuffle,开销大但彻底;coalesce 只合并分区,不触发 shuffle,适合减少分区数但不改变数据分布的场景。如果目的是解决分布不均,coalesce 往往帮不上忙,必须用 repartition。生产环境里我还会配合小文件治理,控制源表文件的大小和数量,从上游减少读取阶段的分区差异。
4. 不写代码也能兜底的配置项:并行度、内核数与内存取舍
4.1 shuffle 分区数的正确设定
spark.sql.shuffle.partitions 是 Spark SQL 做 shuffle 时默认的分区数,默认值是 200。这个参数设置不当,会造成两类问题:分区数太少,每个分区数据量太大,热点更突出;分区数太多,每个分区数据量过小,任务调度和序列化开销反而拖累整体性能。我的经验是让每个分区处理的数据量落在 100MB 到 200MB 之间。
如果一个 Stage 的 shuffle 数据量在 20GB 左右,我会把 spark.sql.shuffle.partitions 设为 200,让每个分区大约 100MB。如果数据量涨到 100GB,分区数就应调到 800 左右。这里的关键是跟着数据量走,而不是拍脑袋设一个值。在 Spark 3 及以上环境,开启 AQE 后可以由 spark.sql.adaptive.coalescePartitions.enabled 自动合并过小的分区,这时候分区数可以设得偏大一些,让 AQE 来兜底。
4.2 内存与溢写的权衡
很多人遇到倾斜就调大 spark.executor.memory,这个操作在短期救急时可以理解,但长期看并不健康。热点 Task 只是少数,你把所有 executor 的内存都调大,相当于为整个集群的少数热点买单。而且热点 key 的数据量如果超过单机承载能力,内存再大也会被撑爆。与其盲目调内存,不如先控制单个 Task 的输入数据量。
spark.shuffle.memoryFraction 和 spark.shuffle.spill 相关参数需要关注,但不要死记硬背。更实用的做法是观察 Spark UI 的 spill 量:如果 disk spill 很大,说明内存明显不够;如果 memory spill 和 disk spill 都不大,说明问题不在内存,而在另一个环节。对离线批处理任务来说,适度允许一些 disk spill 反而比拼命加内存更稳妥,因为 spill 只是慢,不会让作业整体挂掉。
4.3 参数兜底的适用场景与翻车案例
我调过的参数里,最容易翻车的就是分区数。有一年我把一个数据量只有几 GB 的任务的 spark.sql.shuffle.partitions 从 200 改到 2000,结果任务反而更慢了。原因很简单:数据量不够大,分区数翻 10 倍后,绝大部分 Task 处理的数据不足 1MB,大量时间浪费在任务调度和日志通信上。这提醒我,参数调优必须在明确瓶颈的前提下进行,不能为了调而调。
正确的参数调整顺序应该是:先定位倾斜是否真实存在,再决定是否加盐或重分区;然后调整分区数与数据量匹配;最后才考虑内存参数。如果这些做完任务仍然慢,就要跳出参数思维,去检查是不是序列化格式、磁盘 IO 或者外部查询成了新的瓶颈。参数兜底是辅助手段,不是根治方案。
5. 实战复盘:一个日活指标作业从 40 分钟压到 6 分钟的完整排查过程
5.1 任务背景与最初表现
这个任务是一个标准的日活统计作业:读取前一天的用户登录日志表,按渠道和小时粒度聚合出活跃用户数,结果写入 Hive 分区表。最初的任务 SQL 长这样:
sql复制INSERT OVERWRITE TABLE dws_dau_hour PARTITION(dt='2024-06-01')
SELECT channel_id, hour,
count(distinct user_id) as dau
FROM dwd_login_log
WHERE dt = '2024-06-01'
GROUP BY channel_id, hour;
任务每天凌晨定时执行,正常情况下 40 分钟左右完成。某天业务侧上线了一个大促活动,单个渠道的日志量暴涨到全量的 85%。当天任务跑了 2 小时还没结束,调度平台连续报警,值班同事把问题抛到了我这边。
5.2 定位热点的完整链路
我打开 Spark UI,看到 Stage 5(对应 GROUP BY 的 shuffle 和聚合)有 200 个 Task,其中 3 个 Task 的 Shuffle Read Size 都超过 20GB,而其余 Task 平均只有 300MB。这已经是非常典型的倾斜信号。为了确认是哪个 key 出问题,我跑了一个快速分析 SQL:
sql复制SELECT channel_id, count(1) as cnt
FROM dwd_login_log
WHERE dt = '2024-06-01'
GROUP BY channel_id
ORDER BY cnt DESC
LIMIT 10;
结果毫无悬念,某个大促渠道的日志量占全表的 85% 左右。这一个 key 在 GROUP BY 阶段就被哈希分到了同一个分区,导致单个 Task 处理了近 20GB 数据,同时还要做 count(distinct user_id),性能和内存双双告急。
5.3 最终方案与结果对比
针对这个场景,我采用了三步改造。第一步,把日志表中该热点渠道的数据单独拆出来,用两阶段聚合处理;第二步,非热点渠道的数据保持原有逻辑;第三步,把两部分结果 union all 合并。热点渠道的聚合逻辑改成先加盐、再聚合、后合并,避免了 count(distinct) 在单个热点上的集中计算。
下面是改造后的核心思路:
python复制from pyspark.sql import functions as F
# 热点渠道单独处理
hot_df = log_df.filter(F.col("channel_id") == "hot_channel_001")
hot_df = hot_df.withColumn("salt", (F.rand() * 20).cast("int"))
hot_stage1 = (
hot_df
.groupBy("hour", "salt")
.agg(F.countDistinct("user_id").alias("part_dau"))
)
hot_result = (
hot_stage1
.groupBy("hour")
.agg(F.sum("part_dau").alias("dau"))
)
# 非热点渠道正常聚合
normal_df = log_df.filter(F.col("channel_id") != "hot_channel_001")
normal_result = (
normal_df
.groupBy("hour")
.agg(F.countDistinct("user_id").alias("dau"))
)
# 合并结果
final_result = hot_result.unionByName(normal_result)
这里有一个关键点:count(distinct user_id) 对可加性并不严格,但热点渠道内部把 user_id 按 salt 分组后,每个分组里的 user_id 不会跨组重复,所以 sum(part_dau) 可以精确算出总去重数。这正是这个方案可行的前提。
优化后的作业跑了一次,耗时从 40 分钟降到了 6 分钟。这个过程中我还打开了 Spark 3 的 AQE 倾斜 join 优化作为额外保障,但核心收益还是来自两阶段聚合。对比数据如下:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 总耗时 | 40 分钟 | 6 分钟 |
| 最慢 Task 耗时 | 90 分钟 | 25 秒 |
| Task 最大 Shuffle Read | 约 20GB | 约 1.2GB |
| 磁盘溢写量 | 高 | 无 |
这个案例也说明了一个道理:数据倾斜优化不是靠某一招,而是先精确识别热点 key,再围绕热点做拆分和加盐,最后配合参数兜底,才能拿到稳定收益。
6. 沉淀下来的经验清单:从“处理倾斜”到“预防倾斜”
6.1 在数据接入端做前置探测
数据倾斜一旦暴露在凌晨的生产作业里,代价是很大的。与其等它爆发,我更建议在数据接入端就加上前置探测。具体做法很简单:对每天接入的日志表,定期统计关键字段的 TopN 分布,比如 channel_id、user_id、app_id 这些可能成为 shuffle key 的字段,把每天的占比变化记录下来。当某个 key 的占比突然超过预设阈值,比如 30%,就自动触发告警,提醒下游任务可能面临倾斜风险。
这件事在数据量大时并不复杂,因为只需要抽样。对全表做一次 group by key count 在生产库上压力不小,但做一次 1% 的采样统计,开销非常小,足以暴露绝大多数热点趋势。把这个检测做成一个每日定时任务,比等到凌晨被电话吵醒要划算得多。
6.2 把技能固化成通用工具
我们团队后来把两阶段聚合、热点 key 拆分 join 这些逻辑封装成了通用的 Scala 和 PySpark 工具类。业务同学在写 SQL 时如果判断某个 key 可能倾斜,直接调用工具方法,传入表和 key 字段即可,不用每次都重写一套逻辑。这样既降低了试错成本,也减少了加班概率。
工具封装的时候要特别注意:两阶段聚合的使用范围要写进注释,避免同事在 count(distinct) 上乱用导致结果错误。另外,热点 key 列表应该支持从外部文件或参数表读取,而不是硬编码在代码里,这样当业务热度变化时,不需要改代码就能动态调整。
6.3 一些个人习惯
最后分享几个我自己的小习惯。第一,每次优化完倾斜任务,我都会把 Spark UI 的截图和执行计划存进团队的 wiki,形成“倾斜案例库”。下次再遇到类似问题,先翻案例库,往往比从零开始排查快得多。第二,我不会过度迷信盐值数量,而是先观察热点 key 的数据量,再决定 10 份还是 50 份。第三,我会定期检查那些每天跑的任务,看看执行计划里是否出现了新的 Exchange 热点。数据倾斜不是一个一次性解决的问题,它更像是随着业务增长不断出现的长期博弈。把这些方法固化成流程和工具,才能真正从“救火”的状态里解放出来。
