深入了解Spark RDD(未完成)

本文围绕Spark的RDD展开,介绍其是spark最基本的抽象数据类型,阐述了RDD的五大特性、两种算子类型。还讲解了RDD持久化方法及存储等级,分析了不同存储等级的性能影响。此外,探讨了闭包概念,包括其在local和cluster模式下的表现,以及受闭包影响的print情况。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

概述

 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 partitionsgetPartitions--[Partition]
A function for computing each splitcompute-PartitionIterable
A list of dependencies on other RDDsgetDependencies--[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 {...}

 这里的存储等级实际上受到几个变量影响:

  1. _useDisk,是否使用磁盘
  2. _useMemory,是否使用内存
  3. _useOffHeap,是否使用堆外内存
  4. _deserialized,是否不使用序列化
  5. _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,所以当上面例子中的counterforeach函数引用到时,它已经不是driver节点里面中的counter了。虽然driver节点的内存中也保留了一份counter变量,但是它对于executor来说是不可见的。executor只能见到序列化后的闭包中的counter复制品。因此,counter的最终结果仍然是0,因为对于counter的所有操作都引用自序列化闭包中的counter

说白了每个算子都会产生一个闭包,闭包外面的变量传进闭包内都会被复制一份,每一次执行匿名函数的时候,引用的都是全新的变量
另外,在匿名函数外println出来的counterdriver中的,所以不受闭包内操作的影响。

 在本地模式中,如果用单核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,而不是driverstdout,所以driver上是显示不出结果的。
 要打印driver上的所有元素,可以使用该collect方法首先将RDD移动到driver节点rdd.collect().foreach(println)。但是,这会导致driver内存不足,因为collect方法将整个RDD(一个完整的,包括所有partition的RDD)提取到一台机器上,一旦该RDD过大,就会导致OOM。如果只需要打印RDD的部分元素,更安全的方法是使用take方法rdd.take(100).foreach(println)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值