spark 阅读 笔记
来源:互联网 发布:bilibili for mac发热 编辑:程序博客网 时间:2024/05/17 12:52
基本概念(Basic Concepts)
RDD - resillient distributed dataset 弹性分布式数据集
Operation - 作用于RDD的各种操作分为transformation和action
Job - 作业,一个JOB包含多个RDD及作用于相应RDD上的各种operation
Stage - 一个作业分为多个阶段
Partition - 数据分区, 一个RDD中的数据可以分成多个不同的区
DAG - Directed Acycle graph, 有向无环图,反应RDD之间的依赖关系
Narrow dependency - 窄依赖,子RDD依赖于父RDD中固定的data partition
Wide Dependency - 宽依赖,子RDD对父RDD中的所有data partition都有依赖
Caching Managenment -- 缓存管理,对RDD的中间计算结果进行缓存管理以加快整体的处理速度
编程模型(Programming Model)
RDD是只读的数据分区集合,注意是数据集。
作用于RDD上的Operation分为transformantion和action。 经Transformation处理之后,数据集中的内容会发生更改,由数据集A转换成为数据集B;而经Action处理之后,数据集中的内容会被归约为一个具体的数值。
只有当RDD上有action时,该RDD及其父RDD上的所有operation才会被提交到cluster中真正的被执行。
从代码到动态运行,涉及到的组件如下图所示。
演示代码
val sc = new SparkContext("Spark://...", "MyJob", home, jars)val file = sc.textFile("hdfs://...")val errors = file.filter(_.contains("ERROR"))errors.cache()errors.count()
运行态(Runtime view)
不管什么样的静态模型,其在动态运行的时候无外乎由进程,线程组成。
用Spark的术语来说,static view称为dataset view,而dynamic view称为parition view. 关系如图所示
在Spark中的task可以对应于线程,worker是一个个的进程,worker由driver来进行管理。
那么问题来了,这一个个的task是如何从RDD演变过来的呢?下节将详细回答这个问题。
部署(Deployment view)
当有Action作用于某RDD时,该action会作为一个job被提交。
在提交的过程中,DAGScheduler模块介入运算,计算RDD之间的依赖关系。RDD之间的依赖关系就形成了DAG。
每一个JOB被分为多个stage,划分stage的一个主要依据是当前计算因子的输入是否是确定的,如果是则将其分在同一个stage,避免多个stage之间的消息传递开销。
当stage被提交之后,由taskscheduler来根据stage来计算所需要的task,并将task提交到对应的worker.
Spark支持以下几种部署模式1)standalone 2)Mesos 3) yarn. 这些部署模式将作为taskscheduler的初始化入参。
RDD接口(RDD Interface)
RDD由以下几个主要部分组成
- partitions -- partition集合,一个RDD中有多少data partition
- dependencies -- RDD依赖关系
- compute(parition) -- 对于给定的数据集,需要作哪些计算
- preferredLocations -- 对于data partition的位置偏好
- partitioner -- 对于计算出来的数据结果如何分发
缓存机制(caching)
RDD的中间计算结果可以被缓存起来,缓存先选Memory,如果Memory不够的话,将会被写入到磁盘中。
根据LRU(last-recent update)来决定哪先内容继续保存在内存,哪些保存到磁盘。
容错性(Fault-tolerant)
从最初始的RDD到衍生出来的最后一个RDD,中间要经过一系列的处理。那么如何处理中间环节出现错误的场景呢?
Spark提供的解决方案是只对失效的data partition进行事件重演,而无须对整个数据全集进行事件重演,这样可以大大加快场景恢复的开销。
RDD又是如何知道自己的data partition的number该是多少?如果是hdfs文件,那么hdfs文件的block将会成为一个重要的计算依据。
集群管理(cluster management)
task运行在cluster之上,除了spark自身提供的standalone部署模式之外,spark还内在支持yarn和mesos.
Yarn来负责计算资源的调度和监控,根据监控结果来重启失效的task或者是重新distributed task一旦有新的node加入cluster的话。
这一部分的内容需要参考yarn的文档。
小结
在源码阅读时,需要重点把握以下两大主线。
- 静态view 即 RDD, transformation and action
- 动态view 即 life of a job, 每一个job又分为多个stage,每一个stage中可以包含多个rdd及其transformation,这些stage又是如何映射成为task被distributed到cluster中
实验环境搭建
在进行后续操作前,确保下列条件已满足。
- 下载spark binary 0.9.1
- 安装scala
- 安装sbt
- 安装java
启动spark-shell
单机模式运行,即local模式
local模式运行非常简单,只要运行以下命令即可,假设当前目录是$SPARK_HOME
MASTER=local bin/spark-shell
"MASTER=local"就是表明当前运行在单机模式
local cluster方式运行
local cluster模式是一种伪cluster模式,在单机环境下模拟standalone的集群,启动顺序分别如下
- 启动master
- 启动worker
- 启动spark-shell
master
$SPARK_HOME/sbin/start-master.sh
注意运行时的输出,日志默认保存在$SPARK_HOME/logs目录。
master主要是运行类 org.apache.spark.deploy.master.Master,在8080端口启动监听,日志如下图所示
修改配置
- 进入$SPARK_HOME/conf目录
- 将spark-env.sh.template重命名为spark-env.sh
- 修改spark-env.sh,添加如下内容
export SPARK_MASTER_IP=localhostexport SPARK_LOCAL_IP=localhost
运行worker
bin/spark-class org.apache.spark.deploy.worker.Worker spark://localhost:7077 -i 127.0.0.1 -c 1 -m 512M
worker启动完成,连接到master。打开maser的web ui可以看到连接上来的worker. Master WEb UI的监听地址是http://localhost:8080
启动spark-shell
MASTER=spark://localhost:7077 bin/spark-shell
如果一切顺利,将看到下面的提示信息。
Created spark context..Spark context available as sc.
可以用浏览器打开localhost:4040来查看如下内容
- stages
- storage
- environment
- executors
wordcount
上述环境准备妥当之后,我们在sparkshell中运行一下最简单的例子,在spark-shell中输入如下代码
scala>sc.textFile("README.md").filter(_.contains("Spark")).count
上述代码统计在README.md中含有Spark的行数有多少
部署过程详解
Spark布置环境中组件构成如下图所示。
- Driver Program 简要来说在spark-shell中输入的wordcount语句对应于上图的Driver Program.
- Cluster Manager 就是对应于上面提到的master,主要起到deploy management的作用
- Worker Node 与Master相比,这是slave node。上面运行各个executor,executor可以对应于线程。executor处理两种基本的业务逻辑,一种就是driver programme,另一种就是job在提交之后拆分成各个stage,每个stage可以运行一到多个task
Notes: 在集群(cluster)方式下, Cluster Manager运行在一个jvm进程之中,而worker运行在另一个jvm进程中。在local cluster中,这些jvm进程都在同一台机器中,如果是真正的standalone或Mesos及Yarn集群,worker与master或分布于不同的主机之上。
JOB的生成和运行
job生成的简单流程如下
- 首先应用程序创建SparkContext的实例,如实例为sc
- 利用SparkContext的实例来创建生成RDD
- 经过一连串的transformation操作,原始的RDD转换成为其它类型的RDD
- 当action作用于转换之后RDD时,会调用SparkContext的runJob方法
- sc.runJob的调用是后面一连串反应的起点,关键性的跃变就发生在此处
调用路径大致如下
- sc.runJob->dagScheduler.runJob->submitJob
- DAGScheduler::submitJob会创建JobSummitted的event发送给内嵌类eventProcessActor
- eventProcessActor在接收到JobSubmmitted之后调用processEvent处理函数
- job到stage的转换,生成finalStage并提交运行,关键是调用submitStage
- 在submitStage中会计算stage之间的依赖关系,依赖关系分为宽依赖和窄依赖两种
- 如果计算中发现当前的stage没有任何依赖或者所有的依赖都已经准备完毕,则提交task
- 提交task是调用函数submitMissingTasks来完成
- task真正运行在哪个worker上面是由TaskScheduler来管理,也就是上面的submitMissingTasks会调用TaskScheduler::submitTasks
- TaskSchedulerImpl中会根据Spark的当前运行模式来创建相应的backend,如果是在单机运行则创建LocalBackend
- LocalBackend收到TaskSchedulerImpl传递进来的ReceiveOffers事件
- receiveOffers->executor.launchTask->TaskRunner.run
代码片段executor.lauchTask
def launchTask(context: ExecutorBackend, taskId: Long, serializedTask: ByteBuffer) { val tr = new TaskRunner(context, taskId, serializedTask) runningTasks.put(taskId, tr) threadPool.execute(tr) }
说了这么一大通,也就是讲最终的逻辑处理切切实实是发生在TaskRunner这么一个executor之内。
运算结果是包装成为MapStatus然后通过一系列的内部消息传递,反馈到DAGScheduler,这一个消息传递路径不是过于复杂,有兴趣可以自行勾勒。
准备
- spark已经安装完毕
- spark运行在local mode或local-cluster mode
local-cluster mode
local-cluster模式也称为伪分布式,可以使用如下指令运行
MASTER=local[1,2,1024] bin/spark-shell
[1,2,1024] 分别表示,executor number, core number和内存大小,其中内存大小不应小于默认的512M
Driver Programme的初始化过程分析
初始化过程的涉及的主要源文件
- SparkContext.scala 整个初始化过程的入口
- SparkEnv.scala 创建BlockManager, MapOutputTrackerMaster, ConnectionManager, CacheManager
- DAGScheduler.scala 任务提交的入口,即将Job划分成各个stage的关键
- TaskSchedulerImpl.scala 决定每个stage可以运行几个task,每个task分别在哪个executor上运行
- SchedulerBackend
- 最简单的单机运行模式的话,看LocalBackend.scala
- 如果是集群模式,看源文件SparkDeploySchedulerBackend
初始化过程步骤详解
步骤1: 根据初始化入参生成SparkConf,再根据SparkConf来创建SparkEnv, SparkEnv中主要包含以下关键性组件 1. BlockManager 2. MapOutputTracker 3. ShuffleFetcher 4. ConnectionManager
private[spark] val env = SparkEnv.create( conf, "", conf.get("spark.driver.host"), conf.get("spark.driver.port").toInt, isDriver = true, isLocal = isLocal) SparkEnv.set(env)
步骤2:创建TaskScheduler,根据Spark的运行模式来选择相应的SchedulerBackend,同时启动taskscheduler,这一步至为关键
private[spark] var taskScheduler = SparkContext.createTaskScheduler(this, master, appName) taskScheduler.start()
TaskScheduler.start目的是启动相应的SchedulerBackend,并启动定时器进行检测
override def start() { backend.start() if (!isLocal && conf.getBoolean("spark.speculation", false)) { logInfo("Starting speculative execution thread") import sc.env.actorSystem.dispatcher sc.env.actorSystem.scheduler.schedule(SPECULATION_INTERVAL milliseconds, SPECULATION_INTERVAL milliseconds) { checkSpeculatableTasks() } } }
步骤3:以上一步中创建的TaskScheduler实例为入参创建DAGScheduler并启动运行
@volatile private[spark] var dagScheduler = new DAGScheduler(taskScheduler) dagScheduler.start()
步骤4:启动WEB UI
ui.start()
RDD的转换过程
还是以最简单的wordcount为例说明rdd的转换过程
sc.textFile("README.md").flatMap(line=>line.split(" ")).map(word => (word, 1)).reduceByKey(_ + _)
上述一行简短的代码其实发生了很复杂的RDD转换,下面仔细解释每一步的转换过程和转换结果
步骤1:val rawFile = sc.textFile("README.md")
textFile先是生成hadoopRDD,然后再通过map操作生成MappedRDD,如果在spark-shell中执行上述语句,得到的结果可以证明所做的分析
scala> sc.textFile("README.md")14/04/23 13:11:48 WARN SizeEstimator: Failed to check whether UseCompressedOops is set; assuming yes14/04/23 13:11:48 INFO MemoryStore: ensureFreeSpace(119741) called with curMem=0, maxMem=31138775014/04/23 13:11:48 INFO MemoryStore: Block broadcast_0 stored as values to memory (estimated size 116.9 KB, free 296.8 MB)14/04/23 13:11:48 DEBUG BlockManager: Put block broadcast_0 locally took 277 ms14/04/23 13:11:48 DEBUG BlockManager: Put for block broadcast_0 without replication took 281 msres0: org.apache.spark.rdd.RDD[String] = MappedRDD[1] at textFile at :13
步骤2: val splittedText = rawFile.flatMap(line => line.split(" "))
flatMap将原来的MappedRDD转换成为FlatMappedRDD
def flatMap[U: ClassTag](f: T => TraversableOnce[U]): RDD[U] = new FlatMappedRDD(this, sc.clean(f))
步骤3:val wordCount = splittedText.map(word => (word, 1))
利用word生成相应的键值对,上一步的FlatMappedRDD被转换成为MappedRDD
步骤4:val reduceJob = wordCount.reduceByKey(_ + _),这一步最复杂
步骤2,3中使用到的operation全部定义在RDD.scala中,而这里使用到的reduceByKey却在RDD.scala中见不到踪迹。reduceByKey的定义出现在源文件PairRDDFunctions.scala
细心的你一定会问reduceByKey不是MappedRDD的属性和方法啊,怎么能被MappedRDD调用呢?其实这背后发生了一个隐式的转换,该转换将MappedRDD转换成为PairRDDFunctions
implicit def rddToPairRDDFunctions[K: ClassTag, V: ClassTag](rdd: RDD[(K, V)]) = new PairRDDFunctions(rdd)
这种隐式的转换是scala的一个语法特征,如果想知道的更多,请用关键字"scala implicit method"进行查询,会有不少的文章对此进行详尽的介绍。
接下来再看一看reduceByKey的定义
def reduceByKey(func: (V, V) => V): RDD[(K, V)] = { reduceByKey(defaultPartitioner(self), func) } def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)] = { combineByKey[V]((v: V) => v, func, func, partitioner) } def combineByKey[C](createCombiner: V => C, mergeValue: (C, V) => C, mergeCombiners: (C, C) => C, partitioner: Partitioner, mapSideCombine: Boolean = true, serializerClass: String = null): RDD[(K, C)] = { if (getKeyClass().isArray) { if (mapSideCombine) { throw new SparkException("Cannot use map-side combining with array keys.") } if (partitioner.isInstanceOf[HashPartitioner]) { throw new SparkException("Default partitioner cannot partition array keys.") } } val aggregator = new Aggregator[K, V, C](createCombiner, mergeValue, mergeCombiners) if (self.partitioner == Some(partitioner)) { self.mapPartitionsWithContext((context, iter) => { new InterruptibleIterator(context, aggregator.combineValuesByKey(iter, context)) }, preservesPartitioning = true) } else if (mapSideCombine) { val combined = self.mapPartitionsWithContext((context, iter) => { aggregator.combineValuesByKey(iter, context) }, preservesPartitioning = true) val partitioned = new ShuffledRDD[K, C, (K, C)](combined, partitioner) .setSerializer(serializerClass) partitioned.mapPartitionsWithContext((context, iter) => { new InterruptibleIterator(context, aggregator.combineCombinersByKey(iter, context)) }, preservesPartitioning = true) } else { // Don't apply map-side combiner. val values = new ShuffledRDD[K, V, (K, V)](self, partitioner).setSerializer(serializerClass) values.mapPartitionsWithContext((context, iter) => { new InterruptibleIterator(context, aggregator.combineValuesByKey(iter, context)) }, preservesPartitioning = true) } }
reduceByKey最终会调用combineByKey, 在这个函数中PairedRDDFunctions会被转换成为ShuffleRDD,当调用mapPartitionsWithContext之后,shuffleRDD被转换成为MapPartitionsRDD
Log输出能证明我们的分析
res1: org.apache.spark.rdd.RDD[(String, Int)] = MapPartitionsRDD[8] at reduceByKey at :13
RDD转换小结
小结一下整个RDD转换过程
HadoopRDD->MappedRDD->FlatMappedRDD->MappedRDD->PairRDDFunctions->ShuffleRDD->MapPartitionsRDD
整个转换过程好长啊,这一切的转换都发生在任务提交之前。
运行过程分析
数据集操作分类
在对任务运行过程中的函数调用关系进行分析之前,我们也来探讨一个偏理论的东西,作用于RDD之上的Transformantion为什么会是这个样子?
对这个问题的解答和数学搭上关系了,从理论抽象的角度来说,任务处理都可归结为“input->processing->output"。input和output对应于数据集dataset.
在此基础上作一下简单的分类
- one-one 一个dataset在转换之后还是一个dataset,而且dataset的size不变,如map
- one-one 一个dataset在转换之后还是一个dataset,但size发生更改,这种更改有两种可能:扩大或缩小,如flatMap是size增大的操作,而subtract是size变小的操作
- many-one 多个dataset合并为一个dataset,如combine, join
- one-many 一个dataset分裂为多个dataset, 如groupBy
Task运行期的函数调用
task的提交过程参考本系列中的第二篇文章。本节主要讲解当task在运行期间是如何一步步调用到作用于RDD上的各个operation
- TaskRunner.run
- Task.run
- Task.runTask (Task是一个基类,有两个子类,分别为ShuffleMapTask和ResultTask)
- RDD.iterator
- RDD.computeOrReadCheckpoint
- RDD.compute
- RDD.computeOrReadCheckpoint
- RDD.iterator
- Task.runTask (Task是一个基类,有两个子类,分别为ShuffleMapTask和ResultTask)
- Task.run
或许当看到RDD.compute函数定义时,还是觉着f没有被调用,以MappedRDD的compute定义为例
override def compute(split: Partition, context: TaskContext) = firstParent[T].iterator(split, context).map(f)
注意,这里最容易产生错觉的地方就是map函数,这里的map不是RDD中的map,而是scala中定义的iterator的成员函数map, 请自行参考http://www.scala-lang.org/api/2.10.4/index.html#scala.collection.Iterator
堆栈输出
80 at org.apache.spark.rdd.HadoopRDD.getJobConf(HadoopRDD.scala:111) 81 at org.apache.spark.rdd.HadoopRDD$$anon$1.(HadoopRDD.scala:154) 82 at org.apache.spark.rdd.HadoopRDD.compute(HadoopRDD.scala:149) 83 at org.apache.spark.rdd.HadoopRDD.compute(HadoopRDD.scala:64) 84 at org.apache.spark.rdd.RDD.computeOrReadCheckpoint(RDD.scala:241) 85 at org.apache.spark.rdd.RDD.iterator(RDD.scala:232) 86 at org.apache.spark.rdd.MappedRDD.compute(MappedRDD.scala:31) 87 at org.apache.spark.rdd.RDD.computeOrReadCheckpoint(RDD.scala:241) 88 at org.apache.spark.rdd.RDD.iterator(RDD.scala:232) 89 at org.apache.spark.rdd.FlatMappedRDD.compute(FlatMappedRDD.scala:33) 90 at org.apache.spark.rdd.RDD.computeOrReadCheckpoint(RDD.scala:241) 91 at org.apache.spark.rdd.RDD.iterator(RDD.scala:232) 92 at org.apache.spark.rdd.MappedRDD.compute(MappedRDD.scala:31) 93 at org.apache.spark.rdd.RDD.computeOrReadCheckpoint(RDD.scala:241) 94 at org.apache.spark.rdd.RDD.iterator(RDD.scala:232) 95 at org.apache.spark.rdd.MapPartitionsRDD.compute(MapPartitionsRDD.scala:34) 96 at org.apache.spark.rdd.RDD.computeOrReadCheckpoint(RDD.scala:241) 97 at org.apache.spark.rdd.RDD.iterator(RDD.scala:232) 98 at org.apache.spark.scheduler.ShuffleMapTask.runTask(ShuffleMapTask.scala:161) 99 at org.apache.spark.scheduler.ShuffleMapTask.runTask(ShuffleMapTask.scala:102)100 at org.apache.spark.scheduler.Task.run(Task.scala:53)101 at org.apache.spark.executor.Executor$TaskRunner$$anonfun$run$1.apply$mcV$sp(Executor.scala:211)
ResultTask
compute的计算过程对于ShuffleMapTask比较复杂,绕的圈圈比较多,对于ResultTask就直接许多。
override def runTask(context: TaskContext): U = { metrics = Some(context.taskMetrics) try { func(context, rdd.iterator(split, context)) } finally { context.executeOnCompleteCallbacks() } }
计算结果的传递
上面的分析知道,wordcount这个job在最终提交之后,被DAGScheduler分为两个stage,第一个Stage是shuffleMapTask,第二个Stage是ResultTask.
那么ShuffleMapTask的计算结果是如何被ResultTask取得的呢?这个过程简述如下
- ShffuleMapTask将计算的状态(注意不是具体的数据)包装为MapStatus返回给DAGScheduler
- DAGScheduler将MapStatus保存到MapOutputTrackerMaster中
- ResultTask在执行到ShuffleRDD时会调用BlockStoreShuffleFetcher的fetch方法去获取数据
- 第一件事就是咨询MapOutputTrackerMaster所要取的数据的location
- 根据返回的结果调用BlockManager.getMultiple获取真正的数据
BlockStoreShuffleFetcher的fetch函数伪码
val blockManager = SparkEnv.get.blockManager val startTime = System.currentTimeMillis val statuses = SparkEnv.get.mapOutputTracker.getServerStatuses(shuffleId, reduceId) logDebug("Fetching map output location for shuffle %d, reduce %d took %d ms".format( shuffleId, reduceId, System.currentTimeMillis - startTime)) val blockFetcherItr = blockManager.getMultiple(blocksByAddress, serializer) val itr = blockFetcherItr.flatMap(unpackBlock)
注意上述代码中的getServerStatuses及getMultiple,一个是询问数据的位置,一个是去获取真正的数据。
流数据的特点
与一般的文件(即内容已经固定)型数据源相比,所谓的流数据拥有如下的特点
- 数据一直处在变化中
- 数据无法回退
- 数据一直源源不断的涌进
DStream
如果要用一句话来概括Spark Streaming的处理思路的话,那就是"将连续的数据持久化,离散化,然后进行批量处理"。
让我们来仔细分析一下这么作的原因。
- 数据持久化 将从网络上接收到的数据先暂时存储下来,为事件处理出错时的事件重演提供可能,
- 离散化 数据源源不断的涌进,永远没有一个尽头,就像周星驰的喜剧中所说“崇拜之情如黄河之水绵绵不绝,一发而不可收拾”。既然不能穷尽,那么就将其按时间分片。比如采用一分钟为时间间隔,那么在连续的一分钟内收集到的数据集中存储在一起。
- 批量处理 将持久化下来的数据分批进行处理,处理机制套用之前的RDD模式
DStream可以说是对RDD的又一层封装。如果打开DStream.scala和RDD.scala,可以发现几乎RDD上的所有operation在DStream中都有相应的定义。
作用于DStream上的operation分成两类
- Transformation
- Output 表示将输出结果,目前支持的有print, saveAsObjectFiles, saveAsTextFiles, saveAsHadoopFiles
DStreamGraph
有输入就要有输出,如果没有输出,则前面所做的所有动作全部没有意义,那么如何将这些输入和输出绑定起来呢?这个问题的解决就依赖于DStreamGraph,DStreamGraph记录输入的Stream和输出的Stream。
private val inputStreams = new ArrayBuffer[InputDStream[_]]() private val outputStreams = new ArrayBuffer[DStream[_]]() var rememberDuration: Duration = null var checkpointInProgress = false
outputStreams中的元素是在有Output类型的Operation作用于DStream上时自动添加到DStreamGraph中的。
outputStream区别于inputStream一个重要的地方就是会重载generateJob.
初始化流程
StreamingContext
StreamingContext是Spark Streaming初始化的入口点,主要的功能是根据入参来生成JobScheduler
设定InputStream
如果流数据源来自于socket,则使用socketStream。如果数据源来自于不断变化着的文件,则可使用fileStream
提交运行
StreamingContext.start()
数据处理
以socketStream为例,数据来自于socket。
SocketInputDstream启动一个线程,该线程使用receive函数来接收数据
def receive() { var socket: Socket = null try { logInfo("Connecting to " + host + ":" + port) socket = new Socket(host, port) logInfo("Connected to " + host + ":" + port) val iterator = bytesToObjects(socket.getInputStream()) while(!isStopped && iterator.hasNext) { store(iterator.next) } logInfo("Stopped receiving") restart("Retrying connecting to " + host + ":" + port) } catch { case e: java.net.ConnectException => restart("Error connecting to " + host + ":" + port, e) case t: Throwable => restart("Error receiving data", t) } finally { if (socket != null) { socket.close() logInfo("Closed socket to " + host + ":" + port) } } } }
接收到的数据会被先存储起来,存储最终会调用到BlockManager.scala中的函数,那么BlockManager是如何被传递到StreamingContext的呢?利用SparkEnv传入的,注意StreamingContext构造函数的入参。
处理定时器
数据的存储有是被socket触发的。那么已经存储的数据被真正的处理又是被什么触发的呢?
记得在初始化StreamingContext的时候,我们指定了一个时间参数,那么用这个参数会构造相应的重复定时器,一旦定时器超时,调用generateJobs函数。
private val timer = new RecurringTimer(clock, ssc.graph.batchDuration.milliseconds, longTime => eventActor ! GenerateJobs(new Time(longTime)), "JobGenerator")
事件处理函数
/** Processes all events */ private def processEvent(event: JobGeneratorEvent) { logDebug("Got event " + event) event match { case GenerateJobs(time) => generateJobs(time) case ClearMetadata(time) => clearMetadata(time) case DoCheckpoint(time) => doCheckpoint(time) case ClearCheckpointData(time) => clearCheckpointData(time) } }
generteJobs
private def generateJobs(time: Time) { SparkEnv.set(ssc.env) Try(graph.generateJobs(time)) match { case Success(jobs) => val receivedBlockInfo = graph.getReceiverInputStreams.map { stream => val streamId = stream.id val receivedBlockInfo = stream.getReceivedBlockInfo(time) (streamId, receivedBlockInfo) }.toMap jobScheduler.submitJobSet(JobSet(time, jobs, receivedBlockInfo)) case Failure(e) => jobScheduler.reportError("Error generating jobs for time " + time, e) } eventActor ! DoCheckpoint(time) }
generateJobs->generateJob一路下去会调用到Job.run,在job.run中调用sc.runJob,在具体调用路径就不一一列出。
private class JobHandler(job: Job) extends Runnable { def run() { eventActor ! JobStarted(job) job.run() eventActor ! JobCompleted(job) } }
DStream.generateJob函数中定义了jobFunc,也就是在job.run()中使用到的jobFunc
private[streaming] def generateJob(time: Time): Option[Job] = { getOrCompute(time) match { case Some(rdd) => { val jobFunc = () => { val emptyFunc = { (iterator: Iterator[T]) => {} } context.sparkContext.runJob(rdd, emptyFunc) } Some(new Job(time, jobFunc)) } case None => None } }
在这个流程中,DStreamGraph起到非常关键的作用,非常类似于TridentStorm中的graph.
在generateJob过程中,DStream会通过调用compute函数生成相应的RDD,SparkContext则是将基于RDD的抽象转换成为多个stage,而执行。
StreamingContext中一个重要的转换就是DStream到RDD的转换,而SparkContext中一个重要的转换是RDD到Stage及Task的转换。在这两个不同的抽象类中,要注意其中getOrCompute和compute函数的实现。
存储子系统概览
上图是Spark存储子系统中几个主要模块的关系示意图,现简要说明如下
- CacheManager RDD在进行计算的时候,通过CacheManager来获取数据,并通过CacheManager来存储计算结果
- BlockManager CacheManager在进行数据读取和存取的时候主要是依赖BlockManager接口来操作,BlockManager决定数据是从内存(MemoryStore)还是从磁盘(DiskStore)中获取
- MemoryStore 负责将数据保存在内存或从内存读取
- DiskStore 负责将数据写入磁盘或从磁盘读入
- BlockManagerWorker 数据写入本地的MemoryStore或DiskStore是一个同步操作,为了容错还需要将数据复制到别的计算结点,以防止数据丢失的时候还能够恢复,数据复制的操作是异步完成,由BlockManagerWorker来处理这一部分事情
- ConnectionManager 负责与其它计算结点建立连接,并负责数据的发送和接收
- BlockManagerMaster 注意该模块只运行在Driver Application所在的Executor,功能是负责记录下所有BlockIds存储在哪个SlaveWorker上,比如RDD Task运行在机器A,所需要的BlockId为3,但在机器A上没有BlockId为3的数值,这个时候Slave worker需要通过BlockManager向BlockManagerMaster询问数据存储的位置,然后再通过ConnectionManager去获取,具体参看“数据远程获取一节”
支持的操作
由于BlockManager起到实际的存储管控作用,所以在讲支持的操作的时候,以BlockManager中的public api为例
- put 数据写入
- get 数据读取
- remoteRDD 数据删除,一旦整个job完成,所有的中间计算结果都可以删除
启动过程分析
上述的各个模块由SparkEnv来创建,创建过程在SparkEnv.create中完成
val blockManagerMaster = new BlockManagerMaster(registerOrLookup( "BlockManagerMaster", new BlockManagerMasterActor(isLocal, conf)), conf) val blockManager = new BlockManager(executorId, actorSystem, blockManagerMaster, serializer, conf) val connectionManager = blockManager.connectionManager val broadcastManager = new BroadcastManager(isDriver, conf) val cacheManager = new CacheManager(blockManager)
这段代码容易让人疑惑,看起来像是在所有的cluster node上都创建了BlockManagerMasterActor,其实不然,仔细看registerOrLookup函数的实现。如果当前节点是driver则创建这个actor,否则建立到driver的连接。
def registerOrLookup(name: String, newActor: => Actor): ActorRef = { if (isDriver) { logInfo("Registering " + name) actorSystem.actorOf(Props(newActor), name = name) } else { val driverHost: String = conf.get("spark.driver.host", "localhost") val driverPort: Int = conf.getInt("spark.driver.port", 7077) Utils.checkHost(driverHost, "Expected hostname") val url = s"akka.tcp://spark@$driverHost:$driverPort/user/$name" val timeout = AkkaUtils.lookupTimeout(conf) logInfo(s"Connecting to $name: $url") Await.result(actorSystem.actorSelection(url).resolveOne(timeout), timeout) } }
初始化过程中一个主要的动作就是BlockManager需要向BlockManagerMaster发起注册
数据写入过程分析
数据写入的简要流程
- RDD.iterator是与storage子系统交互的入口
- CacheManager.getOrCompute调用BlockManager的put接口来写入数据
- 数据优先写入到MemoryStore即内存,如果MemoryStore中的数据已满则将最近使用次数不频繁的数据写入到磁盘
- 通知BlockManagerMaster有新的数据写入,在BlockManagerMaster中保存元数据
- 将写入的数据与其它slave worker进行同步,一般来说在本机写入的数据,都会另先一台机器来进行数据的备份,即replicanumber=1
序列化与否
写入的具体内容可以是序列化之后的bytes也可以是没有序列化的value. 此处有一个对scala的语法中Either, Left, Right关键字的理解。
数据读取过程分析
def get(blockId: BlockId): Option[Iterator[Any]] = { val local = getLocal(blockId) if (local.isDefined) { logInfo("Found block %s locally".format(blockId)) return local } val remote = getRemote(blockId) if (remote.isDefined) { logInfo("Found block %s remotely".format(blockId)) return remote } None }
本地读取
首先在查询本机的MemoryStore和DiskStore中是否有所需要的block数据存在,如果没有则发起远程数据获取。
远程读取
远程获取调用路径, getRemote->doGetRemote, 在doGetRemote中最主要的就是调用BlockManagerWorker.syncGetBlock来从远程获得数据
def syncGetBlock(msg: GetBlock, toConnManagerId: ConnectionManagerId): ByteBuffer = { val blockManager = blockManagerWorker.blockManager val connectionManager = blockManager.connectionManager val blockMessage = BlockMessage.fromGetBlock(msg) val blockMessageArray = new BlockMessageArray(blockMessage) val responseMessage = connectionManager.sendMessageReliablySync( toConnManagerId, blockMessageArray.toBufferMessage) responseMessage match { case Some(message) => { val bufferMessage = message.asInstanceOf[BufferMessage] logDebug("Response message received " + bufferMessage) BlockMessageArray.fromBufferMessage(bufferMessage).foreach(blockMessage => { logDebug("Found " + blockMessage) return blockMessage.getData }) } case None => logDebug("No response message received") } null }
上述这段代码中最有意思的莫过于sendMessageReliablySync,远程数据读取毫无疑问是一个异步i/o操作,这里的代码怎么写起来就像是在进行同步的操作一样呢。也就是说如何知道对方发送回来响应的呢?
别急,继续去看看sendMessageReliablySync的定义
def sendMessageReliably(connectionManagerId: ConnectionManagerId, message: Message) : Future[Option[Message]] = { val promise = Promise[Option[Message]] val status = new MessageStatus( message, connectionManagerId, s => promise.success(s.ackMessage)) messageStatuses.synchronized { messageStatuses += ((message.id, status)) } sendMessage(connectionManagerId, message) promise.future }
要是我说秘密在这里,你肯定会说我在扯淡,但确实在此处。注意到关键字Promise和Future没。
如果这个future执行完毕,返回s.ackMessage。我们再看看这个ackMessage是在什么地方被写入的呢。看一看ConnectionManager.handleMessage中的代码片段
case bufferMessage: BufferMessage => { if (authEnabled) { val res = handleAuthentication(connection, bufferMessage) if (res == true) { // message was security negotiation so skip the rest logDebug("After handleAuth result was true, returning") return } } if (bufferMessage.hasAckId) { val sentMessageStatus = messageStatuses.synchronized { messageStatuses.get(bufferMessage.ackId) match { case Some(status) => { messageStatuses -= bufferMessage.ackId status } case None => { throw new Exception("Could not find reference for received ack message " + message.id) null } } } sentMessageStatus.synchronized { sentMessageStatus.ackMessage = Some(message) sentMessageStatus.attempted = true sentMessageStatus.acked = true sentMessageStaus.markDone() }
注意,此处的所调用的sentMessageStatus.markDone就会调用在sendMessageReliablySync中定义的promise.Success. 不妨看看MessageStatus的定义。
class MessageStatus( val message: Message, val connectionManagerId: ConnectionManagerId, completionHandler: MessageStatus => Unit) { var ackMessage: Option[Message] = None var attempted = false var acked = false def markDone() { completionHandler(this) } }
我想至此调用关系搞清楚了,scala中的Future和Promise理解起来还有有点费劲。
TachyonStore
在Spark的最新源码中,Storage子系统引入了TachyonStore. TachyonStore是在内存中实现了hdfs文件系统的接口,主要目的就是尽可能的利用内存来作为数据持久层,避免过多的磁盘读写操作。
有关该模块的功能介绍,可以参考http://www.meetup.com/spark-users/events/117307472/
没有HA的Standalone运行模式
先从比较简单的说起,所谓的没有ha是指master节点没有ha。
组成cluster的两大元素即Master和Worker。slave worker可以有1到多个,这些worker都处于active状态。
Driver Application可以运行在Cluster之内,也可以在cluster之外运行,先从简单的讲起即Driver Application独立于Cluster。那么这样的整体框架如下图所示,由driver,master和多个slave worker来共同组成整个的运行环境。
执行顺序
步骤1 运行master
$SPARK_HOME/sbin/start_master.sh
在start_master.sh中最关键的一句就是
"$sbin"/spark-daemon.sh start org.apache.spark.deploy.master.Master 1 --ip $SPARK_MASTER_IP --port $SPARK_MASTER_PORT --webui-port $SPARK_MASTER_WEBUI_PORT
检测Master的jvm进程
root 23438 1 67 22:57 pts/0 00:00:05 /opt/java/bin/java -cp :/root/working/spark-0.9.1-bin-hadoop2/conf:/root/working/spark-0.9.1-bin-hadoop2/assembly/target/scala-2.10/spark-assembly_2.10-0.9.1-hadoop2.2.0.jar -Dspark.akka.logLifecycleEvents=true -Djava.library.path= -Xms512m -Xmx512m org.apache.spark.deploy.master.Master --ip localhost --port 7077 --webui-port 8080
Master的日志在$SPARK_HOME/logs目录下
步骤2 运行worker,可以启动多个
./bin/spark-class org.apache.spark.deploy.worker.Worker spark://localhost:7077
worker运行时,需要注册到指定的master url,这里就是spark://localhost:7077.
Master侧收到RegisterWorker通知,其处理代码如下
case RegisterWorker(id, workerHost, workerPort, cores, memory, workerUiPort, publicAddress) => { logInfo("Registering worker %s:%d with %d cores, %s RAM".format( workerHost, workerPort, cores, Utils.megabytesToString(memory))) if (state == RecoveryState.STANDBY) { // ignore, don't send response } else if (idToWorker.contains(id)) { sender ! RegisterWorkerFailed("Duplicate worker ID") } else { val worker = new WorkerInfo(id, workerHost, workerPort, cores, memory, sender, workerUiPort, publicAddress) if (registerWorker(worker)) { persistenceEngine.addWorker(worker) sender ! RegisteredWorker(masterUrl, masterWebUiUrl) schedule() } else { val workerAddress = worker.actor.path.address logWarning("Worker registration failed. Attempted to re-register worker at same " + "address: " + workerAddress) sender ! RegisterWorkerFailed("Attempted to re-register worker at same address: " + workerAddress) } } }
步骤3 运行Spark-shell
MASTER=spark://localhost:7077 $SPARK_HOME/bin/spark-shell
spark-shell属于application,有关appliation的运行日志存储在$SPARK_HOME/works目录下
spark-shell作为application,在Master侧其处理的分支是RegisterApplication,具体处理代码如下。
case RegisterApplication(description) => { if (state == RecoveryState.STANDBY) { // ignore, don't send response } else { logInfo("Registering app " + description.name) val app = createApplication(description, sender) registerApplication(app) logInfo("Registered app " + description.name + " with ID " + app.id) persistenceEngine.addApplication(app) sender ! RegisteredApplication(app.id, masterUrl) schedule() } }
每当有新的application注册到master,master都要调度schedule函数将application发送到相应的worker,在对应的worker启动相应的ExecutorBackend. 具体代码请参考Master.scala中的schedule函数,代码就不再列出。
步骤4 结果检测
/opt/java/bin/java -cp :/root/working/spark-0.9.1-bin-hadoop2/conf:/root/working/spark-0.9.1-bin-hadoop2/assembly/target/scala-2.10/spark-assembly_2.10-0.9.1-hadoop2.2.0.jar -Dspark.akka.logLifecycleEvents=true -Djava.library.path= -Xms512m -Xmx512m org.apache.spark.deploy.master.Master --ip localhost --port 7077 --webui-port 8080root 23752 23745 21 23:00 pts/0 00:00:25 /opt/java/bin/java -cp :/root/working/spark-0.9.1-bin-hadoop2/conf:/root/working/spark-0.9.1-bin-hadoop2/assembly/target/scala-2.10/spark-assembly_2.10-0.9.1-hadoop2.2.0.jar -Djava.library.path= -Xms512m -Xmx512m org.apache.spark.repl.Mainroot 23986 23938 25 23:02 pts/2 00:00:03 /opt/java/bin/java -cp :/root/working/spark-0.9.1-bin-hadoop2/conf:/root/working/spark-0.9.1-bin-hadoop2/assembly/target/scala-2.10/spark-assembly_2.10-0.9.1-hadoop2.2.0.jar -Dspark.akka.logLifecycleEvents=true -Djava.library.path= -Xms512m -Xmx512m org.apache.spark.deploy.worker.Worker spark://localhost:7077root 24047 23986 34 23:02 pts/2 00:00:04 /opt/java/bin/java -cp :/root/working/spark-0.9.1-bin-hadoop2/conf:/root/working/spark-0.9.1-bin-hadoop2/assembly/target/scala-2.10/spark-assembly_2.10-0.9.1-hadoop2.2.0.jar -Xms512M -Xmx512M org.apache.spark.executor.CoarseGrainedExecutorBackend akka.tcp://spark@localhost:40053/user/CoarseGrainedScheduler 0 localhost 4 akka.tcp://sparkWorker@localhost:53568/user/Worker app-20140511230059-0000
从运行的进程之间的关系可以看出,worker和master之间的连接建立完毕之后,如果有新的driver application连接上master,master会要求worker启动相应的ExecutorBackend进程。此后若有什么Task需要运行,则会运行在这些Executor之上。可以从以下的日志信息得出此结论,当然看源码亦可。
14/05/11 23:02:36 INFO Worker: Asked to launch executor app-20140511230059-0000/0 for Spark shell14/05/11 23:02:36 INFO ExecutorRunner: Launch command: "/opt/java/bin/java" "-cp" ":/root/working/spark-0.9.1-bin-hadoop2/conf:/root/working/spark-0.9.1-bin-hadoop2/assembly/target/scala-2.10/spark-assembly_2.10-0.9.1-hadoop2.2.0.jar" "-Xms512M" "-Xmx512M" "org.apache.spark.executor.CoarseGrainedExecutorBackend" "akka.tcp://spark@localhost:40053/user/CoarseGrainedScheduler" "0" "localhost" "4" "akka.tcp://sparkWorker@localhost:53568/user/Worker" "app-20140511230059-0000"
worker中启动exectuor的相关源码见worker中的receive函数,相关代码如下
case LaunchExecutor(masterUrl, appId, execId, appDesc, cores_, memory_) => if (masterUrl != activeMasterUrl) { logWarning("Invalid Master (" + masterUrl + ") attempted to launch executor.") } else { try { logInfo("Asked to launch executor %s/%d for %s".format(appId, execId, appDesc.name)) val manager = new ExecutorRunner(appId, execId, appDesc, cores_, memory_, self, workerId, host, appDesc.sparkHome.map(userSparkHome => new File(userSparkHome)).getOrElse(sparkHome), workDir, akkaUrl, ExecutorState.RUNNING) executors(appId + "/" + execId) = manager manager.start() coresUsed += cores_ memoryUsed += memory_ masterLock.synchronized { master ! ExecutorStateChanged(appId, execId, manager.state, None, None) } } catch { case e: Exception => { logError("Failed to launch exector %s/%d for %s".format(appId, execId, appDesc.name)) if (executors.contains(appId + "/" + execId)) { executors(appId + "/" + execId).kill() executors -= appId + "/" + execId } masterLock.synchronized { master ! ExecutorStateChanged(appId, execId, ExecutorState.FAILED, None, None) } } } }
关于standalone的部署,需要详细研究的源码文件如下所列。
- deploy/master/Master.scala
- deploy/worker/worker.scala
- executor/CoarseGrainedExecutorBackend.scala
查看进程之间的父子关系,请用"pstree"
使用下图来小结单Master的部署情况。
类的动态加载和反射
在谈部署Driver到Cluster上之前,我们先回顾一下java的一大特性“类的动态加载和反射机制”。本人不是一直写java代码出身,所以好多东西都是边用边学,难免挂一漏万。
所谓的反射,其实就是要解决在运行期实现类的动态加载。
来个简单的例子
package test;public class Demo { public Demo() { System.out.println("Hi!"); } @SuppressWarnings("unchecked") public static void main(String[] args) throws Exception { Class clazz = Class.forName("test.Demo"); Demo demo = (Demo) clazz.newInstance(); }}
谈到这里,就自然想到了一个面试题,“谈一谈Class.forName和ClassLoader.loadClass的区别"。说到面试,我总是很没有信心,面试官都很屌的, :)。
在cluster中运行Driver Application
上一节之所以写到类的动态加载与反射都是为了谈这一节的内容奠定基础。
将Driver application部署到Cluster中,启动的时序大体如下图所示。
- 首先启动Master,然后启动Worker
- 使用”deploy.Client"将Driver Application提交到Cluster中
./bin/spark-class org.apache.spark.deploy.Client launch [client-options] \ \ [application-options]
- Master在收到RegisterDriver的请求之后,会发送LaunchDriver给worker,要求worker启动一个Driver的jvm process
- Driver Application在新生成的JVM进程中运行开始时会注册到master中,发送RegisterApplication给Master
- Master发送LaunchExecutor给Worker,要求Worker启动执行ExecutorBackend的JVM Process
- 一当ExecutorBackend启动完毕,Driver Application就可以将任务提交到ExecutorBackend上面执行,即LaunchTask指令
提交侧的代码,详见deploy/Client.scala
driverArgs.cmd match { case "launch" => // TODO: We could add an env variable here and intercept it in `sc.addJar` that would // truncate filesystem paths similar to what YARN does. For now, we just require // people call `addJar` assuming the jar is in the same directory. val env = Map[String, String]() System.getenv().foreach{case (k, v) => env(k) = v} val mainClass = "org.apache.spark.deploy.worker.DriverWrapper" val classPathConf = "spark.driver.extraClassPath" val classPathEntries = sys.props.get(classPathConf).toSeq.flatMap { cp => cp.split(java.io.File.pathSeparator) } val libraryPathConf = "spark.driver.extraLibraryPath" val libraryPathEntries = sys.props.get(libraryPathConf).toSeq.flatMap { cp => cp.split(java.io.File.pathSeparator) } val javaOptionsConf = "spark.driver.extraJavaOptions" val javaOpts = sys.props.get(javaOptionsConf) val command = new Command(mainClass, Seq("{{WORKER_URL}}", driverArgs.mainClass) ++ driverArgs.driverOptions, env, classPathEntries, libraryPathEntries, javaOpts) val driverDescription = new DriverDescription( driverArgs.jarUrl, driverArgs.memory, driverArgs.cores, driverArgs.supervise, command) masterActor ! RequestSubmitDriver(driverDescription)
接收侧
从Deploy.client发送出来的消息被谁接收呢?答案比较明显,那就是Master。 Master.scala中的receive函数有专门针对RequestSubmitDriver的处理,具体代码如下
case RequestSubmitDriver(description) => { if (state != RecoveryState.ALIVE) { val msg = s"Can only accept driver submissions in ALIVE state. Current state: $state." sender ! SubmitDriverResponse(false, None, msg) } else { logInfo("Driver submitted " + description.command.mainClass) val driver = createDriver(description) persistenceEngine.addDriver(driver) waitingDrivers += driver drivers.add(driver) schedule() // TODO: It might be good to instead have the submission client poll the master to determine // the current status of the driver. For now it's simply "fire and forget". sender ! SubmitDriverResponse(true, Some(driver.id), s"Driver successfully submitted as ${driver.id}") } }
SparkEnv
SparkEnv对于整个Spark的任务来说非常关键,不同的role在创建SparkEnv时传入的参数是不相同的,如Driver和Executor则存在重要区别。
在Executor.scala中,创建SparkEnv的代码如下所示
private val env = { if (!isLocal) { val _env = SparkEnv.create(conf, executorId, slaveHostname, 0, isDriver = false, isLocal = false) SparkEnv.set(_env) _env.metricsSystem.registerSource(executorSource) _env } else { SparkEnv.get } }
Driver Application则会创建SparkContext,在SparkContext创建过程中,比较重要的一步就是生成SparkEnv,其代码如下
private[spark] val env = SparkEnv.create( conf, "", conf.get("spark.driver.host"), conf.get("spark.driver.port").toInt, isDriver = true, isLocal = isLocal, listenerBus = listenerBus) SparkEnv.set(env)
Standalone模式下HA的实现
Spark在standalone模式下利用zookeeper来实现了HA机制,这里所说的HA是专门针对Master节点的,因为上面所有的分析可以看出Master是整个cluster中唯一可能出现单点失效的节点。
采用zookeeper之后,整个cluster的组成如下图所示。
为了使用zookeeper,Master在启动的时候需要指定如下的参数,修改conf/spark-env.sh, SPARK_DAEMON_JAVA_OPTS中添加如下选项。
System propertyMeaningspark.deploy.recoveryModeSet to ZOOKEEPER to enable standby Master recovery mode (default: NONE).spark.deploy.zookeeper.urlThe ZooKeeper cluster url (e.g., 192.168.1.100:2181,192.168.1.101:2181).spark.deploy.zookeeper.dirThe directory in ZooKeeper to store recovery state (default: /spark).实现HA的原理
zookeeper提供了一个Leader Election机制,利用这个机制,可以实现HA功能,具体请参考zookeeper recipes
在Spark中没有直接使用zookeeper的api,而是使用了curator,curator对zookeeper做了相应的封装,在使用上更为友好。
Spark Standalone部署模式回顾
上图是Spark Standalone Cluster中计算模块的简要示意,从中可以看出整个Cluster主要由四种不同的JVM组成
- Master 负责管理整个Cluster,Driver Application和Worker都需要注册到Master
- Worker 负责某一个node上计算资源的管理,如启动相应的Executor
- Executor RDD中每一个Stage的具体执行是在Executor上完成
- Driver Application driver中的schedulerbackend会因为部署模式的不同而不同
换个角度来说,Master对资源的管理是在进程级别,而SchedulerBackend则是在线程的级别。
启动时序图
YARN的基本架构和工作流程
YARN的基本架构如上图所示,由三大功能模块组成,分别是1) RM (ResourceManager) 2) NM (Node Manager) 3) AM(Application Master)
作业提交
- 用户通过Client向ResourceManager提交Application, ResourceManager根据用户请求分配合适的Container,然后在指定的NodeManager上运行Container以启动ApplicationMaster
- ApplicationMaster启动完成后,向ResourceManager注册自己
- 对于用户的Task,ApplicationMaster需要首先跟ResourceManager进行协商以获取运行用户Task所需要的Container,在获取成功后,ApplicationMaster将任务发送给指定的NodeManager
- NodeManager启动相应的Container,并运行用户Task
实例
上述说了一大堆,说白了在编写YARN Application时,主要是实现Client和ApplicatonMaster。实例请参考github上的simple-yarn-app.
Spark on Yarn
结合Spark Standalone的部署模式和YARN编程模型的要求,做了一张表来显示Spark Standalone和Spark on Yarn的对比。
作上述表格的目的就是要搞清楚为什么需要做这些更改,与之前Standalone模式间的对应关系是什么。代码走读时,分析的重点是ApplicationMaster, YarnClusterSchedulerBackend和YarnClusterScheduler
一般来说,在Client中会显示的指定启动ApplicationMaster的类名,如下面的代码所示
ContainerLaunchContext amContainer = Records.newRecord(ContainerLaunchContext.class); amContainer.setCommands( Collections.singletonList( "$JAVA_HOME/bin/java" + " -Xmx256M" + " com.hortonworks.simpleyarnapp.ApplicationMaster" + " " + command + " " + String.valueOf(n) + " 1>" + ApplicationConstants.LOG_DIR_EXPANSION_VAR + "/stdout" + " 2>" + ApplicationConstants.LOG_DIR_EXPANSION_VAR + "/stderr" ) );
但在yarn.Client中并没有直接指定ApplicationMaster的类名,是通过ClientArguments进行了封装,真正指定启动类的名称的地方在ClientArguments中。构造函数中指定了amClass的默认值是org.apache.spark.deploy.yarn.ApplicationMaster
实例说明
将SparkPi部署到Yarn上,下述是具体指令。
$ SPARK_JAR=./assembly/target/scala-2.10/spark-assembly-0.9.1-hadoop2.0.5-alpha.jar \ ./bin/spark-class org.apache.spark.deploy.yarn.Client \ --jar examples/target/scala-2.10/spark-examples-assembly-0.9.1.jar \ --class org.apache.spark.examples.SparkPi \ --args yarn-standalone \ --num-workers 3 \ --master-memory 4g \ --worker-memory 2g \ --worker-cores 1
从输出的日志可以看出, Client在提交的时候,AM指定的是org.apache.spark.deploy.yarn.ApplicationMaster
13/12/29 23:33:25 INFO Client: Command for starting the Spark ApplicationMaster: $JAVA_HOME/bin/java -server -Xmx4096m -Djava.io.tmpdir=$PWD/tmp org.apache.spark.deploy.yarn.ApplicationMaster --class org.apache.spark.examples.SparkPi --jar examples/target/scala-2.9.3/spark-examples-assembly-0.8.1-incubating.jar --args 'yarn-standalone' --worker-memory 2048 --worker-cores 1 --num-workers 3 1> /stdout 2> /stderr
准备
我的编译机器上安装的Linux是archlinux,并安装后如下软件
- scala 2.11
- maven
- git
下载源码
第一步当然是将github上的源码下载下来
git clone https://github.com/apache/spark.git
源码编译
不是直接用maven也不是直接用sbt,而是使用spark中自带的编译脚本make-distribution.sh
export SCALA_HOME=/usr/share/scalacd $SPARK_HOME./make-distribution.sh
如果一切顺利,会在$SPARK_HOME/assembly/target/scala-2.10目录下生成目标文件,比如
assembly/target/scala-2.10/spark-assembly-1.0.0-SNAPSHOT-hadoop1.0.4.jar
使用sbt编译
之前使用sbt编译一直会失败的主要原因就在于有些jar文件因为GFW的原因而访问不了。解决之道当然是添加代理才行。
代理的添加有下面几种方式,具体哪种好用,一一尝试吧,对于最新的spark。使用如下指令即可。
export http_proxy=http://proxy-server:port
方法二,设置JAVA_OPTS
JAVA_OPTS="-Dhttp.proxyServer=proxy-server -Dhttp.proxyPort=portNumber"
运行测试用例
既然能够顺利的编译出jar文件,那么肯定也改动两行代码来试试效果,如果知道自己的发动生效没有呢,运行测试用例是最好的办法了。
假设已经修改了$SPARK_HOME/core下的某些源码,重新编译的话,使用如下指令
export SCALA_HOME=/usr/share/scalamvn package -DskipTests
假设当前在$SPARK_HOME/core目录下,想要运行一下RandomSamplerSuite这个测试用例集合,使用以下指令即可。
export SPARK_LOCAL_IP=127.0.0.1export SPARK_MASTER_IP=127.0.0.1mvn -Dsuites=org.apache.spark.util.random.RandomSamplerSuite test
搭建hadoop
hadoop像它的Logo一样,真得是一个体形无比巨大的大象,如果直接入手去搞这个东东的话,肯定会昏上好长一段时间。个人取巧,从storm弄起,一路走来还算平滑。
hadoop最主要的是hdfs和MapReduce Framework,针对第二代的hadoop即hadoop 2这个Framework变成了非常流行的YARN, 要是没听说过YARN,都不好意思说自己玩过Hadoop了。
不开玩笑了,注意上面一段话中最主要的信息就是hdfs和mapreduce framework,我们接下来的所有配置都是围绕这两个主题来的。
创建用户
添加用户组: hadoop, 添加用户hduser
groupadd hadoopuseradd -b /home -m -g hadoop hduser
下载hadoop运行版
假设当前是以root用户登录,现在要切换成用户hduser
su - hduserid ##检验一下切换是否成功,如果一切ok,将显示下列内容uid=1000(hduser) gid=1000(hadoop) groups=1000(hadoop)
下载hadoop 2.4并解压
cd /home/hduserwget http://mirror.esocc.com/apache/hadoop/common/hadoop-2.4.0/hadoop-2.4.0.tar.gztar zvxf hadoop-2.4.0.tar.gz
设置环境变量
export HADOOP_HOME=$HOME/hadoop-2.4.0export HADOOP_MAPRED_HOME=$HOME/hadoop-2.4.0export HADOOP_COMMON_HOME=$HOME/hadoop-2.4.0export HADOOP_HDFS_HOME=$HOME/hadoop-2.4.0export HADOOP_YARN_HOME=$HOME/hadoop-2.4.0export HADOOP_CONF_DIR=$HOME/hadoop-2.4.0/etc/hadoop
为了避免每次都要重复设置这些变量,可以将上述语句加入到.bashrc文件中。
创建目录
接下来创建的目录是为hadoop中hdfs相关的namenode即datanode使用
mkdir -p $HOME/yarn_data/hdfs/namenodemkdir -p $HOME/yarn_data/hdfs/datanode
修改Hadoop配置文件
下列文件需要相应的配置
- yarn-site.xml
- core-site.xml
- hdfs-site.xml
- mapred-site.xml
切换到hadoop安装目录
$cd $HADOOP_HOME
修改etc/hadoop/yarn-site.xml, 在<configuration>和</configuration>之间添加如下内容,其它文件添加位置与此一致
<property> <name>yarn.nodemanager.aux-services</name> <value>mapreduce_shuffle</value></property><property> <name>yarn.nodemanager.aux-services.mapreduce.shuffle.class</name> <value>org.apache.hadoop.mapred.ShuffleHandler</value></property>
etc/hadoop/core-site.xml
<property> <name>fs.default.name</name> <value>hdfs://localhost:9000</value> <!--YarnClient会用到该配置项--></property>
etc/hadoop/hdfs-site.xml
<property> <name>dfs.replication</name> <value>1</value> </property> <property> <name>dfs.namenode.name.dir</name> <value>file:/home/hduser/yarn_data/hdfs/namenode</value> <!--节点格式化中被用到--> </property> <property> <name>dfs.datanode.data.dir</name> <value>file:/home/hduser/yarn_data/hdfs/datanode</value> </property>
etc/hadoop/mapred-site.xml
<property> <name>mapreduce.framework.name</name> <value>yarn</value></property>
格式化namenode
$ bin/hadoop namenode -format
启动hdfs相关进程
启动namenode
$ sbin/hadoop-daemon.sh start namenode
启动datanode
$sbin/hadoop-daemon.sh start datanode
启动mapreduce framework相关进程
启动Resource Manager
sbin/yarn-daemon.sh start resourcemanager
启动Node Manager
sbin/yarn-daemon.sh start nodemanager
启动Job History Server
sbin/mr-jobhistory-daemon.sh start historyserver
验证部署
$jps18509 Jps17107 NameNode17170 DataNode17252 ResourceManager17309 NodeManager17626 JobHistoryServer
运行wordCount
验证一下hadoop搭建成功与否的最好办法就是在上面跑个wordcount试试
$mkdir in$cat > in/fileThis is one lineThis is another line
将文件复制到hdfs中
$bin/hdfs dfs -copyFromLocal in /in
运行wordcount
bin/hadoop jar ./share/hadoop/mapreduce/hadoop-mapreduce-examples-2.4.0.jar wordcount /in /out
查看运行结果
bin/hdfs dfs -cat /out/*
先歇一会,配置到这里,已经一头汗了,接下来将spark在yarn上的运行,再坚持一小会
在yarn上运行SparkPi
下载spark
下载spark for hadoop2的版本
运行SparkPi
继续以hduser身份运行,最主要的一点就是设置YARN_CONF_DIR或HADOOP_CONF_DIR环境变量
export YARN_CONF_DIR=$HADOOP_HOME/etc/hadoopSPARK_JAR=./assembly/target/scala-2.10/spark-assembly_2.10-0.9.1-hadoop2.2.0.jar \./bin/spark-class org.apache.spark.deploy.yarn.Client \--jar ./examples/target/scala-2.10/spark-examples_2.10-assembly-0.9.1.jar \--class org.apache.spark.examples.JavaSparkPi \--args yarn-standalone \--num-workers 1 \--master-memory 512m \--worker-memory 512m \--worker-cores 1
检查运行结果
运行结果保存在相关application的stdout目录,使用以下指令可以找到
cd $HADOOP_HOMEfind . -name "*stdout"
假设找到的文件为./logs/userlogs/application_1400479924971_0002/container_1400479924971_0002_01_000001/stdout,使用cat可以看到结果
cat ./logs/userlogs/application_1400479924971_0002/container_1400479924971_0002_01_000001/stdoutPi is roughly 3.14028
应用举例
val sqlContext = new org.apache.spark.sql.SQLContext(sc);import sqlContext._case class Person(name: String, age: Int)val person = sc.textFile("examples/src/main/resources/people.txt").map(_.split(" ")).map(p => Person(p(0), p(1).trim.toInt))person.registerAsTable("person")val teenagers = sql("SELECT name, age FROM person WHERE age >= 13 and age <= 19")teenagers.map(t => "name:" + t(0)).collect().foreach(println)
上述代码的逻辑非常清晰,就是将存在于person.txt中年龄界于13到19岁的年轻人名字打印出来。
SQL通用执行过程
SQL的组成部分
SQL语句大家都很熟悉,那么有没有仔细想过其有几大部分组成呢?可能你会说,”这还用问,不就是“select * from tablex where f1=?”,有什么好想吗?“
还是先来看看再说吧,说不定有些新的思维在里面呢?
上图是对最简单的sql语句的重新标注,SELECT表示是一种具体的操作,即查询数据,”f1,f2,f3"表示返回的结果,tableX是数据源,condition部分是查询条件。有没有发觉SQL表达式中的顺序与常见的RDD处理逻辑其在表达的顺序上有差异。还是继续用图来表示不同吧。
SQL语句在分析执行过程中会经历下图所示的几个步骤
- 语法解析
- 操作绑定
- 优化执行策略
- 交付执行
语法解析
语法解析之后,会形成一棵语法树,如下图所示。树中的每个节点是执行的rule,整棵树称之为执行策略。
策略优化
形成上述的执行策略树还只是第一步,因为这个执行策略可以进行优化,所谓的优化就是对树中节点进行合并或是进行顺序上的调整。
以大家熟悉的join操作为例,下图给出一个join优化的示例。A JOIN B等同于B JOIN A,但是顺序的调整可能给执行的性能带来极大的影响,下图就是调整前后的对比图。
再举一例,一般来说尽可能的先实施聚合操作(Aggregate)然后再join
小结
上述一大通分析,希望达到的目的就两个。
- 语法解析之后生成一个执行策略树
- 执行策略树可以优化,优化的过程就是对树中节点进行合并或者顺序调整
有关SQL查询分析优化的具体过程,强烈推荐参考query optimizer deep dive系列文章
SQL在spark中的实现
有了上述内容的铺垫,想必你已经意识到Spark如果要很好的支持sql,势必也要完成,解析,优化,执行的三大过程。
整个SQL部分的代码,其大致分类如下图所示
- SqlParser生成LogicPlan Tree
- Analyzer和Optimizer将各种rule作用于LogicalPlan Tree
- 最终优化生成的LogicalPlan生成Spark RDD
- 最后将生成的RDD交由Spark执行
阶段1:生成LogicalPlan
在sql中引入了一种新的RDD,即SchemaRDD
且看SchemaRDD的构造函数
class SchemaRDD( @transient val sqlContext: SQLContext, @transient protected[spark] val logicalPlan: LogicalPlan)
构造函数中总共两入参一为SparkContext,另一个LogicalPlan。LogicalPlan又是如何生成的呢?
要回答这个问题,不得不回到整个问题的入口点sql函数,sql函数的定义如下
def sql(sqlText: String): SchemaRDD = { val result = new SchemaRDD(this, parseSql(sqlText)) result.queryExecution.toRdd result }
parseSql(sqlText)负责生成LogicalPlan,parseSql就是SqlParser的一个实例。
SqlParser这一部分的代码要理解起来关键是要搞清楚StandardTokenParsers的调用规则,里面有一大堆的符号,如果不理解是什么意思,估计很难理清头绪。
由于apply函数可以不被显示调用,所以parseSql(sqlText)一句其实会隐式的调用SqlParser中的apply函数
def apply(input: String): LogicalPlan = { phrase(query)(new lexical.Scanner(input)) match { case Success(r, x) => r case x => sys.error(x.toString) } }
最最最让人蛋疼的一行代码就是phrase(query)(new lexical.Scanner(input))这里了,翻译过来就是如果输入的input字符串符合Lexical中定义的规则,则继续使用query处理。
看一下query的定义是什么
protected lazy val query: Parser[LogicalPlan] = select * ( UNION ~ ALL ^^^ { (q1: LogicalPlan, q2: LogicalPlan) => Union(q1, q2) } | UNION ~ opt(DISTINCT) ^^^ { (q1: LogicalPlan, q2: LogicalPlan) => Distinct(Union(q1, q2)) } ) | insert
到了这里终于看到有LogicalPlan了,也就是说将普通的string转换成LogicalPlan在这里发生了。
query这段代码同时说明,在目前的spark sql中仅支持select和insert两种操作,至于delete, update暂不支持。
注:即便是到现在,估计你和当初一样对于SqlParser的使用还是一头雾水,不要紧,请参考ref[3]和[4]中的内容,至于那些稀奇古怪的符号到底是什么意思,请参考ref[5].
阶段2:QueryExecution
第一阶段,将string转换成为logicalplan tree,第二阶段将各种规则作用于LogicalPlan。
在第一阶段中展示的代码,哪一句会触发优化规则呢?是sql函数中的"result.queryExecution.toRdd",此处的queryExecution就是QueryExecution。这里又涉及到scala的一个语法糖问题。QueryExecution是一个抽象类,但却看到了下述的代码
protected[sql] def executePlan(plan: LogicalPlan): this.QueryExecution = new this.QueryExecution { val logical = plan }
怎么可以创建抽象类的实例?我的世界坍塌了,呵呵。不要紧张,这在scala的世界是允许的,只不过scala是隐含的创建了一个QueryExecution的子类并初始化而已,java里的原则还是对的,人家背后有猫腻。
Ok,轮到阶段2中最重要的角色QueryExecution闪亮登场了
protected abstract class QueryExecution { def logical: LogicalPlan lazy val analyzed = analyzer(logical) lazy val optimizedPlan = optimizer(analyzed) lazy val sparkPlan = planner(optimizedPlan).next() lazy val executedPlan: SparkPlan = prepareForExecution(sparkPlan) /** Internal version of the RDD. Avoids copies and has no schema */ lazy val toRdd: RDD[Row] = executedPlan.execute() protected def stringOrError[A](f: => A): String = try f.toString catch { case e: Throwable => e.toString } def simpleString: String = stringOrError(executedPlan) override def toString: String = s"""== Logical Plan == |${stringOrError(analyzed)} |== Optimized Logical Plan == |${stringOrError(optimizedPlan)} |== Physical Plan == |${stringOrError(executedPlan)} """.stripMargin.trim def debugExec() = DebugQuery(executedPlan).execute().collect() }
三大步
- lazy val analyzed = analyzer(logical)
- lazy val optimizedPlan = optimizer(analyzed)
- lazy val sparkPlan = planner(optimizedPlan).next()
无论analyzer还是optimizer,它们都是RuleExecutor的子类,
RuleExecutor的默认处理函数是apply,对所有的子类都是一样的,RuleExecutor的apply函数定义如下,
def apply(plan: TreeType): TreeType = { var curPlan = plan batches.foreach { batch => val batchStartPlan = curPlan var iteration = 1 var lastPlan = curPlan var continue = true // Run until fix point (or the max number of iterations as specified in the strategy. while (continue) { curPlan = batch.rules.foldLeft(curPlan) { case (plan, rule) => val result = rule(plan) if (!result.fastEquals(plan)) { logger.trace( s""" |=== Applying Rule ${rule.ruleName} === |${sideBySide(plan.treeString, result.treeString).mkString("\n")} """.stripMargin) } result } iteration += 1 if (iteration > batch.strategy.maxIterations) { logger.info(s"Max iterations ($iteration) reached for batch ${batch.name}") continue = false } if (curPlan.fastEquals(lastPlan)) { logger.trace(s"Fixed point reached for batch ${batch.name} after $iteration iterations.") continue = false } lastPlan = curPlan } if (!batchStartPlan.fastEquals(curPlan)) { logger.debug( s""" |=== Result of Batch ${batch.name} === |${sideBySide(plan.treeString, curPlan.treeString).mkString("\n")} """.stripMargin) } else { logger.trace(s"Batch ${batch.name} has no effect.") } } curPlan }
对于RuleExecutor的子类来说,最主要的是定义自己的batches,来看analyzer中的batches是如何定义的
val batches: Seq[Batch] = Seq( Batch("MultiInstanceRelations", Once, NewRelationInstances), Batch("CaseInsensitiveAttributeReferences", Once, (if (caseSensitive) Nil else LowercaseAttributeReferences :: Nil) : _*), Batch("Resolution", fixedPoint, ResolveReferences :: ResolveRelations :: NewRelationInstances :: ImplicitGenerate :: StarExpansion :: ResolveFunctions :: GlobalAggregates :: typeCoercionRules :_*), Batch("AnalysisOperators", fixedPoint, EliminateAnalysisOperators) )
batch中定义了一系列的规则,这里再次出现语法糖问题。“如何理解::这个操作符”? ::表示cons的意思,即连接生成一个list.
Batch构造函数中需要指定一系列的Rule,像ResolveReferences就是Rule,有关Rule的代码就不一一分析了。
阶段3:LogicalPlan转换成Physical Plan
在阶段3最主要的代码就两行
- lazy val executePlan: SparkPlan = prepareForExecution(sparkPlan)
- lazy val toRdd: RDD[Row] = executedPlan.execute()
与LogicalPlan不同,SparkPlan最重要的区别就是有execute函数
针对Sparkplan的具体实现,又要分成UnaryNode, LeafNode和BinaryNode,简要来说即单目运算符操作,叶子结点,双目运算符操作。每个子类的具体实现可以自行参考源码。
阶段4: 触发RDD执行
RDD被触发真正执行的过程在看了前面几篇文章之后想来难不住你来,所有的所有都在这一行代码。
teenagers.map(p => "name:"+p(0)).foreach(println)
如果真的不明白,建议回头再读一下Spark Job的执行过程分析。
安装概览
整体的安装过程分为以下几步
- 搭建Hadoop集群 (整个cluster由3台机器组成,一台作为Master,另两台作为Slave)
- 编译Spark 1.0,使其支持Hadoop 2.4.0和Hive
- 运行Hive on Spark的测试用例 (Spark和Hadoop Namenode运行在同一台机器)
Hadoop集群搭建
创建虚拟机
创建基于kvm的虚拟机,利用libvirt提供的图形管理界面,创建3台虚拟机,非常方便。内存和ip地址分配如下
- master 2G 192.168.122.102
- slave1 4G 192.168.122.103
- slave2 4G 192.168.122.104
在虚拟机上安装os的过程就略过了,我使用的是arch linux,os安装完成之后,确保以下软件也已经安装
- jdk
- openssh
创建用户组和用户
在每台机器上创建名为hadoop的用户组,添加名为hduser的用户,具体bash命令如下所示
groupadd hadoopuseradd -b /home -m -g hadoop hduserpasswd hduser
无密码登录
在启动slave机器上的datanode或nodemanager的时候需要输入用户名密码,为了避免每次都要输入密码,可以利用如下指令创建无密码登录。注意是从master到slave机器的单向无密码。
cd $HOME/.sshssh-keygen -t dsa
将id_dsa.pub复制为authorized_keys,然后上传到slave1和slave2中的$HOME/.ssh目录
cp id_dsa.pub authorized_keys#确保在slave1和slave2机器中,hduser的$HOME目录下已经创建好了.ssh目录scp authorized_keys slave1:$HOME/.sshscp authorized_keys slave2:$HOME/.ssh
更改每台机器上的/etc/hosts
在组成集群的master, slave1和slave2中,向/etc/hosts文件添加如下内容
192.168.122.102 master192.168.122.103 slave1192.168.122.104 slave2
如果更改完成之后,可以在master上执行ssh slave1来进行测试,如果没有输入密码的过程就直接登录入slave1就说明上述的配置成功。
下载hadoop 2.4.0
以hduser身份登录master,执行如下指令
cd /home/hduserwget http://mirror.esocc.com/apache/hadoop/common/hadoop-2.4.0/hadoop-2.4.0.tar.gzmkdir yarntar zvxf hadoop-2.4.0.tar.gz -C yarn
修改hadoop配置文件
添加如下内容到.bashrc
export HADOOP_HOME=/home/hduser/yarn/hadoop-2.4.0export HADOOP_MAPRED_HOME=$HADOOP_HOMEexport HADOOP_COMMON_HOME=$HADOOP_HOMEexport HADOOP_HDFS_HOME=$HADOOP_HOMEexport YARN_HOME=$HADOOP_HOMEexport HADOOP_CONF_DIR=$HADOOP_HOME/etc/hadoopexport YARN_CONF_DIR=$HADOOP_HOME/etc/hadoop
修改$HADOOP_HOME/libexec/hadoop-config.sh
在hadoop-config.sh文件开头处添加如下内容
export JAVA_HOME=/opt/java
$HADOOP_CONF_DIR/yarn-env.sh
在yarn-env.sh开头添加如下内容
export JAVA_HOME=/opt/javaexport HADOOP_HOME=/home/hduser/yarn/hadoop-2.4.0export HADOOP_MAPRED_HOME=$HADOOP_HOMEexport HADOOP_COMMON_HOME=$HADOOP_HOMEexport HADOOP_HDFS_HOME=$HADOOP_HOMEexport YARN_HOME=$HADOOP_HOMEexport HADOOP_CONF_DIR=$HADOOP_HOME/etc/hadoopexport YARN_CONF_DIR=$HADOOP_HOME/etc/hadoop
xml配置文件修改
文件1: $HADOOP_CONF_DIR/core-site.xml
<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="configuration.xsl"?><configuration> <property> <name>fs.default.name</name> <value>hdfs://master:9000</value> </property> <property> <name>hadoop.tmp.dir</name> <value>/home/hduser/yarn/hadoop-2.4.0/tmp</value> </property></configuration>
文件2: $HADOOP_CONF_DIR/hdfs-site.xml
<?xml version="1.0" encoding="UTF-8"?> <?xml-stylesheet type="text/xsl" href="configuration.xsl"?> <configuration> <property> <name>dfs.replication</name> <value>2</value> </property> <property> <name>dfs.permissions</name> <value>false</value> </property> </configuration>
文件3: $HADOOP_CONF_DIR/mapred-site.xml
<?xml version="1.0"?><configuration> <property> <name>mapreduce.framework.name</name> <value>yarn</value> </property></configuration>
文件4: $HADOOP_CONF_DIR/yarn-site.xml
<?xml version="1.0"?> <configuration> <property> <name>yarn.nodemanager.aux-services</name> <value>mapreduce_shuffle</value> </property> <property> <name>yarn.nodemanager.aux-services.mapreduce.shuffle.class</name> <value>org.apache.hadoop.mapred.ShuffleHandler</value> </property> <property> <name>yarn.resourcemanager.resource-tracker.address</name> <value>master:8025</value> </property> <property> <name>yarn.resourcemanager.scheduler.address</name> <value>master:8030</value> </property> <property> <name>yarn.resourcemanager.address</name> <value>master:8040</value> </property> </configuration>
文件5: $HADOOP_CONF_DIR/slaves
在文件中添加如下内容
slave1slave2
创建tmp目录
在$HADOOP_HOME下创建tmp目录
mkdir $HADOOP_HOME/tmp
复制yarn目录到slave1和slave2
刚才所作的配置文件更改发生在master机器上,将整个更改过的内容全部复制到slave1和slave2。
for target in slave1 slave2do scp -r yarn $target:~/ scp $HOME/.bashrc $target:~/done
批量处理是不是很爽
格式化namenode
在master机器上对namenode进行格式化
bin/hadoop namenode -format
启动cluster集群
sbin/hadoop-daemon.sh start namenodesbin/hadoop-daemons.sh start datanodesbin/yarn-daemon.sh start resourcemanagersbin/yarn-daemons.sh start nodemanagersbin/mr-jobhistory-daemon.sh start historyserver
注意: daemon.sh表示只在本机运行,daemons.sh表示在所有的cluster节点上运行。
验证hadoop集群安装正确与否
跑一个wordcount示例,具体步骤不再列出,可参考本系列中的第11篇
编译Spark 1.0
Spark的编译还是很简单的,所有失败的原因大部分可以归结于所依赖的jar包无法正常下载。
为了让Spark 1.0支持hadoop 2.4.0和hive,请使用如下指令编译
SPARK_HADOOP_VERSION=2.4.0 SPARK_YARN=true SPARK_HIVE=true sbt/sbt assembly
如果一切顺利将会在assembly目录下生成 spark-assembly-1.0.0-SNAPSHOT-hadoop2.4.0.jar
创建运行包
编译之后整个$SPARK_HOME目录下所有的文件体积还是很大的,大概有两个多G。有哪些是运行的时候真正需要的呢,下面将会列出这些目录和文件。
- $SPARK_HOME/bin
- $SPARK_HOME/sbin
- $SPARK_HOME/lib_managed
- $SPARK_HOME/conf
- $SPARK_HOME/assembly/target/scala-2.10
将上述目录的内容复制到/tmp/spark-dist,然后创建压缩包
mkdir /tmp/spark-distfor i in $SPARK_HOME/{bin,sbin,lib_managed,conf,assembly/target/scala-2.10}do cp -r $i /tmp/spark-distdonecd /tmp/tar czvf spark-1.0-dist.tar.gz spark-dist
上传运行包到master机器
将生成的运行包上传到master(192.168.122.102)
scp spark-1.0-dist.tar.gz hduser@192.168.122.102:~/
运行hive on spark测试用例
经过上述重重折磨,终于到了最为紧张的时刻了。
以hduser身份登录master机,解压spark-1.0-dist.tar.gz
#after login into the master as hdusertar zxvf spark-1.0-dist.tar.gzcd spark-dist
更改conf/spark-env.sh
export SPARK_LOCAL_IP=127.0.0.1export SPARK_MASTER_IP=127.0.0.1
运行最简单的example
用bin/spark-shell指令启动shell之后,运行如下scala代码
val sc: SparkContext // An existing SparkContext.val hiveContext = new org.apache.spark.sql.hive.HiveContext(sc)// Importing the SQL context gives access to all the public SQL functions and implicit conversions.import hiveContext._hql("CREATE TABLE IF NOT EXISTS src (key INT, value STRING)")hql("LOAD DATA LOCAL INPATH 'examples/src/main/resources/kv1.txt' INTO TABLE src")// Queries are expressed in HiveQLhql("FROM src SELECT key, value").collect().foreach(println)
如果一切顺利,最后一句hql会返回key及value
数据模型(Data Model)
Hive所有的数据都存在HDFS中,在Hive中有以下几种数据模型
- Tables(表) table和关系型数据库中的表是相对应的,每个表都有一个对应的hdfs目录,表中的数据经序列化后存储在该目录,Hive同时支持表中的数据存储在其它类型的文件系统中,如NFS或本地文件系统
- 分区(Partitions) Hive中的分区起到的作用有点类似于RDBMS中的索引功能,每个Partition都有一个对应的目录,这样在查询的时候,可以减少数据规模
- 桶(buckets) 即使将数据按分区之后,每个分区的规模有可能还是很大,这个时候,按照关键字的hash结果将数据分成多个buckets,每个bucket对应于一个文件
Query Language
HiveQL是Hive支持的类似于SQL的查询语言。HiveQL大体可以分成下面两种类型
- DDL(data definition language) 比如创建数据库(create database),创建表(create table),数据库和表的删除
- DML(data manipulation language) 数据的添加,查询
- UDF(user defined function) Hive还支持用户自定义查询函数
Hive architecture
hive的整体框架图如下图所示
由上图可以看出,Hive的整体架构可以分成以下几大部分
- 用户接口 支持CLI, JDBC和Web UI
- Driver Driver负责将用户指令翻译转换成为相应的MapReduce Job
- MetaStore 元数据存储仓库,像数据库和表的定义这些内容就属于元数据这个范畴,默认使用的是Derby存储引擎
HiveQL执行过程
HiveQL的执行过程如下所述
- parser 将HiveQL解析为相应的语法树
- Semantic Analyser 语义分析
- Logical Plan Generating 生成相应的LogicalPlan
- Query Plan Generating
- Optimizer
最终生成MapReduce的Job,交付给Hadoop的MapReduce计算框架具体运行。
Hive实例
最好的学习就是实战,Hive这一小节还是以一个具体的例子来结束吧。
前提条件是已经安装好hadoop,具体安装可以参考源码走读11或走读9
step 1: 创建warehouse
warehouse用来存储raw data
$ $HADOOP_HOME/bin/hadoop fs -mkdir /tmp$ $HADOOP_HOME/bin/hadoop fs -mkdir /user/hive/warehouse$ $HADOOP_HOME/bin/hadoop fs -chmod g+w /tmp$ $HADOOP_HOME/bin/hadoop fs -chmod g+w /user/hive/warehouse
step 2: 启动hive cli
$ export HIVE_HOME=<hive-install-dir>$ $HIVE_HOME/bin/hive
step 3: 创建表
创建表,首先将schema数据写入到metastore,另一件事情就是在warehouse目录下创建相应的子目录,该子目录以表的名称命名
CREATE TABLE u_data ( userid INT, movieid INT, rating INT, unixtime STRING)ROW FORMAT DELIMITEDFIELDS TERMINATED BY '\t'STORED AS TEXTFILE;
step 4: 导入数据
导入的数据会存储在step 3中创建的表目录下
LOAD DATA LOCAL INPATH '/u.data'OVERWRITE INTO TABLE u_data;
step 5: 查询
SELECT COUNT(*) FROM u_data;
hiveql on Spark
Q: 上一章节花了大量的篇幅介绍了hive由来,框架及hiveql执行过程。那这些东西跟我们标题中所称的hive on spark有什么关系呢?
Ans: Hive的整体解决方案很不错,但有一些地方还值得改进,其中之一就是“从查询提交到结果返回需要相当长的时间,查询耗时太长”。之所以查询时间很长,一个主要的原因就是因为Hive原生是基于MapReduce的,哪有没有办法提高呢。您一定想到了,“不是生成MapReduce Job,而是生成Spark Job”, 充分利用Spark的快速执行能力来缩短HiveQl的响应时间。
下图是Spark 1.0中所支持的lib库,SQL是其唯一新添加的lib库,可见SQL在Spark 1.0中的地位之重要。
HiveContext
HiveContext是Spark提供的用户接口,HiveContext继承自SqlContext。
让我们回顾一下,SqlContext中牵涉到的类及其间的关系如下图所示,具体分析过程参见本系列中的源码走读之11。
既然是继承自SqlContext,那么我们将普通sql与hiveql分析执行步骤做一个对比,可以得到下图。
有了上述的比较,就能抓住源码分析时需要把握的几个关键点
- Entrypoint HiveContext.scala
- QueryExecution HiveContext.scala
- parser HiveQl.scala
- optimizer
数据
使用到的数据有两种
- Schema Data 像数据库的定义和表的结构,这些都存储在MetaStore中
- Raw data 即要分析的文件本身
Entrypoint
hiveql是整个的入口点,而hql是hiveql的缩写形式。
def hiveql(hqlQuery: String): SchemaRDD = { val result = new SchemaRDD(this, HiveQl.parseSql(hqlQuery)) // We force query optimization to happen right away instead of letting it happen lazily like // when using the query DSL. This is so DDL commands behave as expected. This is only // generates the RDD lineage for DML queries, but does not perform any execution. result.queryExecution.toRdd result }
上述hiveql的定义与sql的定义几乎一模一样,唯一的不同是sql中使用parseSql的结果作为SchemaRDD的入参而hiveql中使用HiveQl.parseSql作为SchemaRdd的入参
HiveQL, parser
parseSql的函数定义如代码所示,解析过程中将指令分成两大类
- nativecommand 非select语句,这类语句的特点是执行时间不会因为条件的不同而有很大的差异,基本上都能在较短的时间内完成
- 非nativecommand 主要是select语句
def parseSql(sql: String): LogicalPlan = { try { if (sql.toLowerCase.startsWith("set")) { NativeCommand(sql) } else if (sql.toLowerCase.startsWith("add jar")) { AddJar(sql.drop(8)) } else if (sql.toLowerCase.startsWith("add file")) { AddFile(sql.drop(9)) } else if (sql.startsWith("dfs")) { DfsCommand(sql) } else if (sql.startsWith("source")) { SourceCommand(sql.split(" ").toSeq match { case Seq("source", filePath) => filePath }) } else if (sql.startsWith("!")) { ShellCommand(sql.drop(1)) } else { val tree = getAst(sql) if (nativeCommands contains tree.getText) { NativeCommand(sql) } else { nodeToPlan(tree) match { case NativePlaceholder => NativeCommand(sql) case other => other } } } } catch { case e: Exception => throw new ParseException(sql, e) case e: NotImplementedError => sys.error( s""" |Unsupported language features in query: $sql |${dumpTree(getAst(sql))} """.stripMargin) } }
哪些指令是nativecommand呢,答案在HiveQl.scala中的nativeCommands变量,列表很长,代码就不一一列出。
对于非nativeCommand,最重要的解析函数就是nodeToPlan
toRdd
Spark对HiveQL所做的优化主要体现在Query相关的操作,其它的依然使用Hive的原生执行引擎。
在logicalPlan到physicalPlan的转换过程中,toRdd最关键的元素
override lazy val toRdd: RDD[Row] = analyzed match { case NativeCommand(cmd) => val output = runSqlHive(cmd) if (output.size == 0) { emptyResult } else { val asRows = output.map(r => new GenericRow(r.split("\t").asInstanceOf[Array[Any]])) sparkContext.parallelize(asRows, 1) } case _ => executedPlan.execute().map(_.copy()) }
native command的执行流程
由于native command是一些非耗时的操作,直接使用Hive中原有的exeucte engine来执行即可。这些command的执行示意图如下
analyzer
HiveTypeCoercion
val typeCoercionRules = List(PropagateTypes, ConvertNaNs, WidenTypes, PromoteStrings, BooleanComparisons, BooleanCasts, StringToIntegralCasts, FunctionArgumentConversion)
optimizer
PreInsertionCasts存在的目的就是确保在数据插入执行之前,相应的表已经存在。
override lazy val optimizedPlan = optimizer(catalog.PreInsertionCasts(catalog.CreateTables(analyzed)))
此处要注意的是catalog的用途,catalog是HiveMetastoreCatalog的实例。
HiveMetastoreCatalog是Spark中对Hive Metastore访问的wrapper。HiveMetastoreCatalog通过调用相应的Hive Api可以获得数据库中的表及表的分区,也可以创建新的表和分区。
HiveMetastoreCatalog
HiveMetastoreCatalog中会通过hive client来访问metastore中的元数据,使用了大量的Hive Api。其中包括了广为人知的deSer library。
以CreateTable函数为例说明对Hive Library的依赖。
def createTable( databaseName: String, tableName: String, schema: Seq[Attribute], allowExisting: Boolean = false): Unit = { val table = new Table(databaseName, tableName) val hiveSchema = schema.map(attr => new FieldSchema(attr.name, toMetastoreType(attr.dataType), "")) table.setFields(hiveSchema) val sd = new StorageDescriptor() table.getTTable.setSd(sd) sd.setCols(hiveSchema) // TODO: THESE ARE ALL DEFAULTS, WE NEED TO PARSE / UNDERSTAND the output specs. sd.setCompressed(false) sd.setParameters(Map[String, String]()) sd.setInputFormat("org.apache.hadoop.mapred.TextInputFormat") sd.setOutputFormat("org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat") val serDeInfo = new SerDeInfo() serDeInfo.setName(tableName) serDeInfo.setSerializationLib("org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe") serDeInfo.setParameters(Map[String, String]()) sd.setSerdeInfo(serDeInfo) try client.createTable(table) catch { case e: org.apache.hadoop.hive.ql.metadata.HiveException if e.getCause.isInstanceOf[org.apache.hadoop.hive.metastore.api.AlreadyExistsException] && allowExisting => // Do nothing. } }
实验
结合源码,我们再对一个简单的例子作下说明。
可能你会想,既然spark也支持hql,那么我原先用hive cli创建的数据库和表用spark能不能访问到呢?答案或许会让你很纳闷,“在默认的配置下是不行的”。为什么?
Hive中的meta data采用的存储引擎是Derby,该存储引擎只能有一个访问用户。同一时刻只能有一个人访问,即便以同一用户登录访问也不行。针对这个局限,解决方法就是将metastore存储在mysql或者其它可以多用户访问的数据库中。
具体实例
- 创建表
- 导入数据
- 查询
- 删除表
在启动spark-shell之前,需要先设置环境变量HIVE_HOME和HADOOP_HOME.
启动spark-shell之后,执行如下代码
val hiveContext = new org.apache.spark.sql.hive.HiveContext(sc)// Importing the SQL context gives access to all the public SQL functions and implicit conversions.import hiveContext._hql("CREATE TABLE IF NOT EXISTS src (key INT, value STRING)")hql("LOAD DATA LOCAL INPATH 'examples/src/main/resources/kv1.txt' INTO TABLE src")// Queries are expressed in HiveQLhql("FROM src SELECT key, value").collect().foreach(println)hql("drop table src")
create操作会在/user/hive/warehouse/目录下创建src目录,可以用以下指令来验证
$$HADOOP_HOME/bin/hdfs dfs -ls /user/hive/warehouse/
drop表的时候,不仅metastore中相应的记录被删除,而且原始数据raw file本身也会被删除,即在warehouse目录下对应某个表的目录会被整体删除掉。
上述的create, load及query操作对metastore和raw data的影响可以用下图的表示
hive-site.xml
如果想对hive默认的配置作修改,可以使用hive-site.xml。
具体步骤如下
- 在$SPARK_HOME/conf目录下创建hive-site.xml
- 根据需要,添写相应的配置项的值,可以这样做,将$HIVE_HOME/conf目录下的hive-default.xml复制到$SPARK_HOME/conf,然后重命名为hive-site.xml
Sql新功能预告
为了进一步提升sql的执行速度,在Spark开发团队在发布完1.0之后,会通过codegen的方法来提升执行速度。codegen有点类似于jvm中的jit技术。充分利用了scala语言的特性。
前景分析
Spark目前还缺乏一个非常有影响力的应用,也就通常所说的killer application。SQL是Spark在寻找killer application方面所做的一个积极尝试,也是目前Spark上最有热度的一个话题,但通过优化Hive执行速度来吸引潜在Spark用户,该突破方向选择正确与否还有待市场证明。
Hive除了在执行速度上为人诟病之外,还有一个最大的问题就是多用户访问的问题,相较第一个问题,第二个问题来得更为致命。无论是Facebook在Hive之后推出的Presto还是Cloudera推出的Impala都是针对第二问题提出的解决方案,目前都已经取得的了巨大优势。
图论简介
图的组成
离散数学中非常重要的一个部分就是图论,下面是一个无向连通图
顶点(vertex)
上图中的A,B,C,D,E称为图的顶点。
边
顶点与顶点之间的连线称之为边。
图的数学表示
读大学的时候,一直没有想明白为什么要学劳什子的线性代数。直到这两天看《数学之美》一书时,才发觉,线性代数在一些计算机应用领域,那简直就是不可或缺啊。
我们比较容易理解的平面几何和立体几何(一个是二维,一个是三维),而线性代数解决的其实是一个高维问题,由于无法直觉的感受到,所以很难。如果想比较通俗的理解一下数学为什么有这么多的分支及其内在关联,强烈推荐读一下《数学桥 对高等数学的一次观赏之旅》。
在数学中,用什么来表示图呢,答案就是线性代数里面的矩阵,想想看,图的关联矩阵,图的邻接矩阵。总之就是矩阵啦,线性代数一下子有用了。下面是一个具体的例子。
图的并行化处理
刚才说到图可以用矩阵来表示,图的并行化问题在某种程度上就被转化为矩阵运算的并行化问题。
那么以矩阵的乘法为例,看看其是否可以并行化处理。
以矩阵 A X B 为例,说明并行化处理过程。
将上述的矩阵A和B划分为四个部分,如下图所示
首次对齐之后
子矩阵相乘
相乘之后,A的子矩阵左移,B的子矩阵上移
计算结果合并
图的并行化处理框架,从Pregel说起
上一节的重点有两点
- 图用矩阵来表示,对图的运算就是矩阵的运算
- 矩阵乘法运算可以并行化,动态演示其并行化的原理
你说ok,我明白了。哪有没有一种合适的并行化处理框架可以用来进行图的计算呢,那你肯定想到了MapReduce。
MapReduce尽管也是一个不错的并行化处理框架,但在图计算方面,有许多缺点,主要是计算的中间过程需要存储到硬盘,效率很低。
Google针对图的并行处理,专门提出了一个了不起的框架Pregel。其执行时的动态视图如下所示。
Pregel有如下优点
- 级联可扩性好 scalability
- 容错性强
- 能够很好的表示各种图的常用算法
Pregel的计算模型
计算模型如下图所示,重要的有三个
- 作用于每个顶点的处理逻辑 vertexProgram
- 消息发送,用于相邻节点间的通讯 sendMessage
- 消息合并逻辑 messageCombining
Pregel在Spark中的实现
非常感谢你能坚持看到现在,这篇博客内容很多,有点难。我想还是上一幅图将其内在逻辑整一下再继续说下去。
该图要表示的意思是这样的,Graphx利用了Spark这样了一个并行处理框架来实现了图上的一些可并行化执行的算法。
本篇博客要表达的意思就是上面加红的这句话,请诸位看官仔细理解。
- 算法是否能够并行化与Spark本身无关
- 算法并行化与否的本身,需要通过数学来证明
- 已经证明的可并行化算法,利用Spark来实现会是一个错的选择,因为Graphx支持pregel的图计算模型
Graphx中的重要概念
Graph
毫无疑问,图本身是graphx中一个非常重要的概念。
成员变量
graph中重要的成员变量分别为
- vertices
- edges
- triplets
为什么要引入triplets呢,主要是和Pregel这个计算模型相关,在triplets中,同时记录着edge和vertex. 具体代码就不罗列了。
成员函数
函数分成几大类
- 对所有顶点或边的操作,但不改变图结构本身,如mapEdges, mapVertices
- 子图,类似于集合操作中的filter subGraph
- 图的分割,即paritition操作,这个对于Spark计算来说,很关键,正是因为有了不同的Partition,才有了并行处理的可能, 不同的PartitionStrategy,其收益不同。最容易想到的就是利用Hash来将整个图分成多个区域。
- outerJoinVertices 顶点的外连接操作
图的运算和操作 GraphOps
图的常用算法是集中抽象到GraphOps这个类中,在Graph里作了隐式转换,将Graph转换为GraphOps
implicit def graphToGraphOps[VD: ClassTag, ED: ClassTag] (g: Graph[VD, ED]): GraphOps[VD, ED] = g.ops
支持的操作如下
- collectNeighborIds
- collectNeighbors
- collectEdges
- joinVertices
- filter
- pickRandomVertex
- pregel
- pageRank
- staticPageRank
- connectedComponents
- triangleCount
- stronglyConnectedComponents
RDD
RDD是Spark体系的核心,那么Graphx中引入了哪些新的RDD呢,有俩,分别为
- VertexRDD
- EdgeRDD
较之EdgeRdd,VertexRDD更为重要,其上的操作也很多,主要集中于Vertex之上属性的合并,说到合并就不得不扯到关系代数和集合论,所以在VertexRdd中能看到许多类似于sql中的术语,如
- leftJoin
- innerJoin
至于leftJoin, innerJoin, outerJoin的区别,建议谷歌一下,不再赘述。
Graphx场景分析
图的存储和加载
在进行数学计算的时候,图用线性代数中的矩阵来表示,那么如何进行存储呢?
学数据结构的时候,老师肯定说过好多的办法,不再啰嗦了。
不过在大数据的环境下,如果图很巨大,表示顶点和边的数据不足以放在一个文件中怎么办? 用HDFS
加载的时候,一台机器的内存不足以容下怎么办? 延迟加载,在真正需要数据时,将数据分发到不同机器中,采用级联方式。
一般来说,我们会将所有与顶点相关的内容保存在一个文件中vertexFile,所有与边相关的信息保存在另一个文件中edgeFile。
生成某一个具体的图时,用edge就可以表示图中顶点的关联关系,同时图的结构也表示出来了。
GraphLoader
graphLoader是graphx中专门用于图的加载和生成,最重要的函数就是edgeListFile,定义如下。
def edgeListFile( sc: SparkContext, path: String, canonicalOrientation: Boolean = false, minEdgePartitions: Int = 1, edgeStorageLevel: StorageLevel = StorageLevel.MEMORY_ONLY, vertexStorageLevel: StorageLevel = StorageLevel.MEMORY_ONLY) : Graph[Int, Int] = { val startTime = System.currentTimeMillis // Parse the edge data table directly into edge partitions val lines = sc.textFile(path, minEdgePartitions).coalesce(minEdgePartitions) val edges = lines.mapPartitionsWithIndex { (pid, iter) => val builder = new EdgePartitionBuilder[Int, Int] iter.foreach { line => if (!line.isEmpty && line(0) != '#') { val lineArray = line.split("\\s+") if (lineArray.length < 2) { logWarning("Invalid line: " + line) } val srcId = lineArray(0).toLong val dstId = lineArray(1).toLong if (canonicalOrientation && srcId > dstId) { builder.add(dstId, srcId, 1) } else { builder.add(srcId, dstId, 1) } } } Iterator((pid, builder.toEdgePartition)) }.persist(edgeStorageLevel).setName("GraphLoader.edgeListFile - edges (%s)".format(path)) edges.count() logInfo("It took %d ms to load the edges".format(System.currentTimeMillis - startTime)) GraphImpl.fromEdgePartitions(edges, defaultVertexAttr = 1, edgeStorageLevel = edgeStorageLevel, vertexStorageLevel = vertexStorageLevel) } // end of edgeListFile
应用举例之PageRank
什么是PageRank
pageRank的核心思想
”在互联网上,如果一个网页被很多其它网页所链接,说明它受到普遍的承认和依赖,那么它的排名就很高。“ (摘自数学之美第10章)
你说这也太简单了吧,不是跟没说一个样吗,怎么用数学来表示呢?
呵呵,起初我也这么想的,后来多看了几遍之后,明白了一点点。分析步骤用文字表述如下,
- 网页和网页之间的关系用图来表示
- 网页A和网页B之间的连接关系表示任意一个用户从网页A到转到网页B的可能性(概率)
- 所有网页的排名用一维向量来B来表示
所有网页之间的连接用矩阵A来表示,所有网页排名用B来表示。
pageRank如何进行并行化
好了,上面的数学阐述说明了“网页排名的计算可以最终抽象为矩阵相乘”,而在开始的时候已经证明过矩阵相乘可以并行化处理。
理论研究结束了,接下来的就是工程实现了,借用Pregel模型,PageRank中定义的各主要函数分别如下。
vertexProgram
def vertexProgram(id: VertexId, attr: (Double, Double), msgSum: Double): (Double, Double) = { val (oldPR, lastDelta) = attr val newPR = oldPR + (1.0 - resetProb) * msgSum (newPR, newPR - oldPR) }
sendMessage
def sendMessage(edge: EdgeTriplet[(Double, Double), Double]) = { if (edge.srcAttr._2 > tol) { Iterator((edge.dstId, edge.srcAttr._2 * edge.attr)) } else { Iterator.empty } }
messageCombiner
def messageCombiner(a: Double, b: Double): Double = a + b
一点点启示
通过pageRank这个例子,我们能够搞清楚如何将平素学习的数学理论用以解决实际问题。
“学习的东西总是有价值的,至于用的上用不上,全靠造化了”
完整代码
// Connect to the Spark clusterval sc = new SparkContext("spark://master.amplab.org", "research")// Load my user data and parse into tuples of user id and attribute listval users = (sc.textFile("graphx/data/users.txt") .map(line => line.split(",")).map( parts => (parts.head.toLong, parts.tail) ))// Parse the edge data which is already in userId -> userId formatval followerGraph = GraphLoader.edgeListFile(sc, "graphx/data/followers.txt")// Attach the user attributesval graph = followerGraph.outerJoinVertices(users) { case (uid, deg, Some(attrList)) => attrList // Some users may not have attributes so we set them as empty case (uid, deg, None) => Array.empty[String]}// Restrict the graph to users with usernames and namesval subgraph = graph.subgraph(vpred = (vid, attr) => attr.size == 2)// Compute the PageRankval pagerankGraph = subgraph.pageRank(0.001)// Get the attributes of the top pagerank usersval userInfoWithPageRank = subgraph.outerJoinVertices(pagerankGraph.vertices) { case (uid, attrList, Some(pr)) => (pr, attrList.toList) case (uid, attrList, None) => (0.0, attrList.toList)}println(userInfoWithPageRank.vertices.top(5)(Ordering.by(_._2._1)).mkString("\n"))
小结
本篇讲来讲去就在强调一个问题,Spark是一个分布式并行计算框架。能不能用Spark,其实大体取决于问题的数学模型本身,如果可以并行化处理,则用之,切不可削足适履。
另一个用张图来总结一下提到的数学知识吧。
再一次强烈推荐《数学桥》