1、RDD
Resilient Distributed Dataset (RDD),弹性分布式数据集
弹性是指什么?
1、内存的弹性:内存与磁盘的自动切换
2、容错的弹性:数据丢失可以自动恢复
3、计算的弹性:计算出错重试机制
4、分片的弹性:根据需要重新分片
分布式
就是RDD中的计算逻辑根据分区划分Task发送Executor(不同节点)执行
数据集
RDD中是划分了分区了,有数据的引用,不是数据的存储
主要特性
- A list of partitions 分区
- A function for computing each split 每个切片有一个计算
- A list of dependencies on other RDDs RDD之间是相互依赖的
- Optionally, a Partitioner for key-value RDDs (e.g. to say that the RDD is hash-partitioned) key/value形式的有分区器
- Optionally, a list of preferred locations to compute each split on (e.g. block locations for an HDFS file) 有优选的位置去计算分片
2、RDD依赖
血缘
package com.journey.core.wc;
import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaPairRDD;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.api.java.function.FlatMapFunction;
import org.apache.spark.api.java.function.Function2;
import org.apache.spark.api.java.function.PairFunction;
import scala.Tuple2;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
public class WordCount {
public static void main(String[] args) {
SparkConf conf = new SparkConf()
.setAppName("WordCount")
.setMaster("local[*]");
JavaSparkContext sc = new JavaSparkContext(conf);
// 如果是集群上运行,直接就是hdfs路径了
JavaRDD lineRDD = sc.textFile("datas/wc", 4);
System.out.println(lineRDD.toDebugString());
System.out.println("*************************************");
JavaRDD wordsRDD = lineRDD.flatMap(new FlatMapFunction() {
@Override
public Iterator call(String line) throws Exception {
return Arrays.asList(line.split(" ")).iterator();
}
});
System.out.println(wordsRDD.toDebugString());
System.out.println("*************************************");
JavaPairRDD wordToPairRDD = wordsRDD.mapToPair(new PairFunction() {
@Override
public Tuple2 call(String word) throws Exception {
return Tuple2.apply(word, 1);
}
});
System.out.println(wordToPairRDD.toDebugString());
System.out.println("*************************************");
JavaPairRDD word2CountRDD = wordToPairRDD.reduceByKey(new Function2() {
@Override
public Integer call(Integer v1, Integer v2) throws Exception {
return v1 + v2;
}
});
System.out.println(word2CountRDD.toDebugString());
System.out.println("*************************************");
List> result = word2CountRDD.collect();
System.out.println(result);
sc.stop();
}
}
输出结果 :
(4) datas/wc MapPartitionsRDD[1] at textFile at WordCount.java:25 []
| datas/wc HadoopRDD[0] at textFile at WordCount.java:25 []
*************************************
(4) MapPartitionsRDD[2] at flatMap at WordCount.java:29 []
| datas/wc MapPartitionsRDD[1] at textFile at WordCount.java:25 []
| datas/wc HadoopRDD[0] at textFile at WordCount.java:25 []
*************************************
(4) MapPartitionsRDD[3] at mapToPair at WordCount.java:38 []
| MapPartitionsRDD[2] at flatMap at WordCount.java:29 []
| datas/wc MapPartitionsRDD[1] at textFile at WordCount.java:25 []
| datas/wc HadoopRDD[0] at textFile at WordCount.java:25 []
*************************************
(4) ShuffledRDD[4] at reduceByKey at WordCount.java:47 []
+-(4) MapPartitionsRDD[3] at mapToPair at WordCount.java:38 []
| MapPartitionsRDD[2] at flatMap at WordCount.java:29 []
| datas/wc MapPartitionsRDD[1] at textFile at WordCount.java:25 []
| datas/wc HadoopRDD[0] at textFile at WordCount.java:25 []
*************************************
[(Spark,2), (Hello,4), (World,1), (Mayun,1)]
注意 : 前面4是指分区,有缩进是说明划分了Stage了,不在同一个Stage,其实也就是有了shuffle的操作了
RDD的依赖关系
窄依赖
表示上游的Partition最多被下游RDD的一个Partition使用class OneToOneDependency[T](rdd: RDD[T]) extends NarrowDependency[T](rdd) { override def getParents(partitionId: Int): List[Int] = List(partitionId) }
宽依赖
表示上游的Partition可以多个下游RDD的Partition依赖,会产生shuffleclass ShuffleDependency[K: ClassTag, V: ClassTag, C: ClassTag]( @transient private val _rdd: RDD[_ <: Product2[K, V]], val partitioner: Partitioner, val serializer: Serializer = SparkEnv.get.serializer, val keyOrdering: Option[Ordering[K]] = None, val aggregator: Option[Aggregator[K, V, C]] = None, val mapSideCombine: Boolean = false, val shuffleWriterProcessor: ShuffleWriteProcessor = new ShuffleWriteProcessor) extends Dependency[Product2[K, V]] with Logging {
- RDD阶段划分
RDD会组装成一个DAG,DAG是根据宽依赖进行的阶段的划分,从最后一个RDD开始进行向前找,找到有宽依赖的切分Stage,产生一个新的阶段。这个主要是由DAGScheduler来完成 RDD任务划分
RDD任务切分中的术语 : Application、Job、Stage和Task- Application : 初始化一个SparkContext即生成一个Application
- Job : 一个Action算子就会生成一个Job
- Stage : 遇到宽依赖就会产生一个新的Stage,但是默认是有最后一个Stage的,也就是ResultStage,这样Stage的个数就是 宽依赖个数 + 1,主要是DAGScheduler来完成
- Task : 一个Stage阶段中,最后一个RDD的分区个数就是Task的个数,任务的调度主要是TaskScheduler来完成
3、RDD持久化
RDD Cache缓存
先看一个示例 :
package com.journey.core.rdd.cache;
import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.api.java.function.Function;
import java.util.ArrayList;
import java.util.List;
public class CacheRDD {
public static void main(String[] args) {
SparkConf sparkConf = new SparkConf()
.setAppName("CacheRDD")
.setMaster("local[*]");
JavaSparkContext sc = new JavaSparkContext(sparkConf);
List nums = new ArrayList<>();
nums.add(1);
nums.add(2);
nums.add(3);
JavaRDD numsRDD = sc.parallelize(nums, 2);
// 每个元素 * 10
JavaRDD numsRDDTransform = numsRDD.map(new Function() {
@Override
public Integer call(Integer v1) throws Exception {
System.out.println("****************************");
return v1 * 10;
}
});
numsRDDTransform.collect().forEach(System.out::println);
numsRDDTransform.collect().forEach(System.out::println);
sc.stop();
}
}
打印输出 :
****************************
****************************
****************************
10
20
30
****************************
****************************
****************************
10
20
30
案例想说明什么呢?想法很简单,就是想重复利用一下numsRDDTransform这个算子,然后下面分两个Job来搞事情,但是惊奇的发现,咦?好像就是简单的变量复用了而已,发现计算还是从头开始计算,这里面可以看出两个点:1、RDD默认是不缓存数据的,只是数据搬运工(通过计算逻辑) 2、同时验证RDD血缘依赖,都是从头开始进行执行的
那怎么办呢?我不想让它重复的计算,比如说某些情况下,对重要的前面的逻辑计算,说白了,就是计算出结果很难,前面的结果对后面很重要,而且可能对下面要多出Job使用,或者怕之后计算有什么问题,可以从上面缓存中拿出结果容错执行,所以,来吧,缓存一下吧
修改代码入下 :
package com.journey.core.rdd.cache;
import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.api.java.function.Function;
import java.util.ArrayList;
import java.util.List;
public class CacheRDD {
public static void main(String[] args) {
SparkConf sparkConf = new SparkConf()
.setAppName("CacheRDD")
.setMaster("local[*]");
JavaSparkContext sc = new JavaSparkContext(sparkConf);
List nums = new ArrayList<>();
nums.add(1);
nums.add(2);
nums.add(3);
JavaRDD numsRDD = sc.parallelize(nums, 2);
// 每个元素 * 10
JavaRDD numsRDDTransform = numsRDD.map(new Function() {
@Override
public Integer call(Integer v1) throws Exception {
System.out.println("****************************");
return v1 * 10;
}
});
// 缓存一下RDD
numsRDDTransform.cache();
numsRDDTransform.collect().forEach(System.out::println);
numsRDDTransform.collect().forEach(System.out::println);
sc.stop();
}
}
输出结果 :
****************************
****************************
****************************
10
20
30
10
20
30
嗯,发现确实少了一次打印,但是要注意,cache的操作也是伴随着action的行动算子进行执行的,它自己是没法玩的
同时会增加血缘关系 :
如下图 CachedPartitions 就是增加的血缘
(2) MapPartitionsRDD[1] at map at CacheRDD.java:27 [Memory Deserialized 1x Replicated]
| CachedPartitions: 2; MemorySize: 96.0 B; ExternalBlockStoreSize: 0.0 B; DiskSize: 0.0 B
| ParallelCollectionRDD[0] at parallelize at CacheRDD.java:24 [Memory Deserialized 1x Replicated]
了解一下Cache的原理吧?我点
def cache(): this.type = persist()
发现原来底层调用的是 persist 方法,我再点,
def persist(): this.type = persist(StorageLevel.MEMORY_ONLY),看到StorageLevel.MEMORY_ONLY,这就是将数据缓存在内存中,还有其它的存储级别吗?
val NONE = new StorageLevel(false, false, false, false)
// 磁盘存储,2,3是每个分区存储多少个副本(不同节点),空间换计算时间
val DISK_ONLY = new StorageLevel(true, false, false, false)
val DISK_ONLY_2 = new StorageLevel(true, false, false, false, 2)
val DISK_ONLY_3 = new StorageLevel(true, false, false, false, 3)
// 内存存储,2是每个分区存储多少个副本(不同节点),SER是序列化存储,MEMORY_ONLY_SER是内存不足会溢写到磁盘上
val MEMORY_ONLY = new StorageLevel(false, true, false, true)
val MEMORY_ONLY_2 = new StorageLevel(false, true, false, true, 2)
val MEMORY_ONLY_SER = new StorageLevel(false, true, false, false)
val MEMORY_ONLY_SER_2 = new StorageLevel(false, true, false, false, 2)
val MEMORY_AND_DISK = new StorageLevel(true, true, false, true)
val MEMORY_AND_DISK_2 = new StorageLevel(true, true, false, true, 2)
val MEMORY_AND_DISK_SER = new StorageLevel(true, true, false, false)
val MEMORY_AND_DISK_SER_2 = new StorageLevel(true, true, false, false, 2)
// 堆外存储
val OFF_HEAP = new StorageLevel(true, true, true, false, 1)
RDD Checkpoint检查点
所谓的检查点其实就是通过将RDD中间结果写入磁盘。由于血缘依赖过长造成容错成本过高,这样就不如在中间阶段做检查点容错,如果检测点之后有节点出现问题,可以从检查点开始重新做血缘,减少了开销。对应RDD进行checkpoint操作并不会里面被执行,必须执行Action操作才能触发
示例 :
package com.journey.core.rdd.cache;
import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.api.java.function.Function;
import java.util.ArrayList;
import java.util.List;
public class CacheRDD {
public static void main(String[] args) {
SparkConf sparkConf = new SparkConf()
.setAppName("CacheRDD")
.setMaster("local[*]");
JavaSparkContext sc = new JavaSparkContext(sparkConf);
sc.setCheckpointDir("datas/ck");
List nums = new ArrayList<>();
nums.add(1);
nums.add(2);
nums.add(3);
JavaRDD numsRDD = sc.parallelize(nums, 2);
// 每个元素 * 10
JavaRDD numsRDDTransform = numsRDD.map(new Function() {
@Override
public Integer call(Integer v1) throws Exception {
System.out.println("****************************");
return v1 * 10;
}
});
// 缓存一下,下面有检查点了,为什么要缓存一下呢?其实checkpoint会单独走一个Job,底层判断,如果有cache的数据就不走
// Job了,所以checkpoint之前,最好先cache一下,否则会发现上面照样会打印两遍(所有元素执行一遍算一遍哈)
numsRDDTransform.cache();
// 检查点
numsRDDTransform.checkpoint();
numsRDDTransform.collect().forEach(System.out::println);
numsRDDTransform.collect().forEach(System.out::println);
System.out.println(numsRDDTransform.toDebugString());
sc.stop();
}
}
缓存和检查点区别
- Cache缓存只是将数据保存起来,不会切断血缘依赖。Checkpoint检查点会切断血缘依赖
- Cache缓存的数据通常存储在磁盘、内存等地方,可靠性低。Checkpoint的数据通常存储在HDFS等容错、高可用的文件系统,可靠性高
- 建议对checkpoint的RDD先使用Cache缓存,这样checkpoint的job只需从Cache缓存中读取数据即可,否则需要再从头计算一次RDD
4、累加器
累加器用来把Executor端变量信息聚合到Driver端。在Driver程序中定义的变量,在Executor端的每个Task都会得到这个变量的一份副本,每个task更新这些副本的值后,传回给Driver端进行merge
示例 :
简单累加
package com.journey.core.rdd.acc; import org.apache.spark.SparkConf; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.api.java.function.VoidFunction; import org.apache.spark.util.LongAccumulator; import java.util.ArrayList; import java.util.List; public class AccumulatorTest { public static void main(String[] args) { SparkConf sparkConf = new SparkConf() .setAppName("AccumulatorTest") .setMaster("local[*]"); JavaSparkContext sc = new JavaSparkContext(sparkConf); List
nums = new ArrayList<>(); nums.add(1); nums.add(2); nums.add(3); nums.add(4); LongAccumulator acc = sc.sc().longAccumulator(); JavaRDD numsRDD = sc.parallelize(nums, 2); numsRDD.foreach(new VoidFunction () { @Override public void call(Integer num) throws Exception { acc.add(num); } }); System.out.println(acc.value()); sc.stop(); } } - 自定义累加器
package com.journey.core.rdd.acc;
import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.api.java.function.VoidFunction;
import org.apache.spark.util.AccumulatorV2;
import org.apache.spark.util.LongAccumulator;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class AccumulatorTest2 {
public static void main(String[] args) {
SparkConf sparkConf = new SparkConf()
.setAppName("AccumulatorTest2")
.setMaster("local[*]");
JavaSparkContext sc = new JavaSparkContext(sparkConf);
List words = new ArrayList<>();
words.add("Hello");
words.add("Spark");
words.add("Hello");
JavaRDD wordsRDD = sc.parallelize(words, 2);
WCAccumulator accumulator = new WCAccumulator();
sc.sc().register(accumulator, "accumulator");
wordsRDD.foreach(new VoidFunction() {
@Override
public void call(String word) throws Exception {
accumulator.add(word);
}
});
System.out.println(accumulator.value());
sc.stop();
}
/**
* 自定义累加器
*/
static class WCAccumulator extends AccumulatorV2> {
private Map wordsMap = new HashMap<>();
// 累加器是否为初始状态
@Override
public boolean isZero() {
return wordsMap.isEmpty();
}
// 复制累加器,重新创建一个累加器
@Override
public AccumulatorV2> copy() {
return new WCAccumulator();
}
// 重置累加器
@Override
public void reset() {
wordsMap.clear();
}
// 向累加器中加数据
@Override
public void add(String v) {
Long newCount = wordsMap.getOrDefault(v, 0L) + 1L;
wordsMap.put(v, newCount);
}
@Override
public void merge(AccumulatorV2> other) {
Map otherMap = other.value();
Iterator> iterator = otherMap.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry item = iterator.next();
Long count = wordsMap.get(item.getKey());
if (count != null) {
Long newCount = wordsMap.get(item.getKey()) + item.getValue();
wordsMap.put(item.getKey(), newCount);
} else {
wordsMap.put(item.getKey(), item.getValue());
}
}
}
@Override
public Map value() {
return wordsMap;
}
}
}
5、广播变量
对于广播变量的存储,会在每个Executor内(此处是重点)保存一份,进而保证Executor中的多个Task进行数据的共享
示例 :
package com.journey.core.rdd.bc;
import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.api.java.function.VoidFunction;
import org.apache.spark.broadcast.Broadcast;
import scala.Tuple3;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class BCTest {
public static void main(String[] args) {
SparkConf conf = new SparkConf()
.setAppName("BCTest")
.setMaster("local[*]");
JavaSparkContext sc = new JavaSparkContext(conf);
// 学生id,姓名,班级id
List> studentInfos = new ArrayList<>();
studentInfos.add(Tuple3.apply(1, "zhagnsan", 1));
studentInfos.add(Tuple3.apply(2, "lisi", 2));
studentInfos.add(Tuple3.apply(1, "wangwu", 1));
Map classes = new HashMap<>();
classes.put(1, "班级1");
classes.put(2, "班级2");
Broadcast
结果 :
学号 : 1, 姓名 : zhagnsan, 班级 : 班级1
学号 : 2, 姓名 : lisi, 班级 : 班级2
学号 : 1, 姓名 : wangwu, 班级 : 班级1
6、Yarn Client & Yarn Cluster部署模式
Yarn Cluster
- 执行脚本提交任务,实际是启动一个SparkSubmit的JVM进程
- SparkSubmit 类中的main方法反射调用 YarnClusterApplication 的main方法
- YarnClusterApplication 创建Yarn客户端,然后向Yarn发送指令 : bin/java ApplicationMaster
- Yarn接受到指令后会在指定的NodeManager中启动ApplicationMaster
- ApplicationMaster启动Driver线程,执行用户的作业
- ApplicationMaster向ResourceManager注册,申请资源
- 获取资源后ApplicationMaster向NodeManager发送指令 : bin/java YarnCoarsedExecutorBackend
- CoarseGrainedExecutorBackend 进程会接收消息,跟 Driver 通信,注册已经启动的Executor, 然后启动计算对象 Executor 等待接收任务
- Driver线程继续执行完成作业的调度(DAGScheduler)和任务的执行(TaskScheduler)
- Driver 分配任务并监控任务的执行
注意 : SparkSubmit、ApplicationMaster 和 CoarseGrainedExecutorBackend 是独立的进程;Driver 是独立的线程;Executor 和 YarnClusterApplication 是对象
Yarn Client
- 执行脚本提交任务,实际是启动一个 SparkSubmit 的 JVM 进程
- SparkSubmit 类中的 main 方法反射调用用户代码的 main 方法;
- 启动 Driver 线程,执行用户的作业,并创建 ScheduleBackend;
- YarnClientSchedulerBackend 向 RM 发送指令 : bin/java ExecutorLauncher
- Yarn 框架收到指令后会在指定的 NodeManager 中启动 ExecutorLauncher(实际上还是调用ApplicationMaster 的 main 方法);
- ApplicationMaster 向 ResourceManager 注册,申请资源
- 获取资源后 ApplicationMaster 向 NodeManager 发送指令 : bin/java CoarseGrainedExecutorBackend
- CoarseGrainedExecutorBackend 进程会接收消息,跟 Driver 通信,注册已经启动的Executor, 然后启动计算对象 Executor 等待接收任务
- Driver 分配任务并监控任务的执行
注意 : SparkSubmit、ApplicationMaster 和 YarnCoarseGrainedExecutorBackend 是独立的进 程;Executor 和 Driver 是对象,
和Yarn Cluster主要不同的就是Driver的运行位置,Yarn Client Driver就是 SparkSubmit 中一个线程,Yarn Cluster Driver是ApplicationMaster中的一个线程
6、Spark网络通信
组件解析 :
RpcEndpoint : RPC通信终端。Spark针对每个节点(Driver/ApplicationMaster/Executor)都称之为一个RPC终端,且都实现 RpcEndpoint接口,内部根据不同端点的需求,设计不同的消息和不同的业务处理,如果需要发送(询问)则调用Dispatcher。在 Spark中,所有的终端都存在生命周期
- Constructor
- onStart
- receive*
- onStop
- RpcEnv : RPC上下文环境,每个RPC终端运行时依赖的上下文环境称为RpcEnv;在把当前 Spark版本中使用的NettyRpcEnv(重要),封装了Netty的网络通信
- Dispatcher : 消息调度(分发)器,针对于RPC终端需要发送远程消息或者从远程RPC接收到的消息,分发至对应的指令收件箱(发件箱)。如果指令接收方是自己则存入收件箱,如果指令接收方不是自己,则放入发件箱
- Inbox : 指令消息收件箱。一个本地RpcEndpoint对应一个收件箱,Dispatcher在每次向Inbox存入消息时,都将对应EndpointData加入内部ReceiverQueue中,另外 Dispatcher 创建时会启动一个单独线程进行轮询 ReceiverQueue,进行收件箱消息消费
- RpcEndpointRef : RpcEndpointRef是对远程RpcEndpoint的一个引用。当我们需要向一个具体的RpcEndpoint发送消息时,一般我们需要获取到该RpcEndpoint的引用,然后 通过该应用发送消息
- OutBox : 指令消息发件箱。对于当前RpcEndpoint来说,一个目标RpcEndpoint对应一个发件箱,如果向多个目标RpcEndpoint发送信息,则有多个OutBox。当消息放入 Outbox后,紧接着通过TransportClient 将消息发送出去。消息放入发件箱以及发送过程是在同一个线程中进行
- RpcAddress : 表示远程的RpcEndpointRef的地址,Host+Port
- TransportClient : Netty通信客户端,一个OutBox对应一个TransportClient,TransportClient不断轮询OutBox,根据 OutBox 消息的receiver信息,请求对应的远程TransportServer
- TransportServer : Netty通信服务端,一个RpcEndpoint对应一个TransportServer,接受远程消息后调用Dispatcher分发消息至对应收发件箱
源码如下(以Executor启动给Driver注册为案例):
注意 : 如若看不清,https://www.processon.com/v/645e1c5bf4065d11d7e33da8
7、Spark任务调度
总体启动调度流程
生产环境一般使用YARN Cluster模式,ApplicationMaster进程中会启动Driver线程,Driver线程主要是初始化SparkContext对象,准备运行所需的上下文,然后一方面保持与ApplicationMaster的RPC连接,通过ApplicationMaster申请资源,另一方面根据用户业务逻辑开始调度任务,将任务下发到已有的空闲Executor上。
当ResourceManager向ApplicationMaster返回Container资源时,ApplicationMaster就尝试在对应的NodeManager的Container上启动Executor进程,Executor进程起来后,会向Driver反向注册,注册成功后保持与Driver的心跳,同时等待Driver分发任务,当分发的任务执行完毕后,将任务状态上报给Driver
Spark任务调度
一个Spark应用程序包括 Job、Stage 以及 Task 三个概念:
- Job : 以Action动作算子来划分一个Job
- Stage : 是Job的子集,以RDD宽依赖(即 Shuffle)为界,遇到Shuffle做一次划分,总共Stage数是Shuffle个数 + 1,最后一个Stage叫做ResultStage、其他叫做ShuffleMapStage
- Task : 是Stage的子集,以并行度(分区数)来衡量,分区数是多少,则有多少个task
Driver初始化SparkContext 过程中,会分别初始化 DAGScheduler、TaskScheduler、 SchedulerBackend 以及 HeartbeatReceiver,并启动 SchedulerBackend 以及 HeartbeatReceiver。
- SchedulerBackend : 通过ApplicationMaster申请资源,并不断从 TaskScheduler中拿到合适的Task分发到Executor执行
- HeartbeatReceiver : 负责接收Executor的心跳信息,监控Executor的存活状况,并通知到TaskScheduler
Spark的任务调度总体分为Stage的调度和Task的调度,具体是由DAGScheduler和TaskScheduler两个组件来完成
- DAGScheduler : DAGScheduler负责Stage级的调度,主要是将Job切分成若干Stages,并将每个Stage打包成TaskSet交给TaskScheduler调度
- TaskScheduler : 负责Task级的调度,将DAGScheduler给过来的TaskSet按照指定的调度策略分发到Executor上执行,调度过程中SchedulerBackend负责提供可用资源,其中SchedulerBackend有多种实现,分别对接不同的资源管理系统
调度策略
TaskScheduler支持两种调度策略,一种是FIFO,也是默认的调度策略,另一种是FAIR。 在TaskScheduler初始化过程中会实例化rootPool,表示树的根节点,是Pool类型
- FIFO调度策略
如果是采用FIFO调度策略,则直接简单地将TaskSetManager按照先来先到的方式入队,出队时直接拿出最先进队的TaskSetManager,其树结构如下图所示,TaskSetManager 保 存在一个FIFO队列中 - FAIR 调度策略
FAIR模式中有一个rootPool和多个子Pool,各个子Pool中存储着所有待分配的 TaskSetMagager
在FAIR模式中,需要先对子Pool进行排序,再对子Pool里面的TaskSetMagager进行排序,因为Pool和TaskSetMagager都继承了Schedulable特质,因此使用相同的排序算法
排序过程的比较是基于 air-share来比较的,每个要排序的对象包含三个属性: runningTasks值(正在运行的Task数)、minShare值、weight值,比较时会综合考量 runningTasks值,minShare值以及weight值
注意 : minShare、weight 的值均在公平调度配置文件fairscheduler.xml中被指定,调度池在构建阶段会读取此文件的相关配置
1) 如果A对象的runningTasks大于它的minShare,B对象的runningTasks小于它的 minShare,那么B排在A前面(runningTasks 比 minShare 小的先执行)
2) 如果A、B对象的runningTasks都小于它们的minShare,那么就比较runningTasks 与 minShare的比值(minShare使用率),谁小谁排前面(minShare使用率低的先执行)
3) 如果A、B对象的runningTasks都大于它们的minShare,那么就比较runningTasks与
weight的比值(权重使用率),谁小谁排前面。(权重使用率低的先执行)
4) 如果上述比较均相等,则比较名字
整体上来说就是通过 minShare 和 weight 这两个参数控制比较过程,可以做到让 minShare使用率和权重使用率少(实际运行 task 比例较少)的先运行
FAIR 模式排序完成后,所有的TaskSetManager被放入一个ArrayBuffer里,之后依次 被取出并发送给Executor执行
从调度队列中拿到TaskSetManager后,由于TaskSetManager 封装了一个Stage的所有 Task,并负责管理调度这些Task,那么接下来的工作就是TaskSetManager按照一定的规则 一个个取出Task给TaskScheduler,TaskScheduler再交给 SchedulerBackend去发到Executor上执行
本地化调度
DAGScheduler切割Job,划分Stage, 通过调用submitStage来提交一个Stage对应的tasks,submitStage会调用submitMissingTasks,submitMissingTasks确定每个需要计算的task的preferredLocations,通过调用 getPreferrdeLocations()得到 partition 的优先位置,由于一个partition对应一个Task,此partition的优先位置就是task的优先位置,对于要提交到TaskScheduler的TaskSet中的每一个Task,该task优先位置与其对应的partition对应的优先位置一致
根据每个Task的优先位置,确定Task的Locality级别,Locality一共有五种,优先级 由高到低顺序
名称 | 解析 |
---|---|
PROCESS_LOCAL | 进程本地化,task和数据在同一个Executor中,性能最好 |
NODE_LOCAL | 节点本地化,task和数据在同一个节点中,但是task和数据不在同一个 Executor中,数据需要在进程间进行传输 |
RACK_LOCAL | 机架本地化,task和数据在同一个机架的两个节点上,数据需要通过网络在节点之间进行传输 |
NO_PREF | 对于task来说,从哪里获取都一样,没有好坏之分 |
ANY | task和数据可以在集群的任何地方,而且不在一个机架中,性能最差 |
源码分析
8、Spark Shuffle
ShuffleMapStage与ResultStage
在划分stage时,最后一个stage称为finalStage,它本质上是一个ResultStage 对象,前面的所有stage被称为ShuffleMapStage。ShuffleMapStage的结束伴随着shuffle文件的写磁盘。ResultStage基本上对应代码中的 action算子,即将一个函数应用在RDD的各个partition的数据集上,意味着一个JOb的运行结束
HashShuffle
未优化的HashShuffle
假设前提 : 每个 Executor只有1个 CPU core,也就是说,无论这个Executor上分配多少个task线程,同一时间都只能执行一个task线程。如下图中有3个Reducer,从Task 开始那边各自把自己进行Hash计算(分区器: hash/numreduce 取模),分类出3个不同的类别,每个Task都分成3种类别的数据,想把不同的数据汇聚然后计算出最终的结果,所以 Reducer会在每个Task中把属于自己类别的数据收集过来,汇聚成一个同类别的大集合,每1 个Task输出3份本地文件,这里有4个MapperTasks,所以总共输出了4个Tasks x 3个分类文件=12 个本地小文件
优化后的 HashShuffle
优化的HashShuffle过程就是启用合并机制,合并机制就是复用buffe,开启合并机制
的配置是 spark.shuffle.consolidateFiles。该参数默认值为false,将其设置为 true即可开启优化机制。通常来说,如果我们使用HashShuffleManager,那么都建议开启这个选项
这里还是有4个Tasks,数据类别还是分成3种类型,因为Hash算法会根据你的Key进行分类,在同一个进程中,无论是有多少过Task,都会把同样的Key放在同一个Buffer里,然后把 Buffer中的数据写入以Core数量为单位的本地文件中,(一个Core只有一种类型的Key的数据),每1个Task所在的进程中,分别写入共同进程中的3份本地文件,这里有4个Mapper Tasks,所以总共输出是2个Cores x 3个分类文件 = 6个本地小文件
SortShuffle解析
普通SortShuffle
在该模式下,数据会先写入一个数据结构,比如 : reduceByKey会先写入Map,一边通过 Map局部聚合,一遍写入内存。而Join算子写入ArrayList直接写入内存中。然后需要判断是否达到阈值,如果达到就会将内存数据结构的数据写入到磁盘,清空内存数据结构
在溢写磁盘前,先根据key进行排序,排序过后的数据,会分批写入到磁盘文件中。默认批次为10000条,数据会以每批一万条写入到磁盘文件。写入磁盘文件通过缓冲区溢写的方式,每次溢写都会产生一个磁盘文件,也就是说一个Task过程会产生多个临时文件
最后在每个Task中,将所有的临时文件合并,这就是merge过程,此过程将所有临时文件读取出来,一次写入到最终文件。意味着一个Task的所有数据都在这一个文件中。同时单独写一份索引文件,标识下游各个Task的数据在文件中的索引,startoffset和endoffset
bypass SortShuffle
bypass 运行机制的触发条件如下 :
- shuffle reduce task 数量小于等于spark.shuffle.sort.bypassMergeThreshold参数的值,默认为200
不是聚合类的shuffle算子(比如reduceByKey)
此时task会为每个reduce端的task都创建一个临时磁盘文件,并将数据按key进行
hash然后根据key的hash值,将key写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并创建一个单独的索引文件该过程的磁盘写机制其实跟未经优化的HashShuffleManager是一模一样的,因为都要 创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的HashShuffleManager来说,shuffle read 的性能会更好
而该机制与普通SortShuffleManager运行机制的不同在于 : 不会进行排序。也就是说, 启用该机制的最大好处在于,shuffle write 过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销
9、Spark内存管理
堆内和堆外内
作为一个JVM进程,Executor的内存管理建立在JVM的内存管理之上,Spark 对 JVM的堆内(On-heap)空间进行了更为详细的分配,以充分利用内存。同时,Spark引入了堆外 (Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,进一步优化了内存的使用。堆内内存受到JVM统一管理,堆外内存是直接向操作系统进行内存的申请和释放
堆内内存
堆内内存的大小由Spark应用程序启动时的executor-memory或spark.executor.memory 参数配置。Executor内运行的并发任务共享JVM堆内内存,这些任务在缓存RDD数据和广播(Broadcast)数据时占用的内存被规划为存储(Storage)内存, 而这些任务在执行Shuffle时占用的内存被规划为执行(Execution)内存,剩余的部分不做特殊规划,那些Spark内部的对象实例,或者用户定义的Spark应用程序中的对象实例,均占用剩余的空间。不同的管理模式下,这三部分占用的空间大小各不相同
Spark对堆内内存的管理是一种逻辑上的”规划式”的管理,**因为对象实例占用内存的申请和释放都由JVM完成,Spark只能在申请后和释放前记录这些内存**
申请内存流程如下 :
- Spark在代码中new一个对象实例
- JVM从堆内内存分配空间,创建对象并返回对象引用
- Spark保存该对象的引用,记录该对象占用的内存
释放内存流程如下 :
- Spark记录该对象释放的内存,删除该对象的引用
- 等待JVM的垃圾回收机制释放该对象占用的堆内内存
为什么只是一种逻辑上的管理 ?
我们知道,JVM 的对象可以以序列化的方式存储,序列化的过程是将对象转换为二进
制字节流,本质上可以理解为将非连续空间的链式存储转化为连续空间或块存储,在访问时 则需要进行序列化的逆过程——反序列化,将字节流转化为对象,序列化的方式可以节省存 储空间,但增加了存储和读取时候的计算开销
对于Spark中序列化的对象,由于是字节流的形式,其占用的内存大小可直接计算,而对于非序列化的对象,其占用的内存是通过周期性地采样近似估算而得,即并不是每次新增的数据项都会计算一次占用的内存大小,这种方法降低了时间开销但是有可能误差较大,导致某一时刻的实际内存有可能远远超出预期。此外,在被Spark标记为释放的对象实例,很有可能在实际上并没有被JVM回收,导致实际可用的内存小于Spark记录的可用内存。所以Spark 并不能准确记录实际可用的堆内内存,从而也就无法完全避免内存溢出(OOM, Out of Memory)的异常
虽然不能精准控制堆内内存的申请和释放,但 Spark 通过对存储内存和执行内存各自独立的规划管理,可以决定是否要在存储内存里缓存新的RDD,以及是否为新的任务分配执行内存,在一定程度上可以提升内存的利用率,减少异常的出现
堆外内存
为了进一步优化内存的使用以及提高Shuffle时排序的效率,Spark引入了堆外(Off-heap)内存,使之可以直接在工作节点的系统内存中开辟空间,存储经过序列化的二进制数据
堆外内存意味着把内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机)。这样做的结果就是能保持一个较小的堆,以减少垃圾收集对应用的影
响
利用JDK Unsafe API(从 Spark 2.0 开始,在管理堆外的存储内存时不再基于 Tachyon, 而是与堆外的执行内存一样,基于 JDKUnsafeAPI 实现),Spark可以直接操作系统堆外内存,减少了不必要的内存开销,以及频繁的GC扫描和回收,提升了处理性能。堆外内存可以被精确地申请和释放(堆外内存之所以能够被精确的申请和释放,是由于内存的申请和释放不再通过JVM机制,而是直接向操作系统申请,JVM对于内存的清理是无法准确指定时间点的,因此无法实现精确的释放),而且序列化的数据占用的空间可以被精确计算,所以相比堆内内存来说降低了管理的难度,也降低了误差
在默认情况下堆外内存并不启用,可通过配置 spark.memory.offHeap.enabled 参数启用, 并由spark.memory.offHeap.size参数设定堆外空间的大小。除了没有 other空间,堆外内存与堆内内存的划分方式相同,所有运行中的并发任务共享存储内存和执行内存
内存空间分配
静态内存管理
在Spark最初采用的静态内存管理机制下,存储内存、执行内存和其他内存的大小在Spark应用程序运行期间均为固定的,但用户可以应用程序启动前进行配置,堆内内存的分配如图所示 :
堆内内存的大小需要按照下列方式计算 :
可用的存储内存 = systemMaxMemory * spark.storage.memoryFraction * spark.storage.safety
Fraction
可用的执行内存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safety Fraction
其中systemMaxMemory取决于当前JVM堆内内存的大小,最后可用的执行内存或者存储 内存要在此基础上与各自的memoryFraction参数和safetyFraction 参数相乘得出。上述计算 公式中的两个 safetyFraction 参数,其意义在于在逻辑上预留出 safetyFraction这么一块保险区域,降低因实际内存超出当前预设范围而导致OOM的风险(上文提到,对于非序 列化对象的内存采样估算会产生误差)。值得注意的是,这个预留的保险区域仅仅是一种逻辑上的规划,在具体使用时Spark并没有区别对待,和”其它内存”一样交给了JVM去管理
Storage内存和Execution内存都有预留空间,目的是防止OOM,因为Spark堆内内存大小的记录是不准确的,需要留出保险区域
堆外的空间分配较为简单,只有存储内存和执行内存,如下图所示。可用的执行内存和存储内存占用的空间大小直接由参数spark.memory.storageFraction决定,由于堆外内存占用的空间可以被精确计算,所以无需再设定保险区域。
静态内存管理机制实现起来较为简单,但如果用户不熟悉Spark的存储机制,或没有根据具体的数据规模和计算任务或做相应的配置,很容易造成”一半海水,一半火焰”的局面,即存储内存和执行内存中的一方剩余大量的空间,而另一方却早早被占满,不得不淘汰或移出旧的内容以存储新的内容。由于新的内存管理机制的出现,这种方式目前已经很少有开发者使用,出于兼容旧版本的应用程序的目的,Spark仍然保留了它的实现
统一内存管理
Spark1.6之后引入的统一内存管理机制,**与静态内存管理的区别在于存储内存和执行
内存共享同一块空间,可以动态占用对方的空闲区域**,统一内存管理的堆内内存结构如图所示 :
其中最重要的优化在于动态占用机制,其规则如下 :
- 设定基本的存储内存和执行内存区域(spark.storage.storageFraction参数),该设定确定了双方各自拥有的空间的范围
- 双方的空间都不足时,则存储到硬盘,若自己空间不足而对方空余时,可借用对方的空
间,(存储空间不足是指不足以放下一个完整的Block) - 执行内存的空间被对方占用后,可让对方将占用的部分转存到硬盘,然后”归还”借用的
空间; - 存储内存的空间被对方占用后,无法让对方”归还”,因为需要考虑Shuffle过程中的很
多因素,实现起来较为复杂
总结 : 说白了,其实就是已知别人有钱的时候,我没有钱,则一定要借给我。但是如果对方缺钱的的时候,则要看,这个人的重要性,如果比我重要(也就是他比我更需要钱,也就是Execution内存比较重要,shuffle数据)。我要把欠别人的钱给换上,即使让我破产,也要这么做。否则反过来,这个人没有比我重要(存储内存,RDD缓存和广播变量),好吧,那我拼接实例借的钱,为啥要换?
存储内存管理
RDD的持久化机制
弹性分布式数据集(RDD)作为Spark最根本的数据抽象,是只读的分区记录(Partition)
的集合,只能基于在稳定物理存储中的数据集上创建,或者在其他已有的RDD上执行转换 (Transformation)操作产生一个新的 RDD。转换后的RDD与原始的RDD之间产生的依赖关系,构成了血统(Lineage)。凭借血统,Spark保证了每一个RDD都可以被重新恢复。但 RDD的所有转换都是惰性的,即只有当一个返回结果给Driver的行动(Action)发生时, Spark才会创建任务读取RDD,然后真正触发转换的执行
Task在启动之初读取一个分区时,会先判断这个分区是否已经被持久化,如果没有则需要检查 Checkpoint或按照血统重新计算。所以如果一个RDD上要执行多次行动,可以在第一次行动中使用persist或cache方法,在内存或磁盘中持久化或缓存这个RDD,从而在后面的行动时提升计算速度
事实上,cache方法是使用默认的MEMORY_ONLY的存储级别将RDD持久化到内存,故缓存是一种特殊的持久化。 堆内和堆外存储内存的设计,便可以对缓存RDD时使用的内存做统一的规划和管理
RDD的持久化由Spark的Storage模块负责,实现了RDD与物理存储的解耦合。Storage模块负责管理Spark在计算过程中产生的数据,将那些在内存或磁盘、在本地或远程存取数据的功能封装了起来。在具体实现时Driver端和Executor端的Storage模块构成了主从式的架构,即Driver端的BlockManager为 Master,Executor端的 BlockManager为Slave。 Storage模块在逻辑上以Block为基本存储单位,RDD的每个Partition经过处理后唯一对应一个Block(BlockId 的格式为rdd_RDD-ID_PARTITION-ID )。Driver端的Master负责整个Spark应用程序的Block的元数据信息的管理和维护,而Executor端的Slave需要将Block的更新等状态上报到Master,同时接收Master的命令,例如新增或删除一个RDD
在对RDD持久化时,Spark规定了MEMORY_ONLY、MEMORY_AND_DISK等7种不同的存储级别,而存储级别是以下5个变量的组合 :
class StorageLevel private(
private var _useDisk: Boolean, // 磁盘
private var _useMemory: Boolean, // 这里其实是指堆内内存
private var _useOffHeap: Boolean, // 堆外内存
private var _deserialized: Boolean, // 是否为非序列化
private var _replication: Int = 1 // 副本个数
)
RDD在缓存到存储内存之前,Partition中的数据一般以迭代器(Iterator)的数据结构来访问,这是Scala语言中一种遍历数据集合的方法。通过 Iterator 可以获取分区中每一条序 列化或者非序列化的数据项(Record),这些 Record 的对象实例在逻辑上占用了JVM 堆内内 存的other部分的空间,同一Partition的不同Record的存储空间并不连续。RDD在缓存到存储内存之后,Partition被转换成Block,Record在堆内或堆外存储内存中 占用一块连续的空间。将Partition由不连续的存储空间转换为连续存储空间的过程,Spark 称之为"展开"(Unroll)
Block有序列化和非序列化两种存储格式,具体以哪种方式取决于该RDD的存储级别。非 序列化的Block以一种 DeserializedMemoryEntry的数据结构定义,用一个数组存储所有的对象实例,序列化的Block则以SerializedMemoryEntry的数据结构定义,用字节缓冲区 (ByteBuffer)来存储二进制数据。每个Executor的Storage模块用一个链式Map结构 (LinkedHashMap)来管理堆内和堆外存储内存中所有的Block对象的实例,对这个 LinkedHashMap新增和删除间接记录了内存的申请和释放。 因为不能保证存储空间可以一次容纳Iterator中的所有数据,当前的计算任务在Unroll时要向MemoryManager申请足够的 Unroll空间来临时占位,空间不足则Unroll失败,空间足够时可以继续进行
对于序列化的Partition,其所需的Unroll空间可以直接累加计算,一次申请。 对于非序列化的Partition则要在遍历 Record的过程中依次申请,即每读取一条Record, 采样估算其所需的Unroll空间并进行申请,空间不足时可以中断,释放已占用的Unroll空 间。
如果最终Unroll成功,当前Partition所占用的Unroll空间被转换为正常的缓存RDD的存储空间,如下图所示 :
淘汰与落盘
由于同一个Executor的所有的计算任务共享有限的存储内存空间,当有新的Block需要缓存但是剩余空间不足且无法动态占用时,就要对LinkedHashMap中的旧Block进行淘汰(Eviction),而被淘汰的Block如果其存储级别中同时包含存储到磁盘的要求,则要对其 进行落盘(Drop),否则直接删除该Block
存储内存的淘汰规则为 :
- 被淘汰的旧Block要与新Block的MemoryMode相同,即同属于堆外或堆内内存
- 新旧Block不能属于同一个RDD,避免循环淘汰
- 旧Block所属RDD不能处于被读状态,避免引发一致性问题
- 遍历LinkedHashMap中Block,按照最近最少使用(LRU)的顺序淘汰,直到满足新Block所需的空间。其中LRU是LinkedHashMap的特性
执行内存管理
执行内存主要用来存储任务在执行Shuffle时占用的内存,Shuffle是按照一定规则对RDD数据重新分区的过程,我们来看Shuffle的Write和Read两阶段对执行内存的使用
Shuffle Write
若在map端选择普通的排序方式,会采用ExternalSorter进行外排,在内存中存储数据时主 要占用堆内执行空间
若在map端选择Tungsten的排序方式,则采用ShuffleExternalSorter直接对以序列化形式存储的数据排序,在内存中存储数据时可以占用堆外或堆内执行空间,取决于用户是否开启了堆外内存以及堆外执行内存是否足够
Shuffle Read
在对reduce端的数据进行聚合时,要将数据交给Aggregator处理,在内存中存储数据时占 用堆内执行空间
如果需要进行最终结果排序,则要将再次将数据交给ExternalSorter处理,占用堆内执行空间
在ExternalSorter和Aggregator中,Spark会使用一种叫AppendOnlyMap的哈希表在堆内执行内存中存储数据,但在Shuffle过程中所有数据并不能都保存到该哈希表中,当这个哈希表占用的内存会进行周期性地采样估算,当其大到一定程度,无法再从 MemoryManager申请到新的执行内存时,Spark就会将其全部内容存储到磁盘文件中,这个过程被称为溢存 (Spill),溢存到磁盘的文件最后会被归并(Merge)
Shuffle Write阶段中用到的Tungsten是Databricks公司提出的对Spark优化内存和 CPU 使用的计划(钨丝计划),解决了一些JVM在性能上的限制和弊端。Spark会根据Shuffle的情 况来自动选择是否采用Tungsten排序
Tungsten采用的页式内存管理机制建立在MemoryManager之上,即Tungsten对执行内存 的使用进行了一步的抽象,这样在 Shuffle过程中无需关心数据具体存储在堆内还是堆外。每个内存页用一个MemoryBlock来定义,并用 Object obj和long offset这两个变量统一标 识一个内存页在系统内存中的地址
堆内的MemoryBlock是以long型数组的形式分配的内存,其obj的值为是这个数组的对象引用,offset是long 型数组的在JVM中的初始偏移地址,两者配合使用可以定位这个数组在堆内的绝对地址;堆外的MemoryBlock是直接申请到的内存块,其 obj 为 null,offset是 这个内存块在系统内存中的64位绝对地址。Spark用MemoryBlock巧妙地将堆内和堆外内存页统一抽象封装,并用页表(pageTable)管理每个Task申请到的内存页
如感兴趣,点赞加关注,谢谢