Spark 性能调优实战:从开发到生产落地

Spark 性能调优实战:从开发到生产落地

Spark 是绝大多数大数据平台的主力计算引擎,但在真正的生产环境中,一条 SQL 或一段 DataFrame 代码从"能跑"到"跑得好",中间隔着一整套理解模型、精细调优的工程实践。本文将从作业执行模型开始,一直深入内存管理、Shuffle 优化、并行度、Join 策略与监控诊断,所有内容均以"可动手、可排错"为目标,并在关键环节附上代码和 Spark UI 截图解读(以文字模拟)。


1. 理解 Spark Job:DAG、Stage、Task 的执行模型

1.1 RDD 血缘与 DAG 调度

Spark 应用由一系列的 Transformation(惰性)和 Action(触发计算)组成。每次 Action(如 count()collect()save())都会触发一个 Job 。Job 由 Driver 将 RDD 之间的依赖关系转换为有向无环图(DAG),DAGScheduler 再根据宽窄依赖将其切分为多个 Stage

窄依赖 :子 RDD 的每个分区最多依赖父 RDD 的一个分区。例如 mapfilterflatMap。这些操作可在同一个 Stage 内以**流水线(pipeline)**方式执行,无需 Shuffle。

宽依赖(Shuffle 依赖) :子 RDD 的每个分区依赖于父 RDD 的多个分区,通常是 reduceByKeygroupByKeyjoin(非广播)等。这会导致数据重新分区,是 Stage 的边界。

任务执行示例:

python 复制代码
rdd = sc.textFile("hdfs://...")          #  窄依赖 (Stage 0)
words = rdd.flatMap(lambda x: x.split()) # 窄依赖 (Stage 0)
pairs = words.map(lambda w: (w, 1))      # 窄依赖 (Stage 0)
counts = pairs.reduceByKey(lambda a,b: a+b) # 宽依赖 -> 新 Stage 1
counts.collect()                         # Action -> Job

在这个 Job 中,DAGScheduler 会生成两个 Stage:Stage 0(直到 map)和 Stage 1(Shuffle + reduceByKey)。宽依赖处产生 Shuffle 写入和读取,这就是性能瓶颈的常见位置。

1.2 Task 调度与数据本地性

每个 Stage 由一组 Task 组成,Task 数等于 Stage 最后一个 RDD 的分区数。TaskScheduler 会将这些 Task 分发到集群的 Executor 上。调度时优先考虑数据本地性:

  • PROCESS_LOCAL:数据已在当前 JVM 内存中
  • NODE_LOCAL:数据在同节点磁盘或内存
  • RACK_LOCAL:数据在同机架
  • ANY:跨机架/跨节点

如果数据本地性无法满足,会发生远程读取,延迟大增。我们可以通过 spark.locality.wait 参数控制等待更高本地性的时间(默认 3 秒),过短会快速降级为网络读取,过长则耗费等待时间。

1.3 理解执行计划的利器:explain()

在 SQL/DataFrame 编程中,explain(mode="extended") 可以输出解析后的逻辑计划、优化后的逻辑计划和物理计划,这是理解 DAG 与 Stage 的捷径。

python 复制代码
df = spark.sql("SELECT city, sum(order_cnt) FROM sales GROUP BY city")
df.explain("extended")

输出会体现 WholeStageCodegen(全阶段代码生成)、Exchange(Shuffle)、BroadcastHashJoin 等信息。学会阅读执行计划,是发现性能问题的第一步。


2. 内存管理:统一内存模型与 GC 调优

2.1 Spark 内存分区模型

Spark 1.6 之后采用统一内存模型,每个 Executor 的 JVM 内存分为三块:

  • Reserved Memory(保留内存,默认 300MB):用于 Spark 内部对象
  • User Memory(用户内存,占剩余 + 默认 40%):存储用户自定义数据结构、UDF、RDD 转换中的中间对象
  • Spark Memory Pool (执行+存储内存,占剩余 + 默认 60%):又分为
    • Execution Memory:用于 Shuffle、Join、Sort、Aggregation 等运算的临时缓冲区
    • Storage Memory :用于缓存 RDD/DataFrame(persist/cache

执行内存和存储内存可动态借用 :当执行内存空闲时,存储内存可借用;当执行需要内存时,若存储借用部分未释放,会溢出到磁盘(需 spark.memory.storageFraction 配置限制)。

相关参数(Spark 3.x 典型配置):

复制代码
spark.executor.memory=8g
spark.memory.fraction=0.6       # 堆内用于 Spark Memory + User Memory 的比例
spark.memory.storageFraction=0.5 # Storage 内存占比(Spark Memory 中的 50%)

2.2 避免 Spark OOM 与 GC 问题

2.2.1 常见 OOM 场景
  • Broadcast 变量过大 :导致 Executor 内存耗尽,应确认广播表大小小于(Executor 内存 - 执行内存)的阈值(默认 10 MB 自动广播,可调 spark.sql.autoBroadcastJoinThreshold)。
  • Shuffle 分区数据倾斜:单个 Task 处理过大键值,数据溢出磁盘并可能导致 Executor 挂掉。
  • collect() 回拉太大结果集:Driver 内存爆掉。
  • 序列化对象过大或 UDF 复杂对象累加:在 User Memory 中堆积。
2.2.2 GC 分析与调优

GC 异常的第一个信号是 Spark UI 中 Task 的 GC Time 占比过高(超过 5-10%)。可在 spark-submit 时增加 GC 日志:

复制代码
--conf "spark.executor.extraJavaOptions=-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps"

如果发现频繁 Full GC,考虑:

  • 增加 Executor 内存(但需平衡资源)
  • 减少用于缓存的 Storage 内存占比
  • 使用 G1GC 而非默认的 Parallel GC:-XX:+UseG1GC
  • 减少并行度,降低对象创建速率

代码层面的内存友好实践

  • 优先使用 DataFrame API,它利用 Tungsten 和 Catalyst 优化,内存和 CPU 使用都比 RDD 高效。
  • 避免 groupByKey,改用 reduceByKeyaggregateByKeygroupByKey 会将所有值加载到内存再聚合。
  • 及时 unpersist 不再需要的缓存数据。

3. Shuffle 优化:减少数据倾斜和网络开销

Shuffle 是最昂贵的大数据操作,涉及数据分区、序列化、网络传输、磁盘 I/O 和反序列化。

3.1 诊断 Shuffle 问题

在 Spark UI Stages 页面观察某个 Stage 的 Summary Metrics

  • Shuffle Read Size / Records:是否极大,说明 Shuffle 负担重。
  • Duration 分布 :如果 Max 是 Median 的数倍,存在严重的 数据倾斜
  • Spill (Memory) / Spill (Disk):Shuffle 运行中数据溢出磁盘的量,过多说明执行内存不足或分区过大。

3.2 数据倾斜(Data Skew)的处理实战

倾斜通常是因为部分 key 的数据量远大于其他 key(如热点商品、空值聚集)。解决方法:

3.2.1 两阶段聚合(加盐去盐)

适用于 reduceByKeygroupByKey 等聚合。给倾斜的 key 加随机后缀打散,局部聚合后再去掉后缀二次聚合。

python 复制代码
from pyspark.sql import functions as F
import random

# 假设 df 有 user_id, amount
# 1. 加盐:加 0~9 随机后缀
salted_df = df.withColumn("salted_key", F.concat(F.col("user_id"), F.lit("_"), (F.rand() * 10).cast("int")))

# 2. 第一次聚合:按加盐 key 聚合
partial_agg = salted_df.groupBy("salted_key").agg(F.sum("amount").alias("partial_sum"))

# 3. 去盐:提取原始 key
partial_agg = partial_agg.withColumn("user_id", F.split(F.col("salted_key"), "_")[0])

# 4. 第二次聚合:按 user_id 最终求和
final_agg = partial_agg.groupBy("user_id").agg(F.sum("partial_sum").alias("total_amount"))

注意:该方法增加了一次 Shuffle,适用于倾斜极其严重的场景。

3.2.2 开启自适应查询执行(AQE)

AQE(Spark 3.0+)能自动处理倾斜 Join,无需改代码。配置如下:

复制代码
spark.sql.adaptive.enabled=true
spark.sql.adaptive.coalescePartitions.enabled=true
spark.sql.adaptive.skewJoin.enabled=true
spark.sql.adaptive.skewJoin.skewedPartitionFactor=5
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes=256MB

当 AQE 检测到某分区大小超过了 skewedPartitionThresholdInBytes 且大于中位数分区大小的 skewedPartitionFactor 倍时,它会自动将该分区拆分成多个小分区,分散到不同 Task 执行。

3.2.3 空值处理

如果倾斜来自大量 NULL 值,可以在执行 Join 前将 NULL 替换成随机值,或单独过滤 NULL 用 Union 合并:

python 复制代码
# 过滤空值部分单独 union 处理
df_without_null = df.filter(F.col("key").isNotNull())
df_null = df.filter(F.col("key").isNull())
# 分别处理...

3.3 减少 Shuffle 数据量

  • 使用 Map-side CombinereduceByKey 在 Map 端先进行局部合并,显著减少 Shuffle 写入量。
  • 尽量使用 SQL/DataFrame 的谓词下推:Pushdown 到数据源,减少读取和参与 Shuffle 的数据。
  • 选择高效的序列化 :默认的 Java 序列化比较臃肿,Spark 建议使用 Kryo 序列化,并注册自定义类。
python 复制代码
spark.conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
spark.conf.set("spark.kryo.registrationRequired", "true")
# 注册类
spark.sparkContext.getConf().registerKryoClasses([YourCustomClass])

3.4 Shuffle 文件与磁盘管理

Shuffle 操作会产生大量中间文件,清除不及时会导致磁盘占满(No space left on device)。关键配置:

  • spark.shuffle.file.buffer:每次输出流缓冲大小(默认 32KB),可适当调大到 64KB 减少磁盘 I/O 次数。
  • spark.reducer.maxSizeInFlight:Reduce 端同时拉取数据大小(默认 48MB),调大可提高网络利用率但可能 OOM。
  • 使用 External Shuffle Service(动态资源分配时必须开启),否则删除 Executor 会导致 Shuffle 数据丢失。

4. 动态资源分配与并行度设置

4.1 并行度:Task 数量 = 效率的调节阀

  • 规则 :一个 Stage 的 Task 数由该 Stage 最后一个 RDD 的分区数决定。对于 Shuffle 操作,分区数由 spark.sql.shuffle.partitions 控制(默认 200)。
  • 过小并行度:每个 Task 处理数据过多,内存压力大,易溢出,CPU 利用率低。
  • 过大并行度:Task 过多带来调度开销大(每个 Task 都有启动开销),并且小文件问题严重。

推荐的默认值设定 :对于批处理 ETL,建议 spark.sql.shuffle.partitions = 集群总核数的 2~3 倍。例如 100 个 Executor * 4 核 = 400 核,可设该参数为 800~1200。

但现在 AQE 可以动态合并或增大分区,所以不必过于纠结初始值,按数据量粗估设为 200~500,再交由 AQE 微调即可。

AQE 的自动分区合并

复制代码
spark.sql.adaptive.coalescePartitions.enabled=true
spark.sql.adaptive.advisoryPartitionSizeInBytes=128MB (目标分区大小)

4.2 动态资源分配

动态资源分配 (spark.dynamicAllocation.enabled=true) 允许 Spark 根据负载自动增删 Executor。这对多租户共享集群非常有价值,可提升资源利用率。

关键配置:

复制代码
spark.dynamicAllocation.enabled=true
spark.dynamicAllocation.minExecutors=2
spark.dynamicAllocation.maxExecutors=50
spark.dynamicAllocation.executorIdleTimeout=60s
spark.dynamicAllocation.schedulerBacklogTimeout=1s
spark.shuffle.service.enabled=true   # 必须使用外部 Shuffle 服务

动态分配依赖外部 Shuffle Service,以保证闲置 Executor 被回收后,其产生的 Shuffle 文件仍可被后续 Task 读取。

4.3 并行度对写入行为的影响

写入文件时,Task 个数将直接决定输出文件数量。使用 coalesce() 减少分区数,但要注意 coalesce 是窄依赖,可能导致数据偏移。更好的方法是在写入时使用分区感知写入:

python 复制代码
df.repartition(10).write.mode("overwrite").parquet("/path")
# 或使用 partitionBy 时控制分区数及每个分区内的文件数

对于 Hive/Iceberg 表,建议开启 spark.sql.files.maxRecordsPerFile 或通过表格式的压缩功能(Iceberg rewriteDataFiles)管理小文件。


5. Join 策略选择与 Broadcast Hash Join

Join 是 Spark SQL 中最常见也最需要优化的操作。Spark 支持多种 Join 算法,通过执行计划可清晰看出选择了哪种。

5.1 五种 Join 策略简介

策略 触发条件 特点
Broadcast Hash Join 一张表小于 spark.sql.autoBroadcastJoinThreshold(默认 10MB) 无 Shuffle,小表被广播到所有 Executor
Shuffle Hash Join 两表都较大但又可构建哈希表(较少用) Shuffle + 本地哈希 Join,默认不启用
Sort Merge Join 两表均很大,且按 Join Key 排序(最常见的大数据 Join) 两个 Stage 分别 Shuffle 排序,再合并
Broadcast Nested Loop Join 无等值条件,但其中一表很小可广播 双层循环,非常慢,尽量避免非等值 Join
Cartesian Product Join 无条件 Join 且无广播可能 极其昂贵,必须 re-partition + 双循环

5.2 Broadcast Hash Join 实战技巧

Broadcast Hash Join 是小表 Join 的银色子弹:它在驱动程序将整张表通过 HTTP 或 BT 协议分发到每个 Executor 的内存中,避免大量 Shuffle。

使用方式:

python 复制代码
from pyspark.sql.functions import broadcast
df_big.join(broadcast(df_small), "key", "left")

调大自动广播阈值:很多时候小表实际大小略超过 10MB(例如 50MB),而我们内存足够,可以调大该阈值:

复制代码
spark.sql.autoBroadcastJoinThreshold=104857600  # 100MB

注意事项

  • 必须确保广播表加载后 + 运行内存不超标。
  • Driver 端要将小表全部读入收集到 driver,如果小表巨大而 driver 内存不足会 OOM。确保 spark.driver.memory 足够大。
  • 如果小表是动态生成的(如从 Hive 表读取),Spark 可能会将其物化到磁盘然后收集,这也会影响 driver 内存。
  • 广播后会在每个 Executor 内存中存储一份,过多的广播会造成存储内存压力,导致缓存的 RDD 被驱逐。

5.3 Sort Merge Join 的优化

对于两张大表,通常使用 Sort Merge Join。优化要点仍然是避免倾斜并调节 Shuffle 分区数。

如果发现 Sort Merge Join 执行慢:

  • 在 Join Key 散列良好时,使用 AQE 自动倾斜处理。
  • 如果某侧数据可预聚合,先聚合再 Join(map-side combine)。
  • 考虑分区裁剪和列裁剪,减少 Join 数据量。
  • 必要时修改 Join 顺序:小表 Join 中等表后再 Join 大表,Catalyst 可自动重排,但复杂时可能需要加 Hint。
sql 复制代码
SELECT /*+ BROADCAST(small_tbl), MERGE(medium_tbl, large_tbl) */ ...

(Spark 3.x 支持 Join Hint)

5.4 非等值 Join 与 Join 爆炸

非等值 Join(df1.join(df2, df1.start < df2.timestamp))如果没有广播可用,会退化为 BroadcastNestedLoopJoin 或 CartesianProduct,性能极差。解决方案有:

  • 将范围条件转成等值条件(例如分桶 + 等值 Join),或者使用窗口函数。
  • 若数据模型允许,预处理数据使得变成等值 Join。
  • 对超大表,只能使用近似算法或重构数据管道。

6. 监控与诊断:Spark UI 和日志解读

生产环境下的调优不是"前置参数预制"的过程,而是 观察 → 诊断 → 调整 → 验证 的循环。Spark UI 和日志就是这一过程的核心工具。

6.1 Spark UI 关键页面及其含义

6.1.1 Jobs / Stages 页面
  • Duration:各 Stage 耗时,找到最慢的 Stage。
  • Input Size / Shuffle Read:该 Stage 处理的数据量。
  • GC Time:每个 Task 的 GC 时间,如果占比高,考虑内存重分配或改变 GC 策略。
  • Spill (Memory/Disk):若有大量 Spill,说明 Executor 内存不足以容纳 Shuffle 或聚合中间数据。
  • Skewness:点击 Stage 详情,进入 "Summary Metrics" 查看 Shuffle Read Size 的分布,Max / Median 过大说明倾斜。
6.1.2 SQL / DataFrame 页面

对于 Spark SQL,会有详细的逻辑计划和物理计划图表,高亮显示各个操作符的时间。你可以看到:

  • Exchange 操作是 Shuffle 发生的点。
  • 如果是 BroadcastHashJoin,可以看到广播表的大小。
  • 通过 WholeStageCodegen 括号里的时间,判断算子性能。
6.1.3 Storage 页面

展示缓存的 RDD/DataFrame 信息,包括大小、缓存程度、是否溢出到磁盘。如果看到缓存数据 Disk Size 很大,意味着存储内存不足,适当提高 spark.memory.storageFraction 或减少缓存数量。

6.1.4 Environment 页面

列出全部配置,帮你在排错时快速确认参数是否生效、版本信息、类路径等。

6.2 Spark 日志与 Executor 日志

Driver 日志 :打印执行计划、DAG 构建、任务调度、错误堆栈。通过 spark-submit 的输出或 $SPARK_HOME/logs 获取,是检测 OOM、序列化错误、用户代码异常的第一现场。

Executor 日志 :可查看具体 Task 的异常和 GC 情况,尤其当某些 Task 反复失败时,通过 Spark UI 的 Stages → 失败的 Task 可定位到 stderrstdout

开启 verbose GC 日志后,可使用 GC 日志分析工具(如 GCViewer)观察 GC 频率和停顿时间。

6.3 生产监控最佳实践

  • Spark History Server:保留已完成作业的 UI,用于事后分析。
  • Spark Metrics System:可对接 Prometheus + Grafana,监控 Executor 内存、GC、CPU 使用率、Shuffle 字节等。
  • 报警:对 Stage 失败率、OOM 频次、作业延迟设置报警。
  • 参数一致性 :将通用性能参数固化在集群级别的 spark-defaults.conf 中,避免每个作业随意修改导致不可预期的行为。

7. 整体调优清单(快速参考)

类别 检查项 典型调整动作
并行度 活动 Task 数是否与集群核数匹配 AQE 自动合并/增大,或手动设置 shuffle.partitions
内存 GC 时间占比、Spill 情况 调整 Executor 内存、存储/执行内存比、使用 G1GC
数据倾斜 Stage 中 Task 最大时长与中位数差距 加盐法、开启 AQE Skew Join、过滤空值
Join 策略 物理计划中是否期待 BroadcastHashJoin 却未广播 用小表 broadcast hint、调大广播阈值
Shuffle Shuffle Read 数据量、Shuffle 错误量 Kryo 序列化、map-side 聚合、开启外部 Shuffle 服务
数据读取 是否全表扫描,列裁剪与谓词下推是否生效 使用 Parquet/ORC 列存、分区过滤、Spark SQL 语法
资源动态 需要弹性,避免长时间占用 YARN 队列 开启 Dynamic Allocation + External Shuffle Service

Spark 性能调优没有银弹,但如果你透彻理解了 DAG 与 Stage、内存与 Shuffle、Join 机制、监控回溯,就能在绝大多数场景中快速定位问题并将其解决。希望本文提供的理论、代码和诊断方法,能让你的 Spark 作业从勉强可用,迈向生产级的稳定高效。

相关推荐
专科3年的修炼3 小时前
uni-app移动应用开发第四章
开发语言·javascript·uni-app
武帝为此5 小时前
【Selenium 执行 JavaScript】
javascript·selenium·测试工具
一锤捌拾6 小时前
V8引擎精品漫游指南--Ignition篇(下 一) 动态执行前的事情
前端·javascript
Elastic 中国社区官方博客6 小时前
用于 JavaScript 和 TypeScript 的 ES|QL 查询构建器:流式、类型安全的查询构建
大数据·javascript·数据库·elasticsearch·搜索引擎·typescript·全文检索
蜡台7 小时前
使用 html javascript 实现 金币落袋效果
前端·javascript·html
李白的天不白7 小时前
VUE依赖配置问题
前端·javascript·vue.js
ZC跨境爬虫8 小时前
跟着 MDN 学 HTML day_7:(进阶文本语义标签全覆盖)
前端·javascript·css·ui·html
冰暮流星8 小时前
javascript之事件冒泡与事件捕获
开发语言·前端·javascript
小智社群8 小时前
获取贝壳新房列表
前端·javascript·vue.js