Spark Core 中的 RDD 持久化 (persist()
/ cache()
) 及其存储级别是优化 Spark 作业性能的关键。让我们深入探讨一下:
核心概念:为什么需要持久化?
- 惰性执行: Spark 的 RDD 操作(Transformations)是惰性的。只有当你调用一个 Action(如
count()
,collect()
,saveAsTextFile()
)时,Spark 才会真正开始计算。 - 血统(Lineage): RDD 记录了它是如何从其他 RDD 或稳定存储(如 HDFS)计算得来的。这提供了容错性:如果一个 RDD 的分区丢失,Spark 可以利用血统重新计算它。
- 重复计算的代价: 问题在于,如果你在同一个 RDD 上调用多个 Action,或者在一个 Action 中多次使用同一个 RDD,Spark 每次都会从头开始重新计算整个血统链! 对于迭代算法(如机器学习训练)或交互式查询,这种重复计算会带来巨大的性能开销。
持久化 (persist()
/ cache()
) 的作用:
持久化就是告诉 Spark:“把这个 RDD 的计算结果保存下来(在内存、磁盘或两者中),后续的操作如果需要用到这个 RDD,直接从这里读取,不要再重新计算了。”
cache()
: 这是persist()
的一个便捷方法。它等价于persist(StorageLevel.MEMORY_ONLY)
。也就是说,默认只尝试将数据存储在内存中。persist()
: 这个方法更通用。你可以传递一个StorageLevel
参数来指定数据应该如何存储(仅内存?内存+磁盘?序列化?复制?)。
存储级别 (StorageLevel
):
StorageLevel
定义了 RDD 分区数据在集群中的存储方式。它由以下几个标志组合而成:
useMemory
: 是否将数据存储在 JVM 堆内存中。useDisk
: 是否将数据存储在磁盘上(通常是执行器节点的本地磁盘)。useOffHeap
: (较新版本) 是否使用堆外内存(如 Tachyon/Alluxio)。deserialized
: 数据是否以反序列化的 Java 对象形式存储(更快但占用更多内存)。如果为false
,则存储序列化的字节数组(内存占用小,但使用时需要反序列化,消耗 CPU)。replication
: 数据副本的数量(默认为 1)。增加副本数可以提高容错性并在节点故障时减少重新计算,但会占用更多存储空间。
Spark 提供的主要存储级别:
-
MEMORY_ONLY
(默认的cache()
级别):- 描述: 将 RDD 以反序列化的 Java 对象形式存储在 JVM 堆内存中。如果内存放不下整个 RDD,某些分区将不会被缓存,并在需要时根据血统重新计算。
- 优点: 性能最佳(CPU 开销最小),因为数据是反序列化对象,可直接使用。
- 缺点: 内存占用最大(对象开销)。如果内存不足,部分数据会丢失,导致后续需要重新计算,可能影响性能。
- 何时使用:
- 你有充足的内存容纳整个 RDD 或大部分经常访问的分区。
- RDD 的计算成本非常高(重新计算代价大)。
- 你对作业的延迟要求非常严格。
- RDD 会被频繁访问多次。
-
MEMORY_ONLY_SER
(Java 中是MEMORY_ONLY_SER()
, Scala 中是StorageLevel.MEMORY_ONLY_SER
):- 描述: 将 RDD 以序列化的字节数组形式存储在 JVM 堆内存中。序列化格式更紧凑,通常比
MEMORY_ONLY
节省 2-5 倍内存。如果内存放不下,某些分区将不会被缓存。 - 优点: 比
MEMORY_ONLY
节省大量内存,允许缓存更大的数据集。 - 缺点: 读取数据时需要反序列化,增加了 CPU 开销。数据访问速度略低于
MEMORY_ONLY
。 - 何时使用:
- 你想缓存一个较大的 RDD,但使用
MEMORY_ONLY
内存不足。 - RDD 的计算成本较高。
- 你愿意牺牲一些 CPU 时间来换取更大的内存缓存能力。
- 数据序列化效率较高(如使用 Kryo 序列化)。
- 你想缓存一个较大的 RDD,但使用
- 描述: 将 RDD 以序列化的字节数组形式存储在 JVM 堆内存中。序列化格式更紧凑,通常比
-
MEMORY_AND_DISK
:- 描述: 将 RDD 以反序列化的 Java 对象形式存储在 JVM 堆内存中。如果内存放不下某个分区,会将该分区溢写(spill)到磁盘。需要时,优先从内存读取;内存中没有,再从磁盘读取。
- 优点: 在内存有限的情况下,尽可能利用快速的内存,避免完全重新计算放不下的分区。
- 缺点: 磁盘访问速度远慢于内存。内存部分占用大(反序列化对象)。
- 何时使用:
- RDD 太大无法完全放入内存,但重新计算某些分区的代价又很高。
- 你需要平衡内存和磁盘的使用。
- 预计部分数据会被频繁访问(这部分应尽量留在内存),部分数据访问较少(可以接受从较慢的磁盘读取)。
-
MEMORY_AND_DISK_SER
(Java:MEMORY_AND_DISK_SER()
, Scala:StorageLevel.MEMORY_AND_DISK_SER
):- 描述: 将 RDD 以序列化的字节数组形式存储。优先放在 JVM 堆内存中,内存不足时溢写到磁盘。读取时,内存中的数据需要反序列化,磁盘上的数据读取后也需要反序列化。
- 优点: 内存部分比
MEMORY_AND_DISK
更节省空间,允许在内存中缓存更多数据。是内存和磁盘使用的一种良好折衷。 - 缺点: 访问数据始终有反序列化的 CPU 开销。磁盘访问慢。
- 何时使用: 最常用、最通用的级别之一,尤其当 RDD 较大且内存有限时。
- RDD 太大无法完全放入内存。
- 使用
MEMORY_ONLY_SER
内存仍然不够。 - 你希望最小化重新计算,并愿意接受磁盘访问和反序列化的开销。
-
DISK_ONLY
:- 描述: 只将 RDD 分区存储在磁盘上。
- 优点: 内存占用最小。
- 缺点: 性能最差,因为完全依赖磁盘 I/O。
- 何时使用:
- RDD 极其庞大,内存和磁盘序列化组合 (
MEMORY_AND_DISK_SER
) 也无法有效缓存。 - 重新计算 RDD 的代价相对较低(例如,直接从高效的数据源读取)。
- 对性能要求不高,但需要避免重复从源头加载数据。
- RDD 极其庞大,内存和磁盘序列化组合 (
-
其他级别 (较少用):
OFF_HEAP
(类似MEMORY_ONLY_SER
但使用堆外内存):避免 GC 开销,但管理更复杂。MEMORY_ONLY_2
,MEMORY_AND_DISK_2
, etc.:带复制因子 2 的级别,提供更高的容错性,但存储开销翻倍。
如何选择存储级别?决策树
- RDD 能完全放进内存吗?
- 能 ->
MEMORY_ONLY
(性能最优)。如果内存占用过大,尝试MEMORY_ONLY_SER
。
- 能 ->
- 不能完全放进内存?
- 重新计算 RDD 的代价是否比磁盘 I/O 高很多?
- 是 ->
MEMORY_AND_DISK_SER
(优先选择,内存更高效) 或MEMORY_AND_DISK
(如果反序列化开销过大或对象不易序列化)。 - 否 ->
DISK_ONLY
(如果重新计算代价很低) 或干脆不持久化(让 Spark 按需重新计算可能更快)。
- 是 ->
- 重新计算 RDD 的代价是否比磁盘 I/O 高很多?
最佳实践与注意事项:
- 仅在必要时持久化: 不要盲目
cache()
所有 RDD。评估 RDD 是否会被多次使用(多个 Action 或在一个 Action 中被多次引用)。对于只使用一次的 RDD,持久化只会浪费资源。 - 优先尝试
MEMORY_ONLY
: 如果内存足够,这是最快的方式。 - 内存不足时首选
MEMORY_ONLY_SER
或MEMORY_AND_DISK_SER
: 序列化能显著节省内存,通常是内存受限时的最佳选择。使用高效的序列化库(如 Kryo)能减少 CPU 开销。 - 监控 Spark UI: Spark UI 是查看缓存效果的神器!
- Storage Tab: 查看哪些 RDD/DataFrame 被缓存了、用了什么存储级别、在内存/磁盘中占用了多少空间、缓存了多少分区。
- Stages Tab: 观察作业的执行时间。如果持久化有效,后续依赖该 RDD 的 Stage 的 “Skip” 或读取时间会明显减少。如果看到同一个 RDD 被重复计算(相同的 Stage 多次执行),说明你可能忘记对它进行持久化了。
- 及时释放 (
unpersist()
): 当确定一个持久化的 RDD 不再需要时,调用rdd.unpersist()
显式地释放它占用的内存/磁盘空间。Spark 也会根据 LRU(最近最少使用)策略自动清理旧的持久化数据,但主动管理更好。 - 理解序列化: 使用
_SER
级别时,CPU 开销会增加。权衡内存节省和 CPU 开销。Kryo 通常比 Java 默认序列化更快更紧凑。 cache()
是persist(StorageLevel.MEMORY_ONLY)
的简写: 记住这点,避免混淆。- 容错性: 持久化的数据如果丢失(如节点故障),Spark 依然会利用 RDD 的血统(lineage)进行重新计算。带副本的存储级别(如
_2
) 可以减少重新计算的概率。 - Checkpointing 与 Persist 的区别: 持久化 (
persist/cache
) 主要为了性能优化,数据存储在 Executor 的内存/磁盘上,生命周期通常与 SparkContext/作业相关。Checkpointing 将 RDD 物理写入可靠的分布式文件系统(如 HDFS),切断血统,用于:- 非常长的血统链(减少容错恢复时间)。
- 需要完全切断依赖关系(如迭代算法中)。
- 需要跨作业/应用共享数据。Checkpointing 通常比持久化到磁盘更可靠但开销更大。可以结合使用:先
persist(MEMORY_AND_DISK_SER)
再checkpoint()
。
总结:
RDD 持久化 (persist()
/cache()
) 是 Spark 性能优化的基石,通过避免昂贵的重复计算来加速作业。理解不同 StorageLevel
(MEMORY_ONLY
, MEMORY_ONLY_SER
, MEMORY_AND_DISK
, MEMORY_AND_DISK_SER
, DISK_ONLY
)的特性和适用场景至关重要。选择策略的核心是权衡 内存使用、CPU 开销(序列化/反序列化)、磁盘 I/O 和重新计算的成本。始终通过 Spark UI 监控缓存效果,并在数据不再需要时使用 unpersist()
进行清理。