Spark 执行原理与 PySpark 性能优化学习笔记
一、Spark 核心概念
1.1 技术背景
本文基于 Spark 3.x 版本(生产环境使用 Spark 3.2+,Python 3.9)。以下概念和优化方案均以 Spark 3.x 为基础,部分特性(如 AQE 自适应执行、pandas_udf 的 @pandas_udf 装饰器语法)在 Spark 2.x 中不可用或 API 不同。
Spark 核心使用 Scala 编写,运行在 JVM 上。PySpark 是 Python 对 Scala 核心的封装,Python 驱动进程通过 Py4J 桥接调用 JVM Driver 端的 SparkContext,Executor 端按需 fork Python worker 进程执行 Python UDF。
Python 驱动进程(跑 .py 脚本)
↕ Py4J
JVM Driver 进程(持有 SparkContext,做 DAG 调度、Task 分发)
↕ RPC
JVM Executor 进程 ×N
↕ socket
Python Worker 进程 ×M(按需 fork,跑 Python UDF)
一个 .py 文件通过 spark-submit 提交一次,就是一个 Spark Application。Application 是最外层概念,对应整个运行生命周期。
1.2 四层逻辑概念:Application → Job → Stage → Task
这四层不是随意堆叠的,每层解决一个其他层无法处理的特定问题。
Application:资源管理和生命周期的边界。集群管理器(YARN/K8s/Standalone)以 Application 为单位分配 Driver 和 Executors,维护运行期间的共享状态(SparkContext 配置、广播变量、累加器、缓存数据)。
Job:Lazy evaluation 的触发单元。对应一个 Action。Transformation 只构建 RDD 血缘图不执行,Action 触发实际计算。一个 Application 中有多少个 Action 就有多少个 Job,按提交顺序编号。Job 数量可能取决于运行时数据(如根据 count 结果走不同分支),所以整个 Application 有多少个 Job、多少个 Stage 无法提前确定。
Stage:Shuffle 屏障与流水线优化的分界点。DAGScheduler 从 final RDD 反向回溯血缘图,遇到宽依赖(ShuffleDependency)就切一刀形成 Stage 边界,窄依赖继续走不切。一个 Stage 内部的多个窄依赖 RDD 融合成一遍遍历(pipeline),Stage 之间用 shuffle 文件做同步屏障。前一个 Stage 全部 Task 完成后,下一个 Stage 才能开始。
Task:并行度和数据本地性的最小单元。一个 Task 处理一个 partition,是一对一关系。Stage 有多少个 partition 就生成多少个 Task。Task 是被调度到 Executor 上具体运行的物理单元。
这四层还构成分层容错模型:Task 失败重跑单个 Task,Stage 失败重跑该 Stage 所有 Task,Job 失败重跑整个 Job,Application 失败重启 Application。
1.3 物理执行模型
一个 Task 对应 Executor 上的一个 core(slot),运行在一个线程里。一个 Executor 有几个 core 就能同时运行几个 Task。并行度 = 所有 Executor 的 core 总数。
一个 Executor(JVM 进程)可以同时运行多个 Task,数量由 spark.executor.cores 决定。多个 Task 共享同一个 Executor 的内存(内存是 Executor 级别分配的)。
PySpark 中,涉及 Python UDF 的 Task 会额外 fork Python worker 进程,JVM 线程通过 socket 与 Python worker 通信。spark.python.worker.reuse=true(默认)让 Python worker 进程在 Task 之间复用,避免反复 fork 和 import 库的开销。
二、RDD 与血缘图
2.1 RDD 是什么
RDD(Resilient Distributed Dataset,弹性分布式数据集)是数据的逻辑抽象,不是实际数据本身。RDD 代表"我知道我的数据该怎么从父 RDD 算出来",实际数据在磁盘或 Executor 内存里。
2.2 血缘图(RDD Lineage)
每次 Transformation 接收父 RDD,产出子 RDD。子 RDD 内部记录两样东西:依赖哪些父 RDD(Dependency),以及把父 RDD 数据变成自己数据的函数(算子函数)。这个串联关系形成血缘图。
血缘图是"数据如何被生产出来"的元数据图,每个 RDD 节点存储:
- 依赖关系:指向父 RDD 的引用,以及依赖类型(NarrowDependency 或 ShuffleDependency)。窄依赖记录子 partition 和父 partition 的具体映射;宽依赖附带 shuffle 元信息。
- 计算函数:map 存的是 lambda 函数,filter 存的是判断谓词等。Task 执行时用这个函数对分区数据做变换。
- 分区器:记录数据按什么方式分区(HashPartitioner / RangePartitioner),用于判断两个 RDD 是否 co-partitioned。
- 分区信息:有多少个 partition,以及每个 partition 的元信息(数据地址,不是数据本身)。
- 数据本地性提示:每个 partition 的数据优先在哪个节点,DAGScheduler 用此做数据本地性调度。
- Shuffle 依赖的额外元信息:aggregator(聚合函数)、key 排序器、序列化器、mapSideCombine 标志等。
2.3 窄依赖与宽依赖
这是两个独立维度的分类:
- Transformation vs Action:区分执行时机。Transformation 构建 RDD 血缘图,延迟执行;Action 触发计算,产出结果。Action 不产出新 RDD,不参与依赖分类。
- Narrow vs Wide dependency:区分数据流动方式。窄依赖的子 partition 只依赖父 RDD 中一个固定子集的 partition,不需要 shuffle;宽依赖的子 partition 依赖父 RDD 多个 partition 的数据,需要 shuffle。
Transformation 既有窄依赖也有宽依赖。Action 既不是窄依赖也不是宽依赖。
判断标准:子 partition 是否需要父 RDD 多个 partition 的数据。需要就是宽依赖,不需要就是窄依赖。
常见操作分类:
| 操作 | 类型 | 依赖 | 说明 |
|---|---|---|---|
| map / filter / flatMap | Transformation | 窄依赖 | 每个 partition 原地变换 |
| union | Transformation | 窄依赖 | 拼接,不跨 partition 拉数据 |
| coalesce(shuffle=false) | Transformation | 窄依赖 | 合并相邻 partition |
| groupByKey / reduceByKey | Transformation | 宽依赖 | 按 key 重新分布 |
| join | Transformation | 宽依赖* | 默认需 shuffle;若 co-partitioned 则窄依赖 |
| repartition | Transformation | 宽依赖 | 重新打散数据 |
| sortByKey | Transformation | 宽依赖 | 全局排序需跨 partition |
| collect / count / save / take | Action | 不适用 | 触发执行,不产出新 RDD |
三、执行流程详解
3.1 Transformation 阶段(构建血缘图)
逐行执行 Transformation 代码,每行创建一个新 RDD 对象,记录依赖关系和计算函数。不碰任何数据,数据还在磁盘上。
3.2 Action 触发(DAGScheduler 接手)
Action 作用在血缘链末端的 final RDD 上。DAGScheduler 从 final RDD 开始向前回溯血缘图,逐段检查依赖类型:窄依赖不切继续走,宽依赖切一刀形成 Stage 边界。回溯完成后,血缘图被切分成若干 Stage,Stage 之间按依赖顺序形成 DAG。
3.3 Stage 执行
从最上游 Stage(直接读取数据源)开始逐层执行。上游 Stage 所有 Task 完成后,中间结果通过 shuffle 写到磁盘,下游 Stage 从磁盘拉取 shuffle 数据继续执行,逐层推进直到 final Stage 完成产出 Action 结果。
3.4 完整示例
python
lines = sc.textFile("data.txt") # RDD1
words = lines.flatMap(lambda line: line.split(" ")) # RDD2
pairs = words.map(lambda word: (word, 1)) # RDD3
counts = pairs.reduceByKey(lambda a, b: a + b) # RDD4
filtered = counts.filter(lambda kv: kv[1] >= 2) # RDD5
result = filtered.collect() # Action
血缘图:
RDD1 →(窄)→ RDD2 →(窄)→ RDD3 →(宽)→ RDD4 →(窄)→ RDD5
DAGScheduler 从 RDD5 回溯,在 RDD4↔RDD3 之间切刀(reduceByKey 是宽依赖):
Stage 0: RDD1 → RDD2 → RDD3(flatMap + map 融合成一遍遍历)
↕ shuffle 屏障
Stage 1: RDD4 → RDD5(reduceByKey + filter 融合成一遍遍历)
3.5 Task 数量决定规则
一个 Stage 的 Task 数 = 该 Stage 输出端 RDD 的 partition 数。窄依赖不改变 partition 数,宽依赖由操作的 partitioner 决定新的 partition 数。每个 Task 处理一个 partition。
四、Spark SQL 执行模型
Spark SQL(DataFrame API)在 RDD 执行模型之上增加了 Catalyst 优化器层。DataFrame 操作先构建逻辑计划,经 Catalyst 优化生成物理计划,再翻译为底层 RDD 的 DAG 执行。
4.1 Action 与 Job 的对应
DataFrame 的 Action 同样触发 Job。常见 Action:count()、show()、collect()、write 等。
4.2 count() 的 shuffle 问题
- RDD API 的 count():1 个 Stage,无 shuffle。每个 Task 返回一个 Long 给 Driver,Driver 求和。
- DataFrame API 的 count():2 个 Stage,有 shuffle。Catalyst 生成标准聚合计划:Scan → partial count → Exchange(shuffle 到 SinglePartition) → final count → collect。shuffle 传输的只是每个 partition 的一个数字,数据量极小,性能影响可忽略。
4.3 show() 的执行
show(n) 内部调用 take(n+1),物理计划为 Scan → GlobalLimit → Collect。简单 SELECT 无 shuffle 时为 1 个 Stage。
五、代码层面常见问题与解决方案
前面四章讲的是 Spark 执行原理。从这一章开始进入优化篇。写 PySpark 代码时,有一些常见问题会显著影响性能,多数属于"写了就能改好"的类型,不需要调集群参数。以下系统列举这些问题及其解决方案。
5.1 问题清单总览
代码层面的问题按影响环节可分为三类:数据传输问题(Python 与 JVM 之间怎么搬数据)、执行计划问题(多少个 Job、每个 Job 做了什么)、数据访问问题(读什么、怎么读、怎么写)。
5.2 普通 udf 导致逐行序列化
问题:PySpark 中普通 udf 逐行处理标量值。JVM 把每行数据 pickle 成字节流,通过 socket 发给 Python worker,Python unpickle 后处理,结果再 pickle 发回 JVM。1 亿行就是 1 亿次序列化往返。pickle 协议为每个对象写类型信息、对象头、长度标记,冗余开销大。
解决方案:改用 pandas_udf,用 Apache Arrow 列式格式批量传输,一次传数千行,类型信息只写一次,且 Arrow 是跨语言内存格式,JVM 和 Python 可直接访问同一块内存区域。两个优化叠加:批量传输减少通信次数,列式格式消除每对象的冗余头信息。前提是开启 spark.sql.execution.arrow.pyspark.enabled=true。
python
# 普通 udf(逐行处理标量值)
@udf(StringType())
def merge_fn(col1, col2):
return process(col1, col2)
# pandas_udf(批量处理 Series)
@pandas_udf(StringType())
def merge_pandas_udf(series1, series2):
# series 是每列的 pandas Series(数千行)
results = []
for i in range(len(series1)):
results.append(process(series1.iloc[i], series2.iloc[i]))
return pd.Series(results)
注意事项:pandas 中缺失值是 NaN 而非 None,需要用 pd.isna() 检查并转换,否则会把 NaN 字符串写进 JSON 导致下游解析失败。JSON 解析和序列化本质上是逐行操作无法向量化,但 Arrow 批量传输本身已带来数倍提速。这是 PySpark 任务中通常收益最大的单项优化。
5.3 冗余 Action 触发额外全表扫描
问题:Spark 是惰性求值,每个 Action 触发一个 Job、一次从头到尾的执行。调试用的 show() 和验证用的 count() 会触发额外的全表扫描。如果核心 Action(如写出结果)之外还有 3 个调试 Action,等于多扫了 3 遍全表。
解决方案:分两步。第一步用 cache() 缓存 DataFrame,让后续 Action 从内存读而非重新扫描 Hive。第二步权衡保留还是移除调试 Action------如果核心优化(如 pandas_udf)已带来主要提速,额外扫描的占比不大,保留日志可观测性更合理;如果数据量在 TB 级,每次扫描代价巨大,应移除非必要 Action。
python
df = spark.sql(formatted_sql)
df.cache() # 标记缓存
sql_row_count = df.count() # 扫描 Hive + 缓存
df.show(2) # 从缓存读,不再扫描 Hive
df_output = df.withColumn('__line__', udf(...))
df_output.repartition(n).write.text() # 最终写出
cache 缓存的是 SQL 查询结果经反序列化后的行数据(压缩列式格式存于 Executor 内存),不是 HDFS 原始文件。代价是占用存储内存,与 shuffle 和执行内存共享同一块空间,需要根据可用内存判断是否值得。
5.4 RDD saveAsTextFile 导致 Python 通信开销
问题:通过 df.rdd.repartition(n).map(row_to_json).saveAsTextFile(path) 写出数据时,df.rdd 转换后每个字段提取要逐行通过 Python 通信完成(Python map),引入额外的跨进程通信。
解决方案:改用 DataFrame write API,纯 JVM 操作。
python
# 原始方式(RDD + Python map,有通信开销)
df_output.rdd.repartition(n).map(row_to_json).saveAsTextFile(path)
# 优化方式(DataFrame API,纯 JVM 写入)
df_output.repartition(n).write.text(path)
收益中等偏小,但改动简单无风险。前提是输出格式能被 DataFrame write API 支持(text、json、parquet、csv 等)。
5.5 SQL 裁剪缺失:SELECT * 和缺少分区过滤
问题:SELECT * 会读取所有列,包括不需要的大字段。缺少 WHERE dt='xxx' 分区条件会全表扫描所有分区。这两项在列式存储格式(Parquet/ORC)下浪费尤其严重------列式存储本可只读需要的列文件块、只读对应分区目录,但 SELECT * 和无分区条件绕过了这些优化。
解决方案:始终只 SELECT 需要的列,始终带分区过滤条件。
python
# 差:全表全列扫描
df = spark.sql("SELECT * FROM big_table")
# 好:只读需要的列和分区
df = spark.sql("SELECT poi_id, pic_id, spu_name, pic_url FROM big_table WHERE dt = '20260310'")
收益是数量级的------从扫描数十 GiB 变成扫描几十 MiB。这是成本最低、收益最高的优化之一,纯 SQL 写法调整,不需要改任何参数或代码逻辑。
5.6 collect() 拉全量数据到 Driver
问题:df.collect() 把所有 partition 的数据拉到 Driver 内存中。如果数据量大(如 GB 级),Driver 会 OOM 崩溃。即使不崩溃,序列化和网络传输大量数据到 Driver 也很慢。
解决方案:用 df.write 写到存储系统,而非 collect 到 Driver 处理。如果只需要少量数据做本地处理,用 df.take(n) 或 df.limit(n).collect() 限制拉取量。检查 spark.driver.maxResultSize 配置是否覆盖需求。
python
# 差:全量拉到 Driver
result = df.collect()
process_locally(result)
# 好:写到存储系统
df.write.parquet(output_path)
# 好:只拉少量数据
sample = df.take(100)
5.7 分区操作不当:repartition vs coalesce 误用
先说清楚两个操作各自是什么。
coalesce(n) :减少分区数的操作,不触发 shuffle。意思是"把当前的分区合并到 n 个"。比如当前有 200 个分区,调用 .coalesce(10) 后变成 10 个分区------Spark 把相邻的 20 个分区合并成 1 个,数据不跨网络移动,只是同一个 Executor 上把多个分区的数据拼到一起读。因为它不搬数据,所以速度极快,但只适合减少分区数(n 小于当前分区数)。如果用 coalesce 增加分区数(n 大于当前分区数),它实际不会生成更多分区,等于无效操作。另外因为是简单拼接相邻分区,合并后的分区数据量取决于原来各分区的大小,可能不均匀。
使用场景:filter 操作后数据量大幅减少,原来 200 个分区每个只剩几条数据,每个分区太小、Task 开销大于计算开销。这时用 .coalesce(20) 合并到 20 个分区,不触发 shuffle,快速高效。
合并的好处是什么?主要是两点。第一是减少 Task 调度开销。一个分区对应一个 Task,200 个分区就要启动 200 个 Task,每个 Task 的启动、调度分配、状态跟踪都有固定开销(约 1-5 毫秒/Task)。如果每个分区只有几条数据,计算本身可能只需 0.1 毫秒,但调度开销 5 毫秒,97% 的时间花在框架管理上。合并到 20 个分区后,只需 20 个 Task,每个有足够的数据量让计算开销占主导。第二是减少小文件数。写 HDFS 时每个分区产出一个文件,200 个分区写 200 个小文件,下游读取时打开 200 个文件的 I/O 开销大,HDFS NameNode 的元数据也膨胀。合并到 20 个分区后只产 20 个文件,每个文件数据量更大,读写效率更高。注意这两个好处都和网络开销无关------coalesce 本身就不触发 shuffle、不搬数据,它省的是 Task 管理开销和文件数,不是网络传输。
repartition(n) :重新分布数据的操作,触发 shuffle。意思是"把数据打散到 n 个分区"。比如当前有 200 个分区,调用 .repartition(500) 后变成 500 个分区------Spark 会启动一轮完整 shuffle,把所有数据重新分配。既能增加也能减少分区数。因为走了 shuffle,数据通过网络重新分布,开销大但分布均匀。
使用场景:数据倾斜需要重新分布、需要增加分区数提高并行度、需要控制输出文件数。
repartition 带字段和不带字段的区别:
df.repartition(200) 不带字段:用 RoundRobinPartitioner(轮询),依次把第 1、201、401... 条数据放到分区 0,第 2、202、402... 条放到分区 1,依此类推。分布是均匀的,但不保证相同 key 的数据在同一分区。
df.repartition(200, 'user_id') 带字段:用 HashPartitioner,对 user_id 做哈希后取模分配到 200 个分区。相同 user_id 的数据一定落在同一分区(这是 Join 时 co-partitioned 的前提)。分布的均匀程度取决于 user_id 的基数和分布------如果 user_id 有 1 亿个不同值且分布均匀,每个分区约 50 万个不同 user_id,比较均匀;但如果有个别 user_id 产生大量记录,该 user_id 所在分区就会偏大(数据倾斜)。
问题:该用 coalesce 的场景用了 repartition 会引入不必要的 shuffle(只是想减少分区数,没必要重新搬数据)。该用 repartition 的场景用了 coalesce 会无效(想增加分区数但 coalesce 做不到)或分布不均(coalesce 只是拼接相邻分区,不重新哈希)。
python
# filter 后数据量大幅减少,200 个分区每个只剩几条,合并到 10 个(不 shuffle)
df_filtered = df.filter(df.status == 'active').coalesce(10)
# 数据倾斜,需要按 user_id 重新哈希分布(触发 shuffle)
df_repartitioned = df.repartition(200, 'user_id')
# 只是想均匀打散到 200 个分区,不关心相同 key 是否在同一分区(触发 shuffle)
df_round = df.repartition(200)
分区数经验值:总 core 数的 2-4 倍。太少资源闲置,太多则调度开销大且每个 Task 数据量小。
5.8 Join 未利用广播变量
要理解这个问题,先理解广播变量是什么。
广播变量的概念:广播变量是 Spark 提供的一种只读共享变量的分发机制------由 Driver 创建一个变量,序列化后发送到每个 Executor 缓存一份,该 Executor 上的所有 Task 共享这一份副本,而不是每个 Task 各自带一份。它解决的问题是:分布式环境下,多个 Task 需要访问同一份数据时,如何避免重复传输和重复存储。
举例说明:假设有 500 个 Task 分布在 50 个 Executor 上,每个 Task 都需要一份 200MB 的查找表。如果不使用广播变量,这份数据会随每个 Task 的闭包序列化传输,200MB × 500 = 100GB 的网络传输和内存占用。使用广播变量后,数据只发给每个 Executor 一份,同一个 Executor 上的 10 个 Task 共享同一份,200MB × 50 = 10GB,减少十倍。
为什么 Join 会用到广播:假设有一张 3 亿行的大表(订单数据)和一张 10 万行的小表(城市维表,记录 city_id → city_name 的映射),要按 city_id 关联。默认的 Sort Merge Join 做法是:两张表都按 city_id 做 shuffle 重新分布,各自排序后归并。这意味着 3 亿行的大表要 shuffle(网络传输),10 万行的小表也要 shuffle。但小表只有 10 万行、可能就几 MB,完全可以把整个小表复制到每个 Executor 的内存里,大表就不需要 shuffle 了------每个 Executor 本地就能做 hash 查找。这就是 Broadcast Hash Join:广播小表,大表不动。
问题:如果没有利用广播,小表也跟着大表一起 shuffle。小表数据量虽小,但 shuffle 意味着先写磁盘再从磁盘拉取,且大表的 shuffle 数据量可能很大。一场本可以只搬小表就能解决的 Join,变成两张表都搬。
解决方案 :两种方式触发广播 join。第一种是在代码中显式用 broadcast() 提示 Spark 广播小表:
python
from pyspark.sql.functions import broadcast
# 显式告诉 Spark:small_dim_table 很小,广播它
result = big_table.join(broadcast(small_dim_table), 'city_id')
第二种是调大自动广播阈值 spark.sql.autoBroadcastJoinThreshold(默认 10MB)。Catalyst 优化器在生成执行计划时会估算参与 Join 的表的大小,小于这个阈值的表自动广播。如果小表是 200MB,默认阈值 10MB 不够,调到 300MB 就能自动走广播:
python
spark.conf.set('spark.sql.autoBroadcastJoinThreshold', 300 * 1024 * 1024) # 300MB
什么时候能用、什么时候不能用:广播的本质是把小表完整复制到每个 Executor。所以前提是小表够小------能放进单个 Executor 的内存且复制总量可接受。如果 Executor 有 50 个,小表 200MB,广播总开销是 200MB × 50 = 10GB 网络传输,可以接受。但如果小表是 5GB,50 个 Executor 要复制 250GB,网络和内存都撑不住,这时就不能广播,只能走 shuffle。经验上小表在几百 MB 以内适合广播,超过 1GB 要谨慎评估。
另一个场景------非 Join 的广播变量 :除了 Join,广播变量也用于 Task 间共享任意只读数据。比如 UDF 中需要查一个映射字典,不要把字典放在闭包里(每个 Task 带一份),而是用 spark.sparkContext.broadcast(dict) 广播一次,每个 Executor 共享一份:
python
# 差:字典在闭包里,每个 Task 序列化一份
city_map = {'001': '北京', '002': '上海', ...}
@udf(StringType())
def get_city(city_id):
return city_map.get(city_id, '未知') # city_map 随每个 Task 序列化传输
# 好:广播一次,Executor 上所有 Task 共享
city_map_bc = spark.sparkContext.broadcast(city_map)
@udf(StringType())
def get_city(city_id):
return city_map_bc.value.get(city_id, '未知') # 从广播变量读取,不随 Task 传输
5.9 UDF 中创建大量临时对象
问题:Python UDF 中每行创建大量临时对象(如频繁 json.loads/json.dumps、字符串拼接、字典创建),对象创建速度远超 JVM GC 回收速度,导致 Python worker 内存膨胀甚至 OOM,同时 GC 压力大。
解决方案:在 UDF 内部复用对象、减少不必要的序列化/反序列化。例如模板替换场景中,预编译正则、预解析静态部分、复用字典对象。pandas_udf 可以在批处理中复用更多资源(如只解析一次模板,循环处理整批数据)。
python
# 差:每行重新编译正则、重新解析模板
@udf(StringType())
def replace(val):
pattern = re.compile(r'\{\{(\w+)\}\}') # 每行都编译
return pattern.sub(replacer, template) # 每行都解析模板
# 好:闭包捕获预编译结果,pandas_udf 批量处理
compiled_pattern = re.compile(r'\{\{(\w+)\}\}')
parsed_template = json.loads(template) # 只解析一次
@pandas_udf(StringType())
def replace_batch(series):
# 复用 compiled_pattern 和 parsed_template
results = []
for val in series:
results.append(compiled_pattern.sub(replacer, template))
return pd.Series(results)
5.10 未开启 Arrow 优化导致 UDF 性能回退
问题:即使代码中用了 pandas_udf,如果 spark.sql.execution.arrow.pyspark.enabled 未设置为 true,Arrow 批量传输不生效,pandas_udf 退化为普通传输模式,失去核心优化。
解决方案:在 spark-submit 参数或 SparkSession 初始化时显式开启。这是 pandas_udf 发挥作用的前提条件。
python
# spark-submit 参数
--conf spark.sql.execution.arrow.pyspark.enabled=true
# 或在代码中设置
spark = SparkSession.builder \
.config('spark.sql.execution.arrow.pyspark.enabled', 'true') \
.getOrCreate()
六、性能优化经验沉淀:从哪些维度入手
前面五章分别讲了执行原理和代码层面的常见问题。实际做优化时,面对一个慢的 Spark 作业,需要一套系统化的排查框架,知道从哪些维度入手、先做什么后做什么、每个维度的收益和风险如何权衡。代码层面的问题(第五章)只是其中一个维度,还有资源配置、执行过程、容错稳定性等多个维度需要系统排查。以下提炼出十二个维度,每个维度搭配案例说明。
6.1 十二个优化维度总览
维度一:数据传输开销 --- PySpark 特有,Python 与 JVM 之间数据怎么搬
维度二:执行计划与 Action 设计 --- 多少个 Job,每个 Job 做了什么
维度三:分区与并行度 --- Task 数和 core 数匹不匹配
维度四:内存与 Spill --- 数据处理过程中装不装得下
维度五:Shuffle 与数据倾斜 --- 数据搬运代价和分布均匀性
维度六:容错与稳定性参数 --- 会不会误杀 Executor,超时设置合不合理
维度七:数据源与 I/O 优化 --- 读什么格式、怎么裁剪、小文件问题
维度八:Join 策略优化 --- 广播 join vs 排序合并 join,join 顺序
维度九:数据本地性 --- 数据在哪、Task 跑在哪,两者距离远不远
维度十:序列化优化 --- Kryo vs Java 序列化,shuffle 和 cache 的开销
维度十一:GC 调优 --- 垃圾回收算法选择,GC 占比分析
维度十二:Driver 端瓶颈 --- collect 拉多少数据、调度开销、Driver OOM
优先级判断原则:先看代码层面能不能数量级地消除问题(维度一、二、七、八),再看资源配置够不够(维度三、四),接着看执行过程中的瓶颈(维度五、九、十、十一),最后看兜底和容错(维度六、十二)。因为代码层的优化往往是数量级的提升(如逐行变批量、全表扫描变列裁剪),而参数调优通常是百分比级别的改善。
6.2 维度一:数据传输开销
这个维度只对 PySpark 有意义。纯 Scala/Java Spark 任务不存在 Python 与 JVM 的跨进程通信问题。
PySpark 中 Python UDF 的执行路径是:JVM Executor 把数据序列化 → socket 传给 Python Worker → Python 反序列化处理 → 结果序列化传回 JVM。传输方式决定开销量级。
普通 udf 逐行用 pickle 序列化,每行数据独立 pickle 成字节流,包含类型信息、对象头、长度标记等冗余开销。1 亿行就是 1 亿次序列化往返。pandas_udf 用 Apache Arrow 列式格式批量传输,一次传数千行,类型信息只写一次,且 Arrow 是跨语言内存格式,JVM 和 Python 可直接访问同一块内存区域,无需反序列化中间步骤。两个优化叠加:批量传输减少通信次数,列式格式消除每对象的冗余头信息。
前提条件:必须开启 spark.sql.execution.arrow.pyspark.enabled=true,否则 pandas_udf 退化为普通传输模式。注意 pandas 中缺失值是 NaN 而非 None,代码中需要用 pd.isna() 检查并转换,否则会把 NaN 字符串写进 JSON 导致下游解析失败。
案例:一个多模态数据预处理脚本(3000 万行),核心逻辑是用 Python UDF 做模板占位符替换和 JSON 合并------每行 Hive 数据经过 Python 处理后展开为完整的 JSON 对象。使用普通 udf 时,1 亿行需要 1 亿次 pickle 往返。改为 pandas_udf 后,按约 1000 行/批传输,通信次数降至 10 万次,减少三个数量级。这是该任务单项收益最大的优化。虽然 JSON 解析和序列化本质上是逐行操作无法向量化,但 Arrow 批量传输本身已带来数倍提速。
6.3 维度二:执行计划与 Action 设计
Spark 惰性求值意味着 Transformation 只构建计算图不执行,每个 Action 触发一个 Job、一次从头到尾的执行。Action 越多 = Job 越多 = 全表扫描次数越多。
这个维度的优化包括三个方面:减少冗余 Action、用 cache 复用中间结果、选择更高效的写出方式。
减少冗余 Action:调试用的 show() 和验证用的 count() 会触发额外全表扫描。需要权衡------如果核心优化(如 pandas_udf)已带来主要提速,额外两次扫描的占比不大,保留日志可观测性更合理。但如果每次扫描代价很大(如 TB 级数据),则应移除调试 Action。
用 cache 复用:同一个 DataFrame 被多次 Action 使用时,第一次 Action 后数据缓存在 Executor 内存中(压缩列式格式),后续 Action 直接读缓存,不再扫描 Hive。缓存的是 SQL 查询结果经反序列化后的行数据,不是 HDFS 原始文件。代价是占用存储内存,与 shuffle 和执行内存共享同一块空间。需要根据可用内存判断是否值得。
写出方式选择:DataFrame write API(如 df.repartition(n).write.text(path))是纯 JVM 操作,而 RDD saveAsTextFile 经过 df.rdd 转换后,每个字段提取要逐行通过 Python 通信完成(Python map)。收益中等偏小,但改动简单无风险。
案例:上述脚本产生 5 个 Job:两个 count()、两个 show()、一个 saveAsTextFile。其中前三个是调试用途,各触发一次全表扫描。加上 cache 后,df.count() 和 df.show(2) 复用同一次扫描的缓存结果,省掉一次全表扫描。最终保留 show/count 维护生产环境可观测性,因为 pandas_udf 优化后全表扫描的耗时占比已从 30% 降到 5% 以下。
6.4 维度三:分区与并行度
并行度 = min(partition 数, core 总数)。一个 Task 处理一个 partition,一个 core 同时跑一个 Task。partition 太少 core 吃不饱,资源闲置;partition 太多 core 跑不过来,Task 排队,每个 Task 数据量小,框架调度开销占比大。
关键参数:spark.executor.cores 决定每个 Executor 同时跑几个 Task,spark.dynamicAllocation.maxExecutors 决定最多多少个 Executor,两者乘积是最大并行度。spark.sql.shuffle.partitions 和 spark.default.parallelism 决定 shuffle 后的分区数。
核心匹配关系:partition 数应设为总 core 数的 2-4 倍。这样每个 core 跑 2-4 个 Task,既能充分利用并行度,又让调度器有调度弹性把慢 Task 上的空闲 core 分配给其他 Task。
案例:上述脚本数据量 3000 万行,代码中通过 repartition(num_files) 显式指定分区数。num_files 由 ceil(总行数 / 65536) 计算,3000 万行约 458 个分区,即 458 个 Task。如果 executor.cores=4,maxExecutors=50,最大并行度 200,Task 需要排队约 3 轮。如果 executor.cores=1,最大并行度只有 50,排队约 10 轮,整体耗时显著拉长。对于 PySpark,core=1 有时是刻意的------每个 Executor 只跑一个 Python worker,内存独占,避免多 worker 争抢内存导致 OOM。但如果瓶颈不在内存而在计算并行度,core=1 就是浪费。
6.5 维度四:内存与 Spill
内存不足时数据被溢写到磁盘(Spill),引入磁盘 I/O 和序列化开销。Spill 是性能杀手,一旦出现 GiB 级别的 Spill,说明内存配置和任务数据量不匹配。
关键参数:spark.executor.memory 是 Executor JVM 堆内存,spark.memory.fraction 控制堆内存中用于执行和 shuffle 的比例(默认 0.6),spark.yarn.executor.memoryOverhead 是堆外内存(Python worker 用的就是这块)。
判断方法:在 Stage Summary Metrics 中看 Spill (memory) 和 Spill (disk) 的值。如果 Min 值已达 GiB 级别,说明是系统性内存不足,不是个别 Task 偶发。同时看 GC Time 占 Duration 比例:如果 GC 很低但 Spill 很高,说明不是 GC 压力问题,而是纯粹的内存空间不足。
案例:上述脚本在 Spark History 中显示 Stage 6 的 Spill (memory) 中位数 32.5 GiB,Spill (disk) 中位数 11.5 GiB。每个 Task 的输入数据仅 59 MiB,但 Python UDF 将每行数据展开成完整 JSON 对象后,单行输出从几十字节膨胀到几 KB,处理过程中的中间数据量达到 GiB 级别。spark.memory.fraction=0.3(低于默认 0.6),意味着 16G 堆内存只有 4.8G 用于执行和 shuffle,严重不足。调回 0.6 后执行内存翻倍到 9.6G,预期能大幅减少 Spill。
6.6 维度五:Shuffle 与数据倾斜
Shuffle 是 Stage 之间的数据搬运:上游 Stage 按 key 哈希把数据写到磁盘,下游 Stage 从磁盘拉取。Shuffle 数据量大意味着大量磁盘 I/O 和网络传输。数据倾斜是 shuffle 的衍生态问题:如果 key 分布不均,某个 partition 的数据量远超其他,导致个别 Task 成为长尾。
关键参数:spark.sql.shuffle.partitions(Spark SQL shuffle 分区数)、spark.default.parallelism(RDD shuffle 分区数)、spark.sql.adaptive.enabled(AQE 自适应执行)。
AQE 的价值:开启后在 shuffle map 阶段跑完后收集各 partition 实际数据大小,将相邻小分区合并到接近目标值(spark.sql.adaptive.advisoryPartitionSizeInBytes,默认 64MB)。即使分区数设得偏大,AQE 也能在运行时自动降低分区数。分区"小"是相对于 advisory 目标值而言的。
数据倾斜诊断:同一 Stage 内大部分 Task 几秒完成,个别 Task 跑几十分钟,该慢 Task 的 Shuffle Read Records 远超其他 Task。Min/Max 比值超过 3 倍基本可以确认倾斜。
案例:上述脚本的 shuffle 总量约 212 GiB(17 个 Task × 12.7 GiB),而输入仅 1 GiB,数据膨胀 212 倍------UDF 做模板替换和 JSON 合并后输出远大于输入。但这种情况下数据分布是均匀的(每 Task 处理约 370 万行,Min/Max 比值 < 1.1),没有数据倾斜,瓶颈在于 shuffle 总量过大而非分布不均。优化方向是审视 UDF 输出的数据膨胀是否可避免(如 shuffle 前先过滤不需要的列),以及开启 AQE 作为分区数兜底。
6.7 维度六:容错与稳定性参数
容错参数决定异常情况下 Spark 的行为。配置不当会导致正常 Executor 被误杀(Task 重跑浪费资源)或死掉的 Executor 长时间不被发现(Task 卡住)。
核心原则:心跳间隔应远小于超时值,让 Executor 有机会错过 2-3 次心跳才被判定死亡。推荐心跳间隔 10-30 秒,超时 120-180 秒。
推测执行(Speculation)是双刃剑:它检测到慢 Task 后启动重复 Task 抢跑,先完成的为准。但如果慢 Task 的原因是数据量大(而非节点故障),推测执行会浪费资源------重复 Task 跑一段时间后被杀掉,中间结果丢弃。
案例:上述脚本的 spark.executor.heartbeatInterval=90000s(25 小时)和 spark.network.timeout=90500s(25 小时),间隔仅 0.5 秒。这意味着整个任务运行期间容错检测形同虚设------即使 Executor 已经 OOM 挂掉,25 小时内都不会被发现。正确配置应为心跳 10-30 秒、超时 120-180 秒。同时 spark.speculation=true 导致 Stage 6 有 5 个 Task 被 Killed------这些是推测执行启动的重复 Task,在原 Task 完成后被杀掉,每个已运行了 2.5 分钟左右,浪费了计算资源。如果慢 Task 的原因是数据量大(本案例每 Task 处理 370 万行),推测执行无法解决根本问题,反而增加资源消耗。
6.8 维度七:数据源与 I/O 优化
这个维度关注的是"读数据"和"写数据"的效率,发生在所有计算之前。如果数据源层面有浪费,后面所有优化都是在放大基础上做百分比改善。
文件格式选择:Spark 原生支持 Parquet、ORC、JSON、CSV、Text 等格式。Parquet 和 ORC 是列式存储,天然支持列裁剪(只读需要的列)和谓词下推(过滤条件推到存储层执行)。Text 和 JSON 是行式存储,读全表才能过滤,对于只取几列的查询浪费严重。Hive 表底层格式应在建表时就用 Parquet 或 ORC,避免用 TextFile。
分区裁剪:Hive 表按分区字段(如 dt)组织数据时,查询带 WHERE dt='xxx' 条件,Spark 只扫描对应分区目录,不读其他分区的文件。不加分区过滤条件就是全表扫描。这个优化收益是数量级的------从扫描 365 天的数据变成扫描 1 天。
列裁剪:SELECT col1, col2 FROM table 比 SELECT * FROM table 只读需要的列文件。列式存储格式下,不需要的列对应的文件块根本不会从磁盘读取。行式存储格式下,整行读取后丢弃不需要的列,I/O 浪费。
小文件问题:数据源目录下如果有大量小文件(远小于 HDFS block 大小 128MB),每个文件对应一个 partition/Task,大量小 Task 启动开销大且每个 Task 数据量小。Spark 3.x 有 spark.sql.files.maxPartitionBytes(默认 128MB)和 spark.sql.files.openCostInBytes(默认 4MB)控制文件合并,但如果小文件太多超出合并能力仍会导致 Task 膨胀。
关键参数:spark.sql.files.maxPartitionBytes(单 partition 最大读取字节数)、spark.sql.files.minPartitionNum(最小 partition 数)、spark.sql.files.openCostInBytes(打开文件的估算开销)。
案例:上述脚本查询的是 SELECT poi_id, pic_id, spu_name, pic_url FROM table WHERE dt='${now.datekey}',分区裁剪只扫描当天数据,列裁剪只读 4 个列。如果表底层是 Parquet,这两项优化自动生效。但如果有人改成 SELECT * 或者不加 dt 条件,扫描量可能从 59 MiB 膨胀到数十 GiB。
6.9 维度八:Join 策略优化
Join 是 Spark SQL 中最容易出性能问题的操作,因为几乎所有 Join 都涉及 shuffle(除非两张表已经按相同 key 分区)。选择正确的 Join 策略可以避免不必要的 shuffle。
Spark SQL 支持五种 Join 策略:Broadcast Hash Join(广播哈希 join,不 shuffle)、Shuffle Hash Join(shuffle 后哈希 join)、Sort Merge Join(shuffle 后排序合并 join,默认)、Broadcast Nested Loop Join(笛卡尔积,最慢)、Shuffle-and-Replicate Nested Loop Join。
Broadcast Hash Join 的原理:把小表完整地广播到所有 Executor 内存中,大表不需要 shuffle,每个 Task 本地做 hash 查找。前提是小表能放进广播内存阈值内。阈值由 spark.sql.autoBroadcastJoinThreshold(默认 10MB)控制。一张 500MB 的维表如果调大阈值到 500MB 就能广播,省掉整个 shuffle stage。但广播有上限------广播表复制到每个 Executor,如果 Executor 数量多(如 100 个),500MB × 100 = 50GB 网络开销,得不偿失。
Sort Merge Join 是大数据量 Join 的默认策略:两侧都 shuffle 按 key 重新分布,各自排序后做归并合并。稳定但 shuffle 代价大。Shuffle Hash Join 是 shuffle 后一侧建 hash 表一侧探测,适合一侧数据量远小于另一侧但不至于小到能广播的场景。
Join 顺序也很重要:多表 Join 时,先 Join 小表减少中间结果,后 Join 大表。Catalyst 优化器会自动做 Join Reorder,但对复杂 SQL 不一定最优。
Bucketing(分桶):如果两张表经常 Join 且按相同 key 分桶,可以预先在建表时指定分桶字段,这样 Join 时两侧数据已经按相同 hash 分布,不需要 shuffle。这是从数据建模层面消除 shuffle 的手段。
关键参数:spark.sql.autoBroadcastJoinThreshold(广播 join 阈值,默认 10MB)、spark.sql.join.preferSortMergeJoin(是否优先排序合并 join,默认 true)。
案例:假设上述脚本的数据源需要关联一张门店信息维表(几百 MB)获取门店名称,直接 Join 会产生 shuffle。如果维表只有 200MB,调大 spark.sql.autoBroadcastJoinThreshold 到 300MB 就能走 Broadcast Join,省掉一个 shuffle stage。但要注意 Executor 内存是否够放 200MB 的广播数据(广播数据存在 Executor 的存储内存中)。
6.10 维度九:数据本地性
Spark 调度 Task 时会尽量把 Task 分配到数据所在的节点上,避免网络传输。本地性从高到低分五个级别:PROCESS_LOCAL(Task 和数据在同一 Executor 进程中,最优)、NODE_LOCAL(Task 和数据在同一节点不同进程)、RACK_LOCAL(同一机架)、NO_PREF(无本地性信息)、ANY(任意节点)。
数据本地性的核心权衡是"等还是不等":DAGScheduler 发现某个 partition 的数据在节点 A 上,但节点 A 的 Executor 没有 core 空闲。它可以等待 A 的 Executor 空出来(获得 PROCESS_LOCAL),也可以立即发到其他有空闲 core 的节点(退化为 NODE_LOCAL 或 RACK_LOCAL,需要网络传输数据)。等待由 spark.locality.wait(默认 3 秒)控制,每个级别等这么久再降级。
数据本地性差的征兆:Spark History UI 的 Stage 详情页,Task 的 Locality Level 大量是 ANY 或 RACK_LOCAL,说明数据不在计算节点上。网络传输成为瓶颈。常见原因是动态分配不断新开 Executor(新 Executor 上没有缓存数据),或者集群负载高导致调度器无法匹配到本地节点。
关键参数:spark.locality.wait(本地性等待时间,默认 3 秒)、spark.locality.wait.process(PROCESS_LOCAL 级别等待)、spark.locality.wait.node(NODE_LOCAL 级别等待)。
案例:上述脚本在 Stage 6 的 Task Locality Level 全部是 PROCESS_LOCAL,说明数据本地性很好------Hive 表的 HDFS 数据块和计算 Task 在同一节点。这是因为 YARN 调度器优先在数据所在节点启动 Executor(NodeManager 感知 HDFS block 位置)。如果动态分配新开了 Executor 在没有数据的节点上,Locality 就会退化。对于读 HDFS 数据的任务,保持 Executor 稳定(不频繁新建销毁)有助于维持数据本地性。
6.11 维度十:序列化优化
Spark 中多个环节涉及序列化:shuffle 时数据要序列化写到磁盘再读回,cache 时 RDD/DataFrame 数据序列化为存储格式,广播变量序列化后传输。序列化方式直接影响这些环节的速度和数据体积。
Spark 默认使用 Java 序列化(spark.serializer=org.apache.spark.serializer.JavaSerializer),兼容性好但速度慢、体积大。改用 Kryo 序列化(spark.serializer=org.apache.spark.serializer.KryoSerializer)后,序列化速度提升约 10 倍,体积缩小约 3-5 倍。对于 shuffle 频繁的任务,Kryo 的收益显著。
Kryo 的注意点:需要注册自定义类才能获得最佳性能(spark.kryo.classesToRegister),未注册的类用全限定类名序列化,开销大。spark.kryo.registrationRequired=true 强制所有类必须注册,防止漏注册导致性能回退。
DataFrame API 的序列化由 Catalyst 优化器内部使用 Tungsten 二进制格式管理,不走 Java/Kryo 序列化器,所以纯 DataFrame 任务的序列化开销已经很低。序列化优化主要针对 RDD API 和涉及 Python 通信的场景。
关键参数:spark.serializer(序列化器选择)、spark.kryo.classesToRegister(注册类)、spark.kryo.registrationRequired(强制注册)。
案例:上述脚本如果将 UDF 处理后的数据通过 RDD shuffle 写出,shuffle 过程中的序列化开销不小。但如果改用 DataFrame API(pandas_udf 返回的是 Column),Catalyst 内部用 Tungsten 格式管理,序列化开销已经最小化。所以序列化优化在"已经用 DataFrame API"的前提下收益有限,主要针对还在用 RDD API 的存量任务。
6.12 维度十一:GC 调优
JVM 垃圾回收在 Spark 中是双刃剑:GC 暂停期间 Executor 无法处理 Task,如果 GC 频繁或每次暂停时间长,Task 执行时间被拉长。但 GC 是必要的------对象创建速度远超回收速度时内存会爆。
判断 GC 是否是瓶颈:Spark History UI 的 Stage Summary Metrics 有 GC Time 一栏。如果 GC Time / Duration 超过 30%,说明 GC 压力大。如果 GC Time 很低(如本案例 3-15 秒占 10-23 分钟的不到 2%),说明瓶颈不在 GC 而在别处(如 I/O 或 Python 通信),不需要花精力调 GC。
GC 算法选择:JDK 8 默认 Parallel GC,适合吞吐量优先场景但暂停时间长。G1GC(-XX:+UseG1GC)将堆分为 region,暂停时间可控,适合大堆内存(>8G)的 Executor。对于 Executor 内存 16G 以上的任务,建议切换到 G1GC 并设置暂停目标(-XX:MaxGCPauseMillis=200)。
GC 调优与内存管理的关系:GC 压力大的根因往往是内存配置不合理------要么 executor.memory 太小导致频繁 GC,要么对象创建速度过快(如 UDF 中每行 new 大量临时对象)。先排查代码层面的内存使用,再调 GC 参数。
关键参数:通过 spark.executor.extraJavaOptions 设置 JVM GC 参数,如 -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m。
案例:上述脚本的 GC Time 中位数 5 秒,占 Duration 中位数 15 分钟的 0.6%,说明 GC 不是瓶颈,不需要调优。瓶颈在 Python UDF 的逐行 pickle 传输和内存 Spill。如果换了 pandas_udf + 调了 memory.fraction 后 GC 仍低,就继续跳过 GC 调优,把精力放在更高收益的维度。
6.13 维度十二:Driver 端瓶颈
前面所有维度都关注 Executor 端,但 Driver 端也可能成为瓶颈。Driver 负责 DAG 调度、Task 分发、结果收集,如果 Driver 处理不过来,整个作业变慢。
最常见的 Driver 瓶颈是 collect() 拉取过多数据:df.collect() 把所有 partition 的数据拉到 Driver 内存中。如果数据量大(如 GB 级),Driver 会 OOM 崩溃。即使不崩溃,序列化和网络传输大量数据到 Driver 也很慢。正确做法是用 df.write 写到存储系统,而不是 collect 到 Driver 处理。
Task 数量过多导致调度开销:如果一个 Stage 有数十万个 Task,Driver 的 TaskScheduler 逐个分发和跟踪状态的开销不可忽视。每个 Task 的调度开销约 1-5 毫秒,10 万个 Task 就是 100-500 秒的纯调度时间。这种情况下应合并分区减少 Task 数(coalesce 或调大 partition 大小)。
spark.driver.maxResultSize 限制 Driver 能接收的最大结果大小(默认 1G)。如果 Task 返回的结果(如 count 的聚合值、collect 的数据)总和超过此限制,作业失败。对于大结果集场景需要调大此值,但更根本的解决方案是避免向 Driver 拉大量数据。
Driver 内存不足:如果 DAG 复杂(大量 Stage 依赖)、广播变量多、或 Driver 上跑了 Python 逻辑(PySpark 的 Driver 端 Python 进程),spark.driver.memory 可能不够。PySpark 中 Driver 端的 Python 进程负责解析参数、构建 SparkSession,内存需求通常不大,但如果 Driver 端做了大量数据后处理,需要额外配置。
关键参数:spark.driver.memory(Driver 堆内存,默认 1G)、spark.driver.maxResultSize(最大结果大小,默认 1G)、spark.driver.cores(Driver CPU 核数,默认 1)。
案例:上述脚本在 Driver 端做了 df.show(2) 和 df_output.show(2),各拉 2 行数据到 Driver,数据量极小不构成瓶颈。但如果有人用 df.collect() 拉全量 3000 万行到 Driver 做本地处理,就会直接 OOM。该脚本的 Driver 内存配置为 16G(spark.driver.memory=16G),远超需求,属于过度配置但无害。
6.14 优化检查清单
拿到一个慢的 Spark 作业后,按以下顺序排查。
第一步:通读代码,统计 Action 数量。每个 Action 对应一个 Job,每个 Job 至少一次全表扫描。识别哪些 Action 是必要的(写出结果),哪些是调试用的(show、count),哪些可以合并或用 cache 复用。
第二步:分析数据传输方式。如果用了普通 udf,评估能否改为 pandas_udf。确认 Arrow 优化参数是否开启。这一步往往是 PySpark 任务收益最大的优化。
第三步:检查数据源与 I/O。SQL 是否带了分区过滤条件(WHERE dt=xxx),是否只 SELECT 需要的列(避免 SELECT *),表底层格式是否列式存储(Parquet/ORC),是否存在小文件问题。
第四步:检查 Join 策略。如果有多表 Join,评估小表能否广播(调大 autoBroadcastJoinThreshold),Join 顺序是否合理,频繁 Join 的表是否可以分桶。
第五步:检查分区数与 core 数的匹配关系。partition 数应为总 core 数的 2-4 倍。partition 太少资源闲置,太多则调度开销大。代码中用 repartition 显式控制分区数。
第六步:检查内存配置。spark.executor.memory 够不够,spark.memory.fraction 是否被调低(低于 0.6 需要确认原因),spark.yarn.executor.memoryOverhead 是否覆盖 Python worker 的内存需求。如果 History UI 有 Spill 数据,对照分析。
第七步:检查 Shuffle 配置。spark.sql.shuffle.partitions 是否合理,AQE 是否开启(Spark 3.2+ 默认开启,3.0 需手动设)。如果有数据倾斜,考虑加盐打散或过滤倾斜 key。
第八步:检查数据本地性。History UI 的 Task Locality Level 是否大量退化为 ANY 或 RACK_LOCAL。如果是,考虑减少动态分配的波动或调整 locality.wait。
第九步:检查序列化方式。如果任务用了 RDD API 且 shuffle 频繁,评估是否切换到 Kryo 序列化。纯 DataFrame API 任务通常不需要。
第十步:检查 GC 状况。History UI 的 GC Time / Duration 是否超过 30%。如果超过,排查是内存不足还是对象创建过快,考虑切换 G1GC 或增大 executor.memory。如果 GC 占比低,跳过此步。
第十一步:检查容错参数。心跳间隔和超时值的比值是否合理(间隔应远小于超时),推测执行是否适得其反(数据量大的慢 Task 不适合推测执行)。
第十二步:检查 Driver 端。是否有 collect() 拉取大量数据到 Driver,Task 数是否过多导致调度开销,driver.memory 和 maxResultSize 是否够用。
优化是迭代过程,一次改一项,对比效果,用 Spark History UI 的前后对比验证改善幅度。