本文基于Spark2.1.0版本
0,引言:
Spark一般是部署在分布式环境中的(有可能是在区域集中的集群上,也有可能跨城市),而在分布式环境中,数据在各节点进行网络的传递代价是很大的。借用Spark源码里对groupByKey算子的描述(@note This operation may be very expensive 。。。 ),可见一斑。
/*** @note This operation may be very expensive. If you are grouping in order to perform anaggregation (such as a sum or average) over each key, using`PairRDDFunctions.aggregateByKey` or`PairRDDFunctions.reduceByKey`will provide much better performance.
@note As currently implemented, groupByKey must be able to hold all the key-value pairs for any key in memory. If a key has too many values, it can result in an[[OutOfMemoryError]]. ***/
def groupByKey(partitioner: Partitioner): RDD[(K,Iterable[V])] = self.withScope {...}
1,Shuffle的作用
有一些场景,节点间通过网络传递的数据是很少的。比如从海量日志中提取出ERROR级别的日志,每个节点计算完成后,可以选择把本地得到的结果发送给Driver程序,也可以直接在本地节点上写入外部系统。
而有些场景,是需要每个节点上的数据合并到一起统筹计算的,势必会产生大量的网络开销。比如pairRDD的键值操作,这就涉及到Shuffle过程:Shuffle中译是“洗牌、混洗”,而和洗牌含义不同的是,它不是把数据洗的越乱越好,而是需要把分布在不同节点的数据按照一定的规则聚集到一起的过程。
2,为什么需要分区器
那么,控制好数据的分布以便获得最少的网络传输,可以极大的提升整体性能,减少网络开销。Spark为pairRDD提供的Paritioner,就是为了帮助我们来合理的进行数据分布。本文不会深入介绍具体场景的Shuffle操作的优化,而是会说一些常常被忽略的概念。
3,分区器的种类
HashPartitioner:原理很简单,代码也很简单。对于给定的key,计算其hashCode,并除于分区的个数取余,如果余数小于0,则用余数+分区的个数,最后返回的值就是这个key所属的分区ID。该分区方法可以保证同一组的键出现在同一个节点的分区上。
class HashPartitioner(partitions:Int) extends Partitioner {
require(partitions >=0,s"Number of partitions ($partitions) cannot be negative.")
def numPartitions:Int= partitions
def getPartition(key:Any):Int= key match{
case null=>0
case_ => Utils.nonNegativeMod(key.hashCode,numPartitions}
override def equals(other:Any):Boolean= other match{
caseh: HashPartitioner =>h.numPartitions == numPartitions
case_ =>false}
override defhashCode:Int= numPartitions}
通过partitionBy(下面第4节会有介绍)主动使用该分区器时,可以通过partitions参数指定想要分区的数量:
scala> val rdd3=rdd2.partitionBy(new org.apache.spark.HashPartitioner(3))
scala> rdd3.partitions.size
res1: Int = 3
通过转换操作使用该分区器时(下面第5节会有介绍),可以继承父RDD的分区数量:
scala> val rdd3=rdd2.groupByKey()
scala> rdd3.partitioner
res2: Option[org.apache.spark.Partitioner] = Some(org.apache.spark.HashPartitioner@6)
scala> rdd3.partitions.size
res3: Int = 6 (父rdd2的分区数是6)
RangePartitioner:简单的说就是将一定范围内的数映射到某一个分区内。算法比较复杂,代码也比较多,这里就不举例了,可以自行参考Spark源码Partitioner.scala。
这里要说一个需要关注的地方,看源码中对该分区器的一个note:
/*@note The actual number of partitions created by the RangePartitioner might not be the same as the`partitions`parameter, in the case where the number of sampled records is less than* the value of`partitions`.*/
class RangePartitioner[K: Ordering : ClassTag,V]
因为该分区器使用到了Reservoir sampling(水塘抽样)算法,所以不管用户是通过partitionBy(下面第4节会有介绍)主动使用该分区器,或者通过转换操作使用该分区器时,得到的实际分区数可能和想要的设置的不一样,可能会少于预期。
自定义分区方式:Spark允许用户通过自定义的Partitioner对象,灵活的来控制RDD的分区方式。
比如:需要根据域名分区:www.spark.com 和 www.spark.com/sub
使用哈希或者范围分区器,可能无法把上面两个URL划分到相同的分区内,用户就可以自定义一个基于域名的分区器(如下),这个分区器只对URL中的域名求哈希。
class CustomPartitioner(numParts: Int) extends Partitioner {
def numPartitions: Int = numParts
def getPartition(key: Any): Int = {
val domain =newjava.net.URL(key.toString).getHost()
val code = (domain.hashCode % numPartitions)
if (code <0) {code + numPartitions} else {code}
override def equals(other: Any): Boolean = other match {
case Custom: CustomPartitioner =>Custom.numPartitions == numPartitions
case _ =>false }
def hashCode: Int = numPartitions
}
4,主动使用分区器
用户有的时候想在某些操作前提前对pairRDD按照某种方式进行分区,而不是被动的通过某些转换算子的shuffle过程。这样可以提前对key根据某种规则来分配到相同的分区,减少后续操作的网络传输。(当然,用户可以根据场景灵活的使用第3点说到的3种分区方式)
scala> val rdd1=sc.parallelize(1 to 10) -- 通过scala集合生成ParallelCollectionRDD
rdd1: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at:24
scala> val rdd2=rdd1.map(x=>(x,1)) -- 通过map算子,转换为pairRDD
rdd2: org.apache.spark.rdd.RDD[(Int, Int)] = MapPartitionsRDD[1] at map at:26
-- 通过下面的命令,给rdd3指定HashPartitioner分区器并分10个区
scala> val rdd3=rdd2.partitionBy(new org.apache.spark.HashPartitioner(10))
rdd3: org.apache.spark.rdd.RDD[(Int, Int)] = ShuffledRDD[3] at partitionBy at:28
scala> rdd3.partitioner -- 可以看到rdd3的分区器是HashPartitioner
Option[org.apache.spark.Partitioner] = Some(org.apache.spark.HashPartitioner@a)
scala> rdd3.partitions.size -- rdd3的分区数是10
res11: Int = 10
5,使用某些转换算子会自动为结果RDD生成分区信息
scala> val rdd1=sc.parallelize(1 to 10) -- 通过scala集合生成ParallelCollectionRDD
rdd1: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at:24
scala> val rdd2=rdd1.map(x=>(x,1)) -- 通过map算子,转换为pairRDD
rdd2: org.apache.spark.rdd.RDD[(Int, Int)] = MapPartitionsRDD[1] at map at:26
scala> rdd2.partitioner -- 此时rdd2并没有分区器
res9: Option[org.apache.spark.Partitioner] = None
scala> val rdd3=rdd2.groupByKey() -- 通过groupByKey算子生成rdd3
org.apache.spark.rdd.RDD[(Int, Iterable[Int])] = ShuffledRDD[4] at groupByKey at:28
scala> rdd3.partitioner -- 可以看到rdd3自动生成了HashPartitioner分区器
Option[org.apache.spark.Partitioner] = Some(org.apache.spark.HashPartitioner@6)
scala> val rdd4=rdd2.sortByKey() -- 通过sortByKey算子生成rdd4
rdd4: org.apache.spark.rdd.RDD[(Int, Int)] = ShuffledRDD[7] at sortByKey at:28
scala> rdd4.partitioner -- 可以看到rdd4自动生成了RangePartitioner分区器Option[org.apache.spark.Partitioner]=Some(org.apache.spark.RangePartitioner@53df81fe)
这种机制的好处是,Spark知道rdd3是用哈希分区的,那么后面再对rdd3进行键分区相关的操作时(比如reduceByKey)速度就会快很多(rdd4同理)。
6,什么操作会导致子RDD失去父RDD的分区方式?
比如,使用map()算子生成的RDD,由于该转换操作理论上可能会改变元素的键(Spark并不会去判断是否真的改变了键),所以不再继承父RDD的分区器,如下:
scala> val rdd1=sc.parallelize(1 to 10) -- 通过scala集合生成ParallelCollectionRDD
rdd1: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at:24
scala> val rdd2=rdd1.map(x=>(x,1)) -- 通过map算子,转换为pairRDD
rdd2: org.apache.spark.rdd.RDD[(Int, Int)] = MapPartitionsRDD[1] at map at:26
-- 通过下面的命令,给rdd3指定HashPartitioner分区器并分10个区
scala> val rdd3=rdd2.partitionBy(new org.apache.spark.HashPartitioner(10))
rdd3: org.apache.spark.rdd.RDD[(Int, Int)] = ShuffledRDD[3] at partitionBy at:28
scala> rdd3.partitioner -- 可以看到rdd3的分区器是HashPartitioner
Option[org.apache.spark.Partitioner] = Some(org.apache.spark.HashPartitioner@a)
scala> val rdd4=rdd3.mapValues(x=>x*2) -- 如果仅仅对pairRDD的value操作,则子RDD会继承父RDD的分区器及分区数
scala> rdd4.partitioner -- 可以看到rdd4的分区器也是HashPartitioner
Option[org.apache.spark.Partitioner] = Some(org.apache.spark.HashPartitioner@a)
scala> rdd4.partitions.size -- rdd4的分区数也是10
res11: Int = 10
scala> val rdd5=rdd3.map(x=>(x,1)) -- 如果是对键操作,则子RDD不再继承父RDD的分区器,但是分区数会继承
rdd5: org.apache.spark.rdd.RDD[((Int, Int), Int)] = MapPartitionsRDD[5] at map at:30
scala> rdd5.partitioner -- rdd5的分区器是None
res7: Option[org.apache.spark.Partitioner] = None
scala> rdd5.partitions.size -- rdd5的分区数也是10
res12: Int = 10
7,多元RDD的分区操作后,子RDD如何继承分区信息?
对于两个或多个RDD的操作,生成的新的RDD,其分区方式,取决于父RDD的分区方式。如果两个父RDD都设置过分区方式,则会选择第一个父RDD的分区方式。
scala> a2.partitioner
Option[org.apache.spark.Partitioner] = Some(org.apache.spark.HashPartitioner@6)
scala> b2.partitioner
Option[org.apache.spark.Partitioner]=Some(org.apache.spark.RangePartitioner@4a2cc441)
scala> val c=a2.cogroup(b2) -- 通过父b2、父a2 分组操作生成c
scala> c.partitioner -- c继承第一个父b2的分区方式
Option[org.apache.spark.Partitioner]=Some(org.apache.spark.RangePartitioner@4a2cc441)
scala> val c=b2.cogroup(a2) -- 通过父a2、父b2 分组操作生成c
scala> c.partitioner -- c继承第一个父a2的分区方式
Option[org.apache.spark.Partitioner] = Some(org.apache.spark.HashPartitioner@6)
好了,本章就说这么多吧。
欢迎指正,转载请标明作者和出处,谢谢。