Spark学习笔记

Spark 入门

1. Spark 概述

目标

  1. Spark 是什么
  2. Spark 的特点
  3. Spark 生态圈的组成

1.1. Spark是什么

目标

  1. 了解 Spark 的历史和产生原因, 从而浅显的理解 Spark 的作用

Spark的历史

  • 2009 年由加州大学伯克利分校 AMPLab 开创
  • 2010 年通过BSD许可协议开源发布
  • 2013 年捐赠给Apache软件基金会并切换开源协议到切换许可协议至 Apache2.0
  • 2014 年 2 月,Spark 成为 Apache 的顶级项目
  • 2014 年 11 月, Spark的母公司Databricks团队使用Spark刷新数据排序世界记录

Spark是什么

Apache Spark 是一个快速的, 多用途的集群计算系统, 相对于 Hadoop MapReduce 将中间结果保存在磁盘中, Spark 使用了内存保存中间结果, 能在数据尚未写入硬盘时在内存中进行运算.

Spark 只是一个计算框架, 不像 Hadoop 一样包含了分布式文件系统和完备的调度系统, 如果要使用 Spark, 需要搭载其它的文件系统和更成熟的调度系统

为什么会有Spark

img

Spark 产生之前, 已经有非常成熟的计算系统存在了, 例如 MapReduce, 这些计算系统提供了高层次的API, 把计算运行在集群中并提供容错能力, 从而实现分布式计算.

虽然这些框架提供了大量的对访问利用计算资源的抽象, 但是它们缺少了对利用分布式内存的抽象, 这些框架多个计算之间的数据复用就是将中间数据写到一个稳定的文件系统中(例如HDFS), 所以会产生数据的复制备份, 磁盘的I/O以及数据的序列化, 所以这些框架在遇到需要在多个计算之间复用中间结果的操作时会非常的不高效.

而这类操作是非常常见的, 例如迭代式计算, 交互式数据挖掘, 图计算等.

认识到这个问题后, 学术界的 AMPLab 提出了一个新的模型, 叫做 RDDs.

RDDs 是一个可以容错且并行的数据结构, 它可以让用户显式的将中间结果数据集保存在内中, 并且通过控制数据集的分区来达到数据存放处理最优化.

同时 RDDs 也提供了丰富的 API 来操作数据集.

后来 RDDs 被 AMPLab 在一个叫做 Spark 的框架中提供并开源.

总结

  1. Spark 是Apache的开源框架
  2. Spark 的母公司叫做 Databricks
  3. Spark 是为了解决 MapReduce 等过去的计算系统无法在内存中保存中间结果的问题
  4. Spark 的核心是 RDDs, RDDs 不仅是一种计算框架, 也是一种数据结构

1.2. Spark的特点(优点)

目标

  1. 理解 Spark 的特点, 从而理解为什么要使用 Spark

速度快

  • Spark 的在内存时的运行速度是 Hadoop MapReduce 的100倍
  • 基于硬盘的运算速度大概是 Hadoop MapReduce 的10倍
  • Spark 实现了一种叫做 RDDs 的 DAG 执行引擎, 其数据缓存在内存中可以进行迭代处理

易用

1
2
3
4
df = spark.read.json("logs.json")
df.where("age > 21") \
.select("name.first") \
.show()
  • Spark 支持 Java, Scala, Python, R, SQL 等多种语言的API.
  • Spark 支持超过80个高级运算符使得用户非常轻易的构建并行计算程序
  • Spark 可以使用基于 Scala, Python, R, SQL的 Shell 交互式查询.

通用

  • Spark 提供一个完整的技术栈, 包括 SQL执行, Dataset命令式API, 机器学习库MLlib, 图计算框架GraphX, 流计算SparkStreaming
  • 用户可以在同一个应用中同时使用这些工具, 这一点是划时代的

兼容

  • Spark 可以运行在 Hadoop Yarn, Apache Mesos, Kubernets, Spark Standalone等集群中
  • Spark 可以访问 HBase, HDFS, Hive, Cassandra 在内的多种数据库

总结

  • 支持 Java, Scala, Python 和 R 的 API
  • 可扩展至超过 8K 个节点
  • 能够在内存中缓存数据集, 以实现交互式数据分析
  • 提供命令行窗口, 减少探索式的数据分析的反应时间

1.3. Spark组件

目标

  1. 理解 Spark 能做什么
  2. 理解 Spark 的学习路线

Spark 最核心的功能是 RDDs, RDDs 存在于 spark-core 这个包内, 这个包也是 Spark 最核心的包.

同时 Spark 在 spark-core 的上层提供了很多工具, 以便于适应不用类型的计算.

Spark-Core 和 弹性分布式数据集(RDDs)

  • Spark-Core 是整个 Spark 的基础, 提供了分布式任务调度和基本的 I/O 功能
  • Spark 的基础的程序抽象是弹性分布式数据集(RDDs), 是一个可以并行操作, 有容错的数据集合
    • RDDs 可以通过引用外部存储系统的数据集创建(如HDFS, HBase), 或者通过现有的 RDDs 转换得到
    • RDDs 抽象提供了 Java, Scala, Python 等语言的API
    • RDDs 简化了编程复杂性, 操作 RDDs 类似通过 Scala 或者 Java8 的 Streaming 操作本地数据集合

Spark SQL

  • Spark SQL 在 spark-core 基础之上带出了一个名为 DataSet 和 DataFrame 的数据抽象化的概念
  • Spark SQL 提供了在 Dataset 和 DataFrame 之上执行 SQL 的能力
  • Spark SQL 提供了 DSL, 可以通过 Scala, Java, Python 等语言操作 DataSet 和 DataFrame
  • 它还支持使用 JDBC/ODBC 服务器操作 SQL 语言

Spark Streaming

  • Spark Streaming 充分利用 spark-core 的快速调度能力来运行流分析
  • 它截取小批量的数据并可以对之运行 RDD Transformation
  • 它提供了在同一个程序中同时使用流分析和批量分析的能力

MLlib

  • MLlib 是 Spark 上分布式机器学习的框架. Spark分布式内存的架构 比 Hadoop磁盘式 的 Apache Mahout 快上 10 倍, 扩展性也非常优良
  • MLlib 可以使用许多常见的机器学习和统计算法, 简化大规模机器学习
  • 汇总统计, 相关性, 分层抽样, 假设检定, 随即数据生成
  • 支持向量机, 回归, 线性回归, 逻辑回归, 决策树, 朴素贝叶斯
  • 协同过滤, ALS
  • K-means
  • SVD奇异值分解, PCA主成分分析
  • TF-IDF, Word2Vec, StandardScaler
  • SGD随机梯度下降, L-BFGS

GraphX

GraphX 是分布式图计算框架, 提供了一组可以表达图计算的 API, GraphX 还对这种抽象化提供了优化运行

总结

  • Spark 提供了 批处理(RDDs), 结构化查询(DataFrame), 流计算(SparkStreaming), 机器学习(MLlib), 图计算(GraphX) 等组件
  • 这些组件均是依托于通用的计算引擎 RDDs 而构建出的, 所以 spark-core 的 RDDs 是整个 Spark 的基础

1.4. Spark和Hadoop的异同

Hadoop Spark
类型 基础平台, 包含计算, 存储, 调度 分布式计算工具
场景 大规模数据集上的批处理 迭代计算, 交互式计算, 流计算
延迟
易用性 API 较为底层, 算法适应性差 API 较为顶层, 方便使用
价格 对机器要求低, 便宜 对内存有要求, 相对较贵

2. Spark 集群搭建

目标

  1. 从 Spark 的集群架构开始, 理解分布式环境, 以及 Spark 的运行原理
  2. 理解 Spark 的集群搭建, 包括高可用的搭建方式

2.1. Spark 集群结构

目标

  1. 通过应用运行流程, 理解分布式调度的基础概念
Spark 如何将程序运行在一个集群中?imgSpark 自身是没有集群管理工具的, 但是如果想要管理数以千计台机器的集群, 没有一个集群管理工具还不太现实, 所以 Spark 可以借助外部的集群工具来进行管理整个流程就是使用 Spark 的 Client 提交任务, 找到集群管理工具申请资源, 后将计算任务分发到集群中运行

img

名词解释

  • Driver

    该进程调用 Spark 程序的 main 方法, 并且启动 SparkContext

  • Cluster Manager

    该进程负责和外部集群工具打交道, 申请或释放集群资源

  • Worker

    该进程是一个守护进程, 负责启动和管理 Executor

  • Executor

    该进程是一个JVM虚拟机, 负责运行 Spark Task

img

运行一个 Spark 程序大致经历如下几个步骤

  1. 启动 Drive, 创建 SparkContext
  2. Client 提交程序给 Drive, Drive 向 Cluster Manager 申请集群资源
  3. 资源申请完毕, 在 Worker 中启动 Executor
  4. Driver 将程序转化为 Tasks, 分发给 Executor 执行

问题一: Spark 程序可以运行在什么地方?

集群: 一组协同工作的计算机, 通常表现的好像是一台计算机一样, 所运行的任务由软件来控制和调度**集群管理工具:** 调度任务到集群的软件常见的集群管理工具: Hadoop Yarn, Apache Mesos, Kubernetes

Spark 可以将任务运行在两种模式下:

  • 单机, 使用线程模拟并行来运行程序
  • 集群, 使用集群管理器来和不同类型的集群交互, 将任务运行在集群中

Spark 可以使用的集群管理工具有:

  • Spark Standalone
  • Hadoop Yarn
  • Apache Mesos
  • Kubernetes

问题二: Driver 和 Worker 什么时候被启动?

img

img

  • Standalone 集群中, 分为两个角色: Master 和 Slave, 而 Slave 就是 Worker, 所以在 Standalone 集群中, 启动之初就会创建固定数量的 Worker
  • Driver 的启动分为两种模式: Client 和 Cluster. 在 Client 模式下, Driver 运行在 Client 端, 在 Client 启动的时候被启动. 在 Cluster 模式下, Driver 运行在某个 Worker 中, 随着应用的提交而启动

img

  • 在 Yarn 集群模式下, 也依然分为 Client 模式和 Cluster 模式, 较新的版本中已经逐渐在废弃 Client 模式了, 所以上图所示为 Cluster 模式
  • 如果要在 Yarn 中运行 Spark 程序, 首先会和 RM 交互, 开启 ApplicationMaster, 其中运行了 Driver, Driver创建基础环境后, 会由 RM 提供对应的容器, 运行 Executor, Executor会反向向 Driver 反向注册自己, 并申请 Tasks 执行
  • 在后续的 Spark 任务调度部分, 会更详细介绍

总结

  • Master 负责总控, 调度, 管理和协调 Worker, 保留资源状况等
  • Slave 对应 Worker 节点, 用于启动 Executor 执行 Tasks, 定期向 Master汇报
  • Driver 运行在 Client 或者 Slave(Worker) 中, 默认运行在 Slave(Worker) 中

2.2. Spark 集群搭建

目标

  1. 大致了解 Spark Standalone 集群搭建的过程

    这个部分的目的是搭建一套用于测试和学习的集群, 实际的工作中可能集群环境会更复杂一些

Node01 Node02 Node03
Master Slave Slave
History Server

Step 1 下载和解压

此步骤假设大家的 Hadoop 集群已经能够无碍的运行, 并且 Linux 的防火墙和 SELinux 已经关闭, 时钟也已经同步, 如果还没有, 请参考 Hadoop 集群搭建部分, 完成以上三件事
  1. 下载 Spark 安装包, 下载时候选择对应的 Hadoop 版本(资料中已经提供了 Spark 安装包, 直接上传至集群 Master 即可, 无需遵循以下步骤)

    https://archive.apache.org/dist/spark/spark-2.2.0/spark-2.2.0-bin-hadoop2.7.tgz

    1
    2
    3
    # 下载 Spark
    cd /export/softwares
    wget https://archive.apache.org/dist/spark/spark-2.2.0/spark-2.2.0-bin-hadoop2.7.tgz
  2. 解压并拷贝到export/servers

    1
    2
    3
    4
    5
    # 解压 Spark 安装包
    tar xzvf spark-2.2.0-bin-hadoop2.7.tgz

    # 移动 Spark 安装包
    mv spark-2.2.0-bin-hadoop2.7.tgz /export/servers/spark
  1. 修改配置文件spark-env.sh, 以指定运行参数

    • 进入配置目录, 并复制一份新的配置文件, 以供在此基础之上进行修改

      1
      2
      3
      cd /export/servers/spark/conf
      cp spark-env.sh.template spark-env.sh
      vi spark-env.sh
    • 将以下内容复制进配置文件末尾

      1
      2
      3
      4
      5
      6
      # 指定 Java Home
      export JAVA_HOME=/export/servers/jdk1.8.0

      # 指定 Spark Master 地址
      export SPARK_MASTER_HOST=node01
      export SPARK_MASTER_PORT=7077

Step 2 配置

  1. 修改配置文件 slaves, 以指定从节点为止, 从在使用 sbin/start-all.sh 启动集群的时候, 可以一键启动整个集群所有的 Worker

    • 进入配置目录, 并复制一份新的配置文件, 以供在此基础之上进行修改

      1
      2
      3
      cd /export/servers/spark/conf
      cp slaves.template slaves
      vi slaves
    • 配置所有从节点的地址

      1
      2
      node02
      node03
  2. 配置 HistoryServer

    1. 默认情况下, Spark 程序运行完毕后, 就无法再查看运行记录的 Web UI 了, 通过 HistoryServer 可以提供一个服务, 通过读取日志文件, 使得我们可以在程序运行结束后, 依然能够查看运行过程

    2. 复制 spark-defaults.conf, 以供修改

      1
      2
      3
      cd /export/servers/spark/conf
      cp spark-defaults.conf.template spark-defaults.conf
      vi spark-defaults.conf
    3. 将以下内容复制到spark-defaults.conf末尾处, 通过这段配置, 可以指定 Spark 将日志输入到 HDFS 中

      1
      2
      3
      spark.eventLog.enabled  true
      spark.eventLog.dir hdfs://node01:8020/spark_log
      spark.eventLog.compress true
    4. 将以下内容复制到spark-env.sh末尾, 配置 HistoryServer 启动参数, 使得 HistoryServer 在启动的时候读取 HDFS 中写入的 Spark 日志

      1
      2
      # 指定 Spark History 运行参数
      export SPARK_HISTORY_OPTS="-Dspark.history.ui.port=4000 -Dspark.history.retainedApplications=3 -Dspark.history.fs.logDirectory=hdfs://node01:8020/spark_log"
    5. 为 Spark 创建 HDFS 中的日志目录

      1
      hdfs dfs -mkdir -p /spark_log

Step 3 分发和运行

  1. 将 Spark 安装包分发给集群中其它机器

    1
    2
    3
    cd /export/servers
    scp -r spark root@node02:$PWD
    scp -r spark root@node03:$PWD
  2. 启动 Spark Master 和 Slaves, 以及 HistoryServer

    1
    2
    3
    cd /export/servers/spark
    sbin/start-all.sh
    sbin/start-history-server.sh

目标

Spark 的集群搭建大致有如下几个步骤

  1. 下载和解压 Spark
  2. 配置 Spark 的所有从节点位置
  3. 配置 Spark History server 以便于随时查看 Spark 应用的运行历史
  4. 分发和运行 Spark 集群

2.3. Spark 集群高可用搭建

目标

  1. 简要了解如何使用 Zookeeper 帮助 Spark Standalone 高可用
对于 Spark Standalone 集群来说, 当 Worker 调度出现问题的时候, 会自动的弹性容错, 将出错的 Task 调度到其它 Worker 执行但是对于 Master 来说, 是会出现单点失败的, 为了避免可能出现的单点失败问题, Spark 提供了两种方式满足高可用使用 Zookeeper 实现 Masters 的主备切换使用文件系统做主备切换使用文件系统做主备切换的场景实在太小, 所以此处不再花费笔墨介绍

Step 1 停止 Spark 集群

1
2
cd /export/servers/spark
sbin/stop-all.sh

Step 2 修改配置文件, 增加 Spark 运行时参数, 从而指定 Zookeeper 的位置

  1. 进入 spark-env.sh 所在目录, 打开 vi 编辑

    1
    2
    cd /export/servers/spark/conf
    vi spark-env.sh
  2. 编辑 spark-env.sh, 添加 Spark 启动参数, 并去掉 SPARK_MASTER_HOST 地址

    img

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 指定 Java Home
    export JAVA_HOME=/export/servers/jdk1.8.0_141

    # 指定 Spark Master 地址
    # export SPARK_MASTER_HOST=node01
    export SPARK_MASTER_PORT=7077
    # 指定 Spark History 运行参数
    export SPARK_HISTORY_OPTS="-Dspark.history.ui.port=4000 -Dspark.history.retainedApplications=3 -Dspark.history.fs.logDirectory=hdfs://node01:8020/spark_log"
    # 指定 Spark 运行时参数
    export SPARK_DAEMON_JAVA_OPTS="-Dspark.deploy.recoveryMode=ZOOKEEPER -Dspark.deploy.zookeeper.url=node01:2181,node02:2181,node03:2181 -Dspark.deploy.zookeeper.dir=/spark"

Step 3 分发配置文件到整个集群

1
2
3
cd /export/servers/spark/conf
scp spark-env.sh node02:$PWD
scp spark-env.sh node03:$PWD

Step 4 启动

  1. node01 上启动整个集群

    1
    2
    3
    cd /export/servers/spark
    sbin/start-all.sh
    sbin/start-history-server.sh
  2. node02 上单独再启动一个 Master

    1
    2
    cd /export/servers/spark
    sbin/start-master.sh

Step 5 查看 node01 masternode02 master 的 WebUI

  1. 你会发现一个是 ALIVE(主), 另外一个是 STANDBY(备)

    img

  2. 如果关闭一个, 则另外一个成为ALIVE, 但是这个过程可能要持续两分钟左右, 需要耐心等待

    1
    2
    3
    # 在 Node01 中执行如下指令
    cd /export/servers/spark/
    sbin/stop-master.sh

    img

Spark HA 选举Spark HA 的 Leader 选举使用了一个叫做 Curator 的 Zookeeper 客户端来进行Zookeeper 是一个分布式强一致性的协调服务, Zookeeper 最基本的一个保证是: 如果多个节点同时创建一个 ZNode, 只有一个能够成功创建. 这个做法的本质使用的是 Zookeeper 的 ZAB 协议, 能够在分布式环境下达成一致.
Service port
Master WebUI node01:8080
Worker WebUI node01:8081
History Server node01:4000

2.4. 第一个应用的运行

目标

  1. 从示例应用运行中理解 Spark 应用的运行流程

流程

Step 1 进入 Spark 安装目录中

1
cd /export/servers/spark/

Step 2 运行 Spark 示例任务

1
2
3
4
5
6
7
bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://node01:7077,node02:7077,node03:7077 \
--executor-memory 1G \
--total-executor-cores 2 \
/export/servers/spark/examples/jars/spark-examples_2.11-2.2.3.jar \
100

Step 3 运行结果

1
Pi is roughly 3.141550671141551
刚才所运行的程序是 Spark 的一个示例程序, 使用 Spark 编写了一个以蒙特卡洛算法来计算圆周率的任务蒙特卡洛算法概述img在一个正方形中, 内切出一个圆形img随机向正方形内均匀投 n 个点, 其落入内切圆内的内外点的概率满足如下img以上就是蒙特卡洛的大致理论, 通过这个蒙特卡洛, 便可以通过迭代循环投点的方式实现蒙特卡洛算法求圆周率

计算过程

  1. 不断的生成随机的点, 根据点距离圆心是否超过半径来判断是否落入园内

  2. 通过 img 来计算圆周率

  3. 不断的迭代

  4. 思考1: 迭代计算

    如果上述的程序使用 MapReduce 该如何编写? 是否会有大量的向 HDFS 写入, 后再次读取数据的做法? 是否会影响性能?

    Spark 为什么擅长这类操作? 大家可以发挥想象, 如何解决这种迭代计算的问题

  5. 思考2: 数据规模

    刚才的计算只做了100次, 如果迭代100亿次, 在单机上运行和一个集群中运行谁更合适?

3. Spark 入门

目标

  1. 通过理解 Spark 小案例, 来理解 Spark 应用
  2. 理解编写 Spark 程序的两种常见方式
    1. spark-shell
    2. spark-submit

Spark 官方提供了两种方式编写代码, 都比较重要, 分别如下

  • spark-shell
    Spark shell 是 Spark 提供的一个基于 Scala 语言的交互式解释器, 类似于 Scala 提供的交互式解释器, Spark shell 也可以直接在 Shell 中编写代码执行
    这种方式也比较重要, 因为一般的数据分析任务可能需要探索着进行, 不是一蹴而就的, 使用 Spark shell 先进行探索, 当代码稳定以后, 使用独立应用的方式来提交任务, 这样是一个比较常见的流程
  • spark-submit
    Spark submit 是一个命令, 用于提交 Scala 编写的基于 Spark 框架, 这种提交方式常用作于在集群中运行任务

3.1. Spark shell 的方式编写 WordCount

概要

在初始阶段工作可以全部使用 Spark shell 完成, 它可以加快原型开发, 使得迭代更快, 很快就能看到想法的结果. 但是随着项目规模越来越大, 这种方式不利于代码维护, 所以可以编写独立应用. 一般情况下, 在探索阶段使用 Spark shell, 在最终使用独立应用的方式编写代码并使用 Maven 打包上线运行

接下来使用 Spark shell 的方式编写一个 WordCount

Spark shell 简介启动 Spark shell 进入 Spark 安装目录后执行 spark-shell --master master 就可以提交Spark 任务Spark shell 的原理是把每一行 Scala 代码编译成类, 最终交由 Spark 执行
Master地址的设置Master 的地址可以有如下几种设置方式Table 3. master地址解释local[N]使用 N 条 Worker 线程在本地运行spark://host:port在 Spark standalone 中运行, 指定 Spark 集群的 Master 地址, 端口默认为 7077mesos://host:port在 Apache Mesos 中运行, 指定 Mesos 的地址yarn在 Yarn 中运行, Yarn 的地址由环境变量 HADOOP_CONF_DIR 来指定

Step 1 准备文件

在 Node01 中创建文件 /export/data/wordcount.txt

1
2
3
hadoop spark flume
spark hadoop
flume hadoop

Step 2 启动 Spark shell

1
2
cd /export/servers/spark
bin/spark-shell --master local[2]

Step 3 执行如下代码

1
2
3
4
5
6
7
8
9
scala> val sourceRdd = sc.textFile("file:///export/data/wordcount.txt")
sourceRdd: org.apache.spark.rdd.RDD[String] = file:///export/data/wordcount.txt MapPartitionsRDD[1] at textFile at <console>:24

scala> val flattenCountRdd = sourceRdd.flatMap(.split(" ")).map((, 1))
flattenCountRdd: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[3] at map at <console>:26
scala> val aggCountRdd = flattenCountRdd.reduceByKey(_ + _)
aggCountRdd: org.apache.spark.rdd.RDD[(String, Int)] = ShuffledRDD[4] at reduceByKey at <console>:28
scala> val result = aggCountRdd.collect
result: Array[(String, Int)] = Array((spark,2), (hadoop,3), (flume,2))
sc上述代码中 sc 变量指的是 SparkContext, 是 Spark 程序的上下文和入口正常情况下我们需要自己创建, 但是如果使用 Spark shell 的话, Spark shell 会帮助我们创建, 并且以变量 sc 的形式提供给我们调用

运行流程

img

  1. flatMap(_.split(" ")) 将数据转为数组的形式, 并展平为多个数据
  2. map_, 1 将数据转换为元组的形式
  3. reduceByKey(_ + _) 计算每个 Key 出现的次数

总结

  1. 使用 Spark shell 可以快速验证想法
  2. Spark 框架下的代码非常类似 Scala 的函数式调用

3.2. 读取 HDFS 上的文件

目标

  1. 理解 Spark 访问 HDFS 的两种方式

Step 1 上传文件到 HDFS 中

1
2
3
cd /export/data
hdfs dfs -mkdir /dataset
hdfs dfs -put wordcount.txt /dataset/

Step 2 在 Spark shell 中访问 HDFS

1
2
3
4
5
6
7
8
9
10
val sourceRdd = sc.textFile("hdfs://node01:8020/dataset/wordcount.txt")
val flattenCountRdd = sourceRdd.flatMap(_.split(" ")).map((_, 1))
val aggCountRdd = flattenCountRdd.reduceByKey(_ + _)
val result = aggCountRdd.collect


val sourceRdd = sc.textFile("file///export/servers/wordcount.txt")
val flattenCountRdd = sourceRdd.flatMap(_.split(",")).map((_, 1))
val aggCountRdd = flattenCountRdd.reduceByKey(_ + _)
val result = aggCountRdd.collect
如何使得 Spark 可以访问 HDFS?可以通过指定 HDFS 的 NameNode 地址直接访问, 类似于上面代码中的 sc.textFile("hdfs://node01:8020/dataset/wordcount.txt")img也可以通过向 Spark 配置 Hadoop 的路径, 来通过路径直接访问1.在 spark-env.sh 中添加 Hadoop 的配置路径export HADOOP_CONF_DIR="/etc/hadoop/conf"2.在配置过后, 可以直接使用 hdfs:///路径 的形式直接访问img3.在配置过后, 也可以直接使用路径访问img

3.4. 编写独立应用提交 Spark 任务

目标

  1. 理解如何编写 Spark 独立应用
  2. 理解 WordCount 的代码流程

Step 1 创建工程

  1. 创建 IDEA 工程
    1. imgimgimg
    2. imgimgimg
  2. 增加 Scala 支持
    1. 右键点击工程目录 img
    2. 选择增加框架支持 img
    3. 选择 Scala 添加框架支持

Step 2 编写 Maven 配置文件 pom.xml

  1. 工程根目录下增加文件 pom.xml

  2. 添加以下内容

  1. 因为在 pom.xml 中指定了 Scala 的代码目录, 所以创建目录 src/main/scala 和目录 src/test/scala

  2. 创建 Scala object WordCount

Step 3 编写代码

1
object WordCounts {   def main(args: Array[String]): Unit = {    // 1. 创建 Spark Context    val conf = new SparkConf().setMaster("local[2]")    val sc: SparkContext = new SparkContext(conf) <span class="hljs-comment">// 2. 读取文件并计算词频</span> val source: RDD[String] = sc.textFile(<span class="hljs-string">"hdfs://node01:8020/dataset/wordcount.txt"</span>, <span class="hljs-number">2</span>) val words: RDD[String] = source.flatMap { line => line.split(<span class="hljs-string">" "</span>) } val wordsTuple: RDD[(String, Int)] = words.map { word => (word, <span class="hljs-number">1</span>) } val wordsCount: RDD[(String, Int)] = wordsTuple.reduceByKey { (x, y) => x + y } <span class="hljs-comment">// 3. 查看执行结果</span> println(wordsCount.collect)  } }
和 Spark shell 中不同, 独立应用需要手动创建 SparkContext

Step 4 运行

运行 Spark 独立应用大致有两种方式, 一种是直接在 IDEA 中调试, 另一种是可以在提交至 Spark 集群中运行, 而 Spark 又支持多种集群, 不同的集群有不同的运行方式

直接在 IDEA 中运行 Spark 程序

  1. 在工程根目录创建文件夹和文件

    img

  2. 修改读取文件的路径为dataset/wordcount.txt

    img

  3. 右键运行 Main 方法

    img

提交到 Spark Standalone 集群中运行

spark-submit 命令

1
spark-submit [options] <app jar> <app options>
  • app jar 程序 Jar 包
  • app options 程序 Main 方法传入的参数
  • options 提交应用的参数, 可以有如下选项
参数 解释
--master <url> 同 Spark shell 的 Master, 可以是spark, yarn, mesos, kubernetes等 URL
--deploy-mode <client or cluster> Driver 运行位置, 可选 Client 和 Cluster, 分别对应运行在本地和集群(Worker)中
--class <class full name> Jar 中的 Class, 程序入口
--jars <dependencies path> 依赖 Jar 包的位置
--driver-memory <memory size> Driver 程序运行所需要的内存, 默认 512M
--executor-memory <memory size> Executor 的内存大小, 默认 1G
  1. 在 IDEA 中使用 Maven 打包

    img

  2. 拷贝打包的 Jar 包上传到 node01 中

    img

  3. 在 node01 中 Jar 包所在的目录执行如下命令

    1
    2
    3
    spark-submit --master spark://node01:7077 \
    --class cn.itcast.spark.WordCounts \
    original-spark-0.1.0.jar
如何在任意目录执行 spark-submit 命令?在 /etc/profile 中写入如下export SPARK_BIN=/export/servers/spark/bin export PATH=$PATH:$SPARK_BIN执行 /etc/profile 使得配置生效source /etc/profile

总结: 三种不同的运行方式

Spark shell

  • 作用
    • 一般用作于探索阶段, 通过 Spark shell 快速的探索数据规律
    • 当探索阶段结束后, 代码确定以后, 通过独立应用的形式上线运行
  • 功能
    • Spark shell 可以选择在集群模式下运行, 还是在线程模式下运行
    • Spark shell 是一个交互式的运行环境, 已经内置好了 SparkContext 和 SparkSession 对象, 可以直接使用
    • Spark shell 一般运行在集群中安装有 Spark client 的服务器中, 所以可以自有的访问 HDFS

本地运行

  • 作用
    • 在编写独立应用的时候, 每次都要提交到集群中还是不方便, 另外很多时候需要调试程序, 所以在 IDEA 中直接运行会比较方便, 无需打包上传了
  • 功能
    • 因为本地运行一般是在开发者的机器中运行, 而不是集群中, 所以很难直接使用 HDFS 等集群服务, 需要做一些本地配置, 用的比较少
    • 需要手动创建 SparkContext

集群运行

  • 作用
    • 正式环境下比较多见, 独立应用编写好以后, 打包上传到集群中, 使用spark-submit来运行, 可以完整的使用集群资源
  • 功能
    • 同时在集群中通过spark-submit来运行程序也可以选择是用线程模式还是集群模式
    • 集群中运行是全功能的, HDFS 的访问, Hive 的访问都比较方便
    • 需要手动创建 SparkContext

4. RDD 入门

目标

上面通过一个 WordCount 案例, 演示了 Spark 大致的编程模型和运行方式, 接下来针对 Spark 的编程模型做更详细的扩展

  1. 理解 WordCount 的代码
    1. 从执行角度上理解, 数据之间如何流转
    2. 从原理角度理解, 各个算子之间如何配合
  2. 粗略理解 Spark 中的编程模型 RDD
  3. 理解 Spark 中 RDD 的各个算子
1
object WordCounts {   def main(args: Array[String]): Unit = {    // 1. 创建 Spark Context    val conf = new SparkConf().setMaster("local[2]")    val sc: SparkContext = new SparkContext(conf) <span class="hljs-comment">// 2. 读取文件并计算词频</span> val source: RDD[String] = sc.textFile(<span class="hljs-string">"hdfs://node01:8020/dataset/wordcount.txt"</span>, <span class="hljs-number">2</span>) val words: RDD[String] = source.flatMap { line => line.split(<span class="hljs-string">" "</span>) } val wordsTuple: RDD[(String, Int)] = words.map { word => (word, <span class="hljs-number">1</span>) } val wordsCount: RDD[(String, Int)] = wordsTuple.reduceByKey { (x, y) => x + y } <span class="hljs-comment">// 3. 查看执行结果</span> println(wordsCount.collect)  } }

在这份 WordCount 代码中, 大致的思路如下:

  1. 使用 sc.textFile() 方法读取 HDFS 中的文件, 并生成一个 RDD
  2. 使用 flatMap 算子将读取到的每一行字符串打散成单词, 并把每个单词变成新的行
  3. 使用 map 算子将每个单词转换成 (word, 1) 这种元组形式
  4. 使用 reduceByKey 统计单词对应的频率

其中所使用到的算子有如下几个:

  • flatMap 是一对多
  • map 是一对一
  • reduceByKey 是按照 Key 聚合, 类似 MapReduce 中的 Shuffled

如果用图形表示的话, 如下:

img

总结以及引出新问题

上面大概说了两件事:

  1. 代码流程
  2. 算子

在代码中有一些东西并未交代:

  1. source, words, wordsTuple 这些变量的类型是 RDD[Type], 什么是 RDD?
  2. 还有更多算子吗?

RDD 是什么

img

定义

RDD, 全称为 Resilient Distributed Datasets, 是一个容错的, 并行的数据结构, 可以让用户显式地将数据存储到磁盘和内存中, 并能控制数据的分区.

同时, RDD 还提供了一组丰富的操作来操作这些数据. 在这些操作中, 诸如 map, flatMap, filter 等转换操作实现了 Monad 模式, 很好地契合了 Scala 的集合操作. 除此之外, RDD 还提供了诸如 join, groupBy, reduceByKey 等更为方便的操作, 以支持常见的数据运算.

通常来讲, 针对数据处理有几种常见模型, 包括: Iterative Algorithms, Relational Queries, MapReduce, Stream Processing. 例如 Hadoop MapReduce 采用了 MapReduce 模型, Storm 则采用了 Stream Processing 模型. RDD 混合了这四种模型, 使得 Spark 可以应用于各种大数据处理场景.

RDD 作为数据结构, 本质上是一个只读的分区记录集合. 一个 RDD 可以包含多个分区, 每个分区就是一个 DataSet 片段.

RDD 之间可以相互依赖, 如果 RDD 的每个分区最多只能被一个子 RDD 的一个分区使用,则称之为窄依赖, 若被多个子 RDD 的分区依赖,则称之为宽依赖. 不同的操作依据其特性, 可能会产生不同的依赖. 例如 map 操作会产生窄依赖, 而 join 操作则产生宽依赖.

特点

  1. RDD 是一个编程模型
    1. RDD 允许用户显式的指定数据存放在内存或者磁盘
    2. RDD 是分布式的, 用户可以控制 RDD 的分区
  2. RDD 是一个编程模型
    1. RDD 提供了丰富的操作
    2. RDD 提供了 map, flatMap, filter 等操作符, 用以实现 Monad 模式
    3. RDD 提供了 reduceByKey, groupByKey 等操作符, 用以操作 Key-Value 型数据
    4. RDD 提供了 max, min, mean 等操作符, 用以操作数字型的数据
  3. RDD 是混合型的编程模型, 可以支持迭代计算, 关系查询, MapReduce, 流计算
  4. RDD 是只读的
  5. RDD 之间有依赖关系, 根据执行操作的操作符的不同, 依赖关系可以分为宽依赖和窄依赖

RDD 的分区

img

整个 WordCount 案例的程序从结构上可以用上图表示, 分为两个大部分 存储

文件如果存放在 HDFS 上, 是分块的, 类似上图所示, 这个 wordcount.txt 分了三块

计算

Spark 不止可以读取 HDFS, Spark 还可以读取很多其它的数据集, Spark 可以从数据集中创建出 RDD

例如上图中, 使用了一个 RDD 表示 HDFS 上的某一个文件, 这个文件在 HDFS 中是分三块, 那么 RDD 在读取的时候就也有三个分区, 每个 RDD 的分区对应了一个 HDFS 的分块

后续 RDD 在计算的时候, 可以更改分区, 也可以保持三个分区, 每个分区之间有依赖关系, 例如说 RDD2 的分区一依赖了 RDD1 的分区一

RDD 之所以要设计为有分区的, 是因为要进行分布式计算, 每个不同的分区可以在不同的线程, 或者进程, 甚至节点中, 从而做到并行计算

总结

  1. RDD 是弹性分布式数据集
  2. RDD 一个非常重要的前提和基础是 RDD 运行在分布式环境下, 其可以分区

4.1. 创建 RDD

程序入口 SparkContext

1
2
val conf = new SparkConf().setMaster("local[2]")
val sc: SparkContext = new SparkContext(conf)

SparkContext 是 spark-core 的入口组件, 是一个 Spark 程序的入口, 在 Spark 0.x 版本就已经存在 SparkContext 了, 是一个元老级的 API

如果把一个 Spark 程序分为前后端, 那么服务端就是可以运行 Spark 程序的集群, 而 Driver 就是 Spark 的前端, 在 DriverSparkContext 是最主要的组件, 也是 Driver 在运行时首先会创建的组件, 是 Driver 的核心

SparkContext 从提供的 API 来看, 主要作用是连接集群, 创建 RDD, 累加器, 广播变量等

简略的说, RDD 有三种创建方式

  • RDD 可以通过本地集合直接创建
  • RDD 也可以通过读取外部数据集来创建
  • RDD 也可以通过其它的 RDD 衍生而来

通过本地集合直接创建 RDD

1
2
3
4
5
6
val conf = new SparkConf().setMaster("local[2]")
val sc = new SparkContext(conf)

val list = List(1, 2, 3, 4, 5, 6)
val rddParallelize = sc.parallelize(list, 2)
val rddMake = sc.makeRDD(list, 2)

通过 parallelizemakeRDD 这两个 API 可以通过本地集合创建 RDD

这两个 API 本质上是一样的, 在 makeRDD 这个方法的内部, 最终也是调用了 parallelize

因为不是从外部直接读取数据集的, 所以没有外部的分区可以借鉴, 于是在这两个方法都都有两个参数, 第一个参数是本地集合, 第二个参数是分区数

通过读取外部文件创建 RDD

1
2
3
4
val conf = new SparkConf().setMaster("local[2]")
val sc = new SparkContext(conf)

val source: RDD[String] = sc.textFile("hdfs://node01:8020/dataset/wordcount.txt")
  • 访问方式
    • 支持访问文件夹, 例如 sc.textFile("hdfs:///dataset")
    • 支持访问压缩文件, 例如 sc.textFile("hdfs:///dataset/words.gz")
    • 支持通过通配符访问, 例如 sc.textFile("hdfs:///dataset/*.txt")
如果把 Spark 应用跑在集群上, 则 Worker 有可能在任何一个节点运行所以如果使用 file:///…; 形式访问本地文件的话, 要确保所有的 Worker 中对应路径上有这个文件, 否则可能会报错无法找到文件
  • 分区
    • 默认情况下读取 HDFS 中文件的时候, 每个 HDFS 的 block 对应一个 RDD 的 partition, block 的默认是128M
    • 通过第二个参数, 可以指定分区数量, 例如 sc.textFile("hdfs://node01:8020/dataset/wordcount.txt", 20)
    • 如果通过第二个参数指定了分区, 这个分区数量一定不能小于block
通常每个 CPU core 对应 2 - 4 个分区是合理的值
  • 支持的平台
    • 支持 Hadoop 的几乎所有数据格式, 支持 HDFS 的访问
    • 通过第三方的支持, 可以访问AWS和阿里云中的文件, 详情查看对应平台的 API

通过其它的 RDD 衍生新的 RDD

1
2
3
4
5
val conf = new SparkConf().setMaster("local[2]")
val sc = new SparkContext(conf)

val source: RDD[String] = sc.textFile("hdfs://node01:8020/dataset/wordcount.txt", 20)
val words = source.flatMap { line => line.split(" ") }
  • source 是通过读取 HDFS 中的文件所创建的
  • words 是通过 source 调用算子 map 生成的新 RDD

总结

  1. RDD 的可以通过三种方式创建, 通过本地集合创建, 通过外部数据集创建, 通过其它的 RDD 衍生

4.2. RDD 算子

目标

  1. 理解各个算子的作用
  2. 通过理解算子的作用, 反向理解 WordCount 程序, 以及 Spark 的要点

Map 算子

1
2
3
sc.parallelize(Seq(1, 2, 3))
.map( num => num * 10 )
.collect()

img

img

作用

把 RDD 中的数据 一对一 的转为另一种形式

调用

1
def map[U: ClassTag](f: T ⇒ U): RDD[U]

参数

f → Map 算子是 原RDD → 新RDD 的过程, 这个函数的参数是原 RDD 数据, 返回值是经过函数转换的新 RDD 的数据

注意点

Map 是一对一, 如果函数是 String → Array[String] 则新的 RDD 中每条数据就是一个数组

FlatMap 算子

1
2
3
sc.parallelize(Seq("Hello lily", "Hello lucy", "Hello tim"))
.flatMap( line => line.split(" ") )
.collect()

img

img

作用

FlatMap 算子和 Map 算子类似, 但是 FlatMap 是一对多

调用

1
def flatMap[U: ClassTag](f: T ⇒ List[U]): RDD[U]

参数

f → 参数是原 RDD 数据, 返回值是经过函数转换的新 RDD 的数据, 需要注意的是返回值是一个集合, 集合中的数据会被展平后再放入新的 RDD

注意点

flatMap 其实是两个操作, 是 map + flatten, 也就是先转换, 后把转换而来的 List 展开

ReduceByKey 算子

1
2
3
sc.parallelize(Seq(("a", 1), ("a", 1), ("b", 1)))
.reduceByKey( (curr, agg) => curr + agg )
.collect()

img

img

作用

首先按照 Key 分组, 接下来把整组的 Value 计算出一个聚合值, 这个操作非常类似于 MapReduce 中的 Reduce

调用

1
def reduceByKey(func: (V, V) ⇒ V): RDD[(K, V)]

参数

func → 执行数据处理的函数, 传入两个参数, 一个是当前值, 一个是局部汇总, 这个函数需要有一个输出, 输出就是这个 Key 的汇总结果

注意点

  • ReduceByKey 只能作用于 Key-Value 型数据, Key-Value 型数据在当前语境中特指 Tuple2
  • ReduceByKey 是一个需要 Shuffled 的操作
  • 和其它的 Shuffled 相比, ReduceByKey是高效的, 因为类似 MapReduce 的, 在 Map 端有一个 Cominer, 这样 I/O 的数据便会减少

总结

  1. map 和 flatMap 算子都是转换, 只是 flatMap 在转换过后会再执行展开, 所以 map 是一对一, flatMap 是一对多
  2. reduceByKey 类似 MapReduce 中的 Reduce

目标

  1. 深入理解 RDD 的内在逻辑
  2. 能够使用 RDD 的算子
  3. 理解 RDD 算子的 Shuffle 和缓存
  4. 理解 RDD 整体的使用流程
  5. 理解 RDD 的调度原理
  6. 理解 Spark 中常见的分布式变量共享方式

深入 RDD

1. 深入 RDD

目标

  1. 深入理解 RDD 的内在逻辑, 以及 RDD 的内部属性(RDD 由什么组成)

1.1. 案例

需求

  • 给定一个网站的访问记录, 俗称 Access log
  • 计算其中出现的独立 IP, 以及其访问的次数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]")
val sc = new SparkContext(config)

val result = sc.textFile("dataset/access_log_sample.txt")
.map(item => (item.split(" ")(0), 1))
.filter(item => StringUtils.isNotBlank(item._1))
.reduceByKey((curr, agg) => curr + agg)
.sortBy(item => item._2, false)
.take(10)

result.foreach(item => println(item))`</pre>
</div>
</div>
<div class="paragraph">

针对这个小案例, 我们问出互相关联但是又方向不同的五个问题

</div>
<div class="qlist qanda">
  1. 假设要针对整个网站的历史数据进行处理, 量有 1T, 如何处理?

    放在集群中, 利用集群多台计算机来并行处理

  2. 如何放在集群中运行?

    6088be299490adbaaeece8717ae985e8

    简单来讲, 并行计算就是同时使用多个计算资源解决一个问题, 有如下四个要点

    1
    *   要解决的问题必须可以分解为多个可以并发计算的部分
    • 每个部分要可以在不同处理器上被同时执行
    • 需要一个共享内存的机制
    • 需要一个总体上的协作机制来进行调度
  3. 如果放在集群中的话, 可能要对整个计算任务进行分解, 如何分解?

    f738dbe3df690bc0ba8f580a3e2d1112

    概述

    1
    *   对于 HDFS 中的文件, 是分为不同的 Block 的
    • 在进行计算的时候, 就可以按照 Block 来划分, 每一个 Block 对应一个不同的计算单元

    扩展

    1
    *   `RDD` 并没有真实的存放数据, 数据是从 HDFS 中读取的, 在计算的过程中读取即可
  4. RDD 至少是需要可以 分片 的, 因为 HDFS 中的文件就是分片的, RDD 分片的意义在于表示对源数据集每个分片的计算, RDD 可以分片也意味着 可以并行计算

移动数据不如移动计算是一个基础的优化, 如何做到?

1d344ab200bd12866c26ca2ea6ab1e37

每一个计算单元需要记录其存储单元的位置, 尽量调度过去

在集群中运行, 需要很多节点之间配合, 出错的概率也更高, 出错了怎么办?

5c7bef41f177a96e99c7ad8a500b7310

RDD1 → RDD2 → RDD3 这个过程中, RDD2 出错了, 有两种办法可以解决

1
1.  缓存 RDD2 的数据, 直接恢复 RDD2, 类似 HDFS 的备份机制
  1. 记录 RDD2 的依赖关系, 通过其父级的 RDD 来恢复 RDD2, 这种方式会少很多数据的交互和保存

如何通过父级 RDD 来恢复?

1
1.  记录 RDD2 的父亲是 RDD1

记录 RDD2 的计算函数, 例如记录 RDD2 = RDD1.map(…), map(…) 就是计算函数

当 RDD2 计算出错的时候, 可以通过父级 RDD 和计算函数来恢复 RDD2

假如任务特别复杂, 流程特别长, 有很多 RDD 之间有依赖关系, 如何优化?

dc87ed7f9b653bccb43d099bbb4f537f

上面提到了可以使用依赖关系来进行容错, 但是如果依赖关系特别长的时候, 这种方式其实也比较低效, 这个时候就应该使用另外一种方式, 也就是记录数据集的状态

在 Spark 中有两个手段可以做到

1
1.  缓存
  1. Checkpoint

1.2. 再谈 RDD

目标

理解 RDD 为什么会出现

理解 RDD 的主要特点

理解 RDD 的五大属性

1.2.1. RDD 为什么会出现?

在 RDD 出现之前, 当时 MapReduce 是比较主流的, 而 MapReduce 如何执行迭代计算的任务呢?

306061ee343d8515ecafbce43bc54bc6

多个 MapReduce 任务之间没有基于内存的数据共享方式, 只能通过磁盘来进行共享

这种方式明显比较低效

RDD 如何解决迭代计算非常低效的问题呢?

4fc644616fb13ef896eb3a8cea5d3bd7

在 Spark 中, 其实最终 Job3 从逻辑上的计算过程是: Job3 = (Job1.map).filter, 整个过程是共享内存的, 而不需要将中间结果存放在可靠的分布式文件系统中

这种方式可以在保证容错的前提下, 提供更多的灵活, 更快的执行速度, RDD 在执行迭代型任务时候的表现可以通过下面代码体现

1
2
3
4
5
6
7
8
9
10
`// 线性回归
val points = sc.textFile(...)
.map(...)
.persist(...)
val w = randomValue
for (i <- 1 to 10000) {
val gradient = points.map(p => p.x * (1 / (1 + exp(-p.y * (w dot p.x))) - 1) * p.y)
.reduce(_ + _)
w -= gradient
}`

在这个例子中, 进行了大致 10000 次迭代, 如果在 MapReduce 中实现, 可能需要运行很多 Job, 每个 Job 之间都要通过 HDFS 共享结果, 熟快熟慢一窥便知

1.2.2. RDD 的特点

RDD 不仅是数据集, 也是编程模型

RDD 即是一种数据结构, 同时也提供了上层 API, 同时 RDD 的 API 和 Scala 中对集合运算的 API 非常类似, 同样也都是各种算子

02adfc1bcd91e70c1619fc6a67b13f92

RDD 的算子大致分为两类:

  • Transformation 转换操作, 例如 map flatMap filter
  • Action 动作操作, 例如 reduce collect show

执行 RDD 的时候, 在执行到转换操作的时候, 并不会立刻执行, 直到遇见了 Action 操作, 才会触发真正的执行, 这个特点叫做 惰性求值

RDD 可以分区

2ba2cc9ad8e745c26df482b4e968c802

RDD 是一个分布式计算框架, 所以, 一定是要能够进行分区计算的, 只有分区了, 才能利用集群的并行计算能力

同时, RDD 不需要始终被具体化, 也就是说: RDD 中可以没有数据, 只要有足够的信息知道自己是从谁计算得来的就可以, 这是一种非常高效的容错方式

RDD 是只读的

ed6a534cfe0a56de3c34ac6e1e8d504e

RDD 是只读的, 不允许任何形式的修改. 虽说不能因为 RDD 和 HDFS 是只读的, 就认为分布式存储系统必须设计为只读的. 但是设计为只读的, 会显著降低问题的复杂度, 因为 RDD 需要可以容错, 可以惰性求值, 可以移动计算, 所以很难支持修改.

RDD2 中可能没有数据, 只是保留了依赖关系和计算函数, 那修改啥?

如果因为支持修改, 而必须保存数据的话, 怎么容错?

如果允许修改, 如何定位要修改的那一行? RDD 的转换是粗粒度的, 也就是说, RDD 并不感知具体每一行在哪.

RDD 是可以容错的

5c7bef41f177a96e99c7ad8a500b7310

RDD 的容错有两种方式

保存 RDD 之间的依赖关系, 以及计算函数, 出现错误重新计算

直接将 RDD 的数据存放在外部存储系统, 出现错误直接读取, Checkpoint

1.2.3. 什么叫做弹性分布式数据集

分布式

RDD 支持分区, 可以运行在集群中

弹性

RDD 支持高效的容错

RDD 中的数据即可以缓存在内存中, 也可以缓存在磁盘中, 也可以缓存在外部存储中

数据集

RDD 可以不保存具体数据, 只保留创建自己的必备信息, 例如依赖和计算函数

RDD 也可以缓存起来, 相当于存储具体数据

总结: RDD 的五大属性

首先整理一下上面所提到的 RDD 所要实现的功能:

  1. RDD 有分区
  2. RDD 要可以通过依赖关系和计算函数进行容错
  3. RDD 要针对数据本地性进行优化
  4. RDD 支持 MapReduce 形式的计算, 所以要能够对数据进行 Shuffled

对于 RDD 来说, 其中应该有什么内容呢? 如果站在 RDD 设计者的角度上, 这个类中, 至少需要什么属性?

  • Partition List 分片列表, 记录 RDD 的分片, 可以在创建 RDD 的时候指定分区数目, 也可以通过算子来生成新的 RDD 从而改变分区数目
  • Compute Function 为了实现容错, 需要记录 RDD 之间转换所执行的计算函数
  • RDD Dependencies RDD 之间的依赖关系, 要在 RDD 中记录其上级 RDD 是谁, 从而实现容错和计算
  • Partitioner 为了执行 Shuffled 操作, 必须要有一个函数用来计算数据应该发往哪个分区
  • Preferred Location 优先位置, 为了实现数据本地性操作, 从而移动计算而不是移动存储, 需要记录每个 RDD 分区最好应该放置在什么位置

2. RDD 的算子

目标

  1. 理解 RDD 的算子分类, 以及其特性
  2. 理解常见算子的使用

分类

RDD 中的算子从功能上分为两大类

Transformation(转换) 它会在一个已经存在的 RDD 上创建一个新的 RDD, 将旧的 RDD 的数据转换为另外一种形式后放入新的 RDD

Action(动作) 执行各个分区的计算任务, 将的到的结果返回到 Driver 中

RDD 中可以存放各种类型的数据, 那么对于不同类型的数据, RDD 又可以分为三类

  • 针对基础类型(例如 String)处理的普通算子
  • 针对 Key-Value 数据处理的 byKey 算子
  • 针对数字类型数据处理的计算算子

特点

Spark 中所有的 Transformations 是 Lazy(惰性) 的, 它们不会立即执行获得结果. 相反, 它们只会记录在数据集上要应用的操作. 只有当需要返回结果给 Driver 时, 才会执行这些操作, 通过 DAGScheduler 和 TaskScheduler 分发到集群中运行, 这个特性叫做 惰性求值

默认情况下, 每一个 Action 运行的时候, 其所关联的所有 Transformation RDD 都会重新计算, 但是也可以使用 presist 方法将 RDD 持久化到磁盘或者内存中. 这个时候为了下次可以更快的访问, 会把数据保存到集群上.

2.1. Transformations 算子

Transformation function 解释
map(T ⇒ U) sc.parallelize(Seq(1, 2, 3)) .map( num => num * 10 ) .collect()57c2f77284bfa8f99ade091fdd7e9f83c59d44296918b864a975ebbeb60d4c04作用把 RDD 中的数据 一对一 的转为另一种形式签名def map[U: ClassTag](f: T ⇒ U): RDD[U]参数f → Map 算子是 原RDD → 新RDD 的过程, 传入函数的参数是原 RDD 数据, 返回值是经过函数转换的新 RDD 的数据注意点Map 是一对一, 如果函数是 String → Array[String] 则新的 RDD 中每条数据就是一个数组
flatMap(T ⇒ List[U]) sc.parallelize(Seq("Hello lily", "Hello lucy", "Hello tim")) .flatMap( line => line.split(" ") ) .collect()ec39594f30ca4d59e2ef5cdc60387866f6c4feba14bb71372aa0cb678067c6a8作用FlatMap 算子和 Map 算子类似, 但是 FlatMap 是一对多调用def flatMap[U: ClassTag](f: T ⇒ List[U]): RDD[U]参数f → 参数是原 RDD 数据, 返回值是经过函数转换的新 RDD 的数据, 需要注意的是返回值是一个集合, 集合中的数据会被展平后再放入新的 RDD注意点flatMap 其实是两个操作, 是 map + flatten, 也就是先转换, 后把转换而来的 List 展开Spark 中并没有直接展平 RDD 中数组的算子, 可以使用 flatMap 做这件事
filter(T ⇒ Boolean) sc.parallelize(Seq(1, 2, 3)) .filter( value => value >= 3 ) .collect()25a7aef5e2b8a39145d503f4652cc94505cdb79abd41a7b5baa41a4c62870d73作用Filter 算子的主要作用是过滤掉不需要的内容
mapPartitions(List[T] ⇒ List[U]) RDD[T] ⇒ RDD[U] 和 map 类似, 但是针对整个分区的数据转换
mapPartitionsWithIndex 和 mapPartitions 类似, 只是在函数中增加了分区的 Index
mapValues sc.parallelize(Seq(("a", 1), ("b", 2), ("c", 3))) .mapValues( value => value * 10 ) .collect()7a8b280a054fdab8e8d14549f67b85f9)5551847febe453b134f3a4009df01bec作用MapValues 只能作用于 Key-Value 型数据, 和 Map 类似, 也是使用函数按照转换数据, 不同点是 MapValues 只转换 Key-Value 中的 Value
sample(withReplacement, fraction, seed) sc.parallelize(Seq(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)) .sample(withReplacement = true, 0.6, 2) .collect()03139edb0211652195dccea955f3a9b3ccd1ae121f6f6852158c044441437f04作用Sample 算子可以从一个数据集中抽样出来一部分, 常用作于减小数据集以保证运行速度, 并且尽可能少规律的损失参数Sample 接受第一个参数为 withReplacement, 意为是否取样以后是否还放回原数据集供下次使用, 简单的说, 如果这个参数的值为 true, 则抽样出来的数据集中可能会有重复Sample 接受第二个参数为 fraction, 意为抽样的比例Sample 接受第三个参数为 seed, 随机数种子, 用于 Sample 内部随机生成下标, 一般不指定, 使用默认值
union(other) val rdd1 = sc.parallelize(Seq(1, 2, 3)) val rdd2 = sc.parallelize(Seq(4, 5, 6)) rdd1.union(rdd2) .collect()5f31c2c44aa66db3027fea4624a3c4eb2a8b7d10930251ae32d6d276ab7f41f8
intersection(other) val rdd1 = sc.parallelize(Seq(1, 2, 3, 4, 5)) val rdd2 = sc.parallelize(Seq(4, 5, 6, 7, 8)) rdd1.intersection(rdd2) .collect()a4475b1193be01efc305ef3c39f4b1e876a9873eae8de8a9ed5223921da7c245作用Intersection 算子是一个集合操作, 用于求得 左侧集合 和 右侧集合 的交集, 换句话说, 就是左侧集合和右侧集合都有的元素, 并生成一个新的 RDD
subtract(other, numPartitions) (RDD[T], RDD[T]) ⇒ RDD[T] 差集, 可以设置分区数
distinct(numPartitions) sc.parallelize(Seq(1, 1, 2, 2, 3)) .distinct() .collect()a8cd033d9ce502337ba746d05ca94ae12bfefe5f5cab497d5aded3b7537a58ba作用Distinct 算子用于去重注意点Distinct 是一个需要 Shuffled 的操作本质上 Distinct 就是一个 reductByKey, 把重复的合并为一个
reduceByKey((V, V) ⇒ V, numPartition) sc.parallelize(Seq(("a", 1), ("a", 1), ("b", 1))) .reduceByKey( (curr, agg) => curr + agg ) .collect()a9b444d144d6996c83b33f6a48806a1a07678e1b4d6ba1dfaf2f5df89489def4作用首先按照 Key 分组生成一个 Tuple, 然后针对每个组执行 reduce 算子调用def reduceByKey(func: (V, V) ⇒ V): RDD[(K, V)]参数func → 执行数据处理的函数, 传入两个参数, 一个是当前值, 一个是局部汇总, 这个函数需要有一个输出, 输出就是这个 Key 的汇总结果注意点ReduceByKey 只能作用于 Key-Value 型数据, Key-Value 型数据在当前语境中特指 Tuple2ReduceByKey 是一个需要 Shuffled 的操作和其它的 Shuffled 相比, ReduceByKey 是高效的, 因为类似 MapReduce 的, 在 Map 端有一个 Cominer, 这样 I/O 的数据便会减少
groupByKey() sc.parallelize(Seq(("a", 1), ("a", 1), ("b", 1))) .groupByKey() .collect()466c1ad2b738c4f0d27f2557ecedaf5b27de81df110abb6709bf1c5ffad184ab作用GroupByKey 算子的主要作用是按照 Key 分组, 和 ReduceByKey 有点类似, 但是 GroupByKey 并不求聚合, 只是列举 Key 对应的所有 Value注意点GroupByKey 是一个 ShuffledGroupByKey 和 ReduceByKey 不同, 因为需要列举 Key 对应的所有数据, 所以无法在 Map 端做 Combine, 所以 GroupByKey 的性能并没有 ReduceByKey 好
combineByKey() val rdd = sc.parallelize(Seq( ("zhangsan", 99.0), ("zhangsan", 96.0), ("lisi", 97.0), ("lisi", 98.0), ("zhangsan", 97.0)) ) val combineRdd = rdd.combineByKey( score => (score, 1), (scoreCount: (Double, Int),newScore) => (scoreCount._1 + newScore, scoreCount._2 + 1), (scoreCount1: (Double, Int), scoreCount2: (Double, Int)) => (scoreCount1._1 + scoreCount2._1, scoreCount1._2 + scoreCount2._2) ) val meanRdd = combineRdd.map(score => (score._1, score._2._1 / score._2._2)) meanRdd.collect()741d814a50e4c01686f394df079458bf作用对数据集按照 Key 进行聚合调用combineByKey(createCombiner, mergeValue, mergeCombiners, [partitioner], [mapSideCombiner], [serializer])参数createCombiner 将 Value 进行初步转换mergeValue 在每个分区把上一步转换的结果聚合mergeCombiners 在所有分区上把每个分区的聚合结果聚合partitioner 可选, 分区函数mapSideCombiner 可选, 是否在 Map 端 Combineserializer 序列化器注意点combineByKey 的要点就是三个函数的意义要理解groupByKey, reduceByKey 的底层都是 combineByKey
aggregateByKey() val rdd = sc.parallelize(Seq(("手机", 10.0), ("手机", 15.0), ("电脑", 20.0))) val result = rdd.aggregateByKey(0.8)( seqOp = (zero, price) => price * zero, combOp = (curr, agg) => curr + agg ).collect() println(result)ee33b17dbc78705dbbd76d76ab4a9072作用聚合所有 Key 相同的 Value, 换句话说, 按照 Key 聚合 Value调用rdd.aggregateByKey(zeroValue)(seqOp, combOp)参数zeroValue 初始值seqOp 转换每一个值的函数comboOp 将转换过的值聚合的函数注意点为什么需要两个函数? * aggregateByKey 运行将一个 RDD[(K, V)] 聚合为 RDD[(K, U)], 如果要做到这件事的话, 就需要先对数据做一次转换, 将每条数据从 V 转为 U, seqOp 就是干这件事的 ** 当 seqOp 的事情结束以后, comboOp 把其结果聚合和 reduceByKey 的区别::` aggregateByKey 最终聚合结果的类型和传入的初始值类型保持一致`reduceByKey 在集合中选取第一个值作为初始值, 并且聚合过的数据类型不能改变
foldByKey(zeroValue)((V, V) ⇒ V) sc.parallelize(Seq(("a", 1), ("a", 1), ("b", 1))) .foldByKey(zeroValue = 10)( (curr, agg) => curr + agg ) .collect()c00063a109a0f9e0b1c2b385c5e1cc47a406ff8395bb092e719007661b34d385作用和 ReduceByKey 是一样的, 都是按照 Key 做分组去求聚合, 但是 FoldByKey 的不同点在于可以指定初始值调用foldByKey(zeroValue)(func)参数zeroValue 初始值func seqOp 和 combOp 相同, 都是这个参数注意点FoldByKey 是 AggregateByKey 的简化版本, seqOp 和 combOp 是同一个函数FoldByKey 指定的初始值作用于每一个 Value
join(other, numPartitions) val rdd1 = sc.parallelize(Seq(("a", 1), ("a", 2), ("b", 1))) val rdd2 = sc.parallelize(Seq(("a", 10), ("a", 11), ("a", 12))) rdd1.join(rdd2).collect()bb3eda1410d3b0f6e1bff6d5e6a45879作用将两个 RDD 按照相同的 Key 进行连接调用join(other, [partitioner or numPartitions])参数other 其它 RDDpartitioner or numPartitions 可选, 可以通过传递分区函数或者分区数量来改变分区注意点Join 有点类似于 SQL 中的内连接, 只会再结果中包含能够连接到的 KeyJoin 的结果是一个笛卡尔积形式, 例如 "a", 1), ("a", 2"a", 10), ("a", 11 的 Join 结果集是 "a", 1, 10), ("a", 1, 11), ("a", 2, 10), ("a", 2, 11
cogroup(other, numPartitions) val rdd1 = sc.parallelize(Seq(("a", 1), ("a", 2), ("a", 5), ("b", 2), ("b", 6), ("c", 3), ("d", 2))) val rdd2 = sc.parallelize(Seq(("a", 10), ("b", 1), ("d", 3))) val rdd3 = sc.parallelize(Seq(("b", 10), ("a", 1))) val result1 = rdd1.cogroup(rdd2).collect() val result2 = rdd1.cogroup(rdd2, rdd3).collect() /_ 执行结果: Array( (d,(CompactBuffer(2),CompactBuffer(3))), (a,(CompactBuffer(1, 2, 5),CompactBuffer(10))), (b,(CompactBuffer(2, 6),CompactBuffer(1))), (c,(CompactBuffer(3),CompactBuffer())) ) _/ println(result1) /_ 执行结果: Array( (d,(CompactBuffer(2),CompactBuffer(3),CompactBuffer())), (a,(CompactBuffer(1, 2, 5),CompactBuffer(10),CompactBuffer(1))), (b,(CompactBuffer(2, 6),CompactBuffer(1),Co... _/ println(result2)42262ffe7f3ff35013fbe534d78e3518作用多个 RDD 协同分组, 将多个 RDD 中 Key 相同的 Value 分组调用cogroup(rdd1, rdd2, rdd3, [partitioner or numPartitions])参数rdd… 最多可以传三个 RDD 进去, 加上调用者, 可以为四个 RDD 协同分组partitioner or numPartitions 可选, 可以通过传递分区函数或者分区数来改变分区注意点对 RDD1, RDD2, RDD3 进行 cogroup, 结果中就一定会有三个 List, 如果没有 Value 则是空 List, 这一点类似于 SQL 的全连接, 返回所有结果, 即使没有关联上CoGroup 是一个需要 Shuffled 的操作
cartesian(other) (RDD[T], RDD[U]) ⇒ RDD[(T, U)] 生成两个 RDD 的笛卡尔积
sortBy(ascending, numPartitions) val rdd1 = sc.parallelize(Seq(("a", 3), ("b", 2), ("c", 1))) val sortByResult = rdd1.sortBy( item => item._2 ).collect() val sortByKeyResult = rdd1.sortByKey().collect() println(sortByResult) println(sortByKeyResult)作用排序相关相关的算子有两个, 一个是 sortBy, 另外一个是 sortByKey调用sortBy(func, ascending, numPartitions)参数func 通过这个函数返回要排序的字段ascending 是否升序numPartitions 分区数注意点普通的 RDD 没有 sortByKey, 只有 Key-Value 的 RDD 才有sortBy 可以指定按照哪个字段来排序, sortByKey 直接按照 Key 来排序
partitionBy(partitioner) 使用用传入的 partitioner 重新分区, 如果和当前分区函数相同, 则忽略操作
coalesce(numPartitions) 减少分区数val rdd = sc.parallelize(Seq(("a", 3), ("b", 2), ("c", 1))) val oldNum = rdd.partitions.length val coalesceRdd = rdd.coalesce(4, shuffle = true) val coalesceNum = coalesceRdd.partitions.length val repartitionRdd = rdd.repartition(4) val repartitionNum = repartitionRdd.partitions.length print(oldNum, coalesceNum, repartitionNum)作用一般涉及到分区操作的算子常见的有两个, repartitioincoalesce, 两个算子都可以调大或者调小分区数量调用repartitioin(numPartitions)``coalesce(numPartitions, shuffle)参数numPartitions 新的分区数shuffle 是否 shuffle, 如果新的分区数量比原分区数大, 必须 Shuffled, 否则重分区无效注意点repartitioncoalesce 的不同就在于 coalesce 可以控制是否 Shufflerepartition 是一个 Shuffled 操作
repartition(numPartitions) 重新分区
repartitionAndSortWithinPartitions 重新分区的同时升序排序, 在 partitioner 中排序, 比先重分区再排序要效率高, 建议使用在需要分区后再排序的场景使用

2.2. Action 算子

Action function 解释
reduce( (T, T) ⇒ U ) val rdd = sc.parallelize(Seq(("手机", 10.0), ("手机", 15.0), ("电脑", 20.0))) val result = rdd.reduce((curr, agg) => ("总价", curr._2 + agg._2)) println(result)作用对整个结果集规约, 最终生成一条数据, 是整个数据集的汇总调用reduce( (currValue[T], agg[T]) ⇒ T )注意点reduce 和 reduceByKey 是完全不同的, reduce 是一个 action, 并不是 Shuffled 操作本质上 reduce 就是现在每个 partition 上求值, 最终把每个 partition 的结果再汇总
collect() 以数组的形式返回数据集中所有元素
count() 返回元素个数
first() 返回第一个元素
take( N ) 返回前 N 个元素
takeSample(withReplacement, fract) 类似于 sample, 区别在这是一个 Action, 直接返回结果
fold(zeroValue)( (T, T) ⇒ U ) 指定初始值和计算函数, 折叠聚合整个数据集
saveAsTextFile(path) 将结果存入 path 对应的文件中
saveAsSequenceFile(path) 将结果存入 path 对应的 Sequence 文件中
countByKey() val rdd = sc.parallelize(Seq(("手机", 10.0), ("手机", 15.0), ("电脑", 20.0))) val result = rdd.countByKey() println(result)作用求得整个数据集中 Key 以及对应 Key 出现的次数注意点返回结果为 Map(key → count)常在解决数据倾斜问题时使用, 查看倾斜的 Key
foreach( T ⇒ … ) 遍历每一个元素

应用

1
2
3
4
5
6
7
8
9
​````scala
val rdd = sc.parallelize(Seq(("手机", 10.0), ("手机", 15.0), ("电脑", 20.0)))
// 结果: Array((手机,10.0), (手机,15.0), (电脑,20.0))
println(rdd.collect())
// 结果: Array((手机,10.0), (手机,15.0))
println(rdd.take(2))
// 结果: (手机,10.0)
println(rdd.first())
​````

总结

RDD 的算子大部分都会生成一些专用的 RDD

1
2
map`, `flatMap`, `filter` 等算子会生成 `MapPartitionsRDD
coalesce`, `repartition` 等算子会生成 `CoalescedRDD

常见的 RDD 有两种类型

转换型的 RDD, Transformation

动作型的 RDD, Action

常见的 Transformation 类型的 RDD

map

flatMap

filter

groupBy

reduceByKey

常见的 Action 类型的 RDD

collect

countByKey

reduce

2.3. RDD 对不同类型数据的支持

目标

  1. 理解 RDD 对 Key-Value 类型的数据是有专门支持的
  2. 理解 RDD 对数字类型也有专门的支持

一般情况下 RDD 要处理的数据有三类

  • 字符串
  • 键值对
  • 数字型

RDD 的算子设计对这三类不同的数据分别都有支持

对于以字符串为代表的基本数据类型是比较基础的一些的操作, 诸如 map, flatMap, filter 等基础的算子

对于键值对类型的数据, 有额外的支持, 诸如 reduceByKey, groupByKey 等 byKey 的算子

同样对于数字型的数据也有额外的支持, 诸如 max, min 等

RDD 对键值对数据的额外支持

键值型数据本质上就是一个二元元组, 键值对类型的 RDD 表示为 RDD[(K, V)]

RDD 对键值对的额外支持是通过隐式支持来完成的, 一个 RDD[(K, V)], 可以被隐式转换为一个 PairRDDFunctions 对象, 从而调用其中的方法.

3b365c28403495cb8d07a2ee5d0a6376

既然对键值对的支持是通过 PairRDDFunctions 提供的, 那么从 PairRDDFunctions 中就可以看到这些支持有什么

类别 算子
聚合操作 reduceByKey
foldByKey
combineByKey
分组操作 cogroup
groupByKey
连接操作 join
leftOuterJoin
rightOuterJoin
排序操作 sortBy
sortByKey
Action countByKey
take
collect

RDD 对数字型数据的额外支持

对于数字型数据的额外支持基本上都是 Action 操作, 而不是转换操作

算子 含义
count 个数
mean 均值
sum 求和
max 最大值
min 最小值
variance 方差
sampleVariance 从采样中计算方差
stdev 标准差
sampleStdev 采样的标准差
1
2
3
`val rdd = sc.parallelize(Seq(1, 2, 3))
// 结果: 3
println(rdd.max())`

2.4. 阶段练习和总结

导读

  1. 通过本节, 希望大家能够理解 RDD 的一般使用步骤
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
`// 1. 创建 SparkContext
val conf = new SparkConf().setMaster("local[6]").setAppName("stage_practice1")
val sc = new SparkContext(conf)

// 2. 创建 RDD
val rdd1 = sc.textFile("dataset/BeijingPM20100101_20151231_noheader.csv")
// 3. 处理 RDD
val rdd2 = rdd1.map { item =>
val fields = item.split(",")
((fields(1), fields(2)), fields(6))
}
val rdd3 = rdd2.filter { item => !item._2.equalsIgnoreCase("NA") }
val rdd4 = rdd3.map { item => (item._1, item._2.toInt) }
val rdd5 = rdd4.reduceByKey { (curr, agg) => curr + agg }
val rdd6 = rdd5.sortByKey(ascending = false)
// 4. 行动, 得到结果
println(rdd6.first())`

通过上述代码可以看到, 其实 RDD 的整体使用步骤如下

20190518105630

3. RDD 的 Shuffle 和分区

目标

RDD 的分区操作

Shuffle 的原理

分区的作用

RDD 使用分区来分布式并行处理数据, 并且要做到尽量少的在不同的 Executor 之间使用网络交换数据, 所以当使用 RDD 读取数据的时候, 会尽量的在物理上靠近数据源, 比如说在读取 Cassandra 或者 HDFS 中数据的时候, 会尽量的保持 RDD 的分区和数据源的分区数, 分区模式等一一对应

分区和 Shuffle 的关系

分区的主要作用是用来实现并行计算, 本质上和 Shuffle 没什么关系, 但是往往在进行数据处理的时候, 例如 reduceByKey, groupByKey 等聚合操作, 需要把 Key 相同的 Value 拉取到一起进行计算, 这个时候因为这些 Key 相同的 Value 可能会坐落于不同的分区, 于是理解分区才能理解 Shuffle 的根本原理

Spark 中的 Shuffle 操作的特点

  • 只有 Key-Value 型的 RDD 才会有 Shuffle 操作, 例如 RDD[(K, V)], 但是有一个特例, 就是 repartition 算子可以对任何数据类型 Shuffle
  • 早期版本 Spark 的 Shuffle 算法是 Hash base shuffle, 后来改为 Sort base shuffle, 更适合大吞吐量的场景

3.1. RDD 的分区操作

查看分区数

1
2
`scala> sc.parallelize(1 to 100).count
res0: Long = 100`

873af6194db362a1ab5432372aa8bd21

之所以会有 8 个 Tasks, 是因为在启动的时候指定的命令是 spark-shell --master local[8], 这样会生成 1 个 Executors, 这个 Executors 有 8 个 Cores, 所以默认会有 8 个 Tasks, 每个 Cores 对应一个分区, 每个分区对应一个 Tasks, 可以通过 rdd.partitions.size 来查看分区数量

a41901e5af14f37c88b3f1ea9b97fbfb

同时也可以通过 spark-shell 的 WebUI 来查看 Executors 的情况

24b2646308923d7549a7758f7550e0a8

默认的分区数量是和 Cores 的数量有关的, 也可以通过如下三种方式修改或者重新指定分区数量

创建 RDD 时指定分区数

1
2
3
4
5
6
7
8
9
`scala> val rdd1 = sc.parallelize(1 to 100, 6)
rdd1: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[1] at parallelize at <console>:24

scala> rdd1.partitions.size
res1: Int = 6
scala> val rdd2 = sc.textFile("hdfs:///dataset/wordcount.txt", 6)
rdd2: org.apache.spark.rdd.RDD[String] = hdfs:///dataset/wordcount.txt MapPartitionsRDD[3] at textFile at <console>:24
scala> rdd2.partitions.size
res2: Int = 7`

rdd1 是通过本地集合创建的, 创建的时候通过第二个参数指定了分区数量. rdd2 是通过读取 HDFS 中文件创建的, 同样通过第二个参数指定了分区数, 因为是从 HDFS 中读取文件, 所以最终的分区数是由 Hadoop 的 InputFormat 来指定的, 所以比指定的分区数大了一个.

通过 coalesce 算子指定

1
`coalesce(numPartitions: Int, shuffle: Boolean = false)(implicit ord: Ordering[T] = null): RDD[T]`

numPartitions

新生成的 RDD 的分区数

shuffle

是否 Shuffle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
`scala> val source = sc.parallelize(1 to 100, 6)
source: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[0] at parallelize at <console>:24

scala> source.partitions.size
res0: Int = 6
scala> val noShuffleRdd = source.coalesce(numPartitions=8, shuffle=false)
noShuffleRdd: org.apache.spark.rdd.RDD[Int] = CoalescedRDD[1] at coalesce at <console>:26
scala> noShuffleRdd.toDebugString (1)
res1: String =
(6) CoalescedRDD[1] at coalesce at <console>:26 []
| ParallelCollectionRDD[0] at parallelize at <console>:24 []
scala> val noShuffleRdd = source.coalesce(numPartitions=8, shuffle=false)
noShuffleRdd: org.apache.spark.rdd.RDD[Int] = CoalescedRDD[1] at coalesce at <console>:26
scala> shuffleRdd.toDebugString (2)
res3: String =
(8) MapPartitionsRDD[5] at coalesce at <console>:26 []
| CoalescedRDD[4] at coalesce at <console>:26 []
| ShuffledRDD[3] at coalesce at <console>:26 []
+-(6) MapPartitionsRDD[2] at coalesce at <console>:26 []
| ParallelCollectionRDD[0] at parallelize at <console>:24 []
scala> noShuffleRdd.partitions.size (3)
res4: Int = 6
scala> shuffleRdd.partitions.size
res5: Int = 8`
1 如果 shuffle 参数指定为 false, 运行计划中确实没有 ShuffledRDD, 没有 shuffled 这个过程
2 如果 shuffle 参数指定为 true, 运行计划中有一个 ShuffledRDD, 有一个明确的显式的 shuffled 过程
3 如果 shuffle 参数指定为 false 却增加了分区数, 分区数并不会发生改变, 这是因为增加分区是一个宽依赖, 没有 shuffled 过程无法做到, 后续会详细解释宽依赖的概念

通过 repartition 算子指定

1
2
`repartition(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T]`
repartition` 算子本质上就是 `coalesce(numPartitions, shuffle = true)

45d7a2b6e9e2727504e9cf28adbe6c49

1
2
3
4
5
6
7
8
9
`scala> val source = sc.parallelize(1 to 100, 6)
source: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[7] at parallelize at <console>:24

scala> source.partitions.size
res7: Int = 6
scala> source.repartition(100).partitions.size (1)
res8: Int = 100
scala> source.repartition(1).partitions.size (2)
res9: Int = 1`
1 增加分区有效
2 减少分区有效

repartition 算子无论是增加还是减少分区都是有效的, 因为本质上 repartition 会通过 shuffle 操作把数据分发给新的 RDD 的不同的分区, 只有 shuffle 操作才可能做到增大分区数, 默认情况下, 分区函数是 RoundRobin, 如果希望改变分区函数, 也就是数据分布的方式, 可以通过自定义分区函数来实现

b1181258789202436ca6d2d92e604d59

3.2. RDD 的 Shuffle 是什么

1
2
3
4
`val sourceRdd = sc.textFile("hdfs://node01:9020/dataset/wordcount.txt")
val flattenCountRdd = sourceRdd.flatMap(_.split(" ")).map((_, 1))
val aggCountRdd = flattenCountRdd.reduceByKey(_ + _)
val result = aggCountRdd.collect`

23377ac4a368fc94b6f8f3117af67154

10b536c17409ec37fa1f1b308b2b521e

reduceByKey 这个算子本质上就是先按照 Key 分组, 后对每一组数据进行 reduce, 所面临的挑战就是 Key 相同的所有数据可能分布在不同的 Partition 分区中, 甚至可能在不同的节点中, 但是它们必须被共同计算.

为了让来自相同 Key 的所有数据都在 reduceByKey 的同一个 reduce 中处理, 需要执行一个 all-to-all 的操作, 需要在不同的节点(不同的分区)之间拷贝数据, 必须跨分区聚集相同 Key 的所有数据, 这个过程叫做 Shuffle.

3.3. RDD 的 Shuffle 原理

Spark 的 Shuffle 发展大致有两个阶段: Hash base shuffleSort base shuffle

Hash base shuffle

2daf43cc1750fffab62ae5e16fab54c2

大致的原理是分桶, 假设 Reducer 的个数为 R, 那么每个 Mapper 有 R 个桶, 按照 Key 的 Hash 将数据映射到不同的桶中, Reduce 找到每一个 Mapper 中对应自己的桶拉取数据.

假设 Mapper 的个数为 M, 整个集群的文件数量是 M * R, 如果有 1,000 个 Mapper 和 Reducer, 则会生成 1,000,000 个文件, 这个量非常大了.

过多的文件会导致文件系统打开过多的文件描述符, 占用系统资源. 所以这种方式并不适合大规模数据的处理, 只适合中等规模和小规模的数据处理, 在 Spark 1.2 版本中废弃了这种方式.

Sort base shuffle

94f038994f8553dd32370ae78878d038

对于 Sort base shuffle 来说, 每个 Map 侧的分区只有一个输出文件, Reduce 侧的 Task 来拉取, 大致流程如下

  1. Map 侧将数据全部放入一个叫做 AppendOnlyMap 的组件中, 同时可以在这个特殊的数据结构中做聚合操作

  2. 然后通过一个类似于 MergeSort 的排序算法 TimSort 对 AppendOnlyMap 底层的 Array 排序

    1
    *   先按照 Partition ID 排序, 后按照 Key 的 HashCode 排序
  3. 最终每个 Map Task 生成一个 输出文件, Reduce Task 来拉取自己对应的数据

从上面可以得到结论, Sort base shuffle 确实可以大幅度减少所产生的中间文件, 从而能够更好的应对大吞吐量的场景, 在 Spark 1.2 以后, 已经默认采用这种方式.

但是需要大家知道的是, Spark 的 Shuffle 算法并不只是这一种, 即使是在最新版本, 也有三种 Shuffle 算法, 这三种算法对每个 Map 都只产生一个临时文件, 但是产生文件的方式不同, 一种是类似 Hash 的方式, 一种是刚才所说的 Sort, 一种是对 Sort 的一种优化(使用 Unsafe API 直接申请堆外内存)

4. 缓存

概要

缓存的意义

缓存相关的 API

缓存级别以及最佳实践

4.1. 缓存的意义

使用缓存的原因 - 多次使用 RDD

需求: 在日志文件中找到访问次数最少的 IP 和访问次数最多的 IP

1
2
3
4
5
6
7
8
9
10
11
`val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
.map(item => (item.split(" ")(0), 1))
.filter(item => StringUtils.isNotBlank(item._1))
.reduceByKey((curr, agg) => curr + agg) (1)
val resultLess = interimRDD.sortBy(item => item._2, ascending = true).first()
val resultMore = interimRDD.sortBy(item => item._2, ascending = false).first()
println(s"出现次数最少的 IP : $resultLess, 出现次数最多的 IP : $resultMore")
sc.stop()`
1 这是一个 Shuffle 操作, Shuffle 操作会在集群内进行数据拷贝

在上述代码中, 多次使用到了 interimRDD, 导致文件读取两次, 计算两次, 有没有什么办法增进上述代码的性能?

使用缓存的原因 - 容错

20190511163654

当在计算 RDD3 的时候如果出错了, 会怎么进行容错?

会再次计算 RDD1 和 RDD2 的整个链条, 假设 RDD1 和 RDD2 是通过比较昂贵的操作得来的, 有没有什么办法减少这种开销?

上述两个问题的解决方案其实都是 缓存, 除此之外, 使用缓存的理由还有很多, 但是总结一句, 就是缓存能够帮助开发者在进行一些昂贵操作后, 将其结果保存下来, 以便下次使用无需再次执行, 缓存能够显著的提升性能.

所以, 缓存适合在一个 RDD 需要重复多次利用, 并且还不是特别大的情况下使用, 例如迭代计算等场景.

4.2. 缓存相关的 API

可以使用 cache 方法进行缓存

1
2
3
4
5
6
7
8
9
10
11
12
`val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
.map(item => (item.split(" ")(0), 1))
.filter(item => StringUtils.isNotBlank(item._1))
.reduceByKey((curr, agg) => curr + agg)
.cache() (1)
val resultLess = interimRDD.sortBy(item => item._2, ascending = true).first()
val resultMore = interimRDD.sortBy(item => item._2, ascending = false).first()
println(s"出现次数最少的 IP : $resultLess, 出现次数最多的 IP : $resultMore")
sc.stop()`
1 缓存

方法签名如下

1
`cache(): this.type = persist()`

cache 方法其实是 persist 方法的一个别名

20190511164152

也可以使用 persist 方法进行缓存

1
2
3
4
5
6
7
8
9
10
11
12
val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
.map(item => (item.split(" ")(0), 1))
.filter(item => StringUtils.isNotBlank(item._1))
.reduceByKey((curr, agg) => curr + agg)
.persist(StorageLevel.MEMORY_ONLY) (1)
val resultLess = interimRDD.sortBy(item => item._2, ascending = true).first()
val resultMore = interimRDD.sortBy(item => item._2, ascending = false).first()
println(s"出现次数最少的 IP : $resultLess, 出现次数最多的 IP : $resultMore")
sc.stop()
1 缓存

方法签名如下

1
2
`persist(): this.type
persist(newLevel: StorageLevel): this.type`

persist 方法其实有两种形式, persist()persist(newLevel: StorageLevel) 的一个别名, persist(newLevel: StorageLevel) 能够指定缓存的级别

20190511164532

缓存其实是一种空间换时间的做法, 会占用额外的存储资源, 如何清理?

1
2
3
4
5
6
7
8
9
10
11
12
13
`val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
.map(item => (item.split(" ")(0), 1))
.filter(item => StringUtils.isNotBlank(item._1))
.reduceByKey((curr, agg) => curr + agg)
.persist()
interimRDD.unpersist() (1)
val resultLess = interimRDD.sortBy(item => item._2, ascending = true).first()
val resultMore = interimRDD.sortBy(item => item._2, ascending = false).first()
println(s"出现次数最少的 IP : $resultLess, 出现次数最多的 IP : $resultMore")
sc.stop()`
1 清理缓存

根据缓存级别的不同, 缓存存储的位置也不同, 但是使用 unpersist 可以指定删除 RDD 对应的缓存信息, 并指定缓存级别为 NONE

4.3. 缓存级别

其实如何缓存是一个技术活, 有很多细节需要思考, 如下

  • 是否使用磁盘缓存?
  • 是否使用内存缓存?
  • 是否使用堆外内存?
  • 缓存前是否先序列化?
  • 是否需要有副本?

如果要回答这些信息的话, 可以先查看一下 RDD 的缓存级别对象

1
2
3
4
5
6
7
8
9
10
`val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
.map(item => (item.split(" ")(0), 1))
.filter(item => StringUtils.isNotBlank(item._1))
.reduceByKey((curr, agg) => curr + agg)
.persist()
println(interimRDD.getStorageLevel)
sc.stop()`

打印出来的对象是 StorageLevel, 其中有如下几个构造参数

20190511170124

根据这几个参数的不同, StorageLevel 有如下几个枚举对象

20190511170338

缓存级别 userDisk 是否使用磁盘 useMemory 是否使用内存 useOffHeap 是否使用堆外内存 deserialized 是否以反序列化形式存储 replication 副本数
NONE false false false false 1
DISK_ONLY true false false false 1
DISK_ONLY_2 true false false false 2
MEMORY_ONLY false true false true 1
MEMORY_ONLY_2 false true false true 2
MEMORY_ONLY_SER false true false false 1
MEMORY_ONLY_SER_2 false true false false 2
MEMORY_AND_DISK true true false true 1
MEMORY_AND_DISK true true false true 2
MEMORY_AND_DISK_SER true true false false 1
MEMORY_AND_DISK_SER_2 true true false false 2
OFF_HEAP true true true false 1

如何选择分区级别

Spark 的存储级别的选择,核心问题是在 memory 内存使用率和 CPU 效率之间进行权衡。建议按下面的过程进行存储级别的选择:

如果您的 RDD 适合于默认存储级别(MEMORY_ONLY),leave them that way。这是 CPU 效率最高的选项,允许 RDD 上的操作尽可能快地运行.

如果不是,试着使用 MEMORY_ONLY_SER 和 selecting a fast serialization library 以使对象更加节省空间,但仍然能够快速访问。(Java 和 Scala)

不要溢出到磁盘,除非计算您的数据集的函数是昂贵的,或者它们过滤大量的数据。否则,重新计算分区可能与从磁盘读取分区一样快.

如果需要快速故障恢复,请使用复制的存储级别(例如,如果使用 Spark 来服务 来自网络应用程序的请求)。All 存储级别通过重新计算丢失的数据来提供完整的容错能力,但复制的数据可让您继续在 RDD 上运行任务,而无需等待重新计算一个丢失的分区.

5. Checkpoint

目标

  1. Checkpoint 的作用
  2. Checkpoint 的使用

5.1. Checkpoint 的作用

Checkpoint 的主要作用是斩断 RDD 的依赖链, 并且将数据存储在可靠的存储引擎中, 例如支持分布式存储和副本机制的 HDFS.

Checkpoint 的方式

  • 可靠的 将数据存储在可靠的存储引擎中, 例如 HDFS
  • 本地的 将数据存储在本地

什么是斩断依赖链

斩断依赖链是一个非常重要的操作, 接下来以 HDFS 的 NameNode 的原理来举例说明

HDFS 的 NameNode 中主要职责就是维护两个文件, 一个叫做 edits, 另外一个叫做 fsimage. edits 中主要存放 EditLog, FsImage 保存了当前系统中所有目录和文件的信息. 这个 FsImage 其实就是一个 Checkpoint.

HDFS 的 NameNode 维护这两个文件的主要过程是, 首先, 会由 fsimage 文件记录当前系统某个时间点的完整数据, 自此之后的数据并不是时刻写入 fsimage, 而是将操作记录存储在 edits 文件中. 其次, 在一定的触发条件下, edits 会将自身合并进入 fsimage. 最后生成新的 fsimage 文件, edits 重置, 从新记录这次 fsimage 以后的操作日志.

如果不合并 edits 进入 fsimage 会怎样? 会导致 edits 中记录的日志过长, 容易出错.

所以当 Spark 的一个 Job 执行流程过长的时候, 也需要这样的一个斩断依赖链的过程, 使得接下来的计算轻装上阵.

Checkpoint 和 Cache 的区别

Cache 可以把 RDD 计算出来然后放在内存中, 但是 RDD 的依赖链(相当于 NameNode 中的 Edits 日志)是不能丢掉的, 因为这种缓存是不可靠的, 如果出现了一些错误(例如 Executor 宕机), 这个 RDD 的容错就只能通过回溯依赖链, 重放计算出来.

但是 Checkpoint 把结果保存在 HDFS 这类存储中, 就是可靠的了, 所以可以斩断依赖, 如果出错了, 则通过复制 HDFS 中的文件来实现容错.

所以他们的区别主要在以下两点

Checkpoint 可以保存数据到 HDFS 这类可靠的存储上, Persist 和 Cache 只能保存在本地的磁盘和内存中

Checkpoint 可以斩断 RDD 的依赖链, 而 Persist 和 Cache 不行

因为 CheckpointRDD 没有向上的依赖链, 所以程序结束后依然存在, 不会被删除. 而 Cache 和 Persist 会在程序结束后立刻被清除.

5.2. 使用 Checkpoint

1
2
3
4
5
6
7
8
9
10
11
`val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string")
val sc = new SparkContext(conf)
sc.setCheckpointDir("checkpoint") **(1)**

val interimRDD = sc.textFile("dataset/access_log_sample.txt")
.map(item => (item.split(" ")(0), 1))
.filter(item => StringUtils.isNotBlank(item._1))
.reduceByKey((curr, agg) => curr + agg)
interimRDD.checkpoint() (2)
interimRDD.collect().foreach(println(_))
sc.stop()`
1 在使用 Checkpoint 之前需要先设置 Checkpoint 的存储路径, 而且如果任务在集群中运行的话, 这个路径必须是 HDFS 上的路径
2 开启 Checkpoint
一个小细节val interimRDD = sc.textFile("dataset/access_log_sample.txt") .map(item => (item.split(" ")(0), 1)) .filter(item => StringUtils.isNotBlank(item._1)) .reduceByKey((curr, agg) => curr + agg) .cache() **(1)** interimRDD.checkpoint() interimRDD.collect().foreach(println(_))1checkpoint 之前先 cache 一下, 准没错应该在 checkpoint 之前先 cache 一下, 因为 checkpoint 会重新计算整个 RDD 的数据然后再存入 HDFS 等地方.所以上述代码中如果 checkpoint 之前没有 cache, 则整个流程会被计算两次, 一次是 checkpoint, 另外一次是 collect

6. Spark 底层逻辑

导读

  1. 从部署图了解 Spark 部署了什么, 有什么组件运行在集群中
  2. 通过对 WordCount 案例的解剖, 来理解执行逻辑计划的生成
  3. 通过对逻辑执行计划的细化, 理解如何生成物理计划
如无特殊说明, 以下部分均针对于 Spark Standalone 进行介绍

部署情况

Spark 部分的底层执行逻辑开始之前, 还是要先认识一下 Spark 的部署情况, 根据部署情况, 从而理解如何调度.

WX20190513 233552

针对于上图, 首先可以看到整体上在集群中运行的角色有如下几个:

  • Master Daemon

    负责管理 Master 节点, 协调资源的获取, 以及连接 Worker 节点来运行 Executor, 是 Spark 集群中的协调节点

  • Worker Daemon

    Workers 也称之为叫 Slaves, 是 Spark 集群中的计算节点, 用于和 Master 交互并管理 Executor.

    当一个 Spark Job 提交后, 会创建 SparkContext, 后 Worker 会启动对应的 Executor.

  • Executor Backend

    上面有提到 Worker 用于控制 Executor 的启停, 其实 Worker 是通过 Executor Backend 来进行控制的, Executor Backend 是一个进程(是一个 JVM 实例), 持有一个 Executor 对象

另外在启动程序的时候, 有三种程序需要运行在集群上:

1
Driver

Driver 是一个 JVM 实例, 是一个进程, 是 Spark Application 运行时候的领导者, 其中运行了 SparkContext.

Driver 控制 JobTask, 并且提供 WebUI.

1
Executor

Executor 对象中通过线程池来运行 Task, 一个 Executor 中只会运行一个 Spark ApplicationTask, 不同的 Spark ApplicationTask 会由不同的 Executor 来运行

案例

因为要理解执行计划, 重点不在案例, 所以本节以一个非常简单的案例作为入门, 就是我们第一个案例 WordCount

1
2
3
4
5
6
7
8
9
`val sc = ...

val textRDD = sc.parallelize(Seq("Hadoop Spark", "Hadoop Flume", "Spark Sqoop"))
val splitRDD = textRDD.flatMap(.split(" "))
val tupleRDD = splitRDD.map((, 1))
val reduceRDD = tupleRDD.reduceByKey(_ + _)
val strRDD = reduceRDD.map(item => s"${item._1}, ${item._2}")
println(strRDD.toDebugString)
strRDD.collect.foreach(item => println(item))`

整个案例的运行过程大致如下:

  1. 通过代码的运行, 生成对应的 RDD 逻辑执行图
  2. 通过 Action 操作, 根据逻辑执行图生成对应的物理执行图, 也就是 StageTask
  3. 将物理执行图运行在集群中

逻辑执行图

对于上面代码中的 reduceRDD 如果使用 toDebugString 打印调试信息的话, 会显式如下内容

1
2
3
4
5
`(6) MapPartitionsRDD[4] at map at WordCount.scala:20 []
| ShuffledRDD[3] at reduceByKey at WordCount.scala:19 []
+-(6) MapPartitionsRDD[2] at map at WordCount.scala:18 []
| MapPartitionsRDD[1] at flatMap at WordCount.scala:17 []
| ParallelCollectionRDD[0] at parallelize at WordCount.scala:16 []`

根据这段内容, 大致能得到这样的一张逻辑执行图

20190515002803

其实 RDD 并没有什么严格的逻辑执行图和物理执行图的概念, 这里也只是借用这个概念, 从而让整个 RDD 的原理可以解释, 好理解.

对于 RDD 的逻辑执行图, 起始于第一个入口 RDD 的创建, 结束于 Action 算子执行之前, 主要的过程就是生成一组互相有依赖关系的 RDD, 其并不会真的执行, 只是表示 RDD 之间的关系, 数据的流转过程.

物理执行图

当触发 Action 执行的时候, 这一组互相依赖的 RDD 要被处理, 所以要转化为可运行的物理执行图, 调度到集群中执行.

因为大部分 RDD 是不真正存放数据的, 只是数据从中流转, 所以, 不能直接在集群中运行 RDD, 要有一种 Pipeline 的思想, 需要将这组 RDD 转为 Stage 和 Task, 从而运行 Task, 优化整体执行速度.

以上的逻辑执行图会生成如下的物理执行图, 这一切发生在 Action 操作被执行时.

20190515235205

从上图可以总结如下几个点

  • img 在第一个 Stage 中, 每一个这样的执行流程是一个 Task, 也就是在同一个 Stage 中的所有 RDD 的对应分区, 在同一个 Task 中执行
  • Stage 的划分是由 Shuffle 操作来确定的, 有 Shuffle 的地方, Stage 断开

6.1. 逻辑执行图生成

导读

  1. 如何生成 RDD
  2. 如何控制 RDD 之间的关系

6.1.1. RDD 的生成

重点内容

本章要回答如下三个问题

  • 如何生成 RDD
  • 生成什么 RDD
  • 如何计算 RDD 中的数据
1
2
3
4
5
6
7
8
9
`val sc = ...

val textRDD = sc.parallelize(Seq("Hadoop Spark", "Hadoop Flume", "Spark Sqoop"))
val splitRDD = textRDD.flatMap(.split(" "))
val tupleRDD = splitRDD.map((, 1))
val reduceRDD = tupleRDD.reduceByKey(_ + _)
val strRDD = reduceRDD.map(item => s"${item._1}, ${item._2}")
println(strRDD.toDebugString)
strRDD.collect.foreach(item => println(item))`

明确逻辑计划的边界

Action 调用之前, 会生成一系列的 RDD, 这些 RDD 之间的关系, 其实就是整个逻辑计划

例如上述代码, 如果生成逻辑计划的, 会生成如下一些 RDD, 这些 RDD 是相互关联的, 这些 RDD 之间, 其实本质上生成的就是一个 计算链

20190519000019

接下来, 采用迭代渐进式的方式, 一步一步的查看一下整体上的生成过程

textFile 算子的背后

研究 RDD 的功能或者表现的时候, 其实本质上研究的就是 RDD 中的五大属性, 因为 RDD 透过五大属性来提供功能和表现, 所以如果要研究 textFile 这个算子, 应该从五大属性着手, 那么第一步就要看看生成的 RDD 是什么类型的 RDD

  1. textFile
    
    1
    2
    3
    4
    5
    6
    7



    生成的是



    HadoopRDD
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11

    ![20190519202310](https://doc-1256053707.cos.ap-beijing.myqcloud.com/20190519202310.png)

    ![20190519202411](https://doc-1256053707.cos.ap-beijing.myqcloud.com/20190519202411.png)

    | | 除了上面这一个步骤以外, 后续步骤将不再直接基于代码进行讲解, 因为从代码的角度着手容易迷失逻辑, 这个章节的初心有两个, 一个是希望大家了解 Spark 的内部逻辑和原理, 另外一个是希望大家能够通过本章学习具有代码分析的能力 |
    | ---- | ------------------------------------------------------------ |
    | | |

    2. ```
    HadoopRDD

1
Partitions

对应了

1
HDFS

1
Blocks

20190519203211

其实本质上每个 HadoopRDDPartition 都是对应了一个 HadoopBlock, 通过 InputFormat 来确定 Hadoop 中的 Block 的位置和边界, 从而可以供一些算子使用

  1. HadoopRDD
    
    1
    2
    3
    4
    5
    6
    7







    compute
    1
    2
    3
    4
    5
    6
    7



    函数就是在读取



    HDFS
    1
    2
    3
    4
    5
    6
    7



    中的



    Block
    1
    2
    3
    4
    5

    本质上, `compute` 还是依然使用 `InputFormat` 来读取 `HDFS` 中对应分区的 `Block`

    4. ```
    textFile

这个算子生成的其实是一个

1
MapPartitionsRDD

textFile 这个算子的作用是读取 HDFS 上的文件, 但是 HadoopRDD 中存放是一个元组, 其 Key 是行号, 其 ValueHadoop 中定义的 Text 对象, 这一点和 MapReduce 程序中的行为是一致的

但是并不适合 Spark 的场景, 所以最终会通过一个 map 算子, 将 (LineNum, Text) 转为 String 形式的一行一行的数据, 所以最终 textFile 这个算子生成的 RDD 并不是 HadoopRDD, 而是一个 MapPartitionsRDD

map 算子的背后

20190519101943

  • map 算子生成了 MapPartitionsRDD

    由源码可知, 当 val rdd2 = rdd1.map() 的时候, 其实生成的新 RDDrdd2, rdd2 的类型是 MapPartitionsRDD, 每个 RDD 中的五大属性都会有一些不同, 由 map 算子生成的 RDD 中的计算函数, 本质上就是遍历对应分区的数据, 将每一个数据转成另外的形式

  • MapPartitionsRDD 的计算函数是 collection.map( function )

    真正运行的集群中的处理单元是 Task, 每个 Task 对应一个 RDD 的分区, 所以 collection 对应一个 RDD 分区的所有数据, 而这个计算的含义就是将一个 RDD 的分区上所有数据当作一个集合, 通过这个 Scala 集合的 map 算子, 来执行一个转换操作, 其转换操作的函数就是传入 map 算子的 function

  • 传入 map 算子的函数会被清理

    20190519190306

    这个清理主要是处理闭包中的依赖, 使得这个闭包可以被序列化发往不同的集群节点运行

flatMap 算子的背后

20190519190541

1
flatMap` 和 `map` 算子其实本质上是一样的, 其步骤和生成的 `RDD` 都是一样, 只是对于传入函数的处理不同, `map` 是 `collect.map( function )` 而 `flatMap` 是 `collect.flatMap( function )

从侧面印证了, 其实 Spark 中的 flatMapScala 基础中的 flatMap 其实是一样的

1
textRDD` → `splitRDD` → `tupleRDD

textRDDsplitRDD 再到 tupleRDD 的过程, 其实就是调用 mapflatMap 算子生成新的 RDD 的过程, 所以如下图所示, 就是这个阶段所生成的逻辑计划

20190519211533

总结

如何生成 RDD ?

生成 RDD 的常见方式有三种

从本地集合创建

从外部数据集创建

从其它 RDD 衍生

通过外部数据集创建 RDD, 是通过 Hadoop 或者其它外部数据源的 SDK 来进行数据读取, 同时如果外部数据源是有分片的话, RDD 会将分区与其分片进行对照

通过其它 RDD 衍生的话, 其实本质上就是通过不同的算子生成不同的 RDD 的子类对象, 从而控制 compute 函数的行为来实现算子功能

生成哪些 RDD ?

不同的算子生成不同的 RDD, 生成 RDD 的类型取决于算子, 例如 mapflatMap 都会生成 RDD 的子类 MapPartitions 的对象

如何计算 RDD 中的数据 ?

虽然前面我们提到过 RDD 是偏向计算的, 但是其实 RDD 还只是表示数据, 纵观 RDD 的五大属性中有三个是必须的, 分别如下

Partitions List 分区列表

Compute function 计算函数

Dependencies 依赖

虽然计算函数是和计算有关的, 但是只有调用了这个函数才会进行计算, RDD 显然不会自己调用自己的 Compute 函数, 一定是由外部调用的, 所以 RDD 更多的意义是用于表示数据集以及其来源, 和针对于数据的计算

所以如何计算 RDD 中的数据呢? 一定是通过其它的组件来计算的, 而计算的规则, 由 RDD 中的 Compute 函数来指定, 不同类型的 RDD 子类有不同的 Compute 函数

6.1.2. RDD 之间的依赖关系

导读

  1. 讨论什么是 RDD 之间的依赖关系
  2. 继而讨论 RDD 分区之间的关系
  3. 最后确定 RDD 之间的依赖关系分类
  4. 完善案例的逻辑关系图

什么是 RDD 之间的依赖关系?

20190519211533

  • 什么是关系(依赖关系) ?

    从算子视角上来看, splitRDD 通过 map 算子得到了 tupleRDD, 所以 splitRDDtupleRDD 之间的关系是 map

    但是仅仅这样说, 会不够全面, 从细节上来看, RDD 只是数据和关于数据的计算, 而具体执行这种计算得出结果的是一个神秘的其它组件, 所以, 这两个 RDD 的关系可以表示为 splitRDD 的数据通过 map 操作, 被传入 tupleRDD, 这是它们之间更细化的关系

    但是 RDD 这个概念本身并不是数据容器, 数据真正应该存放的地方是 RDD 的分区, 所以如果把视角放在数据这一层面上的话, 直接讲这两个 RDD 之间有关系是不科学的, 应该从这两个 RDD 的分区之间的关系来讨论它们之间的关系

  • 那这些分区之间是什么关系?

    如果仅仅说 splitRDDtupleRDD 之间的话, 那它们的分区之间就是一对一的关系

    但是 tupleRDDreduceRDD 呢? tupleRDD 通过算子 reduceByKey 生成 reduceRDD, 而这个算子是一个 Shuffle 操作, Shuffle 操作的两个 RDD 的分区之间并不是一对一, reduceByKey 的一个分区对应 tupleRDD 的多个分区

1
reduceByKey` 算子会生成 `ShuffledRDD

reduceByKey 是由算子 combineByKey 来实现的, combineByKey 内部会创建 ShuffledRDD 返回, 具体的代码请大家通过 IDEA 来进行查看, 此处不再截图, 而整个 reduceByKey 操作大致如下过程

20190520010402

去掉两个 reducer 端的分区, 只留下一个的话, 如下

20190520010518

所以, 对于 reduceByKey 这个 Shuffle 操作来说, reducer 端的一个分区, 会从多个 mapper 端的分区拿取数据, 是一个多对一的关系

至此为止, 出现了两种分区见的关系了, 一种是一对一, 一种是多对一

整体上的流程图

20190520011115

6.1.3. RDD 之间的依赖关系详解

导读

上个小节通过例子演示了 RDD 的分区间的关系有两种形式

一对一, 一般是直接转换

多对一, 一般是 Shuffle

本小节会说明如下问题:

  1. 如果分区间得关系是一对一或者多对一, 那么这种情况下的 RDD 之间的关系的正式命名是什么呢?
  2. RDD 之间的依赖关系, 具体有几种情况呢?

窄依赖

假如 rddB = rddA.transform(…), 如果 rddB 中一个分区依赖 rddA 也就是其父 RDD 的少量分区, 这种 RDD 之间的依赖关系称之为窄依赖

换句话说, 子 RDD 的每个分区依赖父 RDD 的少量个数的分区, 这种依赖关系称之为窄依赖

20190520130939

举个栗子

1
2
3
4
5
6
7
8
9
`val sc = ...

val rddA = sc.parallelize(Seq(1, 2, 3))
val rddB = sc.parallelize(Seq("a", "b"))
/**

运行结果: (1,a), (1,b), (2,a), (2,b), (3,a), (3,b)
*/
rddA.cartesian(rddB).collect().foreach(println(_))`
  • 上述代码的 cartesian 是求得两个集合的笛卡尔积
  • 上述代码的运行结果是 rddA 中每个元素和 rddB 中的所有元素结合, 最终的结果数量是两个 RDD 数量之和
  • rddC 有两个父 RDD, 分别为 rddArddB

对于 cartesian 来说, 依赖关系如下

20190520144103

上述图形中清晰展示如下现象

  1. rddC 中的分区数量是两个父 RDD 的分区数量之乘积
  2. rddA 中每个分区对应 rddC 中的两个分区 (因为 rddB 中有两个分区), rddB 中的每个分区对应 rddC 中的三个分区 (因为 rddA 有三个分区)

它们之间是窄依赖, 事实上在 cartesian 中也是 NarrowDependency 这个所有窄依赖的父类的唯一一次直接使用, 为什么呢?

因为所有的分区之间是拷贝关系, 并不是 Shuffle 关系

  • rddC 中的每个分区并不是依赖多个父 RDD 中的多个分区
  • rddC 中每个分区的数量来自一个父 RDD 分区中的所有数据, 是一个 FullDependence, 所以数据可以直接从父 RDD 流动到子 RDD
  • 不存在一个父 RDD 中一部分数据分发过去, 另一部分分发给其它的 RDD

宽依赖

并没有所谓的宽依赖, 宽依赖应该称作为 ShuffleDependency

ShuffleDependency 的类声明上如下写到

1
`Represents a dependency on the output of a shuffle stage.`

上面非常清楚的说道, 宽依赖就是 Shuffle 中的依赖关系, 换句话说, 只有 Shuffle 产生的地方才是宽依赖

那么宽窄依赖的判断依据就非常简单明确了, 是否有 Shuffle ?

举个 reduceByKey 的例子, rddB = rddA.reduceByKey( (curr, agg) ⇒ curr + agg ) 会产生如下的依赖关系

20190520151040

rddB 的每个分区都几乎依赖 rddA 的所有分区

对于 rddA 中的一个分区来说, 其将一部分分发给 rddBp1, 另外一部分分发给 rddBp2, 这不是数据流动, 而是分发

如何分辨宽窄依赖 ?

其实分辨宽窄依赖的本身就是在分辨父子 RDD 之间是否有 Shuffle, 大致有以下的方法

如果是 Shuffle, 两个 RDD 的分区之间不是单纯的数据流动, 而是分发和复制

一般 Shuffle 的子 RDD 的每个分区会依赖父 RDD 的多个分区

但是这样判断其实不准确, 如果想分辨某个算子是否是窄依赖, 或者是否是宽依赖, 则还是要取决于具体的算子, 例如想看 cartesian 生成的是宽依赖还是窄依赖, 可以通过如下步骤

  1. 查看
1
map

算子生成的

1
RDD

20190520155245

  1. 进去
1
RDD

查看

1
getDependence

方法

20190520155314

总结

  • RDD 的逻辑图本质上是对于计算过程的表达, 例如数据从哪来, 经历了哪些步骤的计算
  • 每一个步骤都对应一个 RDD, 因为数据处理的情况不同, RDD 之间的依赖关系又分为窄依赖和宽依赖 *

6.1.4. 常见的窄依赖类型

导读

常见的窄依赖其实也是有分类的, 而且宽窄以来不太容易分辨, 所以通过本章, 帮助同学明确窄依赖的类型

一对一窄依赖

其实 RDD 中默认的是 OneToOneDependency, 后被不同的 RDD 子类指定为其它的依赖类型, 常见的一对一依赖是 map 算子所产生的依赖, 例如 rddB = rddA.map(…)

20190520160405

每个分区之间一一对应, 所以叫做一对一窄依赖

Range 窄依赖

1
Range` 窄依赖其实也是一对一窄依赖, 但是保留了中间的分隔信息, 可以通过某个分区获取其父分区, 目前只有一个算子生成这种窄依赖, 就是 `union` 算子, 例如 `rddC = rddA.union(rddB)

20190520161043

1
rddC` 其实就是 `rddA` 拼接 `rddB` 生成的, 所以 `rddC` 的 `p5` 和 `p6` 就是 `rddB` 的 `p1` 和 `p2

所以需要有方式获取到 rddCp5 其父分区是谁, 于是就需要记录一下边界, 其它部分和一对一窄依赖一样

多对一窄依赖

多对一窄依赖其图形和 Shuffle 依赖非常相似, 所以在遇到的时候, 要注意其 RDD 之间是否有 Shuffle 过程, 比较容易让人困惑, 常见的多对一依赖就是重分区算子 coalesce, 例如 rddB = rddA.coalesce(2, shuffle = false), 但同时也要注意, 如果 shuffle = true 那就是完全不同的情况了

20190520161621

因为没有 Shuffle, 所以这是一个窄依赖

再谈宽窄依赖的区别

宽窄依赖的区别非常重要, 因为涉及了一件非常重要的事情: 如何计算 RDD ?

宽窄以来的核心区别是: 窄依赖的 RDD 可以放在一个 Task 中运行

6.2. 物理执行图生成

  1. 物理图的意义
  2. 如何划分 Task
  3. 如何划分 Stage

物理图的作用是什么?

问题一: 物理图的意义是什么?

物理图解决的其实就是 RDD 流程生成以后, 如何计算和运行的问题, 也就是如何把 RDD 放在集群中执行的问题

Snipaste 2019 05 23 14 00 33

问题二: 如果要确定如何运行的问题, 则需要先确定集群中有什么组件

  • 首先集群中物理元件就是一台一台的机器

  • 其次这些机器上跑的守护进程有两种: Master, Worker

    1
    *   每个守护进程其实就代表了一台机器, 代表这台机器的角色, 代表这台机器和外界通信
    • 例如我们常说一台机器是 Master, 其含义是这台机器中运行了一个 Master 守护进程, 如果一台机器运行了 Master 的同时又运行了 Worker, 则说这台机器是 Master 也可以, 说它是 Worker 也行

真正能运行 RDD 的组件是: Executor, 也就是说其实 RDD 最终是运行在 Executor 中的, 也就是说, 无论是 Master 还是 Worker 其实都是用于管理 Executor 和调度程序的

结论是 RDD 一定在 Executor 中计算, 而 MasterWorker 负责调度和管理 Executor

问题三: 物理图的生成需要考虑什么问题?

要计算 RDD, 不仅要计算, 还要很快的计算 → 优化性能

要考虑容错, 容错的常见手段是缓存 → RDD 要可以缓存

结论是在生成物理图的时候, 不仅要考虑效率问题, 还要考虑一种更合适的方式, 让 RDD 运行的更好

谁来计算 RDD ?

问题一: RDD 是什么, 用来做什么 ?

回顾一下 RDD 的五个属性

1
2
3
4
5
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 是一个数据集的表示, 不仅表示了数据集, 还表示了这个数据集从哪来, 如何计算

但是问题是, 谁来计算 ? 如果为一台汽车设计了一个设计图, 那么设计图自己生产汽车吗 ?

问题二: 谁来计算 ?

前面我们明确了两件事, RDD 在哪被计算? 在 Executor 中. RDD 是什么? 是一个数据集以及其如何计算的图纸.

直接使用 Executor 也是不合适的, 因为一个计算的执行总是需要一个容器, 例如 JVM 是一个进程, 只有进程中才能有线程, 所以这个计算 RDD 的线程应该运行在一个进程中, 这个进程就是 Exeutor, Executor 有如下两个职责

Driver 保持交互从而认领属于自己的任务

20190521111630

接受任务后, 运行任务

20190521111456

所以, 应该由一个线程来执行 RDD 的计算任务, 而 Executor 作为执行这个任务的容器, 也就是一个进程, 用于创建和执行线程, 这个执行具体计算任务的线程叫做 Task

问题三: Task 该如何设计 ?

第一个想法是每个 RDD 都由一个 Task 来计算 第二个想法是一整个逻辑执行图中所有的 RDD 都由一组 Task 来执行 第三个想法是分阶段执行

第一个想法: 为每个 RDD 的分区设置一组 Task

20190521113535

大概就是每个 RDD 都有三个 Task, 每个 Task 对应一个 RDD 的分区, 执行一个分区的数据的计算

但是这么做有一个非常难以解决的问题, 就是数据存储的问题, 例如 Task 1, 4, 7, 10, 13, 16 在同一个流程上, 但是这些 Task 之间需要交换数据, 因为这些 Task 可能被调度到不同的机器上上, 所以 Task1 执行完了数据以后需要暂存, 后交给 Task4 来获取

这只是一个简单的逻辑图, 如果是一个复杂的逻辑图, 会有什么表现? 要存储多少数据? 无论是放在磁盘还是放在内存中, 是不是都是一种极大的负担?

第二个想法: 让数据流动

很自然的, 第一个想法的问题是数据需要存储和交换, 那不存储不就好了吗? 对, 可以让数据流动起来

第一个要解决的问题就是, 要为数据创建管道(Pipeline), 有了管道, 就可以流动

20190521114511

简单来说, 就是为所有的 RDD 有关联的分区使用同一个 Task, 但是就没问题了吗? 请关注红框部分

20190521114717

这两个 RDD 之间是 Shuffle 关系, 也就是说, 右边的 RDD 的一个分区可能依赖左边 RDD 的所有分区, 这样的话, 数据在这个地方流不动了, 怎么办?

第三个想法: 划分阶段

既然在 Shuffle 处数据流不动了, 那就可以在这个地方中断一下, 后面 Stage 部分详解

如何划分阶段 ?

为了减少执行任务, 减少数据暂存和交换的机会, 所以需要创建管道, 让数据沿着管道流动, 其实也就是原先每个 RDD 都有一组 Task, 现在改为所有的 RDD 共用一组 Task, 但是也有问题, 问题如下

20190521114717

就是说, 在 Shuffle 处, 必须断开管道, 进行数据交换, 交换过后, 继续流动, 所以整个流程可以变为如下样子

20190521115759

Task 断开成两个部分, Task4 可以从 Task 1, 2, 3 中获取数据, 后 Task4 又作为管道, 继续让数据在其中流动

但是还有一个问题, 说断开就直接断开吗? 不用打个招呼的呀? 这个断开即没有道理, 也没有规则, 所以可以为这个断开增加一个概念叫做阶段, 按照阶段断开, 阶段的英文叫做 Stage, 如下

20190521120501

所以划分阶段的本身就是设置断开点的规则, 那么该如何划分阶段呢?

  1. 第一步, 从最后一个 RDD, 也就是逻辑图中最右边的 RDD 开始, 向前滑动 Stage 的范围, 为 Stage0
  2. 第二步, 遇到 ShuffleDependency 断开 Stage, 从下一个 RDD 开始创建新的 Stage, 为 Stage1
  3. 第三步, 新的 Stage 按照同样的规则继续滑动, 直到包裹所有的 RDD

总结来看, 就是针对于宽窄依赖来判断, 一个 Stage 中只有窄依赖, 因为只有窄依赖才能形成数据的 Pipeline.

如果要进行 Shuffle 的话, 数据是流不过去的, 必须要拷贝和拉取. 所以遇到 RDD 宽依赖的两个 RDD 时, 要切断这两个 RDDStage.

这样一个 RDD 依赖的链条, 我们称之为 RDD 的血统, 其中有宽依赖也有窄依赖

数据怎么流动 ?

1
2
3
4
5
6
7
8
`val sc = ...

val textRDD = sc.parallelize(Seq("Hadoop Spark", "Hadoop Flume", "Spark Sqoop"))
val splitRDD = textRDD.flatMap(.split(" "))
val tupleRDD = splitRDD.map((, 1))
val reduceRDD = tupleRDD.reduceByKey(_ + _)
val strRDD = reduceRDD.map(item => s"${item._1}, ${item._2}")
strRDD.collect.foreach(item => println(item))`

上述代码是这个章节我们一直使用的代码流程, 如下是其完整的逻辑执行图

20190521161456

如果放在集群中运行, 通过 WebUI 可以查看到如下 DAG 结构

20190521161337

Step 1: 从 ResultStage 开始执行

最接近 Result 部分的 Stage id 为 0, 这个 Stage 被称之为 ResultStage

由代码可以知道, 最终调用 Action 促使整个流程执行的是最后一个 RDD, strRDD.collect, 所以当执行 RDD 的计算时候, 先计算的也是这个 RDD

Step 2: RDD 之间是有关联的

前面已经知道, 最后一个 RDD 先得到执行机会, 先从这个 RDD 开始执行, 但是这个 RDD 中有数据吗 ? 如果没有数据, 它的计算是什么? 它的计算是从父 RDD 中获取数据, 并执行传入的算子的函数

简单来说, 从产生 Result 的地方开始计算, 但是其 RDD 中是没数据的, 所以会找到父 RDD 来要数据, 父 RDD 也没有数据, 继续向上要, 所以, 计算从 Result 处调用, 但是从整个逻辑图中的最左边 RDD 开始, 类似一个递归的过程

20190521162302

6.3. 调度过程

导读

生成逻辑图和物理图的系统组件

JobStage, Task 之间的关系

如何调度 Job

逻辑图

是什么 怎么生成 具体怎么生成

1
2
3
4
5
`val textRDD = sc.parallelize(Seq("Hadoop Spark", "Hadoop Flume", "Spark Sqoop"))
val splitRDD = textRDD.flatMap(_.split(" "))
val tupleRDD = splitRDD.map((_, 1))
val reduceRDD = tupleRDD.reduceByKey(_ + _)
val strRDD = reduceRDD.map(item => s"${item._1}, ${item._2}")`

逻辑图如何生成

上述代码在 Spark Applicationmain 方法中执行, 而 Spark ApplicationDriver 中执行, 所以上述代码在 Driver 中被执行, 那么这段代码执行的结果是什么呢?

一段 Scala 代码的执行结果就是最后一行的执行结果, 所以上述的代码, 从逻辑上执行结果就是最后一个 RDD, 最后一个 RDD 也可以认为就是逻辑执行图, 为什么呢?

例如 rdd2 = rdd1.map(…) 中, 其实本质上 rdd2 是一个类型为 MapPartitionsRDD 的对象, 而创建这个对象的时候, 会通过构造函数传入当前 RDD 对象, 也就是父 RDD, 也就是调用 map 算子的 rdd1, rdd1rdd2 的父 RDD

20190521165818

一个 RDD 依赖另外一个 RDD, 这个 RDD 又依赖另外的 RDD, 一个 RDD 可以通过 getDependency 获得其父 RDD, 这种环环相扣的关系, 最终从最后一个 RDD 就可以推演出前面所有的 RDD

逻辑图是什么, 干啥用

逻辑图其实本质上描述的就是数据的计算过程, 数据从哪来, 经过什么样的计算, 得到什么样的结果, 再执行什么计算, 得到什么结果

可是数据的计算是描述好了, 这种计算该如何执行呢?

物理图

数据的计算表示好了, 该正式执行了, 但是如何执行? 如何执行更快更好更酷? 就需要为其执行做一个规划, 所以需要生成物理执行图

1
`strRDD.collect.foreach(item => println(item))`

上述代码其实就是最后的一个 RDD 调用了 Action 方法, 调用 Action 方法的时候, 会请求一个叫做 DAGScheduler 的组件, DAGScheduler 会创建用于执行 RDDStageTask

DAGScheduler 是一个由 SparkContext 创建, 运行在 Driver 上的组件, 其作用就是将由 RDD 构建出来的逻辑计划, 构建成为由真正在集群中运行的 Task 组成的物理执行计划, DAGScheduler 主要做如下三件事

帮助每个 Job 计算 DAG 并发给 TaskSheduler 调度

确定每个 Task 的最佳位置

跟踪 RDD 的缓存状态, 避免重新计算

从字面意思上来看, DAGScheduler 是调度 DAG 去运行的, DAG 被称作为有向无环图, 其实可以将 DAG 理解为就是 RDD 的逻辑图, 其呈现两个特点: RDD 的计算是有方向的, RDD 的计算是无环的, 所以 DAGScheduler 也可以称之为 RDD Scheduler, 但是真正运行在集群中的并不是 RDD, 而是 TaskStage, DAGScheduler 负责这种转换

Job 是什么 ?

Job 什么时候生成 ?

当一个 RDD 调用了 Action 算子的时候, 在 Action 算子内部, 会使用 sc.runJob() 调用 SparkContext 中的 runJob 方法, 这个方法又会调用 DAGScheduler 中的 runJob, 后在 DAGScheduler 中使用消息驱动的形式创建 Job

简而言之, JobRDD 调用 Action 算子的时候生成, 而且调用一次 Action 算子, 就会生成一个 Job, 如果一个 SparkApplication 中调用了多次 Action 算子, 会生成多个 Job 串行执行, 每个 Job 独立运作, 被独立调度, 所以 RDD 的计算也会被执行多次

Job 是什么 ?

如果要将 Spark 的程序调度到集群中运行, Job 是粒度最大的单位, 调度以 Job 为最大单位, 将 Job 拆分为 StageTask 去调度分发和运行, 一个 Job 就是一个 Spark 程序从 读取 → 计算 → 运行 的过程

一个 Spark Application 可以包含多个 Job, 这些 Job 之间是串行的, 也就是第二个 Job 需要等待第一个 Job 的执行结束后才会开始执行

JobStage 的关系

Job 是一个最大的调度单位, 也就是说 DAGScheduler 会首先创建一个 Job 的相关信息, 后去调度 Job, 但是没办法直接调度 Job, 比如说现在要做一盘手撕包菜, 不可能直接去炒一整颗包菜, 要切好撕碎, 再去炒

为什么 Job 需要切分 ?

20190521161456

  • 因为 Job 的含义是对整个 RDD 血统求值, 但是 RDD 之间可能会有一些宽依赖

  • 如果遇到宽依赖的话, 两个 RDD 之间需要进行数据拉取和复制

    如果要进行拉取和复制的话, 那么一个 RDD 就必须等待它所依赖的 RDD 所有分区先计算完成, 然后再进行拉取

  • 由上得知, 一个 Job 是无法计算完整个 RDD 血统的

如何切分 ?

创建一个 Stage, 从后向前回溯 RDD, 遇到 Shuffle 依赖就结束 Stage, 后创建新的 Stage 继续回溯. 这个过程上面已经详细的讲解过, 但是问题是切分以后如何执行呢, 从后向前还是从前向后, 是串行执行多个 Stage, 还是并行执行多个 Stage

问题一: 执行顺序

在图中, Stage 0 的计算需要依赖 Stage 1 的数据, 因为 reduceRDD 中一个分区可能需要多个 tupleRDD 分区的数据, 所以 tupleRDD 必须先计算完, 所以, 应该在逻辑图中自左向右执行 Stage

问题二: 串行还是并行

还是同样的原因, Stage 0 如果想计算, Stage 1 必须先计算完, 因为 Stage 0 中每个分区都依赖 Stage 1 中的所有分区, 所以 Stage 1 不仅需要先执行, 而且 Stage 1 执行完之前 Stage 0 无法执行, 它们只能串行执行

总结

一个 Stage 就是物理执行计划中的一个步骤, 一个 Spark Job 就是划分到不同 Stage 的计算过程

Stage 之间的边界由 Shuffle 操作来确定

1
*   `Stage` 内的 `RDD` 之间都是窄依赖, 可以放在一个管道中执行
  • Shuffle 后的 Stage 需要等待前面 Stage 的执行

Stage 有两种

1
ShuffMapStage`, 其中存放窄依赖的 `RDD

ResultStage, 每个 Job 只有一个, 负责计算结果, 一个 ResultStage 执行完成标志着整个 Job 执行完毕

StageTask 的关系

20190521120501

前面我们说到 Job 无法直接执行, 需要先划分为多个 Stage, 去执行 Stage, 那么 Stage 可以直接执行吗?

第一点: Stage 中的 RDD 之间是窄依赖

因为 Stage 中的所有 RDD 之间都是窄依赖, 窄依赖 RDD 理论上是可以放在同一个 Pipeline(管道, 流水线) 中执行的, 似乎可以直接调度 Stage 了? 其实不行, 看第二点

第二点: 别忘了 RDD 还有分区

一个 RDD 只是一个概念, 而真正存放和处理数据时, 都是以分区作为单位的

Stage 对应的是多个整体上的 RDD, 而真正的运行是需要针对 RDD 的分区来进行的

第三点: 一个 Task 对应一个 RDD 的分区

一个比 Stage 粒度更细的单元叫做 Task, Stage 是由 Task 组成的, 之所以有 Task 这个概念, 是因为 Stage 针对整个 RDD, 而计算的时候, 要针对 RDD 的分区

假设一个 Stage 中有 10 个 RDD, 这些 RDD 中的分区各不相同, 但是分区最多的 RDD 有 30 个分区, 而且很显然, 它们之间是窄依赖关系

那么, 这个 Stage 中应该有多少 Task 呢? 应该有 30 个 Task, 因为一个 Task 计算一个 RDD 的分区. 这个 Stage 至多有 30 个分区需要计算

总结

1
*   一个 `Stage` 就是一组并行的 `Task` 集合
  • Task 是 Spark 中最小的独立执行单元, 其作用是处理一个 RDD 分区
  • 一个 Task 只可能存在于一个 Stage 中, 并且只能计算一个 RDD 的分区

TaskSet

梳理一下这几个概念, Job > Stage > Task, Job 中包含 Stage 中包含 Task

Stage 中经常会有一组 Task 需要同时执行, 所以针对于每一个 Task 来进行调度太过繁琐, 而且没有意义, 所以每个 Stage 中的 Task 们会被收集起来, 放入一个 TaskSet 集合中

一个 Stage 有一个 TaskSet

TaskSetTask 的个数由 Stage 中的最大分区数决定

整体执行流程

20190522015026

6.3. Shuffle 过程

导读

本章节重点是介绍 Shuffle 的流程, 因为根据 ShuffleWriter 的实现不同, 其过程也不同, 所以前半部分根据默认的存储引擎 SortShuffleWriter 来讲解

后半部分简要介绍一下其它的 ShuffleWriter

Shuffle 过程的组件结构

从整体视角上来看, Shuffle 发生在两个 Stage 之间, 一个 Stage 把数据计算好, 整理好, 等待另外一个 Stage 来拉取

20190522132537

放大视角, 会发现, 其实 Shuffle 发生在 Task 之间, 一个 Task 把数据整理好, 等待 Reducer 端的 Task 来拉取

20190522132852

如果更细化一下, Task 之间如何进行数据拷贝的呢? 其实就是一方 Task 把文件生成好, 然后另一方 Task 来拉取

20190522133401

现在是一个 Reducer 的情况, 如果有多个 Reducer 呢? 如果有多个 Reducer 的话, 就可以在每个 Mapper 为所有的 Reducer 生成各一个文件, 这种叫做 Hash base shuffle, 这种 Shuffle 的方式问题大家也知道, 就是生成中间文件过多, 而且生成文件的话需要缓冲区, 占用内存过大

20190522140738

那么可以把这些文件合并起来, 生成一个文件返回, 这种 Shuffle 方式叫做 Sort base shuffle, 每个 Reducer 去文件的不同位置拿取数据

20190522141807

如果再细化一下, 把参与这件事的组件也放置进去, 就会是如下这样

20190522170646

有哪些 ShuffleWriter ?

大致上有三个 ShufflWriter, Spark 会按照一定的规则去使用这三种不同的 Writer

1
BypassMergeSortShuffleWriter

这种 Shuffle Writer 也依然有 Hash base shuffle 的问题, 它会在每一个 Mapper 端对所有的 Reducer 生成一个文件, 然后再合并这个文件生成一个统一的输出文件, 这个过程中依然是有很多文件产生的, 所以只适合在小量数据的场景下使用

Spark 有考虑去掉这种 Writer, 但是因为结构中有一些依赖, 所以一直没去掉

Reducer 个数小于 spark.shuffle.sort.bypassMergeThreshold, 并且没有 Mapper 端聚合的时候启用这种方式

1
SortShuffleWriter

这种 ShuffleWriter 写文件的方式非常像 MapReduce 了, 后面详说

当其它两种 Shuffle 不符合开启条件时, 这种 Shuffle 方式是默认的

1
UnsafeShuffleWriter

这种 ShuffWriter 会将数据序列化, 然后放入缓冲区进行排序, 排序结束后 Spill 到磁盘, 最终合并 Spill 文件为一个大文件, 同时在进行内存存储的时候使用了 JavaUnsafe API, 也就是使用堆外内存, 是钨丝计划的一部分

也不是很常用, 只有在满足如下三个条件时候才会启用

1
*   序列化器序列化后的数据, 必须支持排序
  • 没有 Mapper 端的聚合
  • Reducer 的个数不能超过支持的上限 (2 ^ 24)

SortShuffleWriter 的执行过程

20190522160031

整个 SortShuffleWriter 如上述所说, 大致有如下几步

  1. 首先 SortShuffleWriterwrite 方法中回去写文件, 这个方法中创建了 ExternalSorter

  2. write 中将数据 insertAllExternalSorter

  3. ExternalSorter 中排序

    1
    1.  如果要聚合, 放入 `AppendOnlyMap` 中, 如果不聚合, 放入 `PartitionedPairBuffer` 中
    1. 在数据结构中进行排序, 排序过程中如果内存数据大于阈值则溢写到磁盘

使用 ExternalSorterwritePartitionedFile 写入输入文件

1
1.  将所有的溢写文件通过类似 `MergeSort` 的算法合并
  1. 将数据写入最终的目标文件中

7. RDD 的分布式共享变量

目标

理解闭包以及 Spark 分布式运行代码的根本原理

理解累加变量的使用场景

理解广播的使用场景

什么是闭包

闭包是一个必须要理解, 但是又不太好理解的知识点, 先看一个小例子

1
2
3
4
5
6
7
8
9
10
11
12
`@Test
def test(): Unit = {
val areaFunction = closure()
val area = areaFunction(2)
println(area)
}

def closure(): Int => Double = {
val factor = 3.14
val areaFunction = (r: Int) => math.pow(r, 2) * factor
areaFunction
}`

上述例子中, closure方法返回的一个函数的引用, 其实就是一个闭包, 闭包本质上就是一个封闭的作用域, 要理解闭包, 是一定要和作用域联系起来的.

能否在 test 方法中访问 closure 定义的变量?

1
2
3
4
5
6
7
8
`@Test
def test(): Unit = {
println(factor)
}

def closure(): Int => Double = {
val factor = 3.14
}`

有没有什么间接的方式?

1
2
3
4
5
6
7
8
9
10
11
`@Test
def test(): Unit = {
val areaFunction = closure()
areaFunction()
}

def closure(): () => Unit = {
val factor = 3.14
val areaFunction = () => println(factor)
areaFunction
}`

什么是闭包?

1
2
`val areaFunction = closure()
areaFunction()`

通过 closure 返回的函数 areaFunction 就是一个闭包, 其函数内部的作用域并不是 test 函数的作用域, 这种连带作用域一起打包的方式, 我们称之为闭包, 在 Scala 中

  • Scala 中的闭包本质上就是一个对象, 是 FunctionX 的实例*

分发闭包

1
2
3
`sc.textFile("dataset/access_log_sample.txt")
.flatMap(item => item.split(""))
.collect()`

上述这段代码中, flatMap 中传入的是另外一个函数, 传入的这个函数就是一个闭包, 这个闭包会被序列化运行在不同的 Executor 中

1d0afe0f7a86237910b974f116fc1747

1
2
3
4
5
6
7
`class MyClass {
val field = "Hello"

def doStuff(rdd: RDD[String]): RDD[String] = {
rdd.map(x => field + x)
}
}`

这段代码中的闭包就有了一个依赖, 依赖于外部的一个类, 因为传递给算子的函数最终要在 Executor 中运行, 所以需要 序列化 MyClass 发给每一个 Executor, 从而在 Executor 访问 MyClass 对象的属性

97d96cbd4169753a9c44c8e3d04735d2

总结

  1. 闭包就是一个封闭的作用域, 也是一个对象
  2. Spark 算子所接受的函数, 本质上是一个闭包, 因为其需要封闭作用域, 并且序列化自身和依赖, 分发到不同的节点中运行

7.1. 累加器

一个小问题

1
2
3
4
5
6
7
`var count = 0

val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]")
val sc = new SparkContext(config)
sc.parallelize(Seq(1, 2, 3, 4, 5))
.foreach(count += _)
println(count)`

上面这段代码是一个非常错误的使用, 请不要仿照, 这段代码只是为了证明一些事情

先明确两件事, var count = 0 是在 Driver 中定义的, foreach(count += _) 这个算子以及传递进去的闭包运行在 Executor 中

这段代码整体想做的事情是累加一个变量, 但是这段代码的写法却做不到这件事, 原因也很简单, 因为具体的算子是闭包, 被分发给不同的节点运行, 所以这个闭包中累加的并不是 Driver 中的这个变量

全局累加器

Accumulators(累加器) 是一个只支持 added(添加) 的分布式变量, 可以在分布式环境下保持一致性, 并且能够做到高效的并发.

原生 Spark 支持数值型的累加器, 可以用于实现计数或者求和, 开发者也可以使用自定义累加器以实现更高级的需求

1
2
3
4
5
6
7
8
`val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]")
val sc = new SparkContext(config)

val counter = sc.longAccumulator("counter")
sc.parallelize(Seq(1, 2, 3, 4, 5))
.foreach(counter.add(_))
// 运行结果: 15
println(counter.value)`

注意点:

  • Accumulator 是支持并发并行的, 在任何地方都可以通过 add 来修改数值, 无论是 Driver 还是 Executor
  • 只能在 Driver 中才能调用 value 来获取数值

在 WebUI 中关于 Job 部分也可以看到 Accumulator 的信息, 以及其运行的情况

41b76292cc02a2e51cb086171e3420fb

累计器件还有两个小特性, 第一, 累加器能保证在 Spark 任务出现问题被重启的时候不会出现重复计算. 第二, 累加器只有在 Action 执行的时候才会被触发.

1
2
3
4
5
6
7
8
`val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]")
val sc = new SparkContext(config)

val counter = sc.longAccumulator("counter")
sc.parallelize(Seq(1, 2, 3, 4, 5))
.map(counter.add(_)) // 这个地方不是 Action, 而是一个 Transformation
// 运行结果是 0
println(counter.value)`

自定义累加器

开发者可以通过自定义累加器来实现更多类型的累加器, 累加器的作用远远不只是累加, 比如可以实现一个累加器, 用于向里面添加一些运行信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
`class InfoAccumulator extends AccumulatorV2[String, Set[String]] {
private val infos: mutable.Set[String] = mutable.Set()

override def isZero: Boolean = {
infos.isEmpty
}
override def copy(): AccumulatorV2[String, Set[String]] = {
val newAccumulator = new InfoAccumulator()
infos.synchronized {
newAccumulator.infos ++= infos
}
newAccumulator
}
override def reset(): Unit = {
infos.clear()
}
override def add(v: String): Unit = {
infos += v
}
override def merge(other: AccumulatorV2[String, Set[String]]): Unit = {
infos ++= other.value
}
override def value: Set[String] = {
infos.toSet
}
}
@Test
def accumulator2(): Unit = {
val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]")
val sc = new SparkContext(config)
val infoAccumulator = new InfoAccumulator()
sc.register(infoAccumulator, "infos")
sc.parallelize(Seq("1", "2", "3"))
.foreach(item => infoAccumulator.add(item))
// 运行结果: Set(3, 1, 2)
println(infoAccumulator.value)
sc.stop()
}`

注意点:

可以通过继承 AccumulatorV2 来创建新的累加器

有几个方法需要重写

1
*   reset 方法用于把累加器重置为 0
  • add 方法用于把其它值添加到累加器中
  • merge 方法用于指定如何合并其他的累加器

value 需要返回一个不可变的集合, 因为不能因为外部的修改而影响自身的值

7.2. 广播变量

目标

  1. 理解为什么需要广播变量, 以及其应用场景
  2. 能够通过代码使用广播变量

广播变量的作用

广播变量允许开发者将一个 Read-Only 的变量缓存到集群中每个节点中, 而不是传递给每一个 Task 一个副本.

  • 集群中每个节点, 指的是一个机器
  • 每一个 Task, 一个 Task 是一个 Stage 中的最小处理单元, 一个 Executor 中可以有多个 Stage, 每个 Stage 有多个 Task

所以在需要跨多个 Stage 的多个 Task 中使用相同数据的情况下, 广播特别的有用

7eb422ef368aec2a1e60636b0f9dfd77

广播变量的API

方法名 描述
id 唯一标识
value 广播变量的值
unpersist 在 Executor 中异步的删除缓存副本
destroy 销毁所有此广播变量所关联的数据和元数据
toString 字符串表示

使用广播变量的一般套路

可以通过如下方式创建广播变量

1
`val b = sc.broadcast(1)`

如果 Log 级别为 DEBUG 的时候, 会打印如下信息

1
2
3
4
5
`DEBUG BlockManager: Put block broadcast_0 locally took  430 ms
DEBUG BlockManager: Putting block broadcast_0 without replication took 431 ms
DEBUG BlockManager: Told master about block broadcast_0_piece0
DEBUG BlockManager: Put block broadcast_0_piece0 locally took 4 ms
DEBUG BlockManager: Putting block broadcast_0_piece0 without replication took 4 ms`

创建后可以使用 value 获取数据

1
`b.value`

获取数据的时候会打印如下信息

1
2
`DEBUG BlockManager: Getting local block broadcast_0
DEBUG BlockManager: Level for block broadcast_0 is StorageLevel(disk, memory, deserialized, 1 replicas)`

广播变量使用完了以后, 可以使用 unpersist 删除数据

1
`b.unpersist`

删除数据以后, 可以使用 destroy 销毁变量, 释放内存空间

1
`b.destroy`

销毁以后, 会打印如下信息

1
2
3
4
`DEBUG BlockManager: Removing broadcast 0
DEBUG BlockManager: Removing block broadcast_0_piece0
DEBUG BlockManager: Told master about block broadcast_0_piece0
DEBUG BlockManager: Removing block broadcast_0`

使用 value 方法的注意点

方法签名 value: T

value 方法内部会确保使用获取数据的时候, 变量必须是可用状态, 所以必须在变量被 destroy 之前使用 value 方法, 如果使用 value 时变量已经失效, 则会爆出以下错误

1
2
3
4
`org.apache.spark.SparkException: Attempted to use Broadcast(0) after it was destroyed (destroy at <console>:27)
at org.apache.spark.broadcast.Broadcast.assertValid(Broadcast.scala:144)
at org.apache.spark.broadcast.Broadcast.value(Broadcast.scala:69)
... 48 elided`

使用 destroy 方法的注意点

方法签名 destroy(): Unit

destroy 方法会移除广播变量, 彻底销毁掉, 但是如果你试图多次 destroy 广播变量, 则会爆出以下错误

1
2
3
4
5
`org.apache.spark.SparkException: Attempted to use Broadcast(0) after it was destroyed (destroy at <console>:27)
at org.apache.spark.broadcast.Broadcast.assertValid(Broadcast.scala:144)
at org.apache.spark.broadcast.Broadcast.destroy(Broadcast.scala:107)
at org.apache.spark.broadcast.Broadcast.destroy(Broadcast.scala:98)
... 48 elided`

广播变量的使用场景

假设我们在某个算子中需要使用一个保存了项目和项目的网址关系的 Map[String, String] 静态集合, 如下

1
2
3
`val pws = Map("Apache Spark" -> "http://spark.apache.org/", "Scala" -> "http://www.scala-lang.org/")

val websites = sc.parallelize(Seq("Apache Spark", "Scala")).map(pws).collect`

上面这段代码是没有问题的, 可以正常运行的, 但是非常的低效, 因为虽然可能 pws 已经存在于某个 Executor 中了, 但是在需要的时候还是会继续发往这个 Executor, 如果想要优化这段代码, 则需要尽可能的降低网络开销

可以使用广播变量进行优化, 因为广播变量会缓存在集群中的机器中, 比 Executor 在逻辑上更 “大”

1
2
`val pwsB = sc.broadcast(pws)
val websites = sc.parallelize(Seq("Apache Spark", "Scala")).map(pwsB.value).collect`

上面两段代码所做的事情其实是一样的, 但是当需要运行多个 Executor (以及多个 Task) 的时候, 后者的效率更高

扩展

正常情况下使用 Task 拉取数据的时候, 会将数据拷贝到 Executor 中多次, 但是使用广播变量的时候只会复制一份数据到 Executor 中, 所以在两种情况下特别适合使用广播变量

一个 Executor 中有多个 Task 的时候

一个变量比较大的时候

而且在 Spark 中还有一个约定俗称的做法, 当一个 RDD 很大并且还需要和另外一个 RDD 执行 join 的时候, 可以将较小的 RDD 广播出去, 然后使用大的 RDD 在算子 map 中直接 join, 从而实现在 Map 端 join

1
2
3
4
5
6
`val acMap = sc.broadcast(myRDD.map { case (a,b,c,b) => (a, c) }.collectAsMap)
val otherMap = sc.broadcast(myOtherRDD.collectAsMap)

myBigRDD.map { case (a, b, c, d) =>
(acMap.value.get(a).get, otherMap.value.get(c).get)
}.collect

一般情况下在这种场景下, 会广播 Map 类型的数据, 而不是数组, 因为这样容易使用 Key 找到对应的 Value 简化使用

总结

  1. 广播变量用于将变量缓存在集群中的机器中, 避免机器内的 Executors 多次使用网络拉取数据
  2. 广播变量的使用步骤: (1) 创建 (2) 在 Task 中获取值 (3) 销毁

SparkSQL

目标

  1. SparkSQL 是什么
  2. SparkSQL 如何使用

1. SparkSQL 是什么

目标

对于一件事的理解, 应该分为两个大部分, 第一, 它是什么, 第二, 它解决了什么问题

  1. 理解为什么会有 SparkSQL
  2. 理解 SparkSQL 所解决的问题, 以及它的使命

1.1. SparkSQL 的出现契机

目标

理解 SparkSQL 是什么

主线

  1. 历史前提
  2. 发展过程
  3. 重要性

数据分析的方式

数据分析的方式大致上可以划分为 SQL 和 命令式两种

命令式

在前面的 RDD 部分, 非常明显可以感觉的到是命令式的, 主要特征是通过一个算子, 可以得到一个结果, 通过结果再进行后续计算.

1
2
3
4
5
sc.textFile("...")
.flatMap(_.split(" "))
.map((_, 1))
.reduceByKey(_ + _)
.collect()

命令式的优点

  • 操作粒度更细, 能够控制数据的每一个处理环节
  • 操作更明确, 步骤更清晰, 容易维护
  • 支持非结构化数据的操作

命令式的缺点

  • 需要一定的代码功底
  • 写起来比较麻烦

SQL

对于一些数据科学家, 要求他们为了做一个非常简单的查询, 写一大堆代码, 明显是一件非常残忍的事情, 所以 SQL on Hadoop 是一个非常重要的方向.

1
2
3
4
5
6
SELECT
name,
age,
school
FROM students
WHERE age > 10

SQL 的优点

  • 表达非常清晰, 比如说这段 SQL 明显就是为了查询三个字段, 又比如说这段 SQL 明显能看到是想查询年龄大于 10 岁的条目

SQL 的缺点

  • 想想一下 3 层嵌套的 SQL, 维护起来应该挺力不从心的吧
  • 试想一下, 如果使用 SQL 来实现机器学习算法, 也挺为难的吧

SQL 擅长数据分析和通过简单的语法表示查询, 命令式操作适合过程式处理和算法性的处理. 在 Spark 出现之前, 对于结构化数据的查询和处理, 一个工具一向只能支持 SQL 或者命令式, 使用者被迫要使用多个工具来适应两种场景, 并且多个工具配合起来比较费劲.

Spark 出现了以后, 统一了两种数据处理范式, 是一种革新性的进步.

因为 SQL 是数据分析领域一个非常重要的范式, 所以 Spark 一直想要支持这种范式, 而伴随着一些决策失误, 这个过程其实还是非常曲折的

img

Hive

解决的问题

  • Hive 实现了 SQL on Hadoop, 使用 MapReduce 执行任务
  • 简化了 MapReduce 任务

新的问题

  • Hive 的查询延迟比较高, 原因是使用 MapReduce 做调度

Shark

解决的问题

  • Shark 改写 Hive 的物理执行计划, 使Spark 作业代替 MapReduce 执行物理计划
  • 使用列式内存存储
  • 以上两点使得 Shark 的查询效率很高

新的问题

  • Shark 重用了 HiveSQL 解析, 逻辑计划生成以及优化, 所以其实可以认为 Shark 只是把 Hive 的物理执行替换为了 Spark 作业
  • 执行计划的生成严重依赖 Hive, 想要增加新的优化非常困难
  • Hive 使用 MapReduce 执行作业, 所以 Hive 是进程级别的并行, 而 Spark 是线程级别的并行, 所以 Hive 中很多线程不安全的代码不适用于 Spark

由于以上问题, Shark 维护了 Hive 的一个分支, 并且无法合并进主线, 难以为继

1
SparkSQL

解决的问题

  • Spark SQL 使用 Hive 解析 SQL 生成 AST 语法树, 将其后的逻辑计划生成, 优化, 物理计划都自己完成, 而不依赖 Hive
  • 执行计划和优化交给优化器 Catalyst
  • 内建了一套简单的 SQL 解析器, 可以不使用 HQL, 此外, 还引入和 DataFrame 这样的 DSL API, 完全可以不依赖任何 Hive 的组件
  • Shark 只能查询文件, Spark SQL 可以直接降查询作用于 RDD, 这一点是一个大进步

新的问题

对于初期版本的 SparkSQL, 依然有挺多问题, 例如只能支持 SQL 的使用, 不能很好的兼容命令式, 入口不够统一等

1
Dataset

SparkSQL 在 2.0 时代, 增加了一个新的 API, 叫做 Dataset, Dataset 统一和结合了 SQL 的访问和命令式 API 的使用, 这是一个划时代的进步

Dataset 中可以轻易的做到使用 SQL 查询并且筛选数据, 然后使用命令式 API 进行探索式分析

重要性imgSparkSQL 不只是一个 SQL 引擎, SparkSQL 也包含了一套对 结构化数据的命令式 API, 事实上, 所有 Spark 中常见的工具, 都是依赖和依照于 SparkSQLAPI 设计的

总结: SparkSQL 是什么

1
SparkSQL` 是一个为了支持 `SQL` 而设计的工具, 但同时也支持命令式的 `API

1.2. SparkSQL 的适用场景

目标

理解 SparkSQL 的适用场景

定义 特点 举例
结构化数据 有固定的 Schema 有预定义的 Schema 关系型数据库的表
半结构化数据 没有固定的 Schema, 但是有结构 没有固定的 Schema, 有结构信息, 数据一般是自描述的 指一些有结构的文件格式, 例如 JSON
非结构化数据 没有固定 Schema, 也没有结构 没有固定 Schema, 也没有结构 指文档图片之类的格式

结构化数据

一般指数据有固定的 Schema, 例如在用户表中, name 字段是 String 型, 那么每一条数据的 name 字段值都可以当作 String 来使用

1
2
3
4
5
6
7
8
9
+----+--------------+---------------------------+-------+---------+
| id | name | url | alexa | country |
+----+--------------+---------------------------+-------+---------+
| 1 | Google | https://www.google.cm/ | 1 | USA |
| 2 | 淘宝 | https://www.taobao.com/ | 13 | CN |
| 3 | 菜鸟教程 | http://www.runoob.com/ | 4689 | CN |
| 4 | 微博 | http://weibo.com/ | 20 | CN |
| 5 | Facebook | https://www.facebook.com/ | 3 | USA |
+----+--------------+---------------------------+-------+---------+

半结构化数据

一般指的是数据没有固定的 Schema, 但是数据本身是有结构的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"firstName": "John",
"lastName": "Smith",
"age": 25,
"phoneNumber":
[
{
"type": "home",
"number": "212 555-1234"
},
{
"type": "fax",
"number": "646 555-4567"
}
]
}

没有固定 Schema

指的是半结构化数据是没有固定的 Schema 的, 可以理解为没有显式指定 Schema
比如说一个用户信息的 JSON 文件, 第一条数据的 phone_num 有可能是 String, 第二条数据虽说应该也是 String, 但是如果硬要指定为 BigInt, 也是有可能的
因为没有指定 Schema, 没有显式的强制的约束

有结构

虽说半结构化数据是没有显式指定 Schema 的, 也没有约束, 但是半结构化数据本身是有有隐式的结构的, 也就是数据自身可以描述自身
例如 JSON 文件, 其中的某一条数据是有字段这个概念的, 每个字段也有类型的概念, 所以说 JSON 是可以描述自身的, 也就是数据本身携带有元信息

SparkSQL 处理什么数据的问题?

  • SparkRDD 主要用于处理 非结构化数据半结构化数据
  • SparkSQL 主要用于处理 结构化数据

SparkSQL 相较于 RDD 的优势在哪?

  • SparkSQL 提供了更好的外部数据源读写支持
    • 因为大部分外部数据源是有结构化的, 需要在 RDD 之外有一个新的解决方案, 来整合这些结构化数据源
  • SparkSQL 提供了直接访问列的能力
    • 因为 SparkSQL 主要用做于处理结构化数据, 所以其提供的 API 具有一些普通数据库的能力

总结: SparkSQL 适用于什么场景?

SparkSQL 适用于处理结构化数据的场景

本章总结

  • SparkSQL 是一个即支持 SQL 又支持命令式数据处理的工具
  • SparkSQL 的主要适用场景是处理结构化数据

2. SparkSQL 初体验

目标

  1. 了解 SparkSQLAPI 由哪些部分组成

2.3. RDD 版本的 WordCount

1
2
3
4
5
6
7
8
val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]")
val sc = new SparkContext(config)

sc.textFile("hdfs://node01:8020/dataset/wordcount.txt")
.flatMap(.split(" "))
.map((, 1))
.reduceByKey(_ + _)
.collect
  • RDD 版本的代码有一个非常明显的特点, 就是它所处理的数据是基本类型的, 在算子中对整个数据进行处理

2.2. 命令式 API 的入门案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
case class People(name: String, age: Int)

val spark: SparkSession = new sql.SparkSession.Builder() (1)
.appName("hello")
.master("local[6]")
.getOrCreate()
import spark.implicits._
val peopleRDD: RDD[People] = spark.sparkContext.parallelize(Seq(People("zhangsan", 9), People("lisi", 15)))
val peopleDS: Dataset[People] = peopleRDD.toDS() (2)
val teenagers: Dataset[String] = peopleDS.where('age > 10) (3)
.where('age < 20)
.select('name)
.as[String]
/*
+----+
|name|
+----+
|lisi|
+----+
*/
teenagers.show()
1 SparkSQL 中有一个新的入口点, 叫做 SparkSession
2 SparkSQL 中有一个新的类型叫做 Dataset
3 SparkSQL 有能力直接通过字段名访问数据集, 说明 SparkSQL 的 API 中是携带 Schema 信息的

SparkSession

SparkContext 作为 RDD 的创建者和入口, 其主要作用有如下两点

  • 创建 RDD, 主要是通过读取文件创建 RDD
  • 监控和调度任务, 包含了一系列组件, 例如 DAGScheduler, TaskSheduler

为什么无法使用 SparkContext 作为 SparkSQL 的入口?

  • SparkContext 在读取文件的时候, 是不包含 Schema 信息的, 因为读取出来的是 RDD
  • SparkContext 在整合数据源如 Cassandra, JSON, Parquet 等的时候是不灵活的, 而 DataFrameDataset 一开始的设计目标就是要支持更多的数据源
  • SparkContext 的调度方式是直接调度 RDD, 但是一般情况下针对结构化数据的访问, 会先通过优化器优化一下

所以 SparkContext 确实已经不适合作为 SparkSQL 的入口, 所以刚开始的时候 Spark 团队为 SparkSQL 设计了两个入口点, 一个是 SQLContext 对应 Spark 标准的 SQL 执行, 另外一个是 HiveContext 对应 HiveSQL 的执行和 Hive 的支持.

Spark 2.0 的时候, 为了解决入口点不统一的问题, 创建了一个新的入口点 SparkSession, 作为整个 Spark 生态工具的统一入口点, 包括了 SQLContext, HiveContext, SparkContext 等组件的功能

新的入口应该有什么特性?

  • 能够整合 SQLContext, HiveContext, SparkContext, StreamingContext 等不同的入口点
  • 为了支持更多的数据源, 应该完善读取和写入体系
  • 同时对于原来的入口点也不能放弃, 要向下兼容

DataFrame & Dataset

img

1
SparkSQL` 最大的特点就是它针对于结构化数据设计, 所以 `SparkSQL` 应该是能支持针对某一个字段的访问的, 而这种访问方式有一个前提, 就是 `SparkSQL` 的数据集中, 要 **包含结构化信息**, 也就是俗称的 `Schema

SparkSQL 对外提供的 API 有两类, 一类是直接执行 SQL, 另外一类就是命令式. SparkSQL 提供的命令式 API 就是 DataFrameDataset, 暂时也可以认为 DataFrame 就是 Dataset, 只是在不同的 API 中返回的是 Dataset 的不同表现形式

1
2
3
4
5
6
// RDD
rdd.map { case Person(id, name, age) => (age, 1) }
.reduceByKey {case ((age, count), (totalAge, totalCount)) => (age, count + totalCount)}

// DataFrame
df.groupBy("age").count("age")

通过上面的代码, 可以清晰的看到, SparkSQL 的命令式操作相比于 RDD 来说, 可以直接通过 Schema 信息来访问其中某个字段, 非常的方便

2.2. SQL 版本 WordCount

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

import spark.implicits._
val peopleRDD: RDD[People] = spark.sparkContext.parallelize(Seq(People("zhangsan", 9), People("lisi", 15)))
val peopleDS: Dataset[People] = peopleRDD.toDS()
peopleDS.createOrReplaceTempView("people")
val teenagers: DataFrame = spark.sql("select name from people where age > 10 and age < 20")
/*
+----+
|name|
+----+
|lisi|
+----+
*/
teenagers.show()

以往使用 SQL 肯定是要有一个表的, 在 Spark 中, 并不存在表的概念, 但是有一个近似的概念, 叫做 DataFrame, 所以一般情况下要先通过 DataFrame 或者 Dataset 注册一张临时表, 然后使用 SQL 操作这张临时表

总结

SparkSQL 提供了 SQL 和 命令式 API 两种不同的访问结构化数据的形式, 并且它们之间可以无缝的衔接

命令式 API 由一个叫做 Dataset 的组件提供, 其还有一个变形, 叫做 DataFrame

3. [扩展] Catalyst 优化器

目标

  1. 理解 SparkSQL 和以 RDD 为代表的 SparkCore 最大的区别
  2. 理解优化器的运行原理和作用

3.1. RDD 和 SparkSQL 运行时的区别

RDD 的运行流程

img

大致运行步骤

先将 RDD 解析为由 Stage 组成的 DAG, 后将 Stage 转为 Task 直接运行

问题

任务会按照代码所示运行, 依赖开发者的优化, 开发者的会在很大程度上影响运行效率

解决办法

创建一个组件, 帮助开发者修改和优化代码, 但是这在 RDD 上是无法实现的

为什么 RDD 无法自我优化?

  • RDD 没有 Schema 信息
  • RDD 可以同时处理结构化和非结构化的数据

SparkSQL 提供了什么?

img

RDD 不同, SparkSQLDatasetSQL 并不是直接生成计划交给集群执行, 而是经过了一个叫做 Catalyst 的优化器, 这个优化器能够自动帮助开发者优化代码

也就是说, 在 SparkSQL 中, 开发者的代码即使不够优化, 也会被优化为相对较好的形式去执行

为什么 SparkSQL 提供了这种能力?

首先, SparkSQL 大部分情况用于处理结构化数据和半结构化数据, 所以 SparkSQL 可以获知数据的 Schema, 从而根据其 Schema 来进行优化

3.2. Catalyst

为了解决过多依赖 Hive 的问题, SparkSQL 使用了一个新的 SQL 优化器替代 Hive 中的优化器, 这个优化器就是 Catalyst, 整个 SparkSQL 的架构大致如下imgAPI 层简单的说就是 Spark 会通过一些 API 接受 SQL 语句收到 SQL 语句以后, 将其交给 Catalyst, Catalyst 负责解析 SQL, 生成执行计划等Catalyst 的输出应该是 RDD 的执行计划最终交由集群运行

img

Step 1 : 解析 SQL, 并且生成 AST (抽象语法树)

img

Step 2 : 在 AST 中加入元数据信息, 做这一步主要是为了一些优化, 例如 col = col 这样的条件, 下图是一个简略图, 便于理解

img

  • score.id → id#1#Lscore.id 生成 id 为 1, 类型是 Long
  • score.math_score → math_score#2#Lscore.math_score 生成 id 为 2, 类型为 Long
  • people.id → id#3#Lpeople.id 生成 id 为 3, 类型为 Long
  • people.age → age#4#Lpeople.age 生成 id 为 4, 类型为 Long

Step 3 : 对已经加入元数据的 AST, 输入优化器, 进行优化, 从两种常见的优化开始, 简单介绍

img

  • 谓词下推 Predicate Pushdown, 将 Filter 这种可以减小数据集的操作下推, 放在 Scan 的位置, 这样可以减少操作时候的数据量

img

  • 列值裁剪 Column Pruning, 在谓词下推后, people 表之上的操作只用到了 id 列, 所以可以把其它列裁剪掉, 这样可以减少处理的数据量, 从而优化处理速度

  • 还有其余很多优化点, 大概一共有一二百种, 随着 SparkSQL 的发展, 还会越来越多, 感兴趣的同学可以继续通过源码了解, 源码在 org.apache.spark.sql.catalyst.optimizer.Optimizer

Step 4 : 上面的过程生成的 AST 其实最终还没办法直接运行, 这个 AST 叫做 逻辑计划, 结束后, 需要生成 物理计划, 从而生成 RDD 来运行

  • 在生成物理计划的时候, 会经过成本模型对整棵树再次执行优化, 选择一个更好的计划
  • 在生成物理计划以后, 因为考虑到性能, 所以会使用代码生成, 在机器中运行
可以使用 queryExecution 方法查看逻辑执行计划, 使用 explain 方法查看物理执行计划img)img也可以使用 Spark WebUI 进行查看img

总结

SparkSQLRDD 不同的主要点是在于其所操作的数据是结构化的, 提供了对数据更强的感知和分析能力, 能够对代码进行更深层的优化, 而这种能力是由一个叫做 Catalyst 的优化器所提供的

Catalyst 的主要运作原理是分为三步, 先对 SQL 或者 Dataset 的代码解析, 生成逻辑计划, 后对逻辑计划进行优化, 再生成物理计划, 最后生成代码到集群中以 RDD 的形式运行

4. Dataset 的特点

目标

  1. 理解 Dataset 是什么
  2. 理解 Dataset 的特性

Dataset 是什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

import spark.implicits._
val dataset: Dataset[People] = spark.createDataset(Seq(People("zhangsan", 9), People("lisi", 15)))
// 方式1: 通过对象来处理
dataset.filter(item => item.age > 10).show()
// 方式2: 通过字段来处理
dataset.filter('age > 10).show()
// 方式3: 通过类似SQL的表达式来处理
dataset.filter("age > 10").show()

问题1: People 是什么?

People 是一个强类型的类

问题2: 这个 Dataset 中是结构化的数据吗?

非常明显是的, 因为 People 对象中有结构信息, 例如字段名和字段类型

问题3: 这个 Dataset 能够使用类似 SQL 这样声明式结构化查询语句的形式来查询吗?

当然可以, 已经演示过了

问题4: Dataset 是什么?

1
Dataset` 是一个强类型, 并且类型安全的数据容器, 并且提供了结构化查询 `API` 和类似 `RDD` 一样的命令式 `API

即使使用 Dataset 的命令式 API, 执行计划也依然会被优化

Dataset 具有 RDD 的方便, 同时也具有 DataFrame 的性能优势, 并且 Dataset 还是强类型的, 能做到类型安全.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scala> spark.range(1).filter('id === 0).explain(true)

== Parsed Logical Plan ==
'Filter ('id = 0)
+- Range (0, 1, splits=8)
== Analyzed Logical Plan ==
id: bigint
Filter (id#51L = cast(0 as bigint))
+- Range (0, 1, splits=8)
== Optimized Logical Plan ==
Filter (id#51L = 0)
+- Range (0, 1, splits=8)
== Physical Plan ==
*Filter (id#51L = 0)
+- *Range (0, 1, splits=8)

Dataset 的底层是什么?

Dataset 最底层处理的是对象的序列化形式, 通过查看 Dataset 生成的物理执行计划, 也就是最终所处理的 RDD, 就可以判定 Dataset 底层处理的是什么形式的数据

1
2
3
val dataset: Dataset[People] = spark.createDataset(Seq(People("zhangsan", 9), People("lisi", 15)))
val internalRDD: RDD[InternalRow] = dataset.queryExecution.toRdd
dataset.queryExecution.toRdd` 这个 `API` 可以看到 `Dataset` 底层执行的 `RDD`, 这个 `RDD` 中的范型是 `InternalRow`, `InternalRow` 又称之为 `Catalyst Row`, 是 `Dataset` 底层的数据结构, 也就是说, 无论 `Dataset` 的范型是什么, 无论是 `Dataset[Person]` 还是其它的, 其最底层进行处理的数据结构都是 `InternalRow

所以, Dataset 的范型对象在执行之前, 需要通过 Encoder 转换为 InternalRow, 在输入之前, 需要把 InternalRow 通过 Decoder 转换为范型对象

img

可以获取 Dataset 对应的 RDD 表示

Dataset 中, 可以使用一个属性 rdd 来得到它的 RDD 表示, 例如 Dataset[T] → RDD[T]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val dataset: Dataset[People] = spark.createDataset(Seq(People("zhangsan", 9), People("lisi", 15)))

/*
(2) MapPartitionsRDD[3] at rdd at Testing.scala:159 []
| MapPartitionsRDD[2] at rdd at Testing.scala:159 []
| MapPartitionsRDD[1] at rdd at Testing.scala:159 []
| ParallelCollectionRDD[0] at rdd at Testing.scala:159 []
*/
(1)
println(dataset.rdd.toDebugString) // 这段代码的执行计划为什么多了两个步骤?
/*
(2) MapPartitionsRDD[5] at toRdd at Testing.scala:160 []
| ParallelCollectionRDD[4] at toRdd at Testing.scala:160 []
*/
(2)
println(dataset.queryExecution.toRdd.toDebugString)
1 使用 Dataset.rddDataset 转为 RDD 的形式
2 Dataset 的执行计划底层的 RDD

可以看到 (1) 对比 (2) 对了两个步骤, 这两个步骤的本质就是将 Dataset 底层的 InternalRow 转为 RDD 中的对象形式, 这个操作还是会有点重的, 所以慎重使用 rdd 属性来转换 DatasetRDD

总结

  1. Dataset 是一个新的 Spark 组件, 其底层还是 RDD
  2. Dataset 提供了访问对象中某个特定字段的能力, 不用像 RDD 一样每次都要针对整个对象做操作
  3. DatasetRDD 不同, 如果想把 Dataset[T] 转为 RDD[T], 则需要对 Dataset 底层的 InternalRow 做转换, 是一个比价重量级的操作

5. DataFrame 的作用和常见操作

目标

  1. 理解 DataFrame 是什么
  2. 理解 DataFrame 的常见操作

DataFrame 是什么?

DataFrameSparkSQL 中一个表示关系型数据库中 的函数式抽象, 其作用是让 Spark 处理大规模结构化数据的时候更加容易. 一般 DataFrame 可以处理结构化的数据, 或者是半结构化的数据, 因为这两类数据中都可以获取到 Schema 信息. 也就是说 DataFrame 中有 Schema 信息, 可以像操作表一样操作 DataFrame.

img

DataFrame 由两部分构成, 一是 row 的集合, 每个 row 对象表示一个行, 二是描述 DataFrame 结构的 Schema.

img

DataFrame 支持 SQL 中常见的操作, 例如: select, filter, join, group, sort, join

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

import spark.implicits._
val peopleDF: DataFrame = Seq(People("zhangsan", 15), People("lisi", 15)).toDF()
/*
+---+-----+
|age|count|
+---+-----+
| 15| 2|
+---+-----+
*/
peopleDF.groupBy('age)
.count()
.show()

通过隐式转换创建 DataFrame

这种方式本质上是使用 SparkSession 中的隐式转换来进行的

1
2
3
4
5
6
7
8
9
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

// 必须要导入隐式转换
// 注意: spark 在此处不是包, 而是 SparkSession 对象
import spark.implicits._
val peopleDF: DataFrame = Seq(People("zhangsan", 15), People("lisi", 15)).toDF()

img

根据源码可以知道, toDF 方法可以在 RDDSeq 中使用

通过集合创建 DataFrame 的时候, 集合中不仅可以包含样例类, 也可以只有普通数据类型, 后通过指定列名来创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

import spark.implicits._
val df1: DataFrame = Seq("nihao", "hello").toDF("text")
/*
+-----+
| text|
+-----+
|nihao|
|hello|
+-----+
*/
df1.show()
val df2: DataFrame = Seq(("a", 1), ("b", 1)).toDF("word", "count")
/*
+----+-----+
|word|count|
+----+-----+
| a| 1|
| b| 1|
+----+-----+
*/
df2.show()

通过外部集合创建 DataFrame

1
2
3
4
5
6
7
8
9
10
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

val df = spark.read
.option("header", true)
.csv("dataset/BeijingPM20100101_20151231.csv")
df.show(10)
df.printSchema()

不仅可以从 csv 文件创建 DataFrame, 还可以从 Table, JSON, Parquet 等中创建 DataFrame, 后续会有单独的章节来介绍

DataFrame 上可以使用的常规操作

需求: 查看每个月的统计数量

Step 1: 首先可以打印 DataFrameSchema, 查看其中所包含的列, 以及列的类型

1
2
3
4
5
6
7
8
9
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

val df = spark.read
.option("header", true)
.csv("dataset/BeijingPM20100101_20151231.csv")
df.printSchema()

Step 2: 对于大部分计算来说, 可能不会使用所有的列, 所以可以选择其中某些重要的列

1
2
3
...

df.select('year, 'month, 'PM_Dongsi)

Step 3: 可以针对某些列进行分组, 后对每组数据通过函数做聚合

1
2
3
4
5
6
7
...

df.select('year, 'month, 'PM_Dongsi)
.where('PM_Dongsi =!= "Na")
.groupBy('year, 'month)
.count()
.show()

使用 SQL 操作 DataFrame

使用 SQL 来操作某个 DataFrame 的话, SQL 中必须要有一个 from 子句, 所以需要先将 DataFrame 注册为一张临时表

1
2
3
4
5
6
7
8
9
10
11
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

val df = spark.read
.option("header", true)
.csv("dataset/BeijingPM20100101_20151231.csv")
df.createOrReplaceTempView("temp_table")
spark.sql("select year, month, count(*) from temp_table where PM_Dongsi != 'NA' group by year, month")
.show()

总结

  1. DataFrame 是一个类似于关系型数据库表的函数式组件
  2. DataFrame 一般处理结构化数据和半结构化数据
  3. DataFrame 具有数据对象的 Schema 信息
  4. 可以使用命令式的 API 操作 DataFrame, 同时也可以使用 SQL 操作 DataFrame
  5. DataFrame 可以由一个已经存在的集合直接创建, 也可以读取外部的数据源来创建

6. Dataset 和 DataFrame 的异同

目标

  1. 理解 DatasetDataFrame 之间的关系
1
DataFrame` 就是 `Dataset

根据前面的内容, 可以得到如下信息

  1. Dataset 中可以使用列来访问数据, DataFrame 也可以
  2. Dataset 的执行是优化的, DataFrame 也是
  3. Dataset 具有命令式 API, 同时也可以使用 SQL 来访问, DataFrame 也可以使用这两种不同的方式访问

所以这件事就比较蹊跷了, 两个这么相近的东西为什么会同时出现在 SparkSQL 中呢?

img

确实, 这两个组件是同一个东西, DataFrameDataset 的一种特殊情况, 也就是说 DataFrameDataset[Row] 的别名

DataFrameDataset 所表达的语义不同

第一点: DataFrame 表达的含义是一个支持函数式操作的 , 而 Dataset 表达是是一个类似 RDD 的东西, Dataset 可以处理任何对象

第二点: DataFrame 中所存放的是 Row 对象, 而 Dataset 中可以存放任何类型的对象

1
2
3
4
5
6
7
8
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

import spark.implicits._
val df: DataFrame = Seq(People("zhangsan", 15), People("lisi", 15)).toDF() (1)
val ds: Dataset[People] = Seq(People("zhangsan", 15), People("lisi", 15)).toDS() (2)
1 DataFrame 就是 Dataset[Row]
2 Dataset 的范型可以是任意类型

第三点: DataFrame 的操作方式和 Dataset 是一样的, 但是对于强类型操作而言, 它们处理的类型不同

1
2
DataFrame` 在进行强类型操作时候, 例如 `map` 算子, 其所处理的数据类型永远是 `Row
df.map( (row: Row) => Row(row.get(0), row.getAs[Int](1) * 10) )(RowEncoder.apply(df.schema)).show()

但是对于 Dataset 来讲, 其中是什么类型, 它就处理什么类型

1
ds.map( (item: People) => People(item.name, item.age * 10) ).show()

第三点: DataFrame 只能做到运行时类型检查, Dataset 能做到编译和运行时都有类型检查

  1. DataFrame 中存放的数据以 Row 表示, 一个 Row 代表一行数据, 这和关系型数据库类似
  2. DataFrame 在进行 map 等操作的时候, DataFrame 不能直接使用 Person 这样的 Scala 对象, 所以无法做到编译时检查
  3. Dataset 表示的具体的某一类对象, 例如 Person, 所以再进行 map 等操作的时候, 传入的是具体的某个 Scala 对象, 如果调用错了方法, 编译时就会被检查出来
1
2
val ds: Dataset[People] = Seq(People("zhangsan", 15), People("lisi", 15)).toDS()
ds.map(person => person.hello) (1)
1 这行代码明显报错, 无法通过编译

Row 是什么?

1
Row` 对象表示的是一个 `行

Row 的操作类似于 Scala 中的 Map 数据类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 一个对象就是一个对象
val p = People(name = "zhangsan", age = 10)

// 同样一个对象, 还可以通过一个 Row 对象来表示
val row = Row("zhangsan", 10)
// 获取 Row 中的内容
println(row.get(1))
println(row(1))
// 获取时可以指定类型
println(row.getAsInt)
// 同时 Row 也是一个样例类, 可以进行 match
row match {
case Row(name, age) => println(name, age)
}

DataFrameDataset 之间可以非常简单的相互转换

1
2
3
4
5
6
7
8
9
10
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

import spark.implicits._
val df: DataFrame = Seq(People("zhangsan", 15), People("lisi", 15)).toDF()
val ds_fdf: Dataset[People] = df.as[People]
val ds: Dataset[People] = Seq(People("zhangsan", 15), People("lisi", 15)).toDS()
val df_fds: DataFrame = ds.toDF()

总结

  1. DataFrame 就是 Dataset, 他们的方式是一样的, 也都支持 APISQL 两种操作方式
  2. DataFrame 只能通过表达式的形式, 或者列的形式来访问数据, 只有 Dataset 支持针对于整个对象的操作
  3. DataFrame 中的数据表示为 Row, 是一个行的概念

7. 数据读写

目标

  1. 理解外部数据源的访问框架
  2. 掌握常见的数据源读写方式

7.1. 初识 DataFrameReader

目标

  • 理解 DataFrameReader 的整体结构和组成
1
2
3
4
5
6
SparkSQL` 的一个非常重要的目标就是完善数据读取, 所以 `SparkSQL` 中增加了一个新的框架, 专门用于读取外部数据源, 叫做 `DataFrameReader
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.DataFrameReader

val spark: SparkSession = ...
val reader: DataFrameReader = spark.read

DataFrameReader 由如下几个组件组成

组件 解释
schema 结构信息, 因为 Dataset 是有结构的, 所以在读取数据的时候, 就需要有 Schema 信息, 有可能是从外部数据源获取的, 也有可能是指定的
option 连接外部数据源的参数, 例如 JDBCURL, 或者读取 CSV 文件是否引入 Header
format 外部数据源的格式, 例如 csv, jdbc, json

DataFrameReader 有两种访问方式, 一种是使用 load 方法加载, 使用 format 指定加载格式, 还有一种是使用封装方法, 类似 csv, json, jdbc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.DataFrame

val spark: SparkSession = ...
// 使用 load 方法
val fromLoad: DataFrame = spark
.read
.format("csv")
.option("header", true)
.option("inferSchema", true)
.load("dataset/BeijingPM20100101_20151231.csv")
// Using format-specific load operator
val fromCSV: DataFrame = spark
.read
.option("header", true)
.option("inferSchema", true)
.csv("dataset/BeijingPM20100101_20151231.csv")

但是其实这两种方式本质上一样, 因为类似 csv 这样的方式只是 load 的封装

img

如果使用 load 方法加载数据, 但是没有指定 format 的话, 默认是按照 Parquet 文件格式读取也就是说, SparkSQL 默认的读取格式是 Parquet

总结

  1. 使用 spark.read 可以获取 SparkSQL 中的外部数据源访问框架 DataFrameReader
  2. DataFrameReader 有三个组件 format, schema, option
  3. DataFrameReader 有两种使用方式, 一种是使用 loadformat 指定格式, 还有一种是使用封装方法 csv, json

7.2. 初识 DataFrameWriter

目标

  1. 理解 DataFrameWriter 的结构

对于 ETL 来说, 数据保存和数据读取一样重要, 所以 SparkSQL 中增加了一个新的数据写入框架, 叫做 DataFrameWriter

1
2
3
4
5
6
val spark: SparkSession = ...

val df = spark.read
.option("header", true)
.csv("dataset/BeijingPM20100101_20151231.csv")
val writer: DataFrameWriter[Row] = df.write

DataFrameWriter 中由如下几个部分组成

组件 解释
source 写入目标, 文件格式等, 通过 format 方法设定
mode 写入模式, 例如一张表已经存在, 如果通过 DataFrameWriter 向这张表中写入数据, 是覆盖表呢, 还是向表中追加呢? 通过 mode 方法设定
extraOptions 外部参数, 例如 JDBCURL, 通过 options, option 设定
partitioningColumns 类似 Hive 的分区, 保存表的时候使用, 这个地方的分区不是 RDD 的分区, 而是文件的分区, 或者表的分区, 通过 partitionBy 设定
bucketColumnNames 类似 Hive 的分桶, 保存表的时候使用, 通过 bucketBy 设定
sortColumnNames 用于排序的列, 通过 sortBy 设定

mode 指定了写入模式, 例如覆盖原数据集, 或者向原数据集合中尾部添加等

Scala 对象表示 字符串表示 解释
SaveMode.ErrorIfExists "error" DataFrame 保存到 source 时, 如果目标已经存在, 则报错
SaveMode.Append "append" DataFrame 保存到 source 时, 如果目标已经存在, 则添加到文件或者 Table
SaveMode.Overwrite "overwrite" DataFrame 保存到 source 时, 如果目标已经存在, 则使用 DataFrame 中的数据完全覆盖目标
SaveMode.Ignore "ignore" DataFrame 保存到 source 时, 如果目标已经存在, 则不会保存 DataFrame 数据, 并且也不修改目标数据集, 类似于 CREATE TABLE IF NOT EXISTS

DataFrameWriter 也有两种使用方式, 一种是使用 format 配合 save, 还有一种是使用封装方法, 例如 csv, json, saveAsTable

1
2
3
4
5
6
7
8
9
val spark: SparkSession = ...

val df = spark.read
.option("header", true)
.csv("dataset/BeijingPM20100101_20151231.csv")
// 使用 save 保存, 使用 format 设置文件格式
df.write.format("json").save("dataset/beijingPM")
// 使用 json 保存, 因为方法是 json, 所以隐含的 format 是 json
df.write.json("dataset/beijingPM1")
默认没有指定 format, 默认的 formatParquet

总结

  1. 类似 DataFrameReader, Writer 中也有 format, options, 另外 schema 是包含在 DataFrame 中的
  2. DataFrameWriter 中还有一个很重要的概念叫做 mode, 指定写入模式, 如果目标集合已经存在时的行为
  3. DataFrameWriter 可以将数据保存到 Hive 表中, 所以也可以指定分区和分桶信息

7.3. 读写 Parquet 格式文件

目标

  1. 理解 Spark 读写 Parquet 文件的语法
  2. 理解 Spark 读写 Parquet 文件的时候对于分区的处理

什么时候会用到 Parquet ?

img

ETL 中, Spark 经常扮演 T 的职务, 也就是进行数据清洗和数据转换.

为了能够保存比较复杂的数据, 并且保证性能和压缩率, 通常使用 Parquet 是一个比较不错的选择.

所以外部系统收集过来的数据, 有可能会使用 Parquet, 而 Spark 进行读取和转换的时候, 就需要支持对 Parquet 格式的文件的支持.

使用代码读写 Parquet 文件

默认不指定 format 的时候, 默认就是读写 Parquet 格式的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

val df = spark.read
.option("header", value = true)
.csv("dataset/911.csv")
// 保存 Parquet 文件
df.write.mode("override").save("dataset/911.parquet")
// 读取 Parquet 文件
val dfFromParquet = spark.read.parquet("dataset/911.parquet")
dfFromParquet.createOrReplaceTempView("911")
spark.sql("select * from 911 where zip > 19000 and zip < 19400").show()

写入 Parquet 的时候可以指定分区

Spark 在写入文件的时候是支持分区的, 可以像 Hive 一样设置某个列为分区列

1
2
3
4
5
6
7
8
9
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

// 从 CSV 中读取内容
val dfFromParquet = spark.read.option("header", value = true).csv("dataset/BeijingPM20100101_20151231.csv")
// 保存为 Parquet 格式文件, 不指定 format 默认就是 Parquet
dfFromParquet.write.partitionBy("year", "month").save("dataset/beijing_pm")

img

这个地方指的分区是类似 Hive 中表分区的概念, 而不是 RDD 分布式分区的含义

分区发现

在读取常见文件格式的时候, Spark 会自动的进行分区发现, 分区自动发现的时候, 会将文件名中的分区信息当作一列. 例如 如果按照性别分区, 那么一般会生成两个文件夹 gender=malegender=female, 那么在使用 Spark 读取的时候, 会自动发现这个分区信息, 并且当作列放入创建的 DataFrame

使用代码证明这件事可以有两个步骤, 第一步先读取某个分区的单独一个文件并打印其 Schema 信息, 第二步读取整个数据集所有分区并打印 Schema 信息, 和第一步做比较就可以确定

1
2
3
4
val spark = ...

val partDF = spark.read.load("dataset/beijing_pm/year=2010/month=1") (1)
partDF.printSchema()
1 把分区的数据集中的某一个区单做一整个数据集读取, 没有分区信息, 自然也不会进行分区发现

img

1
2
val df = spark.read.load("dataset/beijing_pm") (1)
df.printSchema()
1 此处读取的是整个数据集, 会进行分区发现, DataFrame 中会包含分去列

img

配置 默认值 含义
spark.sql.parquet.binaryAsString false 一些其他 Parquet 生产系统, 不区分字符串类型和二进制类型, 该配置告诉 SparkSQL 将二进制数据解释为字符串以提供与这些系统的兼容性
spark.sql.parquet.int96AsTimestamp true 一些其他 Parquet 生产系统, 将 Timestamp 存为 INT96, 该配置告诉 SparkSQLINT96 解析为 Timestamp
spark.sql.parquet.cacheMetadata true 打开 Parquet 元数据的缓存, 可以加快查询静态数据
spark.sql.parquet.compression.codec snappy 压缩方式, 可选 uncompressed, snappy, gzip, lzo
spark.sql.parquet.mergeSchema false 当为 true 时, Parquet 数据源会合并从所有数据文件收集的 Schemas 和数据, 因为这个操作开销比较大, 所以默认关闭
spark.sql.optimizer.metadataOnly true 如果为 true, 会通过原信息来生成分区列, 如果为 false 则就是通过扫描整个数据集来确定

总结

  1. Spark 不指定 format 的时候默认就是按照 Parquet 的格式解析文件
  2. Spark 在读取 Parquet 文件的时候会自动的发现 Parquet 的分区和分区字段
  3. Spark 在写入 Parquet 文件的时候如果设置了分区字段, 会自动的按照分区存储

7.4. 读写 JSON 格式文件

目标

  1. 理解 JSON 的使用场景
  2. 能够使用 Spark 读取处理 JSON 格式文件

什么时候会用到 JSON ?

img

ETL 中, Spark 经常扮演 T 的职务, 也就是进行数据清洗和数据转换.

在业务系统中, JSON 是一个非常常见的数据格式, 在前后端交互的时候也往往会使用 JSON, 所以从业务系统获取的数据很大可能性是使用 JSON 格式, 所以就需要 Spark 能够支持 JSON 格式文件的读取

读写 JSON 文件

将要 Dataset 保存为 JSON 格式的文件比较简单, 是 DataFrameWriter 的一个常规使用

1
2
3
4
5
6
7
8
9
10
val spark: SparkSession = new sql.SparkSession.Builder()
.appName("hello")
.master("local[6]")
.getOrCreate()

val dfFromParquet = spark.read.load("dataset/beijing_pm")
// 将 DataFrame 保存为 JSON 格式的文件
dfFromParquet.repartition(1) (1)
.write.format("json")
.save("dataset/beijing_pm_json")
1 如果不重新分区, 则会为 DataFrame 底层的 RDD 的每个分区生成一个文件, 为了保持只有一个输出文件, 所以重新分区
保存为 JSON 格式的文件有一个细节需要注意, 这个 JSON 格式的文件中, 每一行是一个独立的 JSON, 但是整个文件并不只是一个 JSON 字符串, 所以这种文件格式很多时候被成为 JSON Line 文件, 有时候后缀名也会变为 jsonlbeijing_pm.jsonl{"day":"1","hour":"0","season":"1","year":2013,"month":3} {"day":"1","hour":"1","season":"1","year":2013,"month":3} {"day":"1","hour":"2","season":"1","year":2013,"month":3}

也可以通过 DataFrameReader 读取一个 JSON Line 文件

1
2
3
4
val spark: SparkSession = ...

val dfFromJSON = spark.read.json("dataset/beijing_pm_json")
dfFromJSON.show()
1
JSON` 格式的文件是有结构信息的, 也就是 `JSON` 中的字段是有类型的, 例如 `"name": "zhangsan"` 这样由双引号包裹的 `Value`, 就是字符串类型, 而 `"age": 10` 这种没有双引号包裹的就是数字类型, 当然, 也可以是布尔型 `"has_wife": true

Spark 读取 JSON Line 文件的时候, 会自动的推断类型信息

1
2
3
4
val spark: SparkSession = ...

val dfFromJSON = spark.read.json("dataset/beijing_pm_json")
dfFromJSON.printSchema()

img

1
Spark` 可以从一个保存了 `JSON` 格式字符串的 `Dataset[String]` 中读取 `JSON` 信息, 转为 `DataFrame

这种情况其实还是比较常见的, 例如如下的流程

img

假设业务系统通过 Kafka 将数据流转进入大数据平台, 这个时候可能需要使用 RDD 或者 Dataset 来读取其中的内容, 这个时候一条数据就是一个 JSON 格式的字符串, 如何将其转为 DataFrame 或者 Dataset[Object] 这样具有 Schema 的数据集呢? 使用如下代码就可以

1
2
3
4
5
6
val spark: SparkSession = ...

import spark.implicits._
val peopleDataset = spark.createDataset(
"""{"name":"Yin","address":{"city":"Columbus","state":"Ohio"}}""" :: Nil)
spark.read.json(peopleDataset).show()

总结

  1. JSON 通常用于系统间的交互, Spark 经常要读取 JSON 格式文件, 处理, 放在另外一处
  2. 使用 DataFrameReaderDataFrameWriter 可以轻易的读取和写入 JSON, 并且会自动处理数据类型信息

7.5. 访问 Hive

导读

  1. 整合 SparkSQLHive, 使用 HiveMetaStore 元信息库
  2. 使用 SparkSQL 查询 Hive
  3. 案例, 使用常见 HiveSQL
  4. 写入内容到 Hive

7.5.1. SparkSQL 整合 Hive

导读

  1. 开启 HiveMetaStore 独立进程
  2. 整合 SparkSQLHiveMetaStore

和一个文件格式不同, Hive 是一个外部的数据存储和查询引擎, 所以如果 Spark 要访问 Hive 的话, 就需要先整合 Hive

整合什么 ?

如果要讨论 SparkSQL 如何和 Hive 进行整合, 首要考虑的事应该是 Hive 有什么, 有什么就整合什么就可以

  • MetaStore, 元数据存储

    SparkSQL 内置的有一个 MetaStore, 通过嵌入式数据库 Derby 保存元信息, 但是对于生产环境来说, 还是应该使用 HiveMetaStore, 一是更成熟, 功能更强, 二是可以使用 Hive 的元信息

  • 查询引擎

    SparkSQL 内置了 HiveSQL 的支持, 所以无需整合

为什么要开启 HiveMetaStore

HiveMetaStore 是一个 Hive 的组件, 一个 Hive 提供的程序, 用以保存和访问表的元数据, 整个 Hive 的结构大致如下

img

由上图可知道, 其实 Hive 中主要的组件就三个, HiveServer2 负责接受外部系统的查询请求, 例如 JDBC, HiveServer2 接收到查询请求后, 交给 Driver 处理, Driver 会首先去询问 MetaStore 表在哪存, 后 Driver 程序通过 MR 程序来访问 HDFS 从而获取结果返回给查询请求者

HiveMetaStoreSparkSQL 的意义非常重大, 如果 SparkSQL 可以直接访问 HiveMetaStore, 则理论上可以做到和 Hive 一样的事情, 例如通过 Hive 表查询数据

而 Hive 的 MetaStore 的运行模式有三种

  • 内嵌 Derby 数据库模式

    这种模式不必说了, 自然是在测试的时候使用, 生产环境不太可能使用嵌入式数据库, 一是不稳定, 二是这个 Derby 是单连接的, 不支持并发

  • Local 模式

    LocalRemote 都是访问 MySQL 数据库作为存储元数据的地方, 但是 Local 模式的 MetaStore 没有独立进程, 依附于 HiveServer2 的进程

  • Remote 模式

    Loca 模式一样, 访问 MySQL 数据库存放元数据, 但是 RemoteMetaStore 运行在独立的进程中

我们显然要选择 Remote 模式, 因为要让其独立运行, 这样才能让 SparkSQL 一直可以访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Hive` 开启 `MetaStore
Step 1`: 修改 `hive-site.xml
<property>
<name>hive.metastore.warehouse.dir</name>
<value>/user/hive/warehouse</value>
</property>

<property>
<name>javax.jdo.option.ConnectionURL</name>
<value>jdbc:mysql://node01:3306/hive?createDatabaseIfNotExist=true</value>
</property>
<property>
<name>javax.jdo.option.ConnectionDriverName</name>
<value>com.mysql.jdbc.Driver</value>
</property>
<property>
<name>javax.jdo.option.ConnectionUserName</name>
<value>username</value>
</property>
<property>
<name>javax.jdo.option.ConnectionPassword</name>
<value>password</value>
</property>
<property>
<name>hive.metastore.local</name>
<value>false</value>
</property>
<property>
<name>hive.metastore.uris</name>
<value>thrift://node01:9083</value> //当前服务器
</property>
1
2
3
Step 2`: 启动 `Hive MetaStore
nohup /export/servers/hive/bin/hive --service metastore 2>&1 >> /var/log.log &
SparkSQL` 整合 `Hive` 的 `MetaStore

即使不去整合 MetaStore, Spark 也有一个内置的 MateStore, 使用 Derby 嵌入式数据库保存数据, 但是这种方式不适合生产环境, 因为这种模式同一时间只能有一个 SparkSession 使用, 所以生产环境更推荐使用 HiveMetaStore

SparkSQL 整合 HiveMetaStore 主要思路就是要通过配置能够访问它, 并且能够使用 HDFS 保存 WareHouse, 这些配置信息一般存在于 HadoopHDFS 的配置文件中, 所以可以直接拷贝 HadoopHive 的配置文件到 Spark 的配置目录

1
2
3
4
5
cd /export/servers/hadoop/etc/hadoop
cp hive-site.xml core-site.xml hdfs-site.xml /export/servers/spark/conf/ (1) (2) (3)

scp -r /export/servers/spark/conf node02:/export/servers/spark/conf
scp -r /export/servers/spark/conf node03:/export/servers/spark/conf
1 Spark 需要 hive-site.xml 的原因是, 要读取 Hive 的配置信息, 主要是元数据仓库的位置等信息
2 Spark 需要 core-site.xml 的原因是, 要读取安全有关的配置
3 Spark 需要 hdfs-site.xml 的原因是, 有可能需要在 HDFS 中放置表文件, 所以需要 HDFS 的配置
如果不希望通过拷贝文件的方式整合 Hive, 也可以在 SparkSession 启动的时候, 通过指定 Hive 的 MetaStore 的位置来访问, 但是更推荐整合的方式

7.5.2. 访问 Hive 表

导读

  1. Hive 中创建表
  2. 使用 SparkSQL 访问 Hive 中已经存在的表
  3. 使用 SparkSQL 创建 Hive
  4. 使用 SparkSQL 修改 Hive 表中的数据

Hive 中创建表

第一步, 需要先将文件上传到集群中, 使用如下命令上传到 HDFS

1
2
hdfs dfs -mkdir -p /dataset
hdfs dfs -put studenttabl10k /dataset/

第二步, 使用 Hive 或者 Beeline 执行如下 SQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE DATABASE IF NOT EXISTS spark_integrition;

USE spark_integrition;
CREATE EXTERNAL TABLE student
(
name STRING,
age INT,
gpa string
)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY '\t'
LINES TERMINATED BY '\n'
STORED AS TEXTFILE
LOCATION '/dataset/hive';
LOAD DATA INPATH '/dataset/studenttab10k' OVERWRITE INTO TABLE student;

通过 SparkSQL 查询 Hive 的表

查询 Hive 中的表可以直接通过 spark.sql(…) 来进行, 可以直接在其中访问 HiveMetaStore, 前提是一定要将 Hive 的配置文件拷贝到 Sparkconf 目录

1
2
3
scala> spark.sql("use spark_integrition")
scala> val resultDF = spark.sql("select * from student limit 10")
scala> resultDF.show()

通过 SparkSQL 创建 Hive

通过 SparkSQL 可以直接创建 Hive 表, 并且使用 LOAD DATA 加载数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
val createTableStr =
"""
|CREATE EXTERNAL TABLE student
|(
| name STRING,
| age INT,
| gpa string
|)
|ROW FORMAT DELIMITED
| FIELDS TERMINATED BY '\t'
| LINES TERMINATED BY '\n'
|STORED AS TEXTFILE
|LOCATION '/dataset/hive'
""".stripMargin

spark.sql("CREATE DATABASE IF NOT EXISTS spark_integrition1")
spark.sql("USE spark_integrition1")
spark.sql(createTableStr)
spark.sql("LOAD DATA INPATH '/dataset/studenttab10k' OVERWRITE INTO TABLE student")
spark.sql("select * from student limit").show()

目前 SparkSQL 支持的文件格式有 sequencefile, rcfile, orc, parquet, textfile, avro, 并且也可以指定 serde 的名称

使用 SparkSQL 处理数据并保存进 Hive 表

前面都在使用 SparkShell 的方式来访问 Hive, 编写 SQL, 通过 Spark 独立应用的形式也可以做到同样的事, 但是需要一些前置的步骤, 如下

Step 1: 导入 Maven 依赖

1
2
3
4
5
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-hive_2.11</artifactId>
<version>${spark.version}</version>
</dependency>

Step 2: 配置 SparkSession

如果希望使用 SparkSQL 访问 Hive 的话, 需要做两件事

  1. 开启 SparkSessionHive 支持

    经过这一步配置, SparkSQL 才会把 SQL 语句当作 HiveSQL 来进行解析

  2. 设置 WareHouse 的位置

    虽然 hive-stie.xml 中已经配置了 WareHouse 的位置, 但是在 Spark 2.0.0 后已经废弃了 hive-site.xml 中设置的 hive.metastore.warehouse.dir, 需要在 SparkSession 中设置 WareHouse 的位置

  3. 设置 MetaStore 的位置

1
2
3
4
5
6
7
val spark = SparkSession
.builder()
.appName("hive example")
.config("spark.sql.warehouse.dir", "hdfs://node01:8020/dataset/hive") (1)
.config("hive.metastore.uris", "thrift://node01:9083") (2)
.enableHiveSupport() (3)
.getOrCreate()
1 设置 WareHouse 的位置
2 设置 MetaStore 的位置
3 开启 Hive 支持

配置好了以后, 就可以通过 DataFrame 处理数据, 后将数据结果推入 Hive 表中了, 在将结果保存到 Hive 表的时候, 可以指定保存模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
val schema = StructType(
List(
StructField("name", StringType),
StructField("age", IntegerType),
StructField("gpa", FloatType)
)
)

val studentDF = spark.read
.option("delimiter", "\t")
.schema(schema)
.csv("dataset/studenttab10k")
val resultDF = studentDF.where("age < 50")
resultDF.write.mode(SaveMode.Overwrite).saveAsTable("spark_integrition1.student") (1)
1 通过 mode 指定保存模式, 通过 saveAsTable 保存数据到 Hive

7.6. JDBC

导读

  1. 通过 SQL 操作 MySQL 的表
  2. 将数据写入 MySQL 的表中

准备 MySQL 环境

在使用 SparkSQL 访问 MySQL 之前, 要对 MySQL 进行一些操作, 例如说创建用户, 表和库等

  • Step 1: 连接 MySQL 数据库

    MySQL 所在的主机上执行如下命令

    1
    mysql -u root -p
  • Step 2: 创建 Spark 使用的用户

    登进 MySQL 后, 需要先创建用户

    1
    2
    CREATE USER 'spark'@'%' IDENTIFIED BY 'Spark123!';
    GRANT ALL ON spark_test.* TO 'spark'@'%';
  • Step 3: 创建库和表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    CREATE DATABASE spark_test;

    USE spark_test;
    CREATE TABLE IF NOT EXISTS student(
    id INT AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    age INT NOT NULL,
    gpa FLOAT,
    PRIMARY KEY ( id )
    )ENGINE=InnoDB DEFAULT CHARSET=utf8;

使用 SparkSQLMySQL 中写入数据

其实在使用 SparkSQL 访问 MySQL 是通过 JDBC, 那么其实所有支持 JDBC 的数据库理论上都可以通过这种方式进行访问

在使用 JDBC 访问关系型数据的时候, 其实也是使用 DataFrameReader, 对 DataFrameReader 提供一些配置, 就可以使用 Spark 访问 JDBC, 有如下几个配置可用

属性 含义
url 要连接的 JDBC URL
dbtable 要访问的表, 可以使用任何 SQL 语句中 from 子句支持的语法
fetchsize 数据抓取的大小(单位行), 适用于读的情况
batchsize 数据传输的大小(单位行), 适用于写的情况
isolationLevel 事务隔离级别, 是一个枚举, 取值 NONE, READ_COMMITTED, READ_UNCOMMITTED, REPEATABLE_READ, SERIALIZABLE, 默认为 READ_UNCOMMITTED

读取数据集, 处理过后存往 MySQL 中的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
val spark = SparkSession
.builder()
.appName("hive example")
.master("local[6]")
.getOrCreate()

val schema = StructType(
List(
StructField("name", StringType),
StructField("age", IntegerType),
StructField("gpa", FloatType)
)
)
val studentDF = spark.read
.option("delimiter", "\t")
.schema(schema)
.csv("dataset/studenttab10k")
studentDF.write.format("jdbc").mode(SaveMode.Overwrite)
.option("url", "jdbc:mysql://node01:3306/spark_test")
.option("dbtable", "student")
.option("user", "spark")
.option("password", "Spark123!")
.save()

运行程序

如果是在本地运行, 需要导入 Maven 依赖

1
2
3
4
5
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.47</version>
</dependency>

如果使用 Spark submit 或者 Spark shell 来运行任务, 需要通过 --jars 参数提交 MySQLJar 包, 或者指定 --packagesMaven 库中读取

1
bin/spark-shell --packages  mysql:mysql-connector-java:5.1.47 --repositories http://maven.aliyun.com/nexus/content/groups/public/

MySQL 中读取数据

读取 MySQL 的方式也非常的简单, 只是使用 SparkSQLDataFrameReader 加上参数配置即可访问

1
2
3
4
5
6
7
spark.read.format("jdbc")
.option("url", "jdbc:mysql://node01:3306/spark_test")
.option("dbtable", "student")
.option("user", "spark")
.option("password", "Spark123!")
.load()
.show()

默认情况下读取 MySQL 表时, 从 MySQL 表中读取的数据放入了一个分区, 拉取后可以使用 DataFrame 重分区来保证并行计算和内存占用不会太高, 但是如果感觉 MySQL 中数据过多的时候, 读取时可能就会产生 OOM, 所以在数据量比较大的场景, 就需要在读取的时候就将其分发到不同的 RDD 分区

属性 含义
partitionColumn 指定按照哪一列进行分区, 只能设置类型为数字的列, 一般指定为 ID
lowerBound, upperBound 确定步长的参数, lowerBound - upperBound 之间的数据均分给每一个分区, 小于 lowerBound 的数据分给第一个分区, 大于 upperBound 的数据分给最后一个分区
numPartitions 分区数量
1
2
3
4
5
6
7
8
9
10
11
spark.read.format("jdbc")
.option("url", "jdbc:mysql://node01:3306/spark_test")
.option("dbtable", "student")
.option("user", "spark")
.option("password", "Spark123!")
.option("partitionColumn", "age")
.option("lowerBound", 1)
.option("upperBound", 60)
.option("numPartitions", 10)
.load()
.show()

有时候可能要使用非数字列来作为分区依据, Spark 也提供了针对任意类型的列作为分区依据的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val predicates = Array(
"age < 20",
"age >= 20, age < 30",
"age >= 30"
)

val connectionProperties = new Properties()
connectionProperties.setProperty("user", "spark")
connectionProperties.setProperty("password", "Spark123!")
spark.read
.jdbc(
url = "jdbc:mysql://node01:3306/spark_test",
table = "student",
predicates = predicates,
connectionProperties = connectionProperties
)
.show()

SparkSQL 中并没有直接提供按照 SQL 进行筛选读取数据的 API 和参数, 但是可以通过 dbtable 来曲线救国, dbtable 指定目标表的名称, 但是因为 dbtable 中可以编写 SQL, 所以使用子查询即可做到

1
2
3
4
5
6
7
8
9
10
11
spark.read.format("jdbc")
.option("url", "jdbc:mysql://node01:3306/spark_test")
.option("dbtable", "(select name, age from student where age > 10 and age < 20) as stu")
.option("user", "spark")
.option("password", "Spark123!")
.option("partitionColumn", "age")
.option("lowerBound", 1)
.option("upperBound", 60)
.option("numPartitions", 10)
.load()
.show()

8. Dataset (DataFrame) 的基础操作

导读

这一章节主要目的是介绍 Dataset 的基础操作, 当然, DataFrame 就是 Dataset, 所以这些操作大部分也适用于 DataFrame

  1. 有类型的转换操作
  2. 无类型的转换操作
  3. 基础 Action
  4. 空值如何处理
  5. 统计操作

8.1. 有类型操作

分类 算子 解释
转换 flatMap 通过 flatMap 可以将一条数据转为一个数组, 后再展开这个数组放入 Dataset``import spark.implicits._ val ds = Seq("hello world", "hello pc").toDS() ds.flatMap( _.split(" ") ).show()
map map 可以将数据集中每条数据转为另一种形式import spark.implicits._ val ds = Seq(Person("zhangsan", 15), Person("lisi", 15)).toDS() ds.map( person => Person(person.name, person.age * 2) ).show()
mapPartitions mapPartitionsmap 一样, 但是 map 的处理单位是每条数据, mapPartitions 的处理单位是每个分区import spark.implicits._ val ds = Seq(Person("zhangsan", 15), Person("lisi", 15)).toDS() ds.mapPartitions( iter => { val returnValue = iter.map( item => Person(item.name, item.age * 2) ) returnValue } ) .show()
transform mapmapPartitions 以及 transform 都是转换, mapmapPartitions 是针对数据, 而 transform 是针对整个数据集, 这种方式最大的区别就是 transform 可以直接拿到 Dataset 进行操作imgimport spark.implicits._ val ds = spark.range(5) ds.transform( dataset => dataset.withColumn("doubled", 'id * 2) )
as as[Type] 算子的主要作用是将弱类型的 Dataset 转为强类型的 Dataset, 它有很多适用场景, 但是最常见的还是在读取数据的时候, 因为 DataFrameReader 体系大部分情况下是将读出来的数据转换为 DataFrame 的形式, 如果后续需要使用 Dataset 的强类型 API, 则需要将 DataFrame 转为 Dataset. 可以使用 as[Type] 算子完成这种操作import spark.implicits._ val structType = StructType( Seq( StructField("name", StringType), StructField("age", IntegerType), StructField("gpa", FloatType) ) ) val sourceDF = spark.read .schema(structType) .option("delimiter", "\t") .csv("dataset/studenttab10k") val dataset = sourceDF.as[Student] dataset.show()
过滤 filter filter 用来按照条件过滤数据集import spark.implicits._ val ds = Seq(Person("zhangsan", 15), Person("lisi", 15)).toDS() ds.filter( person => person.name == "lisi" ).show()
聚合 groupByKey grouByKey 算子的返回结果是 KeyValueGroupedDataset, 而不是一个 Dataset, 所以必须要先经过 KeyValueGroupedDataset 中的方法进行聚合, 再转回 Dataset, 才能使用 Action 得出结果其实这也印证了分组后必须聚合的道理import spark.implicits._ val ds = Seq(Person("zhangsan", 15), Person("zhangsan", 15), Person("lisi", 15)).toDS() ds.groupByKey( person => person.name ).count().show()
切分 randomSplit randomSplit 会按照传入的权重随机将一个 Dataset 分为多个 Dataset, 传入 randomSplit 的数组有多少个权重, 最终就会生成多少个 Dataset, 这些权重的加倍和应该为 1, 否则将被标准化val ds = spark.range(15) val datasets: Array[Dataset[lang.Long]] = ds.randomSplit(Array[Double](2, 3)) datasets.foreach(dataset => dataset.show())
sample sample 会随机在 Dataset 中抽样val ds = spark.range(15) ds.sample(withReplacement = false, fraction = 0.4).show()
排序 orderBy orderBy 配合 ColumnAPI, 可以实现正反序排列import spark.implicits._ val ds = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() ds.orderBy("age").show() ds.orderBy('age.desc).show()
sort 其实 orderBysort 的别名, 所以它们所实现的功能是一样的import spark.implicits._ val ds = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() ds.sort('age.desc).show()
分区 coalesce 减少分区, 此算子和 RDD 中的 coalesce 不同, Dataset 中的 coalesce 只能减少分区数, coalesce 会直接创建一个逻辑操作, 并且设置 Shufflefalse``val ds = spark.range(15) ds.coalesce(1).explain(true)
repartitions repartitions 有两个作用, 一个是重分区到特定的分区数, 另一个是按照某一列来分区, 类似于 SQL 中的 DISTRIBUTE BY``val ds = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() ds.repartition(4) ds.repartition('name)
去重 dropDuplicates 使用 dropDuplicates 可以去掉某一些列中重复的行import spark.implicits._ val ds = spark.createDataset(Seq(Person("zhangsan", 15), Person("zhangsan", 15), Person("lisi", 15))) ds.dropDuplicates("age").show()
distinct dropDuplicates 中没有传入列名的时候, 其含义是根据所有列去重, dropDuplicates() 方法还有一个别名, 叫做 distinctimg所以, 使用 distinct 也可以去重, 并且只能根据所有的列来去重import spark.implicits._ val ds = spark.createDataset(Seq(Person("zhangsan", 15), Person("zhangsan", 15), Person("lisi", 15))) ds.distinct().show()
集合操作 except exceptSQL 语句中的 except 一个意思, 是求得 ds1 中不存在于 ds2 中的数据, 其实就是差集val ds1 = spark.range(1, 10) val ds2 = spark.range(5, 15) ds1.except(ds2).show()
intersect 求得两个集合的交集val ds1 = spark.range(1, 10) val ds2 = spark.range(5, 15) ds1.intersect(ds2).show()
union 求得两个集合的并集val ds1 = spark.range(1, 10) val ds2 = spark.range(5, 15) ds1.union(ds2).show()
limit 限制结果集数量val ds = spark.range(1, 10) ds.limit(3).show()

8.2. 无类型转换

分类 算子 解释
选择 select select 用来选择某些列出现在结果集中import spark.implicits._ val ds = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() ds.select($"name").show()
selectExpr SQL 语句中, 经常可以在 select 子句中使用 count(age), rand() 等函数, 在 selectExpr 中就可以使用这样的 SQL 表达式, 同时使用 select 配合 expr 函数也可以做到类似的效果import spark.implicits._ import org.apache.spark.sql.functions._ val ds = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() ds.selectExpr("count(age) as count").show() ds.selectExpr("rand() as random").show() ds.select(expr("count(age) as count")).show()
withColumn 通过 Column 对象在 Dataset 中创建一个新的列或者修改原来的列import spark.implicits._ import org.apache.spark.sql.functions._ val ds = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() ds.withColumn("random", expr("rand()")).show()
withColumnRenamed 修改列名import spark.implicits._ val ds = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() ds.withColumnRenamed("name", "new_name").show()
剪除 drop 剪掉某个列import spark.implicits._ val ds = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() ds.drop('age).show()
聚合 groupBy 按照给定的行进行分组import spark.implicits._ val ds = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() ds.groupBy('name).count().show()

8.5. Column 对象

导读

Column 表示了 Dataset 中的一个列, 并且可以持有一个表达式, 这个表达式作用于每一条数据, 对每条数据都生成一个值, 之所以有单独这样的一个章节是因为列的操作属于细节, 但是又比较常见, 会在很多算子中配合出现

分类 操作 解释
创建 ' 单引号 ' 在 Scala 中是一个特殊的符号, 通过 ' 会生成一个 Symbol 对象, Symbol 对象可以理解为是一个字符串的变种, 但是比字符串的效率高很多, 在 Spark 中, 对 Scala 中的 Symbol 对象做了隐式转换, 转换为一个 ColumnName 对象, ColumnNameColumn 的子类, 所以在 Spark 中可以如下去选中一个列val spark = SparkSession.builder().appName("column").master("local[6]").getOrCreate() import spark.implicits._ val personDF = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() val c1: Symbol = 'name
$ 同理, $ 符号也是一个隐式转换, 同样通过 spark.implicits 导入, 通过 $ 可以生成一个 Column 对象val spark = SparkSession.builder().appName("column").master("local[6]").getOrCreate() import spark.implicits._ val personDF = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() val c2: ColumnName = $"name"
col SparkSQL 提供了一系列的函数, 可以通过函数实现很多功能, 在后面课程中会进行详细介绍, 这些函数中有两个可以帮助我们创建 Column 对象, 一个是 col, 另外一个是 column``val spark = SparkSession.builder().appName("column").master("local[6]").getOrCreate() import org.apache.spark.sql.functions._ val personDF = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() val c3: sql.Column = col("name")
column val spark = SparkSession.builder().appName("column").master("local[6]").getOrCreate() import org.apache.spark.sql.functions._ val personDF = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() val c4: sql.Column = column("name")
Dataset.col 前面的 Column 对象创建方式所创建的 Column 对象都是 Free 的, 也就是没有绑定任何 Dataset, 所以可以作用于任何 Dataset, 同时, 也可以通过 Datasetcol 方法选择一个列, 但是这个 Column 是绑定了这个 Dataset 的, 所以只能用于创建其的 Datasetval spark = SparkSession.builder().appName("column").master("local[6]").getOrCreate() val personDF = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() val c5: sql.Column = personDF.col("name")
Dataset.apply 可以通过 Dataset 对象的 apply 方法来获取一个关联此 DatasetColumn 对象val spark = SparkSession.builder().appName("column").master("local[6]").getOrCreate() val personDF = Seq(Person("zhangsan", 12), Person("zhangsan", 8), Person("lisi", 15)).toDS() val c6: sql.Column = personDF.apply("name")``apply 的调用有一个简写形式val c7: sql.Column = personDF("name")
别名和转换 as[Type] as 方法有两个用法, 通过 as[Type] 的形式可以将一个列中数据的类型转为 Type 类型personDF.select(col("age").as[Long]).show()
as(name) 通过 as(name) 的形式使用 as 方法可以为列创建别名personDF.select(col("age").as("age_new")).show()
添加列 withColumn 通过 Column 在添加一个新的列时候修改 Column 所代表的列的数据personDF.withColumn("double_age", 'age * 2).show()
操作 like 通过 ColumnAPI, 可以轻松实现 SQL 语句中 LIKE 的功能personDF.filter('name like "%zhang%").show()
isin 通过 ColumnAPI, 可以轻松实现 SQL 语句中 ISIN 的功能personDF.filter('name isin ("hello", "zhangsan")).show()
sort 在排序的时候, 可以通过 ColumnAPI 实现正反序personDF.sort('age.asc).show() personDF.sort('age.desc).show()

9. 缺失值处理

导读

  1. DataFrame 中什么时候会有无效值
  2. DataFrame 如何处理无效的值
  3. DataFrame 如何处理 null

缺失值的处理思路

如果想探究如何处理无效值, 首先要知道无效值从哪来, 从而分析可能产生的无效值有哪些类型, 在分别去看如何处理无效值

什么是缺失值

一个值本身的含义是这个值不存在则称之为缺失值, 也就是说这个值本身代表着缺失, 或者这个值本身无意义, 比如说 null, 比如说空字符串

img

关于数据的分析其实就是统计分析的概念, 如果这样的话, 当数据集中存在缺失值, 则无法进行统计和分析, 对很多操作都有影响

缺失值如何产生的

img

Spark 大多时候处理的数据来自于业务系统中, 业务系统中可能会因为各种原因, 产生一些异常的数据

例如说因为前后端的判断失误, 提交了一些非法参数. 再例如说因为业务系统修改 MySQL 表结构产生的一些空值数据等. 总之在业务系统中出现缺失值其实是非常常见的一件事, 所以大数据系统就一定要考虑这件事.

缺失值的类型

常见的缺失值有两种

  • null, NaN 等特殊类型的值, 某些语言中 null 可以理解是一个对象, 但是代表没有对象, NaN 是一个数字, 可以代表不是数字

    针对这一类的缺失值, Spark 提供了一个名为 DataFrameNaFunctions 特殊类型来操作和处理

  • "Null", "NA", " " 等解析为字符串的类型, 但是其实并不是常规字符串数据

    针对这类字符串, 需要对数据集进行采样, 观察异常数据, 总结经验, 各个击破

1
DataFrameNaFunctions

DataFrameNaFunctions 使用 Datasetna 函数来获取

1
2
val df = ...
val naFunc: DataFrameNaFunctions = df.na

当数据集中出现缺失值的时候, 大致有两种处理方式, 一个是丢弃, 一个是替换为某值, DataFrameNaFunctions 中包含一系列针对空值数据的方案

  • DataFrameNaFunctions.drop 可以在当某行中包含 nullNaN 的时候丢弃此行
  • DataFrameNaFunctions.fill 可以在将 nullNaN 充为其它值
  • DataFrameNaFunctions.replace 可以把 nullNaN 替换为其它值, 但是和 fill 略有一些不同, 这个方法针对值来进行替换

如何使用 SparkSQL 处理 nullNaN ?

首先要将数据读取出来, 此次使用的数据集直接存在 NaN, 在指定 Schema 后, 可直接被转为 Double.NaN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val schema = StructType(
List(
StructField("id", IntegerType),
StructField("year", IntegerType),
StructField("month", IntegerType),
StructField("day", IntegerType),
StructField("hour", IntegerType),
StructField("season", IntegerType),
StructField("pm", DoubleType)
)
)

val df = spark.read
.option("header", value = true)
.schema(schema)
.csv("dataset/beijingpm_with_nan.csv")

对于缺失值的处理一般就是丢弃和填充

丢弃包含 nullNaN 的行

当某行数据所有值都是 null 或者 NaN 的时候丢弃此行

1
df.na.drop("all").show()

当某行中特定列所有值都是 null 或者 NaN 的时候丢弃此行

1
df.na.drop("all", List("pm", "id")).show()

当某行数据任意一个字段为 null 或者 NaN 的时候丢弃此行

1
2
df.na.drop().show()
df.na.drop("any").show()

当某行中特定列任意一个字段为 null 或者 NaN 的时候丢弃此行

1
2
df.na.drop(List("pm", "id")).show()
df.na.drop("any", List("pm", "id")).show()

填充包含 nullNaN 的列

填充所有包含 nullNaN 的列

1
df.na.fill(0).show()

填充特定包含 nullNaN 的列

1
df.na.fill(0, List("pm")).show()

根据包含 nullNaN 的列的不同来填充

1
2
3
import scala.collection.JavaConverters._

df.na.fill(Map[String, Any]("pm" -> 0).asJava).show

如何使用 SparkSQL 处理异常字符串 ?

读取数据集, 这次读取的是最原始的那个 PM 数据集

1
2
3
val df = spark.read
.option("header", value = true)
.csv("dataset/BeijingPM20100101_20151231.csv")

使用函数直接转换非法的字符串

1
2
3
4
5
df.select('No as "id", 'year, 'month, 'day, 'hour, 'season,
when('PM_Dongsi === "NA", 0)
.otherwise('PM_Dongsi cast DoubleType)
.as("pm"))
.show()

使用 where 直接过滤

1
2
3
df.select('No as "id", 'year, 'month, 'day, 'hour, 'season, 'PM_Dongsi)
.where('PM_Dongsi =!= "NA")
.show()

使用 DataFrameNaFunctions 替换, 但是这种方式被替换的值和新值必须是同类型

1
2
3
df.select('No as "id", 'year, 'month, 'day, 'hour, 'season, 'PM_Dongsi)
.na.replace("PM_Dongsi", Map("NA" -> "NaN"))
.show()

10. 聚合

导读

  1. groupBy
  2. rollup
  3. cube
  4. pivot
  5. RelationalGroupedDataset 上的聚合操作
1
groupBy

groupBy 算子会按照列将 Dataset 分组, 并返回一个 RelationalGroupedDataset 对象, 通过 RelationalGroupedDataset 可以对分组进行聚合

Step 1: 加载实验数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private val spark = SparkSession.builder()
.master("local[6]")
.appName("aggregation")
.getOrCreate()

import spark.implicits._
private val schema = StructType(
List(
StructField("id", IntegerType),
StructField("year", IntegerType),
StructField("month", IntegerType),
StructField("day", IntegerType),
StructField("hour", IntegerType),
StructField("season", IntegerType),
StructField("pm", DoubleType)
)
)
private val pmDF = spark.read
.schema(schema)
.option("header", value = true)
.csv("dataset/pm_without_null.csv")

Step 2: 使用 functions 函数进行聚合

1
2
3
4
5
6
import org.apache.spark.sql.functions._

val groupedDF: RelationalGroupedDataset = pmDF.groupBy('year)
groupedDF.agg(avg('pm) as "pm_avg")
.orderBy('pm_avg)
.show()

Step 3: 除了使用 functions 进行聚合, 还可以直接使用 RelationalGroupedDatasetAPI 进行聚合

1
2
3
4
5
6
7
groupedDF.avg("pm")
.orderBy('pm_avg)
.show()

groupedDF.max("pm")
.orderBy('pm_avg)
.show()

多维聚合

我们可能经常需要针对数据进行多维的聚合, 也就是一次性统计小计, 总计等, 一般的思路如下

Step 1: 准备数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private val spark = SparkSession.builder()
.master("local[6]")
.appName("aggregation")
.getOrCreate()

import spark.implicits._
private val schemaFinal = StructType(
List(
StructField("source", StringType),
StructField("year", IntegerType),
StructField("month", IntegerType),
StructField("day", IntegerType),
StructField("hour", IntegerType),
StructField("season", IntegerType),
StructField("pm", DoubleType)
)
)
private val pmFinal = spark.read
.schema(schemaFinal)
.option("header", value = true)
.csv("dataset/pm_final.csv")

Step 2: 进行多维度聚合

1
2
3
4
5
6
7
8
9
10
import org.apache.spark.sql.functions._

val groupPostAndYear = pmFinal.groupBy('source, 'year)
.agg(sum("pm") as "pm")
val groupPost = pmFinal.groupBy('source)
.agg(sum("pm") as "pm")
.select('source, lit(null) as "year", 'pm)
groupPostAndYear.union(groupPost)
.sort('source, 'year asc_nulls_last, 'pm)
.show()

大家其实也能看出来, 在一个数据集中又小计又总计, 可能需要多个操作符, 如何简化呢? 请看下面

rollup 操作符

rollup 操作符其实就是 groupBy 的一个扩展, rollup 会对传入的列进行滚动 groupBy, groupBy 的次数为列数量 + 1, 最后一次是对整个数据集进行聚合

Step 1: 创建数据集

1
2
3
4
5
6
7
8
9
import org.apache.spark.sql.functions._

val sales = Seq(
("Beijing", 2016, 100),
("Beijing", 2017, 200),
("Shanghai", 2015, 50),
("Shanghai", 2016, 150),
("Guangzhou", 2017, 50)
).toDF("city", "year", "amount")

Step 1: rollup 的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
sales.rollup("city", "year")
.agg(sum("amount") as "amount")
.sort($"city".desc_nulls_last, $"year".asc_nulls_last)
.show()

/**

结果集:
+---------+----+------+
| city|year|amount|
+---------+----+------+
| Shanghai|2015| 50| <-- 上海 2015 的小计
| Shanghai|2016| 150|
| Shanghai|null| 200| <-- 上海的总计
|Guangzhou|2017| 50|
|Guangzhou|null| 50|
| Beijing|2016| 100|
| Beijing|2017| 200|
| Beijing|null| 300|
| null|null| 550| <-- 整个数据集的总计
+---------+----+------+
/

Step 2: 如果使用基础的 groupBy 如何实现效果?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
val cityAndYear = sales
.groupBy("city", "year") // 按照 city 和 year 聚合
.agg(sum("amount") as "amount")



val city = sales
.groupBy("city") // 按照 city 进行聚合
.agg(sum("amount") as "amount")
.select($"city", lit(null) as "year", $"amount")
val all = sales
.groupBy() // 全局聚合
.agg(sum("amount") as "amount")
.select(lit(null) as "city", lit(null) as "year", $"amount")
cityAndYear
.union(city)
.union(all)
.sort($"city".desc_nulls_last, $"year".asc_nulls_last)
.show()
/**

统计结果:
+---------+----+------+
| city|year|amount|
+---------+----+------+
| Shanghai|2015| 50|
| Shanghai|2016| 150|
| Shanghai|null| 200|
|Guangzhou|2017| 50|
|Guangzhou|null| 50|
| Beijing|2016| 100|
| Beijing|2017| 200|
| Beijing|null| 300|
| null|null| 550|
+---------+----+------+
/

很明显可以看到, 在上述案例中, rollup 就相当于先按照 city, year 进行聚合, 后按照 city 进行聚合, 最后对整个数据集进行聚合, 在按照 city 聚合时, year 列值为 null, 聚合整个数据集的时候, 除了聚合列, 其它列值都为 null

使用 rollup 完成 pm 值的统计

上面的案例使用 rollup 来实现会非常的简单

1
2
3
4
5
6
7
8
import org.apache.spark.sql.functions._



pmFinal.rollup('source, 'year)
.agg(sum("pm") as "pm_total")
.sort('source.asc_nulls_last, 'year.asc_nulls_last)
.show()
1
cube

cube 的功能和 rollup 是一样的, 但也有区别, 区别如下

  • rollup(A, B).sum©

    其结果集中会有三种数据形式: A B C, A null C, null null C

    不知道大家发现没, 结果集中没有对 B 列的聚合结果

  • cube(A, B).sum©

    其结果集中会有四种数据形式: A B C, A null C, null null C, null B C

    不知道大家发现没, 比 rollup 的结果集中多了一个 null B C, 也就是说, rollup 只会按照第一个列来进行组合聚合, 但是 cube 会将全部列组合聚合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import org.apache.spark.sql.functions._

pmFinal.cube('source, 'year)
.agg(sum("pm") as "pm_total")
.sort('source.asc_nulls_last, 'year.asc_nulls_last)
.show()
/**

结果集为

+-------+----+---------+
| source|year| pm_total|
+-------+----+---------+
| dongsi|2013| 735606.0|
| dongsi|2014| 745808.0|
| dongsi|2015| 752083.0|
| dongsi|null|2233497.0|
|us_post|2010| 841834.0|
|us_post|2011| 796016.0|
|us_post|2012| 750838.0|
|us_post|2013| 882649.0|
|us_post|2014| 846475.0|
|us_post|2015| 714515.0|
|us_post|null|4832327.0|
| null|2010| 841834.0| <-- 新增
| null|2011| 796016.0| <-- 新增
| null|2012| 750838.0| <-- 新增
| null|2013|1618255.0| <-- 新增
| null|2014|1592283.0| <-- 新增
| null|2015|1466598.0| <-- 新增
| null|null|7065824.0|
+-------+----+---------+
/

SparkSQL 中支持的 SQL 语句实现 cube 功能

SparkSQL 支持 GROUPING SETS 语句, 可以随意排列组合空值分组聚合的顺序和组成, 既可以实现 cube 也可以实现 rollup 的功能

1
2
3
4
5
6
7
8
9
10
11
pmFinal.createOrReplaceTempView("pm_final")
spark.sql(
"""
|select source, year, sum(pm)
|from pm_final
|group by source, year
|grouping sets((source, year), (source), (year), ())
|order by source asc nulls last, year asc nulls last
""".stripMargin)
.show()
RelationalGroupedDataset

常见的 RelationalGroupedDataset 获取方式有三种

  • groupBy
  • rollup
  • cube

无论通过任何一种方式获取了 RelationalGroupedDataset 对象, 其所表示的都是是一个被分组的 DataFrame, 通过这个对象, 可以对数据集的分组结果进行聚合

1
val groupedDF: RelationalGroupedDataset = pmDF.groupBy('year)

需要注意的是, RelationalGroupedDataset 并不是 DataFrame, 所以其中并没有 DataFrame 的方法, 只有如下一些聚合相关的方法, 如下这些方法在调用过后会生成 DataFrame 对象, 然后就可以再次使用 DataFrame 的算子进行操作了

操作符 解释
avg 求平均数
count 求总数
max 求极大值
min 求极小值
mean 求均数
sum 求和
agg 聚合, 可以使用 sql.functions 中的函数来配合进行操作pmDF.groupBy('year) .agg(avg('pm) as "pm_avg")

11. 连接

导读

  1. 无类型连接 join
  2. 连接类型 Join Types

无类型连接算子 joinAPI

Step 1: 什么是连接

按照 PostgreSQL 的文档中所说, 只要能在一个查询中, 同一时间并发的访问多条数据, 就叫做连接.

做到这件事有两种方式

  1. 一种是把两张表在逻辑上连接起来, 一条语句中同时访问两张表

    1
    select * from user join address on user.address_id = address.id
  2. 还有一种方式就是表连接自己, 一条语句也能访问自己中的多条数据

    1
    select * from user u1 join (select * from user) u2 on u1.id = u2.id

Step 2: join 算子的使用非常简单, 大致的调用方式如下

1
join(right: Dataset[_], joinExprs: Column, joinType: String): DataFrame

Step 3: 简单连接案例

表结构如下

1
2
3
4
5
6
7
8
+---+------+------+            +---+---------+
| id| name|cityId| | id| name|
+---+------+------+ +---+---------+
| 0| Lucy| 0| | 0| Beijing|
| 1| Lily| 0| | 1| Shanghai|
| 2| Tim| 2| | 2|Guangzhou|
| 3|Danial| 0| +---+---------+
+---+------+------+

如果希望对这两张表进行连接, 首先应该注意的是可以连接的字段, 比如说此处的左侧表 cityId 和右侧表 id 就是可以连接的字段, 使用 join 算子就可以将两个表连接起来, 进行统一的查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
val person = Seq((0, "Lucy", 0), (1, "Lily", 0), (2, "Tim", 2), (3, "Danial", 0))
.toDF("id", "name", "cityId")



val cities = Seq((0, "Beijing"), (1, "Shanghai"), (2, "Guangzhou"))
.toDF("id", "name")
person.join(cities, person.col("cityId") === cities.col("id"))
.select(person.col("id"),
person.col("name"),
cities.col("name") as "city")
.show()
/**

执行结果:

+---+------+---------+
| id| name| city|
+---+------+---------+
| 0| Lucy| Beijing|
| 1| Lily| Beijing|
| 2| Tim|Guangzhou|
| 3|Danial| Beijing|
+---+------+---------+
/

Step 4: 什么是连接?

现在两个表连接得到了如下的表

1
2
3
4
5
6
7
8
+---+------+---------+
| id| name| city|
+---+------+---------+
| 0| Lucy| Beijing|
| 1| Lily| Beijing|
| 2| Tim|Guangzhou|
| 3|Danial| Beijing|
+---+------+---------+

通过对这张表的查询, 这个查询是作用于两张表的, 所以是同一时间访问了多条数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
spark.sql("select name from user_city where city = 'Beijing'").show()



/**

执行结果

+------+
| name|
+------+
| Lucy|
| Lily|
|Danial|
+------+
/

img

连接类型

如果要运行如下代码, 需要先进行数据准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private val spark = SparkSession.builder()
.master("local[6]")
.appName("aggregation")
.getOrCreate()



import spark.implicits._
val person = Seq((0, "Lucy", 0), (1, "Lily", 0), (2, "Tim", 2), (3, "Danial", 3))
.toDF("id", "name", "cityId")
person.createOrReplaceTempView("person")
val cities = Seq((0, "Beijing"), (1, "Shanghai"), (2, "Guangzhou"))
.toDF("id", "name")
cities.createOrReplaceTempView("cities")
连接类型 类型字段 解释
交叉连接 cross 解释交叉连接就是笛卡尔积, 就是两个表中所有的数据两两结对交叉连接是一个非常重的操作, 在生产中, 尽量不要将两个大数据集交叉连接, 如果一定要交叉连接, 也需要在交叉连接后进行过滤, 优化器会进行优化imgSQL 语句select * from person cross join cities``Dataset 操作person.crossJoin(cities) .where(person.col("cityId") === cities.col("id")) .show()
内连接 inner 解释内连接就是按照条件找到两个数据集关联的数据, 并且在生成的结果集中只存在能关联到的数据imgSQL 语句select * from person inner join cities on person.cityId = cities.id``Dataset 操作person.join(right = cities, joinExprs = person("cityId") === cities("id"), joinType = "inner") .show()
全外连接 outer, full, fullouter 解释内连接和外连接的最大区别, 就是内连接的结果集中只有可以连接上的数据, 而外连接可以包含没有连接上的数据, 根据情况的不同, 外连接又可以分为很多种, 比如所有的没连接上的数据都放入结果集, 就叫做全外连接imgSQL 语句select * from person full outer join cities on person.cityId = cities.id``Dataset 操作person.join(right = cities, joinExprs = person("cityId") === cities("id"), joinType = "full") // "outer", "full", "full_outer" .show()
左外连接 leftouter, left 解释左外连接是全外连接的一个子集, 全外连接中包含左右两边数据集没有连接上的数据, 而左外连接只包含左边数据集中没有连接上的数据imgSQL 语句select * from person left join cities on person.cityId = cities.id``Dataset 操作person.join(right = cities, joinExprs = person("cityId") === cities("id"), joinType = "left") // leftouter, left .show()
LeftAnti leftanti 解释LeftAnti 是一种特殊的连接形式, 和左外连接类似, 但是其结果集中没有右侧的数据, 只包含左边集合中没连接上的数据imgSQL 语句select * from person left anti join cities on person.cityId = cities.id``Dataset 操作person.join(right = cities, joinExprs = person("cityId") === cities("id"), joinType = "left_anti") .show()
LeftSemi leftsemi 解释和 LeftAnti 恰好相反, LeftSemi 的结果集也没有右侧集合的数据, 但是只包含左侧集合中连接上的数据imgSQL 语句select * from person left semi join cities on person.cityId = cities.id``Dataset 操作person.join(right = cities, joinExprs = person("cityId") === cities("id"), joinType = "left_semi") .show()
右外连接 rightouter, right 解释右外连接和左外连接刚好相反, 左外是包含左侧未连接的数据, 和两个数据集中连接上的数据, 而右外是包含右侧未连接的数据, 和两个数据集中连接上的数据imgSQL 语句select * from person right join cities on person.cityId = cities.id``Dataset 操作person.join(right = cities, joinExprs = person("cityId") === cities("id"), joinType = "right") // rightouter, right .show()

[扩展] 广播连接

Step 1: 正常情况下的 Join 过程

img

Join 会在集群中分发两个数据集, 两个数据集都要复制到 Reducer 端, 是一个非常复杂和标准的 ShuffleDependency, 有什么可以优化效率吗?

Step 2: MapJoin

前面图中看的过程, 之所以说它效率很低, 原因是需要在集群中进行数据拷贝, 如果能减少数据拷贝, 就能减少开销

如果能够只分发一个较小的数据集呢?

img

可以将小数据集收集起来, 分发给每一个 Executor, 然后在需要 Join 的时候, 让较大的数据集在 Map 端直接获取小数据集, 从而进行 Join, 这种方式是不需要进行 Shuffle 的, 所以称之为 MapJoin

Step 3: MapJoin 的常规实现

如果使用 RDD 的话, 该如何实现 MapJoin 呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val personRDD = spark.sparkContext.parallelize(Seq((0, "Lucy", 0),
(1, "Lily", 0), (2, "Tim", 2), (3, "Danial", 3)))

val citiesRDD = spark.sparkContext.parallelize(Seq((0, "Beijing"),
(1, "Shanghai"), (2, "Guangzhou")))
val citiesBroadcast = spark.sparkContext.broadcast(citiesRDD.collectAsMap())
val result = personRDD.mapPartitions(
iter => {
val citiesMap = citiesBroadcast.value
// 使用列表生成式 yield 生成列表
val result = for (person <- iter if citiesMap.contains(person._3))
yield (person._1, person._2, citiesMap(person._3))
result
}
).collect()
result.foreach(println(_))

Step 4: 使用 Dataset 实现 Join 的时候会自动进行 MapJoin

自动进行 MapJoin 需要依赖一个系统参数 spark.sql.autoBroadcastJoinThreshold, 当数据集小于这个参数的大小时, 会自动进行 MapJoin

如下, 开启自动 Join

1
2
3
println(spark.conf.get("spark.sql.autoBroadcastJoinThreshold").toInt / 1024 / 1024)

println(person.crossJoin(cities).queryExecution.sparkPlan.numberedTreeString)

当关闭这个参数的时候, 则不会自动 Map 端 Join 了

1
2
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
println(person.crossJoin(cities).queryExecution.sparkPlan.numberedTreeString)

Step 5: 也可以使用函数强制开启 Map 端 Join

在使用 Dataset 的 join 时, 可以使用 broadcast 函数来实现 Map 端 Join

1
2
3
import org.apache.spark.sql.functions._
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
println(person.crossJoin(broadcast(cities)).queryExecution.sparkPlan.numberedTreeString)

即使是使用 SQL 也可以使用特殊的语法开启

1
2
3
4
5
6
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
val resultDF = spark.sql(
"""
|select /*+ MAPJOIN (rt) */ * from person cross join cities rt
""".stripMargin)
println(resultDF.queryExecution.sparkPlan.numberedTreeString)

12. 窗口函数

目标和步骤

目标

理解窗口操作的语义, 掌握窗口函数的使用

步骤

  1. 案例1, 第一名和第二名
  2. 窗口函数介绍
  3. 案例2, 最优差值

12.1. 第一名和第二名案例

目标和步骤

目标

掌握如何使用 SQLDataFrame 完成名次统计, 并且对窗口函数有一个模糊的认识, 方便后面的启发

步骤

  1. 需求介绍
  2. 代码编写

需求介绍

  1. 数据集

    img

    • product : 商品名称
    • categroy : 类别
    • revenue : 收入
  2. 需求分析

    需求

    • 从数据集中得到每个类别收入第一的商品和收入第二的商品

      关键点是, 每个类别, 收入前两名

      img

    方案1: 使用常见语法子查询

    • 问题1: SparkHive 这样的系统中, 有自增主键吗? 没有
    • 问题2: 为什么分布式系统中很少见自增主键? 因为分布式环境下数据在不同的节点中, 很难保证顺序
    • 解决方案: 按照某一列去排序, 取前两条数据
    • 遗留问题: 不容易在分组中取每一组的前两个
    1
    SELECT * FROM productRevenue ORDER BY revenue LIMIT 2

    方案2: 计算每一个类别的按照收入排序的序号, 取每个类别中的前两个

    img

    思路步骤

    1. 按照类别分组
    2. 每个类别中的数据按照收入排序
    3. 为排序过的数据增加编号
    4. 取得每个类别中的前两个数据作为最终结果

    使用 SQL 就不太容易做到, 需要一个语法, 叫做窗口函数

代码编写

  1. 创建初始环境

    1. 创建新的类 WindowFunction
    2. 编写测试方法
    3. 初始化 SparkSession
    4. 创建数据集

    class WindowFunction { @Test def firstSecond(): Unit = { val spark = SparkSession.builder() .appName("window") .master("local[6]") .getOrCreate() import spark.implicits._ val data = Seq( ("Thin", "Cell phone", 6000), ("Normal", "Tablet", 1500), ("Mini", "Tablet", 5500), ("Ultra thin", "Cell phone", 5000), ("Very thin", "Cell phone", 6000), ("Big", "Tablet", 2500), ("Bendable", "Cell phone", 3000), ("Foldable", "Cell phone", 3000), ("Pro", "Tablet", 4500), ("Pro2", "Tablet", 6500) ) val source = data.toDF("product", "category", "revenue") } }

  1. 方式一: SQL 语句::

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    SELECT
    product,
    category,
    revenue
    FROM (
    SELECT
    product,
    category,
    revenue,
    dense_rank() OVER (PARTITION BY category ORDER BY revenue DESC) as rank
    FROM productRevenue) tmp
    WHERE
    rank <= 2
    • 窗口函数在 SQL 中的完整语法如下

      1
      function OVER (PARITION BY ... ORDER BY ... FRAME_TYPE BETWEEN ... AND ...)
  2. 方式二: 使用 DataFrame 的命令式 API::

    1
    2
    3
    4
    5
    6
    val window: WindowSpec = Window.partitionBy('category)
    .orderBy('revenue.desc)

    source.select('product, 'category, 'revenue, dense_rank() over window as "rank")
    .where('rank <= 2)
    .show()
  • WindowSpec : 窗口的描述符, 描述窗口应该是怎么样的
  • dense_rank() over window : 表示一个叫做 dense_rank() 的函数作用于每一个窗口

总结

  • Spark 中, 使用 SQL 或者 DataFrame 都可以操作窗口
  • 窗口的使用有两个步骤
    1. 定义窗口规则
    2. 定义窗口函数
  • 在不同的范围内统计名次时, 窗口函数非常得力

12.2. 窗口函数

目标和步骤

目标

了解窗口函数的使用方式, 能够使用窗口函数完成统计

步骤

  1. 窗口函数的逻辑
  2. 窗口定义部分
  3. 统计函数部分

窗口函数的逻辑

逻辑 上来讲, 窗口函数执行步骤大致可以分为如下几步

1
dense_rank() OVER (PARTITION BY category ORDER BY revenue DESC) as rank
  1. 根据 PARTITION BY category, 对数据进行分组

    img

  2. 分组后, 根据 ORDER BY revenue DESC 对每一组数据进行排序

    img

  3. 每一条数据 到达窗口函数时, 套入窗口内进行计算

    img

从语法的角度上讲, 窗口函数大致分为两个部分

1
dense_rank() OVER (PARTITION BY category ORDER BY revenue DESC) as rank
  • 函数部分 dense_rank()
  • 窗口定义部分 PARTITION BY category ORDER BY revenue DESC
窗口函数和 GroupBy 最大的区别, 就是 GroupBy 的聚合对每一个组只有一个结果, 而窗口函数可以对每一条数据都有一个结果说白了, 窗口函数其实就是根据当前数据, 计算其在所在的组中的统计数据

窗口定义部分

1
dense_rank() OVER (PARTITION BY category ORDER BY revenue DESC) as rank
  1. Partition 定义

    控制哪些行会被放在一起, 同时这个定义也类似于 Shuffle, 会将同一个分组的数据放在同一台机器中处理

    img

  2. Order 定义

    在一个分组内进行排序, 因为很多操作, 如 rank, 需要进行排序

    img

  3. Frame 定义

    释义

    • 窗口函数会针对 每一个组中的每一条数据 进行统计聚合或者 rank, 一个组又称为一个 Frame

    • 分组由两个字段控制, Partition 在整体上进行分组和分区

    • 而通过 Frame 可以通过 当前行 来更细粒度的分组控制

      举个栗子, 例如公司每月销售额的数据, 统计其同比增长率, 那就需要把这条数据和前面一条数据进行结合计算了

    有哪些控制方式?

    • Row Frame

      通过 "行号" 来表示

      img

    • Range Frame

      通过某一个列的差值来表示

      img

      img

      img

      img

      img

函数部分

1
dense_rank() OVER (PARTITION BY category ORDER BY revenue DESC) as rank

如下是支持的窗口函数

类型 函数 解释
排名函数 rank 排名函数, 计算当前数据在其 Frame 中的位置如果有重复, 则重复项后面的行号会有空挡img
dense_rank 和 rank 一样, 但是结果中没有空挡img
row_number 和 rank 一样, 也是排名, 但是不同点是即使有重复想, 排名依然增长img
分析函数 first_value 获取这个组第一条数据
last_value 获取这个组最后一条数据
lag lag(field, n) 获取当前数据的 field 列向前 n 条数据
lead lead(field, n) 获取当前数据的 field 列向后 n 条数据
聚合函数 * 所有的 functions 中的聚合函数都支持

总结

  • 窗口操作分为两个部分
    • 窗口定义, 定义时可以指定 Partition, Order, Frame
    • 函数操作, 可以使用三大类函数, 排名函数, 分析函数, 聚合函数

12.3. 最优差值案例

目标和步骤

目标

能够针对每个分类进行计算, 求得常见指标, 并且理解实践上面的一些理论

步骤

  1. 需求介绍
  2. 代码实现

需求介绍

  • 源数据集

    img

  • 需求

    统计每个商品和此品类最贵商品之间的差值

  • 目标数据集

    img

代码实现

步骤

  1. 创建数据集
  2. 创建窗口, 按照 revenue 分组, 并倒叙排列
  3. 应用窗口

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
val spark = SparkSession.builder()
.appName("window")
.master("local[6]")
.getOrCreate()

import spark.implicits._
import org.apache.spark.sql.functions._
val data = Seq(
("Thin", "Cell phone", 6000),
("Normal", "Tablet", 1500),
("Mini", "Tablet", 5500),
("Ultra thin", "Cell phone", 5500),
("Very thin", "Cell phone", 6000),
("Big", "Tablet", 2500),
("Bendable", "Cell phone", 3000),
("Foldable", "Cell phone", 3000),
("Pro", "Tablet", 4500),
("Pro2", "Tablet", 6500)
)
val source = data.toDF("product", "category", "revenue")
val windowSpec = Window.partitionBy('category)
.orderBy('revenue.desc)
source.select(
'product, 'category, 'revenue,
((max('revenue) over windowSpec) - 'revenue) as 'revenue_difference
).show()

项目实战

导读

本项目是 SparkSQL 阶段的练习项目, 主要目的是夯实同学们对于 SparkSQL 的理解和使用

数据集

2013年纽约市出租车乘车记录

需求

统计出租车利用率, 到某个目的地后, 出租车等待下一个客人的间隔

1. 业务

导读

  1. 数据集介绍
  2. 业务场景介绍
  3. 和其它业务的关联
  4. 通过项目能学到什么

数据集结构

字段 示例 示意
hack_license BA96DE419E711691B9445D6A6307C170 执照号, 可以唯一标识一辆出租车
pickup_datetime 2013-01-01 15:11:48 上车时间
dropoff_datetime 2013-01-01 15:18:10 下车时间
pickup_longitude -73.978165 上车点
pickup_latitude 40.757977 上车点
dropoff_longitude -73.989838 下车点
dropoff_latitude 40.751171 下车点

其中有三个点需要注意

  • hack_license 是出租车执照, 可以唯一标识一辆出租车
  • pickup_datetimedropoff_datetime 分别是上车时间和下车时间, 通过这个时间, 可以获知行车时间
  • pickup_longitudedropoff_longitude 是经度, 经度所代表的是横轴, 也就是 X 轴
  • pickup_latitudedropoff_latitude 是纬度, 纬度所代表的是纵轴, 也就是 Y 轴

业务场景

在网约车出现之前, 出行很大一部分要靠出租车和公共交通, 所以经常会见到一些情况, 比如说从东直门打车, 告诉师傅要去昌平, 师傅可能拒载. 这种情况所凸显的是一个出租车调度的难题, 所以需要先通过数据来看到问题, 后解决问题.

所以要统计出租车利用率, 也就是有乘客乘坐的时间, 和无乘客空跑的时间比例. 这是一个理解出租车的重要指标, 影响利用率的一个因素就是目的地, 比如说, 去昌平, 可能出租车师傅不确定自己是否要空放回来, 而去国贸, 下车几分钟内, 一定能有新的顾客上车.

而统计利用率的时候, 需要用到时间数据和空间数据来进行计算, 对于时间计算来说, SparkSQL 提供了很多工具和函数可以使用, 而空间计算仍然是一个比较专业的场景, 需要使用到第三方库.

我们的需求是, 在上述的数据集中, 根据时间算出等待时间, 根据地点落地到某个区, 算出某个区的平均等待时间, 也就是这个下车地点对于出租车利用率的影响.

技术点和其它技术的关系

  1. 数据清洗

    数据清洗在几乎所有类型的项目中都会遇到, 处理数据的类型, 处理空值等问题

  2. JSON 解析

    JSON 解析在大部分业务系统的数据分析中都会用到, 如何读取 JSON 数据, 如何把 JSON 数据变为可以使用的对象数据

  3. 地理位置信息处理

    地理位置信息的处理是一个比较专业的场景, 在一些租车网站, 或者像滴滴, Uber 之类的出行服务上, 也经常会处理地理位置信息

  4. 探索性数据分析

    从拿到一个数据集, 明确需求以后, 如何逐步了解数据集, 如何从数据集中探索对应的内容等, 是一个数据工程师的基本素质

  5. 会话分析

    会话分析用于识别同一个用户的多个操作之间的关联, 是分析系统常见的分析模式, 在电商和搜索引擎中非常常见

在这个小节中希望大家掌握的知识

  1. SparkSQL 中对于类型的处理
  2. Scala 中常见的 JSON 解析工具
  3. GeoJson 的使用

2. 流程分析

导读

  1. 分析的步骤和角度
  2. 流程

分析的视角

  1. 理解数据集

    首先要理解数据集, 要回答自己一些问题

    • 这个数据集是否以行作为单位, 是否是 DataFrame 可以处理的, 大部分情况下都是
    • 这个数据集每行记录所代表的实体对象是什么, 例如: 出租车的载客记录
    • 表达这个实体对象的最核心字段是什么, 例如: 上下车地点和时间, 唯一标识一辆车的 License
  2. 理解需求和结果集

    • 小学的时候, 有一次考试考的比较差, 老师在帮我分析的时候, 告诉我, 你下次要读懂题意, 再去大题, 这样不会浪费时间, 于是这个信念贯穿了我这些年的工作.
    • 按照我对开发工作的理解, 在一开始的阶段进行一个大概的思考和面向对象的设计, 并不会浪费时间, 即使这些设计可能会占用一些时间.
    • 对代码的追求也不会浪费时间, 把代码写好, 会减少阅读成本, 沟通成本.
    • 对测试的追求也不会浪费时间, 因为在进行回归测试的时候, 可以尽可能的减少修改对已有代码的冲击.

    所以第一点, 理解需求再动手, 绝对不会浪费时间. 第二点, 在数据分析的任务中, 如何无法理解需求, 可能根本无从动手.

    • 我们的需求是: 出租车在某个地点的平均等待客人时间
    • 简单来说, 结果集中应该有的列: 地点, 平均等待时间
  3. 反推每一个步骤

    结果集中, 应该有的字段有两个, 一个是地点, 一个是等待时间

    地点如何获知? 其实就是乘客的下车点, 但是是一个坐标, 如何得到其在哪个区? 等待时间如何获知? 其实就是上一个乘客下车, 到下一个乘客上车之间的时间, 通过这两个时间的差值便可获知

步骤分析

  1. 读取数据集

    数据集很大, 所以我截取了一小部分, 大概百分之一左右, 如果大家感兴趣的话, 可以将完整数据集放在集群中, 使用集群来计算 “大数据”

  2. 清洗

    数据集当中的某些列名可能使用起来不方便, 或者数据集当中某些列的值类型可能不对, 或者数据集中有可能存在缺失值, 这些都是要清洗的动机, 和理由

  3. 增加区域列

    由于最终要统计的结果是按照区域作为单位, 而不是一个具体的目的地点, 所以要在数据集中增加列中放置区域信息

    1. 既然是放置行政区名字, 应该现有行政区以及其边界的信息
    2. 通过上下车的坐标点, 可以判断是否存在于某个行政区中

    这些判断坐标点是否属于某个区域, 这些信息, 就是专业的领域了

  4. 按照区域, 统计司机两次营运记录之间的时间差

    数据集中存在很多出租车师傅的数据, 所以如何将某个师傅的记录发往一个分区, 在这个分区上完成会话分析呢? 这也是一个需要理解的点

3. 数据读取

导读

  1. 工程搭建
  2. 数据读取

工程搭建

  1. 创建 Maven 工程

  2. 导入 Maven 配置

  1. 创建 Scala 源码目录 src/main/scala

    并且设置这个目录为 Source Root

    img

创建文件, 数据读取

Step 1: 创建文件

创建 Spark Application 主类 cn.itcast.taxi.TaxiAnalysisRunner

1
2
3
4
5
6
package cn.itcast.taxi

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

Step 2: 数据读取

数据读取之前要做两件事

  1. 初始化环境, 导入必备的一些包
  2. 在工程根目录中创建 dataset 文件夹, 并拷贝数据集进去

代码如下

1
object TaxiAnalysisRunner {   def main(args: Array[String]): Unit = {    // 1. 创建 SparkSession    val spark = SparkSession.builder()      .master("local[6]")      .appName("taxi")      .getOrCreate() // 2. 导入函数和隐式转换 import spark.implicits._ import org.apache.spark.sql.functions._ // 3. 读取文件 val taxiRaw = spark.read  .option("header", value = true)  .csv("dataset/half_trip.csv") taxiRaw.show() taxiRaw.printSchema()  } }

运行结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root
|-- medallion: string (nullable = true)
|-- hack_license: string (nullable = true)
|-- vendor_id: string (nullable = true)
|-- rate_code: string (nullable = true)
|-- store_and_fwd_flag: string (nullable = true)
|-- pickup_datetime: string (nullable = true)
|-- dropoff_datetime: string (nullable = true)
|-- passenger_count: string (nullable = true)
|-- trip_time_in_secs: string (nullable = true)
|-- trip_distance: string (nullable = true)
|-- pickup_longitude: string (nullable = true)
|-- pickup_latitude: string (nullable = true)
|-- dropoff_longitude: string (nullable = true)
|-- dropoff_latitude: string (nullable = true)

img

下一步

  1. 剪去多余列

    现在数据集中包含了一些多余的列, 在后续的计算中并不会使用到, 如果让这些列参与计算的话, 会影响整体性能, 浪费集群资源

  2. 类型转换

    可以看到, 现在的数据集中, 所有列类型都是 String, 而在一些统计和运算中, 不能使用 String 来进行, 所以要将这些数据转为对应的类型

5. 数据清洗

导读

  1. Row 对象转为 Trip
  2. 处理转换过程中的报错

数据转换

通过 DataFrameReader 读取出来的数据集是 DataFrame, 而 DataFrame 中保存的是 Row 对象, 但是后续我们在进行处理的时候可能要使用到一些有类型的转换, 也需要每一列数据对应自己的数据类型, 所以, 需要将 Row 所代表的弱类型对象转为 Trip 这样的强类型对象, 而 Trip 对象则是一个样例类, 用于代表一个出租车的行程

Step 1: 创建 Trip 样例类

Trip 是一个强类型的样例类, 一个 Trip 对象代表一个出租车行程, 使用 Trip 可以对应数据集中的一条记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
object TaxiAnalysisRunner {

def main(args: Array[String]): Unit = {
// 此处省略 Main 方法中内容
}
}
/**

代表一个行程, 是集合中的一条记录

@param license 出租车执照号

@param pickUpTime 上车时间

@param dropOffTime 下车时间

@param pickUpX 上车地点的经度

@param pickUpY 上车地点的纬度

@param dropOffX 下车地点的经度

@param dropOffY 下车地点的纬度

/
case class Trip(
license: String,
pickUpTime: Long,
dropOffTime: Long,
pickUpX: Double,
pickUpY: Double,
dropOffX: Double,
dropOffY: Double
)
1
Step 2`: 将 `Row` 对象转为 `Trip` 对象, 从而将 `DataFrame` 转为 `Dataset[Trip]

首先应该创建一个新方法来进行这种转换, 毕竟是一个比较复杂的转换操作, 不能怠慢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
object TaxiAnalysisRunner {

def main(args: Array[String]): Unit = {
// ... 省略数据读取
// 4. 数据转换和清洗
val taxiParsed = taxiRaw.rdd.map(parse)
}
/**

将 Row 对象转为 Trip 对象, 从而将 DataFrame 转为 Dataset[Trip] 方便后续操作
@param row DataFrame 中的 Row 对象
@return 代表数据集中一条记录的 Trip 对象
/
def parse(row: Row): Trip = {

}
}


case class Trip(...)

Step 3: 创建 Row 对象的包装类型

因为在针对 Row 类型对象进行数据转换时, 需要对一列是否为空进行判断和处理, 在 Scala 中为空的处理进行一些支持和封装, 叫做 Option, 所以在读取 Row 类型对象的时候, 要返回 Option 对象, 通过一个包装类, 可以轻松做到这件事

创建一个类 RichRow 用以包装 Row 类型对象, 从而实现 getAs 的时候返回 Option 对象

1
object TaxiAnalysisRunner {   def main(args: Array[String]): Unit = {    // ... // 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(parse)  }  def parse(row: Row): Trip = {...} } case class Trip(...) class RichRow(row: Row) {  def getAs[T](field: String): Option[T] = {    if (row.isNullAt(row.fieldIndex(field)) || StringUtils.isBlank(row.getAsString)) {      None    } else {      Some(row.getAsT)    }  } }

Step 4: 转换

流程已经存在, 并且也已经为空值处理做了支持, 现在就可以进行转换了

首先根据数据集的情况会发现, 有如下几种类型的信息需要处理

  • 字符串类型

    执照号就是字符串类型, 对于字符串类型, 只需要判断空, 不需要处理, 如果是空字符串, 加入数据集的应该是一个 null

  • 时间类型

    上下车时间就是时间类型, 对于时间类型需要做两个处理

    • 转为时间戳, 比较容易处理
    • 如果时间非法或者为空, 则返回 0L
  • Double 类型

    上下车的位置信息就是 Double 类型, Double 类型的数据在数据集中以 String 的形式存在, 所以需要将 String 类型转为 Double 类型

总结来看, 有两类数据需要特殊处理, 一类是时间类型, 一类是 Double 类型, 所以需要编写两个处理数据的帮助方法, 后在 parse 方法中收集为 Trip 类型对象

1
object TaxiAnalysisRunner {   def main(args: Array[String]): Unit = {    // ... // 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(parse)  }  def parse(row: Row): Trip = {    // 通过使用转换方法依次转换各个字段数据    val row = new RichRow(row)    val license = row.getAsString.orNull    val pickUpTime = parseTime(row, "pickup_datetime")    val dropOffTime = parseTime(row, "dropoff_datetime")    val pickUpX = parseLocation(row, "pickup_longitude")    val pickUpY = parseLocation(row, "pickup_latitude")    val dropOffX = parseLocation(row, "dropoff_longitude")    val dropOffY = parseLocation(row, "dropoff_latitude") // 创建 Trip 对象返回 Trip(license, pickUpTime, dropOffTime, pickUpX, pickUpY, dropOffX, dropOffY)  }  /**    * 将时间类型数据转为时间戳, 方便后续的处理    * @param row 行数据, 类型为 RichRow, 以便于处理空值    * @param field 要处理的时间字段所在的位置    * @return 返回 Long 型的时间戳    */  def parseTime(row: RichRow, field: String): Long = {    val pattern = "yyyy-MM-dd HH:mm:ss"    val formatter = new SimpleDateFormat(pattern, Locale.ENGLISH) val timeOption = row.getAs[String](field) timeOption.map( time => formatter.parse(time).getTime )  .getOrElse(0L)  }  /**    * 将字符串标识的 Double 数据转为 Double 类型对象    * @param row 行数据, 类型为 RichRow, 以便于处理空值    * @param field 要处理的 Double 字段所在的位置    * @return 返回 Double 型的时间戳    */  def parseLocation(row: RichRow, field: String): Double = {    row.getAsString.map( loc => loc.toDouble ).getOrElse(0.0D)  } } case class Trip(..) class RichRow(row: Row) {...}

异常处理

在进行类型转换的时候, 是一个非常容易错误的点, 需要进行单独的处理

Step 1: 思路

img

parse 方法应该做的事情应该有两件

  • 捕获异常

    异常一定是要捕获的, 无论是否要抛给 DataFrame, 都要先捕获一下, 获知异常信息

    捕获要使用 try … catch … 代码块

  • 返回结果

    返回结果应该分为两部分来进行说明

    • 正确, 正确则返回数据
    • 错误, 则应该返回两类信息, 一 告知外面哪个数据出了错, 二 告知错误是什么

对于这种情况, 可以使用 Scala 中提供的一个类似于其它语言中多返回值的 Either. Either 分为两个情况, 一个是 Left, 一个是 Right, 左右两个结果所代表的意思可有由用户来指定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
val process = (b: Double) => {       (1)
val a = 10.0
a / b
}

def safe(function: Double => Double, b: Double): Either[Double, (Double, Exception)] = { (2)
try {
val result = function(b) (3)
Left(result)
} catch {
case e: Exception => Right(b, e) (4)
}
}
val result = safe(process, 0) (5)
result match { (6)
case Left(r) => println(r)
case Right((b, e)) => println(b, e)
}
1 一个函数, 接收一个参数, 根据参数进行除法运算
2 一个方法, 作用是让 process 函数调用起来更安全, 在其中 catch 错误, 报错后返回足够的信息 (报错时的参数和报错信息)
3 正常时返回 Left, 放入正确结果
4 异常时返回 Right, 放入报错时的参数, 和报错信息
5 外部调用
6 处理调用结果, 如果是 Right 的话, 则可以进行响应的异常处理和弥补

EitherOption 比较像, 都是返回不同的情况, 但是 EitherRight 可以返回多个值, 而 None 不行

如果一个 Either 有两个结果的可能性, 一个是 Left[L], 一个是 Right[R], 则 Either 的范型是 Either[L, R]

Step 2: 完成代码逻辑

加入一个 Safe 方法, 更安全

1
object TaxiAnalysisRunner {   def main(args: Array[String]): Unit = {    // ... // 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(safe(parse))  }  /**    * 包裹转换逻辑, 并返回 Either    */  def safe[P, R](f: P => R): P => Either[R, (P, Exception)] = {    new Function[P, Either[R, (P, Exception)]] with Serializable {      override def apply(param: P): Either[R, (P, Exception)] = {        try {          Left(f(param))        } catch {          case e: Exception => Right((param, e))        }      }    }  }  def parse(row: Row): Trip = {...}  def parseTime(row: RichRow, field: String): Long = {...}  def parseLocation(row: RichRow, field: String): Double = {...} } case class Trip(..) class RichRow(row: Row) {...}

Step 3: 针对转换异常进行处理

对于 Either 来说, 可以获取 Left 中的数据, 也可以获取 Right 中的数据, 只不过如果当 Either 是一个 Right 实例时候, 获取 Left 的值会报错

所以, 针对于 Dataset[Either] 可以有如下步骤

  1. 试运行, 观察是否报错
  2. 如果报错, 则打印信息解决报错
  3. 如果解决不了, 则通过 filter 过滤掉 Right
  4. 如果没有报错, 则继续向下运行
1
object TaxiAnalysisRunner {   def main(args: Array[String]): Unit = {    ... // 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(safe(parse)) val taxiGood = taxiParsed.map( either => either.left.get ).toDS()  }  ... } ...

很幸运, 在运行上面的代码时, 没有报错, 如果报错的话, 可以使用如下代码进行过滤

1
object TaxiAnalysisRunner {   def main(args: Array[String]): Unit = {    ... // 4. 数据转换和清洗 val taxiParsed = taxiRaw.rdd.map(safe(parse)) val taxiGood = taxiParsed.filter( either => either.isLeft )  .map( either => either.left.get )  .toDS()  }  ... } ...

观察数据集的时间分布

观察数据分布常用手段是直方图, 直方图反应的是数据的 "数量" 分布

img

通过这个图可以看到其实就是乘客年龄的分布, 横轴是乘客的年龄, 纵轴是乘客年龄的频数分布

因为我们这个项目中要对出租车利用率进行统计, 所以需要先看一看单次行程的时间分布情况, 从而去掉一些异常数据, 保证数据是准确的

绘制直方图的 “图” 留在后续的 DMP 项目中再次介绍, 现在先准备好直方图所需要的数据集, 通过数据集来观察即可, 直方图需要的是两个部分的内容, 一个是数据本身, 另外一个是数据的分布, 也就是频数的分布, 步骤如下

  1. 计算每条数据的时长, 但是单位要有变化, 按照分钟, 或者小时来作为时长单位
  2. 统计每个时长的数据量, 例如有 500 个行程是一小时内完成的, 有 300 个行程是 1 - 2 小时内完成

统计时间分布直方图

使用 UDF 的优点和代价

UDF 是一个很好用的东西, 特别好用, 对整体的逻辑实现会变得更加简单可控, 但是有两个非常明显的缺点, 所以在使用的时候要注意, 虽然有这两个缺点, 但是只在必要的地方使用就没什么问题, 对于逻辑的实现依然是有很大帮助的

  1. UDF 中, 对于空值的处理比较麻烦

    例如一个 UDF 接收两个参数, 是 Scala 中的 Int 类型和 Double 类型, 那么, 在传入 UDF 参数的时候, 如果有数据为 null, 就会出现转换异常

  2. 使用 UDF 的时候, 优化器可能无法对其进行优化

    UDF 对于 Catalyst 是不透明的, Catalyst 不可获知 UDF 中的逻辑, 但是普通的 Function 对于 Catalyst 是透明的, Catalyst 可以对其进行优化

Step 1: 编写 UDF, 将行程时长由毫秒单位改为小时单位

定义 UDF, 在 UDF 中做两件事

  1. 计算行程时长
  2. 将时长由毫秒转为分钟
1
object TaxiAnalysisRunner {   def main(args: Array[String]): Unit = {    ... // 5. 过滤行程无效的数据 val hours = (pickUp: Long, dropOff: Long) => {  val duration = dropOff - pickUp  TimeUnit.HOURS.convert(, TimeUnit.MILLISECONDS) } val hoursUDF = udf(hours)  }  ... }

Step 2: 统计时长分布

  1. 第一步应该按照行程时长进行分组
  2. 求得每个分组的个数
  3. 最后按照时长排序并输出结果
1
object TaxiAnalysisRunner {   def main(args: Array[String]): Unit = {    ... // 5. 过滤行程无效的数据 val hours = (pickUp: Long, dropOff: Long) => {  val duration = dropOff - pickUp  TimeUnit.MINUTES.convert(, TimeUnit.MILLISECONDS) } val hoursUDF = udf(hours) taxiGood.groupBy(hoursUDF($"pickUpTime", $"dropOffTime").as("duration"))  .count()  .sort("duration")  .show()  }  ... }

会发现, 大部分时长都集中在 1 - 19 分钟内

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
+--------+-----+
|duration|count|
+--------+-----+
| 0| 86|
| 1| 140|
| 2| 383|
| 3| 636|
| 4| 759|
| 5| 838|
| 6| 791|
| 7| 761|
| 8| 688|
| 9| 625|
| 10| 537|
| 11| 499|
| 12| 395|
| 13| 357|
| 14| 353|
| 15| 264|
| 16| 252|
| 17| 197|
| 18| 181|
| 19| 136|
+--------+-----+

Step 3: 注册函数, 在 SQL 表达式中过滤数据

大部分时长都集中在 1 - 19 分钟内, 所以这个范围外的数据就可以去掉了, 如果同学使用完整的数据集, 会发现还有一些负的时长, 好像是回到未来的场景一样, 对于这种非法的数据, 也要过滤掉, 并且还要分析原因

1
object TaxiAnalysisRunner {   def main(args: Array[String]): Unit = {    ... // 5. 过滤行程无效的数据 val hours = (pickUp: Long, dropOff: Long) => {  val duration = dropOff - pickUp  TimeUnit.MINUTES.convert(, TimeUnit.MILLISECONDS) } val hoursUDF = udf(hours) taxiGood.groupBy(hoursUDF($"pickUpTime", $"dropOffTime").as("duration"))  .count()  .sort("duration")  .show() spark.udf.register("hours", hours) val taxiClean = taxiGood.where("hours(pickUpTime, dropOffTime) BETWEEN 0 AND 3") taxiClean.show()  }  ... }

6. 行政区信息

目标和步骤

目标

能够通过 GeoJSON 判断一个点是否在一个区域内, 能够使用 JSON4S 解析 JSON 数据

步骤

  1. 需求介绍
  2. 工具介绍
  3. 解析 JSON
  4. 读取 Geometry

总结

  • 整体流程
    1. JSON4S 介绍
    2. ESRI 介绍
    3. 编写函数实现 经纬度 → Geometry 转换
  • 后续可以使用函数来进行转换, 并且求得时间差

6.1. 需求介绍

目标和步骤

目标

理解表示地理位置常用的 GeoJSON

步骤

  1. 思路整理
  2. GeoJSON 是什么
  3. GeoJSON 的使用

思路整理

  • 需求

    项目的任务是统计出租车在不同行政区的平均等待时间, 所以源数据集和经过计算希望得到的新数据集大致如下

    • 源数据集

      img

    • 目标数据集

      img

  • 目标数据集分析

    目标数据集中有三列, borough, avg(seconds), stddev_samp(seconds)

    • borough 表示目的地行政区的名称
    • avg(seconds)stddev_samp(seconds)seconds 的聚合, seconds 是下车时间和下一次上车时间之间的差值, 代表等待时间

    所以有两列数据是现在数据集中没有

    • borough 要根据数据集中的经纬度, 求出其行政区的名字
    • seconds 要根据数据集中上下车时间, 求出差值
  • 步骤

    1. 求出 borough
      1. 读取行政区位置信息
      2. 搜索每一条数据的下车经纬度所在的行政区
      3. 在数据集中添加行政区列
    2. 求出 seconds
    3. 根据 borough 计算平均等待时间, 是一个聚合操作

GeoJSON 是什么

  • 定义

    • GeoJSON 是一种基于 JSON 的开源标准格式, 用来表示地理位置信息
    • 其中定了很多对象, 表示不同的地址位置单位
  • 如何表示地理位置

    类型 例子
    img { "type": "Point", "coordinates": [30, 10] }
    线段 img { "type": "Point", "coordinates": [30, 10] }
    多边形 img { "type": "Point", "coordinates": [30, 10] }
    img { "type": "Polygon", "coordinates": [ [[35, 10], [45, 45], [15, 40], [10, 20], [35, 10]], [[20, 30], [35, 35], [30, 20], [20, 30]] ] }
  • 数据集

    • 行政区范围可以使用 GeoJSON 中的多边形来表示

    • 课程中为大家提供了一份表示了纽约的各个行政区范围的数据集, 叫做 nyc-borough-boundaries-polygon.geojson

      img

  • 使用步骤

    1. 创建一个类型 Feature, 对应 JSON 文件中的格式
    2. 通过解析 JSON, 创建 Feature 对象
    3. 通过 Feature 对象创建 GeoJSON 表示一个地理位置的 Geometry 对象
    4. 通过 Geometry 对象判断一个经纬度是否在其范围内

总结

  • 思路
    1. 从需求出发, 设计结果集
    2. 推导结果集所欠缺的字段
    3. 补齐欠缺的字段, 生成结果集, 需求完成
  • 后续整体上要做的事情
    • 需求是查看出租车在不同行政区的等待客人的时间
    • 需要补充两个点, 一是出租车下客点的行政区名称, 二是等待时间
    • 本章节聚焦于行政区的信息补充
  • 学习步骤
    1. 介绍 JSON 解析的工具
    2. 介绍读取 GeoJSON 的工具
    3. JSON 解析
    4. 读取 GeoJSON

6.2. 工具介绍

目标和步骤

目标

理解 JSON 解析和 Geometry 解析所需要的工具, 后续使用这些工具补充行政区信息

步骤

  1. JSON4S
  2. ESRI Geometry

JSON4S 介绍

  • 介绍

    一般在 Java 中, 常使用如下三个工具解析 JSON

    • Gson

      Google 开源的 JSON 解析工具, 比较人性化, 易于使用, 但是性能不如 Jackson, 也不如 Jackson 有积淀

    • Jackson

      Jackson 是功能最完整的 JSON 解析工具, 也是最老牌的 JSON 解析工具, 性能也足够好, 但是 API 在一开始支持的比较少, 用起来稍微有点繁琐

    • FastJson

      阿里巴巴的 JSON 开源解析工具, 以快著称, 但是某些方面用起来稍微有点反直觉

  • 什么是 JSON 解析

    img

    • 读取 JSON 数据的时候, 读出来的是一个有格式的字符串, 将这个字符串转换为对象的过程就叫做解析
    • 可以使用 JSON4S 来解析 JSON, JSON4S 是一个其它解析工具的 Scala 封装以适应 Scala 的对象转换
    • JSON4S 支持 Jackson 作为底层的解析工具
  • Step 1: 导入 Maven 依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!-- JSON4S -->
    <dependency>
    <groupId>org.json4s</groupId>
    <artifactId>json4s-native_2.11</artifactId>
    <version>${json4s.version}</version>
    </dependency>
    <!-- JSON4S 的 Jackson 集成库 -->
    <dependency>
    <groupId>org.json4s</groupId>
    <artifactId>json4s-jackson_2.11</artifactId>
    <version>${json4s.version}</version>
    </dependency>
  • Step 2: 解析 JSON

    步骤

    1. 解析 JSON 对象
    2. 序列化 JSON 对象
    3. 使用 Jackson 反序列化 Scala 对象
    4. 使用 Jackson 序列化 Scala 对象

    代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    import org.json4s._
    import org.json4s.jackson.JsonMethods._
    import org.json4s.jackson.Serialization.{read, write}

    case class Product(name: String, price: Double)
    val product =
    """
    |{"name":"Toy","price":35.35}
    """.stripMargin
    // 可以解析 JSON 为对象
    val obj: Product = parse(product).extra[Product]
    // 可以将对象序列化为 JSON
    val str: String = compact(render(Product("电视", 10.5)))
    // 使用序列化 API 之前, 要先导入代表转换规则的 formats 对象隐式转换
    implicit val formats = Serialization.formats(NoTypeHints)
    // 可以使用序列化的方式来将 JSON 字符串反序列化为对象
    val obj1 = readPerson
    // 可以使用序列化的方式将对象序列化为 JSON 字符串
    val str1 = write(Product("电视", 10.5))

GeoJSON 读取工具的介绍

  • 介绍

    • 读取 GeoJSON 的工具有很多, 但是大部分都过于复杂, 有一些只能 Java 中用
    • 有一个较为简单, 也没有使用底层 C 语言开发的解析 GeoJSON 的类库叫做 ESRI Geometry, Scala 中也可以支持
  • 使用

    ESRI Geometry 的使用比较的简单, 大致就如下这样调用即可

    1
    2
    3
    4
    val mg = GeometryEngine.geometryFromGeoJson(jsonStr, 0, Geometry.Type.Unknown) (1)
    val geometry = mg.getGeometry (2)

    GeometryEngine.contains(geometry, other, csr) (3)
1 读取 JSON 生成 Geometry 对象
2 重点: 一个 Geometry 对象就表示一个 GeoJSON 支持的对象, 可能是一个点, 也可能是一个多边形
3 判断一个 Geometry 中是否包含另外一个 Geometry

总结

  • JSON 解析

    • FastJSONGson 直接在 Scala 中使用会出现问题, 因为 Scala 的对象体系和 Java 略有不同
    • 最为适合 Scala 的方式是使用 JSON4S 作为上层 API, Jackson 作为底层提供 JSON 解析能力, 共同实现 JSON 解析
    • 其使用方式非常简单, 两行即可解析
    1
    2
    implicit val formats = Serialization.formats(NoTypeHints)
    val obj = read[Person](product)
  • GeoJSON 的解析

    • 有一个很适合 Scala 的 GeoJSON 解析工具, 叫做 ESRI Geometry, 其可以将 GeoJSON 字符串转为 Geometry 对象, 易于使用
    1
    GeometryEngine.geometryFromGeoJson(jsonStr, 0, Geometry.Type.Unknown)
  • 后续工作

    1. 读取行政区的数据集, 解析 JSON 格式, 将 JSON 格式的字符串转为对象
    2. 使用 ESRIGeometryEngine 读取行政区的 Geometry 对象的 JSON 字符串, 生成 Geometry 对象
    3. 使用上车点和下车点的坐标创建 Point 对象 ( Geometry 的子类)
    4. 判断 Point 是否在行政区的 Geometry 的范围内 (行政区的 Geometry 其实本质上是子类 Polygon 的对象)

6.3. 具体实现

目标和步骤

目标

通过 JSON4SESRI 配合解析提供的 GeoJSON 数据集, 获取纽约的每个行政区的范围

步骤

  1. 解析 JSON
  2. 使用 ESRI 生成表示行政区的一组 Geometry 对象

解析 JSON

  • 步骤

    1. 对照 JSON 中的格式, 创建解析的目标类
    2. 解析 JSON 数据转为目标类的对象
    3. 读取数据集, 执行解析
  • Step 1: 创建目标类

    • GeoJSON

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      {
      "type": "FeatureCollection",
      "features": [ (1)
      {
      "type": "Feature",
      "id": 0,
      "properties": {
      "boroughCode": 5,
      "borough": "Staten Island",
      "@id": "http:\/\/nyc.pediacities.com\/Resource\/Borough\/Staten_Island"
      },
      "geometry": {
      "type": "Polygon",
      "coordinates": [
      [
      [-74.050508064032471, 40.566422034160816],
      [-74.049983525625748, 40.566395924928273]
      ]
      ]
      }
      }
      ]
      }
      1 features 是一个数组, 其中每一个 Feature 代表一个行政区
    • 目标类

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      case class FeatureCollection(
      features: List[Feature]
      )

      case class Feature(
      id: Int,
      properties: Map[String, String],
      geometry: JObject
      )
      case class FeatureProperties(boroughCode: Int, borough: String)
  • Step 2: 将 JSON 字符串解析为目标类对象

    创建工具类实现功能

    1
    2
    3
    4
    5
    6
    7
    8
    object FeatureExtraction {

    def parseJson(json: String): FeatureCollection = {
    implicit val format: AnyRef with Formats = Serialization.formats(NoTypeHints)
    val featureCollection = readFeatureCollection
    featureCollection
    }
    }
  • Step 3: 读取数据集, 转换数据

    1
    2
    val geoJson = Source.fromFile("dataset/nyc-borough-boundaries-polygon.geojson").mkString
    val features = FeatureExtraction.parseJson(geoJson)

解析 GeoJSON

  • 步骤

    1. 转换 JSONGeometry 对象
  • 表示行政区的 JSON 段在哪

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    {
    "type": "FeatureCollection",
    "features": [
    {
    "type": "Feature",
    "id": 0,
    "properties": {
    "boroughCode": 5,
    "borough": "Staten Island",
    "@id": "http:\/\/nyc.pediacities.com\/Resource\/Borough\/Staten_Island"
    },
    "geometry": { (1)
    "type": "Polygon",
    "coordinates": [
    [
    [-74.050508064032471, 40.566422034160816],
    [-74.049983525625748, 40.566395924928273]
    ]
    ]
    }
    }
    ]
    }
    1 geometry 段即是 Geometry 对象的 JSON 表示
  • 通过 ESRI 解析此段

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    case class Feature(
    id: Int,
    properties: Map[String, String],
    geometry: JObject (1)
    ) {

    def getGeometry: Geometry = { (2)
    GeometryEngine.geoJsonToGeometry(compact(render(geometry)), 0, Geometry.Type.Unknown).getGeometry
    }
    }
1 geometry 对象需要使用 ESRI 解析并生成, 所以此处并没有使用具体的对象类型, 而是使用 JObject 表示一个 JsonObject, 并没有具体的解析为某个对象, 节省资源
2 JSON 转为 Geometry 对象

在出租车 DataFrame 中增加行政区信息

  • 步骤

    1. Geometry 数据集按照区域大小排序
    2. 广播 Geometry 信息, 发给每一个 Executor
    3. 创建 UDF, 通过经纬度获取行政区信息
    4. 统计行政区信息
  • Step 1: 排序 Geometry

    • 动机: 后续需要逐个遍历 Geometry 对象, 取得每条出租车数据所在的行政区, 大的行政区排在前面效率更好一些
    1
    2
    3
    val areaSortedFeatures = features.features.sortBy(feature => {
    (feature.properties("boroughCode"), - feature.getGeometry.calculateArea2D())
    })
  • Step 2: 发送广播

    • 动机: Geometry 对象数组相对来说是一个小数据集, 后续需要使用 Spark 来进行计算, 将 Geometry 分发给每一个 Executor 会显著减少 IO 通量
    1
    val featuresBc = spark.sparkContext.broadcast(areaSortedFeatures)
  • Step 3: 创建 UDF

    • 动机: 创建 UDF, 接收每个出租车数据的下车经纬度, 转为行政区信息, 以便后续实现功能
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    val boroughLookUp = (x: Double, y: Double) => {
    val features: Option[Feature] = featuresBc.value.find(feature => {
    GeometryEngine.contains(feature.getGeometry, new Point(x, y), SpatialReference.create(4326))
    })
    features.map(feature => {
    feature.properties("borough")
    }).getOrElse("NA")
    }

    val boroughUDF = udf(boroughLookUp)
  • Step 4: 测试转换结果, 统计每个行政区的出租车数据数量

    • 动机: 写完功能最好先看看, 运行一下
    1
    2
    3
    taxiClean.groupBy(boroughUDF('dropOffX, 'dropOffY))
    .count()
    .show()

总结

  • 具体的实现分为两个大步骤
    1. 解析 JSON 生成 Geometry 数据
    2. 通过 Geometry 数据, 取得每一条出租车数据的行政区信息
  • Geometry 数据的生成又有如下步骤
    1. 使用 JSON4S 解析行政区区域信息的数据集
    2. 取得其中每一个行政区信息的 Geometry 区域信息, 转为 ESRIGeometry 对象
  • 查询经纬度信息, 获取其所在的区域, 有如下步骤
    1. 遍历 Geometry 数组, 搜索经纬度所表示的 Point 对象在哪个区域内
    2. 返回区域的名称
      • 使用 UDF 的目的是为了统计数据集, 后续会通过函数直接完成功能

7. 会话统计

目标和步骤

目标

  • 统计每个行政区的所有行程, 查看每个行政区平均等候客人的时间
  • 掌握会话统计的方式方法

步骤

  1. 会话统计的概念
  2. 功能实现

会话统计的概念

  • 需求分析

    • 需求

      统计每个行政区的平均等客时间

    • 需求可以拆分为如下几个步骤

      1. 按照行政区分组
      2. 在每一个行政区中, 找到同一个出租车司机的先后两次订单, 本质就是再次针对司机的证件号再次分组
      3. 求出这两次订单的下车时间和上车时间只差, 便是等待客人的时间
      4. 针对一个行政区, 求得这个时间的平均数
    • 问题: 分组效率太低

      分组的效率相对较低

      • 分组是 Shuffle
      • 两次分组, 包括后续的计算, 相对比较复杂
    • 解决方案: 分区后在分区中排序

      1. 按照 License 重新分区, 如此一来, 所有相同的司机的数据就会在同一个分区中

      2. 计算分区中连续两条数据的时间差

        img

      上述的计算存在一个问题, 一个分组会有多个司机的数据, 如何划分每个司机的数据边界? 其实可以先过滤一下, 计算时只保留同一个司机的数据
    • 无论是刚才的多次分组, 还是后续的分区, 都是要找到每个司机的会话, 通过会话来完成功能, 也叫做会话分析

功能实现

  • 步骤

    1. 过滤掉没有经纬度的数据
    2. 按照 License 重新分区并按照 LicensepickUpTime 排序
    3. 求得每个司机的下车和下次上车的时间差
    4. 求得每个行政区得统计数据
  • Step 1: 过滤没有经纬度的数据

    1
    val taxiDone = taxiClean.where("dropOffX != 0 and dropOffY != 0 and pickUpX != 0 and pickUpY != 0")
  • Step 2: 划分会话

    1
    2
    val sessions = taxiDone.repartition('license)
    .sortWithinPartitions('license, 'pickUpTime)
  • Step 3: 求得时间差

    1. 处理每个分区, 通过 ScalaAPI 找到相邻的数据

      1
      2
      3
      sessions.mapPartitions(trips => {
      val viter = trips.sliding(2)
      })
    2. 过滤司机不同的相邻数据

      1
      2
      3
      4
      5
      sessions.mapPartitions(trips => {
      val viter = trips.sliding(2)
      .filter(_.size == 2)
      .filter(p => p.head.license == p.last.license)
      })
    3. 求得时间差

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      def boroughDuration(t1: Trip, t2: Trip): (String, Long) = {
      val borough = boroughLookUp(t1.dropOffX, t1.dropOffY)
      val duration = (t2.pickUpTime - t1.dropOffTime) / 1000
      (borough, duration)
      }

      val boroughDurations = sessions.mapPartitions(trips => {
      val viter = trips.sliding(2)
      .filter(_.size == 2)
      .filter(p => p.head.license == p.last.license)
      viter.map(p => boroughDuration(p.head, p.last))
      }).toDF("borough", "seconds")
  • Step 4: 统计数据

    1
    2
    3
    4
    boroughDurations.where("seconds > 0")
    .groupBy("borough")
    .agg(avg("seconds"), stddev("seconds"))
    .show()

总结

  • 其实会话分析的难点就是理解需求
    • 需求是找到每个行政区的待客时间, 就是按照行政区分组
    • 需求是找到待客时间, 就是按照司机进行分组, 并且还要按照时间进行排序, 才可找到一个司机相邻的两条数据
  • 但是分组和统计的效率较低
    • 可以把相同司机的所有形成发往一个分区
    • 然后按照司机的 License 和上车时间综合排序
    • 这样就可以找到同一个司机的两次行程之间的差值

Spark Streaming

导读

  1. 介绍
  2. 入门
  3. 原理
  4. 操作

Table of Contents

1. Spark Streaming 介绍

导读

  1. 流式计算的场景
  2. 流式计算框架
  3. Spark Streaming 的特点

新的场景

通过对现阶段一些常见的需求进行整理, 我们要问自己一个问题, 这些需求如何解决?

场景 解释
商品推荐 img)img京东和淘宝这样的商城在购物车, 商品详情等地方都有商品推荐的模块商品推荐的要求快速的处理, 加入购物车以后就需要迅速的进行推荐数据量大需要使用一些推荐算法
工业大数据 img现在的工场中, 设备是可以联网的, 汇报自己的运行状态, 在应用层可以针对这些数据来分析运行状况和稳健程度, 展示工件完成情况, 运行情况等工业大数据的需求快速响应, 及时预测问题数据是以事件的形式动态的产品和汇报因为是运行状态信息, 而且一般都是几十上百台机器, 所以汇报的数据量很大
监控 img一般的大型集群和平台, 都需要对其进行监控监控的需求要针对各种数据库, 包括 MySQL, HBase 等进行监控要针对应用进行监控, 例如 Tomcat, Nginx, Node.js 等要针对硬件的一些指标进行监控, 例如 CPU, 内存, 磁盘 等这些工具的日志输出是非常多的, 往往一个用户的访问行为会带来几百条日志, 这些都要汇报, 所以数据量比较大要从这些日志中, 聚合系统运行状况
这样的需求, 可以通过传统的批处理来完成吗?

流计算

  • 批量计算

    img

    数据已经存在, 一次性读取所有的数据进行批量处理

  • 流计算

    img

    数据源源不断的进来, 经过处理后落地

流和批的架构组合

流和批都是有意义的, 有自己的应用场景, 那么如何结合流和批呢? 如何在同一个系统中使用这两种不同的解决方案呢?

混合架构

img

  • 混合架构说明

    混合架构的名字叫做 Lambda 架构, 混合架构最大的特点就是将流式计算和批处理结合起来

    后在进行查询的时候分别查询流系统和批系统, 最后将结果合并在一起

    img

    一般情况下 Lambda 架构分三层

    • 批处理层: 批量写入, 批量读取
    • 服务层: 分为两个部分, 一部分对应批处理层, 一部分对应速度层
    • 速度层: 随机读取, 随即写入, 增量计算
  • 优点

    • 兼顾优点, 在批处理层可以全量查询和分析, 在速度层可以查询最新的数据
    • 速度很快, 在大数据系统中, 想要快速的获取结果是非常困难的, 因为高吞吐量和快速返回结果往往很难兼得, 例如 ImpalaHive, Hive 能进行非常大规模的数据量的处理, Impala 能够快速的查询返回结果, 但是很少有一个系统能够兼得两点, Lambda 使用多种融合的手段从而实现
  • 缺点

    Lambda 是一个非常反人类的设计, 因为我们需要在系统中不仅维护多套数据层, 还需要维护批处理和流式处理两套框架, 这非常困难, 一套都很难搞定, 两套带来的运维问题是是指数级提升的

流式架构

img

  • 流式架构说明

    流式架构常见的叫做 Kappa 结构, 是 Lambda 架构 的一个变种, 其实本质上就是删掉了批处理

  • 优点

    • 非常简单
    • 效率很高, 在存储系统的发展下, 很多存储系统已经即能快速查询又能批量查询了, 所以 Kappa 架构 在新时代还是非常够用的
  • 问题

    丧失了一些 Lambda 的优秀特点

关于架构的问题, 很多时候往往是无解的, 在合适的地方使用合适的架构, 在项目课程中, 还会进行更细致的讨论

Spark Streaming 的特点

特点 说明
Spark StreamingSpark Core API 的扩展 Spark Streaming 具有类似 RDDAPI, 易于使用, 并可和现有系统共用相似代码一个非常重要的特点是, Spark Streaming 可以在流上使用基于 Spark 的机器学习和流计算, 是一个一站式的平台
Spark Streaming 具有很好的整合性 Spark Streaming 可以从 Kafka, Flume, TCP 等流和队列中获取数据Spark Streaming 可以将处理过的数据写入文件系统, 常见数据库中
Spark Streaming 是微批次处理模型 微批次处理的方式不会有长时间运行的 Operator, 所以更易于容错设计微批次模型能够避免运行过慢的服务, 实行推测执行

2. Spark Streaming 入门

导读

  1. 环境准备
  2. 工程搭建
  3. 代码编写
  4. 总结

Netcat 的使用

Step 1: Socket 回顾

img

  • SocketJava 中为了支持基于 TCP / UDP 协议的通信所提供的编程模型

  • Socket 分为 Socket serverSocket client

    Socket server

    监听某个端口, 接收 Socket client 发过来的连接请求建立连接, 连接建立后可以向 Socket client 发送 TCP packet 交互 (被动)

    Socket client

    向某个端口发起连接, 并在连接建立后, 向 Socket server 发送 TCP packet 实现交互 (主动)

  • TCP 三次握手建立连接

    Step 1

    ClientServer 发送 SYN(j), 进入 SYN_SEND 状态等待 Server 响应

    Step 2

    Server 收到 ClientSYN(j) 并发送确认包 ACK(j + 1), 同时自己也发送一个请求连接的 SYN(k)Client, 进入 SYN_RECV 状态等待 Client 确认

    Step 3

    Client 收到 ServerACK + SYN, 向 Server 发送连接确认 ACK(k + 1), 此时, ClientServer 都进入 ESTABLISHED 状态, 准备数据发送

1
Step 2:` `Netcat

img

  • Netcat 简写 nc, 命令行中使用 nc 命令调用
  • Netcat 是一个非常常见的 Socket 工具, 可以使用 nc 建立 Socket server 也可以建立 Socket client
    • nc -l 建立 Socket server, llisten 监听的意思
    • nc host port 建立 Socket client, 并连接到某个 Socket server

创建工程

目标

使用 Spark Streaming 程序和 Socket server 进行交互, 从 Server 处获取实时传输过来的字符串, 拆开单词并统计单词数量, 最后打印出来每一个小批次的单词数量

img

Step 1: 创建工程

  1. 创建 IDEA Maven 工程, 步骤省略, 参考 Spark 第一天工程建立方式
  2. 导入 Maven 依赖, 省略, 参考 Step 2
  3. 创建 main/scala 文件夹和 test/scala 文件夹
  4. 创建包 cn.itcast.streaming
  5. 创建对象 StreamingWordCount

Step 2: Maven 依赖

如果使用 Spark Streaming, 需要使用如下 Spark 的依赖

  • Spark Core: Spark 的核心包, 因为 Spark Streaming 要用到
  • Spark Streaming

Step 3: 编码

object StreamingWordCount { def main(args: Array[String]): Unit = { if (args.length < 2) { System.err.println(“Usage: NetworkWordCount “) System.exit(1) } val sparkConf = new SparkConf().setAppName(“NetworkWordCount”) val ssc = new StreamingContext(sparkConf, Seconds(1)) (1) val lines = ssc.socketTextStream( (2) hostname = args(0), port = args(1).toInt, storageLevel = StorageLevel.MEMORY_AND_DISK_SER) (3) val words = lines.flatMap(.split(“ “)) val wordCounts = words.map(x => (x, 1)).reduceByKey( + _) wordCounts.print() (4) ssc.start() (5) ssc.awaitTermination() (6) } }

1 Spark 中, 一般使用 XXContext 来作为入口, Streaming 也不例外, 所以创建 StreamingContext 就是创建入口
2 开启 SocketReceiver, 连接到某个 TCP 端口, 作为 Socket client, 去获取数据
3 选择 Receiver 获取到数据后的保存方式, 此处是内存和磁盘都有, 并且序列化后保存
4 类似 RDD 中的 Action, 执行最后的数据输出和收集
5 启动流和 JobGenerator, 开始流式处理数据
6 阻塞主线程, 后台线程开始不断获取数据并处理

Step 4: 部署和上线

  1. 使用 Maven 命令 package 打包

    img

  2. 将打好的包上传到 node01

    img

  3. node02 上使用 nc 开启一个 Socket server, 接受 Streaming 程序的连接请求, 从而建立连接发送消息给 Streaming 程序实时处理

    1
    nc -lk 9999
  4. node01 执行如下命令运行程序

    1
    spark-submit --class cn.itcast.streaming.StreamingWordCount  --master local[6] original-streaming-0.0.1.jar node02 9999

Step 5: 总结和知识落地

注意点

  • Spark Streaming 并不是真正的来一条数据处理一条

    img

    Spark Streaming 的处理机制叫做小批量, 英文叫做 mini-batch, 是收集了一定时间的数据后生成 RDD, 后针对 RDD 进行各种转换操作, 这个原理提现在如下两个地方

    • 控制台中打印的结果是一个批次一个批次的, 统计单词数量也是按照一个批次一个批次的统计
    • 多长时间生成一个 RDD 去统计呢? 由 new StreamingContext(sparkConf, Seconds(1)) 这段代码中的第二个参数指定批次生成的时间
  • Spark Streaming 中至少要有两个线程

    在使用 spark-submit 启动程序的时候, 不能指定一个线程

    • 主线程被阻塞了, 等待程序运行
    • 需要开启后台线程获取数据

创建 StreamingContext

1
2
val conf = new SparkConf().setAppName(appName).setMaster(master)
val ssc = new StreamingContext(conf, Seconds(1))
  • StreamingContextSpark Streaming 程序的入口
  • 在创建 StreamingContext 的时候, 必须要指定两个参数, 一个是 SparkConf, 一个是流中生成 RDD 的时间间隔
  • StreamingContext 提供了如下功能
    • 创建 DStream, 可以通过读取 Kafka, 读取 Socket 消息, 读取本地文件等创建一个流, 并且作为整个 DAG 中的 InputDStream
    • RDD 遇到 Action 才会执行, 但是 DStream 不是, DStream 只有在 StreamingContext.start() 后才会开始接收数据并处理数据
    • 使用 StreamingContext.awaitTermination() 等待处理被终止
    • 使用 StreamingContext.stop() 来手动的停止处理
  • 在使用的时候有如下注意点
    • 同一个 Streaming 程序中, 只能有一个 StreamingContext
    • 一旦一个 Context 已经启动 (start), 则不能添加新的数据源 **

各种算子

img

  • 这些算子类似 RDD, 也会生成新的 DStream
  • 这些算子操作最终会落到每一个 DStream 生成的 RDD
算子 释义
flatMap lines.flatMap(_.split(" "))将一个数据一对多的转换为另外的形式, 规则通过传入函数指定
map words.map(x => (x, 1))一对一的转换数据
reduceByKey words.reduceByKey(_ + _)这个算子需要特别注意, 这个聚合并不是针对于整个流, 而是针对于某个批次的数据

2. 原理

  1. 总章
  2. 静态 DAG
  3. 动态切分
  4. 数据流入
  5. 容错机制

总章

Spark Streaming 的特点

  • Spark Streaming 会源源不断的处理数据, 称之为流计算
  • Spark Streaming 并不是实时流, 而是按照时间切分小批量, 一个一个的小批量处理
  • Spark Streaming 是流计算, 所以可以理解为数据会源源不断的来, 需要长时间运行

Spark Streaming 是按照时间切分小批量

  • 如何小批量?

    Spark Streaming 中的编程模型叫做 DStream, 所有的 API 都从 DStream 开始, 其作用就类似于 RDD 之于 Spark Core

    img

    可以理解为 DStream 是一个管道, 数据源源不断的从这个管道进去, 被处理, 再出去

    img

    但是需要注意的是, DStream 并不是严格意义上的实时流, 事实上, DStream 并不处理数据, 而是处理 RDD

    img

    以上, 可以整理出如下道理

    • Spark Streaming 是小批量处理数据, 并不是实时流
    • Spark Streaming 对数据的处理是按照时间切分为一个又一个小的 RDD, 然后针对 RDD 进行处理

    所以针对以上的解读, 可能会产生一种疑惑

    • 如何切分 RDD?
  • 如何处理数据?

    如下代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    val lines: DStream[String] = ssc.socketTextStream(
    hostname = args(0),
    port = args(1).toInt,
    storageLevel = StorageLevel.MEMORY_AND_DISK_SER)

    val words: DStream[String] = lines
    .flatMap(.split(" "))
    .map(x => (x, 1))
    .reduceByKey( + _)

可以看到

  • RDD 中针对数据的处理是使用算子, 在 DStream 中针对数据的操作也是算子

  • DStream 的算子似乎和 RDD 没什么区别

    有一个疑惑

  • 难道 DStream 会把算子的操作交给 RDD 去处理? 如何交?

Spark Streaming 是流计算, 流计算的数据是无限的

什么系统可以产生无限的数据?

img

无限的数据一般指的是数据不断的产生, 比如说运行中的系统, 无法判定什么时候公司会倒闭, 所以也无法断定数据什么时候会不再产生数据

那就会产生一个问题

如何不简单的读取数据, 如何应对数据量时大时小?

如何数据是无限的, 意味着可能要一直运行下去

那就会又产生一个问题

Spark Streaming 不会出错吗? 数据出错了怎么办?

总结

总结下来, 有四个问题

  • DStream 如何对应 RDD?
  • 如何切分 RDD?
  • 如何读取数据?
  • 如何容错?

DAG 的定义

RDDDStreamDAG

如果是 RDDWordCount, 代码大致如下

1
2
3
4
val textRDD = sc.textFile(...)
val splitRDD = textRDD.flatMap(_.split(" "))
val tupleRDD = splitRDD.map((_, 1))
val reduceRDD = tupleRDD.reduceByKey(_ + _)

用图形表示如下

img

同样, DStream 的代码大致如下

1
2
3
val lines: DStream[String] = ssc.socketTextStream(...)
val words: DStream[String] = lines.flatMap(_.split(" "))
val wordCounts: DStream[(String, Int)] = words.map(x => (x, 1)).reduceByKey(_ + _)

同理, DStream 也可以形成 DAG 如下

img

看起来 DStreamRDD 好像哟, 确实如此

RDDDStream 的区别

img

  • DStream 的数据是不断进入的, RDD 是针对一个数据的操作
  • RDD 一样, DStream 也有不同的子类, 通过不同的算子生成
  • 一个 DStream 代表一个数据集, 其中包含了针对于上一个数据的操作
  • DStream 根据时间切片, 划分为多个 RDD, 针对 DStream 的计算函数, 会作用于每一个 DStream 中的 RDD

DStream 如何形式 DAG

img

  • 每个 DStream 都有一个关联的 DStreamGraph 对象
  • DStreamGraph 负责表示 DStream 之间的的依赖关系和运行步骤
  • DStreamGraph 中会单独记录 InputDStreamOutputDStream

切分流, 生成小批量

静态和动态

根据前面的学习, 可以总结一下规律

  • DStream 对应 RDD
  • DStreamGraph 表示 DStream 之间的依赖关系和运行流程, 相当于 RDD 通过 DAGScheduler 所生成的 RDD DAG

但是回顾前面的内容, RDD 的运行分为逻辑计划和物理计划

  • 逻辑计划就是 RDD 之间依赖关系所构成的一张有向无环图
  • 后根据这张 DAG 生成对应的 TaskSet 调度到集群中运行, 如下

img

但是在 DStream 中则不能这么简单的划分, 因为 DStream 中有一个非常重要的逻辑, 需要按照时间片划分小批量

  • Streaming 中, DStream 类似 RDD, 生成的是静态的数据处理过程, 例如一个 DStream 中的数据经过 map 转为其它模样
  • Streaming 中, DStreamGraph 类似 DAG, 保存了这种数据处理的过程

上述两点, 其实描述的是静态的一张 DAG, 数据处理过程, 但是 Streaming 是动态的, 数据是源源不断的来的

img

所以, 在 DStream 中, 静态和动态是两个概念, 有不同的流程

img

  • DStreamGraphDStream 联合起来, 生成 DStream 之间的 DAG, 这些 DStream 之间的关系是相互依赖的关系, 例如一个 DStream 经过 map 转为另外一个 DStream
  • 但是把视角移动到 DStream 中来看, DStream 代表了源源不断的 RDD 的生成和处理, 按照时间切片, 所以一个 DStream DAG 又对应了随着时间的推进所产生的无限个 RDD DAG

动态生成 RDD DAG 的过程

RDD DAG 的生成是按照时间来切片的, Streaming 会维护一个 Timer, 固定的时间到达后通过如下五个步骤生成一个 RDD DAG 后调度执行

  1. 通知 Receiver 将收到的数据暂存, 并汇报存储的元信息, 例如存在哪, 存了什么
  2. 通过 DStreamGraph 复制出一套新的 RDD DAG
  3. 将数据暂存的元信息和 RDD DAG 一同交由 JobScheduler 去调度执行
  4. 提交结束后, 对系统当前的状态 Checkpoint

数据的产生和导入

Receiver

Spark Streaming 中一个非常大的挑战是, 很多外部的队列和存储系统都是分块的, RDD 是分区的, 在读取外部数据源的时候, 会用不同的分区对照外部系统的分片, 例如

img

不仅 RDD, DStream 中也面临这种挑战

img

那么此处就有一个小问题

  • DStream 中是 RDD 流, 只是 RDD 的分区对应了 Kafka 的分区就可以了吗?

答案是不行, 因为需要一套单独的机制来保证并行的读取外部数据源, 这套机制叫做 Receiver

Receiver 的结构

img

为了保证并行获取数据, 对应每一个外部数据源的分区, 所以 Receiver 也要是分布式的, 主要分为三个部分

  • Receiver 是一个对象, 是可以有用户自定义的获取逻辑对象, 表示了如何获取数据
  • Receiver TrackerReceiver 的协调和调度者, 其运行在 Driver
  • Receiver SupervisorReceiver Tracker 调度到不同的几点上分布式运行, 其会拿到用户自定义的 Receiver 对象, 使用这个对象来获取外部数据

Receiver 的执行过程

img

  1. Spark Streaming 程序开启时候, Receiver Tracker 使用 JobScheduler 分发 Job 到不同的节点, 每个 Job 包含一个 Task , 这个 Task 就是 Receiver Supervisor, 这个部分的源码还挺精彩的, 其实是复用了通用的调度逻辑
  2. ReceiverSupervisor 启动后运行 Receiver 实例
  3. Receiver 启动后, 就将持续不断地接收外界数据, 并持续交给 ReceiverSupervisor 进行数据存储
  4. ReceiverSupervisor 持续不断地接收到 Receiver 转来的数据, 并通过 BlockManager 来存储数据
  5. 获取的数据存储完成后发送元数据给 Driver 端的 ReceiverTracker, 包含数据块的 id, 位置, 数量, 大小 等信息

容错

因为要非常长时间的运行, 对于任何一个流计算系统来说, 容错都是非常致命也非常重要的一环, 在 Spark Streaming 中, 大致提供了如下的容错手段

热备

还记得这行代码吗

img

这行代码中的 StorageLevel.MEMORY_AND_DISK_SER 的作用是什么? 其实就是热备份

  • 当 Receiver 获取到数据要存储的时候, 是交给 BlockManager 存储的
  • 如果设置了 StorageLevel.MEMORY_AND_DISK_SER, 则意味着 BlockManager 不仅会在本机存储, 也会发往其它的主机进行存储, 本质就是冗余备份
  • 如果某一个计算失败了, 通过冗余的备份, 再次进行计算即可

img

这是默认的容错手段

冷备

冷备在 Spark Streaming 中的手段叫做 WAL (预写日志)

  • Receiver 获取到数据后, 会交给 BlockManager 存储
  • 在存储之前先写到 WAL 中, WAL 中保存了 Redo Log, 其实就是记录了数据怎么产生的, 以便于恢复的时候通过 Log 恢复
  • 当出错的时候, 通过 Redo Log 去重放数据

重放

  • 有一些上游的外部系统是支持重放的, 比如说 Kafka
  • Kafka 可以根据 Offset 来获取数据
  • SparkStreaming 处理过程中出错了, 只需要通过 Kafka 再次读取即可

3. 操作

导读

这一小节主要目的是为了了解 Spark Streaming 一些特别特殊和重要的操作, 一些基本操作基本类似 RDD

updateStateByKey

需求: 统计整个流中, 所有出现的单词数量, 而不是一个批中的数量

状态

  • 统计总数

    入门案例中, 只能统计某个时间段内的单词数量, 因为 reduceByKey 只能作用于某一个 RDD, 不能作用于整个流

    如果想要求单词总数该怎么办?

  • 状态

    可以使用状态来记录中间结果, 从而每次来一批数据, 计算后和中间状态求和, 于是就完成了总数的统计

    img

实现

  • 使用 updateStateByKey 可以做到这件事
  • updateStateByKey 会将中间状态存入 CheckPoint
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
val sparkConf = new SparkConf().setAppName("NetworkWordCount").setMaster("local[6]")
val sc = new SparkContext(sparkConf)
sc.setLogLevel("ERROR")
val ssc = new StreamingContext(sc, Seconds(1))

val lines: DStream[String] = ssc.socketTextStream(
hostname = "localhost",
port = "9999".toInt,
storageLevel = StorageLevel.MEMORY_AND_DISK_SER)
val words = lines.flatMap(_.split(" ")).map(x => (x, 1))
// 使用 updateStateByKey 必须设置 Checkpoint 目录
ssc.checkpoint("checkpoint")
// updateStateByKey 的函数
def updateFunc(newValue: Seq[Int], runningValue: Option[Int]) = {
// newValue 之所以是一个 Seq, 是因为它是某一个 Batch 的某个 Key 的全部 Value
val currentBatchSum = newValue.sum
val state = runningValue.getOrElse(0)
// 返回的这个 Some(count) 会再次进入 Checkpoint 中当作状态存储
Some(currentBatchSum + state)
}
// 调用
val wordCounts = words.updateStateByKeyInt
wordCounts.print()
ssc.start()
ssc.awaitTermination()

window 操作

需求: 计算过 30s 的单词总数, 每 10s 更新一次

实现

  • 使用 window 即可实现按照窗口组织 RDD
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val sparkConf = new SparkConf().setAppName("NetworkWordCount").setMaster("local[6]")
val sc = new SparkContext(sparkConf)
sc.setLogLevel("ERROR")
val ssc = new StreamingContext(sc, Seconds(1))

val lines: DStream[String] = ssc.socketTextStream(
hostname = "localhost",
port = 9999,
storageLevel = StorageLevel.MEMORY_AND_DISK_SER)
val words = lines.flatMap(_.split(" ")).map(x => (x, 1))
// 通过 window 操作, 会将流分为多个窗口
val wordsWindow = words.window(Seconds(30), Seconds(10))
// 此时是针对于窗口求聚合
val wordCounts = wordsWindow.reduceByKey((newValue, runningValue) => newValue + runningValue)
wordCounts.print()
ssc.start()
ssc.awaitTermination()
  • 既然 window 操作经常配合 reduce 这种聚合, 所以 Spark Streaming 提供了较为方便的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
val sparkConf = new SparkConf().setAppName("NetworkWordCount").setMaster("local[6]")
val sc = new SparkContext(sparkConf)
sc.setLogLevel("ERROR")
val ssc = new StreamingContext(sc, Seconds(1))

val lines: DStream[String] = ssc.socketTextStream(
hostname = "localhost",
port = 9999,
storageLevel = StorageLevel.MEMORY_AND_DISK_SER)
val words = lines.flatMap(_.split(" ")).map(x => (x, 1))
// 开启窗口并自动进行 reduceByKey 的聚合
val wordCounts = words.reduceByKeyAndWindow(
reduceFunc = (n, r) => n + r,
windowDuration = Seconds(30),
slideDuration = Seconds(10))
wordCounts.print()
ssc.start()
ssc.awaitTermination()

窗口时间

img

  • window 函数中, 接收两个参数

    • windowDuration 窗口长度, window 函数会将多个 DStream 中的 RDD 按照时间合并为一个, 那么窗口长度配置的就是将多长时间内的 RDD 合并为一个
    • slideDuration 滑动间隔, 比较好理解的情况是直接按照某个时间来均匀的划分为多个 window, 但是往往需求可能是统计最近 xx分 内的所有数据, 一秒刷新一次, 那么就需要设置滑动窗口的时间间隔了, 每隔多久生成一个 window
  • 滑动时间的问题

    • 如果 windowDuration > slideDuration, 则在每一个不同的窗口中, 可能计算了重复的数据
    • 如果 windowDuration < slideDuration, 则在每一个不同的窗口之间, 有一些数据为能计算进去

    但是其实无论谁比谁大, 都不能算错, 例如, 我的需求有可能就是统计一小时内的数据, 一天刷新两次

Structured Streaming

全天目标

  1. 回顾和展望
  2. 入门案例
  3. Stuctured Streaming 的体系和结构

1. 回顾和展望

本章目标

Structured StreamingSpark Streaming 的进化版, 如果了解了 Spark 的各方面的进化过程, 有助于理解 Structured Streaming 的使命和作用

本章过程

  1. SparkAPI 进化过程
  2. Spark 的序列化进化过程
  3. Spark StreamingStructured Streaming

1.1. Spark 编程模型的进化过程

目标和过程

目标

Spark 的进化过程中, 一个非常重要的组成部分就是编程模型的进化, 通过编程模型可以看得出来内在的问题和解决方案

过程

  1. 编程模型 RDD 的优点和缺陷
  2. 编程模型 DataFrame 的优点和缺陷
  3. 编程模型 Dataset 的优点和缺陷

img

编程模型 解释
RDD rdd.flatMap(_.split(" ")) .map((_, 1)) .reduceByKey(_ + _) .collect针对自定义数据对象进行处理, 可以处理任意类型的对象, 比较符合面向对象RDD 无法感知到数据的结构, 无法针对数据结构进行编程
DataFrame spark.read .csv("...") .where($"name" =!= "") .groupBy($"name") .show()``DataFrame 保留有数据的元信息, API 针对数据的结构进行处理, 例如说可以根据数据的某一列进行排序或者分组DataFrame 在执行的时候会经过 Catalyst 进行优化, 并且序列化更加高效, 性能会更好DataFrame 只能处理结构化的数据, 无法处理非结构化的数据, 因为 DataFrame 的内部使用 Row 对象保存数据SparkDataFrame 设计了新的数据读写框架, 更加强大, 支持的数据源众多
Dataset spark.read .csv("...") .as[Person] .where(_.name != "") .groupByKey(_.name) .count() .show()``Dataset 结合了 RDDDataFrame 的特点, 从 API 上即可以处理结构化数据, 也可以处理非结构化数据DatasetDataFrame 其实是一个东西, 所以 DataFrame 的性能优势, 在 Dataset 上也有

总结

RDD 的优点

  1. 面向对象的操作方式
  2. 可以处理任何类型的数据

RDD 的缺点

  1. 运行速度比较慢, 执行过程没有优化
  2. API 比较僵硬, 对结构化数据的访问和操作没有优化

DataFrame 的优点

  1. 针对结构化数据高度优化, 可以通过列名访问和转换数据
  2. 增加 Catalyst 优化器, 执行过程是优化的, 避免了因为开发者的原因影响效率

DataFrame 的缺点

  1. 只能操作结构化数据
  2. 只有无类型的 API, 也就是只能针对列和 SQL 操作数据, API 依然僵硬

Dataset 的优点

  1. 结合了 RDDDataFrameAPI, 既可以操作结构化数据, 也可以操作非结构化数据
  2. 既有有类型的 API 也有无类型的 API, 灵活选择

1.2. Spark 的 序列化 的进化过程

目标和过程

目标

Spark 中的序列化过程决定了数据如何存储, 是性能优化一个非常重要的着眼点, Spark 的进化并不只是针对编程模型提供的 API, 在大数据处理中, 也必须要考虑性能

过程

  1. 序列化和反序列化是什么
  2. Spark 中什么地方用到序列化和反序列化
  3. RDD 的序列化和反序列化如何实现
  4. Dataset 的序列化和反序列化如何实现

Step 1: 什么是序列化和序列化

Java 中, 序列化的代码大概如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class JavaSerializable implements Serializable {
NonSerializable ns = new NonSerializable();
}

public class NonSerializable {
}
public static void main(String[] args) throws IOException {
// 序列化
JavaSerializable serializable = new JavaSerializable();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("/tmp/obj.ser"));
// 这里会抛出一个 "java.io.NotSerializableException: cn.itcast.NonSerializable" 异常
objectOutputStream.writeObject(serializable);
objectOutputStream.flush();
objectOutputStream.close();
// 反序列化
FileInputStream fileInputStream = new FileInputStream("/tmp/obj.ser");
ObjectInputStream objectOutputStream = new ObjectInputStream(fileInputStream);
JavaSerializable serializable1 = objectOutputStream.readObject();
}

序列化是什么

  • 序列化的作用就是可以将对象的内容变成二进制, 存入文件中保存
  • 反序列化指的是将保存下来的二进制对象数据恢复成对象

序列化对对象的要求

  • 对象必须实现 Serializable 接口
  • 对象中的所有属性必须都要可以被序列化, 如果出现无法被序列化的属性, 则序列化失败

限制

  • 对象被序列化后, 生成的二进制文件中, 包含了很多环境信息, 如对象头, 对象中的属性字段等, 所以内容相对较大
  • 因为数据量大, 所以序列化和反序列化的过程比较慢

序列化的应用场景

  • 持久化对象数据
  • 网络中不能传输 Java 对象, 只能将其序列化后传输二进制数据

Step 2: 在 Spark 中的序列化和反序列化的应用场景

  • Task 分发

    img

    Task 是一个对象, 想在网络中传输对象就必须要先序列化

  • RDD 缓存

    1
    2
    3
    4
    5
    6
    val rdd1 = rdd.flatMap(_.split(" "))
    .map((_, 1))
    .reduceByKey(_ + _)

    rdd1.cache
    rdd1.collect
  • RDD 中处理的是对象, 例如说字符串, Person 对象等
  • 如果缓存 RDD 中的数据, 就需要缓存这些对象
  • 对象是不能存在文件中的, 必须要将对象序列化后, 将二进制数据存入文件
  • 广播变量

    img

    • 广播变量会分发到不同的机器上, 这个过程中需要使用网络, 对象在网络中传输就必须先被序列化
  • Shuffle 过程

    img

    • Shuffle 过程是由 ReducerMapper 中拉取数据, 这里面涉及到两个需要序列化对象的原因
      • RDD 中的数据对象需要在 Mapper 端落盘缓存, 等待拉取
      • MapperReducer 要传输数据对象
  • Spark StreamingReceiver

    img

    • Spark Streaming 中获取数据的组件叫做 Receiver, 获取到的数据也是对象形式, 在获取到以后需要落盘暂存, 就需要对数据对象进行序列化
  • 算子引用外部对象

    1
    2
    3
    4
    5
    class Unserializable(i: Int)

    rdd.map(i => new Unserializable(i))
    .collect
    .foreach(println)
  • Map 算子的函数中, 传入了一个 Unserializable 的对象
  • Map 算子的函数是会在整个集群中运行的, 那 Unserializable 对象就需要跟随 Map 算子的函数被传输到不同的节点上
  • 如果 Unserializable 不能被序列化, 则会报错

Step 3: RDD 的序列化

img

RDD 的序列化

RDD 的序列化只能使用 Java 序列化器, 或者 Kryo 序列化器

为什么?

  • RDD 中存放的是数据对象, 要保留所有的数据就必须要对对象的元信息进行保存, 例如对象头之类的
  • 保存一整个对象, 内存占用和效率会比较低一些

Kryo 是什么

  • KryoSpark 引入的一个外部的序列化工具, 可以增快 RDD 的运行速度

  • 因为 Kryo 序列化后的对象更小, 序列化和反序列化的速度非常快

  • RDD 中使用 Kryo 的过程如下

    1
    2
    3
    4
    5
    6
    7
    8
    val conf = new SparkConf()
    .setMaster("local[2]")
    .setAppName("KyroTest")

    conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
    conf.registerKryoClasses(Array(classOf[Person]))
    val sc = new SparkContext(conf)
    rdd.map(arr => Person(arr(0), arr(1), arr(2)))

Step 4: DataFrameDataset 中的序列化

历史的问题

RDD 中无法感知数据的组成, 无法感知数据结构, 只能以对象的形式处理数据

DataFrameDataset 的特点

  • DataFrameDataset 是为结构化数据优化的

  • DataFrameDataset 中, 数据和数据的 Schema 是分开存储的

    1
    2
    3
    4
    5
    6
    spark.read
    .csv("...")
    .where($"name" =!= "")
    .groupBy($"name")
    .map(row: Row => row)
    .show()
  • DataFrame 中没有数据对象这个概念, 所有的数据都以行的形式存在于 Row 对象中, Row 中记录了每行数据的结构, 包括列名, 类型等

    img

  • Dataset 中上层可以提供有类型的 API, 用以操作数据, 但是在内部, 无论是什么类型的数据对象 Dataset 都使用一个叫做 InternalRow 的类型的对象存储数据

    1
    val dataset: Dataset[Person] = spark.read.csv(...).as[Person]

优化点 1: 元信息独立

  1. RDD 不保存数据的元信息, 所以只能使用 Java Serializer 或者 Kyro Serializer 保存 整个对象

  2. DataFrameDataset 中保存了数据的元信息, 所以可以把元信息独立出来分开保存

    img

  3. 一个 DataFrame 或者一个 Dataset 中, 元信息只需要保存一份, 序列化的时候, 元信息不需要参与

    img

  4. 在反序列化 ( InternalRow → Object ) 时加入 Schema 信息即可

    img

元信息不再参与序列化, 意味着数据存储量的减少, 和效率的增加

优化点 2: 使用堆外内存

  • DataFrameDataset 不再序列化元信息, 所以内存使用大大减少. 同时新的序列化方式还将数据存入堆外内存中, 从而避免 GC 的开销.
  • 堆外内存又叫做 Unsafe, 之所以叫不安全的, 因为不能使用 Java 的垃圾回收机制, 需要自己负责对象的创建和回收, 性能很好, 但是不建议普通开发者使用, 毕竟不安全

总结

  1. 当需要将对象缓存下来的时候, 或者在网络中传输的时候, 要把对象转成二进制, 在使用的时候再将二进制转为对象, 这个过程叫做序列化和反序列化
  2. Spark 中有很多场景需要存储对象, 或者在网络中传输对象
    1. Task 分发的时候, 需要将任务序列化, 分发到不同的 Executor 中执行
    2. 缓存 RDD 的时候, 需要保存 RDD 中的数据
    3. 广播变量的时候, 需要将变量序列化, 在集群中广播
    4. RDDShuffle 过程中 MapReducer 之间需要交换数据
    5. 算子中如果引入了外部的变量, 这个外部的变量也需要被序列化
  3. RDD 因为不保留数据的元信息, 所以必须要序列化整个对象, 常见的方式是 Java 的序列化器, 和 Kyro 序列化器
  4. DatasetDataFrame 中保留数据的元信息, 所以可以不再使用 Java 的序列化器和 Kyro 序列化器, 使用 Spark 特有的序列化协议, 生成 UnsafeInternalRow 用以保存数据, 这样不仅能减少数据量, 也能减少序列化和反序列化的开销, 其速度大概能达到 RDD 的序列化的 20 倍左右

1.3. Spark Streaming 和 Structured Streaming

目标和过程

目标

理解 Spark StreamingStructured Streaming 之间的区别, 是非常必要的, 从这点上可以理解 Structured Streaming 的过去和产生契机

过程

  1. Spark Streaming 时代
  2. Structured Streaming 时代
  3. Spark StreamingStructured Streaming

Spark Streaming 时代

img

  • Spark Streaming 其实就是 RDDAPI 的流式工具, 其本质还是 RDD, 存储和执行过程依然类似 RDD

Structured Streaming 时代

img

  • Structured Streaming 其实就是 DatasetAPI 的流式工具, APIDataset 保持高度一致
1
Spark Streaming` 和 `Structured Streaming
  • Structured Streaming 相比于 Spark Streaming 的进步就类似于 Dataset 相比于 RDD 的进步
  • 另外还有一点, Structured Streaming 已经支持了连续流模型, 也就是类似于 Flink 那样的实时流, 而不是小批量, 但在使用的时候仍然有限制, 大部分情况还是应该采用小批量模式

2.2.0 以后 Structured Streaming 被标注为稳定版本, 意味着以后的 Spark 流式开发不应该在采用 Spark Streaming

2. Structured Streaming 入门案例

目标

了解 Structured Streaming 的编程模型, 为理解 Structured Streaming 时候是什么, 以及核心体系原理打下基础

步骤

  1. 需求梳理
  2. Structured Streaming 代码实现
  3. 运行
  4. 验证结果

2.1. 需求梳理

目标和过程

目标

理解接下来要做的案例, 有的放矢

步骤

  1. 需求
  2. 整体结构
  3. 开发方式

需求

img

  • 编写一个流式计算的应用, 不断的接收外部系统的消息
  • 对消息中的单词进行词频统计
  • 统计全局的结果

整体结构

img

  1. Socket Server 等待 Structured Streaming 程序连接
  2. Structured Streaming 程序启动, 连接 Socket Server, 等待 Socket Server 发送数据
  3. Socket Server 发送数据, Structured Streaming 程序接收数据
  4. Structured Streaming 程序接收到数据后处理数据
  5. 数据处理后, 生成对应的结果集, 在控制台打印

开发方式和步骤

Socket server 使用 Netcat nc 来实现

Structured Streaming 程序使用 IDEA 实现, 在 IDEA 中本地运行

  1. 编写代码
  2. 启动 nc 发送 Socket 消息
  3. 运行代码接收 Socket 消息统计词频

总结

  • 简单来说, 就是要进行流式的词频统计, 使用 Structured Streaming

2.2. 代码实现

目标和过程

目标

实现 Structured Streaming 部分的代码编写

步骤

  1. 创建文件
  2. 创建 SparkSession
  3. 读取 Socket 数据生成 DataFrame
  4. DataFrame 转为 Dataset, 使用有类型的 API 处理词频统计
  5. 生成结果集, 并写入控制台

object SocketProcessor { def main(args: Array[String]): Unit = { // 1. 创建 SparkSession val spark = SparkSession.builder() .master(“local[6]”) .appName(“socket_processor”) .getOrCreate() spark.sparkContext.setLogLevel(“ERROR”) (1) import spark.implicits._ // 2. 读取外部数据源, 并转为 Dataset[String] val source = spark.readStream .format(“socket”) .option(“host”, “127.0.0.1”) .option(“port”, 9999) .load() .as[String] (2) // 3. 统计词频 val words = source.flatMap(.split(“ “)) .map((, 1)) .groupByKey(.1) .count() // 4. 输出结果 words.writeStream .outputMode(OutputMode.Complete()) (3) .format(“console”) (4) .start() (5) .awaitTermination() (6) } }

1 调整 Log 级别, 避免过多的 Log 影响视线
2 默认 readStream 会返回 DataFrame, 但是词频统计更适合使用 Dataset 的有类型 API
3 统计全局结果, 而不是一个批次
4 将结果输出到控制台
5 开始运行流式应用
6 阻塞主线程, 在子线程中不断获取数据

总结

  • Structured Streaming 中的编程步骤依然是先读, 后处理, 最后落地
  • Structured Streaming 中的编程模型依然是 DataFrameDataset
  • Structured Streaming 中依然是有外部数据源读写框架的, 叫做 readStreamwriteStream
  • Structured StreamingSparkSQL 几乎没有区别, 唯一的区别是, readStream 读出来的是流, writeStream 是将流输出, 而 SparkSQL 中的批处理使用 readwrite

2.3. 运行和结果验证

目标和过程

目标

代码已经编写完毕, 需要运行, 并查看结果集, 因为从结果集的样式中可以看到 Structured Streaming 的一些原理

步骤

  1. 开启 Socket server
  2. 运行程序
  3. 查看数据集

开启 Socket server 和运行程序

  1. 在虚拟机 node01 中运行 nc -lk 9999

  2. 在 IDEA 中运行程序

  3. node01 中输入以下内容

    1
    2
    3
    4
    5
    hello world
    hello spark
    hello hadoop
    hello spark
    hello spark

查看结果集

1
2
3
4
5
6
7
8
9
10
11
-------------------------------------------
Batch: 4
-------------------------------------------
+------+--------+
| value|count(1)|
+------+--------+
| hello| 5|
| spark| 3|
| world| 1|
|hadoop| 1|
+------+--------+

从结果集中可以观察到以下内容

  • Structured Streaming 依然是小批量的流处理
  • Structured Streaming 的输出是类似 DataFrame 的, 也具有 Schema, 所以也是针对结构化数据进行优化的
  • 从输出的时间特点上来看, 是一个批次先开始, 然后收集数据, 再进行展示, 这一点和 Spark Streaming 不太一样

总结

  1. 运行的时候需要先开启 Socket server
  2. Structured Streaming 的 API 和运行也是针对结构化数据进行优化过的

3. Stuctured Streaming 的体系和结构

目标

了解 Structured Streaming 的体系结构和核心原理, 有两点好处, 一是需要了解原理才好进行性能调优, 二是了解原理后, 才能理解代码执行流程, 从而更好的记忆, 也做到知其然更知其所以然

步骤

  1. WordCount 的执行原理
  2. Structured Streaming 的体系结构

3.1. 无限扩展的表格

目标和过程

目标

Structured Streaming 是一个复杂的体系, 由很多组件组成, 这些组件之间也会进行交互, 如果无法站在整体视角去观察这些组件之间的关系, 也无法理解 Structured Streaming 的全局

步骤

  1. 了解 Dataset 这个计算模型和流式计算的关系
  2. 如何使用 Dataset 处理流式数据?
  3. WordCount 案例的执行过程和原理

Dataset 和流式计算

可以理解为 Spark 中的 Dataset 有两种, 一种是处理静态批量数据的 Dataset, 一种是处理动态实时流的 Dataset, 这两种 Dataset 之间的区别如下

  • 流式的 Dataset 使用 readStream 读取外部数据源创建, 使用 writeStream 写入外部存储
  • 批式的 Dataset 使用 read 读取外部数据源创建, 使用 write 写入外部存储

如何使用 Dataset 这个编程模型表示流式计算?

img

  • 可以把流式的数据想象成一个不断增长, 无限无界的表
  • 无论是否有界, 全都使用 Dataset 这一套 API
  • 通过这样的做法, 就能完全保证流和批的处理使用完全相同的代码, 减少这两种处理方式的差异

WordCount 的原理

img

  • 整个计算过程大致上分为如下三个部分
    1. Source, 读取数据源
    2. Query, 在流式数据上的查询
    3. Result, 结果集生成
  • 整个的过程如下
    1. 随着时间段的流动, 对外部数据进行批次的划分
    2. 在逻辑上, 将缓存所有的数据, 生成一张无限扩展的表, 在这张表上进行查询
    3. 根据要生成的结果类型, 来选择是否生成基于整个数据集的结果

总结

img

  • Dataset 不仅可以表达流式数据的处理, 也可以表达批量数据的处理
  • Dataset 之所以可以表达流式数据的处理, 因为 Dataset 可以模拟一张无限扩展的表, 外部的数据会不断的流入到其中

3.2. 体系结构

目标和过程

目标

Structured Streaming 是一个复杂的体系, 由很多组件组成, 这些组件之间也会进行交互, 如果无法站在整体视角去观察这些组件之间的关系, 也无法理解 Structured Streaming 的核心原理

步骤

  1. 体系结构
  2. StreamExecution 的执行顺序

体系结构

  • Structured Streaming 中负责整体流程和执行的驱动引擎叫做 StreamExecution

    img

    StreamExecution 在流上进行基于 Dataset 的查询, 也就是说, Dataset 之所以能够在流上进行查询, 是因为 StreamExecution 的调度和管理

  • StreamExecution 如何工作?

    img

    StreamExecution 分为三个重要的部分

    • Source, 从外部数据源读取数据
    • LogicalPlan, 逻辑计划, 在流上的查询计划
    • Sink, 对接外部系统, 写入结果

StreamExecution 的执行顺序

img

  1. 根据进度标记, 从 Source 获取到一个由 DataFrame 表示的批次, 这个 DataFrame 表示数据的源头

    1
    2
    3
    4
    5
    6
    val source = spark.readStream
    .format("socket")
    .option("host", "127.0.0.1")
    .option("port", 9999)
    .load()
    .as[String]

    这一点非常类似 val df = spark.read.csv() 所生成的 DataFrame, 同样都是表示源头

  2. 根据源头 DataFrame 生成逻辑计划

    1
    2
    3
    4
    val words = source.flatMap(_.split(" "))
    .map((_, 1))
    .groupByKey(_._1)
    .count()

    上述代码表示的就是数据的查询, 这一个步骤将这样的查询步骤生成为逻辑执行计划

  3. 优化逻辑计划最终生成物理计划

    img

    这一步其实就是使用 Catalyst 对执行计划进行优化, 经历基于规则的优化和基于成本模型的优化

  4. 执行物理计划将表示执行结果的 DataFrame / Dataset 交给 Sink

    整个物理执行计划会针对每一个批次的数据进行处理, 处理后每一个批次都会生成一个表示结果的 Dataset

    Sink 可以将每一个批次的结果 Dataset 落地到外部数据源

  5. 执行完毕后, 汇报 Source 这个批次已经处理结束, Source 提交并记录最新的进度

增量查询

  • 核心问题

    img

    上图中清晰的展示了最终的结果生成是全局的结果, 而不是一个批次的结果, 但是从 StreamExecution 中可以看到, 针对流的处理是按照一个批次一个批次来处理的

    那么, 最终是如何生成全局的结果集呢?

  • 状态记录

    img

    Structured Streaming 中有一个全局范围的高可用 StateStore, 这个时候针对增量的查询变为如下步骤

    1. StateStore 中取出上次执行完成后的状态
    2. 把上次执行的结果加入本批次, 再进行计算, 得出全局结果
    3. 将当前批次的结果放入 StateStore 中, 留待下次使用

    img

总结

  • StreamExecution 是整个 Structured Streaming 的核心, 负责在流上的查询
  • StreamExecution 中三个重要的组成部分, 分别是 Source 负责读取每个批量的数据, Sink 负责将结果写入外部数据源, Logical Plan 负责针对每个小批量生成执行计划
  • StreamExecution 中使用 StateStore 来进行状态的维护

4. Source

目标和过程

目标

流式计算一般就是通过数据源读取数据, 经过一系列处理再落地到某个地方, 所以这一小节先了解一下如何读取数据, 可以整合哪些数据源

过程

  1. HDFS 中读取数据
  2. Kafka 中读取数据

4.1. 从 HDFS 中读取数据

目标和过程

目标

  • 在数据处理的时候, 经常会遇到这样的场景

    img

  • 有时候也会遇到这样的场景

    img

  • 以上两种场景有两个共同的特点

    • 会产生大量小文件在 HDFS
    • 数据需要处理
  • 通过本章节的学习, 便能够更深刻的理解这种结构, 具有使用 Structured Streaming 整合 HDFS, 从其中读取数据的能力

步骤

  1. 案例结构
  2. 产生小文件并推送到 HDFS
  3. 流式计算统计 HDFS 上的小文件
  4. 运行和总结

4.1.1. 案例结构

目标和步骤

目标

通过本章节可以了解案例的过程和步骤, 以及案例的核心意图

步骤

  1. 案例结构
  2. 实现步骤
  3. 难点和易错点

案例流程

img

  1. 编写 Python 小程序, 在某个目录生成大量小文件
    • Python 是解释型语言, 其程序可以直接使用命令运行无需编译, 所以适合编写快速使用的程序, 很多时候也使用 Python 代替 Shell
    • 使用 Python 程序创建新的文件, 并且固定的生成一段 JSON 文本写入文件
    • 在真实的环境中, 数据也是一样的不断产生并且被放入 HDFS 中, 但是在真实场景下, 可能是 Flume 把小文件不断上传到 HDFS 中, 也可能是 Sqoop 增量更新不断在某个目录中上传小文件
  2. 使用 Structured Streaming 汇总数据
    • HDFS 中的数据是不断的产生的, 所以也是流式的数据
    • 数据集是 JSON 格式, 要有解析 JSON 的能力
    • 因为数据是重复的, 要对全局的流数据进行汇总和去重, 其实真实场景下的数据清洗大部分情况下也是要去重的
  3. 使用控制台展示数据
    • 最终的数据结果以表的形式呈现
    • 使用控制台展示数据意味着不需要在修改展示数据的代码, 将 Sink 部分的内容放在下一个大章节去说明
    • 真实的工作中, 可能数据是要落地到 MySQL, HBase, HDFS 这样的存储系统中

实现步骤

  • Step 1: 编写 Python 脚本不断的产生数据
    1. 使用 Python 创建字符串保存文件中要保存的数据
    2. 创建文件并写入文件内容
    3. 使用 Python 调用系统 HDFS 命令上传文件
  • Step 2: 编写 Structured Streaming 程序处理数据
    1. 创建 SparkSession
    2. 使用 SparkSessionreadStream 读取数据源
    3. 使用 Dataset 操作数据, 只需要去重
    4. 使用 DatasetwriteStream 设置 Sink 将数据展示在控制台中
  • Step 3: 部署程序, 验证结果
    1. 上传脚本到服务器中, 使用 python 命令运行脚本
    2. 开启流计算应用, 读取 HDFS 中对应目录的数据
    3. 查看运行结果

难点和易错点

  1. 在读取 HDFS 的文件时, Source 不仅对接数据源, 也负责反序列化数据源中传过来的数据

    • Source 可以从不同的数据源中读取数据, 如 Kafka, HDFS
    • 数据源可能会传过来不同的数据格式, 如 JSON, Parquet
  2. 读取 HDFS 文件的这个 Source 叫做 FileStreamSource

    从命名就可以看出来这个 Source 不仅支持 HDFS, 还支持本地文件读取, 亚马逊云, 阿里云 等文件系统的读取, 例如: file://, s3://, oss://

  3. 基于流的 Dataset 操作和基于静态数据集的 Dataset 操作是一致的

总结

整个案例运行的逻辑是

  1. Python 程序产生数据到 HDFS
  2. Structured StreamingHDFS 中获取数据
  3. Structured Streaming 处理数据
  4. 将数据展示在控制台

整个案例的编写步骤

  1. Python 程序
  2. Structured Streaming 程序
  3. 运行

4.1.2. 产生小文件并推送到 HDFS

目标和步骤

目标

通过本章节看到 Python 的大致语法, 并了解 Python 如何编写脚本完成文件的操作, 其实不同的语言使用起来并没有那么难, 完成一些简单的任务还是很简单的

步骤

  1. 创建 Python 代码文件
  2. 编写代码
  3. 本地测试, 但是因为本地环境搭建比较浪费大家时间, 所以暂时不再本地测试

代码编写

  • 随便在任一目录中创建文件 gen_files.py, 编写以下内容

import os for index in range(100): content = “”” {“name”:”Michael”} {“name”:”Andy”, “age”:30} {“name”:”Justin”, “age”:19} “”” file_name = “/export/dataset/text{0}.json”.format(index) with open(file_name, “w”) as file: (1) file.write(content) os.system(“/export/servers/hadoop/bin/hdfs dfs -mkdir -p /dataset/dataset/“) os.system(“/export/servers/hadoop/bin/hdfs dfs -put {0} /dataset/dataset/“.format(file_name))

1 创建文件, 使用这样的写法是因为 with 是一种 Python 的特殊语法, 如果使用 with 去创建文件的话, 使用结束后会自动关闭流

总结

  • Python 的语法灵活而干净, 比较易于编写
  • 对于其它的语言可以玩乐性质的去使用, 其实并没有很难

4.1.3. 流式计算统计 HDFS 上的小文件

目标和步骤

目标

通过本章节的学习, 大家可以了解到如何使用 Structured Streaming 读取 HDFS 中的文件, 并以 JSON 的形式解析

步骤

  1. 创建文件
  2. 编写代码

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
val spark = SparkSession.builder()
.appName("hdfs_source")
.master("local[6]")
.getOrCreate()

spark.sparkContext.setLogLevel("WARN")
val userSchema = new StructType()
.add("name", "string")
.add("age", "integer")
val source = spark
.readStream
.schema(userSchema)
.json("hdfs://node01:8020/dataset/dataset")
val result = source.distinct()
result.writeStream
.outputMode(OutputMode.Update())
.format("console")
.start()
.awaitTermination()

总结

  • 以流的形式读取某个 HDFS 目录的代码为

    1
    2
    3
    4
    val source = spark
    .readStream (1)
    .schema(userSchema) (2)
    .json("hdfs://node01:8020/dataset/dataset") (3)
    1 指明读取的是一个流式的 Dataset
    2 指定读取到的数据的 Schema
    3 指定目录位置, 以及数据格式

4.1.4. 运行和流程总结

目标和步骤

目标

通过这个小节对案例的部署以后, 不仅大家可以学到一种常见的部署方式, 同时也能对案例的执行流程和流计算有更深入的了解

步骤

  1. 运行 Python 程序
  2. 运行 Spark 程序
  3. 总结

运行 Python 程序

  1. 上传 Python 源码文件到服务器中

  2. 运行 Python 脚本

    1
    2
    3
    4
    5
    6
    7
    8
    # 进入 Python 文件被上传的位置
    cd ~

    # 创建放置生成文件的目录
    mkdir -p /export/dataset

    # 运行程序
    python gen_files.py

运行 Spark 程序

  1. 使用 Maven 打包

    img

  2. 上传至服务器

  3. 运行 Spark 程序

    1
    2
    3
    4
    5
    # 进入保存 Jar 包的文件夹
    cd ~

    运行流程序
    spark-submit --class cn.itcast.structured.HDFSSource ./original-streaming-0.0.1.jar

总结

img

  1. Python 生成文件到 HDFS, 这一步在真实环境下, 可能是由 FlumeSqoop 收集并上传至 HDFS
  2. Structured StreamingHDFS 中读取数据并处理
  3. Structured Streaming 讲结果表展示在控制台

4.2. 从 Kafka 中读取数据

目标和步骤

目标

通过本章节的学习, 便可以理解流式系统和队列间的关系, 同时能够编写代码从 Kafka 以流的方式读取数据

步骤

  1. Kafka 回顾
  2. Structured Streaming 整合 Kafka
  3. 读取 JSON 格式的内容
  4. 读取多个 Topic 的数据

4.2.1 Kafka 的场景和结构

目标和步骤

目标

通过这一个小节的学习, 大家可以理解 Kfaka 在整个系统中的作用, 日后工作的话, 也必须要先站在更高层去理解系统的组成, 才能完成功能和代码

步骤

  1. Kafka 的应用场景
  2. Kafka 的特点
  3. TopicPartitions

Kafka 是一个 Pub / Sub 系统

  • Pub / SubPublisher / Subscriber 的简写, 中文称作为发布订阅系统

    img

  • 发布订阅系统可以有多个 Publisher 对应一个 Subscriber, 例如多个系统都会产生日志, 通过这样的方式, 一个日志处理器可以简单的获取所有系统产生的日志

    img

  • 发布订阅系统也可以一个 Publisher 对应多个 Subscriber, 这样就类似于广播了, 例如通过这样的方式可以非常轻易的将一个订单的请求分发给所有感兴趣的系统, 减少耦合性

    img

  • 当然, 在大数据系统中, 这样的消息系统往往可以作为整个数据平台的入口, 左边对接业务系统各个模块, 右边对接数据系统各个计算工具

    img

Kafka 的特点

Kafka 有一个非常重要的应用场景就是对接业务系统和数据系统, 作为一个数据管道, 其需要流通的数据量惊人, 所以 Kafka 如果要满足这种场景的话, 就一定具有以下两个特点

  • 高吞吐量
  • 高可靠性

Topic 和 Partitions

  • 消息和事件经常是不同类型的, 例如用户注册是一种消息, 订单创建也是一种消息

    img

  • Kafka 中使用 Topic 来组织不同类型的消息

    img

  • Kafka 中的 Topic 要承受非常大的吞吐量, 所以 Topic 应该是可以分片的, 应该是分布式的

    img

总结

  • Kafka 的应用场景
    • 一般的系统中, 业务系统会不止一个, 数据系统也会比较复杂
    • 为了减少业务系统和数据系统之间的耦合, 要将其分开, 使用一个中间件来流转数据
    • Kafka 因为其吞吐量超高, 所以适用于这种场景
  • Kafka 如何保证高吞吐量
    • 因为消息会有很多种类, Kafka 中可以创建多个队列, 每一个队列就是一个 Topic, 可以理解为是一个主题, 存放相关的消息
    • 因为 Topic 直接存放消息, 所以 Topic 必须要能够承受非常大的通量, 所以 Topic 是分布式的, 是可以分片的, 使用分布式的并行处理能力来解决高通量的问题

4.2.2. Kafka 和 Structured Streaming 整合的结构

目标和步骤

目标

通过本小节可以理解 KafkaStructured Streaming 整合的结构原理, 同时还能理解 Spark 连接 Kafka 的时候一个非常重要的参数

步骤

  1. TopicOffset
  2. KafkaStructured Streaming 的整合结构
  3. Structured Streaming 读取 Kafka 消息的三种方式

Topic 的 Offset

  • Topic 是分区的, 每一个 Topic 的分区分布在不同的 Broker

    img

  • 每个分区都对应一系列的 Log 文件, 消息存在于 Log 中, 消息的 ID 就是这条消息在本分区的 Offset 偏移量

    img

Offset 又称作为偏移量, 其实就是一个东西距离另外一个东西的距离imgKafka 中使用 Offset 命名消息, 而不是指定 ID 的原因是想表示永远自增, ID 是可以指定的, 但是 Offset 只能是一个距离值, 它只会越来越大, 所以, 叫做 Offset 而不叫 ID 也是这个考虑, 消息只能追加到 Log 末尾, 只能增长不能减少

Kafka 和 Structured Streaming 整合的结构

img

分析

  • Structured Streaming 中使用 Source 对接外部系统, 对接 KafkaSource 叫做 KafkaSource
  • KafkaSource 中会使用 KafkaSourceRDD 来映射外部 KafkaTopic, 两者的 Partition 一一对应

结论

Structured Streaming 会并行的从 Kafka 中获取数据

Structured Streaming 读取 Kafka 消息的三种方式

img

  • Earliest 从每个 Kafka 分区最开始处开始获取
  • Assign 手动指定每个 Kafka 分区中的 Offset
  • Latest 不再处理之前的消息, 只获取流计算启动后新产生的数据

总结

  • Kafka 中的消息存放在某个 Topic 的某个 Partition 中, 消息是不可变的, 只会在消息过期的时候从最早的消息开始删除, 消息的 ID 也叫做 Offset, 并且只能正增长
  • Structured Streaming 整合 Kafka 的时候, 会并行的通过 Offset 从所有 TopicPartition 中获取数据
  • Structured Streaming 在从 Kafka 读取数据的时候, 可以选择从最早的地方开始读取, 也可以选择从任意位置读取, 也可以选择只读取最新的

4.2.3. 需求介绍

目标和步骤

目标

通过本章节的学习, 可以掌握一个常见的需求, 并且了解后面案例的编写步骤

步骤

  1. 需求
  2. 数据

需求

  1. 模拟一个智能物联网系统的数据统计

    img

    • 有一个智能家居品牌叫做 Nest, 他们主要有两款产品, 一个是恒温器, 一个是摄像头
    • 恒温器的主要作用是通过感应器识别家里什么时候有人, 摄像头主要作用是通过学习算法来识别出现在摄像头中的人是否是家里人, 如果不是则报警
    • 所以这两个设备都需要统计一个指标, 就是家里什么时候有人, 此需求就是针对这个设备的一部分数据, 来统计家里什么时候有人
  2. 使用生产者在 Kafka 的 Topic : streaming-test 中输入 JSON 数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    {
    "devices": {
    "cameras": {
    "device_id": "awJo6rH",
    "last_event": {
    "has_sound": true,
    "has_motion": true,
    "has_person": true,
    "start_time": "2016-12-29T00:00:00.000Z",
    "end_time": "2016-12-29T18:42:00.000Z"
    }
    }
    }
    }
  3. 使用 Structured Streaming 来过滤出来家里有人的数据

    把数据转换为 时间 → 是否有人 这样类似的形式

数据转换

  1. 追踪 JSON 数据的格式

    可以在一个在线的工具 https://jsonformatter.org/ 中格式化 JSON, 会发现 JSON 格式如下

    img

  2. 反序列化

    JSON 数据本质上就是字符串, 只不过这个字符串是有结构的, 虽然有结构, 但是很难直接从字符串中取出某个值

    而反序列化, 就是指把 JSON 数据转为对象, 或者转为 DataFrame, 可以直接使用某一个列或者某一个字段获取数据, 更加方便

    而想要做到这件事, 必须要先根据数据格式, 编写 Schema 对象, 从而通过一些方式转为 DataFrame

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    val eventType = new StructType()
    .add("has_sound", BooleanType, nullable = true)
    .add("has_motion", BooleanType, nullable = true)
    .add("has_person", BooleanType, nullable = true)
    .add("start_time", DateType, nullable = true)
    .add("end_time", DateType, nullable = true)

    val camerasType = new StructType()
    .add("device_id", StringType, nullable = true)
    .add("last_event", eventType, nullable = true)
    val devicesType = new StructType()
    .add("cameras", camerasType, nullable = true)
    val schema = new StructType()
    .add("devices", devicesType, nullable = true)

总结

  1. 业务简单来说, 就是收集智能家居设备的数据, 通过流计算的方式计算其特征规律
  2. Kafka 常见的业务场景就是对接业务系统和数据系统
    1. 业务系统经常会使用 JSON 作为数据传输格式
    2. 所以使用 Structured Streaming 来对接 Kafka 并反序列化 Kafka 中的 JSON 格式的消息, 是一个非常重要的技能
  3. 无论使用什么方式, 如果想反序列化 JSON 数据, 就必须要先追踪 JSON 数据的结构

4.2.4. 使用 Spark 流计算连接 Kafka 数据源

目标和步骤

目标

通过本章节的数据, 能够掌握如何使用 Structured Streaming 对接 Kafka, 从其中获取数据

步骤

  1. 创建 Topic 并输入数据到 Topic
  2. Spark 整合 kafka
  3. 读取到的 DataFrame 的数据结构

创建 Topic 并输入数据到 Topic

  1. 使用命令创建 Topic

    1
    bin/kafka-topics.sh --create --topic streaming-test --replication-factor 1 --partitions 3 --zookeeper node01:2181
  2. 开启 Producer

    1
    bin/kafka-console-producer.sh --broker-list node01:9092,node02:9092,node03:9092 --topic streaming-test
  3. JSON 转为单行输入

    1
    {"devices":{"cameras":{"device_id":"awJo6rH","last_event":{"has_sound":true,"has_motion":true,"has_person":true,"start_time":"2016-12-29T00:00:00.000Z","end_time":"2016-12-29T18:42:00.000Z"}}}}

使用 Spark 读取 Kafka 的 Topic

  1. 编写 Spark 代码读取 Kafka Topic

    1
    2
    3
    4
    5
    6
    val source = spark.readStream
    .format("kafka")
    .option("kafka.bootstrap.servers", "node01:9092,node01:9092,node03:9092")
    .option("subscribe", "streaming_test")
    .option("startingOffsets", "earliest")
    .load()
    • 三个参数
      • kafka.bootstrap.servers : 指定 KafkaServer 地址
      • subscribe : 要监听的 Topic, 可以传入多个, 传入多个 Topic 则监听多个 Topic, 也可以使用 topic-* 这样的通配符写法
      • startingOffsets : 从什么位置开始获取数据, 可选值有 earliest, assign, latest
    • format 设置为 Kafka 指定使用 KafkaSource 读取数据
  2. 思考: 从 Kafka 中应该获取到什么?

    • 业务系统有很多种类型, 有可能是 Web 程序, 有可能是物联网

      img

      前端大多数情况下使用 JSON 做数据交互

    • 问题1: 业务系统如何把数据给 Kafka ?

      img

      可以主动或者被动的把数据交给 Kafka, 但是无论使用什么方式, 都在使用 KafkaClient 类库来完成这件事, Kafka 的类库调用方式如下

      1
      2
      Producer<String, String> producer = new KafkaProducer<String, String>(properties);
      producer.send(new ProducerRecord<String, String>("HelloWorld", msg));

      其中发给 Kafka 的消息是 KV 类型的

    • 问题2: 使用 Structured Streaming 访问 Kafka 获取数据的时候, 需要什么东西呢?

      • 需求1: 存储当前处理过的 KafkaOffset
      • 需求2: 对接多个 Kafka Topic 的时候, 要知道这条数据属于哪个 Topic
    • 结论

      • Kafka 中收到的消息是 KV 类型的, 有 Key, 有 Value
      • Structured Streaming 对接 Kafka 的时候, 每一条 Kafka 消息不能只是 KV, 必须要有 Topic, Partition 之类的信息
  3. Kafka 获取的 DataFrame 格式

    1
    source.printSchema()

    结果如下

    1
    2
    3
    4
    5
    6
    7
    8
    root
    |-- key: binary (nullable = true)
    |-- value: binary (nullable = true)
    |-- topic: string (nullable = true)
    |-- partition: integer (nullable = true)
    |-- offset: long (nullable = true)
    |-- timestamp: timestamp (nullable = true)
    |-- timestampType: integer (nullable = true)

    Kafka 中读取到的并不是直接是数据, 而是一个包含各种信息的表格, 其中每个字段的含义如下

    Key 类型 解释
    key binary Kafka 消息的 Key
    value binary Kafka 消息的 Value
    topic string 本条消息所在的 Topic, 因为整合的时候一个 Dataset 可以对接多个 Topic, 所以有这样一个信息
    partition integer 消息的分区号
    offset long 消息在其分区的偏移量
    timestamp timestamp 消息进入 Kafka 的时间戳
    timestampType integer 时间戳类型

总结

  1. 一定要把 JSON 转为一行, 再使用 Producer 发送, 不然会出现获取多行的情况

  2. 使用 Structured Streaming 连接 Kafka 的时候, 需要配置如下三个参数

    • kafka.bootstrap.servers : 指定 KafkaServer 地址
    • subscribe : 要监听的 Topic, 可以传入多个, 传入多个 Topic 则监听多个 Topic, 也可以使用 topic-* 这样的通配符写法
    • startingOffsets : 从什么位置开始获取数据, 可选值有 earliest, assign, latest
  3. 从 Kafka 获取到的 DataFrame 的 Schema 如下

    1
    2
    3
    4
    5
    6
    7
    8
    root
    |-- key: binary (nullable = true)
    |-- value: binary (nullable = true)
    |-- topic: string (nullable = true)
    |-- partition: integer (nullable = true)
    |-- offset: long (nullable = true)
    |-- timestamp: timestamp (nullable = true)
    |-- timestampType: integer (nullable = true)

4.2.5. JSON 解析和数据统计

目标和步骤

目标

通过本章的学习, 便能够解析 Kafka 中的 JSON 数据, 这是一个重点中的重点

步骤

  1. JSON 解析
  2. 数据处理
  3. 运行测试

JSON 解析

  1. 准备好 JSON 所在的列

    问题

    Dataset 的结构可以知道 keyvalue 列的类型都是 binary 二进制, 所以要将其转为字符串, 才可进行 JSON 解析

    解决方式

    1
    source.selectExpr("CAST(key AS STRING) as key", "CAST(value AS STRING) as value")
  2. 编写 Schema 对照 JSON 的格式

    • Key 要对应 JSON 中的 Key
    • Value 的类型也要对应 JSON 中的 Value 类型
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    val eventType = new StructType()
    .add("has_sound", BooleanType, nullable = true)
    .add("has_motion", BooleanType, nullable = true)
    .add("has_person", BooleanType, nullable = true)
    .add("start_time", DateType, nullable = true)
    .add("end_time", DateType, nullable = true)

    val camerasType = new StructType()
    .add("device_id", StringType, nullable = true)
    .add("last_event", eventType, nullable = true)
    val devicesType = new StructType()
    .add("cameras", camerasType, nullable = true)
    val schema = new StructType()
    .add("devices", devicesType, nullable = true)
  1. 因为 JSON 中包含 Date 类型的数据, 所以要指定时间格式化方式

    1
    val jsonOptions = Map("timestampFormat" -> "yyyy-MM-dd'T'HH:mm:ss.sss'Z'")
  2. 使用 from_json 这个 UDF 格式化 JSON

    1
    .select(from_json('value, schema, jsonOptions).alias("parsed_value"))
  3. 选择格式化过后的 JSON 中的字段

    因为 JSON 被格式化过后, 已经变为了 StructType, 所以可以直接获取其中某些字段的值

    1
    2
    .selectExpr("parsed_value.devices.cameras.last_event.has_person as has_person",
    "parsed_value.devices.cameras.last_event.start_time as start_time")

数据处理

  1. 统计各个时段有人的数据

    1
    2
    3
    .filter('has_person === true)
    .groupBy('has_person, 'start_time)
    .count()
  2. 将数据落地到控制台

    1
    2
    3
    4
    5
    result.writeStream
    .outputMode(OutputMode.Complete())
    .format("console")
    .start()
    .awaitTermination()

全部代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import org.apache.spark.sql.SparkSession

val spark = SparkSession.builder()
.master("local[6]")
.appName("kafka integration")
.getOrCreate()
import org.apache.spark.sql.streaming.OutputMode
import org.apache.spark.sql.types._
val source = spark
.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "node01:9092,node02:9092,node03:9092")
.option("subscribe", "streaming-test")
.option("startingOffsets", "earliest")
.load()
val eventType = new StructType()
.add("has_sound", BooleanType, nullable = true)
.add("has_motion", BooleanType, nullable = true)
.add("has_person", BooleanType, nullable = true)
.add("start_time", DateType, nullable = true)
.add("end_time", DateType, nullable = true)
val camerasType = new StructType()
.add("device_id", StringType, nullable = true)
.add("last_event", eventType, nullable = true)
val devicesType = new StructType()
.add("cameras", camerasType, nullable = true)
val schema = new StructType()
.add("devices", devicesType, nullable = true)
val jsonOptions = Map("timestampFormat" -> "yyyy-MM-dd'T'HH:mm:ss.sss'Z'")
import org.apache.spark.sql.functions._
import spark.implicits._
val result = source.selectExpr("CAST(key AS STRING) as key", "CAST(value AS STRING) as value")
.select(from_json('value, schema, jsonOptions).alias("parsed_value"))
.selectExpr("parsed_value.devices.cameras.last_event.has_person as has_person",
"parsed_value.devices.cameras.last_event.start_time as start_time")
.filter('has_person === true)
.groupBy('has_person, 'start_time)
.count()
result.writeStream
.outputMode(OutputMode.Complete())
.format("console")
.start()
.awaitTermination()

运行测试

  1. 进入服务器中, 启动 Kafka

  2. 启动 KafkaProducer

    1
    bin/kafka-console-producer.sh --broker-list node01:9092,node02:9092,node03:9092 --topic streaming-test
  3. 启动 Spark shell 并拷贝代码进行测试

    1
    ./bin/spark-shell --packages org.apache.spark:spark-sql-kafka-0-10_2.11:2.2.0
    • 因为需要和 Kafka 整合, 所以在启动的时候需要加载和 Kafka 整合的包 spark-sql-kafka-0-10

5. Sink

目标和步骤

目标

  • 能够串联两端, 理解整个流式应用, 以及其中的一些根本的原理, 比如说容错语义
  • 能够知道如何对接外部系统, 写入数据

步骤

  1. HDFS Sink
  2. Kafka Sink
  3. Foreach Sink
  4. 自定义 Sink
  5. Tiggers
  6. Sink 原理
  7. 错误恢复和容错语义

5.1. HDFS Sink

目标和步骤

目标

能够使用 Spark 将流式数据的处理结果放入 HDFS

步骤

  1. 场景和需求
  2. 代码实现

场景和需求

场景

  • Kafka 往往作为数据系统和业务系统之间的桥梁
  • 数据系统一般由批量处理和流式处理两个部分组成
  • Kafka 作为整个数据平台入口的场景下, 需要使用 StructuredStreaming 接收 Kafka 的数据并放置于 HDFS 上, 后续才可以进行批量处理

img

案例需求

  • Kafka 接收数据, 从给定的数据集中, 裁剪部分列, 落地于 HDFS

代码实现

步骤说明

  1. Kafka 读取数据, 生成源数据集
    1. 连接 Kafka 生成 DataFrame
    2. DataFrame 中取出表示 Kafka 消息内容的 value 列并转为 String 类型
  2. 对源数据集选择列
    1. 解析 CSV 格式的数据
    2. 生成正确类型的结果集
  3. 落地 HDFS

整体代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import org.apache.spark.sql.SparkSession

val spark = SparkSession.builder()
.master("local[6]")
.appName("kafka integration")
.getOrCreate()
import spark.implicits._
val source = spark
.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "node01:9092,node02:9092,node03:9092")
.option("subscribe", "streaming-bank")
.option("startingOffsets", "earliest")
.load()
.selectExpr("CAST(value AS STRING)")
.as[String]
val result = source.map {
item =>
val arr = item.replace(""", "").split(";")
(arr(0).toInt, arr(1).toInt, arr(5).toInt)
}
.as[(Int, Int, Int)]
.toDF("age", "job", "balance")
result.writeStream
.format("parquet") // 也可以是 "orc", "json", "csv" 等
.option("path", "/dataset/streaming/result/")
.start()

5.2. Kafka Sink

目标和步骤

目标

掌握什么时候要将流式数据落地至 Kafka, 以及如何落地至 Kafka

步骤

  1. 场景
  2. 代码

场景

场景

  • 有很多时候, ETL 过后的数据, 需要再次放入 Kafka
  • Kafka 后, 可能会有流式程序统一将数据落地到 HDFS 或者 HBase

img

案例需求

  • Kafka 中获取数据, 简单处理, 再次放入 Kafka

代码

步骤

  1. Kafka 读取数据, 生成源数据集
    1. 连接 Kafka 生成 DataFrame
    2. DataFrame 中取出表示 Kafka 消息内容的 value 列并转为 String 类型
  2. 对源数据集选择列
    1. 解析 CSV 格式的数据
    2. 生成正确类型的结果集
  3. 再次落地 Kafka

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import org.apache.spark.sql.SparkSession

val spark = SparkSession.builder()
.master("local[6]")
.appName("kafka integration")
.getOrCreate()
import spark.implicits._
val source = spark
.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "node01:9092,node02:9092,node03:9092")
.option("subscribe", "streaming-bank")
.option("startingOffsets", "earliest")
.load()
.selectExpr("CAST(value AS STRING)")
.as[String]
val result = source.map {
item =>
val arr = item.replace(""", "").split(";")
(arr(0).toInt, arr(1).toInt, arr(5).toInt)
}
.as[(Int, Int, Int)]
.toDF("age", "job", "balance")
result.writeStream
.format("kafka")
.outputMode(OutputMode.Append())
.option("kafka.bootstrap.servers", "node01:9092,node02:9092,node03:9092")
.option("topic", "streaming-bank-result")
.start()
.awaitTermination()

5.3. Foreach Writer

目标和步骤

目标

掌握 Foreach 模式理解如何扩展 Structured StreamingSink, 同时能够将数据落地到 MySQL

步骤

  1. 需求
  2. 代码

需求

  • 场景

    • 大数据有一个常见的应用场景
      1. 收集业务系统数据
      2. 数据处理
      3. 放入 OLTP 数据
      4. 外部通过 ECharts 获取并处理数据
    • 这个场景下, StructuredStreaming 就需要处理数据并放入 MySQL 或者 MongoDB, HBase 中以供 Web 程序可以获取数据, 图表的形式展示在前端

    img

  • Foreach 模式::

    • 起因

      • Structured Streaming 中, 并未提供完整的 MySQL/JDBC 整合工具
      • 不止 MySQLJDBC, 可能会有其它的目标端需要写入
      • 很多时候 Structured Streaming 需要对接一些第三方的系统, 例如阿里云的云存储, 亚马逊云的云存储等, 但是 Spark 无法对所有第三方都提供支持, 有时候需要自己编写
    • 解决方案

      img

      • 既然无法满足所有的整合需求, StructuredStreaming 提供了 Foreach, 可以拿到每一个批次的数据
      • 通过 Foreach 拿到数据后, 可以通过自定义写入方式, 从而将数据落地到其它的系统
  • 案例需求::

    img

    • Kafka 中获取数据, 处理后放入 MySQL

代码

步骤

  1. 创建 DataFrame 表示 Kafka 数据源
  2. 在源 DataFrame 中选择三列数据
  3. 创建 ForeachWriter 接收每一个批次的数据落地 MySQL
  4. Foreach 落地数据

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import org.apache.spark.sql.SparkSession

val spark = SparkSession.builder()
.master("local[6]")
.appName("kafka integration")
.getOrCreate()
import spark.implicits._
val source = spark
.readStream
.format("kafka")
.option("kafka.bootstrap.servers", "node01:9092,node02:9092,node03:9092")
.option("subscribe", "streaming-bank")
.option("startingOffsets", "earliest")
.load()
.selectExpr("CAST(value AS STRING)")
.as[String]
val result = source.map {
item =>
val arr = item.replace(""", "").split(";")
(arr(0).toInt, arr(1).toInt, arr(5).toInt)
}
.as[(Int, Int, Int)]
.toDF("age", "job", "balance")
class MySQLWriter extends ForeachWriter[Row] {
val driver = "com.mysql.jdbc.Driver"
var statement: Statement = _
var connection: Connection = _
val url: String = "jdbc:mysql://node01:3306/streaming-bank-result"
val user: String = "root"
val pwd: String = "root"
override def open(partitionId: Long, version: Long): Boolean = {
Class.forName(driver)
connection = DriverManager.getConnection(url, user, pwd)
this.statement = connection.createStatement
true
}
override def process(value: Row): Unit = {
statement.executeUpdate(s"insert into bank values(" +
s"${value.getAsInt}, " +
s"${value.getAsInt}, " +
s"${value.getAsInt} )")
}
override def close(errorOrNull: Throwable): Unit = {
connection.close()
}
}
result.writeStream
.foreach(new MySQLWriter)
.start()
.awaitTermination()

5.4. 自定义 Sink

目标和步骤

目标

  • Foreach 倾向于一次处理一条数据, 如果想拿到 DataFrame 幂等的插入外部数据源, 则需要自定义 Sink
  • 了解如何自定义 Sink

步骤

  1. Spark 加载 Sink 流程分析
  2. 自定义 Sink

Spark 加载 Sink 流程分析

  • Sink 加载流程

    1. writeStream 方法中会创建一个 DataStreamWriter 对象

      1
      2
      3
      4
      5
      6
      7
      def writeStream: DataStreamWriter[T] = {
      if (!isStreaming) {
      logicalPlan.failAnalysis(
      "'writeStream' can be called only on streaming Dataset/DataFrame")
      }
      new DataStreamWriter[T](this)
      }
    2. DataStreamWriter 对象上通过 format 方法指定 Sink 的短名并记录下来

      1
      2
      3
      4
      def format(source: String): DataStreamWriter[T] = {
      this.source = source
      this
      }
    3. 最终会通过 DataStreamWriter 对象上的 start 方法启动执行, 其中会通过短名创建 DataSource

      1
      2
      3
      4
      5
      6
      val dataSource =
      DataSource(
      df.sparkSession,
      className = source, (1)
      options = extraOptions.toMap,
      partitionColumns = normalizedParCols.getOrElse(Nil))
      1 传入的 Sink 短名
    4. 在创建 DataSource 的时候, 会通过一个复杂的流程创建出对应的 SourceSink

      1
      lazy val providingClass: Class[_] = DataSource.lookupDataSource(className)
    5. 在这个复杂的创建流程中, 有一行最关键的代码, 就是通过 Java 的类加载器加载所有的 DataSourceRegister

      1
      val serviceLoader = ServiceLoader.load(classOf[DataSourceRegister], loader)
    6. DataSourceRegister 中会创建对应的 Source 或者 Sink

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      trait DataSourceRegister {

      def shortName(): String (1)
      }
      trait StreamSourceProvider {
      def createSource( (2)
      sqlContext: SQLContext,
      metadataPath: String,
      schema: Option[StructType],
      providerName: String,
      parameters: Map[String, String]): Source
      }
      trait StreamSinkProvider {
      def createSink( (3)
      sqlContext: SQLContext,
      parameters: Map[String, String],
      partitionColumns: Seq[String],
      outputMode: OutputMode): Sink
      }
| **1** | 提供短名      |
| ----- | ------------- |
| **2** | 创建 `Source` |
| **3** | 创建 `Sink`   |
  • 自定义 Sink 的方式

    • 根据前面的流程说明, 有两点非常重要
      • Spark 会自动加载所有 DataSourceRegister 的子类, 所以需要通过 DataSourceRegister 加载 SourceSink
      • Spark 提供了 StreamSinkProvider 用以创建 Sink, 提供必要的依赖
    • 所以如果要创建自定义的 Sink, 需要做两件事
      1. 创建一个注册器, 继承 DataSourceRegister 提供注册功能, 继承 StreamSinkProvider 获取创建 Sink 的必备依赖
      2. 创建一个 Sink 子类

自定义 Sink

步骤

  1. 读取 Kafka 数据
  2. 简单处理数据
  3. 创建 Sink
  4. 创建 Sink 注册器
  5. 使用自定义 Sink

代码

import org.apache.spark.sql.SparkSession val spark = SparkSession.builder() .master("local[6]") .appName("kafka integration") .getOrCreate() import spark.implicits._ val source = spark .readStream .format("kafka") .option("kafka.bootstrap.servers", "node01:9092,node02:9092,node03:9092") .option("subscribe", "streaming-bank") .option("startingOffsets", "earliest") .load() .selectExpr("CAST(value AS STRING)") .as[String] val result = source.map { item => val arr = item.replace(""", "").split(";") (arr(0).toInt, arr(1).toInt, arr(5).toInt) } .as[(Int, Int, Int)] .toDF("age", "job", "balance") class MySQLSink(options: Map[String, String], outputMode: OutputMode) extends Sink { override def addBatch(batchId: Long, data: DataFrame): Unit = { val userName = options.get("userName").orNull val password = options.get("password").orNull val table = options.get("table").orNull val jdbcUrl = options.get("jdbcUrl").orNull val properties = new Properties properties.setProperty("user", userName) properties.setProperty("password", password) data.write.mode(outputMode.toString).jdbc(jdbcUrl, table, properties) } } class MySQLStreamSinkProvider extends StreamSinkProvider with DataSourceRegister { override def createSink(sqlContext: SQLContext, parameters: Map[String, String], partitionColumns: Seq[String], outputMode: OutputMode): Sink = { new MySQLSink(parameters, outputMode) } override def shortName(): String = "mysql" } result.writeStream .format("mysql") .option("username", "root") .option("password", "root") .option("table", "streaming-bank-result") .option("jdbcUrl", "jdbc:mysql://node01:3306/test") .start() .awaitTermination()

5.5. Tigger

目标和步骤

目标

掌握如何控制 StructuredStreaming 的处理时间

步骤

  1. 微批次处理
  2. 连续流处理

微批次处理

  • 什么是微批次

    img

    • 并不是真正的流, 而是缓存一个批次周期的数据, 后处理这一批次的数据
  • 通用流程

    步骤

    1. 根据 Spark 提供的调试用的数据源 Rate 创建流式 DataFrame
      • Rate 数据源会定期提供一个由两列 timestamp, value 组成的数据, value 是一个随机数
    2. 处理和聚合数据, 计算每个个位数和十位数各有多少条数据
      • valuelog10 即可得出其位数
      • 后按照位数进行分组, 最终就可以看到每个位数的数据有多少个

    代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    val spark = SparkSession.builder()
    .master("local[6]")
    .appName("socket_processor")
    .getOrCreate()

    import org.apache.spark.sql.functions._
    import spark.implicits._
    spark.sparkContext.setLogLevel("ERROR")
    val source = spark.readStream
    .format("rate")
    .load()
    val result = source.select(log10('value) cast IntegerType as 'key, 'value)
    .groupBy('key)
    .agg(count('key) as 'count)
    .select('key, 'count)
    .where('key.isNotNull)
    .sort('key.asc)
  • 默认方式划分批次

    介绍

    默认情况下的 Structured Streaming 程序会运行在微批次的模式下, 当一个批次结束后, 下一个批次会立即开始处理

    步骤

    1. 指定落地到 Console 中, 不指定 Trigger

    代码

    1
    2
    3
    4
    5
    result.writeStream
    .outputMode(OutputMode.Complete())
    .format("console")
    .start()
    .awaitTermination()
  • 按照固定时间间隔划分批次

    介绍

    使用微批次处理数据, 使用用户指定的时间间隔启动批次, 如果间隔指定为 0, 则尽可能快的去处理, 一个批次紧接着一个批次

    • 如果前一批数据提前完成, 待到批次间隔达成的时候再启动下一个批次
    • 如果前一批数据延后完成, 下一个批次会在前面批次结束后立即启动
    • 如果没有数据可用, 则不启动处理

    步骤

    1. 通过 Trigger.ProcessingTime() 指定处理间隔

    代码

    1
    2
    3
    4
    5
    6
    result.writeStream
    .outputMode(OutputMode.Complete())
    .format("console")
    .trigger(Trigger.ProcessingTime("2 seconds"))
    .start()
    .awaitTermination()
  • 一次性划分批次

    介绍

    只划分一个批次, 处理完成以后就停止 Spark 工作, 当需要启动一下 Spark 处理遗留任务的时候, 处理完就关闭集群的情况下, 这个划分方式非常实用

    步骤

    1. 使用 Trigger.Once 一次性划分批次

    代码

    1
    2
    3
    4
    5
    6
    result.writeStream
    .outputMode(OutputMode.Complete())
    .format("console")
    .trigger(Trigger.Once())
    .start()
    .awaitTermination()

连续流处理

  • 介绍

    • 微批次会将收到的数据按照批次划分为不同的 DataFrame, 后执行 DataFrame, 所以其数据的处理延迟取决于每个 DataFrame 的处理速度, 最快也只能在一个 DataFrame 结束后立刻执行下一个, 最快可以达到 100ms 左右的端到端延迟
    • 而连续流处理可以做到大约 1ms 的端到端数据处理延迟
    • 连续流处理可以达到 at-least-once 的容错语义
    • Spark 2.3 版本开始支持连续流处理, 我们所采用的 2.2 版本还没有这个特性, 并且这个特性截止到 2.4 依然是实验性质, 不建议在生产环境中使用
  • 操作

    步骤

    1. 使用特殊的 Trigger 完成功能

    代码

    1
    2
    3
    4
    5
    6
    result.writeStream
    .outputMode(OutputMode.Complete())
    .format("console")
    .trigger(Trigger.Continuous("1 second"))
    .start()
    .awaitTermination()
  • 限制

    • 只支持 Map 类的有类型操作
    • 只支持普通的的 SQL 类操作, 不支持聚合
    • Source 只支持 Kafka
    • Sink 只支持 Kafka, Console, Memory

5.6. 从 Source 到 Sink 的流程

目标和步骤

目标

理解 SourceSink 的整体原理

步骤

  1. SourceSink 的流程

从 Source 到 Sink 的流程

img

  1. 在每个 StreamExecution 的批次最开始, StreamExecution 会向 Source 询问当前 Source 的最新进度, 即最新的 offset
  2. StreamExecutionOffset 放到 WAL
  3. StreamExecutionSource 获取 start offset, end offset 区间内的数据
  4. StreamExecution 触发计算逻辑 logicalPlan 的优化与编译
  5. 计算结果写出给 Sink
    • 调用 Sink.addBatch(batchId: Long, data: DataFrame) 完成
    • 此时才会由 Sink 的写入操作开始触发实际的数据获取和计算过程
  6. 在数据完整写出到 Sink 后, StreamExecution 通知 Source 批次 id 写入到 batchCommitLog, 当前批次结束

5.7. 错误恢复和容错语义

目标和步骤

目标

理解 Structured Streaming 中提供的系统级别容错手段

步骤

  1. 端到端
  2. 三种容错语义
  3. Sink 的容错

端到端

img

  • Source 可能是 Kafka, HDFS
  • Sink 也可能是 Kafka, HDFS, MySQL 等存储服务
  • 消息从 Source 取出, 经过 Structured Streaming 处理, 最后落地到 Sink 的过程, 叫做端到端

三种容错语义

  • at-most-once

    img

    • 在数据从 SourceSink 的过程中, 出错了, Sink 可能没收到数据, 但是不会收到两次, 叫做 at-most-once
    • 一般错误恢复的时候, 不重复计算, 则是 at-most-once
  • at-least-once

    img

    • 在数据从 SourceSink 的过程中, 出错了, Sink 一定会收到数据, 但是可能收到两次, 叫做 at-least-once
    • 一般错误恢复的时候, 重复计算可能完成也可能未完成的计算, 则是 at-least-once
  • exactly-once

    img

    • 在数据从 SourceSink 的过程中, 虽然出错了, Sink 一定恰好收到应该收到的数据, 一条不重复也一条都不少, 即是 exactly-once
    • 想做到 exactly-once 是非常困难的

Sink 的容错

img

  • 故障恢复一般分为 Driver 的容错和 Task 的容错

    • Driver 的容错指的是整个系统都挂掉了
    • Task 的容错指的是一个任务没运行明白, 重新运行一次
  • 因为 SparkExecutor 能够非常好的处理 Task 的容错, 所以我们主要讨论 Driver 的容错, 如果出错的时候

    • 读取 WAL offsetlog 恢复出最新的 offsets

      StreamExecution 找到 Source 获取数据的时候, 会将数据的起始放在 WAL offsetlog 中, 当出错要恢复的时候, 就可以从中获取当前处理批次的数据起始, 例如 KafkaOffset

    • 读取 batchCommitLog 决定是否需要重做最近一个批次

      Sink 处理完批次的数据写入时, 会将当前的批次 ID 存入 batchCommitLog, 当出错的时候就可以从中取出进行到哪一个批次了, 和 WAL 对比即可得知当前批次是否处理完

    • 如果有必要的话, 当前批次数据重做

      • 如果上次执行在 (5) 结束前即失效, 那么本次执行里 Sink 应该完整写出计算结果
      • 如果上次执行在 (5) 结束后才失效, 那么本次执行里 Sink 可以重新写出计算结果 (覆盖上次结果), 也可以跳过写出计算结果(因为上次执行已经完整写出过计算结果了)
    • 这样即可保证每次执行的计算结果, 在 Sink 这个层面, 是 不重不丢 的, 即使中间发生过失效和恢复, 所以 Structured Streaming 可以做到 exactly-once

容错所需要的存储

  • 存储

    • offsetlogbatchCommitLog 关乎于错误恢复
    • offsetlogbatchCommitLog 需要存储在可靠的空间里
    • offsetlogbatchCommitLog 存储在 Checkpoint
    • WAL 其实也存在于 Checkpoint
  • 指定 Checkpoint

    • 只有指定了 Checkpoint 路径的时候, 对应的容错功能才可以开启
    1
    2
    3
    4
    5
    6
    aggDF
    .writeStream
    .outputMode("complete")
    .option("checkpointLocation", "path/to/HDFS/dir") (1)
    .format("memory")
    .start()
    1 指定 Checkpoint 的路径, 这个路径对应的目录必须是 HDFS 兼容的文件系统

需要的外部支持

如果要做到 exactly-once, 只是 Structured Streaming 能做到还不行, 还需要 SourceSink 系统的支持

  • Source 需要支持数据重放

    当有必要的时候, Structured Streaming 需要根据 startend offsetSource 系统中再次获取数据, 这叫做重放

  • Sink 需要支持幂等写入

    如果需要重做整个批次的时候, Sink 要支持给定的 ID 写入数据, 这叫幂等写入, 一个 ID 对应一条数据进行写入, 如果前面已经写入, 则替换或者丢弃, 不能重复

所以 Structured Streaming 想要做到 exactly-once, 则也需要外部系统的支持, 如下

Source

Sources 是否可重放 原生内置支持 注解
HDFS 可以 已支持 包括但不限于 Text, JSON, CSV, Parquet, ORC
Kafka 可以 已支持 Kafka 0.10.0+
RateStream 可以 已支持 以一定速率产生数据
RDBMS 可以 待支持 预计后续很快会支持
Socket 不可以 已支持 主要用途是在技术会议和讲座上做 Demo

Sink

Sinks 是否幂等写入 原生内置支持 注解
HDFS 可以 支持 包括但不限于 Text, JSON, CSV, Parquet, ORC
ForeachSink 可以 支持 可定制度非常高的 Sink, 是否可以幂等取决于具体的实现
RDBMS 可以 待支持 预计后续很快会支持
Kafka 不可以 支持 Kafka 目前不支持幂等写入, 所以可能会有重复写入

6. 有状态算子

目标和步骤

目标

了解常见的 Structured Streaming 算子, 能够完成常见的流式计算需求

步骤

  1. 常规算子
  2. 分组算子
  3. 输出模式

状态

  • 无状态算子

    img

    • 无状态
  • 有状态算子

    img

    • 有中间状态需要保存
    • 增量查询

总结

6.1. 常规算子

目标和步骤

目标

了解 Structured Streaming 的常规数据处理方式

步骤

  1. 案例

案例

  • 需求

    • 给定电影评分数据集 ratings.dat, 位置在 Spark/Files/Dataset/Ratings/ratings.dat
    • 筛选评分超过三分的电影
    • 以追加模式展示数据, 以流的方式来一批数据处理一批数据, 最终每一批次展示为如下效果
    1
    2
    3
    4
    5
    6
    +------+-------+
    |Rating|MovieID|
    +------+-------+
    | 5| 1193|
    | 4| 3408|
    +------+-------+
  • 步骤

    1. 创建 SparkSession
    2. 读取并处理数据结构
    3. 处理数据
      1. 选择要展示的列
      2. 筛选超过三分的数据
    4. 追加模式展示数据到控制台
  • 代码

    • 读取文件的时候只能读取一个文件夹, 因为是流的操作, 流的场景是源源不断有新的文件读取
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    val source = spark.readStream
    .textFile("dataset/ratings")
    .map(line => {
    val columns = line.split("::")
    (columns(0).toInt, columns(1).toInt, columns(2).toInt, columns(3).toLong)
    })
    .toDF("UserID", "MovieID", "Rating", "Timestamp")

    val result = source.select('Rating, 'MovieID)
    .where('Rating > 3)

总结

  • 针对静态数据集的很多转换算子, 都可以应用在流式的 Dataset 上, 例如 Map, FlatMap, Where, Select

6.2. 分组算子

目标和步骤

目标

能够使用分组完成常见需求, 并了解如何扩展行

步骤

  1. 案例

案例

  • 需求

    • 给定电影数据集 movies.dat, 其中三列 MovieID, Title, Genres
    • 统计每个分类下的电影数量
  • 步骤

    1. 创建 SparkSession

    2. 读取数据集, 并组织结构

      注意 Genresgenres1|genres2 形式, 需要分解为数组

    3. 使用 explode 函数将数组形式的分类变为单值多条形式

    4. 分组聚合 Genres

    5. 输出结果

  • 代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    val source = spark.readStream
    .textFile("dataset/movies")
    .map(line => {
    val columns = line.split("::")
    (columns(0).toInt, columns(1).toString, columns(2).toString.split("\\|"))
    })
    .toDF("MovieID", "Title", "Genres")

    val result = source.select(explode('Genres) as 'Genres)
    .groupBy('Genres)
    .agg(count('Genres) as 'Count)
    result.writeStream
    .outputMode(OutputMode.Complete())
    .format("console")
    .queryName("genres_count")
    .start()
    .awaitTermination()

总结

  • Structured Streaming 不仅支持 groupBy, 还支持 groupByKey