本文介绍两类RDD的checkpoint的作用和原理,并对checkpointing的实现进行分析。
RDD Checkpoint的基本概念
RDD可能经过多个转换(transformations)操作,导致RDD的血缘不断增长。Spark提供了一种将RDD进行稳定持久化存储的方法,当集群发生故障时,Spark不需要从头开始计算RDD的分区。这个特征被称为checkpointing。
checkpointing是一个截断RDD血缘,并把RDD持久化到外部可靠的文件系统(例如:HDFS,S3)或本地文件系统的过程。
由于checkpointing会把RDD的数据写到Spark外部,因此当Spark应用结束时,checkpointing的数据会继续存在。另外,checkpointing将会占用更多的外部存储空间,它的写操作将会消耗系统资源,所以它会比persisting(持久化)操作要慢。但是,checkpointing不会使用任何Spark内存,而且若Spark worker失败,也不会进行重新计算。
Checkpointing的分类
有两类Checkpointing:
- reliable checkpointing(可靠的checkpointing)
这类checkpointing将RDD数据写入到可靠的存储系统中,比如:HDFS,S3等。它允许Spark driver(驱动程序)在当前计算的状态下失败时重新启动。
- local checkpointing(本地checkpointing)
local checkpointing在性能和容错性之间进行权衡,它会跳过把数据保存到可靠且容错性好的存储系统的步骤。把数据写入每个Executor的本地临时存储。这对于RDD构建需要经常截断的长血缘(long lineage)(例如GraphX或spark streaming等)的应用非常有用。
checkpoint和cache/persist的比较
- checkpoint把数据保存到可靠的外部存储系统,而persist/cache使用spark集群的存储资源。
- persist/cache可以指定存储级别,可以把数据保存到内存或磁盘,而checkpoint只能持久化保存。
- persist/cache的性能更快,而checkpoint性能较低。
- persist/cache的数据可能丢失,而checkpoint的数据保存在可靠的外部存储系统,一般不会丢失。
- persist/cache会保存RDD的血缘,而checkpoint会截断RDD的血缘。
- 当Spark应用终止时persist/cache会删除缓存的RDD数据,而checkpoint的RDD数据却不会被删除,这样checkpoint的数据可以在不同Spark应用之间使用。
checkpointing的使用
为了更好的理解checkpoint的实现,我们先看一下使用checkpoint的过程:
- 设置checkpoint存储路径,并通过RDD的checkpoint操作来保存RDD数据
sc.setCheckpointDir("hdfs://hadoop3:7078/data/checkpoint")
val rdd1 = sc.parallelize(Array((1,2),(3,4),(5,6),(7,8)),3)
rdd1.checkpoint()
//注意:要action才能触发checkpoint
rdd1.take(1)
2. 使用checkpoint保存的数据
需要使用checkpoint的保存的数据时,可以通过SparkContext#checkpointFile函数来读取。
- 把下面的程序打成jar包,比如叫:dospark.jar
package org.apache.spark
import org.apache.spark.rdd.RDD
object RDDUtilsInSpark {
def getCheckpointRDD[T](sc:SparkContext, path:String) = {
//path要到part-000000的父目录
val result : RDD[Any] = sc.checkpointFile(path)
result.asInstanceOf[T]
}
}
- 使用工具类读取checkpoint的RDD
import org.apache.spark.rdd.RDD
import org.apache.spark.RDDUtilsInSpark
val checkpointPath="hdfs://hadoop3:7078/data/checkpoint/7d020f7b-9122-4247-ba2d-80b8277a1022/rdd-0"
val rdd : RDD[(Int, Int)]= RDDUtilsInSpark.getCheckpointRDD(sc, checkpointPath)
println(rdd.count())
rdd.collect().foreach(println)
从上面的例子中可以看到,spark中的checkpoint的使用涉及到以下几个函数,下面通过分析这几个函数的实现过程来介绍spark的checkpoint的实现原理。
checkpointing的实现
设置checkpoint的存储路径
在使用checkpoint时首先要设置一个存储的路径。路径的设置是通过SparkContext#setCheckpointDir函数来实现的。该函数的实现要点:
- 若spark是以集群模式运行,给定的checkpoint目录必须是HDFS目录
- RDD的数据并非保存在参数指定的目录下,而是会在参数指定的目录后添加一个UUID的子目录
- 若指定的目录不存在,则会创建该目录
该函数的实现代码如下:
def setCheckpointDir(directory: String) {
// 若不是本地模式,而指定了本地路径,则会记录一个警告日志
if (!isLocal && Utils.nonLocalPaths(directory).isEmpty) {
...
}
// 若是集群模式,目录必须是hdfs目录
checkpointDir = Option(directory).map { dir =>
// 会在给定目录后面添加一个UUID的字符串的子目录
val path = new Path(dir, UUID.randomUUID().toString)
val fs = path.getFileSystem(hadoopConfiguration)
// 若目录不存在,则创建该目录
fs.mkdirs(path)
fs.getFileStatus(path).getPath.toString
}
}
checkpoint()函数的实现
在RDD的抽象类中定义了checkpoint()函数,该函数的实现要点有:
- 检查是否已经设置了checkpoint目录,若没有设置则抛出异常
- 若该RDD已经被checkpoint过了,则什么都不做
- 返回一个ReliableRDDCheckpointData对象,并赋给RDD的checkpointData变量,该对象保存了需要进行checkpoint的所有信息。下一次再执行checkpoint()时,会根据ReliableRDDCheckpointData对象的值来判断该RDD是否已经被checkpoint了。
该函数的实现代码如下:
def checkpoint(): Unit = RDDCheckpointData.synchronized {
if (context.checkpointDir.isEmpty) { //若没有设置checkpoint目录,不能进行checkpoint
throw new SparkException("Checkpoint directory has not been set in the SparkContext")
} else if (checkpointData.isEmpty) { //若该RDD已经checkpoint了,就什么也不做
// 返回ReliableRDDCheckpointData对象
checkpointData = Some(new ReliableRDDCheckpointData(this))
}
}
也就是说,checkpoint()函数只是返回了一个ReliableRDDCheckpointData对象,并设置了变量checkpointData。该函数并没有进行实际的RDD存储的动作,那么什么时候执行checkpoint动作,如何执行呢?下面继续分析。
RDD数据的存储:doCheckpoint()
从上面的例子中可以看出,执行rdd.checkpoint()这一句,不能触发RDD的存储的动作,而必须要加上一个action操作。那么,这是为什么呢?
我们来看一下action的执行过程,每个action操作都会调用runJob来触发任务的执行。注意到,在runJob函数中调用了rdd.doCheckpoint(),部分代码如下:
def runJob[T, U: ClassTag](
rdd: RDD[T],
func: (TaskContext, Iterator[T]) => U,
partitions: Seq[Int],
resultHandler: (Int, U) => Unit): Unit = {
...
rdd.doCheckpoint()
}
可以看到,当调用RDD的action操作时,会触发doCheckpoint动作。我们来看一下doCheckpoint的具体执行过程:
1)doCheckpoint会检查是否已经设置了checkpoint的目录,若没有设置则什么都不做。若设置了checkpoint目录,则会根据checpointing的类型(ReliableCheckpointRDD或LocalCheckpointRDD)来调用完成数据的保存。
2)若是ReliableCheckpointRDD,则会把数据写到外部存储系统中。若是LocalCheckpointRDD,则会检查是否有缺失的分区,若有,则会提交Job来计算该分区的数据,此时数据被保存在Executor端的本地存储系统中。
3)接下来清除血缘,这个清除的操作其实就是删除掉依赖的RDD列表,从实现层面来说,其实就是把RDD的dependencies_变量置成null值。
checkpointing的实现小结
从以上分析可以得出以下几点结论:
- 在进行可靠checkpoint操作时,首先要设置checkpoint的目录。
- 通过RDD调用checkpoint()函数时,实际上并没有开始进行RDD的存储操作,而只是对路径进行了检查,并创建一个ReliableRDDCheckpointData对象,并赋值给:checkpointData变量。
- 实际上执行RDD的checkpoint作是在执行完成了第一个action操作时进行的。
- 若spark是非本地模式运行,则设置的checkpoint路径必须是hdfs的路径。
两类checkpointing的实现
前面已经介绍了,有两类checkpointing。从实现层面来讲,spark中定义了一个checkpointing的抽象类:RDDCheckpointData。并且每对象会和一种RDD相关联。有两个类继承了该抽象类,他们分别是:ReliableRDDCheckpointData和LocalRDDCheckpointData。这两个类分别对应checnpointing的两种类型,他们包含RDD checkpointing的所有信息,并定义了具体执行checkpointing动作的函数:doCheckpoint。
RDDCheckpointData(抽象类)
/ /
| |
ReliableRDDCheckpointData________| |_______LocalRDDCheckpointData
(可靠checkpoint) (本地checkpoint)
这两种类型的checkpoint分别对应该类型的RDD,checkpoint类型的RDD由一个抽象类CheckpointRDD来描述。有两个类实现了该抽象类:LocalCheckpointRDD和ReliableCheckpointRDD。
CheckpointRDD(抽象类)
/ /
| |
ReliableCheckpointRDD________| |_______LocalCheckpointRDD
(可靠checkpointRDD) (本地checkpointRDD)
下面介绍这两类checkpointing的具体实现原理。
可靠的checkpointing
前面的实战例子分析的就是”可靠checkpointing“。我们再回顾一下它的实现要点:
1)首先设置保存checkpoint数据的目录,若是集群模式,该目录是一个hdfs目录,若不存在则会创建该目录。
2)调用RDD.checkpoint()来标记一个需要checkpoint的RDD,但需要注意,必须在对该RDD执行任何任务之前调用此函数。
3)当触发该RDD的Job任务执行时(调用action函数),才会执行checkpoint动作,此时会切断RDD的血缘,然后保存该RDD的数据到外部存储中。
本地checkpointing
本地checkpointing是通过RDD#localCheckpoint函数来实现。该函数通过persist函数来标记该RDD为本地checkpointing(local checkpointing)。
localCheckpoint适用于希望截断RDD 血缘图(RDD linage graph),同时跳过访问外部可靠存储系统的系统消耗的场景。这对于具有需要定期截断的长血统(long linage)的RDD很有用,例如,GraphX。
需要注意的是:Local Checkpointing为了性能牺牲了容错性,特别是:本地检查点数据(checkpointed data)被写入执行器(Executor)中的临时本地存储系统而不是可靠的容错存储系统。其结果是,如果执行程序在计算过程中失败,则可能无法再访问检查点数据(checkpointed data),从而导致无法恢复失败的作业。
另外,Local Checkpointing不需要通过SparkContext#setCheckpointDir来设置checkpointing目录。
localCheckpoint函数的实现
RDD#localCheckpoint函数的实现主要是以下两句:
if (storageLevel == StorageLevel.NONE) {
persist(LocalRDDCheckpointData.DEFAULT_STORAGE_LEVEL)
} else {
persist(LocalRDDCheckpointData.transformStorageLevel(storageLevel), allowOverride = true)
}
从以上源码中可以看出其实现逻辑为:
1)检查RDD的storageLevel是否设置,若没有设置,则调用persist函数,使用默认的存储级别:StorageLevel.MEMORY_AND_DISK。
2)若RDD的storageLevel已经设置,则允许通过persist函数来修改storageLevel。会在现有的storageLevel上添加useDisk选项,也就是说若需要修改storageLevel,则必须要使用磁盘来保存数据。
可见,localCheckpoint函数不需要设置checkpoint路径,它是基于RDD#persist缓存函数来实现的。
执行本地checkpointing动作
当调用RDD#localCheckpoint会调用RDD#persist来设置存储级别,因为是本地checkpointing所以默认会加上useDisk的存储选项。
当触发一个action动作并提交job后,会调用RDD的doCheckpoint函数,此时会调用LocalRDDCheckpointData#doCheckpoint来进行实际的本地checkpointing操作。该操作实际上是检查RDD的分区是否都已经存在,若不存在,则运行一个job来计算该分区。代码实现如下:
private[spark] class LocalRDDCheckpointData[T: ClassTag](@transient private val rdd: RDD[T]) extends RDDCheckpointData[T](rdd) with Logging {
// 确保该RDD被全部缓存了,这样分区才可以在后来恢复。
protected override def doCheckpoint(): CheckpointRDD[T] = {
val level = rdd.getStorageLevel
...
// 查找丢失的分区id
val missingPartitionIndices = rdd.partitions.map(_.index).filter { i =>
!SparkEnv.get.blockManager.master.contains(RDDBlockId(rdd.id, i))
}
// 发起job计算丢失的分区数据块
if (missingPartitionIndices.nonEmpty) {
rdd.sparkContext.runJob(rdd, action, missingPartitionIndices)
}
// 返回本地checkpointRDD对象
new LocalCheckpointRDD[T](rdd)
}
...
}
计算完成后数据块就保存到Executor执行的本地存储系统中。
注意,虽然localCheckpoint是基于RDD的persist函数实现的,但还是会截断RDD的血缘。
另外,在使用本地checkpointing时,启用动态分配并不安全,因为动态分配会删除Executor及其缓存的块。如果需要同时使用动态分配和本地checkpointing这两个功能,建议将spark.dynamicAllocation.cachedExecutorIdleTimeout的值设置得大一些。
本地checkpointing测试
根据"RDD的血缘(Lineage)"一节的例子,我们可以看一下localCheckpoint的具体表现:
scala> rdd20.toDebugString
res0: String =
(10) UnionRDD[8] at union at <console>:36 []
| UnionRDD[7] at union at <console>:36 []
| UnionRDD[6] at union at <console>:36 []
| CartesianRDD[2] at cartesian at <console>:28 []
| ParallelCollectionRDD[0] at parallelize at <console>:24 []
| ParallelCollectionRDD[1] at parallelize at <console>:24 []
| MapPartitionsRDD[3] at map at <console>:26 []
| ParallelCollectionRDD[0] at parallelize at <console>:24 []
| ZippedPartitionsRDD2[4] at zip at <console>:28 []
| ParallelCollectionRDD[0] at parallelize at <console>:24 []
| ParallelCollectionRDD[1] at parallelize at <console>:24 []
| MapPartitionsRDD[5] at keyBy at <console>:26 []
| ParallelCollectionRDD[1] at parallelize at <console>:24 []
// 标记localcheckpointing,注意:不需要设置目录,数据保存在executor端的本地
scala> rdd20.localCheckpoint
res1: rdd20.type = UnionRDD[8] at union at <console>:36
// 触发localcheckpoit的执行
scala> rdd20.count()
res4: Long = 130
// 查看血缘
scala> rdd20.toDebugString
res5: String =
(10) UnionRDD[8] at union at <console>:36 [Disk Memory Deserialized 1x Replicated]
| CachedPartitions: 10; MemorySize: 5.0 KB; ExternalBlockStoreSize: 0.0 B; DiskSize: 0.0 B
| LocalCheckpointRDD[9] at count at <console>:39 [Disk Memory Deserialized 1x Replicated]
可以看到rdd20的分区保存到了Executor端的本地,而且数据同时保存到了磁盘和内存中。这是localCheckpoint的默认存储级别。
总结
本文分析了两类RDD checkpointing的基本概念和实现原理。
可靠checkpoint会把数据块保存到外部可靠存储系统中,这样能保证数据块不会丢失。
而本地checkpoint则把数据保存到Executor的本地,但Executor本地的数据可能会丢失,所以这种checkpoint方式不太稳定,可能导致任务失败。但在资源充足的情况下,可以使用这种方式来提升性能。