上一篇解决了动态资源分配和调度策略。这一篇进入性能诊断——当 Spark 作业慢了,怎么找到瓶颈。

性能调优的第一步不是改配置,是定位问题。Spark UI 提供了五个关键页面(Jobs、Stages、Storage、Environment、Executors),每个页面指向不同类型的性能问题。Event Log 提供了离线分析的完整数据。

本文只抓一个问题:面对一个慢作业,如何从 Spark UI 的指标出发,沿着诊断路径找到根因。

Spark UI 五个页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Jobs 页面
→ 每个 Action 对应一个 Job
→ 关注: Job Duration、Failed Tasks 数量
→ 入口: 找到最慢的 Job,点击进入 Stage 详情

Stages 页面
→ 每个 Stage 的 Task 统计
→ 关注: Task Duration 分布、Shuffle Read/Write、GC Time
→ 核心: 这里是定位性能问题的主战场

Storage 页面
→ 缓存的 RDD/DataFrame 信息
→ 关注: 缓存命中率、内存/磁盘占用
→ 用途: 检查 cache/persist 是否生效

Environment 页面
→ 所有 Spark 配置的实际生效值
→ 用途: 确认配置修改是否真正生效

Executors 页面
→ 每个 Executor 的资源使用
→ 关注: GC Time、Shuffle Read/Write、Storage Memory
→ 用途: 发现资源不均或 Executor 异常

四条诊断路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
作业慢
├── 数据倾斜 → Stage 详情 → Task Duration 分布
│ 症状: 最长 Task 耗时 >> 中位数 (10x+)
│ 确认: Summary Metrics 的 Min/25th/Median/75th/Max

├── Shuffle 过多 → Stage 详情 → Shuffle Read/Write
│ 症状: Shuffle Read/Write 的数据量 >> 输入数据量
│ 确认: Shuffle Spill (Disk) > 0 说明内存不够放 Shuffle 数据

├── 内存不足 → Executors 页面 → GC Time
│ 症状: GC Time 占 Task Duration 的 > 10%
│ 确认: Spill 指标 > 0,或 OOM 导致 Task 失败

└── 序列化瓶颈 → Stage 详情 → Task Deserialization Time
症状: Task Deserialization Time 占比 > 10%
确认: 使用了 Java 序列化而非 Kryo

诊断路径一:数据倾斜

数据倾斜是 Spark 作业最常见的性能问题。表现为:某个 Stage 的大部分 Task 在几秒内完成,但有一两个 Task 运行了几十分钟。

在 Stage 详情页的 Summary Metrics 中:

1
2
3
4
5
Duration:  Min=0.1s  25th=0.5s  Median=0.8s  75th=1.2s  Max=180s
─────────────── 正常 ────────────── ← 异常(倾斜)

Shuffle Read: Min=1MB 25th=5MB Median=8MB 75th=12MB Max=2GB
────────── 正常 ────────────── ← 该分区数据量异常大

如果 Max 和 Median 差距超过 10 倍,基本可以确认数据倾斜。点击 Max 对应的 Task 可以看到它处理的 Shuffle Read 数据量和具体的执行时间线。

应对策略:使用 AQE 的 Skew Join 优化(自动);对倾斜 key 加盐(salting)后分散到多个分区;对小表用广播 Join 避免 Shuffle。

诊断路径二:Shuffle 过多

Shuffle 是 Spark 中最昂贵的操作。如果一个作业有过多的 Shuffle Stage,或者 Shuffle 数据量远超输入数据量,需要检查是否有不必要的 Shuffle。

在 Stages 页面关注 Shuffle Write 和 Shuffle Read 两列:

1
2
3
4
5
6
Stage 0:  Input=10GB    Shuffle Write=10GB   ← 1:1 正常
Stage 1: Shuffle Read=10GB Shuffle Write=10GB ← 又一次全量 Shuffle
Stage 2: Shuffle Read=10GB Output=100MB ← 最终结果很小

问题: 两次 Shuffle 传输了 20GB,但最终结果只有 100MB
优化: 是否可以先过滤再 Shuffle?是否可以用广播 Join 避免第二次 Shuffle?

另一个指标是 Shuffle Spill (Disk):如果大于 0,说明 Shuffle 数据量超过了内存缓冲区,溢写到了磁盘,增加了额外的 I/O。

诊断路径三:内存不足

GC 压力是 Spark 作业变慢的常见原因。在 Executors 页面查看每个 Executor 的 GC Time:

1
2
3
Executor 0:  Total Time=300s  GC Time=45s  (15%) ← GC 占比高
Executor 1: Total Time=300s GC Time=10s (3%) ← 正常
Executor 2: Total Time=300s GC Time=90s (30%) ← 严重 GC 压力

GC Time 占总执行时间的 10% 以上就需要关注。30% 以上说明 Executor 的堆内存严重不足。

应对策略:增加 Executor 内存(–executor-memory);使用 MEMORY_ONLY_SER 减少对象数量;切换到 Kryo 序列化减少内存占用;增加分区数减少单个 Task 的内存消耗。

诊断路径四:序列化瓶颈

在 Stage 详情页的 Summary Metrics 中,Task Deserialization Time 表示 Task 从网络接收到反序列化完成的耗时:

1
2
3
4
Task Deserialization Time:  Median=50ms  Max=2s
Executor Computing Time: Median=200ms Max=500ms

反序列化占比 = 50/250 = 20% ← 过高

如果反序列化时间占比超过 10%,考虑切换到 Kryo 序列化(spark.serializer=org.apache.spark.serializer.KryoSerializer)。Kryo 比 Java 默认序列化快 10 倍左右,序列化后的数据大小也更小。

Event Log 离线分析

Event Log 记录了 Spark 作业执行过程中的所有事件(Job 开始/结束、Stage 提交/完成、Task 执行信息等),写入到 spark.eventLog.dir 配置的目录。

1
2
3
4
5
6
spark.eventLog.enabled=true
spark.eventLog.dir=hdfs:///spark/event-logs

Event Log → Spark History Server (离线 Spark UI)
→ 作业结束后仍可查看完整的执行信息
→ 适合事后分析和跨作业对比

History Server 启动后(sbin/start-history-server.sh),在 Web 界面可以浏览所有已完成作业的 Spark UI,功能和运行时 UI 完全相同。

实验:诊断一个倾斜作业

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 构造倾斜数据
val skewed = sc.parallelize(1 to 1000000).flatMap { i =>
if (i <= 100) (1 to 10000).map(j => (i, j)) // key 1-100 各有 10000 条
else Seq((i, 1)) // 其余 key 各有 1 条
}

val result = skewed.toDF("key", "value")
.groupBy("key").agg(sum("value").as("total"))
result.count()

// 在 Spark UI 中:
// 1. Jobs 页面 → 找到这个 Job
// 2. Stages 页面 → 找到 groupBy 对应的 Stage
// 3. Stage 详情 → Summary Metrics → 对比 Min/Max Duration
// 4. Tasks 列表 → 按 Duration 降序排列 → 最长的 Task 处理了 key 1-100 的大量数据

模式提炼

Spark 的性能诊断体现了指标驱动的调优模式:不是凭直觉改配置,而是先观测指标、定位瓶颈、针对性优化、验证效果。

这个模式在所有性能工程中通用。数据库调优看 EXPLAIN ANALYZE 的执行计划和 I/O 统计。JVM 调优看 GC 日志和堆转储。网络调优看 RTT、丢包率和带宽利用率。Web 性能调优看 Chrome DevTools 的 Waterfall 图和 Lighthouse 报告。

核心原则不变:measure first, optimize second。

工程迁移表

Spark 诊断工具 数据库对应 JVM 对应 Web 性能对应
Spark UI pgAdmin / MySQL Workbench VisualVM Chrome DevTools
Stage 详情(Task 分布) EXPLAIN ANALYZE 线程转储 Request Waterfall
Event Log + History Server 慢查询日志 GC 日志 Access Log
Shuffle Read/Write 指标 I/O 统计(pg_stat_io) 堆内存分配速率 网络传输大小
GC Time 无直接对应 GC Pause Time Time to Interactive

常见误解

误解一:“Task 数等于并行度,增加 Task 数总能加速”。Task 数增加到超过可用 CPU 核心数后,额外的 Task 只是排队等待。而且 Task 太多会增加调度开销。最优 Task 数约等于 Executor 总核心数的 2-3 倍。

误解二:“GC Time 高就加内存”。GC 问题的根因可能不是内存总量不足,而是对象数量过多。如果大量短生命周期的小对象导致 Young GC 频繁,增加 Executor 内存不会改善——改用序列化存储(MEMORY_ONLY_SER)或减少中间对象分配更有效。

误解三:“Spark UI 显示的 Duration 就是实际计算时间”。Duration 包含了调度等待时间、反序列化时间、GC 暂停时间和实际计算时间。需要结合 Executor Computing Time、GC Time、Task Deserialization Time 几个子指标才能分辨瓶颈所在。

练习

  1. 对同一个作业分别用 Java 序列化和 Kryo 序列化执行,在 Spark UI 中对比 Task Deserialization Time 和 Shuffle Write 数据大小。

  2. 构造一个内存不足的场景(executor-memory 设很小),观察 Executors 页面的 GC Time 占比和 Stage 详情的 Spill 指标。然后逐步加大内存,记录 GC Time 的下降曲线。

  3. 在 Spark History Server 中找一个之前执行的作业,定位最慢的 Stage 和该 Stage 中最慢的 Task。记录该 Task 的 Shuffle Read 数据量、Duration 和 GC Time,判断瓶颈类型。

系列导航

序号 主题 状态
00 导读:为什么 Spark 的核心是一张 DAG 已完成
01 RDD:弹性分布式数据集的五大属性 已完成
02 宽依赖与窄依赖:Stage 是怎样划分出来的 已完成
03 DAG Scheduler 与 Task Scheduler:从逻辑计划到物理执行 已完成
04 Shuffle 机制:数据跨分区交换的代价与优化 已完成
05 内存管理与 Tungsten:堆外内存、序列化与代码生成 已完成
06 存储体系:Block Manager、广播变量与累加器 已完成
07 容错机制:Lineage、Checkpoint 与推测执行 已完成
08 Spark SQL 与 Catalyst:从 SQL 文本到物理算子树 已完成
09 DataFrame 与 Dataset:类型安全与性能的折中 已完成
10 Adaptive Query Execution:运行时改写执行计划 已完成
11 数据源 API 与谓词下推:让存储层帮忙过滤 已完成
12 Structured Streaming:把流当成无界表 已完成
13 水位线与状态管理:流处理的时间语义 已完成
14 Spark on YARN / K8s:资源管理与部署模式 已完成
15 动态资源分配与作业调度:多租户集群的资源博弈 已完成
16 性能诊断:Spark UI、Event Log 与调优思路 本篇
17 Spark 与 Flink:两种计算引擎的设计选择 下一篇

参考资料