3.1 MapReduce计算模型
MapReduce计算模型主要由三个阶段构成:Map、Shuffle、Reduce。
Map是映射,负责数据的处理、分发,将原始数据转化为键值对;Reduce是合并,将具有相同key值的value进行处理后再输出新的键值对作为最终结果。为了让Reduce可以并行处理Map的结果,必须对Map的输出进行一定的排序与分割,然后再交给对应的Reduce,而这个将Map输出进一步整理并交给Reduce的过程就是Shuffle。整个MR的大致过程如下所示。
Map和Reduce操作需要自己定义相应Mapper类和Reducer类,以完成所需要的化简、合并操作,而shuffle则是系统自动实现的,了解shuffle的具体流程能编写出更加高效的Mapreduce程序。
Shuffle过程包含在Map和Reduce两端,即Map shuffle和Reduce shuffle
3.2 MapReduce工作流程
1.流程示意图
2.流程详解
上面流程是整个MapReduce整个工作流程,但是Shuffle过程只是从第7步开始到第16步结束,具体Shuffle过程详解,如下:
(1) MapTask收集map()方法输出的kv对,放到内存缓冲区中
(2) 从内存缓冲区不断溢出本地磁盘文件,可能会溢出多个文件
(3) 多个溢出文件会被合并成大的溢出文件
(4) 在溢出过程及合并的过程中,都要调用Partitioner进行分区和针对key进行排序
(5) ReduceTask根据自己的分区号,去各个MapTask机器上取相应的结果分区数据
(6) ReduceTask会取到同一个分区的来自不同MapTask的结果文件,ReduceTask会将这些文件再进行合并(归并排序)
(7) 合并成大文件后,Shuffle的过程也就结束了,后面进入ReduceTask的逻辑运算过程(从文件中取出一个一个的键值对Group,调用用户自定义的reduce()方法)
3.注意
Shuffle中的缓冲区大小会影响到MapReduce程序的执行效率,原则上说,缓冲区越大,磁盘io的次数越少,执行速度就越快。
缓冲区的大小可以通过参数调整,参数:mapreduce.task.io.sort.mb 默认100M。
4.源码解析流程
3.3 MapTask工作机制
(1) Read阶段:MapTask通过用户编写的RecordReader,从输入InputSplit中解析出一个个key/value。
(2) Map阶段:该阶段主要是将解析出的key/value交给用户编写map()函数处理,并产生一系列新的key/value。
(3) Collect收集阶段:在用户编写map()函数中,当数据处理完成后,一般会调用OutputCollector.collect()输出结果。在该函数内部,它会将生成的key/value分区(调用Partitioner),并写入一个环形内存缓冲区中。
(4) Spill阶段:即溢写,当环形缓冲区满后,MapReduce会将数据写到本地磁盘上,生成一个临时文件。将数据写入本地磁盘之前,先要对数据进行一次排序(快速排序),并在必要时对数据进行合并、压缩等操作。
溢写阶段详情:
(一)步骤1:利用快速排序算法对环形缓冲区内的数据进行排序,先按照Partition编号进行排序,然后按照key进行排序。经过排序后,数据以分区为单位聚集在一起,且同一分区内所有数据按照key有序。
(二)步骤2:按照分区编号由小到大依次将每个分区中的数据写入临时文件output/spillN.out
(N表示当前溢写次数)中。如果用户设置了Combiner,则写入文件之前,对每个分区中的数据进行一次合并操作。
(三)步骤3:将分区数据的元信息写到内存索引数据结构SpillRecord中,其中每个分区的元信息包括在临时文件中的偏移量、压缩前数据大小和压缩后数据大小。如果当前内存索引大小超过1MB,则将内存索引写入到文件output/spillN.out.index
中。
(5) Combine阶段:当所有数据处理完成后,MapTask对所有临时文件进行一次合并,以确保最终只会生成一个数据文件,并保存到文件output/file.out中,同时生成相应的索引文件output/file.out.index。
在进行文件合并过程中,MapTask以分区为单位进行合并。对于某个分区,它将采用多轮递归合并的方式。每轮合并mapreduce.task.io.sort.factor
(默认10)个文件,并将产生的文件重新加入待合并列表中,对文件排序后(归并排序),重复以上过程,直到最终得到一个大文件。
让每个MapTask最终只生成一个数据文件,可避免同时打开大量文件和同时读取大量小文件产生的随机读取带来的开销。
Map端相关的属性
mapreduce.tasktracker.http.threads与mapreduce.reduce.shuffle.parallelcopies区别
mapreduce.tasktracker.http.threads表示每个TaskTracker能够提供40(默认)个http 线程来服务于reducer(假设reducer的数量有50个,MapTask输出的文件中可能包含50个分区的数据,所以可能有50个reducer同时来拉取数据,该参数限制了拉取数据的reducer数量)
mapreduce.reduce.shuffle.parallelcopies表示一个reducer能够同时从5(默认)个map端获取数据
3.4 Map Shuffle
在Map端的shuffle过程是对Map的结果进行分区、排序、分割,然后将属于同一划分(分区)的输出合并在一起并写在磁盘上,最终得到一个分区有序的文件。分区有序的含义是map输出的键值对按分区进行排列,具有相同partition值的键值对存储在一起,每个分区里面的键值对又按key值进行升序排列(默认),其流程大致如下
3.4.1 Collector环形缓冲区
3.4.1.1.环形缓冲区作用
环形缓冲区分为三块,空闲区、数据区、索引区。初始位置取名叫做“赤道”,就是圆环上的白线那个位置。初始状态的时候,数据和索引都为0,所有空间都是空闲状态。
mapreduce.task.io.sort.mb,默认100M,可以稍微设置大一些,但不要太大,因为每个spilt就128M。
环形缓冲区写入的时候,有个细节:数据是从赤道的右边开始写入,索引(每次申请4kb)是从赤道左边开始写。这个设计很有意思,这样两个文件各是各的,互不干涉。
在数据和索引占用空间到了mapreduce.map.sort.spill.percent
参数设置的比例时(默认80%,这个是调优的参数),会有两个动作:
(1) 对写入的数据进行原地排序,并把排序好的数据和索引spill到磁盘上去;
(2) 在空闲的20%区域中,重新计算一个新的赤道,然后在新赤道的右边写入数据,左边写入索引;
(3) 当20%写满了,但是上一次80%的数据还没写到磁盘的时候,程序就会panding一下,等80%空间腾出来之后再继续写。
3.4.1.2.环形缓冲区结构
Map的输出结果是由collector处理的,每个Map任务不断地将键值对输出到内存中构造的一个环形数据结构中。使用环形数据结构是为了更有效地使用内存空间,在内存中放置尽可能多的数据。
环形缓冲区,这个数据结构其实就是个字节数组byte[],叫Kvbuffer
,但是这里面不光放置了数据,还放置了一些索引数据,给放置索引数据的区域起了一个Kvmeta
的别名。
数据区域和索引数据区域在Kvbuffer中是相邻不重叠的两个区域,用一个分界点来划分两者,分界点不是亘古不变的,而是每次Spill之后都会更新一次。初始的分界点是0,数据的存储方向是向上增长,索引数据的存储方向是向下增长。
Kvbuffer的指针bufindex(即数据的存储方向)是一直闷着头地向上增长,比如bufindex初始值为0,一个Int型的key写完之后,bufindex增长为4,
一个Int型的value写完之后,bufindex增长为8。(int型的数据占有4个字节)
索引是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位置。
关于Spill触发的条件,也就是Kvbuffer用到什么程度开始Spill。如果把Kvbuffer用得一点都不剩的时候再开始Spill,那Map任务就需要等Spill完成腾出空间之后才能继续写数据;如果Kvbuffer只是满到一定程度,比如80%的时候就开始Spill,那在Spill的同时,Map任务还能继续写数据,如果Spill够快,Map可能都不需要为空闲空间而发愁。两利相衡取其大,一般选择后者。Spill的门限可以通过mapreduce.map.sort.spill.percent
,默认是0.8。
Spill这个重要的过程是由Spill线程承担,Spill线程从Map任务接到“命令”之后就开始正式干活,干的活叫SortAndSpill,原来不仅仅是Spill,在Spill之前还有个颇具争议性的Sort。
3.4.2 Partition
对于map输出的每一个键值对,系统都会给定一个partition,partition值默认是通过计算key的hash值后对Reduce task的数量取模获得。如果一个键值对的partition值为0,意味着这个键值对会交给第一个Reducer处理。
3.4.3 Sort
当Spill触发后,SortAndSpill先把Kvbuffer中的数据按照partition值和key两个关键字升序排序(快速排序),移动的只是索引数据,排序结果是Kvmeta中数据按照partition为单位聚集在一起,同一partition内的按照key升序。
3.4.4 Spill
Spill线程为这次Spill过程创建一个磁盘文件:从所有的本地目录中轮训查找能存储这么大空间的目录,找到之后在其中创建一个类似于“spill12.out”的文件。Spill线程根据排过序的Kvmeta挨个partition把数据写到这个文件中,一个partition对应的数据写完之后顺序地写下个partition,直到把所有的partition遍历完。一个partition在文件中对应的数据也叫段(segment)。在这个过程中如果用户配置了combiner类,那么在写之前会先调用combineAndSpill(),对结果进行进一步合并后再写出。Combiner会优化MapReduce的中间结果,所以它在整个模型中会多次使用。Combiner的输出是Reducer的输入,Combiner绝不能改变最终的计算结果。
所有的partition对应的数据都放在这个文件里,虽然是顺序存放的,但是怎么直接知道某个partition在这个文件中存放的起始位置呢?强大的索引又出场了。有一个三元组记录某个partition对应的数据在这个文件中的索引:起始位置、原始数据长度、压缩之后的数据长度,一个partition对应一个三元组。然后把这些索引信息存放在内存中,如果内存中放不下了,后续的索引信息就需要写到磁盘文件中:从所有的本地目录中轮训查找能存储这么大空间的目录,找到之后在其中创建一个类似于“spill12.out.index”的文件,文件中不光存储了索引数据,还存储了crc32的校验数据。spill12.out.index不一定在磁盘上创建,如果内存(默认1M空间)中能放得下就放在内存中,即使在磁盘上创建了,和spill12.out文件也不一定在同一个目录下。每一次Spill过程就会最少生成一个out文件,有时还会生成index文件,Spill的次数也烙印在文件名中。索引文件和数据文件的对应关系如下图所示:
在Spill线程如火如荼的进行SortAndSpill工作的同时,Map任务不会因此而停歇,而是一无既往地进行着数据输出。Map还是把数据写到kvbuffer中,那问题就来了:只顾着闷头按照bufindex指针向上增长,kvmeta只顾着按照Kvindex向下增长,是保持指针起始位置不变继续跑呢,还是另谋它路?如果保持指针起始位置不变,很快bufindex和Kvindex就碰头了,碰头之后再重新开始或者移动内存都比较麻烦,不可取。Map取kvbuffer中剩余空间的中间位置,用这个位置设置为新的分界点,bufindex指针移动到这个分界点,Kvindex移动到这个分界点的-16位置,然后两者就可以和谐地按照自己既定的轨迹放置数据了,当Spill完成,空间腾出之后,不需要做任何改动继续前进。
Map任务总要把输出的数据写到磁盘上,即使输出数据量很小在内存中全部能装得下,在最后也会把数据刷到磁盘上。
3.4.5 Merge
Map任务如果输出数据量很大,可能会进行好几次Spill,out文件和Index文件会产生很多,分布在不同的磁盘上。最后把这些文件进行合并的merge过程闪亮登场。
Merge过程怎么知道产生的Spill文件都在哪了呢?从所有的本地目录上扫描得到产生的Spill文件,然后把路径存储在一个数组里。Merge过程又怎么知道Spill的索引信息呢?也是从所有的本地目录上扫描得到Index文件,然后把索引信息存储在一个列表里。在之前Spill过程中的时候为什么不直接把这些信息存储在内存中呢,何必又多了这步扫描的操作?特别是Spill的索引数据,之前当内存超限之后就把数据写到磁盘,现在又要从磁盘把这些数据读出来,装到更多的内存中。之所以多此一举,是因为这时kvbuffer这个内存大户已经不再使用了,可以回收,因此有内存空间来装这些数据了。(对于内存空间较大的土豪来说,用内存来省却这两个io步骤还是值得考虑的。)
然后为merge过程创建一个叫file.out的文件和一个叫file.out.Index的文件用来存储最终的输出和索引,一个partition一个partition的进行合并输出。对于某个partition来说,从索引列表中查询这个partition对应的所有索引信息,每个对应一个段插入到段列表中。也就是这个partition对应一个段列表,记录所有的Spill文件中对应的这个partition那段数据的文件名、起始位置、长度等等。
然后对这个partition对应的所有的segment进行合并,目标是合并成一个segment。当这个partition对应很多个segment时,会分批地进行合并:先从segment列表中把第一批取出来,以key为关键字放置成最小堆,然后从最小堆中每次取出最小的输出到一个临时文件中,这样就把这一批段合并成一个临时的段,把它加回到segment列表中;再从segment列表中把第二批取出来合并输出到一个临时segment,把其加入到列表中;这样往复执行,直到剩下的段是一批,输出到最终的文件中。最终的索引数据仍然输出到Index文件中。
3.5 Reduce Shuffle
3.5.1 ReduceTask工作机制
(1) Copy阶段
由于Job的每一个map都会根据reduce(n)数将数据分成n个partition,所以map的中间结果有可能包含每一个reduce需要处理的部分数据的。所以,为了优化reduce的执行时间,hadoop等Job的第一个map结束后,所有的reduce就开始尝试从完成的map下载该reduce对应的partition部分数据,因此map和reduce是交叉进行的,其实就是shuffle。Reduce任务通过HTTP向各个Map任务拖取(下载)它所需要的数据(网络传输),Reducer是如何知道要去哪些机器取数据呢?一旦map任务完成之后,就会通过常规心跳通知父TaskTracker状态已经更新,TaskTracker进而通知JobTracker(这些通知在心跳机制中进行)。reduce的一个线程会周期性地向JobTracker询问,直到提取完所有数据,数据被reduce提走之后,map机器不会立刻删除数据,这是为了预防reduce任务失败需要重做。因此map输出数据是在整个作业完成之后才被删除掉的。
reduce进程启动数据copy线程(Fetcher),通过HTTP方式请求map task所在的TaskTracker获取map task的输出文件。由于map通常有许多个,所以对一个reduce来说,下载也可以是并行的从多个map下载,那到底同时到多少个Mapper下载数据?这个并行度是可以通过mapreduce.reduce.shuffle.parallelcopies(default5)调整。默认情况下,每个Reducer只会有5个map端并行的下载线程在从map下数据,如果一个时间段内Job完成的map有100个或者更多,那么reduce也最多只能同时下载5个map的数据,所以这个参数比较适合map很多并且完成比较快的情况下调大,有利于reduce更快的获取属于自己部分的数据。在Reducer内存和网络都比较好的情况下,可以调大该参数;
reduce的每一个下载线程在下载某个map数据的时候,有可能因为那个map中间结果所在机器发生错误,或者中间结果的文件丢失,或者网络瞬断等等情况,这样reduce的下载就有可能失败,所以reduce的下载线程并不会无休止的等待下去,当一定时间后下载仍然失败,那么下载线程就会放弃这次下载,并在随后尝试从另外的地方下载(因为这段时间map可能重跑)。reduce下载线程的这个最大的下载时间段是可以通过mapreduce.reduce.shuffle.read.timeout
(default180000毫秒)调整的。如果集群环境的网络本身是瓶颈,那么用户可以通过调大这个参数来避免reduce下载线程被误判为失败的情况。一般情况下都会调大这个参数,这是企业级最佳实战。
(2) Merge Sort阶段
这里的merge和map端的merge动作类似,只是缓冲区中存放的是不同map端copy来的数值。Copy过来的数据会先放入内存缓冲区中,然后当内存缓冲区的使用量达到一定程度的时候才spill磁盘。这里的缓冲区大小要比map端的更为灵活,它基于JVM的heap size设置。这个内存大小的控制不像map一样可以通过mapreduce.map.sort.spill.percent来设定,而是通过另外一个参数 mapreduce.reduce.shuffle.input.buffer.percent
(default 0.7f 源码里面写死了) 来设置,这个参数其实是一个百分比,即reduce的shuffle阶段内存缓冲区,最多使用量为:0.7 × maxHeap of reduce task。JVM的heapsize的70%。内存到磁盘merge的启动门限可以通过mapreduce.reduce.shuffle.merge.percent
(default 0.66)配置。也就是说,如果该reduce task的最大heap使用量(通常通过mapreduce.admin.reduce.child.java.opts来设置,比如设置为-Xmx1024m)的一定比例用来缓存数据。默认情况下,reduce会使用其heap size的70%来在内存中缓存数据。假设 mapreduce.reduce.shuffle.input.buffer.percent 为0.7,reduce task的max heapsize为1G,那么用来做下载数据缓存的内存就为大概700MB左右。这700M的内存,跟map端一样,也不是要等到全部写满才会往磁盘刷的,而是当这700M中被使用到了一定的限度(通常是一个百分比),就会开始往磁盘刷(刷磁盘前会先做sort merge)。这个限度阈值也是可以通过参数 mapreduce.reduce.shuffle.merge.percent(default 0.66)来设定。与map 端类似,这也是溢写的过程,这个过程中如果设置了Combiner,也是会启用的,然后在磁盘中生成了众多的溢写文件。这种merge方式一直在运行,直到没有map端的数据时才结束,然后启动磁盘到磁盘的merge方式生成最终的那个文件,merge的时候会进行归并排序。一般Reduce是一边copy一边sort,即copy和sort两个阶段是重叠而不是完全分开的。最终Reduce shuffle过程会输出一个整体有序的数据块。
merge有三种形式
(1) 内存到内存(memToMemMerger)
Hadoop定义了一种MemToMem合并,这种合并将内存中的map输出合并,然后再写入内存。这种合并默认关闭,可以通过mapreduce.reduce.merge.memtomem.enabled(default:false)打开,当map输出文件达到mapreduce.reduce.merge.memtomem.threshold时,触发这种合并。
(2) 内存中Merge(inMemoryMerger)/内存到磁盘
当缓存区数据达到配置的阈值时,这些数据在内存中被合并、写入机器磁盘。阈值有2种配置方式:
-
配置内存比例:前面提到reduce JVM堆内存的一部分用于存放来自map任务的输出,在这基础之上配置一个开始合并数据的比例。假设用于存放map输出的内存为500M,mapreduce.reduce.shuffle.merge.percent配置为0.66,则当内存中的数据达到330M的时候,会触发合并写入。
-
配置map输出数量:通过
mapreduce.reduce.merge.inmem.threshold
配置。在合并的过程中,会对被合并的文件做全局的排序。如果作业配置了Combiner,则会运行combine函数,减少写入磁盘的数据量。
(3) 磁盘上的Merge(onDiskMerger)
在copy过来的数据不断写入磁盘的过程中,一个后台线程会把这些文件合并为更大的、有序的文件。如果map的输出结果进行了压缩,则在合并过程中,需要在内存中解压后才能进行合并。这里的合并只是为了减少最终合并的工作量,也就是还在拷贝map输出时,就开始进行一部分合并工作。合并的过程一样会进行全局排序(归并排序)。
最终磁盘中Merge,当所有map输出都拷贝完毕之后,所有数据被最后合并成一个整体有序的文件,作为reduce任务的输入。这个合并过程是一轮一轮进行的,最后一轮的合并结果直接推送给reduce作为输入,节省了磁盘操作的一个来回。最后(所有map输出都拷贝到reduce之后)进行合并的map输出可能来自合并后写入磁盘的文件,也可能来及内存缓冲,在最后写入内存的map输出可能没有达到阈值触发合并,所以还留在内存中。
每一轮合并不一定合并平均数量的文件数,指导原则是使整个合并过程中写入磁盘的数据量最小,为了达到这个目的,则需要最终的一轮合并中合并尽可能多的数据,因为最后一轮的数据直接作为reduce的输入,无需写入磁盘再读出。因此让最终的一轮合并的文件数达到最大,即合并因子的值,通过mapreduce.task.io.sort.factor
(default:10)来配置。
(3) Reduce阶段
当reducer将所有的map上对应自己partition的数据下载完成后,就会开始真正的reduce计算阶段。由于reduce计算时肯定也是需要消耗内存的,而在读取reduce需要的数据时,同样是需要内存作为buffer,reduce需要多少的内存百分比来作为reducer读已经sort好的数据的buffer大小??默认情况下为0,也就是说,默认情况下,reduce是全部从磁盘开始读处理数据。可以用mapreduce.reduce.input.buffer.percent(default 0.0)(源代码MergeManagerImpl.java:674行)来设置reduce的缓存。如果这个参数大于0,那么就会有一定量的数据被缓存在内存并输送给reduce,当reduce计算逻辑消耗内存很小时,可以分一部分内存用来缓存数据,可以提升计算的速度。所以默认情况下都是从磁盘读取数据,如果内存足够大的话,务必设置该参数让reduce直接从缓存读数据,这样做就有点Spark Cache的感觉;
Reduce阶段,框架为已分组的输入数据中的每个 <key, (list of values)>对调用一次 reduce(WritableComparable,Iterator, OutputCollector, Reporter)方法。Reduce任务的输出通常是通过调用 OutputCollector.collect(WritableComparable,Writable)写入文件系统的。Reducer的输出是没有排序的。
Reduce端相关属性
3.5.2 设置ReduceTask并行度(个数)
ReduceTask的并行度同样影响整个Job的执行并发度和执行效率,但与MapTask的并发数由切片数决定不同,ReduceTask数量的决定是可以直接手动设置
// 默认值是1,手动设置为4
job.setNumReduceTasks(4);
1.实验:测试ReduceTask多少合适
(1) 实验环境:1个Master节点,16个Slave节点:CPU 8GHZ,内存 2G
(2) 实验结论:
改变ReduceTask (数据量为1GB)
MapTask =16
ReduceTask | 1 | 5 | 10 | 15 | 16 | 20 | 25 | 30 | 45 | 60 |
---|---|---|---|---|---|---|---|---|---|---|
总时间 | 892 | 146 | 110 | 92 | 88 | 100 | 128 | 101 | 145 | 104 |
2.注意事项
(1) ReduceTask=0,表示没有Reduce阶段,输出文件个数和Map个数一致;
(2) ReduceTask默认值为1,所以输出文件个数为1个;
(3) 如果数据分布不均匀,就有可能在Reduce阶段产生数据倾斜;
(4) ReduceTask数量并不是任意设置,还要考虑业务逻辑需求,有些情况下,需要计算全局汇总结果,就只能有1个ReduceTask;
(5) 具体多少个ReduceTask,需要根据集群性能而定;
(6) 如果分区数不是1,但是ReduceTask为1,是否执行分区过程。答案是:不执行分区过程。因为MapTask源码中,执行分区的前提是先判断ReduceNum个数是否大于1,不大于1肯定不执行。
3.5.3 mapreduce中partition数量与reduce task数量对结果影响
(1) 情况1:Partition数量为1,reduce数量为3。
设置reduce数量为3
Job.setNumReduceTasks(3);
设置partition数量为1,返回值为0
public class MyPartitioner extends HashPartitioner<MyKey, DoubleWritable> {
// 执行时间越短越好
public int getPartition(MyKey key, DoubleWritable value, int numReduceTasks){
// return (key.getYear()-1949)%numReduceTasks;
return 0;
}
}
结果输出3个文件,但只有part-r-00000有内容,其它两个为0字节。
(2) 情况2:Partition数量为1,返回值5,reduce数量为3。
设置reduce数量为3
Job.setNumReduceTasks(3);
设置partition数量为1,返回值为5
public class MyPartitioner extends HashPartitioner<MyKey, DoubleWritable> {
// 执行时间越短越好
public int getPartition(MyKey key, DoubleWritable value, int numReduceTasks){
// return (key.getYear()-1949)%numReduceTasks;
return 5;
}
}
结果输出3个文件,都为0字节。
(3) 情况3:Partition数量为1,返回值1,reduce数量为3。
结果为输出3个文件,但只有part-r-00001有值。
(4) 情况4:Partition数量为3,返回值0、1、2,reduce数量为2。
如果1<ReduceTask的数量<partition数,则有一部分分区数据无处安放,会Exception;如果ReduceTask的数量=1,则不管MapTask端输出多少个分区文件,最终结果都交给这一个ReduceTask,最终也就只会产生一个结果文件 part-r-00000
总结:生成文件数量由reduce数量决定,值输出到哪个文件由partition返回值决定,输出值正确性由partition数量和返回值决定。一般情况是有多少个partition对应多少个reduce。
3.6 InputFormat数据输入
3.6.1切片与MapTask并行度决定机制
1.问题引出
MapTask的并行度决定Map阶段任务处理并发度,进而影响到整个Job的处理速度。
思考:1G的数据,启动8个MapTask,可以提高集群的并发处理能力。那么1K的数据,也启动8个MapTask,会提高集群性能吗?MapTask并行任务是否越多越好呢?哪些因素影响了MapTask并行度?
2.MapTask并行度决定机制
数据块:Block是HDFS物理上把数据分成一块一块。
数据切片:数据切片只是在逻辑上对输入进行分片,并不会在磁盘上将其切分成片进行存储。
3.6.2 Job提交流程源码和切片源码详解
1.Job提交流程源码详解
waitForCompletion()
submit();
// 1建立连接
connect();
// 1)创建提交Job的代理
new Cluster(getConfiguration());
// (1)判断是本地yarn还是远程
initialize(jobTrackAddr, conf);
// 2 提交job
submitter.submitJobInternal(Job.this, cluster)
// 1)创建给集群提交数据的Stag路径
Path jobStagingArea = JobSubmissionFiles.getStagingDir(cluster, conf);
// 2)获取jobid,并创建Job路径
JobID jobId = submitClient.getNewJobID();
// 3)拷贝jar包到集群
copyAndConfigureFiles(job, submitJobDir);
rUploader.uploadFiles(job, jobSubmitDir);
// 4)计算切片,生成切片规划文件
writeSplits(job, submitJobDir);
maps = writeNewSplits(job, jobSubmitDir);
input.getSplits(job);
// 5)向Stag路径写XML配置文件
writeConf(conf, submitJobFile);
conf.writeXml(out);
// 6)提交Job,返回提交状态
status = submitClient.submitJob(jobId, submitJobDir.toString(), job.getCredentials());
2.FileInputFormat切片源码解析(input.getSplits(job))
(1) 程序先找到数据存储的目录;
(2) 开始遍历处理(规划切片)目录下的每一个文件;
(3) 遍历第一个文件ss.txt;
a.获取文件大小fs.sizeOf(ss.txt)
b.计算切片大小
computeSplitSize(Math.max(minSize, Math.min(maxSize,blocksize)))=blocksize=128M
c.默认情况下,切片大小=blocksize
d.开始切片,形成第一个切片:ss.txt-0:128M,第二个切片ss.txt-128M:256M,第三个切片ss.txt-256M:300M (每次切片时,都要判断切完剩下的部分是否大于块的1.1倍,不大于1.1倍就划分一块切片)
e.将切片信息写到一个切片规划文件中;
f.整个切片的核心过程在getSplit()方法中完成;
g.InputSplit只记录了切片的元数据信息,比如起始位置、长度以及所在的节点列表等。
(4) 提交切片规划文件到YARN上,YARN上的MrAppMaster就可以根据切片规划文件计算开启MapTask数量
3.6.3 FileInputFormat切片机制
切片机制
- 简单地按照文件的内容长度进行切片;
- 切片大小,默认等于Block大小;
- 切片时不考虑数据集整体,而是逐个针对每一个文件单独切片;
案例分析
(1) 输入数据有两个文件
file1.txt 320M
file2.txt 10M
(2) 经过FileInputFormat的切片机制运算后,形成的切片信息如下
file1.txt.split1 0~128
fle1.txt.split2 128~256
file1.txt.split3 256~300
file2.txt.split1 0~10
源码中计算切片大小的公式
Math.max(minSize, Math.min(maxSize, blockSize))
mapreduce.input.fileinputformat.split.minsize
默认值为1
mapreduce.input.fileinputformat.split.maxsize
默认值为Long.MAXValue,所以默认情况下,切片大小=blockSize
切片大小设置
- maxSize(切片最大值):如果该参数调得比blockSize小,则会让切片变小,而且就等于配置的这个参数得值;
- minSize(切片最小值):如果该参数调得比blockSize大,则可以让切片变得比blockSize大;
获取切片信息API
// 获取切片的文件名称
String name = inputSplit.getPath().getName();
// 根据文件类型,获取切片信息
FileSplit inputSplit = (FileSplit) context.getInputSplit();
3.6.4 CombineTextInputFormat切片机制
框架默认的TextInputFormat切片机制是对任务按文件规划切片,不管文件多小,都会是一个单独的切片,都会交给一个MapTask,这样如果有大量小文件,就会产生大量的MapTask,处理效率极其低下。
1.应用场景
CombineTextInputFormat用于小文件过多的场景,它可以将多个小文件从逻辑上规划到一个切片中,这样,多个小文件就可以交给一个MapTask处理。
2.虚拟存储切片最大值设置
CombineTextInputFormat.setMaxInputSplitSize(job, 4194304);// 4m
注意:虚拟存储切片最大值设置最好根据实际的小文件大小情况来设置具体的值。
3.切片机制
生成切片过程包括:虚拟存储过程和切片过程二部分。
(1) 虚拟存储过程
将输入目录下所有文件大小,依次和设置的setMaxInputSplitSize值比较,如果不大于设置的最大值,逻辑上划分一个块。如果输入文件大于设置的最大值且大于两倍,那么以最大值切割一块;当剩余数据大小超过设置的最大值且不大于最大值2倍,此时将文件均分成2个虚拟存储块(防止出现太小切片)。
例如setMaxInputSplitSize值为4M,输入文件大小为8.02M,则先逻辑上分成一个4M。剩余的大小为4.02M,如果按照4M逻辑划分,就会出现0.02M的小的虚拟存储文件,所以将剩余的4.02M文件切分成(2.01M和2.01M)两个文件。
(2) 切片过程
(a)判断虚拟存储的文件大小是否大于setMaxInputSplitSize值,大于等于则单独形成一个切片。
(b)如果不大于则跟下一个虚拟存储文件进行合并,共同形成一个切片。
(c)测试举例:有4个小文件大小分别为1.7M、5.1M、3.4M以及6.8M这四个小文件,则虚拟存储之后形成6个文件块,大小分别为:
1.7M,(2.55M、2.55M),3.4M以及(3.4M、3.4M)
最终会形成3个切片,大小分别为:
(1.7+2.55)M,(2.55+3.4)M,(3.4+3.4)M
3.6.5 CombineTextInputFormat案例
1.需求
将输入的大量小文件合并成一个切片统一处理。
(1) 输入数据
准备4个小文件
(2) 期望
期望一个切片处理4个文件
2.实现过程
(1) 不做任何处理,运行1.8节的WordCount案例程序,观察切片个数为4。
number of splits: 4
(2) 在WordcountDriver中增加如下代码,运行程序,并观察运行的切片个数为3。
驱动类中添加代码如下
// 如果不设置InputFormat,它默认用的是TextInputFormat.class
job.setInputFormatClass(CombineTextInputFormat.class);
//虚拟存储切片最大值设置4m
CombineTextInputFormat.setMaxInputSplitSize(job, 4194304);
运行如果为3个切片。
number of splits: 3
(3) 在WordcountDriver中增加如下代码,运行程序,并观察运行的切片个数为1。
驱动中添加代码如下
// 如果不设置InputFormat,它默认用的是TextInputFormat.class
job.setInputFormatClass(CombineTextInputFormat.class);
//虚拟存储切片最大值设置20m
CombineTextInputFormat.setMaxInputSplitSize(job, 20971520);
运行如果为1个切片。
number of splits: 1
3.6.6 FileInputFormat实现类
思考:在运行MapReduce程序时,输入的文件格式包括:基于行的日志文件、二进制格式文件、数据库表等。那么,针对不同的数据类型,MapReduce是如何读取这些数据了?
FileInputFormat常见的接口实现类包括:TextInputFormat、KeyValueTextInputFormat、NLineInputFormat、CombineTextInputFormat和自定义InputFormat等。
1.TextInputFormat
TextInputFormat是默认的FileInputFormat实现类,按行读取每条记录。
- 键key是存储该行在整个文件中的起始字节偏移量, LongWritable类型。
- 值value是这行的内容,不包括任何行终止符(换行符和回车符),Text类型。
比如,一个分片包含了如下4条文本记录。
Rich learning form
Intelligent learning engine
Learning more convenient
From the real demand for more close to the enterprise
每条记录表示为以下键/值对
(0,Rich learning form)
(19,Intelligent learning engine)
(47,Learning more convenient)
(72,From the real demand for more close to the enterprise)
2.KeyValueTextInputFormat
每一行均为一条记录,被分隔符分割为key,value。可以通过在驱动类中设置来设定分隔符
conf.set(KeyValueLineRecordReader.KEY_VALUE_SEPERATOR, "\t")
默认分隔符是tab(\t)。
例如,输入是一个包含4条记录的分片。其中——>表示一个(水平方向的)制表符。
line1 ——>Rich learning form
line2 ——>Intelligent learning engine
line3 ——>Learning more convenient
line4 ——>From the real demand for more close to the enterprise
每条记录表示为以下键值对
(line1,Rich learning form)
(line2,Intelligent learning engine)
(line3,Learning more convenient)
(line4,From the real demand for more close to the enterprise)
此时的键key是每行排在制表符之前的Text序列。
3.NLineInputFormat
如果使用NlineInputFormat,代表每个map进程处理的InputSplit不再按Block块去划分,而是按NlineInputFormat指定的行数N来划分。即输入文件的总行数/N=切片数,如果不整除,切片数=商+1。
例如,以上面的4行输入为例
Rich learning form
Intelligent learning engine
Learning more convenient
From the real demand for more close to the enterprise
如果N是2,则每个输入分片包含两行,开启2个MapTask。
(0,Rich learning form)
(19,Intelligent learning engine)
另一个 mapper 则收到后两行
(47,Learning more convenient)
(72,From the real demand for more close to the enterprise)
这里的键和值与TextInputFormat生成的一样。
3.6.7 KeyValueTextInputFormat使用案例
1.需求
统计输入文件中每一行的第一个单词相同的行数。
(1) 输入数据
banzhang ni hao
xihuan hadoop banzhang
banzhang ni hao
xihuan hadoop banzhang
(2) 期望结果数据
banzhang 2
xihuan 2
2.需求分析
3.代码实现
(1) 编写Mapper类
package com.test.mapreduce.KeyValueTextInputFormat;
import java.io.IOException;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
public class KVTextMapper extends Mapper<Text, Text, Text, LongWritable>{
// 1 设置value
LongWritable v = new LongWritable(1);
@Override
protected void map(Text key, Text value, Context context)
throws IOException, InterruptedException {
// banzhang ni hao
// 2 写出
context.write(key, v);
}
}
(2) 编写Reducer类
package com.test.mapreduce.KeyValueTextInputFormat;
import java.io.IOException;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
public class KVTextReducer extends Reducer<Text, LongWritable, Text, LongWritable>{
LongWritable v = new LongWritable();
@Override
protected void reduce(Text key, Iterable<LongWritable> values, Context context) throws IOException, InterruptedException {
long sum = 0L;
// 1 汇总统计
for (LongWritable value : values) {
sum += value.get();
}
v.set(sum);
// 2 输出
context.write(key, v);
}
}
(3) 编写Driver类
package com.test.mapreduce.keyvaleTextInputFormat;
import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.KeyValueLineRecordReader;
import org.apache.hadoop.mapreduce.lib.input.KeyValueTextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
public class KVTextDriver {
public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
Configuration conf = new Configuration();
// 设置切割符
conf.set(KeyValueLineRecordReader.KEY_VALUE_SEPERATOR, " ");
// 1 获取job对象
Job job = Job.getInstance(conf);
// 2 设置Driver,关联mapper和reducer
job.setJarByClass(KVTextDriver.class);
job.setMapperClass(KVTextMapper.class);
job.setReducerClass(KVTextReducer.class);
// 3 设置map输出kv类型
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(LongWritable.class);
// 4 设置最终输出kv类型
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(LongWritable.class);
// 5 设置输入输出数据路径
FileInputFormat.setInputPaths(job, new Path(args[0]));
// 设置输入格式
job.setInputFormatClass(KeyValueTextInputFormat.class);
// 6 设置输出数据路径
FileOutputFormat.setOutputPath(job, new Path(args[1]));
// 7 提交job
job.waitForCompletion(true);
}
}
3.6.8 NLineInputFormat使用案例
1.需求
对每个单词进行个数统计,根据每个输入文件的行数来规定输出多少个切片。此案例要求每三行放入一个切片中。
(1) 输入数据
banzhang ni hao
xihuan hadoop banzhang
banzhang ni hao
xihuan hadoop banzhang
banzhang ni hao
xihuan hadoop banzhang
banzhang ni hao
xihuan hadoop banzhang
banzhang ni hao
xihuan hadoop banzhang banzhang ni hao
xihuan hadoop banzhang
(2) 期望输出数据
Number of splits:4
2.需求分析
3.代码实现
(1) 编写Mapper类
package com.test.mapreduce.nline;
import java.io.IOException;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
public class NLineMapper extends Mapper<LongWritable, Text, Text, LongWritable>{
private Text k = new Text();
private LongWritable v = new LongWritable(1);
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
// 1 获取一行
String line = value.toString();
// 2 切割
String[] splited = line.split(" ");
// 3 循环写出
for (int i = 0; i < splited.length; i++) {
k.set(splited[i]);
context.write(k, v);
}
}
}
(2) 编写Reducer类
package com.test.mapreduce.nline;
import java.io.IOException;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
public class NLineReducer extends Reducer<Text, LongWritable, Text, LongWritable>{
LongWritable v = new LongWritable();
@Override
protected void reduce(Text key, Iterable<LongWritable> values, Context context) throws IOException, InterruptedException {
long sum = 0l;
// 1 汇总
for (LongWritable value : values) {
sum += value.get();
}
v.set(sum);
// 2 输出
context.write(key, v);
}
}
(3) 编写Driver类
package com.test.mapreduce.nline;
import java.io.IOException;
import java.net.URISyntaxException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.NLineInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
public class NLineDriver {
public static void main(String[] args) throws IOException, URISyntaxException, ClassNotFoundException, InterruptedException {
// 输入输出路径需要根据自己电脑上实际的输入输出路径设置
args = new String[] {
"e:/input/inputword", "e:/output1" };
// 1 获取job对象
Configuration configuration = new Configuration();
Job job = Job.getInstance(configuration);
// 7设置每个切片InputSplit中划分三条记录
NLineInputFormat.setNumLinesPerSplit(job, 3);
// 8使用NLineInputFormat处理记录数
job.setInputFormatClass(NLineInputFormat.class);
// 2设置Driver,关联mapper和reducer
job.setJarByClass(NLineDriver.class);
job.setMapperClass(NLineMapper.class);
job.setReducerClass(NLineReducer.class);
// 3设置map输出kv类型
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(LongWritable.class);
// 4设置最终输出kv类型
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(LongWritable.class);
// 5设置输入输出数据路径
FileInputFormat.setInputPaths(job, new Path(args[0]));
FileOutputFormat.setOutputPath(job, new Path(args[1]));
// 6提交job
job.waitForCompletion(true);
}
}
4.测试
(1) 输入数据
banzhang ni hao
xihuan hadoo