概述
RDD(Resilient Distributed Dataset,弹性分布式数据集),是spark最基本的抽象数据类型。用来表示一个不可变的,多分区的,可以并行操作的元素集合。
其中,PairRDDFunctions包含的算子只能被键值对RDD调用所以类似(1,2,3)是无法调用的
DoubleRDDFunctions包含的算子只能被元素内均为Double类型的RDD调用
当基本的RDD满足转换成其他类型RDD的条件时,可以直接调用其它类型RDD的算子内部实际上进行了隐式转换
如map算子后接reduceByKey算子时,就会调用rddToPairRDDFunctions
函数
object RDD {
...
// spark1.3之前,这些隐式转换函数在SparkContext.scala中
// 1.3之后移动至此,使得编译器能够自动执行转换而不用另外import SparkContext._
// 为了避免旧版本编译出错,SparkContext中的代码仍然保留
// RDD转化为键值对的PairRDD
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)
}
// 其他的隐式转换
// rddToAsyncRDDActions
// rddToSequenceFileRDDFunctions
// rddToOrderedRDDFunctions
// doubleRDDToDoubleRDDFunctions
// numericRDDToDoubleRDDFunctions
}
RDD的五大特性
A list of partitions
,一个RDD是由一个至多个partitions组成A function for computing each split
,RDD的算子可以应用到内部的每一个partition中A list of dependencies on other RDDs
,从RDDa执行算子到另一个RDDb,一连串的血缘关系lineage
会被保存Optionally, a Partitioner for key-value RDDs (e.g. to say that the RDD is hash-partitioned)
可选的,对于键值对RDD,可以使用分区器进行分区,Optionally, a list of preferred locations to compute each split on (e.g. block locations for an HDFS file)
对于RDD中的每一个partition,都有一系列的优选的存储位置,即为数据本地性
特性 | 对应方法 | 执行位置 | 输入 | 输出 |
---|---|---|---|---|
a list of partitions | getPartitions | - | - | [Partition] |
A function for computing each split | compute | - | Partition | Iterable |
A list of dependencies on other RDDs | getDependencies | - | - | [Dependency] |
a Partitioner for key-value RDDs | ||||
a list of preferred locations to compute each split |
RDD算子
RDD支持两种类型的算子
- transformation
- 该类算子能够基于原先的数据集上创建一个新的数据集
- lazy概念:执行transformation算子之后,并不会马上进行计算,只是先记录该算子作用在某个数据集上。transformation算子只有在action算子触发之后,请求返回一个结果给driver的时候才会真正计算。
- action
- 该类算子将在计算执行完毕后返回一个值或者另一个分布式数据集给driver
- 默认地,action算子执行的时候,会将前面一连串transformation全都执行一遍,即从最初的RDD计算到最后的action算子。为了避免反复的计算,可以将中间结果进行cache或者persist从而缓存到集群的内存上,甚至可以写到磁盘中,以及在节点内缓存多分数据。
RDD持久化
首先,RDD的持久化方法仍然属于transformation,也就是算子是lazy的, (这里需要注意,实际上cache、persist、unpersist既非transformation也非action算子)只有到action执行的时候才会真正将RDD进行缓存。但是如果其他action算子使用到原先缓存的RDD时,之前的dependencies将不需要再被计算,这样就使得action的计算速度加快。
然而用来解除持久化的unpersist方法类似于,但实际不是action,会在调用时立刻消除持久化标记,释放对象内存。
fault-torlerant,即容错能力,当缓存中的RDD中的部分数据丢失,仍然可以通过之前的transformation自动重新计算。
1. 底层cache方法与persist方法(RDD.scala)
/**
* Persist this RDD with the default storage level (`MEMORY_ONLY`).
* persist方法实际上接受两个参数
* 1. newLevel: StorageLevel,即为存储等级,或者赋予新的存储等级
* 2. allowOverride: Boolean,是否允许覆盖原有等级
*/
def persist(): this.type = persist(StorageLevel.MEMORY_ONLY)
/**
* Persist this RDD with the default storage level (`MEMORY_ONLY`).
*/
def cache(): this.type = persist()
cache实际上调用了persist方法,使用了默认参数StorageLevel.MEMORY_ONLY
即只缓存到内存。
// RDD.scala
private def persist(newLevel: StorageLevel, allowOverride: Boolean): this.type = {
... // 处理已经persist过了,想要override storagelevel的逻辑
// 处理初次persist的逻辑
if (storageLevel == StorageLevel.NONE) {
sc.cleaner.foreach(_.registerRDDForCleanup(this))
// 由SparkContext进行持久化,this为RDD对象
sc.persistRDD(this)
}
...
}
// SparkContext.scala
private[spark] def persistRDD(rdd: RDD[_]) {
// 使用key-value存储,key为rdd的id,value为rdd本身
persistentRdds(rdd.id) = rdd
}
// SparkContext.scala
private[spark] val persistentRdds = {
// 底层实际上是ConcurrentMap的实例化对象进行存储
val map: ConcurrentMap[Int, RDD[_]] = new MapMaker().weakValues().makeMap[Int, RDD[_]]()
map.asScala
}
2. 存储等级(StorageLevel.scala)
object StorageLevel {
// 定义存储等级
val NONE = new StorageLevel(false, false, false, false)
val DISK_ONLY = new StorageLevel(true, false, false, false)
val DISK_ONLY_2 = new StorageLevel(true, false, false, false, 2)
val MEMORY_ONLY = new StorageLevel(false, true, false, true)
val MEMORY_ONLY_2 = new StorageLevel(false, true, false, true, 2)
val MEMORY_ONLY_SER = new StorageLevel(false, true, false, false)
val MEMORY_ONLY_SER_2 = new StorageLevel(false, true, false, false, 2)
val MEMORY_AND_DISK = new StorageLevel(true, true, false, true)
val MEMORY_AND_DISK_2 = new StorageLevel(true, true, false, true, 2)
val MEMORY_AND_DISK_SER = new StorageLevel(true, true, false, false)
val MEMORY_AND_DISK_SER_2 = new StorageLevel(true, true, false, false, 2)
val OFF_HEAP = new StorageLevel(true, true, true, false, 1)
// 其他代码
...
}
StorageLevel定义了12中存储等级,分别为
- None,不进行缓存
- DISK_ONLY,只写到磁盘
- DISK_ONLY_2,只写到磁盘,保存2份副本
- MEMORY_ONLY,只写到内存
- MEMORY_ONLY_2,只写到内存,保存2份副本
- MEMORY_ONLY_SER,序列化后写到内存
- MEMORY_ONLY_SER_2,序列化后写到内存,保存2份副本
- MEMORY_AND_DISK,写到内存和磁盘
- MEMORY_AND_DISK_2,写到内存和磁盘,各自保存2份副本
- MEMORY_AND_DISK_SER,序列化后,写到内存和磁盘
- MEMORY_AND_DISK_SER_2,序列化后,写到内存和磁盘,各自保存两份副本
- OFF_HEAP,写到堆外内存和磁盘
class StorageLevel private(
private var _useDisk: Boolean,
private var _useMemory: Boolean,
private var _useOffHeap: Boolean,
private var _deserialized: Boolean,
private var _replication: Int = 1)
extends Externalizable {...}
这里的存储等级实际上受到几个变量影响:
- _useDisk,是否使用磁盘
- _useMemory,是否使用内存
- _useOffHeap,是否使用堆外内存
- _deserialized,是否不使用序列化
- _replication,副本个数,默认为1,即只存一份,没有额外副本
3. 性能影响
无论用户是否主动进行persist,spark都会在执行shuffle类算子的时候,将中间的数据进行persist,这样做是为了避免如果在shuffle过程中遇到节点故障,所有的数据都得重新计算,因此,如果有需要反复使用一些RDD的时候,最好将他们进行持久化。
不同的StorageLevel意味着用户需要在内存使用以及cpu效率两者之间进行权衡,因为序列化serialized需要消耗cpu资源,但是节省内存空间;而不序列化deserialized节省了cpu资源,消耗了更多的内存空间。
- 如果RDD在默认的存储级别(MEMORY_ONLY)上运行正常,那就保持这种状态。这是CPU效率最高的选项,能够使得在RDD上的操作以尽可能快的速度运行。
- 如果内存相对紧张,就尝试MEMORY_ONLY_SER ,并且选择一个快速的序列化库让RDD对象的缓存更加节省空间,这样对cpu的影响还是比较小的。
- 使用序列化的时候,spark会将RDD的每个partition以一个巨大的byte array进行存储。唯一的不足之处是访问速度比较慢,因为还要反序列化。
- 默认的序列化方式是实现
java.io.Serializable
接口。如果想要在性能上有所提升,可以实现java.io.Externalizable
(底层继承了java.io.Serializable
),虽然java的序列化方法比较灵活,但是速度较慢,序列化后的对象空间节省不多。 - spark官方推荐使用kryo进行序列化,能够更加节省存储空间
闭包(closure)
例子
val conf = new SparkConf()
conf.setAppName("closureTestApp").setMaster("local[4]")
// conf.setAppName("closureTestApp").setMaster("local[1]")
var counter = 0
var rdd = sc.parallelize(List(1, 2, 3, 4))
// 这里将会出错
rdd.foreach(x => counter += x)
// 你可能认为最后执行结果为10
println("Counter value: " + counter)
// Counter value: 0
rdd.foreach(x => {
println("before: " + counter)
counter += x
println("after: " + counter)
println("=========")
})
/*
x, before: 0
x, after: 1
=========
x, before: 0
x, after: 4
=========
x, before: 0
x, after: 2
=========
x, before: 0
x, after: 3
=========
*/
local和cluster模式
为了执行job,Spark将处理RDD算子的过程拆分成多个task,每个task都被一个executor执行。在执行之前,Spark会计算task的闭包。
闭包就是指那些对于某个executor自身可见的,用来将计算作用在RDD上的方法和参数
闭包会被序列化之后发送到每个executor上
由于闭包内的变量会被复制一份新的然后再送到executor,所以当上面例子中的counter
foreach函数引用到时,它已经不是driver
节点里面中的counter
了。虽然driver
节点的内存中也保留了一份counter
变量,但是它对于executor
来说是不可见的。executor
只能见到序列化后的闭包中的counter
复制品。因此,counter
的最终结果仍然是0,因为对于counter
的所有操作都引用自序列化闭包中的counter
。
说白了每个算子都会产生一个闭包,闭包外面的变量传进闭包内都会被复制一份,每一次执行匿名函数的时候,引用的都是全新的变量
另外,在匿名函数外println出来的counter
是driver
中的,所以不受闭包内操作的影响。
在本地模式中,如果用单核setMaster("local[1]")
执行foreach,在匿名函数内进行println,最终的counter
将会是正确的结果,因为List中的所有数据没有进行partition,直接发送到一个executor
中,匿名函数中的+=就会是正确的。
甚至如果executor
在和driver
相同的JVM中进行foreach,他们可能引用到相同的counter
,最终连第二个println也会是正确的。
为了确保在这些场景中,这些行为能够如我们所希望的执行,应该使用累加器accumulator
。spark中的累加器专门用来提供一种机制,当partition被分散到集群中的不同work node
,变量的结果仍然能够正确地安全地更新。
闭包其实构建了一个类似循环或者局部定义的方法,不能用来改变一些全局的状态。spark没有定义也不能保证这些对闭包外部的对象引用进行修改的行为(能够得到正确结果)。那些能正确执行的情况也只限于本地模式(local[1]),这只是碰巧而已,如果放到分布式环境下运行就会出现错误。为了避免这种情况,可以使用accumulator
来代替一些全局的聚合。
受到闭包影响的print
另一个常见的习惯用法是尝试使用rdd.foreach(println)
或rdd.map(println)
打印出RDD的元素。在单台机器上,这将生成预期的输出并打印所有RDD的元素。但是,在集群模式下,被executor
调用的output的标准输出stdout
实际上写到了executor
上的stdout,而不是driver
的stdout
,所以driver
上是显示不出结果的。
要打印driver
上的所有元素,可以使用该collect方法首先将RDD移动到driver
节点rdd.collect().foreach(println)
。但是,这会导致driver
内存不足,因为collect方法将整个RDD(一个完整的,包括所有partition的RDD)提取到一台机器上,一旦该RDD过大,就会导致OOM。如果只需要打印RDD的部分元素,更安全的方法是使用take方法rdd.take(100).foreach(println)
。