Spark基础知识笔记(含安装配置与开发环境避坑流程)

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/qq_33588730/article/details/82351806

1.Spark是用于大数据处理的集群计算框架,它不以MapReduce作为执行引擎,而是使用自己的分布式运行环境在集群上工作,可以在YARN上运行并与HDFS配合。Spark最突出的特点是能将作业与作业之间产生的大规模中间工作数据集存储在内存中,在性能上超过中间数据也在磁盘读写的MapReduce一个数量级。从Spark中可以提升性能最大的是迭代算法(对一个数据集重复应用某函数)和交互式分析(用户向数据集发出一系列专用的探索性查询)两种应用。同时,Spark的DAG引擎可以处理任意操作流,并为用户将其转换为单个作业。同时Spark还可以用于构建数据分析工具的平台。

2.Spark的安装配置过程如下:

(1)下载和Hadoop版本匹配的Spark版本包,我这里用的CDH版Hadoop,去以下网址下载CDH版的Spark,按Ctrl+F搜索spark,然后找到最新版的Spark,我这里是5.15.0版的CDH,以及1.6.0版的Spark:

https://archive.cloudera.com/cdh5/cdh/5/

然后将下载好的tar.gz包用以下命令解压到想要放置的目录下:

tar -zvxf /mnt/sda6/spark-1.6.0-cdh5.15.0.tar.gz -C /mnt/sda6/Hadoop

(2)配置Spark的环境变量,使命令行处于任何路径都可以运行Spark命令,用sudo vim /etc/profile命令进入vim编辑器编辑系统环境变量文件/etc/profile,按i键进入编辑模式,写入如下路径变量:

写好后按ESC退出编辑模式进入只读模式,然后按shift+冒号键,在左下角冒号后输入wq保存写入并退出。接着在命令行输入source /etc/profile命令使环境变量文件立刻生效,就能在命令行任何路径下使用Spark命令了。

(3)以上是单机模式运行Spark需要的配置,接下来的配置用于伪分布式运行。将Spark安装目录下的/conf/spark-env.sh.template文件重命名去掉.template后缀,使其从模板文件变为spark-env.sh配置文件(如果想要单机运行依然可以添加.template后缀变回模板文件不被启用),并配置JDK的绝对路径和其他路径变量,如下所示:

需要注意!最后一行配置SPARK_DIST_CLASSPATH变量在CDH版的Spark里非常重要,网上有说可以直接等号后面为$(hadoop classpath),自己试过之后发现这样只能启动Master进程不再报“java.lang.NoClassDefFoundError: org/apache/hadoop/conf/Configuration”,Worker进程依然无法启动并报错“java.lang.NoClassDefFoundError: org/slf4j/Logger”,即使把对应jar包放到安装目录下的/lib目录里也没有用,一定要设置为CDH版Hadoop的绝对路径到/bin然后后面再加/hadoop classpath。然后在该文件再修改以下变量设置Hadoop配置文件所在目录:

export HADOOP_CONF_DIR=/mnt/sda6/Hadoop/hadoop-2.6.0-cdh5.15.0/etc/hadoop

接着,将同目录下的spark-defaults.conf.template文件重命名去掉.template后缀以启用该配置文件,用于设定一些默认的Spark运行环境变量,设定master的地址,如下所示:

最后将同目录下的slaves.template文件重命名去掉.template后缀以启用该文件,用于设置集群的Worker列表,在该文件中输入本机的主机名,如下所示:

这样Spark的伪分布式环境就配置好了,如果是全分布式部署,只需要在slaves文件里设置其他Worker的主机名,并将本机的Spark包和其他环境变量配置拷贝到其他主机即可。

4.如果配置了Spark在YARN上进行资源调度,则启动Spark进程前需要先启动Hadoop。可以用以下命令启动和停止Spark(指定Spark路径是因为启动脚本名称与Hadoop启动脚本名称同名):

$SPARK_HOME/sbin/start-all.sh
$SPARK_HOME/sbin/stop-all.sh

成功启动会生成Master和Worker进程,如下所示:

可以通过spark-shell命令启动Spark shell环境,它是添加了一些Spark功能的Scala交互式解释器,如下所示:

如果启动shell的时候报错“java.lang.NoClassDefFoundError: parquet/hadoop/ParquetOutputCommitter”,就去网上下载一个parquet-hadoop-1.6.0.jar文件,然后放在$SPARK_HOME/lib目录下,并在$SPARK_HOME/conf/spark-defaults.conf文件中加入以下环境变量(以空格分开属性名和属性值),再重启Spark进程,然后再输入spark-shell命令就不会报错了

spark.driver.extraClassPath /mnt/sda6/Hadoop/spark-1.6.0-cdh5.15.0/lib/parquet-hadoop-1.6.0.jar

5.启动spark shell后,从命令行输出可以看到创建了一个名为sc的Scala变量,用于保存SparkContext实例,这是Spark的主要入口点,可以用下面的命令加载一个txt文件:

由上可知,lines变量引用的是一个弹性分布式数据集(Resilient Distributed Dataset,RDD)。RDD是Spark最核心的概念,它是在集群中跨多个机器分区存储的一个只读对象集合。在常见Spark程序中,首先要加载一至多个RDD,它们作为输入通过一系列转换得到一组目标RDD,然后对这些目标RDD执行例如计算结果或者写入存储的操作。“弹性”指的是Spark可以通过重新安排计算来自动重建丢失的分区。不过加载RDD或者执行转换不会立即触发任何数据处理操作,只不过是创建了一个计算的计划,只有当RDD执行例如foreach()、count()、saveAsTextFile()等动作(action)时,才会触发真正的计算。也就是说,RDD本质上是一个函数的封装,而RDD的变换不过是一套数据被多个嵌套的函数处理,是一个虚拟的数据集,整个过程其实是流式的过程,一条数据被各个RDD所包裹的函数处理。例如:

sc.textFile("abc.txt").map().saveAsTextFile("")

把文本行拆分为字段的命令如下所示,通过RDD的map()方法可以对RDD的每个元素应用括号内指定的匹配函数,这里将每一行文本(即一个String)拆分为一个String类型的Scala数组,其中val为第一次赋值之后不能再次赋值的常量,每一个val变量或者某函数操作返回的结果都是一个RDD,而var为可以再次赋值的常量,两者都可以省略类型,Scala会自动推导:

scala> val records=lines.map(_.split("\t"))
records: org.apache.spark.rdd.RDD[Array[String]] = MapPartitionsRDD[2] at map at <console>:29

其中“_”符号表示匹配所有的值或对象,与Java的“*”通配符类似。然后使用过滤器来滤除不良温度记录,其中RDD的filter()方法的输入是一个返回布尔值的函数,rec为输入变量,箭头号表示一个lamda表达式,即箭头左方的输入应用于箭头右方的方法:

scala> val filtered=records.filter(rec => (rec(1) !="9999" && rec(2).matches("[01459]")))
filtered: org.apache.spark.rdd.RDD[Array[String]] = MapPartitionsRDD[3] at filter at <console>:31

Spark的reduceByKey()方法提供分组功能,即对每个键值对应用括号内指定的函数,可以把温度数据按年份字段分组,但该方法需要用Scala Tuple表示的键值对RDD,因此首先用另一个map()把RDD转换为适当的形式,将年份和温度两个字段形成一个个tuple:

scala> val tuples=filtered.map(rec => (rec(0).toInt,rec(1).toInt))
tuples: org.apache.spark.rdd.RDD[(Int, Int)] = MapPartitionsRDD[4] at map at <console>:33

最后可以将元组(tuple)进行聚合,reduceByKey()方法要传入的参数是一个函数,该函数以一对对的键值对作为输入,并将相同的键组合起来形成一个值,如下所示:

在下面例子中,用于求得每个年份的温度最大值,使用了Java的Math.max函数:

scala> val maxTemps=tuples.reduceByKey((a,b) => Math.max(a,b))
maxTemps: org.apache.spark.rdd.RDD[(Int, Int)] = ShuffledRDD[5] at reduceByKey at <console>:35

再通过foreach()方法,并传递println()参数,把其中每个元素打印到控制台,可以看到maxTemps内存储的每个年份的温度最大值。foreach()的作用是对RDD的每个元素应用一个括号内指定的函数,正是foreach()触发了Spark运行一个作业来计算RDD中的值,从而使这些值传递给println()方法:

scala> maxTemps.foreach(println(_))
(1950,22)
(1949,111)

同样,也可以把RDD保存到文件系统,它创建了一个包含分区文件的名为output的目录:

scala> mapTemps.saveAsTextFile("/output")
scala>exit
hadoop fs -cat /output/part-*
(1950,22)
(1949,111)

saveAsTextFile()方法也会触发Spark作业真正开始运行,和reduceByKey()的主要区别在于它没有任何返回值,只是计算得到一个RDD,并将其分区写入/output目录下的文件中。

6.Spark像MapReduce一样也有作业(job)的概念,只是Spark的作业比MapReduce的作业更通用,因为Spark作业由任意的多阶段(stage)有向无环图(DAG)构成,其中每个阶段大致相当于MapReduce中的map阶段或reduce阶段这些阶段又被分解为多个任务(task,Spark实际执行应用的最小单元),任务并行运行在分布于集群的RDD分区上,就像MapReduce的任务一样。Spark作业始终运行在应用(application,例如spark-shell)上下文(用SparkContext实例来表示)中,它提供了RDD分组以及共享变量。一个应用可以串行或并行运行所有作业,并使这些作业能访问由同一应用先前作业所缓存的RDD。

7.Spark shell一条条输入代码命令也很麻烦,可以将多个命令打包形成一个Scala应用以便将来能够运行。这样需要配置Spark和Scala的开发环境:

(1)首先在Intellij IDEA里添加Scala的插件,在Maven工程下按两下SHIFT键进入全局搜索,输入“plugins”,然后点击“Actions”一栏中的plugins,如下所示:

进入插件安装界面后,搜索Scala,然后安装,安装完成后需要按照提示重启Intellij IDEA,如下所示:

(2)Intellij IDEA重启后,接下来要配置Spark和Scala的Maven工程环境,在当前Maven工程的pom.xml配置文件中加入如下依赖(CDH组件的依赖名称根据以下网址的指示填写:https://www.cloudera.com/documentation/enterprise/release-notes/topics/cdh_vd_cdh5_maven_repo.html):

<dependencies>
......
  <dependency>
            <groupId>org.scala-lang</groupId>
            <artifactId>scala-library</artifactId>
            <version>2.10.6</version>
    </dependency>
    <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-core_2.10</artifactId>
            <version>1.6.0-cdh5.15.0</version>
            <scope>provided</scope>
    </dependency>
</dependencies>

<build>
       <plugins>
            <plugin>
                <groupId>net.alchim31.maven</groupId>
                <artifactId>scala-maven-plugin</artifactId>
                <version>3.2.2</version>
                <configuration>
                    <scalaVersion>2.10.6</scalaVersion>
                    <args>
                        <arg>-unchecked</arg>
                        <arg>-deprecation</arg>
                        <arg>-feature</arg>
                    </args>
                    <javacArgs>
                        <javacArg>-source</javacArg>
                        <javacArg>1.8</javacArg>
                        <javacArg>-target</javacArg>
                        <javacArg>1.8</javacArg>
                    </javacArgs>
                </configuration>
                <executions>
                    <execution>
                        <phase>compile</phase>
                        <goals>
                            <goal>compile</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
</build>

其中<plugin>标签下的scala-maven-plugin插件非常关键,如果没有这个插件,编译生成jar包的时候不会把scala文件包括到jar包里,运行spark-submit命令的时候会报错“class not found”,而且该插件版本不能太高,目前最新版3.4.2,但是需要maven版本3.5.4以上,而Intellij IDEA的内置Maven版本为3.3.9。更重要的是,Scala库的版本一定要在2.10.6以上,否则编译的时候会不适配JDK1.8,报错“error while loading charsequence class file charsequence.class is broken”等。

(3)点击左上角菜单中的“Files”->“Project Structure”,进入工程结构配置对话框,点击左侧的“Global Libraries”,然后点击上面的“+”,选择Scala SDK,如下所示:

然后跳出一个小对话框选择Scala的版本,选择2.10.6那个版本即可,如下所示:

添加Scala SDK后,中间栏会出现Scala SDK,然后右键该SDK,点击“Copy to Project Libraries”,将SDK添加到项目的默认Library里去,如下所示:

(4)步骤(2)中的scala-maven-plugin插件需要把Scala文件和Java文件的目录分开存放才能识别,因此需要在工程的/src/main目录下新建一个“scala”目录专门存放Scala文件,右键点击“main”目录,选择“New”->“Directory”,如下所示:

创建“Scala”目录后,需要将该目录也和“java”目录一样设置为源代码根目录,右键点击“Scala”目录,在下方找到“Mark Directory as”,选择“Sources Root”,变为代码根目录会变成蓝色,如下所示:

这样Spark在IDE中的全部开发环境都配好了,可以在“scala”目录下新建一个Scala类来编写Spark的Scala应用程序,如下所示:

命名类的名称时,如果类是Object,则要把“Kind”选项设为Object,如下所示:

8.上面的几个步骤配置好Spark和Scala的IDE与Maven工程环境后,就可以编写Spark应用程序,例子如下所示:

package Spark

import org.apache.spark.SparkContext._
import org.apache.spark.{SparkConf, SparkContext}

object MaxTemperature {  //使用Spark找出最高温度的Scala应用程序
  def main(args:Array[String]): Unit ={
    val conf=new SparkConf().setAppName("Max Temperature")  //创建属性配置的新实例,可以把各种Spark属性传递给应用
    val sc=new SparkContext(conf)  //shell环境会自动创建SparkContext,代码中需要自己创建

    sc.textFile(args(0))  //利用命令行参数指定输入路径,利用方法链避免为每个RDD创建中间变量
      .map(_.split("\t"))  //“_”符号表示匹配所有的值或对象,与Java的“*”通配符类似。这里把文本行拆分为多个字段,将每一行文本(即一个String)拆分为一个String类型的Scala数组
      .filter(rec => (rec(1) != "9999" && rec(2).matches("[01459]")))  //用过滤器来滤除上一行生成数组的不良温度记录,例如温度值缺失为9999,状态码并非指定的几个数字等,RDD中只留下符合的数据
      .map(rec => (rec(0).toInt,rec(1).toInt))  //reduceByKey()方法提供分组合并功能,可以把温度数据按年份字段分组,但该方法需要用Scala Tuple表示的键值对RDD,因此首先用另一个map()把RDD转换为键值对的形式,将年份和温度两个字段形成一个个tuple
      .reduceByKey((a,b) => Math.max(a,b))  //将上一行生成的键值对元组(tuple)进行聚合,reduceByKey()方法的传入参数是一个函数,该函数以一对对键值对作为输入,并将相同键的多个键值对组合形成一个总键值对
      .saveAsTextFile(args(1))  //saveAsTextFile()方法也会触发Spark作业的真正运行,和reduceByKey()的主要区别在于它没有任何返回值,只是计算得到一个RDD,并将其分区写入指定目录下的文件中
  }
}

打包jar和放好输入数据后,用spark-submit命令来运行程序,其中--class参数指定Spark执行哪个类,--master指定作业在哪里运行,local表示所有Spark作业都在本地机器上的一个JVM中运行。如下所示:

spark-submit --class MaxTemperature --master local \
/mnt/sda6/sparkExample.jar /input/sample.txt /output

运行完成后,输出结果如下所示:

9.Scala也基于JVM运行,因此上面的例子也可以用Java来编写,如下所示:

package Spark;

import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaPairRDD;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.api.java.function.Function;
import org.apache.spark.api.java.function.Function2;
import org.apache.spark.api.java.function.PairFunction;
import scala.Tuple2;

public class MaxTemperatureSpark {  //找出每个年份的最高气温
    public static void main(String[] args) throws Exception{
        if(args.length!=2){
            System.err.println("Usage: MaxTemperatureSpark <input path> <output path>");
            System.exit(-1);
        }

        SparkConf conf=new SparkConf();
        JavaSparkContext sc=new JavaSparkContext("local","MaxTemperatureSpark",conf);
        JavaRDD<String> lines=sc.textFile(args[0]);  //RDD实例,即一个输入文件

        JavaRDD<String[]> records=lines.map(new Function<String,String[]>(){  //把文本行拆分为多个字段,将每一行文本(即一个String)拆分为一个String类型的Scala数组
            public String[] call(String s) throws Exception {
                return s.split("\t");
            }
        });

        JavaRDD<String[]> filtered=records.filter(new Function<String[],Boolean>(){  //用过滤器来滤除不良温度记录,例如温度值缺失为9999,状态码并非指定的几个数字等,RDD中留下符合的数据
            public Boolean call(String[] rec) throws Exception {
                return rec[1]!="9999" && rec[2].matches("[01459]");
            }
        });

        JavaPairRDD<Integer,Integer> tuples=filtered.mapToPair(new PairFunction<String[],Integer,Integer>() {  //输入RDD为键值对时,使用JavaPairRDD

            public Tuple2<Integer, Integer> call(String[] rec) throws Exception {  //reduceByKey()方法提供分组功能,可以把温度数据按年份字段分组,但该方法需要用Scala Tuple2表示的键值对RDD,因此首先用另一个mapToPair()把字段RDD转换为适当的形式,将年份和温度两个字段形成一个个tuple
                return new Tuple2<Integer,Integer>(Integer.parseInt(rec[0]),Integer.parseInt(rec[1]));
            }
        });
        //将元组(tuple)进行聚合,reduceByKey()方法的传入参数是一个函数,该函数以一对对键值对作为输入,并将相同键的多个键值对组合起来形成一个总键值对
        JavaPairRDD<Integer,Integer> maxTemps=tuples.reduceByKey(new Function2<Integer,Integer,Integer>(){
            public Integer call(Integer i1, Integer i2) throws Exception {
                return Math.max(i1,i2);
            }
        });
        maxTemps.saveAsTextFile(args[1]);  //saveAsTextFile()方法也会触发Spark作业的真正运行,和reduceByKey()的主要区别在于它没有任何返回值,只是计算得到一个RDD,并将其分区写入指定目录下的文件中
    }
}

其中每个操作都对应一个RDD,前后两个操作的父子RDD之间的关系分为窄依赖和宽依赖,窄依赖指父RDD的分区最多只会被子RDD的一个分区使用,宽依赖指父RDD的一个分区会被子RDD的多个分区使用,前者是一对一或多对一,后者是一对多,如下所示:

DAGScheduler从DAG图末端出发,逆向遍历所有操作的整个依赖关系链,遇到ShuffleDependency(宽依赖关系的一种叫法)就断开,遇到NarrowDependency就将其加入到当前stage,即根据宽依赖来划分stage(也就是是否有shuffle发生,是否会发生数据分区的重组)窄依赖并入一个stage,从尾部逆向碰到一个宽依赖就分一次stage。如下所示:

在进入下一个stage之前,当前阶段的所有任务必须执行完成。因为下一stage的第一个转换一定是重新组织数据,所以必须等当前stage所有结果数据都计算出来才能继续。同样task数量的确定也是从逆向往前找,每个 stage 里面 task 的数目由该 stage 最后一个 RDD 中的 partition 个数决定,上图中每个粗箭头最后指向的RDD分区对应一个task。task分为ResultTask和ShuffleMapTask两种,产生最终结果的是前者,其余全是后者。上面程序的DAG流程图如下所示:

其中到reduceByKey()操作时划分stage,是因为从最后结果RDD(相当于子RDD)逆向出发(逆着箭头方向),到reduceByKey操作之前,结果RDD的父RDD(即stage0最后map()处)会映射到结果RDD的多个分区(因为从箭头正向出发是父RDD的每个分区都经过reduce操作将各个不同键的键值对数据shuffle合并到结果RDD的多个不同键的分区中),属于多个一对多的宽依赖,所以需要在这里划分stage。map()和filter()操作为窄依赖,groupByKey()和reduceByKey()等为宽依赖。

10.RDD是所有Spark程序的核心,创建它有三种方法:(1)来自一个内存中的对象集合(即并行化一个集合);(2)使用外部存储器(如HDFS)中的数据集;(3)对现有RDD进行转换。第一种方法适用于对少量输入数据进行并行CPU密集型计算,例如下面这段代码对数字集合1到10运行独立计算,parallelize()方法用于生成RDD:

scala> val params=sc.parallelize(1 to 10)
scala> val result=params.map(performExpensiveComputation())

其中map()内的函数对输入的值并行运行,并行度由spark.default.parallelism属性确定,默认值取决于Spark作业的节点,本地运行时默认值为当前机器的内核(core)数,在集群上为集群中所有executor节点的内核总数。如果要指定并行度,可以在parallelize()后加入第二个整数参数进行指定。

创建RDD的第二种方法是创建一个外部数据集的引用,例如为文本文件创建一个String对象的RDD,Spark使用了MapReduce1 API中的TextInputFormat来读取文件,意味着它的文件分割行为与Hadoop一致,因此使用HDFS时,每个HDFS块对应一个Spark分区。如果要改变默认分区数可以通过传递第二个参数来修改:

scala> val text=sc.textFile("/input/sample.txt",10)

另一个从文件创建RDD的方法是把整个文本文件作为一个完整文件看待,并返回一个字符串对RDD。字符串对的第一个字符串是文件路径,第二个字符串是文件内容,由于每个文件都要被加载进内存中,因此这种方式只适合小文件

scala> val files=sc.wholeTextFiles("/input")
files: org.apache.spark.rdd.RDD[(String, String)] = /input MapPartitionsRDD[1] at wholeTextFiles at <console>:27

Spark也可以处理文本文件以外的格式,例如读取顺序文件,对于普通Writable类型,Spark可以将它们映射为等效的Java类型,因此使用例如Int代替IntWritable,String代替Text也是一样的:

sc.sequenceFile[IntWritable,Text](inputPath)

从任意Hadoop InputFormat格式创建RDD的方法有两种:对于需要路径输入以及基于文件的格式可以使用hadoopFile(),对于不需要路径输入的格式(例如HBase的TableInputFormat)可以使用hadoopRDD(),它们也是用MapReduce1的API,如果是MapReduce2的新API,需要相应使用newAPIHadoopFile()和newAPIHadoopRDD()。

11.Spark为RDD提供两大类操作:转换(transformation)和动作(action)转换为从现有RDD生成新RDD,而动作则触发对RDD的计算并对计算结果执行某种操作,要么返回给用户,要么保存到外部存储中动作的效果立马显现,而转换是惰性的,在对RDD执行一个动作之前不会为该RDD的任何转换操作采取实际行动。例如如下代码用于将文本文件中的文本行小写化:

scala> val text=sc.textFile("/input/NCDC.txt")
scala> val lower=text.map(_.toLowerCase())
scala> lower.foreach(println(_))

其中map()是一种转换操作,表示为可以在稍后某个时刻对RDD(文本)的每个元素调用一个函数(即toLowerCase()),在调用foreach()方法(这是一个动作)之前,该函数实际上没有被调用。实际上Spark在结果即将被写入控制台之前,才会运行一个作业来读取输入文件并对其中的每一行文本调用toLowerCase()。要判断一个操作是转换还是动作,可以观察返回类型,如果返回的类型是RDD,则它是一个转换,否则就是一个动作Spark中job的划分就是按照action的数量来划分,每遇到一个action就划分一个job,每一个action及前面的一组操作为一个job,例如foreach()、collect()和saveAsTextFile()等。

12.Spark的map()和reduce()操作与MapReduce中的同名函数并不相同,MapReduce中的map和reduce的一般形式为:

map: (K1,V1)→List(K2,V2)
reduce: (K2,list(V2))→list(K3,V3)

MapReduce中的这两个函数都可以返回多个输出对,以list来表示,而在Spark中,这种情况需要通过flatMap()操作实现,它与map()类似只是少了一层嵌套:

scala> val l=List(1,2,3)
l: List[Int] = List(1, 2, 3)

scala> l.map(a => List(a))
res1: List[List[Int]] = List(List(1), List(2), List(3))

scala> l.flatMap(a => List(a))
res2: List[Int] = List(1, 2, 3)

要在Spark中模拟MapReduce的一种方法是使用两个flatMap()操作,且两者之间用groupByKey()和sortByKey()分隔,它们分别执行的是MapReduce中的混洗(shuffle)和排序(sort)操作,不过该方法效率较低仅用于理解:

13.按键为键值对RDD进行聚合操作的三个主要转换函数分别为reduceByKey()、foldByKey()和aggregateByKey(),对应MapReduce中的等效动作为reduce()、fold()和aggregate()。其中最简单的是reduceByKey(),它为键值对中的值重复应用一个函数,直至产生一个结果值。其中键a的值使用加法函数(_+_)来聚合,在下面的例子中即3+1+5=9:

val pairs: RDD[(String, Int)]=sc.parallelize(Array(("a",3),("a",1),("b",7),("a",5)))
val sums: RDD[(String, Int)]=pairs.reduceByKey(_+_)
assert(sums.collect().toSet===Set(("a",9),("b",7)))

下面这段代码给出了如何利用foldByKey()执行相同操作,这次必须要提供一个0值,例如0+3+1+5=9:

val sums: RDD[(String, Int)]=pairs.foldByKey(0)(_+_)
assert(sums.collect().toSet===Set(("a",9),("b",7)))

前两个函数的功能基本一样,都不能改变聚合结果值的类型,要能改变聚合结果值的类型,需要用aggregateByKey(),例如将一些整数值聚合形成一个集合:

val sets: RDD[(String, HashSet[Int])]=pairs.aggregateByKey(new HashSet[Int])(_+=_,_++=_)
assert(sums.collect().toSet===Set(("a", Set(1,3,5)), ("b", Set(7))))

与foldByKey()类似,也需要用new HashSet[Int]创建一个新的空集来表示初始零值,括号内第一个函数_+=_表示把相同键的Int合并到原集合HashSet[Int]中,即把原来Int类型的值合并后转换为集合类型(改变结果值的类型,相当于MR中的partition),如果是_+_会返回一个新集合而不是覆盖原集合。括号内第二个函数_++=_用于合并两个HashSet[Int]集合中的值,把第二个集合中的所有元素添加到第一个集合中(同类型的值合并,相当于MR中的combine)。

14.可以用以下命令把中间数据(例如reduceByKey()之前的tuples)缓存到内存中,调用cache()不会立即缓存RDD,而是用一个标志标记该RDD,指示该RDD在Spark的作业运行时被缓存,所以在后面运行一个reduceByKey()作业并执行foreach()动作:

scala> tuples.cache()
scala> tuples.reduceByKey((a,b) => Math.max(a,b)).foreach(println(_))

其中cache()在内存中缓存的中间数据集RDD分区如下所示,RDD的编号为4,有两个分区0和1(因为输入数据集中就只有1949和1950两个年份的温度数据,以年份为键将相同键的数据分区):

对于大规模作业来说,将中间输入数据缓存到内存可以节省可观的时间,Spark可以在跨集群的内存中缓存数据集,意味着对数据集所做的任何运算都会很快,这样尤其适用于迭代算法等场景,上一次迭代计算的结果可以缓存在内存中,用作下次迭代的输入。而MapReduce在执行另一个计算时必须从磁盘中重新加载输入数据集,如果迭代算法用MapReduce实现,每次迭代都要作为单个MapReduce作业运行,每次迭代的结果都必须写入磁盘,然后在下一次迭代时从磁盘读回,速度较低。

被缓存的RDD只能从同一应用的作业来读取,如果要在应用间共享数据集,则必须在第一个应用中使用saveAs*()方法(例如saveAsTextFile(),saveAsHadoopFile()等)写入到外部存储,然后在第二个应用中使用SparkContext相应方法(如textFile(),hadoopFile()等)进行加载。当应用终止时,缓存的所有RDD应当被销毁,除非这些RDD已被保存,否则无法再次访问。

15.调用cache()会在executor(一般就是Worker)的内存中持久化保存RDD的每个分区,如果executor没有足够内存存储RDD分区,计算不会失败,而是根据需要重新计算分区。对于包含大量转换操作的复杂程序来说,重新计算的代价可能太高,因此Spark提供了不同级别的内存缓存行为,可以用persist()并指定StorageLevel参数来选择。默认缓存级别为MEMORY_ONLY,它在内存中缓存的数据是对象的形式,另一种更紧凑的表示方法是把分区中的元素序列化为字节数组,即MEMORY_ONLY_SER。与前者相比,它多了一份CPU序列化元素的开销,但是如果生成的序列化RDD分区的大小能够放在内存中,而常规缓存方式太大放不进内存时,这份额外开销就是值得的,MEMORY_ONLY_SER还能减少垃圾回收的压力,因为RDD被存储为一个字节数组,而不是大量的对象

通过检查driver(发出应用的节点,一般为客户端)日志文件中的BlockManager消息,可以了解RDD分区的大小是否适合被保存到内存中。每个driver的SparkContext都运行了一个HTTP服务(网址为http://<driver-host>:4040),可以提供运行环境以及正在运行作业的相关信息,包括缓存的RDD分区信息,如下所示,可以看到只有一个job,因为在第8和第9点的程序中只有一个action即saveAsTextFile(),每个action及前面的一组操作划分一个job

默认情况下RDD分区的序列化使用的是Java序列化方法,但是从大小和速度看使用Kryo序列化方法是更好的选择通过压缩序列化分区可以进一步节省空间(再次以增加CPU开销为代价),即把spark.rdd.compress属性设置为true,并且可选地设置spark.io.compression.codec属性。如果executor发现内存容量不够缓存全部RDD分区,但是重新计算数据集代价太高,可以使用MEMORY_AND_DISK缓存模式将内存中的缓存数据部分缓存到磁盘,或者MEMORY_AND_DISK_SER模式,即把序列化数据集从内存部分溢出到磁盘。

16.序列化可以从两个方面来考虑:数据序列化和函数序列化(闭包函数)。默认情况下,Spark通过网络将数据从一个executor发送到另一个executor、或者以序列化形式缓存数据时,使用的都是Java序列化机制(java.io.Serializable),从性能和大小来看效率并不高,使用Kryo序列化机制对于大多数Spark程序都是一个更好的选择,它是一个高效的通用Java序列化库,使用它需要在driver程序中的SparkConf中设置spark.serializer属性,如下所示:

conf.set(“spark.serializer”, “org.apache.spark.serializer.kryoSerializer”)

Kryo不需要被序列化的类实现某个特定接口(例如java.io.Serializable)。常见Java对象、Scala类以及一些常见框架类如Avro的Generic类或Thrift类已经在Kryo中注册,但是自己写的类为了提高性能需要在Kryo中注册,因为Kryo需要生成被序列化对象的类的引用。注册过程就是自己的类继承KryoRegistrator,然后重写registerClasses()方法:

最后在driver程序中将spark.kryo.registrator属性设置为自己注册过的类名:

conf.set(“spark.kryo.registrator”, “CustomKryoRegistrator”)

17.Spark程序经常需要访问一些不属于RDD的数据,例如下面程序在map()操作中用到了一张映射表,变量lookup作为闭包函数的一部分被序列化后传给map():

val lookup=Map(1->"a",2->"e",3->"i",4->"o",5->"u")
val result=sc.parallelize(Array(2,1,3)).map(lookup(_))
assert(result.collect().toSet===Set(“a”, “e”, “i”))

上面的代码使用广播变量(broadcast variable)可以更高效地完成相同的工作。广播变量经过序列化后被发送到各个executor,然后缓存在那里以便后期任务可以在需要时访问它。与常规变量不同,常规变量作为闭包函数的一部分被序列化,在每个任务中都要经过网络被传输一次。广播变量的作用类似于MapReduce中的分布式缓存,只不过广播变量保存在内存中,只有在内存耗尽时才会溢出到磁盘上。通过向SparkContext的broadcast()方法传递即将被广播的变量来创建一个广播变量,返回Broadcast[T],即对类型T的变量的一个封装,需要注意要想在RDD的map()操作中访问这些变量,需要对它们调用.value:

val lookup: Broadcast[Map[Int, String]]=sc.broadcast(Map(1->"a",2->"e",3->"i",4->"o",5->"u"))
val result=sc.parallelize(Array(2, 1, 3)).map(lookup.value(_))
assert(result.collect().toSet===Set(“a”, “e”, “i”))

18.广播变量是单向传播的,即从driver节点到任务节点,因此一个广播变量是不能更新的,也不能将更新传回driver,要做到这一点需要累加器。累加器(accumulator)是在任务中只能对它做加法的共享变量,类似于MapReduce中的计数器。作业完成后,driver程序可以查找累加器的最终值,下面的例子使用累加器对一个整数RDD中的元素个数进行计数,同时使用reduce()动作对RDD中的值求和,代码第一行使用了SparkContext的accumulator()方法创建一个累加器变量count。map()是一个恒等函数,附加效果是使count递增,作业结果计算出来以后对累加器调用value来访问它的值:

val count: Accumulator[Int]=sc.accumulator(0)
val result=sc.parallelize(Array(1,2,3))
  .map(i => {count+=1; i})
  .reduce((x,y) => x+y)
assert(count.value===3)
assert(result===6)

19.Spark有两个独立的实体:driver和executor。driver负责托管应用(SparkContext)并为作业调度任务。executor专属于应用,它在应用运行期间运行,并执行该应用的任务。一般driver作为一个不由集群管理器(cluster manager)管理的客户端运行,而executor运行在集群的计算机上(例如standalone和yarn-client模式,但也有例外,例如yarn-cluster模式)。Spark以stand-alone模式运行作业的过程如下所示:

(1)Spark集群启动后,Worker向Master(在standalone模式下,Cluster Manager就是Master)注册信息。

(2)spark-submit命令提交程序后,driver和application也会向Master注册信息。

(3)driver程序的代码运行到对RDD执行一个action(例如count()、foreach()、saveAsTextFile()等)时,会自动提交一个Spark作业,触发SparkContext对象的runJob()。

(4)driver把application信息注册给Master后,Master根据application信息去Worker节点启动Executor。

(5)Executor会创建运行task的线程池,然后把启动的executor反向注册给Driver。

(6)SparkContext对象包含DAGScheduler和TaskScheduler,DAGScheduler负责把作业分解为若干阶段(stage),并由这些阶段构成一个DAG(有向无环图)。

(7)DAGScheduler将分解的阶段(stage)封装成TaskSet的形式提交到TaskScheduler,它负责把每个阶段中的任务集合提交到集群,同时DAGScheduler还会处理由于Shuffle数据丢失导致的失败。提交下一阶段前,当前阶段的所有任务都要执行完成,因为下一阶段的第一个转换一定是重新组织数据的,所以必须等当前阶段所有数据结果都计算出来才可继续

(8)TaskScheduler维护所有TaskSet,将各任务交给SchedulerBackend进行资源分配和任务调度。

(9)SchedulerBackend给任务分配某个Worker上的executor,将启动任务指令发给对应executor上的ExecutorBackend,由它负责执行任务。

20.对于构建DAG来说,在阶段中运行的任务(task)有两种:shuffleMap任务和result任务。(1)shuffleMap任务就像是MapReduce中mapper的shuffle部分,每个shuffleMap任务在一个RDD分区上运行计算,并根据分区函数把输出写入一组新的分区中,使后面的阶段能够取用该RDD。shuffleMap任务运行在除最终阶段之外的其他所有阶段中。(2)result任务运行在最终阶段,并将结果返回给driver(例如count()的计算结果)。每个result任务在它自己的RDD分区上运行计算,然后把结果发送给driver,再由driver将每个分区的计算结果汇集成最终结果。

最简单的Spark作业不需要使用shuffle,因此只有一个result任务的阶段,就像MapReduce中只有map任务一样。较复杂的Spark作业涉及到分组操作,并且要求一个或多个shuffle阶段。例如下列作业用于为存储在inputPath目录下的文本文件计算词频统计分布图(每行一个单词):

val hist: Map[Int, Long]=sc.textFile(inputPath)
  .map(word => (word.toLowerCase(),1))
  .reduceByKey((a, b) => a+b)
  .map(_.swap)
  .countByKey()

前两个转换是map()和reduceByKey(),用于计算每个单词出现的频率。第三个转换是map(),它交换每个键值对中的键和值,从而得到(count, word)对,最后是countByKey()动作,返回每个计数对应的单词量。由于reduceByKey()必须要有shuffle阶段(根据是否要重新组织数据来划分阶段),因此Spark的DAGScheduler将此作业根据该宽依赖分为两个stage,得到的DAG如下所示:

reduceByKey()转换跨越了两个阶段,因为它使用shuffle实现,就像MapReduce一样,reduce函数一边在map端作为combiner运行(Stage 1),一边又在reduce端作为reducer运行(Stage 2)。它与MapReduce相似的另一个地方是,Spark的shuffle实现将其输出写入到本地磁盘上的分区文件中(对内存中的RDD也如此),并且这些文件将由下一阶段的RDD读取。如果RDD已经被同一应用(SparkContext)中先前的作业保存,那么DAG调度程序不会再创建一些阶段来重新计算它或它的父RDD。当到达shuffle处理即一个stage的末尾时,该shuffle处理将结果数据从内存写入到本地磁盘,下一个stage继续从磁盘上读取输入数据再放到内存里运行。也就是说,只有一个stage内的多个操作的中间数据会一直保存在内存里,stage结束就写入到磁盘作为下一个stage的输入源。

DAG调度程序负责将一个阶段分解为若干任务以提交给TaskScheduler。如果reduceByKey()等函数没有通过第二个参数设置并行度,则根据父RDD来确定,往往是输入数据的分区数。DAG调度程序会为每个任务赋予一个位置偏好(placement preference),以允许任务调度程序充分利用数据本地化(data locality)。例如对于存储在HDFS上的输入RDD分区,它的任务位置偏好就是托管了这些分区的数据块的datanode(也叫node local),而对于在内存中缓存的RDD分区,其任务的偏好位置则是保存这些RDD分区的executor(也称为process local)。

21.当一个阶段的任务集合被发送到任务调度程序后,任务调度程序通过为该application反向注册的executor列表,在斟酌位置偏好的同时构建任务到executor的映射。接着,任务调度程序将任务分配给具有可用内核的executor,并且在executor完成任务时继续分配任务,直到任务集合完成。默认情况下每个任务分派到一个内核,可以通过spark.task.cpus更改。

任务调度程序为某个executor分配任务时,首先分配的是进程本地(process-local)任务,再分配节点本地(node-local)任务,然后分配机架本地(rack-local)任务,最后分配任意(非本地)任务或者推测任务(speculative task,即现有任务的副本,如果任务运行比预期的慢,调度程序可以把很慢的任务再复制一个副本运行,谁先运行好就中断另一个)。Spark利用Akka(一个基于Actor的平台)来构建高度可扩展的事件驱动分布式应用,而不是使用Hadoop RPC进行远程调用。

22.Executor运行任务的步骤如下:(1)首先确保任务的JAR包和文件依赖关系都是最新的,executor在内存中保留了先前任务已使用的所有依赖,只有需要更新的时候才会重新加载。(2)由于任务代码是作为启动任务消息的一部分而发送的序列化字节,因此需要反序列化任务代码。(3)执行任务代码,在Mesos细粒度模式以外的模式下,任务会运行在与executor相同的JVM中,因此任务的启动没有进程开销。

任务可以向driver返回执行结果,这些执行结果被序列化并发送到ExecutorBackend,然后以状态更新消息的形式返回driver。shuffle map任务返回的是一些可以让下一阶段检索其输出分区的信息,而result任务则返回其运行的分区的结果值,driver将结果值收集并汇报给用户。

23.负责管理executor生命周期的是集群管理器(cluster manager),Spark提供的集群管理器模式如下:

(1)本地模式(local):有一个executor与driver运行在同一个JVM中。此模式对于测试或运行小规模作业非常有用。这种模式的命令行URL参数为local(使用一个线程)、local[n](n个线程)或local(*)(机器的每个内核一个线程)。

(2)独立模式(stand-alone):独立模式的集群管理器是一个简单的分布式实现,它运行了一个Master以及一个或多个Worker。当Spark应用启动时,Master要求Worker代表application生成多个executor进程。这种模式的命令行参数URL为spark://host:port。

(3)Mesos模式:Apache Mesos是一个通用的集群资源管理器,允许根据组织策略在不同应用间细化资源共享。默认为细粒度模式,每个Spark任务被当做一个Mesos任务运行,这样做可以更有效地使用集群资源,但是以额外的进程启动开销为代价。在粗粒度模式下,executor在进程中运行任务,因此在Spark应用运行期间的集群资源由executor进程掌管。该模式的命令行参数URL为mesos://host:port。

(4)YARN模式:每个运行的Spark应用对应一个YARN应用实例,通过ResourceManager请求资源。每个executor在自己的YARN container中运行,这种模式的命令行参数URL为yarn-client或yarn-cluster,这两个子模式的区别是:(1)yarn-client模式中driver也在客户端上运行,它独立于集群,客户端向YARN的ResourceManager申请启动application master,这是每个application启动的第一个容器,负责与ResourceManager打交道并请求资源。application master仅仅向YARN请求executor,driver会和请求的Container通信来调度它们工作,因此客户端不能离开。(2)yarn-cluster中的Driver运行在application master中,客户端向ResourceManager请求application master的container,启动application master后把Driver运行在其中。当用户提交作业后可以关掉客户端,作业会继续在YARN上运行,因此yarn-cluster模式不适合运行交互类型的作业。

24.YARN和Mesos集群管理器优于stand-alone模式的集群管理器,因为它们考虑了在集群上运行的其他应用如MapReduce作业的资源需求,并统筹实施调度策略。独立模式的集群管理器对集群资源的资源采用静态分配方法,因此不能随时适应其他应用的变化需求。而且,YARN是唯一一个能与Hadoop的Kerberos安全机制集成的cluster manager

在已有Hadoop集群情况下使用Spark,在YARN运行是最简便的办法。对于具有交互式组件的程序例如spark-shell或pyspark都必须使用yarn-client模式,该模式的任何调试输出都是立即可见的。另一方面,yarn-cluster模式适用于生产环境作业(production job),因为整个应用在集群上运行,这样做更易于保留日志文件以供后续检查,如果application master出现故障,YARN还可以尝试重新运行该应用。

25.在YARN客户端模式下Spark提交作业的步骤如下所示:

(1)driver构建新的SparkContext实例时就启动了与YARN之间的交互。

(2)该SparkContext向YARN的ResourceManager提交一个YARN应用。

(3)YARN的资源管理器启动集群某个节点上NodeManager中的YARN容器(container),并在容器中运行一个名为SparkExecutorLauncher的application master。

(4)这个application master向资源管理器请求资源,用于启动executor。

(5)application master获得容器资源的分配后,在另外某个节点上的NodeManager中启动ExecutorBackend进程作为分配给executor的容器。

(6)每个executor启动后都会连接回SparkContext,向driver反向注册自己。这样SparkContext就知道了可运行任务的executor数量及位置信息,可用于任务位置偏好策略中。

启动executor的数量在spark-shell、spark-submit或py-spark中设置(默认为2个),同时还可以设置每个executor使用的内核数(默认为1)以及内存量(默认为1024MB)。下面的命令行例子显示如何在YARN上运行具有4个executor且每个executor使用1个内核和2GB内存的spark-shell,其中YARN资源管理器的地址没有指定,会自动从Hadoop配置文件中读取(HADOOP_CONF_DIR环境变量):

spark-shell --master yarn-client \
--num-executors 4 \
--executor-cores 1 \
--executor-memory 2g

26.在YARN集群模式下,用户的driver程序在YARN的application master进程中运行,使用spark-submit命令在--master yarn-cluster参数后需要写上集群的主URL。yarn-cluster模式下提交作业步骤如下所示:

(1)spark-submit命令后,客户端会启动一个YARN应用,但没有driver所以不会运行任何用户代码。客户端向ResourceManager请求资源以启动application master。

(2)ResourceManager在集群中某节点上的NodeManager中创建一个容器,在容器中启动application master。该application master中也运行着driver,driver运行用户代码。

(3)运行着driver的application master向ResourceManager请求容器资源以启动executor。

(4)ResourceManager在另外某些节点上的NodeManager中创建容器并在容器中启动一至多个executor,启动的executor向driver所在节点(即application master)的SparkContext反向注册自己的数量及位置信息。

(5)driver知道了有哪些executor及其位置,通过DAGScheduler和TaskScheduler划分各阶段的任务集,并将任务集分派到各executor并监控运行状态。

(6)当整个应用中的作业完成,driver中的SparkContext向ResourceManager申请注销并关闭自己以释放容器资源。

27.在上述两种YARN模式下,executor都是在还没有任何本地数据位置信息前先行启动的,因此可能会使executor位置与保存作业所需输入数据的datanode并不在一个节点上,导致运行作业时很多输入数据在网络中传输,运行效率低,丧失数据本地化特性。因此为了尽量避免这种情况,SparkContext构造函数可以使用第二个参数传递一个优先位置。该优先位置利用InputFormatInfo辅助类根据输入数据的格式和路径计算得到,例如对于文本文件使用TextInputFormat,当向资源管理器请求分配时,application master需要用到这个优先位置:

猜你喜欢

转载自blog.csdn.net/qq_33588730/article/details/82351806