MapReduce经典4幅图(数据流向图、word count过程图、shuffle工作流程图(map端、reduce端)、MapReduce原理图)

553 阅读10分钟

MapReduce经典4幅示意图

第一幅示意图:数据流向图

数据流首先进行了分片(与HDFS的分块大小一致),然后每个分片会分配给一个map进行处理,之后针对reduce的数量产生对应的输出分片,这里原先的分片顺序会打乱,类似于洗牌,之后分别交给reduce处理后输出结果。

第二幅示意图:word count过程图

  1. 将input的文件拆分成splits,由于测试用的文件较小,所以每个文件作为一个split,并将文件按行分割。这一步由mapreduce框架自动完成。
  2. 将分割好的文件交给用户定义的map方法进行处理,生成<key,value>对
  3. 得到map方法输出的<key,value>对后,shuffle过程,会把相同key值相同的放到一起
  4. Reduce过程,把key值相同value值累加,得到新的<key,value>对,并作为word count的输出结果

第三幅示意图:shuffle工作流程图

Shuffle描述着数据从map task输出到reduce task输入的这段过程。

Map函数开始产生输出时,并不是简单地将数据写到本地磁盘,这个过程很复杂,他利用缓冲的方式写到内存,并出于效率的考虑进行与排序与其说shuffle为mapreduce之间的独立处理函数,不如说是map中数据输入reduce一通道,因为shuffle一部分属于对map task,另一部分为reduce task。名词翻译:input split      输入流切分 buffer  in memory    缓冲区存储器partition    划分,分区 sort     排序 spill  to  disk     溢出到磁盘fetch     取出                                                              merge   合并                                    merge on disk   合并到磁盘上map  task    每个 map 任务都有一个环形内存缓冲区,用于存储任务的输出,一旦缓冲内容达到域值(默认值缓冲区大小的80%即80MB),一个后台线程便开始把内容写到(spill)本地磁盘中。在写磁盘过程中, map 输出继续被写到缓冲区,但如果在此期间缓冲区被填满,map 会阻塞直到写磁盘过程完成。在写磁盘之前, 线程首先根据数据最终要传送到的 reducer 把数据划分成相应的分区(partition)。在每个分区中,后台线程按键进行内排序,如果有一个 combiner,它会在排序后的输出上运行。   一旦内存缓冲区达到溢出域值,就会新建一个溢出写文件,因此在 map 任务写完其最后一个输出记录之后,会有几个溢出写文件。在任务完成之前,溢出写文件被合并成一个已分区且已排序的输出文件。    如果已经指定 combiner,并且溢出写次数至少为 3时,则 combiner 就会在输出文件写到磁盘之前运行。运行 combiner 的意义在于使 map 输出更紧凑,使得写到本地磁盘和传给 reducer 的数据更少。    写磁盘时压缩 map 输出往往常用方法,因为这样会让写磁盘的速度更快,节约磁盘空间,并且减少侍给 reducer 的数据量。默认情况下,输出是不压缩的,但只要将mapred.compress.map.output 设置为 true,就可以轻松启用此功能。使用的压缩库由 mapred。map. output. compression. codec 指定.     通过 HTTP 方式得到输出文件的分区。用于文件分区的工作线程的数量由任务的 tracker. http。 threads 属性控制,此设置针对每个tasktracker,而不是针对每个 map 任务槽。默认值是 40,在运行大型作业的大型集群上,此值可以根据需要而增加。reduce  task由上图知,map task合并的磁盘分区分别被多个reduce处理,而一个reduce处理多个map产生的数据,即是多对多关系reduce合并来自多个map的磁盘中数据

   在Hadoop这样的集群环境中,大部分map task与reduce task的执行是在不同的节点上。当然很多情况下Reduce执行时需要跨节点去拉取其它节点上的map task结果。如果集群正在运行的job有很多,那么task的正常执行对集群内部的网络资源消耗会很严重。这种网络消耗是正常的,我们不能限制,能做的就是最大化地减少不必要的消耗。还有在节点内,相比于内存,磁盘IO对job完成时间的影响也是可观的。从最基本的要求来说,我们对Shuffle过程的期望可以有:

A)完整地从map task端拉取数据到reduce 端。

B)在跨节点拉取数据时,尽可能地减少对带宽的不必要消耗。

C)减少磁盘IO对task执行的影响。

如果是自己来设计这段Shuffle过程,那么你要清楚你的设计目标是什么。我想能优化的地方主要在于减少拉取数据的量及尽量使用内存而不是磁盘。

以WordCount为例,假设它有8个map task和3个reduce task。从上图看出,Shuffle过程横跨map与reduce两端,所以下面我也会分两部分来展开。

先看看map端的情况,如下图:

Map端shuffle过程:

上图可能是某个map task的运行情况。拿它与官方图的左半边比较,会发现很多不一致。官方图没有清楚地说明partition, sort与combiner到底作用在哪个阶段。这张图,让大家清晰地了解从map数据输入到map端所有数据准备好的全过程。

   整个流程分了四步。简单些可以这样说,每个map task都有一个内存缓冲区,存储着map的输出结果,当缓冲区快满的时候需要将缓冲区的数据以一个临时文件的方式存放到磁盘,当整个map task结束后再对磁盘中这个map task产生的所有临时文件做合并,生成最终的正式输出文件,然后等待reduce task来拉数据。

当然这里的每一步都可能包含着多个步骤与细节,下面对细节来一一说明:

1)、在map task执行时,它的输入数据来源于HDFS的block,当然在MapReduce概念中,map task只读取split。Split与block的对应关系可能是多对一,默认是一对一。在WordCount例子里,假设map的输入数据都是像“aaa”这样的字符串。

2)、在经过mapper的运行后,我们得知mapper的输出是这样一个key/value对: key是“aaa”, value是数值1。因为当前map端只做加1的操作,在reduce task里才去合并结果集。前面我们知道这个job有3个reduce task,到底当前的“aaa”应该交由哪个reduce去做呢,是需要现在决定的。

   MapReduce提供Partitioner接口,它的作用就是根据key或value及reduce的数量来决定当前的这对输出数据最终应该交由哪个reduce task处理。默认对key hash后再以reduce task数量取模。默认的取模方式只是为了平均reduce的处理能力,如果用户自己对Partitioner有需求,可以订制并设置到job上。

   在我们的例子中,“aaa”经过Partitioner后返回0,也就是这对值应当交由第一个reducer来处理。接下来,需要将数据写入内存缓冲区中,缓冲区的作用是批量收集map结果,减少磁盘IO的影响。我们的key/value对以及Partition的结果都会被写入缓冲区。当然写入之前,key与value值都会被序列化成字节数组。

    整个内存缓冲区就是一个字节数组

3)、这个内存缓冲区是有大小限制的,默认是100MB。当map task的输出结果很多时,就可能会撑爆内存,所以需要在一定条件下将缓冲区中的数据临时写入磁盘,然后重新利用这块缓冲区。这个从内存往磁盘写数据的过程被称为Spill,中文可译为溢写,字面意思很直观。这个溢写是由单独线程来完成,不影响往缓冲区写map结果的线程。溢写线程启动时不应该阻止map的结果输出,所以整个缓冲区有个溢写的比例spill.percent。这个比例默认是0.8,也就是当缓冲区的数据已经达到阈值(buffer size * spill percent = 100MB * 0.8 = 80MB),溢写线程启动,锁定这80MB的内存,执行溢写过程。Map task的输出结果还可以往剩下的20MB内存中写,互不影响。

   当溢写线程启动后,需要对这80MB空间内的key做排序(Sort)。排序是MapReduce模型默认的行为,这里的排序也是对序列化的字节做的排序。

   在这里我们可以想想,因为map task的输出是需要发送到不同的reduce端去,而内存缓冲区没有对将发送到相同reduce端的数据做合并,那么这种合并应该是体现是磁盘文件中的。从官方图上也可以看到写到磁盘中的溢写文件是对不同的reduce端的数值做过合并。所以溢写过程一个很重要的细节在于,如果有很多个key/value对需要发送到某个reduce端去,那么需要将这些key/value值拼接到一块,减少与partition相关的索引记录。

   在针对每个reduce端而合并数据时,有些数据可能像这样:“aaa”/1, “aaa”/1。对于WordCount例子,就是简单地统计单词出现的次数,如果在同一个map task的结果中有很多个像“aaa”一样出现多次的key,我们就应该把它们的值合并到一块,这个过程叫reduce也叫combine。但MapReduce的术语中,reduce只指reduce端执行从多个map task取数据做计算的过程。除reduce外,非正式地合并数据只能算做combine了。其实大家知道的,MapReduce中将Combiner等同于Reducer。

   如果client设置过Combiner,那么现在就是使用Combiner的时候了。将有相同key的key/value对的value加起来,减少溢写到磁盘的数据量。Combiner会优化MapReduce的中间结果,所以它在整个模型中会多次使用。那哪些场景才能使用Combiner呢?从这里分析,Combiner的输出是Reducer的输入,Combiner绝不能改变最终的计算结果。所以从我的想法来看,Combiner只应该用于那种Reduce的输入key/value与输出key/value类型完全一致,且不影响最终结果的场景。比如累加,最大值等。Combiner的使用一定得慎重,如果用好,它对job执行效率有帮助,反之会影响reduce的最终结果。

4)、每次溢写会在磁盘上生成一个溢写文件,如果map的输出结果真的很大,有多次这样的溢写发生,磁盘上相应的就会有多个溢写文件存在。当map task真正完成时,内存缓冲区中的数据也全部溢写到磁盘中形成一个溢写文件。最终磁盘中会至少有一个这样的溢写文件存在(如果map的输出结果很少,当map执行完成时,只会产生一个溢写文件),因为最终的文件只有一个,所以需要将这些溢写文件归并到一起,这个过程就叫做Merge。Merge是怎样的?如前面的例子,“aaa”从某个map task读取过来时值是5,从另外一个map 读取时值是8,因为它们有相同的key,所以得merge成group。什么是group。对于“aaa”就是像这样的:{“aaa”, [5, 8, 2, …]},数组中的值就是从不同溢写文件中读取出来的,然后再把这些值加起来。请注意,因为merge是将多个溢写文件合并到一个文件,所以可能也有相同的key存在,在这个过程中如果client设置过Combiner,也会使用Combiner来合并相同的key。

  至此,map端的所有工作都已结束,最终生成的这个文件也存放在TaskTracker够得着的某个本地目录内。每个reduce task不断地通过RPC(RPC(Remote Procedure Call Protocol)——远程过程调用协议)从JobTracker那里获取map task是否完成的信息,如果reduce task得到通知,获知某台TaskTracker上的map task执行完成,Shuffle的后半段过程开始启动。

简单地说,reduce task在执行之前的工作就是不断地拉取当前job里每个map task的最终结果,然后对从不同地方拉取过来的数据不断地做merge,也最终形成一个文件作为reduce task的输入文件。见下图:

Reduce端shuffle过程:

如map 端的细节图,Shuffle在reduce端的过程也能用图上标明的三点来概括。当前reduce copy数据的前提是它要从JobTracker获得有哪些map task已执行结束,这段过程不细说了,有兴趣的朋友可以自己看一下。Reducer真正运行之前,所有的时间都是在拉取数据,做merge,且不断重复地在做。如前面的方式一样,下面分段地描述reduce 端的Shuffle细节:

1)、Copy过程,简单地拉取数据。Reduce进程启动一些数据copy线程(Fetcher),通过HTTP方式请求map task所在的TaskTracker获取map task的输出文件。因为map task早已结束,这些文件就归TaskTracker管理在本地磁盘中。

2)、Merge阶段。这里的merge如map端的merge动作,只是数组中存放的是不同map端copy来的数值。Copy过来的数据会先放入内存缓冲区中,这里的缓冲区大小要比map端的更为灵活,它基于JVM的heap size设置,因为Shuffle阶段Reducer不运行,所以应该把绝大部分的内存都给Shuffle用。这里需要强调的是,merge有三种形式:

(1)内存到内存 

(2)内存到磁盘 

(3)磁盘到磁盘。

(内存很小,但很重要,它是一个暂存区,数据只能在那里来匆匆去也匆匆,电脑关机后,绝对不能有数据停留在那里~! 而硬盘呢,是可以永久把数据写在上面的一个很大的区域。 CPU总是先打开硬盘上的文件,然后把它映射到内存区里,方便随时调取,程序被关闭时,如果没有发生死锁的情况,它也会响应的把程序存放的数据给注销掉~,至于储存,那就是程序自己的事了,如果他没有设计那个储存程序,就没法储存在硬盘上。

总结:

  1. 内存因为比较小,所以总线的带宽,可以和CPU接近,这样一来读取的速度会比较快,一个程序常用的数据经常会被存放到内存中,方便程序快速读取。

  2. 硬盘因为比较大,所以读盘的速度相对也会很慢,但我们想永久储存更多的文件就需要更多更大的空间,用空间换时间 。说白了硬盘是存数据的地方,内存是存正在处理的东西的地方。)

默认情况下第一种形式不启用,让人比较困惑,是吧。当内存中的数据量到达一定阈值,就启动内存到磁盘的merge。与map 端类似,这也是溢写的过程,这个过程中如果你设置有Combiner,也是会启用的,然后在磁盘中生成了众多的溢写文件。第二种merge方式一直在运行,直到没有map端的数据时才结束,然后启动第三种磁盘到磁盘的merge方式生成最终的那个文件。

3)、Reducer的输入文件。不断地merge后,最后会生成一个“最终文件”。为什么加引号?因为这个文件可能存在于磁盘上,也可能存在于内存中。对我们来说,当然希望它存放于内存中,直接作为Reducer的输入,但默认情况下,这个文件是存放于磁盘中的。至于怎样才能让这个文件出现在内存中,之后的性能优化篇我再说。当Reducer的输入文件已定,整个Shuffle才最终结束。然后就是Reducer执行,把结果放到HDFS上。

第四幅示意图 :MapReduce原理图

从图中不难看出,整个MapReduce分为以下流程:代码编写->作业配置->作业提交->Map任务的分配和执行->处理中间结果->Reduce任务的分配和执行->作业完成

图中:

1.运行作业

2.获取作业ID

3.复制作业资源

4.提交作业

5.初始化作业

6.获取输入分割

7.心跳通信

8.获取作业资源

9.发布

10.运行

  以上过程主要涉及到的实体有客户端(用于MR代码的编写,配置作业,提交作业);TaskTracker(保持与JobTracker通信,在分配的数据片段上执行Map或Reduce任务);HDFS(保存作业的数据、配置信息、作业结果等);JobTracker(初始化作业,分配作业,与TaskTracker通信,协调整个作业的执行)

提交作业

在提交作业前,我们需要对作业进行配置,主要包括:

(1)程序代码

(2)Map和Reduce接口

(3)输入输出路径

(4)其他配置,如InputFormat、OutputFormat等

提交作业的过程可以分为以下几步:

  (1)调用JobTracker对象的getNewJobId()方法从JobTracker处获取当前作业的ID(见图中步骤2)

  (2)检查作业相关路径,在运行代码时,经常遇到报错提示输出目录已存在,所以在运行代码前要确保输出目录不存在

  (3)计算作业的输入划分

  (4)将运行所需资源(如jar文件、配置文件、计算所得输入划分等)复制到作业对于的HDFS上(见步骤3)

  (5)调用JobTracker对象的submitJob()方法来真正提交作业,通知JobTracker作业准备执行(见步骤4)

初始化作业

  JobTracker在客户端调用其submitJob()方法后,会将此调用放入内部的TaskScheduler变量中,进行调度,默认调度方法为:JobQueueTaskScheduler即FIFO调度方式。

  初始化作业分为如下几个步骤:

  (1)从HDFS中读取作业对应的job.split(见步骤6),JobTracker从HDFS中作业对应的路径获取JobClient在步骤3中写入的job.split文件,得到输入数据的划分信息,为后面初始化过程中Map任务的分配做好准备。

  (2)创建并初始化Map任务和Reduce任务。

  (3)创建两个初始化Task,根据个数和输入划分已经配置的信息,并分别初始化Map和Reduce。

分配任务:

  TaskTracker和JobTracker之间的通信和任务分配都是通过心跳机制完成的。TaskTracker会以一定间隔时间向JobTracker发送心跳,告诉自己是否存活,准备执行新任务;而JobTracker在接收到心跳信息后会查看是否有待分配任务,如果有,则会分配给TaskTracker。

执行任务:

  当TaskTracker接收到新任务时就要开始运行任务,第一步就是将任务本地化,将任务所需的数据、配置信息、程序代码从HDFS复制到TaskTracker本地(将步骤8)。该过程主要通过localizeJob()方法来实现任务的本地化,具体包括以下几个步骤:

  (1)将job.split复制到本地

  (2)将job.jar复制到本地

  (3)将job的配置信息写入job.xml

  (4)创建本地任务目录,解压job.jar

  (5)调用launchTaskForJob()方法发布任务(见步骤9)

更新任务执行进度和状态:

  由MapReduce作业分割成的每个任务中都有一组计数器,他们对任务执行过程中的进度组成事件进行计数。如果任务要报告进度,它便会设置一个标志以表明状态变化将会发送到TaskTracker上,另一个监听线程检查到这标志后,会告知TaskTracker当前的任务状态。

完成作业:

  所有TaskTracker任务的执行进度信息都汇总到JobTracker处,当JobTracker接收到最后一个任务的已完成通知后,便把作业的状态设置为“成功”。