全面解析并行计算框架 Spark,以及和 Python 的对接

楔子

在之前的文章中我们详细介绍了 Hadoop,那么本次来聊一聊 Spark。相信 Spark 大家都知道,它是一款基于内存的并行计算框架,在业界占有举足轻重的地位,是很多大数据公司的首选。之前介绍 Hadoop 的时候说过,相比 Spark,MapReduce 是非常鸡肋的,无论是简洁度还是性能,都远远落后于 Spark。此外,Spark 还支持使用多种语言进行编程,比如 Python、R、Java、Scala 等等。而笔者本人是专攻 Python 的,那么本篇文章我们就来全方位的学习一下 Spark,以及如何用 Python 去开发 Spark 应用程序。

和很多大数据框架一样,Spark 也诞生于一篇论文,该论文中提到了一个概念:弹性分布式数据集(RDD),RDD 是一种分布式内存抽象,支持在大规模集群中做内存运算,并具备容错性。对于 Spark 而言,RDD 是最核心的数据结构,整个平台都围绕着 RDD 进行。

关于 RDD 的具体细节我们一会儿详细说,总之 Spark 借鉴了 MapReduce 的思想发展而来,不仅保留了其分布式并行计算的优点,还改进了其缺点。MapReduce 的每一次操作,其结果都需要落盘,然后再从磁盘中读取进行下一次操作。而 Spark 的所有操作都是在内存中进行的,速度要远远快于 MapReduce,此外在操作数据方面还提供了非常丰富的 API。

那么问题来了,Spark 和之前介绍的 Hadoop 有什么区别呢?它能完全取代 Hadoop 吗?我们来对比一下两者的差异。

很明显,Spark 无法完全替代 Hadoop,因为 Hadoop 由三部分组成:HDFS、MapReduce、YARN,分别对应存储、计算、资源调度,而 Spark 只负责计算。尽管 Spark 相较于 MapReduce 有巨大的性能优势,但 HDFS 和 YARN 仍然是许多大数据体系的核心架构,因此如果非要说替代,可以认为 Spark 替代了 Hadoop 内部的 MapReduce 组件。

在使用 Spark 时,一般会搭配 Hadoop,其中 HDFS 负责存储,YARN 负责资源管理,然后 Spark 负责计算。但要注意的是,Spark 不仅可以搭配 Hadoop,还可以搭配 Mesos、Kubernetes,也支持 Standalone 独立运行模式。对于数据源而言,Spark 不仅可以从 HDFS 中读取,像 HBase、Cassandra、Kafka、关系型数据库等等,也是支持的。

Spark 更常见的搭配还是 Hadoop,我们这里也会使用 Hadoop。

Spark 环境搭建

下面我们来搭建 Spark 环境,我这里准备了 5 台 CentOS 虚拟机,主机名和 IP 地址如下。

  • 主机名:satori001,IP地址:192.168.170.128
  • 主机名:satori002,IP地址:192.168.170.129
  • 主机名:satori003,IP地址:192.168.170.130
  • 主机名:satori004,IP地址:192.168.170.131
  • 主机名:satori005,IP地址:192.168.170.132

然后在 /etc/hosts 中配置主机名到 IP 的映射,后续便通过主机名进行访问,5 台机器都这么配。

192.168.170.128 satori001
192.168.170.129 satori002
192.168.170.130 satori003
192.168.170.131 satori004
192.168.170.132 satori005

关闭每个节点防火墙:

systemctl stop firewalld
systemctl disable firewalld

接下来在虚拟机之间配置免密码登录,否则后续在启动 Hadoop 集群的时候,需要输入密码。

# 生成私钥和公钥,一路回车即可
# 生成的私钥存放在 ~/.ssh/id_rsa 文件中、公钥则存放在 ~/.ssh/id_rsa.pub 文件中
ssh-keygen -t rsa  
# 进入到家目录的 .ssh 目录中
cd ~/.ssh  
# 创建 authorized_keys 文件
touch authorized_keys  

在每个节点上都执行上面几个步骤,那么所有节点的 .ssh 目录中都有 id_rsa、id_rsa.pub 和 authorized_keys 这三个文件。如果想实现免密码登陆的话,假设在 A 节点中远程登陆 B 节点想不输入密码,那么就把 A 节点的 id_rsa.pub 里面的内容添加到 B 节点的 authorized_keys 文件中即可。但是注意,这个过程是单向的,如果在 B 节点中远程登陆 A 节点也不想输入密码的话,那么就把 B 节点的 id_rsa.pub 里面的内容添加到 A 节点的 authorized_keys 中。

针对于这个需求,Linux 提供了专门的命令:ssh-copy-id。

ssh-copy-id -i ~/.ssh/id_rsa.pub root@satori001
ssh-copy-id -i ~/.ssh/id_rsa.pub root@satori002
ssh-copy-id -i ~/.ssh/id_rsa.pub root@satori003
ssh-copy-id -i ~/.ssh/id_rsa.pub root@satori004
ssh-copy-id -i ~/.ssh/id_rsa.pub root@satori005

在 5 个节点中都执行以上命令,这样彼此登录便不需要用户名和密码了。

注意:每个节点的 id_rsa.pub 里的内容同时也要添加到自身的 authorized_keys 文件中。

基础配置已经结束,下面来安装相关软件,这里只在 satori001 节点中安装,安装完之后直接通过 scp 命令拷贝到其它节点上即可。


我们所有的软件都安装在 /opt 目录中,首先安装 Java,这里使用的版本是 1.8。安装完成后修改 ~/.bashrc 文件,配置环境变量:

export JAVA_HOME=/opt/jdk1.8.0_221/
export PATH=$JAVA_HOME/bin:$PATH

source 一下之后,在终端中输入 java -version,如果有以下输出,表示安装成功。

没有问题,下面来安装 Hadoop。


我们去 Hadoop 的官网下载 Hadoop 安装包,这里下载的是最新版 3.3.6,然后上传到服务器并解压到 /opt 目录。

完事之后还是修改 ~/.bashrc 文件,配置环境变量:

export JAVA_HOME=/opt/jdk1.8.0_221/
export PATH=$JAVA_HOME/bin:$PATH

export HADOOP_HOME=/opt/hadoop-3.3.6/
export PATH=$HADOOP_HOME/bin:$HADOOP_HOME/sbin:$PATH

接下来修改 Hadoop 的配置文件,配置文件都位于 $HADOOP_HOME/etc/hadoop 目录中。

修改 hadoop-env.sh

# 默认是 ${JAVA_HOME},需要手动改成 java 的安装路径
export JAVA_HOME=/opt/jdk1.8.0_221 

# 从 Hadoop3.x 开始,要求指定相关启动用户
export HDFS_NAMENODE_USER=root
export HDFS_DATANODE_USER=root
export HDFS_SECONDARYNAMENODE_USER=root

修改 core-site.xml

<!-- 指定 HDFS 中 NameNode 的地址,也就是 master 节点的地址
     这里就让 satori001 充当 master,其它节点充当 worker -->
<property>
	<name>fs.defaultFS</name>
	<value>hdfs://satori001:9000</value>
</property>

	
<!-- 指定 Hadoop 内部文件的存储目录,如果不指定,那么重启之后数据就丢失了 -->
<!-- data 目录会自动创建 -->
<property>
	<name>hadoop.tmp.dir</name>
	<value>/opt/hadoop-3.3.6/data</value>
</property>

修改 hdfs-site.xml

<!-- 副本系数 -->
<property>
	<name>dfs.replication</name>
	<value>3</value>
</property>

<!-- namenode 连接 datanode 时,默认会进行 host 解析查询,这里指定为 false -->
<property>
    <name>dfs.namenode.datanode.registration.ip-hostname-check</name>
    <value>false</value>
</property>

修改 workers

satori002
satori003
satori004
satori005

这里指定 worker 节点,因为 satori001 是 master,所以将它之外的 4 个节点作为 worker。

修改 yarn-env.sh:

export JAVA_HOME=/opt/jdk1.8.0_221 

export YARN_RESOURCEMANAGER_USER=root
export YARN_NODEMANAGER_USER=root

修改 yarn-site.xml:

<!-- ResourceManager 对客户端暴露的地址
     客户端通过该地址向 RM 提交应用程序,杀死应用程序等 -->
<property>
    <name>yarn.resourcemanager.address</name>
    <value>satori001:8032</value>
</property>

<!-- ResourceManager 对 ApplicationMaster 暴露的访问地址。
     ApplicationMaster 通过该地址向 RM 申请资源、释放资源等 -->
<property>
    <name>yarn.resourcemanager.scheduler.address</name>
    <value>satori001:8030</value>
</property>

<!-- ResourceManager 对 NodeManager暴露的地址
     NodeManager 通过该地址向 RM 汇报心跳,领取任务等 -->
<property>
    <name>yarn.resourcemanager.resource-tracker.address</name>
    <value>satori001:8031</value>
</property>

<!-- ResourceManager 对管理员暴露的访问地址
     管理员通过该地址向 RM 发送管理命令等。 -->
<property>
    <name>yarn.resourcemanager.admin.address</name>
    <value>satori001:8033</value>
</property>

<!-- ResourceManager对外 webUI 地址,用户可通过该地址在浏览器中查看集群各类信息 -->
<property>
    <name>yarn.resourcemanager.webapp.address</name>
    <value>satori001:8088</value>
</property>

以上监听的端口是默认的,我们没有做改动。

到目前为止,关于 Java 和 Hadoop 我们就配置完了,然后使用 scp 命令将 /opt 目录内的文件拷贝到其它节点上。

scp -r /opt/* root@satori002:/opt/
scp -r /opt/* root@satori003:/opt/
scp -r /opt/* root@satori004:/opt/
scp -r /opt/* root@satori005:/opt/

当然还有环境变量,要将剩余 4 个节点的环境变量也给改了,然后 source 一下。


好了,一切已经准备就绪,下面我们启动 HDFS,不过首先要格式化 NameNode。

下面所有操作只需在 master 节点(satori001)上执行即可。

hdfs namenode -format  

如果格式化成功,那么所有节点会自动创建 /opt/hadoop-3.3.6/data 目录。

然后启动 HDFS,只需要在 master 节点启动即可,会自动启动 worker 节点。

# 关闭的话,输入 stop-dfs.sh
start-dfs.sh

执行完之后,输入 jps 命令,看看各个节点的相关进程有没有启动成功。

我们看到相关进程都已经启动,satori001 是 master 节点,它负责启动 NameNode 和 SecondaryNameNode 进程,其它节点负责启动 DataNode 进程。好了, HDFS 启动成功,下面启动 YARN,我们在 master 节点上执行 start-yarn.sh 命令。

执行完之后,再次输入 jps 查看进程,如果一切正常,那么 master 节点会多出 ResourceManager 进程,而 worker 节点则多出 NodeManager 进程。

结果没有问题,其它 worker 节点也是一样的,到此 HDFS 和 YARN 我们就启动成功了。我们上传一个文件测试一下:

在 satori001 节点上传文件,在 satori002 节点进行读取,结果一切正常。


最后我们来安装 Spark,去官网下载相应的安装包,然后解压即可。完事之后,我们修改 ~/.bashrc,设置环境变量:

export JAVA_HOME=/opt/jdk1.8.0_221/
export PATH=$JAVA_HOME/bin:$PATH

export HADOOP_HOME=/opt/hadoop-3.3.6/
export PATH=$HADOOP_HOME/bin:$HADOOP_HOME/sbin:$PATH

export SPARK_HOME=/opt/spark-3.4.2-bin-hadoop3/
export PATH=$SPARK_HOME/bin:$PATH
export PATH=$SPARK_HOME/sbin:$PATH

然后修改 Spark 配置文件,位于 $SPARK_HOME 的 conf 目录下。

[root@satori001 conf]# cp spark-env.sh.template spark-env.sh

我们修改 spark-env.sh,在里面添加如下内容:

# pyspark 启动的时候默认使用的是 Python2,我们将其改为 Python3
export PYSPARK_DRIVER_PYTHON=/usr/bin/python3
export PYSPARK_PYTHON=/usr/bin/python3

目前配置的是 satori001 节点,还是让它充当 master,其它节点充当 worker。但是其它节点先不急着配,我们先来学习 Spark 的相关概念,等后续介绍运行模式的时候,再来搭建集群。

Spark 的基石 RDD

RDD 指的是弹性分布式数据集(Resilient Distributed Dataset),它是 Spark 计算的核心。尽管现在都使用 DataFrame、Dataset 进行编程,但是它们的底层依旧是依赖于 RDD 的。我们来解释一下 RDD 的这几个单词含义。

  • 弹性:在计算上具有容错性,Spark 是一个计算框架,如果某一个节点挂了,可以自动进行计算之间血缘关系的跟踪
  • 分布式:很好理解,HDFS 上数据是跨节点的,那么 Spark 的计算也是要跨节点的
  • 数据集:可以将数组、文件等一系列数据的集合转换为 RDD

RDD 是 Spark 的一个最基本的抽象 (如果你看一下源码的话,你会发现 RDD 在底层是一个抽象类,抽象类显然不能直接使用,必须要继承并实现其内部的一些方法后才可以使用),它代表了不可变的、元素的分区(partition)集合,这些分区可以被并行操作。假设我们有一个包含 300 万个元素的数组,那么可以将这个数组分成 3 份,每一份对应一个分区,每个分区都可以在不同的机器上进行运算,这样就能提高运算效率。

RDD 特性

RDD 有如下五大特性:

  • 1)RDD 是一系列分区的集合。我们说对于大的数据集可以切分成多份,每一份就是一个分区,可以对每一个分区单独计算,所以 RDD 就是这些所有分区的集合。就类似于 HDFS中 的 block,一个大文件也可以切分成多个 block。
  • 2)RDD 计算会对每一个分区进行计算。假设对 RDD 做一个 map 操作,显然是对 RDD 内部的每一个分区都进行相同的 map 操作。
  • 3)RDD 会依赖于一系列其它的 RDD。假设我们对 RDD1 进行操作得到了 RDD2,然后对 RDD2 操作得到了 RDD3,同理再得到 RDD4。由于 RDD 是不可变的,对 RDD 进行操作会形成新的 RDD,所以 RDD2 依赖于 RDD1,RDD3 依赖于 RDD2,RDD4 依赖于 RDD3。RDD1 => RDD2 => RDD3 => RDD4,RDD 在转换期间就如同流水线一样,之间是存在依赖关系的。这些依赖关系非常重要,假设 RDD1 有五个分区,那么显然 RDD2、RDD3、RDD4 也有五个分区,如果在计算 RDD3 的时候 RDD2 的第四个分区数据丢失了,那么 Spark 会通过 RDD 之间的血缘关系,知道 RDD2 依赖于 RDD1,然后对 RDD1 重新进行之前的计算得到 RDD2 第四个分区的数据(只会计算丢失的分区的数据),所以我们说 RDD 具有容错性。
  • 4)针对于 key-value 类型的 RDD,会有一个 partitioner,来表示这个 RDD 如何进行分区,比如:基于哈希进行分区。如果不是这种类型的 RDD,那么这个 partitioner 显然就是空了。
  • 5)用于计算每一个分区的最好位置。怎么理解呢?我们说数据和计算都是分布式的,如果该分区对应的数据在 A 机器上,那么显然计算该分区的最好位置就是 A 机器。如果计算和数据不在同一个节点,那么我们会把计算移动到相应的节点上,因为在大数据中是有说法的,移动计算优于移动数据。所以 RDD 第五个特性就是具有计算每一个分区最好位置的集合。

Spark 在运行的时候,每一个计算任务就是一个 Task,另外对于 RDD 而言,不是一个 RDD 计算对应一个 Task,而是 RDD 内部的每一个分区计算都会对应一个 Task。假设这个 RDD 具有 5 个分区,那么对这个 RDD 进行一次 map 操作,就会生成 5 个 Task。并且分区的数据是可持久化的,比如:内存、磁盘、内存 + 磁盘、多副本、序列化。

SparkContext 和 SparkConf

如果想创建 RDD,那么必须要先创建 SparkContext 对象,它是程序的入口,无论使用哪种编程语言都是如此。作业的提交,任务的分发,应用的注册都会在SparkContext 中进行。一个 SparkContext 对象代表了和 Spark 的一个连接,只有建立了连接才可以把作业提交到 Spark 集群当中去,只有实例化了 SparkContext 之后才能创建RDD、以及后面要说的 Broadcast 广播变量。至于 SparkConf 是用来指定配置的,它会作为参数传递给 SparkContext。

如何创建 SparkContext 对象呢?我们可以使用 pyspark 模块,直接 pip install 安装即可。

from pyspark import SparkContext, SparkConf

# 指定配置,setAppName 负责指定应用名称,setMaster 负责指定运行模式(关于运行模式一会儿介绍)
conf = SparkConf().setAppName("satori").setMaster("local[*]")
# 实例化 SparkContext 对象
sc = SparkContext(conf=conf)
# 然后就可以使用 sc 来创建 RDD 了

关于 Python 的 pyspark 模块稍后详细说,再来看看 Spark 提供的 Shell。

Spark 提供一个 pyspark shell,我们启动之后输入 sc,发现它默认已经创建了 SparkContext 对象。至于 master 表示运行模式,local[*] 代表本地运行,其中 * 表示使用所有的核(如果只想使用两个核,那么就指定为 local[2] 即可),appName 叫做 PySparkShell。

当然啦,在启动的时候也可以手动指定 master 和 appName。

当 pyspark shell 启动之后,可以通过 webUI 查看相关信息,端口是 4040。

创建 RDD

RDD 是 Spark 的核心,那么如何创建 RDD 呢?答案显然是通过 SparkContext 对象,上面已经说了。我们可以通过编写 py 文件的方式(后面会说)手动创建 SparkContext 对象,也可以通过启动 pyspark shell,直接使用默认创建好的,对,就是那个 sc。由于 SparkContext 实例对象操作方式都是一样的,所以目前就先使用 pyspark shell 来进行编程,后面我们会说如何通过编写脚本的方式进行 Spark 编程,以及如何将作业提交到 Spark 上运行。

创建 RDD 有两种方式:

  • 将一个已经存在的集合转成 RDD
  • 读取存储系统里面的文件,转成 RDD。这个存储系统可以是本地、HDFS、HBase、S3 等等,甚至可以是 MySQL 等关系型数据库

从已经存在的集合创建

可以将一个已存在的集合(比如元组、列表、集合等)转成 RDD。

# 调用 sc.parallelize 方法,可以将已经存在的集合转为 RDD
>>> rdd1 = sc.parallelize(range(10))  
# 显示的是一个 RDD 对象
>>> rdd1  
PythonRDD[1] at RDD at PythonRDD.scala:53
# 如果想查看具体内容,可以调用 collect 方法,这些后面会说
>>> rdd1.collect()  
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]   

# 进行 map 操作得到 rdd2
>>> rdd2 = rdd1.map(lambda x: x + 1)
>>> rdd2.collect()
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# 进行 reduce 操作
>>> rdd2.reduce(lambda x, y: x + y)
55
>>>

我们知道 RDD 是有分区的,在创建时也可以手动指定分区数量。

>>> rdd1 = sc.parallelize(range(10), 5)  
>>> rdd1.glom().collect()
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]
>>> rdd1.getNumPartitions()  # 获取分区数
5

创建 RDD 时指定了 5 个分区,所以每个分区都会对应一个 Task。但因为分区可大可小,如果每个节点的 CPU 只执行一个分区可能有点浪费,比如跑的快的、或者分区的数据集比较少的,很快就跑完了,那么容易造成资源浪费。因此 Spark 官方建议每个 CPU 对应 2 到 4 个分区,这样可以把资源充分利用起来。至于具体设置多少个,这个就取决于实际项目、以及规定的处理时间、节点对应的机器性能等等,所以如果你根据业务找到了比较好的分区个数,那么就传递给 parallelize 的第二个参数即可。

如果没有指定分区数量,那么 Spark 会根据 CPU 核心数来自己决定。

注意:Spark 执行任务的相关细节和信息,可以通过 webUI 查看(端口是 4040)。

从存储系统里面的文件创建

SparkContext 还可以读取存储系统里面的文件来创建 RDD,我们演示一下从本地读取文件、和从 HDFS 上读取文件。

在本地创建一个 girl.txt,并上传到 HDFS 上面。

# 读取文件使用 textFile 方法,传递一个文件路径,同时也可以指定分区
# 可以从本地读取,读取的格式为 "file://文件路径"
>>> rdd1 = sc.textFile("file:///root/girl.txt")
>>> rdd1.collect()
['hello matsuri', 'hello koishi', 'hello matsuri', 'hello marisa']

# 从 HDFS 上读取,格式为 "hdfs://ip:port/文件路径",port 就是 HDFS 集群的 NameNode 端口
# 注意格式:"...//girl.txt" 和 ".../girl.txt" 都是合法的
>>> rdd2 = sc.textFile("hdfs://satori001:9000//girl.txt", 4)
>>> rdd2.collect()
['hello matsuri', 'hello koishi', 'hello matsuri', 'hello marisa']

# 执行 map 操作
>>> rdd3 = rdd1.map(lambda x: x.split())
>>> rdd3.collect()
[['hello', 'matsuri'], ['hello', 'koishi'], ['hello', 'matsuri'], ['hello', 'marisa']]

我们看到通过 textFile 读取外部文件的方式创建 RDD 也是没有问题的,文件的一行对应 RDD 的一个元素。但需要注意:如果是 Spark 集群,并且还是通过本地文件的方式,那么你要保证该文件在所有节点上都存在。

目前我们选择单节点 Spark,因为对于学习来讲,单节点和多节点都是差不多的,不可能因为用的多节点,语法就变了,只是多节点在操作的时候要考虑到通信、资源等问题。比如这里读取本地的 /root/girl.txt,如果你搭建的是集群,那么要保证每个节点都存在 /root/girl.txt,否则节点根本获取不到这个数据。所以在学习语法的时候不建议搭建 Spark 集群,等基础概念了解的差不多了,介绍 Spark 运行模式的时候,我们再搭建。

对于 Spark 集群而言,上面问题的解决办法就是把文件拷贝到每一个节点上面,或者使用网络共享的文件系统。

另外 textFile 不光可以读取文件,还可以读取目录:textFile("/dir"),模糊读取:textFile("/dir/*.txt"),以及读取 gz 压缩包等等。

既然可以读取文件创建 RDD,那么也可以将 RDD 保存为文件,通过 saveAsTextFile 方法。

>>> rdd = sc.parallelize(range(8), 4)
>>> rdd = rdd.map(lambda x: f"甜狗 {x} 号")
# 默认保存在本地,当然也可以加上 file://
>>> rdd.saveAsTextFile("/root/a.txt")
# 保存到 HDFS
>>> rdd.saveAsTextFile("hdfs://satori001:9000/a.txt")

虽然我们保存的文件名是 a.txt,但它并不是一个文本文件,而是一个目录。

里面的每一个 part 对应一个分区,因为 RDD 有 4 个分区,所以有 4 个 part,每个 part 保存的就是对应分区的数据。所以 Spark 是把每个分区写到一个文件里面,而 RDD 在磁盘上会对应一个目录。从这里可以看出,RDD 其实是一个抽象的概念,它表示一系列分区的集合,而这些分区可以分布在不同的节点上。

textFile 除了本地文件、HDFS 文件,还支持 S3,比如 textFile("S3://....") 读取 S3 文件。

另外我们说过 textFile 不仅可以读取指定文件,还可以传递一个目录,会将目录里面的所有文件读取出来合并在一起。

# 读取指定的单个文件
>>> rdd = sc.textFile("hdfs://satori001:9000/a.txt/part-00000")
>>> rdd.collect()
['甜狗 0 号', '甜狗 1 号']

# 也可以指定通配符,这里读取文件名以 3 结尾的文件,也就是 part-00003
>>> rdd = sc.textFile("hdfs://satori001:9000/a.txt/*3")
>>> rdd.collect()
['甜狗 6 号', '甜狗 7 号']

# 指定一个目录,会将目录下的文件全部读取
>>> rdd = sc.textFile("hdfs://satori001:9000/a.txt")
>>> rdd.collect()
['甜狗 0 号', '甜狗 1 号', '甜狗 2 号', '甜狗 3 号', '甜狗 4 号', '甜狗 5 号', '甜狗 6 号', '甜狗 7 号']
>>>

这里我们将刚才写入的文件又读出来了,但是注意,如果你读取的是一堆的小文件,那么更推荐使用 wholeTextFiles 方法,该方法会对分区数量做优化,选择一个合适的分区数。因为分区并不是越多越好,分区变多,也会导致 shuffle 的几率变高。而 shuffle 是一个比较昂贵的操作,它表示节点之间发生了数据交换,显然这是比较耗时的。所以面对大量的小文件,不可能让每个小文件都对应一个分区,那么究竟多少个小文件被归为一个分区呢?可以让 wholeTextFiles 方法帮我们决定。

# 我在 files 目录中生成了 100 个小文件(每个文件里面只有一个字符串 "hello")
# 如果使用 textFile 读取,那么 RDD 会有 100 个分区
>>> rdd = sc.textFile("hdfs://satori001:9000/files")
>>> rdd.getNumPartitions()
100

# 而使用 wholeTextFiles 读取,那么只有两个分区
>>> rdd = sc.wholeTextFiles("hdfs://satori001:9000/files")
>>> rdd.getNumPartitions()
2

因此当读取的多个文件都是大文件,那么用 textFile 没有问题,但若是一堆小文件,更推荐使用 wholeTextFiles 方法,会拥有更好地性能。

然后这两个方法返回的内容也是不一样的:

textFile 会将所有文件的内容合并在一起,而 wholeTextFiles 会对文件作区分,返回的 RDD 里面的每个元素都是元组(包含文件名和文件内容)。

RDD 算子的概念和分类

RDD 提供了很多的算子,那么什么是算子呢?

说白了,rdd 是一个类 RDD 的实例对象,而 rdd 能够调用的方法就是算子,而算子分为两类:transformation 和 action。

transformation 类型的算子是懒加载(lazy)的,当调用该类型的算子时,相当于在构建执行计划,并没有开始执行,返回的是一个新的 RDD。

>>> rdd1 = sc.parallelize([1, 2, 3, 4])
>>> rdd2 = rdd1.map(lambda x: x + 1)
>>> rdd3 = rdd2.map(lambda x: x + 1)
>>> rdd4 = rdd3.map(lambda x: x + 1)
>>>

当调用 action 类型的算子时,构建的执行计划开始工作,此时返回的是具体的执行结果。

>>> rdd4.collect()
[4, 5, 6, 7]

因此 transformation 算子会返回新的 RDD,并记录 RDD 之间的依赖关系(专业术语叫血缘关系),可以想象成一条没有通电的流水线。而 action 算子返回的不是 RDD,它相当于给流水线通上电,让整条流水线开始工作,返回的是执行结果。

常见的 RDD 算子

说完了算子的基本概念,下面我们就来学习具体的常见算子。

transformation 算子

该类型的算子在调用之后返回的依旧是 RDD。

map 算子

这个比较简单,就类似于 Python 的 map。

>>> rdd = sc.parallelize([1, 2, 3, 4])
>>> rdd.map(lambda x: f"甜狗 {x} 号").collect()
['甜狗 1 号', '甜狗 2 号', '甜狗 3 号', '甜狗 4 号']
>>>

如果 RDD 有多个分区,那么每个分区都会执行 map。


flatMap 算子

该类型的算子和 map 类似,但它会做一些扁平化处理。

>>> rdd = sc.parallelize(["Hello Python", "Hello Rust"])
>>> rdd.map(lambda x: x.split()).collect()
[['Hello', 'Python'], ['Hello', 'Rust']]

>>> rdd.flatMap(lambda x: x.split()).collect()
['Hello', 'Python', 'Hello', 'Rust']
>>>

当内部的元素是可迭代对象时,flatMap 会将其展开,我们再举个例子。

>>> rdd = sc.parallelize(["abc", "def"])
>>> rdd.map(lambda x: x).collect()
['abc', 'def']

>>> rdd.flatMap(lambda x: x).collect()
['a', 'b', 'c', 'd', 'e', 'f']

reduceByKey 算子

针对 KV 型 RDD,会自动按照 key 进行分组,然后分别对组内数据(value)执行 reduce 操作。

# 内部元素是二元元组的 RDD,我们称之为 KV 型 RDD
>>> rdd = sc.parallelize([("a", 1), ("b", 1), ("a", 2), ("b", 2), ("c", 4)])
>>> rdd.reduceByKey(lambda x, y: x + y).collect()
[('b', 3), ('c', 4), ('a', 3)]

比较简单,然后基于以上几个算子,我们来做一个词频统计的案例吧,假设有一个 words.txt,内容如下:

hello python
hello golang
hello rust

我们来统计每个单词出现的次数。

>>> rdd = sc.textFile("hdfs://satori001:9000//words.txt")
['hello python', 'hello golang', 'hello rust']
# 按照空格分隔
>>> rdd = rdd.flatMap(lambda x: x.split())
>>> rdd.collect()
['hello', 'python', 'hello', 'golang', 'hello', 'rust']
>>> rdd = rdd.map(lambda x: (x, 1))
>>> rdd.collect()
[('hello', 1), ('python', 1), ('hello', 1), ('golang', 1), ('hello', 1), ('rust', 1)]

# 分组,然后组内元素相加即可
>>> rdd = rdd.reduceByKey(lambda x, y: x + y)
>>> rdd.collect()
[('python', 1), ('rust', 1), ('hello', 3), ('golang', 1)]

以上就是一个简单的词频统计,还是比较简单的,我们继续介绍算子。


mapValues 算子

针对 KV 型 RDD,但只对 value 做处理,key 保持不变。

>>> rdd = sc.parallelize([("a", 1), ("b", 1), ("a", 2), ("b", 2), ("c", 3)])
>>> rdd.mapValues(lambda x: x + 1).collect()
[('a', 2), ('b', 2), ('a', 3), ('b', 3), ('c', 4)]

# 该算子可以使用 map 替代
>>> rdd.map(lambda x: (x[0], x[1] + 1)).collect()
[('a', 2), ('b', 2), ('a', 3), ('b', 3), ('c', 4)]
>>>

groupBy 算子

对 RDD 的数据进行分组。

>>> rdd = sc.parallelize(range(10))
# 相当于给每个元素打上了一个标签,然后相同标签的元素归为一组
>>> rdd = rdd.groupBy(lambda x: "even" if x % 2 == 0 else "odd")
>>> rdd.collect()
[('even', <pyspark.resultiterable.ResultIterable object at 0x7fb58ec5a208>), 
 ('odd', <pyspark.resultiterable.ResultIterable object at 0x7fb58ec5a5f8>)]

# 也可以使用 rdd.mapValues(lambda x: list(x))
>>> rdd = rdd.map(lambda x: (x[0], list(x[1])))
>>> rdd.collect()
[('even', [0, 2, 4, 6, 8]), ('odd', [1, 3, 5, 7, 9])]
>>> 

所以之前的词频统计还可以这么做:

>>> rdd = sc.textFile("hdfs://satori001:9000//words.txt")
# 分隔之后,进行分组
>>> rdd = rdd.flatMap(lambda x: x.split()).groupBy(lambda x: x)
>>> rdd.collect()
[('python', <pyspark.resultiterable.ResultIterable object at 0x7fb58e91ad30>), 
 ('rust', <pyspark.resultiterable.ResultIterable object at 0x7fb58e91aa90>), 
 ('hello', <pyspark.resultiterable.ResultIterable object at 0x7fb58e91aa58>), 
 ('golang', <pyspark.resultiterable.ResultIterable object at 0x7fb58e91a7b8>)]

# 相同标签的归为一组,这里的标签就是元素本身
>>> rdd = rdd.mapValues(lambda x: list(x))
>>> rdd.collect()
[('python', ['python']), 
 ('rust', ['rust']), 
 ('hello', ['hello', 'hello', 'hello']),
 ('golang', ['golang'])]

>>> rdd = rdd.map(lambda x: (x[0], len(x[1])))
>>> rdd.collect()
[('python', 1), ('rust', 1), ('hello', 3), ('golang', 1)]
>>>

貌似要更麻烦一些,不过也是一种方法。


filter 算子

类似 Python 的 filter,用于对数据进行过滤。

>>> rdd = sc.parallelize(range(10))
>>> rdd.collect()
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]                                                  
# 保留值为偶数的元素
>>> rdd.filter(lambda x: x % 2 == 0).collect()
[0, 2, 4, 6, 8]

distinct 算子

对 RDD 进行去重,使内部的元素保持唯一。

>>> rdd = sc.parallelize([1, 2, 3, 1, 1, 2, 3, 4])
>>> rdd.distinct().collect()
[2, 4, 1, 3]

去重之后不保证顺序。


join 算子

对两个 KV 型 RDD 执行 JOIN 操作(类似于 SQL)。

>>> rdd1 = sc.parallelize([(1001, "a1"), (1002, "b1"), (1003, "c1")])
>>> rdd2 = sc.parallelize([(1001, "a2"), (1002, "b2"), (1004, "d1")])
# key 相同的 value 会被合并在一起
>>> rdd1.join(rdd2).collect()
[(1001, ('a1', 'a2')), (1002, ('b1', 'b2'))]

# 类似于 SQL 的 LEFT JOIN
>>> rdd1.leftOuterJoin(rdd2).collect()
[(1001, ('a1', 'a2')), (1002, ('b1', 'b2')), (1003, ('c1', None))]

# 类似于 SQL 的 RIGHT JOIN
>>> rdd1.rightOuterJoin(rdd2).collect()
[(1004, (None, 'd1')), (1001, ('a1', 'a2')), (1002, ('b1', 'b2'))]

intersection 算子

对两个 RDD 取交集。

>>> rdd1 = sc.parallelize([1, 2, 3, 4])
>>> rdd2 = sc.parallelize([3, 4, 5, 6])
>>> rdd1.intersection(rdd2).collect()
[4, 3]

union 算子

将两个 RDD 合并为一个 RDD,相当于对两个 RDD 取并集。

>>> rdd1 = sc.parallelize([1, 2, 3])
>>> rdd2 = sc.parallelize([11, 22, 33])
>>> rdd3 = rdd1.union(rdd2)
>>> rdd3.collect()
[1, 2, 3, 11, 22, 33]

不同类型的 RDD 也是可以合并的。

>>> rdd1 = sc.parallelize([1, 2, 3])
>>> rdd2 = sc.parallelize(["a", "b", "c"])
>>> rdd3 = rdd1.union(rdd2)
>>> rdd3.collect()
[1, 2, 3, 'a', 'b', 'c']

glom 算子

以分区为单位,对 RDD 的数据进行嵌套。

# 默认是两个分区
>>> rdd = sc.parallelize(range(1, 10))
>>> rdd.glom().collect()
[[1, 2, 3, 4], [5, 6, 7, 8, 9]]

# 改成三个分区
>>> rdd = sc.parallelize(range(1, 10), 3)
>>> rdd.glom().collect()
[[1, 2, 3], [4, 5, 6], [7, 8, 9]]

groupByKey 算子

前面介绍过 groupBy 算子,它接收一个函数,来给内部的元素打上一个标签,然后相同标签的元素归为一组。

>>> rdd = sc.parallelize(range(10))
>>> rdd = rdd.groupBy(lambda x: "even" if x % 2 == 0 else "odd")
>>> rdd.collect()
[('even', <pyspark.resultiterable.ResultIterable object at 0x7fb58ec5a208>), 
 ('odd', <pyspark.resultiterable.ResultIterable object at 0x7fb58ec5a5f8>)]

>>> rdd = rdd.map(lambda x: (x[0], list(x[1])))
>>> rdd.collect()
[('even', [0, 2, 4, 6, 8]), ('odd', [1, 3, 5, 7, 9])]
>>> 

而 groupByKey 则是针对于 KV 型 RDD,然后 key 相同的 value 归为一组。

>>> rdd = sc.parallelize([("a", 1), ("a", 2), ("a", 3), ("b", 1), ("b", 2)])
>>> rdd = rdd.groupByKey()
>>> rdd.collect()
[('b', <pyspark.resultiterable.ResultIterable object at 0x7f26d5930400>), 
 ('a', <pyspark.resultiterable.ResultIterable object at 0x7f26d5930470>)]
>>> rdd.mapValues(lambda x: list(x)).collect()
[('b', [1, 2]), ('a', [1, 2, 3])]

所以我们也可以用 groupByKey 实现 groupBy。

>>> rdd = sc.parallelize(range(10))
>>> rdd = rdd.map(lambda x: ("even" if x % 2 == 0 else "odd", x))
>>> rdd.collect()
[('even', 0), ('odd', 1), ('even', 2), ('odd', 3), ('even', 4), 
 ('odd', 5), ('even', 6), ('odd', 7), ('even', 8), ('odd', 9)]
>>> rdd.groupByKey().mapValues(lambda x: list(x)).collect()
[('even', [0, 2, 4, 6, 8]), ('odd', [1, 3, 5, 7, 9])]

当然用 groupBy 实现 groupByKey 也是没问题的。

>>> rdd = sc.parallelize([("a", 1), ("a", 2), ("a", 3), ("b", 1), ("b", 2)])
>>> rdd = rdd.groupBy(lambda x: x[0])
>>> rdd.collect()
[('b', <pyspark.resultiterable.ResultIterable object at 0x7f26d59302e8>), 
 ('a', <pyspark.resultiterable.ResultIterable object at 0x7f26d5930780>)]
>>> rdd = rdd.mapValues(lambda x: [_[1] for _ in x])
>>> rdd.collect()
[('b', [1, 2]), ('a', [1, 2, 3])]

sortBy 算子

对 RDD 内的数据进行排序。

>>> rdd = sc.parallelize([-2, -3, -4, -1, 3, 4, 2, 1])
# 传入一个排序函数,这里先按照正负排序,如果符号相同则按照绝对值大小排序
>>> rdd.sortBy(lambda x: (x > 0, abs(x))).collect()
[-1, -2, -3, -4, 1, 2, 3, 4]

sortBy 函数还可以接收一个 ascending 参数,表示是否升序(默认 True),以及一个 numPartitions 参数,表示用多少个分区排序。如果你希望全局排序,那么分区数显然应该设置为 1。


sortByKey 算子

针对 KV 型 RDD,按照 key 进行排序。

>>> rdd = sc.parallelize([(1, "a"), (3, "b"), (2, "c")])
>>> rdd.sortByKey().collect()
[(1, 'a'), (2, 'c'), (3, 'b')]
>>> rdd.sortByKey(ascending=False, numPartitions=2).collect()
[(3, 'b'), (2, 'c'), (1, 'a')]

显然该算子完全可以用 sortBy 替代。


mapPartitions 算子

它和 map 有点相似,但 map 作用于 RDD 的每个元素,而 mapPartitions 是作用于 RDD 的每个分区。也就是说,RDD 每次会给 map 传递一个数据,而给 mapPartitions 则是以迭代器的形式传递一个分区。

>>> rdd = sc.parallelize([1, 2, 3, 4, 5, 6], 3)
>>> rdd.map(lambda x: x + 10).collect()
[11, 12, 13, 14, 15, 16]
>>> rdd.mapPartitions(lambda x: [_ + 10 for _ in x]).collect()
[11, 12, 13, 14, 15, 16]

所以对于上面的代码,map 里的函数需要调用 6 次,因为有 6 个元素。但 mapPartitions 里的函数则需要调用 3 次,因为有 3 个分区。虽然从 CPU 执行层面来看没有什么差别,但当发生数据传输时,mapPartitions 可以有效减少 IO。


partitionBy 算子

我们说 RDD 有 5 大特性,其中第四个特性是针对于 KV 型 RDD 可以自定义分区规则,而方式便是使用 partitionBy。该算子接收两个参数:

  • 分区数
  • 分区规则(一个函数)

注意分区函数必须返回一个整数,假设分区数为 3,那么分区函数可以返回的值便是 0、1、2,也就是返回分区号(从 0 开始)。同一分区号的元素会落在同一个分区当中,我们举例说明。

>>> data = [("a", 1), ("b", 1), ("c", 1), ("d", 1), ("e", 1)]
# 按照哈希值 % 3 分组的话,"a"、"c"、"e" 是一组,"b" 是一组,"d" 是一组
>>> [hash(item[0]) % 3 for item in data]
[2, 1, 2, 0, 2]
>>> rdd = sc.parallelize(data)
>>> rdd = rdd.partitionBy(3, lambda x: hash(x) % 3)
>>> rdd.glom().collect()
[[('d', 1)], [('b', 1)], [('a', 1), ('c', 1), ('e', 1)]]

RDD 的分区结果和预想的是一样的。


repartition 算子

改变 RDD 的分区数,可以增加也可以减少,取决于传递的整数。和 partitionBy 不同的是,repartition 算子只改变分区数,分区规则是不变的。但是需要注意:在操作分区数量时一定要慎重,因为改变分区会带来以下两个影响。

  • 影响并行计算(内存迭代的并行管道数量),后面说
  • 分区如果增加,极大可能导致 shuffle

一般来说,除了要求全局排序必须设置一个分区之外,大部分情况我们都不需要关心分区相关的 API。

以上就是常见的 transformation 算子,下面介绍 action 算子。

action 算子

该类型的算子在调用之后会返回具体的值,不再返回 RDD。

countByKey 算子

针对 KV 型 RDD,统计 key 出现的次数。

>>> rdd = sc.parallelize([("a", 1), ("a", 2), ("a", 3), ("b", 1), ("b", 2)])
>>> rdd.countByKey()
defaultdict(<class 'int'>, {'a': 3, 'b': 2})

collect 算子

这个算子我们一直都在用,它负责将 RDD 各个分区的数据收集起来,合并为一个列表。如果 RDD 内部的数据量很大,那么可能会撑爆内存。


reduce 算子

类似 Python 的 reduce。

>>> rdd = sc.parallelize([1, 2, 3, 4, 5])
>>> rdd.reduce(lambda x, y: x + y)
15
>>> rdd = sc.parallelize(["a", "b", "c", "d", "e"])
>>> rdd.reduce(lambda x, y: x + y)
'abcde'

fold 算子

和 reduce 一样,对数据进行聚合,但可以接收一个初始值。首先在聚合的时候,每个分区内部会进行聚合,然后再对每个分区聚合的结果进行聚合,而初始值会同时作用在这两个步骤上。什么意思呢?我们举个例子。

>>> rdd = sc.parallelize([1, 2, 3, 4, 5, 6], 3)
>>> rdd.fold(10, lambda x, y: x + y)
61

首先 RDD 有 3 个分区,每个分区内部会进行聚合,那么结果是 3、7、11,但还要加上初始值,所以是 13、17、21。然后每个分区聚合的结果再进行聚合,同时还要加上初始值,因此结果是 10 + 13 + 17 + 21,等于 61。


first 算子

获取 RDD 的第一个元素。

>>> rdd = sc.parallelize([1, 2, 3, 4, 5, 6], 3)
>>> rdd.first()
1

take 算子

获取 RDD 的前 N 个元素。

>>> rdd = sc.parallelize([1, 2, 3, 4, 5, 6])
>>> rdd.take(4)
[1, 2, 3, 4]

top 算子

对 RDD 降序排序,选择前 N 个。

>>> rdd = sc.parallelize([1, 2, 3, 4, 5, 6])
>>> rdd.top(3)
[6, 5, 4]

count 算子

返回 RDD 内部的元素个数。

>>> rdd = sc.parallelize([1, 2, 3, 4, 5, 6])
>>> rdd.count()
6

takeSample 算子

对 RDD 的数据进行抽样,接收三个参数:

  • 参数一:元素是否有放回,如果为 True,那么同一个元素可能会出现多次
  • 参数二:抽样的数量
  • 参数三:随机数种子
# 前两个参数必传,随机种子一般不传,Spark 会自动设置一个随机种子
>>> rdd = sc.parallelize(range(10))
>>> rdd.takeSample(False, 5)
[4, 9, 6, 8, 3]

takeOrdered 算子

对 RDD 升序排序,选择前 N 个。

>>> rdd = sc.parallelize([3, 1, 5, 4, 2])
>>> rdd.takeOrdered(3)
[1, 2, 3]

如果想降序排呢?很简单,使用 top 即可。或者还可以使用 sortBy + take。

>>> rdd = sc.parallelize([3, 1, 5, 4, 2])
# 升序排,选择前 3 个
>>> rdd.sortBy(lambda x: x).take(3)
[1, 2, 3]
# 降序排,选择前 3 个
>>> rdd.sortBy(lambda x: -x).take(3)
[5, 4, 3]

foreach 算子

对 RDD 的每一个元素都执行相同的操作(类似于 map),但该函数没有返回值。

>>> rdd = sc.parallelize([1, 2, 3])
>>> rdd.foreach(print)
1
2
3

foreachPartition 算子

和 foreach 类似,但每次处理的是一整个分区的数据,相当于没有返回值的 mapPartitions。

>>> rdd = sc.parallelize([1, 2, 3, 4, 5, 6], 2)
>>> rdd.foreach(print)
1
2
3
4
5
6
>>> rdd.foreachPartition(print)
<itertools.chain object at 0x7fbc5a35dc18>
<itertools.chain object at 0x7fbc5a35dc18>
>>> rdd.foreachPartition(lambda x: print(list(x)))
[4, 5, 6]
[1, 2, 3]

saveAsTextFile 算子

将 RDD 数据写入到文件系统中,我们在介绍 RDD 的创建时说过。但需要注意:写入时文件不能存在,否则报错。

以上就是 RDD 的一些常见算子,还是比较多的。总之这些算子不建议去记,用的时候再去查就行了,而且事实上我们也很少会使用 RDD 编程,只不过它是上层结构的根基,所以还是有必要了解的。

RDD 的持久化

RDD 之间是具有血缘关系的,比如基于 RDD1 得到 RDD2,基于 RDD2 得到 RDD3,基于 RDD3 得到 RDD4,那么当对 RDD4 执行 collect 算子的时候,整个 RDD 链条便会开始计算。那么问题来了,如果再基于 RDD3 得到 RDD5,然后对 RDD5 执行 collect 算子时,会发生什么呢?

我们说过调用 RDD 的 transformation 算子,相当于在构建执行计划,调用 action 算子时才会开始执行。因此不管是 RDD4 还是 RDD5,它们在调用 collect 方法时,都会从 RDD1 开始逐步执行。

如果执行的逻辑非常复杂,那么每次都从头执行是不是很耗时呢?所以便有了 RDD 的持久化。对于上面这个例子来说,我们完全可以将 RDD3 给持久化,这样 RDD5 调用 collect 方法时,就不用从头开始执行了。

所以 Spark 一个最重要的能力就是它可以通过一些操作来持久化(或者缓存)内存中的数据,当你持久化一个RDD,节点就会存储这个 RDD 的所有分区,以后可以直接在内存中计算、或者在其它的 action 操作时能够重用。这一特性使得之后的 action 操作能够变得更快(通常是 10 个数量级),因此缓存对于迭代式算法或者快速的交互式使用是一个非常有效的工具。

我们可以通过调用 persist() 或者 cache() 方法来持久化一个 RDD,当第一次 action 操作触发时,所有分区数据就会被保存到其他节点的内存当中。并且 Spark Cache 具有容错性:如果 RDD 的某个分区数据丢失了,那么会根据原来创建它的 transformation 操作重新计算。那么 persist 和 cache 有什么区别呢?我们看一下源码。

persist 接收一个 storageLevel 参数,表示缓存级别,默认是 MEMORY_ONLY,表示缓存在内存中。而 cache 则是直接调用了 persist,所以如果不传参数,两者是一样的。而如果想缓存到磁盘,那么就需要调用 persist 方法,并且指定缓存级别。那么缓存级别都有哪些呢?

from pyspark import StorageLevel

# 缓存在内存中
StorageLevel.MEMORY_ONLY
# 缓存在内存中,2 副本
StorageLevel.MEMORY_ONLY_2
# 缓存在磁盘中
StorageLevel.DISK_ONLY
# 缓存在磁盘中,2 副本
StorageLevel.DISK_ONLY_2
# 缓存在磁盘中,3 副本
StorageLevel.DISK_ONLY_3
# 先缓存在内存中,如果内存不够,再缓存在磁盘中
StorageLevel.MEMORY_AND_DISK
# 先缓存在内存中,如果内存不够,再缓存在磁盘中,2 副本
StorageLevel.MEMORY_AND_DISK_2
# 缓存在堆外内存中(系统内存)
StorageLevel.OFF_HEAP

注意:persist 是惰性的,只有在遇到一次 action 操作的时候,才会缓存 RDD 的分区数据。如果不想缓存了,可以调用 unpersist,而 unpersist 是立刻执行的。我们举例说明。

# 对于本地文件,也可以不指定 file://,直接写成 /root/1.txt
>>> rdd = sc.textFile("file:///root/1.txt")
# 持久化 RDD,默认保存在内存中,此时等价于 rdd.cache()
>>> rdd.persist()
PythonRDD[24] at RDD at PythonRDD.scala:53
# 执行 action 操作
>>> rdd.count()
10000       
# 我们查看一下文件的大小
>>> import os
>>> os.stat("/root/1.txt").st_size / 1024
77.044921875

然后我们访问 webUI(端口是 4040),点击 Storage,可以查看缓存信息。

RDD Name,这个就是我们的文件名;Storage Level 表示缓存级别,默认是基于内存的;Cached Partitions 表示缓存的分区数,RDD 默认有两个分区;Size in Memory 表示缓存在内存中的大小,比 77K 的源文件小很多,这是 Spark 基于缓存做的策略;Size on Disk 表示缓存在磁盘中的大小,因为没有缓存到磁盘,所以是 0。

persist 表示持久化,还可以调用 unpersist 取消持久化。

# 取消 RDD 持久化
>>> rdd.unpersist()
file:///root/1.txt MapPartitionsRDD[11] at textFile at NativeMethodAccessorImpl.java:0
>>> 

如果取消了,那么刷新页面,就没有内容了。

Spark 提供的 webUI 非常有用,里面包含了 Spark 集群以及任务执行时的相关信息。

那么问题来了,persist 支持这么多的缓存级别,我们应该使用哪一种呢?官方给出了如下建议。

  • 如果 RDD 能够使用默认的缓存策略搞定,就使用默认策略。这是最有效率的选择,它能允许 RDD 上的操作运行的尽可能的快。
  • 不要把数据写到磁盘,除非你的数据非常的昂贵,不能允许有任何丢失的风险,否则重头计算甚至都比从磁盘读取快。
  • 如果你想快速地从错误中恢复,那么可以使用副本存储策略。比如一个分区数据丢了,但是有两个副本,所以会去选择另一个副本,而不会重新计算。其实所有的存储策略,都可以通过重新计算丢失数据来提供完整的容错,但副本的存在可以让你在不计算丢失数据的情况下继续运行 Task。比如副本为 1,那么丢了只能重新计算了,但如果副本为 2,那么丢了 1 个还有 1 个,直接去取就可以了,就不用重新计算了。

最后保存 RDD 数据还可以使用 checkpoint 方法,但它只能保存在硬盘上(一般是 HDFS),并且在设计上(被认为)是安全的。那么 checkpoint 和 persist 有什么区别呢?

  • persist:多个分区之间分散存储,因为多个分区可以散落在不同节点上。
  • checkpoint:集中收集各个分区数据进行存储。
>>> rdd = sc.textFile("file:///root/1.txt")
# 设置 checkpoint 的保存路径,如果是 local 模式,那么可以用本地文件系统
# 但如果是集群模式,一定要用 HDFS
>>> sc.setCheckpointDir("hdfs://satori001:9000/checkpoint")
# 保存起来
>>> rdd.checkpoint()

我们来看一下 HDFS 上面有没有数据。

显然成功保存了,这里再来对比一下 checkpoint 和 persist 的差异。

  • checkpoint 因为是集中存储,所以不管分区多少,风险是一样的。而 persist 则是将分区缓存到所在的节点本地,所以分区越多,数据丢失的风险越高。
  • checkpoint 支持写入 HDFS,但 persis 不行,而 HDFS 是高可靠存储,所以 checkpoint 被认为是安全的。
  • checkpoint 不支持内存,persist 可以,如果缓存到了内存中,那么性能要由于 checkpoint。
  • checkpoint 被认为是设计安全的,所以不保留血缘关系,比如 RDD1 => RDD2 => RDD3,当 RDD3 调用 checkpoint 时,它和 RDD1、RDD2 之间就没有关系了。而 persist 则是被认为不安全的,所以会保留血缘关系。

所以 persist 是轻量级保存 RDD 数据,可存储在内存或硬盘,是分散存储,在设计上被认为是不安全的(保留 RDD 血缘关系);checkpoint 是重量级保存 RDD 数据,集中存储在 HDFS 上,在设计上被认为是安全的(不保留 RDD 血缘关系),适合保存那些运算时间较长、量比较大的数据。

Spark 运行模式

目前我们的代码都是通过 pyspark shell 执行的,但生产上肯定要编写相应的 py 文件,然后上传到 Spark 集群中运行。下面我们来看 py 文件的编写方式。

# 文件名:main.py
from pyspark import SparkContext

# SparkConf 可以用来指定相关的配置,但是官方不推荐这种硬编码的模式
# 而是通过提交任务的时候指定,因此可以不用创建 SparkConf 对象
# 这里我们直接实例化 SparkContext 对象,命名为 sc
sc = SparkContext()

# 在这里用刚才学习的 RDD 相关的算子,编写你的业务逻辑
rdd = sc.parallelize([1, 2, 3, 4, 5])
rdd = rdd.map(lambda x: f"甜狗 {x} 号")

# 不在 shell 里面了,我们需要 print 才能看到结果
print(rdd.collect())

# 好的习惯,编程结束之后 stop 掉,表示关闭与 Spark 集群连接
# 否则当你再次创建相同的 SparkContext 实例的时候就会报错
# 会提示你:Cannot run multiple SparkContexts at once; existing SparkContext(app=..., master=local[*])
sc.stop()

接下来就要把它提交到 Spark 集群上运行了,通过 spark-submit,但是运行模式可以有多种选择。

local 运行模式

这个应该是最熟悉的模式了,在学习 Spark 或者本地开发的时候最为方便,直接搭建一个 Spark 环境就可以跑了。前面的 pyspark shell,便是以 local 模式启动的。当然,提交作业也可以指定 local 运行模式。

spark-submit --master local[*] --name 万明珠 main.py

--master 负责指定运行模式,local[*] 表示本地模式,并且使用全部的核,--name 负责指定应用程序的名称。我们提交一下看看:

输出的内容非常多,程序的结果就隐藏在中间。

那么问题来了,如果代码逻辑比较复杂,涉及多个文件(目录)该怎么办呢?标准库里面的模块可以直接导入,但我们自己写的依赖怎么提交呢?首先多个文件 (目录)里面一定存在一个启动文件,用来启动整个程序。假设这个启动文件叫 main.py(当然启动文件应该在项目的最外层,如果在项目的包里面,那么它就不可能成为启动文件),那么把除了 main.py 的其它文件打包成一个 zip 包或者 egg,假设叫做 dependency.egg,然后执行的时候就可以这么做:

spark-submit --master xxx --name xxx --py-files dependency.egg main.py

如果我们写的程序需要从命令行中传递参数,那么直接跟在 main.py(启动文件)后面就行。

关于通过 --py-files 提交依赖,一会儿还会单独说。

然后 spark-submit 还支持很多其它参数,具体可以通过 --help 查看,不过很多都用不到。因为 spark-submit 不仅可以提交 Python 程序,还可以提交 Java、Scala 程序,所以里面有很多参数是给其它语言准备的,Python 用不到。

当然啦,不管是 local 运行模式还是其它运行模式,提交方式都是一样的,所以程序中我们没有使用 SparkConf 指定运行模式,因为官方推荐在提交任务的时候通过 --master 指定。

standalone 运行模式

standalone 运行模式则是经典的 master & slave 模式,它是 Spark 自带的。所以搭建 standalone 要保证有多个节点,其中一个是 master,剩下的属于 worker(slave),下面我们就来演示如何搭建。

首先在 $SPARK_HOME 的 conf 目录下有一个 spark_env.sh,我们之前还配置了 Python 环境,将这个文件打开。

# 指定启动 pyspark shell 或提交 Python 任务时的 Python 解释器
export PYSPARK_DRIVER_PYTHON=/usr/bin/python3
# Spark 工作节点使用的 Python 解释器
export PYSPARK_PYTHON=/usr/bin/python3

目前我们只配置了以上两个变量,PYSPARK_DRIVER_PYTHON 负责指定启动 pyspark shell 或提交 Python 任务时的 Python 解释器,PYSPARK_PYTHON 负责指定 Spark 工作节点使用的 Python 解释器。对于 standalone 模式来说,最好保证所有节点的解释器版本是相同的。

然后我们在此基础上继续进行配置,搭建 standalone。

export PYSPARK_DRIVER_PYTHON=/usr/bin/python3
export PYSPARK_PYTHON=/usr/bin/python3

# 配置 JAVA_HOME
export JAVA_HOME=/opt/jdk1.8.0_221/
# 配置 master,让当前的 satori001 节点作为 master
# 然后端口是默认值 7077
export SPARK_MASTER_HOST=satori001
export SPARK_MASTER_PORT=7077

然后配置 workers,首先 cp workers.template workers,然后打开 workers,里面只有一个默认的 localhost。所以即便一个节点,我们依旧可以搭建 standalone 集群,只不过意义不太大。打开 workers 之后,在里面写上 worker 节点。

satori002
satori003
satori004
satori005

这里让 satori001 作为 master,其它节点作为 worker,当前的 satori001 节点已经配置完毕,然后通过 scp 命令将 Spark 目录拷贝到其它节点中。只要保证框架的版本相同,那么所有节点配置就都是一样的。

scp -r /opt/spark-3.4.2-bin-hadoop3/ root@satori002:/opt
scp -r /opt/spark-3.4.2-bin-hadoop3/ root@satori003:/opt
scp -r /opt/spark-3.4.2-bin-hadoop3/ root@satori004:/opt
scp -r /opt/spark-3.4.2-bin-hadoop3/ root@satori005:/opt

然后我们就可以启动 Spark 集群了,相关命令在 $SPARK_HOME/sbin 目录中,不过这里已经配置好了环境变量。我们先执行 start-master.sh 启动 master,然后执行 start-workers.sh 启动 worker,或者更简单的做法,直接执行 start-all.sh 也是可以的。

如果查看 worker 节点,会发现多出了 Worker 进程。

毫无疑问,Spark 集群启动成功。

补充:虽然 satori001 是 master,但它同时也可以做 worker。如果在配置文件 workers 中将 satori001 也加进去,那么 satori001 节点也会启动 Worker 进程。只不过那样的话,satori001 节点的压力就会比较大。

当 Spark 集群启动之后,我们还可以通过 webUI 查看相关信息,端口是 8080(可在 spark-env.sh 中通过 SPARK_MASTER_WEBUI_PORT 进行配置)。

显示集群状态为 ALIVE,并且有 4 个 worker。

然后我们再来说一下,关于 Spark 端口的问题。目前已经出现了三个端口,分别是:4040、7077、8080,我们来解释一下这些端口的区别。

  • 4040:它是查看 pyspark 任务的 webUI 端口,当启动 pyspark shell 或者向 Spark 提交任务时,可通过访问此端口查看具体信息。
  • 7077:它是我们在 spark-env.sh 中设置的端口,master 和 worker 在进行 rpc 通信时使用(如果我们在 spark-env.sh 中不设置,那么默认也是 7077),因为不同机器彼此访问肯定要指定 IP 和端口。而且图片上方还写着 spark://satori001:7077,后续便通过该地址连接到 Spark 集群。
  • 8080:它是查看 Spark 集群信息的 webUI 端口。

下面我们就走一个,启动 pyspark,连接到 Spark 集群当中。

pyspark --master spark://satori001:7077

此时我们就连接到了 Spark 集群中,再来看看集群的 webUI。

注意看里面的 Application ID 和 pyspark shell 中的 app id 是一样的,好了,以 standalone 运行模式启动 Spark 集群、并使用 pyspark 去连接,我们已经知道了,那么如何提交任务呢?

答案很简单,把使用 local 模式提交作业的命令 copy 下来,然后将 local[*] 改成我们的 Spark 集群地址:spark://127.0.0.1:7077 就能提交到 standalone 模式的 Spark 集群了。非常简单,我们测试一下。

任务显然是执行成功了的,但是日志信息太多了,程序的输出在下面,可以自己测试一下。

在使用 Spark 集群的时候,有一个很重要的地方需要注意,由于集群有多个节点,所以在读取文件的时候不要使用本地文件。因为在提交任务时,每个节点执行的代码肯定是相同的,那么在读取本地文件时就会从所在的节点中读取,这就需要我们保证每个节点都有相同的文件。所以在读取文件时,更推荐使用 HDFS。

yarn 运行模式

再来看一下 yarn 模式,它是使用 Spark 的公司采用最多的一个模式。使用 yarn 模式的时候,Spark 充当一个客户端,它需要做的事情就是提交作业到 yarn 上,然后执行。这里的 yarn 指的就是 Hadoop 框架的 YARN 组件,因此为了更好地理解该运行模式,我们先来介绍一下 YARN。在介绍 Hadoop 的时候我们已经说过 YARN 了,这里再来说一遍。

里面有很多角色,可以从两个层面进行分类。

资源管理层面

  • Resource Manager:管理整个集群资源,相当于 Master,后续简称 RM。
  • Node Manager:管理所在节点的资源,相当于 Worker,后续简称 NM。

所以 NM 负责管理单个节点,而每个节点上都有一个 NM,那么多个 NM 就能将所有节点的资源都管理起来,然后这些 NM 统一去向 RM 进行汇报。所以整个资源管理,就是 RM 配合一堆 NM 完成的。

但是光有资源还不够,因为最终的目的还是要完成计算的,所以必须要有干活的。

任务计算层面

  • Application Master(后续简称 AM):任务的管理者,当任务在 NM 上运行的时候,就是由 AM 负责管理,比如任务失败重启,任务资源分配,任务的工作调度,每个任务都对应一个 AM。
  • Task:任务的执行者,真正用来干活的。
  • Container:这个图上没有画,但 AM 和 Task 都运行在 Container 里面。在 YARN 中它代表了资源的抽象,封装了节点上的多维度资源,如内存、CPU、磁盘、网络等等。

所以整个执行流程如下:

  • 1)客户端向 Resource Manager 提交作业,作业一般会被拆分成多个任务;
  • 2)RM 为每个任务在 Node Manager 上分配一个 Container,来运行对应的 Application Master;
  • 3)AM 启动之后要注册到 RM 当中,因为 RM 负责全局的资源管理,AM 要向 RM 为任务的执行申请资源,并且注册之后客户端可以通过 RM 来查询任务的运行情况;
  • 4)AM 申请到资源之后,便要求 NM 再启动一个 Container,然后在 Container 里面运行 Task;

我们再来总结一下这几个角色的作用。

Resource Manager

  • 1)处理客户端请求。客户端想访问集群,比如提交一个应用程序,或者说作业(可以是 Spark 作业,也可以是 MapReduce 作业),要经过 Resource Manager,它是整个资源的管理者,管理整个集群的 CPU、内存、磁盘等资源;
  • 2)监控 Node Manager;
  • 3)启动或监控 Application Master;
  • 4)资源的分配和调度;

Node Manager

  • 1)管理单个节点上的资源,Node Manager 是当前节点资源的管理者,当然它也需要跟 Resource Manager 汇报;
  • 2)处理来自 Resource Manager 的命令,比如启动 Container 运行 Application Master;
  • 3)处理来自 Application Master 的命令,比如启动 Container 运行 Task;

Application Master

  • 1)某个任务的管理者。当任务在 Node Manager 上运行的时候,就是由 Application Master 负责管理,每个任务都会对应一个 AM;
  • 2)负责数据的切分;
  • 3)为应用程序向 RM 申请资源,并分配给内部的任务;
  • 4)任务的监控与容错;

Task

  • 1)任务的实际执行单元,运行在 AM 申请到的 Container 中。每个任务独占一个 Container,从而实现有效的资源管理和隔离。

所以整个 YARN 的流程应该不复杂,RM 管理全局资源,NM 管理单个节点资源,AM 管理单个任务,Task 负责执行任务,Container 代表了资源的抽象,AM 和 Task 都运行在 Container 中。


说完了 YARN 的原理之后,我们再来看看 Spark on YARN 模式。前面我们介绍了 Spark on Standalone,它需要有多个节点,但多个节点就意味着要多消耗资源,并且为了防止 master 单点故障,我们还要配置高可用(基于 Zookeeper 实现)。而服务器的资源总是紧张的,但对于大数据公司来说,基本上都会有 Hadoop 集群(YARN 集群),在这种情况下如果再单独准备 Standalone 集群,那么对资源的利用率就不高了。

所以大部分公司都会将 Spark 运行在 YARN 集群中,因为 YARN 本身就是一个资源调度框架,负责对运行在内部的计算框架进行资源调度管理。而作为典型的计算框架,Spark 本身也可以直接运行在 YARN 中,并接受 YARN 调度。因此,对于 Spark on YARN 来说,无需部署 Spark 集群,只需要有一个节点,充当 Spark 客户端,即可提交任务到 YARN 集群中运行。

下面我们启动 pyspark,并连接到 YARN 集群。

pyspark --master yarn

但如果你直接启动的话,是会报错的,因为不知道 YARN 集群的地址。根据错误提示,我们需要在 spark-env.sh 中配置 HADOOP_CONF_DIR 或这 YARN_CONF_DIR。

# 两个参数配置一个即可,当然两个都配置也没关系,值是一样的,都是配置文件所在路径
export HADOOP_CONF_DIR=/opt/hadoop-3.3.6/etc/hadoop/
export YARN_CONF_DIR=/opt/hadoop-3.3.6/etc/hadoop/

话说为什么配置了这两个参数就没问题了呢?很简单,Spark 会根据配置文件所在目录去找 core-site.xml,里面有 YARN 的集群地址,同理也可以找到 HDFS 的集群地址。

启动成功,这个过程会有一些慢,而提交作业也很简单,只需要将 --master 指定为 yarn 即可。然后我们看一下 YARN 的 webUI,端口是 8088。

我们看到客户端应用程序已经正常地运行到 YARN 当中了,YARN 已经做了记录,名称为 PySparkShell,类型为 SPARK。我们可以在 pyspark 里面编写代码,整个 pyspark shell 相当于提交到 YARN 的应用程序,里面编写的代码就是一个个子任务。

>>> sc.parallelize([1, 2, 3]).map(lambda x: f"甜狗 {x} 号").collect()
['甜狗 1 号', '甜狗 2 号', '甜狗 3 号']

这些任务可以通过 4040 端口查看,我们前面说过的。

有一个已完成的任务,就是我们上面刚执行的。另外我们也可以不用手动输入 4040 端口,在 YARN 的 webUI 页面中,可以直接点击跳转。

点击此处可以直接跳转到任务的详情界面,但是端口没有发生变化,这是 YARN 的跳转服务 webproxyserver 帮我们完成的,了解一下就好。

然后是提交作业,它和 local、standalone 没啥区别:

spark-submit --master yarn --name 万明珠 --py-files dependecy.egg main.py

但是在提交到 YARN 的时候,还可以通过 --deploy-mode 指定部署模式,可选值为 client 或 cluster,比如:

那么问题来了,这两种部署模式有什么区别呢?

  • client:提交作业的进程不能停止,否则作业就会挂掉。
  • cluster:提交完作业,进程就可以断开了,因为 Driver 是运行在 Worker 里面的。

这里面出现了一些概念,我们马上就说,还有 Spark 的架构等等。目前只需要知道,运行模式不同对代码没有影响,我们的代码只用写一份,然后需要什么模式,直接 --master 指定即可。

Spark 的内部细节

在介绍运行模式的时候,我们提到了部署模式、Driver、Worker,它们都代表什么呢?下面就来剖析一下 Spark 的内部细节。

Spark 框架模块

首先是整个 Spark 的功能结构,它包含 Spark Core、Spark Streaming、Spark SQL、Spark GraphX、Spark MLlib,而后面四个部分都是建立在 Spark Core 之上。


Spark Core

Spark 的核心,是 Spark 运行的基础。Spark Core 以 RDD 为数据抽象,提供多种编程语言的 API,可以通过编程进行海量离线数据的计算。


Spark Streaming

以 Spark Core 为基础,提供数据的流式计算。


Spark SQL

基础 Spark Core,提供结构化数据的处理模块。Spark SQL 支持以 SQL 语言对数据进行处理,本身针对离线场景,但基于 Spark SQL 又提供了 StructedStreaming 模块,能够以 Spark SQL 为基础,进行数据的流式计算。


Spark MLlib

以 Spark Core 为基础,进行机器学习计算,内置了大量的机器学习库和 API 算法,方便用户以分布式的模式进行机器学习计算。


Spark GraphX

以 Spark Core 为基础,进行图计算,提供了大量的图计算 API,方便用户以分布式的模式进行图计算。

Spark 的架构以及角色

前面我们介绍了 YARN 的架构,下面再来看看 Spark 的架构。

下面来解释一下相关角色。

Application

用户基于 Spark 开发的应用程序,比如我们前面编写的 main.py。一个应用程序由多个作业(Job)组成,这些 Job 是通过对 RDD 的操作实现的。


Job

作业,当 RDD 调用 action 算子时,就会提交一个作业。作业由多个阶段(Stage)组成,这些阶段会基于数据的 shuffle 操作来划分,每个作业的目标是生成一个结果集。


Stage

阶段,阶段是作业的物理执行计划的一部分,一个阶段由一组并行执行的任务组成,这些任务在各自的分区上执行预定义好的一系列 transformation 操作。阶段的划分基于 shuffle 边界,即每次数据的重新分组和分布都会导致一个新的阶段的开始。


Task

任务,Spark 执行的最小单位。每个阶段包含多个任务,每个任务会对分布式数据集的一个分区执行相同的计算。所有任务并行执行,以实现高效的数据处理。

总结一下就是:一个应用程序(Application)由多个作业(Job)组成,每个作业通过 action 算子的调用产生。作业被分解为多个阶段(Stage),阶段的划分基于 shuffle 操作。然后每个阶段包含多个任务(Task),任务是并行执行的基本单位。

光用文字描述的话,不好理解,我们用代码来描述一下这个过程:

from pyspark import SparkContext

sc = SparkContext()

rdd = sc.parallelize(range(30), 3)
rdd = rdd.filter(lambda x: x > 5)
rdd = rdd.map(lambda x: f"甜狗 {x} 号")
rdd.collect()

整个 py 文件就是一个 Application,它的生命周期从提交开始,到运行结束。然后当调用 rdd.collect() 时,会产生一个 Job,而 Job 由一个或多个 Stage 组成。但因为上面的例子中没有涉及到 shuffle 操作(比如 reduceByKey、groupBy 等),或者说没有产生数据交换,只是执行自己当前的分区数据,所以当前只有一个 Stage。

因此上面的 rdd.filter、rdd.map 都位于一个 Stage 中,然后一个 Stage 由多个任务组成,每个任务对指定的分区执行计算。因为当前 RDD 有 3 个分区,所以会产生 3 个任务,每个任务在各自的分区中执行定义好的一系列 transformation 操作(这里是 filter、map)。

因此关于 Application、Job、Stage、Task 这几个概念我们就说清楚了,我们再用一个生活中的例子对比一下。

把 Application 当成家庭大扫除,打扫房间、打扫厨房、打扫客厅就可以看成是 Job。每个 Job 由多个 Stage 组成,比如打扫厨房包含:清理垃圾、擦除污渍、物品摆放整齐。然后一个 Stage 对应多个 Task,因此家里的爸爸、妈妈、孩子就是 Task,它们并行执行,换句话说就是各自负责一块区域。

好了,我们继续看 Spark 的其它角色。


Driver Program

Driver 是 Spark 应用程序的执行入口点,当使用 spark-submit 提交一个应用程序时,这个过程实际是在 Driver 中执行的。它会执行应用程序的 main 函数,并创建 SparkContext 对象。此外 Driver 还会将应用程序分解成多个 Stage 并调度给集群上的 Executor 执行,基于 RDD 的 DAG 计划执行的顺序,并根据 shuffle 操作将 Job 分解成多个 Stage,每个 Stage 包含多个并行执行的 Task。

Driver 还负责监控和管理 Executor 的生命周期,包括向 Cluster Manager 请求资源,以及在任务执行完毕或失败时释放资源。当然它还负责监控任务的执行情况,并在需要时重试失败的任务或 Stage。


Cluster Manager

集群管理器,一个用于在集群上分配资源的外部服务,比如要用多少 CPU、多少内存等等。我们使用 spark-submit 的时候可以通过:--driver-memory、--driver-cores、--executor-memory、--executor-cores 等等,指定要使用的资源。

Spark 可以在多种集群管理器上运行,使其能够有效地利用分布式资源来执行并行计算任务。集群管理器的主要职责包括维护集群资源信息、分配资源给 Spark 应用程序以及监控和管理执行器(Executor)的生命周期。

Spark 支持以下几种类型的集群管理器:

  • Standalone:Spark 自带的一个轻量级集群管理器,适用于 Spark 专用集群。在 Spark on Standalone 模式下,Spark 自己负责资源调度和分配。
  • YARN:Hadoop 2.x 引入的资源管理器,旨在允许数据处理框架有效地共享 Hadoop 集群。在 YARN 模式下,Spark 应用程序向 YARN 请求资源,并且由 YARN 负责在 Hadoop 集群上分配计算节点给 Spark 的 Driver 和 Executor。
  • Mesos:更通用的集群资源管理系统,它允许在同一集群上运行多种分布式框架(例如 Spark、Hadoop 和其它应用程序)。Mesos 提供了更细粒度的资源分配策略,允许跨多个框架共享资源。
  • Kubernetes:开源的容器编排系统,用于自动部署、扩展和管理容器化应用程序。Spark 从 2.3 版本开始支持在 Kubernetes 上运行,允许 Spark 应用程序以容器的形式在 Kubernetes 集群中运行。Kubernetes 作为集群管理器,管理着容器的生命周期和资源。

如果使用 Standalone 模式,那么这里的 Cluster Manager 就可以认为是 Master 节点,它会启动一个 Master 进程(之前输入 jps 看到过),管理整个集群的资源。如果使用 YARN 模式,那么这里的 Cluster Manager 可以认为是 Resource Manager。

不同的集群管理器可能有不同的术语和架构,但在 Spark 集群中,Cluster Manager(无论是 Spark Standalone 的 Master 进程,还是 YARN 的 Resource Manager 进程)都负责集群资源管理、作业调度和集群监控。


Worker Node

工作节点,如果是 Standalone 模式,那么 Worker Node 就是当前的工作节点,在工作节点上会启动一个 Worker 进程,负责管理节点资源,并启动 Executor 进程。如果是 YARN 模式,那么就是 Node Manager。

每个工作节点上运行一个或多个 Executor 进程,这些 Executor 是由 Driver 动态启动的,用于执行 Task。


Executor

一个进程,用于在 Worker Node 上运行你的应用程序。它可以执行任务、将数据保存到内存或者磁盘上,每一个应用程序都有自己独立的多个 Eexecutor。也就是说,一个 Application 可以对应多个 Executor,但一个 Executor 只会对应一个 Application。

在 Standalone 模式下,工作节点有 Spark 自己的集群管理器管理。但在使用 YARN、Mesos 或 Kubernetes 等集群管理器时,工作节点的资源(如 CPU、内存)被这些外部管理器管理,而 Executor 则运行在这些资源管理器提供的容器(Container)中。


Deploy Mode

部署模式,我们说 Spark 使用 Driver 进程解析应用程序,将 Job 切分成 Stage 并调度 Task 到 Executor 执行。但 Driver 进程的运行地点有两种:客户端和集群内部,对应的部署模式为 client 和 cluster。

  • client 部署模式:Driver 运行在提交 Spark 作业的本地机器上,这意味着 Driver 直接在用户的控制下启动,在控制台中可以直接看到输出。这种模式通常用户交互式分析和调试,因为它允许用户看到作业执行的输出。但因 Driver 和集群不在同一网路环境中,可能会面临延迟。
  • cluster 部署模式:Driver 运行在集群中的某一个工作节点上,由集群管理器负责选择。这种模式适合于生产环境和长时间运行的作业,因为它不依赖于用户启动作业的机器(仅仅负责提交应用),但 Driver 的输出则需要单独收集起来。

默认情况下,Spark 的会采用 client 部署模式运行作业,允许本地机器监控作业执行情况和查看日志输出。但在生产上,我们一定要用 cluster 部署模式。


关于 Spark 的角色就说到这里,内容还是比较多的,我们再来梳理一下。

假设用户有一个应用程序,包含了执行的数据处理逻辑,现在要提交到 Cluster Manager 上运行。

由于提交是通过 Driver 实现的,因此必须要创建 Driver,如果部署模式是 client,那么 Driver 进程会在客户端启动,如果部署模式是 cluster,那么 Cluster Manager 会指定一个 Worker Node 启动 Driver。

Driver 创建之后,会启动应用程序、创建 SparkContext 对象,并与 Cluster Manager 建立通信。Driver 会向 Cluster Manager 请求资源来启动 Executor,请求的资源包括 CPU 核心数、内存大小等,可以通过 spark-submit 命令参数或者通过 SparkConf 进行设置。

Cluster Manager 收到 Driver 的资源请求后,就会根据集群的资源情况和调度策略,在满足资源请求的工作节点上分配资源来启动 Executor。一旦 Executor 进程在工作节点上被成功启动,它们就会和 Driver 建立通信,准备接收和执行任务。

Executor 启动之后,会受到 Cluster Manager 和 Driver 的监控。

随后 Driver 会解析 Spark 应用,生成相应的 Stage,并将这些 Stage 包含的任务分配给 Executor,在 Executor 内部启动线程池执行 Task。同时 Driver 会密切监控 Executor,如果发现某个 Executor 执行效率低,会将任务分配给其它的 Executor。当执行计划中所有的 Stage 都执行完毕,各个 Worker 会向 Driver 汇报,并释放资源。Driver 如果确定都做完了,会再向 Cluster Manager 汇报作业完成,随后中止 SparkContext 并退出。

怎么样,现在是不是都串起来了呢?每个角色单独介绍的话确实不好理解,但如果将整个过程串起来应该就好理解多了。另外在应用执行的时候,这些信息都可以通过 4040 端口查看。


所以我们再回顾一下 Spark 运行模式,会发现运行模式不同,本质上就是角色运行的位置不同。

  • Local:只有一个独立的进程,通过内部的多个线程模拟 Spark 的角色,一般只用于测试。
  • Standalone:Spark 中的各个角色以独立进程的形式存在,组成 Spark 环境。
  • YARN:Spark 中的各个角色运行在 YARN 的容器内部,组成 Spark 环境。当然还有 Mesos,K8S 等等,原理和 YARN 是一样的。

然后再看一下上面的架构图,从里面还可以得出一些信息。

  • 每个应用程序都有自己独立的 Executor,它在程序的整个生命周期中一直存在,并且以多线程的方式运行 Task。这就带来一个好处,每个应用程序之间是隔离的,无论是从调度方面还是从执行方面。然而这也就意味着不同的 SparkContext对象之间的数据是不能共享的,除非你把数据写到一个外部存储系统。
  • Spark 对 Cluster Manager 是不感知的,只要它能获取到 Executor 进程,这些进程之间就可以彼此进行通信,即使在支持其它应用程序的 Cluster Manager 上运行也会变得相对容易。
  • Driver 进程必须要能监听和接收来自于 Executor 的连接,并且我们看到图上的箭头是双向的,因为 Driver 是要发送代码、发送任务给 Executor,而 Executor 执行时也要向 Driver 发送心跳信息,否则挂了怎么办。因此箭头是双向的,Driver 不仅要能连接 Executor、还要能接收 Executor 的连接。
  • 因为 Driver 能够在集群之上调度 Task,所以它应该尽可能地靠近你的 Worker Node,最好是在同一片网络中,因为网络传输需要耗费时间。如果你真的要远程发送请求到集群之上,最好是给 Driver 开一个 RPC,而不是直接运行一个远离(网络意义上离的比较远)Worker Node 的 Driver。当然这种情况只会发生在 client 部署模式,因为此时 Driver 在客户端当中。

最后我们将刚才的应用程序 main.py,以 cluster 部署模式提交到 YARN 上面,并且提交的时候可以指定很多参数。

# --driver-memory <MEM>:指定 Driver 可以使用的最大内存,比如 8g
# --driver-cores <CORES>:指定 Driver 可以使用的 CPU 核心数,仅在集群模式下有效
# --executor-memory <MEM>:指定每个 Executor 可以使用的最大内存,比如 2g
# --executor-cores <CORES>:指定每个 Executor 可以使用的 CPU 核心数
# --num-executors <NUM>:指定启动多少个 Executor
# 当然还有一些更详细的配置项,可以通过 --conf "<KEY>=<VALUE>" 的方式配置

spark-submit \
  --master yarn \
  --deploy-mode cluster \
  --name "万明珠" \
  --driver-memory 2g \
  --driver-cores 4 \
  --executor-memory 1g \
  --executor-cores 1 \
  --num-executors 4 \
  main.py

我们执行一看看。

一切正常,但需要注意:在申请资源时,要保证资源充足,否则报错。另外这里将部署模式指定为 cluster,那么程序中 print 打印的输出就看不到了,需要写到日志文件中。

Spark On YARN 的本质

基于以上学习的内容,我们提出两个问题。

  • Spark On YARN 的本质是什么?
  • 为什么要选择 Spark On YARN?

先来解释第一个问题,对于 Spark On Standalone 来说,需要有多个节点,其中一个节点是 Master,剩余节点是 Worker。然后在 Master 节点上会启动一个 Master 进程,管理整个集群;在 Worker 节点上启动一个 Worker 进程,管理当前节点。Driver 和 Executor 都是单独的进程。

如果是 Spark On YARN,那么 Master 则由 Resource Manager 代替,Worker 由 Node Manager 代替。Driver 可以运行在 Container 中(cluster 部署模式)或客户端进程中(client 部署模式),Executor 则全部运行在 Container 中。


那么为什么要选择 YARN 呢?其实原因前面说过了,为了提高资源利用率,在已有 YARN 的场景下,让 Spark 受到 YARN 的调度可以更好地管理资源并提高利用率。因为生产上不一定只有 Spark 作业,还可以有 MapReduce 作业,这在大数据公司是存在的。而这两种作业都可以跑在 YARN 上,因此就没有必要搞两套集群了,让它们统一接收 YARN 的调度即可。

MapReduce 和 Spark 的区别

Spark 比 MapReduce 的效率要高很多,那么它们之间的差异主要体现在什么地方呢?

MapReduce

  • 一个 MR 程序 = 一个 Job
  • 一个 Job = N 个 Task(Map/Reduce)
  • 一个 Task 对应一个进程
  • Task 运行的时候开启进程,Task 执行完毕后销毁进程。对于多个 Task 来说,开销是比较大的,即使你能通过 JVM 共享内存

Spark

  • 一个 Spark 程序 = 一个 Application = 一个 Driver(创建 sc)+ 多个 Executor
  • 一个 Application = N 个 Job
  • 一个 Job = 1 到 N 个 Stage
  • 一个 Stage = 1 到 N 个 Task
  • 一个 Task 对应一个线程,多个 Task 可以并行地运行在 Executor 中

PySpark 解析

接下来我们说一说 PySpark,这里 PySpark 是一个 Python 类库,和前面用的 $SPARK_HOME/bin/pyspark 不是一个东西。这里先来解释一下类库和框架的区别。

  • 类库:一堆别人写好的代码,可以通过导入进行使用。比如 Pandas 就是 Python 的一个类库,当然不同语言都有自己的类库
  • 框架:可以独立运行,并提供编程结构的软件产品,像 Spark、Hadoop、Hive 就是独立的框架,它们可以单独运行并启动相应的进程

多说一句,Pandas 在处理中小型数据集的时候非常方便,但如果是大型数据集,则需要使用 Spark。并且 Pandas 和 Spark 在概念是存在交集的,我们后续会说。

所以 bin/pyspark 是一个客户端应用程序,提供交互式的 Python 客户端,用于编写 Spark 程序。而接下来要说的 PySpark 则是一个类库,我们需要再 py 代码里面导入它,使用它里面的功能。

PySpark 类库已经内置了 Spark 框架所有的 API,可以通过 PySpark 类库编写 Spark 应用程序,并提交到 Spark 集群运行。当然这里的集群可以是 Spark On Standalone 集群,也可以 Spark On YARN 集群。

然后我们来安装 PySpark,命令:pip install pyspark -i https://pypi.douban.com/simple ,因为包比较大,所以建议使用国内的源。当然前面应该都已经安装过了。

from pyspark import SparkContext, SparkConf

# setMaster 负责指定运行模式,setAppName 负责指定应用名称
# 但是我们说过,官方不推荐硬编码,而是在提交任务时指定
conf = SparkConf().setMaster("yarn").setAppName("万明珠")
sc = SparkContext(conf=conf)

"""
在此处编写相关逻辑
"""

# 好的习惯,编程结束之后 stop 掉,表示关闭与 Spark 集群连接
# 否则当你再次创建相同的 SparkContext 实例的时候就会报错
# 会提示你:Cannot run multiple SparkContexts at once; existing SparkContext(app=..., master=...)
sc.stop()

当然,集群模式和应用名称也可以直接在 SparkContext 里面指定。

from pyspark import SparkContext

sc = SparkContext(master="yarn", appName="万明珠")

我们编写一段代码:

from pyspark import SparkContext

sc = SparkContext()

# 读取文件
rdd = sc.textFile("hdfs://satori001:9000/words.txt")
# 对单词进行分割,然后扁平化
rdd = rdd.flatMap(lambda x: x.split(" "))
# 将单词包装成元组,其中 key 是单词,value 是 1
rdd = rdd.map(lambda x: (x, 1))
# 按照 key 来分组,计算所有 value 的和
rdd = rdd.reduceByKey(lambda x, y: x + y)

# 将 RDD 的数据打印输出出来
print(rdd.collect())
sc.stop()

以上我们就完成了词频统计,需要注意的是:

Driver 是 Spark 应用程序的执行入口点,它会创建 SparkContext 对象。然后具体的任务是由 Executor 执行的,但我们看到读取文件调用的是 SparkContext 对象的方法,所以 Executor 必须要能拿到 SC 对象,于是 Driver 会对 SC 对象进行序列化然后发送给各个 Executor。

那么问题来了,如果有多个 Executor,那么在读文件数据时,多个 Executor 都会全量读取吗?显然不是的,它们会各自读取一部分。然后每个 Executor 对自己读取的部分数据进行计算,当执行 reduceByKey 时(涉及 shuffle 操作),多个 Executor 会传输彼此的数据,然后进行整体汇总。当 reduceByKey 执行完毕,数据还在 Executor 中,然后执行 collect(),再将每个 Executor 的数据收集到 Driver 中。

所以整个 Spark 程序运行,可以分为以下几步:

  • Driver 创建 SparkContext 对象,序列化之后发送给 Executor。
  • Executor 在拿到 SC 对象之后,就可以调用相关 API 读取数据,并行执行计算。当涉及到 shuffle 时,会交换彼此数据。
  • 调用 collect() 时,会将 Executor 上的数据收集到 Driver 中。

整个过程从 Driver 开始,从 Driver 结束。所以我们不难发现,这和单机执行不同,虽然代码只有一份,但是执行会涉及多个 Executor(可以位于多个节点中)。Spark 会将代码进行分解,不同的部分交由不同的组件执行,而并行计算便是由 Executor 负责完成的。

Python On Spark 的执行原理

Spark 是一个典型的 JVM 框架,它底层用的是 Scala 语言,那为什么可以跑 Python 代码呢?肯定是 Spark 官方在背后做了很多工作,我们解释一下。

首先不管跑什么任务,都必须要创建 Driver 和 Executor,前者负责执行环境入口的创建以及任务管理,后者负责执行任务。无论是跑 Java、Scala 还是跑 Python,这两个角色都肯定是存在的。

图中的 JVM Driver 和 JVM Executor 通讯,Driver 要求 Executor 干活,Executor 向 Driver 汇报,大家彼此合作。这一套对于 Java 任务没有任何问题,因为 Spark 的开发语言 Scala 本身就构建在 JVM 之上,但如果 Python 要混进来,是没办法直接工作的。Python 虚拟机和 JVM 无法混在一起,不可能让 JVM 的 Driver 和 Executor 去执行 Python 代码。

因为 Spark 在诞生的时候,Driver 和 Executor 就是基于 JVM 的,为 Java 平台而设计。现在要让它跑 Python,该怎么办呢?总不能将 Spark 用 Python 再重新实现一遍吧。其实完全没有必要,在创建 Python Driver 之后,将它翻译成 JVM Driver 不就行了。Python Driver 无法指挥 JVM Executor 干活,因此它必须要翻译成 JVM Driver 才可以,而翻译的过程便是由 Py4J 完成的。

然后是 Executor,虽然 JVM Driver 可以指挥 JVM Executor 干活了,但相关的代码还是由 Python 编写的。可能有人觉得,应该还会将 Python Executor 翻译成 JVM Executor 吧。其实不是的,和 Driver 不同,Driver 的代码比较简单,可以全部翻译,但 Executor 不行。因为 Executor 相关的代码要比 Driver 复杂很多,毕竟光算子就有上百个,而且还有自己独立的算子,它们是不兼容的。

那么问题来了,Python Driver 可以翻译成 JVM Driver,但 Python Executor 却无法翻译成 JVM Executor,该怎么办呢?于是在 JVM Executor 所在的工作节点上会再启动一个 pyspark 守护进程,作为中转站,将 JVM Executor 的工作指令发送给执行的 Python 进程。

相信整个过程应该很清晰了,整个过程如下:

  • Python Driver 翻译成 JVM Driver。
  • JVM Driver 将操作指令发送给 JVM Executor。
  • JVM Executor 使用 Socket 连接到 pyspark 守护进程,将指令调度到工作的 Python 进程上。所以 Executor 在执行任务时,本质上还是 Python 进程在工作。

因此在 Driver 端,Python 代码会全部翻译好,跑的是 JVM Driver。在 Executor 端,则没有完成翻译,而是通过 pyspark 中转,将 JVM Driver 调度 JVM Executor 的指定转发给 Python 进程。

不难发现,在 Python On Spark 中,有两套语言在运行。Driver 由 JVM 执行,Executor 由 Python 虚拟机执行。

最后我们再来看看官方给的架构图和描述。

可以看到官方给的架构图要简单很多,并且描述也比较精炼,PySpark 的本质就是在 Spark 架构的外层再包装一层 API。因为 Python 很火,用 Python 开发 Spark 应用程序也是一个主流,但 Spark 官方不可能把底层源码用 Python 再实现一遍,所以选择了在 Spark 的外层再包装一层 API。这种方式不仅成本低,也比较稳定,并且基于这种方式可以支持非常多的语言。

总结一下就是:如果用 Java 或 Scala 开发应用程序(本身就是为它们设计的),那么两个 JVM 直接玩。如果带上 Python(还可以是其它语言,比如 R),那么要在 JVM 的外面再包一层,前面的 Driver 走翻译,后面的 Executor 走 Socket(中转调度)。

PySpark 的架构体系

代码在集群上运行,并且代码只有一份,但运行是分布式的。在 Spark 中,非任务处理部分由 Driver 执行(非 RDD 代码),任务处理部分由 Executor 执行(RDD 代码)。因为 Driver 只有一个,所以它是单机运行,而 Executor 可以有很多个,处理 RDD 要并行处理,所以 Executor 是分布式运行。

然后 Driver 端由 JVM 执行,Executor 端由 JVM 做命令转发,底层还是由 Python 解释器进行工作。

所以一份代码,会被拆分成不同的部分,交给不同的组件执行。

提交 Python 依赖

然后再补充一个关键的地方,我们之前提交文件的时候,提交的都是单个文件,但如果提交的文件有依赖怎么办?关于这一点前面提到过,通过 --py-files 参数指定,但是里面有一些细节需要补充。

# 文件名:data.py
DATA = ["2020-11-12", "2021-08-05", "2022-11-28"]


# 文件名:logic.py
import re

def reformat_date(s):
    return re.sub(r"(\d+)-(\d+)-(\d+)", r"\1年\2月\2日", s)


# 文件名:main.py
from pyspark import SparkContext
from data import DATA
from logic import reformat_date

sc = SparkContext()

rdd = sc.parallelize(DATA)
rdd = rdd.map(reformat_date)
print(rdd.collect())
sc.stop()

假设我们要提交 main.py,但它依赖 data.py 和 logic.py,那么在提交的时候如何指定呢?

spark-submit --master yarn --name 万明珠 --py-files data.py,logic.py main.py

通过 --py-files 指定依赖,多个文件之间用逗号分隔,注意:逗号和文件名之间不能有空格。

运行正常,通过 --py-files 指定的文件会在运行时被添加到 sys.path 中,从而让解释器在导入的时候能够找得到。当然了,由于我们是在 main.py 所在的目录中提交的,而依赖的 data.py 和 logic.py 也位于当前目录,所以此时不指定 --py-files 也是可以的。但如果当前目录找不到指定的依赖,那么就必须显式指定了。

指定依赖我们知道了,可要是依赖的文件非常多怎么办?难道要挨个指定吗?这种情况可以使用一种更简洁的做法,将依赖整体打包成一个 zip 文件或 egg 文件。

执行没有问题,这种做法在有大量依赖文件的时候会很方便。当然啦,.py 和 .zip 也是可以共存的,比如我们只将 logic.py 打包。

[root@satori001 ~]# zip logic.zip logic.py 
  adding: logic.py (deflated 14%)
[root@satori001 ~]# spark-submit --master yarn --name 万明珠 --py-files logic.zip,data.py main.py

将 logic.py 打包成 logic.zip,然后和 data.py 一起指定,此时也是没有问题的。

在程序运行的过程中,可以多看看 webUI。

共享变量

在分布式计算中,任务会被分配到集群的不同节点上执行,而每个节点上的任务可能需要访问某些公共数据。如果这些数据在被任务访问之前都需要先独立传输,那么将会导致大量的数据冗余和网络通信开销。于是 PySpark 提供了两种类型的共享变量,来对数据共享和通信进行优化:

  • 广播变量(Broadcast Variables)
  • 累加器(Accumulators)

共享变量应用于分布式计算环境,负责在多个节点之间高效共享数据,下面就来看看具体细节。

广播变量

广播允许程序将一个只读变量缓存在每个节点上,而不是在每个任务中重复发送该数据。广播变量使用的是一种高效的广播算法来分发数据,确保每个节点只接收一份数据副本,因此通过广播变量可以显著减少网络通信量和计算节点之间的数据传输,从而提高分布式计算的效率。

我们举个例子,来说明这一点。

from pprint import pprint
from pyspark import SparkContext

sc = SparkContext()
# 每个元组对应学生的 ID 和姓名
student_info = [
    (1, "古明地觉"),
    (2, "芙兰朵露"),
    (3, "琪露诺"),
]
# 每个元组对应学生的 ID、科目和成绩
student_score = [
    (1, "语文", 90), (1, "数学", 92), (1, "英语", 95),
    (2, "语文", 85), (2, "数学", 97), (2, "英语", 91),
    (3, "语文", 100), (3, "数学", 9), (3, "英语", 100),
]
# 现在如果将 student_score 里面的 ID 换成学生的姓名该怎么办呢?
rdd = sc.parallelize(student_score)
rdd = rdd.map(lambda x: (dict(student_info)[x[0]], x[1], x[2]))

pprint(rdd.collect())
sc.stop()

文件名为 main.py,我们提交一下,看看是否正常运行。

运行是没问题的,然后我们观察一下上面的代码,如果是本地运行那没什么好说的,但如果是提交到 Spark 上分布式运行就不一样了。我们说非 RDD 代码是交由 Driver 运行的,所以 student_info 是 Driver 创建的一个本地列表,构建在 Driver 内部。但问题来了,Executor 在运行 RDD 代码的时候依赖 student_info,而它内部显然是没有的,因为 student_info 在 Driver 里面,它和 Executor 不在同一个地方,该怎么办呢?

对于 Spark 而言这个问题很简单,如果 Executor 需要,那么 Driver 将对象序列化之后再通过网络发过去就行了。

需要注意:Executor 内部会启动线程池,多个 Task 可以并行运行在 Executor 中。如果分区数量大,导致 Task 非常多,那么也会分布在多个 Executor 中。我们就假设有 4 个 Task,分别位于两个 Executor 中。然后 Driver 发送 student_info,它会以 Task 为单位,所以 student_info 会被发送 4 次。

但这就有问题了,我们知道进程内的数据是可以被多个线程共享的,也就是说 Executor 进程只要收到一份数据,它内部的多个线程就都能拿到,完全没必要给每个线程都发一份。所以 student_info 只需要发送两次就可以了,而没必要发送 4 次,从而导致内存浪费以及额外的网络 IO。

怎么解决呢?答案就是广播变量,在创建的时候告诉 Spark 一声,这是一个被 Executor 内部的多个线程共享的广播变量。当 Executor 内部的线程找 Driver 要变量时,Driver 会先看自己之前有没有发给该 Executor 内部的其它线程,如果发过,那么就不发了。然后告诉该线程,自己已经发过了,你去找别的线程要吧。

就好比给班级发锦旗,这个锦旗是班级所有同学共享的。A 班的同学做了好事,那么给 A 班发个锦旗,但如果 A 班的同学又做了好事,那么锦旗就不会再发了。同样的道理,当广播变量被发送之后,就会被 Executor 缓存起来,线程用的话直接用就行,Driver 不会二次发送。

然后我们看看广播变量如何创建:

from pprint import pprint
from pyspark import SparkContext

sc = SparkContext()
# 创建广播变量
student_info = sc.broadcast([
    (1, "古明地觉"),
    (2, "芙兰朵露"),
    (3, "琪露诺"),
])

student_score = [
    (1, "语文", 90), (1, "数学", 92), (1, "英语", 95),
    (2, "语文", 85), (2, "数学", 97), (2, "英语", 91),
    (3, "语文", 100), (3, "数学", 9), (3, "英语", 100),
]
# 调用广播变量的 value 属性,可以将值取出来
rdd = sc.parallelize(student_score)
rdd = rdd.map(lambda x: (dict(student_info.value)[x[0]], x[1], x[2]))

pprint(rdd.collect())
sc.stop()

我们说过,一份代码会被拆分成不同的部分,非 RDD 部分交给 Driver 执行,RDD 部分交给 Executor 分布式执行。现在 Driver 创建了一个广播变量,它会将广播变量发给多个 Executor,但一个 Executor 则只发送一次。当线程需要使用时,直接获取广播变量的 value 属性即可。

因为普通变量不管谁来获取,Driver 都会直接发一份。但如果是广播变量,它会生成一个标记,保证同一个 Executor 只发送一次。

到这里估计有人会产生一个疑问,既然本地变量无法共享,那用 RDD 不也行吗。比如上面的 student_info,将它包装成一个 RDD,这样不就没问题了吗,不仅没有内存浪费,而且还是分布式运行的。表面上看起来是这样,但其实这么做反而会带来执行性能的降低。因为当 student_info 作为 RDD 分散在不同节点上,每个节点只存储了部分数据,为避免找不到 key,需要使用 join 算子将两个分布式的 RDD 关联起来,而这必然会带来 shuffle。

我们一直提到 shuffle,目前只需要知道它是一个比较昂贵的操作,涉及到数据的传输,一会儿单独介绍它。

我们再举个例子解释一下:

假设两个 RDD 的分区数都是三,数据分布如图所示,在对 student_score 做 map 的时候,需要将 student_info 的每个分区的数据都发送过去,这里面涉及了 9 次数据交换(网络传输)。当然我们这里只是举例,数据量还很小,如果是上 T 的数据量,那么 shuffle 的影响就会立即凸显出来。

总结:当数据量不大(比如几百条、几千条、几万条)时,建议使用广播变量。当数据量很大时,建议使用分布式 RDD。

累加器

说完了广播变量,再来看看累加器。累加器提供了一种跨任务累加数据的方式,主要用于计数或求和等操作。累加器的更新只在各节点的任务中进行,只有 Driver 可以读取累加器的值。

我们举例说明:

from pyspark import SparkContext

sc = SparkContext()
# 两个分区
rdd = sc.parallelize(range(1, 11), 2)
# 定义一个全局变量
count = 0
# 定义一个 map 函数
def map_func(x):
    global count
    count += 1
    print(f"count = {count}")
    return f"甜狗 {x} 号"

rdd = rdd.map(map_func)
print(rdd.collect())
print(f"final count = {count}")

你觉得执行这段代码会输出什么结果呢?首先说一句,这段代码是可以直接在本地执行的(python3 xxx.py),只要本地有 Java 环境。这种做法和启动 pyspark shell 的原理是类似的,一般用于学习或者做测试用,生产上我们肯定还是提交到 Spark 集群上面的。

我们发现一些有意思的地方,函数里面的 print(count) 并没有依次输出 1 ~ 10,而是将 1 ~ 5 输出了两次。因为 map_func 是在 Executor 里面执行的,而 count 变量是定义在 Driver 里面的。由于 RDD 有两个分区,那么会有两个线程在执行,Driver 会分别把 count 发给两个线程。每个线程 map 五次,最终两次打印 1 ~ 5,因为这两个 count 是独立的。

不仅如此,发送给 Executor 内部线程的 count 和 Driver 里的 count 也是独立的。不管 Executor 内部对 count 做了什么操作,都不影响外层 Driver 的 count,因此最终打印了 0。

可能有人觉得这是因为整数是不可变对象导致的,如果换成列表呢?答案是结论不变。不管是可变对象还是不可变对象,表现都是一致的。因为多个 Task 可能会位于不同的进程当中,所以 Driver 会将整个对象序列化之后发送过去,而不是简简单单增加一个引用计数。

因此当我们想累加数据的时候,普通的写法是行不通的,而是要使用累加器。

from pyspark import SparkContext

sc = SparkContext()
rdd = sc.parallelize(range(1, 11), 2)
# 这里不再使用普通变量,而是使用累加器变量
count = sc.accumulator(0)
# 定义一个 map 函数
def map_func(x):
    global count
    count += 1
    print(f"count = {count}")
    return f"甜狗 {x} 号"

rdd = rdd.map(map_func)
print(rdd.collect())
print(f"final count = {count}")

在任务里面给累加器变量加 1 的时候,Driver 里的累加器变量会同步进行修改。

这就是累加器的魔力,可以在分布式的场景下完成数据的累加。如果你想拿到累加器里面的值,那么获取它的 value 属性即可。

但还存在一个问题,为啥 Driver 里面的累加器的值是 10,而两个任务还是输出的 1 ~ 5 呢?难道整体不应该输出 1 ~ 10 吗?其实这和累加器的设计有关,累加器只能在 Executor 上执行累加操作,在 Driver 上执行读取操作。这种设计是为了优化分布式计算的性能,减少网络通信的开销。

当 Driver 将累加器传递给两个不同的任务,每个任务分别执行 5 次自增操作时,这些自增操作是并行执行的。每个任务对累加器的修改是局部的,直到任务完成后,这些修改才会被合并到 Driver 的累加器中。因此,虽然每个任务都会将累加器从 0 增加到 5,但这是在它们各自的执行上下文中发生的,而这些局部的累加值会在任务结束时合并到 Driver 的累加器中,最终得到总和为 10。

所以我们可以得出结论:累加器的更新(在 Executor 上)和读取(在 Driver 上)是分离的,任务中看到的累加器值不是全局累加器值,而是该任务在执行过程中对累加器进行操作的局部视图。每个任务的累加器操作是独立的,它们不会看到其它任务对累加器的修改。所有任务完成后,各自对累加器的修改会被汇总到 Driver 的累加器变量里。


但是在使用累加器的时候,有一个陷阱需要注意。

from pyspark import SparkContext

sc = SparkContext()
rdd1 = sc.parallelize(range(1, 11), 2)
count = sc.accumulator(0)

def map_func(x):
    global count
    count += 1

rdd2 = rdd1.map(map_func)
rdd2.collect()
print(count)  # 10
rdd3 = rdd2.map(lambda x: x)
print(count)  # 10
rdd3.collect()
print(count)  # 20

我们看到调用 rdd3.collect() 之后,count 的值变成了 20,这是怎么回事?很简单,当调用 rdd2.collect() 之后数据就已经被收集起来了,那么在 RDD 链条上,rdd2 以及它之前的 RDD 数据就不存在了,因为 RDD 是过程数据。

随后调用 rdd3.collect() 表示又要收集数据,但之前的 rdd2 数据已经不存在了。于是 Spark 会根据血缘关系,从头开始计算,rdd1 => rdd2 => rdd3,而在这个过程中,显然又执行了一次 rdd1.map(map_func),所以累加器的值变成了 20。

因此要理解 RDD 是过程数据这一概念,假设有如下 RDD。

rdd1 = sc.parallelize(...)
rdd2 = rdd1.map(...)
rdd3 = rdd2.map(...)
rdd4 = rdd3.map(...)
# 调用 rdd4.collect() 的时候,会真正开始计算
# rdd1 => rdd2 => rdd3 => rdd4
rdd4.collect()

# 但在调用 rdd4.collect() 之后,整个 RDD 链条上的数据就没了
rdd5 = rdd3.map(...)
# Spark 会找到依赖关系,得出 rdd1 => rdd2 => rdd3 => rdd5
# 于是会从 rdd1 生成 rdd2 开始再执行一遍
rdd5.collect()

所以 RDD 是过程数据,transformation 算子也只是在构建执行计划,遇见 action 算子之后才开始执行。在执行的过程中生成的 RDD 不会单独保存数据,如果要调用 action 算子收集或统计数据,那么只能根据血缘关系从头再来一遍。

from pyspark import SparkContext

sc = SparkContext()
rdd1 = sc.parallelize([1])
rdd2 = rdd1.map(lambda x: print("a"))
rdd3 = rdd2.map(lambda x: print("b"))
rdd4 = rdd3.map(lambda x: print("c"))
# rdd1 => rdd2 => rdd3 => rdd4
rdd4.collect()
"""
a
b
c
"""
# rdd1 => rdd2 => rdd3 => rdd5
rdd5 = rdd3.map(lambda x: print("d"))
rdd5.collect()
"""
a
b
d
"""

但如果不想重头再来怎么办呢?还记得 RDD 的缓存吗,我们可以 cache 或 persist 一下。比如调用 rdd3.cache(),但要在 rdd4.collect() 之前调用,那么后续在调用 rdd5.collect() 的时候,就只会打印出 d,而不是 a b d。

Spark 内核调度

接下来聊一聊关于 Spark 内核的问题,这一部分有助于你对 Spark 运行原理的理解,而且在面试中也是经常会被问到的。

下面我们来逐一介绍。

DAG

Spark 的核心是基于 RDD 实现的,而 Spark Scheduler 便是其核心实现的重要一环,作用是任务调度。Scheduler 会根据 RDD 之间的依赖关系构建 DAG,基于 DAG 划分 Stage,然后将 Stage 中的任务发送到指定节点运行。基于 Spark 的任务调度原理,可以合理规划资源利用,做到尽可能用最少的资源去高效地完成任务计算。

我们说每调用一次 transformation 算子都会生成一个新的 RDD,整个过程相当于在构建执行计划,而这个执行计划便叫做 DAG(有向无环图)。

DAG(有向无环图)从字面意思上来理解,就是有方向并且没有形成闭环的执行流程图。

以上便是一个 DAG 流程图,它标记了执行方向,并且没有形成闭环。

  • 有方向:rdd1 => rdd2 => rdd3 => rdd4 => 结束
  • 无闭环:调用 action 算子(saveAsTextFile)之后就结束了,没有闭环

DAG 的作用就是标识代码逻辑的执行流程,指示程序每一步应该做什么。另外通过 4040 端口可以进入 webUI,查看任务执行的详细信息,而页面中便包含了相应的 DAG。我们举个例子:

import time
from pyspark import SparkContext

sc = SparkContext()
rdd1 = sc.parallelize(range(10))
rdd2 = rdd1.map(lambda x: x + 1)
rdd3 = rdd2.filter(lambda x: x % 2 == 0)
rdd4 = rdd3.map(lambda x: x * 5)
rdd4.collect()
time.sleep(60 * 5)

这里需要单独说明一下,为什么程序的结尾要多一个 time.sleep。首先 SparkContext 对象在创建时,会启动一个 webUI,端口默认是 4040。如果在同一节点上创建了多个 SparkContext 对象,那么相应的 webUI 端口会加 1,比如第二个 SparkContetx 对象的 webUI 端口是 4041,第三个则是 4042,依次类推。当然同一个应用程序,应该保证只有一个 SparkContext 对象。

那么问题的关键来了,既然 SparkContext 对象在创建时会启动 webUI,那如果它被销毁了,这个 webUI 是不是就关闭了。答案确实如此,因此这里使用 time.sleep 让程序不结束,保证 SparkContext 对象在内存中存活。

显然这种做法是比较 low 的,而且使用 4040 只能查看当前执行的任务,之前已完成的任务就看不到了。因此 Spark 还支持我们配置历史服务器,所有执行完毕的任务都会被单独保存起来。这样我们就不用使用 time.sleep 了,等任务执行完毕后直接去历史服务器查看即可,至于历史服务器如何配置,我们稍后再说。

执行程序之后,访问 webUI。

以上就是 Spark 为我们提供的可视化 DAG 流程图,但我们好像并没有看到 map 和 filter 这些算子。原因很简单,因为生成 RDD 之后的 map 和 filter 操作都是窄依赖(一会详细解释),这意味着没有 shuffle 发生,所以它们没有显示。

Spark webUI 给出的 DAG 没有体现出分区,但我们要知道 DAG 中的操作都是在每个分区上并行执行的。


然后再来看看 Job 和 action 算子之间的关系,前面说了,一个应用程序可以被划分为多个 Job,那么这个划分是基于什么呢?我们知道调用 transformation 算子相当于在构建执行计划,而 action 算子则是开关,调用 action 算子时,整个 RDD 链条会开始执行,此时就会产生一个 Job。

总结:一次 action 算子调用,会产生一个 Job,而每个 Job 会对应各自的 DAG。

import time
from pyspark import SparkContext

sc = SparkContext()
rdd1 = sc.parallelize([1, 2, 3, 4, 5])

rdd2 = rdd1.map(lambda x: x + 1)
# 对应一个 Job
rdd2.collect()

rdd3 = rdd2.map(lambda x: x + 1)
# 对应一个 Job
rdd3.collect()

rdd4 = rdd3.map(lambda x: x + 1)
# 对应一个 Job
rdd4.collect()
time.sleep(60 * 5)

代码中出现了 3 个 action 算子,那么应该会划分三个 Job,我们提交到集群中,然后查看 webUI 看看是不是这样。

我们看到有三个 Job,每个 Job 由 action 算子触发,webUI 中也标识了每个 action 算子位于哪一行,点击之后便可查看对应的 DAG。所以 DAG 是绑定在 Job 上面的,它是 RDD 的逻辑执行图,为了构建物理上的 Spark 详细执行计划而生。

  • 代码提交到 Spark 集群中,我们称之为 Application
  • 一个 Application 可以有多个 Job,每个 Job 由 action 算子触发,如果程序中有 N 个 action 算子,那么运行时会产生 N 个 Job
  • 每个 Job 会包含一个 DAG,如果有 N 个 Job,那么就会有 N 个 DAG

宽依赖、窄依赖和 Stage 划分

RDD 五大特性之一便是具有依赖关系(血缘关系),但依赖关系也分两种:窄依赖和宽依赖。

  • 窄依赖:父 RDD 的一个 partition 最多被子 RDD 的一个 partition 所使用
  • 宽依赖:父 RDD 的一个 partition 会被子 RDD 的多个 partition 所使用(伴随 shuffle)

先来看看窄依赖:

像 map、filter、flatMap、union 等等,它们都是窄依赖。窄依赖的特点是父 RDD 的一个分区,最多发给子 RDD 的一个分区,所以窄依赖是可以像流水线一样,一直往下走,过程非常简单。并且窄依赖始终在内存中进行,如果是 MapReduce,那么每一步都需要先落盘。

而对于宽依赖,父 RDD 的一个分区,会发送给子 RDD 的多个分区,也就是父 RDD 的一个分区会被子 RDD 的多个分区所使用。

不难看出,如果是窄依赖,那么子 RDD 在分区数据丢失之后,直接根据父 RDD 对应的分区进行计算即可。如果是宽依赖,那么子 RDD 在分区数据丢失之后,再根据父 RDD 重新计算则是一件比较麻烦的事情,因为它来自父 RDD 的多个分区,会涉及到 shuffle 操作。那什么是 shuffle 呢?

shuffle 是 Spark 用于重新分配数据的一种机制,以便对不同 partition 里面的数据进行分组。比如 reduceByKey,它会将所有 key 相同的 value 都组合在一起,但由于 RDD 有多个分区,那么在组合的过程中肯定要发生数据交换,这会涉及到数据序列化、网络 IO 等等,因此 shuffle 是一个比较昂贵的操作。

我们最后再用一张图,来展示一下窄依赖、宽依赖、以及 shuffle 操作。

reduceByKey 在汇总数据时,会先在每个分区内部汇总,然后整体再全局汇总。

整个过程还是比较好理解的,然后再来说说 Stage 的划分。对于 Spark 来说,会根据 DAG 中的宽依赖,划分出不同的阶段(Stage)。方式是从后向前,遇到一个宽依赖便划分出一个阶段,称为 Stage。从上图中可以看到,基于宽依赖(或者说 shuffle),将 DAG(Job)划分成了两个阶段,而在每个阶段内部一定都是窄依赖。

因此一个 Stage 的边界往往是从某个地方取数据开始,到 shuffle 结束。

我们实际测试一下:

import time
from pyspark import SparkContext

sc = SparkContext()
# 补充:如果是 local 模式执行,那么读取本地文件和 HDFS 文件都行
# 但如果是提交到集群(Standalone 或 YARN),那么一定要使用 HDFS
rdd1 = sc.textFile("hdfs://satori001:9000/text.txt")
rdd2 = rdd1.flatMap(lambda x: x.split(" "))
rdd3 = rdd2.map(lambda x: (x, 1))
rdd4 = rdd3.reduceByKey(lambda x, y: x + y)
rdd4.saveAsTextFile("hdfs://satori001:9000/result")
time.sleep(60 * 5)

我们提交到集群,看一下 DAG:

因为存在一个宽依赖(shuffle),所以 Job 被划分成了两个 Stage。

内存迭代计算

在介绍内存迭代计算之前,先来补充一下前面提到的历史服务器。SparkContext 对象在创建时会启动一个 webUI,上面展示了很多有用的信息,其中包括:

  • 一系列 Stage 和 Task
  • RDD 的大小信息和内存使用情况
  • 执行环境信息
  • 运行的 Executor 信息

但当程序结束,SparkContext 对象被销毁,这个 webUI 就看不到了,所以我们需要配置历史服务器。首先在 Spark 配置文件目录中有一个 spark-defaults.conf.template,我们拷贝一份。

cp spark-defaults.conf.template spark-defaults.conf

然后打开文件、设置参数。

# 启用事件日志记录功能
spark.eventLog.enabled true
# 日志的存储路径,但该路径不会自动创建
# 如果 HDFS 上没有 spark_log,那么会报错,因此需要事先创建
spark.eventLog.dir hdfs://satori001:9000/spark_log
# 表示历史服务器从哪个目录中获取已完成任务的事件日志
spark.history.fs.logDirectory hdfs://satori001:9000/spark_log

# 历史服务器的端口号,默认是 18080
spark.history.ui.port 18080
# 设置日志清除周期
spark.history.fs.cleaner.enabled true
spark.history.fs.cleaner.interval 1d
spark.history.fs.cleaner.maxAge 7d

然后别忘记在 HDFS 上创建相应的目录:hdfs dfs -mkdir /spark_log,创建之后启动历史服务器:start-history-server.sh(位于 $SPARK_HOME 的 sbin 目录中)。

历史服务器启动之后,会监听 18080 端口,不过在查看之前,先随便跑两个任务。

上面显示日志记录在 HDFS 文件系统的 /spark_log 目录中,如果你的作业是凌晨跑的,那么第二天上班的时候也能看。我们注意到在左下角还有 show incomplete applications,显示未完成的任务,因此即便任务挂掉了也是可以看到信息的,这就很方便了。

配置历史服务器对调优也是非常有帮助的,不然作业有多少个 Stage,Stage 里面有多少个并行计算的 Task,每个 Task 计算的时候处理了多少数据、花了多长时间等等我们都不知道。而如果配置了 history server,那么这些信息就都能清晰地展示在页面上,这对调优是很有帮助的。

如果停止历史服务器,可以使用 stop-history-server.sh。


好了,回归正题,我们来说内存迭代计算。

我们一直说 Executor 内部会启动线程池并行执行,而这个并行便体现在分区上。比如 RDD 有三个分区,每个分区对应一个 Task 线程,那么在单个 Stage 中,一个分区的所有 transformation 算子都由一个线程来完成(保证每个分区的迭代计算都是基于内存)。比如:

  • Task线程 1 完成 RDD1(partition1) => RDD2(partition1) => RDD3(partition1)
  • Task线程 2 完成 RDD1(partition2) => RDD2(partition2) => RDD3(partition2)
  • Task线程 3 完成 RDD1(partition3) => RDD2(partition3) => RDD3(partition3)

假设 RDD 的分区数为 N,那么在一个 Stage 里面会有 N 条流水线,每一条都是纯内存计算。比如上图,三个 Task 线程便形成了三个并行的计算管道。但是当出现 shuffle 时,会产生数据传输,这是不可避免的。

Spark 默认受到全局并行度(一会儿说)的限制,除非个别算子有特殊分区的情况,大部分的算子都会遵循全局并行度的要求,来规划分区数。比如全局并行度是 3,那么大部分算子的分区就是 3。总之在 Spark 中我们只推荐设置全局并行度,不要在算子上再设置并行度,否则很容易产生 shuffle。而一旦产生 shuffle,那么就相当于在基于内存的计算管道上面切了一刀,导致部分计算不能走纯内存,从而影响性能。当然一些排序算子除外,计算算子让它默认选择分区数就行了。


现在如果问你为什么 Spark 比 MapReduce 快,相信你一定知道原因,可以从以下两个方面回答:

  • Spark 算子丰富,MapReduce 算子匮乏(只有 map 和 reduce),MapReduce 编程模型使得很难在一套 MR 中处理复杂的任务。很多复杂任务需要写多个 MapReduce 进行串联,多个 MR 通过磁盘交互数据。
  • Spark 可以执行内存迭代计算,算子之间形成 DAG 并划分阶段后,在每个阶段内部形成并行计算管道。但 MapReduce 的 map 和 reduce 之间的交互依旧是基于磁盘。

Spark 并行度

Spark 的并行度表示在同一时间内,最多有多少个 Task 在同时运行,比如设置并行度为 N,那么就会有 N 个 Task 在同时运行,也意味着 RDD 有 N 个分区。然后并行度可以在代码、配置文件,以及提交程序的客户端参数中设置,优先级从高到低如下:

  • 代码中
  • 客户端参数中
  • 配置文件中

我们举例说明,首先是在配置文件中设置。

# 修改 spark-defaults.conf
spark.default.parallelism 100

在客户端参数中:

spark-submit --master yarn --conf "spark.default.parallelism=100"

在代码中设置:

conf = SparkConf()
conf.set("spark.default.parallelism", 100)

当然啦,我们前面在通过 sc.parallelize() 和 sc.textFile() 创建 RDD 的时候,也可以指定并行度,或者说分区数(优先级最高)。如果没有指定,那么会读取 spark.default.parallelism 参数,因此该参数指定的并行度是全局的,也叫全局并行度。

如果都没有指定,那么 Spark 默认会设置一个合适的并行度。

Spark 推荐设置全局并行度,假设并行度为 3,那么创建的 RDD 就会被划分为 3 个分区。但是我们不要在程序运行过程中擅自修改 RDD 的分区(比如调用 repartition 算子),因为会影响内存迭代管道的构建,以及产生额外的 shuffle。


那么在集群中,应该如何规划并行度呢?结论:并行度应该设置为 CPU 总核心数的 2 ~ 10 倍,比如集群的可用 CPU 核心是 100个,那么并行度应该设置为 200 ~ 1000,下面解释一下原因。

首先 CPU 的一个核心在同一时刻只能干一件事情,所以在 100 个核心的情况下,设置 100 个并行似乎是最合适的。但这会产生一个问题,如果 Task 的压力不均衡,某个 Task 先执行完了,就会导致 CPU 核心出现空闲。所以我们增加 Task 的数量,比如 500 个并行,那么同一时间会有 100 个在运行,400 个在等待。这样可以确保某个 Task 运行完毕后,会立即有新的 Task 补上,从而最大程度利用集群的资源。

Spark 任务调度

Spark 的任务由 Driver 进行调度,流程如下:

  • 构建 Driver
  • 创建 SparkContext 对象(执行环境入口对象)
  • DAG Scheduler 在逻辑上对 Task 进行划分
  • Task Scheduler 将逻辑 Task 分配到各个 Executor 上干活,并监控它们
  • Worker(Executor)被 TaskScheduler 管理监控,听从它们的指令干活,并定期汇报进度

前 4 步都是 Driver 的工作,最后一步是 Worker 的工作。然后在里面出现了 DAG Scheduler 和 Task Scheduler,它们是做什么的呢?

  • DAG Scheduler:将逻辑的 DAG 图进行处理,得到逻辑上的 Task 划分。
  • Task Scheduler:基于 DAG Scheduler 的产出,来规划这些逻辑 Task 应该在哪些 Executor 上运行,以及对它们进行监控管理。

画张图解释一下:

图中的 DAG 基于 shuffle 被划分为两个阶段,保证每个阶段内部的操作都是窄依赖的,不会再出现 shuffle。然后 RDD 是有分区的,针对每个分区会在 Executor 的线程池中启动一个子线程,然后在同一个 Stage 内部,每个分区上的所有 transformation 算子都会由同一个线程执行,保证每个分区的迭代计算都是基于内存的,即内存迭代计算。至于不同分区,则会交给不同的线程,这些线程组成了并行计算管道,每个管道负责各自分区的数据迭代与转换。

所以从图中可以看到,在一个 Stage 内部,一个分区上的所有转换操作(内存迭代计算)便构成了一个 Task。由于图中的 RDD 是三分区,DAG 被划分为两个 Stage,因此总共会有 6 个 Task,每个 Task 由单独的线程执行。

而以上我们说的这些便是 DAG Scheduler 要做的事情,它要基于 DAG 以及宽窄依赖,计算出需要分配几个 Task,每个 Task 都负责哪些工作,以及 Task 之间如何交互。

DAG Scheduler 在划分 Task 的时候,不会考虑 Executor。换句话说,不管你启动时通过 --num-executors 参数指定多少个 Executor,对于同一份 DAG,划分的结果不变。

等到 DAG Scheduler 将 Task 划分好,接力棒便会交给 Task Scheduler,它会根据已有的 Executor 数量、可用资源、负载等,将 Task 调度到合适的 Executor 上,并对其进行监控。注意:这个调度并不是随随便便调度,我们知道当出现 shuffle 的时候,那么 Task 之间是需要数据交互的。显然对于需要数据交互的 Task,它们应该被调度到同一个 Executor 中,这样便可以走内存,而不是网络 IO。

一般来说,Executor 的数量应该和 Worker Node 的数量保持一致,也就是一个工作节点上部署一个 Executor 即可。

所以 DAG 调度器就类似于老板,负责根据计划指定任务。Task 调度器类似于总监,负责将任务分配给具体的人,并定期询问进度。

初识 Spark SQL

接下来聊一聊 Spark SQL,从名字上来看显然是让我们像写 SQL 一样去编写 Spark 应用程序。但 Saprk 并不仅仅是 SQL,SQL 只是 Spark 提供的功能之一。

想想 Hive,它们存在的意义都是类似的。如果使用 MapReduce 编程的话,需要会 Java;使用 Spark 编程的话,虽然简单,但也需要你会 Scala、Java、Python 等编程语言中的一种。而 SQL 则是真的 "老少咸宜",并且它已经成为了事实上的一个标准,如果一款框架能让你像写 SQL 一样编写程序的话,那么它一定是非常受欢迎的,就类似于 Hive 一样。

SQL on Hadoop 常用框架

目前我们已经知道为什么要有 SQL 了,而在大数据领域可以基于 SQL 的框架还有其它的,这些框架我们也称之为 SQL on Hadoop,因为数据存储在 Hadoop 的 HDFS 之上,并且支持SQL。

Apache Hive

Hive 可以将 SQL 语句翻译成 MapReduce,不过既然有了 Spark SQL,那么 Hive 是不是用的就不太多了呢?其实不是的,Hive 对于离线的数仓分析来说,用的还是挺多的,因为有一定的时间和沉淀了,非常稳定。并且 Hive 除了可以将 SQL 翻译成 MapReduce(运行在 MapReduce 引擎上),还可以翻译成 Tez、Spark,具体通过参数来设置。

那么 Hive 都有哪些功能呢?

  • 支持 SQL 编程
  • 支持多语言, Java、Python 等都可以通过 Thrift 连接到 Hive 上
  • 可以使用自定义的 UDF, 只要按照相应的标准编写, 然后打包扔到 Hive 上即可; 当然这对于非 Java 程序员来说, 还是比较困难的

Clouhera Impala

这个是 Cloudera 公司开发的,很多公司都是采购他们的 CDH。对于 impala 而言,也是使用 SQL,只不过它不是把 SQL 运行在 MapReduce 之上,而是使用了自己的守护进程。一般情况下,这些进程显然要和 DataNode 安装在同一节点上,因为要读数据。

Impala 特点如下:

  • 支持 SQL
  • 可以通过命令行、代码操作
  • 与 Hive 共享元数据信息, 能够相互操作
  • 基于内存, 性能优于 Hive, 但是吃内存

Spark SQL

Spark 的一个子模块,让 SQL 跑在 Spark 引擎上,这也是我们即将介绍的。


Presto

一个基于 SQL 的交互式查询引擎,可以和 Hive 共享元数据信息,但它主要是提供了一些连接器,通过这些链接器,可以查询 Hive、Cassandra 等框架里面的数据。


Phoenix

HBase 的数据主要基于 API 来查询,这个过程还是比较费劲的,而 Phoenix 支持使用 SQL 来查询 HBase 的数据。


Drill

支持 HDFS、Hive、Spark SQL 等多种后端存储,并进行数据处理。

Spark SQL 的误区

关于 Spark SQL,有很多人认为它就是一个单纯的 SQL 处理框架,这是一个典型的误区。Spark SQL 的官方定义是,一个用于处理海量结构化数据的 Spark 子模块,特点如下:

  • 1. 集成性,在 Spark 编程中可以对接多种复杂 SQL
  • 2. 统一的数据访问方式,支持以类似的方式访问多种数据源,而且可以进行相关操作
  • 3. 兼容 Hive,允许访问业务数仓的数据,所以如果把 Hive 作业迁移到 Spark SQL,成本会小很多
  • 4. 标准的数据连接,提供标准的 JDBC/ODBC 连接方式

Spark SQL 的应用不局限于 SQL,还支持 Hive、JSON、Parquet 文件的直接读取以及操作,SQL 仅仅是 Spark SQL 的功能之一而已。

Spark SQL 和 Hive 的差异

Hive 和 Spark SQL 都属于分布式 SQL 计算引擎,是构建大规模结构化数据计算的绝佳利器,那么这两者的差异体现在哪呢?

Spark SQL 的使用率一直处在领先的位置,但因为历史原因,Hive 仍占有一席之地。

Spark SQL 的数据抽象

我们之前处理数据是通过 RDD 实现的,而数据抽象除了 RDD 还有 DataFrame,当执行 Spark SQL 时就会返回一个 DataFrame。

Spark SQL 执行时返回的 DataFrame 和 Pandas 的 DataFrame 在结构上是一致的,区别就是 Spark DataFrame 是分布式的,而 Pandas DataFrame 是单机的。当然,Spark 当中其实有三种数据抽象:

  • RDD:Spark 最核心的数据抽象
  • DataFrame:可用于 Java、Scala、Python、R 等语言
  • DataSet:因为 Java、Scala 等 JVM 语言带有泛型,为了增强表达能力引入了 DataSet

DataFrame 和 DataSet,最终还是要被翻译成 RDD,所以 RDD 是 Spark 的核心。实际上,RDD 支持的操作已经不少了,但生产中我们还是很少直接使用 RDD 进行编程,而是使用 DataFrame 和 DataSet。

因此用 Python 开发 Spark 程序,我们会用到两种结构,分别是 RDD 和 DataFrame。这两种结构的底层逻辑是相似的,因为 DataFrame 本身就基于 RDD 实现,只是 DataFrame 更专注于二维结构化数据。

所以如果你的数据是二维表结构,那么推荐使用 DataFrame,当然 RDD 也是可以的,只是肯定没有使用 DataFrame 方便。那么问题来了,对于同一份数据,使用 RDD 存储和使用 DataFrame 存储有什么差异呢?

DataFrame 只能按照二维表的格式存储数据,而 RDD 则是存储对象本身(格式可以有多种,一般是列表)。

那么问题来了,DataFrame 和 RDD,哪一种结构更适合 SQL 呢?其实答案前面已经给出了,因为 Spark SQL 执行之后返回的是 DataFrame,那么肯定是 DataFrame 更适合 SQL。至于原因也很简单,SQL 操作的数据是二维表结构,和 DataFrame 是一致的,毕竟 SQL 的含义就是结构化查询语言。而 RDD 存储的是对象,除了列表,还可以是字符串,甚至是自定义类的实例,这些 SQL 是无法处理的。

SparkSession 对象

在介绍 RDD 时,我们说程序的入口对象是 SparkContext,但 Spark 在 2.0 版本推出了 SparkSession,作为 Spark 编码的统一入口对象。基于 SparkSession,可以做到以下两点:

  • 作为 Spark SQL 编程的入口对象
  • 获取 SparkContext 对象,用于 Spark Core 编程

所以后续代码的执行环境入口对象,统一使用 SparkSession。

from pyspark.sql import SparkSession

session = SparkSession.builder.\
    master("yarn").\
    appName("万明珠").\
    config("spark.sql.shuffle.partitions", 4)\
    .getOrCreate()

以上我们就创建了 SparkSession 对象,其中 master 方法用于指定运行模式,appName 方法用于指定应用程序名称,它们都调用了 config 方法。

事实上我们提交任务时也是如此:

# 以下两种方式是等价的
spark-submit --master yarn --name 万明珠 main.py
spark-submit --conf spark.master=yarn --conf spark.app.name=万明珠 main.py

所有的配置都通过 --conf 指定,但为了简便,Spark 也提供了单独的参数,比如 --master 和 --name。关于提交任务这里多补充了一下,并且记得之前说过,运行模式和应用名称不建议在代码中硬编码。

from pyspark.sql import SparkSession

# 所以直接调用 getOrCreate 创建即可
session = SparkSession.builder.getOrCreate()
print(session)
"""
<pyspark.sql.session.SparkSession object at 0x7fae68e57880>
"""

另外,在 pyspark shell 中,默认有一个 SparkContext 对象叫 sc,那么有没有默认的 SparkSession 对象呢?

显然是有的,名字叫 spark,并且通过 sparkContext 属性可以拿到 SparkContext 对象。然后 SparkSession 作为 Spark SQL 编程的入口对象,显然 SQL 语句就是由它来执行的,但是 SparkSession 对象不仅可以执行 SQL,还可以读取各类文件,返回 DataFrame。

所以 Spark SQL 不仅仅是 SQL,官方将它定义为 Spark 的一个子模块,除了 SQL 还可以处理各种文件。

读取数据源,创建 DataFrame

下面就来用 SparkSession 对象读取数据源,创建 DataFrame。关于数据源,支持的种类非常多,比如本地、HDFS、亚马逊 S3、阿里 OSS、腾讯 COS、RDBMS 等,而数据的载体可以是文本、JSON、Parquet、JDBC 等。当数据被读取进来之后,会得到 DataFrame,然后对它进行操作即可。

我们举例说明,目前在 HDFS 上有一个文本文件 data.csv,内容如下:

17,female,古明地觉
400,female,四方茉莉
18,female,椎名真白

我们将它读取出来,得到 DataFrame。

from pyspark.sql import SparkSession

session = SparkSession.builder.getOrCreate()
df = session.read.csv(
    "hdfs://satori001:9000//data.csv",
    sep=",",  # 单元格分隔符
    lineSep="\n",  # 行分隔符
    header=False,  # CSV 是否存在表头
)
# 然后给 DataFrame 指定一个列名
df = df.toDF("年龄", "性别", "姓名")
# 打印 df 的结构
df.printSchema()
# 打印 df 的内容
df.show()

提交到 YARN 上运行。

结果没有问题,并且 DataFrame 还可以注册成一个临时的视图,从而支持 SQL。

为了方便查看结果,我们使用 pyspark shell。

>>> df = spark.read.csv("hdfs://satori001:9000//data.csv", sep=",", header=False)
>>> df = df.toDF("年龄", "性别", "姓名")
# 将结果集注册成一个临时视图
>>> df.createTempView("girls")
# 通过 SQL 进行查询
>>> spark.sql("SELECT * FROM girls WHERE `年龄` >= 18").show()
+----+------+--------+                                                          
|年龄 | 性别 |  姓名   |
+----+------+--------+
| 400|female|四方茉莉 |
|  18|female|椎名真白 |
+----+------+--------+

是不是很方便呢?如果你熟悉 SQL 的话,那么将结果集注册成一个临时视图,然后编写 SQL 查询即可。当然如果不喜欢 SQL 风格,那么还有 DSL 风格,直接对 DataFrame 本身做操作。

非常方便,至于选择哪种方式看你自身喜好。然后关于 DataFrame 更详细的操作,一会儿介绍 DataFrame 的时候再说,目前我们只需要知道 SparkSession 对象可以读取很多的数据源,这些数据在读取进来之后会得到 DataFrame。

DataFrame 解析

数据处理分为三步:读取数据,处理数据,导出数据,而对于 Spark SQL 来说,也是如此。

  • SparkSession 读取数据,得到 DataFrame。
  • 基于 DataFrame 提供的 API,对数据做处理。
  • 将处理后的 DataFrame 导出到指定位置。

下面我们就围绕着这三步,来好好地聊一聊。

DataFrame 的组成

DataFrame 是一个二维表结构,任何一个二维表都有绕不开的三个属性:行、列、表结构描述。正如 MySQL 的表一样,表由许多行组成、表具有多个列、表也有相应的结构信息(列 、列名、类型、约束等)。基于这个前提,DataFrame 的组成如下:

在结构层面

  • StructType 对象描述整个 DataFrame 的表结构
  • StructField 对象描述一个列的信息

在数据层面

  • Row 对象记录一行数据
  • Column 对象记录一列数据,同时也包含列的信息

在表结构层面,DataFrame 由 StructType 描述:

from pyspark.sql.types import StructType, StructField, IntegerType, StringType

struct_type = StructType(
    [
        # 可以传递三个参数:列名、列的类型,是否允许为空(默认允许)
        StructField("id", IntegerType(), False),
        StructField("name", StringType(), True),
        StructField("age", IntegerType(), True),
    ]
)
# 也可以单独添加,由于 add 方法会返回 self,因此可以链式调用
# 比如 struct_type = StructType().add(...).add(...)
struct_type.add("address", StringType())
# 查看已有字段
print(struct_type.names)
"""
['id', 'name', 'age', 'address']
"""

一个 StructField 负责描述一个列,描述信息包含:列名、列的类型、列是否允许为空,由于二维表可以有多个列,那么就会有多个 StructField,而多个 StructField 便组成了 StructType。然后一行数据在代码中被表示为一个 Row 对象,一列数据被表示为一个 Column 对象(同时也包含列的信息)。

创建 DataFrame

说完了 DataFrame 的结构,我们来创建 DataFrame,它的创建方式还是比较多的。除了用 SparkSession 读取数据源的方式创建之外,还有其它方式,我们逐一介绍。

基于 RDD 创建

DataFrame 对象可以从 RDD 转化而来,因为都是分布式数据集,只需要将结构转换一下即可。

RDD 的每个元素都是一个列表,对应 DataFrame 的一行,然后手动指定列的名称,至于类型会基于 RDD 进行推断,这里显然都是字符串。事实上,DataFrame 也可以直接基于列表来创建,和 Pandas 是类似的。

from pyspark.sql import SparkSession
import pandas as pd

data = [
    [1, 2, 3], [4, 5, 6], [7, 8, 9]
]

session = SparkSession.builder.getOrCreate()
df1 = session.createDataFrame(data, schema=["a", "b", "c"])
df2 = pd.DataFrame(data, columns=["a", "b", "c"])
df1.show()
"""
+---+---+---+                                                                   
|  a|  b|  c|
+---+---+---+
|  1|  2|  3|
|  4|  5|  6|
|  7|  8|  9|
+---+---+---+
"""
print(df2)
"""
   a  b  c
0  1  2  3
1  4  5  6
2  7  8  9
"""

然后注意里面的 schema 参数,除了传一个列表,还可以传 StructType 对象。

from pyspark.sql import SparkSession
from pyspark.sql.types import StructType, StructField, IntegerType, StringType

session = SparkSession.builder.getOrCreate()
sc = session.sparkContext
rdd = sc.textFile("hdfs://satori001:9000//data.csv")
rdd = rdd.map(lambda x: x.split(","))
struct_type = StructType(
    [
        StructField("年龄", StringType()),
        StructField("性别", StringType()),
        StructField("姓名", StringType()),
    ]
)
df = session.createDataFrame(rdd, schema=struct_type)
df.show()

这样也是可以的,但是要保证手动指定的类型和 RDD 内部数据的类型是一致的。比如年龄,RDD 内部是以字符串的形式存储的,所以这里也必须要指定为 StringType。

最后 RDD 还有一个 toDF 方法,用于生成 DataFrame。

from pyspark.sql import SparkSession

session = SparkSession.builder.getOrCreate()
sc = session.sparkContext
rdd = sc.textFile("hdfs://satori001:9000//data.csv")
rdd = rdd.map(lambda x: x.split(","))
# 也可以传递 StructType 对象,但要保证类型是一致的
df = rdd.toDF(["年龄", "性别", "姓名"])
df.show()
"""
+----+------+--------+
|年龄 | 性别 |   姓名  |
+----+------+--------+
|  17|female| 古明地觉|
| 400|female| 四方茉莉|
|  18|female| 椎名真白|
+----+------+--------+
"""

以上就是基于 RDD 创建 DataFrame 的几种方式。

基于 Pandas DataFrame 创建

我们还可以将 Pandas 的 DataFrame 转成 Spark 的 DataFrame。

from pyspark.sql import SparkSession
import pandas as pd

pandas_df = pd.DataFrame(
    {
        "name": ["satori", "koishi", "marisa"],
        "age": [17, 16, 18]
    }
)
session = SparkSession.builder.getOrCreate()
spark_df = session.createDataFrame(pandas_df)
spark_df.printSchema()
"""
root
 |-- name: string (nullable = true)
 |-- age: long (nullable = true)
"""
spark_df.show()
"""
+------+---+
|  name|age|
+------+---+
|satori| 17|
|koishi| 16|
|marisa| 18|
+------+---+
"""

比较简单,也是通过 createDataFrame 方法。

以 text 模式读取文件

SparkSession 还可以读取数据源,支持不同模式,比如 text。

from pyspark.sql import SparkSession

session = SparkSession.builder.getOrCreate()
# .txt 和 .csv 本质上都是纯文本
# 如果以 text 模式读取,那么得到的 DataFrame 只会有一列
df = session.read.text("hdfs://satori001:9000//data.csv")
df = df.toDF("data")
df.show()
"""
+-------------------+
|               data|
+-------------------+
| 17,female,古明地觉 |
|400,female,四方茉莉 |
| 18,female,椎名真白 |
+-------------------+
"""

因此我们很少会用 text 模式读取。

以 csv 模式读取文件

再来看看 csv 模式。

from pyspark.sql import SparkSession

session = SparkSession.builder.getOrCreate()
df = session.read.csv(
    "hdfs://satori001:9000//data.csv",
    sep=",",  # 单元格分隔符
    lineSep="\n",  # 行分隔符
    header=False,  # 没有表头
)
df.show()
"""
+---+------+--------+
|_c0|   _c1|     _c2|
+---+------+--------+
| 17|female|古明地觉 |
|400|female|四方茉莉 |
| 18|female|椎名真白 |
+---+------+--------+
"""

header 参数默认为 None,如果不指定或者指定为 False,那么会自动生成表头,然后可以调用 toDF 方法将表头替换掉。如果 header 参数指定为 True,那么会将第一行作为表头。当然 csv 方法还有很多其它参数,可以点进源码中查看。

以 json 模式读取文件

再来看看 json 模式,在 HDFS 上有一个 data.json,内容如下。

{"name":"satori","age": 17}
{"name":"koishi","age": 16}
{"name":"marisa","age": 18}

然后我们以 json 模式读取它。

from pyspark.sql import SparkSession

session = SparkSession.builder.getOrCreate()
df = session.read.json("hdfs://satori001:9000//data.json")
df.show()
"""
+---+------+
|age|  name|
+---+------+
| 17|satori|
| 16|koishi|
| 18|marisa|
+---+------+
"""

以上就是 json 模式。

关于读取文件就说到这里,当然还有很多其它方法,可以通过源码查看。

# 读取文件的相关操作,都是 DataFrameReader 对象的一个方法
from pyspark.sql.readwriter import DataFrameReader

比如读取 Parquet 文件就是 SparkSession().read.parquet()。

DataFrame 的 API

前面说了,DataFrame 支持两种风格进行编程,分别是 SQL 风格和 DSL 风格。

  • SQL 风格:使用 SQL 语句处理 DataFrame 数据。
  • DSL 风格:DSL 指的是领域特定语言,在这里就是 DataFrame 特有的 API。

再来回顾一下使用 SQL 编程。

from pyspark.sql import SparkSession

session = SparkSession.builder.getOrCreate()
df = session.createDataFrame(
    [["satori", 17], ["koishi", 16], ["marisa", 19], ["scarlet", 400]],
    schema=["name", "age"]
)
# 将 df 注册成一个临时视图
# 注意:createTempView 方法要求视图不能存在,否则报错
# 如果你希望视图不存在则创建,存在则替换,那么可以使用 createOrReplaceTempView
df.createTempView("girl")
# 除了临时视图之外,还可以注册成全局视图,通过 createGlobalTempView 和 createOrReplaceGlobalTempView 方法
# 全局视图可以跨 SparkSession 存在,而临时视图只能作用于当前的 SparkSession,不过一般情况下我们只会创建一个 SparkSession

# 使用 SQL 编程
session.sql("SELECT UPPER(name) AS upper_name FROM girl WHERE age > 18").show()
"""
+----------+
|upper_name|
+----------+
|    MARISA|
|   SCARLET|
+----------+
"""

如果你很熟悉 SQL,那么基于 SQL 编程是完全没问题的,即使是像窗口函数这种复杂的语法也是完全支持的。如果不喜欢 SQL,也可以通过 DSL 编程。

from pyspark.sql import SparkSession
from pyspark.sql.functions import upper

session = SparkSession.builder.getOrCreate()
df = session.createDataFrame(
    [["satori", 17], ["koishi", 16], ["marisa", 19], ["scarlet", 400]],
    schema=["name", "age"]
)
# df[df["age"] > 18] 等价于 df.where(df["age"] > 18) 或者 df.filter(df["age"] > 18)
df[df["age"] > 18].select(upper(df["name"]).alias("upper_name")).show()
"""
+----------+
|upper_name|
+----------+
|    MARISA|
|   SCARLET|
+----------+
"""
# 选择 1 条
df.where(df["age"] > 18).limit(1).show()
"""
+------+---+
|  name|age|
+------+---+
|marisa| 19|
+------+---+
"""

还是很简单的,整体操作和 SQL 比较相似。

df.where(...).select(...).groupby(...).sort(...).limit(...).offset(...)

这些方法可以链式调用,每一步都会返回一个 DataFrame。然后 pyspark.sql.functions 里面还提供了大量的函数,支持我们对 DataFrame 的一整列进行操作,并返回 Column 对象。总之如果你熟悉 Pandas 的话,那么用 PySpark DataFrame 会非常简单,因此关于它的 API 就不赘述了,可以参考官网或者询问 ChatGPT。

这里我们举几个复杂的例子(工作中也会经常遇到),来验证一下 PySpark DataFrame 是不是和 Pandas DataFrame 一样强大。

对比两列,创建新列

假设有如下 DataFrame,我们希望创建一个 C 列,它的值来自于 A 列,如果 A 列不为空,否则来自于 B 列。

   A  B
 001  1
None  2
 003  3
None  4
 005  5

那么应如何处理呢?

from pyspark.sql import SparkSession
from pyspark.sql.functions import when

session = SparkSession.builder.getOrCreate()
df = session.createDataFrame(
    [['001', '1'], [None, '2'], ['003', '3'], [None, '4'], ['005', '5']],
    schema=["A", "B"]
)
df.show()
"""
+----+---+
|   A|  B|
+----+---+
| 001|  1|
|null|  2|
| 003|  3|
|null|  4|
| 005|  5|
+----+---+
"""

# 可以通过 withColumn 方法创建一个新列,并指定列名和列值
# 注意:PySpark 的 DataFrame 不支持本地修改,因为它是基于 RDD 实现的,而 RDD 是不可变的
# 所以 df.withColumn 方法会返回一个新的 DataFrame,并在原有的 DataFrame 的基础上增加一列
df = df.withColumn(
    "C",
    # 当 A 列不为空时,使用 A 列的值,否则使用 B 列的值
    when(df["A"].isNotNull(), df["A"]).otherwise(df["B"])
)
df.show()
"""
+----+---+---+
|   A|  B|  C|
+----+---+---+
| 001|  1|001|
|null|  2|  2|
| 003|  3|003|
|null|  4|  4|
| 005|  5|005|
+----+---+---+
"""

还是很简单的,要是我们希望 C 列的值来自于 A 列和 B 列中较大的那一个,该怎么做呢?

from pyspark.sql import SparkSession
from pyspark.sql.functions import greatest

session = SparkSession.builder.getOrCreate()
df = session.createDataFrame(
    [[1, 11], [22, 2], [33, 3], [4, 44], [55, 5]],
    schema=["A", "B"]
)
df.show()
"""
+---+---+
|  A|  B|
+---+---+
|  1| 11|
| 22|  2|
| 33|  3|
|  4| 44|
| 55|  5|
+---+---+
"""

df = df.withColumn(
    "C", greatest(df["A"], df["B"])
)
df.show()
"""
+---+---+---+
|  A|  B|  C|
+---+---+---+
|  1| 11| 11|
| 22|  2| 22|
| 33|  3| 33|
|  4| 44| 44|
| 55|  5| 55|
+---+---+---+
"""

虽然和 Pandas DataFrame 的操作有些不一样,但不难理解,多用用就熟悉了。

行转列

假设有如下 DataFrame:

+-------+-------+-----+
|   name|subject|score|
+-------+-------+-----+
| satori|chinese|   90|
| satori|   math|   95|
| satori|english|   96|
|scarlet|chinese|   87|
|scarlet|   math|   92|
|scarlet|english|   98|
|  cirno|chinese|  100|
|  cirno|   math|    9|
|  cirno|english|   91|
+-------+-------+-----+

我希望变成如下结构,要怎么做呢?

+-------+-------+-------+----+
|   name|chinese|english|math|
+-------+-------+-------+----+
| satori|     90|     96|  95|
|scarlet|     87|     98|  92|
|  cirno|    100|     91|   9|
+-------+-------+-------+----+

这是一个典型的列转行,下面看看如何实现。

from pyspark.sql import SparkSession

session = SparkSession.builder.getOrCreate()
df = session.createDataFrame(
    [
        ["satori", "chinese", 90],
        ["satori", "math", 95],
        ["satori", "english", 96],
        ["scarlet", "chinese", 87],
        ["scarlet", "math", 92],
        ["scarlet", "english", 98],
        ["cirno", "chinese", 100],
        ["cirno", "math", 9],
        ["cirno", "english", 91]
    ],
    schema=["name", "subject", "score"]
)
df.show()
"""
+-------+-------+-----+
|   name|subject|score|
+-------+-------+-----+
| satori|chinese|   90|
| satori|   math|   95|
| satori|english|   96|
|scarlet|chinese|   87|
|scarlet|   math|   92|
|scarlet|english|   98|
|  cirno|chinese|  100|
|  cirno|   math|    9|
|  cirno|english|   91|
+-------+-------+-----+
"""

df = df.groupBy("name").pivot("subject").agg({"score": "max"})
df.show()
"""
+-------+-------+-------+----+
|   name|chinese|english|math|
+-------+-------+-------+----+
| satori|     90|     96|  95|
|scarlet|     87|     98|  92|
|  cirno|    100|     91|   9|
+-------+-------+-------+----+
"""

非常简单,一行就搞定了。

一行生成多行

假设有如下数据:

           姓名        生日                           声优
0    琪亚娜·卡斯兰娜   12月7日                      陶典,钉宫理惠
1   布洛妮娅·扎伊切克  8月18日            TetraCalyx,Hanser,阿澄佳奈,花泽香菜
2  德丽莎·阿波卡利斯   3月28日                     花玲,田村由香里

我希望变成如下格式:

           姓名     生日              声优
0    琪亚娜·卡斯兰娜  12月7日          陶典
1    琪亚娜·卡斯兰娜  12月7日        钉宫理惠
2   布洛妮娅·扎伊切克  8月18日      TetraCalyx
3   布洛妮娅·扎伊切克  8月18日        Hanser
4   布洛妮娅·扎伊切克  8月18日        阿澄佳奈
5   布洛妮娅·扎伊切克  8月18日        花泽香菜
6  德丽莎·阿波卡利斯   3月28日          花玲
7  德丽莎·阿波卡利斯   3月28日       田村由香里

该怎么做呢?

from pyspark.sql import SparkSession
from pyspark.sql.functions import array, explode, split

session = SparkSession.builder.getOrCreate()

df = session.createDataFrame(
    [
        ["琪亚娜·卡斯兰娜", "12月7日", "陶典,钉宫理惠"],
        ["布洛妮娅·扎伊切克", "8月18日", "TetraCalyx,Hanser,阿澄佳奈,花泽香菜"],
        ["德丽莎·阿波卡利斯", "3月28日", "花玲,田村由香里"],
    ], schema=["姓名", "生日", "声优"]
)
# 在原有的 DataFrame 的基础上增加一列,由于 "声优" 这一列已经存在,所以相当于替换
df = df.withColumn("声优", split(df["声优"], ","))
# 当字符串过长时会发生阶段,这里将 truncate 指定为 False,让其不截断显示
df.show(truncate=False)
"""
+-----------------+-------+----------------------------------------+
|姓名             |  生日   |  声优                                  |
+-----------------+-------+----------------------------------------+
|琪亚娜·卡斯兰娜   | 12月7日 |  [陶典, 钉宫理惠]                        |
|布洛妮娅·扎伊切克 | 8月18日 |  [TetraCalyx, Hanser, 阿澄佳奈, 花泽香菜] |
|德丽莎·阿波卡利斯 | 3月28日 |  [花玲, 田村由香里]                       |
+-----------------+-------+----------------------------------------+
"""
# 此时 "声优" 这一列存储的就是列表,我们将它炸开
df = df.withColumn("声优", explode(df["声优"]))
df.show()
"""
+-----------------+-------+-----------+
|             姓名 |   生日|      声优  |
+-----------------+-------+-----------+
|  琪亚娜·卡斯兰娜  |12月7日|      陶典   |
|  琪亚娜·卡斯兰娜  |12月7日|  钉宫理惠   |
|布洛妮娅·扎伊切克  |8月18日|TetraCalyx  |
|布洛妮娅·扎伊切克  |8月18日|    Hanser  |
|布洛妮娅·扎伊切克  |8月18日|  阿澄佳奈   |
|布洛妮娅·扎伊切克  |8月18日|  花泽香菜   |
|德丽莎·阿波卡利斯  |3月28日|      花玲  |
|德丽莎·阿波卡利斯  |3月28日|田村由香里   |
+-----------------+-------+----------+
"""

非常强大,丝滑程度丝毫不弱于 Pandas。

列转行

列转行可以简单地认为是将数据库中的宽表变成一张高表,而之前介绍的行转列则是把一张高表变成一张宽表。假设有如下数据:

   姓名      水果    星期一    星期二   星期三    
 古明地觉     草莓    70斤     72斤     60斤  
雾雨魔理沙    樱桃    61斤     60斤     81斤   
  琪露诺     西瓜    103斤    116斤    153斤 

我们希望变成下面这种形式:

     姓名     水果       日期     销量
   古明地觉    草莓      星期一    70斤
  雾雨魔理沙   樱桃      星期一    61斤
    琪露诺     西瓜      星期一    103斤
   古明地觉    草莓      星期二    72斤
  雾雨魔理沙   樱桃      星期二    60斤
    琪露诺    西瓜       星期二   116斤
   古明地觉    草莓      星期三    60斤
  雾雨魔理沙   樱桃      星期三    81斤
    琪露诺     西瓜      星期三   153斤

当然我们这里字段比较少,如果把星期一到星期日全部都写上去有点太长了。不过从这里也能看出前者对应的是一张宽表,就是字段非常多,我们要将其转换成一张高表。

from pyspark.sql import SparkSession
from pyspark.sql.functions import array, explode, map_from_arrays, lit

session = SparkSession.builder.getOrCreate()

data = [
    ["古明地觉", "草莓", "70斤", "72斤", "60斤"],
    ["雾雨魔理沙", "樱桃", "61斤", "60斤", "81斤"],
    ["琪露诺", "西瓜", "103斤", "116斤", "153斤"]
]
df = session.createDataFrame(data, ["姓名", "水果", "星期一", "星期二", "星期三"])
# array 函数类似于 Python 的 zip
df.select(array(df["星期一"], df["星期二"], df["星期三"]).alias("销量")).show()
"""
+---------------------+
|                 销量 |
+---------------------+
|   [70斤, 72斤, 60斤] |
|   [61斤, 60斤, 81斤] |
|[103斤, 116斤, 153斤] |
+---------------------+
"""
# 但这么做只能得到销量,无法得到日期,因此 explode 还支持对 map 进行炸裂
# 通过 map_from_arrays 将两个数组结合成一个 map,炸裂之后会得到两列,我们起名为 "日期" 和 "销量"
df = df.select(
    df["姓名"], df["水果"],
    explode(
        map_from_arrays(
            array(lit("星期一"), lit("星期二"), lit("星期三")),
            array(df["星期一"], df["星期二"], df["星期三"])
        )
    ).alias("日期", "销量")
)
df.show()
"""
+----------+----+------+-----+
|      姓名 |水果|  日期| 销量  |
+----------+----+------+-----+
|  古明地觉 |草莓 |星期一 | 70斤|
|  古明地觉 |草莓 |星期二 | 72斤|
|  古明地觉 |草莓 |星期三 | 60斤|
|雾雨魔理沙 |樱桃 |星期一 | 61斤|
|雾雨魔理沙 |樱桃 |星期二 | 60斤|
|雾雨魔理沙 |樱桃 |星期三 | 81斤|
|    琪露诺|西瓜 |星期一 |103斤|
|    琪露诺|西瓜 |星期二 |116斤|
|    琪露诺|西瓜 |星期三 |153斤|
+----------+----+------+-----+
"""

虽然顺序不一样,但结果和我们想要的是一致的。

通过这几个案例,不难发现,PySpark DataFrame 的 API 是非常强大的,可以实现你的任何需求。关于 DataFrame 的更多 API 可以查看官网,或者在遇到问题的时候询问 ChatGPT,通过解决实际的问题来学习相关 API,我们这里就不赘述了。

SparkSQL Shuffle

DataFrame 基于 RDD,所以 DataFrame 也会产生 shuffle,默认的分区数(spark.sql.shuffle.partitions)为 200。这个在项目中要合理的设置,一般是集群的 CPU 总核心数的 2 到 4 倍。设置方式有三种:

  • 修改配置文件 spark-defaults.conf:spark.sql.shuffle.partitions 100
  • 提交应用时指定:spark-submit --conf "spark.sql.shuffle.partitions=100"
  • 在代码中设置:SparkSession.builder.config("spark.sql.shuffle.partitions", 100).getOrCreate()

导出 DataFrame

最后来看一下 DataFrame 的导出,将 DataFrame 处理完毕后,我们肯定要导出到某个具体的位置。

from pyspark.sql.readwriter import DataFrameReader, DataFrameWriter

# 读取数据源,可以使用 session.read.xxx
session.read.xxx
# 导出 DataFrame,则是 df.write.xxx
df.write.xxx

session.read 返回的是 DataFrameReader 对象,df.write 返回的是 DataFrameWriter 对象,具体想导出到哪个位置,直接调用相应的方法即可。我们以读取和写入数据库为例,演示一下。

session.read.jdbc()
df.write.jdbc()

操作数据库调用的是 jdbc 方法,走的是 jdbc 协议,因此必须要有相应的驱动。这里我们操作 MySQL,所以要下载 MySQL 的驱动,直接去菜鸟教程下载即可。下载完之后丢到 $SPARK_HOME/jars 目录,该目录包含了大量程序依赖的 jar 包。

from pyspark.sql import SparkSession

session = SparkSession.builder.getOrCreate()
df = session.read.jdbc(
    url="jdbc:mysql://username:password@host:3306/database",
    table="user"
)
df.show()

我们测试一下,看看有没有问题。

读取正常,再来看看导出。

df.write.jdbc(
    url="jdbc:mysql://username:password@host:3306/database",
    table="user",
    mode="append"
)

这里导出也是没有问题的,需要注意里面的参数 mode,它有如下选项。

  • "append":如果表存在,则追加数据
  • "overwrite":如果表存在,则覆盖数据
  • "ignore":如果表存在,则什么也不做
  • "error":如果表存在,则抛出异常,默认选项

如果表不存在,那么会自动创建表并写入数据。

自定义函数

无论是 Hive 还是 Spark SQL,在分析处理数据时,都需要使用函数。Spark SQL 已经自带了非常多的函数,在 pyspark.sql.functions 中,这些函数基本能满足绝大部分工作要求。但如果你的场景比较特殊,找不到满足条件的函数,那么 Spark 也支持自定义。

首先在 Hive 中,自定义函数有以下三种类型:

  • UDF:一对一的关系,输入一个值,返回一个值
  • UDAF:多对一的关系,输入多个值,返回一个值
  • UDTF:一对多的关系,输入一个值,返回多个值,类似于 flatMap,一行变多行

而 Spark SQL 目前仅仅支持 UDF 和 UDAF,其中 Python 只支持 UDF,下面我们来看看如何自定义 UDF。

from pyspark.sql import SparkSession
from pyspark.sql.types import IntegerType

session = SparkSession.builder.getOrCreate()

df = session.createDataFrame(
    [[17], [18], [19]],
    schema=["age"]
)
df.show()
"""
+---+
|age|
+---+
| 17|
| 18|
| 19|
+---+
"""
df.createTempView("tmp_table")

def incr_10_func(x):
    return x + 10

# 注册函数
# 参数一:UDF 的名称,可用于 SQL 风格
# 参数二:处理函数
# 参数三:处理函数的返回值类型,要和实际的函数保持一致
# 返回一个 UDF 对象,可用于 DSL 风格
incr_10_dsl = session.udf.register("incr_10_sql", incr_10_func, IntegerType())

# 执行一下
session.sql("SELECT incr_10_sql(age) AS age FROM tmp_table").show()
"""
+---+
|age|
+---+
| 27|
| 28|
| 29|
+---+
"""
df.select(incr_10_dsl(df["age"]).alias("age")).show()
"""
+---+
|age|
+---+
| 27|
| 28|
| 29|
+---+
"""
# 多说一句,DataFrame 除了 select 之外,还可以使用 selectExpr,来选择指定的列
# selectExpr 接收 SQL 风格的字符串,所以 SQL 和 DSL 两种风格可以结合起来使用
df.selectExpr("age", "incr_10_sql(age) AS age").show()
"""
+---+---+
|age|age|
+---+---+
| 17| 27|
| 18| 28|
| 19| 29|
+---+---+
"""

通过 SparkSession().udf.register() 注册的 UDF 可以同时应用于 SQL 风格和 DSL 风格,但还有一种注册方法,只能用于 DSL 风格。

from pyspark.sql import SparkSession
from pyspark.sql.types import IntegerType
from pyspark.sql.functions import udf

session = SparkSession.builder.getOrCreate()

df = session.createDataFrame(
    [[17], [18], [19]],
    schema=["age"]
)
df.show()
"""
+---+
|age|
+---+
| 17|
| 18|
| 19|
+---+
"""

def incr_10(x):
    return x + 10

incr_10 = udf(incr_10, IntegerType())

df.select(incr_10(df["age"]).alias("age")).show()
"""
+---+
|age|
+---+
| 27|
| 28|
| 29|
+---+
"""

因为只支持 DSL,所以相比第一种方式就是少了个参数而已。


然后再来看看返回数组的 UDF。

from pyspark.sql import SparkSession
from pyspark.sql.types import StringType, ArrayType
from pyspark.sql.functions import udf, explode

session = SparkSession.builder.getOrCreate()

df = session.createDataFrame(
    [["hello rust"], ["hello python"], ["hello golang"]],
    schema=["word"]
)
df.show()
"""
+------------+
|        word|
+------------+
|  hello rust|
|hello python|
|hello golang|
+------------+
"""

def my_split(x):
    return x.split(" ")

my_split = udf(my_split, ArrayType(StringType()))

df.select(my_split(df["word"]).alias("word")).show()
"""
+---------------+
|           word|
+---------------+
|  [hello, rust]|
|[hello, python]|
|[hello, golang]|
+---------------+
"""
df.select(explode(my_split(df["word"])).alias("word")).show()
"""
+------+
|  word|
+------+
| hello|
|  rust|
| hello|
|python|
| hello|
|golang|
+------+
"""

声明数组类型时需要使用 ArrayType,并且还要指定内部元素的类型。


UDF 返回值类型还可以是 map,有了 map 我们便可以生成多列。

from pyspark.sql import SparkSession
from pyspark.sql.types import StringType, MapType
from pyspark.sql.functions import udf, explode

session = SparkSession.builder.getOrCreate()

df = session.createDataFrame(
    [["rust"], ["python"], ["golang"]],
    schema=["word"]
)
df.show()
"""
+------+
|  word|
+------+
|  rust|
|python|
|golang|
+------+
"""

def to_map(x):
    return {"language": x}

# MapType 接收两个参数,分别是 key、value 的类型
to_map = udf(to_map, MapType(StringType(), StringType()))

df.select(to_map(df["word"]).alias("word")).show()
"""
+--------------------+
|                word|
+--------------------+
|  {language -> rust}|
|{language -> python}|
|{language -> golang}|
+--------------------+
"""
df.select(explode(to_map(df["word"])).alias("word", "language")).show()
"""
+--------+--------+
|    word|language|
+--------+--------+
|language|    rust|
|language|  python|
|language|  golang|
+--------+--------+
"""

怎么样,是不是很强大呢?需要什么函数直接定义一个就行了,只要保证声明的返回值类型和实际函数返回的类型是一致的即可。关于返回值类型,Spark 支持的非常多,可以导入 pyspark.sql.types 进行查看。

Spark SQL 运行流程解析

让我们先来回顾一下 RDD 执行流程:

如果简单来看的话,有四个步骤:

  • RDD:代码本身,被提交到集群。
  • DAGScheduler:划分逻辑任务,构建内存管道。
  • TaskScheduler:接手 DAGScheduler 的产出,负责任务分配和管理监控。
  • Worker:负责执行具体的任务。

尽管相比 MapReduce 来说,RDD 已经足够方便了,但它还是比较底层的。RDD 的运行完全按照开发者的代码执行,如果开发者水平有限,那么 RDD 的执行效率也会受到影响。而 SparkSQL 会对代码进行自动优化,以提升代码运行效率,降低开发者水平带来的影响。那么问题来了,为什么 SparkSQL 可以被优化,而 RDD 不行。

很简答,因为 RDD 内部的数据类型和格式是不受限制的,很难进行优化。但 SparkSQL 不同,我们用该模块操作的数据结构是 DataFrame,这是一个固定的二维表格式,可以被针对,而负责执行优化的便是 Catalyst 优化器。

为了解决过多依赖 Hive 的问题,SparkSQL 使用了一个新的 SQL 优化器 Catalyst,用于替代 Hive 中的优化器,整个 SparkSQL 的架构如下:

而我们的重点则是 Catalyst 如何对 SQL 进行优化,并生成 RDD 代码的。

Catalyst 优化器原理解析

Catalyst 优化器的具体流程如下:

下面我们一步一步分析。


步骤一:解析 SQL,生成 AST(抽象语法树)

第一步生成的 AST 完全是按照 SQL 语句本身翻译过来的,没有经过任何优化,两者体现的含义是一致的。我们看一下 AST,需要从下往上看。首先是扫描两张表,然后 Join,之后按照指定条件过滤,再筛选。


步骤二:在 AST 中加入元数据信息,该步骤主要是为了方便后续优化的。

会在原始的 AST 上打一些标记,这些标记是做什么的不用理会,它是 Spark 内部使用的。


步骤三:对已经加入元数据的 AST 进行优化。

优化的途径非常多,我们介绍两个常见的。

  • 谓词下推(Predicate Pushdown):将 Filter 这种可以减少数据量的操作下推,放在 Scan 的位置。比起两表 Join 之后再过滤,肯定是先过滤再 Join 的性能更优,因为可以减少 Join 的数据量(也是在减少 shuffle 的数据量)。
  • 列值裁剪(Column Pruning):在谓词下推后执行列值裁剪,比如这里的 people 表只用到了 id 列,所以可以把其它列给裁剪掉,减少处理的数据量,从而优化处理速度。因此 SparkSQL 非常适合与 Parquet 这种列式存储的文件格式进行搭配。

SparkSQL 内置了一两百种优化规则,感兴趣的话可以通过源码查看:org.apache.spark.sql.catalyst.optimizer.Optimizer。


步骤四:上面生成的 AST 叫做逻辑执行计划(优化过后的),还需要再生成物理执行计划。

  • 生成物理执行计划的时候,会经过成本模型对整棵树再次执行优化,选择一个更好的计划。
  • 生成物理执行计划之后,因为考虑到性能,所以会使用代码生成,在机器中运行。

可以使用 queryExecution 方法查看逻辑执行计划,使用 explain 方法查看物理执行计划。


步骤五:基于物理执行计划,生成 RDD 代码。


总结一下就是:

  • DataFrame 存储的是二维表结构,可以被针对,从而执行自动优化。
  • 自动优化依赖于 Catalyst 优化器。
  • 自动优化的点非常多,但大的方面有两个,分别是谓词下推(行过滤)和列值裁剪(列过滤)。
  • DataFrame 代码在被优化之后,最终还是要转成 RDD 代码去执行。既然要转成 RDD,那么前面说的 DAGScheduler、TaskScheduler、阶段划分、内存迭代管道、宽窄依赖等等,同样也存在于 SparkSQL 当中。

Spark on Hive 的原理和配置

Hive 我们之前说过,对于 Hive 来说就两个组件:

  • 执行引擎,将 SQL 翻译成 MapReduce 并提交到 YARN 上执行。
  • MetaStore 元数据管理中心。

Hive 执行引擎在解析 SQL 的时候,需要使用 MetaStore 服务。因为表在 HDFS 的哪个位置,表里面有哪些字段,类型是什么,这些都属于元数据,由 MetaStore 负责管理。执行引擎会询问 MetaStore 表的相关信息,在拿到信息之后才能生成 MR 去操作 HDFS。

因此对于 Hive 来说,执行引擎和 MetaStore 都是必不可少的。好了,简单回顾了一下 Hive,再来看看 Spark。

Hive 是将 SQL 翻译成 MR,Spark 是将 SQL 翻译成 RDD,两者本质是类似的。只是 Spark 没有元数据管理,如果想写 SQL,那么必须要先读数据得到 DataFrame,然后将它注册为一个视图。而 Spark on Hive 便是将 Hive 的 MetaStore 借给 Spark,让 Spark 也拥有元数据管理。

因为 MetaStore 本质上就是一个元数据管理服务,它能和 Hive 的执行引擎搭配,那么自然也能和 Spark 的执行引擎搭配。

所以将 Spark 的 SQL 执行引擎(SparkSQL)和 Hive 的 MetaStore 结合起来,就得到了 Spark on Hive。说白了就是将 Hive 的 SQL 执行引擎给替换掉了,换成了更有效率的 SparkSQL,以后 SQL 会被翻译成 RDD,而不是 MapReduce。


那么 Spark 要如何连接 Hive 的 MetaStore 呢?在 $SPARK_HOME/conf 目录中新建一个 hive-site.xml,添加如下内容。

<configuration>
    <!-- 告诉 Spark 创建表之后存到哪里 -->
    <property>
            <name>hive.metastore.warehouse.dir</name>
            <value>/user/hive/warehouse</value>
    </property>
    
    <!-- 告诉 Spark,Hive 的 MetaStore 服务的地址 -->    
    <property>
      <name>hive.metastore.uris</name>
      <value>thrift://satori001:9083</value>
    </property>    
</configuration>

以上是在服务器当中配置的,我们也可以在代码中配置。

session = SparkSession.builder. \
    config("spark.sql.shuffle.partitions", 50). \
    config("spark.sql.warehouse.dir", "hdfs://satori001:9000/user/hive/warehouse"). \
    config("hive.metastore.uris", "thrift://satori001:9083"). \
    enableHiveSupport(). \
    getOrCreate()

另外 Hive 的 MetaStore 会依赖关系型数据库,一般是 MySQL,所以 Spark 的 jars 目录要包含能操作 MySQL 的驱动,这里我们之前已经安装过了。

然后不要忘记在 Hive 中开启 MetaStore 服务,修改 Hive 的配置文件 hive-site.xml,添加如下内容:

<property>
    <name>hive.metastore.uris</name>
    <value>satori001:9083</value>
</property>

表示让 Hive 的 MetaStore 服务监听 9083 端口,然后输入 hive --service metastore 即可前台启动 MetaStore 服务。之后 Spark 再创建表时,元数据就会写入到 MySQL,实体数据会存储在 HDFS,表现和 Hive 是一致的,只是 SQL 执行引擎换成了 SparkSQL。

注意:如果你不使用 Hive 的 MetaStore 服务,那么 SparkSQL 在创建表时,会将元数据写入本地的 spark-warehouse 目录,然后实体数据也会存在该目录中。

Spark 3.x 新特性概览

在 TPC-DS 基准测试中,Spark3.0 的性能达到了 Spark2.4 的两倍。

那么相比 2.x,Spark 在 3.x 中都引入了哪些功能呢?

Adaptive Query Execution(自适应查询)

由于数据统计信息(元数据)不足或不准确,以及对成本的错误估算(执行计划调度),导致生成的初始执行计划不理想。于是 Spark 在 3.x 版本提供了 Adaptive Query Execution(简称 AQE),即自适应查询技术,通过在运行时对查询执行计划进行优化,允许 Planner 在运行时执行可选计划。这些可选计划将会基于运行时数据统计进行动态优化,从而提高性能。

开启 AQE 的方式:将 spark.sql.adaptive.enabled 参数指定为 true,可以在代码文件和 spark-defaults.conf 配置文件中指定,或者提交任务时指定。

AQE 提供了三个方面的优化:

  • 动态合并 Shuffle Partitions
  • 动态调整 JOIN 策略
  • 动态优化倾斜 JOIN

动态合并 Shuffle Partitions

动态调整 shuffle 分区的数量,用户可以在开始时设置相对较多的 shuffle 分区数,AQE 会在运行时将相邻的多个小分区合并为较大大分区。

尽管我们规定了分区数,但如果相邻的多个分区的数据量很小的话,那么其实没必要使用多个分区。因此在开启 AQE 之后,会将多个相邻的小分区进行合并。

动态调整 JOIN 策略

此优化可以在一定程度上避免因缺少统计信息或错误估计大小(两种情况也可能同时存在),而导致执行计划性能不佳的情况。具体做法是在运行时将 SORT MERGE JOIN 转换成 BROADCAST HASH JOIN,从而进一步提升性能。

对于右边的数据集,统计的结果是 25MB,但实际只有 8MB,这种差异只会在运行时产生。当开启 AQE 之后,就会识别到这种差异,然后进行优化。默认情况下的 JOIN 是采用 SORT + MERGE 的形式,但开启 AQE 识别到这种差异之后,会发现右边的数据集其实很小,完全可以广播出去。这样每个 Executor 都包含右边数据集的一个副本,将 SORT MERGE JOIN 转成基于广播的哈希 JOIN,从而避免大量的网络传输,提升性能。

动态优化倾斜 JOIN

倾斜 JOIN(Skew Join)可能导致负载的极度不平衡,并严重降低性能。AQE 从 shuffle 文件统计信息中检测到任何倾斜后,可以将倾斜的分区分割成更小的分区,并将它们与另一侧相应的分区连接起来。这种优化可以并行化倾斜处理,获得更好的整体性能。

比如 A JOIN B,但 A 表中分区 A0 的长度明显大于其它分区,因此 A0 便是一个倾斜的分区,在和 B0 进行 JOIN 时,对应的 Task 就变成了长尾 Task。

如果开启了 AQE,那么在执行 A JOIN B 之前,会通过上游 Stage 的统计信息,判断是否存在数据倾斜。由于当前 partition A0 明显超过平均值的数倍,于是会判断 A JOIN B 发生了数据倾斜,且倾斜分区为 partition A0。而为了解决这个问题,Spark AQE 会将 A0 的数据拆成 N 份,使用 N 个 Task 去处理该 partition,每个 Task 只读取若干个 Map 的 shuffle 输出文件。

N 个 Task 各自读取 A0 的部分数据,然后和 B0 做 JOIN,这 N 个 Task 执行的结果和 A0、B0 直接做 JOIN 的结果是等价的。不难看出,在这样的处理中,B 表的 partition 0 会被读取 N 次,虽然增加了一定的额外成本,但通过 N 个任务处理倾斜数据带来的收益是高于成本的。

总结:在 Stage 提交之前,根据上游 Stage 的所有 MapTask 的统计信息,计算得到下游每个 ReduceTask 的 shuffle 输入,因此 Spark AQE 能够自动发现产生数据倾斜的 JOIN,并且做出优化处理,该功能就是 Spark AQE Skew Join(动态优化倾斜 Join)。

当然啦,对分区进行拆分也是有条件的。

  • 分区的数据量大于 spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes(默认是 256MB)
  • 分区的数据量大于 "分区的中位数 * spark.sql.adaptive.skewJoin.skewedPartitionFactor(默认是 10)"

只有两个条件都满足时,AQE 才会进行优化。


以上便是 Spark 在 3.0 提供的 AQE,核心思想就是利用执行结束的上游 Stage 的统计信息(主要是数据量和记录数),来优化下游 Stage 的物理执行计划。而在使用上,我们只需记住三点:

  • 通过将 spark.sql.adaptive.enabled 设置为 true 即可开启 AQE
  • AQE 是自动优化,无需我们设置复杂的参数调整,只要符合条件即可自动应用 AQE 优化
  • AQE 带来了极大的 SparkSQL 性能提升

动态分区裁剪(Dynamic Partition Pruning)

前面介绍 SparkSQL 原理时说过,在生成执行计划之前会进行列值裁剪,将不用的字段过滤掉,而这一步是在运行之前发生的,属于静态裁剪。但除了字段可以过滤之外,分区也可以过滤,只是分区过滤在编译阶段不一定会被优化器识别出来。而当优化器在编译阶段无法识别可跳过的分区时,便可使用动态分区裁剪,即基于运行时推断的信息来对分区进行裁剪。

动态分区裁剪在星型模型中很常见,星型模型是由一个或多个引用了任意个维度表的事实表组成。在连接操作中,我们可以通过识别维度表过滤之后的分区,来裁剪从事实表中读取的分区,从而极大地减少数据量(shuffle 带来的影响),提升性能。在 TPC-DS 基准测试中,102 个查询有 60 个获得了 2 ~ 18 倍的性能提升。

该特性是自动开启的,我们正常编写 SQL,会自动优化。

Koalas

Python 现在已经成为 Spark 中使用最为广泛的编程语言之一,因此也是 3.0 的重点关注领域。很多 Python 开发人员在数据分析方面会使用 Pandas,因为 Pandas 处理中小型数据集非常方便,只要能将数据载入内存,那么便可以完成任何你想要的变换。不过 Pandas 的缺陷是仅限于单节点处理,如果你的数据集过大,那么除了增加节点的内存容量,没有太好的办法。

因此 Spark 官方开发了 Koalas,它是基于 Spark 的 Pandas API 实现,目的是让数据科学家能够在分布式环境中更高效地处理大型数据集。Koalas 在使用上和 Pandas 是一样的,但底层跑的是分布式 RDD。经过一年多的开发,Koalas 已经对 Pandas API 实现了 80%,每月的 PyPI 下载量也迅速增长到了 85 万,并以两周一次的发布节奏快速演进。

安装 Koalas:pip install koalas。

import pandas as pd
import databricks.koalas as ks

pandas_df = pd.DataFrame(
    {"a": [1, 2, 3], "b": [4, 5, 6]}
)
koalas_df = ks.from_pandas(pandas_df)

Koalas DataFrame 的数据结构和 PySpark DataFrame 是一致的,都是基于 RDD 的分布式数据集,在操作的时候依旧会产生 DAG、宽窄依赖、阶段划分、内存迭代等等。但是 Koalas DataFrame 的 API 和 Pandas DataFrame 是一致的, 这种设计既可以让熟悉 Pandas 的开发者不需要额外的学习成本,又能利用 Spark 的分布式计算。

如果你是 Pandas 的重度使用者,那么可以非常方便地将代码切换到 Koalas,只需把 Pandas 的 DataFrame 换成 Koalas 的 DataFrame,然后便可以提交到 Spark 集群之上运行。但对于那些不是特别依赖 Pandas 的开发者来说,他们会更倾向于使用 SparkSQL,因为里面提供的 DataFrame 同样易于操作并且功能强大。

目前来讲,Koalas 和 SparkSQL 用的人都不少,具体使用哪种取决于自身喜好。

小结

关于 Spark 就说到这里,然后我们对学习的内容做一个总结。

Apache Spark 已经成为大数据处理领域的一个重要工具,支持高效的大规模数据处理、灵活的数据分析、实时数据处理、以及机器学习等功能。自从其诞生以来,Spark 已经从一个开源项目成长为一个全面的大数据处理框架,在金融服务、电子商务、社交媒体和物联网等领域都得到了广泛的应用。如果你想从事大数据相关的工作,那么 Spark 必须要掌握。

到目前为止,本篇文章介绍的 Spark 数据处理部分都属于离线处理,也就是对已经存储的大量数据进行批量处理。这种处理模式不需要实时地响应,而是侧重于大规模数据的分析、转换和汇总,例如数据挖掘、日志分析、大规模报告生成等等。

但 Spark 不仅仅支持离线处理,还可以通过 Spark Streaming 和 Structed Streaming 对数据进行实时的流处理,后续我们来介绍 Spark Streaming 和 Structed Streaming。

posted @ 2024-02-18 00:54  万明珠  阅读(1516)  评论(1编辑  收藏  举报