Spark 内存管理调优:executor-memory 不是越大越好
一、"内存越多越快"是一个昂贵的错觉
每次看到有人提交 Spark 任务时自信满满地写--executor-memory 32G,我就想拉个群聊好好聊聊。兄弟,你知不知道 Spark 的内存管理模型里,32G 不是全给计算用的?
先来一张图看清 executor 内存的分配结构:
graph TB subgraph "Executor JVM 进程 (如 32GB)" A["Reserved Memory<br/>300MB 固定预留<br/>给 Spark 引擎内部使用"] B["Spark Memory<br/>(总内存 - Reserved) × 0.6<br/>计算公式缓存的统一内存"] C["User Memory<br/>(总内存 - Reserved) × 0.4<br/>用户数据结构、自定义对象"] D["Overhead<br/>堆外内存<br/>占总内存的 10% 或 384MB取大值"] end B --> B1["Storage Memory<br/>缓存 RDD/广播变量<br/>默认占 Spark Memory 的 50%"] B --> B2["Execution Memory<br/>Shuffle/Join/Aggregation<br/>默认占 Spark Memory 的 50%"] B1 <-.->|动态借用| B2 style A fill:#faa,stroke:#333,stroke-width:2px style D fill:#faa,stroke:#333,stroke-width:2px一目了然了吧?设executor-memory 32G:
- Reserved Memory:扣掉 300MB,剩 31.7G
- Spark Memory(堆内):31.7G × 0.6 ≈19G
- User Memory:31.7G × 0.4 ≈12.7G
- Overhead(堆外):额外需要 32G × 0.1 =3.2G堆外内存
真正给计算用的 Spark Memory 只有 19G,而这对 storage(缓存)和 execution(shuffle/join)还要五五分。更狠的是——Overhead 内存不算在 executor-memory 里,但它就是实打实要占用的!
为什么 Overhead 内存的"隐形存在"是最容易 OOM 的元凶?大部分人配置 executor-memory 时只算了 JVM 堆内内存,完全忘了 Overhead 是独立的堆外分配。一个 32G 的 executor 实际占用 = 32G(堆) + 3.2G(overhead) + 4G(如果开了堆外) = 39.2G——而你提交任务时看到的 "executor-memory=32G" 让人觉得只占了 32G。当你在 YARN 上调度时,如果 NodeManager 认为剩余内存是 40G,你起了两个 32G 的 executor,实际加起来 78.4G——OOM 了,但你盯着 32G×2=64G 想不通为什么。这就是为什么生产环境的内存估算必须加一个"真实占用系数"(1.1 的 overhead + 可能的堆外),而不是直接用 executor-memory 做除法。
二、垃圾回收才是内存过大的隐形杀手
Spark 运行在 JVM 上,JVM 的 GC 有一个反直觉的特性:堆越大,Full GC 越慢。举个例子:
# 配置 A:大 executor,少实例 spark-submit \ --executor-memory 32G \ --executor-cores 8 \ --num-executors 5 \ --conf spark.memory.offHeap.enabled=true \ --conf spark.memory.offHeap.size=4G \ my_job.py # 配置 B:小 executor,多实例 spark-submit \ --executor-memory 8G \ --executor-cores 2 \ --num-executors 20 \ --conf spark.memory.offHeap.enabled=true \ --conf spark.memory.offHeap.size=2G \ my_job.py我们来算一笔账。配置 A 的 32G executor 在处理大规模 shuffle 时,一旦触发 Full GC,STW(Stop The World)时间轻松上 1030 秒。而配置 B 的 8G executor,每个实例 GC 只要 13 秒,20 个实例并行跑,整体吞吐反而更高。
这里有个关键参数的实战调法:
# Spark 内存调优脚本示例 from pyspark.sql import SparkSession spark = SparkSession.builder \ .appName("MemoryTuning") \ .config("spark.executor.memory", "8g") \ .config("spark.executor.cores", "2") \ .config("spark.executor.instances", "20") \ # ---------- 核心内存参数 ---------- # storage 和 execution 的比例。数据分析场景 shuffle 多,execution 可以调大 .config("spark.memory.storageFraction", "0.3") \ # 预留内存比例,默认 0.6 给 Spark Memory .config("spark.memory.fraction", "0.7") \ # ---------- GC 相关 ---------- # G1GC 在大堆场景下比 CMS 更稳定,暂停时间可控 .config("spark.executor.extraJavaOptions", "-XX:+UseG1GC -XX:MaxGCPauseMillis=200") \ # ---------- Shuffle 调优 ---------- # 单个 shuffle 文件大小上限,超了就溢写磁盘 .config("spark.sql.shuffle.partitions", "400") \ .config("spark.shuffle.file.buffer", "64k") \ .getOrCreate() # 处理大规模数据 df = spark.read.parquet("hdfs://datalake/user_log/*.parquet") # 观察 Spark UI 中 Executor 页签的 GC Time,理想情况应 < 10% result = df.groupBy("event_type").count() result.show()为什么 32G 的 Full GC 能卡 30 秒而 8G 只要 2-3 秒?JVM 的 GC 复杂度不是线性增长的。32G 堆的 GC 扫描区域是 8G 堆的 4 倍,但因为 JVM 需要追踪更多的对象引用图和处理更复杂的内存碎片,实际 GC 暂停时间是超线性的。更重要的是,32G 的堆意味着单个 executor 处理的数据量更大——当它触发 Full GC 时,堆里可能有 20GB 的存活对象需要整理,而 G1GC 的 mixed GC 在 32G 堆上做一次完整标记-整理的代价是 8G 堆的 5-8 倍。大堆的 GC 暂停时间会直接反映在 Spark UI 的 Executor 页 GC Time 上——如果 GC Time 超过 Task Time 的 10%,说明 JVM 花在"清理内存"上的时间比"计算数据"还多,内存配置绝对有问题。
三、堆外内存的神奇作用
数据分析场景强烈建议开启堆外内存:
# 堆外内存配置 spark = SparkSession.builder \ .config("spark.memory.offHeap.enabled", "true") \ .config("spark.memory.offHeap.size", "4g") \ # 单个 executor 的堆外内存 # 总内存 = executor-memory + overhead + offHeap.size # 所以这里一个 executor 实际占用: # 8G(堆) + max(384M, 8G×0.1)(overhead) + 4G(堆外) = 12.8G .getOrCreate()堆外内存的好处是不受 JVM GC 管理。Shuffle 过程中的中间数据直接分配在堆外,不会产生 GC 压力。对于数据分析里的高基数 groupBy 和大表 join,堆外内存是救命的。
但注意:堆外内存大小会叠加在 executor 总占用上。如果物理机只有 64G 内存,你起 5 个 8G+4G 堆外的 executor,加上 driver 和 overhead,轻松超标。先看物理资源再设参数,不要凭感觉灌数字。
# 用这个公式估算实际占用 # 总占用 = num_executors × (executor_memory + max(384MB, executor_memory×0.1) + offHeap_size) # + driver_memory # 比如上面配置 B 的 20 个 executor: # 20 × (8G + 0.8G + 2G) = 216G 外加 driver 的 4G # 如果集群只有 150G 总内存,一定会 OOM为什么堆外内存在高基数 groupBy 场景下是"救命的"?高基数 groupBy(比如按 user_id 做聚合,有 1000 万个不同 user_id)需要维护一个巨大的 HashMap 来累加每个 key 的值。这个 HashMap 放在堆内的话,它的键值对对象会产生大量的 GC 压力——每插入 100 万个条目就可能触发一次 Young GC,因为新生代被这些小对象占满了。放在堆外的话,HashMap 的内存空间是直接通过 Unsafe.allocateMemory 分配的,JVM 看不到它——不会被 GC 扫描,不会触发 GC 暂停。代价是:你必须手动管理这部分内存的分配和释放(Spark 帮你做了),而且堆外内存满了不会触发 GC 帮你回收,而是直接抛 OutOfDirectMemoryError。堆外内存的本质是用"自己管理生命周期"换取"零 GC 压力"。
四、动态资源分配:不再浪费
# 开启动态分配,空闲 executor 自动回收 spark = SparkSession.builder \ .config("spark.dynamicAllocation.enabled", "true") \ .config("spark.dynamicAllocation.minExecutors", "5") \ .config("spark.dynamicAllocation.maxExecutors", "50") \ # executor 空闲超过 60s 就被回收 .config("spark.dynamicAllocation.executorIdleTimeout", "60s") \ # 有 pending task 超过 10s 就申请新 executor .config("spark.dynamicAllocation.schedulerBacklogTimeout", "10s") \ .getOrCreate()动态分配在数据分析场景下特别好用。白天的即席查询任务并发低,晚上的 ETL 批处理任务并发高——动态分配自动伸缩,不需要每次改配置、重启任务。
看一个真实场景的对比。在一套 300G 内存的集群上跑日活分析:
| 配置方案 | executor 数 | 单 executor 内存 | 任务耗时 | 集群利用率 |
|---|---|---|---|---|
| 32G×10(静态) | 10 | 32G | 28min | 45% |
| 8G×30(静态) | 30 | 8G | 19min | 68% |
| 8G+动态分配 | 5~40 | 8G | 15min | 82% |
动态分配不仅更快(因为空闲 executor 数更合理),集群利用率也从 45% 提到了 82%。
为什么集群利用率从 45% 到 82% 的提升不是"省了点资源"而是一个架构问题?静态分配 32G×10 的配置意味着不管任务负载多轻,这 10 个 executor 永远占着 320G 内存不放。白天即席查询只需要 5 个 executor 时,另外 5 个空转——其他在排队的任务看着干着急。这不仅是资源浪费,更直接限制了整个集群的并发能力。如果集群有 5 个用户各开 10 个 32G executor,总占用 1600G 内存——集群可能就拖垮了。动态分配让 executor 按需伸缩,实际效果是集群在同一批硬件上能多跑 50% 的任务。这不是"省电费"的问题,是"花同样的钱能不能多干一倍活"的问题。
踩坑提醒
不要在生产环境里对 UDF 使用过大的对象而不调用
unpersist——Spark 的 Storage Memory 默认缓存 RDD 和广播变量。如果你的 UDF 里用到了广播一个 500MB 的字典表,两次广播就会吃掉 1GB 的 Storage Memory——而这些内存是从 Execution Memory 动态借来的。如果 shuffle 正忙时发现内存被 Storage 占了,Spark 会把缓存驱逐到磁盘,但驱逐本身也有性能开销。正确做法:广播变量用完后显式调用broadcast_var.unpersist()。spark.sql.shuffle.partitions的默认值 200 太低——在 TP99 数据量下会变成灾难——200 个分区处理 1TB 数据时每个分区 5GB,一个 task 处理 5GB 数据需要至少 5GB 执行内存。如果你的 execution memory 只有 3GB(8G executor 的 50%),那每个 task 都会溢写磁盘——磁盘 IO 会吃掉所有性能提升。经验公式:单个 partition 的数据量控制在 128-256MB,根据总数据量反算 partitions 数。动态分配 + 长期运行的 Streaming 任务不兼容——
spark.dynamicAllocation的设计前提是"任务有结束时刻",executor 在空闲后被回收。但 Structured Streaming 是 7×24 不间断运行的,executor 永远不会被标记为"idle",动态分配实际不生效。Streaming 任务必须用静态资源配置。别在 Streaming 任务配置里傻傻地开 dynamic allocation。
五、总结
- executor-memory 不是越大越好:JVM 堆越大 GC 越慢,32G 的 executor Full GC 能卡半分钟。建议单 executor 8~16G,通过增加 executor 数量来提升并行度。
- 关注内存分配比例:默认的 storage:execution = 1:1 不一定适合你。数据分析场景 shuffle 多,把
spark.memory.storageFraction调低给 execution 更多空间。 - 堆外内存是真香但别瞎开:它能解决 GC 压力,但会叠加在总内存占用上。算好物理资源再配置。
- 动态分配省心省资源:开启
dynamicAllocation,让集群自动伸缩,白天不浪费,夜里不排队。 - 调优不是给更多资源,而是给对资源配置:把一个大 executor 拆成多个小 executor,往往比撑大单个 executor 更有效。
下次看到有人无脑写--executor-memory 64G的时候,可以把这篇文章甩给他。