数据倾斜的定位方法:从现象到根因

说实话,很多同学一遇到 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%,那全量数据肯定更夸张。

知识体系总览

下面这张图,把定位数据倾斜的完整流程串起来了。你可以把它当作排查时的「检查清单」:

数据倾斜定位流程 数据倾斜定位流程 ① 观察现象 ② Spark UI 定位 Stage ③ 日志定位倾斜 Key ③ countByKey 验证 ④ 确认倾斜 Key 及根因 ⑤ 制定解决方案 提示:UI 定位到 Stage → 日志或 countByKey 定位到具体 Key → 确认根因后选择解决方案

总结一下定位思路

定位数据倾斜,说白了就是三步走:

  1. 看 UI:找到哪个 Stage 的 task 时间或数据量差异最大
  2. 查日志:确认是哪个 partition 出了问题,排除代码逻辑错误
  3. 跑验证:用 countByKey 或 groupBy count 揪出具体的倾斜 key

这三步走完,你基本就知道「谁在捣乱」了。下一章咱们就聊聊,知道了倾斜 key 之后,怎么对症下药。

核心要点:定位不是目的,解决问题才是。但定位不准,解决方案就是瞎蒙。我见过太多人一上来就加盐、改并行度,结果越改越糟。先花 10 分钟定位,能省下 2 小时的试错时间。