4. 执行内存与存储内存:Execution Memory 与 Storage Memory 的职责、动态占用与抢占机制
聊到 Spark 内存管理,有个绕不开的话题:执行内存和存储内存到底怎么分工?它们之间又是怎么抢资源的?
我刚开始调优的时候,也踩过这个坑。当时有个 Shuffle 特别重的任务,我给了很大的存储内存来缓存中间结果,结果 Executor 频繁 Full GC,任务跑得比蜗牛还慢。后来才发现,是执行内存被存储内存挤占了,Shuffle 写数据时拿不到足够的内存,只能疯狂溢写磁盘。
说白了,这两块内存的职责和交互机制,是理解 Spark 内存调优的基石。你想想看,如果连内存怎么分、怎么抢都不清楚,调优就是瞎调。
4.1 两块内存的职责划分
在 Spark 1.6+ 的统一内存管理模型下,堆内内存被划分为三块:Reserved Memory、User Memory,以及我们重点关注的 Spark Memory。而 Spark Memory 又进一步拆分为 Execution Memory 和 Storage Memory。
它们的职责非常清晰:
- Execution Memory(执行内存):用于 Shuffle、Join、Sort、Aggregation 等计算过程中的数据缓冲与中间结果存储。说白了,就是计算引擎的“工作台”。
- Storage Memory(存储内存):用于缓存 RDD 分区、Broadcast 变量、以及 Unroll 数据。它是 Spark 的“缓存仓库”。
我个人的习惯是,把 Execution Memory 想象成 CPU 的 L1/L2 缓存——它要快,用完就释放;而 Storage Memory 更像主存——它可以持久化,但访问速度相对慢一些。
| 维度 | Execution Memory | Storage Memory |
|---|---|---|
| 主要用途 | Shuffle、Join、Sort、Aggregation 的中间缓冲 | RDD 缓存、Broadcast 变量、Unroll 数据 |
| 生命周期 | 任务级,任务结束后立即释放 | 应用级,可跨 Stage 保留,直到被驱逐或清除 |
| 溢出行为 | 溢出到磁盘(Spill) | 溢出到磁盘(Block 被驱逐) |
| 抢占优先级 | 高(可以抢占 Storage Memory) | 低(可以被 Execution 抢占) |
核心要点:Execution Memory 是“计算优先”的,它可以在需要时从 Storage Memory 那里“借”内存。但 Storage Memory 不能反过来抢占 Execution Memory。这个设计是有意为之的——计算任务如果因为内存不足而溢写磁盘,性能损失远大于缓存被驱逐。
4.2 动态占用机制:软边界与硬边界
统一内存管理引入了一个关键概念:动态占用。它不再像 1.6 之前那样给 Execution 和 Storage 划死边界,而是允许它们互相借用。
具体来说,Spark 通过两个参数控制初始划分:
spark.memory.fraction:Spark Memory 占 (JVM Heap - Reserved Memory) 的比例,默认 0.6。spark.memory.storageFraction:Storage Memory 占 Spark Memory 的比例,默认 0.5。
但这两个参数只是初始软边界。实际运行时,Execution 和 Storage 可以突破自己的边界,去占用对方空闲的内存。
举个例子:
// 假设 JVM Heap = 4GB,Reserved Memory = 300MB
// Spark Memory = (4GB - 300MB) * 0.6 ≈ 2.22GB
// Storage 初始 = 2.22GB * 0.5 ≈ 1.11GB
// Execution 初始 = 2.22GB * 0.5 ≈ 1.11GB
// 如果 Execution 需要 1.5GB,而 Storage 只用了 0.5GB
// Execution 可以借用 Storage 空闲的 0.61GB,实际用到 1.72GB
// 但如果 Storage 突然需要更多内存,它只能从 Execution 那里“讨回”被借走的部分
// 注意:Storage 不能强制抢占,只能等 Execution 主动释放
嗯,这里要注意:Storage 不能强制抢占 Execution 的内存。这是设计上的一个不对称策略。为什么?因为 Execution 内存里的数据往往是计算中间状态,比如 Shuffle 的 Map 输出缓冲区,如果被强制回收,会导致计算失败或数据损坏。而 Storage 缓存的数据是冗余的——丢了可以从源头重新计算。
4.3 抢占机制:谁抢谁?怎么抢?
抢占机制是动态占用的核心。我把它总结为三个原则:
- Execution 可以抢占 Storage:当 Execution 需要更多内存时,它可以向 Storage Memory 发出“借内存”请求。Storage Memory 会尝试驱逐(Evict)自己的缓存 Block,腾出空间给 Execution。
- Storage 不能抢占 Execution:Storage 只能“讨回”被 Execution 借走的内存,而且必须等 Execution 主动释放。如果 Execution 一直占用,Storage 只能等待或自己溢写。
- 抢占以 Block 为单位:Storage Memory 在驱逐 Block 时,会按照 LRU(最近最少使用)策略选择要驱逐的 Block。被驱逐的 Block 如果标记了存储级别包含磁盘(如 MEMORY_AND_DISK),会溢写到磁盘;否则直接丢弃。
实战技巧:我曾经在一个实时数仓项目中,发现某些 RDD 缓存频繁被驱逐,导致后续查询变慢。排查后发现是 Shuffle 任务太重,Execution 不断抢占 Storage。解决方案是调大 spark.memory.storageFraction 到 0.6,给缓存留更多“保底”空间。同时把不常用的 RDD 缓存级别改为 MEMORY_AND_DISK_SER,即使被驱逐也能从磁盘恢复。
4.4 核心流程图:内存动态占用与抢占
下面这张图展示了 Execution 和 Storage 之间的动态占用与抢占流程。我建议你仔细看看,尤其是“Storage 讨回”那条虚线路径——它只能等,不能抢。
4.5 实战中的避坑指南
了解了机制,咱们来看看实际项目中容易踩的坑。
避坑 1:Storage 内存设置过大导致 Shuffle 频繁溢写
我曾经接手过一个 Spark Streaming 任务,spark.memory.storageFraction 被设成了 0.8。结果 Batch 处理时,Shuffle 的 Map 输出缓冲区经常拿不到足够内存,大量溢写磁盘,任务延迟从 10 秒飙到 2 分钟。
解决方案:把 spark.memory.storageFraction 调回 0.5,同时给 RDD 缓存加上 MEMORY_AND_DISK_SER,让缓存可以优雅地溢写,而不是死占内存。
避坑 2:Broadcast 变量过大导致 Execution 内存不足
另一个项目里,有人把一张 2GB 的维度表作为 Broadcast 变量广播出去。Storage Memory 一下子被占满,后续的 Join 操作 Execution 内存不够,频繁 Spill。更糟的是,Broadcast 变量是 Storage 内存里的“硬占用”,Execution 抢不走——因为 Broadcast 变量在反序列化时会被 Pin 住。
解决方案:把 Broadcast 阈值调小,或者改用 spark.sql.autoBroadcastJoinThreshold 控制,超过阈值自动走 SortMergeJoin。
调优建议:我个人习惯在监控面板上同时关注两个指标:Shuffle spill (memory) 和 BlockManager memory 的使用率。如果 spill 很高但 Storage 使用率很低,说明 Execution 内存不足,可以适当调大 spark.memory.fraction。反过来,如果 Storage 使用率很高且频繁驱逐,说明缓存压力大,可以调大 spark.memory.storageFraction 或改用磁盘辅助的存储级别。
4.6 小结
Execution Memory 和 Storage Memory 的职责划分、动态占用与抢占机制,是 Spark 内存管理的核心。记住几个关键点:
- Execution 是“计算优先”,可以抢占 Storage;Storage 只能“讨回”,不能强制抢占。
- 动态占用通过软边界实现,初始划分由
spark.memory.storageFraction控制。 - 抢占以 Block 为单位,Storage 按 LRU 策略驱逐缓存。
- 调优时要根据任务类型(计算密集型 vs 缓存密集型)调整
spark.memory.fraction和spark.memory.storageFraction。
嗯,这块内容确实有点绕,但理解了它,后面讲 Shuffle 内存调优、堆外内存管理、以及 OOM 排查时,你就能快速定位问题了。