Spark笔记(二)

键值对操作

  键值对RDD是Spark中许多操作所需要的常见数据类型,这里介绍如何操作键值对RDD。键值对RDD通常用来进行聚合计算,我们要先通过一些初始ETL(抽取、转化、装载)操作来将数据转化为键值对形式,键值对RDD提供了一些新的操作接口(比如统计每个产品的评论,将数据中键相同的分为一组,将两个不同的RDD进行分组合并等)。也会讨论用来让用户控制键值对RDD在各节点上分布情况的高级特性:分区,有时,使用可控的分区方式把常被一起访问的数据放到同一个节点上,可以大大减少应用的通信开销,这回带来明显的性能提升。我们会使用PageRank算法来演示分区的作用,为分布式数据集选择正确的分区方式和为本地数据集选择合适的数据结构很相似——在这两种情况下,数据的分布都会极其明显地影响程序的性能表现。

动机

  Spark为键值对类型的RDD提供了一些专有的操作,这些RDD被称为pair RDD,pair RDD是很多程序的构成要素,因为它们提供了并行操作各个键或跨节点重新进行数据分组的操作接口。例如,pair RDD提供reduceByKey()方法,可以分别归约每个键对应的数据,通常从一个RDD中提取某些字段(例如代表事件时间、用户ID或其它标识符的字段),并使用这些字段作为pair RDD操作中的键。

创建pair RDD

  在Spark中有很多创建pair RDD的方式,很多存储键值对的数据格式会在读取时直接返回由其键值对数据组成的pair RDD,此外,当需要把一个普通的RDD转为pair RDD时,可以调用map()函数来实现,传递的函数需要返回键值对,后面会展示如何将由文本组成的RDD转换为以每行的第一个单词为键的pair RDD。构建键值对RDD的方法在不同的语言中会有所不同,在Python中,为了让提取键之后的数据能够在函数中使用,需要返回一个由二元组组成的RDD。

#python
pairs=lines.map(lambda x:(x.split(" ")[0],x))

在scala中,为了让提取键之后的数据能在函数中使用,同样需要返回二元组,隐式转换可以让二元组RDD支持附加的键值对函数

//Scala
val pairs=lines.map(x=>(x.split(" ")(0),x))

Java没有自带的二元组类型,因此Spark的Java API让用户使用scala.Tuple2类来创建二元组,这个类很简单,Java用户可以通过new Tuple2(elem1,elem2)来创建一个新的二元组,并且可以通过._1()和._2()方法访问其中的元素,Java用户还需要调用专门的Spark函数来创建pair RDD,例如,要使用mapToPair()函数代替基础版的map()函数。

PairFunction<String,String,String> keyData=new PairFunction<String,String,String>(){
  public Tuple2<String,String> call(String x){
    return new Tuple2(x.split(" ")[0],x);
  }
};
JavaPairRDD<String,String> pairs=lines.mapToPair(keyData);

当用Scala和Python从一个内存中的数据集创建pair RDD时,只需要对这个由二元组组成的集合调用SparkContext.parallelize()方法,而要使用Java从内存数据集创建pair RDD的话,则需要使用SparkContext.parallelizePairs()。

pair RDD的转化操作

  pair RDD可以使用标准RDD上的可用的转化操作,之前所述的关于传递函数的规则也都同样适用于pair RDD,由于pair RDD有二元组,所以需要传递的函数应当操作二元组而不是独立的元素。
pair RDD的转化操作(以键值对{(1,2),(3,4),(3,6)}为例)

函数名 目的 示例 结果
reduceByKey(func) 合并具有相同键的值 rdd.reduceByKey((x,y)=>x+y) {(1,2),(3,10)}
groupByKey() 对具有相同键的值进行分组 rdd.groupByKey() {(1,[2]),(3,[4,6])}
combineByKey(createCombiner,mergeValue,mergeCombiners,partitioner) 使用不同的返回类型合并具有相同键的值
mapValues(func) 对pair RDD中的每个值应用一个函数而不改变键 rdd.mapValues(x=>x+1) {(1,3),(3,5),(3,7)}
flatMapValues(func) 对pair RDD中的每个值应用一个返回迭代器的函数,然后对返回的每个元素都生成一个对应原键的键值对记录,通常用于符号化 rdd.flatMapValues(x=>(x to 5)) {(1,2),(1,3),(1,4),(1,5),(3,4),(3,5)}
keys() 返回一个仅有键的RDD rdd.keys() {1,3,3}
values() 返回一个仅有值的RDD rdd.values() {2,4,6}
sortByKey() 返回一个根据键排序的RDD rdd.sortByKey() {(1,2),(3,4),(3,6)}

针对两个pair RDD的转化操作(rdd={(1,2),(3,4),(3,6)}other={(3,9)})

函数名 目的 示例 结果
subtractByKey 删掉RDD中键与other RDD中的键相同的元素 rdd.subtractByKey(other) {(1,2)}
join 对两个RDD进行内连接 rdd.join(other) {(3,(4,9)),(3,(6,9))}
rightOtherJoin 对两个RDD进行连接操作,确保第一个RDD的键必须存在(右外连接) rdd.rightOtherJoin(other) {3,(Some(4),9),(3,(Some(6),9))}
leftOtherJoin 对两个RDD进行连接操作,确保第二个RDD的键必须存在(左外连接) rdd.leftOtherJoin(other) {(1,(2,None)),(3,(4,Some(9))),(3,(6,Some(9)))}
cogroup 将两个RDD中拥有相同键的数据分组到一起 rdd.cogroup(other) {(1,([2],[])),(3,([4,6],[9]))}

pair RDD支持RDD所支持的函数

#Pyhon 对第二个元素进行筛选
result=pairs.filter(lambda keyValue:len(keyValue[1])<20)
//Scala
pairs.filter{case []}

有时只想访问pair RDD的值部分,这时操作二元组很麻烦,由于这是一种常见的使用模式,因此Spark提供了mapValues(func)函数,功能类似于map{case (x,y):(x,func(y))}。

聚合操作

  当数据集以键值对形势组织的时候,聚合具有相同键的元素进行一些统计是很常见的操作,之前讲解过基础RDD上的fold()、combine()、reduce()等操作,pair RDD上择优对应的针对键的转化操作。Spark有一组类似的操作,可以组合具有相同键的值,这些操作返回RDD因此它们是转化操作而不是行动操作。
  reduceByKey()与reduce()类似,都接收一个函数,并使用该函数对值进行合并,reduceByKey()会为数据集中的每个键进行并行的归约操作,每个归约操作会将键相同的值合并起来。因为数据集中可能有大量的键,所以reduceByKey()没有被实现为向用户程序返回一个值的行动操作,实际上,会返回一个由各键和对应键归约出来的结果值组成的新RDD。
  foldByKey()则与fold()类似,都是有一个与RDD和合并函数中的数据类型相同的零值作为初始值,与flod()一样,foldByKey()操作所使用的合并函数对零值与另一个元素进行合并,结果仍为该元素。可以使用reduceByKey()和mapValues()来计算每个键的对应值的均值,这和使用fold()和mapValues()计算整个RDD平均值的过程很相似,对于求平均,可以使用更加专用的函数来获取同样的结果。

扫描二维码关注公众号,回复: 11937640 查看本文章
#Python
rdd.mapValues(lambda x:(x,1)).reduceByKey(lambda x,y:(x[0]+y[0],x[1]+y[1]))
//Scala
rdd.mapValues(x=>(x,1)).reduceByKey((x,y)=>(x._1+y._1,x._2+y._2))

调用reduceByKey()和foldByKey()会在为每个键计算全局的总结果之前先自动在每台机器上进行本地合并,用户不需要指定合并器,更泛化的combineByKey()接口可以让你自定义合并的行为。也可以使用下例的方法来解决经典的分布式单词计数问题,可以使用前一章中讲过的flatMap()来生成以单词为键,数字为一为值的pair RDD,然后像上例那样,使用reduceByKey()对所有的单词进行计数。

#Python
rdd=sc.textFile("s3://...")
words=rdd.flatMap(lambda x:x.split(" "))
result=words.map(lambda x:(x,1)).reduceByKey(lambda x,y:x+y)
//Scala
val input=sc.textFile("s3://...")
val words=input.flatMap(x=>x.split(" "))
val result=words.map(x=>(x,1)).reduceByKey((x,y)=>x+y)
#Java
JavaRDD<String> input=sc.textFile("s3://...")
JavaRDD<String> words=input.flatMap(new FlatMapFunction<String,String>(){
  public Iterable<String> call(String x){return Arrays.asList(x.split(" "));}
});
JavaPairRDD<String,Integer> result=words,mapToPair(
  new PairFunction<String,String,Integer>(){
    public Tuple2<String,INteger> call(String x){return new Tuple2(x,1);}
  }).reduceByKey(
    new Function2<Integer,Integer,Integer>(){
      public Integer call(Integer a,Integer b){return a+b;}
});

事实上,我们可以对第一个RDD使用countByValue()函数,以更快实现单词计数

input.flatMap(x=>x.split(" ")).countByValue()

combineByKey()是最为常用的基于键进行聚合的函数,和aggregate()一样,combineByKey()可以让用户返回与输入数据的类型不同的返回值。要理解combineByKey()函数,要先理解它在处理数据时是如何处理每个元素的,由于combineByKey()会遍历分区中的所有元素,因此每个元素的键要么还没有遇到过,要么就和之前的某个元素的键相同。如果这是一个新的元素,combineByKey()会使用一个叫做createCombiner()的函数来创建那个键对应的累加器的初始值。需要注意的是,这一过程会在每个分区中第一次出现各个键时发生,而不是在整个RDD中第一次出现一个键时发生。如果这时一个在处理当前分区之前已经遇到的键,它会使用mergeValue()方法将该键的累加器对应的当前值与这个新的值进行合并。由于每个分区都是独立处理的,因此对于同一个键可以有多个累加器,如果有两个或者更多的分区都有对应同一个键的累加器,就需要使用用户提供的mergeCombiners()方法将各个分区的结果进行合并。
  combineByKey()有多个参数分别对应聚合操作的各个阶段,因而非常适合用来解释聚合操作各个阶段的功能划分,为了更好地演示combineByKey()是如何工作的,下面来看看如何计算各键对应的平均值

#Python
sumCount=nums.combineByKey((lambda x:(x,1)),(lambda x,y:(x[0]+y,x[1]+1)),(lambda x,y:(x[0]+y[0],x[1]+y[1])))
sumCount,map(lambda key,xy:(key,xy[0]/xy[1])).collectAsMap()
//Scala
val result=input.combineByKey(
  (v)=>(v,1),
  (acc:(Int,Int),v)=>(acc._1+v,acc._2+1),
  (acc1:(Int,Int),acc2:(Int,Int))=>(acc1._1+acc2._1,acc1._2+acc2._2)
  ).map{case (key,value)=>(key,value._1/value._2.toFloat)}
  result.collectAsMap().map(println(_))
)

有很多函数可以进行基于键的数据合并,大多数都是在combineByKey()的基础上实现的,为用户提供了更简单的接口,使用这些专用的聚合函数比手动将数据分组再归约快很多。
  并行度调度。每个RDD都有固定数目的分区,分区数决定了在RDD上执行操作时的并行度。在执行聚合或分组操作时,可以要求Spark使用给定的分区数,Spark始终尝试根据集群的大小推断出一个有意义的默认值,但是有时候你可能要对并行度进行调优来获取更好的性能表现。许多操作符都能接收第二个参数,用来指定分组结果或聚合结果的RDD分区数

#Python
data=[("a",3),("b",4),("a",1)]
sc.parallelize(data).reduceByKey(lambda x,y:x+y)    #默认并行度
sc.parallelize(data).reduceByKey(lambda x,y:x+y,10) #自定义并行度
//Scala
val data=Seq(("a",3),("b",4),("a",1))
sc.parallelize(data).reduceByKey((x,y)=>x+y)        //默认并行度
sc.parallelize(data).reduceByKey((x,y)=>x+y,10)     //自定义并行度

有时,我们希望在除分组操作和聚合操作之外的操作中也能改变RDD的分区,对于这样的情况,Spark提供了repartition()函数,它会把数据通过网络进行混洗,并创建出新的分区集合,但是要注意,对数据进行重新分区是代价相对比较大的操作。Spark中也有一个优化版的repartition(),叫做coalesce(),可以使用Java或Scala中的rdd.partitions.size以及Python中的rdd.getNUmPartitions查看RDD的分区数,确保调用coalesce()时将RDD合并到比现在的分区数更少的分区中。

数据分组

  对于有键的数据,一个常见的用例是将数据根据键进行分组——比如查看一个顾客的所有订单,如果数据已经以预期的方式提取了键,groupByKey()就会使用RDD中的键来对数据进行分组,对于一个由类型K的键和类型V的值组成的RDD,所获取的结果RDD类型会是[K,Iterable[v]]。groupByKey()可以用于未成对的数据上,也可以根据除键相同以外的条件进行分组,可以接收一个函数,对源RDD中的每个元素使用该函数,将返回结果作为键再进行分组。
  如果发现写出了先使用groupByKey()然后再对值使用reduce()或者fold()的代码,很可能可以通过使用一种根据键进行聚合的函数来更高效地实现同样的效果。对每个键归约数据,返回对应每个键的归约值的RDD,而不是把RDD归约为一个内存中的值。例如,rdd.reduceByKey(func)与rdd.groupByKey().mapValues(value=>value.reduce(func))等价,但是前者更为高效,因为避免了为每个键创建存放值的列表的步骤。
  除了对单个RDD的数据进行分组,还可以使用一个叫作cogroup()的函数对多个共享同一个键的RDD进行分组。对两个键的类型均为K而值为V和W的RDD进行cogroup()时,获取的结果RDD类型为[(K,(Iterable[V],Iterable[W]))]。如果其中的一个RDD对于另一个RDD中存在的某个键没有对应的记录,那么对应的迭代器则为空。cogroup()提供了为多个RDD进行数据分组的方法,是连接操作的构成要素,还可以用来求键的交集,还可以同时应用于三个及以上的RDD。

连接

  将有键的数据与另一组有键的数据一起使用是对键值对数据执行的最有用的操作之一,连接数据可能是pair RDD最常用的操作之一,连接方式多种多样:右外连接、左外连接、交叉连接以及内连接。普通的join操作符表示内连接,只有在两个pair RDD中都存在的键才叫输出,当一个输入对应的某个键有多个值时,生成的pair RDD会有来自两个输入RDD的每一组相对应的记录。

//Scala
val storeAddress=sc.parallelize(Seq(
       (Store("Ritual"),"1026 Valencia St"),(Store("Philz"),"748 Van Ness Ave"),
       (Store("Philz"),"3101 24th St"),(Store("Starbucks"),"Seattle")))
val storeRating=sc.parallelize(Seq(
       (Store("Ritual"),4.9),(Store("Philz"),4.8)))
storeAddress.join(storeRating)

有时,不希望结果中的键必须在两个RDD中都存在,例如,在连接客户信息与推荐时,如果一些客户还没有收到推荐,我们仍然不希望丢掉这些顾客。leftOuterJoin(other)和rightOuterJoin(other)都会根据键连接两个RDD,但是允许结果中存在其中的一个pair RDD所缺失的键。在使用leftOuterJoin()产生的pair RDD中,源RDD的每一个键都有对应的记录,每个键相应的值是由一个源RDD中的值与一个有第二个RDD的值的Option(在Java中为Optional)对象组成的二元组。在Python中,如果一个值不存在,则使用None来表示;而数据存在时就用常规的值来表示,不使用任何封装。和join()一样,每个键可以获取多条记录,当这种情况发生时,会获取到两个RDD中对应同一个键的两组笛卡尔积。

storeAddress.leftOuterJoin(storeRating)=={
  (Store("Ritual"),("1026 Valencia St",Some(4,9))),
  (Store("Starbucks"),("Seattle",None)),
  (Store("Philz"),("748 Van Ness Ave",Some(4.8))),
  (Store("Philz"),("3101 24th St",Some(4.8)))}
  
storeAddress.rightOuterJoin(storeRating)=={
  (Store("Ritual"),(Some("1026 Valencia St"),4.9)),
  (Store("Philz"),(Some("748 Van Ness Ave"),4.8)),
  (Store("Philz"),(Some("3101 24th St"),4.8))}
数据排序

  很多时候,让数据排好序是很有用的,尤其是在生成下游输出时,如果键有已定义的顺序,就可以对这种键值对RDD进行排序,当把数据排好序后,后续对数据进行collect()或save()等操作都会获取到有序的数据。我们经常要将RDD倒序排列,因此sortByKey()函数接收一个叫做ascending的参数,表示我们是否相应让结果按升序排列(默认值为true)。有时我们也可能想按完全不同的排序依据进行排序,要支持这种情况,我们可以提供自定义的比较函数

#Python 以字符串顺序对整数进行自定义排序
rdd.sortByKey(ascending=True,numPartitions=None,keyfunc=lambda x:str(x))
//Scala
val input:RDD[(Int,Venue)]=...
implicit val sortIntegersByString=new Ordering[Int]{
  override defcompare(a:Int,b:Int)=a.toString.compare(b.toString)
}
rdd.sortByKey()
#Java
class IntegerComparator implements Comparator<Integer>{
  public int compare(Integer a,Integer b){
    return String.valueOf(a).compareTo(String.valueOf(b))
  }
}
rdd.sortByKey(comp)
pair RDD的行动操作

  和转化操作一样,所有基础RDD支持的传统行动操作也都在pair RDD上可用,pair RDD提供了一些额外的行动操作,可以让我们充分利用数据的键值对特性

函数名 目的 示例 结果
countByKey() 对每个键对应的元素分别计数 rdd.countByKey() {(1,1),(3,2)}
collectAsMap() 将结果以映射表的形式返回,以便查询 rdd.collectAsMap() Map{(1,2),(3,6)}
lookup(key) 返回给定键对应的所有值 rdd.lookup(3) [4,6]

就pair RDD而言,还有别的一些行动操作可以保存RDD,以后会介绍。

数据分区(进阶)

  这里要讨论Spark的特性,是对数据集在节点间的分区控制。在分布式程序中,通信的代价是很大的,因此控制数据分布以获取最少的网络传输可以极大提升整体性能。和单节点的程序需要为记录集合选择合适的数据结构一样,Spark程序可以通过控制RDD分区方式来减少通信开销。分区并不是对所有应用都有好处——比如,如果给定RDD只需要被扫描一次,完全没有必要对其与先进行分区处理,只有当数据集多次在诸如连接这种基于键的操作中使用时,分区才会有帮助。
  Spark中所有的键值对RDD都可以进行分区,系统会根据一个针对键的函数对元素进行分组。尽管Spark没有给出显示控制每个键具体落在哪个工作节点上的方法(部分原因是Spark即使在某些节点失败时依然可以工作),但Spark可以确保同一组的键出现在同一个节点上。比如,可能使用哈希分区将一个RDD分成了100个分区,此时键的哈希值对100取模的结果相同的记录会被放在一个节点上。也可以使用范围分区法,将键在同一个范围区间内的记录都放在同一个节点上。
  举个简单的例子,可以分析这样一个应用,它在内存中保存着一张很大的用户信息表——也就是一个由(UserID,UserInfo)对组成的RDD,其中UserInfo有一个该用户所订阅的主题的列表。该应用会周期性地将这张表与一个小文件进行组合,这个小文件中存着过去五分钟内发生的事件——其实就是一个有(UserID,LinkInfo)对组成的表,存放着过去五分钟内个用户的访问情况。例如,我们可能需要对用户访问其未订阅主题的页面的情况进行统计,我们可以使用Spark的join()操作来实现这个组合操作,其中需要把UserInfo和LinkInfo的有序对根据UserID进行分组。

//Scala
//初始化代码,从HDFS上的一个Hadoop SequenceFile中读取用户信息
//userData中的元素会根据它们被读取时的来源,即HDFS块所在的节点来分布
//Spark此时无法获知某个特定的UserID对应的记录位于哪个节点上
val sc=new SparkContext(...)
val userData=sc.sequenceFile[UserID,UserInfo]("hdfs://...").persist()

//周期性调用函数来处理过去五分钟产生的事件日志
//假设这是一个有(UserID,LinkInfo)对的SequenceFile
def processNewLogs(logFileName:String){
  val events=sc.sequenceFile[UserID,LinkInfo](logFileName)
  val joined=userData.join(events)//RDD of (UserID,(UserInfo,LinkInfo)) pairs
  val offTopicVisits=joined.filter{
    case (userId,(userInfo,linkInfo))=>//Expand the tuple into its components
      !userInfo.topics.contains(linkInfo.topic)
  }.count()
  println("Number of visits to non-subscribed topics:"+offTopicVisits)
}

这段代码可以运行,但是不够高效,这是因为在每次调用processNewLogs()时都会用到join()操作,而我们对数据集是如何分区的却一无所知。默认情况下,连接操作会将两个数据集中的所有键的哈希值都求出来,将该哈希值相同的记录通过网络传到同一台机器上,然后在那台机器上对所有键相同的记录进行连接操作。因为userData表比每五分钟出现的访问日志表events要大的多,所以要浪费时间做很多额外工作:在每次调用时都对userData表进行哈希值计算和跨节点数据混洗,虽然这些数据从来都不会变化。
  要解决这一问题也很简单:在程序开始时,对userData表使用partitionBy()转化操作,将这张表转为哈希分区,可以通过向partitionBy传递一个spark.HashPartitioner对象来实现该操作。

//Scala
val sc=newSparkContext(...)
val userData=sc.sequenceFile[UserID,UserInfo]("hdfs://...")
               .partitionBy(new HashPartitioner(100))//构造100个分区
               .persist()

processNewLogs()方法可以保持不变:在processNewLogs()中,eventsRDD是本地变量,只在该方法中使用了一次,所以为events指定分区没有什么用处。由于在构建userData时调用了partitionBy(),Spark就知道了该RDD 是根据键的哈希值来分区的,这样在调用join()时,Spark就会利用这一点。具体来说,当调用userData.join(events)时,Spark只会对events进行数据混洗操作,将events中特定UserID的记录发送到userData的对应分区所在的那台机器上,这样,需要通过网络传输的数据就大大减少了,程序运行速度也可以显著提升了。
  注意,partitionBy()是一个转化操作,因此它的返回值总是一个新的RDD,但它不会改变原来的RDD,RDD一旦创建就无法修改。因此应该对partitionBy()的结果进行持久化,并保存为userData,而不是原来的sequenceFile()的输出。此外,传给partitionBy()的100表示分区数目,它会控制之后对这个RDD进行进一步操作(比如连接操作)时有多少任务会并行执行,这个值至少应该和集群中的总核心数一样。
  事实上,许多其它Spark操作会自动为结果RDD设定已知的分区方式信息,而且除join()外还有很多操作也会利用到已有的分区信息。比如,sortByKey()和groupByKey()会分别生成范围分区的RDD和哈希分区的RDD,而另一方面,诸如map()这样的操作会导致新的RDD失去父RDD的分区信息,因为这样的操作理论上可能会修改每条记录的键。后面介绍如何获取RDD的分区信息,以及数据分区是如何影响各种Spark操作的。
  Spark的Java和Python的API都和Scala的一样,可以从数据分区中获益,不过,在Python中,不能将HashPartitioner对象传给partitionBy,而只需要把需要的分区数传递过去(例如rdd.partitionBy(100))。

获取RDD的分区方式

  在Scala和Java中,可以使用RDD的partitioner属性(Java中使用partitioner()方法)来获取RDD的分区方式,它会返回一个scala.Option对象,这是Scala中用来存放可能存在的对象的容器类。可以对这个Option对象调用isDefined来检查其中是否有值,调用get来获取其中的值,如果存在值的话,这个值会使一个spark.Partitioner对象。这本质上是一个告诉我们RDD中各个键分别属于哪个分区的函数。
  在Spark shell中使用partitioner属性不仅是检验各种Spark操作如何影响分区方式的一种好办法,还可以用来在程序中检查想要使用的操作是否会生成正确的结果。

//Scala
import org.apache.spark
val pairs=sc.parallelize(List((1,1),(2,2),(3,3)))
pairs
psirs.partitioner
val partitioned=pairs.partitionBy(new spark.HashPartitioner(2))
partitioned
partitioned.partitioner

在这段简短的代码中,创建出了一个由(Int,Int)对组成的RDD,初始时没有分区方式信息(一个值为None的Option对象)。然后通过对第一个RDD进行哈希分区,创建出了第二个RDD,如果确实要在后续操作中使用partitioned,那就应当在定义partitioned时,在第三行输入的最后加上persist()。这和之前的例子中需要对userData调用persist()的原因是一样的:如果不调用persist()的话,后续的RDD操作会对partitioned的整个谱系重新求值,这会导致对pairs一遍又一遍地进行哈希分区操作。

从分区中获益的操作

  Spark的许多操作都引入了将数据根据键跨节点进行混洗的过程,所有这些操作都会从数据分区中获益。就Spark1.0而言,能够从数据分区中获益的操作有cogroup()、groupWith()、join()、leftOuterJoin()、rightOuterJoin()、groupByKey()、reduceByKey()、combineByKey()以及lookup()。
  对于像reduceByKey()这样只作用于单个RDD的操作,运行在未分区的RDD上的时候会导致每个键的所有对应值都在每台机器上进行本地计算,只需要把本地最终归约出的结果值从各工作节点传回主节点,所以原本的网络开销就不算大。而对于诸如cogroup()和join()这样的二元操作,预先进行数据分区会导致其中至少一个RDD(使用已知分区器的那个RDD)不发生数据混洗。如果两个RDD使用同样的分区方式,并且它们还缓存在同样的机器上(比如一个RDD是通过mapValues()从另一个RDD中创建出来的,这两个RDD就会拥有相同的键和分区方式),或者其中一个RDD还没有被计算出来,那么跨节点的数据混洗就不会发生了。

影响分区方式的操作

  Spark内部知道各操作会如何影响分区方式,并将会对数据进行分区的操作的结果RDD自动设置为对应的分区器。例如,如果调用join()来连接两个RDD,由于键相同的元素会被哈希到同一台机器上,Spark知道输出结果也是哈希分区的,这样对连接的结果进行诸如reduceByKey()这样的操作时就会明显变快。
  不过,转化操作的结果并不一定会按已知的分区方式分区,这时输出的RDD可能就会没有设置分区器。例如,当对一个哈希分区的键值对RDD调用map()时,由于传给map()的函数理论上可以改变元素的键,因此结果就不会有固定的分区方式。Spark不会分析你的函数来判断键是否会被保留下来。不过Spark提供了另外两个操作mapValues()和flatMapValues()作为替代方法,它们可以保证每个二元组的键保持不变。
  这里列出了所有会为生成的结果RDD设好分区方式的操作:cogroup()、groupWith()、join()、leftOuterJoin()、rightOuterJoin()、groupByKey()、reduceByKey()、combineByKey()、partitionBy()、sort()、mapValues()(如果父RDD有分区方式的话)、flatMapValues()(如果父RDD有分区方式的话),以及filter()(如果父RDD有分区方式的话)。其它所有的操作生成的结果都不会存在特定的分区方式。
  最后,对于二元操作,输出数据的分区方式取决于父RDD的分区方式。默认情况下,结果会采用哈希分区,分区的数量和操作的并行度一样。不过,如果其中的一个父RDD已经设置过分区方式,那么结果就会采用那种分区方式,如果两个父RDD都设置过分区方式,结果RDD会采用第一个父RDD的分区方式。

示例:PageRank

  PageRank是一种从RDD分区中获益的更复杂的算法,以它为例进行分析。PageRank算法是以Google的Larry Page的名字命名的,用来根据外部文档指向一个文档的链接,对集合中每个文档的重要程度赋一个度量值,该算法可以用于对网页进行排序,当然,也可以用于排序科技文章或社会网络中有影响的用户。
  PageRank是执行多次连接的一个迭代算法,因此它是RDD分区操作的一个很好的用例。算法会维护两个数据集:一个由(pageID,linkList)的元素组成,是每个页面的相邻页面的列表,另一个由(pageID,rank)元素组成,有每个页面的当前排序值。按如下步骤进行计算
(1)将每个页面的排序值初始化为1.0。
(2)在每次迭代中,对页面p,想其每个相邻页面(有直接链接的页面)发送一个值为rank§/numNeighbors§的贡献值。
(3)将每个页面的排序值设为0.15+0.85*contributionsReceived。
最后两步会重复循环,在此过程中,算法会逐渐收敛于每个页面的实际PageRank值。在实际操作中,收敛通常需要大约10轮迭代。

//Scala
//假设相邻页面列表以Spark objectFile的形式存储
val links=sc.objectFile[(String,Seq[String])]("links")
            .partitionBy(new HashPartitioner(100))
            .persist()

//将每个页面的排序值初始化为1.0,由于使用mapValues生成的RDD的分区方式会和“links”一样
var ranks=links.mapValue(v=>1.0)

//运行10轮PageRank迭代
for(i<-0 until 10){
  val contributions=links.join(ranks).flatMap{
    case (pageId,(links,rank))=>
      links.map(dest=>(dest,rank/links.size))
  }
  ranks=contributions.reduceByKey((x,y)=>x+y).mapValues(v=>0.15+0.85*v)
}

//写出最终排名
ranks.saveAsTextFile("ranks")

这就行了,算法从将ranksRDD的每个元素的值初始化为1.0开始,然后在每次迭代中不断更新ranks变量,在Spark中编写PageRank的主体相当简单:首先对当前的ranksRDD和今天的linksRDD进行一次join()操作,来获取每个页面ID对应的相邻页面列表和当前的排序值,然后使用flatMap创建出“contributions”来记录每个页面对各相邻页面的贡献,然后再把这些贡献值按照页面ID(根据获取共享的页面)分别累加起来,把该页面的排序值设为0.15+0.85*contributionsReceived。
  虽然代码本身很简单,这个示例程序还是做了不少事情来确保RDD以比较高效的方式进行分区,以最小化通信开销:
(1)linksRDD在每次迭代中都会和ranks发生连接操作,。由于links是一个静态数据集,所以我们在程序一开始的时候就对它进行了分区操作,这样就不要把它通过网络进行数据混洗了。实际上,linksRDD的字节数一般来说也会比ranks大很多,毕竟有每个页面的相邻页面列表(由页面ID组成),而不仅仅是一个double值,因此这一优化相比PageRank的原始实现(例如普通的MapReduce)节约了相当客观的网络通信开销。
(2)处于同样的原因,我们调用links的persist()方法将它保留在内存中以供每次迭代使用。
(3)当第一次创建ranks时,使用mapValues()而不是map()来保留父RDD(links)的分区方式,这样对它进行的第一次连接操作就会开销很小。
(4)在循环中,我们在reduceByKey()后使用mapValues(),因为reduceByKey()的结果已经是哈希分区的了,这样依赖,下一次循环中将映射操作的结果再次与links进行连接操作时就会更加高效。为了最大化分区相关优化的潜在作用,应该在无需改变元素的键时尽力使用mapValues()或flatMapValues()。

自定义分区方式

  虽然Spark提供的HashPartitioner与RangePartitioner已经能够满足大多数用例,但Spark还是允许你通过提供一个自定义的Partitioner对象来控制RDD的分区方式,这便能利用领域知识进一步减少通信开销。
  举个例子,假设我们要在一个网页的集合上运行前一节中的PageRank算法。在这里,每个页面的ID(RDD中的键)是页面的URL。当我们使用简单的哈希函数进行分区时,拥有相似的URL的页面可能会被分到完全不同的节点上。然而,我们知道在同一个域名下的网页更有可能相互链接。由于PageRank需要在每次迭代中从每个页面向它所有相邻的页面发送一条消息,因此把这些页面分组到同一个分区中会更好。可以使用自定义的分区器来实现仅根据域名而不是整个URL来分区。
  要实现自定义的分区器,需要集成org.apache.spark.Partitioner类并实现以下三个方法

  • numPartitions:Int——返回创建出来的分区数。
  • getPartition(key:Any):Int——返回给定键的分区编号(0到numPartitions-1)。
  • equals():Java——判断相等性的标准方法。这个方法的实现非常重要,Spark需要用这个方法来检查分区器对象是都和其它分区器实例相同。

有一个问题需要注意,当算法依赖于Java的hashCode()方法时,这个方法有可能会返回负数,需要十分谨慎,确保getPartition()永远返回一个非负数。

//Scala 自定义分区方式
class DomainNamePartitioner(numParts:Int) extends Partitioner{
  override def numPartitions:Int=numParts
  override def getPartition(ket:Any):Int={
    val domain=new Java.netURL(key.toString).getHost()
    val code=(domain.hashCode%numPartitions)
    if(code<0){
      code+numPartitions//使其非负
    }else{
      code
    }
  }
  //用来让Spark区分分区函数对象的Java equals方法
  override def equals(other:Any):Boolean=other match{
    case dnp:DomainNamePartitioner=>
      dnp.numPartitions==numPartitions
    case _ =>
    false
  }
}

注意,在equals()方法中,使用Scala的模式匹配操作符(match)来检查other是否是DomainNamePartitioner,并在成立时自动进行类型转换,这和Java中的instanceof()是一样的。
使用自定义的Partitioner是很容易的:只要把它传给partitionBy()方法即可。Spark中有许多依赖于数据混洗的方法,比如join()和groupByKey(),它们也可以接收一个可选的Partitioner对象来控制输出数据的分区方式。在Java中创建一个自定义Partitioner的方法与Scala中的做法非常相似:只需要扩展spark.Partitioner类并且实现必要的方法即可。
在Python中,不需要扩展Partitioner类,而是把一个特定的哈希函数作为一个额外的参数传给RDD.partitionBy()函数

#Python 自定义分区方式
import urlparse

def hash_domain(url):
  return hash(urlparse.urlparse(url).netloc)

rdd.partitionBy(20,hash_domain)#创建20个分区

注意这里所传过去的哈希函数会被其它RDD的分区函数区分开来,如果想要对多个RDD使用相同的分区方式,就应该使用同一个函数对象,比如使用同一个函数对象,比如一个全局函数,而不是为每个RDD创建一个新的函数对象。
  

猜你喜欢

转载自blog.csdn.net/wanchaochaochao/article/details/84841302
今日推荐