4. 资源调优(下):Spark内存模型详解(堆内与堆外)、内存压力测试方法、OOM的常见场景与解决方案
内存调优,说白了就是搞清楚你的数据到底在哪儿待着、怎么待着。很多同学上来就调 spark.executor.memory,结果该崩还是崩。我见过太多团队把内存配得很大,但任务跑着跑着就 OOM 了——原因很简单:他们根本不了解 Spark 的内存模型。
这一节,我们就把内存模型掰开揉碎。堆内、堆外、统一内存、压力测试、OOM 场景,一个一个来。
4.1 Spark 内存模型:堆内与堆外
Spark 的内存管理,从 JVM 视角看,分两大块:堆内内存(On-Heap)和堆外内存(Off-Heap)。
堆内就是 JVM 堆,受 GC 管理。堆外是直接向操作系统申请的内存,绕开了 JVM。为什么要搞堆外?因为 GC 在大堆场景下是个大坑——Full GC 一次可能几十秒,任务直接卡死。
Spark 1.6 之后引入了统一内存管理(Unified Memory Manager)。它把堆内内存划分成几个区域:
| 区域 | 默认比例 | 用途 |
|---|---|---|
| Reserved Memory | 300MB(固定) | 系统预留,存储 Spark 内部对象 |
| User Memory | (可用内存 - Reserved) × 0.25 | 用户代码、数据结构、UDF 中间结果 |
| Spark Memory | (可用内存 - Reserved) × 0.75 | 执行(Shuffle、Join、Sort)和存储(Cache、Broadcast)共用 |
| → Execution Memory | Spark Memory × 0.5(可抢占) | 计算用,Shuffle 读写、聚合、排序 |
| → Storage Memory | Spark Memory × 0.5(可被抢占) | 缓存 RDD、DataFrame、Broadcast 变量 |
这里有个关键点:Execution 和 Storage 可以互相抢占。如果计算压力大,Execution 可以拿走 Storage 的内存;反过来,如果缓存数据多,Storage 也能占 Execution 的地盘。但注意——Storage 被抢占后,缓存的数据会被直接丢弃,需要重新计算。所以,如果你的任务重度依赖 Cache,最好调高 spark.memory.storageFraction,默认是 0.5。
堆外内存呢?它不受这些比例限制。堆外主要用于:
- Netty 网络传输:Shuffle 读写时的零拷贝
- 堆外缓存:通过
spark.memory.offHeap.enabled=true开启,配合spark.memory.offHeap.size指定大小 - PySpark / 原生库:Python UDF、TensorFlow 等
我个人习惯,在需要大量 Shuffle 或频繁 GC 的场景下,会开一点堆外内存。但别开太大——堆外内存泄漏很难排查,而且操作系统不会帮你回收。
核心公式:
Executor 可用内存 = spark.executor.memory - 300MB(Reserved)
Spark Memory = 可用内存 × 0.75
Execution Memory = Spark Memory × spark.memory.storageFraction(默认0.5)
举个例子:你配了 4GB executor,实际给计算用的 Execution Memory 只有 (4096 - 300) × 0.75 × 0.5 ≈ 1423MB。嗯,比你想象的小很多吧?
4.2 内存压力测试方法
光看配置不够,你得知道你的任务到底吃多少内存。我一般用三种方法做压力测试:
4.2.1 使用 Spark 内置的 Metrics
Spark UI 的 Stages 页面有个 Shuffle Read Size / Records 和 Shuffle Write Size / Records。这些能告诉你每个 Task 处理了多少数据。但更细粒度的内存使用,得看 Executors 页面的 Memory 列——它显示的是 Storage Memory 的使用量,不是 Execution。
想看到 Execution Memory 的实时使用?我建议用 Spark Listeners 自定义监控:
// 注册一个 SparkListener,监听 onExecutorMetricsUpdate 事件
// 可以拿到每个 Executor 的 onHeapExecutionMemory、offHeapExecutionMemory 等指标
// 然后推送到 Prometheus + Grafana
4.2.2 构造极端数据做压测
我曾经在一个实时数仓项目里,用 10 倍于线上峰值的数据量 做压测。具体做法:
- 准备一个大数据集(比如 100GB),用
repartition()控制分区数 - 跑一个复杂的 Shuffle 操作(比如
groupBy().agg()) - 观察 Spark UI 的 GC Time 和 Shuffle Spill
- 如果 GC Time 超过 Task 时间的 10%,或者有 Spill 到磁盘,说明内存不够
小技巧:用 spark.sql.adaptive.coalescePartitions.enabled=true 和 spark.sql.adaptive.advisoryPartitionSizeInBytes 控制输出分区大小,避免小文件过多。压测时建议关掉 AQE,先看原始内存压力。
4.2.3 使用 spark.memory.debug 和 JVM 工具
在 Spark 2.4+,可以开启 spark.memory.debug=true,它会在日志里打印每个 Task 的内存分配和释放情况。配合 jstat -gcutil 看 GC 频率,或者用 jmap -histo 看堆内对象分布。
嗯,这里要注意:生产环境别开 debug 日志,否则日志量会爆炸。我一般只在测试集群做压测时开。
4.3 OOM 的常见场景与解决方案
OOM 是 Spark 调优里最头疼的问题。我总结了几种高频场景:
| 场景 | 根因 | 典型表现 | 解决方案 |
|---|---|---|---|
| Driver OOM | collect() 拉回太多数据 | Driver 端 GC 频繁,最后 OOM | 改用 foreach() 或 take();增大 spark.driver.memory |
| Executor OOM(Shuffle) | 单个 Partition 数据倾斜 | 某个 Task 处理的数据量是其他 Task 的 10 倍以上 | 加盐、重分区、调整 spark.sql.adaptive.skewJoin.enabled |
| Executor OOM(Cache) | Cache 数据超过 Storage Memory | Storage Memory 使用率 100%,频繁驱逐 | 调大 spark.memory.storageFraction;改用序列化缓存 .persist(StorageLevel.MEMORY_AND_DISK_SER) |
| Executor OOM(Broadcast) | Broadcast 变量太大 | 每个 Executor 都加载一个大对象 | 检查 spark.sql.autoBroadcastJoinThreshold,默认 10MB,调小或关闭 |
| 堆外 OOM | Netty 缓冲区泄漏或 PySpark 内存泄漏 | 错误信息包含 OutOfDirectMemoryError |
调大 spark.executor.extraJavaOptions=-XX:MaxDirectMemorySize;检查 UDF 是否有资源未释放 |
4.3.1 数据倾斜导致的 OOM
这是最常见的。我曾经遇到一个场景:一个 200 个 Partition 的 Join,其中一个 Partition 占了 80% 的数据。结果那个 Task 直接 OOM,其他 199 个 Task 几秒就跑完了。
解决方案:
- 加盐:对倾斜的 Key 加随机前缀,打散到多个 Partition
- AQE 自动倾斜处理:开启
spark.sql.adaptive.skewJoin.enabled,设置spark.sql.adaptive.skewJoin.skewedPartitionFactor(默认 5)和spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes(默认 256MB) - 手动重分区:
df.repartition(1000, $"key")或df.coalesce(100)
避坑指南:我曾经在加盐时犯过一个错——加盐后忘记在 Join 条件里去掉盐值,导致 Join 结果全错了。加盐一定要在 Join 完成后 .drop("salt") 清理掉。
4.3.2 Cache 过多导致的 OOM
很多人喜欢把中间结果 .cache() 一下,觉得能加速。但如果你 Cache 的数据量超过了 Storage Memory,Spark 会开始驱逐旧数据。驱逐不掉?那就 OOM。
我的建议:
- 能用
MEMORY_AND_DISK_SER就别用MEMORY_ONLY。序列化后内存占用能减少 2-5 倍 - Cache 前先估算数据量:
df.cache().count()看 Storage Memory 使用量 - 用完及时
.unpersist()
4.3.3 Broadcast 变量过大
Spark 默认会把小于 10MB 的表自动 Broadcast。但如果你的小表其实有 50MB,每个 Executor 都要加载一次,内存压力就上来了。
解决方案:
- 调小
spark.sql.autoBroadcastJoinThreshold到 5MB 或 1MB - 或者干脆关闭自动 Broadcast,手动控制:
df.join(broadcast(smallDf), "key")
4.4 内存模型与 OOM 场景的关联图
下面这张图把堆内/堆外、各区域比例、以及 OOM 的触发点串起来了。你可以对照着看,哪个环节出问题,就调哪个参数。
4.5 总结一下
内存调优没有银弹。你得先理解模型,再通过压测找到瓶颈,最后对症下药。我个人习惯的排查顺序:
- 看 Spark UI 的 GC Time 和 Shuffle Spill
- 检查是否有数据倾斜(看 Task 处理时间分布)
- 检查 Cache 和 Broadcast 的使用量
- 用
spark.memory.debug或 JVM 工具定位具体区域 - 调整对应参数,再跑一轮压测验证
记住:内存不是越大越好。配得太大,GC 反而更频繁,任务更慢。找到那个“刚好够用”的点,才是真本事。