MapReduce on Yarn运行原理

一、概念综述

  MapReduce是一种可用于数据处理的编程模型(或计算模型),该模型可以比较简单,但想写出有用的程序却不太容易。MapReduce能将大型数据处理任务分解成很多单个的、可以在服务器集群中并行执行的任务,而这些任务的计算结果可以合并在一起计算最终的结果。最重要的是,MapReduce的优势在于易于编程且能在大型集群(上千节点)并行处理大规模数据集,以可靠,容错的方式部署在商用机器上。

  从MapReduce的所有长处来看,它基本上是一个批处理系统,并不适合交互式分析。不可能执行一条查询并在几秒内或更短的时间内得到结果。典型情况下,执行查询需要几分钟或更多时间。因此,MapReduce更适合那种没有用户在现场等待查询结果的离线使用场景。

  在MapReduce整个过程可以概括为以下过程:

  input split --> map --> shuffle --> reduce --> output

  下图是《Hadoop权威指南》给出的MapReduce运行过程:

             MapReduce运行过程图   

  MapReduce作业是客户端需要执行的一个工作单元:它包括输入数据、MapReduce程序和配置信息。Hadoop将作业分成若干个任务(task)来执行,其中包括两类任务:map任务和reduce任务。这些任务运行在集群的节点上,并通过YARN进行调度。如果一个任务失败,它将在另一个不同的节点上自动重新调度运行。

Input Split:

  Hadoop将MapReduce的输入数据划分成等长的小数据块,称为输入分片(input split)或简称“分片”。Hadoop为每一个分片构建一个map任务,并由该任务来运行用户自定义的map函数从而处理分片中的每条记录。

  拥有许多分片,意味着处理每个分片所需要的时间少于处理整个输入数据所花的时间。因此,如果我们并行处理每个分片,且每个分片数据比较小,那么整个处理过程将获得更好的负载平衡,因为一台较快的计算机能够处理的数据分片比一台较慢的计算机更多,且成一定的比例。即使使用相同的机器,失败的进程或其他并发运行的作业能够实现满意的负载平衡,并且随着分片被切分得更细,负载平衡的质量会更高。另一方面,如果分片切分得太小,那么管理分片得总时间和构建map任务得总时间将决定作业的整个执行时间。对于大多数作业来说,一个合理的分片大小趋向于HDFS的一个块的大小,这样可以确保存储在单个节点上的最大输入块的大小。数据块默认是128MB,不过可以针对集群调整这个默认值,或在每个文件创建时指定。

Map:

  map任务会将集合中的元素从一种形式转化成另一种形式,在这种情况下,输入的键值对会被转换成零到多个键值对输出。其中输入和输出的键必须完全不同,输入和输出的值则可能完全不同。

  Hadoop在存储有输入数据(HDFS中的数据)的节点上运行map任务,可以获得最佳性能,因为它无需使用宝贵的集群带宽资源。这就是所谓的“数据本地化优化”。但是,有时对于一个map任务的输入分片来说,存储该分片的HDFS数据块复本的所有节点可能正在运行其他map任务,此时作业调度需要从某一数据块所在的机架中的一个节点上寻找一个空闲的map槽(slot)来运行该map任务分片。仅仅在非常偶然的情况下(该情况基本上不会发生),会使用其他机架中的节点运行该map任务,这将导致机架与机架之间的网络传输。下图显示了这三种可能性。

map任务的网络传输的三种可能性图

  map任务的输出被称为中间键和中间值,会被发送到reducer做后续处理。但输出结果只写入本地硬盘,而非HDFS。这是为什么?因为map的输出是中间结果:该中间结果由reduce任务处理后才产生最终输出结果,而且一旦作业完成,map的输出结果就可以删除。因此,如果把它存储在HDFS中并实现备份,难免有些小题大做。如果运行map任务的节点在将map中间结果传送给reduce任务之前失败,Hadoop将在另一个节点上重新运行这个map任务以再次构建map中间结果。

Shuffle和排序:

  shuffle和排序在MapReduce流程图中的执行过程

  MapReduce确保每个reducer的输入都是按键排序的。系统执行排序、将map输出作为输入传给reduce的过程称为shuffle。在此,我们将学习shuffle是如何工作的,因为它有助于我们理解工作机制(如果需要优化MapReduce程序)。shuffle属于不断被优化和改进的代码库的一部分,因此下面的描述有必要隐藏一些细节。从许多方面来看,shuffle是MapReduce的“心脏”,是奇迹发生的地方。

Map端shuffle过程:

map端shuffle过程

     1. 读取HDFS上的输入分片input split,每一行解析成一个<key, value>。每一个键值对调用一次map函数。输入<0,helloyou>,<10,hello me>。

  2. 覆盖map(),接收1中产生的<key, value>,然后进行处理,转换为新的<key, value>输出。每个map任务都有一个环形内存缓存区,输出结果会暂且放在环形内存缓冲区中(该缓冲区的大小默认为100MB,由mapreduce.task.io.sort.mb属性控制),当该缓冲区快要溢出时(默认为缓冲区大小的80%,由mapreduce.map.sort.spill.percent属性控制),会由单独线程本地文件系统中创建一个临时溢出文件(spill file),将该缓冲区中的数据写入这个文件,但如果再此期间缓冲区被填满,map会被堵塞直到写入过程完成。溢出写过程按轮询方式将缓冲区中的内容写到mapreduce.cluster.local.dir属性在作业特定子目录下指定的目录中。输出:<hello, 1>,<you, 1>,<hello, 1>,<me, 1>。

  注:当缓冲区的数据值达到阈值(buffer size * spill percent = 100MB * 0.8 = 80MB),溢出写线程启动,锁定这80MB的内存,执行溢出写过程。map任务的输出结果还可以往剩下的20MB内存中写,互不影响。

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

  在写入磁盘之前,线程首先根据reduce任务的数目将数据划分为相同数目的分区,也就是一个reduce任务对应一个分区的数据。这样做是为了避免有些reduce任务分配到大量数据,而有些reduce任务却分到很少数据,甚至没有分到数据的尴尬局面。其实分区就是对数据进行hash的过程。接下来对每个分区内又调用job.setSortComparatorClass()设置的key比较函数类排序(如果没有通过job.setSortComparatorClass()设置key比较函数类,则使用key的实现的compareTo方法)也就是对这80MB空间内的key做排序(sort),这里的排序是对序列化的字节做的排序。如果此时设置了Combiner,将排序后的结果进行Combiner操作,如果至少存在3个溢出文件(通过mapreduce.map.combine.minspills属性设置)时,则combiner就会在输出文件写到磁盘之前再次运行,这样做的目的是让尽可能减少数据写入到磁盘和传递给reduce的数据。排序后:<hello, 1>,<hello, 1>,<me, 1>,<you, 1>,Combiner后:<hello, {1, 1}>,<me, {1}>,<you, {1}>。

  注:combiner可以在输入上反复运行,但并不影响最终结果。如果只有1或2个溢出文件,那么由于map输出规模减少,因而不值得调用combiner带来的开销,因此不会为该map输出再次运行combiner。

  4 当map任务输出最后一个记录时,可能会有很多的溢出文件,这时需要将这些文件合并(merge)。合并的过程中会不断地进行排序和combiner操作,目的有两个:

    ① 尽量减少每次写入磁盘的数据量;

    ② 尽量减少下一复制阶段网络传输的数据量。

  最后合并成了一个已分区且已排序的输出文件, 其中排序步骤是再次调用job.setSortComparatorClass()设置的key比较函数类对所有数据对排序(因为一个reducer接受多个mappers,需要重新排序),二次排序使用到了jobjob.setGroupingComparatorClass()设置的分组函数类。只要这个比较器比较的两个key相同,他们就属于同一个组,它们的value放在一个value迭代器。配置属性mapreduce.task.io.sort.factor控制着一次最多能合并多少流,默认值是10。为了减少网络传输的数据量,节约磁盘空间和写磁盘的速度更快,这里可以将数据压缩,只要将mapreduce.map.output.compress设置为true就可以。数据压缩算法有DEFLATE、gzip、bzip2、LZO、LZ4、Snappy等,可以通过mapreduce.map.output.compress.codec配置压缩类型即可。

  5. 将分区中的数据拷贝给相对应的reduce任务(可选)。reducer通过HTTP得到输出文件的分区。用于文件分区的工作线程的数量由任务的mapreduce.shuffle.max.threads属性控制,此设置针对的是每一个节点管理器,而不是针对每个map任务。默认值0将最大线程数设置为机器中处理器数量的两倍。

  有人可能会问:分区中的数据怎么知道它对应的reduce是哪个呢?其实map任务一直和其节点上的Application Master保持联系,而Application Master又一直和Application Manager保持心跳。所以Application Manager中保存了整个集群中的宏观信息。只要reduce任务向ApplicationManager获取对应的map输出位置就OK了。

   至此,map端的所有工作已经结束了,最终生成的这个文件也存放在运行map任务的tasktracker的本地磁盘上(但reduce输出并不这样)。每个reduce任务不断地通过RPC从JobTracker那获取map任务是否完成的信息,如果reduce任务得到通知,获知某台TaskTracker上的map任务执行完成,shuffle的后半段过程开始启动。

 Reduce端shuffle过程:

  现在,tasktracker需要为分区文件运行reduce任务。下图是reduce端shuffle过程图:

 reduce端shuffle过程图

  1. copy过程,简单地拉取数据。reduce任务需要集群上若干个map任务的map输出作为其特殊的分区文件。每个map任务的完成时间可能不同,因此在每个任务完成时,reduce任务就开始通过HTTP方式请求复制其输出。 reduce任务由少量复制线程,因此能够并行取得map输出。默认值是5个线程,但这个默认值可以修改设置mapreduce.reduce.shuffle.parallelcopies属性即可。

  如果map输出相当小,会被复制到reduce任务JVM的内存(缓冲区大小由mapreduce.reduce.shuffle.input.buffer.percent属性控制,指定用于此用途的堆空间的百分比),否则,map输出被复制到磁盘。一旦内存缓冲区达到阈值大小(由mapreduce.reduce.shuffle.merge.percent决定,默认是0.66)或达到map输出阈值(由mapreduce.reduce.merge.inmem.threshold控制),则合并后溢出写到磁盘中。如果指定combiner,则在合并期间运行它以降低写入硬盘的数据量。

  随着磁盘上副本增多,后台线程会将它们合并为更大的、排好序的文件。这会为后面的合并节省一些时间。

  2. merge阶段。从map端copy过来的数据会先放入JVM的内存缓冲区中,这里的缓冲区大小要比map端更为灵活,它基于JVM的heap size设置的,因为shuffle阶段reducer不运行,所以绝大部分的内存都给shuffle使用。这个merge阶段将合并map输出,维持其顺序排序。这是循环进行的。比如,如果由50个map输出,而合并因子是10(10为默认设置,由mapreduce.task.io.sort.factor属性设置,与map的合并类似),合并将进行5趟。每趟将10个文件合并成一个文件,因此最后有5个中间文件。

  Merge有三种形式:1、内存到内存;2、内存到磁盘;3、磁盘到磁盘。默认情况下第一种形式是不启动的。当内存中的数据量到达一定阈值,就启动内存到磁盘的merge。与map端类似,这也是溢写的过程,在这个过程中如果设置了combiner,也是会启动的,然后在磁盘中合并溢写文件。第二种merge方式一直再运行,直到没有map端的数据时才结束,然后启动第三种磁盘到磁盘的merge方式生成最终的输出文件。

  3. reduce阶段。这是最后阶段了,直接把数据输入reduce函数,也就是对已排序输出中的每个键调用reduce函数,从而省略了一次磁盘往返行程,并没有将这5个文件合并成一个已排序的文件作为最后一趟。最后的合并可以来自内存和磁盘片段。此阶段的输出直接写到输出文件系统,一般为HDFS。如果采用HDFS,由于节点管理器(NodeManager)也运行数据节点(DataNode),所以第一个块复本将被写到本地磁盘。

Reduce:

   reduce任务并不具备数据本地化的优势,单个reduce任务的输入通常来自于所有map任务的输出,或者接收到不同map任务的输出。在本例中,我们假设仅有一个reduce任务,其输入是所有map任务的输出。因此,排过序的map输出需通过网络传输发送到运行reduce任务的节点。数据在reduce端合并,然后由用户定义的reduce函数处理。reduce的输出通常存储在HDFS中以实现可靠存储。对于reduce输出的每个HDFS块,第一个复本存储在本地节点上,其他复本出于可靠性考虑存储在其他机架的节点中。因此,将reduce的输出写入HDFS确实需要占用网络带宽,但这与正常的HDFS管线写入的消耗一样。

  一个reduce任务的完整数据流如图所示。虚线框表示节点,虚线箭头表示节点内部的数据传输,而实线箭头表示不同节点之间的数据传输。

一个reduce任务的MapReduce数据流图

   reduce任务的数量并非由输入数据的大小决定,相反是独立指定的。 

  如果有好多个reduce任务,每个map任务就会针对输出进行分区(partition),即为每个reduce任务建一个分区。每个分区有许多键(及其对应的值),但每个键对应的键-值对记录都在同一个分区中。分区可由用户定义的分区函数控制,但通常用默认的partitioner通过哈希函数来分区,很高效。 

  一般情况下,多个reduce任务的数据流如下图所示。该图很清晰地表明了为什么map任务和reduce任务之间的数据流称为shuffle(混洗),因为每个reduce任务的输入都来自许多map任务。shuffle一般比图中所示的更复杂(上下节已描述了大概),而且调整混洗参数对作业总执行时间的影响非常打。

  多个reduce任务的数据流图

  最后,当数据处理可以完全并行(即无需混洗时),可能会出现无reduce任务的情况。在这种情况下,唯一的非本地节点数据传输是map任务将结果写入HDFS,参见下图所示。

无reduce任务的MapReduce数据流

   

 二、环形内存缓冲区

2.1 什么是环形内存缓冲区

  Map的输出结果是由Collector处理的,每个Map任务不断地将键值对输出到在内存中构造的一个环形数据结构中。使用环形数据结构是为了更有效地使用内存空间,在内存中放置尽可能多的数据。

 

2.2  环形内存缓冲区的数据结构

  这个数据结构其实就是个字节数组,叫Kvbuffer,名如其义,但是这里面不光放置了数据,还放置了一些索引数据,给放置索引数据的区域起了一个Kvmeta的别名,在Kvbuffer的一块区域上穿了一个IntBuffer(字节序采用的是平台自身的字节序)的马甲。数据区域和索引数据区域在Kvbuffer中是相邻不重叠的两个区域,用一个分界点来划分两者,分界点不是亘古不变的,而是每次Spill之后都会更新一次。初始的分界点是0,数据的存储方向是向上增长,索引数据的存储方向是向下增长,Kvbuffer的存放指针bufindex时指向数据区的,是一直闷着头地向上增长,比如bufindex初始值为0,一个Int型的key写完之后,bufindex增长为4,一个Int型的value写完之后,bufindex增长为8。

1 kvoffsets缓冲区:也叫偏移量索引数组,用于保存key/value信息在位置索引kvindices中的偏移量。当kvoffsets的使用率超过io.sort.spill.percent(默认为80%)后,便会触发一次SpillThread线程的“溢写”操作,也就是开始一次spill阶段的操作。
索引数据区域:存元数据信息,都是整数,只存储分区信息(整数)和kvbuffer在数组中的位置
2 kvindices缓冲区:也叫位置索引数组,用于保存key/value在数据缓冲区kvbuffer中的起始位置
3 kvbuffer数据缓冲区:用于保存实际的key/value的值。默认情况下该缓冲区最多可以使用io.sort.mb的95%,当kvbuffer使用率超过io.sort.spill.percent(默认80%)后,便会触发一次SpillThread线程的“溢写”操作,也就是开始一次spill阶段的操作。

  索引是对在kvbuffer中的键值对的索引,是个四元组,包括:value的起始位置、key的起始位置、partition值、value的长度,占用四个Int长度,Kvmeta的存放指针Kvindex每次都是向下跳四个“格子”,然后再向上一个格子一个格子地填充四元组的数据。比如Kvindex初始位置是-4,当第一个键值对写完之后,(Kvindex+0)的位置存放value的起始位置、(Kvindex+1)的位置存放key的起始位置、(Kvindex+2)的位置存放partition的值、(Kvindex+3)的位置存放value的长度,然后Kvindex跳到-8位置,等第二个键值对和索引写完之后,Kvindex跳到-12位置。

 

三、剖析MapReduce作业运行机制

  整个过程描述如下图所示。在最高层,有以下5个独立的实体。

  • client(客户端),提交MapReduce作业。
  • YARN ResourceManager(YARN资源管理器),负责协调集群上计算机资源的分配。
  • YARN NodeManager(YARN节点管理器),负责启动和监视集群中机器上的计算容器(container)。
  • MapReduce的application master,负责协调运行MapReduce作业的任务。它和MapReduce任务在容器中运行,这些容器由资源管理器分配并由节点管理器进行管理。
  • 分布式文件系统(一般为HDFS),用来与其他实体间共享作业文件。

Hadoop运行MapReduce作业的工作原理图

   1. 客户端提交一个MapReduce作业,Job的submit()方法创建一个内部的JobSummiter实例,并且调用其submitJobInternal()方法。提交作业后,waitForCompletion()每秒轮询作业的进度,如果发现自上次报告后有改变,便把进度报告到控制台。作业完成后,如果成功,就显示作业计数器;如果失败,则导致作业失败的错误被记录到控制台。

  2. Job向资源管理器请求一个新应用ID,用于MapReduce作业ID。资源管理器检查作业的输出说明和计算作业的输入分片,如果没有指定输出目录,输出目录已存在或者分片无法计算,那么作业就不提交,错误抛回给MapReduce程序。

  3. 将运行作业所需要的资源(包括作业JAR文件、配置文件和计算所得的输入分片)复制到一个以作业ID命名的目录下的共享文件系统中。作业JAR的复本较多(由mapreduce.client.submit.file.replication属性控制,默认值为10),因此在运行作业的任务时,集群中有很多个复本可供节点管理器访问。

  4. 通过调用资源管理器的submitApplication()方法提交作业。

  5. 资源管理器收到调用它的submitApplication()消息后,便将请求传递给YARN调度器(scheduler)。调度器分配一个容器,然后资源管理器在节点管理器的管理下在容器中启动application master的进程。

  6. MapReduce作业的application master是一个Java应用程序,它的主类是MRAppMaster。由于将接受来自任务的进度和完成报告,因此application master对作业的初始化是通过创建多个薄记对象以保持对作业进度的跟踪来完成的。

  7. 对每一个分片创建一个map任务对象以及由mapreduce.job.reduces属性(通过作业的setNumReduceTasks()方法设置)确定的多个reduce任务对象。任务ID在此时分配。

  application master必须决定如何运行构成MapReduce作业的各个任务。如果作业很小,就选择和自己在同一个JVM上运行任务。与在一个节点上顺序运行这些任务相比,当application master判断在新的容器中分配和运行任务的开销大于并行运行它们的开销时,就会发生这种情况。这样的作业称为uberized,或者uber任务(小作业)运行。

  默认情况下,小作业就是少于10个mapper且只有1个reducer且输入大小小于一个HDFS块的作业(通过设置mapreduce.job.ubertask.maxmaps、mapreduce.job.ubertask.maxreduces和mapreduce.job.ubertask.maxbytes可以改变这几个值)。必须明确启动uber任务(对于单个作业,或者是对整个集群),具体方法是将mapreduce.job.ubertask.enable设置为true。

  最后,在任何任务运行之前,application master调用setupJob()方法设置OutputCommitter。FileOutputCommitter为默认值,表示将建立作业的最终输出目录及任务输出的临时工作空间。

  8. 如果作业不适合作为uber任务运行,那么application master就会为该作业中的所有map任务和reduce任务向资源管理器请求容器。首先为Map任务发出请求,该请求优先级要高于reduce任务的请求,这是因为所有的map任务必须在reduce的排序阶段能够启动前完成。直到有5%的map任务已经完成时,为reduce任务的请求才会发出(慢启动reduce)。

  reduce任务能够在集群中任意位置运行,但map任务的请求有着数据本地化局限,这也是调度器所关注的。map任务的三种情况的详见一、概念综述中的map。

  请求也为任务指定了内存需求和CPU数。在默认情况下,每个map任务和reduce任务都分配到1024MB的内存和一个虚拟的内核,这些值可以在每个作业的基础上进行配置,配置参考如下表:

属性名称 类型 默认值 说明
mapreduce.map.memory.mb int 1024 map容器所用的内存容量
mapreduce.reduce.memory.mb int 1024 reduce容器所用的内存容量
mapreduce.map.cpu.vcores int 1 map容器所用的虚拟内核
mapreduce.reduce.cpu.vcoresp.memory.mb int 1 reduce容器所用的虚拟内核


  9. 一旦资源管理器的调度器为任务分配了一个特定节点上的容器,application master就通过与节点管理器通信来启动容器
。该任务由主类为YarnChild的一个Java应用程序执行。

  10. 在它运行任务之前,首先将任务需要的资源本地化,包括作业的配置、JAR文件和所有来自分布式缓存的文件。

  11. 最后,运行map任务或reduce任务。

 

 

参考资料:《Hadoop权威指南(第四版)》

        https://www.jianshu.com/p/1e542477b59a

        https://www.cnblogs.com/laowangc/p/8961946.html

       https://www.jianshu.com/p/9e4d01b74600

posted @ 2019-08-15 20:16  buildings  阅读(1819)  评论(0编辑  收藏  举报