数据倾斜的定位方法:从现象到根因
说实话,很多同学一遇到 Spark 任务跑得慢,第一反应就是「加资源」。但资源加完了,该慢还是慢。我见过最夸张的一个案例——加了 3 倍 executor,任务反而更慢了。为什么?因为数据倾斜没解决,加再多资源也只是让那个倾斜的 task 死得更慢。
所以这一章,咱们就聊聊怎么精准定位数据倾斜。说白了,就是三招:看 UI、查日志、跑验证。
1. 通过 Spark UI 看 Stage 和 Task 指标
Spark UI 是定位倾斜的第一站。我个人习惯,任务一跑起来就先打开 4040 端口。你想想看,如果所有 task 都跑完了,唯独几个 task 还在那里转圈,那基本就是倾斜了。
关键指标怎么看?
| 指标 | 正常值 | 倾斜信号 |
|---|---|---|
| Shuffle Read Size / Record | 各 task 差异 < 2 倍 | 某个 task 比其他大 10 倍以上 |
| Task Duration | 各 task 时间接近 | 少数 task 耗时是平均值的 5 倍+ |
| GC Time | < 5% 总时间 | 倾斜 task 的 GC 占比异常高 |
| Shuffle Spill (Disk) | 无或极少 | 大量 spill 到磁盘,说明内存不够 |
我的经验:别只看「Duration」这一个指标。有一次我排查一个任务,Duration 看起来挺均匀,但 Shuffle Read Size 差了几十倍。后来发现是数据分布不均匀,但计算逻辑简单,所以时间没拉开。这种「隐性倾斜」更容易被忽略。
Stage 页面怎么看?
点进某个 Stage,看「Summary Metrics」那栏。重点关注 Shuffle Read Size / Records 的 Max 和 Min。如果 Max / Min > 10,基本可以确认倾斜。
嗯,这里要注意:有时候 Shuffle Read Size 不大,但 Shuffle Write Size 差异很大。这说明倾斜发生在 map 端,也就是上游数据就已经歪了。
小技巧:在 Spark UI 的 Stage 页面,点击「Show Additional Metrics」,勾上「Shuffle Spill (Disk)」和「Shuffle Spill (Memory)」。如果某个 task 的 spill 量特别大,说明它处理的数据量远超内存容量——这也是倾斜的间接证据。
2. 通过日志定位倾斜 Key
UI 告诉你「有倾斜」,但没告诉你「哪个 key 在作怪」。这时候就得翻日志了。
我常用的方法:在 shuffle 读或写的地方,加一段自定义的 mapPartitions,把每个 partition 的数据量打出来。但说实话,生产环境不太方便改代码。那怎么办?
从 Spark 日志里捞线索
Spark 的日志里,如果某个 task 处理的数据量特别大,通常会有类似这样的 WARN 日志:
WARN TaskSetManager: Stage 3 contains a task of very large size (1234 MB).
The maximum recommended task size is 100 MB.
看到这个,直接定位到那个 Stage。然后去对应的 executor 日志里,搜 ShuffleBlockFetcherIterator 或者 ExternalSorter 的日志。有时候能看到类似:
INFO ExternalSorter: Thread 42 spilling in-memory map of 1.2 GB to disk (3 times so far)
这说明这个 partition 的数据量已经大到频繁溢写磁盘了。
避坑指南:我曾经在日志里看到某个 task 的 Shuffle Read Size 是 2GB,但其他 task 只有 50MB。当时我以为是数据倾斜,查了半天发现是 数据读取逻辑写错了——某个 join 条件漏了过滤,导致笛卡尔积。所以,日志定位到倾斜后,一定要回看代码逻辑,别急着上解决方案。
3. 使用 countByKey 快速验证
UI 和日志都指向了某个 Stage,但具体是哪个 key 导致的?最直接的办法:countByKey。
怎么用?
假设你的 RDD 或 DataFrame 经过 groupBy 或 reduceByKey 后出现了倾斜,直接在倾斜前的 RDD 上跑:
// 假设 rdd 是 (key, value) 对
val keyCounts = rdd.map(_._1).countByKey()
// 按出现次数降序排列,取前 10
keyCounts.toSeq.sortBy(-_._2).take(10).foreach(println)
输出结果大概长这样:
(null, 12345678)
("", 5432109)
("hot_key_1", 2345678)
("hot_key_2", 1234567)
...
看到没?null 和空字符串经常是罪魁祸首。我遇到过好几次,业务数据里某个字段默认值是空字符串,结果 groupBy 的时候全聚到一个 partition 里了。
DataFrame 版本怎么搞?
如果你用的是 DataFrame,可以这样:
df.groupBy("key_column")
.count()
.orderBy(desc("count"))
.show(10, false)
这样就能看到每个 key 的数据量分布。如果某个 key 的 count 是其他 key 的几百倍,那它就是倾斜的元凶。
我的习惯:countByKey 虽然方便,但要注意——它会把所有数据拉到 driver 端。如果数据量特别大,driver 内存可能会爆。所以我一般会在 countByKey 之前先 sample 一下:
rdd.sample(false, 0.1).map(_._1).countByKey()
采样 10% 的数据,基本就能看出分布趋势了。如果采样数据里某个 key 已经占了 80%,那全量数据肯定更夸张。
知识体系总览
下面这张图,把定位数据倾斜的完整流程串起来了。你可以把它当作排查时的「检查清单」:
总结一下定位思路
定位数据倾斜,说白了就是三步走:
- 看 UI:找到哪个 Stage 的 task 时间或数据量差异最大
- 查日志:确认是哪个 partition 出了问题,排除代码逻辑错误
- 跑验证:用 countByKey 或 groupBy count 揪出具体的倾斜 key
这三步走完,你基本就知道「谁在捣乱」了。下一章咱们就聊聊,知道了倾斜 key 之后,怎么对症下药。
核心要点:定位不是目的,解决问题才是。但定位不准,解决方案就是瞎蒙。我见过太多人一上来就加盐、改并行度,结果越改越糟。先花 10 分钟定位,能省下 2 小时的试错时间。