Spark2.1.0文档:Spark编程指南-Spark Programming Guide

1 概述

从一个较高的层次来看,每一个 Spark 应用程序由两部分组成:driver program(驱动程序)端运行的 main 函数以及在整个集群中被执行的各种并行操作。Spark 提供的主要抽象是一个弹性分布式数据集(RDD),它是可以被并行处理且跨节点分布的元素的集合。我们可以通过三种方式得到一个RDD

1、 可以从一个 Hadoop 文件系统(或者任何其它 Hadoop 支持的文件系统)创建RDD;

2、 并行化一个在 driver program端已存在的 Scala 集合;

3、 通过 transforming(转换)算子从一个已存在的RDD创建一个 新的RDD。

用户为了让RDD在整个并行操作中更高效的重用,可以将一个 RDD 持久化到内存中。而且,即使某些节点发生故障,RDD也能够自动的恢复丢失的数据分区。

 Spark 中的第二个抽象是能够用于并行操作的 shared variables(共享变量),默认情况下,当 Spark 中的方法作为一组任务运行在不同节点上时,它会为每一个变量创建副本并发送到各个任务中去。有时候,需要在任务之间或任务和驱动程序之间共享一个变量。Spark 支持两种类型的共享变量broadcast variables(广播变量),它可以用于在所有节点上缓存一个值,和 accumulators(累加器),他是一个只能被added(增加)的变量,例如 counters  sums

您可以启动Spark的交互式shell来完成本篇指南的所有示例。

2 连接到Spark

Spark 2.1.0默认基于Scala 2.11来构建和分发。 (Spark可以基于其他版本的Scala来构建。)要在Scala中编写应用程序,您将需要使用兼容的Scala版本(例如2.11.X)。(译者注:对于直接使用官网预编译包的同学请务必安装scala2.11.X)

 

要编写Spark应用程序,您需要添加Spark的Maven依赖。 Spark可通过MavenCentral获得:

groupId = org.apache.spark
artifactId = spark-core_2.11
version = 2.1.0

另外,如果您希望访问HDFS集群,则需要为您的HDFS版本的hadoop-client添加依赖关系。

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

最后,您需要将一些Spark类导入到程序中。 添加以下行:

import org.apache.spark.SparkContext
import org.apache.spark.SparkConf

(在Spark 1.3.0之前,您需要显式导入org.apache.spark.SparkContext._才能启用基本的隐式转换。)

3 初始化Spark

在Spark程序中必须做的第一件事是创建一个SparkContext对象,该对象告诉Spark如何访问集群。 要创建SparkContext,您首先需要构建一个包含有关应用程序信息的SparkConf对象。

每个JVM进程中只能有一个SparkContext是活动的。 在创建新的SparkContext之前,必须调用SparkContext.stop()将原有的SparkContext实例停掉。

val conf=new SparkConf().setAppName(appName).setMaster(master)
val sc = new SparkContext(conf)

appName参数是应用程序在集群UI上显示的名称。 master是Spark,Mesos或YARN集群的URL,或以本地模式运行的特殊“local”字符串。 实际上,当在集群上运行时,您不需要在程序中指定master的地址,而是使用spark-submit启动应用程序并指定相关参数。 但是,对于本地测试和单元测试,您可以通过“local”来以单机模式运行Spark进程。

3.1使用shell

在Spark shell中,将默认创建一个名为sc的SparkContext实例。您自己创建的SparkContext实例将无法正常工作。 您可以通过--master参数设置context连接的主机URL,并且可以通过将逗号分隔的列表传递给--jars参数来将JAR添加到classpath。 您还可以通过向--packages参数提供逗号分隔的maven坐标列表,将依赖关系(例如SparkPackages)添加到shell会话。 对于可能存在依赖关系的其他存储库(例如Sonatype)可以通过--repositories参数指定。 例如,要在四个内核上运行bin / spark-shell,请使用:

$ ./bin/spark-shell --master local[4]

或者,还要将code.jar添加到其classpath中,请使用:

$ ./bin/spark-shell --master local[4] --jars code.jar

使用maven坐标来添加依赖关系:

$ ./bin/spark-shell --master local[4] --packages "org.example:example:0.1"

有关参数选项的完整列表,请运行spark-shell --help。 在幕后,spark-shell实际上也是通过spark-submit脚本来提交运行的。

4 弹性分布式数据集RDD

Spark对数据的处理是围绕着弹性分布数据集(RDD)的概念建立起来的,RDD是可以并行操作的可容错的元素集合。 创建RDD有两种方法:并行化驱动程序中的现有集合,或者引用外部存储系统(如共享文件系统,HDFS,HBase或提供Hadoop InputFormat的任何数据源)中的数据集。

4.1 并行化一个集合

通过调用SparkContext的parallelize方法来将驱动程序端的Scala集合并行化。 集合的元素被复制以形成可以并行操作的分布式数据集。例如,下面是如何创建一个包含数字1到5的并行集合:

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

一旦创建完成,分布式数据集(distData)就可以被并行化处理。例如,我们可以调用distData.reduce((a,b)=> a + b)来对数组的元素进行求和。我们稍后介绍将分布式数据集的操作。

 

并行集合的一个重要参数是将数据集被切分的分区数。Spark将为集群的每个分区运行一个任务。通常,您需要为集群中的每个CPU分配2-4个分区。 通常,Spark会根据您的群集配置自动设置分区数。但是,您也可以通过将其作为第二个参数手动添加,以设置并行度(例如sc.parallelize(data, 10))。注意:代码中的某些地方使用术语“片”(分区的同义词)来保持向后兼容性。

4.2 使用外部数据源

Spark可以从任何Hadoop支持的持久层创建分布式数据集,包括本地文件系统,HDFS,Cassandra,HBase,Amazon S3等。Spark支持文本文件,SequenceFiles(译者注:SequenceFile文件是Hadoop用来存储二进制形式的key-value对而设计的一种平面文件(Flat File))和任何其他Hadoop InputFormat。

可以使用SparkContext的TextFile方法读取文本文件并创建RDD,该方法的参数为文件的URI(机器上的本地路径,或hdfs://,s3n://,等等),并将其作为行的集合读取,下面是一个示例:

scala>val distFile=sc.textFile("data.txt")
distFile:org.apache.spark.rdd.RDD[String]=data.txtMapPartitionsRDD[10]attextFileat<console>:26

一旦RDD创建完成,就可以在distFile上执行数据集操作。例如,我们可以使用map和reduce操作将所有行的长度相加,像这样: distFile.map(s=> s.length).reduce((a, b) => a + b).

有关Spark读取文件的一些注意事项:

1、 如果使用本地文件系统上的文件路径,则文件也必须在worker node上的相同路径上可访问。要么将该文件复制到所有worker节点,要么使用网络挂载的共享文件系统。

2、 Spark的所有基于文件的读入方法,包括textFile,支持目录读取,压缩文件读取和包含通配符的文件名的读取。例如,您可以使用 textFile("/my/directory")textFile("/my/directory/*.txt"), textFile("/my/directory/*.gz").

3、 textFile还支持通过一个可选的第二个参数来控制分区的数量。默认情况下,Spark为文件的每个块创建一个分区(HDFS中默认为128MB),但也可以通过传递较大的值来请求更高数量的分区。请注意,分区的数量不能比数据块的数量少。

除了文本文件,Spark的Scala API还支持其他几种数据格式:

1、 SparkContext.wholeTextFiles允许您读取包含多个小文本文件的目录,并将它们作为(文件名,内容)对返回。 这与textFile方法不同,textFile将为每个文件中每行生成一条记录。

2、 对于SequenceFiles,使用SparkContext的sequenceFile [K,V]方法,其中K和V是文件中的键和值的类型。K和V应该是Hadoop的Writable接口的子类,如IntWritable和Text。此外,Spark允许您使用本地类型来替代几个常见的Writable子类; 例如,sequenceFile [Int,String]相当于sequenceFile [IntWritable,Text]。

3、 对于其他Hadoop InputFormats,您可以使用SparkContext.hadoopRDD方法读取数据,该方法接受任意的JobConf和输入格式类,key类和value类。这些设置与使用相同输入源的Hadoop作业相同。 您还可以使用SparkContext.newAPIHadoopRDD方法,该方法会调用“新的”MapReduce API(org.apache.hadoop.mapreduce)作为InputFormats。

4、 RDD.saveAsObjectFile和SparkContext.objectFile方法支持以包含  “序列化Java对象”的简单格式保存RDD。 虽然比专用格式Avro效率低,但它提供了一种简单的方式来保存任何RDD。

4.3 RDD算子

RDD支持两种类型的操作:transformations用于从已存在的数据集创建新的数据集,和actions在执行完数据集上的计算后会返回一个值给driver program。 例如,map操作是一个transformation操作,它将函数传递给数据集中的每个元素并返回包含所有计算结果的新RDD。 另一方面,reduce是一个action操作,它通过一些函数对RDD的所有元素进行聚合并将最终结果返回给driver program(尽管还有一个返回分布式数据集的并行的reduceByKey操作)。

Spark中的所有的transformation操作都是lazy的,因为它们不会立即计算结果。 相反,他们只是记住对于基本数据集(例如文件)的一系列转换操作。只有当某个动作需要将结果返回给driver program时,才会执行转换操作。此设计使Spark能够更高效地运行。例如,一个通过map创建然后被reduce操作聚合的数据集仅需要将reduce的结果返回给驱动程序,而无需返回较大的map结果数据集。

默认情况下,每次通过transformation操作得来的RDD可能会在每次对其进行操作时重新执行之前的transformation操作。但是,您也可以使用persist(或cache)方法在内存中持久化RDD,在这种情况下,Spark将在下次查询时直接访问集群内存上的数据,效率可以大大提升。Spark还支持在磁盘上持久存储RDD,或跨多个节点进行复制。

4.3.1 基本操作

为了说明RDD的基本操作,请考虑以下基本程序:

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

第一行定义了一个指向外部文件的基本RDD。 此时数据集未加载到内存中或者有其它操作:lines只是指向文件的指针。 第二行定义lineLengths为map转换的结果。由于惰性计算的特性,仍然不会立即计算出lineLengths。 最后,我们对lineLengths进行reduce操作,这是一个action操作。在这个时候,Spark才会将计算分解为一个个task并在分布式的机器上运行,每台机器都执行部分数据的map和进行本地的聚合操作,仅将计算结果返回给驱动程序(driverprogram)。

如果我们希望接下来再次使用lineLengths,最好在reduce操作之前加上:

lineLengths.persist()

如此一来lineLengths在第一次计算之后,其结果会被缓存在内存中。

4.3.2 向Spark传递方法

Spark的API很大程度上依赖于在驱动程序中传递函数然后在集群上去执行。有两种推荐方式来实现这一点:

1、 匿名函数,当函数体比较短时推荐这种用法。

2、 全局单例对象中的静态方法。例如,你可以定义object MyFunctions并且像下面这样传递MyFunctions.func1方法:

object MyFunctions{
  deffunc1(s:String):String={...}
}
myRdd.map(MyFunctions.func1)

需要注意的是,虽然你也可以传递对某个类的实例(而不是单例对象)中的方法的引用,但这需要发送整个对象以及对象中的所有方法。例如,考虑下面的情况:

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

在这里,如果我们创建一个新的MyClass实例并调用doStuff方法,那么里面的map会引用MyClass实例的func1方法,所以整个对象需要被发送到集群。它类似于写rdd.map(x=> this.func1(x))。

与此类似,访问外部对象中的字段将会导致整个对象被引用:

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


相当于这种写法:rdd.map(x => this.field + x),这会引用这个对象的所有东西(方法、字段)。为了避免这个问题,最简单的方法是将field(域)拷贝一份作为本地变量而不是直接引用外部的字段:

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

4.3.3 理解闭包

使用Spark的一个难点是在跨集群执行代码时需要了解变量和方法的范围和生命周期。在RDD算子中对其范围之外的变量进行修改是常见的导致混乱的原因。在下面的例子中,我们将尝试使用foreach()操作来增加计数器并观察结果,对于其他操作也可能会出现类似的问题。

4.3.3.1 例子

下面的例子试图对一个简单RDD中的所有元素进行求和,根据操作是否发生在同一个JVM中,可能会出现不同的行为。一个常见的例子是以本地模式(--master= local [n])运行Spark应用,以及将Spark应用部署到集群中(例如通过Spark-submit将程序提交到yarn集群):

var counter=0
var rdd=sc.parallelize(data)
// Wrong: Don't do this!!
rdd.foreach(x=>counter+=x)
println("Counter value: "+counter)
4.3.3.2 本地模式 vs 集群模式

上述代码的行为是不确定的,可能不会以预期的方式工作。为了执行任务,Spark将RDD的处理操作分解为一个个tasks。在执行之前,Spark会计算task的closure(闭包)。closure是对于executor在RDD上执行计算必须可见的那些变量和方法(在上述例子中为foreach())。 该closure被序列化并发送给每个executor。

发送给每个executor的闭包中的变量现在只是一个副本,因此,当在foreach()方法中引用计数器时,它不再是驱动程序节点上的那个计数器了。在驱动程序节点的内存中仍然有一个计数器,但这对executors来说已经不再可见了! executor只能看到已经序列化的闭包的副本。因此,计数器的最终值仍然为零,因为对计数器的所有操作都是在序列化闭包中的值之上进行的。

在本地模式(local)下,在某些情况下,foreach() 函数可能在与驱动程序相同的JVM内执行,并引用相同的原始计数器,所以在本地模式中计数器可能会被更新。

为了确保在这些场景下程序能拥有确定的行为,你应该使用累加器(Accumulator)。 Spark中的累加器专门用于提供一种机制,这种机制使得在群集中的worker节点上执行切分的任务时能够安全的更新变量。本指南的“累加器”部分将会有更详细的讨论。

一般来说,闭包 - 构造如循环或本地定义的方法不应该用于突变某些全局状态。 Spark不定义或保证从关闭外部引用的对象的突变行为。 这样做的一些代码可能在本地模式下工作,但这只是意外,并且这种代码在分布式模式下将不会按预期的方式运行。如果需要进行全局聚合,则使用累加器。

一般来说像闭包(closures)这种类似于循环或者本地定义方法不应该被用于改变一些全局的状态。Spark不能保证在闭包内修改闭包之外的变量这种行为的确定性,有这样行为的代码可能在本地模式下能够输出正确结果,但是这只是意外,这种行为在分布式模式下将不会按照预期的方式运行,如何需要全局的聚合操作,请务必使用累加器。

4.3.3.3 打印RDD中的元素

另一个常见的惯用语法是试图使用 rdd.foreach(println)或者rdd.map(println)来打印RDD的元素。在单个机器上,这将产生预期的输出并打印RDD中所有的元素。但是,在cluster模式下,stdout被executor调用而且输出会被写入executor的stdout,而不是驱动程序上的那个,所以在驱动程序上的stdout不会显示这些元素!要在驱动程序端打印所有元素,可以使用collect()方法先将RDD提取到驱动程序节点:rdd.collect().foreach(println)。不过这可能会导致驱动程序端的内存资源被耗尽,因为collect()会将整个RDD提取到一台机器上; 如果只需要打印RDD中的几个元素,一个更安全的方法是使用take():rdd.take(100).foreach(println)。

4.3.4 使用键值对

虽然大多数Spark操作适用于包含任何类型对象的RDD,但是几个特殊操作只能在包含键值对的RDD上使用。 最常见的是分布式“shuffle”操作,例如按键对元素进行分组或聚合。

在Scala中,这些操作在包含Tuple2对象的RDD(Scala语言中的内置元组,通过简单写入(a,b)创建)中自动可用。键值对操作均定义在PairRDDFunctions类中,当在包含二元组的RDD上执行键值对操作时会自动应用该类中的方法。

例如,以下代码使用键值对上的reduceByKey操作来计算每行文本在文件中的出现次数:

val lines=sc.textFile("data.txt")
val pairs=lines.map(s=>(s,1))
val counts=pairs.reduceByKey((a,b)=>a+b)

我们也可以使用counts.sortByKey()来按字母顺序对键值对排序,最后用counts.collect()将计算结果作为对象数组返回到驱动程序端。

注意:当使用自定义对象作为key时,必须确保自定义的equals()方法有一个与之相匹配的hashCode()方法。有关完整的详细信息,请参阅Object.hashCode()文档。

4.3.5 变换算子(transformations)

下表列出了Spark支持的一些常见的转换(transformation)操作。有关详细信息,请参阅RDD API文档(Scala,Java,Python,R)和pair RDD函数文档(Scala,Java)。

Transformation

Meaning

map(func)

对源数据中的每个元素分别执行函数func,将结果形成一个新的分布式数据集并返回。

filter(func)

选择经func函数运算返回true的元素形成新的数据集并返回。

flatMap(func)

类似于map操作,但是每个元素可以被映射成0个或多个输出(所以func的返回类型应该是Seq)

mapPartitions(func)

类似于map操作,但是是分别运行于RDD的每个分区(块)之上,所以当在元素类型为T的RDD上运行时,func的类型必须为:Iterator<T> => Iterator<U> 

mapPartitionsWithIndex(func)

与mapPartitions类似,但func也提供了一个表示分区索引的整数型参数,所以当在元素类型为T的RDD上运行时,func必须是类型(Int, Iterator<T>) => Iterator<U> 

sample(withReplacementfractionseed)

用于从RDD中随机提取一定比例(fraction)的样本,作为一个子集返回。Seed为随机数发生器的种子,withReplacement参数用于指定同一个元素是否可以被提取多次。

union(otherDataset)

返回一个包含源数据集和参数数据集中元素的并集组成的新数据集。

intersection(otherDataset)

返回源数据集和参数数据集的交集

distinct([numTasks]))

返回源数据集中的元素去重后的新数据集。

groupByKey([numTasks])

当对包含(k,v)对的数据集调用时,返回一个包含(k, Iterable<V>)对的新数据集。

注意:如果要进行分组以便按key执行聚合操作(如求和或平均值),则使用reduceByKey或aggregateByKey将获得更好的性能。

注意:默认情况下,输出中的并行级别取决于父RDD的分区数。 您可以传递一个可选的numTasks参数来自定义分区的数量。

reduceByKey(func, [numTasks])

当对(K, V)对的数据集进行调用时,返回(K, V)对的数据集,其中使用给定的函数func以reduce的方式聚合每个键的值,该函数类型必须为:(V,V) => V。像groupByKey一样,可以通过可选的第二个参数来配置reduce任务的数量。

aggregateByKey(zeroValue)(seqOpcombOp, [numTasks])

当在包含(K,V)对的数据集上调用该方法,返回值是一个包含(K,U)的数据集,该方法使用给定的组合函数和“零值”对相同key的value进行聚合。允许聚合结果的值的类型和输入数据的类型不同,同时也避免了不必要的内存资源分配。像groupByKey一样,reduce任务的数量可以通过可选的第二个参数进行配置。

sortByKey([ascending], [numTasks])

如果某个包含(K,V)对的数据集中Key的类型实现了Ordered接口,那么可以在该数据集上调用这个方法对键值对按key排序。Ascending参数指定升序(true)还是降序(false)。

join(otherDataset, [numTasks])

源RDD的元素类型为(K,V),参数中RDD的元素类型为(K,W)那么该方法会返回元素类型为(K,(V,W))的数据集(译者注:类似于求笛卡尔积)。可以通过leftOuterJoinrightOuterJoin, fullOuterJoin.实现外连接。

cogroup(otherDataset, [numTasks])

源RDD的元素类型为(K,V),参数中RDD的元素类型为(K,W)那么该方法会返回元素类型为(K,(Iterable<V>, Iterable<W>)) 元组的数据集。该方法和groupWith功能一样。

cartesian(otherDataset)

当对包含类型T和U的数据集进行调用时,返回包含(T,U)对(所有元素对)的数据集(译者注:即笛卡尔积,慎用!)。

pipe(command[envVars])

通过pipe运行外部程序,每个分区中的元素作为外部程序入参运行一次外部程序,而外部程序的输出有创建一个新的RDD。

coalesce(numPartitions)

将RDD中的分区数减少到numPartitions。 过滤大型数据集后,运行该操作可以提高接下来的数据处理效率。

repartition(numPartitions)

重新对RDD中的数据shuffle并分区,该操作总会通过网络进行数据shuffle(即机器之间会有数据交换)。

repartitionAndSortWithinPartitions(partitioner)

根据给定的partitioner重新对RDD分区,并且在每个生成的分区中,根据key对分区中的记录进行排序。 这比调用repartion,然后在每个分区中排序更有效,因为它可以在shuffle的时候完成排序动作。

 

4.3.6 行动算子(actions)

下面的表格列出了Spark支持的一些常用的Action操作。有关详细信息,请参阅RDD API文档和pair RDD functions文档。

Action

Meaning

reduce(func)

使用函数func(接受两个参数并返回一个值)来聚合数据集的元素。该函数应该是commutative和associative,以便它可以并行计算。

collect()

将RDD当做数组返回到驱动程序端,在执行过滤或者其他能够返回一个足够小的数据集的操作后使用collect通常会使一个不错的选择。

count()

返回数据集中元素的个数

first()

返回数据集中的第一个元素(和take(1)类似)

take(n)

返回数据集中前n个元素。

takeSample(withReplacementnum, [seed])

从数据集中随机采样num个元素,并作为数组返回,withReplacement用于指定是否可以重复采样,seed是一个可选的随机数发生器种子。

takeOrdered(n[ordering])

根据自然顺序或者自定义的比较器确定的顺序,比较返回排名前n个元素。

saveAsTextFile(path)

将数据集中的所有元素以文本文件的形式写入本地文件系统、HDFS或其他hadoop文件系统中的指定目录,Spark将在每个元素上调用toString方法将其转换为文本文件上的一行文本(每个分区生成一个文件)。

saveAsSequenceFile(path(Java and Scala)

将数据集的元素作为Hadoop SequenceFile写入本地文件系统、HDFS或其他hadoop支持的文件系统的给定路径中。该方法可作用于包含元素类型为:实现了Hadoop的writable接口的键值对的RDD。在Scala中一些基本的类型如Int、Double、String都可以隐式转换为Writable的类型。

saveAsObjectFile(path(Java and Scala)

将数据集保存为简单的java序列化格式,后面你可以使用SparkContext.objectFile()方法来读取该文件。

countByKey()

仅适用于(K,V)类型的RDD。对每个key的个数进行统计并返回(K,Int)类型的hashmap

foreach(func)

在数据集的每个元素上运行函数func。 这通常用于产生“副作用”,例如更新累加器或与外部存储系统进行交互。

注意:在foreach()中修改非累加器类型的变量可能会导致不确定的行为。 有关详细信息,请参阅了解闭包章节。

 

4.3.7 shuffle操作

Spark中的某些操作会触发被称之为shuffle的事件,shuffle是Spark的重新分布数据的机制,这使得数据可以跨不同的区进行分组。这通常涉及在executor或者机器之间拷贝数据,从而使得shuffle成为复杂和代价高昂的操作。

4.3.7.1 背景

我们可以以reduceByKey操作为示例来了解在shuffle过程中会发生什么。 reduceByKey操作生成一个新的RDD,其中对单个key对应的所有值执行reduce函数定义的操作,并将结果作为该key最终唯一对应的值。这个问题挑战在于,并不是单个key对应的所有值都存在同一个分区上,或者甚至位于同一个机器上,但是它们必须位于同一位置我们才能计算出结果。

在Spark中,特定的操作往往要求某类数据不能是跨分区分布的。在计算过程中,一个分区上运行一个task - 因此,为了重新组织数据以便执行reduceByKey中对单个key对应的values的聚合操作,Spark需要执行一个all-to-all 的操作。它读取所有分区中的数据,以查找每个key对应的所有value,然后将值跨分区汇总以计算每个键的最终结果 - 这个过程就被称为shuffle。

尽管shuffle之后每个分区中的元素集合都是确定的,且分区本身是有序的,但是分区中的数据并不是有序的,如果你希望shuffle后的数据是有序的,你可以使用:

1、 mapPartitions来对每个分区进行排序,例如:.sorted;

2、 repartitionAndSortWithinPartitions来对每个分区排序,同时还可以重新分区;

3、 sortBy来生成一个全局有序的RDD

会导致shuffle的操作包括:repartion、coalesce这样的重分区操作和groupByKey和reduceByKey这样的“ByKey操作”(除了按key计数),还有一些join操作,比如cogroup和join。

4.3.7.2 性能影响

Shuffle是代价高昂的操作,因为它涉及磁盘I/O,数据序列化和网络I/O。为了组织shuffle过程中的数据,Spark会生成一组任务,这些任务包括用于组织数据的map任务和用于聚合数据的reduce任务,这种任务命名方式来自于MapReduce,但是和Spark的map、reduce算子没有直接关系。

在内部,单个map任务的结果将被暂存到内存中,直到达到一定的阈值。然后,这些结果根据目标分区进行排序并写入单个文件。 在reduce端,task读取和其相关的排序好的数据块。

某些shuffle操作可能会占用大量的内存,因为有些操作使用in-memory数据结构(译者注:例如hashMap)来在数据传输之前或之后组织数据记录。特别的,reduceByKey和aggregateByKey在map操作中创建这些数据结构,而其他的“ByKey”操作则在reduce端创建这些数据结构。当数据规模超出内存限制的时候,spark会将数据溢出到磁盘,这会带来额外的磁盘I/O和垃圾回收的开销。

Shuffle过程还会在磁盘上生成大量的中间文件。从Spark 1.3开始,这些文件将被保留,直到相应的RDD不再使用并被垃圾回收。这样做的好处在于,如果需要根据lineage图重新计算某些RDD,就不需要重新创建shuffle文件。 如果应用程序保留对这些RDD的引用或GC不频繁启动,垃圾收集操作的间隔时间可能会很长。这意味着长时间运行的Spark作业可能会占用大量的磁盘空间。在配置Spark上下文时,由spark.local.dir配置参数指定临时存储目录。

可以通过调整各项配置参数来对shuffle进行调优。具体细节请参阅“Spark配置指南”中的“shuffle 行为”部分。

4.4 RDD持久性

Spark中最重要的功能之一是可以在操作中持久化(或缓存)内存中的数据集。在持久化一个RDD之后,每个节点都会将自己负责计算的分区缓存在内存中,接下来对于该数据集(或从其导出的数据集)的操作都可以重用该数据集。这样可以大大加快接下来的操作(通常超过10倍)。缓存机制是spark适用于迭代算法和快速交互场景的关键所在。

您可以使用persist()或cache()方法标记需要持久化的RDD。在action操作第一次触发计算之后,该RDD将被保存在集群的内存中。Spark的缓存是可容错的 - 如果RDD的任何分区丢失,它将根据最初创建数据集的操作自动重新计算。

此外,可以使用不同的存储级别存储每个持久化的RDD,从而允许您将数据集存储到磁盘上,或者保存在内存中(但需要将其序列化为Java对象以节省空间),或将其在节点之间进行复制。可以通过将StorageLevel对象(Scala,Java,Python)传递给persist()方法来设置这些级别。Cache()方法是使用默认存储级别的简写,即StorageLevel.MEMORY_ONLY(在内存中存储反序列化的对象)。所有的存储级别以及说明如下表:

Storage Level

Meaning

MEMORY_ONLY

将RDD作为反序列化的Java对象存储在JVM中。如果内存存不下RDD的所有分区,某些分区将不会被缓存,每次需要时都会重新计算。这是默认级别。

MEMORY_AND_DISK

将RDD作为反序列化的Java对象存储在JVM中。如果内存存不下RDD的所有分区,会将一部分分区存储到磁盘中,并在需要时从磁盘中读取。

MEMORY_ONLY_SER 

(Java and Scala)

将RDD存储为序列化的Java对象(每个分区存储为一个字节数组)。这通常比反序列化对象格式节省空间,特别是在使用fast serializer(快速序列化器)的情况下,但是在读取数据时会耗费较长的CPU时间。

MEMORY_AND_DISK_SER 

(Java and Scala)

与MEMORY_ONLY_SER类似,但是会将内存存不下的分区溢出到磁盘,而不是每次都需要重新计算这些存不下的分区。

DISK_ONLY

将RDD所有分区都存储到磁盘上。

MEMORY_ONLY_2, MEMORY_AND_DISK_2,.

与上述的级别相同,但会将RDD的每个分区保存两份,且这两份分散到集群不同的两个节点。

OFF_HEAP (experimental)

与MEMORY_ONLY_SER类似,但将数据存储在off-heap memory(堆外内存)中。这需要在配置项中启用堆外内存。

注意:在Python中,存储的对象将始终使用Pickle库进行序列化,因此是否选择序列化级别都是无关紧要的。 Python中的可用存储级别包括MEMORY_ONLY,MEMORY_ONLY_2,MEMORY_AND_DISK,MEMORY_AND_DISK_2,DISK_ONLY和DISK_ONLY_2。

在shuffle操作(例如reduceByKey)中,有些时候即使用户没有调用persist()方法,Spark也会自动保留一些中间数据。这样做是为了在当遇到某个节点在执行shuffle操作时发生故障,可以避免重新计算整个输入。如果某个RDD会被重复使用,我们仍然强烈建议用户在生成RDD之前调用persist()方法。

4.4.1 如何选择持久化级别?

Spark的提供不同的存储级别选项旨在向使用者提供“内存使用”和“CPU效率”之间权衡。 我们建议您通过考虑以下几点来确定选用哪一个:

1、 如果内存容量能够容纳RDD所有的分区,建议使用默认的持久化级别(MEMORY_ONLY),在这个级别上cpu的效率最高,且定义在RDD之上的操作都能很快被执行(译者注:因为没有反序列化和磁盘I/O)。

2、 如果内存容不下这么多数据,可以尝试MEMORY_ONLY_SER并且选择一个快速的序列化库来节省对象占用的空间,这种方式仍可以使数据尽快被计算。(译者注:对于计算过程而言,多了反序列化这一步,但是由于不需要磁盘I/O所以仍然是效率很高的方式)。

3、 尽量避免将数据溢出到磁盘,除非重新计算该数据的耗时很长,或者需要过滤大量的数据。否则,从磁盘读取数据还不如重新计算这部分数据来的快。

4、 如果需要要快速的故障恢复,请使用带有复制机制的存储级别(例如,使用Spark来响应来自Web应用程序的请求)。所有存储级别通过重新计算丢失的数据来提供完整的容错能力,但复制的数据可让您继续在RDD上运行任务,而无需重新计算丢失的分区。

4.4.2 移除数据

Spark会自动监视每个节点的缓存使用情况,并以最近最少使用(LRU)算法丢弃旧的数据分区。如果要手动删除RDD,而不是等待它自动被清理出缓存,请使用RDD.unpersist()方法。

5 共享变量

通常,当传递到Spark操作(如map或reduce)的函数在远程集群节点上执行时,它会为函数中使用的所有变量创建独立的副本然后在其上进行操作。这些变量被复制到每个机器,并且远程机器上的变量的更新不会传播回驱动程序。在任务之间读写共享的变量一般是低效的做法。但是,Spark为满足两种常用的使用模式提供了两种有限类型的共享变量:广播变量(broadcast variables)和累加器(accumulators)。

5.1 广播变量

广播变量允许程序员在每台机器上保留一个只读变量,而不是向每个任务分发一个副本。例如,可以使用广播变量以更加有效的方式为每个节点提供大型输入数据集的副本。Spark还尝试使用高效的广播算法分发广播变量,以降低通信成本。

Spark中的各种操作被划分为一组stage然后分阶段执行,这些stage由分布式的“shuffle”操作隔开。在每个stage中Spark会自动广播tasks需要的通用数据。以这种方式广播的数据以序列化形式进行缓存,并在运行每个task之前进行反序列化。这意味着,显式创建广播变量仅在跨多个阶段的任务需要相同数据或者特别需要以反序列化格式缓存数据时才有用。

广播变量通过调用SparkContext.broadcast(v)从变量v创建。广播变量实际是v的包装器,其值可以通过调用value方法来访问。下面的代码显示了广播变量的创建和使用:

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)

创建广播变量后,就应在群集中运行的任何函数中使用它,而不是使用原来的v,以避免v会被多次发送到节点。另外,在创建广播变量之后不应再修改对象v,以确保所有节点获得的广播变量的值是相同的(例如,如果变量可能会在修改后被发送到新节点就会导致在集群中的不同节点对相同的广播变量取到不同的值的情况)。

5.2 累加器

累加器是一个变量,该变量只能通过associative(可结合的)和commutative(可交换的)类型的操作(译者注:即数学上的满足加法结合律和加法交换律)被“added(加)”。累加器可以被用于实现计数器(和MapReduce过程一样)和求和操作。Spark本身支持数字类型的累加器,程序员可以通过实现接口实现对新类型的支持。

作为用户,您可以创建命名或未命名的累加器。 如下图所示,命名的累加器(在本例中为counter)将显示在Web UI中,用于展示每个stage中累加器的修改情况。Spark在“Tasks”表中显示由每个修改的对应累加器的值。


在UI中跟踪累加器有助于了解正在运行的stage的进度(注意:Python中尚不支持该功能)。

可以通过调用SparkContext.longAccumulator()或SparkContext.doubleAccumulator()来分别创建Long或Double类型的数字累加器。然后可以使用add方法将在群集上正在运行的任务中的值加到这个累加器中。 但是,累加器的值对于每个task都是不可见的。只有驱动程序(driverprogram)可以使用累加器的value方法读取累加器的值。

下面的代码展示了一个用累加器来将数组的元素相加的例子:

scala>val accum=sc.longAccumulator("My Accumulator")
accum:org.apache.spark.util.LongAccumulator=LongAccumulator(id:0,name:Some(MyAccumulator),value:0)
scala>sc.parallelize(Array(1,2,3,4)).foreach(x=>accum.add(x))
...
10/09/2918:41:08INFOSparkContext:Tasksfinishedin0.317106s
scala>accum.value
res2:Long=10

虽然上面的代码使用的是内置支持的Long类型的累加器,但开发人员也可以通过继承AccumulatorV2来创建自定义类型的累加器。 AccumulatorV2抽象类有几个方法,在子类中必须将其覆盖:将累加器重置为零的reset方法,将另一个值添加到累加器中的add方法,将另一个相同类型的累加器合并进来的merge方法。关于其他必须覆盖的方法请参阅API文档。举个例子,假设我们有一个代表数学向量的MyVector类,我们可以这样写:

class VectorAccumulatorV2 extends AccumulatorV2[MyVector, MyVector]{
  privateval myVector:MyVector=MyVector.createZeroVector
  defreset():Unit={
    myVector.reset()
  }
  defadd(v:MyVector):Unit={
    myVector.add(v)
  }
  ...
}
// Then, create an Accumulator of this type:
val my VectorAcc=new VectorAccumulatorV2
// Then, register it into spark context:
sc.register(myVectorAcc,"MyVectorAcc1")

请注意,当程序员定义自己的AccumulatorV2类型时,结果的类型可以与添加的元素的类型不同。

对于仅在spark action类型操作内执行的累加器更新,Spark保证每个任务对累加器的更新只会被应用一次,即重新启动的任务将不会更新该值。在transformation操作中,用户应该注意,如果重新执行tasks或job stages,则每个task对累加器的更新可能会被多次应用。

累加器不会改变Spark的lazy计算方式。如果它们在RDD的操作中被更新,则只有在对RDD的计算真正执行的时候,才会更新其值。因此,累加器更新不能保证在像map()这样的lazy变换中立即执行。 以下代码片段给出了这种特定的示例:

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.

上面代码中,第二行代码结束后累加器的值仍然是0,因为此时map操作还没有被执行。

6 部署应用到集群

在另一篇应用程序提交指南中将介绍如何将应用程序提交到群集。简而言之,一旦将应用程序打包到JAR(用于Java /Scala)或一组.py或.zip文件(对于Python)中,bin /spark-submit脚本可以将其提交给任何Spark支持的集群管理器。

7 通过java/scala提交spark作业

org.apache.spark.launcher包提供了使用简单Java API用于将Spark作业作为子进程(注意:这里是“进程”)启动的类。

8 单元测试

你可以使用任何流行的单元测试框架对Spark应用进行单元测试。只需在测试中创建一个SparkContext,将master URL设置为local,运行您的操作,然后调用SparkContext.stop()释放连接。应确保在finally块或测试框架的tearDown方法中停止context,因为Spark不支持在同一程序中同时运行两个context。

9 下一步可以做什么

您可以在Spark官方网站上看到一些Spark程序的示例。此外,Spark在examples目录中也包含了几个示例(Scala,Java,Python,R)。您可以通过将类名传递给Spark的bin / run-example脚本来运行Java和Scala示例; 例如:

./bin/run-example SparkPi

对于python示例,使用spark-submit:

./bin/spark-submit examples/src/main/python/pi.py

对于R示例,使用spark-submit:

./bin/spark-submit examples/src/main/r/dataframe.R

有关程序的优化, configuration tuning 页面提供有关最佳实践的信息。它们对于确保您的数据以有效的格式存储在内存中尤为重要。有关部署方面,cluster mode overview 描述了分布式操作涉及的组件和支持的集群管理器。

最后,Scala,Java,Python和R中提供了完整的API文档。


评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值