Spark 内存管理调优:executor-memory 不是越大越好
Spark 内存管理调优executor-memory 不是越大越好一、内存越多越快是一个昂贵的错觉每次看到有人提交 Spark 任务时自信满满地写--executor-memory 32G我就想拉个群聊好好聊聊。兄弟你知不知道 Spark 的内存管理模型里32G 不是全给计算用的先来一张图看清 executor 内存的分配结构graph TB subgraph Executor JVM 进程 (如 32GB) A[Reserved Memorybr/300MB 固定预留br/给 Spark 引擎内部使用] B[Spark Memorybr/(总内存 - Reserved) × 0.6br/计算公式缓存的统一内存] C[User Memorybr/(总内存 - Reserved) × 0.4br/用户数据结构、自定义对象] D[Overheadbr/堆外内存br/占总内存的 10% 或 384MB取大值] end B -- B1[Storage Memorybr/缓存 RDD/广播变量br/默认占 Spark Memory 的 50%] B -- B2[Execution Memorybr/Shuffle/Join/Aggregationbr/默认占 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 32GReserved Memory扣掉 300MB剩 31.7GSpark Memory堆内31.7G × 0.6 ≈19GUser Memory31.7G × 0.4 ≈12.7GOverhead堆外额外需要 32G × 0.1 3.2G堆外内存真正给计算用的 Spark Memory 只有 19G而这对 storage缓存和 executionshuffle/join还要五五分。更狠的是——Overhead 内存不算在 executor-memory 里但它就是实打实要占用的为什么 Overhead 内存的隐形存在是最容易 OOM 的元凶大部分人配置 executor-memory 时只算了 JVM 堆内内存完全忘了 Overhead 是独立的堆外分配。一个 32G 的 executor 实际占用 32G(堆) 3.2G(overhead) 4G(如果开了堆外) 39.2G——而你提交任务时看到的 executor-memory32G 让人觉得只占了 32G。当你在 YARN 上调度时如果 NodeManager 认为剩余内存是 40G你起了两个 32G 的 executor实际加起来 78.4G——OOM 了但你盯着 32G×264G 想不通为什么。这就是为什么生产环境的内存估算必须加一个真实占用系数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.enabledtrue \ --conf spark.memory.offHeap.size4G \ my_job.py # 配置 B小 executor多实例 spark-submit \ --executor-memory 8G \ --executor-cores 2 \ --num-executors 20 \ --conf spark.memory.offHeap.enabledtrue \ --conf spark.memory.offHeap.size2G \ my_job.py我们来算一笔账。配置 A 的 32G executor 在处理大规模 shuffle 时一旦触发 Full GCSTWStop 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:MaxGCPauseMillis200) \ # ---------- 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 个 8G4G 堆外的 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(静态)1032G28min45%8G×30(静态)308G19min68%8G动态分配5~408G15min82%动态分配不仅更快因为空闲 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 只有 3GB8G 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的时候可以把这篇文章甩给他。