RDD 的 5 个特征
下面来说一下 RDD 这东西,它是 Resilient Distributed Datasets 的简写。
咱们来看看 RDD 在源码的解释。
- A list of partitions: 在大数据领域,大数据都是分割成若干个部分,放到多个服务器上,这样就能做到多线程的处理数据,这对处理大数据量是非常重要的。分区意味着,可以使用多个线程了处理。
- A function for computing each split:作用在每个分区里面的函数,当我们读取数据之后,当然是要对其加工的,加工的定义就是我们编写的函数,这些函数主要包含转化算子、控制算子、行动算子。
- A list of dependencies on other RDDs。一个 Spark Application 下面可以有多个 Job ,一个 action 算子就可以分出一个 job ,一个 job 里面又可以分出若干个 stage , 一个 stage 中又有多个 RDD ,RDD 之间是用上下游关系的,就像流水线的工序,公休之间也会有先后之分的,例如,手机装壳之后才能上螺丝,这种上下游关系,使用依赖描述的,依赖又分为窄依赖和宽依赖。 那两个 RDD 为例,rdd2 依赖于 rdd1 ,
- Optionally, a Partitioner for key-value RDDs (e.g. to say that the RDD is hash-partitioned)
- Optionally, a list of preferred locations to compute each split on (e.g. block locations for
- an HDFS file)
RDD 源代码
RDD 的代码是非常多的,一个 RDD.scala 类就有 2000 多行。我们只捡能说明问题的就行了。
abstract class RDD[T: ClassTag](
// SparkContext 是代码的运行环境,SparkContext 中有一个 TaskSchedule 和 DAGSchedule ,前者是申请资源,后者是将 job 分割为多个 Stage ,然后提交给相应的 Executor
@transient private var _sc: SparkContext,
// deps 代表了上游算子依赖,上游可能有多个依赖,所以这里是一个 Seq .
// 这个 Seq 就是 RDD 中依赖的具体体现
@transient private var deps: Seq[Dependency[_]]
) extends Serializable with Logging {
// compute 函数代表了 RDD 第二个特征,作用在 partition 上面的函数。
@DeveloperApi
def compute(split: Partition, context: TaskContext): Iterator[T]
// 此函数是 RDD 第一个特征的具体表现,各个 RDD 的具体实现,可以根据它获得 RDD 中的分区
protected def getPartitions: Array[Partition]
// 还是依赖相关的函数
protected def getDependencies: Seq[Dependency[_]] = deps
// 此函数对应了 RDD 的第 5 个特征。各个 RDD 的实现类,在此函数中,实现就近数据的查找。
protected def getPreferredLocations(split: Partition): Seq[String] = Nil
// 此函数对应了 RDD 的第四个特征,针对 PairRDDFunction 的分区器。
@transient val partitioner: Option[Partitioner] = None
def sparkContext: SparkContext = sc
val id: Int = sc.newRddId()
final def dependencies: Seq[Dependency[_]] = {
...
}
final private def internalDependencies: Option[Seq[Dependency[_]]] = {
...
}
final def partitions: Array[Partition] = {
...
}
final def preferredLocations(split: Partition): Seq[String] = {
checkpointRDD.map(_.getPreferredLocations(split)).getOrElse {
getPreferredLocations(split)
}
}
final def iterator(split: Partition, context: TaskContext): Iterator[T] = {
}
}
object RDD {
private[spark] val CHECKPOINT_ALL_MARKED_ANCESTORS =
"spark.checkpoint.checkpointAllMarkedAncestors"
implicit def rddToPairRDDFunctions[K, V](rdd: RDD[(K, V)])
(implicit kt: ClassTag[K], vt: ClassTag[V], ord: Ordering[K] = null): PairRDDFunctions[K, V] = {
new PairRDDFunctions(rdd)
}
// 此方法对应了 RDD 的第四个特征,有了它,只要将 RDD 中的数据转化为 tuple2 的数据格式,就能自动调用 PairRDDFunction 中的函数。
implicit def rddToAsyncRDDActions[T: ClassTag](rdd: RDD[T]): AsyncRDDActions[T] = {
new AsyncRDDActions(rdd)
}
}
还有更重要的一点,就是第二个特征,作用在分区上的函数,RDD 加上 PairRDDFunction 上的函数有很多,可以在上一篇 Spark 核心API 中找到。
下面以 Workd Count 为例子,画图来说明 RDD 的特性。
val conf: SparkConf = new SparkConf().setMaster("local[*]").setAppName(this.getClass.getCanonicalName.init)
val sc: SparkContext = new SparkContext(conf)
sc.textfile("hdfs://nameservice/user/test_data/file.txt")
.flapMap(_.split(","))
.map((_,1))
.reduceByKey(_+_)
.foreach(println)
先来看看 textFile 底层是什么?
// SparkContext
def textFile(
path: String,
minPartitions: Int = defaultMinPartitions): RDD[String] = withScope {
assertNotStopped()
hadoopFile(path, classOf[TextInputFormat], classOf[LongWritable], classOf[Text],
minPartitions).map(pair => pair._2.toString).setName(path)
}
def hadoopFile[K, V](
path: String,
inputFormatClass: Class[_ <: InputFormat[K, V]],
keyClass: Class[K],
valueClass: Class[V],
minPartitions: Int = defaultMinPartitions): RDD[(K, V)] = withScope {
assertNotStopped()
FileSystem.getLocal(hadoopConfiguration)
val confBroadcast = broadcast(new SerializableConfiguration(hadoopConfiguration))
val setInputPathsFunc = (jobConf: JobConf) => FileInputFormat.setInputPaths(jobConf, path)
// 最后返回的是 HadoopRDD ,这是我们认识的第一个 RDD
new HadoopRDD(
this,
confBroadcast,
Some(setInputPathsFunc),
inputFormatClass,
keyClass,
valueClass,
minPartitions).setName(path)
以 HadoopRDD 为例子,我们来看看 RDD 的五个特性。
第一个特征是分区,来看一下 HadoopRDD 的 getPartitions 方法。
override def getPartitions: Array[Partition] = {
val jobConf = getJobConf()
try {
// 获取 splite , 这其实就是将一个 HDFS 文件切分成若干个分区。
val allInputSplits = getInputFormat(jobConf).getSplits(jobConf, minPartitions)
val inputSplits = if (ignoreEmptySplits) {
allInputSplits.filter(_.getLength > 0)
} else {
allInputSplits
}
// 根据分区大小来提示优化策略
if (inputSplits.length == 1 && inputSplits(0).isInstanceOf[FileSplit]) {
...
}
// 将 FileSplite 组成 hadoopPartition
val array = new Array[Partition](inputSplits.size)
for (i <- 0 until inputSplits.size) {
array(i) = new HadoopPartition(id, i, inputSplits(i))
}
array
} catch {
...
}
}
从 getPatitions 方法,可以看到使用 hadoop-client 的接口,将 HDFS 的文件切成若干 HadoopPartition ,然后返回一个数组 Array[Partition]。
第二个特征是作用在分区上的函数,那就来到来 compute 函数。
override def compute(theSplit: Partition, context: TaskContext): InterruptibleIterator[(K, V)] = {
// 构造 NextIterator 迭代器
val iter = new NextIterator[(K, V)] {
...
private var reader: RecordReader[K, V] = null
...
reader =
try {
inputFormat.getRecordReader(split.inputSplit.value, jobConf, Reporter.NULL)
} catch {
...
}
private val key: K = if (reader == null) null.asInstanceOf[K] else reader.createKey()
private val value: V = if (reader == null) null.asInstanceOf[V] else reader.createValue()
// 重新 getNext 方法,此方法其实就是从 HDFS 的文件中哪里一行数据,
// K 为对应此行在文件中的位置,
// V 为此行的数据
override def getNext(): (K, V) = {
try {
finished = !reader.next(key, value)
} catch {
...
}
(key, value)
}
// 关闭 HDFS 客户端和服务器端的连接
override def close(): Unit = {
}
}
new InterruptibleIterator[(K, V)](context, iter)
}
上面的代码中 inputSplit 其实是 FileInputSplit ,reader 是 LineRecordReader 。HadoopRDD 的功能就是从 HDFS 中取数据,向后发送,所以没有数据处理的逻辑。
第三个特征是描述 RDD 中的依赖。HadoopRDD 是第一个 RDD 所以它前面已经没有了 RDD 。从下面 HadoopRDD 的定义就能看出来。Dependency 为 Nil
class HadoopRDD[K, V](
sc: SparkContext,
broadcastedConf: Broadcast[SerializableConfiguration],
initLocalJobConfFuncOpt: Option[JobConf => Unit],
inputFormatClass: Class[_ <: InputFormat[K, V]],
keyClass: Class[K],
valueClass: Class[V],
minPartitions: Int)
// RDD构造函数是 RDD(SparkContext , Dependency)
// 从下面的代码中,可以看到 Dependency 为 Nil 。
extends RDD[(K, V)](sc, Nil){
...
}
preferedLocation 和 key-value RDDS 的特征在 HadoopRDD 没有体现出来。
下面再看 flatMap ,
def flatMap[U: ClassTag](f: T => TraversableOnce[U]): RDD[U] = withScope {
val cleanF = sc.clean(f)
new MapPartitionsRDD[U, T](this, (_, _, iter) => iter.flatMap(cleanF))
}
从上面代码中可以看到,MapPartitionsRDD 是 flatMap 的 RDD。还是这五个特征来看 MapPartitionsRDD ,
// MapPartitionsRDD#getPartitions
override def getPartitions: Array[Partition] = firstParent[T].partitions
// RDD#firstParent
protected[spark] def firstParent[U: ClassTag]: RDD[U] = {
dependencies.head.rdd.asInstanceOf[RDD[U]]
}
从上面的代码,可以看出,getPartitions 方法取出是第一个父 RDD 的分区,这是第一个特征。
第二个特征是作用在分区上面的计算,flatMap 是将 line 分裂成单个的单词,所以这里需要函数,就是 f ,
f 其实是在 flatMap 函数中定义的 (_, _, iter) => iter.flatMap(cleanF)
, 而 cleanF 就是
我们自定义的 _.split(“\s”) 的,而接收它的是一个 iterator 的 flatMap ,这个 flatMap 是 scala
原生的,并不是 RDD#flatMap。
override def compute(split: Partition, context: TaskContext): Iterator[U] =
f(context, split.index, firstParent[T].iterator(split, context))
从代码中,看到 f 的第三个入参是第一个父 RDD 的迭代器。
第三个特征是依赖关系,可以从 MapPartitionsRDD 的定义看出。
private[spark] class MapPartitionsRDD[U: ClassTag, T: ClassTag](
var prev: RDD[T],
f: (TaskContext, Int, Iterator[T]) => Iterator[U], // (TaskContext, partition index, iterator)
preservesPartitioning: Boolean = false,
isFromBarrier: Boolean = false,
isOrderSensitive: Boolean = false)
extends RDD[U](prev) {...}
def this(@transient oneParent: RDD[_]) =
this(oneParent.context, List(new OneToOneDependency(oneParent)))
现在只要弄清楚 pre 是那个 RDD 就可以了,当我们调用 sc.textFile(path).flatMap(_.split(“\s”)),其实 textFile 返回的是 HadoopRDD,所以是 HadoopRDD 调用的 flatMap ,所以 prev 就是 HadoopRDD 的引用。到这里,问题应该就清晰了,OneToOneDependency保存的父 RDD ,再有明显可以看出是窄依赖,一对一嘛。
preferedLocation 和 key-value RDD 同样都没体现出来。
下面来看看 map((_,1)) 使用了什么 RDD。
def map[U: ClassTag](f: T => U): RDD[U] = withScope {
val cleanF = sc.clean(f)
new MapPartitionsRDD[U, T](this, (_, _, iter) => iter.map(cleanF))
}
从代码来看,f 函数被 map 调用了,对应的 RDD 也是 MapPartitionRDD ,就是迭代器调用的方法发生了改变。还是按照老办法,把五个特征找出来。
第一个是 getPartitions 返回还是第一个 parent RDD 的分区。
第二个是 compute 中调用的是第一个 parentRDD 分区的迭代器。
第三个是 dependency 是 flatMap 对应的 MapPartiionRDD
preferedLocation 和 key-value RDD 同样都没体现出来。
最后是 reduceByKey(+) , reduceByKey 是 PairRDDFunction 的函数,这是咋回事,map((_,1)) 返回的不是 MapPartitionRDD 吗?怎么又变成 PairRDDFunction 了,这就要讲到 Scala 的隐式转化, 请看下面的代码:
object RDD {
...
implicit def rddToPairRDDFunctions[K, V](rdd: RDD[(K, V)])
(implicit kt: ClassTag[K], vt: ClassTag[V], ord: Ordering[K] = null): PairRDDFunctions[K, V] = {
new PairRDDFunctions(rdd)
}
...
}
当调用某个类的方法,发现此类没有这个方法,则就取找隐式方法,这里隐式方法是 rddToPairRDDFunctions,它最终将 MapPartitionRDD 转化为了 PairRDDFunction ,这样就实现了自动化的转化,所以这里能够调用 reduceByKey 方法,这也对应了 RDD 的第四个特性,key-value RDD 。看到这里,它的意思就是将那些数据类型为 (key , value) 的 RDD 自动转化为 PairRDDFunctiono , 并且调用上面的方法。
接着看 reduceByKey 的源码,
def reduceByKey(func: (V, V) => V): RDD[(K, V)] = self.withScope {
reduceByKey(defaultPartitioner(self), func)
}
def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)] = self.withScope {
combineByKeyWithClassTag[V]((v: V) => v, func, func, partitioner)
}
从上面的源码看出,reduceByKey 底层使用的是 combinerByKey() , combinerByKey 在之前的文章已经讲过了,
def combineByKeyWithClassTag[C](
createCombiner: V => C,
mergeValue: (C, V) => C,
mergeCombiners: (C, C) => C,
partitioner: Partitioner,
mapSideCombine: Boolean = true,
serializer: Serializer = null)(implicit ct: ClassTag[C]): RDD[(K, C)] = self.withScope {
....
val aggregator = new Aggregator[K, V, C](
self.context.clean(createCombiner),
self.context.clean(mergeValue),
self.context.clean(mergeCombiners))
if (self.partitioner == Some(partitioner)) {
...
} else {
new ShuffledRDD[K, V, C](self, partitioner)
.setSerializer(serializer)
.setAggregator(aggregator)
.setMapSideCombine(mapSideCombine)
}
}
从代码得,combineByKey 底层使用的是 ShuffleRDD 。
override def getPartitions: Array[Partition] = {
Array.tabulate[Partition](part.numPartitions)(i => new ShuffledRDDPartition(i))
}
override def compute(split: Partition, context: TaskContext): Iterator[(K, C)] = {
val dep = dependencies.head.asInstanceOf[ShuffleDependency[K, V, C]]
val metrics = context.taskMetrics().createTempShuffleReadMetrics()
SparkEnv.get.shuffleManager.getReader(
dep.shuffleHandle, split.index, split.index + 1, context, metrics)
.read()
.asInstanceOf[Iterator[(K, C)]]
}
在 compute 中并没有从依赖中取出迭代器,而是调用了 ShuffleManager#getReader 方法,这是因为 combineByKey 是做分区操作的,所以要将相同 key 的数据通过网络发送到不同的机器上,其实就是 Map-Reduce 计算引擎的 shuffle 过程,这里也是一样的,这也是 ShuffleRDD 名称的由来。
paritition 的特性:
- parttion 特性,在 compute 函数中,使用 ShuffleManager 拿到 shuffle 到本分区的数据。这里是根据 key 进行了重新的分区
- compute 的特性,ShuffleRDD 的计算函数是封装在了 aggregator 成员变量了,而 aggregator 又被保存到了ShuffleDependency 中,其实是在 BlockStoreShuffleReader 中调用了 combinerBykey 中的我们自定义的函数。
- 依赖的是 ShuffleDependency ,就是宽依赖。
- key-value RDD 的特性 ,其实就是 PairRDDFunction 的隐式转化,在 reduceByKey 中体现的比较明显。
- preferedLocation 还是没有找对应的逻辑。
下面以图的方式来总结一下,HadoopRDD、MapPartitionsRDD、ShuffleRDD 这三个 RDD 在 word count 这个例子中的对应关系。