Spark权威指南(中文版)----第12章 弹性分布式数据集RDD
本书的前一部分介绍了Spark的结构化api。在几乎所有的计算场景中,您都应该优先使用这些api。话虽如此,有时只使用Higher-Level API无法解决你的问题。对于这些情况,您可能需要使用Spark的底层api,特别是弹性分布式数据集(RDD)、SparkContext,以及分布式共享变量,如累加器和广播变量。接下来的章节将介绍这些api以及如何使用它们。
警告如果你是Spark新手,请不要从这个地方学习。从结构化的api开始,您将更快地提高生产率! |
12.1. 什么是Low-Level api
有两组low-level api:一组用于操作分布式数据(RDDs),另一组用于分发和操作分布式共享变量(广播变量和累加器)。
12.1.1. 何时使用low-level api ?
您应该在以下三种情况下使用low-level api:
-
您需要一些在高级api中无法找到的功能; 例如,如果您需要非常严格地控制跨集群的物理数据放置。
-
您需要维护一些使用RDDs编写的遗留代码库。
-
您需要执行一些自定义的共享变量操作。我们将在第14章详细讨论共享变量。
这些是您应该使用low-level API的原因,但是,理解这些API仍然很有帮助,因为所有的Spark工作负载都可以编译成这些基本元素。当您调用DataFrame转换时,它实际上只是一组RDD转换。当开始调试越来越复杂的工作负载时,这种理解可以简化您的任务。
即使您是一名高级开发人员,希望能从Spark中获得最大的好处,我们仍然建议您关注结构化api。但是,有时您可能想要使用一些较低级的工具来完成您的任务。您可能需要使用这些api来使用一些遗留代码,实现一些自定义分区器,或者在数据管道执行过程中更新和跟踪变量的值这些工具为您提供了更细粒度的控制,但代价是防止搬起石头砸自己的脚。
12.1.2. 如何使用 Low-Level APIs?
SparkContext是Low-Level API功能的入口点。您可以通过SparkSession访问它,SparkSession是在Spark集群中执行计算的工具。我们将在第15章进一步讨论,但是现在,您只需知道您可以通过以下调用访问SparkContext:
spark.sparkContext
12.2. 关于RDD
RDDs是Spark 1. X系列版本中的主要API。在Spark2.x版本中仍然可以使用,但并不常用。然而,正如我们在本书前面所指出的,几乎所有您运行的Spark代码,无论是DataFrames还是Datasets,都可以编译为RDD。在本书的下一部分介绍的Spark UI中,也描述了关于RDDs的作业执行情况。因此,您至少应该对RDD是什么以及如何使用它有一个基本的了解。
简而言之,RDD表示一个不可变的、分区的记录集合,可以并行操作。但是,与DataFrames(每个记录都是结构化的行,包含字段信息(schema))不同,在RDDs中,记录只是程序员选择的Java、Scala或Python对象。
RDDs给您完全的控制,因为RDD中的每个记录都是一个Java或Python对象。你可以在这些对象中用任何你想要的格式存储任何你想要的东西。这给了你很大的力量,但不是没有潜在的问题。每个操作和值之间的交互都必须手动写代码定义,这意味着无论你想要执行什么任务,你都必须“重新发明轮子”。此外,优化还需要更多的手工工作,因为Spark并不像使用结构化api那样理解您的记录的内部结构。例如,Spark的结构化api会自动将数据存储在优化的、压缩的二进制格式中,从而实现相同的空间效率和性能,您还需要在对象中实现这种格式,并在所有底层操作中进行计算。类似地,在Spark SQL中自动出现的重新排序过滤器和聚合等优化需要手工实现。出于这个原因和其他原因,我们强烈建议在可能的情况下使用Spark结构化api。
RDDAPI与我们在书的前一部分看到的Dataset相似,只是RDDs没有存储在结构化的数据引擎中,或者说没有使用结构化的数据引擎操作数据。但是,在RDDs和Dataset之间来回转换是很简单的,所以您可以使用两个API,来利用每个API的优缺点。我们将在本书的这一部分展示如何做到这一点。
12.2.1. RDD的类型
如果您查看Spark的API文档,您会注意到有很多RDD的子类。在大多数情况下,这些是DataFrame API用于创建优化的物理执行计划的内部表示。然而,作为一个Spark开发人员/用户,您可能只会创建两种类型的RDDs:“通用的”RDD类型或提供附加功能的键值RDD,例如按键聚合。就您的目的而言,这将是唯一重要的两种类型的RDDs。它们都只是表示对象的集合,但是键值RDDs有特殊的操作,以及按键进行自定义分区的概念。
让我们正式定义RDD。在内部,每个RDD的特征包括五个主要属性:
-
一个分区列表
-
一个计算方法,用于计算每一个数据分区
-
一个RDD的依赖列表
-
一个Partitioner(分区器),对于键值对RDD,此项可选
-
一个数据分区计算的首选位置列表(例如,用于Hadoop分布式文件系统[HDFS]文件的块位置。),此项可选
注意
Partitioner可能是你在代码中使用RDDs的核心原因之一。如果您正确地使用它,指定您自己的自定义分区可以给您显著的性能和稳定性改进。在第13章中,当我们引入键值对RDDs时,这将更深入地讨论。
上述的5个属性决定了Spark计划调度和执行用户程序的能力。不同类型的RDDs实现各自版本的上述属性,允许您定义新的数据源。
RDDs遵循我们在前几章中看到的完全相同的Spark编程范式。提供了transformation操作(具有延迟计算特性)和action操作(触发真正的计算),以分布式方式操作数据。这些工作与在DataFrames和Dataset数据集上的transformation和action操作相同。但是,在RDDs中没有“rows”的概念; 单个记录只是原始的Java/Scala/Python对象。您可以手动操作这些,而不是使用结构化api中的函数库。
RDDapi在Python和Scala和Java中都可以使用。对于Scala和Java,性能在很大程度上是相同的,巨大的性能开销,产生在操作原始对象时。然而,在使用RDDs时,Python可能会损失大量的性能。运行Python RDDs等于逐行运行Python用户定义函数(udf)。正如我们在第六章中看到的。我们将数据序列化到Python进程中,在Python中对其进行操作,然后将其序列化回Java虚拟机(JVM)。这将导致PythonRDD操作的高开销。尽管过去有很多人用它们来运行生产代码,但我们建议在Python中使用结构化api,如果不是绝对必要的话,不要使用RDD Low-Level API。
12.2.2. 何时使用RDD
一般来说,除非您有一个非常非常具体的原因,否则您不应该手动创建RDDs。它们是一个更低级的API,提供了大量的功能,但也缺少结构化API中可用的许多优化。对于绝大多数的用例来说,DataFrames将比RDDs更高效、更稳定、更有表现力。
为什么要使用RDDs的最可能原因是您需要对数据的物理分布(数据的自定义分区)进行细粒度的控制。
12.2.3. Datasets和caseclass类型的 RDDs
我们在网上看到这个问题,发现它很有趣:Case class类型的RDD和Dataset有什么不同?不同之处在于,Dataset仍然可以利用结构化api所提供的丰富的功能和优化。对于Dataset,您不需要在JVM类型或Spark类型之间进行选择,您可以选择最容易做的或最灵活的。
12.3. 创建RDD
现在我们讨论了一些关键的RDD属性,让我们开始应用它们,以便更好地理解如何使用它们。
12.3.1. DataFrames、dataset和RDDs之间的互操作
获取RDDs的最简单方法之一是来自现有的DataFrame或Dataset。将这些数据转换为RDD很简单:只需在任何这些数据类型上使用rdd()方法即可。您会注意到,如果从Dataset[T]转换到RDD,您将获得适当的本机类型T(记住这只适用于Scala和Java):
// in Scala: converts a Dataset[Long] to RDD[Long]
spark.range(500).rdd
因为Python没有Dataset—它只有dataframe—您将得到类型Row的RDD:
spark.range(10).rdd
要对这个数据进行操作,您需要将这个Row对象转换为正确的数据类型或从中提取值,如下面的示例所示。这是一个RDD类型Row:
// in Scala
spark.range(10).toDF().rdd.map(rowObject => rowObject.getLong(0))
# in Python
spark.range(10).toDF("id").rdd.map(lambda row: row[0])
您可以使用相同的方法从RDD中创建DataFrame或Dataset。你所需要做的就是在RDD上调用toDF方法:
// in Scala
spark.range(10).rdd.toDF()
# in Python
spark.range(10).rdd.toDF()
这个命令创建一个Row类型的RDD。row是Spark用于在结构化api中表示数据的内部Catalyst格式。这个功能使您可以在结构化和低级api之间进行跳转,如果它适合您的用例。(我们在第13章讨论过这个问题。)
RDDAPI与第11章中的DatasetAPI非常相似,因为它们非常相似(RDDs是Dataset的底层表示),但是RDD没有结构化API所做的许多方便的功能和接口。
12.3.2. 从本地集合创建RDD
要从集合中创建一个RDD,您需要使用SparkContext(在SparkSession中)上的parallelize方法。这将单个节点集合变为并行集合。在创建这个并行集合时,您还可以显式地声明您想要分配该数组的分区的数量。在这种情况下,我们创建两个分区:
// in Scala
val myCollection = "Spark The Definitive Guide : Big Data Processing Made Simple" .split(" ")
val words = spark.sparkContext.parallelize(myCollection, 2)
# in Python
myCollection = "Spark The Definitive Guide : Big Data Processing Made Simple" .split(" ")
words = spark.sparkContext.parallelize(myCollection, 2)
另外一个特性是,您可以根据给定的名称在Spark UI中显示这个RDD:
// in Scala
words.setName("myWords")
words.name // myWords
words.setName("myWords")
words.name()
12.3.3. 从数据源中创建RDD
尽管可以从数据源或文本文件创建RDDs,但使用Data Source api通常更可取。RDDs不像DataFrames那样有“DataSource api”的概念; 它们主要定义它们的依赖结构和分区列表。我们在第9章中看到的数据源API几乎总是一种更好的数据读取方式。也就是说,您也可以使用sparkContext读取数据作为RDDs。例如,让我们逐行读取一个文本文件:
spark.sparkContext.textFile("/some/path/withTextFiles")
这将创建一个RDD,其中RDD中的每个记录表示该文本文件中的一行。或者,您可以读取每个文本文件成为单个记录的数据。这里的用例是每个文件都是一个文件,它由一个大的JSON对象或一些你将作为个人操作的文档组成:
spark.sparkContext.wholeTextFiles("/some/path/withTextFiles")
在这个RDD中,文件的名称是第一个对象,文本文件的值是第二个字符串对象。
12.4. RDD操作
你操作RDDs的方式和你操作DataFrame的方式是一样的。如上所述,核心区别在于您操纵原始Java或Scala对象而不是Spark类型。还缺少可以用来简化计算的辅助函数。相反,您必须定义每个filter,map函数、聚合以及您想要作为函数的任何其他操作。为了演示一些数据操作,让我们使用前面创建的简单RDD(words)来定义更多的细节。
12.4.1. Transformations
在大多数情况下,许多transformation对应了在结构化api中提供的功能。正如您使用DataFrames和Datasets一样,您可以在一个RDD上指定transformation来创建另一个RDD。在这样做的过程中,我们将RDD定义为对另一个RDD的依赖,以及对RDD中包含的数据的一些操作。
12.4.1.1. distinct方法
一个distinct的方法调用RDD,从RDD中删除重复数据:
words.distinct().count()//结果是10。
12.4.1.2. filter方法
filter()操作相当于创建一个类似sql的where子句。您可以在RDD中查看我们的记录,并查看哪些匹配了谓词功能。这个函数只需要返回一个布尔类型作为filter函数。输入应该是给定的数据行。在下一个示例中,我们过滤RDD,仅保留以字母“S”开头的单词:
// in Scala
def startsWithS(individual:String) = {
individual.startsWith("S")
}
# in Python
def startsWithS(individual):
return individual.startswith("S")
现在我们已经定义了函数,我们来过滤一下数据。如果您阅读了第11章,这将使您感到非常熟悉,因为我们只是使用了一个函数逐行作用于在RDD中的记录。该函数定义为在RDD中的每个记录上单独工作:
// in Scala
words.filter(word => startsWithS(word)).collect()
# in Python
words.filter(lambda word: startsWithS(word)).collect()
这就产生了Spark 和 Simple结果。我们可以看到,像Dataset API一样,它返回原生类型。这是因为我们从不强制我们的数据转换为Row类型,也不需要在收集数据之后转换数据。
12.4.1.3. map方法
将传入map方法的函数应用于rdd中的每一条记录,并返回在代码中指定的值。
// in Scala
val words2 = words.map(word => (word, word(0), word.startsWith("S")))
随后,您可以通过在一个新函数中选择相关的布尔值来对此进行过滤:
// in Scala
words2.filter(record => record._3).take(5)
12.4.1.4. flatmap方法
flatmap方法对map方法进行了扩展。有时候,一行数据,经过处理后,可能需要返回多行结果。例如,您可能希望将您的一组单词flatmap成一组字符。因为每一个单词,包含多个字符,所以,需要使用flatmap函数来处理。flatmap要求map函数的输出是可迭代的,可以展开:
生成S, P, A, R, K.。
12.4.1.5. sortBy方法
要对RDD进行排序,必须使用sortBy方法,就像任何其他RDD操作一样,通过指定一个函数从RDDs中的对象中提取一个值,然后基于此进行排序。例如,下面的例子以单词长度从最长到最短排序:
// in Scala
words.sortBy(word => word.length() * -1).take(2)
12.4.1.6. RandomSplits
我们也可以使用randomSplit方法将一个RDD随机分割为一个RDDs数组,该方法接受一个权值数组和一个随机种子:
这将返回一个RDDs数组,您可以单独操作该数组。
12.4.2. action操作
action操作触发了transformation操作的真正执行。action操作要么向driver程序收集数据,要么向外部数据源写入数据。
12.4.2.1. reduce方法
可以使用reduce方法,为其指定一个函数,然后其会将RDD中数据任意个数的数据值合并为一个值。例如,一个RDD中包含数字集合,可以使用reduce方法将这些数字相加,最后形成一个相加结果值。
// in Scala
spark.sparkContext.parallelize(1 to 20).reduce(_ + _) // 210
# in Python
spark.sparkContext.parallelize(range(1, 21)).reduce(lambda x, y: x + y) # 210
也可以用这个来得到一些像我们刚才定义的单词中最长的单词。实现关键是要定义正确的函数:
// in Scala
def wordLengthReducer(leftWord:String, rightWord:String): String = {
if (leftWord.length > rightWord.length)
return leftWord
else
return rightWord
}
words.reduce(wordLengthReducer)
12.4.2.2. count方法
可以使用它计算RDD中的数据行数:words.count()
12.4.2.3. countByValue方法
该方法计算给定RDD中值的数量。但是,它最终将结果集加载到driver程序的内存中。您应该仅在预期结果map很小的情况下使用此方法,因为整个结果集将加载到驱动程序的内存中。因此,这种方法仅在一个场景中,即行总数较低或不同项的数量较低的情况下才有意义:
words.countByValue()
12.4.2.4. first方法
返回结果集的第一个值。
12.4.2.5. max/min方法
12.4.2.5. take方法
take方法及衍生方法(takeOrdered,takeSample, 和 top),首先扫描一个分区partition,然后使用该分区的结果来估计满足这个限制所需的额外分区的数量。top()实际上是takeOrdered()的反面,它根据隐含的顺序选择最上面的值:
words.take(5)
words.takeOrdered(5)
words.top(5)
val withReplacement = true
val numberToTake = 6
val randomSeed = 100L
words.takeSample(withReplacement, numberToTake, randomSeed)
12.4.3. 保存结果数据到文件(action操作)
保存结果数据到文件意味着写入纯文本文件。对于RDDs,不能“save”到传统意义上的数据源。您必须遍历这些分区,以便将每个分区的内容保存到一些外部数据库中。这是一种low-level的方法,它揭示了在高级api中正在执行的底层操作。Spark将处理每个分区的数据,并将其写到目的地。
12.4.3.1. saveAsTextFile
要保存到文本文件,只需指定路径和可选的压缩编解码器:
words.saveAsTextFile("file:/tmp/bookTitle")
要设置压缩编解码器,我们必须从Hadoop导入适当的编解码器。您可以在org.apache.hadoop. iot .compress包中找到这些内容。
// in Scala
import org.apache.hadoop.io.compress.BZip2Codec
words.saveAsTextFile("file:/tmp/bookTitleCompressed", classOf[BZip2Codec])
12.4.3.2. sequenceFile
Spark最初源于Hadoop生态系统,因此它与各种Hadoop工具的集成相当紧密。sequenceFile是由二进制键值对组成的平面文件。它广泛用于MapReduce程序的输入/输出文件格式。
Spark可以使用saveAsObjectFile方法或显式地编写键值对来写入sequencefile,如第13章所述:
words.saveAsObjectFile("/tmp/my/sequenceFilePath")
12.4.3.3. hadoopfile
可以保存各种不同的Hadoop文件格式。这些允许您指定类、输出格式、Hadoop配置和压缩方案(有关这些格式的信息,请阅读Hadoop:权威指南[O 'Reilly, 2015]。)。这些格式在很大程度上是不相关的,除非您是在Hadoop生态系统中深入工作,或者使用一些遗留的mapReduce作业。
12.5. Caching缓存
DataFrames和Dataset有缓存策略,同样的原则也适用于为缓存RDDs。您可以缓存或持久化RDD。默认情况下,缓存和持久化只处理内存中的数据。
words.cache()
我们可以将一个存储级别指定为singleton对象中的任何存储级别:org.apache.spark.storage。StorageLevel,它仅是组合;磁盘,内存,堆外内存的任意组合。
随后我们可以查询这个存储级别(在第20章讨论持久性时,我们讨论了存储级别):
12.6. Checkpointing
DataFrame API中不可用的一个特性是检查点的概念。检查点是将RDD保存到磁盘的行为,以便将来对该RDD的引用指向磁盘上的中间结果分区,而不是从原始源重新计算RDD。这类似于缓存,只不过它不是存储在内存中,而是存储在磁盘中。这在执行迭代计算时非常有用,类似于缓存的用例:
spark.sparkContext.setCheckpointDir("/some/path/for/checkpointing")
words.checkpoint()
现在,当我们引用这个RDD时,它将来自检查点而不是源数据。这可能是一个有益的优化。
12.7. 将RDDs传输到系统命令
pipe方法可能是Spark比较有趣的方法之一。使用pipe,可以将管道元素创建的RDD返回到分叉的外部流程。结果RDD是通过每个分区执行一次给定的进程来计算的。每个输入分区的所有元素都被写入进程的stdin标准输入,作为由换行分隔的输入行。生成的分区由进程的stdout输出组成,每一行stdout都会生成输出分区的一个元素。即使对于空分区,也会调用进程。
可以通过提供两个函数定制打印行为。
我们可以使用一个简单的例子,将每个分区通过管道传输到命令wc。每一行都将作为新行传递进来,所以如果我们执行行数,我们将得到行数,每个分区有一行:
在这种情况下,每个分区有5行。
12.8. mapPartitions
前面的命令显示,在实际执行代码时,Spark基于每个分区进行操作。您可能已经注意到,RDD上map函数的返回签名实际上是MapPartitionsRDD。这是因为map只是mapPartitions的一个行式别名,这使得映射单个分区(表示为迭代器)成为可能。这是因为在集群上,我们分别操作每个分区(而不是特定的行)。一个简单的例子为我们的数据中的每个分区创建值“1”,下面的表达式的和将计算我们有多少个分区:
当然,这意味着我们在基于每个分区进行操作,并允许我们在整个分区上执行操作。这对于在RDD的整个子数据集上执行某些操作非常有用。您可以将一个分区类或组的所有值收集到一个分区中,然后使用任意函数和控件对整个组进行操作。这方面的一个示例用例是,您可以通过一些自定义机器学习算法对其进行管道传输,并为该公司的数据集部分训练一个单独的模型。Facebook的一名工程师在2017年东部Spark峰会上展示了一个类似的用例,展示了他们对管道操作的特殊实现。
与mapPartitions类似的其他函数包括mapPartitionsWithIndex。使用这个函数,您可以指定一个接受索引(在分区内)的函数和一个遍历分区内所有项的迭代器。分区索引是RDD中的分区号,它标识数据集中每个记录的位置(并且可能允许您调试)。你可以用这个来测试你的map函数是否正确:
12.9. foreachPartition
虽然mapPartitions需要一个返回值才能正常工作,但是下一个函数不需要。foreachPartition只是遍历数据的所有分区。区别在于此函数没有返回值。这对于处理每个分区非常有用,比如将其写入数据库。实际上,这是编写了多少数据源连接器。你可以创建我们自己的文本文件源,如果你想通过指定输出到临时目录与随机ID:
12.10. glom
glom是一个有趣的函数,它接受数据集中的每个分区并将其转换为数组。如果要将数据收集到driver程序中,并且希望每个分区都有一个数组,那么这将非常有用。然而,这可能会导致严重的稳定性问题,因为如果您有大的分区或大量的分区,driver程序很容易崩溃。
在下面的例子中,你可以看到我们得到了两个分区,每个单词都属于一个分区:
12.11. 结束语
在本章中,您了解了RDD api的基础知识,包括单RDD操作。第13章涉及更高级的RDD概念,如join和key-valueRDDs。