【PySpark】弹性分布式数据集(Resilient Distributed Dataset, RDD)

Spark 最基本的数据抽象是弹性分布式数据集(Resilient Distributed Dataset, 下文用 RDD 代指)。Spark 基于 RDD 定义了很多数据操作,从而使得数据处理的代码十分简洁、高效。所以,要想深入学习 Spark,我们必须首先理解 RDD 的设计思想和特性。

一、为什么需要新的数据抽象模型?

传统的 MapReduce 框架之所以运行速度缓慢,很重要的原因就是有向无环图的中间计算结果需要写入硬盘这样的稳定存储介质中来防止运行结果丢失。

而每次调用中间计算结果都需要要进行一次硬盘的读取,反复对硬盘进行读写操作以及潜在的数据复制和序列化操作大大提高了计算的延迟。

因此,很多研究人员试图提出一个新的分布式存储方案,不仅保持之前系统的稳定性、错误恢复和可扩展性,还要尽可能地减少硬盘 I/O 操作。

一个可行的设想就是在分布式内存中,存储中间计算的结果,因为对内存的读写操作速度远快于硬盘。而 RDD 就是一个基于分布式内存的数据抽象,它不仅支持基于工作集的应用,同时具有数据流模型的特点。

二、RDD 的定义

RDD 有以下基本特性:分区、不可变和并行操作。

2.1 分区

顾名思义,分区代表同一个 RDD 包含的数据被存储在系统的不同节点中,这也是它可以被并行处理的前提。

逻辑上,我们可以认为 RDD 是一个大的数组。数组中的每个元素代表一个分区(Partition)。

在物理存储中,每个分区指向一个存放在内存或者硬盘中的数据块(Block),而这些数据块是独立的,它们可以被存放在系统中的不同节点。

所以,RDD 只是抽象意义的数据集合,分区内部并不会存储具体的数据。下图很好地展示了 RDD 的分区逻辑结构:
请添加图片描述
RDD 中的每个分区存有它在该 RDD 中的 index。通过 RDD 的 ID 和分区的 index 可以唯一确定对应数据块的编号,从而通过底层存储层的接口中提取到数据进行处理。

在集群中,各个节点上的数据块会尽可能地存放在内存中,只有当内存没有空间时才会存入硬盘。这样可以最大化地减少硬盘读写的开销。

虽然 RDD 内部存储的数据是只读的,但是,我们可以去修改(例如通过 repartition 转换操作)并行计算单元的划分结构,也就是分区的数量。

2.2 不可变性

不可变性代表每一个 RDD 都是只读的,它所包含的分区信息不可以被改变。既然已有的 RDD 不可以被改变,我们只可以对现有的 RDD 进行转换(Transformation)操作,得到新的 RDD 作为中间计算的结果。从某种程度上讲,RDD 与函数式编程的 Collection 很相似。

lines = sc.textFile("data.txt")
lineLengths = lines.map(lambda s: len(s))
totalLength = lineLengths.reduce(lambda a, b: a + b)

在上述的简单例子中,我们首先读入文本文件 data.txt,创建了第一个 RDD lines,它的每一个元素是一行文本。然后调用 map 函数去映射产生第二个 RDD lineLengths,每个元素代表每一行简单文本的字数。最后调用 reduce 函数去得到第三个 RDD totalLength,它只有一个元素,代表整个文本的总字数。

那么这样会带来什么好处呢?显然,对于代表中间结果的 RDD,我们需要记录它是通过哪个 RDD 进行哪些转换操作得来,即依赖关系,而不用立刻去具体存储计算出的数据本身。

这样做有助于提升 Spark 的计算效率,并且使错误恢复更加容易。试想,在一个有 N 步的计算模型中,如果记载第 N 步输出 RDD 的节点发生故障,数据丢失,我们可以从第 N-1 步的 RDD 出发,再次计算,而无需重复整个 N 步计算过程。

这样的容错特性也是 RDD 为什么是一个“弹性”的数据集的原因之一。后边我们会提到 RDD 如何存储这样的依赖关系。

2.3 并行操作

由于单个 RDD 的分区特性,使得它天然支持并行操作,即不同节点上的数据可以被分别处理,然后产生一个新的 RDD。

三、RDD 的结构

每一个 RDD 里都会包括分区信息、所依赖的父 RDD 以及通过怎样的转换操作才能由父 RDD 得来等信息。

让我们来看一个 RDD 的简易结构示意图:
请添加图片描述

3.1 SparkContext & SparkConf

SparkContext 是所有 Spark 功能的入口,它代表了与 Spark 节点的连接,可以用来创建 RDD 对象以及在节点中的广播变量等。一个线程只有一个 SparkContext。SparkConf 则是一些参数配置信息。

3.2 Partitions

Partitions 前文中我已经提到过,它代表 RDD 中数据的逻辑结构,每个 Partition 会映射到某个节点内存或硬盘的一个数据块。Partitioner 决定了 RDD 的分区方式,目前有两种主流的分区方式:Hash partitioner 和 Range partitioner。Hash,顾名思义就是对数据的 Key 进行散列分区,Range 则是按照 Key 的排序进行均匀分区。此外我们还可以创建自定义的 Partitioner。

3.3 依赖关系

Dependencies 是 RDD 中最重要的组件之一。如前文所说,Spark 不需要将每个中间计算结果进行数据复制以防数据丢失,因为每一步产生的 RDD 里都会存储它的依赖关系,即它是通过哪个 RDD 经过哪个转换操作得到的。

父 RDD 的分区和子 RDD 的分区之间是否是一对一的对应关系呢?Spark 支持两种依赖关系:窄依赖(Narrow Dependency)和宽依赖(Wide Dependency)。
在这里插入图片描述
窄依赖就是父 RDD 的分区可以一一对应到子 RDD 的分区,宽依赖就是父 RDD 的每个分区可以被多个子 RDD 的分区使用。
请添加图片描述
显然,窄依赖允许子 RDD 的每个分区可以被并行处理产生,而宽依赖则必须等父 RDD 的所有分区都被计算好之后才能开始处理。

如上图所示,一些转换操作如 map、filter 会产生窄依赖关系,而 Join、groupBy 则会生成宽依赖关系。这很容易理解,因为 map 是将分区里的每一个元素通过计算转化为另一个元素,一个分区里的数据不会跑到两个不同的分区。而 groupBy 则要将拥有所有分区里有相同 Key 的元素放到同一个目标分区,而每一个父分区都可能包含各种 Key 的元素,所以它可能被任意一个子分区所依赖。

Spark 之所以要区分宽依赖和窄依赖是出于以下两点考虑:

  • 窄依赖可以支持在同一个节点上链式执行多条命令,例如在执行了 map 后,紧接着执行 filter。相反,宽依赖需要所有的父分区都是可用的,可能还需要调用类似 MapReduce 之类的操作进行跨节点传递。
  • 从失败恢复的角度考虑,窄依赖的失败恢复更有效,因为它只需要重新计算丢失的父分区即可,而宽依赖牵涉到 RDD 各级的多个父分区。

3.4 Checkpoint

基于 RDD 的依赖关系,如果任意一个 RDD 在相应的节点丢失,你只需要从上一步的 RDD 出发再次计算,便可恢复该 RDD。但是,如果一个 RDD 的依赖链比较长,而且中间又有多个 RDD 出现故障的话,进行恢复可能会非常耗费时间和计算资源。而检查点(Checkpoint)的引入,就是为了优化这些情况下的数据恢复。

很多数据库系统都有检查点机制,在连续的 transaction 列表中记录某几个 transaction 后数据的内容,从而加快错误恢复。RDD 中的检查点的思想与之类似。在计算过程中,对于一些计算过程比较耗时的 RDD,我们可以将它缓存至硬盘或 HDFS 中,标记这个 RDD 有被检查点处理过,并且清空它的所有依赖关系。同时,给它新建一个依赖于 CheckpointRDD 的依赖关系,CheckpointRDD 可以用来从硬盘中读取 RDD 和生成新的分区信息。这样,当某个子 RDD 需要错误恢复时,回溯至该 RDD,发现它被检查点记录过,就可以直接去硬盘中读取这个 RDD,而无需再向前回溯计算。

3.5 存储级别(Storage Level)

存储级别(Storage Level)是一个枚举类型,用来记录 RDD 持久化时的存储级别,常用的有以下几个:

  • MEMORY_ONLY:只缓存在内存中,如果内存空间不够则不缓存多出来的部分。这是 RDD 存储级别的默认值。
  • MEMORY_AND_DISK:缓存在内存中,如果空间不够则缓存在硬盘中。
  • DISK_ONLY:只缓存在硬盘中。
  • MEMORY_ONLY_2 和 MEMORY_AND_DISK_2 等:与上面的级别功能相同,只不过每个分区在集群中两个节点上建立副本。

这就是我们在前文提到过的,Spark 相比于 Hadoop 在性能上的提升。我们可以随时把计算好的 RDD 缓存在内存中,以便下次计算时使用,这大幅度减小了硬盘读写的开销。

3.6 迭代函数(Iterator)和计算函数(Compute)

迭代函数(Iterator)和计算函数(Compute)是用来表示 RDD 怎样通过父 RDD 计算得到的。迭代函数会首先判断缓存中是否有想要计算的 RDD,如果有就直接读取,如果没有,就查找想要计算的 RDD 是否被检查点处理过。如果有,就直接读取,如果没有,就调用计算函数向上递归,查找父 RDD 进行计算。

四、RDD 的转换操作

RDD 的数据操作分为两种:转换(Transformation)和动作(Action)。

顾名思义,转换是用来把一个 RDD 转换成另一个 RDD,而动作则是通过计算返回一个结果。

不难想到,之前举例的 map、filter、groupByKey 等都属于转换操作。

4.1 Map

map 是最基本的转换操作。与 MapReduce 中的 map 一样,它把一个 RDD 中的所有数据通过一个函数,映射成一个新的 RDD,任何原 RDD 中的元素在新 RDD 中都有且只有一个元素与之对应。


rdd = sc.parallelize(["b", "a", "c"]) #parallelize并行化集合是根据一个已经存在的Scala集合创建的RDD对象
rdd2 = rdd.map(lambda x: (x, 1)) # [('b', 1), ('a', 1), ('c', 1)]

4.2 Filter

filter 这个操作,是选择原 RDD 里所有数据中满足某个特定条件的数据,去返回一个新的 RDD。如下例所示,通过 filter,只选出了所有的偶数。


rdd = sc.parallelize([1, 2, 3, 4, 5])
rdd2 = rdd.filter(lambda x: x % 2 == 0) # [2, 4]

4.3 mapPartitions

mapPartitions 是 map 的变种。不同于 map 的输入函数是应用于 RDD 中每个元素,mapPartitions 的输入函数是应用于 RDD 的每个分区,也就是把每个分区中的内容作为整体来处理的,所以输入函数的类型是 Iterator[T] => Iterator[U]。


rdd = sc.parallelize([1, 2, 3, 4], 2)
def f(iterator): yield sum(iterator)
rdd2 = rdd.mapPartitions(f) # [3, 7]

在 mapPartitions 的例子中,我们首先创建了一个有两个分区的 RDD。mapPartitions 的输入函数是对每个分区内的元素求和,所以返回的 RDD 包含两个元素:1+2=3 和 3+4=7。

4.4 groupByKey

groupByKey 和 SQL 中的 groupBy 类似,是把对象的集合按某个 Key 来归类,返回的 RDD 中每个 Key 对应一个序列。


rdd = sc.parallelize([("a", 1), ("b", 1), ("a", 2)])
rdd.groupByKey().collect()
//"a" [1, 2]
//"b" [1]

附上API文档:https://www.codingdict.com/article/8883

五、RDD 的动作操作

动作则是通过计算返回一个结果。

5.1 Collect

RDD 中的动作操作 collect 与函数式编程中的 collect 类似,它会以数组的形式,返回 RDD 的所有元素。需要注意的是,collect 操作只有在输出数组所含的数据数量较小时使用,因为所有的数据都会载入到程序的内存中,如果数据量较大,会占用大量 JVM 内存,导致内存溢出。


rdd = sc.parallelize(["b", "a", "c"])
rdd.map(lambda x: (x, 1)).collect() // [('b', 1), ('a', 1), ('c', 1)]

实际上,上述转换操作中所有的例子,最后都需要将 RDD 的元素 collect 成数组才能得到标记好的输出。

5.2 Reduce

与 MapReduce 中的 reduce 类似,它会把 RDD 中的元素根据一个输入函数聚合起来。


from operator import add
sc.parallelize([1, 2, 3, 4, 5]).reduce(add)  // 15

5.3 Count

Count 会返回 RDD 中元素的个数。

sc.parallelize([2, 3, 4]).count() // 3

5.4 CountByKey

仅适用于 Key-Value pair 类型的 RDD,返回具有每个 key 的计数的 的 map。


rdd = sc.parallelize([("a", 1), ("b", 1), ("a", 1)])
sorted(rdd.countByKey().items()) // [('a', 2), ('b', 1)]

为什么要区分转换和动作呢?虽然转换是生成新的 RDD,动作是把 RDD 进行计算生成一个结果,它们本质上不都是计算吗?这是因为,所有转换操作都很懒,它只是生成新的 RDD,并且记录依赖关系。但是 Spark 并不会立刻计算出新 RDD 中各个分区的数值。直到遇到一个动作时,数据才会被计算,并且输出结果给 Driver。比如,在之前的例子中,你先对 RDD 进行 map 转换,再进行 collect 动作,这时 map 后生成的 RDD 不会立即被计算。只有当执行到 collect 操作时,map 才会被计算。而且,map 之后得到的较大的数据量并不会传给 Driver,只有 collect 动作的结果才会传递给 Driver。

这种惰性求值的设计优势是什么呢?让我们来看这样一个例子。假设,你要从一个很大的文本文件中筛选出包含某个词语的行,然后返回第一个这样的文本行。你需要先读取文件 textFile() 生成 rdd1,然后使用 filter() 方法生成 rdd2,最后是行动操作 first(),返回第一个元素。读取文件的时候会把所有的行都存储起来,但我们马上就要筛选出只具有特定词组的行了,等筛选出来之后又要求只输出第一个。这样是不是太浪费存储空间了呢?确实。所以实际上,Spark 是在行动操作 first() 的时候开始真正的运算:只扫描第一个匹配的行,不需要读取整个文件。所以,惰性求值的设计可以让 Spark 的运算更加高效和快速。

让我们总结一下 Spark 执行操作的流程吧:Spark 在每次转换操作的时候,使用了新产生的 RDD 来记录计算逻辑,这样就把作用在 RDD 上的所有计算逻辑串起来,形成了一个链条。当对 RDD 进行动作时,Spark 会从计算链的最后一个 RDD 开始,依次从上一个 RDD 获取数据并执行计算逻辑,最后输出结果。

六、RDD 的持久化(缓存)

每当我们对 RDD 调用一个新的 action 操作时,整个 RDD 都会从头开始运算。因此,如果某个 RDD 会被反复重用的话,每次都从头计算非常低效,我们应该对多次使用的 RDD 进行一个持久化操作。

Spark 的 persist() 和 cache() 方法支持将 RDD 的数据缓存至内存或硬盘中,这样当下次对同一 RDD 进行 Action 操作时,可以直接读取 RDD 的结果,大幅提高了 Spark 的计算效率。


rdd = sc.parallelize([1, 2, 3, 4, 5])
rdd1 = rdd.map(lambda x: x+5)
rdd2 = rdd1.filter(lambda x: x % 2 == 0)
rdd2.persist()
count = rdd2.count() // 3
first = rdd2.first() // 6
rdd2.unpersist()

在文中的代码例子中你可以看到,我们对 RDD2 进行了多个不同的 action 操作。由于在第四行我把 RDD2 的结果缓存在内存中,所以 Spark 无需从一开始的 rdd 开始算起了(持久化处理过的 RDD 只有第一次有 action 操作时才会从源头计算,之后就把结果存储下来,所以在这个例子中,count 需要从源头开始计算,而 first 不需要)。

在缓存 RDD 的时候,它所有的依赖关系也会被一并存下来。所以持久化的 RDD 有自动的容错机制。如果 RDD 的任一分区丢失了,通过使用原先创建它的转换操作,它将会被自动重算。

持久化可以选择不同的存储级别。正如我们讲 RDD 的结构时提到的一样,有 MEMORY_ONLY,MEMORY_AND_DISK,DISK_ONLY 等。cache() 方法会默认取 MEMORY_ONLY 这一级别。

参考资料:

  1. 弹性分布式数据集:Spark大厦的地基(上)
  2. 弹性分布式数据集:Spark大厦的地基(下)
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值