Spark 的内存管理是其性能优化的核心,理解 Executor 内存划分和常见 OOM 原因对于开发稳定、高效的 Spark 应用至关重要。
一、Executor 内存划分 (Unified Memory Management - Spark 1.6+ 默认)
Spark 从 1.6 版本开始引入了 统一内存管理 (Unified Memory Management),取代了之前的静态划分。Executor 的 JVM 堆内存被划分为以下四个主要区域:
-
Reserved Memory (预留内存)
- 大小: 固定为 300MB。
- 用途: 预留给 Spark 内部使用,存储系统内部数据结构、避免 OOM 的安全缓冲。用户代码和 Spark 存储/执行都无法使用这部分内存。
- 重要性: 这部分是硬性预留的,
spark.executor.memory
必须至少大于 300MB,否则启动失败。
-
User Memory (用户内存)
- 大小: 占
(spark.executor.memory - Reserved Memory) * (1 - spark.memory.fraction)
。默认spark.memory.fraction = 0.6
(60%),所以 User Memory 默认占(总内存 - 300MB) * 0.4
(40%)。 - 用途: 完全由用户代码控制。用于存储:
- RDD 转换操作中用户自定义数据结构 (如
mapPartitions
里创建的集合、哈希表、缓存对象等)。 - 用户代码中的变量、对象。
- RDD 转换操作中用户自定义数据结构 (如
- 特点:
- Spark 不管理这部分内存的使用和回收。
- 如果用户代码在此区域分配过多内存(如创建大数组、Map),是导致 OOM 的常见原因。
- 大小: 占
-
Spark Memory (Spark 托管内存)
- 大小: 占
(spark.executor.memory - Reserved Memory) * spark.memory.fraction
。默认是(总内存 - 300MB) * 0.6
(60%)。 - 划分: 进一步分为两个子区域,它们之间没有硬性边界,可以互相借用:
- Storage Memory (存储内存):
- 用途: 用于缓存 RDD(
persist()
/cache()
)、广播变量 (broadcast
)、以及 Task 结果的存储(Shuffle Write 的中间数据也临时使用这里)。 - 驱逐策略: 当存储内存不足且有新的块要缓存时,会根据 RDD 的
StorageLevel
(如MEMORY_ONLY
,MEMORY_AND_DISK
) 决定:要么直接丢弃旧块(MEMORY_ONLY
),要么将旧块溢出 (spill
) 到磁盘(MEMORY_AND_DISK
)。
- 用途: 用于缓存 RDD(
- Execution Memory (执行/计算内存):
- 用途: 用于任务执行时的计算。这是最关键的、任务运行所必需的内存。具体用于:
- Shuffle 过程中的数据排序、聚合、哈希表 (
PartitionedAppendOnlyMap
,PartitionedPairBuffer
)。 - Join 操作中的哈希表 (Hash Join)。
- 聚合操作 (
aggregateByKey
,reduceByKey
) 中的中间聚合状态。 - 窗口函数等复杂转换的中间状态。
- Shuffle 过程中的数据排序、聚合、哈希表 (
- 特点:
- 按 Task 分配且不可抢占: 多个 Task 共享 Execution 内存池。每个 Task 能获得的最小内存为
spark.executor.memory * spark.memory.fraction * spark.memory.storageFraction / numConcurrentTasks
。一个 Task 占用的内存不能被另一个正在运行的 Task 强行释放,即使后者内存不足。 这是导致 OOM 的一个重要机制。 - Spill 机制: 如果 Task 需要的内存超过了它能申请到的上限,它会将中间数据溢出 (
spill
) 到磁盘。虽然避免了 OOM,但会显著降低性能。
- 按 Task 分配且不可抢占: 多个 Task 共享 Execution 内存池。每个 Task 能获得的最小内存为
- 用途: 用于任务执行时的计算。这是最关键的、任务运行所必需的内存。具体用于:
- Storage Memory (存储内存):
- 大小: 占
-
Off-Heap Memory (堆外内存)
- 启用: 通过
spark.memory.offHeap.enabled=true
开启。 - 大小: 由
spark.memory.offHeap.size
指定。 - 用途: 与堆内
Spark Memory
类似,也分为Storage
和Execution
两部分(共享池)。数据以二进制格式存储。 - 优点:
- 避免 GC 开销: 数据不受 JVM GC 管理,减少因 Full GC 导致的停顿。
- 潜在的大内存支持: 理论上可突破 JVM 堆大小的限制。
- 缺点:
- 序列化/反序列化开销可能更大(需要显式转换)。
- 调试和管理更复杂。
- 如果使用不当,也会发生堆外 OOM (
OutOfDirectMemoryError
)。
- 启用: 通过
图示 Executor 内存布局 (堆内,假设 spark.memory.fraction=0.6
):
+-------------------------------------------------------+
| Executor JVM Heap |
| |
| +-----------------+---------------------------------+ |
| | Reserved Memory | User Memory (40%) | |
| | (300MB Fixed) | | |
| +-----------------+---------------------------------+ |
| | | | |
| | Spark Memory (60%) | | |
| | | | |
| | +-----------------------------+ | | |
| | | Storage Memory | | | |
| | | (Caching, Broadcasts) | | | |
| | | | | | |
| | +-----------------------------+ | Shared Region | |
| | | | | (Flexible | |
| | | Execution Memory | | Boundary) | |
| | | (Shuffle, Joins, Aggs) | | | |
| | | | | | |
| | +-----------------------------+ | | |
| | | | |
| +---------------------------------------------------+ |
+-------------------------------------------------------+
^ ^
| |
|-- Can borrow space from each other --|
二、理解 Spark OOM (OutOfMemoryError) 常见原因
Spark OOM 主要发生在 Executor 进程内,通常伴随 java.lang.OutOfMemoryError: Java heap space
或 java.lang.OutOfMemoryError: GC overhead limit exceeded
(本质也是堆空间不足)。常见原因按内存区域分析:
-
User Memory 区域 OOM:
- 根本原因: 用户代码在 User Memory 区域分配了过多对象,耗尽了该区域内存。
- 典型场景:
- Driver 端 OOM:
- 在 Driver 上使用
collect()
、take(n)
(n 很大) 或toPandas()
将大量结果数据拉取回 Driver。Driver 的 User Memory 被撑爆。 - 在 Driver 上创建或处理非常大的数据结构(如大 Map、大 List)。
- 在 Driver 上使用
- Executor 端 OOM:
- 在 RDD 转换算子 (
map
,mapPartitions
,flatMap
,foreach
,foreachPartition
等) 或 UDF 中,处理单个分区时创建了过大的数据结构。例如:- 在
mapPartitions
中加载整个分区的数据到一个大 List 或 Map 中再处理。 - UDF 逻辑复杂,产生大量中间对象,GC 跟不上分配速度。
- 在
- 使用
repartition
/coalesce
导致单个分区数据量过大,处理该分区的 Task 在 User Memory 中创建的对象总量超出限制。
- 在 RDD 转换算子 (
- Driver 端 OOM:
-
Execution Memory 区域 OOM:
- 根本原因: 执行 Task 所需的计算内存 (Execution Memory) 超过了 Task 能申请到的上限,且无法完全 Spill 到磁盘(或 Spill 本身也需要内存)。
- 典型场景:
- 数据倾斜 (Data Skew): 这是 最常见原因 之一!
- 某个 Key 的数据量异常巨大,导致处理该 Key 所在分区的 Task 需要处理远超其他 Task 的数据量。
- 该 Task 在执行聚合 (
reduceByKey
,groupByKey
)、排序 (sortByKey
)、或 Join (尤其是 Hash Join) 操作时,需要在内存中维护巨大的哈希表、排序缓冲区或聚合状态,远超其应得份额的 Execution Memory。其他 Task 空闲,但该 Task OOM。
- 大表 Join (尤其 Shuffle Hash Join 或 Sort Merge Join 未完全优化):
- 如果 Join 的一侧表很大,构建哈希表 (Broadcast Hash Join 的广播表太大时) 或进行排序合并时,可能消耗大量 Execution Memory。
- 大窗口或复杂聚合: 窗口函数 (
window
) 或状态流处理 (updateStateByKey
,mapGroupsWithState
) 中维护的每个 Key 的状态过大或过多。 - Shuffle 操作配置不当:
spark.sql.shuffle.partitions
或spark.default.parallelism
设置过小,导致单个 Shuffle 分区数据量过大,Task 处理时内存需求激增。- Shuffle Read 阶段,Reducer Task 需要拉取大量数据并在内存中进行聚合或排序。
- Task 并发度过高: 单个 Executor 上同时运行的 Task 数 (
spark.executor.cores
) 设置过高。虽然总 Execution Memory 池大小不变,但每个 Task 能分到的最小保证内存 (minMemoryPerTask
) 会变小。如果一个 Task 实际需要更多内存,就可能 OOM。
- 数据倾斜 (Data Skew): 这是 最常见原因 之一!
-
Storage Memory 区域 OOM:
- 根本原因: 试图缓存 (
persist()
/cache()
) 的数据量超过了 Storage Memory 可用空间,且无法完全溢出到磁盘(MEMORY_AND_DISK
级别会尝试 Spill,但 Spill 本身有开销,极端情况也可能失败)。 - 典型场景:
- 缓存了过多的 RDD 或过大的 DataFrame/DataSet。
- 广播变量 (
broadcast
) 非常大 (广播变量也使用 Storage Memory)。 - 缓存的 RDD/DataFrame 的分区数过少,导致单个缓存块过大。
- 根本原因: 试图缓存 (
-
Off-Heap Memory OOM:
- 根本原因: 启用了堆外内存,但堆外内存分配超出了
spark.memory.offHeap.size
。 - 现象:
java.lang.OutOfMemoryError: Direct buffer memory
。
- 根本原因: 启用了堆外内存,但堆外内存分配超出了
-
其他原因:
- Executor 总内存 (
spark.executor.memory
) 设置过低: 根本不够用。 - JVM GC 问题: 虽然根本原因是内存不足,但如果 GC 效率极低 (如频繁 Full GC,
GC overhead limit exceeded
),会加剧 OOM 发生。 - 内存泄漏 (较少见但存在): 用户代码或 Spark 内部 (罕见) 存在对象引用未释放,导致内存无法回收。
- Shuffle Service 不稳定 (External Shuffle Service): 如果 Executor 在 Shuffle Write 完成后退出,依赖 External Shuffle Service 提供 Shuffle 数据。如果 ESS 不稳定或配置不当,可能导致 Fetch Failed,进而重试 Task,增加内存压力。
- Executor 总内存 (
三、OOM 排查与解决思路
-
定位 OOM 发生位置:
- Driver OOM: 错误栈通常包含
collect()
,take()
,show()
,toPandas()
等 Action 操作,或 Driver 初始化代码。 - Executor OOM: 查看 Executor 日志 (
stderr
),错误栈通常会指向具体的 Task 执行代码 (如某个map
,reduce
,join
,aggregate
操作) 或 Shuffle 相关类 (ExternalAppendOnlyMap
,UnsafeExternalSorter
,TaskMemoryManager
)。
- Driver OOM: 错误栈通常包含
-
分析 Spark Web UI / History Server:
- Stages 页: 查看失败 Stage,检查是否有 Task 失败次数异常多 (
Failed
列)。重点看失败 Task 的Input Size / Records
是否远大于其他 Task (数据倾斜)。 - Storage 页: 检查缓存的数据量是否过大。评估缓存是否必要。
- Executors 页: 查看 OOM 前 Executor 的内存使用情况 (
Storage Memory
,Exec Memory
占比),GC 时间。检查spark.executor.cores
和并发 Task 数。 - SQL 页 (如果使用 DataFrame/Dataset): 查看物理计划,确认 Join 策略 (Broadcast? SortMerge? ShuffleHash?),聚合方式等。
- Stages 页: 查看失败 Stage,检查是否有 Task 失败次数异常多 (
-
针对性解决方案:
- Driver OOM:
- 避免
collect()
/toPandas()
大结果集: 使用write
写到存储系统后处理。 - 增大 Driver 内存:
spark.driver.memory
。 - 优化 Driver 端代码: 避免创建大集合。
- 避免
- Executor OOM - 通用:
- 增大 Executor 内存:
spark.executor.memory
(注意集群资源限制)。 - 增加 Executor 数量:
spark.executor.instances
(分摊内存压力)。 - 调整内存比例:
- 如果 User Memory 不足 (用户代码复杂):适当减小
spark.memory.fraction
(如 0.5),增大 User Memory 占比。慎用! - 如果 Execution/Storage Memory 不足:适当增大
spark.memory.fraction
(如 0.7),或增大总内存。 - 调整 Storage/Execution 初始比例:
spark.memory.storageFraction
(默认 0.5)。
- 如果 User Memory 不足 (用户代码复杂):适当减小
- 启用堆外内存:
spark.memory.offHeap.enabled=true
,spark.memory.offHeap.size=...
(解决 GC 相关问题)。 - 优化 GC: 使用 G1GC (
-XX:+UseG1GC
),调整相关参数。
- 增大 Executor 内存:
- Executor OOM - 数据倾斜:
- 预处理倾斜 Key: 过滤、分离、加盐 (Salting - 给倾斜 Key 添加随机前缀/后缀打散)、自定义分区器。
- 增大 Shuffle 分区数:
spark.sql.shuffle.partitions
/spark.default.parallelism
(打散数据)。 - 使用倾斜 Join 优化:
spark.sql.adaptive.skewJoin.enabled=true
(AQE),或手动使用SMJ
+spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes
/spark.sql.adaptive.skewJoin.skewedPartitionFactor
。 - 避免
groupByKey
: 优先使用reduceByKey
/aggregateByKey
(预聚合)。
- Executor OOM - Shuffle/Join/Agg 相关:
- 增大 Shuffle 缓冲区:
spark.shuffle.file.buffer
,spark.shuffle.spill.batchSize
(减少 Spill 次数),spark.sql.shuffle.partitions
(增加分区数)。 - 鼓励 Broadcast Join: 确保小表能被广播 (
spark.sql.autoBroadcastJoinThreshold
),使用broadcast
hint。 - 调整聚合/Join 策略: 理解并选择合适策略 (SortMergeJoin vs BroadcastHashJoin)。
- 允许更多 Spill:
spark.shuffle.spill=true
(默认 true),调整spark.shuffle.memoryFraction
(旧版) 或相关 Spill 阈值 (新版在 Unified 下自动管理)。这是最后防线,会牺牲性能。
- 增大 Shuffle 缓冲区:
- Executor OOM - Task 并发度:
- 降低单个 Executor 的并发度:减少
spark.executor.cores
(增加 Executor 数量来补偿)。
- 降低单个 Executor 的并发度:减少
- Storage OOM:
- 评估缓存必要性: 移除不必要的
cache()
/persist()
。 - 使用更合适的 StorageLevel: 如
MEMORY_AND_DISK
代替MEMORY_ONLY
,DISK_ONLY
。 - 检查广播变量大小: 确保广播的变量确实是小表。
- 增加 Executor 总内存或
spark.memory.fraction
。 - 增大
spark.memory.storageFraction
(让 Storage 初始占比更大)。
- 评估缓存必要性: 移除不必要的
- Driver OOM:
-
高级工具:
- 生成 Heap Dump: 在 OOM 时添加 JVM 参数
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dumps
分析内存中对象。 - Profiling: 使用 JProfiler, YourKit, Java Flight Recorder 等工具分析内存分配和对象生命周期。
- Spark 自省 API:
SparkEnv.get.memoryManager
查看内存使用情况 (调试代码)。
- 生成 Heap Dump: 在 OOM 时添加 JVM 参数
总结: Spark OOM 问题错综复杂,需结合日志、UI、配置和业务逻辑综合分析。核心思路是:理解内存划分 -> 定位溢出区域 -> 分析具体原因 (数据倾斜/配置不当/用户代码问题) -> 针对性优化 (资源/配置/代码/数据)。 预防胜于治疗,在开发阶段就应预估数据量级,合理设计分区,避免常见陷阱。