【PySpark 学习笔记 六】性能优化:分区、Shuffle、缓存与数据倾斜

前五篇写完了从读取、清洗、聚合、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 性能优化的排查思路,本质上就三个方向:

  1. 并行度够不够:数据量大,但分区少,Executor 闲着没事干
  2. Shuffle 多不多:跨节点数据搬运太多,网络和磁盘 IO 成了瓶颈
  3. 数据均不均匀:大部分数据集中在一个分区,一个 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 = 数据跨节点重新分布

当某个操作需要读取其他分区的数据时(比如 groupByjoindistinct),Spark 必须把相同 Key 的数据从不同节点搬到同一个节点上。

为什么聚合操作必须搬数据?以 groupBy("city").agg(sum("amount")) 为例:要算北京的订单总金额,必须看到所有 北京的订单。但北京的数据散落在 100 个节点上,每个节点只有一部分。单个节点只能算出局部小计,得不到全国总额。聚合操作的定义本身就是跨数据的------sumcountavg 都需要看到全部数据才能算出正确结果。

相比之下,filterselectmap 这种操作每行独立处理,不需要看其他分区的数据,所以不触发 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(showwritecount)都会从头重新计算整个血缘链。

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 太多,而是缓存没设。先诊断,再治疗。

相关推荐
超爱西西鸭39 分钟前
ArkTS传感器开发:加速度计与数据监听
学习·华为·harmonyos·鸿蒙
我爱cope1 小时前
【计算机网络 | 网络层6:IPv4 数据报格式:TTL、分片、首部校验和分别有什么用?】
网络·学习·计算机网络
math_hongfan1 小时前
鸿蒙 ArkTS 国际化:多语言支持与资源管理
学习·华为·harmonyos·鸿蒙
~kiss~1 小时前
Memory Agent System 之 主动式记忆体 Proactive Memory
学习
hongmai6668881 小时前
竖插专用:ESP8684-WROOM-07-H2X模组上手记录
笔记·单片机·嵌入式硬件·物联网·risc-v
超爱西西鸭1 小时前
基于HarmonyOS的表单与校验:输入验证与正则表达式
学习·华为·harmonyos·鸿蒙
MartinYeung53 小时前
[论文学习]PoisonBench:评估语言模型对投毒偏好数据的脆弱性
人工智能·学习·语言模型
是枚小菜鸡儿吖4 小时前
把视频教程变成可复习的笔记:Docker 部署 BiliNote,自动转写与总结
笔记·docker·容器
运维全栈笔记4 小时前
Vue + Spring Boot 前后端分离项目部署笔记(若依 RuoYi-Vue 3.9.2)
运维·服务器·vue.js·spring boot·笔记·开源·开源软件