前五篇写完了从读取、清洗、聚合、SQL 查询到 UDF 的全流程,管道能跑了。但上了生产环境后遇到三个问题:一个任务跑了 40 分钟、某个 Executor OOM 了、同一张表有时候快有时候慢。本文回答一个问题:Spark 跑得慢的时候,从哪里下手?
本文要点
- 分区(Partition)是并行度的基本单位,分区数直接决定任务快慢
- Shuffle 是性能成本最高的环节,减少 Shuffle 是优化的第一要务
- 广播变量:小表广播到大表,省掉整个 Shuffle
- 缓存(persist / cache):什么时候该缓存、选哪个存储级别
- 数据倾斜:80% 的时间花在 20% 的数据上,怎么发现、怎么处理
- Spark UI:从 Job / Stage / Task 三个层面定位性能瓶颈
- AQE(自适应查询执行):Spark 3.x 的自动优化机制
零 一个慢任务
业务场景
某电商平台的日级 ETL 管道,每天凌晨 2 点跑前一天的全量订单数据(约 5000 万条,约 15GB),关联商品维度表,按城市 + 品类做聚合,生成日报写入 Paimon 表。
python
# 日级订单聚合 ETL
orders = spark.table("ods.orders") # 5000万条,15GB
result = (
orders
.filter(col("dt") == "2026-08-24") # 当天数据
.filter(col("order_status") == "completed")
.join(products, "product_id") # 关联商品维度表
.groupBy("city", "category")
.agg(
count("*").alias("order_count"),
sum("amount").alias("total_amount"),
avg("amount").alias("avg_amount")
)
)
result.write.mode("overwrite").saveAsTable("dw.daily_city_category_report")
这个管道在测试环境跑没问题,10 万条数据 30 秒搞定。但上了生产环境,5000 万条数据跑了 40 分钟。运维要求在 10 分钟内跑完。
性能排查的三板斧
Spark 性能优化的排查思路,本质上就三个方向:
- 并行度够不够:数据量大,但分区少,Executor 闲着没事干
- Shuffle 多不多:跨节点数据搬运太多,网络和磁盘 IO 成了瓶颈
- 数据均不均匀:大部分数据集中在一个分区,一个 Task 跑死,其他 Task 早早结束等着
后面每一节都围绕这三个方向展开。
一 分区:并行度的基本单位
1.1 分区是什么
回顾第二篇核心概念:DataFrame 在物理上被切成多个 Partition(分区),每个分区是一块独立的数据块,分布在不同的 Executor 上。
一个分区对应一个 Task,一个 Task 在一个 Executor 的一个核上执行。所以:
分区数 = 并行度 = 同时能跑多少个 Task
DataFrame(1000GB)
├── Partition 0 (200MB)→ Task 0 → Executor 1 Core 1
├── Partition 1 (200MB)→ Task 1 → Executor 1 Core 2
├── Partition 2 (200MB)→ Task 2 → Executor 2 Core 1
├── ...
└── Partition 4999 (200MB) → Task 4999 → Executor 100 Core 50

如果分区数太少(比如只有 4 个分区,但有 100 个核),96 个核在空转。如果分区数太多(比如 10 万个分区,但只有 100 个核),每个 Task 处理的数据量太少,调度开销反而比计算开销大。
1.2 分区和 Executor 的关系
上面提到"Executor",需要厘清几个概念:
分区和 Executor(节点)是多对多的关系:
Executor A(4 Core) Executor B(4 Core)
┌────────────────────────┐ ┌────────────────────────┐
│ P0 P1 P2 P3 │ │ P4 P5 P6 P7 │
│ ↓ ↓ ↓ ↓ │ │ ↓ ↓ ↓ ↓ │
│ Task0 Task1 Task2 Task3 │ │ Task4 Task5 Task6 Task7│
│(Core1)(Core2)(Core3)(Core4)│ │(Core1)(Core2)(Core3)(Core4)│
└────────────────────────┘ └────────────────────────┘
- 一个分区在某一时刻只在一个 Executor 上
- 一个 Executor 有多个 Core,可以同时处理多个分区
- 分区数 ≥ Core 数才能让所有 Core 都忙起来
文章里说的"节点",准确说法是 Executor------它是 Spark 资源分配和任务调度的基本单位。Executor 是一个 JVM 进程,部署方式决定了它跑在哪:
| 部署模式 | Executor 实际是什么 |
|---|---|
| YARN | 一个 JVM 进程,运行在 YARN Container 里 |
| Kubernetes | 一个 Pod |
| Standalone | 一个 JVM 进程,直接跑在物理机/虚拟机上 |
| Local 模式 | 一个 JVM 进程里的多个线程 |
一个物理机上可以跑多个 Executor。理解分区时,把"节点"替换成"Executor"更精确:一个分区在一个 Executor 上被一个 Task 处理,一个 Executor 的多个 Core 可以同时处理多个分区。
1.3 分区数怎么决定
不同阶段,分区数的决定方式不同:
| 阶段 | 分区数由什么决定 | 默认值 |
|---|---|---|
| 读取阶段 | 文件数 / 分片大小 | Parquet:每个文件一个或多个分区 |
| Map 阶段(窄依赖) | 上游分区数 | 继承父分区数 |
| Shuffle 阶段 | spark.sql.shuffle.partitions |
200 |
读取阶段的分区数据从哪来 :从分布式存储(Paimon/HDFS/S3)直接读到 Executor 内存 ,不经过本地磁盘。每个 Executor 从 HDFS/S3 读取分配给自己的文件块,反序列化后放在内存里,每个文件块对应一个分区。所以读取阶段的分区数取决于上游写了多少个文件------Paimon 表如果有 4 个数据文件,读取时就是 4 个分区;如果有 500 个文件,就是 500 个分区。这也是为什么前面说"从源头解决------让上游写入时分好文件数"。
最常出问题的就是 Shuffle 阶段。默认 200 个分区在大多数场景下不是最优的:
- 数据量 15GB,200 个分区,平均每个分区 75MB------偏小,调度开销大
- 数据量 500GB,200 个分区,平均每个分区 2.5GB------偏大,单个 Task 容易 OOM
1.4 怎么知道当前有多少分区
调之前先看清楚现状,别盲调:
python
# 查看当前 DataFrame 的分区数
df.rdd.getNumPartitions()
# 估算每个分区平均多大(总数据量 / 分区数)
# 比如读了一张 15GB 的表,分区数是 4
# → 平均每个分区 3.75GB,严重偏大,需要 repartition
print(f"分区数: {df.rdd.getNumPartitions()}")
也可以在 Spark UI 的 Stages 页面看每个 Stage 的 Task 数------Task 数就是分区数。
1.5 怎么调
python
# 方法一:全局设置 Shuffle 分区数(最常用)
spark.conf.set("spark.sql.shuffle.partitions", 500)
# 方法二:只对当前 DataFrame 调整
df.repartition(500) # 重新分区为 500 个(会触发 Shuffle)
df.coalesce(50) # 合并为 50 个(尽量不触发 Shuffle)
# 方法三:按列分区(解决数据倾斜的利器)
df.repartition(100, "city") # 按 city 列重新哈希分区为 100 个
repartition vs coalesce 的区别:
repartition |
coalesce |
|
|---|---|---|
| 是否触发 Shuffle | 是 | 否(减少分区时) |
| 数据是否均匀 | 均匀(哈希分区) | 不均匀(合并相邻分区) |
| 适用场景 | 增加分区、按列分区 | 减少分区(写出前合并小文件) |
为什么
coalesce减少分区不触发 Shuffle,增加分区却会?关键在于:合并可以在本地做,拆分必须跨节点搬数据。合并(减少分区)------本地收拢,不搬数据:
Executor A ┌──────┐ ┌──────┐ ┌────────────┐ │ P0 │ │ P1 │ → │ P0+P1 合并 │ │100MB │ │100MB │ │ 200MB │ └──────┘ └──────┘ └────────────┘ 数据还在同一个 Executor,只是把两个小盒子倒进一个大盒子,不需要网络传输扩散(增加分区)------必须搬数据到其他 Executor:
Executor A Executor A Executor B ┌────────────┐ ┌──────┐ ┌──────┐ │ P0 200MB │ → │ P0a │ ← 搬 → │ P0b │ └────────────┘ │100MB │ 网络 │100MB │ └──────┘ └──────┘ 原来 200MB 都在一个 Executor,拆成两份后一份要搬到另一个 Executor本地拆分技术上可以做,但如果这个 Executor 的 Core 已经满了,拆出来的新分区只能排队,并行度并没有提高。真正增加并行度需要把数据分到更多 Executor 的 Core 上,必须跨网络------就是 Shuffle。所以
coalesce增加分区时,底层直接 fallback 到repartition。
总结一下合并和扩散的本质区别:
| 合并(coalesce 减少分区) | 扩散(repartition 增加分区) | |
|---|---|---|
| 数据流向 | 同 Executor 内存内合并 | 内存 → 本地磁盘 → 网络 → 对方内存 |
| 跨不跨节点 | 不跨 | 跨 |
| 经过磁盘 | 不经过 | 经过(Shuffle Write) |
| 经过网络 | 不经过 | 经过 |
| 触发 Shuffle | 否 | 是 |
| 开销 | 几乎无 | 有(磁盘 IO + 网络) |
经验值:每个分区处理 100MB ~ 1GB 数据比较合适。15GB 数据设 50-150 个分区比较合理。
1.6 回到案例
案例中的管道跑 40 分钟,第一步检查分区数。读取 5000 万条订单数据后,看一下分区数:
python
print(f"订单表分区数: {orders.rdd.getNumPartitions()}")
如果只有 4 个分区(4 个文件),意味着 15GB 数据只有 4 个 Task 在处理,并行度严重不足。这时候需要:
python
orders = spark.table("ods.orders").repartition(100)
或者从源头解决------让上游写入时分好文件数。
二 Shuffle:性能成本最高的环节
2.1 什么是 Shuffle
在第二篇核心概念里讲过,这里快速回顾:
Shuffle = 数据跨节点重新分布
当某个操作需要读取其他分区的数据时(比如 groupBy、join、distinct),Spark 必须把相同 Key 的数据从不同节点搬到同一个节点上。
为什么聚合操作必须搬数据?以
groupBy("city").agg(sum("amount"))为例:要算北京的订单总金额,必须看到所有 北京的订单。但北京的数据散落在 100 个节点上,每个节点只有一部分。单个节点只能算出局部小计,得不到全国总额。聚合操作的定义本身就是跨数据的------sum、count、avg都需要看到全部数据才能算出正确结果。相比之下,
filter、select、map这种操作每行独立处理,不需要看其他分区的数据,所以不触发 Shuffle------这就是窄依赖。
Shuffle 前(Map 阶段,各干各的) Shuffle 后(Reduce 阶段,按 Key 聚合)
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ P0: A,B,C│ │ P1: A,B,D│ ──────▶ │ P0: A,A,A│ │ P1: B,B,C│
│ P2: A,C,D│ │ P3: B,C,D│ │ P2: C,C,D│ │ P3: D,D,D│
└──────────┘ └──────────┘ └──────────┘ └──────────┘

2.2 Shuffle 的三段过程
Shuffle 不是一步完成的,而是分三段,涉及磁盘和网络:
Map Task Reduce Task
┌──────────┐ ┌──────────┐
│ 处理本地数据 │ │ 聚合数据 │
│ 按Key分桶 │ │ 排序、合并 │
│ 写本地磁盘 │ ──网络传输──→ │ 拉取数据 │
│(Shuffle │ 读远程磁盘文件 │(Shuffle │
│ Write) │ │ Read) │
└──────────┘ └──────────┘
| 阶段 | 做什么 | 在哪执行 | IO 类型 |
|---|---|---|---|
| Shuffle Write | Map 端按 Key 分桶,写本地磁盘 | Map 端 Executor | 本地磁盘写 |
| 网络传输 | Reduce 端从各 Map 节点拉取数据 | 跨节点 | 网络 + 磁盘读 |
| Shuffle Read | 排序、合并、聚合 | Reduce 端 Executor | 内存计算 |
所以 Shuffle 慢的原因不是计算本身,而是写磁盘 + 网络传输 + 反序列化------这些 IO 开销远大于 CPU 计算。
2.3 Shuffle 中间文件的生命周期
Shuffle 写的磁盘文件是临时的,不会永久占用存储:
Map Task 执行 → 写本地磁盘 → Reduce Task 拉取 → Stage 完成后清理
保留中间文件唯一的意义是容错:如果某个 Reduce Task 挂了需要重试,还能从 Map 端重新拉数据。一旦 Stage 成功,文件就删了。
不用等 SparkSession 结束------Stage 跑完就清理。
一个容易混淆的点:Shuffle 全程跟分布式存储(HDFS/S3/Paimon)无关,只用到 Executor 的本地磁盘和内存。
- Shuffle Write 写的是 Executor 的本地磁盘(
spark.local.dir),不是 HDFS/S3- 网络传输是从 Map 端本地磁盘读到 Reduce 端内存,不经过分布式存储
- Shuffle Read 在 Reduce 端内存中排序合并,内存不够才 spill 到本地磁盘
分布式存储(Paimon/HDFS)只在两个端点出现:开头读数据(
spark.table("ods.orders"))、结尾写结果(write.saveAsTable(...))。中间的 Shuffle 全是 Executor 本地的事------本地磁盘 + 内存 + 网络,跟表格式无关。
2.4 哪些操作会触发 Shuffle
| 操作 | 是否 Shuffle | 原因 |
|---|---|---|
filter / select |
否 | 窄依赖,每个分区独立处理 |
map / withColumn |
否 | 窄依赖 |
groupBy / agg |
是 | 需要相同 Key 聚到一起 |
join(默认) |
是 | 需要相同 Key 聚到一起 |
distinct |
是 | 需要相同值聚到一起 |
orderBy |
是 | 全局排序需要跨分区 |
repartition |
是 | 主动重新分区 |
coalesce(减少) |
否 | 合并相邻分区 |
coalesce(增加) |
是 | 实际调用 repartition |
2.5 怎么减少 Shuffle
原则:能用窄依赖解决的,绝不用 Shuffle。
python
# 不好的写法:先 join 再 filter,多了一次 Shuffle
orders.join(products, "product_id").filter(col("amount") > 100)
# 好的写法:先 filter 再 join,减少参与 join 的数据量
orders.filter(col("amount") > 100).join(products, "product_id")
不过上面这个例子 Catalyst 优化器通常会自动做谓词下推。但有些场景优化器帮不了:
python
# 不好的写法:先 join 再聚合,两次 Shuffle
joined = orders.join(products, "product_id")
result = joined.groupBy("city", "category").agg(...)
# 好的写法:先聚合再 join,减少 join 的数据量
# 先在订单表里按 city + product_id 聚合
pre_agg = orders.groupBy("city", "product_id").agg(
count("*").alias("order_count"),
sum("amount").alias("total_amount")
)
# 再和商品表 join(数据量已经大幅减少)
result = pre_agg.join(products, "product_id").groupBy("city", "category").agg(...)
另一个常见技巧:用 broadcast 代替 join(下一节详细讲)。
2.6 Shuffle 相关的配置
python
# Shuffle 分区数(最常调的参数)
spark.conf.set("spark.sql.shuffle.partitions", 200)
# Shuffle 数据序列化方式(Kryo 比 Java 原生快)
spark.conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
# Shuffle 文件缓冲区大小
spark.conf.set("spark.shuffle.file.buffer", "1MB")
三 广播变量:小表广播,省掉整个 Shuffle
3.1 原理
在第四篇进阶里介绍过 Broadcast Join,这里从性能优化的角度补充。
普通 Join 的过程:
表A(大表,1000分区) 表B(小表,100分区)
↓ ↓
└──────── Shuffle ────────┘
相同 Key 的数据聚到一起
↓
Join 结果
Broadcast Join 的过程:
表A(大表,1000分区) 表B(小表,10MB)
↓ ↓
↓ 广播到每个 Executor
↓ ┌──────────┐
↓ │ 表B副本1 │
↓ │ 表B副本2 │
↓ │ 表B副本N │
└─── 本地 Join ──────┘

小表被广播到每个 Executor 的内存里,大表不用 Shuffle,直接在本地做 Join。
3.2 怎么用
python
from pyspark.sql.functions import broadcast
# 方式一:手动标记
result = orders.join(broadcast(products), "product_id")
# 方式二:Spark 自动判断
# 如果表小于 spark.sql.autoBroadcastJoinThreshold(默认 10MB),自动广播
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "50MB")
3.3 什么时候用
| 条件 | 是否适合广播 |
|---|---|
| 小表 < 100MB | 适合 |
| 小表 100MB ~ 1GB | 看集群内存,谨慎用 |
| 小表 > 1GB | 不适合 |
| 小表字段多(宽表) | 即使行数少,实际大小可能很大 |
注意:广播是把小表发到每个 Executor。如果有 50 个 Executor,小表 100MB,总共消耗 5GB 内存。
3.4 广播变量(Broadcast Variable)
除了 Broadcast Join,还有一个概念叫广播变量,用于共享只读变量:
python
# 把一个 Python 字典广播到所有 Executor
city_mapping = {"110000": "北京", "310000": "上海", "440100": "广州"}
broadcast_mapping = spark.sparkContext.broadcast(city_mapping)
# 在 UDF 里使用
from pyspark.sql.functions import udf
from pyspark.sql.types import StringType
def get_city_name(code):
return broadcast_mapping.value.get(code, "未知")
get_city_name_udf = udf(get_city_name, StringType())
result = orders.withColumn("city_name", get_city_name_udf(col("city_code")))
不用广播变量的话,每个 Task 都会拷贝一份 city_mapping,内存浪费且传输慢。
四 缓存与持久化
4.1 为什么要缓存
Spark 的懒执行机制意味着每次调用 action(show、write、count)都会从头重新计算整个血缘链。
python
df = spark.table("ods.orders").filter(...).join(...) # 一长串转换
df.count() # 从头算一遍
df.show() # 又从头算一遍
df.write... # 又从头算一遍
如果 df 被多次使用,缓存可以避免重复计算。
缓存的本质是用一份内存空间,换掉重复计算的时间。不缓存时,每次 action 都从源头重新算;缓存后,中间结果在内存里保留一份,后续直接用。
不缓存------每次 action 都从头算:
python
# 清洗 + 关联后的数据,后续要用 4 次
clean_df = (spark.table("ods.orders")
.filter(col("dt") == "2026-08-24")
.filter(col("amount") > 0)
.join(products, "product_id")
.join(users, "user_id"))
# 用途1:写入 DWS 层
clean_df.write.saveAsTable("dws.clean_orders")
# 用途2:数据质量检查
total = clean_df.count()
bad = clean_df.filter(col("city").isNull()).count()
# 用途3:城市维度报表
clean_df.groupBy("city").agg(sum("amount")).write.saveAsTable("dws.city_daily")
# 用途4:品类维度报表
clean_df.groupBy("category").agg(sum("amount")).write.saveAsTable("dws.category_daily")
# 每次 action(write、count)都重新:读表 → filter → filter → join → join
# 4 次使用 = 4 遍读表 + 4 遍 filter + 4 遍 join
缓存------中间结果留在内存里,后续复用:
python
clean_df = (spark.table("ods.orders")
.filter(col("dt") == "2026-08-24")
.filter(col("amount") > 0)
.join(products, "product_id")
.join(users, "user_id"))
clean_df = clean_df.cache() # 标记:以后要缓存(此时内存里还没有数据)
clean_df.count() # 触发计算:读表 → filter → filter → join → join
# 算完后结果留在内存里
# 以下 4 次操作都从内存读,不用重新读表、filter、join
clean_df.write.saveAsTable("dws.clean_orders")
total = clean_df.count()
bad = clean_df.filter(col("city").isNull()).count()
clean_df.groupBy("city").agg(sum("amount")).write.saveAsTable("dws.city_daily")
clean_df.groupBy("category").agg(sum("amount")).write.saveAsTable("dws.category_daily")
# 4 次使用 = 1 遍读表 + 1 遍 filter + 1 遍 join
count() 不是必须的------后续任何 action(write、show 等)都能触发缓存,第一个 action 承担"计算 + 缓存 + 执行自身"。用 count() 单独触发只是一种习惯:它轻量(只数行数不处理列),能快速验证缓存是否成功,也方便把"准备缓存"和"后续业务"的时间分开排查。去掉 count,直接调 write,功能上完全没影响。
不缓存时不会"冗余多份数据"------DataFrame 不持有数据,只持有逻辑描述。每次 action 触发时从头计算,算完中间结果全部释放,内存里不留东西。真正的浪费不是内存冗余,而是重复计算。
4.2 数据的生命周期
理解 Spark 中不同类型数据的生命周期,有助于判断什么时候该缓存、什么时候该释放:
| 数据类型 | 存储位置 | 什么时候清理 |
|---|---|---|
| Shuffle 中间文件(Map 写的) | Executor 本地磁盘 | Stage 完成后 |
| Shuffle Read 的数据(Reduce 拉的) | 内存 | 聚合完就释放 |
cache() 缓存的数据 |
内存 / 磁盘 | 手动 unpersist() 或 Session 结束 |
write() 写出的结果 |
HDFS / 对象存储 | 持久化,不清理 |
| 从表读取的数据 | 内存 | 用完就释放 |
不缓存、不 write 出去的东西,全都是临时的------SparkSession 结束,所有 Executor 上的临时文件和内存数据都会被清理。Shuffle 中间文件甚至更短命,Stage 跑完就没了。
4.3 persist vs cache
python
# cache() 等价于 persist(MEMORY_AND_DISK)
df.cache()
# persist() 可以指定存储级别
from pyspark import StorageLevel
df.persist(StorageLevel.MEMORY_AND_DISK)
常用存储级别对比:
| 级别 | 放哪 | 内存不够时 | 优势 | 劣势 |
|---|---|---|---|---|
MEMORY_ONLY |
只放内存 | 丢弃,不缓存,后续重新计算 | 读取最快(纯内存) | 放不下就白标记了,后续还得重算 |
MEMORY_AND_DISK |
先内存,溢出写磁盘 | 溢写到本地磁盘 | 放得下走内存,放不下走磁盘,不丢数据 | 磁盘读取比内存慢 |
MEMORY_ONLY_SER |
序列化后放内存 | 丢弃,重新计算 | 序列化后省空间(约省 2-5 倍) | 读取时要反序列化,有 CPU 开销 |
MEMORY_AND_DISK_SER |
序列化放内存,溢出写磁盘 | 溢写到磁盘 | 最省空间,不丢数据 | 反序列化 + 磁盘读取,最慢 |
DISK_ONLY |
只放磁盘 | 都在磁盘 | 不占内存 | 每次读都走磁盘,最慢 |
核心取舍就一个问题:内存不够时,重新计算 vs 从磁盘读,哪个更快?
- 上游计算简单(只 filter 一下),重算快,不如丢弃 →
MEMORY_ONLY - 上游计算重(多表 join、复杂清洗),重算慢,溢写到磁盘更划算 →
MEMORY_AND_DISK
python
# 大多数场景:默认 cache() 就够(MEMORY_AND_DISK)
df.cache()
# 上游计算重、数据量大、放不进内存:明确用 MEMORY_AND_DISK
df.persist(StorageLevel.MEMORY_AND_DISK)
# 序列化版本省空间但读取有反序列化开销,只有内存非常紧张时才值得
# 生产环境几乎不用 MEMORY_ONLY_SER 和 MEMORY_AND_DISK_SER
一句话:内存够用直接
cache(),上游计算重且数据量大用MEMORY_AND_DISK,序列化版本基本不碰。
4.4 什么时候该缓存
该缓存的场景:
python
# 场景一:同一个 DataFrame 被多次使用
df = spark.table("ods.orders").filter(...).join(...)
df.createOrReplaceTempView("clean_orders")
# 两个 SQL 查询都用到 clean_orders
spark.sql("SELECT city, COUNT(*) FROM clean_orders GROUP BY city")
spark.sql("SELECT category, SUM(amount) FROM clean_orders GROUP BY category")
# 不缓存的话,每个 SQL 查询都会重新执行整个血缘链
df.cache()
不该缓存的场景:
python
# 场景一:只用一次,没必要缓存
df = spark.table("ods.orders").filter(...)
df.write.parquet("output") # 写完就没了,缓存就是浪费内存
# 场景二:数据太大,缓存反而拖慢
huge_df = spark.table("ods.tera_orders") # 1TB 数据
huge_df.cache() # 缓存 1TB 数据到内存?不如不缓存
4.5 缓存的注意事项
python
# 用完一定要释放
df.unpersist()
# 缓存是惰性的,要触发 action 才真正缓存
df.cache() # 只是标记
df.count() # 触发计算,真正缓存
# 现在 df 才在内存里
4.6 回到案例
案例中的管道有一个 join 操作,关联了商品维度表。如果同一个 ETL 任务里有多段逻辑都需要 join 商品表:
python
products = spark.table("dim.products")
# 第一段逻辑
report1 = orders.join(products, "product_id").groupBy("city").agg(...)
# 第二段逻辑
report2 = orders.join(products, "product_id").groupBy("category").agg(...)
商品表被 join 了两次,每次都会重新读取和计算。缓存商品表:
python
products = spark.table("dim.products").cache()
products.count() # 触发缓存
五 数据倾斜:80% 的时间花在 20% 的数据上
5.1 什么是数据倾斜
数据倾斜是指:大部分数据集中在一个或几个分区上,其他分区数据量很少。
正常情况: 倾斜情况:
┌────┐┌────┐┌────┐┌────┐ ┌────────────┐┌──┐┌──┐┌──┐
│100M││100M││100M││100M│ │ 800MB ││10M││5M││2M│
└────┘└────┘└────┘└────┘ └────────────┘└──┘└──┘└──┘
Task0 Task1 Task2 Task3 Task0 Task1 Task2 Task3
10s 10s 10s 10s 120s 1s 0.5s 0.2s
总时间 = 10s 总时间 = 120s(被最慢的 Task 拖死)

案例中的订单数据就有这个问题:北京、上海的订单量占全国的 40%,其他城市只占 60%。按城市 groupBy 时,北京和上海的分区数据量远大于其他城市,导致这两个 Task 跑 10 分钟,其他 Task 跑 30 秒就结束了等着。
5.2 怎么发现数据倾斜
方法一:Spark UI 看 Task 时间分布
进入 Spark UI → 对应 Job → 对应 Stage → 看 Task 的 Duration 分布。如果大部分 Task 几秒完成,少数 Task 跑几十分钟,就是倾斜。
方法二:代码检查
python
# 检查 Key 分布
df.groupBy("city").count().orderBy(col("count").desc()).show(50)
5.3 怎么处理
优先用 AQE(自适应查询)------Spark 3.0+ 推荐做法
AQE 在 Shuffle 之后自动检测倾斜:如果某个分区大小远超中位数(默认 5 倍),自动拆分成多个小分区,多个 Reduce Task 各自处理一部分,最后合并结果。不需要改一行业务代码。
python
# 开启 AQE
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5") # 5 倍中位数触发拆分
AQE 的倾斜处理原理:
Shuffle 后检测到倾斜
分区0: 800MB(北京) ← 远超中位数,自动拆分
分区1: 10MB
分区2: 5MB
↓
自动拆分分区0
分区0a: 200MB → Task 0a
分区0b: 200MB → Task 0b
分区0c: 200MB → Task 0c
分区0d: 200MB → Task 0d
↓
合并结果,对外透明(业务代码无感知)
但 AQE 有覆盖不到的地方------手动加盐
AQE 目前只对 Join 自动处理倾斜,groupBy 的倾斜管不了。如果 groupBy 某个 Key 的数据量远超其他 Key,AQE 不会自动拆分,这时候还得手动加盐:
python
from pyspark.sql.functions import concat, lit, rand, floor
# 给倾斜的 Key 加随机后缀,打散到多个分区
salted = orders.withColumn(
"salted_city",
concat(col("city"), lit("_"), floor(rand() * 10))
)
# 先按 salted_city 聚合(打散了)
partial = salted.groupBy("salted_city").agg(sum("amount").alias("amount"))
# 再去掉 salt 后缀,二次聚合
result = partial.withColumn("city", col("salted_city").substr(1, 6)) \
.groupBy("city").agg(sum("amount").alias("total_amount"))
手动加盐的麻烦在于:要找倾斜的 Key、加随机后缀、关联表也要对应扩容、聚合后再去掉后缀。步骤多、容易写错、不好维护。
| 场景 | AQE 能处理吗 | 手动加盐 |
|---|---|---|
| Join 倾斜(大表 join,某个 Key 特别多) | 能 | 能 |
| groupBy 倾斜(按城市聚合,北京数据特别多) | 不能 | 能 |
| 自定义逻辑倾斜(复杂业务逻辑导致) | 不能 | 能 |
方法三:过滤异常 Key
python
# 如果是脏数据导致的倾斜(比如 null 值大量聚集)
orders = orders.filter(col("city").isNotNull())
5.4 回到案例
案例中按 city 分组时北京、上海数据倾斜。先开 AQE 兜底:
python
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
如果 case 里是 groupBy 导致的倾斜(不是 Join),AQE 管不了,再手动加盐。
六 Spark UI:性能排查的眼睛
6.1 三个页面看什么
| 页面 | 看什么 | 能发现什么问题 |
|---|---|---|
| Jobs | 每个 Job 耗时、Stage 数 | 哪个 Job 慢、有没有多余 Job |
| Stages | Task 数量、Duration 分布、Shuffle 读写量 | 倾斜、Shuffle 过大、分区不合理 |
| SQL/DataFrame | 查询计划、各节点耗时 | 谓词是否下推、Join 策略是否合理 |
6.2 关键指标
Stage 详情页:
- Task Duration 分布:如果 max 和 median 差距 > 5 倍,可能是倾斜
- Shuffle Read/Write:如果某个 Task 的 Shuffle Read 远大于其他,说明数据分布不均
- GC Time:如果 GC 时间占比 > 20%,说明内存不足
- Spill:如果有 spill,说明内存不够,数据被写磁盘
6.3 执行计划
python
# 查看逻辑执行计划
df.explain()
# 查看物理执行计划
df.explain("extended")
关键看三点:
| 看什么 | 判断什么 |
|---|---|
PushedFilters |
谓词是否下推到数据源层(减少读取量) |
BroadcastHashJoin vs SortMergeJoin |
Join 策略是否合理(小表该广播) |
Exchange |
有没有不必要的 Shuffle |
七 AQE:自适应查询执行
Spark 3.0 引入的 AQE(Adaptive Query Execution)会在运行时动态调整执行计划。前面几节多次提到它,这里统一讲清楚。
7.1 为什么需要 AQE
Catalyst 优化器在编译时生成执行计划,但它看不到实际数据分布。比如:
- 不知道哪个 Key 会倾斜------因为还没跑
- 不知道 Shuffle 后每个分区多大------因为还没 Shuffle
- 不知道 join 后某张表变小了------因为还没 join
AQE 在运行时根据实际统计数据动态调整计划,弥补了 Catalyst 的盲区。
7.2 AQE 做三件事
python
# 开启 AQE(总开关)
spark.conf.set("spark.sql.adaptive.enabled", "true")
第一件:动态合并分区
python
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
Shuffle 后根据实际数据量合并小分区。比如设了 500 个分区,但实际数据只有 5GB,平均每个分区 10MB------太小了。AQE 自动合并为 50 个分区,减少 Task 调度开销。
第二件:动态切换 Join 策略
python
spark.conf.set("spark.sql.adaptive.localShuffleReader.enabled", "true")
运行时如果发现某张表经过 filter 后变小了(比如从 1GB 变成 10MB),自动从 SortMergeJoin 切换到 BroadcastHashJoin。
第三件:动态优化倾斜 Join
python
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
检测到某个分区远大于其他分区(默认阈值:超过中位数的 5 倍),自动拆分大分区,拆成多个小 Task 并行处理。
7.3 AQE 不是银弹
AQE 能处理的倾斜是运行时发现的倾斜,但以下情况管不了:
- 代码逻辑层面的低效(比如先 join 再 filter,AQE 不会帮你改顺序)
- UDF 内部的性能问题(黑盒,优化器看不进去)
- 内存配置不合理(Executor 内存不够,AQE 也救不了)
所以 AQE 是锦上添花,不是替代手动优化。手动优化(合理的分区、减少 Shuffle、缓存复用)是基础,AQE 在基础上再补一层自动调优。
八 完整优化案例
回到最初的 ETL 管道,综合运用前面的技巧:
python
from pyspark.sql.functions import col, count, sum, avg, broadcast
# 1. 开启 AQE
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
# 2. 设置合理的 Shuffle 分区数(15GB 数据,100 个分区,每个约 150MB)
spark.conf.set("spark.sql.shuffle.partitions", 100)
# 3. 读取数据
orders = spark.table("ods.orders")
products = spark.table("dim.products")
# 4. 先过滤再 join,减少参与 Shuffle 的数据量
filtered_orders = (
orders
.filter(col("dt") == "2026-08-24")
.filter(col("order_status") == "completed")
)
# 5. 缓存商品表(小表,多次使用时避免重复读取)
products = products.select("product_id", "category").cache()
products.count() # 触发缓存
# 6. 广播 join(商品表小,广播到每个 Executor,省掉大表 Shuffle)
result = (
filtered_orders
.join(broadcast(products), "product_id")
.groupBy("city", "category")
.agg(
count("*").alias("order_count"),
sum("amount").alias("total_amount"),
avg("amount").alias("avg_amount")
)
)
# 7. 写出前合并小文件(coalesce 不触发 Shuffle)
result.coalesce(10).write.mode("overwrite").saveAsTable("dw.daily_city_category_report")
# 8. 释放缓存
products.unpersist()
优化效果
| 指标 | 优化前 | 优化后 | 原因 |
|---|---|---|---|
| 耗时 | 40 分钟 | 6 分钟 | 多项优化叠加 |
| Shuffle 量 | 15GB(全表 Shuffle) | 2GB(广播后省掉大表 Shuffle) | Broadcast Join |
| 倾斜 | 北京/上海 Task 10 分钟 | 最长 Task 2 分钟 | AQE 自动拆分 |
| 重复计算 | 商品表 join 两次 | 缓存后只算一次 | cache() |
| 小文件 | 写出 200 个小文件 | 合并为 10 个 | coalesce(10) |
优化清单
| 步骤 | 优化项 | 效果 |
|---|---|---|
| 1 | 开启 AQE | 自动处理倾斜 + 合并小分区 |
| 2 | 调 Shuffle 分区数 | 并行度合理 |
| 3 | 先 filter 再 join | 减少参与 join 的数据量 |
| 4 | 缓存维度表 | 避免重复读取 |
| 5 | Broadcast Join | 省掉大表 Shuffle |
| 6 | coalesce 合并小文件 | 减少输出文件数 |
九 小结
| 优化方向 | 核心思路 | 关键操作 |
|---|---|---|
| 分区调优 | 让每个 Task 处理 100MB~1GB | repartition / coalesce / spark.sql.shuffle.partitions |
| 减少 Shuffle | 能不搬数据就不搬 | 先 filter 再 join、先聚合再 join |
| 广播小表 | 小表广播,省掉 Shuffle | broadcast() / autoBroadcastJoinThreshold |
| 缓存复用 | 避免重复计算 | cache() / persist() |
| 数据倾斜 | 打散大 Key、AQE 自动处理 | 加盐 / AQE / 过滤异常 Key |
| Spark UI | 用数据说话,不靠猜 | Jobs / Stages / SQL 页面 |
一条经验总结:先看 Spark UI 定位瓶颈,再对症下药。 不要盲目调参数------跑得慢的原因可能不是分区数,而是数据倾斜;不是 Shuffle 太多,而是缓存没设。先诊断,再治疗。