Skip to content

cache/persist 何时用、何时害 ​

难度与前置

难度:进阶
前置:跑过多次 Action 的 Spark 作业;会看 Storage / Executors 页。
读完能做什么:判断何时 cache/persist,选存储级别,避免把执行内存挤爆导致 Spill/OOM,并用命中率与耗时验收。

适用场景 ​

  • 同一 DataFrame 被多个 Action 重复使用(训练前特征、多路写出、复杂分支)
  • 迭代算法(图计算、ML)反复扫同一份数据
  • 大代价上游(重 Join / 重解析)结果要被下游多次消费

不适用:

  • 血统很短、只 Action 一次的 ETL(cache 纯浪费)
  • 数据极大,集群内存明显不够(应落盘中间表)
  • 为「感觉加速」在每个临时 DF 上盲 cache

真实场景(一条) ​

特征作业:清洗后的 df_feat 被 6 个模型分支 count/write。未 persist 时每个分支重算上游 Join,总 3.5h。persist(MEMORY_AND_DISK) 后第一次 Action 建缓存,后续分支命中,总 70min。后来有人 cache 了含大 JSON 的原始 DF,Storage 占满,执行内存被挤,Shuffle Spill 暴涨——改为只缓存投影后的窄表,恢复稳定。

怎么做(步骤清单) ​

1. 先问三个问题 ​

  1. 会被 Action 几次? 1 次 → 通常不 cache
  2. 重算成本多大? 轻扫描 → 不值得;重 Join/UDF → 值得
  3. 能否放进内存? 估行列数 × 行宽;不够则 MEMORY_AND_DISK 或落 Hive/HDFS 中间表
scala
// 粗估:缓存前看计划与数据量
df.explain()
df.queryExecution.stringWithStats // 或 Spark UI SQL

2. cache vs persist ​

API含义
cache()等价 persist(MEMORY_AND_DISK)(现代 Spark)或 MEMORY_ONLY(视版本,以文档为准)
persist(level)显式存储级别
unpersist()释放;长时间作业必须成对

习惯:生产代码写 persist(StorageLevel.xxx) + unpersist(blocking=true),并注释「为何缓存」。

3. 存储级别怎么选 ​

级别适用风险
MEMORY_ONLY小、反复用、绝对够内存不够则部分分区重算
MEMORY_AND_DISK默认稳妥磁盘慢,但仍优于全重算
MEMORY_ONLY_SER省内存(旧 RDD)CPU 反序列化
DISK_ONLY内存极紧接近中间文件
OFF_HEAP(若启用)堆外配置复杂

DataFrame 优先让 Catalyst/ Tungsten 管理;不要混用过时经验「一律 MEMORY_ONLY」。

scala
import org.apache.spark.storage.StorageLevel

val feat = spark.table("dwd_feat_di")
  .where($"dt" === bizDate)
  .select("user_id", "f1", "f2", "f3") // 先投影再缓存
  .persist(StorageLevel.MEMORY_AND_DISK)

feat.write.mode("overwrite").parquet(pathA) // 触发缓存
feat.write.mode("overwrite").parquet(pathB) // 命中
feat.unpersist(blocking = true)

4. 正确插入点(DAG 位置) ​

  • 在重计算之后、多分支之前 persist
  • 先 filter/project 再 persist,切忌缓存宽原始表
  • 多路径共用:在分叉点缓存一次
  • 不要在循环里对不断变大的 DF 反复 cache 不释放
text
读源 → 重 Join/解析 → [persist] → 分支A写出
                              └→ 分支B聚合
                              └→ 分支C抽样
                         → unpersist

5. 何时用中间表替代 cache ​

条件建议
数据 > 集群可用缓存容量明显INSERT 中间分区表
需跨作业 / 跨天复用表,不是 cache
要独立重跑下游落盘 + 分区幂等
仅 job 内多次 Actionpersist 即可

中间表配合 幂等写入。

6. 与内存、Spill、OOM 的关系 ​

  • Storage 占用 ↑ → Execution 内存 ↓ → Shuffle Spill ↑ 甚至 OOM
  • 缓存大宽表是 Spill / OOM 常见根因之一
  • 观察 UI:Storage 页命中率;Executors 的磁盘/内存
bash
# 作业完成后核对(示意)
# Storage: Size in Memory / Fraction Cached
# 目标:计划缓存的分区 Fully Cached;命中后 Stage 不再重跑上游

7. 失效与正确性 ​

  • 源数据变了但 DF 血统仍指向旧缓存 → 结果错(少见但危险:跨 Action 间外部表被覆盖)
  • Checkpoint 裁剪血统 ≠ cache;别混用概念
  • 动态分区写入后不要假设「同名 DF 仍是旧快照」

8. 清单:决定「cache」前勾选 ​

  1. Action ≥ 2?
  2. 上游代价是否明显高于缓存代价?
  3. 已投影到最小列集?
  4. 选了合适 StorageLevel?
  5. 有 unpersist 或作业结束即释放?
  6. 内存不够是否改落盘表?

常见坑(至少牢记这些) ​

  1. 每个临时 DF 都 cache
    Storage 碎片化,执行内存被榨干,整体更慢。

  2. cache 后不再 Action
    懒执行:没 Action 就没缓存;或误以为 cache 本身触发计算。

  3. 缓存宽表含大列
    JSON/二进制进内存,直接逼出 Spill/OOM。

  4. 忘记 unpersist
    长作业多数据集轮流占满,后期 Stage 异常慢。

  5. 用 cache 代替 Shuffle 优化
    该减分区倾斜、该 Broadcast 的仍要做;cache 不修复坏模型。

  6. 集群扛不住却坚持 MEMORY_ONLY
    分区丢失触发灾难性重算;改 MEMORY_AND_DISK 或中间表。

如何验收(可量化) ​

  • [ ] 多分支作业总耗时下降;上游重 Stage 只出现一次
  • [ ] Storage:目标 DF Fraction Cached ≈ 100%(或预期比例)
  • [ ] Spill / GC 不因 cache 恶化(对比改前)
  • [ ] unpersist 后 Storage 释放
  • [ ] 结果与未缓存版本抽样一致
scala
// 命中验证思路:看 UI 中第二个 Action 的 Stage 是否跳过上游 Shuffle
feat.count()
feat.count() // 第二次应显著更快
sql
-- 若改落盘中间表,对账
SELECT COUNT(*), SUM(f1), COUNT(DISTINCT user_id)
FROM mid_feat_di WHERE dt = '${biz_date}';
bash
echo "job,mode,duration_min,storage_gb,spill_gb"
# feature_train,no_cache,210,0,120
# feature_train,persist_narrow,70,18,15

相关面试题 ​

占位:与题库小程序对齐后补链接(建议关联:Spark 内存模型、cache vs checkpoint、Spill、DAG 优化)。


改写说明:结构与判断标准参考 Wood 原创数仓/面试文,已改写成可执行清单;未包含未公开公司数据与内部表名。