为能够进行高并发和高吞吐的数据处理,Spark封装了三大数据结构,用于不同的数据场景。
包括 RDD、累加器、广播变量。下面详细介绍这三大数据结构。
一、RDD
1、什么是RDD
前面提到RDD 是弹性分布式数据集,是Spark最基本的数据抽象。代表一个不可变、可分区、元素可并发计算的集合。
弹性包括:
存储的弹性,涉及内存、磁盘的自动切换。
容错的弹性,数据丢失会自动恢复。
计算的弹性,当计算出错会启动重试机制,默认task 重试4次,stage 重试4次。
调度的弹性,DAGScheduler 会自动处理stage 和 task的失败,调度到正常的节点上执行。
分片的弹性,可以根据需要重新分片。
数据集:封装了计算逻辑,但不保存数据。
数据抽象:抽象成一个类,通过子类具体实现。
不可变:RDD封装的计算逻辑不可以改变,如需改变则需要重新生成RDD。
可分区:任务被分成多个Task,发送到不同的Executor 执行,从而实现并行计算。
2、为什么有RDD
Spark提供了一个抽象的数据模型,不用担心底层数据的分布式特性,只需将具体的计算逻辑表达为一系列转换操作。不同的RDD之间的转换还可以形成依赖关系,进而实现管道化,避免中间结果的存储,降低数据复制、磁盘IO以及序列化等操作的开销。
3、RDD属性
主要属性包括:
4、RDD优点
通过上面的介绍可以发现RDD的一些特性优点,其中就包括: 支持迭代、流批处理、交互式SQL查询,同时适合多用户管理,运行应用程序的弹性扩展和缩减计算资源等。
对比Hadoop的MapReduce, Spark 通过丰富的RDD 计算模型,让开发人员更容易掌握。计算的中间结果优先存放到内存减少中间结果落盘带来的磁盘IO开销。同时在计算过程中分区相同的转换构成流水在一个task上执行;分区不同的需要shuffle操作的,则被划分到不同的stage,从而实现的分布式和并发计算,大大提升了计算性能。
5、RDD算子
算子可以理解为方法函数,主要包括 转换算子 和 行动算子两类,在后续的文章中会针对这两类算子进行详细的介绍,这里只需理解概念。其中 转换算子 是 功能逻辑的封装,懒加载不执行。而行动算子是实际出发任务调度和作业执行,每一个行动算子就触发生成一个Job。
6、算子的执行端
简单理解,RDD 算子的执行端包扩两个部分,算子外部的操作是在Driver端,而内部的操作是发送到Executor 端执行。
7、RDD操作流程
RDD的操作流程可以四类:创建操作、转换操作、控制操作、行动操作。
创建操作:用于创建RDD,方式包括来自内存集合或外部存储,如 textFile("hdfs://xxx/word.txt");
通过转换生成,如 makeRDD(1 to 5) 生成一个序列。
转换操作:将一个RDD通过一定的操作转换生成一个新的RDD,这个过程是惰性的,即只定义不执行。如:map(x=> x*2)
控制操作:是对RDD的持久化,按照不同的存储策略存储到不同的地方,如内存、磁盘等。
行动操作:触发Spark运行的操作,底层是调用环境对象的runJob放放风,创建一个Job并提交执行,并将结果保存成集合、变量 或 外部存储中。
案例:
val conf: SparkConf = new SparkConf().setMaster("local[*]").setAppName("TestRDD") val sc = new SparkContext(conf) // 定义SparkContext 环境对象 val arr: RDD[Int] = sc.makeRDD(1 to 5) // 创建操作,生成一个序列RDD val mapRDD: RDD[Int] = arr.map(x => x * 2) // 转换操作,对序列的每一个元素乘2 mapRDD.cache() // 控制操作,缓存到内存中,也可以存储到磁盘或者 checkpoint 持久化操作,这个在后面会详细讲解。 mapRDD.collect().foreach(println) // 执行操作, 将结果打印 sc.stop() // 关闭环境
8、RDD序列化
为了将对象数据在网络上传输,需要将其做转换。其中将对象数据转换二进制是序列化,将二进制对象数据恢复则是反序列化。在Spark中RDD有时需要引用外部对象或数据,所以需要序列化。当然Spark中还有其他一些需要序列化的场景如 Task 分发、 RDD缓存、Shuffle 过程等,有兴趣可以自行研究。
RDD的序列化只能使用Java 序列化器 和 Kryo序列化器。后续有时间再详细展开讲解。
9、RDD依赖关系
前面文章提到一个任务会被划分成多个Stage,每个Stage再被划分成多个task。这个过程就构成了一个DAG(有向无环图), 在DAG中,相邻的两个RDD的关系是依赖关系,多个连续的RDD 的依赖关系是血缘关系。每个RDD都会保存血缘关系,一旦出现错误,可以通过血缘关系将数据重新计算。
当一个RDD的分区被其下游的RDD的多个分区依赖,则这个关系就是宽依赖。
当一个RDD的分区只会被下游的RDD的一个分区依赖,这种关系叫窄依赖。
在DAG划分中,当存在宽依赖时,就会被划分出一个Stage,每个Stage在根据分区的不同划分成N个Task。执行过程中,碰到宽依赖,则需等待上一阶段计算完成才会进行下一阶段。而碰到窄依赖时,多个分区可以并行计算。
查看依赖关系可以通过 打印 RDD的 debug信息(toDebugString)或者 (dependencies)
val arr: RDD[Int] = sc.makeRDD(1 to 5) val mapRDD: RDD[Int] = arr.map(x => x * 2) mapRDD.cache() mapRDD.collect().foreach(println) println(mapRDD.toDebugString)
输出:
(4) MapPartitionsRDD[1] at map at SparkRDDDemo.scala:14 [Memory Deserialized 1x Replicated]
| CachedPartitions: 4; MemorySize: 96.0 B; DiskSize: 0.0 B
| ParallelCollectionRDD[0] at makeRDD at SparkRDDDemo.scala:13 [Memory Deserialized 1x Replicated]
10、持久化和CheckPoint
在实际的开发中会碰到 RDD的计算或转换比较耗时的节点,可将这些RDD持久化或在缓存便于下次使用,从而提升效率。
持久化方法包括: cache 默认, persist (可设置存储级别,包括 内存、磁盘、内存和磁盘以及多份等),在需要执行持久化的点 添加 如:
xxrdd.cache() 或者 xxrdd.persist(StorageLevel.NONE) 等,便可实现。
Checkpoint:设置检查点,将数据落盘实现RDD的容错和高可用。需要先设置保存路径在执行。如:
sc.setCheckpointDir("output")
xxrdd.checkpoint()
需要注意的是,checkpoint 会重建血缘关系。如:
两者的区别:
通过对比可以发现,持久化在作业执行完后会丢失,而checkpoint 会涉及磁盘IO的开销,所以在实际生产中会将两者结合起来使用,即可以能提升性能又能保证数据的安全。
11、RDD容错
通过之前的介绍,可以总结出对于RDD的容错分为三个层面。
调度层:利用重试达到容错,包括Stage 和 Task的重试。当Stage 输出出错时,上层调度器DAGScheduler 会进行重试。当Task输出出错时,上层调度器TaskScheduler 会进行重试。默认都是4次。
血缘层:利用血缘关系进行恢复。当部分计算结果丢失时,会根据血缘关系恢复计算。窄依赖中,子RDD分区丢失,重算父RDD分区即可。宽依赖中,丢失一个子RDD分区,需要重算父RDD的所有分区,此时可能存在冗余计算开销。
Checkpoint层:利用检查点重做血缘关系达到容错。将RDD写入磁盘做检查点,如果之后的节点出现问题,则可以从检查点的RDD重做血缘关系,特别是在宽依赖上做checkpoint 可以避免重新计算带来的冗余计算开销。
二、累加器
1、需求场景
默认情况下,spark在集群的多个不同节点上并行运行一个函数时,会把函数中涉及到的每个变量在每个子任务中生成一个副本,但有时需要在多个任务中同时修改一个变量,此时就需要共享一个写变量。
2、说明
累加器就是这个分布式的共享只写变量。在Driver端定义的变量,会在Executor端的每一个task得到一个新的副本,task 更新后会回传到Driver 端,进行合并。
3、使用案例
求列表元素的和
val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4), 2) // 定义一个2分区的列表 // var sum = 0 // rdd.foreach( // num => { // sum += num // } // ) // println(sum) // 获取系统累加器 val sumAcc: LongAccumulator = sc.longAccumulator("sum") rdd.foreach( num => { sumAcc.add(num) } ) println(sumAcc.value)
输出:10
说明:
累加器一般用在行动算子中,如果出现在转换算子中,可能会出现少计算的现象,因为转换算子时懒加载,没有行动算子是不会执行。当然如果重复执行行动算子可能出现多次计算的情况,需要注意。
三、广播变量
1、需求场景
需求场景同累加器,只不过这个共享变量是只读变量。
2、说明
把变量在所有节点的内存中共享,在每个机器上缓存一份只读变量,而不是为每一个任务生成一个副本。可以用来高效的分发大对象。
3、使用案例
模拟两张表join
第一种方式(匹配模式):
val rdd1: RDD[(String, Int)] = sc.makeRDD(List( ("a", 1), ("b", 2), ("c", 3) )) val rdd2: mutable.Map[String, Int] = mutable.Map(("a", 4), ("b", 5), ("d", 6)) rdd1.map{ case (word, count) =>{ val i: Int = rdd2.getOrElse(word, 0) (word, (count, i)) } }.collect().foreach(println)
第二种方式(广播方式):
val rdd1: RDD[(String, Int)] = sc.makeRDD(List( ("a", 1), ("b", 2), ("c", 3) )) val rdd2: mutable.Map[String, Int] = mutable.Map( ("a", 4), ("b", 5), ("d", 6)) val bc: Broadcast[mutable.Map[String, Int]] = sc.broadcast(rdd2) rdd1.map{ case (word, count) =>{ // 方法广播变量 val i1: Int = bc.value.getOrElse(word, 0) (word, (count, i1)) } }.collect().foreach(println)
自此关于Spark的三大数据结构已讲解完成。后续会针对RDD算子做进一步的说明。