1008深度分析wordcount

以 Spark 2.1.0 的 examples 項目自帶的 JavaWordCount 爲例,從 Java 語言出發,介紹廣爲人知的 word count,並展示 Spark API 的使用。通過對此例子的介紹,還將幫助讀者把調度系統、計算引擎、部署模式等內容串聯起來,以對 Spark 核心知識的掌握上升到一個更高的層次。

JavaWordCount 的實現

public final class JavaWordCount {
  private static final Pattern SPACE = Pattern.compile(" ");

  public static void main(String[] args) throws Exception {

    if (args.length < 1) {// 保證必須有參數,此參數代表待讀取文件
      System.err.println("Usage: JavaWordCount <file>");
      System.exit(1);
    }

    SparkSession spark = SparkSession
      .builder() // 創建SparkSession的構建器
      .master("local[1]")// 設置部署模式
      .appName("JavaWordCount")// 設置JavaWordCount例子的應用名稱
      .getOrCreate();// 使用構建器構造SparkSession實例
    // 獲取DataFrameReader,使用DataFrameReader將文本文件轉換爲DataFrame
    JavaRDD<String> lines = spark.read().textFile(args[0]).javaRDD();
    // 使用RDD的flatMap 方法對MapPartitionsRDD進行轉換
    JavaRDD<String> words = lines.flatMap(new FlatMapFunction<String, String>() {
      @Override
      public Iterator<String> call(String s) {// 轉換函數的作用是對每行文本進行單詞拆分
        return Arrays.asList(SPACE.split(s)).iterator();
      }
    });
    // 使用RDD的mapToPair方法對MapPartitionsRDD進行轉換
    JavaPairRDD<String, Integer> ones = words.mapToPair(
      new PairFunction<String, String, Integer>() {
        @Override
        public Tuple2<String, Integer> call(String s) { // 轉換函數的作用是生成每個單詞和1的對偶
          return new Tuple2<>(s, 1);
        }
      });
    // 使用RDD的reduceByKey方法對MapPartitionsRDD進行轉換
    JavaPairRDD<String, Integer> counts = ones.reduceByKey(
      new Function2<Integer, Integer, Integer>() {
        @Override
        // 轉換函數的作用是對每個單詞的計數值累加
        public Integer call(Integer i1, Integer i2) {
          return i1 + i2;
        }
      });
    // 使用RDD的collect方法對MapPartitionsRDD及其上游轉換進行計算
    List<Tuple2<String, Integer>> output = counts.collect();
    for (Tuple2<?,?> tuple : output) {
      System.out.println(tuple._1() + ": " + tuple._2());
    }
    spark.stop();// 停止SparkSession
  }
}

Job 準備階段

JavaWordCount 首先對 SparkSession SparkContext 進行初始化然後通過 Data-FrameReader textFile 方法生成 DataFrame最後調用 RDD 的一系列轉換 API 對 RDD 進行轉換並構造出 DAG

SparkSession 與 SparkContext 的初始化

JavaWordCount 的 main 方法中首先調用 SparkSession 的 builder 方法創建 Builder然後調用 Builder 的 master 和 appName 兩個方法給 Builder 的 options 中添加 spark.master spark.app.name 兩個選項最後調用 Builder 的getOrCreate 方法獲取或創建 SparkSession 實例在實例化SparkSession 的過程中如果用戶沒有指定 Spark-Context那麼將創建SparkContext 並對 SparkContext 初始化

DataFrame 的生成

在創建了 SparkSession 實例後調用 SparkSession 的 read 方法創建DataFrameReader 實例然後調用 DataFrameReader textFile 方法讀取參數中指定文件的內容根據我們對 DataFrameReader textFile 方法的分析我們知道其實際上調用了 text 方法和 select 方法而 text 方法又依賴於 format 方法設置待讀取文件的格式和 load 方法讀取文件的內容DataFrameReader 的 load 方法會將 BaseRelation 轉換爲 Dataset[Row] Data-Frame

RDD 的轉換與 DAG 的構建

Dataset 剛被實例化的時候其屬性 rdd 的語句塊並未執行所以當JavaWordCount 調用 DataSet 的 javaRDD 方法時會使得 rdd 的語句塊執行根據我們對 rdd 語句塊的分析將會調用 QueryExecution 的 toRdd 方法QueryExecution 的 toRdd 方法將使用 Spark SQL 的執行計劃首先構造 FileScanRDD然後調用 RDD 的 mapPartitionsWithIndex 方法創建FileScanRDD 的下游 MapPartitionsRDD最後調用 RDD 的mapPartitionsWithIndexInternal 方法創建更下游的 MapPartitionsRDD完成對 RDD 的部分轉換和依賴關係的構建如圖所示

注意

由於 Spark SQL 不屬於本書要講解的內容所以這裏只是簡單說明 RDD 的轉換與 DAG 構建相關的內容早期版本的 Spark 中Spark SQL 與 RDD 的轉換及 DAG 的構建是互相分離的部分現在的版本已經將部分 RDD 轉換及 DAG 構建的工作放在了 Spark SQL
在執行完 Spark SQL 的執行計劃後還調用 RDD 的 mapPartitions 方法構造更下游的 MapPartitionsRDD此時 RDD 的 DAG 如圖

在調用了 DataSet 的 javaRDD 方法(實際調用 RDD 的 toJavaRDD 方法)後,MapPartitionsRDD 被封裝爲類型爲 JavaRDD 的 lines。

由於 JavaRDD 繼承了特質 JavaRDDLike,所以 lines 的 flatMap 方法實際是繼承自Java-RDDLike 的 flatMap 方法。在調用 JavaRDDLike 的 flatMap 方法時,以FlatMapFunction 的匿名實現類作爲函數參數。JavaRDDLike 的 flatMap 方法的實現如下。

def flatMap[U](f: FlatMapFunction[T, U]): JavaRDD[U] = {
  def fn: (T) => Iterator[U] = (x: T) => f.call(x).asScala
  JavaRDD.fromRDD(rdd.flatMap(fn)(fakeClassTag[U]))(fakeClassTag[U])
}
此時JavaRDD 內部的 rdd 屬性實質是最下游的 MapPartitionsRDD調用 Map-Parti-tionsRDD 的父類 RDD 的 flatMap 方法構造下游的MapPartitions-RDD此時 RDD 的 DAG 如圖所示

由於變量 words 的類型依然是 JavaRDD所以調用 words 的 mapToPair 方法其實也繼承自特質 JavaRDDLike其實現如下
def mapToPair[K2, V2](f: PairFunction[T, K2, V2]): JavaPairRDD[K2, V2] = {
  def cm: ClassTag[(K2, V2)] = implicitly[ClassTag[(K2, V2)]]
  new JavaPairRDD(rdd.map[(K2, V2)](f)(cm))(fakeClassTag[K2], fakeClassTag[V2])
}
根據 mapToPair 的實現在調用 JavaRDD 內部的 rdd最下游的MapPartitionsRDD的父類 RDD 的 map 方法PairFunction 的匿名實現類作爲函數參數構造下游的 MapPartitionsRDD並將此MapPartitionsRDD 封裝爲 JavaPairRDD此時 RDD 的 DAG 如圖所示
由於變量 ones 的類型爲 JavaPairRDD所以 ones 的 reduceByKey 方法繼承自JavaPair-RDDJavaPairRDD reduceByKey 方法的實現如下
def reduceByKey(func: JFunction2[V, V, V]): JavaPairRDD[K, V] = {
  fromRDD(reduceByKey(defaultPartitioner(rdd), func))
}
JavaPairRDD reduceByKey 方法首先調用 defaultPartitioner 方法獲取默認的分區計算器然後調用 JavaPairRDD 中重載的另一個reduceByKey 方法
def defaultPartitioner(rdd: RDD[_], others: RDD[_]*): Partitioner = {
  val rdds = (Seq(rdd) ++ others)
  val hasPartitioner = rdds.filter(_.partitioner.exists(_.numPartitions > 0))
  if (hasPartitioner.nonEmpty) {
    hasPartitioner.maxBy(_.partitions.length).partitioner.get
  } else {
    if (rdd.context.conf.contains("spark.default.parallelism")) {
      new HashPartitioner(rdd.context.defaultParallelism)
    } else {
      new HashPartitioner(rdds.map(_.partitions.length).max)
    }
  }
}

可以看到 defaultPartitioner 方法的執行邏輯如下。

  1. 如果 RDD 中有分區計算器,且分區計算器計算得到的分區數量大於零,那麼從這些分區計算器中挑選分區數量最多的那個分區計算器作爲當前 RDD 的分區計算器。
  2. 如果 RDD 中沒有分區計算器,則以 HashPartitioner 作爲當前 RDD 的分區計算器。
JavaPairRDD reduceByKey 方法
def reduceByKey(partitioner: Partitioner, func: JFunction2[V, V, V]): JavaPair-RDD[K, V] =
  fromRDD(rdd.reduceByKey(partitioner, func))
JavaPairRDD reduceByKey 方法將首先調用PairRDDFunctions reduceByKey 方法然後再次封裝爲JavaPairRDD
PairRDDFunctions reduceByKey 方法
def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)] = self.withScope {
  combineByKeyWithClassTag[V]((v: V) => v, func, func, partitioner)
}

PairRDDFunctions 的 reduceByKey 方法將調用PairRDDFunctions 的 combineByKeyWithClassTag 方法。

PairRDDFunctions 的 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("HashPartitioner 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, con-text))
    }, preservesPartitioning = true)
  } else {
    new ShuffledRDD[K, V, C](self, partitioner)
      .setSerializer(serializer)
      .setAggregator(aggregator)
      .setMapSideCombine(mapSideCombine)
  }
}

根據 combineByKeyWithClassTag 方法的實現,其執行步驟如下。

  1. 創建聚合器(Aggregator)。
  2. 如果當前 RDD 的分區計算器與指定的分區計算器相同,則調用 RDD 的mapParti-tions 方法創建 MapPartitionsRDD。
  3. 如果當前 RDD 的分區計算器與指定的分區計算器不相同,則創建ShuffledRDD。

在 JavaWordCount 的例子中,調用 combineByKeyWithClassTag 方法將創建ShuffledRDD。需要注意的是,ShuffledRDD 的 deps 爲 null,這是因爲ShuffledRDD 的依賴 ShuffleDependency 是在其 getDependencies 方法被調用時才創建的。

ShuffleDependency 的 getDependencies 方法

override def getDependencies: Seq[Dependency[_]] = {
  val serializer = userSpecifiedSerializer.getOrElse {
    val serializerManager = SparkEnv.get.serializerManager
    if (mapSideCombine) {
      serializerManager.getSerializer(implicitly[ClassTag[K]], implicitly[Class-Tag[C]])
    } else {
      serializerManager.getSerializer(implicitly[ClassTag[K]], implicitly[Class-Tag[V]])
    }
  }
  List(new ShuffleDependency(prev, part, serializer, keyOrdering, aggregator, mapSideCombine))
}
此時 RDD 的 DAG 如圖所示

注意

由於本例使用了 SparkSesion 的 API 來實現 word count所以構建了圖中的 RDD 及 DAG如果我們採用 SparkContext 的 API 來實現 word count生成的 RDD 及 DAG 會有所不同比如會生成 HadoopRDD深入理解 Spark核心思想與源碼分析中對使用 SparkContext 的 API 實現的 word count 例子有深入介紹

Job 的提交與調度

JavaWordCount 的最後調用了動作 API——collect這將引發對 Job 的提交和調度Job 的提交與調度大致可以分爲 Stage 的劃分ShuffleMapTask 的調度和執行及 Result-Task 的喚起調度和執行下面將對這些環節進行深入的分析

Stage 的劃分

由於 counts 的類型是 JavaPairRDD所以調用 counts 的 collect 方法實際繼承自父類 AbstractJavaRDDLike其實現如下

def collect(): JList[T] =
rdd.collect().toSeq.asJava

上面的代碼中主要調用了 ShuffledRDD 的父類 RDD 的 collect 方法。根據 collect 方法的實現,將以圖所示的由 RDD 組成的 DAG 爲參數,調用 Spark-Context 的 runJob 方法。SparkContext 的 runJob 方法終將調用 run-Job 方法,進而將 RDD 組成的 DAG 提交給DAGScheduler 進行調度。根據對 DAGScheduler 的分析,對 DAG 中的 RDD 進行階段劃分後的 Stage 如圖 所示。

圖中除了 ShuffledRDD 被劃入 ResultStage 外,其餘的 RDD 都被劃入到了Shuffle-MapStage 中。ShuffleMapStage 的 ID 爲 0,ResultStage 的 ID 爲 1。

ShuffleMapTask 的調度與執行

 
劃分完 Stage 後雖然首先提交 ResultStage但實際會率先提交 ResultStage 的父 Stage ShuffleMapStage提交 ShuffleMapStage 時會按照分區數目創建多個 ShuffleMapTaskDAGScheduler 將這些ShuffleMapTask 打包爲 TaskSet通過 TaskSchedulerImpl submitTasks 方法提交給 TaskSchedulerImplTaskSchedulerImpl 爲 TaskSet 創建 TaskSetManager並將 TaskSetManager 放入調度池參與到 FIFO 或 Fair 算法中進行調度在被調度後會向TaskSchedulerImpl 申請資源,最後將 Task 序列化後封裝爲 LaunchTask 消息再發送給CoarseGrainedExecutorBackendCoarseGrainedExecutorBackend 接收到 LaunchTask 消息後將調用 Executor launchTask 方法Executor 的 launchTask 方法在運行 Task 時將創建TaskRunnerTaskRunner 實現了 Runnable 接口的 run 方法TaskRunner 的 run 方法中將調用 Task 的 run 方法Task 的 run 方法將調用具體 Task 實現類此時爲 ShuffleMapTask的 runTask 方法ShuffleMapTask 經過迭代計算後將結果通過 SortShuffleWriter 寫入磁盤
ShuffleMapTask 經過 RDD 管道中對 iterator computeOrReadCheckpoint 的層層調用最終到達 FileScanRDD查看此時的線程棧會更直觀如圖所示
從圖中看到最底層執行計算的 RDD 是 FileScanRDD其 compute 方法實際是讀取文件列表中每個文件的內容對其 compute 方法的實現感興趣的讀者可自行查閱根據對 MapPartitionsRDD 的 compute 方法的分析ShuffleMapTask 將在迭代計算的過程中完成對從文件中讀取的每行數據的分詞計數和聚合

ResultTask 的喚起、調度與執行

TaskRunner 將在 ShuffleMapTask 執行成功後調用 SchedulerBackend 的實現類比如 local 模式下的 LocalSchedulerBackend Standalone 模式下的StandaloneSchedulerBackend statusUpdate 方法最終導致TaskSchedulerImpl statusUpdate 方法被調用TaskScheduler-Impl statusUpdate 方法發現 Task 是執行成功的狀態那麼調用 TaskResultGetter enqueueSuccessfulTask 方法獲取 ShuffleMapTask 的狀態並將此狀態交給 DAGScheduler 處理DAGScheduler taskEnded 方法對於ShuffleMapTask需要將 Stage 的 shuffleId outputLocs 中的 MapStatus 註冊到 mapOutputTracker如果有某些分區的 Task 執行失敗則重新提交ShuffleMapStage否則調用 submitWaitingChildStages 方法提交當前ShuffleMapStage 的子 Stage ResultStageResultStage 的提交與調度同ShuffleMapStage 大致相同區別有會按照分區數量創建多個 ResultTaskTask 的 run 方法將調用 ResultTask 的 runTask 方法ResultTask 經過迭代計算後不會將結果寫入磁盤Result-Task 的迭代計算線程棧如圖所示
根據我們對 ShuffledRDD 的 compute 方法的分析ShuffledRDD 將使用 BlockStoreShuffleReader 的 read 方法獲取 ShuffleMapTask 輸出的 Block 並在 reduce 端進行聚合或排序ResultTask 執行成功的結果最後也交由 DAG-Scheduler taskEnded 方法處理taskEnded 方法中會調用 JobWaiter resultHandler 函數將各個 ResultTask 的結果收攏最後通過 JavaWordCount 例子中的打印語句將整個 Job 的執行結果打印出來部分打印結果如圖所示
 
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章