本教程源于2016年3月出版书籍《Spark原理、机制及应用》 ,在此以知识共享为初衷公开部分内容,如有兴趣,请支持正版书籍。
Spark综合了前人分布式数据处理架构和语言的优缺点,使用简洁、一致的函数式语言Scala作为主要开发语言,同时为了方便更多语言背景的人使用,还支持Java、Python和R语言。Spark因为其弹性分布式数据集(RDD)的抽象数据结构设计,通过实现抽象类RDD可以产生面对不同应用场景的子类。本章将先介绍Spark编程模型、RDD的相关概念、常用API源码及应用案例,然后具体介绍四大应用框架,为后续进一步学习Spark框架打下基础。
3.1 Spark 编程模型概述
Spark的编程模型如图3-1所示。
图3-1 Spark编程模型
开发人员在编写Spark应用的时候,需要提供一个包含main函数的驱动程序作为程序的入口,开发人员根据自己的需求,在main函数中调用Spark提供的数据操纵接口,利用集群对数据执行并行操作。
Spark为开发人员提供了两类抽象接口。第一类抽象接口是弹性分布式数据集(Resilient Distributed Dataset,下文简称RDD),顾名思义,RDD是对数据集的抽象封装,开发人员可以通过RDD提供的开发接口来访问和操纵数据集合,而无需了解数据的存储介质(内存或磁盘)、文件系统(本地文件系统、HDFS或Tachyon)、存储节点(本地或远程节点)等诸多实现细节;第二类抽象是共享变量(Shared Variables),通常情况下,一个应用程序在运行的时候会被划分成分布在不同执行器之上的多个任务,从而提高运算的速度,每个任务都会有一份独立的程序变量拷贝,彼此之间互不干扰,然而在某些情况下需要任务之间相互共享变量,Apache Spark提供了两类共享变量,它们分别是:广播变量(Broadcast Variable)和累加器(Accumulators)。第3.3节将介绍RDD的基本概念和RDD提供的编程接口,并在后面详细解读接口的源码实现,从而加深对RDD的理解,此外会在第3.4节中介绍两类共享变量的使用方法。
3.2 Spark Context
SparkContext是整个项目程序的入口,无论从本地读取文件(textfile方法)还是从HDFS读取文件或者通过集合并行化获得RDD,都先要创建SparkContext对象,然后使用SparkContext对RDD进行创建和后续的转换操作。本节主要介绍SparkContext类的作用和创建过程,然后通过一个简单的例子向读者介绍SparkContext的应用方法,从应用角度来理解其作用。
3.2.1 SparkContext的作用
SparkContext除了是Spark的主要入口,它也可以看作是对用户的接口,它代表与Spark集群的连接对象,由图3-2可以看到,SparkContext主要存在于Driver Program中。可以使用SparkContext来创建集群中的RDD、累积量和广播量,在后台SparkContext还能发送任务给集群管理器。每一个JVM只能有运行一个程序,即对应只有一个SparkContext处于激活状态,因此在创建新的SparkContext前需要把旧的SparkContext停止。
图3-2 SparkContext在Spark架构图中的位置
3.2.2 SparkContext创建
SparkContext的创建过程首先要加载配置文件,然后创建SparkEnv、TaskScheduler和DAGScheduler,具体过程和源码分析如下。
1.加载配置文件SparkConf
SparkConf在初始化时,需先选择相关的配置參数,包含master、appName、sparkHome、jars、environment等信息,然后通过构造方法传递给SparkContext,这里的构造函数有多种表达形式,当SparkContex获取了全部相关的本地配置信息后开始下一步操作。
def this(master: String, appName: String, conf: SparkConf) =
this(SparkContext.updatedConf(conf, master, appName))
def this(
master: String,
appName: String,
sparkHome: String = null,
jars: Seq[String] = Nil,
environment: Map[String, String] = Map(),
preferredNodeLocationData: Map[String, Set[SplitInfo]] = Map()) =
{
this(SparkContext.updatedConf(newSparkConf(),master,appName,sparkHome,jars,environment))
this.preferredNodeLocationData = preferredNodeLocationData
}
2.创建SparkEnv
创建SparkConf后就需要创建SparkEnv,这里面包括了很多Spark执行时的重要组件,包括 MapOutputTracker、ShuffleFetcher、BlockManager等,在这里源码是通过SparkEnv类的伴生对象SparkEnv Object内的createDriverEnv方法实现的。
private[spark] defcreateDriverEnv(
conf: SparkConf,
isLocal: Boolean,
listenerBus: LiveListenerBus,
mockOutputCommitCoordinator:Option[OutputCommitCoordinator] = None): SparkEnv = {
assert(conf.contains("spark.driver.host"),"spark.driver.host is not set on the driver!")
assert(conf.contains("spark.driver.port"),"spark.driver.port is not set on the driver!")
val hostname =conf.get("spark.driver.host")
val port =conf.get("spark.driver.port").toInt
create(
conf,
SparkContext.DRIVER_IDENTIFIER,
hostname,
port,
isDriver = true,
isLocal = isLocal,
listenerBus = listenerBus,
mockOutputCommitCoordinator =mockOutputCommitCoordinator
)
}
<p style="background:#F3F3F3;">
</p>
3.创建TaskScheduler
创建SparkEnv后,就需要创建SparkContext中调度执行方面的变量TaskScheduler。
private[spark] var (schedulerBackend, taskScheduler) =
SparkContext.createTaskScheduler(this, master)
private val heartbeatReceiver = env.actorSystem.actorOf(
Props(new HeartbeatReceiver(taskScheduler)), "HeartbeatReceiver")
@volatile private[spark] var dagScheduler: DAGScheduler = _
try {
dagScheduler = new DAGScheduler(this)
} catch {
case e: Exception => {
try {
stop()
} finally {
throw new SparkException("Error while constructing DAGScheduler", e)
}
}
}
// start TaskScheduler after taskScheduler sets DAGScheduler reference in DAGScheduler's
// constructor
taskScheduler.start()
TaskScheduler是依据Spark的执行模式进行初始化的,详细代码在SparkContext中的createTaskScheduler方法中。在这里以Standalone模式为例,它会将sc传递给TaskSchedulerImpl,然后创建SparkDeploySchedulerBackend并初始化,最后返回Scheduler对象。
case SPARK_REGEX(sparkUrl) =>
val scheduler = new TaskSchedulerImpl(sc)
val masterUrls = sparkUrl.split(",").map("spark://" + _)
val backend = new SparkDeploySchedulerBackend(scheduler, sc, masterUrls)
scheduler.initialize(backend)
(backend, scheduler)
4.创建DAGScheduler
创建TaskScheduler对象后,再将TaskScheduler对象传至DAGScheduler,用来创建DAGScheduler对象。
@volatile private[spark] var dagScheduler: DAGScheduler = _
try {
dagScheduler = new DAGScheduler(this)
} catch {
case e: Exception => {
try {
stop()
} finally {
throw new SparkException("Error while constructing DAGScheduler", e)
}
}
}
def this(sc: SparkContext) = this(sc, sc.taskScheduler)
创建DAGScheduler后再调用其start()方法将其启动。以上4点是整个SparkContext的创建过程,这其中包含了很多重要的步骤,从这个过程能理解Spark的初始启动情况。
3.2.3 使用shell
除了单独编写一个应用程序的方式之外,Spark还提供了一个交互式Shell来使用。在Shell中,用户的每条语句都能在输入完毕后及时得到结果,而无需手动编译和运行程序。Shell的使用十分简单,改变当前工作路径到Spark的安装目录,执行指令$ ./bin/spark-shell即可进入Shell。
在Shell中,系统根据命令提供的参数自动配置和生成了一个SparkContext对象sc,直接使用即可,无需再手动实例化SparkContext。除了结果会实时显示之外,其余操作与编写单独应用程序类似。读者可直接参考Spark官方提供的Spark ProgrammingGuide等文档,在此不做具体介绍。
3.2.4 应用实践
这里向读者介绍一段用于统计文件中字母a和字母b出现频率的Spark应用,通过这个程序向读者展示SparkContext的用法。
【例3-1】简单的Spark程序
/* SimpleApp.scala */
import org.apache.spark.SparkContext
import org.apache.spark.SparkContext._
import org.apache.spark.SparkConf
object SimpleApp {
def main(args: Array[String]) {
val logFile = "YOUR_SPARK_HOME/README.md" // 本地文件目录
val conf = new SparkConf().setAppName("Simple Application") //给Application命名
val sc = new SparkContext(conf) //创建SparkContext
val logData = sc.textFile(logFile, 2).cache() //缓存文件
val numAs = logData.filter(line => line.contains("a")).count() //计算字母a的个数
val numBs = logData.filter(line => line.contains("b")).count() //计算字母b的个数
println("Lines with a: %s, Lines with b: %s".format(numAs, numBs)) //打印结果
}
这个例子中,首先创建本地文件目录logFile和配置文件conf,然后使用配置信息conf实例化SparkContext得到sc,得到sc之后就可以从本地文件中读取数据并把数据转化成RDD,并命名为logData,然后logData调用filter方法分别计算包含字母a的行数和包含字母b的行数,最后打印出结果。该例子中使用了SparkContext的实例化对象创建RDD数据集。
3.3 RDD简介
本节主要介绍弹性分布式数据集RDD的相关概念,其中包括RDD创建来源、两种重要的Transformation和Action操作、数据持久化和检查点机制,通过对Spark中RDD核心抽象的深入理解,能帮助读者全面理解后面的RDD的分区、并行计算和依赖等机制和变换过程。
3.3.1 RDD创建
RDD是Spark应用程序开发过程中最为基本也最为重要的一类数据结构,RDD被定义为只读、分区化的记录集合,更为通俗来讲,RDD是对原始数据的进一步封装,封装导致两个结果:第一个结果是数据访问权限被限制,数据只能被读,而无法被修改;第二个结果是数据操作功能被强化,使得数据能够实现分布式存储、并发处理、自动容错等等诸多功能。Spark的整个计算过程都是围绕数据集RDD来进行,下面将会对RDD的创建以及数据结构进行简单介绍。
1.RDD的两类来源
1)将未被封装的原始数据进行封装操作得到,根据原始数据的存在形式,又可被进一步分成由集合并行化获得或从外部数据集中获得。
2)由其他RDD通过转换操作获得,由于RDD的只读特性,内部的数据无法被修改,因此RDD内部提供了一系列数据转换(Transformation)操作接口,这类接口可返回新的RDD,而不影响原来的RDD内容。在后面第3章3.3节中将会对RDD的创建方法进行更加详尽的说明。
2.RDD内部数据结构
1)分区信息的列表
2)对父RDD的依赖信息
3)对Key-Value键值对数据类型的分区器(可选)
4)计算分区的函数
5)每个数据分区的物理地址列表(可选)
RDD的数据操作并非在调用内部接口的一刻便开始计算,而是遇到要求将数据返回给驱动程序,或者写入到文件的接口时,才会进行真正的计算,这类会触发计算的操作称为动作(Action)操作,而这种延时计算的特性,被称为RDD计算的惰性(Lazy),在第六章机篇将分别讲述动作操作和惰性特征。
在第1章中说过,Spark是一套内存计算框架,其能够将频繁使用的中间数据存储在内存当中,数据被使用的频率越高,性能提升越明显。数据的内存化操作在RDD层次上,体现为RDD的持久化操作,在3.3.4节描述RDD的持久化操作。除此之外,RDD还提供了类似于持久化操作的检查点机制,表面看上去与存储在HDFS的持久化操作类似,实际使用上又有诸多不同,在3.3.5小节描述RDD的检查点机制。
3.3.2 RDD转换操作
转换(Transformation)操作是由一个RDD转换到另一个新的RDD,例如,map操作在RDD中是一个转换操作,map转换会让RDD中的每一个数据都通过一个指定函数得到一个新的RDD。
RDD内部可以封装任意类型的数据,但某些操作只能应用在封装键值对类型数据的RDD之上,例如转换操作reduceByKey、groupByKey和countByKey等。
表3-1展示了RDD所提供的所有转换操作及其含义。
表3-1:RDD提供的转换操作
Transformation |
算子作用 |
map(func) |
新RDD中的数据由原RDD中的每个数据通过函数func得到 |
filter(func) |
新RDD种的数据由原RDD中每个能使函数func返回true值的数据组成 |
flatMap(func) |
类似于map转换,但func的返回值是一个Seq对象,Seq中的元素个数可以是0或者多个 |
mapPartitions(func) |
类似于map转换,但func的输入不是一个数据项,则是一个分区,若RDD内数据类型为T,则func必须是Iterator<T> => Iterator<U>类型 |
mapPartitionsWithIndex(func) |
类似于mapPartitions转换,但func的数据还多了一个分区索引,即func类型是(Int, Iterator<T> => Iterator<U>) |
sample(withReplacement, fraction, seed) |
对fraction中的数据进行采样,可以选择是否要进行替换,需要提供一个随机数种子 |
union(otherDataset) |
新RDD中数据是原RDD与RDD otherDataset中数据的并集 |
Intersection(otherDataset) |
新RDD中数据是原RDD与RDD otherDataset中数据的交集 |
distinct([numTasks]) |
新RDD中数据是原RDD中数据去重的结果 |
groupByKey([numTasks]) |
原RDD中数据类型为(K, V)对,新RDD中数据类型为(K, Iterator(V))对,即将相同K的所有V放到一个迭代器中 |
reduceByKey(func, [numTasks]) |
原RDD和新RDD数据的类型都为(K, V)对,让原RDD相同K的所有V依次经过函数func,得到的最终值作为K的V |
aggregateByKey(zeroValue)(seqOp, combOp, [numTasks]) |
原RDD数据的类型为(K, V),新RDD数据的类型为(K, U),类似于groupbyKey函数,但聚合函数由用户指定。键值对的值的类型可以与原RDD不同 |
sortByKey([ascending], [numTasks]) |
原RDD和新RDD数据的类型为(K, V)键值对,新RDD的数据根据ascending的指定顺序或者逆序排序 |
join(otherDataset, [numTasks]) |
原RDD数据的类型为(K, V),otherDataset数据的类型为(K, W),对于相同的K,返回所有的(K, (V, W)) |
cogroup(otherDataset, [numTasks]) |
原RDD数据的类型为(K, V),otherDataset数据的类型为(K, W),对于相同的K,返回所有的(K, Iterator<V>, Iterator<W>) |
catesian(otherDataset) |
原RDD数据的类型为为T,otherDataset数据的类型为U,返回所有的(T, U) |
pipe(command, [envValue]) |
令原RDD中的每个数据以管道的方式依次通过命令command,返回得到的标准输出 |
coalesce(numPartitions) |
减少原RDD中分区的数目至指定值numPartitions |
repartition(numPartitions) |
修改原RDD中分区的数目至指定值numPartitions |
3.3.3 RDD动作操作
相对于转换,动作(Action)操作用于向驱动(Driver)程序返回值或者将值写入到文件当中。例如reduce动作会使用同一个指定函数让RDD中的所有数据做一次聚合,把运算的结果返回。表3-2展示了RDD所提供的所有动作操作及其含义。
表3-2:RDD提供的动作操作
Action |
算子作用 |
reduce(func) |
令原RDD中的每个值依次经过函数func,func的类型为(T, T) => T,返回最终结果 |
collect() |
将原RDD中的数据打包成数组并返回 |
count() |
返回原RDD中数据的个数 |
first() |
返回原RDD中的第一个数据项 |
take(n) |
返回原RDD中前n个数据项,返回结果为数组 |
takeSample(withReplacement, num, [seed]) |
对原RDD中的数据进行采样,返回num个数据项 |
saveAsTextFile(path) |
将原RDD中的数据写入到文本文件当中 |
saveAsSequenceFile(path)(Java and Scala) |