MapReduce是一种编程模型,用于大规模数据集(大于1TB)的并行运算。 概念"Map(映射)"和"Reduce(归约)",是它们的主要思想,都是从函数式编程语言里借来的,还有从矢量编程语言里借来的特性。 它极大地方便了编程人员在不会分布式并行编程的情况下,将自己的程序运行在分布式系统上。
一、总览
首先说下Hadoop 的四大组件: HDFS:分布式存储系统。 MapReduce:分布式计算系统。 YARN: hadoop 的资源调度系统。 Common: 以上三大组件的底层支撑组件,主要提供基础工具包和 RPC 框架等。 Mapreduce 是一个分布式运算程序的编程框架,是用户开发“基于 hadoop的数据分析 应用”的核心框架。 Mapreduce 核心功能是将用户编写的业务逻辑代码和自带默认组件整合成一个完整的分布式运算程序,并发运行在一个 hadoop 集群上。
二、MapReduce作业运行流程
从整体层面上看,有五个独立的实体: - 客户端,提交 MapReduce 作业。 - YARN 资源管理器(YARN resource manager),负责协调集群上计算机资源的分配。 - YARN 节点管理器(YARN node manager),负责启动和监视集群中机器上的计算容器(container)。 - MapReduce的 application master,负责协调MapReduce 作业的任务。MRAppMaster 和 MapReduce 任务运行在容器中,该容器由资源管理器进行调度(schedule)[此处理解为划分、分配更为合适] 且由节点管理器进行管理。 - 分布式文件系统(通常是 HDFS),用来在其他实体间共享作业文件。
作业提交(Job Submission)
在 Job 对象上面调用 submit() 方法,在内部创建一个 JobSubmitter 实例,然后调用该实例的 submitJobInternal() 方法(图1步骤1)。如果使用waitForCompletion() 方法来进行提交作业,该方法每隔 1 秒轮询作业的进度,如果进度有所变化,将该进度报告给控制台(console)。当作业成功完成,作业计数器被显示出来。否则,导致作业失败的错误被记录到控制台。
JobSubmitter所实现的作业提交过程如下:
- 向资源管理器(ResourceManager)请求一个 application ID,该 ID 被用作 MapReduce 作业的 ID(步骤2)。
- 检查作业指定的输出(output)目录。例如,如果该输出目录没有被指定或者已经存在,作业不会被提交且一个错误被抛出给 MapReduce 程序
- 为作业计算输入分片(input splits)。如果分片不能被计算(可能因为输入路径(input paths)不存在),该作业不会被提交且一个错误被抛出给 MapReduce 程序。
- 拷贝作业运行必备的资源,包括作业 JAR 文件,配置文件以及计算的输入分片,到一个以作业 ID 命名的共享文件系统目录中(步骤3)。作业 JAR 文件以一个高副本因子(a high replication factor)进行拷贝(由 mapreduce.client.submit.file.replication 属性控制,默认值为 10),所以在作业任务运行时,在集群中有很多的作业 JAR 副本供节点管理器来访问。
- 通过在资源管理器(ResourceManager)上调用 submitApplication 来提交作业(步骤4)。
作业初始化(Job Initialization)
当资源管理器接受到 submitApplication() 方法的调用,它把请求递交给 YARN 调度器(scheduler)。调度器分配了一个容器(container),资源管理器在该容器中启动 application master 进程,该进程被节点管理器(NodeManager)管理(步骤5a 和 5b)。
MapReduce 作业的 application master 是一个 Java 应用,它的主类是 MRAppMaster。它通过创建一定数量的簿记对象(bookkeeping object)跟踪作业进度来初始化作业(步骤6),该簿记对象接受任务报告的进度和完成情况。接下来,application master 从共享文件系统中获取客户端计算的输入分片(步骤7)。然后它为每个分片创建一个 map 任务,同样创建由 mapreduce.job.reduces 属性控制的多个reduce 任务对象(或者在 Job 对象上通过 setNumReduceTasks() 方法设置)。任务ID在此时分配。
Applcation master 必须决定如何运行组成 MapReduce 作业的任务。如果作业比较小,application master 可能选择在和它自身运行的 JVM 上运行这些任务。这种情况发生的前提是,application master 判断分配和运行任务在一个新的容器上的开销超过并行运行这些任务所带来的回报,据此和顺序地在同一个节点上运行这些任务进行比较。这样的作业被称为 uberized,或者作为一个 uber 任务运行。
一个小的作业具有哪些资格?默认的情况下,它拥有少于 10 个 mapper,只有一个 reducer,且单个输入的 size 小于 HDFS block 的。(注意,这些值可以通过 mapreduce.job.ubertask.maxmaps, mapreduce.job.ubertask.maxreduces, mapreduce.job.ubertask.maxbytes 进行设置)。Uber 任务必须显示地将 mapreduce.job.ubertask.enable 设置为 true
最后,在任何任务运行之前, application master 调用 OutputCommiter 的 setupJob() 方法。系统默认是使用 FileOutputCommiter,它为作业创建最终的输出目录和任务输出创建临时工作空间(temporary working space)。
任务分配(Task Assignment)
如果作业没有资格作为 uber 任务来运行,那么 application master 为作业中的 map 任务和 reduce 任务向资源管理器(ResourceManager)请求容器(container)(步骤8)。首先要为 map 任务发送请求,该请求优先级高于 reduce 任务的请求,因为所有的 map 任务必须在 reduce 的排序阶段(sort phase)能够启动之前完成。reduce 任务的请求至少有 5% 的 map 任务已经完成才会发出(可配置)。
reduce 任务可以运行在集群中的任何地方,但是 map 任务的请求有数据本地约束(data locality constraint),调度器尽力遵守该约束(try to honor)。在最佳的情况下,任务的输入是数据本地的(data local)-- 也就是任务运行在分片驻留的节点上。或者,任务可能是机架本地的(rack local),也就是和分片在同一个机架上,而不是同一个节点上。有一些任务既不是数据本地的也不是机架本地的,该任务从不同机架上面获取数据而不是任务本身运行的节点上。对于特定的作业,你可以通过查看作业计数器(job's counters)来确定任务的位置级别(locality level)。
请求也为任务指定内存需求和 CPU 数量。默认,每个 map 和 recude 任务被分配 1024 MB的内存和一个虚拟的核(virtual core)。这些值可以通过如下属性(mapreduce.map.memory.mb, mapreduce.reduce.memory.mb, mapreduce.map.cpu.vcores, mapreduce.reduce.cpu.vcores)在每个作业基础上进行配置(遵守 Memory settings in YARN and MapReduce 中描述的最小最大值)。
任务执行
一旦资源调度器在一个特定的节点上为一个任务分配一个容器所需的资源,application master 通过连接节点管理器来启动这个容器(步骤9a 和9b)。任务通过一个主类为 YarnChild 的 Java 应用程序来执行。在它运行任务之前,它会将任务所需的资源本地化,包括作业配置,JAR 文件以及一些在分布式缓存中的文件(步骤10)。最后,它运行 map 或者 reduce 任务(步骤11)。
YarnChild 在一个指定的 JVM 中运行,所以任何用户自定义的 map 和 reduce 函数的 bugs(或者甚至在 YarnChild)都不会影响到节点管理器(NodeManager) -- 比如造成节点管理的崩溃或者挂起。
每个任务能够执行计划(setup)和提交(commit)动作,它们运行在和任务本身相同的 JVM 当中,由作业的 OutputCommiter 来确定。对于基于文件的作业,提交动作把任务的输出从临时位置移动到最终位置。提交协议确保当推测执行可用时,在复制的任务中只有一个被提交,其他的都被取消掉。
进度和状态的更新
MapReduce 作业是长时间运行的批处理作业(long-running batch jobs),运行时间从几十秒到几小时。由于可能运行时间很长,所以用户得到该作业的处理进度反馈是很重要的。
作业和任务都含有一个状态,包括运行状态、maps 和 reduces 的处理进度,作业计数器的值,以及一个状态消息或描述(可能在用户代码中设置)。这些状态会在作业的过程中改变。那么它是如何与客户端进行通信的?
当一个任务运行,它会保持进度的跟踪(就是任务完成的比例)。对于 map 任务,就是被处理的输入的比例。对于 reduce 任务,稍微复杂一点,但是系统任然能够估算已处理的 reduce 输入的比例。通过把整个过程分为三个部分,对应于 shuffle 的三个阶段。例如,如果一个任务运行 reducer 完成了一半的输入,该任务的进度就是 5/6,因为它已经完成了 copy 和 sort 阶段(1/3 each)以及 reduce 阶段完成了一半(1/6)。
MapReduce 的进度组成 进度不总是可测的,但是它告诉 Hadoop 一个任务在做的一些事情。例如,任务的写输出记录是有进度的,即使不能用总进度的百分比(因为它自己也可能不知道到底有多少输出要写,也可能不知道需要写的总量)来表示进度报告非常重要,Hadoop 不会使一个报告进度的任务失败(not fail a task that's making progress)。如下的操作构成了进度: - 读取输入记录(在 mapper 或者 reducer 中)。 - 写输出记录(在 mapper 或者 reducer 中)。 - 设置状态描述(由 Reporter 的或 TaskAttempContext 的 setStatus() 方法设置)。 - 计数器的增长(使用 Reporter 的 incrCounter() 方法 或者 Counter 的 increment() 方法)。 - 调用 Reporter 的或者 TaskAttemptContext 的 progress() 方法。
任务有一些计数器,它们在任务运行时记录各种事件,这些计数器要么是框架内置的,例如:已写入的map输出记录数,要么是用户自定义的。
当 map 或 reduce 任务运行时,子进程使用 umbilical 接口和父 application master 进行通信。任务每隔三秒钟通过 umbilical 接口报告其进度和状态(包括计数器)给 application master,application master会形成一个作业的聚合视图。
在作业执行的过程中,客户端每秒通过轮询 application master 获取最新的状态(间隔通过 mapreduce.client.progressmonitor.polinterval 设置)。客户端也可使用 Job 的 getStatus() 方法获取一个包含作业所有状态信息的 JobStatus 实例,过程如下:
作业完成(Job Completion)
当 application master 接受到最后一个任务完成的通知,它改变该作业的状态为 “successful”。当 Job 对象轮询状态,它知道作业已经成功完成,所以它打印一条消息告诉用户以及从 waitForCompletion() 方法返回。此时,作业的统计信息和计数器被打印到控制台。
Application master 也可以发送一条 HTTP 作业通知,如果配置了的话。当客户端想要接受回调时,可以通过 mapreduce.job.end-notification.url 属性进行配置。
最后,当作业完成,application master 和作业容器清理他们的工作状态(所以中间输入会被删除),然后 OutputCommiter 的 commitJob() 方法被调用。作业的信息被作业历史服务器存档,以便日后用户查询。
三、 MapReduce计算流程
这里先给出官网上关于这个过程的经典流程图:
树形图如下:
Map
在进行海量数据处理时,外存文件数据I/O访问会成为一个制约系统性能的瓶颈,因此,Hadoop的Map过程实现的一个重要原则就是:计算靠近数据,这里主要指两个方面
- 代码靠近数据:
原则:本地化数据处理(locality),即一个计算节点尽可能处理本地磁盘上所存储的数据;
尽量选择数据所在DataNode启动Map任务;
这样可以减少数据通信,提高计算效率; -
数据靠近代码:
当本地没有数据处理时,尽可能从同一机架或最近其他节点传输数据进行处理(host选择算法)。
输入
- map task只读取split分片,split与block(hdfs的最小存储单位,默认为64MB)可能是一对一也能是一对多,但是对于一个split只会对应一个文件的一个block或多个block,不允许一个split对应多个文件的多个block;
- 这里切分和输入数据的时会涉及到InputFormat的文件切分算法和host选择算法。
文件切分算法,主要用于确定InputSplit的个数以及每个InputSplit对应的数据段。FileInputFormat以文件为单位切分生成InputSplit,对于每个文件,由以下三个属性值决定其对应的InputSplit的个数:
- goalSize: 它是根据用户期望的InputSplit数目计算出来的,即totalSize/numSplits。其中,totalSize为文件的总大小;numSplits为用户设定的Map Task个数,默认情况下是1;
- minSize:InputSplit的最小值,由配置参数mapred.min.split.size确定,默认是1;
- blockSize:文件在hdfs中存储的block大小,不同文件可能不同,默认是64MB。
这三个参数共同决定InputSplit的最终大小,计算方法如下:
splitSize=max{minSize, min{gogalSize,blockSize}}
Partition
- 作用:将map的结果发送到相应的reduce端,总的partition的数目等于reducer的数量。
- 实现功能:
- map输出的是key/value对,决定于当前的mapper的part交给哪个reduce的方法是:mapreduce提供的Partitioner接口,对key进行hash后,再以reducetask数量取模,然后到指定的job上(HashPartitioner,可以通过
job.setPartitionerClass(MyPartition.class
)自定义)。- 然后将数据写入到内存缓冲区,缓冲区的作用是批量收集map结果,减少磁盘IO的影响。key/value对以及Partition的结果都会被写入缓冲区。在写入之前,key与value值都会被序列化成字节数组。
- 要求:负载均衡,效率;
spill(溢写):sort & combiner
- 作用:把内存缓冲区中的数据写入到本地磁盘,在写入本地磁盘时先按照partition、再按照key进行排序(quick sort);
- 注意:
- 这个spill是由另外单独的线程来完成,不影响往缓冲区写map结果的线程;
- 内存缓冲区默认大小限制为100MB,它有个溢写比例(spill.percent),默认为0.8,当缓冲区的数据达到阈值时,溢写线程就会启动,先锁定这80MB的内存,执行溢写过程,maptask的输出结果还可以往剩下的20MB内存中写,互不影响。然后再重新利用这块缓冲区,因此Map的内存缓冲区又叫做环形缓冲区(两个指针的方向不会变,下面会详述);
- 在将数据写入磁盘之前,先要对要写入磁盘的数据进行一次排序操作,先按
中的partition分区号排序,然后再按key排序,这个就是sort操作,最后溢出的小文件是分区的,且同一个分区内是保证key有序的;
combine:
执行combine操作要求开发者必须在程序中设置了combine(程序中通过job.setCombinerClass(myCombine.class
)自定义combine操作)。
- 程序中有两个阶段可能会执行combine操作:
- map输出数据根据分区排序完成后,在写入文件之前会执行一次combine操作(前提是作业中设置了这个操作);
- 如果map输出比较大,溢出文件个数大于3(此值可以通过属性min.num.spills.for.combine配置)时,在merge的过程(多个spill文件合并为一个大文件)中还会执行combine操作;
- combine主要是把形如
, 这样的key值相同的数据进行计算,计算规则与reduce一致,比如:当前计算是求key对应的值求和,则combine操作后得到 这样的结果。 - 注意事项:不是每种作业都可以做combine操作的,只有满足以下条件才可以:
- reduce的输入输出类型都一样,因为combine本质上就是reduce操作;
- 计算逻辑上,combine操作后不会影响计算结果,像求和就不会影响;
merge
- merge过程:当map很大时,每次溢写会产生一个spill_file,这样会有多个spill_file,而最终的一个map task输出只有一个文件,因此,最终的结果输出之前会对多个中间过程进行多次溢写文件(spill_file)的合并,此过程就是merge过程。也即是,待Map Task任务的所有数据都处理完后,会对任务产生的所有中间数据文件做一次合并操作,以确保一个Map Task最终只生成一个中间数据文件。
- 注意:
- 如果生成的文件太多,可能会执行多次合并,每次最多能合并的文件数默认为10,可以通过属性min.num.spills.for.combine配置;
- 多个溢出文件合并时,会进行一次排序,排序算法是多路归并排序;
- 是否还需要做combine操作,一是看是否设置了combine,二是看溢出的文件数是否大于等于3;
- 最终生成的文件格式与单个溢出文件一致,也是按分区顺序存储,并且输出文件会有一个对应的索引文件,记录每个分区数据的起始位置,长度以及压缩长度,这个索引文件名叫做file.out.index。
内存缓冲区
- 在Map Task任务的业务处理方法map()中,最后一步通过OutputCollector.collect(key,value)或context.write(key,value)输出Map Task的中间处理结果,在相关的collect(key,value)方法中,会调用Partitioner.getPartition(K2 key, V2 value, int numPartitions)方法获得输出的key/value对应的分区号(分区号可以认为对应着一个要执行Reduce Task的节点),然后将
暂时保存在内存中的MapOutputBuffe内部的环形数据缓冲区,该缓冲区的默认大小是100MB,可以通过参数io.sort.mb来调整其大小。 - 当缓冲区中的数据使用率达到一定阀值后,触发一次Spill操作,将环形缓冲区中的部分数据写到磁盘上,生成一个临时的Linux本地数据的spill文件;然后在缓冲区的使用率再次达到阀值后,再次生成一个spill文件。直到数据处理完毕,在磁盘上会生成很多的临时文件。
- 缓存有一个阀值比例配置,当达到整个缓存的这个比例时,会触发spill操作;触发时,map输出还会接着往剩下的空间写入,但是写满的空间会被锁定,数据溢出写入磁盘。当这部分溢出的数据写完后,空出的内存空间可以接着被使用,形成像环一样的被循环使用的效果,所以又叫做环形内存缓冲区;
-
MapOutputBuffer内部存数的数据采用了两个索引结构,涉及三个环形内存缓冲区。下来看一下两级索引结构:
这三个环形缓冲区的含义分别如下:
- kvoffsets缓冲区:也叫偏移量索引数组,用于保存key/value信息在位置索引 kvindices 中的偏移量。当 kvoffsets 的使用率超过 io.sort.spill.percent (默认为80%)后,便会触发一次 SpillThread 线程的“溢写”操作,也就是开始一次 Spill 阶段的操作。
- kvindices缓冲区:也叫位置索引数组,用于保存 key/value 在数据缓冲区 kvbuffer 中的起始位置。
- kvbuffer即数据缓冲区:用于保存实际的 key/value 的值。默认情况下该缓冲区最多可以使用 io.sort.mb 的95%,当 kvbuffer 使用率超过 io.sort.spill.percent (默认为80%)后,便会触发一次 SpillThread 线程的“溢写”操作,也就是开始一次 Spill 阶段的操作。
写入到本地磁盘时,对数据进行排序,实际上是对kvoffsets这个偏移量索引数组进行排序。
Reduce
Reduce过程的经典流程图如下:
copy过程
- 作用:拉取数据;
- 过程:Reduce进程启动一些数据copy线程(Fetcher),通过HTTP方式请求map task所在的TaskTracker获取map task的输出文件。因为这时map task早已结束,这些文件就归NodeManager管理在本地磁盘中。
- 默认情况下,当整个MapReduce作业的所有已执行完成的Map Task任务数超过Map Task总数的5%后,JobTracker便会开始调度执行Reduce Task任务。然后Reduce Task任务默认启动mapred.reduce.parallel.copies(默认为5)个MapOutputCopier线程到已完成的Map Task任务节点上分别copy一份属于自己的数据。 这些copy的数据会首先保存的内存缓冲区中,当内冲缓冲区的使用率达到一定阀值后,则写到磁盘上。
内存缓冲区
- 这个内存缓冲区大小的控制就不像map那样可以通过io.sort.mb来设定了,而是通过另外一个参数来设置:mapred.job.shuffle.input.buffer.percent(default 0.7), 这个参数其实是一个百分比,意思是说,shuffile在reduce内存中的数据最多使用内存量为:0.7 × maxHeap of reduce task。
- 如果该reduce task的最大heap使用量(通常通过mapred.child.java.opts来设置,比如设置为-Xmx1024m)的一定比例用来缓存数据。默认情况下,reduce会使用其heapsize的70%来在内存中缓存数据。如果reduce的heap由于业务原因调整的比较大,相应的缓存大小也会变大,这也是为什么reduce用来做缓存的参数是一个百分比,而不是一个固定的值了。
merge过程
- Copy过来的数据会先放入内存缓冲区中,这里的缓冲区大小要比 map 端的更为灵活,它基于 JVM 的heap size设置,因为 Shuffle 阶段 Reducer 不运行,所以应该把绝大部分的内存都给 Shuffle 用。
- 这里需要强调的是,merge 有三种形式:1)内存到内存 2)内存到磁盘 3)磁盘到磁盘。默认情况下第一种形式是不启用的。当内存中的数据量到达一定阈值,就启动内存到磁盘的 merge(图中的第一个merge,之所以进行merge是因为reduce端在从多个map端copy数据的时候,并没有进行sort,只是把它们加载到内存,当达到阈值写入磁盘时,需要进行merge) 。这和map端的很类似,这实际上就是溢写的过程,在这个过程中如果你设置有Combiner,它也是会启用的,然后在磁盘中生成了众多的溢写文件,这种merge方式一直在运行,直到没有 map 端的数据时才结束,然后才会启动第三种磁盘到磁盘的 merge (图中的第二个merge)方式生成最终的那个文件。
- 在远程copy数据的同时,Reduce Task在后台启动了两个后台线程对内存和磁盘上的数据文件做合并操作,以防止内存使用过多或磁盘生的文件过多。
reducer的输入文件
- merge的最后会生成一个文件,大多数情况下存在于磁盘中,但是需要将其放入内存中。当reducer 输入文件已定,整个 Shuffle 阶段才算结束。然后就是 Reducer 执行,把结果放到 HDFS 上。
小结:
- shuffle是mapreduce优化的重点地方;
- 环形内存缓冲区 :因此map写入磁盘的过程十分的复杂,更何况map输出时候要对结果进行排序,内存开销是很大的,所以开启环形内存缓冲区专门用于输出;默认是100MB,阈值是0.8;
- spill(溢写):缓冲区>80%,写入磁盘;溢写前先排序,后合并,写入磁盘;
- Partition:Partitioner操作和map阶段的输入分片(Input split)很像,Partitioner会找到对应的map输出文件,然后进行复制操作,作为reduce的输入;
- reduce阶段:和map函数一样也是程序员编写的,最终结果是存储在hdfs上的。
Split是怎么划分的? 参考FileInputFormat类中split切分算法和host选择算法介绍
参考:
MapReduce 过程详解:https://www.cnblogs.com/npumenglei/p/3631244.html
MapReduce之Shuffle过程详述:http://matt33.com/2016/03/02/hadoop-shuffle/
MapReduce过程简析:https://www.jianshu.com/p/0ec306605df8
MapReduce执行过程:https://zhuanlan.zhihu.com/p/45305945