概述
本文介绍了Spark分区的实现原理,并对其源码进行了分析。
分区器(Partitioner)的基本概念
关于Spark分区的基本概念和介绍,可以参考我的这篇文章:Spark2.0-RDD分区原理分析。这里我们再回顾一下Spark分区的概念:
从概念上讲,分区器(Partitioner)定义了如何分布记录,从而定义每个任务将处理哪些记录。
从实现层面来说,Partitioner是一个带有以下两个方法的抽象类:numPartitions和getPartition。该类的定义如下:
abstract class Partitioner extends Serializable {
def numPartitions: Int
def getPartition(key: Any): Int
}
- numPartitions:定义分区后RDD中的分区数。
- getPartition:定义从key到分区的整数索引的映射,其中应该发送具有该key的记录。
Spark提供的分区器(Partitioner)对象有两种实现:HashPartitioner和RangePartitioner(在2.3中有更多的实现)。如果这些都不满足需要,可以自定义分区程序。
HashPartitioner
当RDD没有Partitioner时,会把HashPartitioner作为默认的Partitioner。它通过计算key的hashcode来对数据进行分区。该类的实现代码如下:
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的实现:
成员变量 | 说明 |
---|---|
ascending | 定义分区数据的排序方式,默认是升序。定义如下:private var ascending: Boolean = true |
samplePointsPerPartitionHint | 每个分区具有的样本数目。 |
partitions | 分区数,可以为0。当该参数为0时,表示对空RDD进行排序。 |
ordering | 排序需要用到的工具类,定义了:大于,小于等操作 |
rangeBounds | 保存分区数的,带上限的数组 |
numPartitions | 该RDD的分区数量。 |
binarySearch | 获取一个二分查找的对象。由于key是可排序的,所以使用二分加快查询性能。 |
getPartition | 获取分区对应的索引id |
自定义分区器(Partitioner)
通过继承Partitioner抽象类,可以定制自己的分区器。要定义自己的分区器可能需要实现以下函数:
成员名 | 说明 |
---|---|
numPartitions | 分区数 |
getPartition | key作为参数(与被分区的RDD类型相同),返回表示分区索引的整数,该分区指定具有该key的记录所在的位置。 返回的整数必须介于零和分区数之间(在numPartitions定义)。 |
equals | 定义两个分区是否相等的方法。 |
hashcode | 仅当重写了equals方法时才需要此方法。 HashPartitioner的hashcode就是它的分区数。 RangePartitioner的hashcode是从范围边界派生的哈希函数。 |
总结
本文介绍了RDD的Partitioner原理,并对其实现进行了简要分析。