spark rdd 做判断_RDD的分区原理

本文深入探讨了Spark中RDD的分区原理,包括分区特点、默认分区行为、RDD的分区实现、分区标识与分区器。重点阐述了HashPartitioner和RangePartitioner的使用,以及自定义分区器的实现。理解RDD分区对于提升Spark应用性能至关重要。
摘要由CSDN通过智能技术生成

每个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的分区行为是不同的。下面对不同操作的默认分区行为进行了一个总结:

  • 分布式化一个本地数据集

a1dc8ff6bfa6b82839bda905db98b4fa.png
  • 从HDFS中读取数据

4f66633cba8c9a16dfdab5e72a340aca.png
  • 通过转换函数来创建RDD

12e8762adbfef886ed3f51f014ab261b.png
  • 通过聚合的方式来生成RDD

12e56a274cf6be1f4417af0a5db1696a.png

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的实现:

a5c852055db4cf0b13a2fa3f0063c00b.png

自定义分区器(Partitioner)

通过继承Partitioner抽象类,可以定制自己的分区器。要定义自己的分区器需要实现以下函数

b0f92dc7d24f030ce7746863c178931f.png

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操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值