3. 缓存实战:RDD.cache()与RDD.persist()的正确使用姿势

说实话,我见过太多人把 cache() 和 persist() 当成银弹了。一遇到性能问题,先给 RDD 挂个 .cache(),结果内存炸了,任务反而更慢。

这两个方法,说白了就是 Spark 的「内存备忘录」。你把中间结果记下来,下次再用就不用重新算了。但怎么记、记在哪里、什么时候擦掉,这里面的门道可不少。

核心结论先放这:cache() 是 persist(StorageLevel.MEMORY_ONLY) 的简写。两者底层走的是同一套机制,区别只在于存储级别的默认值。

3.1 什么时候该用缓存?

我在项目中总结过一个「三用三不用」原则,分享给你:

✅ 三种必须用缓存的场景

  1. 同一个 RDD 被多次 Action 触发——比如你算完一个宽依赖后,既要做 count(),又要做 saveAsTextFile(),还要做 collect()。没有缓存,每次 Action 都会把整个血缘重新跑一遍。
  2. 迭代计算——机器学习算法里,同一个数据集要反复训练几十轮。我做过一个 ALS 推荐模型,没加缓存前每轮迭代 12 分钟,加上缓存后降到 2 分钟。
  3. 关键检查点——在长血缘的中间步骤做缓存,防止某个下游任务失败后整个 DAG 重算。

❌ 三种不要用缓存的场景

  1. 只被使用一次的 RDD——缓存本身有序列化和存储开销,用一次就丢,纯属浪费。
  2. 数据量远超可用内存——强行缓存会导致频繁的磁盘溢出,性能还不如重新计算。
  3. 血缘很短的简单操作——比如 map() 之后马上 filter(),重算代价极低,没必要缓存。

3.2 cache() 和 persist() 的底层差异

很多人以为 cache() 和 persist() 是两个东西。其实不是。我翻过 Spark 源码,cache() 内部就是调了 persist(StorageLevel.MEMORY_ONLY)。

真正的差异在于 存储级别。看这张表:

存储级别 是否内存 是否磁盘 是否序列化 副本数 CPU 开销
MEMORY_ONLY 是 否 否 1 低
MEMORY_ONLY_SER 是 否 是 1 中
MEMORY_AND_DISK 是 是 否 1 低
MEMORY_AND_DISK_SER 是 是 是 1 中
DISK_ONLY 否 是 是 1 高
OFF_HEAP 堆外 否 是 1 中

我的建议:如果数据能全放内存,用 MEMORY_ONLY,性能最好。如果内存不够但磁盘够,用 MEMORY_AND_DISK,至少不会 OOM。如果数据对象太大,用 MEMORY_ONLY_SER,序列化后体积能缩小 3-5 倍。

3.3 实战代码:怎么用才正确?

先看一个反面教材。我曾经接手过一个项目,代码长这样:

val rawRdd = sc.textFile("hdfs://data/events")
val filteredRdd = rawRdd.filter(_.contains("ERROR"))
filteredRdd.cache()
filteredRdd.count()
filteredRdd.saveAsTextFile("output/errors")

问题在哪?cache() 是 lazy 操作,它不会立即缓存。只有遇到 Action 才会真正触发。上面这段代码里,count() 触发了第一次计算,此时数据被缓存。但 saveAsTextFile() 复用了缓存,没问题。

真正的问题在于——没有及时清理缓存。如果这个 RDD 后面不再用了,它占着内存不放,后面的任务就会缺内存。

正确的姿势应该是:

val rawRdd = sc.textFile("hdfs://data/events")
val filteredRdd = rawRdd.filter(_.contains("ERROR"))
filteredRdd.cache()

// 第一次 Action:触发缓存
val count = filteredRdd.count()
println(s"Error count: $count")

// 第二次 Action:复用缓存
filteredRdd.saveAsTextFile("output/errors")

// 用完后立即清理
filteredRdd.unpersist()

我曾经踩过的坑:有一次在循环里反复 cache() 同一个 RDD,忘了 unpersist()。结果每个迭代都生成一份新缓存,内存里堆了十几份副本,直接导致 Executor 频繁 Full GC。记住:cache() 和 unpersist() 要成对出现。

3.4 存储级别选择的决策流程

每次选存储级别时,我都会在心里过一遍这个决策树。我把它画成了 SVG 图,方便你理解:

缓存存储级别决策流程 RDD 需要缓存? 数据量 < 可用内存? 是 否 单个对象很大? 磁盘空间可用? MEMORY_ONLY_SER MEMORY_ONLY MEMORY_AND_DISK DISK_ONLY 💡 决策要点 优先用 MEMORY_ONLY,内存不够时降级到 MEMORY_AND_DISK 序列化能省内存但增加 CPU 开销,需要权衡

3.5 缓存的最佳实践清单

最后,我把自己多年积累的缓存经验整理成了一份清单。每次写缓存代码前,我都会对照检查一遍:

  • 先估算数据量——用 rdd.count() 或采样估算,确保缓存不会撑爆内存。我习惯留出 30% 的内存余量给其他任务。
  • 缓存时机要准——在宽依赖(shuffle)之后、多个 Action 之前做缓存,收益最大。窄依赖重算成本低,没必要缓存。
  • 序列化要选对——如果用了 MEMORY_ONLY_SER,记得注册 Kryo 序列化器。默认的 Java 序列化又慢又占空间,我见过一个项目换成 Kryo 后缓存体积缩小了 4 倍。
  • 及时清理——unpersist() 是同步的,会阻塞直到缓存被清除。如果不想阻塞,可以用 unpersist(blocking = false)。
  • 监控缓存状态——在 Spark UI 的 Storage 标签页里,可以看到每个 RDD 的缓存级别、大小和命中率。我每次调优都会盯着这个页面看。

一个小技巧:如果你不确定该用哪个存储级别,先用 MEMORY_ONLY 跑一次,看 Spark UI 里有没有缓存被 evict(驱逐)。如果有,说明内存不够,换成 MEMORY_AND_DISK。如果磁盘 I/O 成了瓶颈,再考虑序列化。

嗯,缓存这块其实不难,关键是要养成「先想清楚再动手」的习惯。别像我早期那样,上来就 .cache() 一把梭,最后还得花更多时间排查问题。