每个RDD都被划分成一个或多个分区,这些分区会保存到Spark集群的多个节点上,另外,Spark的每个计算节点可能存储RDD的一个或多个分区。
RDD数据的分区存储为Spark任务的执行带来了很多的优势:
1)Spark的任务会同时在RDD的各个分区上进行计算,然后再把各个分区的计算结果进行整合得到最终结果。所以,分区非常重要,它让Spark任务可以更好的并行执行。
2)Spark遵循数据局部性原则,工作节点使用更靠近它们的数据进行处理。通过分区,将减少网络I/O,以便可以更快地处理数据。
3)在进行RDD转换时,通常会有大量跨网络的数据传输(也就是所谓的:shuffle)。所以,分区变得非常重要。对于key-value的数据,key相似或在同一范围的数据会在同一分区中,这样减少了网络之间的数据传输,可以同一个节点中完成处理过程,从而大大提升了处理效率。
分区的特点
spark的分区有以下特点:
- 在Spark集群中每个工作节点,可能都包含一个或多个分区。
- Spark中使用的分区数是可配置的,但要注意,分区太少或分区太多都不好。分区太少,会导致较少的并发、数据倾斜、或不正确的资源利用。分区太多,导致任务调度花费比实际执行时间更多的时间。若没有配置分区数,默认的分区数是:所有执行程序节点上的内核总数。
- Spark保证同一个分区的数据位于同一个机器上,不会跨多台机器保存。
- Spark为每个分区分配一个任务,每个worker一次可以处理一个任务。
我们知道,任务是在worker节节点上执行,而分区也保存在worker节点上,而无论任务做什么计算都是基于分区数据进行的。这就意味着:每个阶段的基础任务数等于分区数。
也就是说:每个阶段的任务不会大于分区数。由于分区数决定了并行度,因此这也是进行性能调优时需要考虑的重要的方面。选择适当的分区属性可以大大提高应用程序的性能。
默认分区
RDD创建方式的不同,会产生不同的默认分区行为。比如:从hdfs中读取文件来创建RDD和通过一个RDD更具转换操作生成另一个新的RDD的分区行为是不同的。下面对不同操作的默认分区行为进行了一个总结:
- 分布式化一个本地数据集
- 从HDFS中读取数据
- 通过转换函数来创建RDD
- 通过聚合的方式来生成RDD
RDD的分区实现
前面已经分析过RDD的存储和计算都是基于分区来进行的,那么RDD是如何通过分区来计算和存储的呢?下面我们来分析RDD的分区原理。
在RDD的顶层抽象类中,有关分区的成员变量有以下几个:
- 计算RDD某个分区的数据:compute函数
RDD数据的计算在RDD#compute函数中完成,该函数实际上是计算RDD每个分区的数据,代码如下,其中的split参数就是需要计算分区的标识符(也就是Partition对象,后面会进行详细分析)。
// 代码位置: package org.apache.spark.rdd.RDD
def compute(split: Partition, context: TaskContext): Iterator[T]
- 获取RDD的所有分区标识:getPartitions
每个分区都是以Partition对象来进行标识的,该类保存了每个分区的索引等信息。
protected def getPartitions: Array[Partition]
- 获取分区标识的数组:partitions
该成员其实是通过函数RDD#getPartitions来获取RDD的分区标识,但它会考虑RDD是否已经checkpoint,若RDD已经checkpointed会先从checkpointed数据中获取分区标识,若没有获取到,才调用getPartitions函数。
要注意的是,调用成功后,会把数据保存到RDD的成员变量:partitions_中,若该成员变量已经有值了,下一次将直接使用。所以,通常情况下getPartitions函数只会被调用一次。
final def partitions: Array[Partition] = {
// 从checnpoint数据中获取分区标识
checkpointRDD.map(_.partitions).getOrElse {
// 若没有获取到,且以前没有获取过分区标识数据
if (partitions_ == null) {
// 获取分区标识数组,并保存
partitions_ = getPartitions
...
}
// 若以前已经获取过,直接返回保存的结果
partitions_
}
}
- 获取RDD的分区数
获取RDD的分区数量。
final def getNumPartitions: Int = partitions.length
- 获取分区所在的最佳位置
获取某个分区所在的最佳位置。
final def preferredLocations(split: Partition): Seq[String] = {
checkpointRDD.map(_.getPreferredLocations(split)).getOrElse {
getPreferredLocations(split)
}
}
- 分区的依赖:Dependency
我们知道RDD之间是有依赖的,其实这种依赖也是基于分区来实现的。当我们说子RDD依赖父RDD时,其意识是子RDD的某个分区依赖父RDD的一个或多个分区。RDD的依赖,会在后面章节详细讲解,这里我们看一个依赖实现的例子:
abstract class NarrowDependency[T](_rdd: RDD[T]) extends Dependency[T] {
def getParents(partitionId: Int): Seq[Int]
override def rdd: RDD[T] = _rdd
}
NarrowDependency是一个抽象类,该类代表一种依赖类型:即窄依赖。表示父RDD的某个分区最多被一个子RDD所使用。可以看到getParents函数会返回某个分区id对应的父RDD的分区id的列表。
分区和Task的生成
通过上一小节我们知道:RDD#compute用来计算某个分区的数据;而RDD#getPartitions用来获取RDD的分区标识;所谓RDD的依赖其实是RDD分区之间的依赖。那么,这些成员函数是如何被使用的呢?
实际上RDD的这些分区操作主要用于Task的创建和执行。我们最终的任务就是要创建Task,运行Task并计算出RDD的数据。Spark会根据RDD之间的依赖关系来寻找需要计算的RDD分区,并为每个分区生成一个Task提交给Executor端执行。
在Executor端执行任务时,通过RDD#getPartitions来获取分区的标识(Partition对象),若发现依赖的分区不存在,则调用RDD#compute函数来计算分区的数据(也可能直接读取checkpoint数据或cache的RDD数据)。
分区标识: Partition
RDD不同,分区类型也不同。分区标识用来区分不同的分区类型,它定义了一个接口Partition,不同类型的分区对该接口有不同的实现。接口的定义如下:
// 代码位置: package org.apache.spark
trait Partition extends Serializable {
def index: Int //分区的索引
override def hashCode(): Int = index // 默认情况下hashCode等于索引
override def equals(other: Any): Boolean = super.equals(other) // 重载equals函数
}
分区器(Partitioner)
我们知道RDD是分布式数据集,它的数据按分区的方式分布在集群的多个节点上。分区的方式(也就是分区器的实现方式)将决定每个分区数据的大小。那么,分区器是什么呢?
从概念上讲,分区器(Partitioner)定义了如何分布数据,决定一个RDD可以分成多少个分区,每个分区的数据量有多大,从而决定了每个Task将处理哪些数据。
一般来说分区器是针对key-value值的RDD,并通过对key的运算来划分分区。非key-value形式的RDD无法根据数据特征来进行分区,也就没有设置分区器,此时Spark会把数据均匀的分配到执行节点上。
目前的版本提供了三种分区器(后面会详细讲解这三种分区器的原理):
- HashPartitioner
- RangePartitioner
- 自定义分区器
从实现层面来说,spark定义了一个抽象类:Partitioner,所有具体的Partitioner都需要继承该抽象类,并实现该抽象类的以下两个方法:
abstract class Partitioner extends Serializable {
def numPartitions: Int
def getPartition(key: Any): Int
}
- numPartitions:定义分区后RDD中的分区数。
- getPartition:定义从key到分区的整数索引(分区id)的映射,应该返回具有该key的记录。
该抽象类实际上定义了:对一个key-value的RDD,如何通过key进行分区。要注意的是:分区器必须是确定性的,相同的分区key必须返回相同的分区id(每个分区都有一个唯一的id号进行标识)。
默认分区器
除了定义分区器抽象类,spark还定义了一个object Partitioner类,在该类中定义了默认的分区行为,提供了两个函数:
object Partitioner {
// 返回最合适的分区器
def defaultPartitioner(rdd: RDD[_], others: RDD[_]*): Partitioner
// 判断是否是复合条件的分区器
private def isEligiblePartitioner
}
- defaultPartitioner函数
该函数会选择一个分区器,用于多个RDD之间的类分组操作。
若设置了参数spark.default.parallelism,将使用SparkContext的defaultParallelism的值作为默认分区数,否则,我们将使用上游RDD中的最大分区数。
如果可以,该函数会从上游RDD中选择具有最大分区数的分区器(Partitioner)。 如果此分区器符合条件(通过isEligiblePartitioner来判断),或者分区数大于默认分区数,将会使用此分区器。否则,将使用具有默认分区数的HashPartitioner。
另外,除非设置了spark.default.parallelism,否则分区的数量将与最大的上游RDD中的分区的数量相同,这样做,最不可能导致内存不足错误。
Spark提供的分区器(Partitioner)对象有两种实现:HashPartitioner和RangePartitioner(在2.3中有更多的实现)。若这些都不满足需要,可以自己实现分区器类。
HashPartitioner
HashPartitioner是基于Java的 Object.hashCode
来实现的分区器。根据Object.hashCode
来对key进行计算得到一个整数,再通过公式:Object.hashCode%numPartitions
计算某个key该分到哪个分区。
需要注意的是,Java数组具有基于数组下标而不是其内容的hashCode,因此若尝试使用HashPartitioner对RDD[Array[ _ ]] 或 RDD[(Array[], )] 进行分区,可能会产生错误的结果。
另外,当RDD没有Partitioner时,会把HashPartitioner作为默认的Partitioner。该类的实现代码如下:
class HashPartitioner(partitions: Int) extends Partitioner {
require(partitions >= 0, s"Number of partitions ($partitions) cannot be negative.")
// 确定的分区的数量
def numPartitions: Int = partitions
// key到分区id的映射,这里是通过取模的方式实现
def getPartition(key: Any): Int = key match {
case null => 0
// 取模运算:hashcode%分区数
case _ => Utils.nonNegativeMod(key.hashCode, numPartitions)
}
// 重新定义equal函数,若是HashPartitioner且分区数相等,返回true
override def equals(other: Any): Boolean = other match {
case h: HashPartitioner =>
h.numPartitions == numPartitions
case _ =>
false
}
// 把HashPartitioner的hashCode设置为分区数
override def hashCode: Int = numPartitions
}
注意:传给HashPartitioner(partitions: Int)的参数partitions不能为负。
HashPartitioner具有以下特点:
- HashPartitioner根据key的哈希值(hashcode)确定子分区的索引位置。
- HashPartitioner需要一个分区参数,该参数确定输出RDD中的分区数和散列函数中使用的分区数。若没有指定该参数,spark则使用SparkConf中spark.default.parallelism值的值来确定分区数。
- 若没有设置默认并行度值(spark.default.parallelism参数的值),则spark默认为RDD在其血缘(lineage)中具有的最大分区数。
- 在使用HashPartitioner的宽转换(wide transform)(例如aggregateByKey)中,可选的分区数参数用作散列分区程序的参数。
RangePartitioner
RangePartitioner(范围分区)将其key位于相同范围内的记录分配给给定分区。排序需要RangePartitioner,因为RangePartitioner能够确保:通过对给定分区内的记录进行排序,最终完成整个RDD的排序。
RangePartitioner首先通过采样确定每个分区的范围边界:优化跨分区的记录进行均匀分布。然后,RDD中的每个记录将被shuffled到其范围界限内包括该key的分区。
高度不平衡的数据(即,某些key的许多值而不是其他key,如果key的分布不均匀)会使采样不准确,不均匀的分区可能导致下游任务比其他任务更慢,而导致整个任务变慢。
如果与某个关键字相关联的所有记录的重复key太多而被分配到一个执行器(executor),则范围分区(如散列分区)可能会导致内存错误。与排序相关的性能问题通常是由范围分区步骤的这些问题引起的。
使用Spark创建RangePartitioner不仅需要分区数量的参数,还需要实际的RDD,用来获取样本。 RDD必须是元组,并且key必须具有已定义的顺序。
实际上,采样需要部分评估RDD,从而导致执行图(graph)中断。 因此,范围分区实际上既是转换(transformation)操作又是action(动作)操作。 在范围分区中采样需要消耗资源,有一定成本,通常,RangePartitioner(范围分区)比HashPartitioner(散列分区)更耗性能。由于key要求被排序,这样就无法在元组的所有RDD上进行范围分区。
因此,键/值操作(例如聚合)需要使用HashPartitioner作为默认值,这些操作需要每个key都位于同一台机器上但不以特定方式排序的记录。但是,也可以使用自定义分区程序或范围分区程序执行这些方法。
RangePartitioner的实现:
自定义分区器(Partitioner)
通过继承Partitioner抽象类,可以定制自己的分区器。要定义自己的分区器需要实现以下函数
Spark分区实战
下面是一个分区实战的例子,通过这个例子可以更好的理解分区的工作方式,能更好的使用自定义分区。
import org.apache.spark.Partitioner
class CustTwoPartitioner(override val numPartitions: Int) extends Partitioner {
def getPartition(key: Any): Int = key match {
case s: String => {
if (s(0).toUpper > 'C') 1 else 0
}
}
}
var x = sc.parallelize(Array(("aa",1),("bb",1),("cc",1),("dd",1),("ee",1)), 3)
var y = x.partitionBy(new TwoPartsPartitioner(2))
以上 代码,我们 自定义了一个分区器,并根据自定义的分区器对RDD进行重新分区。但要注意,若改变分区 数量或分区器通常会导致Shuffle操作。