【Spark】RDD

705 阅读9分钟

这是我参与11月更文挑战的第13天,活动详情查看:2021最后一次更文挑战

一、RDD 的核心概念

RDDSpark 最核心的数据结构,RDDResilient Distributed Dataset)全称为弹性分布式数据集,是 Spark 对数据的核心抽象,也是最关键的抽象,它实质上是一组分布式的 JVM 不可变对象集合,不可变决定了它是只读的,所以 RDD 在经过变换产生新的 RDD 时,(如下图中 A-B),原有 RDD 不会改变。 Ciqc1F6qfhyAEvFNAAIRggB-Gcs425.png

弹性主要表现在两个方面:

  • 在面对出错情况(例如任意一台节点宕机)时,Spark 能通过 RDD 之间的依赖关系恢复任意出错的 RDD(如 BD 可以算出最后的 RDD),RDD 就像一块海绵一样,无论怎么挤压,都像海绵一样完整;
  • 在经过转换算子处理时,RDD 中的分区数以及分区所在的位置随时都有可能改变。

每个 RDD 都有如下几个成员:

  • 分区的集合;
  • 用来基于分区进行计算的函数(算子);
  • 依赖(与其他 RDD)的集合;
  • 对于键-值型的 RDD 的散列分区器(可选);
  • 对于用来计算出每个分区的地址集合(可选,如 HDFS 上的块存储的地址)。
// 源码中解释如下:
Internally, each RDD is characterized by five main properties:

  - A list of partitions
  - A function for computing each split
  - A list of dependencies on other RDDs
  - 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_0 根据 HDFS 上的块地址生成,块地址集合是 RDD_0 的成员变量,RDD_1RDD_0 与转换(transform)函数(算子)转换而成,该算子其实是 RDD_0 内部成员。从这个角度上来说,RDD_1 依赖于 RDD_0,这种依赖关系集合也作为 RDD_1 的成员变量而保存。

CgqCHl6qfjeAZbhpAAEjgjsLvIg341.png

(1)RDD 特点

特点:

  • 分区
  • 只读
  • 依赖
  • 缓存
  • checkpoint
  1. 分区

RDD 逻辑上是分区的, 每个分区的数据是抽象存在的, 计算的时候会通过一个 compute 函数得到每个分区的数据。 如果 RDD 是通过已有的文件系统构建, 则 compute 函数是读取指定文件系统中的数据, 如果 RDD 是通过其他 RDD 转换而来, 则 compute 函数是执行转换逻辑将其他 RDD 的数据进行转换。

如图:

2020-11-2714-35-30.png

  1. 只读

RDD 是只读的, 要想改变 RDD 中的数据, 只能在现有的 RDD 基础上创建新的 RDD; 一个 RDD 转换为另一个 RDD, 通过丰富的操作算子(mapfilterunionjoinreduceByKey... ...)实现, 不再像 MR 那样只能写 mapreduce 了。

RDD 的操作算子包括两类:

  • transformation: 用来对 RDD 进行转化, 延迟执行(Lazy);
  • action : 用来触发 RDD 的计算; 得到相关计算结果或者将 RDD 保存的文件系统中;

如图: 2020-11-2714-35-48.png

  1. 依赖

RDDs 通过操作算子进行转换, 转换得到的新 RDD 包含了从其他 RDDs 衍生所必需的信息, RDDs 之间维护着这种血缘关系(lineageRDD 之间的关系), 也称之为依赖。 依赖包括两种:

  • 窄依赖: RDDs 之间分区是一一对应的(1:1n:1)
  • 宽依赖: 子 RDD 每个分区与父 RDD 的每个分区都有关, 是多对多的关系(即 n:m)。有 shuffle 发生。

如图: 2020-11-2714-39-22.png

  1. 缓存

可以控制存储级别(内存、磁盘等)来进行缓存。 如果在应用程序中多次使用同一个 RDD, 可以将该 RDD 缓存起来, 该 RDD 只有在第一次计算的时候会根据血缘关系得到分区的数据, 在后续其他地方用到该 RDD 的时候, 会直接从缓存处取而不用再根据血缘关系计算, 这样就加速后期的重用。

如图: 2020-11-2714-40-17.png

  1. checkpoint

虽然 RDD 的血缘关系天然地可以实现容错, 当 RDD 的某个分区数据失败或丢失, 可以通过血缘关系重建。 但是于长时间迭代型应用来说, 随着迭代的进行, RDDs 之间的血缘关系会越来越长, 一旦在后续迭代过程中出错, 则需要通过非常长的血缘关系去重建, 势必影响性能。 RDD 支持 checkpoint 将数据保存到持久化的存储中, 这样就可以切断之前的血缘关系, 因为 checkpoint 后的 RDD 不需要知道它的父 RDDs 了, 它可以从 checkpoint 处拿到数据。

(2)RDD 源码

Spark 源码中,RDD 是一个抽象类,根据具体的情况有不同的实现,比如 RDD_0 可以是 MapPartitionRDD,而 RDD_1 由于产生了 Shuffle(数据混洗),则是 ShuffledRDD

RDD 源码:

// 表示RDD之间的依赖关系的成员变量
@transient private var deps: Seq[Dependency[_]]

// 分区器成员变量
@transient val partitioner: Option[Partitioner] = None

// 该RDD所引用的分区集合成员变量
@transient private var partitions_ : Array[Partition] = null

// 得到该RDD与其他RDD之间的依赖关系
protected def getDependencies: Seq[Dependency[_]] = deps

// 得到该RDD所引用的分区
protected def getPartitions: Array[Partition]

// 得到每个分区地址
protected def getPreferredLocations(split: Partition): Seq[String] = Nil

// distinct算子
def distinct(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T] = 

withScope  {
    map(x => (x, null)).reduceByKey((x, y) => x, numPartitions).map(_._1)
}

特别注意其中一条:

@transient private var partitions_ : Array[Partition] = null

RDD 是分区的集合,本质上还是一个集合,所以在理解时,可以用分区之类的概念去理解,但是在使用时,就可以忘记这些,把其当做是一个普通的集合。

举例:

val list: List[Int] = List(1,2,3,4,5)
println(list.map(x => x + 1).filter { x => x > 1}.reduce(_ + _))

......

val list: List[Int] = spark.sparkContext.parallelize(List(1,2,3,4,5))
println(list.map(x => x + 1).filter { x => x > 1}.reduce(_ + _))

(3)编程模型

RDD 编程模型:

  1. 创建 RDD
  2. tranformation 算子操作
  3. action 算子操作
  4. 其他操作

二、如何创建 RDD

在创建 RDD 之前,可以将 RDD 的类型分为以下几类:

  • 并行集合
  • HDFS 中读取
  • 从外部数据源读取
  • PairRDD

建议: Standalone 模式 或 本地模式学习 RDD 的各种算子;

不需要 HA ; 不需要 IDEA

-- 本地模式启动
$ spark-shell --master local

-- 实操如下:
[root@linux121 ~]# spark-shell --master local
21/02/19 09:46:16 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use setLogLevel(newLevel).
Spark context Web UI available at http://linux121:4040
Spark context available as 'sc' (master = local, app id = local-1613699181443).
Spark session available as 'spark'.
Welcome to
      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /___/ .__/\_,_/_/ /_/\_\   version 2.4.5
      /_/
         
Using Scala version 2.12.10 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_231)
Type in expressions to have them evaluated.
Type :help for more information.

特别需要注意的是:spark-shell 中默认设定 spark contextsc

Spark context available as 'sc' (master = local, app id = local-1613699181443).

(1)集合中创建 RDD

从集合中创建 RDD, 主要用于测试。Spark 提供了以下函数: parallelizemakeRDDrange

这种 RDD 纯粹是为了学习,将内存中的集合变量转换为 RDD,没太大实际意义。

备注: rdd.collect 方法在生产环境中不要使用, 会造成 Driver OOM

val rdd1 = sc.parallelize(Array(1,2,3,4,5))
val rdd2 = sc.parallelize(1 to 100)
// 检查 RDD 分区数
rdd2.getNumPartitions
rdd2.partitions.length
rdd3 = sc.makeRDD(List(1,2,3,4,5))
val rdd4 = sc.makeRDD(1 to 100)
rdd4.getNumPartitions
val rdd5 = sc.range(1, 100, 3)
rdd5.getNumPartitions
val rdd6 = sc.range(1, 100, 2 ,10)
rdd6.getNumPartitions



// 实操如下:
scala> val rdd1 = sc.parallelize(Array(1,2,3,4,5))
rdd1: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24

scala> rdd1.getNumPartitions
res0: Int = 1

scala> rdd1.partitions.length
res1: Int = 1

scala> val rdd1 = sc.makeRDD(1 to 100)
rdd1: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[1] at makeRDD at <console>:24

scala> rdd1.getNumPartitions
res2: Int = 1

(2)从文件系统创建 RDD

textFile() 方法来从文件系统中加载数据创建 RDD。方法将文件的 URI 作为参数, 这个 URI 可以是:

  1. 本地文件系统

    使用本地文件系统要注意: 该文件是不是在所有的节点存在(在 Standalone 模式下),若是使用 local模式则没有问题。

  2. 分布式文件系统 HDFS 的地址

    HDFS 中读取,这种生成 RDD 的方式是非常常用的。

  3. Amazon S3 的地址

// 从本地文件系统加载数据
val lines = sc.textFile("file:///root/data/wc.txt")

// 从分布式文件系统加载数据
val lines = sc.textFile("hdfs://linux121:9000/user/root/data/uaction.dat")
val lines = sc.textFile("/user/root/data/uaction.dat")
val lines = sc.textFile("data/uaction.dat")

(3)从外部数据源读取

SparkMySQL 中读取数据返回的 RDD 类型是 JdbcRDD,顾名思义,是基于 JDBC 读取数据的,这点与 Sqoop 是相似的,但不同的是 JdbcRDD 必须手动指定数据的上下界,也就是以 MySQL 表某一列的最值作为切分分区的依据。

//val spark: SparkSession = .......

val lowerBound = 1
val upperBound = 1000
val numPartition = 10

val rdd = new JdbcRDD(spark.sparkcontext,() => {
       Class.forName("com.mysql.jdbc.Driver").newInstance()
       DriverManager.getConnection("jdbc:mysql://localhost:3306/db", "root", "123456")
   },

   "SELECT content FROM mysqltable WHERE ID >= ? AND ID <= ?",
   lowerBound, 
   upperBound, 
   numPartition,
   r => r.getString(1)
)

既然是基于 JDBC 进行读取,那么所有支持 JDBC 的数据库都可以通过这种方式进行读取,也包括支持 JDBC 的分布式数据库,但是需要注意的是,从代码可以看出,这种方式的原理是利用多个 Executor 同时查询互不交叉的数据范围,从而达到并行抽取的目的。但是这种方式的抽取性能受限于 MySQL 的并发读性能,单纯提高 Executor 的数量到某一阈值后,再提升对性能影响不大。

上面介绍的是通过 JDBC 读取数据库的方式,对于 HBase 这种分布式数据库来说,情况有些不同,HBase 这种分布式数据库,在数据存储时也采用了分区的思想,HBase 的分区名为 Region,那么基于 Region 进行导入这种方式的性能就会比上面那种方式快很多,是真正的并行导入。

//val spark: SparkSession = .......

val sc = spark.sparkcontext
val tablename = "your_hbasetable"
val conf = HBaseConfiguration.create()
conf.set("hbase.zookeeper.quorum", "zk1,zk2,zk3")
conf.set("hbase.zookeeper.property.clientPort", "2181")
conf.set(TableInputFormat.INPUT_TABLE, tablename)
val rdd= sc.newAPIHadoopRDD(conf, classOf[TableInputFormat],
classOf[org.apache.hadoop.hbase.io.ImmutableBytesWritable],
classOf[org.apache.hadoop.hbase.client.Result]) 

// 利用HBase API解析出行键与列值
rdd_three.foreach{case (_,result) => {

    val rowkey = Bytes.toString(result.getRow)
    val value1 = Bytes.toString(result.getValue("cf".getBytes,"c1".getBytes))
}

值得一提的是 HBase 有一个第三方组件叫 Phoenix,可以让 HBase 支持 SQLJDBC,在这个组件的配合下,第一种方式也可以用来抽取 HBase 的数据,此外,Spark 也可以读取 HBase 的底层文件 HFile,从而直接绕过 HBase 读取数据。

通过第三方库的支持,Spark 几乎能够读取所有的数据源,例如 Elasticsearch

(4)从 RDD 创建RDD

PairRDD 与其他 RDD 并无不同,只不过它的数据类型是 Tuple2[K,V],表示键值对,因此这种 RDD 也被称为 PairRDD,泛型为 RDD[(K,V)]

而普通 RDD 的数据类型为 IntString 等。这种数据结构决定了 PairRDD 可以使用某些基于键的算子,如分组、汇总等。

PairRDD 可以由普通 RDD 转换得到:

//val spark: SparkSession = .......

val a = spark.sparkcontext.textFile("/user/me/wiki").map(x => (x,x))