6.824:Spark

Spark是MapReduce的后继产物,它目前被广泛地运用在数据中心的大数据计算中。Spark相比MapReduce只是简单的分两个阶段执行,它首先会根据整个程序的执行逻辑生成一个多步骤的数据流图,后续再对整个执行流程进行优化,并优化MapReduce中频繁的IO读写的问题,同时提供了一种新的容错机制。

Lineage graph

val lines = spark.read.textFile("in").rdd
val links1 = lines.map{ s =>
    val parts = s.split("\\s+")
        (parts(0), parts(1))
}
val links2 = links1.distinct()
val links3 = links2.groupByKey()
val links4 = links3.cache()
var ranks = links.mapValues(v => 1.0)

for (i <- 1 to 10) {
  val jj = links4.join(ranks)
  val contribs = jj.values.flatMap{
    case (urls, rank) =>
        urls.map(url => (url, rank / urls.size))
  }
  ranks = contribs.reduceByKey(_+_).mapValues(0.15 + 0.85 * _)
}

val output = ranks.collect()
output.foreach(tup => println(s"${tup._1} has rank:    ${tup._2} ."))

PageRank是谷歌用来计算不同网页搜索结果重要性的一个算法。像这个算法就是一个比较典型的例子,不适合用MapReduce来执行该算法。

PageRank中涉及了遍历,同时还有循环,这个循环需要运行很多次来进行迭代计算分数。

首先,我们需要知道一点就是在Spark中,程序在没有执行到collect()函数前,那些代码都不会真正执行,driver只会将那些代码生成Lineage graph。

PageRank中,首先会读取输入文件,这些输入文件中包含了大量的网页链接,每一行包含2个url,以下所示,每一行的数据的含义举例来说u1 u3,u1这个链接的网页包含u3这个链接,可以跳转过去。

val lines = spark.read.textFile("in").rdd
in:
u1 u3
u1 u1
u2 u3
u2 u2
u3 u1

Spark是针对haddoop设计的,因此这些输入数据是已经被分好区放在了HDFS中,Spark会试着以某种方式将这个读取任务分配给对应的worker来做,Spark可以根据这行代码的运行逻辑进行优化,让存储了对应数据分区的机器来执行对应分区的计算,这样就可以直接本地读取数据,减少网络交互。

读取完文件中的数据后,就是进行map操作,第二行代码如下所示,会将读取进来的每一行的数据变为一个个元组。这里的操作仅需在把本机器读取到的数据变为元组即可,因此本地处理即可,没有涉及任何网络通信,这个速度将会十分快速。同时,这里还可以进行优化,可以让机器read和map操作同时进行,这里的map操作无需等到读完全部数据后再进行。

val links1 = lines.map{ s =>
    val parts = s.split("\\s+")
        (parts(0), parts(1))
}

parts的值:
(u1,u3)
(u1,u1)
(u2,u3)
(u2,u2)
(u3,u1)

根据以上两条代码,我们可以得到上图的这个类似计算流程图的东西,方块之间用箭头连接表示步骤之间的连接,这些流程也被称为transformation。图中用红色虚线围起来的一列表示一个worker的工作流程。将HDFS上存储对应分区的机器指定为worker,让该worker本地读取文件,并本地进行map操作。这些工作没有涉及到网络通信,都是本地工作即可,paper中就称这一阶段为narrow dependencies(窄依赖),也就是这个处理流程中的数据record和其他record是彼此独立没有关系的,图中每个竖着的chain执行流程之间不会有彼此依赖的问题。

我们需要注意一点,在这里Spark相比MapReduce不同的一点就是,Map动作后,Spark并不会让worker将结果写回HDFS中,而是会将worker将执行结果保存在本地内存中,后续Reduce阶段也将会直接去指定机器的内存中读取数据,这可以有效避免频繁写入HDFS产生大量的文件。

上面讲的操作都是Narrow transformation的,下面讲后续的非Narrow的操作。

我们可以看到后续就是distinct()函数,这是去重操作。我们需要在数TB大小的数据集中找到重复项,并且在输入数据中哪些数据都是随机顺序排列的,distinct需要将那些重复的元素替换为一个,并且需要通过某种方式把那些一样的元素放到一起后才能进行去重,而这个过程必然是需要进行网络通信的。

这些数据分散在众多worker上,后续就是需要对数据进行shuffle处理,将一样的元素放到同一个worker上,随后那个worker即可对这些重复项进行处理。这个shuffle过程,其实就是使用hash的方式,就好比之前单词计数那个lab中,对单词的哈希值对worker的数量进行求余,从而将这个单词分配给指定worker来进行reduce处理。

这涉及到非本地的transformation操作,paper中就称这是wide transformation,这很像是MapReduce中的Reduce部分。

已知distinct操作会在多个worker上执行,distinct是针对单个key来进行单独处理的,因此将会根据key来对计算任务进行划分。Spark中就会让那些执行Map的worker再对本地的内存中的元组中的key值来进行hash,哈希值再根据worker数量来进行求余,并据此来选择哪个worker来处理这部分数据。因此,Narrow stage最后发生的事情就是将输出结果进行哈希就求余再拆分到不同的bucket中,并交由不同的worker来进行下一步的transformation。注意:这里并不是和MapReduce一样,将结果写到HDFS中,而是写到本地内存中,这可以避免了数TB数据IO的开销。

val links2 = links1.distinct()
val links3 = links2.groupByKey()
links3的值:
((u2, (u2,u3)), (u3, (u1)) , (u1, (u1,u3)))

可见执行这些worker执行distinct操作时,就会去其他worker的内存中指定的bucket中取数据到本地,这样之后那些相同的key的元组都会聚集到一个worker上,随后即可去重。

wide transformation使用的成本都十分高昂,这里面涉及了大量的网络通信,并且这一步需要等待上一阶段的narrow stage中的所有处理完成才行。已知Spark在执行程序前,会先构建好这个程序的Lineage graph,了解这个程序的完整执行逻辑流程,随后便可优化执行流程,减少wide transformation的开销,像这个程序中,我们可以看到distinct函数后续的操作是groupByKey函数,这个分组函数的操作也是根据key来进行分组的,这个过程其实和去重操作一模一样,因此,Spark将groupByKey这个操作放在去重操作后面,随后groupByKey这个wide transformation即可基于去重的结果直接分组即可,无需再进行网络通信移动数据,因为去重操作已经将相同key值的数据都移动到了一个worker上。

我们需要明白一点就是这种优化往往需要了解程序的完整运行逻辑后才能找到的,因此Spark执行程序会先构筑Lineage graph,随后根据这个lineage graph优化程序后,再开始执行程序,分配任务给worker。

由于Spark执行程序各个阶段的结果都是保存在内存中,前面步骤的执行结果会随着程序后续步骤的执行而被丢弃,如果这个步骤的结果后面需要重复使用则需要进行内存持久化,而links3这个数据在下面的for循环中会被反复使用,避免links3这个数据在内存中丢失导致需要重新读取数TB的数据进行distinct、groupByKey等步骤来重新生成links3,这里调用了cache()函数来让这个数据在内存中持久化(注意不是在持久化到HDFS中)因此程序员在写Spark的程序的时候,也需要注意这一点,需要显式地持久化数据,可见程序员也可以对Spark对程序执行流程的优化进行干涉,提供进一步的优化,例如Spark在shuffle中提供的hash函数不够好,程序员可以进行干涉提供更好的hash函数

mapValues(v => 1.0)这条代码其实就是给每个page链接的分数进行初始化,初始分数为1。后续将在for循环中不断迭代,不同page链接的分数将不断迭代更新向一个具体值收敛。

val links4 = links3.cache()
var ranks = links.mapValues(v => 1.0)

ranks的值:
(u2,1.0), (u3,1.0), (u1,1.0)

后续就是循环体,循环体中首先就是join操作,join操作同样是一个wide transformation,需要将对应的links中的元素和ranks放在一起,两者都是需要将URL作为key来对数据进行shuffle操作,但是Spark了解整个程序的执行流程,因此知道前面的操作已经完成了以URL作为key进行shuffle的操作,因此无需再进行shuffle操作移动任何数据。

contribs的值就是根据rank/urls.size来计算出各个url的PageRank contribution值

后续我们要将每个page在contribs中该page相关的pagerank contribution值相加,因此这里需要以url为key再进行一次shuffle,将同个url的pagerank contribution交由同一个worker来进行相加,再通过mapValues计算出这个url的新的PageRank。

for (i <- 1 to 10) {
  val jj = links4.join(ranks)
  val contribs = jj.values.flatMap{
    case (urls, rank) =>
        urls.map(url => (url, rank / urls.size))
  }
  ranks = contribs.reduceByKey(_+_).mapValues(0.15 + 0.85 * _)
}
第一次循环中
jj的值:
(u2, (u2,u3), 1.0), (u3, (u1), 1.0), (u1, (u1,u3), 1.0)
contribs的值:
(u2,0.5), (u3,0.5), (u1,1.0), (u1,0.5), (u3,0.5)
ranks的值:
(u2,0.575), (u3,1.0), (u1,1.424999999999999998)

最后是collect函数以及输出结果的函数。这次还是再次强调以下,直到最后的collect操作之前,这段代码所做的就只是生成lineage graph,并不会对数据进行处理。

val output = ranks.collect()
output.foreach(tup => println(s"${tup._1} has rank:    ${tup._2} ."))

如图就是paper中展示的PageRank算法的Lineage graph。我们可以看到lineage graph针对循环并不会生成一个环形图,而是随着循环的执行,不断往这个图中添加一个又一个节点。

当生成了完整的Lineage graph,Spark即可进行优化,优化结束后,Spark中的driver会将编译优化后各个步骤的机器码发送给各个worker,各个worker直接执行收到的机器码。

从这个程序可以看出来Spark和MapReduce的差别,Spark会生成Lineage Graph并对程序进行优化处理,同时各个步骤之间的数据的流通不是先写到HDFS中,在从HDFS中读出来。Spark是直接保存在内存中,并且一个worker会持久化那些会重复使用的数据在内存中,当这个worker后续执行任务时,直接读取本地内存中的数据即可。

fault tolerance

Spark的容错能力我们没必要像数据库那样严格要求。我们对数据库要求是不能有任何数据的丢失和错误。而Spark即使出错了,也完全可以重复执行计算并得到完全相同的结果,但是如果是从头开始重新计算,那么这个成本就会变得十分高昂。因此,Spark的容错机制的设计侧重于让重复计算的成本降低。

首先,Spark不会对driver机器进行复制。虽说driver机器崩溃了,lineage graph也没了,那么就需要重新执行整个流程了,但是每隔几个月才可能有一台机器发生问题,并且driver机器仅只有一台,需要发生问题的那台恰巧是众多机器中的那一台driver,这个概率是很小很小的。同时,输入数据保存在HDFS中,有多个副本确保安全,不存在driver崩溃后无法再找到输入数据重新执行该任务的问题。

因此,容错机制针对的对象应该是占据服务器数量多数的worker机器,Spark都是将任务切分成多个步骤划分给多个worker执行,如果一个worker崩溃了,那么后续就可以将这个崩溃的worker的任务重新划分给存活的worker,让它去并行执行多个任务。但是如果这个任务的lineage graph很长的话,那么任务的重新执行还是会浪费较多的时间。

我们需要特别考虑一个情况,这个情况中程序的lineage graph如下图所示,开始是窄依赖的任务,后续便是执行宽依赖的任务,最后又是窄依赖的任务。当Spark执行这些操作的时候,它执行每个transformation操作后,然后会把输出结果再传给下一个transformation操作,后续这个输出结果不会被保存下来,除非程序员显式调用了cache函数来告诉Spark这个数据需要在内存中持久化,一般来说这些输出结果并不会保存。随着Spark一步步执行这些transformation操作,早期的结果被丢弃,当一个worker在执行宽依赖的transformation时发生了故障,后续这个宽依赖的transformation需要重新执行,但是这个宽依赖的transformation依赖于前面的各个worker上的窄依赖的transformation的输出结果,然而那些结果早就被丢弃,此时就需要每个worker都重新执行narrow stage的任务来生成结果,提供结果作为这个wide dependencies的transformation的输入数据。

这导致我们需要让每一个worker都从头重新执行这些操作,这样的开销过于巨大,我们无法接受。我们希望一个worker崩溃后,我们只需重新执行少量的工作即可恢复过来。因此,Spark允许我们定期创建一个checkpoint来调用persist来将程序目前的执行结果保存到HDFS上,即使worker崩溃了,也可以从HDFS中读取到最新的checkpoint的状态,从那儿开始重新执行。

就以我们上面的PageRank程序来讲,我们可以每10次遍历,保存一下。由于这些程序执行的数据量都是TB级别的,因此每次保存都需要花费不少时间,因此需要限制保存频率来限制成本。

Spark能够采取这个checkpoint机制来实现故障恢复,这完全是因为Spark中,每个任务的执行逻辑和输入的数据都是已经被提前确定的,这个任务的输出结果也是确定的,因此当一个worker崩溃时,仅需让这个worker重新读取那些输入数据并执行被要求的任务即可恢复,而不是让所有worker从头开始执行整个计算过程。

但是传统的大数据执行模型中,每个集群执行的逻辑,处理的输入数据都是不确定的,整个计算过程具有不确定性,支持不确定的执行结果,这也使得没有一个很好的故障恢复策略。

总结

Spark就是升级版的MapReduce,它会根据程序的Lineage graph先对程序进行优化处理,同时Lineage graph每个步骤不再将执行结果写回到HDFS中,而是将结果放在内存中供下一步骤来读取,避免了海量数据IO带来的开销。此外,Spark中的这种RDD可以持久化保存在内存中,这实现了数据的高效复用,避免了重新用到数据时而需要重新计算。以上这些点,基本都是MapReduce中没法做到,并且也因此会带来巨大的额外开销的点。

Spark并不适合所有的处理方式,它只适合在离线的情况下,对海量的数据进行批处理。Spark并不适用于处理那些要求低延迟的简单事务,例如:添加商品到购物车的事务。Spark适合用来离线分析消费者的购买习惯。

paper中提及到Spark并不善于对数据流处理,因为Spark要求所有的输入数据都是可用的。但是,很多情况都是需要实时的数据流分析,例如分析所有用户在网站上的点击行为,理解用户的行为。因此,Spark也有一个变种,叫做Spark Streaming,它适合处理数据流,它会将到达的数据流拆分成一批批的数据,并对这一批批的数据进行处理。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值