Spark 入门
1. Spark 概述
目标
- Spark 是什么
- Spark 的特点
- Spark 生态圈的组成
1.1. Spark是什么
目标
- 了解 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
Spark 产生之前, 已经有非常成熟的计算系统存在了, 例如 MapReduce, 这些计算系统提供了高层次的API, 把计算运行在集群中并提供容错能力, 从而实现分布式计算.
虽然这些框架提供了大量的对访问利用计算资源的抽象, 但是它们缺少了对利用分布式内存的抽象, 这些框架多个计算之间的数据复用就是将中间数据写到一个稳定的文件系统中(例如HDFS), 所以会产生数据的复制备份, 磁盘的I/O以及数据的序列化, 所以这些框架在遇到需要在多个计算之间复用中间结果的操作时会非常的不高效.
而这类操作是非常常见的, 例如迭代式计算, 交互式数据挖掘, 图计算等.
认识到这个问题后, 学术界的 AMPLab 提出了一个新的模型, 叫做 RDDs
.
RDDs
是一个可以容错且并行的数据结构, 它可以让用户显式的将中间结果数据集保存在内中, 并且通过控制数据集的分区来达到数据存放处理最优化.
同时 RDDs
也提供了丰富的 API 来操作数据集.
后来 RDDs 被 AMPLab 在一个叫做 Spark 的框架中提供并开源.
总结
- Spark 是Apache的开源框架
- Spark 的母公司叫做 Databricks
- Spark 是为了解决 MapReduce 等过去的计算系统无法在内存中保存中间结果的问题
- Spark 的核心是 RDDs, RDDs 不仅是一种计算框架, 也是一种数据结构
1.2. Spark的特点(优点)
目标
- 理解 Spark 的特点, 从而理解为什么要使用 Spark
速度快
- Spark 的在内存时的运行速度是 Hadoop MapReduce 的100倍
- 基于硬盘的运算速度大概是 Hadoop MapReduce 的10倍
- Spark 实现了一种叫做 RDDs 的 DAG 执行引擎, 其数据缓存在内存中可以进行迭代处理
易用
1 | df = spark.read.json("logs.json") |
- 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组件
目标
- 理解 Spark 能做什么
- 理解 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 集群搭建
目标
- 从 Spark 的集群架构开始, 理解分布式环境, 以及 Spark 的运行原理
- 理解 Spark 的集群搭建, 包括高可用的搭建方式
2.1. Spark 集群结构
目标
- 通过应用运行流程, 理解分布式调度的基础概念
Spark 如何将程序运行在一个集群中?![]() |
|
---|---|
名词解释
Driver
该进程调用 Spark 程序的 main 方法, 并且启动 SparkContext
Cluster Manager
该进程负责和外部集群工具打交道, 申请或释放集群资源
Worker
该进程是一个守护进程, 负责启动和管理 Executor
Executor
该进程是一个JVM虚拟机, 负责运行 Spark Task
运行一个 Spark 程序大致经历如下几个步骤
- 启动 Drive, 创建 SparkContext
- Client 提交程序给 Drive, Drive 向 Cluster Manager 申请集群资源
- 资源申请完毕, 在 Worker 中启动 Executor
- Driver 将程序转化为 Tasks, 分发给 Executor 执行
问题一: Spark 程序可以运行在什么地方?
集群: 一组协同工作的计算机, 通常表现的好像是一台计算机一样, 所运行的任务由软件来控制和调度**集群管理工具:** 调度任务到集群的软件常见的集群管理工具: Hadoop Yarn, Apache Mesos, Kubernetes | |
---|---|
Spark 可以将任务运行在两种模式下:
- 单机, 使用线程模拟并行来运行程序
- 集群, 使用集群管理器来和不同类型的集群交互, 将任务运行在集群中
Spark 可以使用的集群管理工具有:
- Spark Standalone
- Hadoop Yarn
- Apache Mesos
- Kubernetes
问题二: Driver 和 Worker 什么时候被启动?
- Standalone 集群中, 分为两个角色: Master 和 Slave, 而 Slave 就是 Worker, 所以在 Standalone 集群中, 启动之初就会创建固定数量的 Worker
- Driver 的启动分为两种模式: Client 和 Cluster. 在 Client 模式下, Driver 运行在 Client 端, 在 Client 启动的时候被启动. 在 Cluster 模式下, Driver 运行在某个 Worker 中, 随着应用的提交而启动
- 在 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 集群搭建
目标
大致了解 Spark Standalone 集群搭建的过程
这个部分的目的是搭建一套用于测试和学习的集群, 实际的工作中可能集群环境会更复杂一些
Node01 | Node02 | Node03 |
---|---|---|
Master | Slave | Slave |
History Server |
Step 1 下载和解压
此步骤假设大家的 Hadoop 集群已经能够无碍的运行, 并且 Linux 的防火墙和 SELinux 已经关闭, 时钟也已经同步, 如果还没有, 请参考 Hadoop 集群搭建部分, 完成以上三件事 | |
---|---|
下载 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解压并拷贝到
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
修改配置文件
spark-env.sh
, 以指定运行参数进入配置目录, 并复制一份新的配置文件, 以供在此基础之上进行修改
1
2
3cd /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 配置
修改配置文件
slaves
, 以指定从节点为止, 从在使用sbin/start-all.sh
启动集群的时候, 可以一键启动整个集群所有的 Worker进入配置目录, 并复制一份新的配置文件, 以供在此基础之上进行修改
1
2
3cd /export/servers/spark/conf
cp slaves.template slaves
vi slaves配置所有从节点的地址
1
2node02
node03
配置
HistoryServer
默认情况下, Spark 程序运行完毕后, 就无法再查看运行记录的 Web UI 了, 通过 HistoryServer 可以提供一个服务, 通过读取日志文件, 使得我们可以在程序运行结束后, 依然能够查看运行过程
复制
spark-defaults.conf
, 以供修改1
2
3cd /export/servers/spark/conf
cp spark-defaults.conf.template spark-defaults.conf
vi spark-defaults.conf将以下内容复制到
spark-defaults.conf
末尾处, 通过这段配置, 可以指定 Spark 将日志输入到 HDFS 中1
2
3spark.eventLog.enabled true
spark.eventLog.dir hdfs://node01:8020/spark_log
spark.eventLog.compress true将以下内容复制到
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"为 Spark 创建 HDFS 中的日志目录
1
hdfs dfs -mkdir -p /spark_log
Step 3 分发和运行
将 Spark 安装包分发给集群中其它机器
1
2
3cd /export/servers
scp -r spark root@node02:$PWD
scp -r spark root@node03:$PWD启动 Spark Master 和 Slaves, 以及 HistoryServer
1
2
3cd /export/servers/spark
sbin/start-all.sh
sbin/start-history-server.sh
目标
Spark 的集群搭建大致有如下几个步骤
- 下载和解压 Spark
- 配置 Spark 的所有从节点位置
- 配置 Spark History server 以便于随时查看 Spark 应用的运行历史
- 分发和运行 Spark 集群
2.3. Spark 集群高可用搭建
目标
- 简要了解如何使用 Zookeeper 帮助 Spark Standalone 高可用
对于 Spark Standalone 集群来说, 当 Worker 调度出现问题的时候, 会自动的弹性容错, 将出错的 Task 调度到其它 Worker 执行但是对于 Master 来说, 是会出现单点失败的, 为了避免可能出现的单点失败问题, Spark 提供了两种方式满足高可用使用 Zookeeper 实现 Masters 的主备切换使用文件系统做主备切换使用文件系统做主备切换的场景实在太小, 所以此处不再花费笔墨介绍 | |
---|---|
Step 1 停止 Spark 集群
1 | cd /export/servers/spark |
Step 2 修改配置文件, 增加 Spark 运行时参数, 从而指定 Zookeeper 的位置
进入
spark-env.sh
所在目录, 打开 vi 编辑1
2cd /export/servers/spark/conf
vi spark-env.sh编辑
spark-env.sh
, 添加 Spark 启动参数, 并去掉 SPARK_MASTER_HOST 地址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 | cd /export/servers/spark/conf |
Step 4 启动
在
node01
上启动整个集群1
2
3cd /export/servers/spark
sbin/start-all.sh
sbin/start-history-server.sh在
node02
上单独再启动一个 Master1
2cd /export/servers/spark
sbin/start-master.sh
Step 5 查看 node01 master
和 node02 master
的 WebUI
你会发现一个是
ALIVE(主)
, 另外一个是STANDBY(备)
如果关闭一个, 则另外一个成为
ALIVE
, 但是这个过程可能要持续两分钟左右, 需要耐心等待1
2
3# 在 Node01 中执行如下指令
cd /export/servers/spark/
sbin/stop-master.sh
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. 第一个应用的运行
目标
- 从示例应用运行中理解 Spark 应用的运行流程
流程
Step 1 进入 Spark 安装目录中
1 | cd /export/servers/spark/ |
Step 2 运行 Spark 示例任务
1 | bin/spark-submit \ |
Step 3 运行结果
1 | Pi is roughly 3.141550671141551 |
刚才所运行的程序是 Spark 的一个示例程序, 使用 Spark 编写了一个以蒙特卡洛算法来计算圆周率的任务蒙特卡洛算法概述![]() ![]() ![]() |
|
---|---|
计算过程
不断的生成随机的点, 根据点距离圆心是否超过半径来判断是否落入园内
通过
来计算圆周率
不断的迭代
思考1: 迭代计算
如果上述的程序使用 MapReduce 该如何编写? 是否会有大量的向 HDFS 写入, 后再次读取数据的做法? 是否会影响性能?
Spark 为什么擅长这类操作? 大家可以发挥想象, 如何解决这种迭代计算的问题
思考2: 数据规模
刚才的计算只做了100次, 如果迭代100亿次, 在单机上运行和一个集群中运行谁更合适?
3. Spark 入门
目标
- 通过理解 Spark 小案例, 来理解 Spark 应用
- 理解编写 Spark 程序的两种常见方式
- spark-shell
- 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 | hadoop spark flume |
Step 2 启动 Spark shell
1 | cd /export/servers/spark |
Step 3 执行如下代码
1 | scala> val sourceRdd = sc.textFile("file:///export/data/wordcount.txt") |
sc上述代码中 sc 变量指的是 SparkContext, 是 Spark 程序的上下文和入口正常情况下我们需要自己创建, 但是如果使用 Spark shell 的话, Spark shell 会帮助我们创建, 并且以变量 sc 的形式提供给我们调用 |
|
---|---|
运行流程
flatMap(_.split(" "))
将数据转为数组的形式, 并展平为多个数据map_, 1
将数据转换为元组的形式reduceByKey(_ + _)
计算每个 Key 出现的次数
总结
- 使用 Spark shell 可以快速验证想法
- Spark 框架下的代码非常类似 Scala 的函数式调用
3.2. 读取 HDFS 上的文件
目标
- 理解 Spark 访问 HDFS 的两种方式
Step 1 上传文件到 HDFS 中
1 | cd /export/data |
Step 2 在 Spark shell 中访问 HDFS
1 | val sourceRdd = sc.textFile("hdfs://node01:8020/dataset/wordcount.txt") |
如何使得 Spark 可以访问 HDFS?可以通过指定 HDFS 的 NameNode 地址直接访问, 类似于上面代码中的 sc.textFile("hdfs://node01:8020/dataset/wordcount.txt") ![]() spark-env.sh 中添加 Hadoop 的配置路径export HADOOP_CONF_DIR="/etc/hadoop/conf" 2.在配置过后, 可以直接使用 hdfs:///路径 的形式直接访问![]() ![]() |
|
---|---|
3.4. 编写独立应用提交 Spark 任务
目标
- 理解如何编写 Spark 独立应用
- 理解 WordCount 的代码流程
Step 1 创建工程
- 创建 IDEA 工程
→
→
→
→
- 增加 Scala 支持
- 右键点击工程目录
- 选择增加框架支持
- 选择 Scala 添加框架支持
- 右键点击工程目录
Step 2 编写 Maven 配置文件 pom.xml
工程根目录下增加文件
pom.xml
添加以下内容
因为在
pom.xml
中指定了 Scala 的代码目录, 所以创建目录src/main/scala
和目录src/test/scala
创建 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 程序
在工程根目录创建文件夹和文件
修改读取文件的路径为
dataset/wordcount.txt
右键运行 Main 方法
提交到 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 |
在 IDEA 中使用 Maven 打包
拷贝打包的 Jar 包上传到 node01 中
在 node01 中 Jar 包所在的目录执行如下命令
1
2
3spark-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 的编程模型做更详细的扩展
- 理解 WordCount 的代码
- 从执行角度上理解, 数据之间如何流转
- 从原理角度理解, 各个算子之间如何配合
- 粗略理解 Spark 中的编程模型 RDD
- 理解 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 代码中, 大致的思路如下:
- 使用
sc.textFile()
方法读取 HDFS 中的文件, 并生成一个RDD
- 使用
flatMap
算子将读取到的每一行字符串打散成单词, 并把每个单词变成新的行 - 使用
map
算子将每个单词转换成(word, 1)
这种元组形式 - 使用
reduceByKey
统计单词对应的频率
其中所使用到的算子有如下几个:
flatMap
是一对多map
是一对一reduceByKey
是按照 Key 聚合, 类似 MapReduce 中的 Shuffled
如果用图形表示的话, 如下:
总结以及引出新问题
上面大概说了两件事:
- 代码流程
- 算子
在代码中有一些东西并未交代:
- source, words, wordsTuple 这些变量的类型是
RDD[Type]
, 什么是RDD
? - 还有更多算子吗?
RDD 是什么
定义
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 操作则产生宽依赖.
特点
- RDD 是一个编程模型
- RDD 允许用户显式的指定数据存放在内存或者磁盘
- RDD 是分布式的, 用户可以控制 RDD 的分区
- RDD 是一个编程模型
- RDD 提供了丰富的操作
- RDD 提供了 map, flatMap, filter 等操作符, 用以实现 Monad 模式
- RDD 提供了 reduceByKey, groupByKey 等操作符, 用以操作 Key-Value 型数据
- RDD 提供了 max, min, mean 等操作符, 用以操作数字型的数据
- RDD 是混合型的编程模型, 可以支持迭代计算, 关系查询, MapReduce, 流计算
- RDD 是只读的
- RDD 之间有依赖关系, 根据执行操作的操作符的不同, 依赖关系可以分为宽依赖和窄依赖
RDD 的分区
整个 WordCount 案例的程序从结构上可以用上图表示, 分为两个大部分 存储
文件如果存放在 HDFS 上, 是分块的, 类似上图所示, 这个 wordcount.txt
分了三块
计算
Spark 不止可以读取 HDFS, Spark 还可以读取很多其它的数据集, Spark 可以从数据集中创建出 RDD
例如上图中, 使用了一个 RDD 表示 HDFS 上的某一个文件, 这个文件在 HDFS 中是分三块, 那么 RDD 在读取的时候就也有三个分区, 每个 RDD 的分区对应了一个 HDFS 的分块
后续 RDD 在计算的时候, 可以更改分区, 也可以保持三个分区, 每个分区之间有依赖关系, 例如说 RDD2 的分区一依赖了 RDD1 的分区一
RDD 之所以要设计为有分区的, 是因为要进行分布式计算, 每个不同的分区可以在不同的线程, 或者进程, 甚至节点中, 从而做到并行计算
总结
- RDD 是弹性分布式数据集
- RDD 一个非常重要的前提和基础是 RDD 运行在分布式环境下, 其可以分区
4.1. 创建 RDD
程序入口 SparkContext
1 | val conf = new SparkConf().setMaster("local[2]") |
SparkContext
是 spark-core 的入口组件, 是一个 Spark 程序的入口, 在 Spark 0.x 版本就已经存在 SparkContext
了, 是一个元老级的 API
如果把一个 Spark 程序分为前后端, 那么服务端就是可以运行 Spark 程序的集群, 而 Driver
就是 Spark 的前端, 在 Driver
中 SparkContext
是最主要的组件, 也是 Driver
在运行时首先会创建的组件, 是 Driver
的核心
SparkContext
从提供的 API 来看, 主要作用是连接集群, 创建 RDD, 累加器, 广播变量等
简略的说, RDD 有三种创建方式
- RDD 可以通过本地集合直接创建
- RDD 也可以通过读取外部数据集来创建
- RDD 也可以通过其它的 RDD 衍生而来
通过本地集合直接创建 RDD
1 | val conf = new SparkConf().setMaster("local[2]") |
通过 parallelize
和 makeRDD
这两个 API 可以通过本地集合创建 RDD
这两个 API 本质上是一样的, 在 makeRDD
这个方法的内部, 最终也是调用了 parallelize
因为不是从外部直接读取数据集的, 所以没有外部的分区可以借鉴, 于是在这两个方法都都有两个参数, 第一个参数是本地集合, 第二个参数是分区数
通过读取外部文件创建 RDD
1 | val conf = new SparkConf().setMaster("local[2]") |
- 访问方式
- 支持访问文件夹, 例如
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
数
- 默认情况下读取 HDFS 中文件的时候, 每个 HDFS 的
通常每个 CPU core 对应 2 - 4 个分区是合理的值 | |
---|---|
- 支持的平台
- 支持 Hadoop 的几乎所有数据格式, 支持 HDFS 的访问
- 通过第三方的支持, 可以访问AWS和阿里云中的文件, 详情查看对应平台的 API
通过其它的 RDD 衍生新的 RDD
1 | val conf = new SparkConf().setMaster("local[2]") |
source
是通过读取 HDFS 中的文件所创建的words
是通过source
调用算子map
生成的新 RDD
总结
- RDD 的可以通过三种方式创建, 通过本地集合创建, 通过外部数据集创建, 通过其它的 RDD 衍生
4.2. RDD 算子
目标
- 理解各个算子的作用
- 通过理解算子的作用, 反向理解 WordCount 程序, 以及 Spark 的要点
Map 算子
1 | sc.parallelize(Seq(1, 2, 3)) |
作用
把 RDD 中的数据 一对一 的转为另一种形式
调用
1 | def map[U: ClassTag](f: T ⇒ U): RDD[U] |
参数
f
→ Map 算子是 原RDD → 新RDD
的过程, 这个函数的参数是原 RDD 数据, 返回值是经过函数转换的新 RDD 的数据
注意点
Map 是一对一, 如果函数是 String → Array[String]
则新的 RDD 中每条数据就是一个数组
FlatMap 算子
1 | sc.parallelize(Seq("Hello lily", "Hello lucy", "Hello tim")) |
作用
FlatMap 算子和 Map 算子类似, 但是 FlatMap 是一对多
调用
1 | def flatMap[U: ClassTag](f: T ⇒ List[U]): RDD[U] |
参数
f
→ 参数是原 RDD 数据, 返回值是经过函数转换的新 RDD 的数据, 需要注意的是返回值是一个集合, 集合中的数据会被展平后再放入新的 RDD
注意点
flatMap 其实是两个操作, 是 map + flatten
, 也就是先转换, 后把转换而来的 List 展开
ReduceByKey 算子
1 | sc.parallelize(Seq(("a", 1), ("a", 1), ("b", 1))) |
作用
首先按照 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 的数据便会减少
总结
- map 和 flatMap 算子都是转换, 只是 flatMap 在转换过后会再执行展开, 所以 map 是一对一, flatMap 是一对多
- reduceByKey 类似 MapReduce 中的 Reduce
目标
- 深入理解 RDD 的内在逻辑
- 能够使用 RDD 的算子
- 理解 RDD 算子的 Shuffle 和缓存
- 理解 RDD 整体的使用流程
- 理解 RDD 的调度原理
- 理解 Spark 中常见的分布式变量共享方式
深入 RDD
1. 深入 RDD
目标
- 深入理解 RDD 的内在逻辑, 以及 RDD 的内部属性(RDD 由什么组成)
1.1. 案例
需求
- 给定一个网站的访问记录, 俗称 Access log
- 计算其中出现的独立 IP, 以及其访问的次数
1 | val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]") |
假设要针对整个网站的历史数据进行处理, 量有 1T, 如何处理?
放在集群中, 利用集群多台计算机来并行处理
如何放在集群中运行?
简单来讲, 并行计算就是同时使用多个计算资源解决一个问题, 有如下四个要点
1
* 要解决的问题必须可以分解为多个可以并发计算的部分
- 每个部分要可以在不同处理器上被同时执行
- 需要一个共享内存的机制
- 需要一个总体上的协作机制来进行调度
如果放在集群中的话, 可能要对整个计算任务进行分解, 如何分解?
概述
1
* 对于 HDFS 中的文件, 是分为不同的 Block 的
- 在进行计算的时候, 就可以按照 Block 来划分, 每一个 Block 对应一个不同的计算单元
扩展
1
* `RDD` 并没有真实的存放数据, 数据是从 HDFS 中读取的, 在计算的过程中读取即可
RDD
至少是需要可以 分片 的, 因为 HDFS 中的文件就是分片的,RDD
分片的意义在于表示对源数据集每个分片的计算,RDD
可以分片也意味着 可以并行计算
移动数据不如移动计算是一个基础的优化, 如何做到?
每一个计算单元需要记录其存储单元的位置, 尽量调度过去
在集群中运行, 需要很多节点之间配合, 出错的概率也更高, 出错了怎么办?
RDD1 → RDD2 → RDD3 这个过程中, RDD2 出错了, 有两种办法可以解决
1 | 1. 缓存 RDD2 的数据, 直接恢复 RDD2, 类似 HDFS 的备份机制 |
- 记录 RDD2 的依赖关系, 通过其父级的 RDD 来恢复 RDD2, 这种方式会少很多数据的交互和保存
如何通过父级 RDD 来恢复?
1 | 1. 记录 RDD2 的父亲是 RDD1 |
记录 RDD2 的计算函数, 例如记录 RDD2 = RDD1.map(…)
, map(…)
就是计算函数
当 RDD2 计算出错的时候, 可以通过父级 RDD 和计算函数来恢复 RDD2
假如任务特别复杂, 流程特别长, 有很多 RDD 之间有依赖关系, 如何优化?
上面提到了可以使用依赖关系来进行容错, 但是如果依赖关系特别长的时候, 这种方式其实也比较低效, 这个时候就应该使用另外一种方式, 也就是记录数据集的状态
在 Spark 中有两个手段可以做到
1 | 1. 缓存 |
- Checkpoint
1.2. 再谈 RDD
目标
理解 RDD 为什么会出现
理解 RDD 的主要特点
理解 RDD 的五大属性
1.2.1. RDD 为什么会出现?
在 RDD 出现之前, 当时 MapReduce 是比较主流的, 而 MapReduce 如何执行迭代计算的任务呢?
多个 MapReduce 任务之间没有基于内存的数据共享方式, 只能通过磁盘来进行共享
这种方式明显比较低效
RDD 如何解决迭代计算非常低效的问题呢?
在 Spark 中, 其实最终 Job3 从逻辑上的计算过程是: Job3 = (Job1.map).filter
, 整个过程是共享内存的, 而不需要将中间结果存放在可靠的分布式文件系统中
这种方式可以在保证容错的前提下, 提供更多的灵活, 更快的执行速度, RDD 在执行迭代型任务时候的表现可以通过下面代码体现
1 | `// 线性回归 |
在这个例子中, 进行了大致 10000 次迭代, 如果在 MapReduce 中实现, 可能需要运行很多 Job, 每个 Job 之间都要通过 HDFS 共享结果, 熟快熟慢一窥便知
1.2.2. RDD 的特点
RDD 不仅是数据集, 也是编程模型
RDD 即是一种数据结构, 同时也提供了上层 API, 同时 RDD 的 API 和 Scala 中对集合运算的 API 非常类似, 同样也都是各种算子
RDD 的算子大致分为两类:
- Transformation 转换操作, 例如
map
flatMap
filter
等 - Action 动作操作, 例如
reduce
collect
show
等
执行 RDD 的时候, 在执行到转换操作的时候, 并不会立刻执行, 直到遇见了 Action 操作, 才会触发真正的执行, 这个特点叫做 惰性求值
RDD 可以分区
RDD 是一个分布式计算框架, 所以, 一定是要能够进行分区计算的, 只有分区了, 才能利用集群的并行计算能力
同时, RDD 不需要始终被具体化, 也就是说: RDD 中可以没有数据, 只要有足够的信息知道自己是从谁计算得来的就可以, 这是一种非常高效的容错方式
RDD 是只读的
RDD 是只读的, 不允许任何形式的修改. 虽说不能因为 RDD 和 HDFS 是只读的, 就认为分布式存储系统必须设计为只读的. 但是设计为只读的, 会显著降低问题的复杂度, 因为 RDD 需要可以容错, 可以惰性求值, 可以移动计算, 所以很难支持修改.
RDD2 中可能没有数据, 只是保留了依赖关系和计算函数, 那修改啥?
如果因为支持修改, 而必须保存数据的话, 怎么容错?
如果允许修改, 如何定位要修改的那一行? RDD 的转换是粗粒度的, 也就是说, RDD 并不感知具体每一行在哪.
RDD 是可以容错的
RDD 的容错有两种方式
保存 RDD 之间的依赖关系, 以及计算函数, 出现错误重新计算
直接将 RDD 的数据存放在外部存储系统, 出现错误直接读取, Checkpoint
1.2.3. 什么叫做弹性分布式数据集
分布式
RDD 支持分区, 可以运行在集群中
弹性
RDD 支持高效的容错
RDD 中的数据即可以缓存在内存中, 也可以缓存在磁盘中, 也可以缓存在外部存储中
数据集
RDD 可以不保存具体数据, 只保留创建自己的必备信息, 例如依赖和计算函数
RDD 也可以缓存起来, 相当于存储具体数据
总结: RDD 的五大属性
首先整理一下上面所提到的 RDD 所要实现的功能:
- RDD 有分区
- RDD 要可以通过依赖关系和计算函数进行容错
- RDD 要针对数据本地性进行优化
- 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 的算子
目标
- 理解 RDD 的算子分类, 以及其特性
- 理解常见算子的使用
分类
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 算子
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 | ````scala |
总结
RDD 的算子大部分都会生成一些专用的 RDD
1 | map`, `flatMap`, `filter` 等算子会生成 `MapPartitionsRDD |
常见的 RDD 有两种类型
转换型的 RDD, Transformation
动作型的 RDD, Action
常见的 Transformation 类型的 RDD
map
flatMap
filter
groupBy
reduceByKey
常见的 Action 类型的 RDD
collect
countByKey
reduce
2.3. RDD 对不同类型数据的支持
目标
- 理解 RDD 对 Key-Value 类型的数据是有专门支持的
- 理解 RDD 对数字类型也有专门的支持
一般情况下 RDD 要处理的数据有三类
- 字符串
- 键值对
- 数字型
RDD 的算子设计对这三类不同的数据分别都有支持
对于以字符串为代表的基本数据类型是比较基础的一些的操作, 诸如 map, flatMap, filter 等基础的算子
对于键值对类型的数据, 有额外的支持, 诸如 reduceByKey, groupByKey 等 byKey 的算子
同样对于数字型的数据也有额外的支持, 诸如 max, min 等
RDD 对键值对数据的额外支持
键值型数据本质上就是一个二元元组, 键值对类型的 RDD 表示为 RDD[(K, V)]
RDD 对键值对的额外支持是通过隐式支持来完成的, 一个 RDD[(K, V)]
, 可以被隐式转换为一个 PairRDDFunctions
对象, 从而调用其中的方法.
既然对键值对的支持是通过 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 | `val rdd = sc.parallelize(Seq(1, 2, 3)) |
2.4. 阶段练习和总结
导读
- 通过本节, 希望大家能够理解 RDD 的一般使用步骤
1 | `// 1. 创建 SparkContext |
通过上述代码可以看到, 其实 RDD 的整体使用步骤如下
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 | `scala> sc.parallelize(1 to 100).count |
之所以会有 8 个 Tasks, 是因为在启动的时候指定的命令是 spark-shell --master local[8]
, 这样会生成 1 个 Executors, 这个 Executors 有 8 个 Cores, 所以默认会有 8 个 Tasks, 每个 Cores 对应一个分区, 每个分区对应一个 Tasks, 可以通过 rdd.partitions.size
来查看分区数量
同时也可以通过 spark-shell 的 WebUI 来查看 Executors 的情况
默认的分区数量是和 Cores 的数量有关的, 也可以通过如下三种方式修改或者重新指定分区数量
创建 RDD 时指定分区数
1 | `scala> val rdd1 = sc.parallelize(1 to 100, 6) |
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 | `scala> val source = sc.parallelize(1 to 100, 6) |
1 | 如果 shuffle 参数指定为 false , 运行计划中确实没有 ShuffledRDD , 没有 shuffled 这个过程 |
---|---|
2 | 如果 shuffle 参数指定为 true , 运行计划中有一个 ShuffledRDD , 有一个明确的显式的 shuffled 过程 |
3 | 如果 shuffle 参数指定为 false 却增加了分区数, 分区数并不会发生改变, 这是因为增加分区是一个宽依赖, 没有 shuffled 过程无法做到, 后续会详细解释宽依赖的概念 |
通过 repartition
算子指定
1 | `repartition(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T]` |
1 | `scala> val source = sc.parallelize(1 to 100, 6) |
1 | 增加分区有效 |
---|---|
2 | 减少分区有效 |
repartition
算子无论是增加还是减少分区都是有效的, 因为本质上 repartition
会通过 shuffle
操作把数据分发给新的 RDD 的不同的分区, 只有 shuffle
操作才可能做到增大分区数, 默认情况下, 分区函数是 RoundRobin
, 如果希望改变分区函数, 也就是数据分布的方式, 可以通过自定义分区函数来实现
3.2. RDD 的 Shuffle 是什么
1 | `val sourceRdd = sc.textFile("hdfs://node01:9020/dataset/wordcount.txt") |
reduceByKey
这个算子本质上就是先按照 Key 分组, 后对每一组数据进行 reduce
, 所面临的挑战就是 Key 相同的所有数据可能分布在不同的 Partition 分区中, 甚至可能在不同的节点中, 但是它们必须被共同计算.
为了让来自相同 Key 的所有数据都在 reduceByKey
的同一个 reduce
中处理, 需要执行一个 all-to-all
的操作, 需要在不同的节点(不同的分区)之间拷贝数据, 必须跨分区聚集相同 Key 的所有数据, 这个过程叫做 Shuffle
.
3.3. RDD 的 Shuffle 原理
Spark 的 Shuffle 发展大致有两个阶段: Hash base shuffle
和 Sort base shuffle
Hash base shuffle
大致的原理是分桶, 假设 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
对于 Sort base shuffle 来说, 每个 Map 侧的分区只有一个输出文件, Reduce 侧的 Task 来拉取, 大致流程如下
Map 侧将数据全部放入一个叫做 AppendOnlyMap 的组件中, 同时可以在这个特殊的数据结构中做聚合操作
然后通过一个类似于 MergeSort 的排序算法 TimSort 对 AppendOnlyMap 底层的 Array 排序
1
* 先按照 Partition ID 排序, 后按照 Key 的 HashCode 排序
最终每个 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 | `val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string") |
1 | 这是一个 Shuffle 操作, Shuffle 操作会在集群内进行数据拷贝 |
---|---|
在上述代码中, 多次使用到了 interimRDD
, 导致文件读取两次, 计算两次, 有没有什么办法增进上述代码的性能?
使用缓存的原因 - 容错
当在计算 RDD3 的时候如果出错了, 会怎么进行容错?
会再次计算 RDD1 和 RDD2 的整个链条, 假设 RDD1 和 RDD2 是通过比较昂贵的操作得来的, 有没有什么办法减少这种开销?
上述两个问题的解决方案其实都是 缓存
, 除此之外, 使用缓存的理由还有很多, 但是总结一句, 就是缓存能够帮助开发者在进行一些昂贵操作后, 将其结果保存下来, 以便下次使用无需再次执行, 缓存能够显著的提升性能.
所以, 缓存适合在一个 RDD 需要重复多次利用, 并且还不是特别大的情况下使用, 例如迭代计算等场景.
4.2. 缓存相关的 API
可以使用 cache
方法进行缓存
1 | `val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string") |
1 | 缓存 |
---|---|
方法签名如下
1 | `cache(): this.type = persist()` |
cache 方法其实是 persist
方法的一个别名
也可以使用 persist 方法进行缓存
1 | val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string") |
1 | 缓存 |
---|---|
方法签名如下
1 | `persist(): this.type |
persist
方法其实有两种形式, persist()
是 persist(newLevel: StorageLevel)
的一个别名, persist(newLevel: StorageLevel)
能够指定缓存的级别
缓存其实是一种空间换时间的做法, 会占用额外的存储资源, 如何清理?
1 | `val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string") |
1 | 清理缓存 |
---|---|
根据缓存级别的不同, 缓存存储的位置也不同, 但是使用 unpersist
可以指定删除 RDD 对应的缓存信息, 并指定缓存级别为 NONE
4.3. 缓存级别
其实如何缓存是一个技术活, 有很多细节需要思考, 如下
- 是否使用磁盘缓存?
- 是否使用内存缓存?
- 是否使用堆外内存?
- 缓存前是否先序列化?
- 是否需要有副本?
如果要回答这些信息的话, 可以先查看一下 RDD 的缓存级别对象
1 | `val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string") |
打印出来的对象是 StorageLevel
, 其中有如下几个构造参数
根据这几个参数的不同, StorageLevel
有如下几个枚举对象
缓存级别 | 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
目标
- Checkpoint 的作用
- 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 | `val conf = new SparkConf().setMaster("local[6]").setAppName("debug_string") |
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 底层逻辑
导读
- 从部署图了解
Spark
部署了什么, 有什么组件运行在集群中 - 通过对
WordCount
案例的解剖, 来理解执行逻辑计划的生成 - 通过对逻辑执行计划的细化, 理解如何生成物理计划
如无特殊说明, 以下部分均针对于 Spark Standalone 进行介绍 |
|
---|---|
部署情况
在 Spark
部分的底层执行逻辑开始之前, 还是要先认识一下 Spark
的部署情况, 根据部署情况, 从而理解如何调度.
针对于上图, 首先可以看到整体上在集群中运行的角色有如下几个:
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
控制 Job
和 Task
, 并且提供 WebUI
.
1 | Executor |
Executor
对象中通过线程池来运行 Task
, 一个 Executor
中只会运行一个 Spark Application
的 Task
, 不同的 Spark Application
的 Task
会由不同的 Executor
来运行
案例
因为要理解执行计划, 重点不在案例, 所以本节以一个非常简单的案例作为入门, 就是我们第一个案例 WordCount
1 | `val sc = ... |
整个案例的运行过程大致如下:
- 通过代码的运行, 生成对应的
RDD
逻辑执行图 - 通过
Action
操作, 根据逻辑执行图生成对应的物理执行图, 也就是Stage
和Task
- 将物理执行图运行在集群中
逻辑执行图
对于上面代码中的 reduceRDD
如果使用 toDebugString
打印调试信息的话, 会显式如下内容
1 | `(6) MapPartitionsRDD[4] at map at WordCount.scala:20 [] |
根据这段内容, 大致能得到这样的一张逻辑执行图
其实 RDD 并没有什么严格的逻辑执行图和物理执行图的概念, 这里也只是借用这个概念, 从而让整个 RDD 的原理可以解释, 好理解.
对于 RDD 的逻辑执行图, 起始于第一个入口 RDD 的创建, 结束于 Action 算子执行之前, 主要的过程就是生成一组互相有依赖关系的 RDD, 其并不会真的执行, 只是表示 RDD 之间的关系, 数据的流转过程.
物理执行图
当触发 Action 执行的时候, 这一组互相依赖的 RDD 要被处理, 所以要转化为可运行的物理执行图, 调度到集群中执行.
因为大部分 RDD 是不真正存放数据的, 只是数据从中流转, 所以, 不能直接在集群中运行 RDD, 要有一种 Pipeline 的思想, 需要将这组 RDD 转为 Stage 和 Task, 从而运行 Task, 优化整体执行速度.
以上的逻辑执行图会生成如下的物理执行图, 这一切发生在 Action 操作被执行时.
从上图可以总结如下几个点
在第一个
Stage
中, 每一个这样的执行流程是一个Task
, 也就是在同一个 Stage 中的所有 RDD 的对应分区, 在同一个 Task 中执行- Stage 的划分是由 Shuffle 操作来确定的, 有 Shuffle 的地方, Stage 断开
6.1. 逻辑执行图生成
导读
- 如何生成 RDD
- 如何控制 RDD 之间的关系
6.1.1. RDD 的生成
重点内容
本章要回答如下三个问题
- 如何生成 RDD
- 生成什么 RDD
- 如何计算 RDD 中的数据
1 | `val sc = ... |
明确逻辑计划的边界
在 Action
调用之前, 会生成一系列的 RDD
, 这些 RDD
之间的关系, 其实就是整个逻辑计划
例如上述代码, 如果生成逻辑计划的, 会生成如下一些 RDD
, 这些 RDD
是相互关联的, 这些 RDD
之间, 其实本质上生成的就是一个 计算链
接下来, 采用迭代渐进式的方式, 一步一步的查看一下整体上的生成过程
textFile
算子的背后
研究 RDD
的功能或者表现的时候, 其实本质上研究的就是 RDD
中的五大属性, 因为 RDD
透过五大属性来提供功能和表现, 所以如果要研究 textFile
这个算子, 应该从五大属性着手, 那么第一步就要看看生成的 RDD
是什么类型的 RDD
textFile
HadoopRDD1
2
3
4
5
6
7
生成的是
1
2
3
4
5
6
7
8
9
10
11


| | 除了上面这一个步骤以外, 后续步骤将不再直接基于代码进行讲解, 因为从代码的角度着手容易迷失逻辑, 这个章节的初心有两个, 一个是希望大家了解 Spark 的内部逻辑和原理, 另外一个是希望大家能够通过本章学习具有代码分析的能力 |
| ---- | ------------------------------------------------------------ |
| | |
2. ```
HadoopRDD
的
1 | Partitions |
对应了
1 | HDFS |
的
1 | Blocks |
其实本质上每个 HadoopRDD
的 Partition
都是对应了一个 Hadoop
的 Block
, 通过 InputFormat
来确定 Hadoop
中的 Block
的位置和边界, 从而可以供一些算子使用
HadoopRDD
compute1
2
3
4
5
6
7
的
HDFS1
2
3
4
5
6
7
函数就是在读取
Block1
2
3
4
5
6
7
中的
1
2
3
4
5
本质上, `compute` 还是依然使用 `InputFormat` 来读取 `HDFS` 中对应分区的 `Block`
4. ```
textFile
这个算子生成的其实是一个
1 | MapPartitionsRDD |
textFile
这个算子的作用是读取 HDFS
上的文件, 但是 HadoopRDD
中存放是一个元组, 其 Key
是行号, 其 Value
是 Hadoop
中定义的 Text
对象, 这一点和 MapReduce
程序中的行为是一致的
但是并不适合 Spark
的场景, 所以最终会通过一个 map
算子, 将 (LineNum, Text)
转为 String
形式的一行一行的数据, 所以最终 textFile
这个算子生成的 RDD
并不是 HadoopRDD
, 而是一个 MapPartitionsRDD
map
算子的背后
map
算子生成了MapPartitionsRDD
由源码可知, 当
val rdd2 = rdd1.map()
的时候, 其实生成的新RDD
是rdd2
,rdd2
的类型是MapPartitionsRDD
, 每个RDD
中的五大属性都会有一些不同, 由map
算子生成的RDD
中的计算函数, 本质上就是遍历对应分区的数据, 将每一个数据转成另外的形式MapPartitionsRDD
的计算函数是collection.map( function )
真正运行的集群中的处理单元是
Task
, 每个Task
对应一个RDD
的分区, 所以collection
对应一个RDD
分区的所有数据, 而这个计算的含义就是将一个RDD
的分区上所有数据当作一个集合, 通过这个Scala
集合的map
算子, 来执行一个转换操作, 其转换操作的函数就是传入map
算子的function
传入
map
算子的函数会被清理这个清理主要是处理闭包中的依赖, 使得这个闭包可以被序列化发往不同的集群节点运行
flatMap
算子的背后
1 | flatMap` 和 `map` 算子其实本质上是一样的, 其步骤和生成的 `RDD` 都是一样, 只是对于传入函数的处理不同, `map` 是 `collect.map( function )` 而 `flatMap` 是 `collect.flatMap( function ) |
从侧面印证了, 其实 Spark
中的 flatMap
和 Scala
基础中的 flatMap
其实是一样的
1 | textRDD` → `splitRDD` → `tupleRDD |
由 textRDD
到 splitRDD
再到 tupleRDD
的过程, 其实就是调用 map
和 flatMap
算子生成新的 RDD
的过程, 所以如下图所示, 就是这个阶段所生成的逻辑计划
总结
如何生成 RDD
?
生成 RDD
的常见方式有三种
从本地集合创建
从外部数据集创建
从其它 RDD
衍生
通过外部数据集创建 RDD
, 是通过 Hadoop
或者其它外部数据源的 SDK
来进行数据读取, 同时如果外部数据源是有分片的话, RDD
会将分区与其分片进行对照
通过其它 RDD
衍生的话, 其实本质上就是通过不同的算子生成不同的 RDD
的子类对象, 从而控制 compute
函数的行为来实现算子功能
生成哪些 RDD
?
不同的算子生成不同的 RDD
, 生成 RDD
的类型取决于算子, 例如 map
和 flatMap
都会生成 RDD
的子类 MapPartitions
的对象
如何计算 RDD
中的数据 ?
虽然前面我们提到过 RDD
是偏向计算的, 但是其实 RDD
还只是表示数据, 纵观 RDD
的五大属性中有三个是必须的, 分别如下
Partitions List
分区列表
Compute function
计算函数
Dependencies
依赖
虽然计算函数是和计算有关的, 但是只有调用了这个函数才会进行计算, RDD
显然不会自己调用自己的 Compute
函数, 一定是由外部调用的, 所以 RDD
更多的意义是用于表示数据集以及其来源, 和针对于数据的计算
所以如何计算 RDD
中的数据呢? 一定是通过其它的组件来计算的, 而计算的规则, 由 RDD
中的 Compute
函数来指定, 不同类型的 RDD
子类有不同的 Compute
函数
6.1.2. RDD 之间的依赖关系
导读
- 讨论什么是 RDD 之间的依赖关系
- 继而讨论 RDD 分区之间的关系
- 最后确定 RDD 之间的依赖关系分类
- 完善案例的逻辑关系图
什么是 RDD
之间的依赖关系?
什么是关系(依赖关系) ?
从算子视角上来看,
splitRDD
通过map
算子得到了tupleRDD
, 所以splitRDD
和tupleRDD
之间的关系是map
但是仅仅这样说, 会不够全面, 从细节上来看,
RDD
只是数据和关于数据的计算, 而具体执行这种计算得出结果的是一个神秘的其它组件, 所以, 这两个RDD
的关系可以表示为splitRDD
的数据通过map
操作, 被传入tupleRDD
, 这是它们之间更细化的关系但是
RDD
这个概念本身并不是数据容器, 数据真正应该存放的地方是RDD
的分区, 所以如果把视角放在数据这一层面上的话, 直接讲这两个 RDD 之间有关系是不科学的, 应该从这两个 RDD 的分区之间的关系来讨论它们之间的关系那这些分区之间是什么关系?
如果仅仅说
splitRDD
和tupleRDD
之间的话, 那它们的分区之间就是一对一的关系但是
tupleRDD
到reduceRDD
呢?tupleRDD
通过算子reduceByKey
生成reduceRDD
, 而这个算子是一个Shuffle
操作,Shuffle
操作的两个RDD
的分区之间并不是一对一,reduceByKey
的一个分区对应tupleRDD
的多个分区
1 | reduceByKey` 算子会生成 `ShuffledRDD |
reduceByKey
是由算子 combineByKey
来实现的, combineByKey
内部会创建 ShuffledRDD
返回, 具体的代码请大家通过 IDEA
来进行查看, 此处不再截图, 而整个 reduceByKey
操作大致如下过程
去掉两个 reducer
端的分区, 只留下一个的话, 如下
所以, 对于 reduceByKey
这个 Shuffle
操作来说, reducer
端的一个分区, 会从多个 mapper
端的分区拿取数据, 是一个多对一的关系
至此为止, 出现了两种分区见的关系了, 一种是一对一, 一种是多对一
整体上的流程图
6.1.3. RDD 之间的依赖关系详解
导读
上个小节通过例子演示了 RDD 的分区间的关系有两种形式
一对一, 一般是直接转换
多对一, 一般是 Shuffle
本小节会说明如下问题:
- 如果分区间得关系是一对一或者多对一, 那么这种情况下的 RDD 之间的关系的正式命名是什么呢?
- RDD 之间的依赖关系, 具体有几种情况呢?
窄依赖
假如 rddB = rddA.transform(…)
, 如果 rddB
中一个分区依赖 rddA
也就是其父 RDD
的少量分区, 这种 RDD
之间的依赖关系称之为窄依赖
换句话说, 子 RDD 的每个分区依赖父 RDD 的少量个数的分区, 这种依赖关系称之为窄依赖
举个栗子
1 | `val sc = ... |
- 上述代码的
cartesian
是求得两个集合的笛卡尔积 - 上述代码的运行结果是
rddA
中每个元素和rddB
中的所有元素结合, 最终的结果数量是两个RDD
数量之和 rddC
有两个父RDD
, 分别为rddA
和rddB
对于 cartesian
来说, 依赖关系如下
上述图形中清晰展示如下现象
rddC
中的分区数量是两个父RDD
的分区数量之乘积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 )
会产生如下的依赖关系
rddB
的每个分区都几乎依赖 rddA
的所有分区
对于 rddA
中的一个分区来说, 其将一部分分发给 rddB
的 p1
, 另外一部分分发给 rddB
的 p2
, 这不是数据流动, 而是分发
如何分辨宽窄依赖 ?
其实分辨宽窄依赖的本身就是在分辨父子 RDD
之间是否有 Shuffle
, 大致有以下的方法
如果是 Shuffle
, 两个 RDD
的分区之间不是单纯的数据流动, 而是分发和复制
一般 Shuffle
的子 RDD
的每个分区会依赖父 RDD
的多个分区
但是这样判断其实不准确, 如果想分辨某个算子是否是窄依赖, 或者是否是宽依赖, 则还是要取决于具体的算子, 例如想看 cartesian
生成的是宽依赖还是窄依赖, 可以通过如下步骤
- 查看
1 | map |
算子生成的
1 | RDD |
- 进去
1 | RDD |
查看
1 | getDependence |
方法
总结
- RDD 的逻辑图本质上是对于计算过程的表达, 例如数据从哪来, 经历了哪些步骤的计算
- 每一个步骤都对应一个 RDD, 因为数据处理的情况不同, RDD 之间的依赖关系又分为窄依赖和宽依赖 *
6.1.4. 常见的窄依赖类型
导读
常见的窄依赖其实也是有分类的, 而且宽窄以来不太容易分辨, 所以通过本章, 帮助同学明确窄依赖的类型
一对一窄依赖
其实 RDD
中默认的是 OneToOneDependency
, 后被不同的 RDD
子类指定为其它的依赖类型, 常见的一对一依赖是 map
算子所产生的依赖, 例如 rddB = rddA.map(…)
每个分区之间一一对应, 所以叫做一对一窄依赖
Range 窄依赖
1 | Range` 窄依赖其实也是一对一窄依赖, 但是保留了中间的分隔信息, 可以通过某个分区获取其父分区, 目前只有一个算子生成这种窄依赖, 就是 `union` 算子, 例如 `rddC = rddA.union(rddB) |
1 | rddC` 其实就是 `rddA` 拼接 `rddB` 生成的, 所以 `rddC` 的 `p5` 和 `p6` 就是 `rddB` 的 `p1` 和 `p2 |
所以需要有方式获取到 rddC
的 p5
其父分区是谁, 于是就需要记录一下边界, 其它部分和一对一窄依赖一样
多对一窄依赖
多对一窄依赖其图形和 Shuffle
依赖非常相似, 所以在遇到的时候, 要注意其 RDD
之间是否有 Shuffle
过程, 比较容易让人困惑, 常见的多对一依赖就是重分区算子 coalesce
, 例如 rddB = rddA.coalesce(2, shuffle = false)
, 但同时也要注意, 如果 shuffle = true
那就是完全不同的情况了
因为没有 Shuffle
, 所以这是一个窄依赖
再谈宽窄依赖的区别
宽窄依赖的区别非常重要, 因为涉及了一件非常重要的事情: 如何计算 RDD
?
宽窄以来的核心区别是: 窄依赖的 RDD
可以放在一个 Task
中运行
6.2. 物理执行图生成
- 物理图的意义
- 如何划分 Task
- 如何划分 Stage
物理图的作用是什么?
问题一: 物理图的意义是什么?
物理图解决的其实就是 RDD
流程生成以后, 如何计算和运行的问题, 也就是如何把 RDD 放在集群中执行的问题
问题二: 如果要确定如何运行的问题, 则需要先确定集群中有什么组件
首先集群中物理元件就是一台一台的机器
其次这些机器上跑的守护进程有两种:
Master
,Worker
1
* 每个守护进程其实就代表了一台机器, 代表这台机器的角色, 代表这台机器和外界通信
- 例如我们常说一台机器是
Master
, 其含义是这台机器中运行了一个Master
守护进程, 如果一台机器运行了Master
的同时又运行了Worker
, 则说这台机器是Master
也可以, 说它是Worker
也行
- 例如我们常说一台机器是
真正能运行 RDD
的组件是: Executor
, 也就是说其实 RDD
最终是运行在 Executor
中的, 也就是说, 无论是 Master
还是 Worker
其实都是用于管理 Executor
和调度程序的
结论是 RDD
一定在 Executor
中计算, 而 Master
和 Worker
负责调度和管理 Executor
问题三: 物理图的生成需要考虑什么问题?
要计算 RDD
, 不仅要计算, 还要很快的计算 → 优化性能
要考虑容错, 容错的常见手段是缓存 → RDD
要可以缓存
结论是在生成物理图的时候, 不仅要考虑效率问题, 还要考虑一种更合适的方式, 让 RDD
运行的更好
谁来计算 RDD ?
问题一: RDD 是什么, 用来做什么 ?
回顾一下 RDD
的五个属性
1 | A list of partitions |
简单的说就是: 分区列表, 计算函数, 依赖关系, 分区函数, 最佳位置
分区列表, 分区函数, 最佳位置, 这三个属性其实说的就是数据集在哪, 在哪更合适, 如何分区
计算函数和依赖关系, 这两个属性其实说的是数据集从哪来
所以结论是 RDD
是一个数据集的表示, 不仅表示了数据集, 还表示了这个数据集从哪来, 如何计算
但是问题是, 谁来计算 ? 如果为一台汽车设计了一个设计图, 那么设计图自己生产汽车吗 ?
问题二: 谁来计算 ?
前面我们明确了两件事, RDD
在哪被计算? 在 Executor
中. RDD
是什么? 是一个数据集以及其如何计算的图纸.
直接使用 Executor
也是不合适的, 因为一个计算的执行总是需要一个容器, 例如 JVM
是一个进程, 只有进程中才能有线程, 所以这个计算 RDD
的线程应该运行在一个进程中, 这个进程就是 Exeutor
, Executor
有如下两个职责
和 Driver
保持交互从而认领属于自己的任务
接受任务后, 运行任务
所以, 应该由一个线程来执行 RDD
的计算任务, 而 Executor
作为执行这个任务的容器, 也就是一个进程, 用于创建和执行线程, 这个执行具体计算任务的线程叫做 Task
问题三: Task 该如何设计 ?
第一个想法是每个 RDD
都由一个 Task
来计算 第二个想法是一整个逻辑执行图中所有的 RDD
都由一组 Task
来执行 第三个想法是分阶段执行
第一个想法: 为每个 RDD 的分区设置一组 Task
大概就是每个 RDD
都有三个 Task
, 每个 Task
对应一个 RDD
的分区, 执行一个分区的数据的计算
但是这么做有一个非常难以解决的问题, 就是数据存储的问题, 例如 Task 1, 4, 7, 10, 13, 16
在同一个流程上, 但是这些 Task
之间需要交换数据, 因为这些 Task
可能被调度到不同的机器上上, 所以 Task1
执行完了数据以后需要暂存, 后交给 Task4
来获取
这只是一个简单的逻辑图, 如果是一个复杂的逻辑图, 会有什么表现? 要存储多少数据? 无论是放在磁盘还是放在内存中, 是不是都是一种极大的负担?
第二个想法: 让数据流动
很自然的, 第一个想法的问题是数据需要存储和交换, 那不存储不就好了吗? 对, 可以让数据流动起来
第一个要解决的问题就是, 要为数据创建管道(Pipeline
), 有了管道, 就可以流动
简单来说, 就是为所有的 RDD
有关联的分区使用同一个 Task
, 但是就没问题了吗? 请关注红框部分
这两个 RDD
之间是 Shuffle
关系, 也就是说, 右边的 RDD
的一个分区可能依赖左边 RDD
的所有分区, 这样的话, 数据在这个地方流不动了, 怎么办?
第三个想法: 划分阶段
既然在 Shuffle
处数据流不动了, 那就可以在这个地方中断一下, 后面 Stage
部分详解
如何划分阶段 ?
为了减少执行任务, 减少数据暂存和交换的机会, 所以需要创建管道, 让数据沿着管道流动, 其实也就是原先每个 RDD
都有一组 Task
, 现在改为所有的 RDD
共用一组 Task
, 但是也有问题, 问题如下
就是说, 在 Shuffle
处, 必须断开管道, 进行数据交换, 交换过后, 继续流动, 所以整个流程可以变为如下样子
把 Task
断开成两个部分, Task4
可以从 Task 1, 2, 3
中获取数据, 后 Task4
又作为管道, 继续让数据在其中流动
但是还有一个问题, 说断开就直接断开吗? 不用打个招呼的呀? 这个断开即没有道理, 也没有规则, 所以可以为这个断开增加一个概念叫做阶段, 按照阶段断开, 阶段的英文叫做 Stage
, 如下
所以划分阶段的本身就是设置断开点的规则, 那么该如何划分阶段呢?
- 第一步, 从最后一个
RDD
, 也就是逻辑图中最右边的RDD
开始, 向前滑动Stage
的范围, 为Stage0
- 第二步, 遇到
ShuffleDependency
断开Stage
, 从下一个RDD
开始创建新的Stage
, 为Stage1
- 第三步, 新的
Stage
按照同样的规则继续滑动, 直到包裹所有的RDD
总结来看, 就是针对于宽窄依赖来判断, 一个 Stage
中只有窄依赖, 因为只有窄依赖才能形成数据的 Pipeline
.
如果要进行 Shuffle
的话, 数据是流不过去的, 必须要拷贝和拉取. 所以遇到 RDD
宽依赖的两个 RDD
时, 要切断这两个 RDD
的 Stage
.
这样一个 RDD 依赖的链条, 我们称之为 RDD 的血统, 其中有宽依赖也有窄依赖 | |
---|---|
数据怎么流动 ?
1 | `val sc = ... |
上述代码是这个章节我们一直使用的代码流程, 如下是其完整的逻辑执行图
如果放在集群中运行, 通过 WebUI
可以查看到如下 DAG
结构
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
开始, 类似一个递归的过程
6.3. 调度过程
导读
生成逻辑图和物理图的系统组件
Job
和 Stage
, Task
之间的关系
如何调度 Job
逻辑图
是什么 怎么生成 具体怎么生成
1 | `val textRDD = sc.parallelize(Seq("Hadoop Spark", "Hadoop Flume", "Spark Sqoop")) |
逻辑图如何生成
上述代码在 Spark Application
的 main
方法中执行, 而 Spark Application
在 Driver
中执行, 所以上述代码在 Driver
中被执行, 那么这段代码执行的结果是什么呢?
一段 Scala
代码的执行结果就是最后一行的执行结果, 所以上述的代码, 从逻辑上执行结果就是最后一个 RDD
, 最后一个 RDD
也可以认为就是逻辑执行图, 为什么呢?
例如 rdd2 = rdd1.map(…)
中, 其实本质上 rdd2
是一个类型为 MapPartitionsRDD
的对象, 而创建这个对象的时候, 会通过构造函数传入当前 RDD
对象, 也就是父 RDD
, 也就是调用 map
算子的 rdd1
, rdd1
是 rdd2
的父 RDD
一个 RDD
依赖另外一个 RDD
, 这个 RDD
又依赖另外的 RDD
, 一个 RDD
可以通过 getDependency
获得其父 RDD
, 这种环环相扣的关系, 最终从最后一个 RDD
就可以推演出前面所有的 RDD
逻辑图是什么, 干啥用
逻辑图其实本质上描述的就是数据的计算过程, 数据从哪来, 经过什么样的计算, 得到什么样的结果, 再执行什么计算, 得到什么结果
可是数据的计算是描述好了, 这种计算该如何执行呢?
物理图
数据的计算表示好了, 该正式执行了, 但是如何执行? 如何执行更快更好更酷? 就需要为其执行做一个规划, 所以需要生成物理执行图
1 | `strRDD.collect.foreach(item => println(item))` |
上述代码其实就是最后的一个 RDD
调用了 Action
方法, 调用 Action
方法的时候, 会请求一个叫做 DAGScheduler
的组件, DAGScheduler
会创建用于执行 RDD
的 Stage
和 Task
DAGScheduler
是一个由 SparkContext
创建, 运行在 Driver
上的组件, 其作用就是将由 RDD
构建出来的逻辑计划, 构建成为由真正在集群中运行的 Task
组成的物理执行计划, DAGScheduler
主要做如下三件事
帮助每个 Job
计算 DAG
并发给 TaskSheduler
调度
确定每个 Task
的最佳位置
跟踪 RDD
的缓存状态, 避免重新计算
从字面意思上来看, DAGScheduler
是调度 DAG
去运行的, DAG
被称作为有向无环图, 其实可以将 DAG
理解为就是 RDD
的逻辑图, 其呈现两个特点: RDD
的计算是有方向的, RDD
的计算是无环的, 所以 DAGScheduler
也可以称之为 RDD Scheduler
, 但是真正运行在集群中的并不是 RDD
, 而是 Task
和 Stage
, DAGScheduler
负责这种转换
Job
是什么 ?
Job
什么时候生成 ?
当一个 RDD
调用了 Action
算子的时候, 在 Action
算子内部, 会使用 sc.runJob()
调用 SparkContext
中的 runJob
方法, 这个方法又会调用 DAGScheduler
中的 runJob
, 后在 DAGScheduler
中使用消息驱动的形式创建 Job
简而言之, Job
在 RDD
调用 Action
算子的时候生成, 而且调用一次 Action
算子, 就会生成一个 Job
, 如果一个 SparkApplication
中调用了多次 Action
算子, 会生成多个 Job
串行执行, 每个 Job
独立运作, 被独立调度, 所以 RDD
的计算也会被执行多次
Job
是什么 ?
如果要将 Spark
的程序调度到集群中运行, Job
是粒度最大的单位, 调度以 Job
为最大单位, 将 Job
拆分为 Stage
和 Task
去调度分发和运行, 一个 Job
就是一个 Spark
程序从 读取 → 计算 → 运行
的过程
一个 Spark Application
可以包含多个 Job
, 这些 Job
之间是串行的, 也就是第二个 Job
需要等待第一个 Job
的执行结束后才会开始执行
Job
和 Stage
的关系
Job
是一个最大的调度单位, 也就是说 DAGScheduler
会首先创建一个 Job
的相关信息, 后去调度 Job
, 但是没办法直接调度 Job
, 比如说现在要做一盘手撕包菜, 不可能直接去炒一整颗包菜, 要切好撕碎, 再去炒
为什么 Job
需要切分 ?
因为
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
执行完毕
Stage
和 Task
的关系
前面我们说到 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
TaskSet
中 Task
的个数由 Stage
中的最大分区数决定
整体执行流程
6.3. Shuffle 过程
导读
本章节重点是介绍 Shuffle
的流程, 因为根据 ShuffleWriter
的实现不同, 其过程也不同, 所以前半部分根据默认的存储引擎 SortShuffleWriter
来讲解
后半部分简要介绍一下其它的 ShuffleWriter
Shuffle
过程的组件结构
从整体视角上来看, Shuffle
发生在两个 Stage
之间, 一个 Stage
把数据计算好, 整理好, 等待另外一个 Stage
来拉取
放大视角, 会发现, 其实 Shuffle
发生在 Task
之间, 一个 Task
把数据整理好, 等待 Reducer
端的 Task
来拉取
如果更细化一下, Task
之间如何进行数据拷贝的呢? 其实就是一方 Task
把文件生成好, 然后另一方 Task
来拉取
现在是一个 Reducer
的情况, 如果有多个 Reducer
呢? 如果有多个 Reducer
的话, 就可以在每个 Mapper
为所有的 Reducer
生成各一个文件, 这种叫做 Hash base shuffle
, 这种 Shuffle
的方式问题大家也知道, 就是生成中间文件过多, 而且生成文件的话需要缓冲区, 占用内存过大
那么可以把这些文件合并起来, 生成一个文件返回, 这种 Shuffle
方式叫做 Sort base shuffle
, 每个 Reducer
去文件的不同位置拿取数据
如果再细化一下, 把参与这件事的组件也放置进去, 就会是如下这样
有哪些 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
文件为一个大文件, 同时在进行内存存储的时候使用了 Java
得 Unsafe API
, 也就是使用堆外内存, 是钨丝计划的一部分
也不是很常用, 只有在满足如下三个条件时候才会启用
1 | * 序列化器序列化后的数据, 必须支持排序 |
- 没有
Mapper
端的聚合 Reducer
的个数不能超过支持的上限 (2 ^ 24)
SortShuffleWriter
的执行过程
整个 SortShuffleWriter
如上述所说, 大致有如下几步
首先
SortShuffleWriter
在write
方法中回去写文件, 这个方法中创建了ExternalSorter
write
中将数据insertAll
到ExternalSorter
中在
ExternalSorter
中排序1
1. 如果要聚合, 放入 `AppendOnlyMap` 中, 如果不聚合, 放入 `PartitionedPairBuffer` 中
- 在数据结构中进行排序, 排序过程中如果内存数据大于阈值则溢写到磁盘
使用 ExternalSorter
的 writePartitionedFile
写入输入文件
1 | 1. 将所有的溢写文件通过类似 `MergeSort` 的算法合并 |
- 将数据写入最终的目标文件中
7. RDD 的分布式共享变量
目标
理解闭包以及 Spark 分布式运行代码的根本原理
理解累加变量的使用场景
理解广播的使用场景
什么是闭包
闭包是一个必须要理解, 但是又不太好理解的知识点, 先看一个小例子
1 | `@Test |
上述例子中, closure
方法返回的一个函数的引用, 其实就是一个闭包, 闭包本质上就是一个封闭的作用域, 要理解闭包, 是一定要和作用域联系起来的.
能否在 test
方法中访问 closure
定义的变量?
1 | `@Test |
有没有什么间接的方式?
1 | `@Test |
什么是闭包?
1 | `val areaFunction = closure() |
通过 closure
返回的函数 areaFunction
就是一个闭包, 其函数内部的作用域并不是 test
函数的作用域, 这种连带作用域一起打包的方式, 我们称之为闭包, 在 Scala 中
- Scala 中的闭包本质上就是一个对象, 是 FunctionX 的实例*
分发闭包
1 | `sc.textFile("dataset/access_log_sample.txt") |
上述这段代码中, flatMap
中传入的是另外一个函数, 传入的这个函数就是一个闭包, 这个闭包会被序列化运行在不同的 Executor 中
1 | `class MyClass { |
这段代码中的闭包就有了一个依赖, 依赖于外部的一个类, 因为传递给算子的函数最终要在 Executor 中运行, 所以需要 序列化 MyClass
发给每一个 Executor
, 从而在 Executor
访问 MyClass
对象的属性
总结
- 闭包就是一个封闭的作用域, 也是一个对象
- Spark 算子所接受的函数, 本质上是一个闭包, 因为其需要封闭作用域, 并且序列化自身和依赖, 分发到不同的节点中运行
7.1. 累加器
一个小问题
1 | `var count = 0 |
上面这段代码是一个非常错误的使用, 请不要仿照, 这段代码只是为了证明一些事情
先明确两件事, var count = 0
是在 Driver 中定义的, foreach(count += _)
这个算子以及传递进去的闭包运行在 Executor 中
这段代码整体想做的事情是累加一个变量, 但是这段代码的写法却做不到这件事, 原因也很简单, 因为具体的算子是闭包, 被分发给不同的节点运行, 所以这个闭包中累加的并不是 Driver 中的这个变量
全局累加器
Accumulators(累加器) 是一个只支持 added
(添加) 的分布式变量, 可以在分布式环境下保持一致性, 并且能够做到高效的并发.
原生 Spark 支持数值型的累加器, 可以用于实现计数或者求和, 开发者也可以使用自定义累加器以实现更高级的需求
1 | `val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]") |
注意点:
- Accumulator 是支持并发并行的, 在任何地方都可以通过
add
来修改数值, 无论是 Driver 还是 Executor - 只能在 Driver 中才能调用
value
来获取数值
在 WebUI 中关于 Job 部分也可以看到 Accumulator 的信息, 以及其运行的情况
累计器件还有两个小特性, 第一, 累加器能保证在 Spark 任务出现问题被重启的时候不会出现重复计算. 第二, 累加器只有在 Action 执行的时候才会被触发.
1 | `val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]") |
自定义累加器
开发者可以通过自定义累加器来实现更多类型的累加器, 累加器的作用远远不只是累加, 比如可以实现一个累加器, 用于向里面添加一些运行信息
1 | `class InfoAccumulator extends AccumulatorV2[String, Set[String]] { |
注意点:
可以通过继承 AccumulatorV2
来创建新的累加器
有几个方法需要重写
1 | * reset 方法用于把累加器重置为 0 |
- add 方法用于把其它值添加到累加器中
- merge 方法用于指定如何合并其他的累加器
value
需要返回一个不可变的集合, 因为不能因为外部的修改而影响自身的值
7.2. 广播变量
目标
- 理解为什么需要广播变量, 以及其应用场景
- 能够通过代码使用广播变量
广播变量的作用
广播变量允许开发者将一个 Read-Only
的变量缓存到集群中每个节点中, 而不是传递给每一个 Task 一个副本.
- 集群中每个节点, 指的是一个机器
- 每一个 Task, 一个 Task 是一个 Stage 中的最小处理单元, 一个 Executor 中可以有多个 Stage, 每个 Stage 有多个 Task
所以在需要跨多个 Stage 的多个 Task 中使用相同数据的情况下, 广播特别的有用
广播变量的API
方法名 | 描述 |
---|---|
id |
唯一标识 |
value |
广播变量的值 |
unpersist |
在 Executor 中异步的删除缓存副本 |
destroy |
销毁所有此广播变量所关联的数据和元数据 |
toString |
字符串表示 |
使用广播变量的一般套路
可以通过如下方式创建广播变量
1 | `val b = sc.broadcast(1)` |
如果 Log 级别为 DEBUG 的时候, 会打印如下信息
1 | `DEBUG BlockManager: Put block broadcast_0 locally took 430 ms |
创建后可以使用 value
获取数据
1 | `b.value` |
获取数据的时候会打印如下信息
1 | `DEBUG BlockManager: Getting local block broadcast_0 |
广播变量使用完了以后, 可以使用 unpersist
删除数据
1 | `b.unpersist` |
删除数据以后, 可以使用 destroy
销毁变量, 释放内存空间
1 | `b.destroy` |
销毁以后, 会打印如下信息
1 | `DEBUG BlockManager: Removing broadcast 0 |
使用 value
方法的注意点
方法签名 value: T
在 value
方法内部会确保使用获取数据的时候, 变量必须是可用状态, 所以必须在变量被 destroy
之前使用 value
方法, 如果使用 value
时变量已经失效, 则会爆出以下错误
1 | `org.apache.spark.SparkException: Attempted to use Broadcast(0) after it was destroyed (destroy at <console>:27) |
使用 destroy
方法的注意点
方法签名 destroy(): Unit
destroy
方法会移除广播变量, 彻底销毁掉, 但是如果你试图多次 destroy
广播变量, 则会爆出以下错误
1 | `org.apache.spark.SparkException: Attempted to use Broadcast(0) after it was destroyed (destroy at <console>:27) |
广播变量的使用场景
假设我们在某个算子中需要使用一个保存了项目和项目的网址关系的 Map[String, String]
静态集合, 如下
1 | `val pws = Map("Apache Spark" -> "http://spark.apache.org/", "Scala" -> "http://www.scala-lang.org/") |
上面这段代码是没有问题的, 可以正常运行的, 但是非常的低效, 因为虽然可能 pws
已经存在于某个 Executor
中了, 但是在需要的时候还是会继续发往这个 Executor
, 如果想要优化这段代码, 则需要尽可能的降低网络开销
可以使用广播变量进行优化, 因为广播变量会缓存在集群中的机器中, 比 Executor
在逻辑上更 “大”
1 | `val pwsB = sc.broadcast(pws) |
上面两段代码所做的事情其实是一样的, 但是当需要运行多个 Executor
(以及多个 Task
) 的时候, 后者的效率更高
扩展
正常情况下使用 Task 拉取数据的时候, 会将数据拷贝到 Executor 中多次, 但是使用广播变量的时候只会复制一份数据到 Executor 中, 所以在两种情况下特别适合使用广播变量
一个 Executor 中有多个 Task 的时候
一个变量比较大的时候
而且在 Spark 中还有一个约定俗称的做法, 当一个 RDD 很大并且还需要和另外一个 RDD 执行 join
的时候, 可以将较小的 RDD 广播出去, 然后使用大的 RDD 在算子 map
中直接 join
, 从而实现在 Map 端 join
1 | `val acMap = sc.broadcast(myRDD.map { case (a,b,c,b) => (a, c) }.collectAsMap) |
一般情况下在这种场景下, 会广播 Map 类型的数据, 而不是数组, 因为这样容易使用 Key 找到对应的 Value 简化使用
总结
- 广播变量用于将变量缓存在集群中的机器中, 避免机器内的 Executors 多次使用网络拉取数据
- 广播变量的使用步骤: (1) 创建 (2) 在 Task 中获取值 (3) 销毁
SparkSQL
目标
SparkSQL
是什么SparkSQL
如何使用
1. SparkSQL 是什么
目标
对于一件事的理解, 应该分为两个大部分, 第一, 它是什么, 第二, 它解决了什么问题
- 理解为什么会有
SparkSQL
- 理解
SparkSQL
所解决的问题, 以及它的使命
1.1. SparkSQL 的出现契机
目标
理解 SparkSQL
是什么
主线
- 历史前提
- 发展过程
- 重要性
数据分析的方式
数据分析的方式大致上可以划分为 SQL
和 命令式两种
命令式
在前面的 RDD
部分, 非常明显可以感觉的到是命令式的, 主要特征是通过一个算子, 可以得到一个结果, 通过结果再进行后续计算.
1 | sc.textFile("...") |
命令式的优点
- 操作粒度更细, 能够控制数据的每一个处理环节
- 操作更明确, 步骤更清晰, 容易维护
- 支持非结构化数据的操作
命令式的缺点
- 需要一定的代码功底
- 写起来比较麻烦
SQL
对于一些数据科学家, 要求他们为了做一个非常简单的查询, 写一大堆代码, 明显是一件非常残忍的事情, 所以 SQL on Hadoop
是一个非常重要的方向.
1 | SELECT |
SQL 的优点
- 表达非常清晰, 比如说这段
SQL
明显就是为了查询三个字段, 又比如说这段SQL
明显能看到是想查询年龄大于 10 岁的条目
SQL 的缺点
- 想想一下 3 层嵌套的
SQL
, 维护起来应该挺力不从心的吧 - 试想一下, 如果使用
SQL
来实现机器学习算法, 也挺为难的吧
SQL
擅长数据分析和通过简单的语法表示查询, 命令式操作适合过程式处理和算法性的处理. 在 Spark
出现之前, 对于结构化数据的查询和处理, 一个工具一向只能支持 SQL
或者命令式, 使用者被迫要使用多个工具来适应两种场景, 并且多个工具配合起来比较费劲.
而 Spark
出现了以后, 统一了两种数据处理范式, 是一种革新性的进步.
因为 SQL
是数据分析领域一个非常重要的范式, 所以 Spark
一直想要支持这种范式, 而伴随着一些决策失误, 这个过程其实还是非常曲折的
Hive
解决的问题
Hive
实现了SQL on Hadoop
, 使用MapReduce
执行任务- 简化了
MapReduce
任务
新的问题
Hive
的查询延迟比较高, 原因是使用MapReduce
做调度
Shark
解决的问题
Shark
改写Hive
的物理执行计划, 使用Spark
作业代替MapReduce
执行物理计划- 使用列式内存存储
- 以上两点使得
Shark
的查询效率很高
新的问题
Shark
重用了Hive
的SQL
解析, 逻辑计划生成以及优化, 所以其实可以认为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
进行探索式分析
重要性![]() SparkSQL 不只是一个 SQL 引擎, SparkSQL 也包含了一套对 结构化数据的命令式 API , 事实上, 所有 Spark 中常见的工具, 都是依赖和依照于 SparkSQL 的 API 设计的 |
|
---|---|
总结: SparkSQL
是什么
1 | SparkSQL` 是一个为了支持 `SQL` 而设计的工具, 但同时也支持命令式的 `API |
1.2. SparkSQL 的适用场景
目标
理解 SparkSQL
的适用场景
定义 | 特点 | 举例 | |
---|---|---|---|
结构化数据 | 有固定的 Schema |
有预定义的 Schema |
关系型数据库的表 |
半结构化数据 | 没有固定的 Schema , 但是有结构 |
没有固定的 Schema , 有结构信息, 数据一般是自描述的 |
指一些有结构的文件格式, 例如 JSON |
非结构化数据 | 没有固定 Schema , 也没有结构 |
没有固定 Schema , 也没有结构 |
指文档图片之类的格式 |
结构化数据
一般指数据有固定的 Schema
, 例如在用户表中, name
字段是 String
型, 那么每一条数据的 name
字段值都可以当作 String
来使用
1 | +----+--------------+---------------------------+-------+---------+ |
半结构化数据
一般指的是数据没有固定的 Schema
, 但是数据本身是有结构的
1 | { |
没有固定 Schema
指的是半结构化数据是没有固定的 Schema
的, 可以理解为没有显式指定 Schema
比如说一个用户信息的 JSON
文件, 第一条数据的 phone_num
有可能是 String
, 第二条数据虽说应该也是 String
, 但是如果硬要指定为 BigInt
, 也是有可能的
因为没有指定 Schema
, 没有显式的强制的约束
有结构
虽说半结构化数据是没有显式指定 Schema
的, 也没有约束, 但是半结构化数据本身是有有隐式的结构的, 也就是数据自身可以描述自身
例如 JSON
文件, 其中的某一条数据是有字段这个概念的, 每个字段也有类型的概念, 所以说 JSON
是可以描述自身的, 也就是数据本身携带有元信息
SparkSQL
处理什么数据的问题?
Spark
的RDD
主要用于处理 非结构化数据 和 半结构化数据SparkSQL
主要用于处理 结构化数据
SparkSQL
相较于 RDD
的优势在哪?
SparkSQL
提供了更好的外部数据源读写支持- 因为大部分外部数据源是有结构化的, 需要在
RDD
之外有一个新的解决方案, 来整合这些结构化数据源
- 因为大部分外部数据源是有结构化的, 需要在
SparkSQL
提供了直接访问列的能力- 因为
SparkSQL
主要用做于处理结构化数据, 所以其提供的API
具有一些普通数据库的能力
- 因为
总结: SparkSQL
适用于什么场景?
SparkSQL
适用于处理结构化数据的场景
本章总结
SparkSQL
是一个即支持SQL
又支持命令式数据处理的工具SparkSQL
的主要适用场景是处理结构化数据
2. SparkSQL 初体验
目标
- 了解
SparkSQL
的API
由哪些部分组成
2.3. RDD 版本的 WordCount
1 | val config = new SparkConf().setAppName("ip_ana").setMaster("local[6]") |
RDD
版本的代码有一个非常明显的特点, 就是它所处理的数据是基本类型的, 在算子中对整个数据进行处理
2.2. 命令式 API 的入门案例
1 | case class People(name: String, age: Int) |
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
等的时候是不灵活的, 而DataFrame
和Dataset
一开始的设计目标就是要支持更多的数据源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
1 | SparkSQL` 最大的特点就是它针对于结构化数据设计, 所以 `SparkSQL` 应该是能支持针对某一个字段的访问的, 而这种访问方式有一个前提, 就是 `SparkSQL` 的数据集中, 要 **包含结构化信息**, 也就是俗称的 `Schema |
而 SparkSQL
对外提供的 API
有两类, 一类是直接执行 SQL
, 另外一类就是命令式. SparkSQL
提供的命令式 API
就是 DataFrame
和 Dataset
, 暂时也可以认为 DataFrame
就是 Dataset
, 只是在不同的 API
中返回的是 Dataset
的不同表现形式
1 | // RDD |
通过上面的代码, 可以清晰的看到, SparkSQL
的命令式操作相比于 RDD
来说, 可以直接通过 Schema
信息来访问其中某个字段, 非常的方便
2.2. SQL 版本 WordCount
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
以往使用 SQL
肯定是要有一个表的, 在 Spark
中, 并不存在表的概念, 但是有一个近似的概念, 叫做 DataFrame
, 所以一般情况下要先通过 DataFrame
或者 Dataset
注册一张临时表, 然后使用 SQL
操作这张临时表
总结
SparkSQL
提供了 SQL
和 命令式 API
两种不同的访问结构化数据的形式, 并且它们之间可以无缝的衔接
命令式 API
由一个叫做 Dataset
的组件提供, 其还有一个变形, 叫做 DataFrame
3. [扩展] Catalyst 优化器
目标
- 理解
SparkSQL
和以RDD
为代表的SparkCore
最大的区别 - 理解优化器的运行原理和作用
3.1. RDD 和 SparkSQL 运行时的区别
RDD
的运行流程
大致运行步骤
先将 RDD
解析为由 Stage
组成的 DAG
, 后将 Stage
转为 Task
直接运行
问题
任务会按照代码所示运行, 依赖开发者的优化, 开发者的会在很大程度上影响运行效率
解决办法
创建一个组件, 帮助开发者修改和优化代码, 但是这在 RDD
上是无法实现的
为什么 RDD
无法自我优化?
RDD
没有Schema
信息RDD
可以同时处理结构化和非结构化的数据
SparkSQL
提供了什么?
和 RDD
不同, SparkSQL
的 Dataset
和 SQL
并不是直接生成计划交给集群执行, 而是经过了一个叫做 Catalyst
的优化器, 这个优化器能够自动帮助开发者优化代码
也就是说, 在 SparkSQL
中, 开发者的代码即使不够优化, 也会被优化为相对较好的形式去执行
为什么 SparkSQL
提供了这种能力?
首先, SparkSQL
大部分情况用于处理结构化数据和半结构化数据, 所以 SparkSQL
可以获知数据的 Schema
, 从而根据其 Schema
来进行优化
3.2. Catalyst
为了解决过多依赖 Hive 的问题, SparkSQL 使用了一个新的 SQL 优化器替代 Hive 中的优化器, 这个优化器就是 Catalyst , 整个 SparkSQL 的架构大致如下![]() API 层简单的说就是 Spark 会通过一些 API 接受 SQL 语句收到 SQL 语句以后, 将其交给 Catalyst , Catalyst 负责解析 SQL , 生成执行计划等Catalyst 的输出应该是 RDD 的执行计划最终交由集群运行 |
|
---|---|
Step 1 : 解析 SQL
, 并且生成 AST
(抽象语法树)
Step 2 : 在 AST
中加入元数据信息, 做这一步主要是为了一些优化, 例如 col = col
这样的条件, 下图是一个简略图, 便于理解
score.id → id#1#L
为score.id
生成id
为 1, 类型是Long
score.math_score → math_score#2#L
为score.math_score
生成id
为 2, 类型为Long
people.id → id#3#L
为people.id
生成id
为 3, 类型为Long
people.age → age#4#L
为people.age
生成id
为 4, 类型为Long
Step 3 : 对已经加入元数据的 AST
, 输入优化器, 进行优化, 从两种常见的优化开始, 简单介绍
- 谓词下推
Predicate Pushdown
, 将Filter
这种可以减小数据集的操作下推, 放在Scan
的位置, 这样可以减少操作时候的数据量
列值裁剪
Column Pruning
, 在谓词下推后,people
表之上的操作只用到了id
列, 所以可以把其它列裁剪掉, 这样可以减少处理的数据量, 从而优化处理速度还有其余很多优化点, 大概一共有一二百种, 随着
SparkSQL
的发展, 还会越来越多, 感兴趣的同学可以继续通过源码了解, 源码在org.apache.spark.sql.catalyst.optimizer.Optimizer
Step 4 : 上面的过程生成的 AST
其实最终还没办法直接运行, 这个 AST
叫做 逻辑计划
, 结束后, 需要生成 物理计划
, 从而生成 RDD
来运行
- 在生成
物理计划
的时候, 会经过成本模型
对整棵树再次执行优化, 选择一个更好的计划 - 在生成
物理计划
以后, 因为考虑到性能, 所以会使用代码生成, 在机器中运行
可以使用 queryExecution 方法查看逻辑执行计划, 使用 explain 方法查看物理执行计划![]() ![]() Spark WebUI 进行查看![]() |
|
---|---|
总结
SparkSQL
和 RDD
不同的主要点是在于其所操作的数据是结构化的, 提供了对数据更强的感知和分析能力, 能够对代码进行更深层的优化, 而这种能力是由一个叫做 Catalyst
的优化器所提供的
Catalyst
的主要运作原理是分为三步, 先对 SQL
或者 Dataset
的代码解析, 生成逻辑计划, 后对逻辑计划进行优化, 再生成物理计划, 最后生成代码到集群中以 RDD
的形式运行
4. Dataset 的特点
目标
- 理解
Dataset
是什么 - 理解
Dataset
的特性
Dataset
是什么?
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
问题1: People
是什么?
People
是一个强类型的类
问题2: 这个 Dataset
中是结构化的数据吗?
非常明显是的, 因为 People
对象中有结构信息, 例如字段名和字段类型
问题3: 这个 Dataset
能够使用类似 SQL
这样声明式结构化查询语句的形式来查询吗?
当然可以, 已经演示过了
问题4: Dataset
是什么?
1 | Dataset` 是一个强类型, 并且类型安全的数据容器, 并且提供了结构化查询 `API` 和类似 `RDD` 一样的命令式 `API |
即使使用 Dataset
的命令式 API
, 执行计划也依然会被优化
Dataset
具有 RDD
的方便, 同时也具有 DataFrame
的性能优势, 并且 Dataset
还是强类型的, 能做到类型安全.
1 | scala> spark.range(1).filter('id === 0).explain(true) |
Dataset
的底层是什么?
Dataset
最底层处理的是对象的序列化形式, 通过查看 Dataset
生成的物理执行计划, 也就是最终所处理的 RDD
, 就可以判定 Dataset
底层处理的是什么形式的数据
1 | val dataset: Dataset[People] = spark.createDataset(Seq(People("zhangsan", 9), People("lisi", 15))) |
所以, Dataset
的范型对象在执行之前, 需要通过 Encoder
转换为 InternalRow
, 在输入之前, 需要把 InternalRow
通过 Decoder
转换为范型对象
可以获取 Dataset
对应的 RDD
表示
在 Dataset
中, 可以使用一个属性 rdd
来得到它的 RDD
表示, 例如 Dataset[T] → RDD[T]
1 | val dataset: Dataset[People] = spark.createDataset(Seq(People("zhangsan", 9), People("lisi", 15))) |
1 | 使用 Dataset.rdd 将 Dataset 转为 RDD 的形式 |
---|---|
2 | Dataset 的执行计划底层的 RDD |
可以看到 (1)
对比 (2)
对了两个步骤, 这两个步骤的本质就是将 Dataset
底层的 InternalRow
转为 RDD
中的对象形式, 这个操作还是会有点重的, 所以慎重使用 rdd
属性来转换 Dataset
为 RDD
总结
Dataset
是一个新的Spark
组件, 其底层还是RDD
Dataset
提供了访问对象中某个特定字段的能力, 不用像RDD
一样每次都要针对整个对象做操作Dataset
和RDD
不同, 如果想把Dataset[T]
转为RDD[T]
, 则需要对Dataset
底层的InternalRow
做转换, 是一个比价重量级的操作
5. DataFrame 的作用和常见操作
目标
- 理解
DataFrame
是什么 - 理解
DataFrame
的常见操作
DataFrame
是什么?
DataFrame
是 SparkSQL
中一个表示关系型数据库中 表
的函数式抽象, 其作用是让 Spark
处理大规模结构化数据的时候更加容易. 一般 DataFrame
可以处理结构化的数据, 或者是半结构化的数据, 因为这两类数据中都可以获取到 Schema
信息. 也就是说 DataFrame
中有 Schema
信息, 可以像操作表一样操作 DataFrame
.
DataFrame
由两部分构成, 一是 row
的集合, 每个 row
对象表示一个行, 二是描述 DataFrame
结构的 Schema
.
DataFrame
支持 SQL
中常见的操作, 例如: select
, filter
, join
, group
, sort
, join
等
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
通过隐式转换创建 DataFrame
这种方式本质上是使用 SparkSession
中的隐式转换来进行的
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
根据源码可以知道, toDF
方法可以在 RDD
和 Seq
中使用
通过集合创建 DataFrame
的时候, 集合中不仅可以包含样例类, 也可以只有普通数据类型, 后通过指定列名来创建
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
通过外部集合创建 DataFrame
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
不仅可以从 csv
文件创建 DataFrame
, 还可以从 Table
, JSON
, Parquet
等中创建 DataFrame
, 后续会有单独的章节来介绍
在 DataFrame
上可以使用的常规操作
需求: 查看每个月的统计数量
Step 1: 首先可以打印 DataFrame
的 Schema
, 查看其中所包含的列, 以及列的类型
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
Step 2: 对于大部分计算来说, 可能不会使用所有的列, 所以可以选择其中某些重要的列
1 | ... |
Step 3: 可以针对某些列进行分组, 后对每组数据通过函数做聚合
1 | ... |
使用 SQL
操作 DataFrame
使用 SQL
来操作某个 DataFrame
的话, SQL
中必须要有一个 from
子句, 所以需要先将 DataFrame
注册为一张临时表
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
总结
DataFrame
是一个类似于关系型数据库表的函数式组件DataFrame
一般处理结构化数据和半结构化数据DataFrame
具有数据对象的 Schema 信息- 可以使用命令式的
API
操作DataFrame
, 同时也可以使用SQL
操作DataFrame
DataFrame
可以由一个已经存在的集合直接创建, 也可以读取外部的数据源来创建
6. Dataset 和 DataFrame 的异同
目标
- 理解
Dataset
和DataFrame
之间的关系
1 | DataFrame` 就是 `Dataset |
根据前面的内容, 可以得到如下信息
Dataset
中可以使用列来访问数据,DataFrame
也可以Dataset
的执行是优化的,DataFrame
也是Dataset
具有命令式API
, 同时也可以使用SQL
来访问,DataFrame
也可以使用这两种不同的方式访问
所以这件事就比较蹊跷了, 两个这么相近的东西为什么会同时出现在 SparkSQL
中呢?
确实, 这两个组件是同一个东西, DataFrame
是 Dataset
的一种特殊情况, 也就是说 DataFrame
是 Dataset[Row]
的别名
DataFrame
和 Dataset
所表达的语义不同
第一点: DataFrame
表达的含义是一个支持函数式操作的 表
, 而 Dataset
表达是是一个类似 RDD
的东西, Dataset
可以处理任何对象
第二点: DataFrame
中所存放的是 Row
对象, 而 Dataset
中可以存放任何类型的对象
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
1 | DataFrame 就是 Dataset[Row] |
---|---|
2 | Dataset 的范型可以是任意类型 |
第三点: DataFrame
的操作方式和 Dataset
是一样的, 但是对于强类型操作而言, 它们处理的类型不同
1 | DataFrame` 在进行强类型操作时候, 例如 `map` 算子, 其所处理的数据类型永远是 `Row |
但是对于 Dataset
来讲, 其中是什么类型, 它就处理什么类型
1 | ds.map( (item: People) => People(item.name, item.age * 10) ).show() |
第三点: DataFrame
只能做到运行时类型检查, Dataset
能做到编译和运行时都有类型检查
DataFrame
中存放的数据以Row
表示, 一个Row
代表一行数据, 这和关系型数据库类似DataFrame
在进行map
等操作的时候,DataFrame
不能直接使用Person
这样的Scala
对象, 所以无法做到编译时检查Dataset
表示的具体的某一类对象, 例如Person
, 所以再进行map
等操作的时候, 传入的是具体的某个Scala
对象, 如果调用错了方法, 编译时就会被检查出来
1 | val ds: Dataset[People] = Seq(People("zhangsan", 15), People("lisi", 15)).toDS() |
1 | 这行代码明显报错, 无法通过编译 |
---|---|
Row
是什么?
1 | Row` 对象表示的是一个 `行 |
Row
的操作类似于 Scala
中的 Map
数据类型
1 | // 一个对象就是一个对象 |
DataFrame
和 Dataset
之间可以非常简单的相互转换
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
总结
DataFrame
就是Dataset
, 他们的方式是一样的, 也都支持API
和SQL
两种操作方式DataFrame
只能通过表达式的形式, 或者列的形式来访问数据, 只有Dataset
支持针对于整个对象的操作DataFrame
中的数据表示为Row
, 是一个行的概念
7. 数据读写
目标
- 理解外部数据源的访问框架
- 掌握常见的数据源读写方式
7.1. 初识 DataFrameReader
目标
- 理解
DataFrameReader
的整体结构和组成
1 | SparkSQL` 的一个非常重要的目标就是完善数据读取, 所以 `SparkSQL` 中增加了一个新的框架, 专门用于读取外部数据源, 叫做 `DataFrameReader |
DataFrameReader
由如下几个组件组成
组件 | 解释 |
---|---|
schema |
结构信息, 因为 Dataset 是有结构的, 所以在读取数据的时候, 就需要有 Schema 信息, 有可能是从外部数据源获取的, 也有可能是指定的 |
option |
连接外部数据源的参数, 例如 JDBC 的 URL , 或者读取 CSV 文件是否引入 Header 等 |
format |
外部数据源的格式, 例如 csv , jdbc , json 等 |
DataFrameReader
有两种访问方式, 一种是使用 load
方法加载, 使用 format
指定加载格式, 还有一种是使用封装方法, 类似 csv
, json
, jdbc
等
1 | import org.apache.spark.sql.SparkSession |
但是其实这两种方式本质上一样, 因为类似 csv
这样的方式只是 load
的封装
如果使用 load 方法加载数据, 但是没有指定 format 的话, 默认是按照 Parquet 文件格式读取也就是说, SparkSQL 默认的读取格式是 Parquet |
|
---|---|
总结
- 使用
spark.read
可以获取 SparkSQL 中的外部数据源访问框架DataFrameReader
DataFrameReader
有三个组件format
,schema
,option
DataFrameReader
有两种使用方式, 一种是使用load
加format
指定格式, 还有一种是使用封装方法csv
,json
等
7.2. 初识 DataFrameWriter
目标
- 理解
DataFrameWriter
的结构
对于 ETL
来说, 数据保存和数据读取一样重要, 所以 SparkSQL
中增加了一个新的数据写入框架, 叫做 DataFrameWriter
1 | val spark: SparkSession = ... |
DataFrameWriter
中由如下几个部分组成
组件 | 解释 |
---|---|
source |
写入目标, 文件格式等, 通过 format 方法设定 |
mode |
写入模式, 例如一张表已经存在, 如果通过 DataFrameWriter 向这张表中写入数据, 是覆盖表呢, 还是向表中追加呢? 通过 mode 方法设定 |
extraOptions |
外部参数, 例如 JDBC 的 URL , 通过 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 | val spark: SparkSession = ... |
默认没有指定 format , 默认的 format 是 Parquet |
|
---|---|
总结
- 类似
DataFrameReader
,Writer
中也有format
,options
, 另外schema
是包含在DataFrame
中的 DataFrameWriter
中还有一个很重要的概念叫做mode
, 指定写入模式, 如果目标集合已经存在时的行为DataFrameWriter
可以将数据保存到Hive
表中, 所以也可以指定分区和分桶信息
7.3. 读写 Parquet 格式文件
目标
- 理解
Spark
读写Parquet
文件的语法 - 理解
Spark
读写Parquet
文件的时候对于分区的处理
什么时候会用到 Parquet
?
在 ETL
中, Spark
经常扮演 T
的职务, 也就是进行数据清洗和数据转换.
为了能够保存比较复杂的数据, 并且保证性能和压缩率, 通常使用 Parquet
是一个比较不错的选择.
所以外部系统收集过来的数据, 有可能会使用 Parquet
, 而 Spark
进行读取和转换的时候, 就需要支持对 Parquet
格式的文件的支持.
使用代码读写 Parquet
文件
默认不指定 format
的时候, 默认就是读写 Parquet
格式的文件
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
写入 Parquet
的时候可以指定分区
Spark
在写入文件的时候是支持分区的, 可以像 Hive
一样设置某个列为分区列
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
这个地方指的分区是类似 Hive 中表分区的概念, 而不是 RDD 分布式分区的含义 |
|
---|---|
分区发现
在读取常见文件格式的时候, Spark
会自动的进行分区发现, 分区自动发现的时候, 会将文件名中的分区信息当作一列. 例如 如果按照性别分区, 那么一般会生成两个文件夹 gender=male
和 gender=female
, 那么在使用 Spark
读取的时候, 会自动发现这个分区信息, 并且当作列放入创建的 DataFrame
中
使用代码证明这件事可以有两个步骤, 第一步先读取某个分区的单独一个文件并打印其 Schema
信息, 第二步读取整个数据集所有分区并打印 Schema
信息, 和第一步做比较就可以确定
1 | val spark = ... |
1 | 把分区的数据集中的某一个区单做一整个数据集读取, 没有分区信息, 自然也不会进行分区发现 |
---|---|
1 | val df = spark.read.load("dataset/beijing_pm") (1) |
1 | 此处读取的是整个数据集, 会进行分区发现, DataFrame 中会包含分去列 |
---|---|
配置 | 默认值 | 含义 |
---|---|---|
spark.sql.parquet.binaryAsString |
false |
一些其他 Parquet 生产系统, 不区分字符串类型和二进制类型, 该配置告诉 SparkSQL 将二进制数据解释为字符串以提供与这些系统的兼容性 |
spark.sql.parquet.int96AsTimestamp |
true |
一些其他 Parquet 生产系统, 将 Timestamp 存为 INT96 , 该配置告诉 SparkSQL 将 INT96 解析为 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 则就是通过扫描整个数据集来确定 |
总结
Spark
不指定format
的时候默认就是按照Parquet
的格式解析文件Spark
在读取Parquet
文件的时候会自动的发现Parquet
的分区和分区字段Spark
在写入Parquet
文件的时候如果设置了分区字段, 会自动的按照分区存储
7.4. 读写 JSON 格式文件
目标
- 理解
JSON
的使用场景 - 能够使用
Spark
读取处理JSON
格式文件
什么时候会用到 JSON
?
在 ETL
中, Spark
经常扮演 T
的职务, 也就是进行数据清洗和数据转换.
在业务系统中, JSON
是一个非常常见的数据格式, 在前后端交互的时候也往往会使用 JSON
, 所以从业务系统获取的数据很大可能性是使用 JSON
格式, 所以就需要 Spark
能够支持 JSON 格式文件的读取
读写 JSON
文件
将要 Dataset
保存为 JSON
格式的文件比较简单, 是 DataFrameWriter
的一个常规使用
1 | val spark: SparkSession = new sql.SparkSession.Builder() |
1 | 如果不重新分区, 则会为 DataFrame 底层的 RDD 的每个分区生成一个文件, 为了保持只有一个输出文件, 所以重新分区 |
---|---|
保存为 JSON 格式的文件有一个细节需要注意, 这个 JSON 格式的文件中, 每一行是一个独立的 JSON , 但是整个文件并不只是一个 JSON 字符串, 所以这种文件格式很多时候被成为 JSON Line 文件, 有时候后缀名也会变为 jsonl beijing_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 | val spark: SparkSession = ... |
1 | JSON` 格式的文件是有结构信息的, 也就是 `JSON` 中的字段是有类型的, 例如 `"name": "zhangsan"` 这样由双引号包裹的 `Value`, 就是字符串类型, 而 `"age": 10` 这种没有双引号包裹的就是数字类型, 当然, 也可以是布尔型 `"has_wife": true |
Spark
读取 JSON Line
文件的时候, 会自动的推断类型信息
1 | val spark: SparkSession = ... |
1 | Spark` 可以从一个保存了 `JSON` 格式字符串的 `Dataset[String]` 中读取 `JSON` 信息, 转为 `DataFrame |
这种情况其实还是比较常见的, 例如如下的流程
假设业务系统通过 Kafka
将数据流转进入大数据平台, 这个时候可能需要使用 RDD
或者 Dataset
来读取其中的内容, 这个时候一条数据就是一个 JSON
格式的字符串, 如何将其转为 DataFrame
或者 Dataset[Object]
这样具有 Schema
的数据集呢? 使用如下代码就可以
1 | val spark: SparkSession = ... |
总结
JSON
通常用于系统间的交互,Spark
经常要读取JSON
格式文件, 处理, 放在另外一处- 使用
DataFrameReader
和DataFrameWriter
可以轻易的读取和写入JSON
, 并且会自动处理数据类型信息
7.5. 访问 Hive
导读
- 整合
SparkSQL
和Hive
, 使用Hive
的MetaStore
元信息库 - 使用
SparkSQL
查询Hive
表 - 案例, 使用常见
HiveSQL
- 写入内容到
Hive
表
7.5.1. SparkSQL 整合 Hive
导读
- 开启
Hive
的MetaStore
独立进程 - 整合
SparkSQL
和Hive
的MetaStore
和一个文件格式不同, Hive
是一个外部的数据存储和查询引擎, 所以如果 Spark
要访问 Hive
的话, 就需要先整合 Hive
整合什么 ?
如果要讨论 SparkSQL
如何和 Hive
进行整合, 首要考虑的事应该是 Hive
有什么, 有什么就整合什么就可以
MetaStore
, 元数据存储SparkSQL
内置的有一个MetaStore
, 通过嵌入式数据库Derby
保存元信息, 但是对于生产环境来说, 还是应该使用Hive
的MetaStore
, 一是更成熟, 功能更强, 二是可以使用Hive
的元信息查询引擎
SparkSQL
内置了HiveSQL
的支持, 所以无需整合
为什么要开启 Hive
的 MetaStore
Hive
的 MetaStore
是一个 Hive
的组件, 一个 Hive
提供的程序, 用以保存和访问表的元数据, 整个 Hive
的结构大致如下
由上图可知道, 其实 Hive
中主要的组件就三个, HiveServer2
负责接受外部系统的查询请求, 例如 JDBC
, HiveServer2
接收到查询请求后, 交给 Driver
处理, Driver
会首先去询问 MetaStore
表在哪存, 后 Driver
程序通过 MR
程序来访问 HDFS
从而获取结果返回给查询请求者
而 Hive
的 MetaStore
对 SparkSQL
的意义非常重大, 如果 SparkSQL
可以直接访问 Hive
的 MetaStore
, 则理论上可以做到和 Hive
一样的事情, 例如通过 Hive
表查询数据
而 Hive 的 MetaStore 的运行模式有三种
内嵌
Derby
数据库模式这种模式不必说了, 自然是在测试的时候使用, 生产环境不太可能使用嵌入式数据库, 一是不稳定, 二是这个
Derby
是单连接的, 不支持并发Local
模式Local
和Remote
都是访问MySQL
数据库作为存储元数据的地方, 但是Local
模式的MetaStore
没有独立进程, 依附于HiveServer2
的进程Remote
模式和
Loca
模式一样, 访问MySQL
数据库存放元数据, 但是Remote
的MetaStore
运行在独立的进程中
我们显然要选择 Remote
模式, 因为要让其独立运行, 这样才能让 SparkSQL
一直可以访问
1 | Hive` 开启 `MetaStore |
1 | Step 2`: 启动 `Hive MetaStore |
即使不去整合 MetaStore
, Spark
也有一个内置的 MateStore
, 使用 Derby
嵌入式数据库保存数据, 但是这种方式不适合生产环境, 因为这种模式同一时间只能有一个 SparkSession
使用, 所以生产环境更推荐使用 Hive
的 MetaStore
SparkSQL
整合 Hive
的 MetaStore
主要思路就是要通过配置能够访问它, 并且能够使用 HDFS
保存 WareHouse
, 这些配置信息一般存在于 Hadoop
和 HDFS
的配置文件中, 所以可以直接拷贝 Hadoop
和 Hive
的配置文件到 Spark
的配置目录
1 | cd /export/servers/hadoop/etc/hadoop |
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 表
导读
- 在
Hive
中创建表 - 使用
SparkSQL
访问Hive
中已经存在的表 - 使用
SparkSQL
创建Hive
表 - 使用
SparkSQL
修改Hive
表中的数据
在 Hive
中创建表
第一步, 需要先将文件上传到集群中, 使用如下命令上传到 HDFS
中
1 | hdfs dfs -mkdir -p /dataset |
第二步, 使用 Hive
或者 Beeline
执行如下 SQL
1 | CREATE DATABASE IF NOT EXISTS spark_integrition; |
通过 SparkSQL
查询 Hive
的表
查询 Hive
中的表可以直接通过 spark.sql(…)
来进行, 可以直接在其中访问 Hive
的 MetaStore
, 前提是一定要将 Hive
的配置文件拷贝到 Spark
的 conf
目录
1 | scala> spark.sql("use spark_integrition") |
通过 SparkSQL
创建 Hive
表
通过 SparkSQL
可以直接创建 Hive
表, 并且使用 LOAD DATA
加载数据
1 | val createTableStr = |
目前 SparkSQL
支持的文件格式有 sequencefile
, rcfile
, orc
, parquet
, textfile
, avro
, 并且也可以指定 serde
的名称
使用 SparkSQL
处理数据并保存进 Hive 表
前面都在使用 SparkShell
的方式来访问 Hive
, 编写 SQL
, 通过 Spark
独立应用的形式也可以做到同样的事, 但是需要一些前置的步骤, 如下
Step 1: 导入 Maven
依赖
1 | <dependency> |
Step 2: 配置 SparkSession
如果希望使用 SparkSQL
访问 Hive
的话, 需要做两件事
开启
SparkSession
的Hive
支持经过这一步配置,
SparkSQL
才会把SQL
语句当作HiveSQL
来进行解析设置
WareHouse
的位置虽然
hive-stie.xml
中已经配置了WareHouse
的位置, 但是在Spark 2.0.0
后已经废弃了hive-site.xml
中设置的hive.metastore.warehouse.dir
, 需要在SparkSession
中设置WareHouse
的位置设置
MetaStore
的位置
1 | val spark = SparkSession |
1 | 设置 WareHouse 的位置 |
---|---|
2 | 设置 MetaStore 的位置 |
3 | 开启 Hive 支持 |
配置好了以后, 就可以通过 DataFrame
处理数据, 后将数据结果推入 Hive
表中了, 在将结果保存到 Hive
表的时候, 可以指定保存模式
1 | val schema = StructType( |
1 | 通过 mode 指定保存模式, 通过 saveAsTable 保存数据到 Hive |
---|---|
7.6. JDBC
导读
- 通过
SQL
操作MySQL
的表 - 将数据写入
MySQL
的表中
准备 MySQL
环境
在使用 SparkSQL
访问 MySQL
之前, 要对 MySQL
进行一些操作, 例如说创建用户, 表和库等
Step 1: 连接
MySQL
数据库在
MySQL
所在的主机上执行如下命令1
mysql -u root -p
Step 2: 创建
Spark
使用的用户登进
MySQL
后, 需要先创建用户1
2CREATE USER 'spark'@'%' IDENTIFIED BY 'Spark123!';
GRANT ALL ON spark_test.* TO 'spark'@'%';Step 3: 创建库和表
1
2
3
4
5
6
7
8
9
10CREATE 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;
使用 SparkSQL
向 MySQL
中写入数据
其实在使用 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 | val spark = SparkSession |
运行程序
如果是在本地运行, 需要导入 Maven
依赖
1 | <dependency> |
如果使用 Spark submit
或者 Spark shell
来运行任务, 需要通过 --jars
参数提交 MySQL
的 Jar
包, 或者指定 --packages
从 Maven
库中读取
1 | bin/spark-shell --packages mysql:mysql-connector-java:5.1.47 --repositories http://maven.aliyun.com/nexus/content/groups/public/ |
从 MySQL
中读取数据
读取 MySQL
的方式也非常的简单, 只是使用 SparkSQL
的 DataFrameReader
加上参数配置即可访问
1 | spark.read.format("jdbc") |
默认情况下读取 MySQL
表时, 从 MySQL
表中读取的数据放入了一个分区, 拉取后可以使用 DataFrame
重分区来保证并行计算和内存占用不会太高, 但是如果感觉 MySQL
中数据过多的时候, 读取时可能就会产生 OOM
, 所以在数据量比较大的场景, 就需要在读取的时候就将其分发到不同的 RDD
分区
属性 | 含义 |
---|---|
partitionColumn |
指定按照哪一列进行分区, 只能设置类型为数字的列, 一般指定为 ID |
lowerBound , upperBound |
确定步长的参数, lowerBound - upperBound 之间的数据均分给每一个分区, 小于 lowerBound 的数据分给第一个分区, 大于 upperBound 的数据分给最后一个分区 |
numPartitions |
分区数量 |
1 | spark.read.format("jdbc") |
有时候可能要使用非数字列来作为分区依据, Spark
也提供了针对任意类型的列作为分区依据的方法
1 | val predicates = Array( |
SparkSQL
中并没有直接提供按照 SQL
进行筛选读取数据的 API
和参数, 但是可以通过 dbtable
来曲线救国, dbtable
指定目标表的名称, 但是因为 dbtable
中可以编写 SQL
, 所以使用子查询即可做到
1 | spark.read.format("jdbc") |
8. Dataset (DataFrame) 的基础操作
导读
这一章节主要目的是介绍 Dataset
的基础操作, 当然, DataFrame
就是 Dataset
, 所以这些操作大部分也适用于 DataFrame
- 有类型的转换操作
- 无类型的转换操作
- 基础
Action
- 空值如何处理
- 统计操作
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 |
mapPartitions 和 map 一样, 但是 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 |
map 和 mapPartitions 以及 transform 都是转换, map 和 mapPartitions 是针对数据, 而 transform 是针对整个数据集, 这种方式最大的区别就是 transform 可以直接拿到 Dataset 进行操作![]() import 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 配合 Column 的 API , 可以实现正反序排列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 |
其实 orderBy 是 sort 的别名, 所以它们所实现的功能是一样的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 会直接创建一个逻辑操作, 并且设置 Shuffle 为 false``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() 方法还有一个别名, 叫做 distinct ![]() distinct 也可以去重, 并且只能根据所有的列来去重import spark.implicits._ val ds = spark.createDataset(Seq(Person("zhangsan", 15), Person("zhangsan", 15), Person("lisi", 15))) ds.distinct().show() |
|
集合操作 | except |
except 和 SQL 语句中的 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 对象, ColumnName 是 Column 的子类, 所以在 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 , 同时, 也可以通过 Dataset 的 col 方法选择一个列, 但是这个 Column 是绑定了这个 Dataset 的, 所以只能用于创建其的 Dataset 上val 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 方法来获取一个关联此 Dataset 的 Column 对象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 |
通过 Column 的 API , 可以轻松实现 SQL 语句中 LIKE 的功能personDF.filter('name like "%zhang%").show() |
isin |
通过 Column 的 API , 可以轻松实现 SQL 语句中 ISIN 的功能personDF.filter('name isin ("hello", "zhangsan")).show() |
|
sort |
在排序的时候, 可以通过 Column 的 API 实现正反序personDF.sort('age.asc).show() personDF.sort('age.desc).show() |
9. 缺失值处理
导读
DataFrame
中什么时候会有无效值DataFrame
如何处理无效的值DataFrame
如何处理null
缺失值的处理思路
如果想探究如何处理无效值, 首先要知道无效值从哪来, 从而分析可能产生的无效值有哪些类型, 在分别去看如何处理无效值
什么是缺失值
一个值本身的含义是这个值不存在则称之为缺失值, 也就是说这个值本身代表着缺失, 或者这个值本身无意义, 比如说 null
, 比如说空字符串
关于数据的分析其实就是统计分析的概念, 如果这样的话, 当数据集中存在缺失值, 则无法进行统计和分析, 对很多操作都有影响
缺失值如何产生的
Spark 大多时候处理的数据来自于业务系统中, 业务系统中可能会因为各种原因, 产生一些异常的数据
例如说因为前后端的判断失误, 提交了一些非法参数. 再例如说因为业务系统修改 MySQL
表结构产生的一些空值数据等. 总之在业务系统中出现缺失值其实是非常常见的一件事, 所以大数据系统就一定要考虑这件事.
缺失值的类型
常见的缺失值有两种
null
,NaN
等特殊类型的值, 某些语言中null
可以理解是一个对象, 但是代表没有对象,NaN
是一个数字, 可以代表不是数字针对这一类的缺失值,
Spark
提供了一个名为DataFrameNaFunctions
特殊类型来操作和处理"Null"
,"NA"
," "
等解析为字符串的类型, 但是其实并不是常规字符串数据针对这类字符串, 需要对数据集进行采样, 观察异常数据, 总结经验, 各个击破
1 | DataFrameNaFunctions |
DataFrameNaFunctions
使用 Dataset
的 na
函数来获取
1 | val df = ... |
当数据集中出现缺失值的时候, 大致有两种处理方式, 一个是丢弃, 一个是替换为某值, DataFrameNaFunctions
中包含一系列针对空值数据的方案
DataFrameNaFunctions.drop
可以在当某行中包含null
或NaN
的时候丢弃此行DataFrameNaFunctions.fill
可以在将null
和NaN
充为其它值DataFrameNaFunctions.replace
可以把null
或NaN
替换为其它值, 但是和fill
略有一些不同, 这个方法针对值来进行替换
如何使用 SparkSQL
处理 null
和 NaN
?
首先要将数据读取出来, 此次使用的数据集直接存在 NaN
, 在指定 Schema
后, 可直接被转为 Double.NaN
1 | val schema = StructType( |
对于缺失值的处理一般就是丢弃和填充
丢弃包含 null
和 NaN
的行
当某行数据所有值都是 null
或者 NaN
的时候丢弃此行
1 | df.na.drop("all").show() |
当某行中特定列所有值都是 null
或者 NaN
的时候丢弃此行
1 | df.na.drop("all", List("pm", "id")).show() |
当某行数据任意一个字段为 null
或者 NaN
的时候丢弃此行
1 | df.na.drop().show() |
当某行中特定列任意一个字段为 null
或者 NaN
的时候丢弃此行
1 | df.na.drop(List("pm", "id")).show() |
填充包含 null
和 NaN
的列
填充所有包含 null
和 NaN
的列
1 | df.na.fill(0).show() |
填充特定包含 null
和 NaN
的列
1 | df.na.fill(0, List("pm")).show() |
根据包含 null
和 NaN
的列的不同来填充
1 | import scala.collection.JavaConverters._ |
如何使用 SparkSQL
处理异常字符串 ?
读取数据集, 这次读取的是最原始的那个 PM
数据集
1 | val df = spark.read |
使用函数直接转换非法的字符串
1 | df.select('No as "id", 'year, 'month, 'day, 'hour, 'season, |
使用 where
直接过滤
1 | df.select('No as "id", 'year, 'month, 'day, 'hour, 'season, 'PM_Dongsi) |
使用 DataFrameNaFunctions
替换, 但是这种方式被替换的值和新值必须是同类型
1 | df.select('No as "id", 'year, 'month, 'day, 'hour, 'season, 'PM_Dongsi) |
10. 聚合
导读
groupBy
rollup
cube
pivot
RelationalGroupedDataset
上的聚合操作
1 | groupBy |
groupBy
算子会按照列将 Dataset
分组, 并返回一个 RelationalGroupedDataset
对象, 通过 RelationalGroupedDataset
可以对分组进行聚合
Step 1: 加载实验数据
1 | private val spark = SparkSession.builder() |
Step 2: 使用 functions
函数进行聚合
1 | import org.apache.spark.sql.functions._ |
Step 3: 除了使用 functions
进行聚合, 还可以直接使用 RelationalGroupedDataset
的 API
进行聚合
1 | groupedDF.avg("pm") |
多维聚合
我们可能经常需要针对数据进行多维的聚合, 也就是一次性统计小计, 总计等, 一般的思路如下
Step 1: 准备数据
1 | private val spark = SparkSession.builder() |
Step 2: 进行多维度聚合
1 | import org.apache.spark.sql.functions._ |
大家其实也能看出来, 在一个数据集中又小计又总计, 可能需要多个操作符, 如何简化呢? 请看下面
rollup
操作符
rollup
操作符其实就是 groupBy
的一个扩展, rollup
会对传入的列进行滚动 groupBy
, groupBy
的次数为列数量 + 1
, 最后一次是对整个数据集进行聚合
Step 1: 创建数据集
1 | import org.apache.spark.sql.functions._ |
Step 1: rollup
的操作
1 | sales.rollup("city", "year") |
Step 2: 如果使用基础的 groupBy 如何实现效果?
1 | val cityAndYear = sales |
很明显可以看到, 在上述案例中, rollup
就相当于先按照 city
, year
进行聚合, 后按照 city
进行聚合, 最后对整个数据集进行聚合, 在按照 city
聚合时, year
列值为 null
, 聚合整个数据集的时候, 除了聚合列, 其它列值都为 null
使用 rollup
完成 pm
值的统计
上面的案例使用 rollup
来实现会非常的简单
1 | import org.apache.spark.sql.functions._ |
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 | import org.apache.spark.sql.functions._ |
SparkSQL
中支持的 SQL
语句实现 cube
功能
SparkSQL
支持 GROUPING SETS
语句, 可以随意排列组合空值分组聚合的顺序和组成, 既可以实现 cube
也可以实现 rollup
的功能
1 | pmFinal.createOrReplaceTempView("pm_final") |
常见的 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. 连接
导读
- 无类型连接
join
- 连接类型
Join Types
无类型连接算子 join
的 API
Step 1: 什么是连接
按照 PostgreSQL 的文档中所说, 只要能在一个查询中, 同一时间并发的访问多条数据, 就叫做连接.
做到这件事有两种方式
一种是把两张表在逻辑上连接起来, 一条语句中同时访问两张表
1
select * from user join address on user.address_id = address.id
还有一种方式就是表连接自己, 一条语句也能访问自己中的多条数据
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 | +---+------+------+ +---+---------+ |
如果希望对这两张表进行连接, 首先应该注意的是可以连接的字段, 比如说此处的左侧表 cityId
和右侧表 id
就是可以连接的字段, 使用 join
算子就可以将两个表连接起来, 进行统一的查询
1 | val person = Seq((0, "Lucy", 0), (1, "Lily", 0), (2, "Tim", 2), (3, "Danial", 0)) |
Step 4: 什么是连接?
现在两个表连接得到了如下的表
1 | +---+------+---------+ |
通过对这张表的查询, 这个查询是作用于两张表的, 所以是同一时间访问了多条数据
1 | spark.sql("select name from user_city where city = 'Beijing'").show() |
连接类型
如果要运行如下代码, 需要先进行数据准备
1 | private val spark = SparkSession.builder() |
连接类型 | 类型字段 | 解释 |
---|---|---|
交叉连接 | cross |
解释交叉连接就是笛卡尔积, 就是两个表中所有的数据两两结对交叉连接是一个非常重的操作, 在生产中, 尽量不要将两个大数据集交叉连接, 如果一定要交叉连接, 也需要在交叉连接后进行过滤, 优化器会进行优化![]() SQL 语句select * from person cross join cities``Dataset 操作person.crossJoin(cities) .where(person.col("cityId") === cities.col("id")) .show() |
内连接 | inner |
解释内连接就是按照条件找到两个数据集关联的数据, 并且在生成的结果集中只存在能关联到的数据![]() SQL 语句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 |
解释内连接和外连接的最大区别, 就是内连接的结果集中只有可以连接上的数据, 而外连接可以包含没有连接上的数据, 根据情况的不同, 外连接又可以分为很多种, 比如所有的没连接上的数据都放入结果集, 就叫做全外连接![]() SQL 语句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 |
解释左外连接是全外连接的一个子集, 全外连接中包含左右两边数据集没有连接上的数据, 而左外连接只包含左边数据集中没有连接上的数据![]() SQL 语句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 是一种特殊的连接形式, 和左外连接类似, 但是其结果集中没有右侧的数据, 只包含左边集合中没连接上的数据![]() SQL 语句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 的结果集也没有右侧集合的数据, 但是只包含左侧集合中连接上的数据![]() SQL 语句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 |
解释右外连接和左外连接刚好相反, 左外是包含左侧未连接的数据, 和两个数据集中连接上的数据, 而右外是包含右侧未连接的数据, 和两个数据集中连接上的数据![]() SQL 语句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
过程
Join
会在集群中分发两个数据集, 两个数据集都要复制到 Reducer
端, 是一个非常复杂和标准的 ShuffleDependency
, 有什么可以优化效率吗?
Step 2: Map
端 Join
前面图中看的过程, 之所以说它效率很低, 原因是需要在集群中进行数据拷贝, 如果能减少数据拷贝, 就能减少开销
如果能够只分发一个较小的数据集呢?
可以将小数据集收集起来, 分发给每一个 Executor
, 然后在需要 Join
的时候, 让较大的数据集在 Map
端直接获取小数据集, 从而进行 Join
, 这种方式是不需要进行 Shuffle
的, 所以称之为 Map
端 Join
Step 3: Map
端 Join
的常规实现
如果使用 RDD
的话, 该如何实现 Map
端 Join
呢?
1 | val personRDD = spark.sparkContext.parallelize(Seq((0, "Lucy", 0), |
Step 4: 使用 Dataset
实现 Join
的时候会自动进行 Map
端 Join
自动进行 Map
端 Join
需要依赖一个系统参数 spark.sql.autoBroadcastJoinThreshold
, 当数据集小于这个参数的大小时, 会自动进行 Map
端 Join
如下, 开启自动 Join
1 | println(spark.conf.get("spark.sql.autoBroadcastJoinThreshold").toInt / 1024 / 1024) |
当关闭这个参数的时候, 则不会自动 Map 端 Join 了
1 | spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1) |
Step 5: 也可以使用函数强制开启 Map 端 Join
在使用 Dataset 的 join 时, 可以使用 broadcast 函数来实现 Map 端 Join
1 | import org.apache.spark.sql.functions._ |
即使是使用 SQL 也可以使用特殊的语法开启
1 | spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1) |
12. 窗口函数
目标和步骤
目标
理解窗口操作的语义, 掌握窗口函数的使用
步骤
- 案例1, 第一名和第二名
- 窗口函数介绍
- 案例2, 最优差值
12.1. 第一名和第二名案例
目标和步骤
目标
掌握如何使用 SQL
和 DataFrame
完成名次统计, 并且对窗口函数有一个模糊的认识, 方便后面的启发
步骤
- 需求介绍
- 代码编写
需求介绍
数据集
product
: 商品名称categroy
: 类别revenue
: 收入
需求分析
需求
从数据集中得到每个类别收入第一的商品和收入第二的商品
关键点是, 每个类别, 收入前两名
方案1: 使用常见语法子查询
- 问题1:
Spark
和Hive
这样的系统中, 有自增主键吗? 没有 - 问题2: 为什么分布式系统中很少见自增主键? 因为分布式环境下数据在不同的节点中, 很难保证顺序
- 解决方案: 按照某一列去排序, 取前两条数据
- 遗留问题: 不容易在分组中取每一组的前两个
1
SELECT * FROM productRevenue ORDER BY revenue LIMIT 2
方案2: 计算每一个类别的按照收入排序的序号, 取每个类别中的前两个
思路步骤
- 按照类别分组
- 每个类别中的数据按照收入排序
- 为排序过的数据增加编号
- 取得每个类别中的前两个数据作为最终结果
使用
SQL
就不太容易做到, 需要一个语法, 叫做窗口函数
代码编写
创建初始环境
- 创建新的类
WindowFunction
- 编写测试方法
- 初始化
SparkSession
- 创建数据集
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") } }
- 创建新的类
方式一:
SQL
语句::1
2
3
4
5
6
7
8
9
10
11
12
13SELECT
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 ...)
方式二: 使用
DataFrame
的命令式API
::1
2
3
4
5
6val 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
都可以操作窗口 - 窗口的使用有两个步骤
- 定义窗口规则
- 定义窗口函数
- 在不同的范围内统计名次时, 窗口函数非常得力
12.2. 窗口函数
目标和步骤
目标
了解窗口函数的使用方式, 能够使用窗口函数完成统计
步骤
- 窗口函数的逻辑
- 窗口定义部分
- 统计函数部分
窗口函数的逻辑
从 逻辑 上来讲, 窗口函数执行步骤大致可以分为如下几步
1 | dense_rank() OVER (PARTITION BY category ORDER BY revenue DESC) as rank |
根据
PARTITION BY category
, 对数据进行分组分组后, 根据
ORDER BY revenue DESC
对每一组数据进行排序在 每一条数据 到达窗口函数时, 套入窗口内进行计算
从语法的角度上讲, 窗口函数大致分为两个部分
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 |
Partition
定义控制哪些行会被放在一起, 同时这个定义也类似于
Shuffle
, 会将同一个分组的数据放在同一台机器中处理Order
定义在一个分组内进行排序, 因为很多操作, 如
rank
, 需要进行排序Frame
定义释义
窗口函数会针对 每一个组中的每一条数据 进行统计聚合或者
rank
, 一个组又称为一个Frame
分组由两个字段控制,
Partition
在整体上进行分组和分区而通过
Frame
可以通过 当前行 来更细粒度的分组控制举个栗子, 例如公司每月销售额的数据, 统计其同比增长率, 那就需要把这条数据和前面一条数据进行结合计算了
有哪些控制方式?
Row Frame
通过
"行号"
来表示Range Frame
通过某一个列的差值来表示
函数部分
1 | dense_rank() OVER (PARTITION BY category ORDER BY revenue DESC) as rank |
如下是支持的窗口函数
类型 | 函数 | 解释 |
---|---|---|
排名函数 | rank |
排名函数, 计算当前数据在其 Frame 中的位置如果有重复, 则重复项后面的行号会有空挡![]() |
dense_rank |
和 rank 一样, 但是结果中没有空挡![]() |
|
row_number |
和 rank 一样, 也是排名, 但是不同点是即使有重复想, 排名依然增长![]() |
|
分析函数 | first_value |
获取这个组第一条数据 |
last_value |
获取这个组最后一条数据 | |
lag |
lag(field, n) 获取当前数据的 field 列向前 n 条数据 |
|
lead |
lead(field, n) 获取当前数据的 field 列向后 n 条数据 |
|
聚合函数 | * |
所有的 functions 中的聚合函数都支持 |
总结
- 窗口操作分为两个部分
- 窗口定义, 定义时可以指定
Partition
,Order
,Frame
- 函数操作, 可以使用三大类函数, 排名函数, 分析函数, 聚合函数
- 窗口定义, 定义时可以指定
12.3. 最优差值案例
目标和步骤
目标
能够针对每个分类进行计算, 求得常见指标, 并且理解实践上面的一些理论
步骤
- 需求介绍
- 代码实现
需求介绍
源数据集
需求
统计每个商品和此品类最贵商品之间的差值
目标数据集
代码实现
步骤
- 创建数据集
- 创建窗口, 按照
revenue
分组, 并倒叙排列 - 应用窗口
代码
1 | val spark = SparkSession.builder() |
项目实战
导读
本项目是 SparkSQL 阶段的练习项目, 主要目的是夯实同学们对于 SparkSQL 的理解和使用
数据集
2013年纽约市出租车乘车记录
需求
统计出租车利用率, 到某个目的地后, 出租车等待下一个客人的间隔
1. 业务
导读
- 数据集介绍
- 业务场景介绍
- 和其它业务的关联
- 通过项目能学到什么
数据集结构
字段 | 示例 | 示意 |
---|---|---|
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_datetime
和dropoff_datetime
分别是上车时间和下车时间, 通过这个时间, 可以获知行车时间pickup_longitude
和dropoff_longitude
是经度, 经度所代表的是横轴, 也就是 X 轴pickup_latitude
和dropoff_latitude
是纬度, 纬度所代表的是纵轴, 也就是 Y 轴
业务场景
在网约车出现之前, 出行很大一部分要靠出租车和公共交通, 所以经常会见到一些情况, 比如说从东直门打车, 告诉师傅要去昌平, 师傅可能拒载. 这种情况所凸显的是一个出租车调度的难题, 所以需要先通过数据来看到问题, 后解决问题.
所以要统计出租车利用率, 也就是有乘客乘坐的时间, 和无乘客空跑的时间比例. 这是一个理解出租车的重要指标, 影响利用率的一个因素就是目的地, 比如说, 去昌平, 可能出租车师傅不确定自己是否要空放回来, 而去国贸, 下车几分钟内, 一定能有新的顾客上车.
而统计利用率的时候, 需要用到时间数据和空间数据来进行计算, 对于时间计算来说, SparkSQL 提供了很多工具和函数可以使用, 而空间计算仍然是一个比较专业的场景, 需要使用到第三方库.
我们的需求是, 在上述的数据集中, 根据时间算出等待时间, 根据地点落地到某个区, 算出某个区的平均等待时间, 也就是这个下车地点对于出租车利用率的影响.
技术点和其它技术的关系
数据清洗
数据清洗在几乎所有类型的项目中都会遇到, 处理数据的类型, 处理空值等问题
JSON 解析
JSON
解析在大部分业务系统的数据分析中都会用到, 如何读取 JSON 数据, 如何把 JSON 数据变为可以使用的对象数据地理位置信息处理
地理位置信息的处理是一个比较专业的场景, 在一些租车网站, 或者像滴滴,
Uber
之类的出行服务上, 也经常会处理地理位置信息探索性数据分析
从拿到一个数据集, 明确需求以后, 如何逐步了解数据集, 如何从数据集中探索对应的内容等, 是一个数据工程师的基本素质
会话分析
会话分析用于识别同一个用户的多个操作之间的关联, 是分析系统常见的分析模式, 在电商和搜索引擎中非常常见
在这个小节中希望大家掌握的知识
SparkSQL
中对于类型的处理Scala
中常见的JSON
解析工具GeoJson
的使用
2. 流程分析
导读
- 分析的步骤和角度
- 流程
分析的视角
理解数据集
首先要理解数据集, 要回答自己一些问题
- 这个数据集是否以行作为单位, 是否是
DataFrame
可以处理的, 大部分情况下都是 - 这个数据集每行记录所代表的实体对象是什么, 例如: 出租车的载客记录
- 表达这个实体对象的最核心字段是什么, 例如: 上下车地点和时间, 唯一标识一辆车的
License
- 这个数据集是否以行作为单位, 是否是
理解需求和结果集
- 小学的时候, 有一次考试考的比较差, 老师在帮我分析的时候, 告诉我, 你下次要读懂题意, 再去大题, 这样不会浪费时间, 于是这个信念贯穿了我这些年的工作.
- 按照我对开发工作的理解, 在一开始的阶段进行一个大概的思考和面向对象的设计, 并不会浪费时间, 即使这些设计可能会占用一些时间.
- 对代码的追求也不会浪费时间, 把代码写好, 会减少阅读成本, 沟通成本.
- 对测试的追求也不会浪费时间, 因为在进行回归测试的时候, 可以尽可能的减少修改对已有代码的冲击.
所以第一点, 理解需求再动手, 绝对不会浪费时间. 第二点, 在数据分析的任务中, 如何无法理解需求, 可能根本无从动手.
- 我们的需求是: 出租车在某个地点的平均等待客人时间
- 简单来说, 结果集中应该有的列: 地点, 平均等待时间
反推每一个步骤
结果集中, 应该有的字段有两个, 一个是地点, 一个是等待时间
地点如何获知? 其实就是乘客的下车点, 但是是一个坐标, 如何得到其在哪个区? 等待时间如何获知? 其实就是上一个乘客下车, 到下一个乘客上车之间的时间, 通过这两个时间的差值便可获知
步骤分析
读取数据集
数据集很大, 所以我截取了一小部分, 大概百分之一左右, 如果大家感兴趣的话, 可以将完整数据集放在集群中, 使用集群来计算 “大数据”
清洗
数据集当中的某些列名可能使用起来不方便, 或者数据集当中某些列的值类型可能不对, 或者数据集中有可能存在缺失值, 这些都是要清洗的动机, 和理由
增加区域列
由于最终要统计的结果是按照区域作为单位, 而不是一个具体的目的地点, 所以要在数据集中增加列中放置区域信息
- 既然是放置行政区名字, 应该现有行政区以及其边界的信息
- 通过上下车的坐标点, 可以判断是否存在于某个行政区中
这些判断坐标点是否属于某个区域, 这些信息, 就是专业的领域了
按照区域, 统计司机两次营运记录之间的时间差
数据集中存在很多出租车师傅的数据, 所以如何将某个师傅的记录发往一个分区, 在这个分区上完成会话分析呢? 这也是一个需要理解的点
3. 数据读取
导读
- 工程搭建
- 数据读取
工程搭建
创建 Maven 工程
导入 Maven 配置
创建 Scala 源码目录
src/main/scala
并且设置这个目录为
Source Root
创建文件, 数据读取
Step 1
: 创建文件
创建 Spark Application 主类 cn.itcast.taxi.TaxiAnalysisRunner
1 | package cn.itcast.taxi |
Step 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 | root |
下一步
剪去多余列
现在数据集中包含了一些多余的列, 在后续的计算中并不会使用到, 如果让这些列参与计算的话, 会影响整体性能, 浪费集群资源
类型转换
可以看到, 现在的数据集中, 所有列类型都是
String
, 而在一些统计和运算中, 不能使用String
来进行, 所以要将这些数据转为对应的类型
5. 数据清洗
导读
- 将
Row
对象转为Trip
- 处理转换过程中的报错
数据转换
通过 DataFrameReader
读取出来的数据集是 DataFrame
, 而 DataFrame
中保存的是 Row
对象, 但是后续我们在进行处理的时候可能要使用到一些有类型的转换, 也需要每一列数据对应自己的数据类型, 所以, 需要将 Row
所代表的弱类型对象转为 Trip
这样的强类型对象, 而 Trip
对象则是一个样例类, 用于代表一个出租车的行程
Step 1
: 创建 Trip
样例类
Trip
是一个强类型的样例类, 一个 Trip
对象代表一个出租车行程, 使用 Trip
可以对应数据集中的一条记录
1 | object TaxiAnalysisRunner { |
1 | Step 2`: 将 `Row` 对象转为 `Trip` 对象, 从而将 `DataFrame` 转为 `Dataset[Trip] |
首先应该创建一个新方法来进行这种转换, 毕竟是一个比较复杂的转换操作, 不能怠慢
1 | object TaxiAnalysisRunner { |
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
: 思路
parse
方法应该做的事情应该有两件
捕获异常
异常一定是要捕获的, 无论是否要抛给
DataFrame
, 都要先捕获一下, 获知异常信息捕获要使用
try … catch …
代码块返回结果
返回结果应该分为两部分来进行说明
- 正确, 正确则返回数据
- 错误, 则应该返回两类信息, 一 告知外面哪个数据出了错, 二 告知错误是什么
对于这种情况, 可以使用 Scala
中提供的一个类似于其它语言中多返回值的 Either
. Either
分为两个情况, 一个是 Left
, 一个是 Right
, 左右两个结果所代表的意思可有由用户来指定
1 | val process = (b: Double) => { (1) |
1 | 一个函数, 接收一个参数, 根据参数进行除法运算 |
---|---|
2 | 一个方法, 作用是让 process 函数调用起来更安全, 在其中 catch 错误, 报错后返回足够的信息 (报错时的参数和报错信息) |
3 | 正常时返回 Left , 放入正确结果 |
4 | 异常时返回 Right , 放入报错时的参数, 和报错信息 |
5 | 外部调用 |
6 | 处理调用结果, 如果是 Right 的话, 则可以进行响应的异常处理和弥补 |
Either
和 Option
比较像, 都是返回不同的情况, 但是 Either
的 Right
可以返回多个值, 而 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]
可以有如下步骤
- 试运行, 观察是否报错
- 如果报错, 则打印信息解决报错
- 如果解决不了, 则通过
filter
过滤掉Right
- 如果没有报错, 则继续向下运行
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() } ... } ... |
观察数据集的时间分布
观察数据分布常用手段是直方图, 直方图反应的是数据的 "数量"
分布
通过这个图可以看到其实就是乘客年龄的分布, 横轴是乘客的年龄, 纵轴是乘客年龄的频数分布
因为我们这个项目中要对出租车利用率进行统计, 所以需要先看一看单次行程的时间分布情况, 从而去掉一些异常数据, 保证数据是准确的
绘制直方图的 “图” 留在后续的 DMP
项目中再次介绍, 现在先准备好直方图所需要的数据集, 通过数据集来观察即可, 直方图需要的是两个部分的内容, 一个是数据本身, 另外一个是数据的分布, 也就是频数的分布, 步骤如下
- 计算每条数据的时长, 但是单位要有变化, 按照分钟, 或者小时来作为时长单位
- 统计每个时长的数据量, 例如有
500
个行程是一小时内完成的, 有300
个行程是1 - 2
小时内完成
统计时间分布直方图
使用 UDF
的优点和代价
UDF
是一个很好用的东西, 特别好用, 对整体的逻辑实现会变得更加简单可控, 但是有两个非常明显的缺点, 所以在使用的时候要注意, 虽然有这两个缺点, 但是只在必要的地方使用就没什么问题, 对于逻辑的实现依然是有很大帮助的
UDF
中, 对于空值的处理比较麻烦例如一个
UDF
接收两个参数, 是Scala
中的Int
类型和Double
类型, 那么, 在传入UDF
参数的时候, 如果有数据为null
, 就会出现转换异常使用
UDF
的时候, 优化器可能无法对其进行优化UDF
对于Catalyst
是不透明的,Catalyst
不可获知UDF
中的逻辑, 但是普通的Function
对于Catalyst
是透明的,Catalyst
可以对其进行优化
Step 1
: 编写 UDF
, 将行程时长由毫秒单位改为小时单位
定义 UDF
, 在 UDF
中做两件事
- 计算行程时长
- 将时长由毫秒转为分钟
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 | 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 | +--------+-----+ |
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
数据
步骤
- 需求介绍
- 工具介绍
- 解析
JSON
- 读取
Geometry
总结
- 整体流程
- JSON4S 介绍
- ESRI 介绍
- 编写函数实现
经纬度 → Geometry
转换
- 后续可以使用函数来进行转换, 并且求得时间差
6.1. 需求介绍
目标和步骤
目标
理解表示地理位置常用的 GeoJSON
步骤
- 思路整理
GeoJSON
是什么GeoJSON
的使用
思路整理
需求
项目的任务是统计出租车在不同行政区的平均等待时间, 所以源数据集和经过计算希望得到的新数据集大致如下
源数据集
目标数据集
目标数据集分析
目标数据集中有三列,
borough
,avg(seconds)
,stddev_samp(seconds)
borough
表示目的地行政区的名称avg(seconds)
和stddev_samp(seconds)
是seconds
的聚合,seconds
是下车时间和下一次上车时间之间的差值, 代表等待时间
所以有两列数据是现在数据集中没有
borough
要根据数据集中的经纬度, 求出其行政区的名字seconds
要根据数据集中上下车时间, 求出差值
步骤
- 求出
borough
- 读取行政区位置信息
- 搜索每一条数据的下车经纬度所在的行政区
- 在数据集中添加行政区列
- 求出
seconds
- 根据
borough
计算平均等待时间, 是一个聚合操作
- 求出
GeoJSON 是什么
定义
GeoJSON
是一种基于JSON
的开源标准格式, 用来表示地理位置信息- 其中定了很多对象, 表示不同的地址位置单位
如何表示地理位置
类型 例子 点 { "type": "Point", "coordinates": [30, 10] }
线段 { "type": "Point", "coordinates": [30, 10] }
多边形 { "type": "Point", "coordinates": [30, 10] }
{ "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
使用步骤
- 创建一个类型
Feature
, 对应JSON
文件中的格式 - 通过解析
JSON
, 创建Feature
对象 - 通过
Feature
对象创建GeoJSON
表示一个地理位置的Geometry
对象 - 通过
Geometry
对象判断一个经纬度是否在其范围内
- 创建一个类型
总结
- 思路
- 从需求出发, 设计结果集
- 推导结果集所欠缺的字段
- 补齐欠缺的字段, 生成结果集, 需求完成
- 后续整体上要做的事情
- 需求是查看出租车在不同行政区的等待客人的时间
- 需要补充两个点, 一是出租车下客点的行政区名称, 二是等待时间
- 本章节聚焦于行政区的信息补充
- 学习步骤
- 介绍
JSON
解析的工具 - 介绍读取
GeoJSON
的工具 JSON
解析- 读取
GeoJSON
- 介绍
6.2. 工具介绍
目标和步骤
目标
理解 JSON
解析和 Geometry
解析所需要的工具, 后续使用这些工具补充行政区信息
步骤
JSON4S
ESRI Geometry
JSON4S 介绍
介绍
一般在
Java
中, 常使用如下三个工具解析JSON
Gson
Google
开源的JSON
解析工具, 比较人性化, 易于使用, 但是性能不如Jackson
, 也不如Jackson
有积淀Jackson
Jackson
是功能最完整的JSON
解析工具, 也是最老牌的JSON
解析工具, 性能也足够好, 但是API
在一开始支持的比较少, 用起来稍微有点繁琐FastJson
阿里巴巴的
JSON
开源解析工具, 以快著称, 但是某些方面用起来稍微有点反直觉
什么是
JSON
解析- 读取
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
步骤
- 解析
JSON
对象 - 序列化
JSON
对象 - 使用
Jackson
反序列化Scala
对象 - 使用
Jackson
序列化Scala
对象
代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import 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
4val 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
解析FastJSON
和Gson
直接在Scala
中使用会出现问题, 因为Scala
的对象体系和Java
略有不同- 最为适合
Scala
的方式是使用JSON4S
作为上层API
,Jackson
作为底层提供JSON
解析能力, 共同实现JSON
解析 - 其使用方式非常简单, 两行即可解析
1
2implicit 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)
- 有一个很适合 Scala 的 GeoJSON 解析工具, 叫做
后续工作
- 读取行政区的数据集, 解析
JSON
格式, 将JSON
格式的字符串转为对象 - 使用
ESRI
的GeometryEngine
读取行政区的Geometry
对象的JSON
字符串, 生成Geometry
对象 - 使用上车点和下车点的坐标创建
Point
对象 (Geometry
的子类) - 判断
Point
是否在行政区的Geometry
的范围内 (行政区的Geometry
其实本质上是子类Polygon
的对象)
- 读取行政区的数据集, 解析
6.3. 具体实现
目标和步骤
目标
通过 JSON4S
和 ESRI
配合解析提供的 GeoJSON
数据集, 获取纽约的每个行政区的范围
步骤
- 解析
JSON
- 使用
ESRI
生成表示行政区的一组Geometry
对象
解析 JSON
步骤
- 对照
JSON
中的格式, 创建解析的目标类 - 解析
JSON
数据转为目标类的对象 - 读取数据集, 执行解析
- 对照
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
10case 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
8object FeatureExtraction {
def parseJson(json: String): FeatureCollection = {
implicit val format: AnyRef with Formats = Serialization.formats(NoTypeHints)
val featureCollection = readFeatureCollection
featureCollection
}
}
Step 3: 读取数据集, 转换数据
1
2val geoJson = Source.fromFile("dataset/nyc-borough-boundaries-polygon.geojson").mkString
val features = FeatureExtraction.parseJson(geoJson)
解析 GeoJSON
步骤
- 转换
JSON
为Geometry
对象
- 转换
表示行政区的 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
10case 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 中增加行政区信息
步骤
- 将
Geometry
数据集按照区域大小排序 - 广播
Geometry
信息, 发给每一个Executor
- 创建
UDF
, 通过经纬度获取行政区信息 - 统计行政区信息
- 将
Step 1: 排序
Geometry
- 动机: 后续需要逐个遍历
Geometry
对象, 取得每条出租车数据所在的行政区, 大的行政区排在前面效率更好一些
1
2
3val 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
10val 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
3taxiClean.groupBy(boroughUDF('dropOffX, 'dropOffY))
.count()
.show()
总结
- 具体的实现分为两个大步骤
- 解析
JSON
生成Geometry
数据 - 通过
Geometry
数据, 取得每一条出租车数据的行政区信息
- 解析
Geometry
数据的生成又有如下步骤- 使用
JSON4S
解析行政区区域信息的数据集 - 取得其中每一个行政区信息的
Geometry
区域信息, 转为ESRI
的Geometry
对象
- 使用
- 查询经纬度信息, 获取其所在的区域, 有如下步骤
- 遍历
Geometry
数组, 搜索经纬度所表示的Point
对象在哪个区域内 - 返回区域的名称
- 使用
UDF
的目的是为了统计数据集, 后续会通过函数直接完成功能
- 使用
- 遍历
7. 会话统计
目标和步骤
目标
- 统计每个行政区的所有行程, 查看每个行政区平均等候客人的时间
- 掌握会话统计的方式方法
步骤
- 会话统计的概念
- 功能实现
会话统计的概念
需求分析
需求
统计每个行政区的平均等客时间
需求可以拆分为如下几个步骤
- 按照行政区分组
- 在每一个行政区中, 找到同一个出租车司机的先后两次订单, 本质就是再次针对司机的证件号再次分组
- 求出这两次订单的下车时间和上车时间只差, 便是等待客人的时间
- 针对一个行政区, 求得这个时间的平均数
问题: 分组效率太低
分组的效率相对较低
- 分组是
Shuffle
- 两次分组, 包括后续的计算, 相对比较复杂
- 分组是
解决方案: 分区后在分区中排序
按照
License
重新分区, 如此一来, 所有相同的司机的数据就会在同一个分区中计算分区中连续两条数据的时间差
上述的计算存在一个问题, 一个分组会有多个司机的数据, 如何划分每个司机的数据边界? 其实可以先过滤一下, 计算时只保留同一个司机的数据 无论是刚才的多次分组, 还是后续的分区, 都是要找到每个司机的会话, 通过会话来完成功能, 也叫做会话分析
功能实现
步骤
- 过滤掉没有经纬度的数据
- 按照
License
重新分区并按照License
和pickUpTime
排序 - 求得每个司机的下车和下次上车的时间差
- 求得每个行政区得统计数据
Step 1: 过滤没有经纬度的数据
1
val taxiDone = taxiClean.where("dropOffX != 0 and dropOffY != 0 and pickUpX != 0 and pickUpY != 0")
Step 2: 划分会话
1
2val sessions = taxiDone.repartition('license)
.sortWithinPartitions('license, 'pickUpTime)Step 3: 求得时间差
处理每个分区, 通过
Scala
的API
找到相邻的数据1
2
3sessions.mapPartitions(trips => {
val viter = trips.sliding(2)
})过滤司机不同的相邻数据
1
2
3
4
5sessions.mapPartitions(trips => {
val viter = trips.sliding(2)
.filter(_.size == 2)
.filter(p => p.head.license == p.last.license)
})求得时间差
1
2
3
4
5
6
7
8
9
10
11
12def 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
4boroughDurations.where("seconds > 0")
.groupBy("borough")
.agg(avg("seconds"), stddev("seconds"))
.show()
总结
- 其实会话分析的难点就是理解需求
- 需求是找到每个行政区的待客时间, 就是按照行政区分组
- 需求是找到待客时间, 就是按照司机进行分组, 并且还要按照时间进行排序, 才可找到一个司机相邻的两条数据
- 但是分组和统计的效率较低
- 可以把相同司机的所有形成发往一个分区
- 然后按照司机的
License
和上车时间综合排序 - 这样就可以找到同一个司机的两次行程之间的差值
Spark Streaming
导读
- 介绍
- 入门
- 原理
- 操作
Table of Contents
1. Spark Streaming 介绍
导读
- 流式计算的场景
- 流式计算框架
Spark Streaming
的特点
新的场景
通过对现阶段一些常见的需求进行整理, 我们要问自己一个问题, 这些需求如何解决?
场景 | 解释 |
---|---|
商品推荐 | ![]() ![]() |
工业大数据 | ![]() |
监控 | ![]() MySQL , HBase 等进行监控要针对应用进行监控, 例如 Tomcat , Nginx , Node.js 等要针对硬件的一些指标进行监控, 例如 CPU , 内存, 磁盘 等这些工具的日志输出是非常多的, 往往一个用户的访问行为会带来几百条日志, 这些都要汇报, 所以数据量比较大要从这些日志中, 聚合系统运行状况 |
这样的需求, 可以通过传统的批处理来完成吗? | |
---|---|
流计算
批量计算
数据已经存在, 一次性读取所有的数据进行批量处理
流计算
数据源源不断的进来, 经过处理后落地
流和批的架构组合
流和批都是有意义的, 有自己的应用场景, 那么如何结合流和批呢? 如何在同一个系统中使用这两种不同的解决方案呢?
混合架构
混合架构说明
混合架构的名字叫做
Lambda 架构
, 混合架构最大的特点就是将流式计算和批处理结合起来后在进行查询的时候分别查询流系统和批系统, 最后将结果合并在一起
一般情况下 Lambda 架构分三层
- 批处理层: 批量写入, 批量读取
- 服务层: 分为两个部分, 一部分对应批处理层, 一部分对应速度层
- 速度层: 随机读取, 随即写入, 增量计算
优点
- 兼顾优点, 在批处理层可以全量查询和分析, 在速度层可以查询最新的数据
- 速度很快, 在大数据系统中, 想要快速的获取结果是非常困难的, 因为高吞吐量和快速返回结果往往很难兼得, 例如
Impala
和Hive
,Hive
能进行非常大规模的数据量的处理,Impala
能够快速的查询返回结果, 但是很少有一个系统能够兼得两点,Lambda
使用多种融合的手段从而实现
缺点
Lambda
是一个非常反人类的设计, 因为我们需要在系统中不仅维护多套数据层, 还需要维护批处理和流式处理两套框架, 这非常困难, 一套都很难搞定, 两套带来的运维问题是是指数级提升的
流式架构
流式架构说明
流式架构常见的叫做
Kappa 结构
, 是Lambda 架构
的一个变种, 其实本质上就是删掉了批处理优点
- 非常简单
- 效率很高, 在存储系统的发展下, 很多存储系统已经即能快速查询又能批量查询了, 所以
Kappa 架构
在新时代还是非常够用的
问题
丧失了一些
Lambda
的优秀特点
关于架构的问题, 很多时候往往是无解的, 在合适的地方使用合适的架构, 在项目课程中, 还会进行更细致的讨论
Spark Streaming
的特点
特点 | 说明 |
---|---|
Spark Streaming 是 Spark Core API 的扩展 |
Spark Streaming 具有类似 RDD 的 API , 易于使用, 并可和现有系统共用相似代码一个非常重要的特点是, Spark Streaming 可以在流上使用基于 Spark 的机器学习和流计算, 是一个一站式的平台 |
Spark Streaming 具有很好的整合性 |
Spark Streaming 可以从 Kafka , Flume , TCP 等流和队列中获取数据Spark Streaming 可以将处理过的数据写入文件系统, 常见数据库中 |
Spark Streaming 是微批次处理模型 |
微批次处理的方式不会有长时间运行的 Operator , 所以更易于容错设计微批次模型能够避免运行过慢的服务, 实行推测执行 |
2. Spark Streaming 入门
导读
- 环境准备
- 工程搭建
- 代码编写
- 总结
Netcat
的使用
Step 1
: Socket
回顾
Socket
是Java
中为了支持基于TCP / UDP
协议的通信所提供的编程模型Socket
分为Socket server
和Socket client
Socket server
监听某个端口, 接收
Socket client
发过来的连接请求建立连接, 连接建立后可以向Socket client
发送TCP packet
交互 (被动)Socket client
向某个端口发起连接, 并在连接建立后, 向
Socket server
发送TCP packet
实现交互 (主动)TCP
三次握手建立连接Step 1
Client
向Server
发送SYN(j)
, 进入SYN_SEND
状态等待Server
响应Step 2
Server
收到Client
的SYN(j)
并发送确认包ACK(j + 1)
, 同时自己也发送一个请求连接的SYN(k)
给Client
, 进入SYN_RECV
状态等待Client
确认Step 3
Client
收到Server
的ACK + SYN
, 向Server
发送连接确认ACK(k + 1)
, 此时,Client
和Server
都进入ESTABLISHED
状态, 准备数据发送
1 | Step 2:` `Netcat |
Netcat
简写nc
, 命令行中使用nc
命令调用Netcat
是一个非常常见的Socket
工具, 可以使用nc
建立Socket server
也可以建立Socket client
nc -l
建立Socket server
,l
是listen
监听的意思nc host port
建立Socket client
, 并连接到某个Socket server
创建工程
目标
使用 Spark Streaming
程序和 Socket server
进行交互, 从 Server
处获取实时传输过来的字符串, 拆开单词并统计单词数量, 最后打印出来每一个小批次的单词数量
Step 1:
创建工程
- 创建
IDEA Maven
工程, 步骤省略, 参考Spark
第一天工程建立方式 - 导入
Maven
依赖, 省略, 参考Step 2
- 创建
main/scala
文件夹和test/scala
文件夹 - 创建包
cn.itcast.streaming
- 创建对象
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
1 | 在 Spark 中, 一般使用 XXContext 来作为入口, Streaming 也不例外, 所以创建 StreamingContext 就是创建入口 |
---|---|
2 | 开启 Socket 的 Receiver , 连接到某个 TCP 端口, 作为 Socket client , 去获取数据 |
3 | 选择 Receiver 获取到数据后的保存方式, 此处是内存和磁盘都有, 并且序列化后保存 |
4 | 类似 RDD 中的 Action , 执行最后的数据输出和收集 |
5 | 启动流和 JobGenerator , 开始流式处理数据 |
6 | 阻塞主线程, 后台线程开始不断获取数据并处理 |
Step 4:
部署和上线
使用 Maven 命令 package 打包
将打好的包上传到
node01
在
node02
上使用nc
开启一个Socket server
, 接受Streaming
程序的连接请求, 从而建立连接发送消息给Streaming
程序实时处理1
nc -lk 9999
在
node01
执行如下命令运行程序1
spark-submit --class cn.itcast.streaming.StreamingWordCount --master local[6] original-streaming-0.0.1.jar node02 9999
Step 5:
总结和知识落地
注意点
Spark Streaming
并不是真正的来一条数据处理一条Spark Streaming
的处理机制叫做小批量, 英文叫做mini-batch
, 是收集了一定时间的数据后生成RDD
, 后针对RDD
进行各种转换操作, 这个原理提现在如下两个地方- 控制台中打印的结果是一个批次一个批次的, 统计单词数量也是按照一个批次一个批次的统计
- 多长时间生成一个
RDD
去统计呢? 由new StreamingContext(sparkConf, Seconds(1))
这段代码中的第二个参数指定批次生成的时间
Spark Streaming
中至少要有两个线程在使用
spark-submit
启动程序的时候, 不能指定一个线程- 主线程被阻塞了, 等待程序运行
- 需要开启后台线程获取数据
创建 StreamingContext
1 | val conf = new SparkConf().setAppName(appName).setMaster(master) |
StreamingContext
是Spark Streaming
程序的入口- 在创建
StreamingContext
的时候, 必须要指定两个参数, 一个是SparkConf
, 一个是流中生成RDD
的时间间隔 StreamingContext
提供了如下功能- 创建
DStream
, 可以通过读取Kafka
, 读取Socket
消息, 读取本地文件等创建一个流, 并且作为整个DAG
中的InputDStream
RDD
遇到Action
才会执行, 但是DStream
不是,DStream
只有在StreamingContext.start()
后才会开始接收数据并处理数据- 使用
StreamingContext.awaitTermination()
等待处理被终止 - 使用
StreamingContext.stop()
来手动的停止处理
- 创建
- 在使用的时候有如下注意点
- 同一个
Streaming
程序中, 只能有一个StreamingContext
- 一旦一个
Context
已经启动 (start
), 则不能添加新的数据源 **
- 同一个
各种算子
- 这些算子类似
RDD
, 也会生成新的DStream
- 这些算子操作最终会落到每一个
DStream
生成的RDD
中
算子 | 释义 |
---|---|
flatMap |
lines.flatMap(_.split(" ")) 将一个数据一对多的转换为另外的形式, 规则通过传入函数指定 |
map |
words.map(x => (x, 1)) 一对一的转换数据 |
reduceByKey |
words.reduceByKey(_ + _) 这个算子需要特别注意, 这个聚合并不是针对于整个流, 而是针对于某个批次的数据 |
2. 原理
- 总章
- 静态
DAG
- 动态切分
- 数据流入
- 容错机制
总章
Spark Streaming
的特点
Spark Streaming
会源源不断的处理数据, 称之为流计算Spark Streaming
并不是实时流, 而是按照时间切分小批量, 一个一个的小批量处理Spark Streaming
是流计算, 所以可以理解为数据会源源不断的来, 需要长时间运行
Spark Streaming
是按照时间切分小批量
如何小批量?
Spark Streaming
中的编程模型叫做DStream
, 所有的API
都从DStream
开始, 其作用就类似于RDD
之于Spark Core
可以理解为
DStream
是一个管道, 数据源源不断的从这个管道进去, 被处理, 再出去但是需要注意的是,
DStream
并不是严格意义上的实时流, 事实上,DStream
并不处理数据, 而是处理RDD
以上, 可以整理出如下道理
Spark Streaming
是小批量处理数据, 并不是实时流Spark Streaming
对数据的处理是按照时间切分为一个又一个小的RDD
, 然后针对RDD
进行处理
所以针对以上的解读, 可能会产生一种疑惑
- 如何切分
RDD
?
如何处理数据?
如下代码
1
2
3
4
5
6
7
8
9val 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
是流计算, 流计算的数据是无限的
什么系统可以产生无限的数据?
无限的数据一般指的是数据不断的产生, 比如说运行中的系统, 无法判定什么时候公司会倒闭, 所以也无法断定数据什么时候会不再产生数据
那就会产生一个问题
如何不简单的读取数据, 如何应对数据量时大时小?
如何数据是无限的, 意味着可能要一直运行下去
那就会又产生一个问题
Spark Streaming
不会出错吗? 数据出错了怎么办?
总结
总结下来, 有四个问题
DStream
如何对应RDD
?- 如何切分
RDD
? - 如何读取数据?
- 如何容错?
DAG
的定义
RDD
和 DStream
的 DAG
如果是 RDD
的 WordCount
, 代码大致如下
1 | val textRDD = sc.textFile(...) |
用图形表示如下
同样, DStream
的代码大致如下
1 | val lines: DStream[String] = ssc.socketTextStream(...) |
同理, DStream
也可以形成 DAG
如下
看起来 DStream
和 RDD
好像哟, 确实如此
RDD
和 DStream
的区别
DStream
的数据是不断进入的,RDD
是针对一个数据的操作- 像
RDD
一样,DStream
也有不同的子类, 通过不同的算子生成 - 一个
DStream
代表一个数据集, 其中包含了针对于上一个数据的操作 DStream
根据时间切片, 划分为多个RDD
, 针对DStream
的计算函数, 会作用于每一个DStream
中的RDD
DStream
如何形式 DAG
- 每个
DStream
都有一个关联的DStreamGraph
对象 DStreamGraph
负责表示DStream
之间的的依赖关系和运行步骤DStreamGraph
中会单独记录InputDStream
和OutputDStream
切分流, 生成小批量
静态和动态
根据前面的学习, 可以总结一下规律
DStream
对应RDD
DStreamGraph
表示DStream
之间的依赖关系和运行流程, 相当于RDD
通过DAGScheduler
所生成的RDD DAG
但是回顾前面的内容, RDD
的运行分为逻辑计划和物理计划
- 逻辑计划就是
RDD
之间依赖关系所构成的一张有向无环图 - 后根据这张
DAG
生成对应的TaskSet
调度到集群中运行, 如下
但是在 DStream
中则不能这么简单的划分, 因为 DStream
中有一个非常重要的逻辑, 需要按照时间片划分小批量
- 在
Streaming
中,DStream
类似RDD
, 生成的是静态的数据处理过程, 例如一个DStream
中的数据经过map
转为其它模样 - 在
Streaming
中,DStreamGraph
类似DAG
, 保存了这种数据处理的过程
上述两点, 其实描述的是静态的一张 DAG
, 数据处理过程, 但是 Streaming
是动态的, 数据是源源不断的来的
所以, 在 DStream
中, 静态和动态是两个概念, 有不同的流程
DStreamGraph
将DStream
联合起来, 生成DStream
之间的DAG
, 这些DStream
之间的关系是相互依赖的关系, 例如一个DStream
经过map
转为另外一个DStream
- 但是把视角移动到
DStream
中来看,DStream
代表了源源不断的RDD
的生成和处理, 按照时间切片, 所以一个DStream DAG
又对应了随着时间的推进所产生的无限个RDD DAG
动态生成 RDD DAG
的过程
RDD DAG
的生成是按照时间来切片的, Streaming
会维护一个 Timer
, 固定的时间到达后通过如下五个步骤生成一个 RDD DAG
后调度执行
- 通知
Receiver
将收到的数据暂存, 并汇报存储的元信息, 例如存在哪, 存了什么 - 通过
DStreamGraph
复制出一套新的RDD DAG
- 将数据暂存的元信息和
RDD DAG
一同交由JobScheduler
去调度执行 - 提交结束后, 对系统当前的状态
Checkpoint
数据的产生和导入
Receiver
在 Spark Streaming
中一个非常大的挑战是, 很多外部的队列和存储系统都是分块的, RDD
是分区的, 在读取外部数据源的时候, 会用不同的分区对照外部系统的分片, 例如
不仅 RDD
, DStream
中也面临这种挑战
那么此处就有一个小问题
DStream
中是RDD
流, 只是RDD
的分区对应了Kafka
的分区就可以了吗?
答案是不行, 因为需要一套单独的机制来保证并行的读取外部数据源, 这套机制叫做 Receiver
Receiver
的结构
为了保证并行获取数据, 对应每一个外部数据源的分区, 所以 Receiver
也要是分布式的, 主要分为三个部分
Receiver
是一个对象, 是可以有用户自定义的获取逻辑对象, 表示了如何获取数据Receiver Tracker
是Receiver
的协调和调度者, 其运行在Driver
上Receiver Supervisor
被Receiver Tracker
调度到不同的几点上分布式运行, 其会拿到用户自定义的Receiver
对象, 使用这个对象来获取外部数据
Receiver
的执行过程
- 在
Spark Streaming
程序开启时候,Receiver Tracker
使用JobScheduler
分发Job
到不同的节点, 每个Job
包含一个Task
, 这个Task
就是Receiver Supervisor
, 这个部分的源码还挺精彩的, 其实是复用了通用的调度逻辑 ReceiverSupervisor
启动后运行Receiver
实例Receiver
启动后, 就将持续不断地接收外界数据, 并持续交给ReceiverSupervisor
进行数据存储ReceiverSupervisor
持续不断地接收到Receiver
转来的数据, 并通过BlockManager
来存储数据- 获取的数据存储完成后发送元数据给
Driver
端的ReceiverTracker
, 包含数据块的id
, 位置, 数量, 大小 等信息
容错
因为要非常长时间的运行, 对于任何一个流计算系统来说, 容错都是非常致命也非常重要的一环, 在 Spark Streaming
中, 大致提供了如下的容错手段
热备
还记得这行代码吗
这行代码中的 StorageLevel.MEMORY_AND_DISK_SER
的作用是什么? 其实就是热备份
- 当 Receiver 获取到数据要存储的时候, 是交给 BlockManager 存储的
- 如果设置了
StorageLevel.MEMORY_AND_DISK_SER
, 则意味着BlockManager
不仅会在本机存储, 也会发往其它的主机进行存储, 本质就是冗余备份 - 如果某一个计算失败了, 通过冗余的备份, 再次进行计算即可
这是默认的容错手段
冷备
冷备在 Spark Streaming
中的手段叫做 WAL
(预写日志)
- 当
Receiver
获取到数据后, 会交给BlockManager
存储 - 在存储之前先写到
WAL
中,WAL
中保存了Redo Log
, 其实就是记录了数据怎么产生的, 以便于恢复的时候通过Log
恢复 - 当出错的时候, 通过
Redo Log
去重放数据
重放
- 有一些上游的外部系统是支持重放的, 比如说
Kafka
Kafka
可以根据Offset
来获取数据- 当
SparkStreaming
处理过程中出错了, 只需要通过Kafka
再次读取即可
3. 操作
导读
这一小节主要目的是为了了解 Spark Streaming
一些特别特殊和重要的操作, 一些基本操作基本类似 RDD
updateStateByKey
需求: 统计整个流中, 所有出现的单词数量, 而不是一个批中的数量 | |
---|---|
状态
统计总数
入门案例中, 只能统计某个时间段内的单词数量, 因为
reduceByKey
只能作用于某一个RDD
, 不能作用于整个流如果想要求单词总数该怎么办?
状态
可以使用状态来记录中间结果, 从而每次来一批数据, 计算后和中间状态求和, 于是就完成了总数的统计
实现
- 使用
updateStateByKey
可以做到这件事 updateStateByKey
会将中间状态存入CheckPoint
中
1 | val sparkConf = new SparkConf().setAppName("NetworkWordCount").setMaster("local[6]") |
window
操作
需求: 计算过 30s 的单词总数, 每 10s 更新一次 |
|
---|---|
实现
- 使用
window
即可实现按照窗口组织 RDD
1 | val sparkConf = new SparkConf().setAppName("NetworkWordCount").setMaster("local[6]") |
- 既然
window
操作经常配合reduce
这种聚合, 所以Spark Streaming
提供了较为方便的方法
1 | val sparkConf = new SparkConf().setAppName("NetworkWordCount").setMaster("local[6]") |
窗口时间
在
window
函数中, 接收两个参数windowDuration
窗口长度,window
函数会将多个DStream
中的RDD
按照时间合并为一个, 那么窗口长度配置的就是将多长时间内的RDD
合并为一个slideDuration
滑动间隔, 比较好理解的情况是直接按照某个时间来均匀的划分为多个window
, 但是往往需求可能是统计最近xx分
内的所有数据, 一秒刷新一次, 那么就需要设置滑动窗口的时间间隔了, 每隔多久生成一个window
滑动时间的问题
- 如果
windowDuration > slideDuration
, 则在每一个不同的窗口中, 可能计算了重复的数据 - 如果
windowDuration < slideDuration
, 则在每一个不同的窗口之间, 有一些数据为能计算进去
但是其实无论谁比谁大, 都不能算错, 例如, 我的需求有可能就是统计一小时内的数据, 一天刷新两次
- 如果
Structured Streaming
全天目标
- 回顾和展望
- 入门案例
Stuctured Streaming
的体系和结构
1. 回顾和展望
本章目标
Structured Streaming
是 Spark Streaming
的进化版, 如果了解了 Spark
的各方面的进化过程, 有助于理解 Structured Streaming
的使命和作用
本章过程
Spark
的API
进化过程Spark
的序列化进化过程Spark Streaming
和Structured Streaming
1.1. Spark 编程模型的进化过程
目标和过程
目标
Spark
的进化过程中, 一个非常重要的组成部分就是编程模型的进化, 通过编程模型可以看得出来内在的问题和解决方案
过程
- 编程模型
RDD
的优点和缺陷 - 编程模型
DataFrame
的优点和缺陷 - 编程模型
Dataset
的优点和缺陷
编程模型 | 解释 |
---|---|
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 对象保存数据Spark 为 DataFrame 设计了新的数据读写框架, 更加强大, 支持的数据源众多 |
Dataset |
spark.read .csv("...") .as[Person] .where(_.name != "") .groupByKey(_.name) .count() .show()``Dataset 结合了 RDD 和 DataFrame 的特点, 从 API 上即可以处理结构化数据, 也可以处理非结构化数据Dataset 和 DataFrame 其实是一个东西, 所以 DataFrame 的性能优势, 在 Dataset 上也有 |
总结
RDD
的优点
- 面向对象的操作方式
- 可以处理任何类型的数据
RDD
的缺点
- 运行速度比较慢, 执行过程没有优化
API
比较僵硬, 对结构化数据的访问和操作没有优化
DataFrame
的优点
- 针对结构化数据高度优化, 可以通过列名访问和转换数据
- 增加
Catalyst
优化器, 执行过程是优化的, 避免了因为开发者的原因影响效率
DataFrame
的缺点
- 只能操作结构化数据
- 只有无类型的
API
, 也就是只能针对列和SQL
操作数据,API
依然僵硬
Dataset
的优点
- 结合了
RDD
和DataFrame
的API
, 既可以操作结构化数据, 也可以操作非结构化数据 - 既有有类型的
API
也有无类型的API
, 灵活选择
1.2. Spark 的 序列化 的进化过程
目标和过程
目标
Spark
中的序列化过程决定了数据如何存储, 是性能优化一个非常重要的着眼点, Spark
的进化并不只是针对编程模型提供的 API
, 在大数据处理中, 也必须要考虑性能
过程
- 序列化和反序列化是什么
Spark
中什么地方用到序列化和反序列化RDD
的序列化和反序列化如何实现Dataset
的序列化和反序列化如何实现
Step 1: 什么是序列化和序列化
在 Java
中, 序列化的代码大概如下
1 | public class JavaSerializable implements Serializable { |
序列化是什么
- 序列化的作用就是可以将对象的内容变成二进制, 存入文件中保存
- 反序列化指的是将保存下来的二进制对象数据恢复成对象
序列化对对象的要求
- 对象必须实现
Serializable
接口 - 对象中的所有属性必须都要可以被序列化, 如果出现无法被序列化的属性, 则序列化失败
限制
- 对象被序列化后, 生成的二进制文件中, 包含了很多环境信息, 如对象头, 对象中的属性字段等, 所以内容相对较大
- 因为数据量大, 所以序列化和反序列化的过程比较慢
序列化的应用场景
- 持久化对象数据
- 网络中不能传输
Java
对象, 只能将其序列化后传输二进制数据
Step 2: 在 Spark
中的序列化和反序列化的应用场景
Task
分发Task
是一个对象, 想在网络中传输对象就必须要先序列化RDD
缓存1
2
3
4
5
6val rdd1 = rdd.flatMap(_.split(" "))
.map((_, 1))
.reduceByKey(_ + _)
rdd1.cache
rdd1.collect
RDD
中处理的是对象, 例如说字符串,Person
对象等- 如果缓存
RDD
中的数据, 就需要缓存这些对象 - 对象是不能存在文件中的, 必须要将对象序列化后, 将二进制数据存入文件
广播变量
- 广播变量会分发到不同的机器上, 这个过程中需要使用网络, 对象在网络中传输就必须先被序列化
Shuffle
过程Shuffle
过程是由Reducer
从Mapper
中拉取数据, 这里面涉及到两个需要序列化对象的原因RDD
中的数据对象需要在Mapper
端落盘缓存, 等待拉取Mapper
和Reducer
要传输数据对象
Spark Streaming
的Receiver
Spark Streaming
中获取数据的组件叫做Receiver
, 获取到的数据也是对象形式, 在获取到以后需要落盘暂存, 就需要对数据对象进行序列化
算子引用外部对象
1
2
3
4
5class Unserializable(i: Int)
rdd.map(i => new Unserializable(i))
.collect
.foreach(println)
- 在
Map
算子的函数中, 传入了一个Unserializable
的对象 Map
算子的函数是会在整个集群中运行的, 那Unserializable
对象就需要跟随Map
算子的函数被传输到不同的节点上- 如果
Unserializable
不能被序列化, 则会报错
Step 3: RDD
的序列化
RDD
的序列化
RDD 的序列化只能使用 Java 序列化器, 或者 Kryo 序列化器
为什么?
- RDD 中存放的是数据对象, 要保留所有的数据就必须要对对象的元信息进行保存, 例如对象头之类的
- 保存一整个对象, 内存占用和效率会比较低一些
Kryo
是什么
Kryo
是Spark
引入的一个外部的序列化工具, 可以增快RDD
的运行速度因为
Kryo
序列化后的对象更小, 序列化和反序列化的速度非常快在
RDD
中使用Kryo
的过程如下1
2
3
4
5
6
7
8val 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: DataFrame
和 Dataset
中的序列化
历史的问题
RDD
中无法感知数据的组成, 无法感知数据结构, 只能以对象的形式处理数据
DataFrame
和 Dataset
的特点
DataFrame
和Dataset
是为结构化数据优化的在
DataFrame
和Dataset
中, 数据和数据的Schema
是分开存储的1
2
3
4
5
6spark.read
.csv("...")
.where($"name" =!= "")
.groupBy($"name")
.map(row: Row => row)
.show()DataFrame
中没有数据对象这个概念, 所有的数据都以行的形式存在于Row
对象中,Row
中记录了每行数据的结构, 包括列名, 类型等Dataset
中上层可以提供有类型的API
, 用以操作数据, 但是在内部, 无论是什么类型的数据对象Dataset
都使用一个叫做InternalRow
的类型的对象存储数据1
val dataset: Dataset[Person] = spark.read.csv(...).as[Person]
优化点 1: 元信息独立
RDD
不保存数据的元信息, 所以只能使用Java Serializer
或者Kyro Serializer
保存 整个对象DataFrame
和Dataset
中保存了数据的元信息, 所以可以把元信息独立出来分开保存一个
DataFrame
或者一个Dataset
中, 元信息只需要保存一份, 序列化的时候, 元信息不需要参与在反序列化 (
InternalRow → Object
) 时加入Schema
信息即可
元信息不再参与序列化, 意味着数据存储量的减少, 和效率的增加
优化点 2: 使用堆外内存
DataFrame
和Dataset
不再序列化元信息, 所以内存使用大大减少. 同时新的序列化方式还将数据存入堆外内存中, 从而避免GC
的开销.- 堆外内存又叫做
Unsafe
, 之所以叫不安全的, 因为不能使用Java
的垃圾回收机制, 需要自己负责对象的创建和回收, 性能很好, 但是不建议普通开发者使用, 毕竟不安全
总结
- 当需要将对象缓存下来的时候, 或者在网络中传输的时候, 要把对象转成二进制, 在使用的时候再将二进制转为对象, 这个过程叫做序列化和反序列化
- 在
Spark
中有很多场景需要存储对象, 或者在网络中传输对象Task
分发的时候, 需要将任务序列化, 分发到不同的Executor
中执行- 缓存
RDD
的时候, 需要保存RDD
中的数据 - 广播变量的时候, 需要将变量序列化, 在集群中广播
RDD
的Shuffle
过程中Map
和Reducer
之间需要交换数据- 算子中如果引入了外部的变量, 这个外部的变量也需要被序列化
RDD
因为不保留数据的元信息, 所以必须要序列化整个对象, 常见的方式是Java
的序列化器, 和Kyro
序列化器Dataset
和DataFrame
中保留数据的元信息, 所以可以不再使用Java
的序列化器和Kyro
序列化器, 使用Spark
特有的序列化协议, 生成UnsafeInternalRow
用以保存数据, 这样不仅能减少数据量, 也能减少序列化和反序列化的开销, 其速度大概能达到RDD
的序列化的20
倍左右
1.3. Spark Streaming 和 Structured Streaming
目标和过程
目标
理解 Spark Streaming
和 Structured Streaming
之间的区别, 是非常必要的, 从这点上可以理解 Structured Streaming
的过去和产生契机
过程
Spark Streaming
时代Structured Streaming
时代Spark Streaming
和Structured Streaming
Spark Streaming
时代
Spark Streaming
其实就是RDD
的API
的流式工具, 其本质还是RDD
, 存储和执行过程依然类似RDD
Structured Streaming
时代
Structured Streaming
其实就是Dataset
的API
的流式工具,API
和Dataset
保持高度一致
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
时候是什么, 以及核心体系原理打下基础
步骤
- 需求梳理
Structured Streaming
代码实现- 运行
- 验证结果
2.1. 需求梳理
目标和过程
目标
理解接下来要做的案例, 有的放矢
步骤
- 需求
- 整体结构
- 开发方式
需求
- 编写一个流式计算的应用, 不断的接收外部系统的消息
- 对消息中的单词进行词频统计
- 统计全局的结果
整体结构
Socket Server
等待Structured Streaming
程序连接Structured Streaming
程序启动, 连接Socket Server
, 等待Socket Server
发送数据Socket Server
发送数据,Structured Streaming
程序接收数据Structured Streaming
程序接收到数据后处理数据- 数据处理后, 生成对应的结果集, 在控制台打印
开发方式和步骤
Socket server
使用 Netcat nc
来实现
Structured Streaming
程序使用 IDEA
实现, 在 IDEA
中本地运行
- 编写代码
- 启动
nc
发送Socket
消息 - 运行代码接收
Socket
消息统计词频
总结
- 简单来说, 就是要进行流式的词频统计, 使用
Structured Streaming
2.2. 代码实现
目标和过程
目标
实现 Structured Streaming
部分的代码编写
步骤
- 创建文件
- 创建
SparkSession
- 读取
Socket
数据生成DataFrame
- 将
DataFrame
转为Dataset
, 使用有类型的API
处理词频统计 - 生成结果集, 并写入控制台
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
中的编程模型依然是DataFrame
和Dataset
Structured Streaming
中依然是有外部数据源读写框架的, 叫做readStream
和writeStream
Structured Streaming
和SparkSQL
几乎没有区别, 唯一的区别是,readStream
读出来的是流,writeStream
是将流输出, 而SparkSQL
中的批处理使用read
和write
2.3. 运行和结果验证
目标和过程
目标
代码已经编写完毕, 需要运行, 并查看结果集, 因为从结果集的样式中可以看到 Structured Streaming
的一些原理
步骤
- 开启
Socket server
- 运行程序
- 查看数据集
开启 Socket server
和运行程序
在虚拟机
node01
中运行nc -lk 9999
在 IDEA 中运行程序
在
node01
中输入以下内容1
2
3
4
5hello world
hello spark
hello hadoop
hello spark
hello spark
查看结果集
1 | ------------------------------------------- |
从结果集中可以观察到以下内容
Structured Streaming
依然是小批量的流处理Structured Streaming
的输出是类似DataFrame
的, 也具有Schema
, 所以也是针对结构化数据进行优化的- 从输出的时间特点上来看, 是一个批次先开始, 然后收集数据, 再进行展示, 这一点和
Spark Streaming
不太一样
总结
- 运行的时候需要先开启
Socket server
Structured Streaming
的 API 和运行也是针对结构化数据进行优化过的
3. Stuctured Streaming 的体系和结构
目标
了解 Structured Streaming
的体系结构和核心原理, 有两点好处, 一是需要了解原理才好进行性能调优, 二是了解原理后, 才能理解代码执行流程, 从而更好的记忆, 也做到知其然更知其所以然
步骤
WordCount
的执行原理Structured Streaming
的体系结构
3.1. 无限扩展的表格
目标和过程
目标
Structured Streaming
是一个复杂的体系, 由很多组件组成, 这些组件之间也会进行交互, 如果无法站在整体视角去观察这些组件之间的关系, 也无法理解 Structured Streaming
的全局
步骤
- 了解
Dataset
这个计算模型和流式计算的关系 - 如何使用
Dataset
处理流式数据? WordCount
案例的执行过程和原理
Dataset
和流式计算
可以理解为 Spark
中的 Dataset
有两种, 一种是处理静态批量数据的 Dataset
, 一种是处理动态实时流的 Dataset
, 这两种 Dataset
之间的区别如下
- 流式的
Dataset
使用readStream
读取外部数据源创建, 使用writeStream
写入外部存储 - 批式的
Dataset
使用read
读取外部数据源创建, 使用write
写入外部存储
如何使用 Dataset
这个编程模型表示流式计算?
- 可以把流式的数据想象成一个不断增长, 无限无界的表
- 无论是否有界, 全都使用
Dataset
这一套API
- 通过这样的做法, 就能完全保证流和批的处理使用完全相同的代码, 减少这两种处理方式的差异
WordCount
的原理
- 整个计算过程大致上分为如下三个部分
Source
, 读取数据源Query
, 在流式数据上的查询Result
, 结果集生成
- 整个的过程如下
- 随着时间段的流动, 对外部数据进行批次的划分
- 在逻辑上, 将缓存所有的数据, 生成一张无限扩展的表, 在这张表上进行查询
- 根据要生成的结果类型, 来选择是否生成基于整个数据集的结果
总结
Dataset
不仅可以表达流式数据的处理, 也可以表达批量数据的处理Dataset
之所以可以表达流式数据的处理, 因为Dataset
可以模拟一张无限扩展的表, 外部的数据会不断的流入到其中
3.2. 体系结构
目标和过程
目标
Structured Streaming
是一个复杂的体系, 由很多组件组成, 这些组件之间也会进行交互, 如果无法站在整体视角去观察这些组件之间的关系, 也无法理解 Structured Streaming
的核心原理
步骤
- 体系结构
StreamExecution
的执行顺序
体系结构
在
Structured Streaming
中负责整体流程和执行的驱动引擎叫做StreamExecution
StreamExecution
在流上进行基于Dataset
的查询, 也就是说,Dataset
之所以能够在流上进行查询, 是因为StreamExecution
的调度和管理StreamExecution
如何工作?StreamExecution
分为三个重要的部分Source
, 从外部数据源读取数据LogicalPlan
, 逻辑计划, 在流上的查询计划Sink
, 对接外部系统, 写入结果
StreamExecution
的执行顺序
根据进度标记, 从
Source
获取到一个由DataFrame
表示的批次, 这个DataFrame
表示数据的源头1
2
3
4
5
6val source = spark.readStream
.format("socket")
.option("host", "127.0.0.1")
.option("port", 9999)
.load()
.as[String]这一点非常类似
val df = spark.read.csv()
所生成的DataFrame
, 同样都是表示源头根据源头
DataFrame
生成逻辑计划1
2
3
4val words = source.flatMap(_.split(" "))
.map((_, 1))
.groupByKey(_._1)
.count()上述代码表示的就是数据的查询, 这一个步骤将这样的查询步骤生成为逻辑执行计划
优化逻辑计划最终生成物理计划
这一步其实就是使用
Catalyst
对执行计划进行优化, 经历基于规则的优化和基于成本模型的优化执行物理计划将表示执行结果的
DataFrame / Dataset
交给Sink
整个物理执行计划会针对每一个批次的数据进行处理, 处理后每一个批次都会生成一个表示结果的
Dataset
Sink
可以将每一个批次的结果Dataset
落地到外部数据源执行完毕后, 汇报
Source
这个批次已经处理结束,Source
提交并记录最新的进度
增量查询
核心问题
上图中清晰的展示了最终的结果生成是全局的结果, 而不是一个批次的结果, 但是从
StreamExecution
中可以看到, 针对流的处理是按照一个批次一个批次来处理的那么, 最终是如何生成全局的结果集呢?
状态记录
在
Structured Streaming
中有一个全局范围的高可用StateStore
, 这个时候针对增量的查询变为如下步骤- 从
StateStore
中取出上次执行完成后的状态 - 把上次执行的结果加入本批次, 再进行计算, 得出全局结果
- 将当前批次的结果放入
StateStore
中, 留待下次使用
- 从
总结
StreamExecution
是整个Structured Streaming
的核心, 负责在流上的查询StreamExecution
中三个重要的组成部分, 分别是Source
负责读取每个批量的数据,Sink
负责将结果写入外部数据源,Logical Plan
负责针对每个小批量生成执行计划StreamExecution
中使用StateStore
来进行状态的维护
4. Source
目标和过程
目标
流式计算一般就是通过数据源读取数据, 经过一系列处理再落地到某个地方, 所以这一小节先了解一下如何读取数据, 可以整合哪些数据源
过程
- 从
HDFS
中读取数据 - 从
Kafka
中读取数据
4.1. 从 HDFS 中读取数据
目标和过程
目标
在数据处理的时候, 经常会遇到这样的场景
有时候也会遇到这样的场景
以上两种场景有两个共同的特点
- 会产生大量小文件在
HDFS
上 - 数据需要处理
- 会产生大量小文件在
通过本章节的学习, 便能够更深刻的理解这种结构, 具有使用
Structured Streaming
整合HDFS
, 从其中读取数据的能力
步骤
- 案例结构
- 产生小文件并推送到
HDFS
- 流式计算统计
HDFS
上的小文件 - 运行和总结
4.1.1. 案例结构
目标和步骤
目标
通过本章节可以了解案例的过程和步骤, 以及案例的核心意图
步骤
- 案例结构
- 实现步骤
- 难点和易错点
案例流程
- 编写
Python
小程序, 在某个目录生成大量小文件Python
是解释型语言, 其程序可以直接使用命令运行无需编译, 所以适合编写快速使用的程序, 很多时候也使用Python
代替Shell
- 使用
Python
程序创建新的文件, 并且固定的生成一段JSON
文本写入文件 - 在真实的环境中, 数据也是一样的不断产生并且被放入
HDFS
中, 但是在真实场景下, 可能是Flume
把小文件不断上传到HDFS
中, 也可能是Sqoop
增量更新不断在某个目录中上传小文件
- 使用
Structured Streaming
汇总数据HDFS
中的数据是不断的产生的, 所以也是流式的数据- 数据集是
JSON
格式, 要有解析JSON
的能力 - 因为数据是重复的, 要对全局的流数据进行汇总和去重, 其实真实场景下的数据清洗大部分情况下也是要去重的
- 使用控制台展示数据
- 最终的数据结果以表的形式呈现
- 使用控制台展示数据意味着不需要在修改展示数据的代码, 将
Sink
部分的内容放在下一个大章节去说明 - 真实的工作中, 可能数据是要落地到
MySQL
,HBase
,HDFS
这样的存储系统中
实现步骤
- Step 1: 编写
Python
脚本不断的产生数据- 使用
Python
创建字符串保存文件中要保存的数据 - 创建文件并写入文件内容
- 使用
Python
调用系统HDFS
命令上传文件
- 使用
- Step 2: 编写
Structured Streaming
程序处理数据- 创建
SparkSession
- 使用
SparkSession
的readStream
读取数据源 - 使用
Dataset
操作数据, 只需要去重 - 使用
Dataset
的writeStream
设置Sink
将数据展示在控制台中
- 创建
- Step 3: 部署程序, 验证结果
- 上传脚本到服务器中, 使用
python
命令运行脚本 - 开启流计算应用, 读取 HDFS 中对应目录的数据
- 查看运行结果
- 上传脚本到服务器中, 使用
难点和易错点
在读取
HDFS
的文件时,Source
不仅对接数据源, 也负责反序列化数据源中传过来的数据Source
可以从不同的数据源中读取数据, 如Kafka
,HDFS
- 数据源可能会传过来不同的数据格式, 如
JSON
,Parquet
读取
HDFS
文件的这个Source
叫做FileStreamSource
从命名就可以看出来这个
Source
不仅支持HDFS
, 还支持本地文件读取, 亚马逊云, 阿里云 等文件系统的读取, 例如:file://
,s3://
,oss://
基于流的
Dataset
操作和基于静态数据集的Dataset
操作是一致的
总结
整个案例运行的逻辑是
Python
程序产生数据到HDFS
中Structured Streaming
从HDFS
中获取数据Structured Streaming
处理数据- 将数据展示在控制台
整个案例的编写步骤
Python
程序Structured Streaming
程序- 运行
4.1.2. 产生小文件并推送到 HDFS
目标和步骤
目标
通过本章节看到 Python
的大致语法, 并了解 Python 如何编写脚本完成文件的操作, 其实不同的语言使用起来并没有那么难, 完成一些简单的任务还是很简单的
步骤
- 创建
Python
代码文件 - 编写代码
- 本地测试, 但是因为本地环境搭建比较浪费大家时间, 所以暂时不再本地测试
代码编写
- 随便在任一目录中创建文件
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 | val spark = SparkSession.builder() |
总结
以流的形式读取某个 HDFS 目录的代码为
1
2
3
4val source = spark
.readStream (1)
.schema(userSchema) (2)
.json("hdfs://node01:8020/dataset/dataset") (3)1 指明读取的是一个流式的 Dataset
2 指定读取到的数据的 Schema
3 指定目录位置, 以及数据格式
4.1.4. 运行和流程总结
目标和步骤
目标
通过这个小节对案例的部署以后, 不仅大家可以学到一种常见的部署方式, 同时也能对案例的执行流程和流计算有更深入的了解
步骤
- 运行
Python
程序 - 运行
Spark
程序 - 总结
运行 Python 程序
上传
Python
源码文件到服务器中运行
Python
脚本1
2
3
4
5
6
7
8# 进入 Python 文件被上传的位置
cd ~
# 创建放置生成文件的目录
mkdir -p /export/dataset
# 运行程序
python gen_files.py
运行 Spark 程序
使用
Maven
打包上传至服务器
运行
Spark
程序1
2
3
4
5# 进入保存 Jar 包的文件夹
cd ~
运行流程序
spark-submit --class cn.itcast.structured.HDFSSource ./original-streaming-0.0.1.jar
总结
Python
生成文件到HDFS
, 这一步在真实环境下, 可能是由Flume
和Sqoop
收集并上传至HDFS
Structured Streaming
从HDFS
中读取数据并处理Structured Streaming
讲结果表展示在控制台
4.2. 从 Kafka 中读取数据
目标和步骤
目标
通过本章节的学习, 便可以理解流式系统和队列间的关系, 同时能够编写代码从 Kafka
以流的方式读取数据
步骤
Kafka
回顾Structured Streaming
整合Kafka
- 读取
JSON
格式的内容 - 读取多个
Topic
的数据
4.2.1 Kafka 的场景和结构
目标和步骤
目标
通过这一个小节的学习, 大家可以理解 Kfaka
在整个系统中的作用, 日后工作的话, 也必须要先站在更高层去理解系统的组成, 才能完成功能和代码
步骤
Kafka
的应用场景Kafka
的特点Topic
和Partitions
Kafka 是一个 Pub / Sub 系统
Pub / Sub
是Publisher / Subscriber
的简写, 中文称作为发布订阅系统发布订阅系统可以有多个
Publisher
对应一个Subscriber
, 例如多个系统都会产生日志, 通过这样的方式, 一个日志处理器可以简单的获取所有系统产生的日志发布订阅系统也可以一个
Publisher
对应多个Subscriber
, 这样就类似于广播了, 例如通过这样的方式可以非常轻易的将一个订单的请求分发给所有感兴趣的系统, 减少耦合性当然, 在大数据系统中, 这样的消息系统往往可以作为整个数据平台的入口, 左边对接业务系统各个模块, 右边对接数据系统各个计算工具
Kafka 的特点
Kafka
有一个非常重要的应用场景就是对接业务系统和数据系统, 作为一个数据管道, 其需要流通的数据量惊人, 所以 Kafka
如果要满足这种场景的话, 就一定具有以下两个特点
- 高吞吐量
- 高可靠性
Topic 和 Partitions
消息和事件经常是不同类型的, 例如用户注册是一种消息, 订单创建也是一种消息
Kafka
中使用Topic
来组织不同类型的消息Kafka
中的Topic
要承受非常大的吞吐量, 所以Topic
应该是可以分片的, 应该是分布式的
总结
Kafka
的应用场景- 一般的系统中, 业务系统会不止一个, 数据系统也会比较复杂
- 为了减少业务系统和数据系统之间的耦合, 要将其分开, 使用一个中间件来流转数据
- Kafka 因为其吞吐量超高, 所以适用于这种场景
Kafka
如何保证高吞吐量- 因为消息会有很多种类,
Kafka
中可以创建多个队列, 每一个队列就是一个Topic
, 可以理解为是一个主题, 存放相关的消息 - 因为
Topic
直接存放消息, 所以Topic
必须要能够承受非常大的通量, 所以Topic
是分布式的, 是可以分片的, 使用分布式的并行处理能力来解决高通量的问题
- 因为消息会有很多种类,
4.2.2. Kafka 和 Structured Streaming 整合的结构
目标和步骤
目标
通过本小节可以理解 Kafka
和 Structured Streaming
整合的结构原理, 同时还能理解 Spark
连接 Kafka
的时候一个非常重要的参数
步骤
Topic
的Offset
Kafka
和Structured Streaming
的整合结构Structured Streaming
读取Kafka
消息的三种方式
Topic 的 Offset
Topic
是分区的, 每一个Topic
的分区分布在不同的Broker
上每个分区都对应一系列的
Log
文件, 消息存在于Log
中, 消息的ID
就是这条消息在本分区的Offset
偏移量
Offset 又称作为偏移量, 其实就是一个东西距离另外一个东西的距离![]() Kafka 中使用 Offset 命名消息, 而不是指定 ID 的原因是想表示永远自增, ID 是可以指定的, 但是 Offset 只能是一个距离值, 它只会越来越大, 所以, 叫做 Offset 而不叫 ID 也是这个考虑, 消息只能追加到 Log 末尾, 只能增长不能减少 |
|
---|---|
Kafka 和 Structured Streaming 整合的结构
分析
Structured Streaming
中使用Source
对接外部系统, 对接Kafka
的Source
叫做KafkaSource
KafkaSource
中会使用KafkaSourceRDD
来映射外部Kafka
的Topic
, 两者的Partition
一一对应
结论
Structured Streaming
会并行的从 Kafka
中获取数据
Structured Streaming 读取 Kafka 消息的三种方式
Earliest
从每个Kafka
分区最开始处开始获取Assign
手动指定每个Kafka
分区中的Offset
Latest
不再处理之前的消息, 只获取流计算启动后新产生的数据
总结
Kafka
中的消息存放在某个Topic
的某个Partition
中, 消息是不可变的, 只会在消息过期的时候从最早的消息开始删除, 消息的ID
也叫做Offset
, 并且只能正增长Structured Streaming
整合Kafka
的时候, 会并行的通过Offset
从所有Topic
的Partition
中获取数据Structured Streaming
在从Kafka
读取数据的时候, 可以选择从最早的地方开始读取, 也可以选择从任意位置读取, 也可以选择只读取最新的
4.2.3. 需求介绍
目标和步骤
目标
通过本章节的学习, 可以掌握一个常见的需求, 并且了解后面案例的编写步骤
步骤
- 需求
- 数据
需求
模拟一个智能物联网系统的数据统计
- 有一个智能家居品牌叫做
Nest
, 他们主要有两款产品, 一个是恒温器, 一个是摄像头 - 恒温器的主要作用是通过感应器识别家里什么时候有人, 摄像头主要作用是通过学习算法来识别出现在摄像头中的人是否是家里人, 如果不是则报警
- 所以这两个设备都需要统计一个指标, 就是家里什么时候有人, 此需求就是针对这个设备的一部分数据, 来统计家里什么时候有人
- 有一个智能家居品牌叫做
使用生产者在 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"
}
}
}
}使用 Structured Streaming 来过滤出来家里有人的数据
把数据转换为
时间 → 是否有人
这样类似的形式
数据转换
追踪 JSON 数据的格式
可以在一个在线的工具
https://jsonformatter.org/
中格式化JSON
, 会发现JSON
格式如下反序列化
JSON
数据本质上就是字符串, 只不过这个字符串是有结构的, 虽然有结构, 但是很难直接从字符串中取出某个值而反序列化, 就是指把
JSON
数据转为对象, 或者转为DataFrame
, 可以直接使用某一个列或者某一个字段获取数据, 更加方便而想要做到这件事, 必须要先根据数据格式, 编写
Schema
对象, 从而通过一些方式转为DataFrame
1
2
3
4
5
6
7
8
9
10
11
12
13
14val 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)
总结
- 业务简单来说, 就是收集智能家居设备的数据, 通过流计算的方式计算其特征规律
Kafka
常见的业务场景就是对接业务系统和数据系统- 业务系统经常会使用 JSON 作为数据传输格式
- 所以使用
Structured Streaming
来对接Kafka
并反序列化Kafka
中的JSON
格式的消息, 是一个非常重要的技能
- 无论使用什么方式, 如果想反序列化
JSON
数据, 就必须要先追踪JSON
数据的结构
4.2.4. 使用 Spark 流计算连接 Kafka 数据源
目标和步骤
目标
通过本章节的数据, 能够掌握如何使用 Structured Streaming
对接 Kafka
, 从其中获取数据
步骤
- 创建
Topic
并输入数据到Topic
Spark
整合kafka
- 读取到的
DataFrame
的数据结构
创建 Topic 并输入数据到 Topic
使用命令创建
Topic
1
bin/kafka-topics.sh --create --topic streaming-test --replication-factor 1 --partitions 3 --zookeeper node01:2181
开启
Producer
1
bin/kafka-console-producer.sh --broker-list node01:9092,node02:9092,node03:9092 --topic streaming-test
把
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
编写
Spark
代码读取Kafka Topic
1
2
3
4
5
6val 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
: 指定Kafka
的Server
地址subscribe
: 要监听的Topic
, 可以传入多个, 传入多个 Topic 则监听多个 Topic, 也可以使用topic-*
这样的通配符写法startingOffsets
: 从什么位置开始获取数据, 可选值有earliest
,assign
,latest
format
设置为Kafka
指定使用KafkaSource
读取数据
- 三个参数
思考: 从
Kafka
中应该获取到什么?业务系统有很多种类型, 有可能是
Web
程序, 有可能是物联网前端大多数情况下使用
JSON
做数据交互问题1: 业务系统如何把数据给
Kafka
?可以主动或者被动的把数据交给
Kafka
, 但是无论使用什么方式, 都在使用Kafka
的Client
类库来完成这件事,Kafka
的类库调用方式如下1
2Producer<String, String> producer = new KafkaProducer<String, String>(properties);
producer.send(new ProducerRecord<String, String>("HelloWorld", msg));其中发给
Kafka
的消息是KV
类型的问题2: 使用
Structured Streaming
访问Kafka
获取数据的时候, 需要什么东西呢?- 需求1: 存储当前处理过的
Kafka
的Offset
- 需求2: 对接多个
Kafka Topic
的时候, 要知道这条数据属于哪个Topic
- 需求1: 存储当前处理过的
结论
Kafka
中收到的消息是KV
类型的, 有Key
, 有Value
Structured Streaming
对接Kafka
的时候, 每一条Kafka
消息不能只是KV
, 必须要有Topic
,Partition
之类的信息
从
Kafka
获取的DataFrame
格式1
source.printSchema()
结果如下
1
2
3
4
5
6
7
8root
|-- 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
时间戳类型
总结
一定要把
JSON
转为一行, 再使用Producer
发送, 不然会出现获取多行的情况使用 Structured Streaming 连接 Kafka 的时候, 需要配置如下三个参数
kafka.bootstrap.servers
: 指定Kafka
的Server
地址subscribe
: 要监听的Topic
, 可以传入多个, 传入多个 Topic 则监听多个 Topic, 也可以使用topic-*
这样的通配符写法startingOffsets
: 从什么位置开始获取数据, 可选值有earliest
,assign
,latest
从 Kafka 获取到的 DataFrame 的 Schema 如下
1
2
3
4
5
6
7
8root
|-- 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
数据, 这是一个重点中的重点
步骤
JSON
解析- 数据处理
- 运行测试
JSON 解析
准备好
JSON
所在的列问题
由
Dataset
的结构可以知道key
和value
列的类型都是binary
二进制, 所以要将其转为字符串, 才可进行JSON
解析解决方式
1
source.selectExpr("CAST(key AS STRING) as key", "CAST(value AS STRING) as value")
编写
Schema
对照JSON
的格式Key
要对应JSON
中的Key
Value
的类型也要对应JSON
中的Value
类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14val 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)
因为
JSON
中包含Date
类型的数据, 所以要指定时间格式化方式1
val jsonOptions = Map("timestampFormat" -> "yyyy-MM-dd'T'HH:mm:ss.sss'Z'")
使用
from_json
这个UDF
格式化JSON
1
.select(from_json('value, schema, jsonOptions).alias("parsed_value"))
选择格式化过后的
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
2
3.filter('has_person === true)
.groupBy('has_person, 'start_time)
.count()将数据落地到控制台
1
2
3
4
5result.writeStream
.outputMode(OutputMode.Complete())
.format("console")
.start()
.awaitTermination()
全部代码
1 | import org.apache.spark.sql.SparkSession |
运行测试
进入服务器中, 启动
Kafka
启动
Kafka
的Producer
1
bin/kafka-console-producer.sh --broker-list node01:9092,node02:9092,node03:9092 --topic streaming-test
启动
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
目标和步骤
目标
- 能够串联两端, 理解整个流式应用, 以及其中的一些根本的原理, 比如说容错语义
- 能够知道如何对接外部系统, 写入数据
步骤
HDFS Sink
Kafka Sink
Foreach Sink
- 自定义
Sink
Tiggers
Sink
原理- 错误恢复和容错语义
5.1. HDFS Sink
目标和步骤
目标
能够使用 Spark
将流式数据的处理结果放入 HDFS
步骤
- 场景和需求
- 代码实现
场景和需求
场景
Kafka
往往作为数据系统和业务系统之间的桥梁- 数据系统一般由批量处理和流式处理两个部分组成
- 在
Kafka
作为整个数据平台入口的场景下, 需要使用StructuredStreaming
接收Kafka
的数据并放置于HDFS
上, 后续才可以进行批量处理
案例需求
- 从
Kafka
接收数据, 从给定的数据集中, 裁剪部分列, 落地于HDFS
代码实现
步骤说明
- 从
Kafka
读取数据, 生成源数据集- 连接
Kafka
生成DataFrame
- 从
DataFrame
中取出表示Kafka
消息内容的value
列并转为String
类型
- 连接
- 对源数据集选择列
- 解析
CSV
格式的数据 - 生成正确类型的结果集
- 解析
- 落地
HDFS
整体代码
1 | import org.apache.spark.sql.SparkSession |
5.2. Kafka Sink
目标和步骤
目标
掌握什么时候要将流式数据落地至 Kafka, 以及如何落地至 Kafka
步骤
- 场景
- 代码
场景
场景
- 有很多时候,
ETL
过后的数据, 需要再次放入Kafka
- 在
Kafka
后, 可能会有流式程序统一将数据落地到HDFS
或者HBase
案例需求
- 从
Kafka
中获取数据, 简单处理, 再次放入Kafka
代码
步骤
- 从
Kafka
读取数据, 生成源数据集- 连接
Kafka
生成DataFrame
- 从
DataFrame
中取出表示Kafka
消息内容的value
列并转为String
类型
- 连接
- 对源数据集选择列
- 解析
CSV
格式的数据 - 生成正确类型的结果集
- 解析
- 再次落地
Kafka
代码
1 | import org.apache.spark.sql.SparkSession |
5.3. Foreach Writer
目标和步骤
目标
掌握 Foreach
模式理解如何扩展 Structured Streaming
的 Sink
, 同时能够将数据落地到 MySQL
步骤
- 需求
- 代码
需求
场景
- 大数据有一个常见的应用场景
- 收集业务系统数据
- 数据处理
- 放入
OLTP
数据 - 外部通过
ECharts
获取并处理数据
- 这个场景下,
StructuredStreaming
就需要处理数据并放入MySQL
或者MongoDB
,HBase
中以供Web
程序可以获取数据, 图表的形式展示在前端
- 大数据有一个常见的应用场景
Foreach 模式::
起因
- 在
Structured Streaming
中, 并未提供完整的MySQL/JDBC
整合工具 - 不止
MySQL
和JDBC
, 可能会有其它的目标端需要写入 - 很多时候
Structured Streaming
需要对接一些第三方的系统, 例如阿里云的云存储, 亚马逊云的云存储等, 但是Spark
无法对所有第三方都提供支持, 有时候需要自己编写
- 在
解决方案
- 既然无法满足所有的整合需求,
StructuredStreaming
提供了Foreach
, 可以拿到每一个批次的数据 - 通过
Foreach
拿到数据后, 可以通过自定义写入方式, 从而将数据落地到其它的系统
- 既然无法满足所有的整合需求,
案例需求::
- 从
Kafka
中获取数据, 处理后放入MySQL
- 从
代码
步骤
- 创建
DataFrame
表示Kafka
数据源 - 在源
DataFrame
中选择三列数据 - 创建
ForeachWriter
接收每一个批次的数据落地MySQL
Foreach
落地数据
代码
1 | import org.apache.spark.sql.SparkSession |
5.4. 自定义 Sink
目标和步骤
目标
Foreach
倾向于一次处理一条数据, 如果想拿到DataFrame
幂等的插入外部数据源, 则需要自定义Sink
- 了解如何自定义
Sink
步骤
Spark
加载Sink
流程分析- 自定义
Sink
Spark 加载 Sink 流程分析
Sink
加载流程writeStream
方法中会创建一个DataStreamWriter
对象1
2
3
4
5
6
7def writeStream: DataStreamWriter[T] = {
if (!isStreaming) {
logicalPlan.failAnalysis(
"'writeStream' can be called only on streaming Dataset/DataFrame")
}
new DataStreamWriter[T](this)
}在
DataStreamWriter
对象上通过format
方法指定Sink
的短名并记录下来1
2
3
4def format(source: String): DataStreamWriter[T] = {
this.source = source
this
}最终会通过
DataStreamWriter
对象上的start
方法启动执行, 其中会通过短名创建DataSource
1
2
3
4
5
6val dataSource =
DataSource(
df.sparkSession,
className = source, (1)
options = extraOptions.toMap,
partitionColumns = normalizedParCols.getOrElse(Nil))1 传入的 Sink
短名在创建
DataSource
的时候, 会通过一个复杂的流程创建出对应的Source
和Sink
1
lazy val providingClass: Class[_] = DataSource.lookupDataSource(className)
在这个复杂的创建流程中, 有一行最关键的代码, 就是通过
Java
的类加载器加载所有的DataSourceRegister
1
val serviceLoader = ServiceLoader.load(classOf[DataSourceRegister], loader)
在
DataSourceRegister
中会创建对应的Source
或者Sink
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19trait 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
加载Source
和Sink
- Spark 提供了
StreamSinkProvider
用以创建Sink
, 提供必要的依赖
- 所以如果要创建自定义的
Sink
, 需要做两件事- 创建一个注册器, 继承
DataSourceRegister
提供注册功能, 继承StreamSinkProvider
获取创建Sink
的必备依赖 - 创建一个
Sink
子类
- 创建一个注册器, 继承
- 根据前面的流程说明, 有两点非常重要
自定义 Sink
步骤
- 读取
Kafka
数据 - 简单处理数据
- 创建
Sink
- 创建
Sink
注册器 - 使用自定义
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
的处理时间
步骤
- 微批次处理
- 连续流处理
微批次处理
什么是微批次
- 并不是真正的流, 而是缓存一个批次周期的数据, 后处理这一批次的数据
通用流程
步骤
- 根据
Spark
提供的调试用的数据源Rate
创建流式DataFrame
Rate
数据源会定期提供一个由两列timestamp, value
组成的数据,value
是一个随机数
- 处理和聚合数据, 计算每个个位数和十位数各有多少条数据
- 对
value
求log10
即可得出其位数 - 后按照位数进行分组, 最终就可以看到每个位数的数据有多少个
- 对
代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17val 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
程序会运行在微批次的模式下, 当一个批次结束后, 下一个批次会立即开始处理步骤
- 指定落地到
Console
中, 不指定Trigger
代码
1
2
3
4
5result.writeStream
.outputMode(OutputMode.Complete())
.format("console")
.start()
.awaitTermination()- 指定落地到
按照固定时间间隔划分批次
介绍
使用微批次处理数据, 使用用户指定的时间间隔启动批次, 如果间隔指定为
0
, 则尽可能快的去处理, 一个批次紧接着一个批次- 如果前一批数据提前完成, 待到批次间隔达成的时候再启动下一个批次
- 如果前一批数据延后完成, 下一个批次会在前面批次结束后立即启动
- 如果没有数据可用, 则不启动处理
步骤
- 通过
Trigger.ProcessingTime()
指定处理间隔
代码
1
2
3
4
5
6result.writeStream
.outputMode(OutputMode.Complete())
.format("console")
.trigger(Trigger.ProcessingTime("2 seconds"))
.start()
.awaitTermination()一次性划分批次
介绍
只划分一个批次, 处理完成以后就停止
Spark
工作, 当需要启动一下Spark
处理遗留任务的时候, 处理完就关闭集群的情况下, 这个划分方式非常实用步骤
- 使用
Trigger.Once
一次性划分批次
代码
1
2
3
4
5
6result.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
依然是实验性质, 不建议在生产环境中使用
- 微批次会将收到的数据按照批次划分为不同的
操作
步骤
- 使用特殊的
Trigger
完成功能
代码
1
2
3
4
5
6result.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 的流程
目标和步骤
目标
理解 Source
到 Sink
的整体原理
步骤
- 从
Source
到Sink
的流程
从 Source 到 Sink 的流程
- 在每个
StreamExecution
的批次最开始,StreamExecution
会向Source
询问当前Source
的最新进度, 即最新的offset
StreamExecution
将Offset
放到WAL
里StreamExecution
从Source
获取start offset
,end offset
区间内的数据StreamExecution
触发计算逻辑logicalPlan
的优化与编译- 计算结果写出给
Sink
- 调用
Sink.addBatch(batchId: Long, data: DataFrame)
完成 - 此时才会由
Sink
的写入操作开始触发实际的数据获取和计算过程
- 调用
- 在数据完整写出到
Sink
后,StreamExecution
通知Source
批次id
写入到batchCommitLog
, 当前批次结束
5.7. 错误恢复和容错语义
目标和步骤
目标
理解 Structured Streaming
中提供的系统级别容错手段
步骤
- 端到端
- 三种容错语义
Sink
的容错
端到端
Source
可能是Kafka
,HDFS
Sink
也可能是Kafka
,HDFS
,MySQL
等存储服务- 消息从
Source
取出, 经过Structured Streaming
处理, 最后落地到Sink
的过程, 叫做端到端
三种容错语义
at-most-once
- 在数据从
Source
到Sink
的过程中, 出错了,Sink
可能没收到数据, 但是不会收到两次, 叫做at-most-once
- 一般错误恢复的时候, 不重复计算, 则是
at-most-once
- 在数据从
at-least-once
- 在数据从
Source
到Sink
的过程中, 出错了,Sink
一定会收到数据, 但是可能收到两次, 叫做at-least-once
- 一般错误恢复的时候, 重复计算可能完成也可能未完成的计算, 则是
at-least-once
- 在数据从
exactly-once
- 在数据从
Source
到Sink
的过程中, 虽然出错了,Sink
一定恰好收到应该收到的数据, 一条不重复也一条都不少, 即是exactly-once
- 想做到
exactly-once
是非常困难的
- 在数据从
Sink 的容错
故障恢复一般分为
Driver
的容错和Task
的容错Driver
的容错指的是整个系统都挂掉了Task
的容错指的是一个任务没运行明白, 重新运行一次
因为
Spark
的Executor
能够非常好的处理Task
的容错, 所以我们主要讨论Driver
的容错, 如果出错的时候读取
WAL offsetlog
恢复出最新的offsets
当
StreamExecution
找到Source
获取数据的时候, 会将数据的起始放在WAL offsetlog
中, 当出错要恢复的时候, 就可以从中获取当前处理批次的数据起始, 例如Kafka
的Offset
读取
batchCommitLog
决定是否需要重做最近一个批次当
Sink
处理完批次的数据写入时, 会将当前的批次ID
存入batchCommitLog
, 当出错的时候就可以从中取出进行到哪一个批次了, 和WAL
对比即可得知当前批次是否处理完如果有必要的话, 当前批次数据重做
- 如果上次执行在
(5)
结束前即失效, 那么本次执行里Sink
应该完整写出计算结果 - 如果上次执行在
(5)
结束后才失效, 那么本次执行里Sink
可以重新写出计算结果 (覆盖上次结果), 也可以跳过写出计算结果(因为上次执行已经完整写出过计算结果了)
- 如果上次执行在
这样即可保证每次执行的计算结果, 在 Sink 这个层面, 是 不重不丢 的, 即使中间发生过失效和恢复, 所以
Structured Streaming
可以做到exactly-once
容错所需要的存储
存储
offsetlog
和batchCommitLog
关乎于错误恢复offsetlog
和batchCommitLog
需要存储在可靠的空间里offsetlog
和batchCommitLog
存储在Checkpoint
中WAL
其实也存在于Checkpoint
中
指定
Checkpoint
- 只有指定了
Checkpoint
路径的时候, 对应的容错功能才可以开启
1
2
3
4
5
6aggDF
.writeStream
.outputMode("complete")
.option("checkpointLocation", "path/to/HDFS/dir") (1)
.format("memory")
.start()1 指定 Checkpoint
的路径, 这个路径对应的目录必须是HDFS
兼容的文件系统- 只有指定了
需要的外部支持
如果要做到 exactly-once
, 只是 Structured Streaming
能做到还不行, 还需要 Source
和 Sink
系统的支持
Source
需要支持数据重放当有必要的时候,
Structured Streaming
需要根据start
和end offset
从Source
系统中再次获取数据, 这叫做重放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
算子, 能够完成常见的流式计算需求
步骤
- 常规算子
- 分组算子
- 输出模式
状态
无状态算子
- 无状态
有状态算子
- 有中间状态需要保存
- 增量查询
总结
6.1. 常规算子
目标和步骤
目标
了解 Structured Streaming
的常规数据处理方式
步骤
- 案例
案例
需求
- 给定电影评分数据集
ratings.dat
, 位置在Spark/Files/Dataset/Ratings/ratings.dat
- 筛选评分超过三分的电影
- 以追加模式展示数据, 以流的方式来一批数据处理一批数据, 最终每一批次展示为如下效果
1
2
3
4
5
6+------+-------+
|Rating|MovieID|
+------+-------+
| 5| 1193|
| 4| 3408|
+------+-------+- 给定电影评分数据集
步骤
- 创建 SparkSession
- 读取并处理数据结构
- 处理数据
- 选择要展示的列
- 筛选超过三分的数据
- 追加模式展示数据到控制台
代码
- 读取文件的时候只能读取一个文件夹, 因为是流的操作, 流的场景是源源不断有新的文件读取
1
2
3
4
5
6
7
8
9
10val 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. 分组算子
目标和步骤
目标
能够使用分组完成常见需求, 并了解如何扩展行
步骤
- 案例
案例
需求
- 给定电影数据集
movies.dat
, 其中三列MovieID
,Title
,Genres
- 统计每个分类下的电影数量
- 给定电影数据集
步骤
创建
SparkSession
读取数据集, 并组织结构
注意
Genres
是genres1|genres2
形式, 需要分解为数组使用
explode
函数将数组形式的分类变为单值多条形式分组聚合
Genres
输出结果
代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17val 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