通過WordCount解析Spark RDD內部原始碼機制

|舊市拾荒|發表於2020-09-02

一、Spark WordCount動手實踐

我們通過Spark WordCount動手實踐,編寫單詞計數程式碼;在wordcount.scala的基礎上,從資料流動的視角深入分析Spark RDD的資料處理過程。

首先需要建立一個文字檔案helloSpark.txt,helloSpark.txt的文字內容如下。

Hello Spark Hello Scala
Hello Hadoop
Hello Flink
Spark is Awesome

然後在Eclipse中編寫wordcount.scala的程式碼如下。

import org.apache.spark.SparkConf
import org.apache.spark.SparkContext


object wordcount {
  def main(args: Array[String]): Unit = {
    
    // 第1步:建立Spark的配置物件SparkConf,設定Spark程式執行時的配置資訊,
    val conf = new SparkConf().setAppName("My First Spark APP").setMaster("local")
    
    // 第2步:建立SparkContext物件
    val sc = new SparkContext(conf)
    
    // 第3步:根據具體的資料來源來建立RDD
    val lines = sc.textFile("helloSpark.txt", 1)
    
    // 第4步:對初始的RDD進行Transformation級別的處理,如通過map、filter等
    val words = lines.flatMap{line=>line.split(" ")}
    val pairs = words.map{word=>(word,1)}
    val wordCountsOdered = pairs.reduceByKey(_+_).map(
      pair=>(pair._2,pair._1)    
    ).sortByKey(false).map(pair=>(pair._2,pair._1))
    wordCountsOdered.collect.foreach(wordNumberPair=>println(wordNumberPair._1+" : "+wordNumberPair._2))
    sc.stop()
    
  }
}

在Eclipse中執行程式,wordcount.scala的執行結果如下:

通過WordCount解析Spark RDD內部原始碼機制

二、解析RDD生成的內部機制

下面詳細解析一下wordcount.scala的執行原理。

(1)從資料流動視角解密WordCount,使用Spark作單詞計數統計,搞清楚資料到底是怎麼流動的。

(2)從RDD依賴關係的視角解密WordCount。Spark中的一切操作都是RDD,後面的RDD對前面的RDD有依賴關係。

(3)DAG與血統Lineage的思考。

在wordcount.scala的基礎上,我們從資料流動的視角分析資料到底是怎麼處理的。下面有一張WordCount資料處理過程圖,由於圖片較大,為了方便閱讀,將原圖分成兩張圖,如下面兩張圖所示。

通過WordCount解析Spark RDD內部原始碼機制

通過WordCount解析Spark RDD內部原始碼機制

資料在生產環境中預設在HDFS中進行分散式儲存,如果在分散式叢集中,我們的機器會分成不同的節點對資料進行處理,這裡我們在本地測試,重點關注資料是怎麼流動的。處理的第一步是獲取資料,讀取資料會生成HadoopRDD。

在WordCount.scala中,單擊sc.textFile進入Spark框架,SparkContext.scala的textFile的原始碼如下。

  /**
   * Read a text file from HDFS, a local file system (available on all nodes), or any
   * Hadoop-supported file system URI, and return it as an RDD of Strings.
   */
  def textFile(
      path: String,
      minPartitions: Int = defaultMinPartitions): RDD[String] = withScope {
    assertNotStopped()
    hadoopFile(path, classOf[TextInputFormat], classOf[LongWritable], classOf[Text],
      minPartitions).map(pair => pair._2.toString).setName(path)
  }

下面看一下hadoopFile的原始碼,HadoopRDD從Hdfs上讀取分散式資料,並且以資料分片的方式存在於叢集中。所謂的資料分片,就是把我們要處理的資料分成不同的部分,例如,在叢集中有4個節點,粗略的劃分可以認為將資料分成4個部分,4條語句就分成4個部分。例如,Hello Spark在第一臺機器上,Hello Hadoop在第二臺機器上,Hello Flink在第三臺機器上,Spark is Awesome在第四臺機器上。HadoopRDD幫助我們從磁碟上讀取資料,計算的時候會分散式地放入記憶體中,Spark執行在Hadoop上,要藉助Hadoop來讀取資料。

Spark的特點包括:分散式、基於記憶體(部分基於磁碟)、可迭代;預設分片策略Block多大,分片就多大。但這種說法不完全準確,因為分片記錄可能跨兩個Block,所以一個分片不會嚴格地等於Block的大小。例如,HDFS的Block大小是128MB的話,分片可能多幾個位元組或少幾個位元組。分片不一定小於128MB,因為如果最後一條記錄跨兩個Block,分片會把最後一條記錄放在前一個分片中。這裡,HadoopRDD用了4個資料分片,設想為128M左右。

hadoopFile的原始碼如下。

  def hadoopFile[K, V](
      path: String,
      inputFormatClass: Class[_ <: InputFormat[K, V]],
      keyClass: Class[K],
      valueClass: Class[V],
      minPartitions: Int = defaultMinPartitions): RDD[(K, V)] = withScope {
    assertNotStopped()

    // This is a hack to enforce loading hdfs-site.xml.
    // See SPARK-11227 for details.
    FileSystem.getLocal(hadoopConfiguration)

    // A Hadoop configuration can be about 10 KB, which is pretty big, so broadcast it.
    val confBroadcast = broadcast(new SerializableConfiguration(hadoopConfiguration))
    val setInputPathsFunc = (jobConf: JobConf) => FileInputFormat.setInputPaths(jobConf, path)
    new HadoopRDD(
      this,
      confBroadcast,
      Some(setInputPathsFunc),
      inputFormatClass,
      keyClass,
      valueClass,
      minPartitions).setName(path)
  }

SparkContext.scala的textFile原始碼中,呼叫hadoopFile方法後進行了map轉換操作,map對讀取的每一行資料進行轉換,讀入的資料是一個Tuple,Key值為索引,Value值為每行資料的內容,生成MapPartitionsRDD。這裡,map(pair => pair._2.toString)是基於HadoopRDD產生的Partition去掉的行Key產生的Value,第二個元素是讀取的每行資料內容。MapPartitionsRDD是Spark框架產生的,執行中可能產生一個RDD,也可能產生兩個RDD。例如,textFile中Spark框架就產生了兩個RDD,即HadoopRDD和MapPartitionsRDD。下面是map的原始碼。

  /**
   * Return a new RDD by applying a function to all elements of this RDD.
   */
  def map[U: ClassTag](f: T => U): RDD[U] = withScope {
    val cleanF = sc.clean(f)
    new MapPartitionsRDD[U, T](this, (context, pid, iter) => iter.map(cleanF))
  }

我們再來看一下WordCount業務程式碼,對讀取的每行資料進行flatMap轉換。這裡,flatMap對RDD中的每一個Partition的每一行資料內容進行單詞切分,如有4個Partition分別進行單詞切分,將“Hello Spark”切分成單詞“Hello”和“Spark”,對每一個Partition中的每一行進行單詞切分併合併成一個大的單詞例項的集合。flatMap轉換生成的仍然是MapPartitionsRDD:

  /**
   *  Return a new RDD by first applying a function to all elements of this
   *  RDD, and then flattening the results.
   */
  def flatMap[U: ClassTag](f: T => TraversableOnce[U]): RDD[U] = withScope {
    val cleanF = sc.clean(f)
    new MapPartitionsRDD[U, T](this, (context, pid, iter) => iter.flatMap(cleanF))
  }

繼續WordCount業務程式碼,計數之後進行一個關鍵的reduceByKey操作,對全域性的資料進行計數統計。reduceByKey對相同的Key進行Value的累計(包括Local和Reducer級別,同時Reduce)。reduceByKey在MapPartitionsRDD之後,在Local reduce級別本地進行了統計,這裡也是MapPartitionsRDD。例如,在本地將(Hello,1),(Spark,1),(Hello,1),(Scala,1)匯聚成(Hello,2),(Spark,1),(Scala,1)。

Shuffle之前的Local Reduce操作主要負責本地區域性統計,並且把統計以後的結果按照分割槽策略放到不同的file。舉一個簡單的例子,如果下一個階段Stage是3個並行度,每個Partition進行local reduce以後,將自己的資料分成3種型別,最簡單的方式是根據HashCode按3取模。

PairRDDFunctions.scala的reduceByKey的原始碼如下。

  /**
   * Merge the values for each key using an associative and commutative reduce function. This will
   * also perform the merging locally on each mapper before sending results to a reducer, similarly
   * to a "combiner" in MapReduce. Output will be hash-partitioned with the existing partitioner/
   * parallelism level.
   */
  def reduceByKey(func: (V, V) => V): RDD[(K, V)] = self.withScope {
    reduceByKey(defaultPartitioner(self), func)
  }  

至此,前面所有的操作都是一個Stage,一個Stage意味著什麼:完全基於記憶體操作。父Stage:Stage內部的操作是基於記憶體迭代的,也可以進行Cache,這樣速度快很多。不同於Hadoop的Map Redcue,Hadoop Map Redcue每次都要經過磁碟。

reduceByKey在Local reduce本地匯聚以後生成的MapPartitionsRDD仍屬於父Stage;然後reduceByKey展開真正的Shuffle操作,Shuffle是Spark甚至整個分散式系統的效能瓶頸,Shuffle產生ShuffleRDD,ShuffledRDD就變成另一個Stage,為什麼是變成另外一個Stage?因為要網路傳輸,網路傳輸不能在記憶體中進行迭代。

從WordCount業務程式碼pairs.reduceByKey(_+_)中看一下PairRDDFunctions.scala的reduceByKey的原始碼。

  /**
   * Merge the values for each key using an associative and commutative reduce function. This will
   * also perform the merging locally on each mapper before sending results to a reducer, similarly
   * to a "combiner" in MapReduce.
   */
  def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)] = self.withScope {
    combineByKeyWithClassTag[V]((v: V) => v, func, func, partitioner)
  }

reduceByKey內部呼叫了combineByKeyWithClassTag方法。下面看一下PairRDDFunctions. scala的combineByKeyWithClassTag的原始碼。

  def combineByKeyWithClassTag[C](
      createCombiner: V => C,
      mergeValue: (C, V) => C,
      mergeCombiners: (C, C) => C,
      partitioner: Partitioner,
      mapSideCombine: Boolean = true,
      serializer: Serializer = null)(implicit ct: ClassTag[C]): RDD[(K, C)] = self.withScope {
    require(mergeCombiners != null, "mergeCombiners must be defined") // required as of Spark 0.9.0
    if (keyClass.isArray) {
      if (mapSideCombine) {
        throw new SparkException("Cannot use map-side combining with array keys.")
      }
      if (partitioner.isInstanceOf[HashPartitioner]) {
        throw new SparkException("Default partitioner cannot partition array keys.")
      }
    }
    val aggregator = new Aggregator[K, V, C](
      self.context.clean(createCombiner),
      self.context.clean(mergeValue),
      self.context.clean(mergeCombiners))
    if (self.partitioner == Some(partitioner)) {
      self.mapPartitions(iter => {
        val context = TaskContext.get()
        new InterruptibleIterator(context, aggregator.combineValuesByKey(iter, context))
      }, preservesPartitioning = true)
    } else {
      new ShuffledRDD[K, V, C](self, partitioner)
        .setSerializer(serializer)
        .setAggregator(aggregator)
        .setMapSideCombine(mapSideCombine)
    }
  }

在combineByKeyWithClassTag方法中就用new()函式建立了ShuffledRDD。

前面假設有4臺機器平行計算,每臺機器在自己的記憶體中進行迭代計算,現在產生Shuffle,資料就要進行分類,MapPartitionsRDD資料根據Hash已經分好類,我們就抓取MapPartitionsRDD中的資料。我們從第一臺機器中獲取的內容為(Hello,2),從第二臺機器中獲取的內容為(Hello,1),從第三臺機器中獲取的內容為(Hello,1),把所有的Hello都抓過來。同樣,我們把其他的資料(Hadoop,1),(Flink,1)……都抓過來。

這就是Shuffle的過程,根據資料的分類拿到自己需要的資料。注意,MapPartitionsRDD屬於第一個Stage,是父Stage,內部基於記憶體進行迭代,不需要操作都要讀寫磁碟,所以速度非常快;從計算運算元的角度講,reduceByKey發生在哪裡?reduceByKey發生的計算過程包括兩個RDD:一個是MapPartitionsRDD;一個是ShuffledRDD。ShuffledRDD要產生網路通訊。

reduceByKey之後,我們將結果收集起來,進行全域性級別的reduce,產生reduceByKey的最後結果,如將(Hello,2),(Hello,1),(Hello,1)在內部變成(Hello,4),其他資料也類似統計。這裡reduceByKey之後,如果通過Collect將資料收集起來,就會產生MapPartitionsRDD。從Collect的角度講,MapPartitionsRDD的作用是將結果收集起來傳送給Driver;從saveAsTextFile輸出到Hdfs的角度講,例如輸出(Hello,4),其中Hello是key,4是Value嗎?不是!這裡(Hello,4)就是value,這就需要設計一個key出來。

下面是RDD.scala的saveAsTextFile方法。

  /**
   * Save this RDD as a text file, using string representations of elements.
   */
  def saveAsTextFile(path: String): Unit = withScope {
    // https://issues.apache.org/jira/browse/SPARK-2075
    //
    // NullWritable is a `Comparable` in Hadoop 1.+, so the compiler cannot find an implicit
    // Ordering for it and will use the default `null`. However, it's a `Comparable[NullWritable]`
    // in Hadoop 2.+, so the compiler will call the implicit `Ordering.ordered` method to create an
    // Ordering for `NullWritable`. That's why the compiler will generate different anonymous
    // classes for `saveAsTextFile` in Hadoop 1.+ and Hadoop 2.+.
    //
    // Therefore, here we provide an explicit Ordering `null` to make sure the compiler generate
    // same bytecodes for `saveAsTextFile`.
    val nullWritableClassTag = implicitly[ClassTag[NullWritable]]
    val textClassTag = implicitly[ClassTag[Text]]
    val r = this.mapPartitions { iter =>
      val text = new Text()
      iter.map { x =>
        text.set(x.toString)
        (NullWritable.get(), text)
      }
    }
    RDD.rddToPairRDDFunctions(r)(nullWritableClassTag, textClassTag, null)
      .saveAsHadoopFile[TextOutputFormat[NullWritable, Text]](path)
  }

RDD.scala的saveAsTextFile方法中的iter.map {x=>text.set(x.toString) (NullWritable.get(), text)},這裡,key轉換成Null,value就是內容本身(Hello,4)。saveAsHadoopFile中的TextOutputFormat要求輸出的是key-value的格式,而我們處理的是內容。回顧一下,之前我們在textFile讀入資料的時候,讀入split分片將key去掉了,計算的是value。因此,輸出時,須將丟失的key重新弄進來,這裡key對我們沒有意義,但key對Spark框架有意義,只有value對我們有意義。第一次計算的時候我們把key丟棄了,所以最後往HDFS寫結果的時候需要生成key,這符合對稱法則和能量守恆形式。

三、WordCount總結:

  第一個Stage有哪些RDD?HadoopRDD、MapPartitionsRDD、MapPartitionsRDD、MapPartitionsRDD、MapPartitionsRDD。

  第二個Stage有哪些RDD?ShuffledRDD、MapPartitionsRDD。

  

 

 

相關文章