外观
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. 先问三个问题
- 会被 Action 几次? 1 次 → 通常不 cache
- 重算成本多大? 轻扫描 → 不值得;重 Join/UDF → 值得
- 能否放进内存? 估行列数 × 行宽;不够则
MEMORY_AND_DISK或落 Hive/HDFS 中间表
scala
// 粗估:缓存前看计划与数据量
df.explain()
df.queryExecution.stringWithStats // 或 Spark UI SQL2. 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抽样
→ unpersist5. 何时用中间表替代 cache
| 条件 | 建议 |
|---|---|
| 数据 > 集群可用缓存容量明显 | INSERT 中间分区表 |
| 需跨作业 / 跨天复用 | 表,不是 cache |
| 要独立重跑下游 | 落盘 + 分区幂等 |
| 仅 job 内多次 Action | persist 即可 |
中间表配合 幂等写入。
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」前勾选
- Action ≥ 2?
- 上游代价是否明显高于缓存代价?
- 已投影到最小列集?
- 选了合适 StorageLevel?
- 有
unpersist或作业结束即释放? - 内存不够是否改落盘表?
常见坑(至少牢记这些)
每个临时 DF 都 cache
Storage 碎片化,执行内存被榨干,整体更慢。cache 后不再 Action
懒执行:没 Action 就没缓存;或误以为cache本身触发计算。缓存宽表含大列
JSON/二进制进内存,直接逼出 Spill/OOM。忘记 unpersist
长作业多数据集轮流占满,后期 Stage 异常慢。用 cache 代替 Shuffle 优化
该减分区倾斜、该 Broadcast 的仍要做;cache 不修复坏模型。集群扛不住却坚持 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 原创数仓/面试文,已改写成可执行清单;未包含未公开公司数据与内部表名。