RDD基础

RDD(Resilient Distributed Dataset),即弹性分布式数据集。它是分布在多个计算机节点上可并行操作元素集合,是Spark主要的编程抽象。

RDD是不可变的分布式对象集合,每个RDD都被分为多个分区、可以运行在集群中不同的节点上。

它是Spark对数据的核心抽象,Spark中对数据的操作,不外乎就是创建RDD、转化已有的RDD以及调用RDD操作进行求值

创建RDD

创建一个RDD主要有两种方式,分别是读取外部数据集,以及在驱动器程序中对一个集合进行并行化

一般在学习中经常会直接将一个已有的集合传递给SparkContext的parallelize()方法:

In [1]: lines = sc.parallelize(['Hello,', 'Spark!'])

不过因为这样的方法会提前将一个数据集放入到一台机器的内存中,造成不必要的空间占用,所以在正式的开发与测试中很少使用这样的方式创建RDD。

更常用的方式是从外部读取数据集,如使用SparkContext的textFile()方法读取一个文本文件:

In [3]: lines = sc.textFile('spark-2.4.0-bin-hadoop2.7/README.md')

RDD操作

RDD的操作,主要有转化操作(transformation)和行动操作(action)两种。

转化操作

定义

对一个已有的RDD进行转化操作,将返回一个新的RDD。

比如我们可以使用filter()方法对一个已有的RDD进行转化,从中筛选我们需要的数据:

In [5]: lines = sc.textFile('spark-2.4.0-bin-hadoop2.7/README.md')                                                                                                                                                                                                            

In [6]: pythonLines = lines.filter(lambda line: 'Python' in line)                                                                                                                                                                                                             

In [7]: pythonLines.collect()                                                                                                                                                                                                                                                 
Out[7]: 
['high-level APIs in Scala, Java, Python, and R, and an optimized engine that',
 '## Interactive Python Shell',
 'Alternatively, if you prefer Python, you can use the Python shell:']

上面的代码中,lines是通过从README.md文件中读取数据集创建的RDD,在对lines调用filter()方法后,筛选出README.md中包含字符串“Python”的行得到了pythonLines这样一个新的RDD。

特点

惰性求值,即在进行转化操作时,并不会对数据集进行存储或计算,只有在调用行动操作后,才会真正地进行计算。

拿上面的例子来看,试想一下,如果在转化操作时就进行存储与计算的话,会发生什么——调用textFile()后,我们将README.md文件中的数据直接存放至内存,调用filter()后,对数据进行筛选,将筛选出的数据又放入内存,调用行动操作collect()后将筛选出的数据打印出来——我们真的有必要将整个README.md放入内存中么?我们需要打印的数据仅仅是包含字符串“Python”的行而已,并不需要存取整个文件而导致多余的内存占用。

使用惰性求值,能够通过将操作整合到一起以减少计算数据的步骤,避免不必要的资源浪费,同时,在开发时,我们不需要把心思花在组织一次庞大的操作,而只需要通过一次次小的操作来构建程序,在真正计算时由Spark为我们将这些小操作组织起来,这样也更利于操作的管理

简单总结一下,当我们对RDD调用转化操作时,操作并不会立即执行,Spark只会在内部记录下所要求执行的操作的相关信息。由此可见,RDD并不是存放了特定数据的数据集,而是我们通过转化操作构建出的记录如何计算数据的指令列表

值得注意的是,从已有的RDD中派生出新的RDD时,Spark会使用谱系图来记录它们之间的依赖关系。这样是为了能够按需计算每个RDD,以及持久化RDD来在数据发生丢失时进行一定程度的恢复。

常见方法

1.map():接收一个函数,把这个函数用于RDD中的每个元素,将函数的返回值作为结果RDD中对应的元素的值。

In [8]: sentences = sc.parallelize(['I love cake', 'Mary loves cookie'])                                                                                                                                                                                                      

In [9]: mapLines = sentences.map(lambda line: line.split(' '))                                                                                                                                                                                                                

In [10]: mapLines.collect()                                                                                                                                                                                                                                                   
Out[10]: [['I', 'love', 'cake'], ['Mary', 'loves', 'cookie']]

代码中的lambda表达式是从RDD中接收数据,并将每个接收到的字符串数据调用split()方法。

2.flatMap():与map()方法类似,该方法接收一个函数,把这个函数应用于RDD中的每个元素,但是它将返回一个值序列的迭代器。这样说可能有点抽象,不如看看例子:

In [11]: flatMapLines = sentences.flatMap(lambda line: line.split(' '))                                                                                                                                                                                                       

In [12]: flatMapLines.collect()                                                                                                                                                                                                                                               
Out[12]: ['I', 'love', 'cake', 'Mary', 'loves', 'cookie']

3.filter():接收一个函数,将满足该函数的值放入新的RDD中返回。这个方法在前文已有举例,不再单独说明。

4.union():合并操作,这个方法合并两个RDD后,会保留两个数据集中相同的元素。

5.intersection():与union()方法类似,不过这个方法将会剔除两个数据集中重复的元素,不过因为需要经过网络混洗数据来发现共有元素,所以性能较差。

6.subtract():从一个RDD中剔除另一个RDD中存在的元素。也需要网络混洗,存在性能较差的问题。

7.cartesian():计算两个RDD中元素的笛卡尔积。求大规模的RDD笛卡尔积时,开销将会很大。

行动操作

定义

对数据集进行实际的计算,会将最终求得的结果返回到驱动器程序、或写入外部存储系统中。

常见方法

1.reduce():接收一个函数,这个函数会操作RDD的两个元素,返回一个类型相同的新元素。比如对一个数据集中的元素进行累加:

In [14]: nums = sc.parallelize([1, 2, 3, 4 ,5, 6, 7, 8, 9])                                                                                                                                                                                                                   

In [15]: nums.reduce(lambda x, y: x + y)                                                                                                                                                                                                                                      
Out[15]: 45

2.fold():与reduce()类似,不过fold()方法需要设置一个初始值,在计算时,初始值会参与每个分区的计算,在最后对每个分区的合并计算时,初始值也会参与。这样说或许有些抽象,还是通过一个例子来看一下:

In [59]: numsDivide = sc.parallelize([1, 2, 3, 4, 5, 6, 7, 8, 9], 2)                                                                                                                                                                                                          

In [60]: numsDivide.fold(10, lambda x, y: x + y)                                                                                                                                                                                                                              
Out[60]: 75

In [61]: numsDivide = sc.parallelize([1, 2, 3, 4, 5, 6, 7, 8, 9], 3)                                                                                                                                                                                                          

In [62]: numsDivide.fold(10, lambda x, y: x + y)                                                                                                                                                                                                                              
Out[62]: 85

第一个numsDivide有两个分区,先看做[1, 2, 3, 4]、[5, 6, 7, 8],初始值为10,在计算时,先对第一个分区的元素进行累加,同时要加上初始值,第二个分区也是如此,最后将两个分区的累加值相加合并,并且再次加上初始值,得到结果75。对于后面3个分区的计算同理。

3.aggregate():先来思考一下,对nums这个RDD中的元素求平均值,该怎么做——reduce()和fold()都要求返回值类型与元素类型相同,但是在求平均值时,我们需要各元素的累加和以及元素的总数,所以我们需要得到一个二元组才能进行计算,所以如果使用reduce()求平均值,我们需要先用map()进行一次转换:

In [63]: sumCount = nums.map(lambda x: (x, 1)).reduce(lambda x, y: (x[0] + y [0], x[1] + y[1]))                                                                                                                                                                               

In [64]: sumCount[0] / sumCount[1]                                                                                                                                                                                                                                            
Out[64]: 5.0

对于aggregate()方法,这样的过程则会简单一些,使用这个方法可以将我们从返回类型必须和元素类型一致的限制中解放出来,和fold()类似的,aggregate()也需要提供一个初始值,参与每个分区以及分区合并时的计算,来看看用aggregate()求平均值该怎么实现:

In [67]: sumCount = nums.aggregate((0, 0), 
    ...:             (lambda acc, value: (acc[0] + value, acc[1] + 1)), 
    ...:             (lambda acc1, acc2: (acc1[0] + acc2[0], acc1[1] + acc2[1])))                                                                                                                                                                                             

In [68]: sumCount[0] / sumCount[1]                                                                                                                                                                                                                                            
Out[68]: 5.0

第一个参数即初始值,第二个参数是一个函数,用来对每个分区进行计算,将元素的累加和与元素个数作为一个二元组,第三个参数也是一个函数,用来合并各个分区,在这个例子中,nums这个RDD只有一个分区。

还有一些将RDD的元素值以集合或单个值的形式返回给驱动器程序的方法,比如之前的例子中已经出现过的collect(),就是将RDD中的所有元素返回,这个方法在涉及数据规模不那么庞大的单元测试中经常用到,因为这个方法会直接把数据返回到内存中,使用时对数据量一定要有所考虑。相对应地,take()方法则能返回指定数量的元素,top()方法会按照默认的排序方式将元素排序后返回指定数量的元素。first()方法能返回第一个元素。count()方法返回元素个数。

值得注意的是,有些函数只能用于特定类型的RDD

持久化

如前文所述,RDD总是惰性求值的,这意味着每次调用行动操作时,都会进行重新求值,但是对于会频繁使用到的RDD来说,尤其是对于迭代计算,这样的开销其实还是很大的,所以Spark也提供了缓存的功能。当Spark持久化一个RDD时,会将值存储在计算该RDD的节点上,当该节点的持久化数据发生丢失时,又会对RDD重新进行计算。如果想要避免持久化数据丢失带来的性能影响,我们也可以将持久化数据备份到多个节点上。

进行持久化,需要调用persist()方法,而根据操作需求的不同,又可以选择不同的持久化级别:

spark RDD是放在内存的吗_Spark基础

In [73]: import pyspark
In [78]: nums.persist(pyspark.StorageLevel.MEMORY_ONLY_SER_2)                                                                                                                                                                                                                 
Out[78]: ParallelCollectionRDD[23] at parallelize at PythonRDD.scala:195
In [79]: nums.unpersist()                                                                                                                                                                                                                                                     
Out[79]: ParallelCollectionRDD[23] at parallelize at PythonRDD.scala:195

在持久化级别后面加上“_2”,意味着将持久化数据存为两份。调用unpersist()解除持久化手动将持久化数据从缓存中移除。

当需要缓存的数据太多内存放不下时,Spark会根据LRU策略,自动将最近最少用到的缓存从内存中移除。对于仅仅是将数据存放到内存的缓存级别来说,每次重新用到已被移除的缓存时,都会造成重新计算,而对于使用内存与磁盘的缓存级别来说,每一次移除的分区都将写入磁盘。无论是哪种情况,作业都不会因缓存了太多数据而被打断。但是,缓存不必要的数据自然会导致有用的数据可能被移除,因而造成不必要的计算开销,所以在设计缓存时还是要多加斟酌。

小结

RDD是学习Spark的基础,本文对RDD的知识进行了一些简单的总结归纳,我们可以按照下面的思维导图来进行学习与巩固,不妨看着图对一些知识进行回想,当你全部能在心中有个回答的时候,那就说明你已掌握得差不多了:

spark RDD是放在内存的吗_spark RDD是放在内存的吗_02