回顾Spark的概念与应用

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.partitionsspark.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=4maxExecutors=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 tableSELECT * 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 的前后对比验证改善幅度。

相关推荐
一知半解仙2 小时前
大国博弈与机器人纪元:我们正站在怎样的历史拐点?
大数据·人工智能·机器人
亚古数据2 小时前
亚古数据:新西兰公司工商报告Company Extract全解析
大数据·人工智能
baopixiaoz3 小时前
AI量化策略师|Web3 量化交易研究员
大数据·人工智能·python·区块链
IT古董3 小时前
【WMS学习笔记系列】01-需求分析
大数据·数据库
成长之路5143 小时前
【数据集】MD&A管理层讨论与分析文本(2001-2025年)
大数据
浅念-4 小时前
MySQL 索引底层完整详解|磁盘Page|B+树推导|聚簇非聚簇索引|索引SQL操作
大数据·数据库·b树·sql·mysql·面试·职场和发展
小马过河R5 小时前
数据仓库入门:是什么、怎么做、和数据库有什么区别?
大数据·数据库·数据仓库·架构·驾驭工程·fde
leisoo80975 小时前
涨停板次日表现因子怎么挖掘本地化Python全流程实战
大数据·人工智能·python
用户3610588626125 小时前
SparkSQL 数据源与底层架构深度剖析
大数据·spark