1. Shuffle数据倾斜的本质

数据倾斜这个问题,我入行第三年才真正「吃透」。当时线上一个 30 分钟的任务,跑了一个半小时还没结束,最后 OOM 挂掉了。排查下来,就是某个 key 的数据量是其他 key 的几百倍。嗯,从那天起,我养成了一个习惯:但凡涉及 Shuffle 的任务,先看一眼 key 分布。

1.1 什么是数据倾斜

说白了,数据倾斜就是数据分布不均匀。在分布式计算里,数据被分到不同的分区去处理。理想情况下,每个分区的数据量差不多,大家同时跑完。但现实往往是——某个分区扛了 80% 的数据,其他分区闲得发慌。

核心定义:数据倾斜是指在分布式计算过程中,数据在各个分区之间的分布严重不均,导致部分任务处理的数据量远大于其他任务的现象。

举个例子。你有一张订单表,按用户 ID 做聚合。普通用户一年下 10 单,但某个大客户一年下了 100 万单。这个用户的 ID 就会成为「倾斜 key」。所有跟它相关的计算,都会压到同一个分区上。

为什么会这样?因为分布式计算的分区策略,通常是基于 key 的哈希值取模。同一个 key 永远落在同一个分区。key 本身的数据量越大,那个分区就越倒霉。

1.2 Shuffle 阶段的工作原理

要理解数据倾斜,必须先搞懂 Shuffle。Shuffle 是 Spark 里最「重」的阶段,也是问题最多的地方。

我习惯把 Shuffle 拆成三个子阶段来看:

  1. Map 端写数据——每个 Map 任务处理完数据后,按分区号排序,写到本地磁盘。
  2. 网络传输——Reduce 任务从各个 Map 任务的磁盘上拉取属于自己的数据。
  3. Reduce 端聚合——Reduce 任务拿到数据后,做分组、聚合、排序等操作。

你想想看,如果某个 key 的数据量特别大,它在 Map 端写的时候,对应的分区文件就特别大。到了网络传输阶段,那个分区的数据要占用大量带宽。最后到 Reduce 端,那个任务的内存压力直接爆表。

Shuffle 三阶段与数据倾斜 Map 任务 1 分区 0(正常) 分区 1(正常) 分区 2(倾斜!) Map 任务 2 分区 0(正常) 分区 1(正常) 分区 2(倾斜!) Map 任务 3 分区 0(正常) 分区 1(正常) 分区 2(倾斜!) 网络传输:分区 2 数据量是其他分区的 10 倍 Reduce 0(正常) Reduce 1(正常) Reduce 2(倾斜) OOM / 长尾 倾斜 key 导致分区 2 在 Map 端、网络传输、Reduce 端全面成为瓶颈

1.3 数据倾斜的典型表现

数据倾斜不会直接告诉你「我倾斜了」。它通常通过两种现象暴露出来:OOM 和 任务长尾。

1.3.1 OOM(内存溢出)

这是最要命的。某个 Executor 突然挂掉,日志里写着 java.lang.OutOfMemoryError。为什么会 OOM?

我遇到过这样一个案例:一个 groupBy 操作,某个 key 对应的 value 列表有 200 万条。Spark 默认的 Shuffle 读缓冲区是 48MB,但那个分区拉过来的数据有 2GB。缓冲区根本装不下,直接撑爆。

避坑指南:我曾经在调优时只关注了 spark.executor.memory,忽略了 Shuffle 读缓冲区的大小。结果内存给了 8GB,任务还是 OOM。后来才发现,spark.shuffle.file.buffer 和 spark.reducer.maxSizeInFlight 这两个参数才是关键。

OOM 的典型场景:

  • groupByKey / reduceByKey——某个 key 的 value 列表过大
  • join——大表 join 小表,但小表某个 key 的数据量异常大
  • distinct / countDistinct——倾斜 key 导致单个分区需要处理海量去重
  • 窗口函数——row_number() over(partition by ...) 中分区键倾斜

1.3.2 任务长尾

长尾比 OOM 更隐蔽。任务没有挂,但就是跑不完。你打开 Spark UI,看到 99% 的任务已经完成,剩下 1% 的任务进度条卡在 99%。

我记得有一次帮业务团队排查,一个 200 个分区的任务,199 个分区在 5 分钟内跑完,最后一个分区跑了 45 分钟。那个分区处理的数据量,是其他分区的 30 倍。

指标 正常分区 倾斜分区 差距倍数
数据量(条) 50 万 1500 万 30x
Shuffle 读(MB) 120 3600 30x
执行时间 4 分 20 秒 43 分 10 秒 10x
GC 时间 12 秒 8 分 30 秒 42x

长尾带来的连锁反应:

  • 资源浪费——其他 Executor 空闲,但集群资源被长尾任务占着
  • 延迟放大——下游任务依赖这个 stage 的输出,整个 DAG 被拖慢
  • 稳定性下降——长尾任务更容易触发 GC,GC 又进一步拖慢处理速度,形成恶性循环

快速判断方法:在 Spark UI 的 Stages 页面,看「Shuffle Read Size / Records」这一列。如果某个任务的数值明显高于中位数(比如 5 倍以上),基本可以断定存在数据倾斜。

1.4 数据倾斜的根因分类

根据我的经验,数据倾斜的根因可以归为三类。搞清楚是哪一类,才能对症下药。

  1. Key 本身倾斜——数据天然分布不均,比如热点用户、热门商品、空值 key
  2. 分区策略不当——默认的 hash 分区无法应对倾斜 key,需要自定义分区器
  3. 业务逻辑导致——比如 join 时小表广播失败、groupBy 的维度粒度过粗

你想想看,第一类是最常见的。空值 key 尤其坑人。我曾经遇到一个 case,某个字段 60% 是 null,groupBy 之后所有 null 都聚到一个分区,那个分区直接炸了。解决方案很简单——给 null 加随机后缀。