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 的一个分区。例如 map、filter、flatMap。这些操作可在同一个 Stage 内以**流水线(pipeline)**方式执行,无需 Shuffle。
宽依赖(Shuffle 依赖) :子 RDD 的每个分区依赖于父 RDD 的多个分区,通常是 reduceByKey、groupByKey、join(非广播)等。这会导致数据重新分区,是 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,改用reduceByKey或aggregateByKey,groupByKey会将所有值加载到内存再聚合。 - 及时
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 两阶段聚合(加盐去盐)
适用于 reduceByKey 或 groupByKey 等聚合。给倾斜的 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 Combine :
reduceByKey在 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 可定位到 stderr 和 stdout。
开启 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 作业从勉强可用,迈向生产级的稳定高效。