Spark和Hadoop的根本差异是多个作业之间的数据通信问题:
Spark多个作业之间数据通信是基于内存,而Hadoop是基于磁盘.
大数据的Helloworld:WordCount
//导入依赖 spark3.0.0对应scala2.12
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-core-2.12</artifactId>
<version>3.0.0</version>
</dependency>
对项目添加scala框架(右击Add Framework Support选择scala2.12)
设置datas目录下word.txt文件
hello java
hello hadoop
hello scala
hello spark
主要流程
//1.建立和Spark框架的连接
val sparkConf = new SparkConf().setMaster("spark运行环境").setAppName("程序名")
val sc = new SparkContext(sparkConf)
//2.执行业务操作
//获取数据
val lines:RDD[String] = sc.textFile("datas")
//将一行行的数据拆分为一个个的单词
val words:RDD[String] = lines.flatMap(_.split(" "))
//将单词转为(单词,1)
val wordToOne = words.map(
word => (word,1)
)
//将相同key的数据,可以对value进行reduce聚合
val wordToCount = wordToOne.reduceByKey(_ + _)
//将分布在不同节点上的数据合并到驱动程序中的一个集合中,返回一个数组,其中包含RDD的所有元素
val array:Array[(String,Int)] = wordToCount.collect()
//遍历打印
array.foreach(println)
//3.关闭连接
sc.stop()
ps:如果不想打印很多的日志,可以在resources下配置log4j.properties来选择打印哪些日志信息(http://t.csdn.cn/j23pE)
本地/Local环境
不需要其他任何节点资源就可以在本地执行Spark代码,一般用于教学,演示,调试
将对应的spark压缩包解压后使用bin/spark-shell启动Local环境
在命令行工具中使用如下命令可以达到先前在idea中一样的效果
sc.textFile("data/word.txt").flatMap(_.split(" ")).map((_,1)).reduceByKey(_+_).collect
在对应主机地址的4040端口可以访问web界面
当我们想将idea中的操作于命令行中实现,以此能在web界面访问查看进度,我们可以将对应的实现打成jar包,传入主机中用下面的命令来实现使用
bin/spark-submit \
--class org.apache.spark.example.SparkPi \
--master local[2] \
./examples/jars/spark-examples_2.12-3.0.0.jar \
10
//--class表示要执行程序的主类
//--master local[2] 部署模式,默认为本地模式,数字为分配的虚拟cpu核数量
//spark-examples_2.12_3.0.0.jar 运行的应用类所在的jar包
//10 为程序的入口参数,这里的例子是用于设定当前应用的任务数量
Standalone(独立部署)模式
由spark自身提供计算资源,无需其他框架.降低了和其他第三方资源框架的耦合性,独立性非常强.
修改conf下的slaves.template为slaves,在里面配置上自己节点的work节点
linux1
linux2
linux3
修改spark-env.sh.template为spark-env.sh
export JAVA_HOME=/opt/module/jdk1.8.0_144
SPARK_MASTER_HOST=linux1
SPARK_MASTER_RORT=7077
#JAVA_HoME填写对应的jdk地址
#SPARK_MASTER_HOST填写集群对应的master节点
#7077端口,相当于hadoop3内部通信的8020端口,此处的端口需要确认自己的Hadoop配置
向集群的其他节点分发spark-standadlone目录
启动:sbin/spark-all.sh
提交任务
bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://linux1:7077 \
./examples/jars/spark-examples_2.12-3.0.0.jar \
10
(和local模式大同小异,不通点在于将--master 对应的环境修改为spark集群模式)
提交指令详细为
--class Spark程序中包含主函数的类
--master Spark程序运行的模式(环境) 可选值有:local[*],spark://linux1:7077,Yarn
--executor-memory 1G 指定每个executor可用内存为1G
--total-executor-cores 2 指定所有executor使用的cpu核数为2个
--executor-cores 指定每个executor使用的cpu核数
application-jar 打包好的jar包,包含依赖.如果是file://path,那么所有的节点的path都应包含同样的jar
application-arguments 传给main()方法的参数
配置历史服务
- 修改spark-defaults.conf.template 为 spark-defaults.conf
- 配置日志存储路径
spark.eventLog.enabled true
spark.eventLog.dir hdfs://linux1:8020/directory
#需要提前启动好hadoop集群,8020对应hadoop内部的端口通信端口号,directory需要提前存在
- 修改spark-env.sh,添加日志配置
export SPARK_HISTORY_OPTS="
-Dspark.history.ui.port=18080
-Dspark.history.fs.logDirectory=hdfs://linux1:8020/directory
-Dspark.history.retainedApplications=30"
#参数一:web端访问端口号18080
#参数二:指定历史服务器日志存储路径
#参数三:指定保存Application历史记录的个数,超过这个值,旧的信息会被删除,这是内存中的应用数,而非页面上显示的页面数
分发conf目录
启动历史服务器
sbin/start-history-server.sh
配置高可用(HA)
停止集群,启动zookeeper
修改spark-env.sh文件添加如下配置
#将我们原先设定的SAPRK_MASTER_HOST和PORT注释掉
#MASTER监控页面默认访问端口为8080,但可能会和zookeeper冲突,改成8989
SPARK_MASTER_WEBUI_PORT=8989
export SPARK_DAEMON_JAVA_OPTS="
-Dspark.deploy.recoveryMode=ZOOKEEPER
-Dspark.deploy.zookeeper.url=linux1,linux2,linux3
-Dspark.deploy.zookeeper.dir=/spark"
分发
启动集群后,再启动linux2的单独Master节点,此时为备用状态.可以去linux2:8989查看
sbin/start-master.sh
yarn模式
Spark主要是计算框架,而非资源调度框架,本身提供的资源调度并非强项,和其他专业资源调度框架使用更靠谱,国内使用Yarn非常多
修改hadoop的yarn-site.xml文件配置,并分发
<!--是否启动一个线程检查每个任务正使用的物理内存量,如果任务超出分配值,则直接将其kill,默认为true-->
<property>
<name>yarn.nodemanager.pmem-check-enabled</name>
<value>false</value>
</property>
<!--是否启动一个线程检查每个任务正使用的虚拟内存量,如果任务超过分配值,则直接将其kill,默认是true-->
<property>
<name>yarn.nodemanager.vmem-check-enabled</name>
<value>false</value>
</property>
修改spark-env.sh,添加JAVA_HOME和YARN_CONF_DIR配置
mv spark-env.sh.template spark-env.sh
export JAVA_HOME=/opt/module/jdk1.8.0_144
YARN_CONF_DIR=/opt/module/hadoop/etc/hadoop
启动hdfs和yarn
同样的提交任务的--master需要修改,再加一个--deploy-mode cluster意思为集群部署模式
bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master yarn \
--deploy-mode cluster \
./examples/jars/spark-examples_2.12_3.0.0.jar \
10
配置历史服务器
和前Standalone配置历史一致,只多一步修改spark-defaults.conf,与yarn进行关联,这样在mr执行端口8088的页面可以点击History查看历史
spark.yarn.historyServer.address=linux1:18080
spark.history.ui.port=18080
Window模式
执行bin目录下的spark-shell.cmd文件,启动Spark本地化环境
在bin目录cmd,提交应用程序
spark-submit --class org.apache.spark.examples.SparkPi --master local[2] ../examples/jars/spark-examples_2.12-3.0.0.jar 10
端口号总结
查看Spark-shell运行任务情况 4040
SparkMaster内部通信 7077
Standalone模式下,SparkMaster Web端口 8088
历史服务器 18080
Driver
Spark驱动器节点,用于执行Spark任务中的main方法,负责实际代码的执行工作.驱使整个应用运行起来的程序,也称之为Dirver类
负责:
- 将用户程序转化为作业job
- 在Executor之间调度任务
- 跟踪Executor的执行情况
- 通过UI展示查询运行情况
Executor
Spark Executor是集群中工作节点(Worker)中的一个JVM进程,负责在Spark作业中运行具体任务(Task),任务彼此之间相互独立.Spark应用启动时,Executor节点被同时启动,并且始终伴随整个Spark应用的生命周期而存在.如果有Executor节点发生了故障或崩溃,Spark应用也可以继续执行,将出错节点上的任务调度到其他Executor节点继续运行
- 运行组成spark应用的任务,将结果返回给驱动器进程
- 用自身块管理器(Block Manager)为用户程序中要求缓存的RDD提供内存式存储.RDD是直接缓存在Executor进程中的,故此任务在运行时可以利用缓存数据加速运算
解释一下JVM进程
JVM进程是指运行在操作系统上的Java虚拟机实例。
当你运行一个Java程序时,实际上是启动了一个JVM进程,该进程负责加载和执行你的Java代码。
当你编写了一个包含main方法并打印"Hello, World!"的Java类时,这并不是一个JVM进程。
相反,当你使用java命令运行这个类时,会启动一个JVM进程来执行这个Java程序。
JVM会加载你的类,执行main方法,并输出"Hello, World!"。
简而言之,Java程序是在JVM进程中运行的,而编写的Java类本身并不是一个JVM进程。
JVM进程是Java运行时环境的实例,用于执行和管理Java应用程序。
Master&Worker
独立部署环境中,不需要依赖其他的资源调度框架,自身就实现了资源调度功能.Master类似于Yarn环境中的ResourceManager,Worker类似于NodeManager
ApplicationMaster
上文提到的Driver和Executor是计算相关的组件而Master和Worker是资源相关的组件,如果他们直接交互的话,耦合性就增大了,增加一个ApplicationMaster就可以降低耦合,Driver想要资源就委托ApplicationMaster,ApplicationMaster再向Master申请
并发&并行
真正的多任务同时执行----并行
当我们配置Executor的核数时超过了物理核数就会使用虚拟核数,以并行的姿态展现,实际是不停地切换执行----------------------并发
RDD&Task
我们知道java中有io的概念,创建一个读文件流读取文件,但这是字节流,如果我们需要读取的是字符流,我们就要将字节流先攒下来对应一个字符的字节流个数再用字符流包装将其翻译为字符,这是一个读取字符的io过程.我们创建这种类时规定好了数据和逻辑,但如果我们是分布式的理念的话,就需要将一整个数据分为对应节点的份数,这样才能达到并发执行不重复数据的目的,所以我们就要有一个subStream类来继承原本的类,拿到对应的数据,逻辑操作不变.
对应的Task就是这样的概念,我们在Driver类中设置好数据与逻辑,然后将其分为对应的Task发送给Executor也就是一个个JVM实例执行.这里面的RDD其实就是以装饰者设计模式,完成相应的逻辑包装,最后依据数据的分区不同分为一个一个的Task(逻辑是一样的)发送给Executor
RDD的工作原理:
- 启动Yarn集群环境,RM和NM相应启动
- Spark通过申请资源创建调度节点和计算节点(就是我们的Driver和Executor,他们都是运行在一个一个的NM里的)
- Spark框架根据需求将计算逻辑根据分区划分成不同的任务,实际就是在Driver里规定好RDD的先后依赖关系,然后分区将task放到TaskPool中,等待后面的分发
- 调度节点(Driver)将任务根据计算节点状态发送到对应的计算节点进行计算
所以总结来说就是将逻辑进行封装,生成Task发给Executor执行计算
分区的数量
内存创建的RDD
内存创建RDD的makeRDD方法可以传递第二个参数,这个参数表示分区的数量,如果没有传这个参数,makeRDD会使用默认值:defaultParallelism(默认分区数)
源码:scheduler.conf.getInt("spark.default.parallelism")
如果获取不到,那么使用totalCores属性,这个属性取值为当前环境的最大可用核数(conf的local方括号里设置的值)
读文件创建的RDD
textFile可以将文件作为数据处理的数据源,可以通过第二个参数minPartitions指定分区数
minPartitions: 最小分区数量,真正的分区数量可能会比他大
minPartitions的默认值:defaultMinPartitions函数
defaultMinPartitions函数为math.min(defaultParallelism,2)
而defaultParallelism的值除非你在创建SparkContext时指定了spark.default.parallelism属性,那么他的值将是这个属性的值,否则是Spark集群的核数
默认分区数量计算方式:
Spark读取文件,底层实际使用Hadoop的读取方式
totalSize: 所有需要读取的文件统计得到的字节数总和
假设我们要读取的文件1.txt内容为(换行的字节数为2,用@@代替表示换行)
1@@
2@@
3
sc.textFile("1.txt")
那么
totalSize = 7
goalSize = totalSize / (numSplits == 0 ? 1 : numSplits);
即goalSize = 7 / 2 = 3 意思为每个分区放3个字节
但其实7 / 3 = 2...1 即每个分区放3个字节,还有一个字节无处放
根据Hadoop的1.1原理,即多出来这个大小占每个分区的大小如果超过10%就另开一个分区,如果小于10%不会产生新的分区
所以这里的1 / 3 = 33.3%大于10%故会产生新的分区,即3个分区
数据分区的分配
内存中创建的RDD:
得到数组的长度length,以及分区数numSlices
def positions(length: Long, numSlices: Int): Interator[(Int,Int)] = {
(0 until numSlices).iterator.map{ i =>
val start = ((i * length) / numSlices).toInt
val end = (((i + 1) * length) / numSlices).toInt
(start,end)
}
}
就是将分区号传入,每个分区号的start为分区号 * 数组的长度 / 分区数,截断小数部分只取整数
end为((分区号 + 1) * 数组长度 / 分区数),截断小数部分只取整数
例子:
makeRDD(list(1,2,3,4,5),3)
那么length为5,numSlices为3
第一个分区0:
start = (0 * 5) / 3 = 0
end = ((0 + 1) * 5) / 3 = 1
数据位置起始为[0,1)
数据为1
第二个分区1:
start = (1 * 5) / 3 = 1
end = ((1 + 1) * 5) / 3 = 3
数据位置起始为[1,3)
数据为2,3
第三个分区2:
start = (2 * 5) / 3 = 3
end = ((2 + 1) * 5) / 3 = 5
数据位置起始为[3,5)
数据为4,5
读取文件创建的RDD:
Spark读取文件,采用的是hadoop的方式读取,所以一行一行的读取,和字节数没有关系
数据读取时以偏移量为单位
根据前面的读取文件创建RDD的分区数设置例子为例
将换行以@@表示,那么1.txt实际内容对应的偏移量为
1@@ => 012
2@@ => 345
3 => 6
数据根据分区的偏移量范围计算,且偏移量不会重复读取
正确理解偏移量范围的含义而非按个读取
0号分区:[0,3] => 1@@2
但又因为是按行读取,所以实际为
1@@2@@
1号分区:[3,6] => 3
2号分区:[6,7] => 空
如果数据源为多个文件,那么计算分区时以文件为单位进行分区,即先将一个文件分区完成后,再将另外一个文件分区,他们之间是相互独立的
RDD转换算子类型
根据数据处理方式的不同将算子整体上分为
- Value类型
- 双Value类型
- Key-Value类型
map算子
将数据源中的数据进行转换和改变,但是不会减少或增多数据
rdd.map(_ * 2)
关键点:rdd的计算一个分区内的数据时一个一个执行逻辑,只有前一个数据全部的逻辑执行完毕后,才会执行下一个数据,分区内数据的执行是有序的;不同分区数据计算是无序的
mapPartitions算子
将待处理的数据以分区为单位发送到计算节点进行处理,因为传递一个迭代器返回一个迭代器对于元素的个数没有要求,所以这里的处理包括过滤数据,但要保证返回的仍然是一个迭代器
rdd.mapPartitions(
iter => {
List(iter.max).iterator
}
)
较之于map算子.我们发现在一个分区内他是一个一个执行的,效率不高,类似于javaIO中的字节流,一个一个的读效率不高,于是有了mapPartitons算子.将分区内的数据全部读取到迭代器中再一块进行逻辑操作,减少了io次数,提高了效率;但是由于处理完的数据不会被释放掉,在内存较小,数据量较大的场景下,也会更容易导致内存溢出.
完成比完美更重要
mapPartitionsWithIndex算子
在mapPartitins算子基础上增加了一个分区索引,解决一些特殊需求逻辑
//现在只要第二个分区的数据
rdd.mapPartitionsWithIndex(
(index,iter) => {
if (index == 1){
iter
}else{
Nil.iterator
}
}
)
flatMap算子
将原数据进行扁平化处理
//数据源是List((List(1,2)),(List(3,4)))
//需求:拿出一个一个独立的数字:1,2,3,4
rdd.flatMap(
list => {
list
}
)
//这里可能会有疑惑,传入的是一个列表,怎么返回还是一个列表
//我们传入一个列表,将他扁平化处理,但是不可能返回多个结果
//所以需要有一个容器来盛放,故还是选择了列表盛放
//但是两次列表的意义已经不同了
//即这中间过程为:
// List((List(1,2)),(List(3,4))) => 1,2,3,4 => List(1,2,3,4)
//原先的列表是List((List(1,2)),(List(3,4)))
//现在返回的列表是List(1,2,3,4)
//将List("hello Scala","hello Spark")拆分为一个一个的单词
rdd.flatMap(
s => {
s.split(" ")
}
)
//split方法返回的就是一个可迭代的数组
//原先:List("hello Scala","hello Spark")
//现在:List("hello","Scala","hello","Spark")
//数据源中的数据不是相同类型,怎么扁平化处理
//数据源:List(List(1,2),3,List(4,5))
//我们可以使用模式匹配来将对应数据转换为大部分源数据的格式
rdd.flatMap(
data => {
data match {
case list:List[_] => list
case dat => List(dat)
}
}
)
//使用模式匹配将原先的列表保留,非列表包装成列表,这样数据就是同一种类型了,就可以正常扁平化了
glom算子
将同一个分区的数据直接转换为相同类型的内存数组进行处理,分区不变
//使用mapPartitions可以做到取每个分区的最大值,glom也可行
//随后再将每个分区的最大值相加
val rdd:RDD[Int] = sc.makeRDD(List(1,2,3,4),2)
val glomRDD: RDD[Array[Int]] = rdd.glom()
val maxRDD: RDD[Int] = glomRDD.map(
array => {
array.max
}
)
println(maxRDD.collect().sum)
groupBy算子
groupBy会将数据源中的每一个数据进行分组判断,根据返回的分组key进行分组,相同的key值的数据会放置在一个组中
//数据源List("Hello","Spark","Scala","Hadoop"),将他以首字母分组
rdd.groupBy(_.charAt(0))
注意:分组和分区没有必然关系
grouyBy会将数据打乱,重新组合,这个操作称之为shuffle
一个组的数据在一个分区中,但是并不是说一个分区中只有一个组
filter算子
将数据根据指定的规则进行筛选过滤,符合规则的数据保留,不符合规则的数据丢弃
当数据进行筛选过滤后,分区不变,但是分区内的数据可能不均衡,生产环境下,可能会出现数据倾斜
//数据源为List(1,2,3,4)
//将偶数过滤,只留下奇数
rdd.filter(num => num %2 != 0)
sample算子
根据指定的规则从数据集中抽取数据
def sample(
withReplacement:Boolean, //抽取后是否放回,true放回
fraction:Double, //抽取不放回:代表数据源中每条数据被抽取的概率,当种子确定时,基准值也就确定了,抽取出来得到数据就是固定的
//抽取放回:代表数据源中的每条数据被抽取的可能次数
seed:Long = Utils.random.nextLong //随机数的种子
//如果不传递第三个参数,那么使用的是当前系统时间
):RDD[T]
//这个基准值的概念就相当于考试及格线
//当种子确定了(每个数据的分数就确定了)
//每条数据被抽取的概率确定了(及格线确定了)
//我们就知道哪些数据过了这个及格线,那么这些数据就被抽出来了
//种子/fraction不变,被抽出来的数据永远都是这些
可以用来寻找数据倾斜的那个倾斜数据
distinct算子
将数据集中重复的数据去重
rdd.distinct()
实现原理
map(x => (x,null)).reduceByKey((x,_) => x,numPartitions).map(_._1)
//数据源为List(1,2,3,4,1,2,3,4)
map(x => (x,null))
1,2,3,4,1,2,3,4
||
\/
(1,null),(2,null),(3,null),(4,null),(1,null),(2,null),(3,null),(4,null)
.reduceByKey(
(x,_) => x,
numPartitions
)
对于每个键的值,都只取第一个值(1,null),(1,null)
||
\/
(null,null)
||
\/
null
||
\/
(1,null)
.map(_._1)
(1,null) => 1
coalesce算子
缩减分区
val rdd = sc.makeRDD(List(1,2,3,4,5,6),3)
val newRDD:RDD[Int] = rdd.coalesce(2)
//理性情况是将(1,2,3)一个分区,(4,5,6)一个分区
//可实际是(1,2),(3,4,5,6)
//因为(3,4)原本是一个分区
//如果coalesce不开启shuffle处理是不会将分区的数据打乱重新组合的
//但即使开启shuffle也不一定能达到理想效果,极大概率还是无序的三个一分区,只能起到均衡效果防止数据倾斜
coalesce方法默认情况下不会将分区的数据打乱重新组合,这种情况下的缩减分区可能会导致数据不均衡,出现数据倾斜;如果想要让数据均衡,可以进行shuffle处理,第二个参数设为true
coalesce算子可以扩大分区,但是如果不进行shuffle操作是没有意义的,不起作用,所以如果想要实现扩大分区的效果,需要使用shuffle操作
Spark考虑到我们这样写比较混乱,故简化了coalesce扩大分区用repartition包装
缩减分区:coalesce算子,想要实现数据均衡时采用shuffle
扩大分区:repartition算子
repartition算子
底层就是coalesce算子开启shuffle的扩大分区
sortBy算子
根据指定的规则进行排序,默认是升序,第二个参数设置为false改为降序
val rdd = sc.makeRDD(List(6,2,4,5,3,1),2)
val newRDD:RDD[Int] = rdd.sortBy(num => num)
sortBy对分区数量不进行改动,但对所有分区的数据打乱重新排序,所以底层是有shuffle过程的
双值类型
val rdd1 = sc.makeRDD(List(1,2,3,4))
val rdd2 = sc.makeRDD(List(3,4,5,6))
//交集[3,4]
rdd1.intersection(rdd2)
//并集[1,2,3,4,3,4,5,6]
rdd1.union(rdd2)
//差集
//在rdd1角度[1,2]
rdd1.subtract(rdd2)
//rdd2角度[5,6]
rdd2.subtract(rdd1)
//拉链:将相同位置的数据放在一个元组中[(1,3),(2,4),(3,5),(4,6)]
rdd1.zip(rdd2)
交集,并集,差集 要求两个数据源数据类型保持一致
拉链操作两个数据源的类型可以不一致,但要求两个数据源的分区数量且分区中的数据数量保持一致
mapValues算子
对键值对RDD中的值进行映射(对RDD中的每个键值对的值应用一个函数),而键保持不变
rdd.mapValues(value => value * 2)
mapValues实际是将相同Key的value变为了迭代器
partitionBy算子
根据指定的分区规则对数据进行重分区,默认分区器为HashPartitioner
分区器有
- HashPartitioner
- RangePartitioner-->在排序中使用的多
- PythonPatitioner-->只有在特定的包才能使用
HashPartitioner内部有一个equals方法判断重分区的分区器类型和分区数量是否有变化,partitionBy算子中的==其实就是调用equals函数,如果没有变化,不会进行操作直接返回
我们可以模仿HashPatitioner来写我们自己的分区器
reduceByKey算子
对相同的key的value进行reduce聚合,聚合方式是两两聚合,传入的参数是对值如何处理
val rdd = sc.makeRDD(List(("a",1),("a",2),("a",3),("b",4)))
rdd.reduceByKey(
(x:Int,y:Int) => {
{x + y}
}
)
reduceByKey中如果key的数据只有一个,是不会参与运算
groupByKey算子
将数据源中的数据,相同key的数据分在一个组中,形成一个对偶元组,元组中的第一个元素就是key,第二个元素就是相同key的value的集合
val rdd = sc.makeRDD(List(("a",1),("a",2),("a",3),("b",2)))
val grouRDD:RDD[(String,Iterable[Int])] = rdd.groupByKey();
与groupBy不同在于
- groupByKey是固定按KEY来分组,groupBy可以传入参数按照自定义哪个来分组
rdd.groupByKey();
rdd.groupBy(_._1);
- 最后的结果,groupByKey是以key作第一个元素,value作第二个元素;groupBy是以key作第一个元素,原数据作第二个元素(kv)
//groupByKey() ---> RDD[(String,Iterable[Int])]
(a,CompactBuffer(1,2,3))
(b,CompactBUffer(4))
//groupBY() ---> RDD[(String,Iterable[(String,Int)])]
(a,CompactBuffer((a,1),(a,2),(a,3)))
(b,COmpactBuffer(b,4))
groupByKey和reduceByKey的区别
groupByKey:
从大体上看是将不同分区的数据打乱重新组合;我们想要每一个算子高度并行处理逻辑,但显然这里是做不到的,因为假设我们如果groupByKey后使用map方法算出值,不等待直接并行会导致结果出错,我们必须等待每一个分区的相同key数据都到这一个组中后再进行后续逻辑,但如果原本不同分组中有大量相同key的数据,有可能导致Executor中内存不够,所以这中间其实是先将不同分组的数据写到磁盘中,然后所有数据分组写磁盘完成后再读入内存.
spark中,shuffle操作必须落盘处理,不能在内存中数据等待,会导致内存溢出
所以有磁盘IO,性能一定不会很好
reduceByKey:
reduceByKey会在原先内存中的每一个分区中提前聚合,这样落盘时数据量大大减少,这样虽然仍需要shuffle写磁盘次数减少了,读磁盘次数也减少了,性能就提高了
故好就好在,reduceByKey进行了combine预聚合
reduceByKey支持分区内预聚合功能,可以有效减少shuffle时落盘的数据量,提升shuffle的性能
aggerateByKey算子
在reduceByKey中有combine预聚合,故就有了"分区内"和"分区间"的概念,但是在reduceByKey中的分区内和分区间的逻辑都是相同的,但如果实际开发中对于分区内和分区间逻辑不一样的操作就没法实现了(例如要求不同分区的value的最大值的和),故有了aggerateByKey来解决
aggerateBykey存在函数柯里化,有两个参数列表
- 第一个参数列表,需要传递一个参数,表示初始值
- 主要用于当碰见第一个key的时候,和value进行分区间计算
- 第二个参数列表需要传递两个参数
- 第一个参数表示分区内计算规则
- 第二个参数表示分区间计算规则
rdd.aggerateByKey(0)(
(x,y) => math.max(x,y),
(x,y) => x + y
)
aggerateByKey最终返回的数据结果应该和初始值的类型一致
//需求:求得相同key的值的平均值
val rdd = sc.makeRDD(List(("a",1),("a",2),("b",3),("b",4),("b",5),("a",6)))
//统计平均值光计算值得和不够,还要知道次数
val newRDD:RDD[(String,(Int,Int))] = rdd.aggregateByKey((0,0))(
(t,v) => {
//取初始值的一个为值,值相加,第二个为次数
(t._1 + v,t._2 + 1)
},
(t1,t2) => {
//分区间的相同key的值相加,次数相加
(t1._1 + t2._1,t1._2 + t2._2)
}
)
//这样我们得到的rdd类型为(key,(相同key的值和,个数))
//再使用mapValues来对值操作拿和除个数,得到结果
val resultRDD:RDD[(String,Int)] = newRDD.mapValues{
case(num,cnt) => {
num / cnt
}
}
foldByKey
如果聚合计算时,分区内和分区间计算规则相同,可以使用foldByKey来简写,传入初始值,逻辑即可
rdd.foldByKey(0)(_ + _)
他与reduceByKey的区别在于
foldByKey
它允许你提供一个初始值
reduceByKey
没有提供一个明确的初始值参数,但它使用每个键的第一个值作为初始值,然后将这个初始值与该键的所有其他值进行合并
combineByKey算子
combineByKey就是对相同key的第一个数据进行转换,变为我们想要的初始值格式,再进行分区内,分区间的逻辑
在aggregateByKey算子的求相同key的value的平均值的例子中,我们设置了一个初始值并且和后面的数据进行了逻辑,但没有记录初始值参与的次数,有些讲不通.于是就想到将原数据对应key的第一个数据进行相关操作来转换为我们需要的格式来方便我们后面的计算,于是就有了combineByKey
//需求:求得相同key的值的平均值
val rdd = sc.makeRDD(List(("a",1),("a",2),("b",3),("b",4),("b",5),("a",6)))
val newRDD:RDD[(String,(Int,Int))] = rdd.combineByKey(
//第一个参数:将相同key的第一个数据进行结构的转换,实现操作
v => (v,1),
//第二个参数:分区内的计算规则
(t:(Int,Int),v) => {
(t._1 + v,t._2 + 1)
},
//第三个参数:分区间的计算规则
(t1:(Int,Int),t2:(Int,Int)) => {
(t1._1 + t2._1,t1._2 + t2._2)
}
)
val resultRDD:RDD[(String,Int)] = newRDD.mapValues{
case(num,cnt) => {
num / cnt
}
}
总结聚合算子
- reduceByKey
- aggerateByKey
- foldByKey
- combineByKey
他们几个的底层源码都是一致的,不过是进行了不同的参数封装
- 第一参数是对相同key的第一个数据(与初始值)的操作
- 第二个参数是分区内操作
- 第三个参数是分区间操作
回忆一下怎么衍生出来的,我们先是思考为什么有reduceByKey算子,我们完全可以用groupByKey算子来达到分组分区的效果,再用其它算子或方法来达到预期效果.但通过查看源码我们发现reduceByKey算子的底层比groupByKey算子底层的shuffle过程(打乱重新组合)效果更好,因为我们在还未写磁盘时就将数据提前按照逻辑聚合,缩小了数据大小,这样在写磁盘和读磁盘都减少了IO次数!之后我们想到,reduceByKey的分区内和分区间操作都只能时一样的,但遇到某些特殊需求时我们分区内和分区间操作是不一样的,所以我们就有了aggerateByKey,来实现分区内和分区间的逻辑不同的,并且还新增了一个初始值概念来解决返回值固定只能是原数据类型的问题.之后我们又想到了有没有能够做到有初始值概念来改变数据结构但分区内和分区间的逻辑相同的简写方法,于是就有有了foldByKey,但我们在一个求相同Key的值的平均值概念中体会到,我们明明将初始值也算作计算的一个分子但并没有把他算进求平均值的分母,于是就改变想法,能不能做到我们不新增初始值,只对相同key的第一条数据进行处理,结构转换来达到初始值的概念但又不会出现不算分母,故combinByKey诞生.(这只是我们的学习理解过程,并不是底层源码的实现过称)
join算子
两个不同数据源的数据,相同的key的value会连接在一起,形成元组
如果两个数据源中key没有匹配上,那么数据不会出现在结果中
val rdd1 = sc.makeRDD(List(("a",1),("b",2),("c",3)))
val rdd2 = sc.makeRDD(List(("a",5),("d",6),("e",4)))
val joinRDD:RDD[(String,(Int,Int))] = rdd1.join(rdd2)
//结果
(a,(1,5))
如果两个数据源中key有多个相同的,会依次匹配,可能会出现笛卡尔积,数据量会几何性增长,会导致性能降低
val rdd1 = sc.makeRDD(List(("a",1),("a",2),("c",3)))
val rdd2 = sc.makeRDD(List(("a",5),("c",6),("a",4)))
val joinRDD:RDD[(String,(Int,Int))] = rdd1.join(rdd2)
//结果
(a,(1,5))
(a,(1,4))
(a,(2,5))
(a,(2,4))
(c,(3,6))
leftOuterJoin算子
类似于sql语句中的LEFT JOIN,以左为主
val rdd1 = sc.makeRDD(List(("a",1),("b",2),("c",3)))
val rdd2 = sc.makeRDD(List(("a",4),("b",5)))
rdd1.leftOuterJoin(rdd2)
//结果
(a,(1,Some(4)))
(b,(2,Some(5)))
(c,(3,None))
rightOuterJoin算子
类似于sql语句中的RIGHT JOIN,以右为主
val rdd1 = sc.makeRDD(List(("a",1),("b",2)))
val rdd2 = sc.makeRDD(List(("a",4),("b",5),("c",6)))
rdd1.rightOuterJoin(rdd2)
//结果
(a,(Some(1),4))
(b,(Some(2),5))
(c,(None,6))
cogroup算子
理解为connect + group
val rdd1 = sc.makeRDD(List(("a",1),("b",2)))
val rdd2 = sc.makeRDD(List(("a",4),("b",5),("c",6),("c",7)))
rdd1.cogroup(rdd2)
//结果
(a,(CompactBuffer(1),CompactBuffer(4)))
(b,(CompactBuffer(2),CompactBuffer(5)))
(c,(CompactBuffer(),CompactBuffer(6,7)))
union算子
合并两个数据集中的元素,返回一个包含两个数据集所有元素的新数据集。
合并操作并不去除重复的元素,它简单地将两个数据集的元素合并在一起。
val resultRDD = rdd1.union(rdd2)
union
操作的成本取决于两个数据集的大小。如果两个数据集的大小差异较大,可能会导致性能问题。
如果想要合并两个数据集并去除重复的元素,可以考虑使用 distinct
算子
行动算子
其实就是触发作业(Job)执行的方法
底层代码调用的是环境对象的runJob方法
底层代码中会创建ActiveJob,并提交执行
reduce算子
聚集 RDD 中的所有元素,先聚合分区内数据,再聚合分区间数据
rdd.reduce(_ + _)
collect算子
将不同分区的数据按照分区顺序采集到Driver端内存中,形成数组
rdd.collect()
count算子
返回RDD中元素的个数
rdd.count()
first算子
返回RDD中的第一个元素
rdd.first()
take算子
返回一个由RDD的前n个元素组成的数组
rdd.take(2)
takeOrdered算子
RDD中数据排序后,返回前n个元素组成的数组
rdd.takeOrdered(3)
aggregate算子
和aggregateByKey类似,但aggregate是行动算子,直接返回结果而非RDD,并且aggregateByKey的初始值只会参与分区内计算;aggregate的初始值不仅参与分区内计算还参与分区间计算
val rdd = sc.makeRDD(List(1,2,3,4),2)
val result = rdd.aggregate(10)(_ + _,_ + _)
//结果为
10+1+2+10+3+4+10=40而非30
fold算子
对aggregate算子的分区内和分区间逻辑相同时简化
val rdd = sc.makeRDD(List(1,2,3,4),2)
val result = rdd.fold(10)(_ + _)
//结果为
10+1+2+10+3+4+10=40而非30
countByValue算子
返回map类型的集合对应rdd中每种元素出现的次数
val rdd = sc.makeRDD(List(1,1,1,4),2)
rdd.countByValue()
//结果
Map(4->1,1->3)
countByKey算子
返回的rdd中每种key出现的次数
val rdd = sc.makeRDD(List(("a",1),("a",2),("a",3)))
rdd.countByKey()
//结果为
Map(a->3)
save相关算子
- saveAsTextFile
- saveAsObjectFile
- saveAsSequenceFile
- saveAsSequenceFile方法要求数据的格式必须为k,v类型
foreach算子
val rdd = sc.makeRDD(List(1,2,3,4))
//这里的foreach是Driver端内存集合的循环遍历方法
//打印的结果是有序的,因为collecte算子从各个Executor顺序收集回Driver端
//在Driver端调用foreach方法来循环遍历数组打印
rdd.collect().foreach(println)
//这里的foreach是Executor端内存数据打印
//打印的结果是无序的
//因为这里的打印都是在foreach算子在每个Executor中调用prinln方法打印
//而每个Executor的执行是并行的,故分区间的执行都是无序的,打印的先后也就是无序的了
rdd.foreach(println)
这个例子就能体现出为什么我们给spark RDD的方法专门起个名字叫算子
算子: Operator(操作)
RDD的方法和Scala集合对象的方法不一样
集合对象的方法都是在同一个节点的内存中完成的
RDD的方法可以将计算逻辑发送到Executor端(分布式节点)执行
为了区分不同的处理效果,所以将RDD的方法称之为算子
RDD的方法外部的操作都是在Driver端执行的,而方法内部的逻辑代码是在Executor端执行的
序列化
注意点:
类的构造函数是类的属性,构造函数需要进行闭包检测,其实就等同于类进行闭包检测
rdd的持久化
rdd.cache()
cache默认持久化的操作,只能将数据保存到内存中,如果想要保存到磁盘文件,需要更改存储级别
rdd.persist(StorageLevel.DISK_ONLY)
//StorageLevel有很多存储级别可自行选择
rdd通过cache或者persist方法将前面的计算结果缓存,默认情况下会把数据以缓存在JVM的堆内存中.但是并不是这两个方法被调用时立即缓存,而是触发后面的action算子时,该rdd将会被缓存在计算节点的内存中,并供后面重用
checkpoint
checkpoin需要落盘,需要对spark对象(上下文)setCheckpointDir("路径")指定检查点保存路径
persist持久化到磁盘没有要我们指定路径的原因是persist持久化只是临时文件,在作业执行完毕后会被删除,而checkpoint即使作业执行完毕后,不会被删除,一般保存路径都是在分布式存储系统hdfs中
cache/persist/checkpoint比较
- cache
- 将数据临时存储在内存中进行数据重用
- 会在血缘关系中添加新的依赖,一旦出现问题,可以重头读取文件(想查看血缘关系可以调用rdd的toDebugString()方法)
- persist
- 将数据临时存储在磁盘文件中进行数据重用
- 涉及到磁盘IO,性能较低,但是数据安全
- 如果作业执行完毕,临时保存的数据文件就会丢失
- 会在血缘关系中添加新的依赖,一旦出现问题,可以重头读取文件
- checkpoint
- 将数据长久地保存在磁盘文件中进行数据重用
- 涉及到磁盘IO,性能较低,但是数据安全
- 为了保证数据安全,所以一般情况下,会独立执行作业(在一遍作业执行完毕后还会再执行一次)
- 为了能够提高效率,一般情况下,是需要和cache联合使用
- 执行过程中,会切断血缘关系,重新建立新的血缘关系
- checkpoint等同于改变数据源
自定义分区器
//自定义分区器
//1.继承Partitoner
//2.重写方法
class MyPartitioner extends Partitioner{
//分区数量
override def numPartitions: Int = 3
//根据数据的key值返回数据所在的分区索引(从0开始)
override def getPartition(key: Any): Int = {
key match {
case "meissi" => 0
case "neymar" => 1
case _ => 2
}
}
}
累加器
分布式共享只写变量
在Driver程序中定义的变量,在Executor端的每个Task都会得到这个变量的一份新的副本,每个task更新这些副本的值后,传回Driver端进行merge
//获取系统累加器
//spark默认就提供了简单数据聚合的累加器,传入的参数是给累加器取的名字
//系统累加器还有doubleAccumulator,collectionAccumularot....
val sumAcc = sc.longAccumulator("sum")
//后面就当一个正常变量用就行
rdd.foreach(
num => {
//使用累加器
sumAcc.add(num)
}
)
累加器还有一些问题需要注意:
少加:转换算子中调用累加器,如果没有行动算子的话,那么不会执行
多加:累加器是全局共享的,每多调一次行动算子,他就会多执行一遍
故一般情况下,累加器会放置在行动算子进行操作
自定义累加器
{
......
val rdd = sc.makeRDD(List("hello","spark","hello"))
//创建累加器对象
//向spark进行注册
val wcAcc = new MyAccumulatot()
//向spark进行注册
sc.register(wcAcc,"wordCountAcc")
rdd.foreach(
word => {
//数据的累加(使用累加器)
wcAcc.add(word)
}
)
//获取累加器的结果
println(wcAcc.value)
sc.stop()
}
//自定义数据累加器:WordCount
//1.继承AccumulatorV2,定义泛型
// IN:累加器输入的数据类型
// OUT:累加器返回的数据类型
//2.重写方法
class MyAccumulator extends AccumulatorV2[String,mutable.Map[String,Long]]{
//创建一个容器来放单词和出现次数
private var wcMap = mutable.Map[String,Long]()
//判断是否初始状态
override def isZero:Boolean = {
wcMap.isEmpty
}
//复制累加器
override def copy():AccumulatorV2[String,mutable.Map[String,Long]] = {
new MyAccumulator()
}
//重置(清空)累加器
override def reset(): Unit = {
wcMap.clear()
}
//获取累加器需要计算的值
override def add(word:String):Unit = {
val newCnt = wcMap.getOrElse(word,0L) + 1
wcMap.update(word,newCnt)
}
//Driver合并多个累加器
override def merge(other:AccumulatorV2[String,mutable.Map[String,Long]]): Unit = {
val map1 = this.wcMap
val map2 = other.value
map2.foreach{
case(word,count) => {
val newCount = map1.getOrElse(word,0L) + count
map1.update(word,newCount)
}
}
}
//累加器结果
override def value:mutable.Map[String,Long] = {
wcMap
}
}
广播变量
闭包数据都是以Task为单位发送的,每个任务中包含闭包数据,这样可能会导致,一个Executor中包含大量重复的数据,并且占用大量的内存
但Executor其实就是一个JVM,所以在启动时,会自动分配内存,完全可以将任务中的闭包数据放置在Executor的内存中,达到共享的目的
Spark中的广播变量就可以将闭包的数据保存到Executor的内存中,但Spark中的广播变量不能够更改:分布式共享只读变量
......
val rdd1 = sc.makeRDD(List(("a",1),("b",2),("c",3)))
val map = mutable.Map(("a",4),("b",5),("c",6))
//封装广播变量
val value:Broadcast[mutable.Map[String,Int]] = sc.broadcast(map)
rdd1.map{
case(w,c) => {
//访问广播变量
val l: Int = bc.value.getOrElse(w,0)
(w,(c,l))
}
}.collect().foreach(println)
sc.stop()
Spark源码及流程的重难点
- 环境准备(Yarn集群)
- Driver
- Executor
- 组件通信
- Driver => Executor
- Executor => Driver
- Executor => Executor
- 应用程序的执行
- RDD依赖
- 阶段的划分
- 任务的切分
- 任务的调度
- 任务的执行
- shuffle
- shuffle的原理和执行过程
- shuffle写磁盘
- shuffle读磁盘
- 内存的管理
- 内存的分类
- 内存的配置
源码
起点
提交任务代码实际是调用spark-submit脚本,spark-submit脚本调用Spark-submit2脚本,spark-submit2调用spark-class脚本并传入class:org.apache.spark.deploy.SparkSubmit,spark-class脚本实际最后执行的是"java org.apache.spark.deploy.SparkSubmit",这实际是打开一个JVM虚拟机来执行这个类,打开一个JVM虚拟机实际就相当于启动了一个java进程,使用jps指令是可以查看到的,当启动一个java虚拟机去执行一个进程的时候,他会走对应类的main方法,也就是我们的org.apache.spark.deploy.SparkSubmit的main方法来执行整个逻辑
进入SparkSubmit的伴生对象中,进入main方法,main方法中会new一个SparkSubmit对象为submit,随后submit调用doSubmit方法传入我们提交命令时的参数args
doSubmit方法中实际是调用父类的doSubmit方法,这个方法中定义了一个变量appArgs,他是调用parseArguments方法并传入args,也就是解析我们的参数;
在parseArguments方法中传入args来new了一个SparkSubmitArguments对象
SparkSubmitArguments这个类中会调用parse方法传入args,这个方法会用正则表达式来解析args参数,拿到参数名称和值后传入handle方法,handle方法就使用模式匹配将参数值明确解析好,例如名称为--MASTER值为yarn,则将SparkSubmitArguments中名为master的属性赋值为yarn,同理将参数全部解析.但action属性一直没有赋值,后面会判断action有没有值如果没有就赋值SUBMIT
随后appArgs获取action属性模式匹配,如果是SUBMIT就执行submit方法并传入appArgs
submit方法中会判断是否为Standalone模式,如果不是就会执行doRunMain方法,这个方法中会再判断命令行参数是否有代理用户,没传的情况下就会执行runMain方法并传入args参数
runMain方法中会定义一个(childArgs,childClasspath,sparkConf,childMainClass)变量,这个变量是调用prepareSubmitEnvironment方法(准备提交环境)并传入args参数创建的
在准备提交环境方法中会判断是哪种集群,我们使用的yarn集群,所以将childMainClass赋值为YARN_CLUSTER_SUBMIT_CLASS而这个值实际是"org.apache.spark.deploy.yarn.YarnClusterApplication"
接下来创建一个变量loader是getSubmitClassLoader(sparkConf)就是类加载器,之后使用classForName传入childMainClass,通过这个类名得到类的信息mainClass,之后会创建一个app变量,这个变量实际是判断mainClass是否继承了SparkApplication,如果继承于他,就会将mainClass通过构造器创建实例赋给app,没有继承于他就会传入mainClass来new一个JavaMainApplication对象赋给app
app调用start方法,start方法里new Client对象,在Client类中有一个yarnClient属性,这个属性是YarnClient调用createYarnClient的方法创建的,这个方法里new YarnClientImpl,而这个类中有一个属性叫做rmClient
app的start方法里的new Client对象会执行run方法,run方法里会执行submitApplication方法返回一个appId,这个appId是全局yarn的appId
submitApplication方法内会执行yarnClient.start()即yarn的客户端启动,就建立了与yarn集群的连接,这方法里的yarnClient.createApplication()就会告诉rm我们要创建一个应用得到一个响应,这个响应就是appId,接下类就会创建我们的提交环境和容器环境,之后就是提交
在源码阅读处暂停,需要去学习Flink,空时再来更新本文