7.spark core之數據分區

簡介

  spark一個最重要的特性就是對數據集在各個節點的分區進行控制。控制數據分佈可以減少網絡開銷,極大地提升整體性能。

  只有Pair RDD纔有分區,非Pair RDD分區的值是None。如果RDD只被掃描一次,沒必要預先分區處理;如果RDD多次在諸如連接這種基於鍵的操作中使用時,分區纔有作用。

分區器

  分區器決定了RDD的分區個數及每條數據最終屬於哪個分區。

  spark提供了兩個分區器:HashPartitioner和RangePartitioner,它們都繼承於org.apache.spark.Partitioner類並實現三個方法。

  • numPartitions: Int: 指定分區數
  • getPartition(key: Any): Int: 分區編號(0~numPartitions-1)
  • equals(): 檢查分區器對象是否和其他分區器實例相同,判斷兩個RDD分區方式是否一樣。

HashPartitioner分區

  HashPartitioner分區執行原理:對於給定的key,計算其hashCode,再除以分區數取餘,最後的值就是這個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 {
    case h: HashPartitioner =>
      h.numPartitions == numPartitions
    case _ =>
      false
  }

  override def hashCode: Int = numPartitions
}

RangePartitioner分區

  HashPartitioner分區可能導致每個分區中數據量的不均勻。而RangePartitioner分區則儘量保證每個分區中數據量的均勻,將一定範圍內的數映射到某一個分區內。分區與分區之間數據是有序的,但分區內的元素是不能保證順序的。

  RangePartitioner分區執行原理:

  • 計算總體的數據抽樣大小sampleSize,計算規則是:至少每個分區抽取20個數據或者最多1M的數據量。
  • 根據sampleSize和分區數量計算每個分區的數據抽樣樣本數量sampleSizePrePartition
  • 調用RangePartitioner的sketch函數進行數據抽樣,計算出每個分區的樣本。
  • 計算樣本的整體佔比以及數據量過多的數據分區,防止數據傾斜。
  • 對於數據量比較多的RDD分區調用RDD的sample函數API重新進行數據抽取。
  • 將最終的樣本數據通過RangePartitoner的determineBounds函數進行數據排序分配,計算出rangeBounds。
class RangePartitioner[K: Ordering : ClassTag, V](
                partitions: Int,
                rdd: RDD[_ <: Product2[K, V]],
                private var ascending: Boolean = true)
  extends Partitioner {

  // We allow partitions = 0, which happens when sorting an empty RDD under the default settings.
  require(partitions >= 0, s"Number of partitions cannot be negative but found $partitions.")

  // 獲取RDD中K類型數據的排序器
  private var ordering = implicitly[Ordering[K]]

  // An array of upper bounds for the first (partitions - 1) partitions
  private var rangeBounds: Array[K] = {
    if (partitions <= 1) {
      // 如果給定的分區數小於等於1的情況下,直接返回一個空的集合,表示數據不進行分區
      Array.empty
    } else {
      // This is the sample size we need to have roughly balanced output partitions, capped at 1M.
      // 給定總的數據抽樣大小,最多1M的數據量(10^6),最少20倍的RDD分區數量,也就是每個RDD分區至少抽取20條數據
      val sampleSize = math.min(20.0 * partitions, 1e6)
      // Assume the input partitions are roughly balanced and over-sample a little bit.
      // RDD各分區中的數據量可能會出現傾斜的情況,乘於3的目的就是保證數據量小的分區能夠採樣到足夠的數據,而對於數據量大的分區會進行第二次採樣
      val sampleSizePerPartition = math.ceil(3.0 * sampleSize / rdd.partitions.size).toInt
      // 從rdd中抽取數據,返回值:(總rdd數據量, Array[分區id,當前分區的數據量,當前分區抽取的數據])
      val (numItems, sketched) = RangePartitioner.sketch(rdd.map(_._1), sampleSizePerPartition)
      if (numItems == 0L) {
        // 如果總的數據量爲0(RDD爲空),那麼直接返回一個空的數組
        Array.empty
      } else {
        // If a partition contains much more than the average number of items, we re-sample from it
        // to ensure that enough items are collected from that partition.
        // 計算總樣本數量和總記錄數的佔比,佔比最大爲1.0
        val fraction = math.min(sampleSize / math.max(numItems, 1L), 1.0)
        // 保存樣本數據的集合buffer
        val candidates = ArrayBuffer.empty[(K, Float)]
        // 保存數據分佈不均衡的分區id(數據量超過fraction比率的分區)
        val imbalancedPartitions = mutable.Set.empty[Int]
        // 計算抽取出來的樣本數據
        sketched.foreach { case (idx, n, sample) =>
          if (fraction * n > sampleSizePerPartition) {
            // 如果fraction乘以當前分區中的數據量大於之前計算的每個分區的抽象數據大小,那麼表示當前分區抽取的數據太少了,該分區數據分佈不均衡,需要重新抽取
            imbalancedPartitions += idx
          } else {
            // 當前分區不屬於數據分佈不均衡的分區,計算佔比權重,並添加到candidates集合中
            // The weight is 1 over the sampling probability.
            val weight = (n.toDouble / sample.size).toFloat
            for (key <- sample) {
              candidates += ((key, weight))
            }
          }
        }

        // 對於數據分佈不均衡的RDD分區,重新進行數據抽樣
        if (imbalancedPartitions.nonEmpty) {
          // Re-sample imbalanced partitions with the desired sampling probability.
          // 獲取數據分佈不均衡的RDD分區,並構成RDD
          val imbalanced = new PartitionPruningRDD(rdd.map(_._1), imbalancedPartitions.contains)
          // 隨機種子
          val seed = byteswap32(-rdd.id - 1)
          // 利用rdd的sample抽樣函數API進行數據抽樣
          val reSampled = imbalanced.sample(withReplacement = false, fraction, seed).collect()
          val weight = (1.0 / fraction).toFloat
          candidates ++= reSampled.map(x => (x, weight))
        }

        // 將最終的抽樣數據計算出rangeBounds出來
        RangePartitioner.determineBounds(candidates, partitions)
      }
    }
  }

  // 下一個RDD的分區數量是rangeBounds數組中元素數量+ 1個
  def numPartitions: Int = rangeBounds.length + 1

  // 二分查找器,內部使用java中的Arrays類提供的二分查找方法
  private var binarySearch: ((Array[K], K) => Int) = CollectionsUtils.makeBinarySearch[K]

  // 根據RDD的key值返回對應的分區id。從0開始
  def getPartition(key: Any): Int = {
    // 強制轉換key類型爲RDD中原本的數據類型
    val k = key.asInstanceOf[K]
    var partition = 0
    if (rangeBounds.length <= 128) {
      // If we have less than 128 partitions naive search
      // 如果分區數據小於等於128個,那麼直接本地循環尋找當前k所屬的分區下標
      while (partition < rangeBounds.length && ordering.gt(k, rangeBounds(partition))) {
        partition += 1
      }
    } else {
      // Determine which binary search method to use only once.
      // 如果分區數量大於128個,那麼使用二分查找方法尋找對應k所屬的下標;
      // 但是如果k在rangeBounds中沒有出現,實質上返回的是一個負數(範圍)或者是一個超過rangeBounds大小的數(最後一個分區,比所有數據都大)
      partition = binarySearch(rangeBounds, k)
      // binarySearch either returns the match location or -[insertion point]-1
      if (partition < 0) {
        partition = -partition - 1
      }
      if (partition > rangeBounds.length) {
        partition = rangeBounds.length
      }
    }

    // 根據數據排序是升序還是降序進行數據的排列,默認爲升序
    if (ascending) {
      partition
    } else {
      rangeBounds.length - partition
    }
  }

影響分區的算子操作

  影響分區的算子操作有:cogroup()、groupWith()、join()、leftOuterJoin()、rightOuterJoin()、groupByKey()、reduceByKey()、combineByKey()、partitionBy()、repartition()、coalesce()、sort()、mapValues()(如果父RDD有分區方式)、flatMapValues()(如果父RDD有分區方式)。

  對於執行兩個RDD的算子操作,輸出數據的分區方式取決於父RDD的分區方式。默認情況下,結果會採用哈希分區,分區的數量和操作的並行度一樣。不過,如果其中一個父RDD設置過分區方式,結果就採用那種分區方式;如果兩個父RDD都設置過分區方式,結果RDD採用第一個父RDD的分區方式。

repartition和partitionBy的區別

  repartition 和 partitionBy 都是對數據進行重新分區,默認都是使用 HashPartitioner。但是二者之間的區別有:

  • partitionBy只能用於Pair RDD
  • 都作用於Pair RDD時,結果也不一樣
    repartition和partitionBy的區別.jpg

  其實partitionBy的結果纔是我們所預期的。repartition 其實使用了一個隨機生成的數來當作 key,而不是使用原來的key。

def repartition(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T] = withScope {
    coalesce(numPartitions, shuffle = true)
}

def coalesce(numPartitions: Int, shuffle: Boolean = false)(implicit ord: Ordering[T] = null)
      : RDD[T] = withScope {
    if (shuffle) {
      /** Distributes elements evenly across output partitions, starting from a random partition. */
      val distributePartition = (index: Int, items: Iterator[T]) => {
        var position = (new Random(index)).nextInt(numPartitions)
        items.map { t =>
          // Note that the hash code of the key will just be the key itself. The HashPartitioner
          // will mod it with the number of total partitions.
          position = position + 1
          (position, t)
        }
      } : Iterator[(Int, T)]

      // include a shuffle step so that our upstream tasks are still distributed
      new CoalescedRDD(
        new ShuffledRDD[Int, T, T](mapPartitionsWithIndex(distributePartition),
        new HashPartitioner(numPartitions)),
        numPartitions).values
    } else {
      new CoalescedRDD(this, numPartitions)
    }
}

repartition和coalesce的區別

  兩個算子都是對RDD的分區進行重新劃分,repartition只是coalesce接口中shuffle爲true的簡易實現,(假設RDD有N個分區,需要重新劃分成M個分區)

  • N<M。一般情況下N個分區有數據分佈不均勻的狀況,利用HashPartitioner函數將數據重新分區爲M個,這時需要將shuffle設置爲true。
  • 如果N>M並且N和M相差不多(假如N是1000,M是100),這時可以將shuffle設置爲false,不進行shuffle過程,父RDD和子RDD之間是窄依賴關係。在shuffle爲false的情況下,如果N<M時,coalesce是無效的。
  • 如果N>M並且兩者相差懸殊,這時如果將shuffle設置爲false,父子RDD是窄依賴關係,同處在一個Stage中,就可能造成spark程序的並行度不夠,從而影響性能。如果在M爲1的時候,爲了使coalesce之前的操作有更好的並行度,可以將shuffle設置爲true。

實例分析

需求

  統計用戶訪問其未訂閱主題頁面的情況。

  • 用戶信息表:由(UserID,UserInfo)組成的RDD,UserInfo包含該用戶所訂閱的主題列表。
  • 事件表:由(UserID,LinkInfo)組成的RDD,存放着每五分鐘內網站各用戶訪問情況。

代碼實現

val sc = new SparkContext()
val userData = sc.sequenceFile[UserID,LinkInfo]("hdfs://...").persist
def processNewLogs(logFileName:String){
    val events = sc.sequenceFile[UserID, LinkInfo](logFileName)
    //RDD of (UserID,(UserInfo,LinkInfo)) pairs
    val joined = usersData.join(events)
    val offTopicVisits = joined.filter {
        // Expand the tuple into its components
        case (userId, (userInfo, linkInfo)) => 
            !userInfo.topics.contains(linkInfo.topic)
    }.count()
    println("Number of visits to non-subscribed opics: " + offTopicVisits)
}

缺點

  連接操作會將兩個數據集中的所有鍵的哈希值都求出來,將哈希值相同的記錄通過網絡傳到同一臺機器上,然後再對所有鍵相同的記錄進行連接操作。userData表數據量很大,所以這樣進行哈希計算和跨節點數據混洗非常耗時。

改進代碼實現

val userData = sc.sequenceFile[UserID,LinkInfo]("hdfs://...")
.partionBy(new HashPartiotioner(100))
.persist()

優點

  userData表進行了重新分區,將鍵相同的數據都放在一個分區中。然後調用persist持久化結果數據,不用每次都計算哈希和跨節點混洗。程序運行速度顯著提升。


忠於技術,熱愛分享。歡迎關注公衆號:java大數據編程,瞭解更多技術內容。

這裏寫圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章