spark 学习笔记

Spark Core

Spark 是一种基于内存的快速,通用,可扩展的大数据分析计算引擎

和Hadoop 进行比较

  • Hadoop MapReduce 由于其设计初衷并不是为了满足循环迭代式数据流处理,因此在多并行运行的数据可复用场景(如:机器学习、图挖掘算法、交互式数据挖掘算法)中存在诸多计算效率等问题。所以 Spark 应运而生,Spark 就是在传统的 MapReduce 计算框架的基础上,利用其计算过程的优化,从而大大加快了数据分析、挖掘的运行和读写速度,并将计算单元缩小到更适合并行计算和重复使用的 RDD 计算模型
  • 它的核心技术是弹性分布式数据集(Resilient Distributed Datasets),提供了比MapReduce 丰富的模型,可以快速在内存中对数据集进行多次迭代,来支持复杂的数据挖掘算法和图形计算算法
  • Spark 和Hadoop 的根本差异是多个作业之间的数据通信问题 : Spark 多个作业之间数据通信是基于内存,而 Hadoop 是基于磁盘
  • Spark Task 的启动时间快。Spark 采用 fork 线程的方式,而 Hadoop 采用创建新的进程的方式。
  • Spark 只有在 shuffle 的时候将数据写入磁盘,而 Hadoop 中多个 MR 作业之间的数据交互都要依赖于磁盘交互

核心模块

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • spark core

    Spark Core 中提供了 Spark 最基础与最核心的功能,Spark 其他的功能如:Spark SQL,Spark Streaming,GraphX, MLlib 都是在 Spark Core 的基础上进行扩展的

  • spark sql

    Spark SQL 是 Spark 用来操作结构化数据的组件。通过 Spark SQL,用户可以使用 SQL或者 Apache Hive 版本的 SQL 方言(HQL)来查询数据。

  • spark streaming

    Spark Streaming 是 Spark 平台上针对实时数据进行流式计算的组件,提供了丰富的处理数据流的 API。

  • spark MLlib

    MLlib 是 Spark 提供的一个机器学习算法库。MLlib 不仅提供了模型评估、数据导入等额外的功能,还提供了一些更底层的机器学习原语

  • spark GraphX

    GraphX 是 Spark 面向图计算提供的框架与算法库

快速上手

创建maven项目

plugin scala 插件

依赖

<dependencies>
    <dependency>
        <groupId>org.apache.spark</groupId>
        <artifactId>spark-core_2.12</artifactId>
        <version>3.0.0</version>
    </dependency>
</dependencies>
<build>
    <plugins>
        <!-- 该插件用于将 Scala 代码编译成 class 文件 -->
        <plugin>
            <groupId>net.alchim31.maven</groupId>
            <artifactId>scala-maven-plugin</artifactId>
            <version>3.2.2</version>
            <executions>
                <execution>
                    <!-- 声明绑定到 maven 的 compile 阶段 -->
                    <goals>
                        <goal>testCompile</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-assembly-plugin</artifactId>
            <version>3.1.0</version>
            <configuration>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
            </configuration>
            <executions>
                <execution>
                    <id>make-assembly</id>
                    <phase>package</phase>
                    <goals>
                        <goal>single</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

wordcount 案例

object Spark01_WordCount {
    def main(args: Array[String]): Unit = {

        // Application
        // Spark框架
        // TODO 建立和Spark框架的连接
        // JDBC : Connection
        val sparConf = new SparkConf().setMaster("local").setAppName("WordCount")
        val sc = new SparkContext(sparConf)

        // TODO 执行业务操作

        // 1. 读取文件,获取一行一行的数据
        //    hello world
        val lines: RDD[String] = sc.textFile("datas")

        // 2. 将一行数据进行拆分,形成一个一个的单词(分词)
        //    扁平化:将整体拆分成个体的操作
        //   "hello world" => hello, world, hello, world
        val words: RDD[String] = lines.flatMap(_.split(" "))

        // 3. 将数据根据单词进行分组,便于统计
        //    (hello, hello, hello), (world, world)
        val wordGroup: RDD[(String, Iterable[String])] = words.groupBy(word=>word)

        // 4. 对分组后的数据进行转换
        //    (hello, hello, hello), (world, world)
        //    (hello, 3), (world, 2)
        val wordToCount = wordGroup.map {
            case ( word, list ) => {
                (word, list.size)
            }
        }

        // 5. 将转换结果采集到控制台打印出来
        val array: Array[(String, Int)] = wordToCount.collect()
        array.foreach(println)

        // TODO 关闭连接
        sc.stop()

    }
}

执行过程中,会产生大量的执行日志,如果为了能够更好的查看程序的执行结果,可以在项

目的 resources 目录中创建 log4j.properties 文件,并添加日志配置信息

log4j.rootCategory=ERROR, console
log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.target=System.err
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n
# Set the default spark-shell log level to ERROR. When running the spark-shell,the
# log level for this class is used to overwrite the root logger's log level, so that
# the user can have different defaults for the shell and regular Spark apps.
log4j.logger.org.apache.spark.repl.Main=ERROR
# Settings to quiet third party logs that are too verbose
log4j.logger.org.spark_project.jetty=ERROR
log4j.logger.org.spark_project.jetty.util.component.AbstractLifeCycle=ERROR
log4j.logger.org.apache.spark.repl.SparkIMain$exprTyper=ERROR
log4j.logger.org.apache.spark.repl.SparkILoop$SparkILoopInterpreter=ERROR
log4j.logger.org.apache.parquet=ERROR
log4j.logger.parquet=ERROR
# SPARK-9183: Settings to avoid annoying messages when looking up nonexistent 
# UDFs in SparkSQL with Hive support
log4j.logger.org.apache.hadoop.hive.metastore.RetryingHMSHandler=FATAL
log4j.logger.org.apache.hadoop.hive.ql.exec.FunctionRegistry=ERROR

异常处理

如果本机操作系统是 Windows,在程序中使用了 Hadoop 相关的东西,比如写入文件到HDFS,则会遇到如下异常:

failed to locate the winutils binary in the hadoop binary

windows 系统用到了 hadoop 相关的服务,解决办法是通过配置关联到 windows 的系统依赖就可以了

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

spark 运行环境

Spark 作为一个数据处理框架和计算引擎,被设计在所有常见的集群环境中运行, 在国内工作中主流的环境为 Yarn,不过逐渐容器式环境也慢慢流行起来。接下来,我们就分别看看不同环境下 Spark 的运行

local 模式 4040

tar -zxvf spark-3.0.0-bin-hadoop3.2.tgz -C /opt/module
cd /opt/module 
mv spark-3.0.0-bin-hadoop3.2 spark-local
cd spark-local/
bin/spark-shell

提交应用

bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master local[2] \
./examples/jars/spark-examples_2.12-3.0.0.jar \
10
  • –class 表示要执行程序的主类,此处可以更换为咱们自己写的应用程序
  • –master local[2] 部署模式,默认为本地模式,数字表示分配的虚拟 CPU 核数量
  • spark-examples_2.12-3.0.0.jar 运行的应用类所在的 jar 包,实际使用时,可以设定为咱们自己打的 jar 包
  • 数字 10 表示程序的入口参数,用于设定当前应用的任务数量

standalone 模式

local 本地模式毕竟只是用来进行练习演示的,真实工作中还是要将应用提交到对应的集群中去执行,这里我们来看看只使用 Spark 自身节点运行的集群模式,也就是我们所谓的独立部署(Standalone)模式。Spark 的 Standalone 模式体现了经典的 master-slave 模式。

集群规划:

linux1linux2linux3
sparkworker masterworkerworker
解压缩文件
tar -zxvf spark-3.0.0-bin-hadoop3.2.tgz -C /opt/module
cd /opt/module 
mv spark-3.0.0-bin-hadoop3.2 spark-standalone
修改配置文件
  1. 进入解压缩后路径的 conf 目录,修改 slaves.template 文件名为 slaves

    mv slaves.template slaves
    
  2. 修改 slaves 文件,添加 work 节点

    linux1
    linux2
    linux3
    
  3. 修改 spark-env.sh.template 文件名为 spark-env.sh

    mv spark-env.sh.template spark-env.sh
    
  4. 修改 spark-env.sh 文件,添加 JAVA_HOME 环境变量和集群对应的 master 节点

    export JAVA_HOME=/opt/module/jdk1.8.0_144
    SPARK_MASTER_HOST=linux1
    SPARK_MASTER_PORT=7077
    

    注意:7077 端口,相当于 hadoop3 内部通信的 8020 端口,此处的端口需要确认自己的Hadoop配置

  5. 分发 spark-standalone 目录

    xsync spark-standalone
    
启动集群
sbin/start-all.sh
提交应用 7077
bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://linux1:7077 \
./examples/jars/spark-examples_2.12-3.0.0.jar \
10

–class 表示要执行程序的主类

–master spark://linux1:7077 独立部署模式,连接到 Spark 集群

spark-examples_2.12-3.0.0.jar 运行类所在的 jar 包

数字 10 表示程序的入口参数,用于设定当前应用的任务数量

提交参数说明

在提交应用中,一般会同时一些提交参数

bin/spark-submit \
--class <main-class>
--master <master-url> \
... # other options
<application-jar> \
[application-arguments]
参数解释可选值举例
–classSpark 程序中包含主函数的类
–masterSpark 程序运行的模式(环境)模式:local[*]、spark://linux1:7077、Yarn
–executor-memory 1G指定每个 executor 可用内存为 1G符合集群内存配置即可,具体情况具体分析。
–total-executor-cores 2指定所有executor使用的cpu核数为 2 个
–executor-cores指定每个executor使用的cpu核数
application-jar打包好的应用 jar,包含依赖。这个 URL 在集群中全局可见。 比 如 hdfs:// 共享存储系统,如果是file:// path,那么所有的节点的 path 都包含同样的 jar
application-arguments传给 main()方法的参数
配置历史服务 18080

由于 spark-shell 停止掉后,集群监控 linux1:4040 页面就看不到历史任务的运行情况,所以开发时都配置历史服务器记录任务运行情况。

  1. 修改 spark-defaults.conf.template 文件名为 spark-defaults.conf

    mv spark-defaults.conf.template spark-defaults.conf
    
  2. 修改 spark-default.conf 文件,配置日志存储路径

    spark.eventLog.enabled true
    spark.eventLog.dir hdfs://linux1:8020/directory
    

    注意:需要启动 hadoop 集群,HDFS 上的 directory 目录需要提前存在

    sbin/start-dfs.sh
    hadoop fs -mkdir /directory
    
  3. 修改 spark-env.sh 文件, 添加日志配置

    export SPARK_HISTORY_OPTS="
    -Dspark.history.ui.port=18080 
    -Dspark.history.fs.logDirectory=hdfs://linux1:8020/directory 
    -Dspark.history.retainedApplications=30"
    

    参数 1 含义:WEB UI 访问的端口号为 18080

    参数 2 含义:指定历史服务器日志存储路径

    参数 3 含义:指定保存 Application 历史记录的个数,如果超过这个值,旧的应用程序

    信息将被删除,这个是内存中的应用数,而不是页面上显示的应用数。

  4. 分发配置,重新启动集群和历史服务

    xsync conf
    sbin/start-all.sh
    sbin/start-history-server.sh
    
  5. 重新执行任务

    bin/spark-submit \
    --class org.apache.spark.examples.SparkPi \
    --master spark://linux1:7077 \
    ./examples/jars/spark-examples_2.12-3.0.0.jar \
    10
    
  6. 查看历史服务:http://linux1:18080

配置高可用

所谓的高可用是因为当前集群中的 Master 节点只有一个,所以会存在单点故障问题。所以为了解决单点故障问题,需要在集群中配置多个 Master 节点,一旦处于活动状态的 Master发生故障时,由备用 Master 提供服务,保证作业可以继续执行。这里的高可用一般采用Zookeeper 设置

集群规划

Linux1Linux2Linux3
SparkMaster Zookeeper WorkerMaster Zookeeper WorkerZookeeper Worker
  1. 停止集群,启动 Zookeeper

    sbin/stop-all.sh
    xstart zk
    
  2. 修改 spark-env.sh 文件添加如下配置

    #注释如下内容:
    #SPARK_MASTER_HOST=linux1
    #SPARK_MASTER_PORT=7077
    添加如下内容:
    #Master 监控页面默认访问端口为 8080,但是可能会和 Zookeeper 冲突,所以改成 8989,也可以自定义,访问 UI 监控页面时请注意
    SPARK_MASTER_WEBUI_PORT=8989
    export SPARK_DAEMON_JAVA_OPTS="
    -Dspark.deploy.recoveryMode=ZOOKEEPER 
    -Dspark.deploy.zookeeper.url=linux1,linux2,linux3
    -Dspark.deploy.zookeeper.dir=/spark"
    
  3. 分发配置文件,启动集群

    xsync conf/
    sbin/start-all.sh
    
  4. 启动 linux2 的单独 Master 节点,此时 linux2 节点 Master 状态处于备用状态

    sbin/start-master.sh
    

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  5. 提交应用到高可用集群\

    bin/spark-submit \
    --class org.apache.spark.examples.SparkPi \
    --master spark://linux1:7077,linux2:7077 \
    ./examples/jars/spark-examples_2.12-3.0.0.jar \
    10
    
  6. 停止 linux1 的 Master 资源监控进程

  7. 查看 linux2 的 Master 资源监控 Web UI,稍等一段时间后,linux2 节点的 Master 状态提升为活动状态

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Yarn 模式(常用)

独立部署(Standalone)模式由 Spark 自身提供计算资源,无需其他框架提供资源。这种方式降低了和其他第三方资源框架的耦合性,独立性非常强。但是你也要记住,Spark 主要是计算框架,而不是资源调度框架,所以本身提供的资源调度并不是它的强项,所以还是和其他专业的资源调度框架集成会更靠谱一些。所以接下来我们来学习在强大的 Yarn 环境下 Spark 是如何工作的(其实是因为在国内工作中,Yarn 使用的非常多)

  • 解压缩文件

    将 spark-3.0.0-bin-hadoop3.2.tgz 文件上传到 linux 并解压缩,放置在指定位置。

  • 修改配置文件

    修改 hadoop 配置文件/opt/module/hadoop/etc/hadoop/yarn-site.xml, 并分发

    <!--是否启动一个线程检查每个任务正使用的物理内存量,如果任务超出分配值,则直接将其杀掉,默认是 true -->
    <property>
     <name>yarn.nodemanager.pmem-check-enabled</name>
     <value>false</value>
    </property>
    <!--是否启动一个线程检查每个任务正使用的虚拟内存量,如果任务超出分配值,则直接将其杀掉,默认是 true -->
    <property>
     <name>yarn.nodemanager.vmem-check-enabled</name>
     <value>false</value>
    </property>
    
  • 修改 conf/spark-env.sh添加 JAVA_HOME 和 YARN_CONF_DIR 配置

    mv spark-env.sh.template spark-env.sh
    
    export JAVA_HOME=/opt/module/jdk1.8.0_144
    YARN_CONF_DIR=/opt/module/hadoop/etc/hadoop
    
  • 启动 HDFS 以及 YARN 集群

  • 提交应用

    bin/spark-submit \
    --class org.apache.spark.examples.SparkPi \
    --master yarn \
    --deploy-mode cluster \
    ./examples/jars/spark-examples_2.12-3.0.0.jar \
    10
    

    查看 http://linux2:8088 页面,点击 History,查看历史页面

配置历史服务器
  • 修改 spark-defaults.conf.template 文件名为 spark-defaults.conf

    mv spark-defaults.conf.template spark-defaults.conf
    
  • 修改 spark-default.conf 文件,配置日志存储路径

    spark.eventLog.enabled true
    spark.eventLog.dir hdfs://linux1:8020/directory
    

    注意:需要启动 hadoop 集群,HDFS 上的目录需要提前存在

    sbin/start-dfs.sh
    hadoop fs -mkdir /directory
    
  • 修改 spark-env.sh 文件, 添加日志配置

    export SPARK_HISTORY_OPTS="
    -Dspark.history.ui.port=18080 
    -Dspark.history.fs.logDirectory=hdfs://linux1:8020/directory 
    -Dspark.history.retainedApplications=30"
    

    参数 1 含义:WEB UI 访问的端口号为 18080

    参数 2 含义:指定历史服务器日志存储路径

    参数 3 含义:指定保存 Application 历史记录的个数,如果超过这个值,旧的应用程序信息将被删除,这个是内存中的应用数,而不是页面上显示的应用数

  • 修改 spark-defaults.conf

    spark.yarn.historyServer.address=linux1:18080
    spark.history.ui.port=18080
    
  • 启动历史服务

    sbin/start-history-server.sh
    
  • 重新提交应用

    bin/spark-submit \
    --class org.apache.spark.examples.SparkPi \
    --master yarn \
    --deploy-mode client \
    ./examples/jars/spark-examples_2.12-3.0.0.jar \
    10
    
  • Web 页面查看日志:http://linux2:8088

window模式

Spark 非常暖心地提供了可以在 windows 系统下启动本地集群的方式,这样,在不使用虚拟机的情况下,也能学习 Spark 的基本使用

步骤
  • 解压缩文件

    将文件 spark-3.0.0-bin-hadoop3.2.tgz 解压缩到无中文无空格的路径中

  • 启动本地环境

    执行解压缩文件路径下 bin 目录中的 spark-shell.cmd 文件,启动 Spark 本地环境

    在 bin 目录中创建 input 目录,并添加 word.txt 文件, 在命令行中输入脚本代码

  • 命令行提交应用

    sc.textFile("input/word.txt").flatMap(_.split(" ")).map((_,1)).reduceByKey(_+_).collect
    

    web ui ——(http://192.168.10.5:4040/jobs/)

    在 dos 命令行窗口下

    spark-submit --class org.apache.spark.examples.SparkPi --master local[2] ../examples/jars/spark-examples_2.12-3.0.0.jar 10
    

端口号总结

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • Spark 查看当前 Spark-shell 运行任务情况端口号:4040(计算)
  • Spark Master 内部通信服务端口号:7077
  • Standalone 模式下,Spark Master Web 端口号:8080(资源)
  • Spark 历史服务器端口号:18080
  • Hadoop YARN 任务运行情况查看端口号:8088

Spark 运行架构

Spark 框架的核心是一个计算引擎,整体来说,它采用了标准 master-slave 的结构。

如下图所示,它展示了一个 Spark 执行时的基本结构。图形中的 Driver 表示 master,负责管理整个集群中的作业任务调度。图形中的 Executor 则是 slave,负责实际执行任务

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Driver

Spark 驱动器节点,用于执行 Spark 任务中的 main 方法,负责实际代码的执行工作。Driver 在 Spark 作业执行时主要负责:

  • 将用户程序转化为作业(job)
  • 在 Executor 之间调度任务(task)
  • 跟踪 Executor 的执行情况
  • 通过 UI 展示查询运行情况

实际上,我们无法准确地描述 Driver 的定义,因为在整个的编程过程中没有看到任何有关Driver 的字眼。所以简单理解,所谓的 Driver 就是驱使整个应用运行起来的程序,也称之为Driver 类

Executor

Spark Executor 是集群中工作节点(Worker)中的一个 JVM 进程,负责在 Spark 作业中运行具体任务(Task),任务彼此之间相互独立。Spark 应用启动时,Executor 节点被同时启动,并且始终伴随着整个 Spark 应用的生命周期而存在。如果有 Executor 节点发生了故障或崩溃,Spark 应用也可以继续执行,会将出错节点上的任务调度到其他 Executor 节点上继续运行。

Executor 有两个核心功能:

  • 负责运行组成 Spark 应用的任务,并将结果返回给驱动器进程
  • 它们通过自身的块管理器(Block Manager)为用户程序中要求缓存的 RDD 提供内存式存储。RDD 是直接缓存在 Executor 进程内的,因此任务可以在运行时充分利用缓存数据加速运算。

Standlone

Master & Worker

Spark 集群的独立部署环境中,不需要依赖其他的资源调度框架,自身就实现了资源调度的功能,所以环境中还有其他两个核心组件:Master 和 Worker,

  • Master 是一个进程,主要负责资源的调度和分配,并进行集群的监控等职责,类似于 Yarn 环境中的 RM,
  • Worker 也是进程,一个 Worker 运行在集群中的一台服务器上,由 Master 分配资源对数据进行并行的处理和计算,类似于 Yarn 环境中 NM。
Application

Hadoop 用户向 YARN 集群提交应用程序时,提交程序中应该包含 ApplicationMaster,用于向资源调度器申请执行任务的资源容器 Container,运行用户自己的程序任务 job,监控整个任务的执行,跟踪整个任务的状态,处理任务失败等异常情况。

说的简单点就是,ResourceManager(资源)和 Driver(计算)之间的解耦合靠的就是ApplicationMaster

核心概念

Executor 与 Core

Spark Executor 是集群中运行在工作节点(Worker)中的一个 JVM 进程,是整个集群中的专门用于计算的节点。

在提交应用中,可以提供参数指定计算节点的个数,以及对应的资源

这里的资源一般指的是工作节点 Executor 的内存大小和使用的虚拟 CPU 核(Core)数量

应用程序相关启动参数如下:

名称说明
–num-executors配置 Executor 的数量
–executor-memory配置每个 Executor 的内存大小
–executor-cores配置每个 Executor 的虚拟 CPU core 数量

**并行度(**Parallelism)

在分布式计算框架中一般都是多个任务同时执行,由于任务分布在不同的计算节点进行计算,所以能够真正地实现多任务并行执行,记住,这里是并行,而不是并发。这里我们将整个集群并行执行任务的数量称之为并行度。那么一个作业到底并行度是多少呢?这个取决于框架的默认配置。应用程序也可以在运行过程中动态修改

有向无环图(DAG)

DAG(Directed Acyclic Graph)有向无环图是由点和线组成的拓扑图形,该图形具有方向,不会闭环。

是以 Spark 为代表的第三代的计算引擎。第三代计算引擎的特点主要是 Job 内部的 DAG 支持(不跨越 Job),以及实时计算

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里所谓的有向无环图,并不是真正意义的图形,而是由 Spark 程序直接映射成的数据流的高级抽象模型。简单理解就是将整个程序计算的执行过程用图形表示出来,这样更直观,更便于理解,可以用于表示程序的拓扑结构

提交流程(yarn模式)

Spark 应用程序提交到 Yarn 环境中执行的时候,一般会有两种部署执行的方式:Client和 Cluster。两种模式主要区别在于:Driver 程序的运行节点位置

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Yarn Client 模式

Client 模式将用于监控和调度的 Driver 模块在客户端执行,而不是在 Yarn 中,所以一般用于测试

  • Driver 在任务提交的本地机器上运行

  • Driver 启动后会和 ResourceManager 通讯申请启动 ApplicationMaster

  • ResourceManager 分配 container,在合适的 NodeManager 上启动 ApplicationMaster,负责向 ResourceManager 申请 Executor 内存

  • ResourceManager 接到 ApplicationMaster 的资源申请后会分配 container,然后ApplicationMaster 在资源分配指定的 NodeManager 上启动 Executor 进程

  • Executor 进程启动后会向 Driver 反向注册,Executor 全部注册完成后 Driver 开始执行main 函数

  • 之后执行到 Action 算子时,触发一个 Job,并根据宽依赖开始划分 stage,每个 stage 生

    成对应的 TaskSet,之后将 task 分发到各个 Executor 上执行

Yarn Cluster 模式

Cluster 模式将用于监控和调度的 Driver 模块启动在 Yarn 集群资源中执行。一般应用于实际生产环境

  • 在 YARN Cluster 模式下,任务提交后会和 ResourceManager 通讯申请启动ApplicationMaster,

  • 随后 ResourceManager 分配 container,在合适的 NodeManager 上启动ApplicationMaster,

  • 此时的 ApplicationMaster 就是 Driver。

  • Driver 启动后向 ResourceManager 申请 Executor 内存,ResourceManager 接到ApplicationMaster 的资源申请后会分配 container,然后在合适的 NodeManager 上启动Executor 进程

  • Executor 进程启动后会向 Driver 反向注册,Executor 全部注册完成后 Driver 开始执行main 函数,

  • 之后执行到 Action 算子时,触发一个 Job,并根据宽依赖开始划分 stage,每个 stage 生成对应的 TaskSet,之后将 task 分发到各个 Executor 上执行

Spark 核心编程

Spark 计算框架为了能够进行高并发和高吞吐的数据处理,封装了三大数据结构,用于

处理不同的应用场景。三大数据结构分别是:

  • RDD : 弹性分布式数据集
  • 累加器:分布式共享只写变量
  • 广播变量:分布式共享只读变量

RDD

RDD(Resilient Distributed Dataset)叫做弹性分布式数据集,是 Spark 中最基本的数据处理模型。代码中是一个抽象类,它代表一个弹性的、不可变、可分区、里面的元素可并行计算的集合

  • 弹性
    • 存储的弹性:内存与磁盘的自动切换;
    • 容错的弹性:数据丢失可以自动恢复;
    • 计算的弹性:计算出错重试机制;
    • 分片的弹性:可根据需要重新分片。
  • 分布式:数据存储在大数据集群不同节点上
  • 数据集:RDD 封装了计算逻辑,并不保存数据
  • 数据抽象:RDD 是一个抽象类,需要子类具体实现
  • 不可变:RDD 封装了计算逻辑,是不可以改变的,想要改变,只能产生新的 RDD,在新的 RDD 里面封装计算逻辑
  • 可分区、并行计算

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

RDD 与 IO

IO 操作体现了装饰者设计模式

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

RDD的数据处理类似于IO流,也有装饰者模式

RDD 的数据只有在调用collect方法时,才会真正执行业务逻辑,之前的封装全部都是功能的扩展

RDD 是不保存数据的,但是IO可以临时保存一部分数据

核心属性

五个配置

Internally, each RDD is characterized by five main properties:

  • A list of partitions
  • A function for computing each split
  • A list of dependencies on other RDDs
  • Optionally, a Partitioner for key-value RDDs (e.g. to say that the RDD is hash-partitioned)
  • Optionally, a list of preferred locations to compute each split on (e.g. block locations for an HDFS file)
  • 分区列表

    RDD 数据结构中存在分区列表,用于执行任务时并行计算,是实现分布式计算的重要属性

    protected def getPartitions: Array[Partition]
    
  • 分区计算函数

    Spark 在计算时,是使用分区函数对每一个分区进行计算

      @DeveloperApi
      def compute(split: Partition, context: TaskContext): Iterator[T]
    
  • RDD 之间的依赖关系

    RDD 是计算模型的封装,当需求中需要将多个计算模型进行组合时,就需要将多个 RDD 建立依赖关系

    protected def getDependencies: Seq[Dependency[_]] = deps
    
  • 分区器(可选)

    当数据为 KV 类型数据时,可以通过设定分区器自定义数据的分区

    @transient val partitioner: Option[Partitioner] = None
    
  • 首选位置(可选)

    计算数据时,可以根据计算节点的状态选择不同的节点位置进行计算

    判断计算发送到哪个节点,效率最优

    移动数据不如移动计算

    protected def getPreferredLocations(split: Partition): Seq[String] = Nil
    

执行原理

task就是rdd,我现在完全明白了55555。计算方法在自己提交的client里面,也就是算子以及算子里面传递的函数,然后数据分割发送给excutor进行执行,真正的执行逻辑在excutor。


从计算的角度来讲,数据处理过程中需要计算资源(内存 & CPU)和计算模型(逻辑)。执行时,需要将计算资源和计算模型进行协调和整合。

Spark 框架在执行时,先申请资源,然后将应用程序的数据处理逻辑分解成一个一个的计算任务。然后将任务发到已经分配资源的计算节点上, 按照指定的计算模型进行数据计算。最后得到计算结果。

RDD 是 Spark 框架中用于数据处理的核心模型,接下来我们看看,在 Yarn 环境中,RDD的工作原理

  1. 启动 Yarn 集群环境

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  2. Spark 通过申请资源创建调度节点和计算节点

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  3. Spark 框架根据需求将计算逻辑根据分区划分成不同的任务

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  4. 调度节点将任务根据计算节点状态发送到对应的计算节点进行计算

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从以上流程可以看出 RDD 在整个流程中主要用于将逻辑进行封装,并生成 Task 发送给Executor 节点执行计算,一起看看 Spark 框架中 RDD 是具体是如何进行数据处理的。

RDD 编程

RDD 创建
  • 从集合(内存)中创建 RDD

    object RDD_Memory {
      // todo 创建环境
      //  * 表示当前环境最大可用核数 不写 单线程
    
      val sparkConf=new SparkConf().setMaster("local[*]").setAppName("wordCount") // 表示spark 运行的环境
      val sc : SparkContext=new SparkContext(sparkConf)
    
      // todo 创建RDD
      // parallelize : 并行
      // 从内存中创建RDD,将内存中集合的数据作为处理的数据源
      private val rdd1: RDD[Int] = sc.parallelize(List(1, 2, 3, 4))
      // makeRDD方法在底层实现时其实就是调用了rdd对象的parallelize方法。
      private val rdd2: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4))
      rdd1.collect().foreach(println)
      rdd2.collect().foreach(println)
    
      // todo 关闭环境
    
      sc.stop()
    }
    
    
  • 从外部存储(文件)创建 RDD

    object Spark02_RDD_File {
    
        def main(args: Array[String]): Unit = {
    
            // TODO 准备环境
            val sparkConf = new SparkConf().setMaster("local[*]").setAppName("RDD")
            val sc = new SparkContext(sparkConf)
    
            // TODO 创建RDD
            // 从文件中创建RDD,将文件中的数据作为处理的数据源
            // path路径默认以当前环境的根路径为基准。可以写绝对路径,也可以写相对路径
            //sc.textFile("D:\\mineworkspace\\idea\\classes\\atguigu-classes\\datas\\1.txt")
            //val rdd: RDD[String] = sc.textFile("datas/1.txt")
            // path路径可以是文件的具体路径,也可以目录名称
            //val rdd = sc.textFile("datas")
            // path路径还可以使用通配符 *
            //val rdd = sc.textFile("datas/1*.txt")
            // path还可以是分布式存储系统路径:HDFS
            val rdd = sc.textFile("hdfs://linux1:8020/test.txt")
            rdd.collect().foreach(println)
    
            // TODO 关闭环境
            sc.stop()
        }
    }
    
object Spark02_RDD_File1 {

    def main(args: Array[String]): Unit = {

        // TODO 准备环境
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("RDD")
        val sc = new SparkContext(sparkConf)

        // TODO 创建RDD
        // 从文件中创建RDD,将文件中的数据作为处理的数据源

        // textFile : 以行为单位来读取数据,读取的数据都是字符串
        // wholeTextFiles : 以文件为单位读取数据
        //    读取的结果表示为元组,第一个元素表示文件路径,第二个元素表示文件内容
        val rdd = sc.wholeTextFiles("datas")

        rdd.collect().foreach(println)

        // TODO 关闭环境
        sc.stop()
    }
}
  • 从其他 RDD 创建

    主要是通过一个 RDD 运算完后,再产生新的 RDD。详情请参考后续章节

  • 直接创建 RDD(new)

    使用 new 的方式直接构造 RDD,一般由 Spark 框架自身使用

RDD 并行度与分区

默认情况下,Spark 可以将一个作业切分多个任务后,发送给 Executor 节点并行计算,而能 够并行计算的任务数量我们称之为并行度。这个数量可以在构建 RDD 时指定。记住,这里的并行执行的任务数量,并不是指的切分任务的数量,不要混淆了

object Spark02_RDD_File_Par {

    def main(args: Array[String]): Unit = {

        // TODO 准备环境 并行度
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("RDD")
        sparkConf.set("spark.default.parallelism","5")  // 这里就是并行度
        val sc = new SparkContext(sparkConf)

        // TODO 创建RDD

        // RDD的并行度 & 分区
        // makeRDD方法可以传递第二个参数,这个参数表示分区的数量
        // 第二个参数可以不传递,那么makeRDD方法会使用默认值:defaultParallelism(默认并行度)
        // scheduler.conf.getInt("spark.default.parallelism",totalCores)
//        val rdd=sc.makeRDD{
//            List(1,2,3,4),2
//        }
        // textFile可以将文件作为数据处理的数据源,默认也可以设定分区。
        //     minPartitions : 最小分区数量  numSlices 分区数量
        //     math.min(defaultParallelism, 2)
        //val rdd = sc.textFile("datas/1.txt")
        // 如果不想使用默认的分区数量,可以通过第二个参数指定分区数
        // Spark读取文件,底层其实使用的就是Hadoop的读取方式
        // 分区数量的计算方式:
        //    totalSize = 7
        //    goalSize =  7 / 2 = 3(byte)

        //    7 / 3 = 2...1 (1.1) + 1 = 3(分区)

        //
        val rdd = sc.textFile("datas/1.txt", 2)

        rdd.saveAsTextFile("output")


        // TODO 关闭环境
        sc.stop()
    }
}

分区

读取内存数据时,数据可以按照并行度的设定进行数据的分区操作,数据分区规则的如下

def positions(length: Long, numSlices: Int): Iterator[(Int, Int)] = {
      (0 until numSlices).iterator.map { i =>
        val start = ((i * length) / numSlices).toInt
        val end = (((i + 1) * length) / numSlices).toInt
        (start, end)
      }
    }

object Spark02_RDD_File_Par1 {

    def main(args: Array[String]): Unit = {

        // TODO 准备环境
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("RDD")
        val sc = new SparkContext(sparkConf)

        // TODO 创建RDD
        // 数据 1 2  3  4 5  length=5 num=3
        // 0 => [0, 1]  => 1
        // 1 => [1, 3]  =>  2 3
        // 2 => [3, 5]  =>  4 5
        // 【1】,【2,3】,【3, 5】
        val rdd = sc.textFile("datas/1.txt", 3)

        rdd.saveAsTextFile("output")


        // TODO 关闭环境
        sc.stop()
    }
}

读取文件数据时,而切片规则和数据读取的规则有些差异,具体 Spark 核心源码如下

hadoopfiles->fileInputFormat
  /** Splits files returned by {@link #listStatus(JobConf)} when
   * they're too big.*/ 
  public InputSplit[] getSplits(JobConf job, int numSplits)
    throws IOException {
    StopWatch sw = new StopWatch().start();
    FileStatus[] files = listStatus(job);
object Spark02_RDD_File_Par1 {

    def main(args: Array[String]): Unit = {

        // TODO 准备环境
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("RDD")
        val sc = new SparkContext(sparkConf)

        // TODO 创建RDD
        // TODO 数据分区的分配
        // 1. 数据以行为单位进行读取
        //    spark读取文件,采用的是hadoop的方式读取,所以一行一行读取,和字节数没有关系
        // 2. 数据读取时以偏移量为单位,偏移量不会被重复读取
        /*
           1@@   => 012
           2@@   => 345
           3     => 6

         */
        // 3. 数据分区的偏移量范围的计算 3是包含的
        // 0 => [0, 3]  => 1 2
        // 1 => [3, 6]  =>  3
        // 2 => [6, 7]  =>


        // 数据 1 2  3  4 5  length=5 num=3
        // 0 => [0, 1]  => 1
        // 1 => [1, 3]  =>  2 3
        // 2 => [3, 5]  =>  4 5
        // 【1】,【2,3】,【3, 5】
        val rdd = sc.textFile("datas/1.txt", 3)

        rdd.saveAsTextFile("output")


        // TODO 关闭环境
        sc.stop()
    }
}

RDD 算子(就是分装的逻辑)

RDD 根据数据处理方式的不同将算子整体上分为 Value 类型、双 Value 类型和 Key-Value类型

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

转换算子
  • map 将处理的数据逐条进行映射转换,这里的转换可以是类型的转换,也可以是值的转换

    def map[U: ClassTag] (f: T => U): RDD[U]

    val dataRDD: RDD[Int] = sparkContext.makeRDD(List(1,2,3,4))
    val dataRDD1: RDD[Int] = dataRDD.map(
     num => { 
         num * 2
     } )
    val dataRDD2: RDD[String] = dataRDD1.map(
     num => {
     "" + num
     } )
    

    小功能:从服务器日志数据 apache.log 中获取用户请求 URL 资源路径

    // 1. rdd的计算一个分区内的数据是一个一个执行逻辑
    //    只有前面一个数据全部的逻辑执行完毕后,才会执行下一个数据。
    //    分区内数据的执行是有序的。
    // 2. 不同分区数据计算是无序的。
    
  • mapPartitions 先把分区的数据一个先拿到

    将待处理的数据以分区为单位发送到计算节点进行处理,这里的处理是指可以进行任意的处

    理,哪怕是过滤数据。

    def mapPartitions[U: ClassTag](

    f: Iterator[T] => Iterator[U],

    preservesPartitioning: Boolean = false): RDD[U]

    object Spark02_RDD_Operator_Transform {
    
        def main(args: Array[String]): Unit = {
    
            val sparkConf = new SparkConf().setMaster("local[*]").setAppName("Operator")
            val sc = new SparkContext(sparkConf)
    
            // TODO 算子 - mapPartitions
            val rdd = sc.makeRDD(List(1,2,3,4), 2)
    
            // mapPartitions : 可以以分区为单位进行数据转换操作
            //                 但是会将整个分区的数据加载到内存进行引用
            //                 如果处理完的数据是不会被释放掉,存在对象的引用。
            //                 在内存较小,数据量较大的场合下,容易出现内存溢出。
            val mpRDD: RDD[Int] = rdd.mapPartitions(
                iter => {
                    println(">>>>>>>>>>")
                    iter.map(_ * 2)
                }
            )
            mpRDD.collect().foreach(println)
    
            sc.stop()
    
        }
    }
    

小功能:获取每个数据分区的最大值

object Spark02_RDD_Operator_Transform_Test {

    def main(args: Array[String]): Unit = {

        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("Operator")
        val sc = new SparkContext(sparkConf)

        // TODO 算子 - mapPartitions
        val rdd = sc.makeRDD(List(1,2,3,4), 2)

        // 【1,2】,【3,4】
        // 【2】,【4】
        val mpRDD = rdd.mapPartitions(
            iter => {
                List(iter.max).iterator
            }
        )
        mpRDD.collect().foreach(println)

        sc.stop()

    }
}

map和mapPartitions 的区别

map不会减少或增多数据,MapPartitions算子需要传递一个迭代器,返回一个迭代器,所以可以增加或减少数据

Map 算子因为类似于串行操作,性能比较低;mapPartitions 算子类似于批处理,性能较高。但是 mapPartitions 算子会长时间占用内存,那么这样会导致内存可能不够用,出现内存溢出的错误

  • mapPartitionsWithIndex

    将待处理的数据以分区为单位发送到计算节点进行处理,这里的处理是指可以进行任意的处理,哪怕是过滤数据,在处理时同时可以获取当前分区索引。

    def mapPartitionsWithIndex[U: ClassTag](

    f: (Int, Iterator[T]) => Iterator[U],

    preservesPartitioning: Boolean = false): RDD[U]

    object Spark03_RDD_Operator_Transform1 {
        def main(args: Array[String]): Unit = {
            val sparkConf = new SparkConf().setMaster("local[*]").setAppName("Operator")
            val sc = new SparkContext(sparkConf)
    
            // TODO 算子 - mapPartitions
            val rdd = sc.makeRDD(List(1,2,3,4))
            val mpiRDD = rdd.mapPartitionsWithIndex(
                (index, iter) => {
                    // 1,   2,    3,   4
                    //(1,1)(3,2),(5,3),(7,4)
                    iter.map(
                        num => {
                            (index, num)
                        }
                    )
                }
            )
            mpiRDD.collect().foreach(println)
            sc.stop()
        }
    }
    
  • flatMap

    将处理的数据进行扁平化后再进行映射处理,所以算子也称之为扁平映射

    def flatMap[U: ClassTag] (f: T => TraversableOnce[U]): RDD[U]

    小功能:将 List(List(1,2),3,List(4,5))进行扁平化操作

    object testflatmap {
      def main(args: Array[String]): Unit = {
        val sparkConf: SparkConf = new SparkConf().setMaster("local[*]").setAppName("Operator")
        val sc = new SparkContext(sparkConf)
    
        val rdd = sc.makeRDD(
          List(List(1, 2), 3, List(3, 4))
        )
    
        val flatrdd = rdd.flatMap(
          data => {
            data match {
              case list:List[_] => list
              case dat => List(dat)
            }
          }
        )
    
        flatrdd.collect().foreach(println)
    
        sc.stop()
      }
    }
    
  • glom

    将同一个分区的数据直接转换为相同类型的内存数组进行处理,分区不变

    def glom(): RDD[Array[T]]

    小功能:计算所有分区最大值求和(分区内取最大值,分区间最大值求和)

    object testglom {
      def main(args: Array[String]): Unit = {
    
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("Operator")
        val sc = new SparkContext(sparkConf)
    
        // TODO 算子 - glom
        val rdd : RDD[Int] = sc.makeRDD(List(1,2,3,4), 2)
        // List=>Int
        // Int=>Array
        val glomRDD: RDD[Array[Int]] = rdd.glom()
    
        glomRDD.collect().foreach(data=> println(data.mkString(",")))
        // 【1,2】,【3,4】
        // 【2】,【4】
        // 【6】
        val maxRDD: RDD[Int] = glomRDD.map(
          array => {
            array.max
          }
        )
        println(maxRDD.collect().sum)
        sc.stop()
      }
    }
    
  • groupby

    def groupBy[K](f: T => K)(implicit kt: ClassTag[K]): RDD[(K, Iterable[T])]

    将数据根据指定的规则进行分组, 分区默认不变,但是数据会被打乱重新组合,这样的操作称之为 shuffle(一个分区的数据被打散进入不同的分区。)。极限情况下,数据可能被分在同一个分区中

    一个组的数据在一个分区中,但是并不是说一个分区中只有一个组

    object Spark06_RDD_Operator_Transform {
        def main(args: Array[String]): Unit = {
            val sparkConf = new SparkConf().setMaster("local[*]").setAppName("Operator")
            val sc = new SparkContext(sparkConf)
    
            // TODO 算子 - groupBy
            val rdd : RDD[Int] = sc.makeRDD(List(1,2,3,4), 2)
    
            // groupBy会将数据源中的每一个数据进行分组判断,根据返回的分组key进行分组
            // 相同的key值的数据会放置在一个组中
            // 分区和分组没有必然的联系
            def groupFunction(num:Int) = {
                num % 2
            }
    
            val groupRDD: RDD[(Int, Iterable[Int])] = rdd.groupBy(groupFunction)
            groupRDD.collect().foreach(println)
            sc.stop()
        }
    }
    
  • fliter

    def filter(f: T => Boolean): RDD[T]

    当数据进行筛选过滤后,分区不变,但是分区内的数据可能不均衡,生产环境下,可能会出现数据倾斜

  • sample

    def sample(

    withReplacement: Boolean,

    fraction: Double,

    seed: Long = Utils.random.nextLong): RDD[T]

    判断数据倾斜的原因

  • distinct

    def distinct()(implicit ord: Ordering[T] = null): RDD[T]

    def distinct(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T]

    object Spark09_RDD_Operator_Transform {
    
      def main(args: Array[String]): Unit = {
    
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("Operator")
        val sc = new SparkContext(sparkConf)
    
        // TODO 算子 - filter
        val rdd = sc.makeRDD(List(1, 2, 3, 4, 1, 2, 3, 4))
    
        // distinct的逻辑
        // map(x => (x, null)).reduceByKey((x, _) => x, numPartitions).map(_._1)
    
        // (1, null),(2, null),(3, null),(4, null),(1, null),(2, null),(3, null),(4, null)
        // (1, null)(1, null)(1, null) 这些一起聚合 得到下面
        // (null, null) => null
        // (1, null) => 1
        val rdd1: RDD[Int] = rdd.distinct()
    
        rdd1.collect().foreach(println)
    
    
        sc.stop()
    
      }
    }
    
  • coalesce

    根据数据量缩减分区,用于大数据集过滤后,提高小数据集的执行效率

    当 spark 程序中,存在过多的小任务的时候,可以通过 coalesce 方法,收缩合并分区,减少分区的个数,减小任务调度成本

    def coalesce(numPartitions: Int, shuffle: Boolean = false,

    partitionCoalescer: Option[PartitionCoalescer] = Option.empty)

    (implicit ord: Ordering[T] = null)
    RDD[T]
    // TODO 算子 - filter
            val rdd = sc.makeRDD(List(1,2,3,4,5,6), 3)
    
            // coalesce方法默认情况下不会将分区的数据打乱重新组合
            // 这种情况下的缩减分区可能会导致数据不均衡,出现数据倾斜
            // 如果想要让数据均衡,可以进行shuffle处理
            //val newRDD: RDD[Int] = rdd.coalesce(2)
            val newRDD: RDD[Int] = rdd.coalesce(2,true)
    
  • repartition

    该操作内部其实执行的是 coalesce 操作,参数 shuffle 的默认值为 true。无论是将分区数多的RDD 转换为分区数少的 RDD,还是将分区数少的 RDD 转换为分区数多的 RDD,repartition操作都可以完成,因为无论如何都会经 shuffle 过程

    // TODO 算子 - filter
            val rdd = sc.makeRDD(List(1,2,3,4,5,6), 2)
    
            // coalesce算子可以扩大分区的,但是如果不进行shuffle操作,是没有意义,不起作用。
            // 所以如果想要实现扩大分区的效果,需要使用shuffle操作
            // spark提供了一个简化的操作
            // 缩减分区:coalesce,如果想要数据均衡,可以采用shuffle
            // 扩大分区:repartition, 底层代码调用的就是coalesce,而且肯定采用shuffle
            //val newRDD: RDD[Int] = rdd.coalesce(3, true)
            val newRDD: RDD[Int] = rdd.repartition(3)
    

    coalease 可以扩大分区 不shuffle没有意义(数据均衡) ,数据分区不会被打乱

  • sortBy

    该操作用于排序数据。在排序之前,可以将数据通过 f 函数进行处理,之后按照 f 函数处的结果进行排序,默认为升序排列。排序后新产生的 RDD 的分区数与原 RDD 的分区数一致。中间存在 shuffle 的过程

    val rdd = sc.makeRDD(List(("1", 1), ("11", 2), ("2", 3)), 2)
    
            // sortBy方法可以根据指定的规则对数据源中的数据进行排序,默认为升序,第二个参数可以改变排序的方式
            // sortBy默认情况下,不会改变分区。但是中间存在shuffle操作
            val newRDD = rdd.sortBy(t=>t._1.toInt, false)
    
            newRDD.collect().foreach(println)
    
双value型 两个rdd
  • intersection

  • union

  • substract

  • zip

    zip 分区数据量必须一致

    zip rdd 分区也得 一致

object Spark13_RDD_Operator_Transform {

    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("Operator")
        val sc = new SparkContext(sparkConf)

        // TODO 算子 - 双Value类型

        // 交集,并集和差集要求两个数据源数据类型保持一致
        // 拉链操作两个数据源的类型可以不一致

        val rdd1 = sc.makeRDD(List(1,2,3,4))
        val rdd2 = sc.makeRDD(List(3,4,5,6))
        val rdd7 = sc.makeRDD(List("3","4","5","6"))

        // 交集 : 【3,4】
        val rdd3: RDD[Int] = rdd1.intersection(rdd2)
        //val rdd8 = rdd1.intersection(rdd7)
        println(rdd3.collect().mkString(","))

        // 并集 : 【1,2,3,4,3,4,5,6】
        val rdd4: RDD[Int] = rdd1.union(rdd2)
        println(rdd4.collect().mkString(","))

        // 差集 : 【1,2】
        val rdd5: RDD[Int] = rdd1.subtract(rdd2)
        println(rdd5.collect().mkString(","))

        // 拉链 : 【1-3,2-4,3-5,4-6】
        val rdd6: RDD[(Int, Int)] = rdd1.zip(rdd2)
        val rdd8 = rdd1.zip(rdd7)
        println(rdd6.collect().mkString(","))

        sc.stop()
    }
}
key-value型
  • partitionBY

    def partitionBy(partitioner: Partitioner): RDD[(K, V)]

    伴生对象 PairRDDFunctions rddToPairRDDFunctions

    val mapRDD:RDD[(Int, Int)] = rdd.map((_,1))
            // RDD => PairRDDFunctions
            // 隐式转换(二次编译)
    
            // partitionBy根据指定的分区规则对数据进行重分区
            val newRDD = mapRDD.partitionBy(new HashPartitioner(2))
            newRDD.partitionBy(new HashPartitioner(2))
    
    • 如果重分区的分区器和当前 RDD 的分区器一样怎么办?

      一样(类型,数量),返回他自己,不一样 new 一个

    • Spark 还有其他分区器吗?

      hashPartition RangePartition PythonPartitioner

    • 如果想按照自己的方法进行数据分区怎么办?

      自定义分区器 继承partitioner

  • reducebyKey

    def reduceByKey(func: (V, V) => V): RDD[(K, V)]

    def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)]

    // reduceByKey : 相同的key的数据进行value数据的聚合操作
            // scala语言中一般的聚合操作都是两两聚合,spark基于scala开发的,所以它的聚合也是两两聚合
            // 【1,2,3】
            // 【3,3】
            // 【6】
            // reduceByKey中如果key的数据只有一个,是不会参与运算的。
            val reduceRDD: RDD[(String, Int)] = rdd.reduceByKey( (x:Int, y:Int) => {
                println(s"x = ${x}, y = ${y}")
                x + y
            } )
    
  • groupByKey

    def groupByKey(): RDD[(K, Iterable[V])]

    def groupByKey(numPartitions: Int): RDD[(K, Iterable[V])]

    def groupByKey(partitioner: Partitioner): RDD[(K, Iterable[V])]

groupBykey 和 reduceBykey 的区别

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

预聚合

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

reduceByKey 支持分区内预聚合,可以有效减少shuffle时落盘的数据量,提升shuffle的性能

所以在分组聚合的场合下,推荐使用 reduceByKey,如果仅仅是分组而不需要聚合。那么还是只能使用 groupByKey

  • aggregateByKey

    将数据根据不同的规则进行分区内计算和分区间计算

    val rdd = sc.makeRDD(List(
                ("a", 1), ("a", 2), ("a", 3), ("a", 4)),2)
            // (a,【1,2】), (a, 【3,4】)
            // (a, 2), (a, 4)  max
            // (a, 6)          聚合 计算规则不同
    
            // aggregateByKey存在函数柯里化,有两个参数列表
            // 第一个参数列表,需要传递一个参数,表示为初始值
            //       主要用于当碰见第一个key的时候,和value进行分区内计算
            // 第二个参数列表需要传递2个参数
            //      第一个参数表示分区内计算规则
            //      第二个参数表示分区间计算规则
    
            // math.min(x, y)
            // math.max(x, y)
            rdd.aggregateByKey(0)(
                (x, y) => math.max(x, y),
                (x, y) => x + y
            ).collect.foreach(println)
    

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • foldByKey

    当分区内计算规则和分区间计算规则相同时,aggregateByKey 就可以简化为 foldByKey

    def foldByKey(zeroValue: V)(func: (V, V) => V): RDD[(K, V)]

    //rdd.aggregateByKey(0)(_+_, _+_).collect.foreach(println)
    
            // 如果聚合计算时,分区内和分区间计算规则相同,spark提供了简化的方法
            rdd.foldByKey(0)(_+_).collect.foreach(println)
    
  • combineByKey

    最通用的对 key-value 型 rdd 进行聚集操作的聚集函数(aggregation function)。类似于aggregate(),combineByKey()允许用户返回值的类型与输入不一致。

    val rdd = sc.makeRDD(List(
        ("a", 1), ("a", 2), ("b", 3),
        ("b", 4), ("b", 5), ("a", 6)
    ),2)
    
    // combineByKey : 方法需要三个参数
    // 第一个参数表示:将相同key的第一个数据进行结构的转换,实现操作
    // 第二个参数表示:分区内的计算规则
    // 第三个参数表示:分区间的计算规则
    val newRDD : RDD[(String, (Int, Int))] = rdd.combineByKey(
        v => (v, 1),
        ( t:(Int, Int), v ) => {
            (t._1 + v, t._2 + 1)
        },
        (t1:(Int, Int), t2:(Int, Int)) => {
            (t1._1 + t2._1, t1._2 + t2._2)
        }
    )
    

reduceByKey: 相同 key 的第一个数据不进行任何计算,分区内和分区间计算规则相同

FoldByKey: 相同 key 的第一个数据和初始值进行分区内计算,分区内和分区间计算规则相同

AggregateByKey:相同 key 的第一个数据和初始值进行分区内计算,分区内和分区间计算规则可以不相同

CombineByKey:当计算时,发现数据结构不满足要求时,可以让第一个数据转换结构。分区内和分区间计算规则不相同

底层都是 combineByKeyWithClassTag[U]

  • sortByKey

    在一个(K,V)的 RDD 上调用,K 必须实现 Ordered 接口(特质),返回一个按照 key 进行排序的

    def sortByKey(ascending: Boolean = true, numPartitions: Int = self.partitions.length) : RDD[(K, V)]

    val dataRDD1 = sparkContext.makeRDD(List(("a",1),("b",2),("c",3)))
    val sortRDD1: RDD[(String, Int)] = dataRDD1.sortByKey(true)
    val sortRDD1: RDD[(String, Int)] = dataRDD1.sortByKey(false)
    
  • join

    在类型为(K,V)和(K,W)的 RDD 上调用,返回一个相同 key 对应的所有元素连接在一起的(K,(V,W))的 RDD

    def join[W](other: RDD[(K, W)]): RDD[(K, (V, W))]

    val rdd1 = sc.makeRDD(List(
                ("a", 1), ("a", 2), ("c", 3)
            ))
    
            val rdd2 = sc.makeRDD(List(
                ("a", 5), ("c", 6),("a", 4)
            ))
    
            // join : 两个不同数据源的数据,相同的key的value会连接在一起,形成元组
            //        如果两个数据源中key没有匹配上,那么数据不会出现在结果中
            //        如果两个数据源中key有多个相同的,会依次匹配,可能会出现笛卡尔乘积,数据量会几何性增长,会导致性能降低。
            val joinRDD: RDD[(String, (Int, Int))] = rdd1.join(rdd2)
    
  • **leftOuterJoin ** rightOuterJoin

  • cogroup

    在类型为(K,V)和(K,W)的 RDD 上调用,返回一个(K,(Iterable,Iterable))类型的 RDD

    val rdd1 = sc.makeRDD(List(
                ("a", 1), ("b", 2)//, ("c", 3)
            ))
    
            val rdd2 = sc.makeRDD(List(
                ("a", 4), ("b", 5),("c", 6),("c", 7)
            ))
    
            // cogroup : connect + group (分组,连接)
            val cgRDD: RDD[(String, (Iterable[Int], Iterable[Int]))] = rdd1.cogroup(rdd2)
    
行动算子

所谓的行动算子,其实就是触发作业(Job)执行的方法
底层代码调用的是环境对象的runJob方法
底层代码中会创建ActiveJob,并提交执行。

  • reduce

    聚集 RDD 中的所有元素,先聚合分区内数据,再聚合分区间数据

    val rdd: RDD[Int] = sc.makeRDD(List(1,2,3,4))
    // 聚合数据
    val reduceResult: Int = rdd.reduce(_+_)
    
  • collect

    在驱动程序中,以数组 Array 的形式返回数据集的所有元素

  • count

    返回 RDD 中元素的个数

  • first

    返回 RDD 中的第一个元素

  • take

    返回一个由 RDD 的前 n 个元素组成的数组

  • takeOrdered

    返回该 RDD 排序后的前 n 个元素组成的数组

  • aggregate

    分区的数据通过初始值和分区内的数据进行聚合,然后再和初始值进行分区间的数据聚合

    val rdd = sc.makeRDD(List(1,2,3,4),2)
    
            // TODO - 行动算子
    
            //10 + 13(10+1+2) + 17(10+3+4) = 40
            // aggregateByKey : 初始值只会参与分区内计算
            // aggregate : 初始值会参与分区内计算,并且和参与分区间计算
            //val result = rdd.aggregate(10)(_+_, _+_)
            val result = rdd.fold(10)(_+_)
    
  • fold

    当aggregate分区内和分区间规则相同的时候,可以用fold

    val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4))
    val foldResult: Int = rdd.fold(0)(_+_)
    
  • countByKey countByValue

    统计每种 key/value 的个数

  • save

    将数据保存到不同格式的文件中

    def saveAsTextFile(path: String): Unit

    def saveAsObjectFile(path: String): Unit

    def saveAsSequenceFile(

    path: String,

    codec: Option[Class[_ <: CompressionCodec]] = None): Unit

    // 保存成 Text 文件
    rdd.saveAsTextFile("output")
    // 序列化成对象保存到文件
    rdd.saveAsObjectFile("output1")
    // 保存成 Sequencefile 文件
    // saveAsSequenceFile方法要求数据的格式必须为K-V类型
    rdd.map((_,1)).saveAsSequenceFile("output2")
    
  • foreach

    分布式遍历 RDD 中的每一个元素,调用指定函数

     // foreach 其实是Driver端内存集合的循环遍历方法
            rdd.collect().foreach(println)
            println("******************")
            // foreach 其实是Executor端内存数据打印
            rdd.foreach(println)
    
            // 算子 : Operator(操作)
            //         RDD的方法和Scala集合对象的方法不一样
            //         集合对象的方法都是在同一个节点的内存中完成的。
            //         RDD的方法可以将计算逻辑发送到Executor端(分布式节点)执行
            //         为了区分不同的处理效果,所以将RDD的方法称之为算子。
            //        RDD的方法外部的操作都是在Driver端执行的,而方法内部的逻辑代码是在Executor端执行。
    

RDD 序列化

从计算的角度, 算子以外的代码都是在 Driver 端执行, 算子里面的代码都是在 Executor端执行。那么在 scala 的函数式编程中,就会导致算子内经常会用到算子外的数据,这样就形成了闭包的效果,如果使用的算子外的数据无法序列化,就意味着无法传值给 Executor端执行,就会发生错误,所以需要在执行任务计算前,检测闭包内的对象是否可以进行序列化,这个操作我们称之为闭包检测

object Spark01_RDD_Serial {

    def main(args: Array[String]): Unit = {
        val sparConf = new SparkConf().setMaster("local").setAppName("WordCount")
        val sc = new SparkContext(sparConf)

        val rdd: RDD[String] = sc.makeRDD(Array("hello world", "hello spark", "hive", "atguigu"))

        val search = new Search("h")

        //search.getMatch1(rdd).collect().foreach(println)
        search.getMatch2(rdd).collect().foreach(println)

        sc.stop()
    } 
    // 查询对象   case 样例类可以序列化
    // 类的构造参数其实是类的属性, 构造参数需要进行闭包检测,其实就等同于类进行闭包检测
    class Search(query:String){

        def isMatch(s: String): Boolean = {
            s.contains(this.query)
        }

        // 函数序列化案例
        def getMatch1 (rdd: RDD[String]): RDD[String] = {
            rdd.filter(isMatch)
        }

        // 属性序列化案例
        def getMatch2(rdd: RDD[String]): RDD[String] = {
            val s = query
            // rdd 算子 在executor中引用的s 是字符串能够序列化
            rdd.filter(x => x.contains(s))
        }
    }
}
kryo 序列化框架

Java 的序列化能够序列化任何的类。但是比较重(字节多),序列化后,对象的提交也比较大。Spark 出于性能的考虑,Spark2.0 开始支持另外一种 Kryo 序列化机制。Kryo 速度是 Serializable 的 10 倍。当 RDD 在 Shuffle 数据的时候,简单数据类型、数组和字符串类型已经在 Spark 内部使用 Kryo 来序列化

object serializable_Kryo {
    def main(args: Array[String]): Unit = {
        val conf: SparkConf = new SparkConf()
        .setAppName("SerDemo")
        .setMaster("local[*]")
        // 替换默认的序列化机制
        .set("spark.serializer","org.apache.spark.serializer.KryoSerializer")
        // 注册需要使用 kryo 序列化的自定义类
        .registerKryoClasses(Array(classOf[Searcher]))
        val sc = new SparkContext(conf)
        val rdd: RDD[String] = sc.makeRDD(Array("hello world", "hello atguigu", "atguigu", "hahah"), 2)
        val searcher = new Searcher("hello")
        val result: RDD[String] = searcher.getMatchedRDD1(rdd)
        result.collect.foreach(println)
    } 
}

RDD 血缘关系

RDD 只支持粗粒度转换,即在大量记录上执行的单个操作。将创建 RDD 的一系列 Lineage(血统)记录下来,以便恢复丢失的分区。RDD 的 Lineage 会记录 RDD 的元数据信息和转换行为,当该 RDD 的部分分区数据丢失时,它可以根据这些信息来重新运算和恢复丢失的数据分区

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

RDD 依赖关系 dependencies

这里所谓的依赖关系,其实就是两个相邻 RDD 之间的关系

val sc: SparkContext = new SparkContext(conf)
val fileRDD: RDD[String] = sc.textFile("input/1.txt")
println(fileRDD.dependencies)
println("----------------------")
RDD 窄依赖

窄依赖表示每一个父(上游)RDD 的 Partition 最多被子(下游)RDD 的一个 Partition 使用,

窄依赖我们形象的比喻为独生子女。

class OneToOneDependency[T](rdd: RDD[T]) extends NarrowDependency[T](rdd)
RDD 宽依赖

宽依赖表示同一个父(上游)RDD 的 Partition 被多个子(下游)RDD 的 Partition 依赖,

会引起 Shuffle

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

RDD 阶段划分 阶段的数量=shuffle依赖的数量+1

DAG(Directed Acyclic Graph)有向无环图是由点和线组成的拓扑图形,该图形具有方向,不会闭环。例如,DAG 记录了 RDD 的转换过程和任务的阶段

DAGScheduler 类

try {
      // New stage creation may throw an exception if, for example, jobs are run on a HadoopRDD whose underlying HDFS files have been deleted.
      finalStage = createResultStage(finalRDD, func, partitions, jobId, callSite)
}

private def createResultStage(
 rdd: RDD[_],
 func: (TaskContext, Iterator[_]) => _,
 partitions: Array[Int],
 jobId: Int,
 callSite: CallSite): ResultStage = {
val parents = getOrCreateParentStages(rdd, jobId)
val id = nextStageId.getAndIncrement()
val stage = new ResultStage(id, rdd, func, partitions, parents, jobId, callSite)
stageIdToStage(id) = stage
updateJobIdStageIdMaps(jobId, stage)
stage
}

private def getOrCreateParentStages(rdd: RDD[_], firstJobId: Int): List[Stage] = {
    getShuffleDependencies(rdd).map { shuffleDep =>
      getOrCreateShuffleMapStage(shuffleDep, firstJobId)
    }.toList
  }

private def getOrCreateShuffleMapStage(
      shuffleDep: ShuffleDependency[_, _, _],
      firstJobId: Int): ShuffleMapStage = {
    shuffleIdToMapStage.get(shuffleDep.shuffleId) match {
      case Some(stage) =>
        stage

      case None =>
        // Create stages for all missing ancestor shuffle dependencies.
        getMissingAncestorShuffleDependencies(shuffleDep.rdd).foreach { 
            dep =>if (!shuffleIdToMapStage.contains(dep.shuffleId)) {
            createShuffleMapStage(dep, firstJobId)
          }
        }
        // Finally, create a stage for the given shuffle dependency.
        createShuffleMapStage(shuffleDep, firstJobId)
    }
  }

当RDD中存在shuffle依赖时,阶段会自动增加一个

阶段的数量=shuffle依赖的数量+1

ResultStage只有一个,最后需要执行的阶段

RDD 任务划分

RDD 任务切分中间分为:Application、Job、Stage 和 Task

  • Application:初始化一个 SparkContext 即生成一个 Application;

  • Job:一个 Action 算子就会生成一个 Job;

  • Stage:Stage 等于宽依赖(ShuffleDependency)的个数加 1;

  • Task:一个 Stage 阶段中,最后一个 RDD 的分区个数就是 Task 的个数。

    任务数量=当前阶段最后一个RDD的分区数量

注意:Application->Job->Stage->Task 每一层都是 1 对 n 的关系。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

任务的名称和阶段 是对应的

shuffleMapStage => shuffleMapTask

ResultStage=> ResultStage

RDD 持久化

从头 从头读数据一遍 对象重用意义并不大

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

RDD cache

RDD 通过 Cache 或者 Persist 方法将前面的计算结果缓存,默认情况下会把数据以缓存在 JVM 的堆内存中。但是并不是这两个方法被调用时立即缓存,而是触发后面的 action 算 子时,该 RDD 将会被缓存在计算节点的内存中,并供后面重用。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

object Spark02_RDD_Persist {

  def main(args: Array[String]): Unit = {
    val sparConf = new SparkConf().setMaster("local").setAppName("Persist")
    val sc = new SparkContext(sparConf)

    val list = List("Hello Scala", "Hello Spark")
    val rdd = sc.makeRDD(list)
    val flatRDD = rdd.flatMap(_.split(" "))

    val mapRDD = flatRDD.map(word => {
      println("@@@@@@@@@@@@")
      (word, 1)
    })
    // cache默认持久化的操作,只能将数据保存到内存中,如果想要保存到磁盘文件
    // 需要更改存储级别
    mapRDD.cache()
    //
    val reduceRDD: RDD[(String, Int)] = mapRDD.reduceByKey(_ + _)
    reduceRDD.collect().foreach(println)
    println("**************************************")
    val groupRDD = mapRDD.groupByKey()
    groupRDD.collect().foreach(println)

    sc.stop()
  }
}
RDD persist

persist 默认级别是 memory only

缓存有可能丢失,或者存储于内存的数据由于内存不足而被删除,RDD 的缓存容错机制保证了即使缓存丢失也能保证计算的正确执行。通过基于 RDD 的一系列转换,丢失的数据会被重算,由于 RDD 的各个 Partition 是相对独立的,因此只需要计算丢失的部分即可,

并不需要重算全部 Partition。

Spark 会自动对一些 Shuffle 操作的中间数据做持久化操作(比如:reduceByKey)。这样做的目的是为了当一个节点 Shuffle 失败了避免重新计算整个输入。但是,在实际使用的时候,如果想重用数据,仍然建议调用 persist 或 cache

// cache 操作会增加血缘关系,不改变原有的血缘关系
println(wordToOneRdd.toDebugString)
// 数据缓存。
wordToOneRdd.cache()
// 可以更改存储级别
mapRdd.persist(StorageLevel.MEMORY_AND_DISK_2)

所以在数据执行较长,或数据比较重要的场合也可以采用持久化操作

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

RDD checkPoint

检查点其实就是通过将 RDD 中间结果写入磁盘,由于血缘依赖过长会造成容错成本过高,这样就不如在中间阶段做检查点容错,如果检查点之后有节点出现问题,可以从检查点开始重做血缘,减少了开销。对 RDD 进行 checkpoint 操作并不会马上被执行,必须执行 Action 操作才能触发。

object Spark04_RDD_Persist {

    def main(args: Array[String]): Unit = {
        val sparConf = new SparkConf().setMaster("local").setAppName("Persist")
        val sc = new SparkContext(sparConf)
        sc.setCheckpointDir("cp")

        val list = List("Hello Scala", "Hello Spark")

        val rdd = sc.makeRDD(list)

        val flatRDD = rdd.flatMap(_.split(" "))

        val mapRDD = flatRDD.map(word=>{
            println("@@@@@@@@@@@@")
            (word,1)
        })
        // checkpoint 需要落盘,需要指定检查点保存路径
        // 检查点路径保存的文件,当作业执行完毕后,不会被删除
        // 一般保存路径都是在分布式存储系统:HDFS
        mapRDD.checkpoint()

        val reduceRDD: RDD[(String, Int)] = mapRDD.reduceByKey(_+_)
        reduceRDD.collect().foreach(println)
        println("**************************************")
        val groupRDD = mapRDD.groupByKey()
        groupRDD.collect().foreach(println)


        sc.stop()
    }
}

缓存和检查点区别

// cache : 将数据临时存储在内存中进行数据重用
//         会在血缘关系中添加新的依赖。一旦,出现问题,可以重头读取数据
// persist : 将数据临时存储在磁盘文件中进行数据重用
//           涉及到磁盘IO,性能较低,但是数据安全
//           如果作业执行完毕,临时保存的数据文件就会丢失
// checkpoint : 将数据长久地保存在磁盘文件中进行数据重用
//           涉及到磁盘IO,性能较低,但是数据安全
//           为了保证数据安全,所以一般情况下,会独立执行作业  会产生新的作业
//           为了能够提高效率,一般情况下,是需要和cache联合使用
//           执行过程中,会切断血缘关系。重新建立新的血缘关系
//           checkpoint等同于改变数据源

建议对 checkpoint()的 RDD 使用 Cache 缓存,这样 checkpoint 的 job 只需从 Cache 缓存中读取数据即可,否则需要再从头计算一次 RDD

mapRDD.cache()
mapRDD.checkpoint()

RDD 分区器

Spark 目前支持 Hash 分区和 Range 分区,和用户自定义分区。Hash 分区为当前的默认分区。分区器直接决定了 RDD 中分区的个数、RDD 中每条数据经过 Shuffle 后进入哪个分区,进而决定了 Reduce 的个数。

  • 只有 Key-Value 类型的 RDD 才有分区器,非 Key-Value 类型的 RDD 分区的值是 None

  • 每个 RDD 的分区 ID 范围:0 ~ (numPartitions - 1),决定这个值是属于那个分区的。

Hash 分区:对于给定的 key,计算其 hashCode,并除以分区个数取余

Range 分区:将一定范围内的数据映射到一个分区中,尽量保证每个分区数据均匀,而且分区间有序

object Spark01_RDD_Part {

    def main(args: Array[String]): Unit = {
        val sparConf = new SparkConf().setMaster("local").setAppName("WordCount")
        val sc = new SparkContext(sparConf)

        val rdd = sc.makeRDD(List(
            ("nba", "xxxxxxxxx"),
            ("cba", "xxxxxxxxx"),
            ("wnba", "xxxxxxxxx"),
            ("nba", "xxxxxxxxx"),
        ),3)
        val partRDD: RDD[(String, String)] = rdd.partitionBy( new MyPartitioner )

        partRDD.saveAsTextFile("output")

        sc.stop()
    }

    /**
      * 自定义分区器
      * 1. 继承Partitioner
      * 2. 重写方法
      */
    class MyPartitioner extends Partitioner{
        // 分区数量
        override def numPartitions: Int = 3

        // 根据数据的key值返回数据所在的分区索引(从0开始)
        override def getPartition(key: Any): Int = {
            key match {
                case "nba" => 0
                case "wnba" => 1
                case _ => 2
            }
        }
    }
}

RDD 文件保存

Spark 的数据读取及数据保存可以从两个维度来作区分:文件格式以及文件系统。

文件格式分为:text 文件、csv 文件、sequence 文件以及 Object 文件;

文件系统分为:本地文件系统、HDFS、HBASE 以及数据库。

  • load

    object Spark01_RDD_IO_Load {
    
        def main(args: Array[String]): Unit = {
            val sparConf = new SparkConf().setMaster("local").setAppName("WordCount")
            val sc = new SparkContext(sparConf)
    
            val rdd = sc.textFile("output1")
            println(rdd.collect().mkString(","))
    
            val rdd1 = sc.objectFile[(String, Int)]("output2")
            println(rdd1.collect().mkString(","))
    
            val rdd2 = sc.sequenceFile[String, Int]("output3")
            println(rdd2.collect().mkString(","))
    
            sc.stop()
        }
    }
    
  • save

    object Spark01_RDD_IO_Save {
    
        def main(args: Array[String]): Unit = {
            val sparConf = new SparkConf().setMaster("local").setAppName("WordCount")
            val sc = new SparkContext(sparConf)
    
            val rdd = sc.makeRDD(
                List(
                    ("a", 1),
                    ("b", 2),
                    ("c", 3)
                )
            )
    
            rdd.saveAsTextFile("output1")
            rdd.saveAsObjectFile("output2")
            rdd.saveAsSequenceFile("output3")
    
            sc.stop()
        }
    }
    

累加器 分布式共享只写变量

分布式 分布式统计值

共享 被driver端共享

只写 累加器之间的值相互访问不了

案例分析

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

闭包 不传回

var sum = 0
        rdd.foreach(  // 分布式循环 有可能变好几回 sum=1 sum=2 不确定
            num => {
                sum += num
            }
        )
        println("sum = " + sum)
// 打印结果是 sum=0

累加器可以返回结果

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

系统累加器
// 获取系统累加器
// Spark默认就提供了简单数据聚合的累加器
val sumAcc = sc.longAccumulator("sum")
// 例子
//sc.doubleAccumulator
//sc.collectionAccumulator

rdd.foreach(
    num => {
        // 使用累加器
        sumAcc.add(num)
    }
)

// 获取累加器的值
println(sumAcc.value)
自定义累加器
object Spark04_Acc_WordCount {

  def main(args: Array[String]): Unit = {

    val sparConf = new SparkConf().setMaster("local").setAppName("Acc")
    val sc = new SparkContext(sparConf)

    val rdd = sc.makeRDD(List("hello", "spark", "hello"))

    // 累加器 : WordCount
    // 创建累加器对象
    val wcAcc = new MyAccumulator()
    // 向Spark进行注册
    sc.register(wcAcc, "wordCountAcc")

    rdd.foreach(
      word => {
        // 数据的累加(使用累加器)
        wcAcc.add(word)
      }
    )

    // 获取累加器累加的结果
    println(wcAcc.value)

    sc.stop()

  }
  /*
    自定义数据累加器:WordCount

    1. 继承AccumulatorV2, 定义泛型
       IN : 累加器输入的数据类型 String
       OUT : 累加器返回的数据类型 mutable.Map[String, Long]

    2. 重写方法(6)
   */
  class MyAccumulator extends AccumulatorV2[String, mutable.Map[String, Long]] {

    private var wcMap = mutable.Map[String, Long]()

    // 判断是否初始状态
    override def isZero: Boolean = {
      wcMap.isEmpty
    }

    override def copy(): AccumulatorV2[String, mutable.Map[String, Long]] = {
      new MyAccumulator()
    }

    override def reset(): Unit = {
      wcMap.clear()
    }

    // 获取累加器需要计算的值
    override def add(word: String): Unit = {
      val newCnt = wcMap.getOrElse(word, 0L) + 1
      wcMap.update(word, newCnt)
    }

    // Driver合并多个累加器
    override def merge(other: AccumulatorV2[String, mutable.Map[String, Long]]): Unit = {

      val map1 = this.wcMap
      val map2 = other.value

      map2.foreach{
        case ( word, count ) => {
          val newCount = map1.getOrElse(word, 0L) + count
          map1.update(word, newCount)
        }
      }
    }

    // 累加器结果
    override def value: mutable.Map[String, Long] = {
      wcMap
    }`
  }
}

广播变量 分布式共享只读变量

Spark 中的广播变量就可以将闭包的数据保存到Executor内存中

Spark中的广播变量不能够更改:分布式共享只读变量

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

object Spark06_Bc {

    def main(args: Array[String]): Unit = {

        val sparConf = new SparkConf().setMaster("local").setAppName("Acc")
        val sc = new SparkContext(sparConf)

        val rdd1 = sc.makeRDD(List(
            ("a", 1),("b", 2),("c", 3)
        ))
        val map = mutable.Map(("a", 4),("b", 5),("c", 6))

        // 封装广播变量
        val bc: Broadcast[mutable.Map[String, Int]] = sc.broadcast(map)

        rdd1.map {
            case (w, c) => {
                // 方法广播变量
                val l: Int = bc.value.getOrElse(w, 0)
                (w, (c, l))
            }
        }.collect().foreach(println)
        
        sc.stop()

    }
}

工程化代码

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

函数柯里化

trait TApplication {
    def start(master:String ="local[*]", app:String = "Application")( op : => Unit ): Unit = {
        val sparConf = new SparkConf().setMaster(master).setAppName(app)
        val sc = new SparkContext(sparConf)
        EnvUtil.put(sc)

        try {
            op
        } catch {
            case ex => println(ex.getMessage)
        }

        // TODO 关闭连接
        sc.stop()
        EnvUtil.clear()
    }
}

Spark SQL

Spark SQL 为了简化 RDD 的开发,提高开发效率,提供了 2 个编程抽象,类似 Spark Core 中的 RDD

  • DataFrame\
  • DataSet

特点

  • 易整合

    无缝的整合了 SQL 查询和 Spark 编程

  • 统一的数据访问

    使用相同的方式连接不同的数据源

  • 兼容 Hive

    在已有的仓库上直接运行 SQL 或者 HiveQL

  • 标准数据连接

    通过 JDBC 或者 ODBC 来连接

查询起点

在老的版本中,SparkSQL 提供两种 SQL 查询起始点:一个叫 SQLContext,用于 Spark自己提供的 SQL 查询;一个叫 HiveContext,用于连接 Hive 的查询。

SparkSession 是 Spark 最新的 SQL 查询起始点,实质上是 SQLContext 和 HiveContext的组合,所以在SQLContex 和 HiveContext 上可用的 API 在 SparkSession 上同样是可以使用的。SparkSession 内部封装了 SparkContext,所以计算实际上是由 sparkContext 完成的。当我们使用 spark-shell 的时候, spark 框架会自动的创建一个名称叫做 spark 的 SparkSession 对 象, 就像我们以前可以自动获取到一个 sc 来表示 SparkContext 对象一样

DataFrame

DataFrame 与 RDD 的主要区别在于,前者带有 schema 元信息,即 DataFrame所表示的二维表数据集的每一列都带有名称和类型。

与 Hive 类似,DataFrame 也支持嵌套数据类型(struct、array 和 map)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

DataFrame 是为数据提供了 Schema 的视图。可以把它当做数据库中的一张表来对待

DataFrame 也是懒执行的,但性能上比 RDD 要高,主要原因:优化的执行计划,即查询计划通过 Spark catalyst optimiser 进行优化

基本操作

Spark SQL 的 DataFrame API 允许我们使用 DataFrame 而不用必须去注册临时表或者生成 SQL 表达式。DataFrame API 既有 transformation 操作也有 action 操作。

创建 DataFrame

查看Spark支持创建文件的数据源格式

 spark.read.

在 spark 的 bin/data 目录中创建 user.json 文件

读取 json 文件创建 DataFrame

{"username":"zhangsan","age":20}
scala> val df = spark.read.json("data/user.json")
df: org.apache.spark.sql.DataFrame = [age: bigint, username: string]

注意:如果从内存中获取数据,spark 可以知道数据类型具体是什么。如果是数字,默认作为 Int 处理;但是从文件中读取的数字,不能确定是什么类型,所以用 bigint 接收,可以和Long 类型转换,但是和 Int 不能进行转换

SQL 语法

SQL 语法风格是指我们查询数据的时候使用 SQL 语句来查询,这种风格的查询必须要有临时视图或者全局视图来辅助

读取 JSON 文件创建 DataFrame

对 DataFrame 创建一个临时表 createOrReplaceTempView

scala> val df = spark.read.json("data/user.json")
df: org.apache.spark.sql.DataFrame = [age: bigint, username: string]
scala> df.createOrReplaceTempView("people")

通过 SQL 语句实现查询全表

scala> val sqlDF = spark.sql("SELECT * FROM people")
sqlDF: org.apache.spark.sql.DataFrame = [age: bigint, name: string]

// 结果展示
scala> sqlDF.show

注意:普通临时表是 Session 范围内的,如果想应用范围内有效,可以使用全局临时表。使用全局临时表时需要全路径访问,如:global_temp.people

对于 DataFrame 创建一个全局表 跨越 Session

通过 SQL 语句实现查询全表

scala> spark.sql("SELECT * FROM global_temp.people").show()
+---+--------+
|age|username|
+---+--------+
| 20|zhangsan|
| 30| lisi|
| 40| wangwu|
+---+--------+

scala> spark.newSession().sql("SELECT * FROM global_temp.people").show()

DSL 语法 domain-specific language

DataFrame 提供一个特定领域语言(domain-specific language, DSL)去管理结构化的数据。可以在 Scala, Java, Python 和 R 中使用 DSL,使用 DSL 语法风格不必去创建临时视图了

  1. 创建一个 DataFrame

    scala> val df = spark.read.json("data/user.json")
    
  2. 查看 DataFrame 的 Schema 信息

    scala> df.printSchema
    
  3. 只查看"username"列数据

    scala> df.select("username").show(
    
  4. 查看"username"列数据以及"age+1"数据

    注意:涉及到运算的时候, 每列都必须使用$, 或者 采用引号表达式:单引号+字段名

    scala> df.select($"username",$"age" + 1).show
    scala> df.select('username, 'age + 1).show()
    scala> df.select('username, 'age + 1 as "newage").show()
    
  5. 查看"age"大于"30"的数据

    scala> df.filter($"age">30).show
    scala> df.filter('age>30).show
    
  6. 按照"age"分组,查看数据条数

    scala> df.groupBy("age").count.show
    
RDD 转换为 DataFrame

在 IDEA 中开发程序时,如果需要 RDD 与 DF 或者 DS 之间互相操作,那么需要引入

import spark.implicits._

这里的 spark 不是 Scala 中的包名,而是创建的 sparkSession 对象的变量名称,所以必须先创建 SparkSession 对象再导入。这里的 spark 对象不能使用 var 声明,因为 Scala 只支持val 修饰的对象的引入

scala> val idRDD = sc.textFile("data/id.txt")
scala> idRDD.toDF("id").show

实际开发中,一般通过样例类将 RDD 转换为 DataFrame

scala> case class User(name:String, age:Int) 
scala> sc.makeRDD(List(("zhangsan",30), ("lisi",40))).map(t=>User(t._1, 
t._2)).toDF.show
DataFrame 转换为 RDD

DataFrame 其实就是对 RDD 的封装,所以可以直接获取内部的 RDD

scala> val rdd = df.rdd

scala> val df = sc.makeRDD(List(("zhangsan",30), ("lisi",40))).map(t=>User(t._1, 
t._2)).toDF
scala> val rdd = df.rdd
scala> val array = rdd.collect

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

注意:此时得到的 RDD 存储类型为 Row

scala> array(0)
scala> array(0)(0)
scala> array(0).getAs[String]("name")

DataSet

DataSet 是具有强类型的数据集合,需要提供对应的类型信息

自定义数据类型

创建DataSet

  • 使用样例类序列创建 DataSet

    scala> case class Person(name: String, age: Long)
    scala> val caseClassDS = Seq(Person("zhangsan",2)).toDS()
    scala> caseClassDS.show
    
  • 使用基本类型的序列创建 DataSet

    val ds = Seq(1,2,3,4,5).toDS
    ds.show
    

注意:在实际使用的时候,很少用到把序列转换成DataSet,更多的是通过RDD来得到DataSet

RDD 转换 DataSet

SparkSQL 能够自动将包含有 case 类的 RDD 转换成 DataSet,case 类定义了 table 的结构,case 类属性通过反射变成了表的列名。Case 类可以包含诸如 Seq 或者 Array 等复杂的结构

scala> case class User(name:String, age:Int)
scala> sc.makeRDD(List(("zhangsan",30), ("lisi",49))).map(t=>User(t._1, t._2)).toDS

DataSet 转换 RDD

DataSet 其实也是对 RDD 的封装,所以可以直接获取内部的 RDD

scala> case class User(name:String, age:Int)
scala> sc.makeRDD(List(("zhangsan",30), ("lisi",49))).map(t=>User(t._1, t._2)).toDS
scala> val rdd = res11.rdd
scala> rdd.collect

DataFrame DataSet 转换

DataFrame 其实是 DataSet 的特例,所以它们之间是可以互相转换的。

  • DataFrame 转换为 DataSet

    scala> case class User(name:String, age:Int)
    scala> val df = sc.makeRDD(List(("zhangsan",30), ("lisi",49))).toDF("name","age")
    scala> val ds = df.as[User]
    
  • DataSet 转换为 DataFrame

    scala> val ds = df.as[User]
    scala> val df = ds.toDF
    

RDD、DataFrame、DataSet三者的关系

如果同样的数据都给到这三个数据结构,他们分别计算之后,都会给出相同的结果。不同是的他们的执行效率和执行方式。在后期的 Spark 版本中,DataSet 有可能会逐步取代 RDD和 DataFrame 成为唯一的 API 接口。

共性
  • RDD、DataFrame、DataSet 全都是 spark 平台下的分布式弹性数据集,为处理超大型数据提供便利;
  • 三者都有惰性机制,在进行创建、转换,如 map 方法时,不会立即执行,只有在遇到Action 如 foreach 时,三者才会开始遍历运算;
  • 三者有许多共同的函数,如 filter,排序等;
  • 在对 DataFrame 和 Dataset 进行操作许多操作都需要这个包:import spark.implicits._(在创建好 SparkSession 对象后尽量直接导入)
  • 三者都会根据 Spark 的内存情况自动缓存运算,这样即使数据量很大,也不用担心会内存溢出
  • 三者都有 partition 的概念
  • DataFrame 和 DataSet 均可使用模式匹配获取各个字段的值和类型
区别
  • RDD

    RDD 一般和 spark mllib 同时使用

    RDD 不支持 sparksql 操作

  • DataFrame

    与 RDD 和 Dataset 不同,DataFrame 每一行的类型固定为 Row,每一列的值没法直接访问,只有通过解析才能获取各个字段的值

    DataFrame 与 DataSet 一般不与 spark mllib 同时使用

    DataFrame 与 DataSet 均支持 SparkSQL 的操作,比如 select,groupby 之类,还能注册临时表/视窗,进行 sql 语句操作

    DataFrame 与 DataSet 支持一些特别方便的保存方式,比如保存成 csv,可以带上表头,这样每一列的字段名一目了然(后面专门讲解)

  • DataSet

    Dataset 和 DataFrame 拥有完全相同的成员函数,区别只是每一行的数据类型不同。DataFrame 其实就是 DataSet 的一个特例 type DataFrame = Dataset[Row]

    DataFrame 也可以叫 Dataset[Row],每一行的类型是 Row,不解析,每一行究竟有哪些字段,各个字段又是什么类型都无从得知,只能用上面提到的 getAS 方法或者共性中的第七条提到的模式匹配拿出特定字段。而 Dataset 中,每一行是什么类型是不一定的,在自定义了 case class 之后可以很自由的获得每一行的信息

IDEA 开发SparkSQL

添加依赖

<dependency>
 <groupId>org.apache.spark</groupId>
 <artifactId>spark-sql_2.12</artifactId>
 <version>3.0.0</version>
</dependency>

demo 代码

object SparkSQL01_Demo {
    def main(args: Array[String]): Unit = {
        //创建上下文环境配置对象
        val conf: SparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkSQL01_Demo")
        //创建 SparkSession 对象
        val spark: SparkSession = SparkSession.builder().config(conf).getOrCreate()
        //RDD=>DataFrame=>DataSet 转换需要引入隐式转换规则,否则无法转换
        //spark 不是包名,是上下文环境对象名
        import spark.implicits._
        //读取 json 文件 创建 DataFrame {"username": "lisi","age": 18}
        val df: DataFrame = spark.read.json("input/test.json")
        //df.show()
        //SQL 风格语法
        df.createOrReplaceTempView("user")
        //spark.sql("select avg(age) from user").show
        //DSL 风格语法
        //df.select("username","age").show()
        //*****RDD=>DataFrame=>DataSet*****
        //RDD
        val rdd1: RDD[(Int, String, Int)] = 
        spark.sparkContext.makeRDD(List((1,"zhangsan",30),(2,"lisi",28),(3,"wangwu",
                                                                         20)))
        //DataFrame
        val df1: DataFrame = rdd1.toDF("id","name","age")
        //df1.show()
        //DateSet
        val ds1: Dataset[User] = df1.as[User]
        //ds1.show()
        //*****DataSet=>DataFrame=>RDD*****
        //DataFrame
        val df2: DataFrame = ds1.toDF()
        //RDD 返回的 RDD 类型为 Row,里面提供的 getXXX 方法可以获取字段值,类似 jdbc 处理结果集,
        但是索引从 0 开始
        val rdd2: RDD[Row] = df2.rdd
        //rdd2.foreach(a=>println(a.getString(1)))
        //*****RDD=>DataSet*****
        rdd1.map{
            case (id,name,age)=>User(id,name,age)
        }.toDS()
        //*****DataSet=>=>RDD*****
        ds1.rdd
        //释放资源
        spark.stop()
    } }
case class User(id:Int,name:String,age:Int)

用户自定义函数

用户可以通过 spark.udf 功能添加自定义函数,实现自定义功能。

UDF

创建 DataFrame

注册 UDF

创建临时表

应用 UDF

scala> val df = spark.read.json("data/user.json")
scala> spark.udf.register("addName",(x:String)=> "Name:"+x)
scala> df.createOrReplaceTempView("people")
scala> spark.sql("Select addName(name),age from people").show()
UDAF

强类型的 Dataset 和弱类型的 DataFrame 都提供了相关的聚合函数, 如 count(),countDistinct(),avg(),max(),min()。

除此之外,用户可以设定自己的自定义聚合函数。通过继承 强类型聚合函数Aggregator

需求:计算平均工资

一个需求可以采用很多种不同的方法实现需求

  1. 实现方式——RDD

    val conf: SparkConf = new SparkConf().setAppName("app").setMaster("local[*]")
    val sc: SparkContext = new SparkContext(conf)
    val res: (Int, Int) = sc.makeRDD(List(("zhangsan", 20), ("lisi", 30), ("wangw", 
    40))).map {
     case (name, age) => {
     (age, 1)
     }
    }.reduce {
     (t1, t2) => {
         (t1._1 + t2._1, t1._2 + t2._2)
     } }
    println(res._1/res._2)
    // 关闭连接
    sc.stop() 
    
  2. 实现方式 - 累加器

    class MyAC extends AccumulatorV2[Int,Int]{
        var sum:Int = 0
        var count:Int = 0
        override def isZero: Boolean = {
            return sum ==0 && count == 0
        }
        override def copy(): AccumulatorV2[Int, Int] = {
            val newMyAc = new MyAC
            newMyAc.sum = this.sum
            newMyAc.count = this.count
            newMyAc
        }
        override def reset(): Unit = {
            sum =0
            count = 0
        }
        override def add(v: Int): Unit = {
            sum += v
            count += 1
        }
        override def merge(other: AccumulatorV2[Int, Int]): Unit = {
            other match {
                case o:MyAC=>{
                    sum += o.sum
                    count += o.count
                }
                case _=>
            }
        }
        override def value: Int = sum/count
    }
    
  3. 实现方式 - UDAF - 弱类型

    过时

  4. 实现方式 - UDAF - 强类型

    object Spark03_SparkSQL_UDAF1 {
      def main(args: Array[String]): Unit = {
    
        // TODO 创建SparkSQL的运行环境
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("sparkSQL")
        val spark = SparkSession.builder().config(sparkConf).getOrCreate()
    
        val df = spark.read.json("datas/user.json")
        df.createOrReplaceTempView("user")
    
        spark.udf.register("ageAvg", functions.udaf(new MyAvgUDAF()))
    
        spark.sql("select ageAvg(age) from user").show
    
    
        // TODO 关闭环境
        spark.close()
      }
      /*
       自定义聚合函数类:计算年龄的平均值
       1. 继承org.apache.spark.sql.expressions.Aggregator, 定义泛型
           IN : 输入的数据类型 Long
           BUF : 缓冲区的数据类型 Buff
           OUT : 输出的数据类型 Long
       2. 重写方法(6)
       */
      case class Buff( var total:Long, var count:Long )
      class MyAvgUDAF extends Aggregator[Long, Buff, Long]{
        // z & zero : 初始值或零值
        // 缓冲区的初始化
        override def zero: Buff = {
          Buff(0L,0L)
        }
    
        // 根据输入的数据更新缓冲区的数据
        override def reduce(buff: Buff, in: Long): Buff = {
          buff.total = buff.total + in
          buff.count = buff.count + 1
          buff
        }
    
        // 合并缓冲区
        override def merge(buff1: Buff, buff2: Buff): Buff = {
          buff1.total = buff1.total + buff2.total
          buff1.count = buff1.count + buff2.count
          buff1
        }
    
        //计算结果
        override def finish(buff: Buff): Long = {
          buff.total / buff.count
        }
    
        // 缓冲区的编码操作  自定义的类就叫product
        override def bufferEncoder: Encoder[Buff] = Encoders.product
    
        // 输出的编码操作 scala 存在的类就叫 scalaLong
        override def outputEncoder: Encoder[Long] = Encoders.scalaLong
      }
    }
    

早期spark可以使用dsl 强类型

数据的加载和保存

通用的加载和保存方式

SparkSQL 提供了通用的保存数据和数据加载的方式。这里的通用指的是使用相同的API,根据不同的参数读取和保存不同格式的数据,SparkSQL 默认读取和保存的文件格式为 parquet

加载数据

spark.read.load 是加载数据的通用方法

scala> spark.read.
csv format jdbc json load option options orc parquet schema table text textFile

如果读取不同格式的数据,可以对不同的数据格式进行设定

scala> spark.read.format("…")[.option("…")].load("…")
  • format(“…”):指定保存的数据类型,包括"csv"、“jdbc”、“json”、“orc”、“parquet"和"textFile”。
  • save (“…”):在"csv"、“orc”、"parquet"和"textFile"格式下需要传入保存数据的路径。
  • option(“…”):在"jdbc"格式下需要传入 JDBC 相应参数,url、user、password 和 dbtable保存操作可以使用 SaveMode, 用来指明如何处理数据,使用 mode()方法来设置。有一点很重要: 这些 SaveMode 都是没有加锁的, 也不是原子操作。
Scala/JavaAny LanguageMeaning
SaveMode.ErrorIfExists(default)“error”(default)如果文件已经存在则抛出异常
SaveMode.Append“append”如果文件已经存在则追加
SaveMode.Overwrite“overwrite”如果文件已经存在则覆盖
SaveMode.Ignore“ignore”如果文件已经存在则忽略
df.write.mode("append").json("/opt/module/data/output")

Parquet

Spark SQL 的默认数据源为 Parquet 格式。Parquet 是一种能够有效存储嵌套数据的列式存储格式。

数据源为 Parquet 文件时,Spark SQL 可以方便的执行所有的操作,不需要使用 format。修改配置项 spark.sql.sources.default,可修改默认数据源格式

加载数据

scala> val df = spark.read.load("examples/src/main/resources/users.parquet")
scala> df.show

保存数据

scala> var df = spark.read.json("/opt/module/data/input/people.json")
//保存为 parquet 格式
scala> df.write.mode("append").save("/opt/module/data/output")

JSON

Spark SQL 能够自动推测 JSON 数据集的结构,并将它加载为一个 Dataset[Row]. 可以通过 SparkSession.read.json()去加载 JSON 文件。

注意:Spark 读取的 JSON 文件不是传统的 JSON 文件,每一行都应该是一个 JSON 串。格式如下:

{"name":"Michael"}
{"name":"Andy""age":30}
[{"name":"Justin""age":19},{"name":"Justin""age":19}]
  1. 导入隐式转换

    import spark.implicits._
    
  2. 加载 JSON 文件

    val path = "/opt/module/spark-local/people.json"
    val peopleDF = spark.read.json(path)
    
  3. 创建临时表

    peopleDF.createOrReplaceTempView("people")
    
  4. 数据查询

    val teenagerNamesDF = spark.sql("SELECT name FROM people WHERE age BETWEEN 13 
    AND 19")
    teenagerNamesDF.show()
    

CSV

Spark SQL 可以配置 CSV 文件的列表信息,读取 CSV 文件,CSV 文件的第一行设置为数据列

spark.read.format("csv").option("sep", ";").option("inferSchema","true").option("header", "true").load("data/user.csv")

MySQL

Spark SQL 可以通过 JDBC 从关系型数据库中读取数据的方式创建 DataFrame,通过对DataFrame 一系列的计算后,还可以将数据再写回关系型数据库中。如果使用 spark-shell 操作,可在启动 shell 时指定相关的数据库驱动路径或者将相关的数据库驱动放到 spark 的类路径下

bin/spark-shell --jars mysql-connector-java-5.1.27-bin.jar
  1. 导入依赖

    <dependency>
     <groupId>mysql</groupId>
     <artifactId>mysql-connector-java</artifactId>
     <version>5.1.27</version>
    </dependency>
    
  2. 读取数据

    val conf: SparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkSQL")
    //创建 SparkSession 对象
    val spark: SparkSession = SparkSession.builder().config(conf).getOrCreate()
    import spark.implicits._
    //方式 1:通用的 load 方法读取
    spark.read.format("jdbc")
        .option("url", "jdbc:mysql://linux1:3306/spark-sql")
        .option("driver", "com.mysql.jdbc.Driver")
        .option("user", "root")
        .option("password", "123123")
        .option("dbtable", "user")
        .load().show
    //方式 2:通用的 load 方法读取 参数另一种形式
    spark.read.format("jdbc").options(Map("url"->"jdbc:mysql://linux1:3306/spark-sql?user=root&password=123123","dbtable"->"user","driver"->"com.mysql.jdbc.Driver")).load().show
    //方式 3:使用 jdbc 方法读取
    val props: Properties = new Properties()
    props.setProperty("user", "root")
    props.setProperty("password", "123123")
    val df: DataFrame = spark.read.jdbc("jdbc:mysql://linux1:3306/spark-sql", "user", props)
    df.show
    //释放资源
    spark.stop()
    
  3. 写入数据

    case class User2(name: String, age: Long)
    
    val conf: SparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkSQL")
    //创建 SparkSession 对象
    val spark: SparkSession = SparkSession.builder().config(conf).getOrCreate()
    import spark.implicits._
    val rdd: RDD[User2] = spark.sparkContext.makeRDD(List(User2("lisi", 20), User2("zs", 30)))
    val ds: Dataset[User2] = rdd.toDS
    //方式 1:通用的方式 format 指定写出类型
    ds.write
     .format("jdbc")
     .option("url", "jdbc:mysql://linux1:3306/spark-sql")
     .option("user", "root")
     .option("password", "123123")
     .option("dbtable", "user")
     .mode(SaveMode.Append)
     .save()
    

Hive

Apache Hive 是 Hadoop 上的 SQL 引擎,Spark SQL 编译时可以包含 Hive 支持,也可以不包含。包含 Hive 支持的 Spark SQL 可以支持 Hive 表访问、UDF (用户自定义函数)

若要把 Spark SQL 连接到一个部署好的 Hive 上,你必须把 hive-site.xml 复制到Spark 的配置文件目录中($SPARK_HOME/conf)。

两种方式:内嵌的hive ,外部独立的hive

开发中一般都是外部

外部的Hive

如果想连接外部已经部署好的 Hive,需要通过以下几个步骤:

  • Spark 要接管 Hive 需要把 hive-site.xml 拷贝到 conf/目录下
  • 把 Mysql 的驱动 copy 到 jars/目录下
  • 如果访问不到 hdfs,则需要把 core-site.xml 和 hdfs-site.xml 拷贝到 conf/目录下
  • 重启 spark-shell
scala> spark.sql("show tables").show

代码实现

<dependency>
 <groupId>org.apache.spark</groupId>
 <artifactId>spark-hive_2.12</artifactId>
 <version>3.0.0</version>
</dependency>
<dependency>
 <groupId>org.apache.hive</groupId>
 <artifactId>hive-exec</artifactId>
 <version>1.2.1</version>
</dependency>
<dependency>
 <groupId>mysql</groupId>
 <artifactId>mysql-connector-java</artifactId>
 <version>5.1.27</version>
</dependency>

将 hive-site.xml 文件拷贝到项目的 resources 目录中,代码实现

//创建 SparkSession
val spark: SparkSession = SparkSession
 .builder()
 .enableHiveSupport()
 .master("local[*]")
 .appName("sql")
 .getOrCreate()

注意:在开发工具中创建数据库默认是在本地仓库,通过参数修改数据库仓库的地址:

config(“spark.sql.warehouse.dir”, “hdfs://linux1:8020/user/hive/warehouse”)

如果在执行操作时,出现如下错误:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以代码最前面增加如下代码解决:

System.setProperty("HADOOP_USER_NAME", "root")

此处的 root 改为你们自己的 hadoop 用户名称

Spark Streaming

数据处理的方式

流式(Streaming)数据处理

批量处理(batch)数据处理

数据处理延迟的长短

实时数据处理:毫秒级别

离线数据处理:小时 or 天 级别

spark streaming 基于 spark core的 不是真正的流式

准实时(秒,分钟) 微批次(时间)的数据处理框架

概述

Spark Streaming 使用离散化流(discretized stream)作为抽象表示,叫作 DStream。

DStream 是随时间推移而收到的数据的序列。在内部,每个时间区间收到的数据都作为 RDD 存在,而 DStream 是由这些 RDD 所组成的序列(因此得名“离散化”)。所以简单来将,DStream 就是对 RDD 在实时数据处理场景的一种封装。

数据输入后可以用 Spark 的高度抽象原语如:map、reduce、join、window 等进行运算。

架构

  • 整体

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • sparkstreaming

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

采集器 所以有个周期的概念

背压机制

1.5 版本开始 Spark Streaming 可以动态控制数据接收速率来适配集群数据处理能力。背压机制(即 Spark Streaming Backpressure): 根据JobScheduler 反馈作业的执行信息来动态调整 Receiver 数据接收率。

通过属性“spark.streaming.backpressure.enabled”来控制是否启用 backpressure 机制,默认值false,即不启用

入门

wordcount 案例

依赖

<dependency>
 <groupId>org.apache.spark</groupId>
 <artifactId>spark-streaming_2.12</artifactId>
 <version>3.0.0</version>
</dependency>

编写代码

object SparkStreaming01_WordCoun {
  def main(args: Array[String]): Unit = {

    // TODO 创建环境对象
    // StreamingContext创建时,需要传递两个参数
    // 第一个参数表示环境配置
    val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
    // 第二个参数表示批量处理的周期(采集周期)
    val ssc = new StreamingContext(sparkConf, Seconds(3))

    // TODO 逻辑处理
    // 获取端口数据
    val lines: ReceiverInputDStream[String] = ssc.socketTextStream("localhost", 9999)

    val words = lines.flatMap(_.split(" "))

    val wordToOne = words.map((_,1))

    val wordToCount: DStream[(String, Int)] = wordToOne.reduceByKey(_+_)

    wordToCount.print()

    // 由于SparkStreaming采集器是长期执行的任务,所以不能直接关闭
    // 如果main方法执行完毕,应用程序也会自动结束。所以不能让main执行完毕
    //ssc.stop()
    // 1. 启动采集器
    ssc.start()
    // 2. 等待采集器的关闭
    ssc.awaitTermination()
  }
}
解析

Discretized Stream 是 Spark Streaming 的基础抽象,代表持续性的数据流和经过各种 Spark 原语操作后的结果数据流。在内部实现上,DStream 是一系列连续的 RDD 来表示。每个 RDD 含有一段时间间隔内的数据。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

对数据的操作也是按照 RDD 为单位来进行的

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

计算过程由 Spark Engine 来完成

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Dstream 创建

RDD队列

测试过程中,可以通过使用 ssc.queueStream(queueOfRDDs)来创建 DStream,每一个推送到这个队列中的 RDD,都会作为一个 DStream 处理。

object SparkStreaming02_Queue {

    def main(args: Array[String]): Unit = {

        // TODO 创建环境对象
        // StreamingContext创建时,需要传递两个参数
        // 第一个参数表示环境配置
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        // 第二个参数表示批量处理的周期(采集周期)
        val ssc = new StreamingContext(sparkConf, Seconds(3))
        //3.创建 RDD 队列
        val rddQueue = new mutable.Queue[RDD[Int]]()
        //4.创建 QueueInputDStream
        val inputStream = ssc.queueStream(rddQueue,oneAtATime = false)
        val mappedStream = inputStream.map((_,1))
        val reducedStream = mappedStream.reduceByKey(_ + _)
        reducedStream.print()

        ssc.start()

        for (i <- 1 to 5) {
            rddQueue += ssc.sparkContext.makeRDD(1 to 300, 10)
            Thread.sleep(2000)
        }

        ssc.awaitTermination()
    }
}

自定义数据源

需要继承 Receiver,并实现 onStart、onStop 方法来自定义数据源采集

案例实操

object SparkStreaming03_DIY {

    def main(args: Array[String]): Unit = {

        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))

        // receiverStream
        val messageDS: ReceiverInputDStream[String] = ssc.receiverStream(new MyReceiver())
        messageDS.print()

        ssc.start()
        ssc.awaitTermination()
    }
    /*
    自定义数据采集器
    1. 继承Receiver,定义泛型, 传递参数
    2. 重写方法
     */
    class MyReceiver extends Receiver[String](StorageLevel.MEMORY_ONLY) {
        private var flg = true
        override def onStart(): Unit = {
            new Thread(new Runnable {
                override def run(): Unit = {
                    while ( flg ) {
                        val message = "采集的数据为:" + new Random().nextInt(10).toString
                        store(message)
                        Thread.sleep(500)
                    }
                }
            }).start()
        }

        override def onStop(): Unit = {
            flg = false;
        }
    }
}

kafka数据源(面试,开发重点)

版本选型

ReceiverAPI:需要一个专门的 Executor 去接收数据,然后发送给其他的 Executor 做计算。

存在的问题,接收数据的 Executor 和计算的 Executor 速度会有所不同,特别在接收数据的 Executor速度大于计算的 Executor 速度,会导致计算数据的节点内存溢出。早期版本中提供此方式,当前版本不适用

DirectAPI:是由计算的 Executor 来主动消费 Kafka 的数据,速度由自身控制。

Kafka 0-10 Direct 模式

采集节点位置策略 采集 计算 首选

依赖

<dependency>
    <groupId>org.apache.spark</groupId>
    <artifactId>spark-streaming-kafka-0-10_2.12</artifactId>
    <version>3.0.0</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.10.1</version>
</dependency>

编写代码

object SparkStreaming04_Kafka {
    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))

        val kafkaPara: Map[String, Object] = Map[String, Object](
            ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG ->"linux1:9092,linux2:9092,linux3:9092",
            ConsumerConfig.GROUP_ID_CONFIG -> "atguigu","key.deserializer" -> "org.apache.kafka.common.serialization.StringDeserializer","value.deserializer" ->"org.apache.kafka.common.serialization.StringDeserializer"
        )

        val kafkaDataDS: InputDStream[ConsumerRecord[String, String]] = KafkaUtils.createDirectStream[String, String](
            ssc,
            // 采集位置策略
            LocationStrategies.PreferConsistent,
            ConsumerStrategies.Subscribe[String, String](Set("atguiguNew"), kafkaPara)
        )
        kafkaDataDS.map(_.value()).print()


        ssc.start()
        ssc.awaitTermination()
    }
}

DStream 转换

DStream 上的操作与 RDD 的类似,分为 Transformations(转换)和 Output Operations(输出)两种,此外转换操作中还有一些比较特殊的原语,如:updateStateByKey()、transform()以及各种 Window 相关的原语

无状态转化操作

无状态转化操作就是把简单的 RDD 转化操作应用到每个批次上,也就是转化 DStream 中的每一个 RDD。

注意,针对键值对的 DStream 转化操作(比如reduceByKey())要添加 import StreamingContext._才能在 Scala 中使用

需要记住的是,尽管这些函数看起来像作用在整个流上一样,但事实上每个 DStream 在内部是由许多 RDD(批次)组成,且无状态转化操作是分别应用到每个 RDD 上的。

例如:reduceByKey()会归约每个时间区间中的数据,但不会归约不同区间之间的数据

tramsform

Transform 允许 DStream 上执行任意的 RDD-to-RDD 函数。即使这些函数并没有在 DStream的 API 中暴露出来,通过该函数可以方便的扩展 Spark API。该函数每一批次调度一次。其实也就是对 DStream 中的 RDD 应用转换

object SparkStreaming06_State_Transform {
    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))

        val lines = ssc.socketTextStream("localhost", 9999)

        // transform方法可以将底层RDD获取到后进行操作
        // 1. DStream功能不完善
        // 2. 需要代码周期性的执行

        // Code : Driver端
        val newDS: DStream[String] = lines.transform(
            rdd => {
                // Code : Driver端,(周期性执行)
                rdd.map(
                    str => {
                        // Code : Executor端  分布式 每个节点执行
                        str
                    }
                )
            }
        )
        // Code : Driver端
        val newDS1: DStream[String] = lines.map(
            data => {
                // Code : Executor端
                data
            }
        )

        ssc.start()
        ssc.awaitTermination()
    }
}
join

两个流之间的 join 需要两个流的批次大小一致,这样才能做到同时触发计算。计算过程就是对当前批次的两个流中各自的 RDD 进行 join,与两个 RDD 的 join 效果相同。

object SparkStreaming06_State_Join {

    def main(args: Array[String]): Unit = {

        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(5))

        val data9999 = ssc.socketTextStream("localhost", 9999)
        val data8888 = ssc.socketTextStream("localhost", 8888)

        val map9999: DStream[(String, Int)] = data9999.map((_,9))
        val map8888: DStream[(String, Int)] = data8888.map((_,8))

        // 所谓的DStream的Join操作,其实就是两个RDD的join
        val joinDS: DStream[(String, (Int, Int))] = map9999.join(map8888)

        joinDS.print()

        ssc.start()
        ssc.awaitTermination()
    }

}

有状态转化操作

UpdateStateByKey

UpdateStateByKey 原语用于记录历史记录,有时,我们需要在 DStream 中跨批次维护状态(例如流计算中累加 wordcount)。针对这种情况,updateStateByKey()为我们提供了对一个状态变量的访问,用于键值对形式的 DStream。给定一个由(键,事件)对构成的 DStream,并传递一个指定如何根据新的事件更新每个键对应状态的函数,它可以构建出一个新的 DStream,其内部数据为(键,状态) 对。

updateStateByKey() 的结果会是一个新的 DStream,其内部的 RDD 序列是由每个时间区间对应的(键,状态)对组成的。

object SparkStreaming05_State {

    def main(args: Array[String]): Unit = {

        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))
        ssc.checkpoint("cp")

        // 无状态数据操作,只对当前的采集周期内的数据进行处理
        // 在某些场合下,需要保留数据统计结果(状态),实现数据的汇总
        // 使用有状态操作时,需要设定检查点路径
        val datas = ssc.socketTextStream("localhost", 9999)

        val wordToOne = datas.map((_,1))

        //val wordToCount = wordToOne.reduceByKey(_+_)

        // updateStateByKey:根据key对数据的状态进行更新
        // 传递的参数中含有两个值
        // 第一个值表示相同的key的value数据
        // 第二个值表示缓存区相同key的value数据
        val state = wordToOne.updateStateByKey(
            ( seq:Seq[Int], buff:Option[Int] ) => {
                val newCount = buff.getOrElse(0) + seq.sum
                Option(newCount)
            }
        )

        state.print()

        ssc.start()
        ssc.awaitTermination()
    }

}
windowOperations

移动 一个采集周期一个采集周期的移动

Window Operations 可以设置窗口的大小和滑动窗口的间隔来动态的获取当前 Steaming 的允许状态。所有基于窗口的操作都需要两个参数,分别为窗口时长以及滑动步长

窗口时长:计算内容的时间范围;

滑动步长:隔多久触发一次计算

注意:这两者都必须为采集周期大小的整数倍

object SparkStreaming06_State_Window {
    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))

        val lines = ssc.socketTextStream("localhost", 9999)
        val wordToOne = lines.map((_,1))

        // 窗口的范围应该是采集周期的整数倍
        // 窗口可以滑动的,但是默认情况下,一个采集周期进行滑动
        // 这样的话,可能会出现重复数据的计算,为了避免这种情况,可以改变滑动的滑动(步长)
        val windowDS: DStream[(String, Int)] = wordToOne.window(Seconds(6), Seconds(6))

        val wordToCount = windowDS.reduceByKey(_+_)

        wordToCount.print()

        ssc.start()
        ssc.awaitTermination()
    }
}

方法

  • window(windowLength, slideInterval)
  • countByWindow(windowLength, slideInterval)
  • reduceByWindow(func, windowLength, slideInterval)
  • reduceByKeyAndWindow(func, windowLength, slideInterval, [numTasks])
  • reduceByKeyAndWindow(func, invFunc, windowLength, slideInterval, [numTasks])
object SparkStreaming06_State_Window1 {
    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))
        ssc.checkpoint("cp")

        val lines = ssc.socketTextStream("localhost", 9999)
        val wordToOne = lines.map((_,1))

        // reduceByKeyAndWindow : 当窗口范围比较大,但是滑动幅度比较小,那么可以采用增加数据和删除数据的方式
        // 无需重复计算,提升性能。
        val windowDS: DStream[(String, Int)] =
            wordToOne.reduceByKeyAndWindow(
                (x:Int, y:Int) => { x + y},
                (x:Int, y:Int) => {x - y},
                Seconds(9), Seconds(3))

        windowDS.print()

        ssc.start()
        ssc.awaitTermination()
    }
}

DStream 输出

输出操作指定了对流数据经转化操作得到的数据所要执行的操作(例如把结果推入外部数据库或输出到屏幕上)。与 RDD 中的惰性求值类似,如果一个 DStream 及其派生出的 DStream 都没有被执行输出操作,那么这些 DStream 就都不会被求值。如果 StreamingContext 中没有设定输出操作,整个 context 就都不会启动。

输出操作如下

  • print():——在运行流程序的驱动结点上打印 DStream 中每一批次数据的最开始 10 个元素。这

    用于开发和调试。

  • saveAsTextFiles(prefix, [suffix]):

  • saveAsObjectFiles(prefix, [suffix]):

  • saveAsHadoopFiles(prefix, [suffix]):

  • foreachRDD(func):

注意:

  1. 连接不能写在 driver 层面(序列化)
  2. 如果写在 foreach 则每个 RDD 中的每一条数据都创建,得不偿失;
  3. 增加 foreachPartition,在分区创建(获取)。

优雅的关闭

强制关闭 thread,stop() 数据冲突 安全问题 没运行 double 64位分两步 容易打断出问题

流式任务需要 7*24 小时执行,但是有时涉及到升级代码需要主动停止程序,但是分

布式程序,没办法做到一个个进程去杀死,所有配置优雅的关闭就显得至关重要了。

object SparkStreaming08_Close {

    def main(args: Array[String]): Unit = {

        /*
           线程的关闭:
           val thread = new Thread()
           thread.start()

           thread.stop(); // 强制关闭

         */

        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))

        val lines = ssc.socketTextStream("localhost", 9999)
        val wordToOne = lines.map((_,1))

        wordToOne.print()

        ssc.start()

        // 如果想要关闭采集器,那么需要创建新的线程
        // 而且需要在第三方程序中增加关闭状态
        new Thread(
            new Runnable {
                override def run(): Unit = {
                    // 优雅地关闭
                    // 计算节点不在接收新的数据,而是将现有的数据处理完毕,然后关闭
                    // Mysql : Table(stopSpark) => Row => data
                    // Redis : Data(K-V)
                    // ZK    : /stopSpark
                    // HDFS  : /stopSpark
                    /*
                    while ( true ) {
                        if (true) {
                            // 获取SparkStreaming状态
                            val state: StreamingContextState = ssc.getState()
                            if ( state == StreamingContextState.ACTIVE ) {
                                ssc.stop(true, true)
                            }
                        }
                        Thread.sleep(5000)
                    }
                     */

                    Thread.sleep(5000)
                    val state: StreamingContextState = ssc.getState()
                    if ( state == StreamingContextState.ACTIVE ) {
                        // 第二个开启
                        ssc.stop(true, true)
                    }
                    System.exit(0)
                }
            }
        ).start()

        ssc.awaitTermination() // block 阻塞main线程


    }

}

文件恢复

object SparkStreaming09_Resume {
    def main(args: Array[String]): Unit = {
        val ssc = StreamingContext.getActiveOrCreate("cp", ()=>{
            val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
            val ssc = new StreamingContext(sparkConf, Seconds(3))

            val lines = ssc.socketTextStream("localhost", 9999)
            val wordToOne = lines.map((_,1))

            wordToOne.print()

            ssc
        })

        ssc.checkpoint("cp")

        ssc.start()
        ssc.awaitTermination() // block 阻塞main线程
    }
}

案例实操

注意:

  1. 连接不能写在 driver 层面(序列化)
  2. 如果写在 foreach 则每个 RDD 中的每一条数据都创建,得不偿失;
  3. 增加 foreachPartition,在分区创建(获取)。
object SparkStreaming11_Req1_BlackList {

    def main(args: Array[String]): Unit = {

        val sparkConf = new SparkConf().setMaster("local[*]").setAppName("SparkStreaming")
        val ssc = new StreamingContext(sparkConf, Seconds(3))

        val kafkaPara: Map[String, Object] = Map[String, Object](
            ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG -> "linux1:9092,linux2:9092,linux3:9092",
            ConsumerConfig.GROUP_ID_CONFIG -> "atguigu",
            "key.deserializer" -> "org.apache.kafka.common.serialization.StringDeserializer",
            "value.deserializer" -> "org.apache.kafka.common.serialization.StringDeserializer"
        )

        val kafkaDataDS: InputDStream[ConsumerRecord[String, String]] = KafkaUtils.createDirectStream[String, String](
            ssc,
            LocationStrategies.PreferConsistent,
            ConsumerStrategies.Subscribe[String, String](Set("atguiguNew"), kafkaPara)
        )
        val adClickData = kafkaDataDS.map(
            kafkaData => {
                val data = kafkaData.value()
                val datas = data.split(" ")
                AdClickData(datas(0),datas(1),datas(2),datas(3),datas(4))
            }
        )

        val ds = adClickData.transform(
            rdd => {
                // TODO 通过JDBC周期性获取黑名单数据
                val blackList = ListBuffer[String]()

                val conn = JDBCUtil.getConnection
                val pstat = conn.prepareStatement("select userid from black_list")

                val rs: ResultSet = pstat.executeQuery()
                while ( rs.next() ) {
                    blackList.append(rs.getString(1))
                }

                rs.close()
                pstat.close()
                conn.close()

                // TODO 判断点击用户是否在黑名单中
                val filterRDD = rdd.filter(
                    data => {
                        !blackList.contains(data.user)
                    }
                )

                // TODO 如果用户不在黑名单中,那么进行统计数量(每个采集周期)
                filterRDD.map(
                    data => {
                        val sdf = new SimpleDateFormat("yyyy-MM-dd")
                        val day = sdf.format(new java.util.Date( data.ts.toLong ))
                        val user = data.user
                        val ad = data.ad

                        (( day, user, ad ), 1) // (word, count)
                    }
                ).reduceByKey(_+_)
            }
        )

        ds.foreachRDD(
            rdd => {
                rdd.foreach{
                    case ( ( day, user, ad ), count ) => {
                        println(s"${day} ${user} ${ad} ${count}")
                        if ( count >= 30 ) {
                            // TODO 如果统计数量超过点击阈值(30),那么将用户拉入到黑名单
                            val conn = JDBCUtil.getConnection
                            // 防止多次插入userid
                            val pstat = conn.prepareStatement(
                                """
                                  |insert into black_list (userid) values (?)
                                  |on DUPLICATE KEY
                                  |UPDATE userid = ?
                                """.stripMargin)
                            // ? 传参  stripMargin 多行
                            pstat.setString(1, user)
                            pstat.setString(2, user)
                            pstat.executeUpdate()
                            pstat.close()
                            conn.close()
                        } else {
                            // TODO 如果没有超过阈值,那么需要将当天的广告点击数量进行更新。
                            val conn = JDBCUtil.getConnection
                            val pstat = conn.prepareStatement(
                                """
                                  | select
                                  |     *
                                  | from user_ad_count
                                  | where dt = ? and userid = ? and adid = ?
                                """.stripMargin)

                            pstat.setString(1, day)
                            pstat.setString(2, user)
                            pstat.setString(3, ad)
                            val rs = pstat.executeQuery()
                            // 查询统计表数据
                            if ( rs.next() ) {
                                // 如果存在数据,那么更新
                                val pstat1 = conn.prepareStatement(
                                    """
                                      | update user_ad_count
                                      | set count = count + ?
                                      | where dt = ? and userid = ? and adid = ?
                                    """.stripMargin)
                                pstat1.setInt(1, count)
                                pstat1.setString(2, day)
                                pstat1.setString(3, user)
                                pstat1.setString(4, ad)
                                pstat1.executeUpdate()
                                pstat1.close()
                                // TODO 判断更新后的点击数据是否超过阈值,如果超过,那么将用户拉入到黑名单。
                                val pstat2 = conn.prepareStatement(
                                    """
                                      |select
                                      |    *
                                      |from user_ad_count
                                      |where dt = ? and userid = ? and adid = ? and count >= 30
                                    """.stripMargin)
                                pstat2.setString(1, day)
                                pstat2.setString(2, user)
                                pstat2.setString(3, ad)
                                val rs2 = pstat2.executeQuery()
                                if ( rs2.next() ) {
                                    val pstat3 = conn.prepareStatement(
                                        """
                                          |insert into black_list (userid) values (?)
                                          |on DUPLICATE KEY
                                          |UPDATE userid = ?
                                        """.stripMargin)
                                    pstat3.setString(1, user)
                                    pstat3.setString(2, user)
                                    pstat3.executeUpdate()
                                    pstat3.close()
                                }

                                rs2.close()
                                pstat2.close()
                            } else {
                                // 如果不存在数据,那么新增
                                val pstat1 = conn.prepareStatement(
                                    """
                                      | insert into user_ad_count ( dt, userid, adid, count ) values ( ?, ?, ?, ? )
                                    """.stripMargin)

                                pstat1.setString(1, day)
                                pstat1.setString(2, user)
                                pstat1.setString(3, ad)
                                pstat1.setInt(4, count)
                                pstat1.executeUpdate()
                                pstat1.close()
                            }

                            rs.close()
                            pstat.close()
                            conn.close()
                        }
                    }
                }
            }
        )

        ssc.start()
        ssc.awaitTermination()
    }
    // 广告点击数据
    case class AdClickData( ts:String, area:String, city:String, user:String, ad:String )

}
object SparkStreaming11_Req1_BlackList1 {

    def main(args: Array[String]): Unit = {
        ds.foreachRDD(
            rdd => {
                // rdd. foreach方法会每一条数据创建连接
                // foreach方法是RDD的算子,算子之外的代码是在Driver端执行,算子内的代码是在Executor端执行
                // 这样就会涉及闭包操作,Driver端的数据就需要传递到Executor端,需要将数据进行序列化
                // 数据库的连接对象是不能序列化的。

                // RDD提供了一个算子可以有效提升效率 : foreachPartition
                // 可以一个分区创建一个连接对象,这样可以大幅度减少连接对象的数量,提升效率
                rdd.foreachPartition(iter => {
                        val conn = JDBCUtil.getConnection
                        iter.foreach{
                            case ( ( day, user, ad ), count ) => {

                            }
                        }
                        conn.close()
                    }
                )
    }
    // 广告点击数据
    case class AdClickData( ts:String, area:String, city:String, user:String, ad:String )

}

Spark 内核和优化

Spark 内核泛指 Spark 的核心运行机制,包括 Spark 核心组件的运行机制、Spark 任务调度机制、Spark 内存管理机制、Spark 核心功能的运行原理等,熟练掌握 Spark 内核原理,能够帮助我们更好地完成 Spark 代码设计,并能够帮助我们准确锁定项目运行过程中出现的问题的症结所在

spark 核心组件

Driver

Spark 驱动器节点,用于执行 Spark 任务中的 main 方法,负责实际代码的执行工作。

Driver 在 Spark 作业执行时主要负责:

  • 将用户程序转化为作业(Job);
  • 在 Executor 之间调度任务(Task);
  • 跟踪 Executor 的执行情况;
  • 通过 UI 展示查询运行情况

Executor

Spark Executor 对象是负责在 Spark 作业中运行具体任务,任务彼此之间相互独立。Spark 应用启动时,ExecutorBackend 节点被同时启动,并且始终伴随着整个 Spark 应用的生命周期而存在。如果有 ExecutorBackend 节点发生了故障或崩溃,Spark 应用也可以继续执行,会将出错节点上的任务调度到其他 Executor 节点上继续运行

Executor 有两个核心功能:

  1. 负责运行组成 Spark 应用的任务,并将结果返回给驱动器(Driver);
  2. 它们通过自身的块管理器(Block Manager)为用户程序中要求缓存的 RDD 提供内存式存储。RDD 是直接缓存在 Executor 进程内的,因此任务可以在运行时充分利用缓存数据加速运算

运行流程

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  1. 任务提交后,都会先启动 Driver 程序
  2. 随后 Driver 向集群管理器注册应用程序
  3. 之后集群管理器根据此任务的配置文件分配 Executor 并启动
  4. Driver 开始执行 main 函数,Spark 查询为懒执行,当执行到 Action 算子时开始反向推算,根据宽依赖进行 Stage 的划分,随后每一个 Stage 对应一个 Taskset,Taskset 中有多个 Task,查找可用资源 Executor 进行调度
  5. 根据本地化原则,Task 会被分发到指定的 Executor 去执行,在任务执行的过程中,Executor 也会不断与 Driver 进行通信,报告任务运行情况

Spark 部署模式

Spark 支持多种集群管理器(Cluster Manager),分别为

  • Standalone
  • Hadoop YARN
  • Apache Mesos
  • K8S

YARN 模式

YARN Cluster模式
  • 执行脚本提交任务,实际是启动一个 SparkSubmit 的 JVM 进程;
  • SparkSubmit 类中的 main 方法反射调用 YarnClusterApplication 的 main 方法;
  • YarnClusterApplication 创建 Yarn 客户端,然后向 Yarn 服务器发送执行指令:bin/java ApplicationMaster;
  • Yarn 框架收到指令后会在指定的 NM 中启动 ApplicationMaster;
  • ApplicationMaster 启动 Driver 线程,执行用户的作业;
  • AM 向 RM 注册,申请资源;
  • 获取资源后 AM 向 NM 发送指令:bin/java YarnCoarseGrainedExecutorBackend;
  • CoarseGrainedExecutorBackend 进程会接收消息,跟 Driver 通信,注册已经启动的Executor;然后启动计算对象 Executor 等待接收任务
  • Driver 线程继续执行完成作业的调度和任务的执行。
  • Driver 分配任务并监控任务的执行

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

注意:SparkSubmit、ApplicationMaster 和 CoarseGrainedExecutorBackend 是独立的进程;**Driver是独立的线程;**Executor 和 YarnClusterApplication 是对象。

YARN Client模式

注意:

SparkSubmit、ApplicationMaster 和 YarnCoarseGrainedExecutorBackend 是独立的进程;

Executor 和 Driver 是对象。

Yarn 集群模式和客户端模式主要区别在于Driver程序执行的位置

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 执行脚本提交任务,实际是启动一个SparkSubmit的JVM进程

  • SparkSubmit类中的main方法反射调用用户代码的main方法

  • 启动 Driver 线程,执行用户的作业,并创建 ScheduleBackend;

  • YarnClientSchedulerBackend 向 RM 发送指令:bin/java ExecutorLauncher;

  • Yarn 框架收到指令后会在指定的 NM 中启动 ExecutorLauncher(实际上还是调用ApplicationMaster 的 main 方法)

  • AM 向 RM 注册,申请资源;

  • 获取资源后 AM 向 NM 发送指令:bin/java CoarseGrainedExecutorBackend

  • CoarseGrainedExecutorBackend 进程会接收消息,跟 Driver 通信,注册已经启动的

    Executor;然后启动计算对象 Executor 等待接收任务

  • Driver 分配任务并监控任务的执行。

Spark 通信架构概述

Spark2.x 版本使用 Netty 通讯框架作为内部通讯组件

Spark 通讯框架中各个组件(Client/Master/Worker)可以认为是一个个独立的实体,**各个实体之间通过消息来进行通信。**具体各个组件之间的关系图如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Endpoint(Client/Master/Worker)有 1 个 InBox 和 N 个 OutBox(N>=1,N 取决于当前 Endpoint与多少其他的 Endpoint 进行通信,一个与其通讯的其他 Endpoint 对应一个 OutBox),Endpoint接收到的消息被写入 InBox,发送出去的消息写入 OutBox 并被发送到其他 Endpoint 的 InBox中。

Spark通信终端

Driver

class DriverEndpoint extends IsolatedRpcEndpoint

Executor

class CoarseGrainedExecutorBackend extends IsolatedRpcEndpoint

解析

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Spark 任务调度机制

在生产环境下,Spark 集群的部署方式一般为 YARN-Cluster 模式,之后的内核分析内容中我们默认集群的部署方式为 YARN-Cluster 模式。

Driver 线程主要是初始化 SparkContext 对象 , 准备运行所需的上下文 , 然后一方面保持与ApplicationMaster 的 RPC 连接,通过 ApplicationMaster 申请资源,另一方面根据用户业务逻辑开始调度任务,将任务下发到已有的空闲 Executor 上。

当 ResourceManager 向 ApplicationMaster 返回 Container 资源时,ApplicationMaster 就尝试在对应的 Container 上启动 Executor 进程,Executor 进程起来后,会向 Driver 反向注册,注册成功后保持与 Driver 的心跳,同时等待 Driver 分发任务,当分发的任务执行完毕后,将任务状态上报给 Driver。

概述

  • Job 是以 Action 方法为界,遇到一个 Action 方法则触发一个 Job;
  • Stage 是 Job 的子集,以 RDD 宽依赖(即 Shuffle)为界,遇到 Shuffle 做一次划分;
  • Task 是 Stage 的子集,以并行度(分区数)来衡量,分区数是多少,则有多少个 task。

Spark 的任务调度总体来说分两路进行,一路是 Stage 级的调度,一路是 Task 级的调度,总体调度流程如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Spark RDD 通过其 Transactions 操作,形成了 RDD 血缘(依赖)关系图,即 DAG,最后通过 Action 的调用,触发 Job 并调度执行,执行过程中会创建两个调度器:DAGScheduler和 TaskScheduler。

  • DAGScheduler 负责 Stage 级的调度,主要是将 job 切分成若干 Stages,并将每个 Stage打包成 TaskSet 交给 TaskScheduler 调度。
  • **TaskScheduler 负责 Task 级的调度,**将 DAGScheduler 给过来的 TaskSet 按照指定的调度策略分发到 Executor 上执行,调度过程中 SchedulerBackend 负责提供可用资源,其中SchedulerBackend 有多种实现,分别对接不同的资源管理系统

Spark Stage 调度

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • Job 由最终的 RDD 和 Action 方法封装而成;

  • SparkContext 将 Job 交给 DAGScheduler 提交,它会根据 RDD 的血缘关系构成的 DAG进行切分,将一个 Job 划分为若干 Stages,具体划分策略是,由最终的 RDD 不断通过依赖回溯判断父依赖是否是宽依赖,即以 Shuffle 为界,划分 Stage,窄依赖的 RDD 之间被划分到同一个 Stage 中,可以进行 pipeline 式的计算**。划分的 Stages 分两类,一类叫做 ResultStage,为 DAG 最下游的 Stage,由 Action 方法决定,另一类叫做ShuffleMapStage,为下游 Stage 准备数据,**下面看一个简单的例子 WordCount。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Job 由 saveAsTextFile 触发,该 Job 由 RDD-3 和 saveAsTextFile 方法组成,根据 RDD 之间的依赖关系从 RDD-3 开始回溯搜索,直到没有依赖的 RDD-0,在回溯搜索过程中,RDD- 3 依赖 RDD-2,并且是宽依赖,所以在 RDD-2 和 RDD-3 之间划分 Stage,RDD-3 被划到最后一个 Stage,即 ResultStage 中,RDD-2 依赖 RDD-1,RDD-1 依赖 RDD-0,这些依赖都是窄依赖,所以将 RDD-0、RDD-1 和 RDD-2 划分到同一个 Stage,形成 pipeline 操作,。即ShuffleMapStage 中,实际执行的时候,数据记录会一气呵成地执行 RDD-0 到 RDD-2 的转化。不难看出,其本质上是一个深度优先搜索(Depth First Search)算法

一个 Stage 是否被提交,需要判断它的父 Stage 是否执行,只有在父 Stage 执行完毕才能提交当前 Stage,如果一个 Stage 没有父 Stage,那么从该 Stage 开始提交。Stage 提交时会将 Task 信息(分区信息以及方法等)序列化并被打包成 TaskSet 交给 TaskScheduler,一个Partition 对应一个 Task,另一方面 TaskScheduler 会监控 Stage 的运行状态,只有 Executor 丢失或者 Task 由于 Fetch 失败才需要重新提交失败的 Stage 以调度运行失败的任务,其他类型的 Task 失败会在 TaskScheduler 的调度过程中重试

Spark Task 调度

Spark Task 的调度是由 TaskScheduler 来完成,由前文可知,DAGScheduler 将 Stage 打包到交给 TaskScheTaskSetduler,TaskScheduler 会将 TaskSet 封装为 TaskSetManager 加入到调度队列中

TaskSetManager 负责监控管理 同一 个 Stage 中的 Tasks, TaskScheduler 就是以TaskSetManager 为单元来调度任务。

前面也提到,TaskScheduler 初始化后会启动 SchedulerBackend,它负责跟外界打交道,接收 Executor 的注册信息,并维护 Executor 的状态,所以说 SchedulerBackend 是管“粮食”的,同时它在启动后会定期地去“询问”TaskScheduler 有没有任务要运行,也就是说,它会定期地“问”TaskScheduler“我有这么余粮,你要不要啊”,TaskScheduler 在 SchedulerBackend“问”它的时候,会从调度队列中按照指定的调度策略选择 TaskSetManager 去调度运行,大致方法调用流程如下图所示

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

调度策略

TaskScheduler 支持两种调度策略,一种是 FIFO,也是默认的调度策略,另一种是 FAIR

在 TaskScheduler 初始化过程中会实例化 rootPool,表示树的根节点,是 Pool 类型。

FIFO
FAIR

排序过程的比较是基于 Fair-share 来比较的,每个要排序的对象包含三个属性: runningTasks值(正在运行的Task数)、minShare值、weight值,比较时会综合考量runningTasks值,minShare 值以及 weight 值

注意,minShare、weight 的值均在公平调度配置文件 fairscheduler.xml 中被指定,调度池在构建阶段会读取此文件的相关配置

  1. 如果A对象的runningTasks大于它的minShare,B对象的runningTasks小于它的minShare,那么 B 排在 A 前面;(runningTasks 比 minShare 小的先执行)
  2. 如果 A、B 对象的 runningTasks 都小于它们的 minShare,那么就比较 runningTasks 与minShare 的比值(minShare 使用率),谁小谁排前面;(minShare 使用率低的先执行)
  3. 如果 A、B 对象的 runningTasks 都大于它们的 minShare,那么就比较 runningTasks 与 weight 的比值(权重使用率),谁小谁排前面。(权重使用率低的先执行)
  4. 如果上述比较均相等,则比较名字

Spark Shuffle

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Shuffle MapStage 与 ResultStage

ShuffleMapStage 的结束伴随着 shuffle 文件的写磁盘。

ResultStage 基本上对应代码中的 action 算子,即将一个函数应用在 RDD 的各个 partition的数据集上,意味着一个 job 的运行结束

HashShuffle 解析

每个 Executor 只有 1 个 CPU core,也就是说,无论这个 Executor 上分配多少个 task 线程,同一时间都只能执行一个 task 线程。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

优化后的hashShuffle

优化的 HashShuffle 过程就是启用合并机制,合并机制就是复用 buffer,开启合并机制的配置是 spark.shuffle.consolidateFiles。该参数默认值为 false,将其设置为 true 即可开启优化机制

每个shuffleFileGroup会对应一批磁盘文件,磁盘文件的数量与下游stage的task数量是相同的。

这里还是有 4 个 Tasks,数据类别还是分成 3 种类型,因为 Hash 算法会根据你的 Key 进行分类,在同一个进程中,无论是有多少过 Task,都会把同样的 Key 放在同一个 Buffer里,然后把 Buffer 中的数据写入以 Core 数量为单位的本地文件中,(一个 Core 只有一种类型的 Key 的数据),每 1 个 Task 所在的进程中,分别写入共同进程中的 3 份本地文件,这里有 4 个 Mapper Tasks,所以总共输出是 2 个 Cores x 3 个分类文件 = 6 个本地小文件。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

SortShuffle

后面就引入了 Sort Based Shuffle, map端的任务会按照Partition id以及key对记录进行排序。同时将全部结果写到一个数据文件中,同时生成一个索引文件

普通

在该模式下,数据会先写入一个数据结构,reduceByKey 写入 Map,一边通过 Map 局部聚合,一遍写入内存。Join 算子写入 ArrayList 直接写入内存中。然后需要判断是否达到阈值,如果达到就会将内存数据结构的数据写入到磁盘,清空内存数据结构

先根据 key 进行排序,排序过后的数据,会分批写入到磁盘文件中。默认批次为 10000 条,数据会以每批一万条写入到磁盘文件。写入磁盘文件通过缓冲区溢写的方式,每次溢写都会产生一个磁盘文件,也就是说一个 Task 过程会产生多个临时文件

在这里插入图片描述

最后在每个 Task 中,将所有的临时文件合并,这就是 merge 过程,此过程将所有临时文件读取出来,一次写入到最终文件。意味着一个 Task 的所有数据都在这一个文件中。同时单独写一份索引文件,标识下游各个Task的数据在文件中的索引,start offset和end offset。

bypass
  • shuffle map task数量小于spark.shuffle.sort.bypassMergeThreshold=200参数的值
  • 不是聚合类的 shuffle 算子(比如 reduceByKey)。

而该机制与普通 SortShuffleManager 运行机制的不同在于:不会进行排序。也就是说,启用该机制的最大好处在于,shuffle write 过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销

该过程的磁盘写机制其实跟未经优化的 HashShuffleManager 是一模一样的,因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的 HashShuffleManager 来说,shuffle read 的性能会更好

在这里插入图片描述

Spark 内存管理

堆内和堆外内存规划

作为一个 JVM 进程,Executor 的内存管理建立在 JVM 的内存管理之上,Spark 对 JVM的堆内(On-heap)空间进行了更为详细的分配,以充分利用内存。同时,Spark 引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,进一步优化了内存的使用。堆内内存受到 JVM 统一管理,堆外内存是直接向操作系统进行内存的申请和释放

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

堆内内存

堆内内存的大小 , 由 Spark 应 用 程 序 启 动 时 的 – executor-memory 或spark.executor.memory 参数配置。

  1. Executor 内运行的并发任务共享 JVM 堆内内存,这些任务在缓存 RDD 数据和广播(Broadcast)数据时占用的内存被规划为存储(Storage)内存
  2. 而这些任务在执行 Shuffle 时占用的内存被规划为执行(Execution)内存
  3. 剩余的部分不做特殊规划,那些 Spark 内部的对象实例,或者用户定义的 Spark 应用程序中的对象实例,均

占用剩余的空间。

Spark 对堆内内存的管理是一种逻辑上的”规划式”的管理,因为对象实例占用内存的申请和释放都由 JVM 完成,Spark 只能在申请后和释放前记录这些内存,我们来看其具体流程:

JVM 的对象可以以序列化的方式存储,序列化的过程是将对象转换为二进制字节流,本质上可以理解为将非连续空间的链式存储转化为连续空间或块存储,在访问时则需要进行序列化的逆过程——反序列化,将字节流转化为对象,序列化的方式可以节省存储空间,但增加了存储和读取时候的计算开销。

对于 Spark 中序列化的对象,由于是字节流的形式,其占用的内存大小可直接计算,而对于非序列化的对象,其占用的内存是通过周期性地采样近似估算而得,即并不是每次新增的数据项都会计算一次占用的内存大小,这种方法降低了时间开销但是有可能误差较大,导致某一时刻的实际内存有可能远远超出预期。此外,在被 Spark 标记为释放的对象实例,很有可能在实际上并没有被 JVM 回收,导致实际可用的内存小于 Spark 记录的可用内存。所以 Spark 并不能准确记录实际可用的堆内内存,从而也就无法完全避免内存溢出(OOM, Out of Memory)的异常。

虽然不能精准控制堆内内存的申请和释放,但 Spark 通过对存储内存和执行内存各自独立的规划管理,可以决定是否要在存储内存里缓存新的 RDD,以及是否为新的任务分配执行内存,在一定程度上可以提升内存的利用率,减少异常的出现

堆外内存

为了进一步优化内存的使用以及提高 Shuffle 时排序的效率,Spark 引入了堆外(Off heap)内存,使之可以直接在工作节点的系统内存中开辟空间,存储经过序列化的二进制数据。

堆外内存意味着把内存对象分配在 Java 虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机)。这样做的结果就是能保持一个较小的堆,以减少垃圾收集对应用的影响。

利用 JDK Unsafe API,Spark 可以直接操作系统堆外内存,减少了不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能。堆外内存可以被精确地申请和释放(堆外内存之所以能够被精确的申请和释放,是由于内存的申请和释放不再通过 JVM 机制,而是直接向操作系统申请,JVM 对于内存的清理是无法准确指定时间点的,因此无法实现精确的释放),而且序列化的数据占用的空间可以被精确计算,所以相比堆内内存来说降低了管理的难度,也降低了误差

在默认情况下堆外内存并不启用,可通过配置 spark.memory.offHeap.enabled 参数启用,并由 spark.memory.offHeap.size 参数设定堆外空间的大小。除了没有 other 空间,堆外内存与堆内内存的划分方式相同,所有运行中的并发任务共享存储内存和执行内存

内存空间分配

静态内存管理

在 Spark 最初采用的静态内存管理机制下,存储内存、执行内存和其他内存的大小在Spark 应用程序运行期间均为固定的,但用户可以应用程序启动前进行配置

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

堆外的空间分配较为简单,只有存储内存和执行内存,如下图所示。可用的执行内存和存储内存占用的空间大小直接由参数 spark.memory.storageFraction 决定,由于堆外内存占用的空间可以被精确计算,所以无需再设定保险区域。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

统一内存管理

与静态内存管理的区别在于存储内存和执行内存共享同一块空间,可以动态占用对方的空闲区域

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

统一内存管理的堆外内存结构如下图所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

其中最重要的优化在于动态占用机制,其规则如下:

  1. 设定基本的存储内存和执行内存区域(spark.storage.storageFraction 参数),该设定确定了双方各自拥有的空间的范围;
  2. 双方的空间都不足时,则存储到硬盘;若己方空间不足而对方空余时,可借用对方的空间;(存储空间不足是指不足以放下一个完整的 Block)
  3. 执行内存的空间被对方占用后,可让对方将占用的部分转存到硬盘,然后”归还”借用的空间
  4. 存储内存的空间被对方占用后,无法让对方”归还”,因为需要考虑 Shuffle 过程中的很多因素,实现起来较为复杂

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

存储内存管理

所以如果一个 RDD 上要执行多次行动,可以在第一次行动中使用 persist 或 cache 方法,在内存或磁盘中持久化或缓存这个 RDD,从而在后面的行动时提升计算速度

RDD 的持久化由 Spark 的 Storage 模块负责,实现了 RDD 与物理存储的解耦合。Storage模块负责管理 Spark 在计算过程中产生的数据,将那些在内存或磁盘、在本地或远程存取数据的功能封装了起来。在具体实现时 Driver 端和 Executor 端的 Storage 模块构成了主从式的架构,即 Driver 端的 BlockManager 为 Master,Executor 端的 BlockManager 为 Slave。Storage 模块在逻辑上以 Block 为基本存储单位,RDD 的每个 Partition 经过处理后唯一对应一个 Block(BlockId 的格式为 rdd_RDD-ID_PARTITION-ID )。Driver 端的 Master 负责整个 Spark 应用程序的 Block 的元数据信息的管理和维护,而 Executor 端的 Slave 需要将 Block的更新等状态上报到 Master,同时接收 Master 的命令,例如新增或删除一个 RDD。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

将 Partition 由不连续的存储空间转换为连续存储空间的过程,Spark称之为"展开"(Unroll)。

每个 Executor 的 Storage 模块用一个链式 Map 结构(LinkedHashMap)来管理堆内和堆外存储内存中所有的 Block 对象的实例,对这个LinkedHashMap 新增和删除间接记录了内存的申请和释放

要向 MemoryManager 申请足够的 Unroll 空间来临时占位,空间不足则 Unroll 失败,空间足够时可以继续进行。

对于序列化的 Partition,其所需的 Unroll 空间可以直接累加计算,一次申请。

对于非序列化的 Partition 则要在遍历 Record 的过程中依次申请,即每读取一条 Record,

采样估算其所需的 Unroll 空间并进行申请,空间不足时可以中断,释放已占用的 Unroll 空间。

如果最终 Unroll 成功,当前 Partition 所占用的 Unroll 空间被转换为正常的缓存 RDD 的存储空间

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在静态内存管理时,Spark 在存储内存中专门划分了一块 Unroll 空间,其大小是固定的

统一内存管理时则没有对 Unroll 空间进行特别区分,当存储空间不足时会根据动态占用机制进行处理

淘汰与落盘

由于同一个 Executor 的所有的计算任务共享有限的存储内存空间,当有新的 Block 需要缓存但是剩余空间不足且无法动态占用时,就要对 LinkedHashMap 中的旧 Block 进行淘汰(Eviction),而被淘汰的 Block 如果其存储级别中同时包含存储到磁盘的要求,则要对其进行落盘(Drop),否则直接删除该 Block。

存储内存的淘汰规则为:

➢ 被淘汰的旧 Block 要与新 Block 的 MemoryMode 相同,即同属于堆外或堆内内存;

➢ 新旧 Block 不能属于同一个 RDD,避免循环淘汰;

➢ 旧 Block 所属 RDD 不能处于被读状态,避免引发一致性问题;

➢ 遍历 LinkedHashMap 中 Block,按照最近最少使用(LRU)的顺序淘汰,直到满足新

Block 所需的空间。其中 LRU 是 LinkedHashMap 的特性。

落盘的流程则比较简单,如果其存储级别符合_useDisk 为 true 的条件,再根据其_deserialized

判断是否是非序列化的形式,若是则对其进行序列化,最后将数据存储到磁盘,在 Storage

模块中更新其信息。

执行内存

执行内存主要用来存储任务在执行 Shuffle 时占用的内存,Shuffle 是按照一定规则对RDD 数据重新分区的过程,我们来看 Shuffle 的 Write 和 Read 两阶段对执行内存的使用

shuffle write

若在 map 端选择普通的排序方式,会采用 ExternalSorter 进行外排,在内存中存储数据时主要占用堆内执行空间。

若在 map 端选择 Tungsten 的排序方式,则采用 ShuffleExternalSorter 直接对以序列化形式存储的数据排序,在内存中存储数据时可以占用堆外或堆内执行空间,取决于用户是否开启了堆外内存以及堆外执行内存是否足够

shuffle read

释放**

[外链图片转存中…(img-NXBjVHID-1705923045838)]

堆内内存

堆内内存的大小 , 由 Spark 应 用 程 序 启 动 时 的 – executor-memory 或spark.executor.memory 参数配置。

  1. Executor 内运行的并发任务共享 JVM 堆内内存,这些任务在缓存 RDD 数据和广播(Broadcast)数据时占用的内存被规划为存储(Storage)内存
  2. 而这些任务在执行 Shuffle 时占用的内存被规划为执行(Execution)内存
  3. 剩余的部分不做特殊规划,那些 Spark 内部的对象实例,或者用户定义的 Spark 应用程序中的对象实例,均

占用剩余的空间。

Spark 对堆内内存的管理是一种逻辑上的”规划式”的管理,因为对象实例占用内存的申请和释放都由 JVM 完成,Spark 只能在申请后和释放前记录这些内存,我们来看其具体流程:

JVM 的对象可以以序列化的方式存储,序列化的过程是将对象转换为二进制字节流,本质上可以理解为将非连续空间的链式存储转化为连续空间或块存储,在访问时则需要进行序列化的逆过程——反序列化,将字节流转化为对象,序列化的方式可以节省存储空间,但增加了存储和读取时候的计算开销。

对于 Spark 中序列化的对象,由于是字节流的形式,其占用的内存大小可直接计算,而对于非序列化的对象,其占用的内存是通过周期性地采样近似估算而得,即并不是每次新增的数据项都会计算一次占用的内存大小,这种方法降低了时间开销但是有可能误差较大,导致某一时刻的实际内存有可能远远超出预期。此外,在被 Spark 标记为释放的对象实例,很有可能在实际上并没有被 JVM 回收,导致实际可用的内存小于 Spark 记录的可用内存。所以 Spark 并不能准确记录实际可用的堆内内存,从而也就无法完全避免内存溢出(OOM, Out of Memory)的异常。

虽然不能精准控制堆内内存的申请和释放,但 Spark 通过对存储内存和执行内存各自独立的规划管理,可以决定是否要在存储内存里缓存新的 RDD,以及是否为新的任务分配执行内存,在一定程度上可以提升内存的利用率,减少异常的出现

堆外内存

为了进一步优化内存的使用以及提高 Shuffle 时排序的效率,Spark 引入了堆外(Off heap)内存,使之可以直接在工作节点的系统内存中开辟空间,存储经过序列化的二进制数据。

堆外内存意味着把内存对象分配在 Java 虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机)。这样做的结果就是能保持一个较小的堆,以减少垃圾收集对应用的影响。

利用 JDK Unsafe API,Spark 可以直接操作系统堆外内存,减少了不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能。堆外内存可以被精确地申请和释放(堆外内存之所以能够被精确的申请和释放,是由于内存的申请和释放不再通过 JVM 机制,而是直接向操作系统申请,JVM 对于内存的清理是无法准确指定时间点的,因此无法实现精确的释放),而且序列化的数据占用的空间可以被精确计算,所以相比堆内内存来说降低了管理的难度,也降低了误差

在默认情况下堆外内存并不启用,可通过配置 spark.memory.offHeap.enabled 参数启用,并由 spark.memory.offHeap.size 参数设定堆外空间的大小。除了没有 other 空间,堆外内存与堆内内存的划分方式相同,所有运行中的并发任务共享存储内存和执行内存

内存空间分配

静态内存管理

在 Spark 最初采用的静态内存管理机制下,存储内存、执行内存和其他内存的大小在Spark 应用程序运行期间均为固定的,但用户可以应用程序启动前进行配置

[外链图片转存中…(img-PYWKMsTT-1705923045838)]

堆外的空间分配较为简单,只有存储内存和执行内存,如下图所示。可用的执行内存和存储内存占用的空间大小直接由参数 spark.memory.storageFraction 决定,由于堆外内存占用的空间可以被精确计算,所以无需再设定保险区域。

[外链图片转存中…(img-rmKIE08A-1705923045838)]

统一内存管理

与静态内存管理的区别在于存储内存和执行内存共享同一块空间,可以动态占用对方的空闲区域

[外链图片转存中…(img-pUweVXzK-1705923045838)]

统一内存管理的堆外内存结构如下图所示:

[外链图片转存中…(img-HVNPoqeB-1705923045838)]

其中最重要的优化在于动态占用机制,其规则如下:

  1. 设定基本的存储内存和执行内存区域(spark.storage.storageFraction 参数),该设定确定了双方各自拥有的空间的范围;
  2. 双方的空间都不足时,则存储到硬盘;若己方空间不足而对方空余时,可借用对方的空间;(存储空间不足是指不足以放下一个完整的 Block)
  3. 执行内存的空间被对方占用后,可让对方将占用的部分转存到硬盘,然后”归还”借用的空间
  4. 存储内存的空间被对方占用后,无法让对方”归还”,因为需要考虑 Shuffle 过程中的很多因素,实现起来较为复杂

[外链图片转存中…(img-mlcU7O53-1705923045839)]

存储内存管理

所以如果一个 RDD 上要执行多次行动,可以在第一次行动中使用 persist 或 cache 方法,在内存或磁盘中持久化或缓存这个 RDD,从而在后面的行动时提升计算速度

RDD 的持久化由 Spark 的 Storage 模块负责,实现了 RDD 与物理存储的解耦合。Storage模块负责管理 Spark 在计算过程中产生的数据,将那些在内存或磁盘、在本地或远程存取数据的功能封装了起来。在具体实现时 Driver 端和 Executor 端的 Storage 模块构成了主从式的架构,即 Driver 端的 BlockManager 为 Master,Executor 端的 BlockManager 为 Slave。Storage 模块在逻辑上以 Block 为基本存储单位,RDD 的每个 Partition 经过处理后唯一对应一个 Block(BlockId 的格式为 rdd_RDD-ID_PARTITION-ID )。Driver 端的 Master 负责整个 Spark 应用程序的 Block 的元数据信息的管理和维护,而 Executor 端的 Slave 需要将 Block的更新等状态上报到 Master,同时接收 Master 的命令,例如新增或删除一个 RDD。

[外链图片转存中…(img-DLctHKeX-1705923045839)]

将 Partition 由不连续的存储空间转换为连续存储空间的过程,Spark称之为"展开"(Unroll)。

每个 Executor 的 Storage 模块用一个链式 Map 结构(LinkedHashMap)来管理堆内和堆外存储内存中所有的 Block 对象的实例,对这个LinkedHashMap 新增和删除间接记录了内存的申请和释放

要向 MemoryManager 申请足够的 Unroll 空间来临时占位,空间不足则 Unroll 失败,空间足够时可以继续进行。

对于序列化的 Partition,其所需的 Unroll 空间可以直接累加计算,一次申请。

对于非序列化的 Partition 则要在遍历 Record 的过程中依次申请,即每读取一条 Record,

采样估算其所需的 Unroll 空间并进行申请,空间不足时可以中断,释放已占用的 Unroll 空间。

如果最终 Unroll 成功,当前 Partition 所占用的 Unroll 空间被转换为正常的缓存 RDD 的存储空间

[外链图片转存中…(img-wFDPEkLv-1705923045839)]

在静态内存管理时,Spark 在存储内存中专门划分了一块 Unroll 空间,其大小是固定的

统一内存管理时则没有对 Unroll 空间进行特别区分,当存储空间不足时会根据动态占用机制进行处理

淘汰与落盘

由于同一个 Executor 的所有的计算任务共享有限的存储内存空间,当有新的 Block 需要缓存但是剩余空间不足且无法动态占用时,就要对 LinkedHashMap 中的旧 Block 进行淘汰(Eviction),而被淘汰的 Block 如果其存储级别中同时包含存储到磁盘的要求,则要对其进行落盘(Drop),否则直接删除该 Block。

存储内存的淘汰规则为:

➢ 被淘汰的旧 Block 要与新 Block 的 MemoryMode 相同,即同属于堆外或堆内内存;

➢ 新旧 Block 不能属于同一个 RDD,避免循环淘汰;

➢ 旧 Block 所属 RDD 不能处于被读状态,避免引发一致性问题;

➢ 遍历 LinkedHashMap 中 Block,按照最近最少使用(LRU)的顺序淘汰,直到满足新

Block 所需的空间。其中 LRU 是 LinkedHashMap 的特性。

落盘的流程则比较简单,如果其存储级别符合_useDisk 为 true 的条件,再根据其_deserialized

判断是否是非序列化的形式,若是则对其进行序列化,最后将数据存储到磁盘,在 Storage

模块中更新其信息。

执行内存

执行内存主要用来存储任务在执行 Shuffle 时占用的内存,Shuffle 是按照一定规则对RDD 数据重新分区的过程,我们来看 Shuffle 的 Write 和 Read 两阶段对执行内存的使用

shuffle write

若在 map 端选择普通的排序方式,会采用 ExternalSorter 进行外排,在内存中存储数据时主要占用堆内执行空间。

若在 map 端选择 Tungsten 的排序方式,则采用 ShuffleExternalSorter 直接对以序列化形式存储的数据排序,在内存中存储数据时可以占用堆外或堆内执行空间,取决于用户是否开启了堆外内存以及堆外执行内存是否足够

shuffle read
  • 21
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值