1.cache & persist 算子
cache 和 persist 这两个算子的执行原理一样,cache 的底层实现仍然是 persist,persist
提供了不同的存储级别。这里特别要注意的是:
//三种使用方式等价
cache() = persist() = persist(MEMORY_ONLY)
从下面源码来看,cache() 函数的源码,其实调的就是 persist(), 而 persist() 调的则是
persist(StorageLevel.MEMORY_ONLY)。
/**
* 1.当用户调用 cache 时,其实调的就是 persist(),
*/
def cache(): this.type = persist()
/**
* 2.然而,persist() 函数其实调用的是 persist(StorageLevel.MEMORY_ONLY)
* 使用的缓存级别为 MEMORY_ONLY ,也就是内存缓存
*/
def persist(): this.type = persist(StorageLevel.MEMORY_ONLY)
2. Spark 缓存级别
关于缓存级别, Spark 官方基于以下 3 个方面进行衡量:
-
存储位置。在 Spark 的存储级别中,既可以存储在内存,也可以存储在磁盘。对于
MEMORY_ONLY
默认级别,当内存不够时,剩下的 partition 便不会进行缓存
,使用的时候需要重新计算。 -
是否序列化缓存数据。对缓存数据进行序列化,可以减少存储空间的开销,但是在反序列化的时会带来一定的延时。
- 缓存数据是否进行备份。把缓存数据复制多份存储到其它节点上,解决了单节点缓存数据失效问题,但会消耗更多的存储空间。
根据上述 3 个方面组合,Spark 一共提供了以下 12 种存储级别,你可以根据我的注释去理解。
val NONE = new StorageLevel(false, false, false, false) // 不存储
val DISK_ONLY = new StorageLevel(true, false, false, false) //只存储在磁盘,不序列化,副本为 1
val DISK_ONLY_2 = new StorageLevel(true, false, false, false, 2) //只存储在磁盘,不序列化,副本为 2
val MEMORY_ONLY = new StorageLevel(false, true, false, true) //只存储在内存,不序列化,副本为 1
val MEMORY_ONLY_2 = new StorageLevel(false, true, false, true, 2) //只存储在内存,不序列化,副本为 2
val MEMORY_ONLY_SER = new StorageLevel(false, true, false, false) //只存储在内存,序列化,副本为 1
val MEMORY_ONLY_SER_2 = new StorageLevel(false, true, false, false, 2) //只存储在内存,序列化,副本为 2
val MEMORY_AND_DISK = new StorageLevel(true, true, false, true) //内存 + 磁盘,不序列化,副本为 1
val MEMORY_AND_DISK_2 = new StorageLevel(true, true, false, true, 2) //内存 + 磁盘,不序列化,副本为 2
val MEMORY_AND_DISK_SER = new StorageLevel(true, true, false, false) //内存 + 磁盘,序列化,副本为 1
val MEMORY_AND_DISK_SER_2 = new StorageLevel(true, true, false, false, 2) //内存 + 磁盘,序列化,副本为 2
val OFF_HEAP = new StorageLevel(false, false, true, false) //存储在堆外内存
在实际生产环境中,缓存级别该如何选择?
不同的缓存级别所对应的需求也不同,我们在选择时主要考虑以下两个问题:
-
是否有足够内存、磁盘空间进行缓存?没有足够的内存、磁盘空间但又需要进行数据缓存,可以选择
MEMORY_AND_DISK
或者
MEMORY_AND_DISK_SER
级别缓存数据。 -
如果数据缓存到磁盘上,那么读取缓存数据的时间是否大于重新计算出该数据的时间。如果是,可以不缓存或者分配更大的内存来进行缓存。
3.用户调用 cache 后,系统是怎么对 RDD 进行 cache 的?
关于它的实现原理,我们只针对 persist(StorageLevel.MEMORY_ONLY)
这一种情况进行分析。假设用户在业务程序中调用了 cache() ,它底层实际会调用下面这个函数:
private def persist(newLevel: StorageLevel, allowOverride: Boolean): this.type = {
// 处理存储级别变化的情况
if (storageLevel != StorageLevel.NONE && newLevel != storageLevel && !allowOverride) {
throw new UnsupportedOperationException(
"Cannot change storage level of an RDD after it was already assigned a level")
}
//如果当前 RDD 是第一次被持久化,需要在 SparkContext 中注册资源清理函数,这只执行一次
if (storageLevel == StorageLevel.NONE) {
sc.cleaner.foreach(_.registerRDDForCleanup(this))
//把要缓存的 RDD 存到一个 Map(id,RDD) 的数据结构中
sc.persistRDD(this)
}
//为 RDD 指定缓存级别
storageLevel = newLevel
this
}
从上述源码来看,实际上用户在使用 cache()
算子进行缓存时,此时只是把分区数据,打上了一个存储级别标记 (每一个 RDD 都有一个 storageLevel 变量,初始默认为 NONE)
而并没有真正立马执行 RDD 缓存,这个算子是一个懒加载执行,只有当 RDD 真正被计算时,RDD 才会被缓存。一旦存储级别被指定了之后,在相同的 SparkContext 下就不能修改。
4.那会在什么时候真正执行缓存这个动作呢?
当用户程序调用 Action 算子触发计算,task 便会在计算 Partition 时,判断该 Partition 是否是需要 cache,如果需要被缓存,则先把 Partition 结果计算出来,计算完后立马缓存到内存。
在 RDD 的抽象类中,提供了一个迭代器函数 iterator()
,通过这个迭代器函数便可以访问到 RDD 中的分区数据,也就是从这里开始进行计算数据。
final def iterator(split: Partition, context: TaskContext): Iterator[T] = {
//遍历数据时,先判断 RDD 的存储级别是否为 NONE,如果用户在某一个 RDD 执行了 cache 或者
//persist,此时 RDD 中 storagelevel 已经被修改,所以会从缓存中获取,获取不到则重新计算
if (storageLevel != StorageLevel.NONE) {
getOrCompute(split, context)
} else {
//如果还是默认的存储级别 NONE,迭代要么从 checkpoint 的目录中读取,要么重新计算
computeOrReadCheckpoint(split, context)
}
}
在 iterator() 源码中,先判断当前分区数据的存储级别,如果用户之前调用了 cache() 算子,此时分区数据的存储级别应该不为 NONE 这个级别。因此,便会调用 getOrCompute() 函数。
getOrCompute 函数
private[spark] def getOrCompute(partition: Partition, context: TaskContext): Iterator[T] = {
//先获取 RDD 的 block id
val blockId = RDDBlockId(id, partition.index)
var readCachedBlock = true
//先根据 blockId 从 blockManager 查看是否已经被缓存了
SparkEnv.get.blockManager.getOrElseUpdate(blockId, storageLevel, elementClassTag, () => {
readCachedBlock = false
computeOrReadCheckpoint(partition, context)
}) match {
.....
}
}
当 RDD.iterator() 被调用时, 也就是要计算该 RDD 中某个 partition 的时候。首先,会生成一个 blockId, 表明是要存哪个 RDD 的哪个 partition。
注意:这个 blockId 类型是 RDDBlockId,它是由 rddid + partitionId 组成 。
之后,会把计算出来的 partition 数据放到 BlockManager 中的 MemoryStore 中,MemoryStorye 维护了一个 LinkedHashMap[blockId,memoryEntry]
key 是 blockId,value 是当前缓存的数据。
因此,缓存的分区数据最后会存放在 LinkedHashMap 数据结构中,LinkedHashMap 是基于双向链表实现的。