Spark RDD详解

1.Spark 介绍:

spark出现的主要原因
在这里插入图片描述

多个 MapReduce 任务之间没有基于内存的数据共享方式, 只能通过磁盘来进行共享。这种方式明显比较低效。

在 Spark 中, 计算不涉及与其他节点进行数据交换,Spark可以在内存中一次性完成这些操作,也就是中间结果无须落盘,减少了磁盘IO的操作。

1.1 Spark核心组件

在这里插入图片描述

spark的核心是Spark Core,其中上面的Spark Sql对接的是Hive等结构化查询,Spark Streaming是对接的流式计算,后面的那两个也是主要用在科学任务中,但是他们的基础都是spark core,而Spark core的核心就是RDD操作,RDD的操作重要的就是算子。

Spark Core提供Spark最基础与最核心的功能,主要包括以下功能:

(1)、SparkContext:通常而言,Driver Application的执行与输出都是通过SparkContext来完成的。在正式提交Application之前,首先需要初始化SparkContext。SparkContext隐藏了网络通信、分布式部署、消息通信、存储能力、计算能力、缓存、测量系统、文件服务、Web服务等内容,应用程序开发者只需要使用SparkContext提供的API完成功能开发。SparkContext内置的DAGScheduler负责创建Job,将DAG中的RDD划分到不同的Stage,提交Stage等功能。内置的TaskScheduler负责资源的申请,任务的提交及请求集群对任务的调度等工作。(2)、存储体系:Spark优先考虑使用各节点的内存作为存储,当内存不足时才会考虑使用磁盘,这极大地减少了磁盘IO,提升了任务执行的效率,使得Spark适用于实时计算、流式计算等场景。此外,Spark还提供了以内存为中心的高容错的分布式文件系统Tachyon供用户进行选择。Tachyon能够为Spark提供可靠的内存级的文件共享服务。(3)、计算引擎:计算引擎由SparkContext中的DAGScheduler、RDD以及具体节点上的Executor负责执行的Map和Reduce任务组成。DAGScheduler和RDD虽然位于SparkContext内部,但是在任务正式提交与执行之前会将Job中的RDD组织成有向无环图(DAG),并对Stage进行划分,决定了任务执行阶段任务的数量、迭代计算、shuffle等过程。(4)、部署模式:由于单节点不足以提供足够的存储和计算能力,所以作为大数据处理的Spark在SparkContext的TaskScheduler组件中提供了对Standalone部署模式的实现和Yarn、Mesos等分布式资源管理系统的支持。通过使用Standalone、Yarn、Mesos等部署模式为Task分配计算资源,提高任务的并发执行效率。

1.2 Spark 架构图

在这里插入图片描述

Spark集群启动时,需要从主节点和从节点分别启动Master进程和Worker进程,对整个集群进行控制。在一个Spark应用的执行过程中

  • Driver是应用的逻辑执行起点,运行Application的main函数并创建SparkContext。

  • DAGScheduler把Job中的RDD有向无环图根据依赖关系划分为多个Stage,每一个Stage是一个TaskSet。

  • TaskScheduler把Task分发给Worker中的Executor。

  • Worker启动Executor,Executor启动线程池用于执行Task。

Cluster Manager:指的是在集群上获取资源的外部服务。包括 Standalone、Apache Mesos、Hadoop Yarn 三种模式。

master节点: 常驻master守护进程,负责管理worker节点,我们从master节点提交应用。driver可以运行在master上,也可以运行worker上(根据部署模式的不同)。

2 RDD:

2.1 RDD简介

RDD,全称是 Resilient Distributed Datasets 弹性分布式数据集。spark中的数据抽象,编程抽象。

  • Resilient:不可变、容错的,通过依赖形成DAG,能够进行重算。就像带有弹性的海绵,不管怎样挤压(分区遭到破坏)都是完整的。

  • Distributed:数据分散在不同节点(机器、进程)

  • Dataset:一个由多个分区(partition)组成的数据集

2.1.1 RDD属性:

在这里插入图片描述

(1)一组分片(Partition),即数据集的基本组成单位。对于RDD来说,每个分片都会被一个计算任务处理,并决定并行计算的粒度。用户可以在创建RDD时指定RDD的分片个数,如果没有指定,那么就会采用默认值。默认值就是程序所分配到的CPU Core的数目。

(2)一个计算每个分区的函数。Spark中RDD的计算是以分片为单位的,每个RDD都会实现compute函数以达到这个目的。compute函数会对迭代器进行复合,不需要保存每次计算的结果。调用内部的compute函数来计算一个分区的数据。compute函数负责的是父RDD分区数据到子RDD分区数据的变换逻辑。

(3)RDD之间的依赖关系。RDD的每次转换都会生成一个新的RDD,所以RDD之间就会形成类似于流水线一样的前后依赖关系。在部分分区数据丢失时,Spark可以通过这个依赖关系重新计算丢失的分区数据,而不是对RDD的所有分区进行重新计算。

(4)一个Partitioner,即RDD的分片函数。当前Spark中实现了两种类型的分片函数,一个是基于哈希的HashPartitioner,另外一个是基于范围的RangePartitioner。只有对于于key-value的RDD,才会有Partitioner,非key-value的RDD的Parititioner的值是None。Partitioner函数不但决定了RDD本身的分片数量,也决定了parent RDD Shuffle输出时的分片数量。

  • RangePartitioner(范围分区)将其key位于相同范围内的记录分配给给定分区。当RDD没有Partitioner时,会把HashPartitioner作为默认的Partitioner。

  • HashPartitioner是基于Java的 Object.hashCode来实现的分区器。根据Object.hashCode来对key进行计算得到一个整数,再通过公式:Object.hashCode%numPartitions 计算某个key该分到哪个分区。

(5)一个列表,存储存取每个Partition的优先位置(preferred location)。对于一个HDFS文件来说,这个列表保存的就是每个Partition所在的块的位置。按照“移动数据不如移动计算”的理念,Spark在进行任务调度的时候,会尽可能地将计算任务分配到其所要处理数据块的存储位置。

2.2 Spark 任务里面 RDD 位置

1.spark 任务执行过程

在这里插入图片描述

一个Spark程序可以被划分为一个或多个Job,划分的依据是RDD的Action算子,每遇到一个RDD的Action操作就生成一个新的Job。每个spark Job在具体执行过程中因为shuffle的存在,需要将其划分为一个或多个可以并行计算的stage,划分的依据是RDD间的Dependency关系,当遇到Wide Dependency时因需要进行shuffle操作,这涉及到了不同Partition之间进行数据合并,故以此为界划分不同的Stage。Stage是由Task组组成的并行计算,因此每个stage中可能存在多个Task,这些Task执行相同的程序逻辑,只是它们操作的数据不同。一般RDD的一个Partition对应一个Task,Task可以分为ResultTask和ShuffleMapTask。

ResultTask:输出是result。ResultTask获取父RDD分区数据之后,把分区数据作为参数输入到action函数中,最终计算出特定的结果返回给driver。DAG的最后一个阶段会为每个结果的partition生成一个ResultTask,即每个Stage里面的Task的数量是由该Stage中最后一个RDD的Partition的数量所决定的

ShuffleMapTask:输出是shuffle所需数据。ShuffleMapTask获取父RDD分区数据之后,把分区数据作为参数传入分区函数,最终形成新的RDD中的分区数据,保存在各个Executor节点中,并将分区数据信息MapStatus返回给driver。ShuffleMapTask需要将自己的计算结果通过shuffle到下一个stage中。

2.对应 RDD 部分:

在这里插入图片描述

输入可能以多个文件的形式存储在HDFS上,每个File都包含了很多块,称为Block。当Spark读取这些文件作为输入时,会根据具体数据格式对应的InputFormat进行解析,一般是将若干个Block合并成一个输入分片,称为InputSplit,注意InputSplit不能跨越文件。

Block是物理概念,而Split是逻辑概念,最后数据的分片是根据Split来的。一个文件可能大于BlockSize也可能小于BlockSize,大于它就会被分成多个Block存储到不同的机器上,SplitSize可能大于BlockSize也可能小于BlockSize,SplitSize如果大于BlockSize,那么一个Split就可能要跨多个Block。随后将为这些输入分片生成具体的Task。InputSplit与Task是一一对应的关系。随后这些具体的Task每个都会被分配到集群上的某个节点的某个Executor去执行。

  • 每个节点可以起一个或多个Executor。

  • 每个Executor由若干core组成,每个Executor的每个core一次只能执行一个Task。

  • 每个Task执行的结果就是生成了目标RDD的一个partiton。

注意: 这里的core是虚拟的core而不是机器的物理CPU核,可以理解为就是Executor的一个工作线程。

而 Task被执行的并发度 = Executor数目 * 每个Executor核数。

至于partition的数目:

对于数据读入阶段,例如sc.textFile,输入文件被划分为多少InputSplit就会需要多少初始Task。

在Map阶段partition数目保持不变。

在Reduce阶段,RDD的聚合会触发shuffle操作,聚合后的RDD的partition数目跟具体操作有关,例如repartition操作会聚合成指定分区数,还有一些算子是可配置的。

大致数量对应关系:

spark程序 : job = 1 :n

job : stage = 1 :n

stage : task = 1 : n

task : rdd partition = 1 : 1

task : hdfs block = m : n

2.3 RDD 分类

RDD的具体实现类有几十种(大概60+),介绍下最常见的几种:

源数据RDD:

spark支持读取不同的数据源,如下例子:

  • 支持hdfs文件读取, HadoopRDD

  • 支持jdbc读取数据库,JdbcRDD

MapPartitionsRDD

MapPartitionsRDD对于父RDD的依赖类型只能是OneToOneDependency,代表将函数应用到每一个分区的计算。

相关transformation:map, flatMap, filter, mapPartitions 等

ShuffledRDD

对于父RDD的依赖类型只能是ShuffleDependency,代表需要改变分区方式进行shuffle的计算。

会创建ShuffledRDD的transformation:reduceByKey, sortByKey 等

3.算子(operators)

从大方向来说,Spark 算子大致可以分为以下两类

Transformation:操作是延迟计算的,也就是说从一个RDD 转换生成另一个 RDD 的转换操作不是马上执行,需要等到有 Action 操作的时候才会真正触发运算。

Action:会触发 Spark 提交作业(Job),并将数据输出 Spark系统。

3.1创建算子

RDD 有三种创建方式

  • RDD 可以通过本地集合直接创建

  • RDD 也可以通过读取外部数据集来创建

  • RDD 也可以通过其它的 RDD 衍生而来

通过本地集合直接创建 RDD:

val conf = new SparkConf().setMaster("local[2]")
// SparkContext 主要作用是连接集群, 创建 RDD, 累加器, 广播变量等
val sc = new SparkContext(conf)
val rddParallelize = sc.parallelize(Seq(1, 2, 3, 4, 5, 6), 2)

3.2 转换算子(Transformation算子)

主要做的是就是将一个已有的RDD生成另外一个RDD。Transformation具有lazy特性(延迟加载)。Transformation算子的代码不会真正被执行。只有当我们的程序里面遇到一个action算子的时候,代码才会真正的被执行。这种设计让Spark更加有效率地运行。

常用的Transformation:

map(func)

返回一个新的RDD,该RDD由每一个输入元素经过func函数转换后组成

filter(func)

返回一个新的RDD,该RDD由经过func函数计算后返回值为true的输入元素组成

flatMap(func)

类似于map,但是每一个输入元素可以被映射为0或多个输出元素(所以func应该返回一个序列,而不是单一元素)

mapPartitions(func)

类似于map,但独立地在RDD的每一个分片上运行,因此在类型为T的RDD上运行时,func的函数类型必须是Iterator[T] => Iterator[U]

mapPartitionsWithIndex(func)

类似于mapPartitions,但func带有一个整数参数表示分片的索引值,因此在类型为T的RDD上运行时,func的函数类型必须是

(Int, Interator[T]) => Iterator[U]

sample(withReplacement, fraction, seed)

根据fraction指定的比例对数据进行采样,可以选择是否使用随机数进行替换,seed用于指定随机数生成器种子

union(otherDataset)

对源RDD和参数RDD求并集后返回一个新的RDD

intersection(otherDataset)

对源RDD和参数RDD求交集后返回一个新的RDD

distinct([numTasks]))

对源RDD进行去重后返回一个新的RDD

groupByKey([numTasks])

在一个(K,V)的RDD上调用,返回一个(K, Iterator[V])的RDD

reduceByKey(func, [numTasks])

在一个(K,V)的RDD上调用,返回一个(K,V)的RDD,使用指定的reduce函数,将相同key的值聚合到一起,与groupByKey类似,reduce任务的个数可以通过第二个可选的参数来设置

通过使用local combiner先做 一次聚合运算,减少数据的shuffler。

aggregateByKey(zeroValue)(seqOp, combOp, [numTasks])

和reduceByKey类似,但更具灵活性,可以自定义在分区内和分区间的聚合操作

sortByKey([ascending], [numTasks])

在一个(K,V)的RDD上调用,K必须实现Ordered接口,返回一个按照key进行排序的(K,V)的RDD

sortBy(func,[ascending], [numTasks])

与sortByKey类似,但是更灵活 第一个参数是根据什么排序 第二个是怎么排序 false倒序 第三个排序后分区数 默认与原RDD一样

join(otherDataset, [numTasks])

在类型为(K,V)和(K,W)的RDD上调用,返回一个相同key对应的所有元素对在一起的(K,(V,W))的RDD 相当于内连接(求交集)

cogroup(otherDataset, [numTasks])

在类型为(K,V)和(K,W)的RDD上调用,返回一个(K,(Iterable,Iterable))类型的RDD

cartesian(otherDataset)

两个RDD的笛卡尔积 的成很多个K/V

pipe(command, [envVars])

调用外部程序

coalesce(numPartitions)

重新分区 第一个参数是要分多少区,第二个参数是否shuffle 默认false 少分区变多分区 true 多分区变少分区 false

repartition(numPartitions)

重新分区 必须shuffle 参数是要分多少区 少变多

底层调用 coalesce,代码:coalesce(numPartitions, shuffle = true)

repartitionAndSortWithinPartitions(partitioner)

重新分区+排序 比先分区再排序效率高 对K/V的RDD进行操作

foldByKey(zeroValue)(seqOp)

该函数用于K/V做折叠,合并处理 ,与aggregate类似 第一个括号的参数应用于每个V值 第二括号函数是聚合例如:+

combineByKey

合并相同的key的值 rdd1.combineByKey(x => x, (a: Int, b: Int) => a + b, (m: Int, n: Int) => m + n)

partitionBy(partitioner)

对RDD进行分区 partitioner是分区器 例如new HashPartition(2

cache

RDD缓存,可以避免重复计算从而减少时间,区别:cache内部调用了persist算子,cache默认就一个缓存级别MEMORY-ONLY ,而persist则可以选择缓存级别

persist

Subtract(rdd)

返回前rdd元素不在后rdd的rdd

leftOuterJoin

leftOuterJoin类似于SQL中的左外关联left outer join,返回结果以前面的RDD为主,关联不上的记录为空。只能用于两个RDD之间的关联,如果要多个RDD关联,多关联几次即可。

rightOuterJoin

rightOuterJoin类似于SQL中的有外关联right outer join,返回结果以参数中的RDD为主,关联不上的记录为空。只能用于两个RDD之间的关联,如果要多个RDD关联,多关联几次即可

subtractByKey

substractByKey和基本转换操作中的subtract类似只不过这里是针对K的,返回在主RDD中出现,并且不在otherRDD中出现的元素

可能产生 shuffle 的算子 :

shuffle: 把不同节点的数据拉取到同一个节点的过程就叫做Shuffle。

shuffle过程中,各个节点上的相同key都会先写入本地磁盘文件中,然后其它节点需要通过网络传输拉取各个节点上的磁盘文件中的相同key。而且相同key都拉取到同一个节点进行聚合操作时,还有可能会因为一个节点上处理的key过多,导致内存不够存放,进而溢写到磁盘文件中。因此在shuffle过程中,可能会发生大量的磁盘文件读写的IO操作,以及数据的网络传输操作。磁盘IO和网络数据传输也是shuffle性能较差的主要原因。

一个优化例子:用 broadcast join 代替 join。broadcast join 把小表广播到各个节点。

1.去重

def distinct()
def distinct(numPartitions: Int)

2.聚合:

def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)]
def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)]
def groupBy[K](f: T => K, p: Partitioner):RDD[(K, Iterable[V])]
def groupByKey(partitioner: Partitioner):RDD[(K, Iterable[V])]
def aggregateByKey[U: ClassTag](zeroValue: U, partitioner: Partitioner): RDD[(K, U)]
def aggregateByKey[U: ClassTag](zeroValue: U, numPartitions: Int): RDD[(K, U)]
def combineByKey[C](createCombiner: V => C, mergeValue: (C, V) => C, mergeCombiners: (C, C) => C): RDD[(K, C)]
def combineByKey[C](createCombiner: V => C, mergeValue: (C, V) => C, mergeCombiners: (C, C) => C, numPartitions: Int): RDD[(K, C)]
def combineByKey[C](createCombiner: V => C, mergeValue: (C, V) => C, mergeCombiners: (C, C) =>

3.排序

def sortByKey(ascending: Boolean = true, numPartitions: Int = self.partitions.length): RDD[(K, V)]
def sortBy[K](f: (T) => K, ascending: Boolean = true, numPartitions: Int = this.partitions.length

4.重分区

def coalesce(numPartitions: Int, shuffle: Boolean = false, partitionCoalescer: Option[PartitionCoalescer] = Option.empty)
def repartition(numPartitions: Int)(implicit ord: Ordering[T] = null)

5.集合或者表操作

def intersection(other: RDD[T]): RDD[T]
def intersection(other: RDD[T], partitioner: Partitioner)(implicit ord: Ordering[T] = null): RDD[T]
def intersection(other: RDD[T], numPartitions: Int): RDD[T]
def subtract(other: RDD[T], numPartitions: Int): RDD[T]
def subtract(other: RDD[T], p: Partitioner)(implicit ord: Ordering[T] = null): RDD[T]
def subtractByKey[W: ClassTag](other: RDD[(K, W)]): RDD[(K, V)]
def subtractByKey[W: ClassTag](other: RDD[(K, W)], numPartitions: Int): RDD[(K, V)]
def subtractByKey[W: ClassTag](other: RDD[(K, W)], p: Partitioner): RDD[(K, V)]
def join[W](other: RDD[(K, W)], partitioner: Partitioner): RDD[(K, (V, W))]
def join[W](other: RDD[(K, W)]): RDD[(K, (V, W))]
def join[W](other: RDD[(K, W)], numPartitions: Int): RDD[(K, (V, W))]
def leftOuterJoin[W](other: RDD[(K, W)]): RDD[(K, (V, Option[W]))]

3.3执行算子(Action算子)

触发代码的运行,我们一段spark代码里面至少需要有一个action操作。

常用的Action:

reduce(func)

通过func函数聚集RDD中的所有元素,这个功能必须是课交换且可并联的

collect()

在驱动程序中,以数组的形式返回数据集的所有元素

count()

返回RDD的元素个数

first()

返回RDD的第一个元素(类似于take(1))

take(n)

返回一个由数据集的前n个元素组成的数组

takeSample(withReplacement,num, [seed])

返回一个数组,该数组由从数据集中随机采样的num个元素组成,可以选择是否用随机数替换不足的部分,seed用于指定随机数生成器种子

saveAsTextFile(path)

将数据集的元素以textfile的形式保存到HDFS文件系统或者其他支持的文件系统,对于每个元素,Spark将会调用toString方法,将它装换为文件中的文本

saveAsSequenceFile(path)

将数据集中的元素以Hadoop sequencefile的格式保存到指定的目录下,可以使HDFS或者其他Hadoop支持的文件系统。

saveAsObjectFile(path)

countByKey()

针对(K,V)类型的RDD,返回一个(K,Int)的map,表示每一个key对应的元素个数。

foreach(func)

在数据集的每一个元素上,运行函数func进行更新。

aggregate

先对分区进行操作,在总体操作

reduceByKeyLocally

lookup

top

fold

foreachPartition

4.依赖

Spark 中 RDD 的每一次Transformation都会生成一个新的 RDD,这样 RDD 之间就会形成类似于流水线一样的前后依赖关系,在 Spark 中,依赖关系被定义为两种类型,分别是窄依赖和宽依赖:

4.1窄依赖(NarrowDependency)

每个父 RDD 的一个分区最多被子 RDD 的一个分区所使用,即 RDD 之间是一对一的关系。窄依赖的情况下,如果下一个 RDD 执行时,某个分区执行失败(数据丢失),只需要重新执行父 RDD 的对应分区即可进行数恢复。例如map、filter、union等算子都会产生窄依赖。

  • 1对1依赖(OneToOneDependency),1个子RDD的分区对应于1个父RDD的分区。map、fIlter、join with inputs co-partitioned(若parent RDD有已知的partitioner(若已知的partitioner相同,两个RDD会协同,那么就能避免网络传输,两个parent RDD 的相同partition会在同一个节点上),那么可能如上图的“join with inputs co-partitioned”,只能产生窄依赖)。一个RDD的partition仅仅和另一个RDD中已知个数的Partition进行join,那么这种类型的join操作就是窄依赖。例子参考:https://cloud.tencent.com/developer/article/1390312

  • 多对1依赖(RangeDependency),1个子RDD的分区对应于N个父RDD的分区。范围依赖,如 union。

  • 1 对部分 PruneDependency: 裁剪依赖,过滤掉部分分区,如PartitionPruningRDD
    在这里插入图片描述
    在这里插入图片描述

4.2宽依赖(WideDependency,或 ShuffleDependency)

是指一个父 RDD 的分区会被子 RDD 的多个分区所使用,即 RDD 之间是一对多的关系。当遇到宽依赖操作时,数据会产生Shuffle,所以也称之为ShuffleDependency。宽依赖情况下,如果下一个 RDD 执行时,某个分区执行失败(数据丢失),则需要将父 RDD 的所有分区全部重新执行才能进行数据恢复。

  • 1个父RDD对应非全部多个子RDD分区,比如groupByKey,reduceByKey,sortByKey。

  • 1个父RDD对应所有子RDD分区,比如未经协同划分的join
    在这里插入图片描述

  • 宽依赖等价于 shuffle。参考:https://www.zhihu.com/question/309639663
    在这里插入图片描述

  • 发生 join 不一定产生宽依赖:如窄依赖的图(join with co-partitioned input)

  • 发生 join 不一定产生 shuffle:如窄依赖的图(join with co-partitioned input),参考 https://stackoverflow.com/questions/28395376/does-a-join-of-co-partitioned-rdds-cause-a-shuffle-in-apache-spark 。sql 可能优化为 BroadcastHashJoin
    在这里插入图片描述

窄依赖和宽依赖的概念主要用在两个地方:一个是容错中相当于Redo日志的功能;另一个是在调度中构建DAG作为不同Stage的划分点。

5.容错

一般来说,分布式数据集的容错性有两种方式:数据检查点(checkpoint机制)和记录数据的更新。面向大规模数据分析,数据检查点操作成本很高,需要通过数据中心的网络连接在机器之间复制庞大的数据集,而网络带宽往往比内存带宽低得多,同时还需要消耗更多的存储资源。因此,Spark选择记录更新的方式。但是,如果更新粒度太细太多,那么记录更新成本也不低。因此,RDD只支持粗粒度转换,即只记录单个块上执行的单个操作,然后将创建RDD的一系列变换序列(每个RDD都包含了他是如何由其他RDD变换过来的以及如何重建某一块数据的信息。因此RDD的容错机制又称“血统(Lineage)”容错)记录下来,以便恢复丢失的分区。Lineage本质上很类似于数据库中的重做日志(Redo Log),只不过这个重做日志粒度很大,是对全局数据做同样的重做进而恢复数据。

5.1血统Lineage

RDD血缘(RDD Lineage),也可以叫:RDD依赖关系图。当我们计算一个RDD时,会依赖一个或多个父RDD的数据,而这些父RDD又会依赖它自身的父RDD,这样RDD之间的依赖关系就形成了一个有向无环图(也叫DAG图),这些依赖关系被记录在一个图中,这就是RDD的血缘(也叫RDD Lineage)。

  • Lineage简介

相比其他系统的细颗粒度的内存数据更新级别的备份或者LOG机制,RDD的Lineage记录的是粗颗粒度的特定数据Transformation操作(如filter、map、join等)行为。当这个RDD的部分分区数据丢失时,它可以通过Lineage获取足够的信息来重新运算和恢复丢失的数据分区。因为这种粗颗粒的数据模型,限制了Spark的运用场合,所以Spark并不适用于所有高性能要求的场景,但同时相比细颗粒度的数据模型,也带来了性能的提升。

  • 查看血缘
/**
    * mapValue 也是 map, 只不过map作用于整条数据, mapValue 作用于 Value
    */
  @Test
  def mapValues(): Unit = {
    val rdd1 = sc.parallelize(Seq(("a", 1), ("b", 2), ("c", 3)))
    val rdd2 = rdd1.mapValues( item => item * 10 )
    println(rdd2.toDebugString)
    rdd2.collect()
      .foreach(println(_))
  }

1.rdd.toDebugString: 打印RDD的血缘关系
在这里插入图片描述

2.spark UI的"DAG Visualization"来查看某个RDD的计算DAG图,从而可以反推出依赖关系
在这里插入图片描述

  • 宽窄依赖关系的特性

第一,窄依赖可以在某个计算节点上直接通过计算父RDD的某块数据计算得到子RDD对应的某块数据;宽依赖则要等到父RDD所有数据都计算完成之后,并且父RDD的计算结果进行hash并传到对应节点上之后才能计算子RDD。第二,数据丢失时,对于窄依赖只需要重新计算丢失的那一块数据来恢复;对于宽依赖则要将祖先RDD中的所有数据块全部重新计算来恢复。所以在长“血统”链特别是有宽依赖的时候,需要在适当的时机设置数据检查点。也是这两个特性要求对于不同依赖关系要采取不同的任务调度机制和容错恢复机制。

  • 容错原理

在容错机制中,如果一个节点死机了,而且运算窄依赖,则只要把丢失的父RDD分区重算即可,不依赖于其他节点。而宽依赖需要父RDD的所有分区都存在,重算就很昂贵了。可以这样理解开销的经济与否:在窄依赖中,在子RDD的分区丢失、重算父RDD分区时,父RDD相应分区的所有数据都是子RDD分区的数据,并不存在冗余计算。在宽依赖情况下,丢失一个子RDD分区重算的每个父RDD的每个分区的所有数据并不是都给丢失的子RDD分区用的,会有一部分数据相当于对应的是未丢失的子RDD分区中需要的数据,这样就会产生冗余计算开销,这也是宽依赖开销更大的原因。因此如果使用Checkpoint算子来做检查点,不仅要考虑Lineage是否足够长,也要考虑是否有宽依赖,对宽依赖加Checkpoint是最物有所值的。

如下图所示,如果 RDD_1 中的 Partition3 出错丢失,图1.1 Spark 会回溯到 Partition3 的父分区 RDD_0 的 Partition3,图1.2则会回溯到父分区RDD_0的Partition0和Partition3,对 RDD_0 的 Partition3 重算算子,得到 RDD_1 的 Partition3。其他分区丢失也是同理重算进行容错恢复。
在这里插入图片描述

如果 RDD_1 中的 Partition3 丢失出错,由于其父分区是 RDD_0 的所有分区,所以需要回溯到 RDD_0,重算 RDD_0 的所有分区,然后将 RDD_1 的 Partition3 需要的数据聚集合并为 RDD_1 的 Partition3。在这个过程中,由于 RDD_0 中不是 RDD_1 中 Partition3 需要的数据也全部进行了重算,所以产生了大量冗余数据重算的开销。
在这里插入图片描述

5.2 checkpoint 数据检查点

以下两种情况下,RDD需要加检查点。

1.DAG中的Lineage过长,如果重算,则开销太大。
2.在宽依赖上做Checkpoint获得的收益更大。

由于RDD是只读的,所以Spark的RDD计算中一致性不是主要关心的内容,内存相对容易管理,这也是设计者很有远见的地方,这样减少了框架的复杂性,提升了性能和可扩展性,为以后上层框架的丰富奠定了强有力的基础。在RDD计算中,通过检查点机制进行容错,传统做检查点有两种方式:通过冗余数据和日志记录更新操作。在RDD中的doCheckPoint方法相当于通过冗余数据来缓存数据,而之前介绍的血统就是通过相当粗粒度的记录更新操作来实现容错的。

检查点(本质是通过将RDD写入Disk做检查点)是为了通过lineage做容错的辅助,lineage过长会造成容错成本过高,这样就不如在中间阶段做检查点容错,如果之后有节点出现问题而丢失分区,从做检查点的RDD开始重做Lineage,就会减少开销。

@Test
  def checkpoint(): Unit = {
    val conf = new SparkConf().setAppName("cache_prepare").setMaster("local[6]")
    val sc = new SparkContext(conf)
    // 设置保存 checkpoint 的目录, 也可以设置为 HDFS 上的目录
    sc.setCheckpointDir("checkpoint")
    // RDD 的处理部分
    val source = sc.textFile("dataset/access_log_sample.txt")
    val countRDD = source.map( item => (item.split(" ")(0), 1) )
    val cleanRDD = countRDD.filter( item => StringUtils.isNotEmpty(item._1) )
    var aggRDD = cleanRDD.reduceByKey( (curr, agg) => curr + agg )

    aggRDD.checkpoint()

    val lessIp = aggRDD.sortBy(item => item._2, ascending = true).first()
    val moreIp = aggRDD.sortBy(item => item._2, ascending = false).first()

    println((lessIp, moreIp))
  }

在这里插入图片描述

6.缓存

6.1缓存(cache/persist)

cache和persist其实是RDD的两个API,并且cache底层调用的就是persist,区别之一就在于cache不能显示指定缓存方式,只能缓存在内存中,但是persist可以通过指定缓存方式,比如显示指定缓存在内存中、内存和磁盘并且序列化等。通过RDD的缓存,后续可以对此RDD或者是基于此RDD衍生出的其他的RDD处理中重用这些缓存的数据集
在这里插入图片描述

@Test
  def cacheTest: Unit = {
      val conf = new SparkConf().setAppName("test").setMaster("local[2]")
      val sc = new SparkContext(conf)
      val zeros = Seq.fill(10000)(0)
      val randomRDD = sc.parallelize(zeros).map(x => Random.nextInt())
      val filteredRDD = randomRDD.filter(x => x > 0)
//      val rdd2 = filteredRDD.cache()
      val rdd2 = filteredRDD.persist(StorageLevel.MEMORY_ONLY)
    
//      val rdd2 = filteredRDD
      val count1 = rdd2.count()
      val count2 = rdd2.count()
      println((count1, count2))
  }

debug可以看到算子 storageLevel 是缓存在内存的的
在这里插入图片描述

UI 里面 的 stage 图可以看到算子是 cached 的
在这里插入图片描述

6.2 checkpoint与cache/persist对比

在这里插入图片描述

  • 都是lazy操作,只有action算子触发后才会真正进行缓存或checkpoint操作(懒加载操作是Spark任务很重要的一个特性,不仅适用于Spark RDD还适用于Spark sql等组件)

  • 从本质上说:checkpoint是容错机制;cache是优化机制

  • checkpoint将数据写到共享存储中(hdfs);cache通常是内存中。

  • 运算时间很长或运算量太大才能得到的 RDD,computing chain 过长或依赖其他 RDD 很多的RDD,需要做checkpoint。会被重复使用的(但不能太大)RDD,做cache。实际上,将 ShuffleMapTask 的输出结果存放到本地磁盘也算是 checkpoint,只不过这个checkpoint 的主要目的是去 partition 输出数据。

  • RDD 的checkpoint 操作完成后会斩断lineage,改变原有lineage,生成新的CheckpointRDD。通常存于hdfs,高可用且更可靠。而cache操作对lineage没有影响。缓存把 RDD 计算出来然后放在内存中,但是RDD 的依赖链(相当于数据库中的redo 日志), 血统不能丢掉, 当某个点某个 executor 宕了,上面cache 的RDD就会丢掉, 需要通过 依赖链重新计算出来, checkpoint 是把 RDD 保存在 HDFS中, 是多副本可靠存储,所以依赖链就可以丢掉了,就斩断了依赖链(血统), 是通过复制实现的高容错。

7.实战

7.1wordcount例子:

  @Test
  def rddCreationFiles(): Unit = {
    val rdd1: RDD[String] = sc.textFile("dataset/wordcount.txt")
    
    //     1. 把整句话拆分为多个单词
    val rdd2: RDD[String] = rdd1.flatMap(item => item.split(" ") )
    //     2. 把每个单词指定一个词频1
    val rdd3: RDD[(String, Int)] = rdd2.map(item => (item, 1) )
    //     3. 聚合
    val rdd4: RDD[(String, Int)] = rdd3.reduceByKey((curr, agg) => curr + agg )

    //  得到结果
    val result: Array[(String, Int)] = rdd4.collect()
    result.foreach(item => println(item))
  }

RDD转换细节:
在这里插入图片描述

RDD逻辑转换关系图:
在这里插入图片描述

7.2 sql 任务 Spark UI 如何定位到具体的代码:

1.通过stage DAG图中的coordinator id可以找到在SQL页面对应的位置, 例如:

在这里插入图片描述

DAG常见名词

HiveTableScan:扫描hive表

WholeStageCodegen: Whole-Stage Code Generation (aka WholeStageCodegen or WholeStageCodegenExec) fuses multiple operators (as a subtree of plans that support codegen) together into a single Java function that is aimed at improving execution performance. It collapses a query into a single optimized function that eliminates virtual function calls and leverages CPU registers for intermediate data.将多个operators合并成一个java函数,从而提高执行速度

HashAggregate:基于Hash Map 的聚合实现,如sum,count

Project:投影/只取所需列

Exchange:stage间隔,产生了shuffle。

ExchangeCoordinator:An Exchange coordinator is used to determine the number of post-shuffle partitions for a stage that needs to fetch shuffle data from one or multiple stages. The current implementation adds ExchangeCoordinator while we are adding Exchanges. Exchange协调器用于确定需要从一个或多个阶段获取混洗数据的阶段的混洗后分区的数量。是Exchange协调器,是一个用于决定怎么在stage之间进行shuffle数据的coordinator。这个协调器用于决定之后的shuffle有多少个partition用来需要fetch shuffle 数据。

Exchange & ExchangeCoordinator

首先讲一下Exchange,顾名思义就是交换,是为了在多个线程之间进行数据交换,完成并行。

Exchange分为两种,一种是BroadcastExchange另外一种是ShuffleExchange。Broadcast就是将数据发送至driver,然后由driver广播,这适合于数据量较小时候的shuffle。另一种ShuffleExchange就比较常见了,就是多对多的分发。

如果开启了spark.sql.adaptive.enabled,也就是自适应执行,那么在使用ShuffleExchange的时候有对应的ExchangeCoordinator;如果没开启ae,那就不需要协调器。

Filter:过滤(如果筛选字段为分区,不属于Filter,属于HiveTableScan)

CollectLimit:limit 数据

SortMergeJoin: 大表 join 大表

BroadcastHashJoin:小表广播 join 其他表

MapPartitionRDD:RDD的一种。 MapPartitionsRDD对于父RDD的依赖类型只能是OneToOneDependency,代表将函数应用到每一个分区的计算。相关transformation:map, flatMap, filter, mapPartitions等等

ShuffledRDD:RDD的一种。对于父RDD的依赖类型只能是ShuffleDependency,代表需要改变分区方式进行shuffle的计算。会创建ShuffledRDD的transformation:RDD:coalescePairRDDFunctions: reduceByKey, combineByKeyWithClassTag , partitionBy (分区方式不同时) 等OrderedRDDFunctions: sortByKey, repartitionAndSortWithinPartitions

2.也可以创建中间表,中间表会产生 job,缩小代码范围。

7.3 join 探索

代码

@Test
  def join2(): Unit = {
    val rdd1 = sc.parallelize(Seq(("a", 1), ("a", 2), ("b", 1), ("b", 2), ("a", 1), ("a", 2)),
      5)
    val rdd2 = sc.parallelize(Seq(("a", 10), ("a", 11), ("a", 12), ("b", 3), ("b", 1), ("a", 10), ("a", 11)),
      5)

    val rdd3 = rdd1.join(rdd2)
    println("rdd3.dependencies:" + rdd3.dependencies)
    // 收集并打印
    rdd3.collect()
      .foreach(println(_))
  }

输出窄依赖:
在这里插入图片描述

数据已经关联上:
在这里插入图片描述

查看每个分区的数据:

// 打印每个分区的数据
    rdd1.mapPartitionsWithIndex((index, iter) => {
      iter.foreach(item => println("rdd1 partition index:" + index + " item:" + item))
      iter
    }).collect()
    rdd2.mapPartitionsWithIndex((index, iter) => {
      iter.foreach(item => println("rdd2 partition index:" + index + " item:" + item))
      iter
    }).collect()

数据分布均匀,在不同分区,那为什么是一对一依赖呢?

查看 UI 一个 job 三个 stage:
在这里插入图片描述

TableScanHiveTableScan
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

ParallelCollectionRDD [1] 对应 rdd2

ParallelCollectionRDD [0] 对应 rdd1

CoGroupedRDD [2] 对应 rdd3 的之前一个 rdd 的之前一个 rdd

MapPartitionsRDD [3] 对应 rdd3 的之前一个 rdd

MapPartitionsRDD [4] 对应 rdd3

CoGroupedRDD [4]

MapPartitionsRDD [5

debug 后发现图:
在这里插入图片描述

那之前窄依赖图上的 join 是怎么回事呢?

/**
   * Return an RDD containing all pairs of elements with matching keys in `this` and `other`. Each
   * pair of elements will be returned as a (k, (v1, v2)) tuple, where (k, v1) is in `this` and
   * (k, v2) is in `other`. Performs a hash join across the cluster.
   */
  def join[W](other: RDD[(K, W)]): RDD[(K, (V, W))] = self.withScope {
    join(other, defaultPartitioner(self, other))
  }

  /**
   * Return an RDD containing all pairs of elements with matching keys in `this` and `other`. Each
   * pair of elements will be returned as a (k, (v1, v2)) tuple, where (k, v1) is in `this` and
   * (k, v2) is in `other`. Uses the given Partitioner to partition the output RDD.
   */
  def join[W](other: RDD[(K, W)], partitioner: Partitioner): RDD[(K, (V, W))] = self.withScope {
    this.cogroup(other, partitioner).flatMapValues( pair =>
      for (v <- pair._1.iterator; w <- pair._2.iterator) yield (v, w)
    )
  }

  /**
   * For each key k in `this` or `other`, return a resulting RDD that contains a tuple with the
   * list of values for that key in `this` as well as `other`.
   */
  def cogroup[W](other: RDD[(K, W)], partitioner: Partitioner)
      : RDD[(K, (Iterable[V], Iterable[W]))] = self.withScope {
    if (partitioner.isInstanceOf[HashPartitioner] && keyClass.isArray) {
      throw new SparkException("HashPartitioner cannot partition array keys.")
    }
    val cg = new CoGroupedRDD[K](Seq(self, other), partitioner)
    cg.mapValues { case Array(vs, w1s) =>
      (vs.asInstanceOf[Iterable[V]], w1s.asInstanceOf[Iterable[W]])
    }
  }


  override def getDependencies: Seq[Dependency[_]] = {
    rdds.map { rdd: RDD[_] =>
      
      if (rdd.partitioner == Some(part)) {
        logDebug("Adding one-to-one dependency with " + rdd)
        new OneToOneDependency(rdd)
      } else {
        logDebug("Adding shuffle dependency with " + rdd)
        new ShuffleDependency[K, Any, CoGroupCombiner](
          rdd.asInstanceOf[RDD[_ <: Product2[K, _]]], part, serializer)
      }
    }
  }

其中的rdds就是进行cogroup的rdd序列,也就是PairRDDFunctions.cogroup方法中传入的Seq(self, other) .

重点来了,对于所有参与cogroup的rdd,如果它的partitioner和结果CoGroupedRDD的partitioner相同,则该rdd会成为CoGroupedRDD的一个oneToOne窄依赖,否则就是一个shuffle依赖,即宽依赖。

我们知道,只有宽依赖才会触发shuffle,所以RDD的join可以避免shuffle的条件是:参与join的所有rdd的partitioner都和结果rdd的partitioner相同

解释参考: https://www.jianshu.com/p/6bf887bf52b2

一个窄依赖例子

代码:

  @Test
  def join5(): Unit = {
    val rdd1 = sc.parallelize(Seq(("a", 1), ("a", 2), ("b", 1), ("b", 2), ("a", 1), ("a", 2)),
      5)
    val rdd2 = sc.parallelize(Seq(("a", 10), ("a", 11), ("a", 12), ("b", 3), ("b", 1), ("a", 10), ("a", 11)),
      5)
    // 使用 HashPartitioner 分区
    val rdd4 = rdd1.partitionBy(new HashPartitioner(3))
    val rdd5 = rdd2.partitionBy(new HashPartitioner(4))
    val rdd3 = rdd4.join(rdd5)
    // 收集并打印
    rdd3.collect()
      .foreach(println(_))

  }

UI图:

一个 job ,四个 stage
在这里插入图片描述

ParallelCollectionRDD [1] 对应 rdd2

ParallelCollectionRDD [0] 对应 rdd1

ShuffledRDD [2] 对应 rdd4

ShuffledRDD [3] 对应 rdd5

CoGroupedRDD [4] 对应 rdd3 的之前一个 rdd 的之前一个 rdd

MapPartitionsRDD [5] 对应 rdd3 的之前一个 rdd

MapPartitionsRDD [6] 对应 rdd3

CoGroupedRDD [4]

MapPartitionsRDD [5

debug 图:
在这里插入图片描述

一个 broadcast 广播 join 例子

  @Test
  def join(): Unit = {
    // 数据, 假装这个数据很大, 大概一百兆
    val v = Map("Spark" -> "http://spark.apache.cn", "Scala" -> "http://www.scala-lang.org")
    // 创建广播
    //    val bc = sc.broadcast(v)

    // 将其中的 Spark 和 Scala 转为对应的网址
    val r = sc.parallelize(Seq(("Spark", 1), ("Scala", 2)))
    val bc = sc.parallelize(Seq(("Spark", "http://spark.apache.cn"), ("Scala", "http://www.scala-lang.org")))

    val result = r.join(bc)

    result.foreach(println(_))
  }

本质是把小表广播分发到另一张大表所在的分区节点上,大表查这个 kv 的 map 的 value。

 /**
    * 使用广播, 大幅度减少 value 的复制
    */
  @Test
  def bc2(): Unit = {
    // 数据, 假装这个数据很大, 大概一百兆
    val v = Map("Spark" -> "http://spark.apache.cn", "Scala" -> "http://www.scala-lang.org")

    val config = new SparkConf().setMaster("local[6]").setAppName("bc")
    val sc = new SparkContext(config)

    // 创建广播
    val bc = sc.broadcast(v)

    // 将其中的 Spark 和 Scala 转为对应的网址
    val r = sc.parallelize(Seq("Spark", "Scala"))

    // 在算子中使用广播变量代替直接引用集合, 只会复制和executor一样的数量
    // 在使用广播之前, 复制 map 了 task 数量份
    // 在使用广播以后, 复制次数和 executor 数量一致
    val result = r.map(item => bc.value(item)).collect()

    result.foreach(println(_))
  }

只有一个 stage,直接 map 取内存数据

参考:

https://juejin.im/post/6844903982582726669 Spark Core 解析:RDD

https://cloud.tencent.com/developer/article/1085223 Spark运行机制与原理详解目录Spark Internals

https://www.cnblogs.com/qingyunzong/p/8899715.html Spark学习之路 (三)Spark之RDD

  • 0
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Spark RDD(弹性分布式数据集)是Spark中最基本的数据结构之一,它是一个不可变的分布式对象集合,可以在集群中进行并行处理。RDD可以从Hadoop文件系统中读取数据,也可以从内存中的数据集创建。RDD支持两种类型的操作:转换操作和行动操作。转换操作是指对RDD进行转换,生成一个新的RDD,而行动操作是指对RDD进行计算并返回结果。RDD具有容错性,因为它们可以在节点之间进行复制,以便在节点故障时恢复数据。 Spark RDD的特点包括: 1. 分布式:RDD可以在集群中进行并行处理,可以在多个节点上进行计算。 2. 不可变性:RDD是不可变的,一旦创建就不能修改,只能通过转换操作生成新的RDD。 3. 容错性:RDD具有容错性,因为它们可以在节点之间进行复制,以便在节点故障时恢复数据。 4. 惰性计算:RDD的计算是惰性的,只有在行动操作时才会进行计算。 5. 缓存:RDD可以缓存到内存中,以便在后续操作中快速访问。 Spark RDD的转换操作包括: 1. map:对RDD中的每个元素应用一个函数,生成一个新的RDD。 2. filter:对RDD中的每个元素应用一个函数,返回一个布尔值,将返回值为true的元素生成一个新的RDD。 3. flatMap:对RDD中的每个元素应用一个函数,生成一个新的RDD,该函数返回一个序列,将所有序列中的元素合并成一个新的RDD。 4. groupByKey:将RDD中的元素按照key进行分组,生成一个新的RDD。 5. reduceByKey:将RDD中的元素按照key进行分组,并对每个分组中的元素进行reduce操作,生成一个新的RDDSpark RDD的行动操作包括: 1. count:返回RDD中元素的个数。 2. collect:将RDD中的所有元素收集到一个数组中。 3. reduce:对RDD中的所有元素进行reduce操作,返回一个结果。 4. foreach:对RDD中的每个元素应用一个函数。 5. saveAsTextFile:将RDD中的元素保存到文本文件中。 以上就是Spark RDD的详细介绍。 ### 回答2: Apache Spark是一款基于内存的分布式计算系统,可以处理大规模数据,其中最为重要的就是Spark中的RDD(Resilient Distributed Datasets,弹性分布式数据集),RDDSpark中的基本数据结构,是一种类似于数组的分布式数据集,可以被分割成多个分区,并在集群中的多个节点间进行并行计算。RDDSpark提高执行效率和数据可靠性的重要手段。 在Spark中,RDD具有以下三个特点:弹性、不可变和可分区。弹性指RDD能够自动进行数据分区和容错,即使节点出现故障,也能够自动从故障的节点中复制数据,提高了数据的可靠性和并行计算的效率。不可变指RDD一旦创建就不能够被改变,可以进行转换操作生成新的RDD,也可以被缓存到内存中以供重复使用。可分区则指RDD中可以被分成多个分区,实现并行计算。 SparkRDD的API提供了丰富的操作方法,常见的操作包括:转换操作和动作操作。转换操作指对RDD进行转换操作,返回一个新的RDD对象,例如map()、filter()等;动作操作指对RDD进行计算并返回结果,例如reduce()、collect()等。 值得注意的是,RDD是一种惰性求值的数据结构,即当对RDD进行转换操作时并不会立即进行计算,而是当需要对RDD进行动作操作时才会进行计算,这种惰性求值的机制可以进一步提高Spark的效率。同时,为了提高计算效率,可以使用RDD的持久化(缓存)功能,将RDD持久化到内存中,以便复用。 总之,RDDSpark中的核心数据结构,其弹性、不可变和可分区的特点以及丰富的API操作方法,为Spark实现高效计算和数据处理提供了重要的支持。 ### 回答3: Spark RDDSpark的核心抽象,代表分布式的元素集合,支持多种操作和转换。RDD可以看作是一个不可变的分布式内存数据集合,由一些分布式的partition(分区)组成。 1. RDD的特性: - 分布式的数据集,可以跨越多个节点进行计算 - 可以并行处理,充分利用集群计算资源 - 不可变的数据集,任何对数据集的操作都会生成新的数据集 - 支持多种类型的转换操作,如map、filter、reduce、groupByKey等 2. RDD的创建: - 通过外部数据源创建RDD:从HDFS或其他存储系统中读取数据创建 - 通过程序中的数据结构创建RDD:从内存中的数据结构中创建 - 通过其他RDD转换创建RDD:通过对已有的RDD进行转换操作创建 3. RDD的转换: RDD支持多种类型的操作和转换,如map、filter、reduce、groupByKey等。这些转换操作不会立即执行,而是记录下来,等到需要输出结果时才会真正执行。 4. RDD的行动: 行动操作是指对RDD进行计算并返回结果的操作,如count、collect等。行动操作会立即触发RDD的计算过程。 5. RDD的缓存: RDD支持缓存操作,将一个RDD的结果缓存在内存中,提高后续对该RDD的计算效率。缓存可以在计算过程中多次使用,通过unpersist清理缓存。 6. RDD的持久化: 当RDD的计算过程非常复杂时,可以将计算过程中得到的RDD进行持久化以便后续使用。持久化可以选择将RDD保存在磁盘中或者内存中,也可以将RDD复制到多个节点上以保障数据的可靠性。 7. RDD的checkpoint: RDD的checkpoint是指将RDD的计算结果保存在HDFS或其他分布式存储系统中,以便后续查询和还原数据集。在计算复杂的RDD时,使用checkpoint可以避免计算过程中数据丢失的问题。 总的来说,Spark RDDSpark分布式计算的核心特性,其提供对大规模数据集的分布式处理能力,以及丰富的操作和转换方式,使得程序员可以轻松地处理海量数据。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值