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 的执行结果打印出来部分打印结果如图所示
 
 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值