前言:为什么Spark内存管理至关重要
Apache Spark 作为大数据处理领域的事实标准框架,其核心优势之一就是基于内存的计算模型。相比 Hadoop MapReduce 的磁盘迭代模式,Spark 能够将中间结果保存在内存中,大幅提升计算速度。然而,内存资源是有限且昂贵的——如果管理不当,频繁的垃圾回收(GC)和磁盘溢出(Spill)会严重拖慢作业效率,甚至导致 OOM(Out of Memory)崩溃。
本文将从 Spark 内存管理的内核设计出发,详细剖析堆内内存的分区模型、堆外内存的配置策略、统一内存管理的演进过程,并结合实际案例给出可落地的调优方案。无论你是在维护 Spark 3.x 集群,还是从 Spark 2.x 升级迁移,这篇文章都能帮你理清思路。

Spark 内存管理的发展历程
在深入细节之前,有必要先了解 Spark 内存管理的版本演进。这能帮助我们理解为什么现在的配置方式和之前不同。
Spark 1.0 ~ 1.5:静态内存管理(Static Memory Manager)
早期的 Spark 采用静态分区策略,在 SparkConf 中硬性划分各区域的比例。存储内存(Storage)和执行内存(Execution)之间以固定边界隔开,彼此不能借用。这种设计简单直观,但缺点也很明显:存储压力大时执行内存空闲却无法使用,反之亦然,造成资源浪费。
Spark 1.6+:统一内存管理(Unified Memory Manager)
社区在 Spark 1.6 中引入了统一内存管理模型,这是目前 Spark 2.x/3.x 的默认方案。核心变化是存储内存和执行内存之间可以互相借用,但借用方在被借方需要时可以强制收回。这个设计显著提升了内存利用率。
| 版本 | 内存管理器 | 特点 | 优缺点 |
|---|---|---|---|
| Spark 1.0-1.5 | StaticMemoryManager | 固定分区,边界不可借用 | 简单但浪费资源 |
| Spark 1.6+ | UnifiedMemoryManager | 存储/执行可互相借用 | 利用率高,但GC压力大 |
| Spark 3.0+ | UnifiedMemoryManager (优化) | 引入动态分区、Packed Pointer | GC优化、支持GPU |
统一内存管理核心模型
在 Spark 1.6+ 中,Executor 的内存布局由以下几个关键组件组成。理解它们之间的比例关系是调优的第一步。
总体内存布局
一个 Executor 进程的 JVM 堆可以分为以下区域:
- 预留内存(Reserved Memory):固定 300MB,用于 Spark 内部对象存储,不受用户配置控制。
- 用户内存(User Memory):用于开发者自定义的数据结构和用户代码中创建的 RDD 分区对象。
- Spark 内存(Spark Memory):由统一内存管理器管理,进一步分为存储内存和执行内存。
具体的计算公式如下:
// Spark Memory = (JVM Heap - Reserved Memory) × spark.memory.fraction
// 默认 spark.memory.fraction = 0.6
// Storage Memory = Spark Memory × spark.memory.storageFraction
// 默认 spark.memory.storageFraction = 0.5
// 以 8GB Executor 为例:
// Reserved = 300MB
// Spark Memory = (8192 - 300) × 0.6 ≈ 4735MB
// Storage = 4735 × 0.5 ≈ 2367MB
// Execution = 4735 × 0.5 ≈ 2368MB
// User Memory = (8192 - 300) × 0.4 ≈ 3157MB
执行内存 vs 存储内存
执行内存(Execution Memory)用于 Shuffle、Join、Sort、Aggregation 等操作中的中间数据缓存。这部分内存如果不足,数据会溢出到磁盘(Spill),引发大量磁盘 I/O。
存储内存(Storage Memory)用于缓存 RDD 分区数据、Broadcast 变量以及 Accumulator 等。当设置了 persist() 或 cache() 时,数据就存放在这里。
统一内存管理的关键行为可以用以下伪代码概括:
// 执行内存可以向存储内存借用(反之亦然)
// 但执行内存有"驱逐权"(Eviction)
// 存储内存占用过多时,执行内存可以强制驱逐:
// 1. 如果是 RDD 缓存块(StorageLevel 为 MEMORY_ONLY),直接丢弃
// 2. 如果是 MEMORY_AND_DISK,先写磁盘再释放
// 执行内存占用过多时,存储内存不能强制驱逐
// 存储只能在执行内存释放后,被动回收空间
这个非对称设计是有意为之的——执行内存里的 Shuffle 数据通常是”一次性的中间态”,丢了需要重算;而存储内存中的缓存块有完整的 lineage 信息,可以通过重新计算恢复。
堆外内存(Off-Heap Memory)
除了 JVM 堆内内存,Spark 还支持堆外内存模式。堆外内存通过 Java 的 Unsafe API 或 Netty 的 DirectByteBuffer 分配,绕过了 JVM 垃圾回收器。
堆外内存的优势
- 减少 GC 压力:大块数据不进入 JVM 堆,避免 Full GC 带来的停顿
- 高吞吐:数据以二进制格式存储,序列化后直接操作,无需 Java 对象开销
- 精确内存占用:不再受 JVM 对象头、对齐填充等额外开销影响
启用堆外内存
spark.memory.offHeap.enabled true
spark.memory.offHeap.size 4g
spark.memory.offHeap.useUnsafe true # 默认即为true
值得注意的是,堆外内存并不是替换堆内内存,而是在 Spark Memory 之外额外分配的一块区域。启用后,序列化数据可以优先存储在堆外,降低 JVM 对象数量。
Spark 3.x 的内存相关新特性
Spark 3.0 以上版本带来了多项与内存相关的改进:
动态分区裁剪(Dynamic Partition Pruning)
在 Join 场景下,Spark 3.0 引入了动态分区裁剪,可以在运行时根据过滤条件动态跳过不需要的分区。这减少了需要扫描和处理的数据量,间接降低了内存消耗。
-- 以下 SQL 在 Spark 3.0+ 中会自动触发动态分区裁剪
SELECT /*+ BROADCAST(d) */
f.*, d.name
FROM fact_sales f
JOIN dim_date d ON f.date_id = d.id
WHERE d.year = 2024
自适应查询执行(AQE)对内存的影响
Spark 3.0 引入的 AQE 可以在运行时动态调整 Shuffle 分区数、优化 Join 策略、处理数据倾斜。这些优化对内存使用有直接帮助:
- 自动减少分区数(
coalescing post-shuffle partitions):减少小任务的内存碎片 - 自动切换 Join 策略:将 Sort Merge Join 转为 Broadcast Hash Join,大幅降低执行内存需求
- 自动处理数据倾斜:将倾斜分区拆分成更小的子分区,避免单分区撑爆内存
spark.sql.adaptive.enabled true
spark.sql.adaptive.coalescePartitions.enabled true
spark.sql.adaptive.coalescePartitions.minPartitionNum 10
spark.sql.adaptive.advisoryPartitionSizeInBytes 64m
GPU 加速与 RAPIDS Accelerator
Spark 3.x 结合 NVIDIA RAPIDS Accelerator for Apache Spark,可以将数据处理管道卸载到 GPU。GPU 有自己的显存(VRAM),Spark 会自动管理数据的 CPU/GPU 内存传输。这需要额外的配置:
spark.plugins com.nvidia.spark.SQLPlugin
spark.rapids.memory.gpu.pooling.enabled true
spark.rapids.memory.gpu.allocSize 2g
spark.rapids.sql.concurrentGpuTasks 2
典型问题与调优实战
问题一:频繁 Full GC
现象:Spark UI 的 GC Time 指标持续偏高(超过总运行时间的 10%),Job 运行缓慢。
根因分析:通常有三种情况:
- 堆内对象过多:大量未序列化的 RDD 缓存占用了堆空间
- Shuffle 数据量过大:Shuffle Read 阶段一次性拉取的数据超出执行内存上限
- Broadcast 变量过大:Broadcast 大表时会将整个对象反序列化存入堆内
解决方案:
# 方案1:增大 Executor 内存
spark.executor.memory 16g
spark.executor.memoryOverhead 4g # 堆外额外开销
# 方案2:使用 Kryo 序列化,减少对象体积
spark.serializer org.apache.spark.serializer.KryoSerializer
spark.kryo.registrationRequired true
spark.kryo.classesToRegister com.myapp.MyClass
# 方案3:降低 spark.memory.fraction,给 User Memory 更多空间
spark.memory.fraction 0.5
# 方案4:优化 GC 算法(推荐 G1GC)
spark.executor.extraJavaOptions "-XX:+UseG1GC -XX:+ParallelRefProcEnabled"
问题二:数据倾斜导致 OOM
现象:即使集群资源充足,部分 Executor 因处理的数据量过大而崩溃,Spark UI 显示大量 Spill 记录。
解决方案:
# AQE 自动处理(推荐,Spark 3.0+)
spark.sql.adaptive.skewJoin.enabled true
spark.sql.adaptive.skewJoin.skewedPartitionFactor 5
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes 256m
# 手动 Salting(适用于 Spark 2.x 或复杂场景)
from pyspark.sql import functions as F
# 给 Join key 加盐打散
df_skewed = df_skewed.withColumn("salt", (F.rand() * 10).cast("int"))
df_even = df_even.withColumn("salt", F.lit(0))
# 扩大维度表并加上所有 salt
df_even_expanded = df_even.crossJoin(
spark.range(10).toDF("salt")
)
# 用复合key Join
result = df_skewed.join(
df_even_expanded,
on=["join_key", "salt"],
how="left"
)
问题三:Shuffle Spill 过多
现象:Spark UI 的 Shuffle Spill (Memory) 和 Shuffle Spill (Disk) 指标差异悬殊,说明大量数据溢出到了磁盘。
根因分析:Shuffle Write 阶段,每个 Map 任务在按 Partition 排序时,如果哈希表放不下全部数据,就会触发 Spill。
解决方案:
# 增大单个 Partition 的数据量 → 减少 Partition 数 → 减少 Map 端哈希表数量
spark.sql.shuffle.partitions 200 # 根据数据量调整,默认200
spark.default.parallelism 200
# 调整 Shuffle 缓冲区大小
spark.shuffle.file.buffer 64k
spark.shuffle.spill.batchSize 10000
spark.shuffle.spill.initialMemoryThreshold 5m
# 使用 Tungsten-Sort Shuffle(默认)
spark.shuffle.manager tungsten-sort
# 对于超大 Shuffle,考虑使用 External Shuffle Service
spark.shuffle.service.enabled true
内存调优 Checklist
以下是一个快速自检清单,适用于日常作业优化:
| 检查项 | 推荐值 | 说明 |
|---|---|---|
| spark.executor.memory | 4G ~ 32G | 越大越好,但需考虑 GC 开销和集群总资源 |
| spark.memory.fraction | 0.6 ~ 0.75 | 应用中 UDF/UDAF 很多时可降低至 0.5 |
| spark.memory.storageFraction | 0.3 ~ 0.5 | 缓存密集型作业可提高,Shuffle 密集型可降低 |
| spark.executor.cores | 2 ~ 8 | core 太多会增加内存并发竞争 |
| spark.sql.adaptive.enabled | true (Spark 3+) | 开启动态调整,强烈推荐 |
| spark.serializer | KryoSerializer | 减少对象体积 5-10 倍 |
| spark.shuffle.service.enabled | true (YARN) | 减少 Executor 移除时的重复计算 |
总结
Spark 的内存管理是一个涉及 JVM 堆内/堆外、统一内存管理器、Shuffle 引擎、GC 调优等多个层面的系统工程。理解 Spark 统一内存模型中存储内存和执行内存的”借用 vs 驱逐”关系,是调优的核心前提。
在实践中,建议遵循”先监控、后诊断、再调优”的原则:
- 先通过 Spark UI 的 Stages 页面查看 GC Time、Shuffle Spill 等关键指标
- 根据指标定位瓶颈是内存不足、GC 过频 还是数据倾斜
- 针对性修改配置参数,一次只改一个参数,观察效果
对于 Spark 3.x 用户,强烈推荐开启 AQE(自适应查询执行)和动态分区裁剪,这些功能可以自动处理大部分常见的性能问题。此外,G1GC 垃圾回收器和 Kryo 序列化应该是所有 Spark 作业的标配选项。
希望本文能帮助你更好地驾驭 Spark 的内存模型,写出更高效、更稳定的大数据处理作业。
汤不热吧