Spark权威指南(中文版)----第13章 高级RDD操作
第12章探讨了单一RDD操作的基础。您学习了如何创建RDDs以及为什么要使用它们。此外,我们还讨论了map、filter、reduce以及如何创建函数来转换单个RDD数据。本章将介绍高级的RDD操作,并关注键值RDDs,这是一种用于操作数据的强大抽象。我们还讨论了一些更高级的主题,比如自定义分区,这是您可能希望首先使用RDDs的原因。通过使用自定义分区函数,您可以精确地控制数据如何在集群中布局,并相应地操作该分区。在我们开始之前,让我们总结一下我们将要讨论的主要话题:
- 聚合和键值RDD
- 自定义分区
- RDD join操作
让我们使用我们在上一章使用的数据集:
// in Scala
val myCollection = "Spark The Definitive Guide : Big Data Processing Made Simple" .split(" ")
val words = spark.sparkContext.parallelize(myCollection, 2)
13.1. Key-Value RDDs
在RDDs上有许多方法(…ByKey结尾的方法)要求您将数据以键值格式表示。每当您在方法名称中看到ByKey时,就意味着您只能在PairRDD类型上执行此操作。最简单的方法是将当前的RDD映射到一个基本的键值结构。这意味着在RDD的每个记录中有两个值:
// in Scala
words.map(word => (word.toLowerCase, 1))
13.1.1. keyBy
前面的示例演示了创建key的简单方法。但是,您还可以使用keyBy函数来实现相同的结果,指定一个从当前值创建key的函数。在本例中,你是在用单词中的第一个字母作为key,将原始记录作为key对应的值。
// in Scala
val keyword = words.keyBy(word => word.toLowerCase.toSeq(0).toString)
13.1.2. mapValues
在您拥有一组键值对之后,就可以开始操作它们了。如果我们有一个元组tuple,Spark会假设第一个元素是键,第二个元素是值。在这种格式下,您可以显式地选择map-over值(并忽略单个键)。当然,您可以手动进行操作,但是当您知道您要修改这些值时,mapValues可以帮助你防止错误:
// in Scala
keyword.mapValues(word => word.toUpperCase).collect()
13.1.3. 提取key和value
当我们使用键值对格式时,我们还可以使用以下方法提取的键或值:
// in Scala
keyword.keys.collect()
keyword.values.collect()
13.1.4. lookup
您可能想要使用RDD的一个有趣的任务是查找特定键的结果。如果我们查找“s”,我们将得到与“Spark”和“Simple”相关的两个值:
keyword.lookup("s").foreach(println)
13.2. Aggregations聚合
您可以在普通的RDDs或PairRDDs上执行聚合,这取决于您使用的方法。让我们用我们的一些数据集来证明这一点:
// in Scala
val chars = words.flatMap(word => word.toLowerCase.toSeq)
val KVcharacters = chars.map(letter => (letter, 1))
def maxFunc(left:Int, right:Int) = math.max(left, right)
def addFunc(left:Int, right:Int) = left + right
val nums = sc.parallelize(1 to 30, 5)
在您做完准备工作之后,您可以做一些类似countByKey的事情,它计算每个键值数据。
13.2.1. countByKey
您可以计算每个键的元素数量,将结果收集到本地map中。您还可以使用一个近似方法来实现这一点,这使得您可以在使用Scala或Java时指定一个超时和信心值:
// in Scala
val timeout = 1000L //milliseconds
val confidence = 0.95
KVcharacters.countByKey()
KVcharacters.countByKeyApprox(timeout, confidence)
13.2.2. 理解聚合的实现方式
有几种方法可以创建键值PairRDDs。然而,这个实现对于工作稳定性来说非常重要。让我们比较两个基本的选择,groupBy和reduce。
13.2.2.1. groupByKey
查看API文档,您可能会认为groupByKey与每个分组的映射是总结每个键的计数的最佳方法:
// in Scala
KVcharacters.groupByKey().map(row => (row._1, row._2.reduce(addFunc))).collect()
然而,对于大多数情况来说,这是解决问题的错误方法。这里的基本问题是,在将函数应用到它们之前,每个执行器必须在内存中保留给定键的所有值。为什么这个有问题?如果您有大量的键倾斜,一些分区可能会被一个给定键的大量值完全超载,您将会得到outofmemoryerror。这显然不会引起我们当前数据集的问题,但是它会导致严重的问题。这不一定会发生,但会发生。
当groupByKey有意义时,就会出现用例。如果每个键的值都是一致的,并且知道它们将适合于给定的执行器,那么您将会很好。当你这样做的时候,很好地知道你到底在做什么。添加用例的首选方法是:reduceByKey。
13.2.2.2. reduceByKey
因为我们执行的是一个简单的计数,更稳定的方法是执行相同的flatMap,然后执行map将每个字母实例映射到数字1,然后使用求和函数执行一个reduceByKey来收集数组。这个实现更稳定,因为reduce发生在每个分区中,不需要将所有东西都放在内存中。这大大提高了操作的速度和操作的稳定性:
KVcharacters.reduceByKey(addFunc).collect()
reduceByKey方法返回一个组(键)的RDD,并返回没有进行排序的元素序列。
13.2.3. 其他聚合方法
我们发现,在当今的Spark中,用户遇到这种工作负载(或需要执行这种操作)的情况非常少见。当您可以使用结构化api执行更简单的聚合时,使用这些极低级别工具的理由并不多。这些函数基本上允许您非常具体地、非常低级地控制在机器集群上如何执行给定的聚合。
13.2.3.1. aggregate
这个函数需要一个null和start起始值,然后要求您指定两个不同的函数。在分区内的第一个聚合,跨分区的第二个聚合。开始值将用于两个聚合级别:
// in Scala
nums.aggregate(0)(maxFunc, addFunc)
此方法确实具有一些性能影响,因为它在driver端执行最终的聚合。如果从executors端返回到driver端的结果集太大,会导致driver端内存溢出。此时可以使用treeAggregate。它与aggregate (在用户级)做相同的事情,但以另一种方式进行。它基本上是“下推”一些子聚合(从executor到executor创建树),然后在驱动程序上执行最终的聚合。拥有多个级别可以帮助您确保在聚合过程中驱动程序不会耗尽内存。
// in Scala
val depth = 3
nums.treeAggregate(0)(maxFunc, addFunc, depth)
13.2.3.2. aggregateByKey
这个函数和aggregate函数一样,但是它不是按分区进行分区,而是按键执行。开始值和函数遵循相同的属性:
/ /in Scala
KVcharacters.aggregateByKey(0)(addFunc, maxFunc).collect()
13.2.3.3. combineByKey
您可以指定一个组合器,而不是指定一个聚合函数。这个组合在给定的键上操作,并根据某个函数合并值。然后合并不同的组合输出结果。我们还可以将输出分区的数量指定为自定义输出分区器:
// in Scala
val valToCombiner = (value:Int) => List(value)
val mergeValuesFunc = (vals:List[Int], valToAppend:Int) => valToAppend :: vals
val mergeCombinerFunc = (vals1:List[Int], vals2:List[Int]) => vals1 ::: vals2
// now we define these as function variables
val outputPartitions = 6
KVcharacters
.combineByKey(
valToCombiner,
mergeValuesFunc,
mergeCombinerFunc,
outputPartitions)
.collect()
13.3. CoGroup
CoGroup操作可以用来合并三个key-value RDD(Scala语言)。按key值合并。这实际上只是一个基于分组的RDD连接。
// in Scala
import scala.util.Random
val distinctChars = words.flatMap(word => word.toLowerCase.toSeq).distinct
val charRDD = distinctChars.map(c => (c, new Random().nextDouble()))
val charRDD2 = distinctChars.map(c => (c, new Random().nextDouble()))
val charRDD3 = distinctChars.map(c => (c, new Random().nextDouble()))
charRDD.cogroup(charRDD2, charRDD3).take(5)
13.4. Joins
RDDs与我们在结构化API中看到的连接非常相似,尽管RDD的join,你可以进行更细粒度的控制。它们都遵循相同的基本格式:
- 我们想要连接的两个RDDs,
- 它们应该输出的输出分区数量(可选)
- 自定义分区函数(可选)
我们将在本章后面讨论分区函数。
13.4.1. Inner Join
现在我们将演示一个内部连接。注意我们如何设置我们希望看到的输出分区的数量:
// in Scala
val keyedChars = distinctChars.map(c => (c, new Random().nextDouble()))
val outputPartitions = 10
KVcharacters.join(keyedChars).count()
KVcharacters.join(keyedChars, outputPartitions).count()
我们不会为其他连接提供一个示例,但是它们都遵循相同的基本格式。您可以在第8章的概念级别了解以下连接类型:
- fullOuterJoin
- leftOuterJoin
- rightOuterJoin
- Cartesian(这又是非常危险的!它不接受连接键,可以有一个巨大的输出。
13.4.2. zips
最后一种连接实际上并不是一个连接,但是它确实合并了两个RDDs,所以把它标记为连接是值得的。zip可以让你“zip”两个RDDs,假设它们的长度相同。这将创建一个PairRDD。两个RDDs必须具有相同数量的分区和相同数量的元素:
// in Scala
val numRange = sc.parallelize(0 to 9, 2)
words.zip(numRange).collect()
# in Python
numRange = sc.parallelize(range(10), 2)
words.zip(numRange).collect()
这给了我们以下的结果,一个key-value数组:
13.5. 分区控制(未完待续......)
使用RDDs,您可以控制数据在集群中的物理分布方式。其中一些方法与我们在结构化api中拥有的方法基本相同,但是关键的附加功能(结构化api中不存在)是指定分区函数的能力(正式的自定义Partitioner,稍后我们将讨论基本方法)。
13.5.1. coalesce
coalesce有效地合并了同一worker上的分区,会避免重新分区时数据的shuffle。例如,我们的words RDD目前是两个分区,我们可以使用coalesce将其压缩为一个分区,而不会导致数据的shuffle:
13.5.2. repartition
repartition操作允许增加或减少重新数据分区,但在过程中执行跨节点的shuffle。在map和filter类型操作中,增加分区的数量可以提高并行度:
13.5.3. repartitionAndSortWithinPartitions
该操作使您能够重新分区,并指定每个输出分区的顺序。我们将省略这个示例,因为它的文档很好,但是分区和key的比较逻辑都可以由用户指定。
13.5.4. 自定义分区
这种能力是您希望使用RDDs的主要原因之一。自定义分区器在结构化api中不可用,因为它们实际上没有逻辑对应项。它们是低层的实现细节,对作业能否成功运行具有重要影响。为这个操作激发自定义分区的典型例子是PageRank,我们试图控制集群上数据的布局并避免shuffle。在我们的购物数据集中,这可能意味着按每个客户ID进行分区(稍后我们将讨论这个示例)。
简而言之,自定义分区的唯一目标是均匀分布集群中的数据,以便解决数据倾斜等问题。
如果要使用自定义分区器,应该从结构化api下拉到RDDs,应用自定义分区器,然后将其转换回DataFrame或Dataset。通过这种方式,您可以同时获得两个方面的优势,只需要在需要的时候使用自定义分区。
要执行自定义分区,您需要实现自己的继承Partitioner的类。只有当您对业务问题有大量的领域知识时才需要这样做—如果您只是希望对一个值甚至一组值(列)进行分区,那么在DataFrame API中进行分区是值得的。
让我们来看一个例子:
Spark有两个内置的分区器,您可以在RDD API中利用它们,一个用于离散值的HashPartitioner和一个RangePartitioner。这两种方法分别适用于离散值和连续值。Spark的结构化api已经使用了这些,尽管我们可以在RDDs中使用相同的东西:
虽然散列和范围分区器很有用,但它们还相当初级。有时,您需要执行一些非常底层的分区,因为您要处理非常大的数据和很大的key倾斜。key倾斜意味着一些key的值比其他key的值要多得多。您希望尽可能地打乱这些key,以提高并行性,并防止在执行过程中出现OutOfMemoryErrors。
一个实例可能是,当且仅当key匹配某种格式时,需要对更多key进行分区。例如,我们可能知道在您的数据集中有两个客户总是使分析变慢,我们需要比其他客户id更深入地分解它们。事实上,这两个客户id存在严重的数据倾斜,需要单独处理,而其他所有客户id可以归到大的分组。这显然是一个有点夸张的例子,但是您也可能在您的数据中看到类似的情况:
运行此操作之后,您将看到每个分区中的结果计数。后两个数字会有所不同,因为我们是随机分布的(当我们在Python中做同样的操作时,您将看到),但是同样的原则也适用:
此自定义key分发逻辑仅在RDD级别可用。当然,这是一个简单的例子,但是它确实显示了使用任意逻辑以物理方式在集群周围分布数据的强大功能。
13.6. CustomSerialization自定义序列化器
最后一个值得讨论的高级主题是Kryo序列化问题。您希望并行化(或函数)的任何对象都必须是可序列化的:
默认的序列化可能非常慢。Spark可以使用Kryo库(版本2)更快地序列化对象。Kryo比Java序列化(通常高达10x)要快得多,压缩率更高,但是它不支持所有可序列化的类型,并且要求您预先注册将在程序中使用的类,以获得最佳性能。
您可以通过在初始化job时,在SparkConf中设置参数"spark.serializer"的值为"org.apache.spark.serializer.KryoSerializer"的方式来使用Kryo(我们将在本书的下一部分讨论这个问题)。此配置项配置的serializer,是用于在worker节点之间shuffle数据和将RDDs序列化到磁盘的序列化器。Kryo不是默认序列化器的唯一原因是其要求定制注册,但是我们建议在任何网络密集型应用程序中尝试它。从Spark 2.0.0开始,当使用简单类型、简单类型数组或字符串类型的RDDs进行shuffle时,我们在内部使用Kryo serializer。
Spark自动包含Kryo序列化器,用于Twitter chill库的AllScalaRegistrar中包含的许多常用的Scala核心类。
要注册自己的自定义类与Kryo,使用registerKryoClasses方法:
13.7. 结束语
在本章中,我们讨论了许多关于RDDs的更高级的主题。特别值得注意的是关于自定义分区的部分,它允许您使用非常特定的函数来布局数据。在第14章中,我们将讨论Spark的另一个低阶工具:分布式变量。