RDD有与调度系统相关的 API, 还提供了很多其他类型的 API,包括对 RDD 进行转换的 API、对 RDD 进行计算(动作)的 API 及 RDD 检查点相关的 API。转换 API 里的计算是延迟的,也就是说调用转换 API 不会向 Spark 集群提交 Job,更不会执行转换计算。只有调用了动作 API,才会提交 Job 并触发对转换计算的执行。
转换 API
转换(transform)是指对现有 RDD 执行某个函数后转换为新的 RDD 的过程。转换前的 RDD 与转换后的 RDD 之间具有依赖和血缘关系。RDD 的多次转换将创建出多个 RDD,这些 RDD 构成了一张单向依赖的图,也就是 DAG。
mapPartitions
mapPartitions 方法用于将 RDD 转换为 MapPartitionsRDD,其实现如代码清单
def mapPartitions[U: ClassTag](
f: Iterator[T] => Iterator[U],
preservesPartitioning: Boolean = false): RDD[U] = withScope {
val cleanedF = sc.clean(f)
new MapPartitionsRDD(
this,
(context: TaskContext, index: Int, iter: Iterator[T]) => cleanedF(iter),
preservesPartitioning)
}
为便于理解,这里假设函数 f 的作用是过滤出大于 0 的数字,那么 mapPartitions 方法的执行可以用图表示。
mapPartitionsWithIndex
mapPartitionsWithIndex 方法用于创建一个将与分区索引相关的函数应用到 RDD 的每个分区的 MapPartitionsRDD。
def mapPartitionsWithIndex[U: ClassTag](
f: (Int, Iterator[T]) => Iterator[U],
preservesPartitioning: Boolean = false): RDD[U] = withScope {
val cleanedF = sc.clean(f)
new MapPartitionsRDD(
this,
(context: TaskContext, index: Int, iter: Iterator[T]) => cleanedF(index, iter),
preservesPartitioning)
}
mapPartitionsWithIndex 与 mapPartitions 相似,区别在于多接收分区索引的参数。我们假设函数 f 的作用是将每个分区的数字累加并且与分区索引以逗号分隔输出,那么 mapPartitionsWithIndex 方法的执行可以用图表示。
mapPartitionsWithIndexInternal
mapPartitionsWithIndexInternal 方法用于创建一个将函数应用到 RDD 的每个分区的 MapPartitionsRDD。由于此方法是私有的,所以只在Spark SQL 内部使用。
private[spark] def mapPartitionsWithIndexInternal[U: ClassTag](
f: (Int, Iterator[T]) => Iterator[U],
preservesPartitioning: Boolean = false): RDD[U] = withScope {
new MapPartitionsRDD(
this,
(context: TaskContext, index: Int, iter: Iterator[T]) => f(index, iter),
preservesPartitioning)
}
flatMap
flatMap 方法用于向 RDD 中的所有元素应用函数,并对结果扁平化处理。
def flatMap[U: ClassTag](f: T => TraversableOnce[U]): RDD[U] = withScope {
val cleanF = sc.clean(f)
new MapPartitionsRDD[U, T](this, (context, pid, iter) => iter.flatMap(cleanF))
}
flatMap 方法也将返回 MapPartitionsRDD。我们假设函数 f 的作用是将给每个数字加上 5,那么 flatMap 方法的执行可以用图表示。
map
map 方法用于向 RDD 中的所有元素应用函数。
def map[U: ClassTag](f: T => U): RDD[U] = withScope {
val cleanF = sc.clean(f)
new MapPartitionsRDD[U, T](this, (context, pid, iter) => iter.map(cleanF))
}
我们假设函数 f 的作用是将给每个数字加上 5,那么 map 方法的执行可以用图表示。
toJavaRDD
toJavaRDD 方法用于将 RDD 自己转换为 JavaRDD,其实现如所示。
def toJavaRDD() : JavaRDD[T] = {
new JavaRDD(this)(elementClassTag)
}
动作 API
由于转换 API 都是预先编织好,但是不会执行的,所以 Spark 需要一些 API 来触发对转换的执行。动作 API 触发对数据的转换后,将接收到一些结果数据,动作 API 因此还具备对这些数据进行收集、遍历、叠加的功能。
collect
collect 方法将调用 SparkContext 的 runJob 方法提交基于 RDD 的所有分区上的作业,并返回数组形式的结果。
def collect(): Array[T] = withScope {
val results = sc.runJob(this, (iter: Iterator[T]) => iter.toArray)
Array.concat(results: _*)
}
foreach
foreach 方法将调用 SparkContext 的 runJob 方法提交将函数应用到 RDD 中所有元素的作业。
def foreach(f: T => Unit): Unit = withScope {
val cleanF = sc.clean(f)
sc.runJob(this, (iter: Iterator[T]) => iter.foreach(cleanF))
}
reduce
reduce 方法按照指定的函数对 RDD 中的元素进行叠加操作
def reduce(f: (T, T) => T): T = withScope {
val cleanF = sc.clean(f)
val reducePartition: Iterator[T] => Option[T] = iter => {
if (iter.hasNext) {
Some(iter.reduceLeft(cleanF))
} else {
None
}
}
var jobResult: Option[T] = None
val mergeResult = (index: Int, taskResult: Option[T]) => {
if (taskResult.isDefined) {
jobResult = jobResult match {
case Some(value) => Some(f(value, taskResult.get))
case None => taskResult
}
}
}
sc.runJob(this, reducePartition, mergeResult)
jobResult.getOrElse(throw new UnsupportedOperationException("empty collection"))
}
为了便于说明 reduce 的作用,这里假设函数 f 的定义是:(L,R)=>(L+R),那么可以用图来表示 reduce 的效果。
检查点 API 的实现分析
RDD 中提供了很多与检查点相关的 API,通过对这些 API 的使用,Spark 应用程序才能够启用、保存及使用检查点,提高应用程序的容灾和容错能力。
检查点的启用
用户提交的 Spark 作业必须主动调用 RDD 的 checkpoint 方法,才会启动检查点功能。
def checkpoint(): Unit = RDDCheckpointData.synchronized {
if (context.checkpointDir.isEmpty) {
throw new SparkException("Checkpoint directory has not been set in the SparkContext")
} else if (checkpointData.isEmpty) {
checkpointData = Some(new ReliableRDDCheckpointData(this))
}
}
给 SparkContext 指定 checkpointDir 是启用检查点机制的前提。可以使用 SparkContext 的 setCheckpointDir 方法设置checkpointDir。如果没有指定 RDDCheckpointData,那么创建ReliableRDDCheckpointData。
检查点的保存
RDD 的 doCheckpoint 方法用于将 RDD 的数据保存到检查点。由于此方法是私有的,只能在 RDD 内部使用。
private[spark] def doCheckpoint(): Unit = {
RDDOperationScope.withScope(sc, "checkpoint", allowNesting = false, ignore-Parent = true) {
if (!doCheckpointCalled) {
doCheckpointCalled = true
if (checkpointData.isDefined) {
if (checkpointAllMarkedAncestors) {
dependencies.foreach(_.rdd.doCheckpoint())
}
checkpointData.get.checkpoint()
} else {
dependencies.foreach(_.rdd.doCheckpoint())
}
}
}
}
doCheckpoint 方法的执行步骤如下。
- 如果 checkpointData 中保存了 RDDCheckpointData,调用RDDCheckpointData 的 checkpoint 方法(见代码清单 10-11)保存检查点。如果需要对祖先 RDD 保存检查点,那么还会调用每个依赖的 RDD 的doCheckpoint 方法。由于在启用检查点时,保存到 check-pointData 中的是RDDCheckpointData 的子类 ReliableRDDCheckpointData,因此RDDCheck-pointData 的 checkpoint 方法中将调用ReliableRDDCheckpointData 的 doCheckpoint 方法(见代码清单 10-14)。
- 如果 checkpointData 中没有保存 RDDCheckpointData,那么调用每个依赖的 RDD 的 doCheckpoint 方法。
检查点的使用
曾介绍过获取 RDD 的分区数组的 partitions 方法、获取指定分区的偏好位置的 preferredLocations 方法、获取当前 RDD 的所有依赖的 dependencies 方法。虽然这几个方法的作用不同,但是实现方式却是类似的,即首先从 RDD 关联的 CheckpointRDD 中查找对应信息。
根据对检查点的启用和保存的分析,负责为 RDD 提供检查点服务的实际是 Reli-ableCheckpointRDD。因此当调用 RDD 的 partitions 方法时,会首先调用ReliableCheck-pointRDD 的 partitions 方法,进而调用 ReliableCheckpointRDD 的 getPartitions 方法,最后才调用 RDD 自己的 getPartitions 方法。当调用 RDD 的 preferred-Locations 方法时,首先会调用ReliableCheckpointRDD 的 getPreferredLocations 方法,当调用 RDD 的 dependencies 方法时,首先会尝试将 ReliableCheck-pointRDD 封装为 OneToOneDependency。
除了以上场景外,对 RDD 的迭代计算也涉及对检查点的使用,其中将调用 Reliable-CheckpointRDD 的 compute 方法。迭代计算的内容将在下一小节介绍。
迭代计算
分析 ShuffleMapTask 和 ResultTask 的 runTask 方法时已经看到,Task 的执行离不开对 RDD 的 iterator 方法的调用。RDD 的 iterator 方法是迭代计算的入口
final def iterator(split: Partition, context: TaskContext): Iterator[T] = {
if (storageLevel != StorageLevel.NONE) {
getOrCompute(split, context)
} else {
computeOrReadCheckpoint(split, context)
}
}
iterator 方法的执行步骤如下。
- 如果 RDD 的存储级别(StorageLevel)不是 NONE,那么根据对StorageLevel 的分析,StorageLevel 的构造器是私有的。这些内置的存储级别除 NONE 外,至少会使用磁盘、堆内内存、堆外内存三者之一,因此可以调用 getOrCompute 方法从这些存储中尝试获取计算结果。
- 如果 RDD 的存储级别(StorageLevel)是 NONE,那么说明分区任务可能是初次执行且存储中还没有任务的执行结果,所以会调用computeOrReadCheckpoint 方法计算或者从检查点恢复。
小贴士: 这里需要说说 iterator 方法的容错处理过程。如果某个分区任务执行失败,但是其他分区任务执行成功,可以利用 DAGScheduler 对 Stage 重新调度。失败的分区任务将从检查点恢复状态,而那些执行成功的分区任务由于其执行结果已经缓存到存储体系,所以调用 getOrCompute 方法获取即可,不需要再次执行。
private[spark] def getOrCompute(partition: Partition, context: TaskContext): Iterator[T] = {
val blockId = RDDBlockId(id, partition.index)
var readCachedBlock = true
SparkEnv.get.blockManager.getOrElseUpdate(blockId, storageLevel, elementClass-Tag, () => {
readCachedBlock = false
computeOrReadCheckpoint(partition, context)
}) match {
case Left(blockResult) =>
if (readCachedBlock) {
val existingMetrics = context.taskMetrics().inputMetrics
existingMetrics.incBytesRead(blockResult.bytes)
new InterruptibleIterator[T](context, blockResult.data.asInstanceOf[Iterator-[T]]) {
override def next(): T = {
existingMetrics.incRecordsRead(1)
delegate.next()
}
}
} else {
new InterruptibleIterator(context, blockResult.data.asInstanceOf[Iterator[T]])
}
case Right(iter) =>
new InterruptibleIterator(context, iter.asInstanceOf[Iterator[T]])
}
}
getOrCompute 方法的执行步骤如下。
- 调用 BlockManager 的 getOrElseUpdate 方法(见代码清单 6-81)先尝试从存储体系中获取 RDD 分区的 Block,否则调用 computeOrReadCheckpoint 方法从检查点读取或计算。
- 对 getOrElseUpdate 方法返回的结果进行匹配,将返回的 BlockResult 的 data 属性或返回的 Iterator 封装为 InterruptibleIterator。
computeOrReadCheckpoint 方法在存在检查点时直接从检查点读取数据,否则需要调用 compute 继续计算。computeOrReadCheckpoint 方法的实现如下所示。
private[spark] def computeOrReadCheckpoint(split: Partition, context: Task-Context): Iterator[T] =
{
if (isCheckpointedAndMaterialized) {
firstParent[T].iterator(split, context)
} else {
compute(split, context)
}
}
private[spark] def isCheckpointedAndMaterialized: Boolean =
checkpointData.exists(_.isCheckpointed)
computeOrReadCheckpoint 方法的执行步骤如下。
- 如果 checkpointData 中保存了 RDDCheckpointData 且其检查点的状态(cpState)是 Checkpointed,那么调用 firstParent 方法找到其父 RDD,然后调用父 RDD 的 iterator 方法。由于 firstParent 中调用了dependencies,且当前 RDD 的父 RDD 实际是 ReliableCheckpointRDD,那么对 ReliableCheckpointRDD 的 iterator 方法的调用最终将转变为对ReliableCheckpointRDD 的 compute 方法的调用,从而从检查点文件读取之前保存的计算结果。
- 如果 checkpointData 中没有保存 RDDCheckpointData 或其检查点的状态(cpState)不是 Checkpointed,那么调用 compute 方法进行计算。
找到父亲 RDD
protected[spark] def firstParent[U: ClassTag]: RDD[U] = {
dependencies.head.rdd.asInstanceOf[RDD[U]]
}
每个 RDD 实现的 compute 方法都不相同。曾经介绍了Reliable-CheckpointRDD 的 compute 方法。此处再以 MapPartitionsRDD 和ShuffledRDD 为例,来看看它们各自实现的 compute 方法。
MapPartitionsRDD 实现的 compute 方法如下所示。
override def compute(split: Partition, context: TaskContext): Iterator[U] =
f(context, split.index, firstParent[T].iterator(split, context))
ShuffledRDD 实现的 compute 方法如代码下所示。
override def compute(split: Partition, context: TaskContext): Iterator[(K, C)] = {
val dep = dependencies.head.asInstanceOf[ShuffleDependency[K, V, C]]
SparkEnv.get.shuffleManager.getReader(dep.shuffleHandle, split.index, split.index + 1, context)
.read()
.asInstanceOf[Iterator[(K, C)]]
}
可以看到,ShuffledRDD 的 compute 方法首先调用 SortShuffleManager 的getReader 方法获取 BlockStoreShuffleReader,然后调用BlockStoreShuffleReader 的 read 方法获取 map 任务输出的 Block 并在 reduce 端进行聚合或排序。