Spark RDD

Spark RDD

一、RDD结构与操作

1. RDD的创建

RDD只支持两种创建方式:

(1)基于在稳定物理存储中的数据集或者基于内存中的集合创建;

(2)从其他已有的 RDD 上执行确定性操作来创建,这些确定性操作称为转换,如 map , filter , groupBy , join

2. RDD的结构

每个RDD有5个主要属性:

  • 一组分片(Partition),即数据集的基本组成单位。对于RDD来说,每个分片都会被一个计算任务处理,并决定并行计算的粒度。用户可以在创建RDD时指定RDD的分片个数,如果没有指定,那么就会采用默认值。默认值就是程序所分配到的CPU Core的数目。下图描述了分区存储的计算模型。每个分片分配的存储是由BlockManager实现的。每个分区都会被逻辑映射成BlockManager的一个Block,而这个Block会被一个Task负责计算。

在这里插入图片描述

  • 一个计算每个分区的函数(Compute)。Spark中RDD的计算是以分片为单位的,每个RDD都会实现compute函数以达到这个目的。compute函数会对迭代器进行复合,不需要保存每次计算的结果。
  • RDD之间的依赖关系(Lineage)。RDD的每次转换都会生成一个新的RDD,所以RDD之间就会形成类似于流水线一样的前后依赖关系。在部分分区数据丢失时,Spark可以通过这个依赖关系重新计算丢失的分区数据,而不是对RDD的所有分区进行重新计算。
  • 一个Partitioner,即RDD的分片函数。当前Spark中实现了两种类型的分片函数,一个是基于哈希的HashPartitioner,另外一个是基于范围的RangePartitioner。只有对于key-value的RDD,才会有Partitioner,非key-value的RDD的Parititioner的值是None。Partitioner函数不但决定了RDD本身的分片数量,也决定了parent RDD Shuffle输出时的分片数量。
  • 一个列表,存储存取每个Partition的优先位置(preferred location)。对于一个HDFS文件来说,这个列表保存的就是每个Partition所在的块的位置。按照“移动数据不如移动计算”的理念,Spark在进行任务调度的时候,会尽可能地将计算任务分配到其所要处理数据块的存储位置。
3. RDD的分区

当前Spark中实现了两种类型的分片函数,一个是基于哈希的HashPartitioner,另外一个是基于范围的RangePartitioner:

1)HashPartitioner原理:
p a r t i t i o n I d = k e y . h a s h C o d e    %    p a r t i t i o n N u m s p a r t i t i o n I d ∈ [ 0 ,    p a r t i t i o n N u m s − 1 ) partitionId = key.hashCode\ \ \% \ \ partitionNums \\ partitionId \in [0, \ \ partitionNums-1) partitionId=key.hashCode  %  partitionNumspartitionId[0,  partitionNums1)

Hash分区弊端:可能导致每个分区中数据量的不均匀,极端情况下会导致某些分区拥有RDD的全部数据。

2)RangePartitioner原理:

1)从整个RDD中采样;2)将样本数据排序, 形成一个Array[KEY]类型的数组变量rangeBounds;3)判断key在rangeBounds中所处的范围;4)给出该key值在下一个RDD中的分区id下标;
Range分区器要求RDD中的KEY类型必须是可以排序的

3)自定义分区

继承 org.apache.spark.Partitioner 并实现几个方法

class MyPartitioner(override val numPartitions: Int) extends Partitioner {

    override def getPartition(key: Any): Int = key match {
        case s: String => if (s(0).toUpper > 'H') 1 else 0
    }

}

查看rdd的分区示例:

def printPartitionId(rdd: RDD[(String, Int)]): Unit = {
    rdd.partitions.foreach(p => {
        val partRdd = rdd.mapPartitionsWithIndex {
            case(index:Int,value:Iterator[(String,Int)]) =>
                if (index == p.index) value else Iterator()
        }
        val dataPartitioned = partRdd.collect()
        println(s"partitionId: ${p.index}, value: ${dataPartitioned.foreach(print)}")
    })
}
def main(args: Array[String]): Unit = {
    val conf = new SparkConf().setMaster("local[2]")
    val session = SparkSession.builder().config(conf).getOrCreate()
    val sc = session.sparkContext
    val data = Array(
        ("abc",1), ("girl",1), ("jetbrains", 2),
        ("spark",1), ("zuul",1), ("hbase",1)
    )
    val rdd = sc.parallelize(data)
    val rdd2 = rdd.partitionBy(new MyPartitioner(2))
    printPartitionId(rdd2)
    // (abc,1)(girl,1)(hbase,1)partitionId: 0, value: ()
    // (jetbrains,2)(spark,1)(zuul,1)partitionId: 1, value: ()

    println("--------------------------------------------------")
    val rdd3 = rdd.partitionBy(new HashPartitioner(2))
    printPartitionId(rdd3)
    // (abc,1)(girl,1)(jetbrains,2)(zuul,1)partitionId: 0, value: ()
    // (spark,1)(hbase,1)partitionId: 1, value: ()
        
    println("--------------------------------------------------")
    val rdd4 = rdd.partitionBy(new RangePartitioner(2, rdd))
    printPartitionId(rdd4)
    // (abc,1)(girl,1)(hbase,1)partitionId: 0, value: ()
    // (jetbrains,2)(spark,1)(zuul,1)partitionId: 1, value: ()
}
4. RDD的转换

RDD中的所有转换(Transformation算子)都是惰性的,也就是说,它们并不会直接计算结果。相反的,它们只是记住这些应用到基础数据集(例如一个文件)上的转换动作。只有当发生一个要求返回结果给Driver的动作时(Action算子),这些转换才会真正运行。

默认情况下,每一个转换过的RDD都会在它执行一个动作时被重新计算。不过也可以使用persist(或者cache)方法,在内存中持久化一个RDD。在这种情况下,Spark将会在集群中保存相关元素,下次查询这个RDD时能更快访问它。也支持在磁盘上持久化数据集,或在集群间复制数据集。

常见的转换算子
转换含义
map(func)返回通过函数func传递源的每个元素而形成的新分布式数据集。
filter(func)返回通过选择func返回真实来源的元素而形成的新数据集。
mapPartitions(func)类似于map,但在RDD的每个分区(块)上分别运行,因此func在T类型的RDD上运行时必须是Iterator=>Iterator类型。
mapPartitionsWithIndex(func)与mapPartitions类似,但为func提供了一个表示分区索引的整数值,因此func在T类型的RDD上运行时必须是(Int,Iterator)=>Iterator类型。
flatMap(func)类似于map,但在RDD的每个分区(块)上单独运行,因此futc必须是类型)的传道器
sample(withReplacement, fraction, seed)使用给定的随机数生成器种子对数据的一小部分进行采样,无论是否更换。
union(otherDataset)返回一个新的数据集,其中包含源数据集中的元素和参数的并集。
distinct([numPartitions]))返回包含源数据集的不同元素的新数据集。
groupByKey([numPartitions])调用(K,V)对的数据集时,返回(K,Iterable)对的数据集。

注意:如果您分组是为了对每个key执行聚合(例如求和或平均值),那么使用它或将产生更好的性能。

注意:默认情况下,输出中的并行度取决于父RDD的分区数。您可以传递一个可选参数来设置不同数量的任务。
reduceByKey(func, [numPartitions])对(K,V)对的数据集调用时,返回(K,V)对的数据集,其中每个键的值使用给定的reduce函数func进行聚合,该函数的类型必须为(V,V)=>V。与中一样,reduce任务的数量可以通过可选的第二个参数进行配置。
sortByKey([ascending], [numPartitions])在有序的(K,V)对上使用时,返回按键升序或降序排序的(K,V)对数据集,如布尔参数中指定的。
join(otherDataset, [numPartitions])在(K,V)和(K,W)类型的数据集上调用时,返回(K,(V,W))对的数据集,其中包含每个键的所有元素对。当然也支持outer joins
repartition(numPartitions)随机地重新排列RDD中的数据,创建更多或更少的分区,并在它们之间保持平衡。这个操作会在整个集群上进行shuffle。
repartitionAndSortWithinPartitions(partitioner)根据给定的分区器重新划分RDD,并在每个生成的分区中,按其键对记录进行排序。这比在每个分区内调用然后排序更有效,因为它可以将排序向下推到shuffle机制中。
常见的Action算子
ActionMeaning
reduce(func)使用函数*func(需要两个参数并返回一个参数)*聚合数据集的元素。该函数应是相交的和关联的,以便可以并行正确计算。
collect()在驱动程序中返回数据集的所有元素作为数组。这通常是有用的过滤器或其他操作后,返回足够小的数据子集。
count()返回数据集中的元素数。
first()返回数据集的第一个元素(类似于取(1))。
take(n)返回带有数据集前n个元素的数组。
takeSample(withReplacement, num, [seed])返回带有数据集中数字元素随机样本的数组,无论是否更换,可选地预指定随机数生成器种子。
saveAsTextFile(path)将数据集的元素写入本地文件系统、HDFS 或任何其他 Hadoop 支持的文件系统中的给定目录中的文本文件(或一组文本文件)。Spark 将调用每个元素的串联,将其转换为文件中的文本行。
saveAsSequenceFile(path) (Java and Scala)将数据集的元素写成 Hadoop 序列文件,在本地文件系统、HDFS 或任何其他 Hadoop 支持的文件系统中的给定路径中。这可在实现 Hadoop 的可写接口的关键值对的 RD 上提供。在 Scala 中,它还可用于隐含可转换为可写字的类型(Spark 包括用于基本类型(如 Int、Double、String 等)的转换)。
saveAsObjectFile(path) (Java and Scala)使用 Java 序列化以简单格式编写数据集的元素,然后可以使用该格式加载。SparkContext.objectFile()
countByKey()仅适用于类型(K、V)的RDD。返回与每个key计数的 (K, Int) 对的HashMap。
foreach(func)在数据集的每个元素上运行一个函数func。
5. RDD的Shuffle

reduceByKey会将一个RDD中的每一个key对应的所有value聚合成一个value,然后生成一个新的RDD,元素类型是<key,value>对的形式,这样每一个key对应一个聚合起来的value。

但每一个key对应的value不一定都是在一个partition中,也不太可能在同一个节点上,因为RDD是分布式的弹性的数据集,它的partition极有可能分布在各个节点上。

所以产生了shuffle。

  • **Shuffle Write:**上一个stage的每一个map task就必须保证将自己处理的当前分区中的数据相同的key写入一个分区文件中,可能会写入多个不同的分区文件中。
  • **Shuffle Read:**reduce task就会从上一个stage的所有task所在的机器上寻找属于自己的那些分区文件,这样就可以保证每一个key所对应的value都会汇聚在同一个节点上去处理和聚合。

Spark中有两种Shuffle管理类型,HashShuffleManager和SortShuffleManager,Spark1.2之前是HashShuffleManager,Spark1.2引入SortShuffleManager,在Spark2.0+版本中已经将HashShuffleManager丢弃。

不同shuffle管理类型产生的磁盘小文件的个数:

HashShuffleManager:
  • Hash Based Shuffle

在这里插入图片描述

在HashShuffle没有优化之前,每一个ShufflleMapTask会为每一个ReduceTask创建一个bucket缓存,并且会为每一个bucket创建一个文件。这个bucket存放的数据就是经过Partitioner操作(默认是HashPartitioner)之后找到对应的bucket然后放进去,最后将数据刷新bucket缓存的数据到磁盘上,即对应的block file。ReduceTask则会获取到属于自己的那份block file,进行聚合。

  • 优化后的HashShuffle

在这里插入图片描述

每一个Executor进程根据核数,决定Task的并发数量,比如executor核数是2,就是可以并发运行两个task,如果是一个则只能运行一个task。

假设executor核数是1,ShuffleMapTask数量是M,那么它依然会根据ResultTask的数量R,创建R个bucket缓存,然后对key进行hash,数据进入不同的bucket中,每一个bucket对应着一个block file,用于刷新bucket缓存里的数据。

当下一个task运行的时候,就不会再创建新的bucket和block file,而是复用之前的task已经创建好的bucket和block file。即所谓同一个Executor进程里所有Task都会把相同的key放入相同的bucket缓冲区中。

1). 普通机制:M(map task的个数)*R(reduce task的个数)

存在的问题:

(a)在shuffle Write过程中会产生很多写磁盘小文件的对象

(b)在shuffle read过程中会产生很多读取磁盘小文件的对象

(c)在JVM堆内存中对象过多回来造成频繁的gc,gc还无法解决运行所需要的内存的话,就会OOM。

(d)在数据传输过程中会有频繁的网络通信,频繁的网络通信出现通信故障的可能性大大增加,一旦网络通信出现了故障会导致shuffle file cannot find 由于这个错误导致的task失败,TaskScheduler不负责重试,由DAGScheduler负责重试Stage。

2). 优化机制:C(core的个数)*R(Reduce的个数)

存在的问题:

(a)如果 Reducer 端的并行任务或者是数据分片过多的话则 Core * Reducer Task 依旧过大,也会产生很多小文件

SortShuffleManager:

为了缓解Shuffle过程产生文件数过多和Writer缓存开销过大的问题,spark引入了类似于hadoop Map-Reduce的shuffle机制。该机制每一个ShuffleMapTask不会为后续的任务创建单独的文件,而是会将所有的Task结果写入同一个文件,并且对应生成一个索引文件。以前的数据是放在内存缓存中,等到数据完了再刷到磁盘,现在为了减少内存的使用,在内存不够用的时候,可以将输出溢写到磁盘,结束的时候,再将这些不同的文件联合内存的数据一起进行归并,从而减少内存的使用量。一方面文件数量显著减少,另一方面减少Writer缓存所占用的内存大小,而且同时避免GC的风险和频率。

在这里插入图片描述

在同一个 core 上先后运行的两个 map task的输出, 对应同一个文件的不同的 segment上, 称为一个 FileSegment, 形成一个 ShuffleBlockFile

map端的任务会按照PartitionId以及key对记录进行排序。同时将全部结果写到一个数据文件中,同时生成一个索引文件。

再后面就就引入了 Tungsten-Sort Based Shuffle, 这个是直接使用堆外内存和新的内存管理模型,节省了内存空间和大量的gc, 提升了性能。

Sort-Based Shuffle有几种不同的策略:BypassMergeSortShuffleWriter、SortShuffleWriter和UnasfeSortShuffleWriter。

1). 普通机制:2*M

(a)比较适合数据量很大的场景或者集群规模很大

(b)引入了外部外部排序器,可以支持在Map端进行本地聚合或者不聚合

(c)如果外部排序器enable了spill功能,如果内存不够,可以先将输出溢写到本地磁盘,最后将内存结果和本地磁盘的溢写文件进行合并

2). bypass机制,没有排序:2*M

(a)主要用于处理不需要排序和聚合的Shuffle操作,所以数据是直接写入文件,数据量较大的时候,网络I/O和内存负担较重

(b) 主要适合处理Reducer任务数量比较少的情况下

(c)将每一个分区写入一个单独的文件,最后将这些文件合并,减少文件数量;但是这种方式需要并发打开多个文件,对内存消耗比较大

3). 谨慎使用

6. RDD的缓存

Spark速度非常快的原因之一,就是在不同操作中在内存中持久化(或缓存)一个数据集。当持久化一个RDD后,每一个节点都将把计算的分片结果保存在内存中,并在对此数据集(或者衍生出的数据集)进行的其他动作(action)中重用。这使得后续的动作变得更加迅速(通常快10倍)。RDD相关的持久化和缓存,是Spark最重要的特征之一。可以说,缓存是Spark构建迭代式算法和快速交互式查询的关键。通过persist()或cache()方法可以标记一个要被持久化的RDD,一旦首次被触发,该RDD将会被保留在计算节点的内存中并重用。实际上,cache()是使用persist()的快捷方法:

storage level参数说明
MEMORY_ONLY将 RDD 以反序列化的 Java 对象的形式存储在 JVM 中. 如果内存空间不够,部分数据分区将不会被缓存,在每次需要用到这些数据时重新进行计算。这是默认的级别。cache()方法对应的级别就是这个级别
MEMORY_AND_DISK将 RDD 以反序列化的 Java 对象的形式存储在 JVM 中。如果内存空间不够,将未缓存的数据分区存储到磁盘,在需要使用这些分区时从磁盘读取。
MEMORY_ONLY_SER将 RDD 以序列化的 Java 对象的形式进行存储(每个分区为一个 byte 数组)。这种方式会比反序列化对象的方式节省很多空间,尤其是在使用 fast serialize时会节省更多的空间,但是在读取时会使得 CPU 的 read 变得更加密集。如果内存空间不够,部分数据分区将不会被缓存,在每次需要用到这些数据时重新进行计算。
MEMORY_AND_DISK_SER类似于 MEMORY_ONLY_SER ,但是溢出的分区会存储到磁盘,而不是在用到它们时重新计算。如果内存空间不够,将未缓存的数据分区存储到磁盘,在需要使用这些分区时从磁盘读取。
DISK_ONLY只在磁盘上缓存 RDD。
MEMORY_ONLY_2、MEMORY_AND_DISK_2、etc.与上面的级别功能相同,只不过每个分区在集群中两个节点上建立副本。
OFF_HEAP将数据存储在 off-heap memory 中。使用堆外内存,这是Java虚拟机里面的概念,堆外内存意味着把内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机)。使用堆外内存的好处:可能会利用到更大的内存存储空间。但是对于数据的垃圾回收会有影响,需要程序员来处理。

Spark 也会自动持久化一些在 shuffle 操作过程中产生的临时数据(比如 reduceByKey),即便是用户并没有调用持久化的方法。这样做可以避免当 shuffle 阶段时如果一个节点挂掉了就得重新计算整个数据的问题。如果用户打算多次重复使用这些数据,我们仍然建议用户自己调用持久化方法对数据进行持久化。

7. RDD的计算
Task

原始的RDD经过一系列转换后,会在最后一个RDD上触发一个动作,这个动作会生成一个Job。在Job被划分为一批计算任务(Task)后,这批Task会被提交到集群上的计算节点去计算。计算节点执行计算逻辑的部分称为Executor。Executor在准备好Task的运行时环境后,会通过调用org.apache.spark.scheduler.Task#run来执行计算。

Spark的Task分为两种:

  • org.apache.spark.scheduler.ShuffleMapTask
  • org.apache.spark.scheduler.ResultTask

简单来说,DAG的最后一个阶段会为每个结果的Partition生成一个ResultTask,其余所有的阶段都会生成ShuffleMapTask。生成的Task会被发送到已经启动的Executor上,由Executor来完成计算任务的执行,执行过程的实现在org.apache.spark.executor.Executor.TaskRunner#run。

划分依据
  • Application:初始化一个SparkContext即生成一个Application;
  • Job:一个Action算子就会生成一个Job;
  • Stage:根据RDD之间的依赖关系的不同将Job划分成不同的Stage,遇到一个宽依赖则划分一个Stage;
  • Task:一个Stage是一个TaskSet,将Stage划分的结果发送到不同的Executor执行即为一个Task。

以下为 任务调度逻辑图:

在这里插入图片描述

8. RDD关于数据库连接
// 每个RDD的每个的每行记录获取一个连接,导致短连接太多,不建议使用
dstream.foreachRDD { rdd =>
  rdd.foreach { record =>
    val connection = createNewConnection()
    connection.send(record)
    connection.close()
  }
}

// 每个RDD记录获取一个连接,导致短连接太多,不建议使用
dstream.foreachRDD { rdd =>
  val connection = createNewConnection()  // executed at the driver
  rdd.foreach { record =>
    connection.send(record) // executed at the worker
  }
}

// 每个分区获取一个连接,不过在此之前要估算数据量,自动或者手动重分区
dstream.foreachRDD { rdd =>
  rdd.foreachPartition { partitionOfRecords =>
    val connection = createNewConnection()
    partitionOfRecords.foreach(record => connection.send(record))
    connection.close()
  }
}

// 每个分区只获取一个连接,同时,使用连接池管理连接
dstream.foreachRDD { rdd =>
  rdd.foreachPartition { partitionOfRecords =>
    // ConnectionPool is a static, lazily initialized pool of connections
    val connection = ConnectionPool.getConnection()
    partitionOfRecords.foreach(record => connection.send(record))
    ConnectionPool.returnConnection(connection)  // return to the pool for future reuse
  }
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值