预览
从表面上看,每个Spark都包含一个驱动程序,它负责运行main
函数并执行各种并行操作。Spark提供的主要抽象是弹性分布式数据集(RDD),它是一个可以并行操作,按照分区分布在整个集群中的元素集合。RDD可以从Hadoop文件系统中的文件创建,也可以从驱动程序中的集合创建。用户也可以持久化RDD到内存中。最后,RDD能够自动从失败的节点中恢复数据。
Spark的第二个抽象是可以用于并行操作的共享变量。通常,Spark将函数作为任务并行运行在不同节点上,Spark会为每个任务传递一份函数使用变量的拷贝。有时,变量需要在任务之间,或者任务与驱动程序之间共享。因此Spark提供了两种类型的共享变量:广播变量,可以在所有节点的内存中缓存的数据。累加器,只能执行“加”操作。
使用Spark
Spark 2.4.0默认使用Scala 2.11版本。编写Spark应用程序,需要先添加Spark依赖,Maven坐标如下:
groupId = org.apache.spark
artifactId = spark-core_2.11
version = 2.4.0
此外,如果需要访问HDFS集群,还需要添加hadoop-client
依赖:
groupId = org.apache.hadoop
artifactId = hadoop-client
version =
最后,在程序中引入必要的Spark类:
import org.apache.spark.SparkContext
import org.apache.spark.SparkConf
初始化Spark
Spark程序的第一件事是创建SparkContext
对象,它负责访问Spark集群。SparkContext
对象需要一个包含必要配置信息的SparkConf
对象作为参数。
每个JVM中只能有一个活动的SparkContext
对象。请确保在创建新SparkContext
之前调用stop()
方法关闭旧的SparkContext
。
val conf = new SparkConf().setAppName(appName).setMaster(master)
new SparkContext(conf)
appName
参数就是应用程序的名字。master
参数的值为集群URL,本地模式运行时的值为“local”。通常,集群模式下不需要显式指定master
的值,它可以通过spark-submit
脚本传递。测试时可以使用“local”让程序在本地运行。
使用shell
Spark shell中自动包含一个创建好的SparkContext
,变量名为sc
。可以通过--master
选项设置上下文的master
值,也可以通过--jars
选项将类库添加到Spark的类路径中。--packages
选项允许你使用Maven坐标为Spark添加依赖。额外的Maven仓库可以通过--repositories
选项指定。例如,本地4线程运行bin/spark-shell
,可以:
$ ./bin/spark-shell --master local[4]
添加类库可以:
$ ./bin/spark-shell --master local[4] --jars code.jar
使用Maven坐标的方式如下:
$ ./bin/spark-shell --master local[4] --packages "org.example:example:0.1"
完整的选项列表,可以通过bin/spark-shell --help
查看。
弹性分布式数据集(RDD)
Spark是围绕着弹性分布式数据集(RDD)这一概念构建的,RDD是一种可并行操作,可容错的数据集合。创建RDD的方式有两种:并行化驱动程序中的集合,或者引用外部存储系统中的数据集,例如HDFS,HBase等。
并行化集合
调用SparkContext
的parallelize
方法即可并行化一个集合。集合中的数据用于构建一个可以并行操作的分布式数据集。例如:
val data = Array(1, 2, 3, 4, 5)
val distData = sc.parallelize(data)
创建完毕后,可以通过distData.reduce((a, b) => a + b)
将数组中的值求和。
并行化集合中一个很重要的参数是该数据集的分区数。Spark会为每个分区启动一个任务。通常应当为集群中的每个CPU分配2-4个分区。Spark一般会尝试自动设置分区数,不过用户依然可以手动设置该值,例如sc.parallelize(data, 10)
。
外部数据集
Spark可以从任何Hadoop支持的存储源创建RDD,包括本地文件系统,HDFS,HBase等。
SparkContext
的textFile
方法可以创建文本RDD。它以文件的URI为参数,返回一个行的集合。例如:
scala> val distFile = sc.textFile("data.txt")
distFile: org.apache.spark.rdd.RDD[String] = data.txt MapPartitionsRDD[10] at textFile at :26
创建完毕后,distFile
就可以操作了。例如,可以使用map
和reduce
结合起来求所有行的长度:distFile.map(s => s.length).reduce((a, b) => a + b)
。
使用Spark读取文件的要点:
- 如果读取本地文件,必须确保所有节点都可以访问该文件。可以将文件拷贝到各个节点,或者使用网络挂载共享。
- Spark所有基于文件的输入方法,包括
textFile
,都支持目录,压缩文件,以及通配符。例如:textFile("/my/directory")
,textFile("/my/directory/*.txt")
,textFile("/my/directory/*.gz")
。 -
textFile
方法有一个可选参数,可以控制文件的分区数。Spark默认为文件的每个block(HDFS默认block大小为128MB)创建一个分区。用户可以手动设置分区数,但是分区数不能少于block数。
除了文本文件,Spark的Scala API也支持其它几种数据格式:
-
SparkContext.wholeTextFiles
方法可以读取包含大量小文件的目录,返回结果的格式为序对(filename,content)。分区是由本地数据决定的,某些情况下,可能会导致分区过少。对于这种情况,wholeTextFiles
提供了第二个可选参数,用于控制最小分区数。 -
SparkContext
的sequenceFile[K, V]
方法可以读取SequenceFile,其中K
和V
是文件中键与值的类型。这些类型应当扩展自HadoopWritable
接口,例如IntWritable
和Text
。此外还可以使用某些原生类型,例如sequenceFile[Int, String]
。 - 其他的Hadoop输入格式,可以使用
SparkContext.hadoopRDD
方法。还可以使用SparkContext.newAPIHadoopRDD
方法。 -
RDD.saveAsObjectFile
和SparkContext.objectFile
方法可以将RDD保存成一个简单格式的序列化Java对象。
RDD操作
RDD支持两种类型操作:transformation,它可以将一个RDD转换成另一个RDD,action,在数据集上执行计算并将结果返回给驱动程序。例如,map
就是一个transformation,它将数据集中的元素通过函数进行转换,并返回一个新的RDD。reduce
是一个action,它使用函数将RDD中的元素进行聚合,并将最终结果返回给驱动程序。
所有transformation都是延迟执行的,即它们不会马上计算出结果,而是等到某个action需要计算结果的时候才执行。这种设计可以让Spark更高效。
所有RDD都可以重复执行计算,也可以使用persist
或cache
方法持久化RDD到内存中。Spark也支持将RDD持久化到硬盘上,或者在其它节点中备份。
基础
下面是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
并没有立即计算。最后,调用reduce
动作,此时Spark将整个计算过程分成任务发送给各个节点,每个节点运行自己的本地操作,最后将结果返回给驱动程序。
可以将lineLengths
持久化到内存,以便再次使用:
lineLengths.persist()
传递函数
Spark API严重依赖在驱动程序中传递的函数,有两种推荐的传递方式:
- 匿名函数,适用于简短的代码
- 全局单例对象中的静态方法。例如
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
方法,方法中的map
会引用这个实例,因此该实例需要发送到集群中。写法类似于rdd.map(x => this.func1(x))
。
同样的,访问外部对象的字段也会引用整个对象。
class MyClass {
val field = "Hello"
def doStuff(rdd: RDD[String]): RDD[String] = { rdd.map(x => field + x) }
}
代码等同于rdd.map(x => this.field + x)
,引用了整个对象this
。要避免这个问题,最好将field
声明为本地变量。
def doStuff(rdd: RDD[String]): RDD[String] = {
val field_ = this.field
rdd.map(x => field_ + x)
}
理解闭包
Spark的难点之一就是如何理解集群运行时变量和方法的作用域和生命周期。在变量作用域之外修改变量是一个很常见的错误。在下面的例子中我们使用foreach
进行计数。
示例
看看下面的代码,在不同环境下可能会得出不同的结果,例如本地模式和集群模式。
var counter = 0
var rdd = sc.parallelize(data)
// Wrong: Don't do this!!
rdd.foreach(x => counter += x)
println("Counter value: " + counter)
本地模式 vs 集群模式
上面代码的结果是不可预测的。为了执行作业,Spark将RDD计算过程分解成多个任务,每个任务由一个executor执行。执行之前,Spark需要计算出任务的闭包。闭包就是那些执行RDD计算过程中使用的变量和方法(本例中为foreach()
)对executor来说必须是可见的。闭包会被序列化并发送到各个executor中。
发往executor的闭包中的变量是原值的拷贝,因此,foreach
函数中引用的counter,并不是驱动节点中的那个counter。驱动节点的内存中依然有一个counter变量,但对executor来说是不可见的。executor只能看到闭包中的拷贝。因此,counter的最终值会是0,因为所有对counter的操作实际引用的都是闭包中的变量。
本地模式的某些情况下,foreach
函数会和驱动程序运行在同一个JVM中,这是它们引用的是同一个counter,最终会得到正确的结果。
在上面的情形中我们最好使用累加器。累加器是Spark提供用来安全更新变量的机制。
一般来说,像是循环或者本地定义的函数这样的闭包构造不应当修改全局状态。Spark无法确保这种行为的正确性。
打印RDD元素
另一个愚蠢的行为是尝试使用rdd.foreach(println)
或rdd.map(println)
打印RDD中的元素。单机模式下,这种操作能够看到输出内容,但是在集群模式下,负责输出的stdout
实际上属于executor,而不在驱动节点上,因此驱动程序中的stdout
不会显示任何内容。要在驱动程序中打印数据,一种方法是调用collect()
方法将所有数据汇总到驱动节点,之后打印:rdd.collect().foreach(println)
。如果数据量过大,可能造成驱动节点内存溢出。如果只需要打印部分数据,更安全的方法是使用take()
:rdd.take(100).foreach(println)
。
使用key-value序对
多数RDD操作都支持任意类型的对象,一部分操作是针对key-value序对的。最常见的就是分布式“洗牌”操作,例如按key分组和聚合元素。
在Scala中,如果RDD的类型是Tuple2
(语言中内置的元组类型,可直接使用(a, b)
创建),这些操作可以直接使用。这些操作都包含在PairRDDFunctions
类中。
例如,下面代码使用reduceByKey
统计文本中每行出现的次数:
val lines = sc.textFile("data.txt")
val pairs = lines.map(s => (s, 1))
val counts = pairs.reduceByKey((a, b) => a + b)
也可以调用counts.sortByKey()
对结果进行排序。
Transformations
下面列出的是常用transformation操作。
Actions
下面列出的是常用action操作。
洗牌操作
某些Spark操作会触发一个洗牌事件。洗牌是Spark用来重新分布数据一种机制。它解决的是跨executor和机器拷贝数据的问题,因此比较消耗资源。
背景
以reduceByKey
操作为例。reduceByKey
操作会将具有相同key的值组合成一个元组,最后返回一个新的RDD。这个操作的挑战之一就是与key关联的值可能分别在不同的分区中,甚至是不同机器中。为了计算这些值必须组织到一起。
在Spark中,数据通常不会为了某个操作跨分区分布。整个计算过程中,一个任务只会操作一个分区,因此,为了组织一个reduceByTask
任务所需的数据,Spark需要执行一个多对多操作。Spark需要读取所有的分区找到所有key对应的所有值,然后将这些值按照key跨分区重新分区,这就是洗牌。
尽管洗牌后每个分区中的元素是确定的,分区的顺序也是确定的,但是分区内元素的顺序是不确定的。如果希望对元素进行排序,可以:
- 在
mapPartitions
中对每个分区进行排序 - 使用
repartitionAndSortWithinPartitions
在重分区时排序 - 使用
sortBy
排序
重分区操作(例如repartition
和coalesce
),ByKey操作(例如groupByKey
和reduceByKey
,除了计数)和连接操作(例如cogroup
和join
)都会导致洗牌操作。
性能
洗牌是一个“昂贵”的操作,因为它包括磁盘I/O,数据序列化和网络I/O。为了组织洗牌数据,Spark生成一组任务,map任务负责组织数据,reduce任务负责聚合数据。这两个术语是从MapReduce得来的,指的并不是Spark中的map
和reduce
操作。
在实现上,每个map任务返回的结果都保存在内存中,知道内存被填满。然后,这些数据根据目标分区排序并写入一个单独的文件中。在reduce阶段,任务读取文件中相关的数据块。
某些洗牌操作会占用大量的堆内存,因为它们会在转换前后使用内存数据结构组织记录。其中,reduceByKey
和aggregateByKey
会在map阶段占用内存,其它ByKey操作会在reduce阶段占用内存。内存被填满时Spark会将数据写到磁盘,从而增加磁盘I/O和垃圾收集的次数。
洗牌还会在磁盘上生成大量中间文件。以Spark 1.3为例,这些文件会一直存在,知道对应的RDD不在使用并被垃圾回收。这样做的好处是当血缘关系重新计算时不必重新创建洗牌文件。如果Spark一直引用RDD或GC发生的频率比较低,垃圾收集可能很长时间才会执行一次。这意味着长时运行的Spark作业会占用大量的磁盘空间。临时存储目录可以通过spark.local.dir
选项配置。
RDD持久化
持久化是Spark一项很重要的能力。当用户持久化一个RDD,所有节点都会在内存中存储它计算所需的所有分区,并在之后的操作中复用。
persist()
和cache()
方法都可以持久化RDD。第一个action操作执行后,RDD就被缓存在所有节点的内存中。Spark的缓存是容错的 - 如果RDD的分区丢失了,Spark会自动从原始RDD重新计算并回复数据。
此外,持久化的RDD可以有不同的存储级别。这些级别通过StorageLevel
对象设置。cache()
方法是persist()
方法的一个特例,它使用StorageLevel.MEMORY_ONLY
级别。全部的存储级别如下:
执行洗牌操作时,Spark也会自动持久化一些中间数据。这么做的目的是为了避免节点失败时重新执行洗牌数据。
如何选择存储级别
存储级别实际是内存使用和CPU效率之间的折中。可以依据以下规则选择存储级别:
- 如果RDD满足默认级别(
MEMORY_ONLY
),就使用默认级别。这是最有效的选项。 - 如果不满足默认级别,尝试使用
MEMORY_ONLY_SER
级别并选择一个高效的序列化类库。 - 除非你的计算过程很耗时,否则不要持久化到磁盘。
- 如果需要快速容错恢复功能,可以使用备份存储级别。
移除数据
Spark自动监控各个节点上的缓存使用情况,按照LRU顺序删除旧的分区。也可以使用RDD.unpersist()
方法手动移除数据。
共享变量
通常,函数传递给一个Spark操作(例如map
或reduce
)后执行在不同的集群节点上,函数中使用的变量都是独立的副本。这些变量拷贝到各个节点上,对这些变量的修改并不会传递回驱动程序。一般来说,跨任务读写共享变量效率不高,不过Spark依然提供了两种类型有限的共享变量:广播变量和累加器。
广播变量
广播变量是缓存在所有节点上的只读变量。它可以更有效的为每个节点提供大数据集。Spark会使用一些高效的广播算法分布广播变量。
Spark action是分解成一组stage逐步执行,根据“洗牌”操作拆分。Spark会自动广播各个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)
累加器
累加器是只能“加”操作的变量。累加器可以用来计数和求和。Spark原生支持数值类型的累加器,用户可以自定义新类型。
用户可以创建命名或未命名的累加器,命名的累加器会展示在web界面上。
SparkContext.longAccumulator()
或SparkContext.doubleAccumulator()
方法都可以创建数值类型累加器。任务可以调用add
方法执行加操作,但是只有驱动程序可以读取累加器的值。例如:
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
def reset(): Unit = {
myVector.reset()
}
def add(v: MyVector): Unit = {
myVector.add(v)
}
...
}
// Then, create an Accumulator of this type:
val myVectorAcc = new VectorAccumulatorV2
// Then, register it into spark context:
sc.register(myVectorAcc, "MyVectorAcc1")
累加器的更新操作只在遇到action后才执行,Spark确保所有操作都只执行一次。累加器并没有改变Spark的延迟计算模型。它的操作只在遇到一个action时才会执行。下面代码说明了了这个特点:
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.