1、认识MapReduce MapReduce 是一种可用于数据处理的编程模型,有一下特点:
- 编程模型简单,但业务实现不一定简单;
- Hadoop可以运行各种该语言编写的MapReduce程序,如java,python 等,很多企业为求开发效率采用python来开发MapReduce程序;
- MapReduce 程序是并行运行的,所以又叫批处理程序。
2、MapReduce 组成及工作机制 MapReduce由哪几部分组成呢?
- 首先MapReduce 由一个run方法,通常称为驱动的放来来配置job的一些参数,也可以把一些全局的变量设置到Configuration配置参数里面,最后submit job,是它的主要工作。
- 既然是MapReduce 那必然有map和reduce方法其实这两个方法分别包含在两个不同的static 类中。其中Map类是必须要有的,Reduce是可选的。
除了必须的编程模型外,还有一些可选的比如: Combiner,Combiner是干什么的呢?其实可以理解为Map阶段的reduce,因为reduce 的开启不限定是否本地化,所以大量的map端输出数据需要在server之间进行copy,造成了大量的网络和cpu消耗,为了减少这种资源消耗,增加了Combiner 类,可以在map本地先做一步reduce,再对结果进行copy到最终的reduce端。节约了大量的服务器资源。 其实还有一部分可编程组件这里没有说到,下面会说到MapReduce的流程,在下面的过程中会关联说明。3、MapReduce 作业运行机制 (1)作业流程 在Map/Reduce框架中,每一次计算请求,被称为作业。为了完成这个作业,它进行两步走的战略,首先是将其拆分成若干个Map任务,分配到不同的机器上去执行,每一个Map任务拿输入文件的一部分作为自己的输入,经过一些计算,生成某种格式的中间文件,这种格式,与最终所需的文件格式完全一致,但是仅仅包含一部分数据。因此,等到所有Map任务完成后,它会进入下一个步骤,用以合并这些中间文件获得最后的输出文件。此时,系统会生成若干个Reduce任务,同样也是分配到不同的机器去执行,它的目标,就是将若干个Map任务生成的中间文件为汇总到最后的输出文件中去。最终,作业完成。 其中执行多少个map任务 系统参数无法设定,需要程序根据输入文件的大小和输入文件的多少进行判断,原则是是一个block一个map,如果小文件众多,则可能会产生大量生存周期较短的map任务,因为每个任务的初始化均需要耗费一些内存,造成了大量的资源浪费,所以这种情况尽量减少。如果存在这种问题,却是严重影响mapreduce任务的执行时间,最有效的解决方式其实是CombineFileInputFormat 类的使用,但是CombineFileInputFormat其实是一个 abstract 类,需要实现其中的createRecordReader方法才能应用到程序中。而在使用了TableInputFormat的job中,Hbase Table 就作为了输入数据源,那这时最小的map输入单位则为region,如果region 在设置支出比较大,比如hbase.hregion.max.filesize=1073741824 也就是1G 或者更大,那对于数据量少的输入来说,总map数较少,就存在任务分配不均的问题。 下面看MapReduce作业流程图,这时社区提供的一张图: I、作业提交过程 首先MapReduce program 启动runJob,在程序中通过JobClient的submit来实现,在新API中表面上看不是这样的,但内部仍然是通过JobClient的submit方法来实现; JobClient 向JobTracker 发送请求,通过调用JobTracker的getNewJobId从而获得一个jobid。每个job都会有jobname 可能相同可能不同,但jobid却是唯一的。 再获得jobid后,client启动copy资源的过程。将作业所需要的(jar文件、配置文件和计算所得的输入分片信息)copy进HDFS,如果是文件或者archive那就在Distributed cache中创建一个连接,如果libjars不为空,那就将其加入到分布式缓存中去。 从而实现作业jar文件的本地化,此时再告诉jobtracker作业准备执行,才是才实现submit的真正提交。 关于这个 看下面的例子: hadoop jar newjob.jar -files=rule.txt -libjars=new.jar -archives=dict.zip 显然会用到newjob.jar 文件也就是我们的作业程序,除了这个之外用到了 rule.txt JobClient会把他copy到HDFS 并加入到分布式缓存中,并建立连接,相当于手机短号一样,使用方便, 同样-archives 的文件也会以这个方式处理,而libjars 稍有不同.但终归都要放入到HDFS。 对于一个典型的MapReduce作业来说,可能包含以下资源: 1)程序jar包,这就是用户用java 来写的程序并打包。 2)作业配置文件,描述MapReduce应用程序的配置信息,根据程序中和hadoop配置文件组合的一个xml文件 3)依赖的第三方jar包,即运行是用libjars引入的第三方jar包 4)依赖的归档文件,这其中包括一个技巧,如果依赖多个文件,那直接将这多个文件打包成archive文件 -archives方式制定,而不用像-files=file1.zip,file2.zip这样以逗号分隔一大串文件。 5)依赖的普通文件,-files引入的依赖文件,比如把一下规则文件,字典文件,或者条件配置文件,可能每次都更新,变化比较大的文件用这个方式引入。比较方便。 但所有的这些依赖都要通过DistributeedCache来完成,其实DistributedCache是Hadoop为方便用户进行应用改程序开发而设计的数据分发工具,具体怎么分发,它内部来完成,对用户来说是透明的。 DistributedCache将文件分为两种可见级别,分别是private 和Public级别。其中private 级别文件只会被当前操作用户使用,不能与其它用户共享;而public级别文件则不同,它在每个节点上都保存一份,可备几点上的所有作业和用户共享,这样可以大大降低文件复制代价,提供作业运行效率,一个文件或者目录要成为public级别,续同时满足两个条件: 1)该文件或者目录对所有用户/用户组均有读权限 r权限 2)该文件或者目录所有前辈目录对所有用户/用户组有可执行权限 x 这其中还涉及一个问题,文件上传到HDFS后,可能有大量的节点从HDFS上下载这些文件,如果存储的节点比较少,那大量节点从这几个少数节点*问,造成访问热点。所以JobClient在上传这些共享文件时会调高他们的副本数默认是10 ,参数mapred.submit.replication 默认是10,将负载做一个分摊。 说到这,有必要说一个参数dfs.replication 这个参数是block的设定副本数默认是3 如果小于这个值 但是>dfs.replication.min 则系统返回成功,但是始终会给以用户警告,提示副本不足,但是如果将dfs.replication 设置为2,那此时上传的block默认副本就是2,及时以后再将dfs.replication 改为3或者更大的数,都不影响以前上传文件的默认副本数。 II、作业初始化过程 当submit提交之后,此时还没有真正执行,而是把此调用放进一个内存队列中,交给作业调度器去进行调度,在上一节中说到了MapReduce作业的三种调度器,默认是FIFO的时序调度器。初始化包括创建一个正在运行作业的对象用来封装任务和记录信息,继而方便跟踪任务的状态和进程。 进而从共享文件中获取文件的分片信息,并为每个分片分配一个map task,而分配多少个reduce task 呢?mapred.reduce.task参数来决定的,默认是1个,在这个过程中将map task和reduce task 都创建好,都准备好,才开始进入task 的分配阶段。 III、任务的分配 tasktracker和jobtracker之间始终保持一个心跳,这个心跳周期性的在tasktracker和jobtracker之间传输消息,那么这个心跳什么作用呢? 1)、判断TaskTracker是否还活着; 2)、JobTracker及时获得各个TaskTracker节点上资源的使用情况和任务运行状态;
3)、给TaskTracker分配任务。
那么,心跳是由谁发起的呢?JobTracker从不会主动的向TaskTracker发送任何的信息,而是由TaskTracker节点主动通过心跳来向JobTracker获取属于自己的信.
此时再我们的task中,tasktracker通过心跳告诉jobtracker 我心在比较闲,没有任务(程序是比较诚实的),jobtracker看到tasktracker有空闲了任务槽了,那就会分配新的task到相应的tasktracker中,这期间。当然分配任务的时候还要考虑这个block的任务是否适合在这个tasktracker上运行,即考虑的数据的本地化。 IV、任务的执行 任务分配好了,那应该到执行任务的阶段了,既然要执行任务,那就应该要执行的jar 和必需的资源以及配置参数,这是tasktracker会将其从hdfs复制到本地文件系统,并将其放进一个以jobid命名的本地工作目录,所有的都全了,tasktracker 启动一个TaskRunner来运行该任务,此时TaskRunner启动一个新的JVM来运行每个MapTask或者ReduceTask,但是重用JVM的情况是存在的,这样省去了重新启动新的JVM的资源消耗,本人建议开启JVM重用机制。 而这个参数是mapred.job.reuse.jvm.num.tasks,默认是1,即每一个任务启动一个jvm进程,如果设置为>1的整数表示最多可有几个task重用一个jvm进程。建议设置为-1 即no limit 不做限制。同时这么做也有利于我们的小文件Map,减少大量的JVM创建消耗。 (2)MapReduce编程模型 先看一下任务在变成组建中的转化过程(单Task) 1)将输入数据解析成Key/Value对,具体解析成何种Key/Value跟在驱动中配置的输入方式有关,比如: TextInputFormat 将每行的首字符在整个文件中的偏移量作为Key(LongWritable),本行中的所有内容作为Value(Text) KeyValueTextInputFormat 则根据默认的第一个\t 也就是tab 之前的所有内容当做Key(Text),之后全部作为Value(Text) 分割字符可以根据自己需求来设定,key.value.separator.in.input.line ,在 新API中由mapreduce.input.keyvaluelinerecordreader.key.value.separator 设定。 ...... 2)将Key/Value对映射到map,每个block 一个map slot,针对每条记录调用一次map 函数。在这有必要说一下Map类包含哪几个可重写或者必须重新的方法 I、setup 方法,这个方法可以重写,不做便利,每个map任务或者reduce任务只调用一次,用户参数的初始化。 II、run 方法,这个方法也可以重写,该方法内部实现很简单 只有一个while 循环,当recordreader.hasNext() 则调用一次map方法 III、map方法,在第二步中说了,当recordreader判断是否还有记录时,就会将数据key/value 映射到map 方法中,而map方法也是最主要的逻辑计算方法。 3)当进行必要的计算后,map 进行输出产生中间数据,数据在内存中,当数据达到一定的大小时,开始spill到硬盘上,这个地方说一下,默认当spill发生三次时或调用combiner组件,进行map阶段的reduce,spill阀值的参数是由min.num.spills.for.combine 来设定的,默认是3,很容易理解,如果每个map在merge的时候都没有经过三次以上的spill那即使设置了Combiner类,那也不会调用,所以这又要注意第二点。 每个map任务的输出中间数据都有一个环形内存缓冲区,这个参数由:io.sort.mb 默认是100m,hadoop有一个不好的地方是,有的地方参数设置的是字节,有的地方可以用上述的方式来设定,比如100m 这个根据实际内存大小可适度调整,当缓冲区的内存内容达到io.sort.mb*io.sort.spill.percent时,就开始启动一个后台线程将内容spill到硬盘中,io.sort.spill.percent 默认是0.80 也就是80%,在写磁盘的过程中,map输出的数据继续写剩余的20%空间,这样两个线程同时操作,如果缓冲空间被填满,那map就会阻塞一直到写磁盘过程完成,释放足够的缓冲区。所以单一调大io.sort.mb 也许是不够的,还需要调整spill 比例,进而降低这map阻塞情况的发生,这两个值都是经验值,根据实际的数据来设定这两个值。而spill 则是按照轮训的方式将数据写入到mapred.local.dir 这个路径默认是${hadoop.tmp.dir}/mapred/local 也就是说在临时目录里面,如果集群重启,数据将丢失,建议修改此目录。而实际sort的方法是可以自定义的 比如默认的sort方法是QuickSort 是由map.sort.class 来定义的,可参照QuickSort来定义sort方法。 Combiner的编写要符合
-
- Combiner的输入必须和Reducer的输入一致;
- Combiner的输出必须和Reduce的输入一致;
- 最后结果有无Combiner必须一致,否则job 必然是错误的。
4)分组,根据map输出key 对中间数据进行分组,分组类可以根据需求自定义,实现自定义分组类,可以实现一些功能,比如:key 虽然不一致,但是有关联,那就将这两 类数据分到一个组,举个例子,现在北京买房,必须以家庭为单位进行考核,如果有一套房,可以再买一套,如果家庭有两套房,那就不允许再买。 我们输入的数据是 {a家庭-张三,有房} {b家庭-王五,有房} {a家庭-李四,有房} 如果不重定义分组函数,那就无法统计出a家庭到底有几套房,就无法判断这个家庭是否有资格再买房,那就重新定义一个分组类,将split(key,'-')[0] 即用a家庭当做key进行分组,这样就一目了然了,这样又能达到家庭分组的目的,有不影响对个人的分析。 5)分区,在数据写磁盘之前(还在内存中)需要对数据进行一个分区,从而不同分区的数据对key 进行分配到不同的reduce中去。并且在各自分区内 数据是有序的这个过程是sort的过程,即对各分区的数据进行排序,看下图 ,这需要注意的是,不同于Sort phase 阶段,在copy阶段(paration分区是在copy阶段进行的)也有一个sort的过程,因为是分区内排序,所以关注的并不多。这也是有意义的,因为这样保证在Sort阶段数据相对有序的,减少下一阶段整体排序的时间消耗。tracker.http.threads 是控制文件分区的http 线程数量,默认是40 ,大型集群可以根据需求提高。 6) 4和6 都属于shuffle的过程, 7)中间数据压缩,如果采用中间数据压缩的话,可大大减少数据传递的资源消耗,减少传输的时间,进而减少job的整体时间,需要设置mapred.compress.map.output 设为true即可,默认的压缩算法是mapred.map.output.compression.codec指定的,默认是org.apache.hadoop.io.compress.DefaultCodec 后缀是.deflate 可以随意更改压缩库,压缩库是JAVA通过JNI来调用的,使用系统级资源效率会有所提升,所以如果系统没有安装的压缩算法,我们需要将压缩库加入到系统LD_LIBRARY_PATH 中。这样集群就可以使用我们自定义的压缩库。 8)reduce阶段,在此阶段,对已经排好序的输出中的每个键都要调用reduce函数,此阶段的输出一般是写入到HDFS,如果用到TableOutputFormat 或者DBOutputFormat 了那数据就会写入hbase 或者 关系型数据库。 至此,一个MapRedcue job 执行完成了。但是上面没有想详细的说Reduce阶段的三个子阶段,在这汇总说一下,在Map输出之后,所有map不可能全部同时完成,所以,只要有一个map task结束之后就开始了 此阶段的第一个阶段 Copy phase,copy 完成分区之后即开始第二个阶段Sort Phase 前面说过map阶段已经对数据进行sort,这次是在merge操作,所以理论上应该叫merge阶段,汇集各个map节点copy过来的数据并进行merge,再就进入了第8点说的reduce阶段。 (3)推测执行 MapReduce模型将作业分解成任务,然后并行的运行任务,以使作业的整体执行时间少于各个任务顺序执行的时间。这使得作业执行时间对运行缓慢的任务很敏感,因为只运行一个缓慢的任务会使得整个作业所用的时间远长于执其它任务的时间。当一个作业由几百或几千任务组成时,可能就出现了个别任务运行缓慢,从而导致真个job缓慢。 当系统发现执行比预期慢的任务(慢是系统认为的),它会在另外的机器上重新启动一个相同的任务,这样两个任务同时执行,哪个先结束,就会kill掉慢的那一个,这就是推测执行。 推测执行原则上是一种优化措施,但这并不表示推测执行可以提高作业的效率,进而减少整体时间,从所有的作业统计上,还没有发现几个后起的任务比先起的任务先运行完的先例,却是存在,但属于极个别的情况。再考虑的多一点,多运行一个任务,必然占用一个任务槽,且kill掉慢任务的时间是在快任务结束后,所以空浪费机器资源,且如果是因为作业的不可靠导致产生的推测执行,新任务也不肯能解决存在的问题,个人建议关闭推测执行(默认是开启的)。 推测执行设计map task的推测执行 和reduce task 的推测执行分别是: mapred.map.task.speculative.execution 默认 true; mapred.reduce.tasks.speculative.execution 默认 true; 3、Hadoop数据类型 前面说了MapReduce 中数据的传输是key value的方式,但是MapReduce框架并不允许他们是任意的类,只能是一些序列化的类。系统提供了一些常用的类:
BooleanWritable |
标准布尔变量的封装 |
ByteWritable |
单字节数的封装 |
DoubleWritable |
双字节数的封装 |
FloatWritable |
浮点数的封装 |
IntWritable |
整数的封装 |
LongWritable |
Long的封装 |
Text |
使用UTF8格式的文本封装 |
NullWritable |
无键值时的占位符 |
VIntWritable |
可变整形数的封装 |
这些类基本都实现了WritableComparable<T> 接口的类既可以是键又可以是值,前面说了Key Value 必须实现序列化的类,其实WritableComparable 类是Writable和java.lang.Comparable 接口的组合,那对于键来说,必须实现Comparable ,因为在shuffle阶段会对键进行排序,需要进行比较,而值则无所谓,仅仅做简单传递不会做比较。 当然可以自定义Writable类,那必须实现Writable 或WritableComparable接口。