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 抢占机制:谁抢谁?怎么抢?

抢占机制是动态占用的核心。我把它总结为三个原则:

  1. Execution 可以抢占 Storage:当 Execution 需要更多内存时,它可以向 Storage Memory 发出“借内存”请求。Storage Memory 会尝试驱逐(Evict)自己的缓存 Block,腾出空间给 Execution。
  2. Storage 不能抢占 Execution:Storage 只能“讨回”被 Execution 借走的内存,而且必须等 Execution 主动释放。如果 Execution 一直占用,Storage 只能等待或自己溢写。
  3. 抢占以 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 讨回”那条虚线路径——它只能等,不能抢。

Spark 执行内存与存储内存动态占用与抢占流程图 Execution Memory 与 Storage Memory 动态占用与抢占流程 初始划分:spark.memory.storageFraction = 0.5 Storage Memory(存储内存) RDD 缓存 / Broadcast / Unroll Execution Memory(执行内存) Shuffle / Join / Sort / Aggregation Execution 需要更多内存 请求借用 Storage 检查空闲内存 有空闲 → 直接借出 直接借出空闲内存给 Execution 驱逐 Block(LRU 策略)腾出空间 Execution 获得额外内存,继续计算 Storage 只能“讨回”,不能强制抢占 说明:Execution 可以主动抢占 Storage(实线路径),但 Storage 只能等待 Execution 释放(虚线路径)。 被驱逐的 Block 如果存储级别包含磁盘,会溢写到磁盘;否则直接丢弃。

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 排查时,你就能快速定位问题了。