MapReduce工作流程及Shuffle原理概述
引言:
虽然MapReduce计算框架简化了分布式程序设计,将所有并行程序需要关注的设计细节抽象成公共模块并交由系统实现,用户只需关注自己的应用程序的逻辑实现,提高了开发效率。但开发者如果对Mapreduce计算框架如何实现这样的魔术没有一个基本的了解,那么将无法利用框架本身提供的灵活性编写MapReduce程序,在面临多任务、大数据而出现大量数据倾斜,计算速度慢等问题时,也无法给出解决方案,所以了解MapReduce工作流程和Shuffle原理是学习MapReduce程序设计的必修课。初学Hadoop时,看过的书籍和课程都偏向于把Yarn和MapReduce的原理分开来介绍,这就像把厨师(Yarn)和做菜的流程(MapReduce)分开介绍一样,初学者往往很难将两者结合起来,本文努力将厨师和如何制作每道菜的整个流程结合起来,介绍MapReduce和Yarn的工作原理。笔者才疏学浅,此文为个人愚见,如有不正,希望读者不吝指正。
一、Yarn是什么东西?
首先,不严谨的说:Yarn是由Resourcemanager、NodeManager、ApplicationMaster、Container等组件构成,他就像是一个分布式的操作系统,负责整个MapReduce程序计算过程中所有数据和硬件资源的调度,这种资源的调度表现在某一MapReduce任务进行到具体的某一阶段时,资源的调度就由Yarn某一具体的组件负责,你可以把他比作厨师在每道菜的制作进行到各个流程时,会使用不同的厨具。
对于Yarn的介绍就先到这里,对Yarn的各个组件有个映像即可。下面来介绍MapReduce执行之前需要做哪些准备工作:
在前面关于任务提交的源码分析一文中指出:在使用API客户端提交MapReduce程序到Hadoop集群上的过程中,程序执行到 job.waitForCompletion(ture),首先调用connect方法,初始化Cluster对象,然后用这个对象创建JobRunner,如果获得的Cluster是Yarn集群的抽象封装,那么这个Cluster返回的JobRunner就是YarnRunner了,而如果获得的Cluster是本机文件系统的抽象封装,那么Cluster返回的JobRunner就是LocalJobRunner(这里不再赘述LocalJobRunner以及本地运行模式是如何执行)。YarnRunner会向Resourcemanager节点发送一个Application,Resourcemanager收到(Application)应用请求后返回给申请所在节点一个hdfs上的路径(hdfs://.../.staging/)以及application_id,随后YarnRunner将数据的切片信息(job.split)、本次job的配置信息(job.xml)、MapReduce程序打成的jar包提交到hdfs://.../.staging/application_id/这个路径下。至此,MapReduce程序的准备工作完成了。
当MapReduce相关的配置信息提交完成后,Yarn的工作边开始了,下面详细介绍Yarn在MapReduce计算的全流程中所做的具体工作:
当job.split、job.xml、jar包都提交到hdfs://.../.staging/application_id/目录以后,YarnRunner会通知Resourcemanager资源提交完毕,进而向Resourcemanager申请一个MR ApplicationMaster,Resourcemanager将此次请求初始化为一个Task,并放入资源调度器中(老版本的资源调度器是一个单列的FIFO结构,现在的版本是多列FIFO结构)。此后某一空闲的NodeManager节点(大概率是此MapReduce计算数据所在的节点)会从Resourcemanager节点的调度队列中领取此Task任务,领取到此Task后,会初始化一个MR ApplicationMaster并向Resourcemanager节点注册自己以表明身份信息,注册完成后创建Container用于保存抽象化的本地硬件资源(注意:并不是一个节点只能存在一个Container,而是当一个节点接收到一个MapReduce计算任务时,会为该任务所需的资源单独创建一个Container,如果一个节点处理多个MapReduce任务时,就会创建多个Container,所以我们说MapReduce是运行在Container中的,由于Container封装了本次计算在本节点锁分摊的任务量所需的计算资源,所以可以把他想象成你在windows上开了一个虚拟机,用于部署web服务),这些资源包括CPU和RAM等等。完成Container的创建后,此NodeManager会将hdfs://.../.staging/application_id/下的本次job的数据信息(不是数据本体,而是包含了切片信息和数据所在节点的信息)下载到本节点。随后MR ApplicationMaster会根据split切片数量向Resourcemanager申请相应数量的节点作为计算节点(map节点),我们把用于map计算的节点称为MapTask容器。Resourcemanager收到申请后同样会在调度队列中创建Task,然后会有相应数量的节点(大概率是数据本体所在节点)成为MapTask容器,即Map计算节点,MapTask也会为此MapReduce任务创建Container,当一个节点有能力并且同时处理多个MapReduce任务时,会为每一个任务所需的资源单独创建一个Container保存抽象的资源。Resourcemanager会把这些节点的地址通知给MR ApplicationMaster所在节点,随后,MR ApplicationMaster向MapTask节点发送程序启动脚本。等Map计算完成后(不严谨,事实上是部分的Map计算完成后就会启动ReduceTask,而非所有map),会将数据落到磁盘。最后MR ApplicationMaster会向Resourcemanager申请ReduceTask容器用于处理reduce计算,ReduceTask也会放入调度队列中。申请ReduceTask容器的数量是在客户端指定的,如果没有指定,则默认为一个ReduceTask。ReduceTask所在节点对从map节点中拷贝属于自己分区内的数据进行reduce操作。最后将计算结果输出给context,由OutputFormat格式化后保存到文件。
大概的工作流程如下图(下图忽略了map落盘reduce输出落盘):
关于Yarn的介绍到这里就结束了,可以看出Yarn的各个组件支持了MapReduce程序的执行过程,负责整个MapReduce程序计算过程中所有数据和硬件资源的调度,这种资源的调度表现在某一MapReduce任务进行到具体的某一阶段时,资源的调度就由Yarn某一具体的组件负责。进行的下面介绍MapReduce程序的全流程。
二、MapReduce
尽管MapReduce的业务逻辑灵活多变,但也可以从宏观角度来研究一个MapReduce程序的各个阶段:
上面介绍说:MR Applicationmaster根据hdfs://.../.staging/application_id/下的信息计算出切片数后,Resourcemanager会分配相应数量的节点作为MapTask节点进行map计算。这里取其中一个MapTask为例,来研究一个map节点的工作细节:
MapTask1会根据配置信息读取自己需要处理的片,并使用对应的RecordReader将文件数据转化为K-V对,这个阶段为format(格式化)阶段。格式化的意义在于将数据变成Mapper方法可以接收的K-V类型(即参数类型),map方法经处理后又以K-V对的形式将数据写回context中。然后由outputCollector将数据进行收集,outputCollector在将数据写到shuffle之前,会计算该数据(K-V)的分区号,然后将K-V和分区号一并写入shuffle中。默认情况下是按照Key的哈希值和Integer的最大值做与运算然后和RedueTask数量进行模运算,进而计算出分区数,由于在reduce阶段各节点所分配的数据是由这个分区号决定的,所以这样做的目的是初步平衡个reduce节点计算量,防止数据倾斜,但是这个ReduceTask的数量是可以在Driver中人为指定的,如果不指定则默认为1,也就说默认情况下(不指定分区方法和ReduceTask数量)所有的Key-Value只会计算出一个分区号,也就是说所有的计算都在一个节点上进行。
上图所示为两个分区号,这是自定义分区和RedueTask数为2。但是注意,这里只是调用了ReduceTask的数值,还没有进入Reduce阶段,现阶段属于Map阶段。
你可能会问,这不也是固定的流程吗?怎么会体现“设计理念”呢?其实设计就体现在InputFormat、RecordReader、OutputCollector这些都是可以自定义的,由于自定义的InputFormat和RecordReader(事实上自定义InputFormat的核心就是自定义RecordReader),同样的数据会以不同的K-V形式输给map方法,数据处理自然也会有更多的灵活性,自定义OutputCollector中自定义分区计算方法,会让map输出的key-value对根据开发者的意愿计算出一个分区号,并交给shuffle处理,Shuffle会对带有同一分区号的数据进行区内排序,当数据排序完成后会溢写到磁盘中,溢写出的数据具有分区且区内有序的特点,而往往(并非绝对)数据的分区号也决定了该区号的数据最终交给哪一个ReduceTask节点处理。关于自定义InputFormat和OutputCollector不是本文的重点,这里不再赘述。
当数据进入Shuffle后,又经历了什么呢?下面详细介绍上图红框内的工作流程:
首先,进入Shuffle的数据会在Shuffle中进行一次快排:
排序流程大致如下:Shuffle可以看作为内存中的一个环形集合,首尾相接,结构上类似于双向链表。当标记有分区号的K-V对进入Shuffle后,Shuffle会将同一分区的数据进行一次快排,Shuffle分为两个部分,按图示,两个部分分别用于存储索引标识和K-V对。在排序时采用如下方案:假如索引为2的K-V大于索引为1的K-V值,就会将标识为2的K-V的索引放到第一位。也就是说:Shuffle排序并不对数据本身做移动,而是对数据体量较小的索引标识做移动,这样降低了IO压力。当数据排序完成并且Shuffle中存储的数据占了Shuffle空间(100mb)的80%时(这个可以自定义),会按照逆时针的顺序(2>1>4>3)将对应的K-V值溢写到磁盘中,保存在一个临时文件。临时文件中数据的特点就是分区且区内有序。在数据量较大时,会发生多次溢写,产生多个临时文件。
Shuffle排序的规则也可以由开发者自定义,很多情况下,key是用户自定义的Javabean类型,并且希望Shuffle在排序时按照该类型的一个或多个属性值进行排序,而不是用Shuffle原生的排序规则,就可以自定义排序器。
排序: 1. 排序时MR框架在Shuffle阶段自动进行的 2. 在MapTask端发生两次排序,在排序时,用户唯一可以控制的时提供一个key的比较器 3. 设置key的比较器 ① 用户可以自定义key的比较器,自定义的比较器必须是一个RawComparator类型的类,重点是实现compareTo()方法 ② 用户通过key,让key实现WritableComparable接口,系统自动提供一个比较器,重点是实现compareTo()方法 4. 排序的分类 全排序: 对所有的数据进行排序,指的是生成一个结果文件,这个结果文件整体有序 部分排序:最终生成N个文件,每个文件内部整体有序 二次排序:在对key进行比较时,比较的条件为多个 辅助排序:在进入reduce阶段时,通过比较key是否相同,将相同的key分为1组 |
为了方便讲解,下面将使用案例的方式,本案例假设Shuffle存在多次溢写,存在两个分区,【】内的数据为一个分区,分区号为从左往右由0开始数。
我们知道相同key的一组key-vlaue对会“组团”调用一次Reduce的ruduce方法。那么在数据从内存(Shuffle)溢写到磁盘的过程中<第一处可以引入Combiner的地方>可以加一个Combiner,其本质就是一个Reducer,会对数据进行一轮的合并,比如第一轮溢写出的两个分区内的数据为{【(a,1),(a,1),(c,1)】,【(B,1),(B,1),(D,1),(D,1)】},两个分区(本例以大小写分区)内的数据虽然有序,但是数据有冗余,当相同的K-V值数量很多时,会对IO造成不必要的压力,所以在此环节中引入Combiner将数据合并为{【(a,2),(c,1)】}、{【(B,2),(D,2)】}两个分区(默认情况下Combiner是不开启的,在不影响数据正确性的情况下建议开启,Combiner适合 + - 操作,不适合* / 操作),这样再将数据从内存写入硬盘时,IO压力就会降低。在默认情况下Combiner是不开启的,因为在很多业务场景中,Combiner可能会造成数据的误差。附:Combiner其实就是一个Reducer,他的工作和Reducer一样是对同一组的数据做处理,而且实现相同的接口(Reducer<>),所以很多情况(当Combiner与某一Reducer逻辑相同)下,可以直接使用已经定义好Reducer作Combiner。
上图虽然将分区和排序从Shuffle环中画了出来,但实质上排序和分区都是在Shuffle中完成的,且顺序并且没有先后之分。
多次溢写出多组数据,产生多个文件,假设第一轮溢写的结果为{【(a,2),(c,1)】}、{【(B,2),(D,2)】},第二轮溢写并合并的结果为{【(a,3),(c,2)】}、{【(B,1),(D,3)】},产生里两个临时文件spill0.out/spill1.out。那么进行归并排序后变成{【(a,2),(a,3),(c,1),(c,2)】},{【(B,2),(B,1),(D,2),(D,3)】}依旧会存在相同的Key的情况,那么也可以选择在归并排序时使用Combiner进行合并<第二处可以引入Combiner的地方>,结果为:{【(a,5),(c,3)】}、{【(B,3),(D,5)】}。这是一个MapTask的工作结果,当存在多个MapTask时,会产生多组输出数据,假设存在第二个MapTask节点的输出结果为:{【(a,3),(c,4)】}、{【(B,2),(D,6)】}。至此MapTask的工作完成了。
map阶段结束。
现在进入ReduceTask阶段,假设我们设定了两个ReduceTask,并且自定义了分区规划:将分区一(key小写字母的一组)交给ReduceTask1来处理,将分区2交给ReduceTask2处理,那么所有MapTask输出结果中的分区1的数据都会进入ReduceTask1中(分区2的数据进入ReduceTask2中,不论是哪一个MapTask产生的)。此时shuffle线程从多个MapTask节点读取同一个分区内的数据,然后进行归并排序,按照相同的Key分组,比如分区1分为两组:"(a,5),(a,3)"、"(c,3),(c,4)",在合并时,如果shuffle所使用的内存不够,也会将部分数据溢写到磁盘,如果此时设置了使用Combiner进行合并<第三处可以引入Combiner的地方>,数据也会被combine之后"(a,8) , (c,7)"再溢写磁盘。这里假设没有使用Combiner,"(a,5),(a,3)"、"(c,3),(c,4)"会一组一组的调用Reducer的reduce方法【即相同的Key作为一组数据调用一次reduce方法,把values保存在reduce方法的values数组中,然后循环反序列化,将一对一对的序列化K-V值反序列化注入(set方法)到reduce内自定义的key和value对象中,从始至终用于接收反序列化结果的key—value对象固定不变,但是循环反序列过程中自定义的对象的属性值却依次的按照本次处理的数据集合(values)内元素的顺序而改变】,最后reduce方法将(a,8)、(c,7)写回context中,交给OutputFormat处理,最终输出两个文件。
注意1:此外,当存在多个ReduceTask节点时,默认情况下一个ReduceTask只处理一个分区的数据,但是可以通过自定义Partitioner(MapTask通过Partitioner来计算分区号)的方式(继承HashPartitioner重写getPartition方法)将具体的一个或多个分区路由给单个ReduceTask节点处理,也可以将一个分区内的一部分数据路由给某个ReduceTask处理。
分区: 1. 分区是在MapTask中通过Partitioner来计算分区号来实现的 2. Partitioner的初始化 ① 计算总的分区数partitions,该数值取决于用户设置的reduceTask的数量 ② partitions>1时,默认尝试获取用户设置的Partitioner,如果用户没有自定义,就会使用HashPartitioner,HashPartitioner根据key的hashcode计算分区号,相同的key或者hash值相同的key分到一个区 ③ partitions<1时,默认初始化一个Partitioner,这个Partitioner计算的所有key的分区号都为0 3. 注意 通常在Job的设置中,希望将数据分为几个区,就设置reduceTask的数量为对应的数量 partitions=设置的reduceTask的数量,0<=分区器计算的区号<partitions |
注意2:虽然默认情况下是key相同的作为一组调用一次reduce方法,即以key为传入key,以value组成的列表values传入reduce方法,下一组key相同的再调用一次reduce方法,每组key-values分开调用。但是可以通过自定义GroupingComparator(k,knext)的方式“欺骗”reduce方法,强制的将不同的key划分成一组,调用一次reduce方法。事实上这只是改变了原有的分组方案。
分组: 1. 分组通过分组比较器,对进入reduce的key进行对比,key相同的分为一组,一次性进入Reducer,被reduce方式使用 2. 分组比较器的设置 ① 用户可以自定义key的分组比较器,自定义的比较器必须是一个RawComparator类型的类,重点要实现compareTo()方法 ② 如果没有设置key的分组比较器,默认采用在Map阶段排序时key使用的比较器 |
ReduceTask工作完成后,会将数据写回context,至此reduce阶段就结束了。由于存在两个ReduceTask,会产生两个输出文件。
需要额外补充:分区规划是可以自定义的,我们可以自定义哪些key进入同一分区,也可以自定义哪一分区交给那个ReduceTask处理,但需要注意,如果在指定某一分区交给一个不存在的ReduceTask时,会报错,如果自定义了较多的ReduceTask数,而实际用于处理固定分区的ReduceTask又较少,就会产生节点资源的浪费,并且最终输出几个空文件。当某一Reducer节点接收的数据量大于其他Reducer节点时,计算速度会明显慢于其他节点,这就是数据倾斜,解决数据倾斜的方法就是合理的分区和合理规划分区路由到ReduceTask的方案,尽量使各分区内的数据量保持均衡。
最后OutputFormat以固定的格式将context中的数据写到文件中,这个OutputFormat也可以自定义,与自定义InPutFormat相同,自定义OutputFormat的核心是自定义RecordWriter,是RecordWriter决定以什么样的K-V格式写到文件中的。
MapReduce虽然将分布式计算的流程封装成一个框架,让用户使用单线程的开发模式开发分布式程序,但是作为框架,MapReduce却提供了比较高的灵活性。从自定义数据输入格式化InputFormat、RecordReader,到Mapper处理数据,到OutPutCollector自定义分区规则改变分区方案平衡各区数据量,到自定义Shuffle排序比较器改变排序方案,到自定义分组比较器改变分组方案“组团”调用reduce方法,到reduce方法处理数据,最后自定义OutputFormat改变输出数据的格式。从始至终,MapReduce提供的灵活性和遍历性让大规模数据的处理变得简单易行。