Spark 2.3.0 RDD Programming Guide 学习笔记

一 概述

每个spark应用都会有一个driver程序,运行用户main函数,在集群上执行此应用的并行操作。spark主要的抽象出来的概念是RDD,它是一个数据的集合,以分区方式分布式存储在各个集群节点上,因此可以在分区上进行并行操作。RDD可以通过hadoop支持的文件系统上的文件创建,或者driver程序中存在的scala集合。用户可以对RDD进行持久化操作,缓存到内存中,提高RDD多次参与计算的效率。RDD某节点上的分区丢失可恢复。

另外一个抽象出来的概念是可用于并行操作的共享变量。一个函数spark会把它分解为多个task,在不同的节点进行并行执行,因此每个任务会保持函数中的变量的一个拷贝。然后有些时候,我们的需求是多个task或driver和task共享一个变量。spark支持两种共享变量:广播变量(每个节点共享一个变量);累加器(可用来做count和sum操作)。

二 连接Spark

spark2.3.0默认使用scala2.11,所以如果你使用2.3.0版本进行scala版本程序开发,在你的idea中配置2.11.X版本scala环境。

首先你要把spark-core(如果不需要其他例如spark sql或streaming等,仅仅对RDD进行操作)依赖加到pom文件中:

    <dependency>
      <groupId>org.apache.spark</groupId>
      <artifactId>spark-core_2.11</artifactId>
      <version>2.3.0</version>
    </dependency>

一般会用到hdfs,所以需要hadoop client依赖:

    <dependency>
      <groupId>org.apache.hadoop</groupId>
      <artifactId>hadoop-client</artifactId>
      <version>your hdfs version</version>
    </dependency>

最后,你需要在driver程序里引入两个变量:

import org.apache.spark.SparkContext(spark程序总的切入点)
import org.apache.spark.SparkConf(spark程序配置)

三 初始化Spark

你的spark程序里首先要创建一个SparkContext实例,它需要一个SparkConf(你应用程序的配置)实例进行初始化。如下:

val conf = new SparkConf().setAppName(appName).setMaster(master)(设置一些spark的配置参数,一般master参数不在程序里硬编码,会用spark-submit传进来)
new SparkContext(conf)

一个JVM只有一个SparkContext实例,所以你如果想新建一个实例,必须先停止之前的实例。

appName会显示在集群监控页面上,master可以设置为spark(spark standlone)、mesos、yarn(hadoop yarn)形式的url或者“local”(本地运行,一般测试阶段使用)。

3.1 使用Spark Shell

在spark-shell中,SparkContext实例sc已经自动创建好,如下图所示(同时创建了一个SparkSession实例,在spark sql中使用,并且可以通过4040端口网页进行作业监控):


由于一个jvm只能实例化一个,所以你如果自己再去创建则不会起作用。

可以用过--master参数设置SparkContext连接到的master,--jars添加额外的一些依赖包,--packages添加包,--repositories添加仓库。

$ ./bin/spark-shell --master local[4](本地执行,4个线程)
$ ./bin/spark-shell --master local[4] --jars code.jar(如果程序中使用的依赖包没有打到一个jar包里,那么这里必须指定依赖的包)
$ ./bin/spark-shell --master local[4] --packages "org.example:example:0.1"(通过maven依赖方式添加)
$ ./bin/spark-shell --help(忘记了随时查看使用方法)

四 RDDs

4.1 数据源

两种方法创建RDDs:并行化driver程序中集合;外部存储系统(例如一个共享文件系统、hdfs、hbase或者其他任何提供hadoop inputFormat的数据源)中得到的数据集合。

第一种:driver数据集

val data = Array(1, 2, 3, 4, 5)
val distData = sc.parallelize(data)

通过SparkContext的parallelize方法对driver程序中的集合进行转化,转化为一个可并行操作的分布式数据集(集合本来是一个driver程序所在节点的一个普通的集合,通过此操作转化为分布在集群上的RDD)。

转化成RDD之后,就可以使用RDD的一些并行操作函数进行数据处理。

你也可以通过对parallelize添加第二个参数:sc.parallelize(data,10)控制转化后的RDD的分区数来控制并发。一般一个集群中一个cpu建议2-4个分区,所以加入你local方式运行程序,本机cpu为4个,那么建议RDD分区数为2*4-4*4个。

第二种:外部数据集

spark可以从hadoop支持的任何存储源中创建分布式数据集,例如本地文件系统、hdfs、cassandra、hbase、amazon s3等。spark支持文本文件、sequencefiles以及其他hadoop inputformat。

文本文件RDDs可以通过SparkContext的textFile方法创建,方法传入文件所在位置的url(要么是本地路径,要么是hdfs://开头的hdfs路径、S3A://开头的s3路径等等··),如下:

val distFile = sc.textFile("data.txt")(默认会去hdfs上去找文件,所以如果你要使用本地文件的话,需要这么写file:///usr/local/spark/README.md)

注意:

1.如果是本地系统路径,路径中的文件必须在其他worker节点同样的目录中也能访问到。所以你需要把文件拷贝到其他节点同样的目录或者使用network-mounted共享文件系统。

2.spark所有基于文件的input方法,包括textFile,支持目录、压缩文件、通配符。例如你可以这么使用:textFile("/my/directory"), textFile("/my/directory/*.txt"), and textFile("/my/directory/*.gz")

3.textFile也可以用第二个参数控制文件的分区数。默认是一个文件块(hdfs中默认块大小为128M)一个分区,但是你可以通过此参数传入一个更大的值来增加并发(例如默认128M一个块,文件本来是10个块,你就可以传入比10大的一个值来增加分区数)。注意你不能传入比实际块个数小的值(文件存在hdfs中,块只能比设置的块大小(默认128M)小)。

4.除了文本文件,spark也支持其他几个数据格式:

SparkContext.wholeTextFiles可以让你读取目录中包含的多个小的文本文件,已(filename,context)键值对形式返回。相比于textFile方法,textFile是把文件以一行作为一个RDD数据集的一个记录返回的。数据分区是由数据的本地化决定的,有时候会造成分区数较少,所以你可以传入第二个参数来控制最小分区数;

sequenceFiles,使用SparkContext的sequenceFile[k,v]方法读取。k和v应该是hadoop的Writable接口的子类,例如IntWritable和Text。另外,spark允许你使用基本数据类型,例如sequenceFile[Int,String],它会自动转成IntWritables和Texts。;

对于其他hadoop InputFormats,你可以使用SparkContext.hadoopRDD方法读取。可以使用JobConf来指定input foramt类、key类和value类。针对hadoop的“new”mapreduce api,可以使用SparkContext.newAPIHadoopRDD;

RDD.saveAsObjectFile和SparkContext.objectFile支持以序列化java实体类的格式保存RDD。

4.2 RDD操作

4.2.1 基础

支持两种操作:transformations(从存在的数据集转化成另一个新的数据集)和actions(数据集上经过一系列计算后返回driver端一个值)。例如map函数,就是对一个数据集上的每一个记录进行传入的函数操作后得到的一个新的结果数据集。reduce函数就是使用一些函数对所以数据集记录进行聚合操作,最终得到一个最终聚合值(可以是一个数值,也可以使用reduceByKey返回一个分布式数据集)返回给driver端。

spark的所有的transformations操作都是懒惰的,也就是说他们只是记住在原始的RDD上进行的一系列操作而不真正的进行计算操作,只有当一个action操作需要计算出一个结果返回给driver端的时候,才会去回过头来一步步进行计算。这样的好处就是可以更加高效的进行计算,因为中间过程的transformations操作只会在worker节点进行,不会返回给dirver端,最终只会返回reduce计算的结果给driver端,而不是很大的mapped的数据集。

默认情况下,你每次运行一个action操作,RDD的transformations都会从头到尾执行一遍,会重复进行。而你的程序中一般不会只有一个action,多个action之前的transformatons有可能会重合,因此重合部分的transformations会重复进行计算,影响效率。所以你可以对重合部分的最后一个transformations进行持久化操作(使用persist和cache方法)。持久化操作会把RDD保存到集群中,多个action运行时,重合部分的转换操作不会重复进行。持久化操作支持持久化到内存、磁盘,也可以对数据进行多副本容错(如果持久化的数据丢失,那么还是会重复计算,如果多副本容错后,就降低了重复计算的风险)。

val lines = sc.textFile("data.txt")
val lineLengths = lines.map(s => s.length)
val totalLength = lineLengths.reduce((a, b) => a + b)

上面例子,求data.txt文件中所有行的长度。首先通过textFile读取文件,转换成String类型的RDD(文件每行字符串一个RDD记录) lines,lines通过map转换成另外一个Int类型的RDD(每个记录是原文件每行的长度) lineLengths,以上都是懒惰的,并不会立刻执行,最后进行reduce操作,对lineLentths的每个记录进行累计求和操作,最终得到一个值返回给driver端,此刻,就会从头到尾执行这三行计算过程。

如果你还有多lineLengths进行别的action操作,那么在上面reduce之前要多lineLengths进行persist操作:

lineLengths.persist()

4.2.2 函数传递

driver端会把函数传到集群节点去执行,有两种推荐的传递方式:

1.匿名函数。

2.全局单例静态方法。如下(把func1方法传到集群节点的task中去执行):

object MyFunctions {
  def func1(s: String): String = { ... }
}

myRdd.map(MyFunctions.func1)

注意,如果使用以下方式:

class MyClass {
  def func1(s: String): String = { ... }
  def doStuff(rdd: RDD[String]): RDD[String] = { rdd.map(func1) }
}

如果你新建了Myclass,然后调用了它的doStuff方法,相当于调用了rdd.map(x => this.func1(x)),会把整个实例都传到集群,类似的,如果引用了类的成员变量,也会传整个实例,如下:

class MyClass {
  val field = "Hello"
  def doStuff(rdd: RDD[String]): RDD[String] = { rdd.map(x => field + x) }
}

所以,如果要避免这种额外的消耗,建议对使用到的成员变量进行一个局部变量拷贝,如下:

def doStuff(rdd: RDD[String]): RDD[String] = {
  val field_ = this.field
  rdd.map(x => field_ + x)
}

函数中先对成员变量进行了拷贝,所以在集群的task中,都会有一个成员变量的拷贝,而不是在集群task中进行this引用,所以不需要传整个实例。

4.2.3 理解闭包

spark中比较难理解的就是集群环境下执行代码的变量和方法的作用域和生命周期,RDD操作中修改它作用域范围外的变量,是造成这种难以理解的症结所在。我们会用在RDD的foreach操作中进行计数来举例说明这个问题,当然其他操作也可能会有相似的情况。

var counter = 0
var rdd = sc.parallelize(data)

// Wrong: Don't do this!!
rdd.foreach(x => counter += x)

println("Counter value: " + counter)

上面的例子中,本地local环境执行和集群模式执行结果会不同。

我们知道上面的程序在driver端进行,counter是一个driver端全局范围的一个变量,rdd的foreach操作会把x => counter+=x这个匿名函数序列化发往集群的task中执行,所以,每个task都会有一个counter的拷贝,foreach操作执行完之后,只是计算出了每个分区的数据求和,最后各个task的各个counter不会进行聚合累加并返回给driver端,所以driver端最后打印的counter值仍然是0.这里就可以用之后讲到累加器来实现了。

还有一个例子是rdd.foreach(println),我们的目的是把rdd的所有记录都打印出来,但是println被发到集群其他节点执行后,打印也是打印到集群上的机器,所以driver端你是看不到的。

4.2.4 key-value键值对

相比于一般的RDD,key-value类型的是以(key,value)这种形式作为一条记录的RDD,在此种类型RDD上可以进行特定kv类型的transformations和actions。例如reduceByKey、sortByKey等。

注意:如果是自定义的key,那么必须实现自己的equals、hashCode方法,因为类似于reduceByKey这种函数,是把相同key的记录进行聚合操作,所以必须要提供判别key是否相同的依据,就是key的equals和hashCode方法。

4.2.5 transformations和actions

这里参考官方文档,具体使用具体研究。

4.2.6 shuffle操作

特定的一些操作会触发spark的shuffle操作,shuffle操作代表为了一些聚合操作,要跨分区重新分发数据,因为要跨executor和机器来拷贝数据,所以shuffle操作是非常耗费资源的昂贵操作,要尽量优化或者避免。

要理解这个概念,可以使用reduceByKey来进行。reduceByKey作用于(key,value)键值对类型的RDD,会对相同key的记录进行某种操作,所以输入必须是(key,key的value集合),但是原始数据是分散分布的,相同key的记录可能分布在不同机器、不同分区上,所以首先要把相同key的记录聚合在一起,比如key=a的记录,那么就要从所有的分区里找出所有的key=a的记录,然后聚合在一起就行reduceByKey操作,所以就会进行shuffle操作。

repartition操作(repartition、coalesce)、ByKey操作(groupByKey、reduceByKey)、join操作(cogroup、join)都会引起shuffle操作。

性能问题:

shuffle操作涉及磁盘IO、数据序列化、网络IO,所以是非常昂贵的操作。为了完成shuffle操作,spark会启动很多map task去组织和处理数据,reduce task去聚合这些数据(这里的map和reduce不特指spark的map和reduce函数,而是类比于hadoop的mr过程)。

一个原始分区(一个分区一个task),map任务读取此分区数据进行处理,读取到task内存放不下就会把这些数据排序保存到一个单独文件中,reduce侧再去读取这些分类好的数据进行相关聚合操作。

特定的shuffle操作会因为要使用内存数据结构来组织处理RDD数据集合记录,而使用大量的heap内存。所以当内存不够用的时候就会落到磁盘,就会导致磁盘IO和垃圾回收。

shuffle过程也会生成大量的中间文件(spark.local.dir),这些文件会一直存在,直到相关的RDD(RDD数据内存放不下就会放磁盘)不再使用或被垃圾回收。之所以这样做,是为了如果要重新计算,这些shuffle文件不会被重新创建。所以如果你的程序一直保持这些RDD的引用,垃圾回收就不会去回收。这意味着,如果你的应用是一个长时间运行的程序,那么就会使用大量的磁盘空间。

4.2.7 RDD持久化

为了提高计算效率(如果一个RDD,在之后会用于多次的action操作),会对RDD进行持久化操作。涉及两个方法cache和persist,可以传StorageLevel参数给persist函数控制持久化策略。cache其实就是使用SorageLevel.MEMORY_ONLY策略的持久化方法。StorageLevel策略如下:

RDD A---->map操作为RDD B---->map操作为RDD C(持久化) ------>action操作D、E

MEMORY_ONLY:只存储到内存中(未序列化),RDD数据会存储在内存中,存不下的分区就不会被缓存,后面每次action操作都会重复进行计算。比如C的一些分区没有内存空间持久化了,D、E action操作对于已经缓存的分区直接进行计算就可以了,但未缓存的分区,就会从A到C从头到尾计算一遍。

MEMORY_AND_DISK:保存到内存(未序列化),内存放不下放磁盘。例如C一些分区保存到内存,一些保存到磁盘,后面action D、E计算的时候,内存中的直接计算,磁盘的会先读取再参与计算(相比于纯内存的,多了一个磁盘读取操作)

MEMORY_ONLY_SER:只保存到内存中(序列化),这样会节省内存空间放更多数据,但后面计算就要消耗cpu去反序列化

MEMORY_AND_DISK_SER:同上可理解

DISK_ONLY:只保存到磁盘。

MEMORY_ONLY_2、MEMORY_AND_DISK_2,等:同上策略,只不过要备份存储,为了防止数据丢失,容错处理。如果数据丢失造成的重复计算的代价太大的场合,同时存储空间够用情况下可以使用。

OFF_HEAP(实验性质):和MEMORY_ONLY_SER相似,只不过是在off-heap memory中保存数据。

选择哪个存储策略?

其实就是根据你实际情况在内存和cpu之间权衡。

1:如果内存够用,程序执行想越快越好,那么就用默认的MEMORY_ONLY。

2:如果内存不够,但是cpu很富裕,那就用MEMORY_ONLY_SER,但要选一个比较快的序列化库。

3:尽量不要去用落地到磁盘的策略,除非你从头到尾重复计算的代价很大或者RDD C数据量很大(基本和最初的原始数据一样大)。重复计算一个分区可能和你从磁盘读取一样快。

4:如果你想要快速容错,那么要使用副本存储策略。以上所有策略都是容错的,比如C的某分区数据丢失,那么它可以通过重新计算得到(不必所有分区都进行重复计算),但如果你用了副本存储策略,那么就可以直接使用副本数据。

数据删除:

spark会使用LRU策略(最近最少使用)进行数据删除,如果你想手动更快的对数据进行清理,那么就要调用RDD.unpersist函数进行。

五 共享变量

main函数里的一些transformations和actions操作,传入的函数,会被传送到各个节点去执行,所以函数内的变量实际上是一个拷贝,最终计算结果,不会回传到driver端,所以如果想做一些跨task的共享的读写变量,spark提供了两种共享变量:广播变量和累加器

5.1 广播变量

广播变量一个机器维持一个,而不是一个task一个,是只读变量。所以把一个比较大数据量的数据集广播出去,一个节点一个拷贝,而不是一个task一个,这样会减少数据的网络传输以及存储空间等。

action操作是由一系列的stage组成的,而stage的划分是shuffle操作划分的,也就是遇到shuffle操作,之前的一些列转换就是一个stage。spark会把各个stage用到的数据自动广播出去(每个task一个拷贝),这些数据是序列化存储的,task运行之前要反序列化。所以当你的需求是:多个stage需要相同的数据(如果仅仅一个stage);反序列化缓存数据,使用广播变量才有意义。

通过如下创建变量和访问:


scala> val broadcastVar = sc.broadcast(Array(1, 2, 3))
broadcastVar: org.apache.spark.broadcast.Broadcast[Array[Int]] = Broadcast(0)

scala> broadcastVar.value
res0: Array[Int] = Array(1, 2, 3)

创建变量之后,集群上运行的任何需要用到的函数中使用,而不是使用广播变量的值,如果还是使用它的值,那么就会造成多次传送数据到节点。广播变量广播出去后就不应该再去改变它,以确保所有的节点上的广播变量的值一样。

5.2 累加器

rdd的操作函数是并行操作的,累加器在这些函数中使用,能够实现一些计数和求和功能。累加器支持数值类型,也支持自定义的类型。

你可以创建有名称的累加器或者无名称的。建议对累加器进行命名,这样在web ui中就可以一目了然的看到累加器的更新情况。在task执行情况列表里,可以看到累加器的各个task执行后的值情况。如下:

可以使用SparkContext.longAccumulator或SparkContext.doubleAccumulator去创建Long或Double类型的累加器,然后你就可以在集群上task里使用add方法对其进行累加。在集群任务上累加器不可读,只能driver端可以使用value方法读取到它的值。如下是数值类型的累加器的基本使用(求和):

scala> val accum = sc.longAccumulator("My Accumulator")
accum: org.apache.spark.util.LongAccumulator = LongAccumulator(id: 0, name: Some(My Accumulator), value: 0)

scala> sc.parallelize(Array(1, 2, 3, 4)).foreach(x => accum.add(x))
...
10/09/29 18:41:08 INFO SparkContext: Tasks finished in 0.317106 s

scala> accum.value
res2: Long = 10

你也可以通过集成AccumulatorV2来实现自己的累加器。AccumulatorV2抽象类需要实现几个方法:reset:重置累加器为0;add:怎么在累加器上添加新值;merge:怎么合并另外一个累加器。例如下面定义自己的向量累加器:

class VectorAccumulatorV2 extends AccumulatorV2[MyVector, MyVector] {//根据自己的需求,[]里可以是自定义的一些类型

  private val myVector: MyVector = MyVector.createZeroVector//累加器保存最终值的实例(可以和要累加的类型不同,比如这里不是MyVector类型,而是其他类型)

  def reset(): Unit = {
    myVector.reset()//置0
  }

  def add(v: MyVector): Unit = {
    myVector.add(v)//这里就是在集群函数中执行的add方法的实现
  }
  ...//还有merge方法,是用来把各个task中最终的累加器值合并的时候用的。
}

// Then, create an Accumulator of this type:
val myVectorAcc = new VectorAccumulatorV2
// Then, register it into spark context:
sc.register(myVectorAcc, "MyVectorAcc1")
累加器不会改变spark的懒惰执行机制,所以如果你在map中使用了累加器,然后在action操作之前读取累加器的值,那么读到的值还是0,如下:
val accum = sc.longAccumulator
data.map { x => accum.add(x); x }
// Here, accum is still 0 because no actions have caused the map operation to be computed.

所以累加器在遇到action操作才会去真正的触发累加更新,这里有个陷阱要注意,例如对RDD A进行map操作,map中使用了累加器,得到RDD B,然后对RDD B先后执行了action C和D。那么C之后累加器的值就是你所期望的累加值,但是由于B没有持久化操作,所以D执行的时候,又从头到尾进行了一次完成作业,所以累加器又加了一遍,所以D之后访问累加器,就得到了加倍的累加值。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

hjbbjh0521

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值