Hadoop笔记(3)mapreduce原理和实践

前一篇文章中,我们详细介绍了Hadoop的核心存储组件——HDFS,本文我们继续介绍Hadoop的另外两个组件MapReduce和Yarn。在第一篇文章中,我们已经对MapReduce的整体结构做了介绍,并以求top3问题为例做了简要说明,核心就是计算的两个阶段:map阶段和reduce阶段。本文我们主要以案例的形式介绍具体执行过程。

1. wordcount

对于mapreduce的学习来说,wordcount(单词统计)无疑就是Hadoop版的helloword了。根据mapreduce思想,如果需要统计文本中的单词频次,可以有两种方法:第一种,先统计每个block中的词频(map阶段),再对前面的每个block词频做聚合(reduce阶段);第二种,更简单一些的处理方法,在map阶段只做单词筛选工作,不统计词频,词频统计全部放在reduce阶段执行。两种方法的结果肯定是一样的,区别只是第一种方法的map阶段词频是每个block的聚合,第二种方法的map阶段词频都是1,两种方法各有优劣。因为第二种方法比较简单,我们先实现第二种方法,如果需要手动编写一个mapreduce程序,主要就是实现 Mapper 和 Reducer 两个关键类,先放一下核心代码:

import java.io.IOException;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

public class WordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable>{
		
	@Override
	protected void map(LongWritable key, Text value, Context context)
			throws IOException, InterruptedException {
		   
		//获取每一行的文本内容
		String lines = value.toString();
		String[] words = lines.split(" ");
		
		for (String word :words) {
			context.write(new Text(word), new IntWritable(1));
		}
	}
}

这个继承自 Mapper 的 WordCountMapper 实现类就是主体程序MapTask的执行实现,再具体一点就是 map 方法,map方法里面定义的就是用户的业务逻辑,Hadoop每读取一个key-value,都会调用map方法(map方法其实是被run方法调用的)。map阶段需要定义四个参数类型,分别为 Mapper<KEYIN, VALUEIN, KEYOUT, VALUEOUT>,map方法的参数就是对应的输入key-value。

  • KEYIN 指框架读取到数据的key类型,在默认的读取数据组件InputFormat下,读取的key是一行文本的偏移量,所以key的类型是long类型的,这个比较容易理解
  • VALUEIN 指框架读取到数据的value类型,在默认的读取数据组件InputFormat下,读到的value就是一行文本的内容,所以value的类型是String类型的,只不过Hadoop做了一层高级封装,变成 Text 
  • KEYOUT 指用户自定义逻辑方法返回的数据中key的类型,这个是由用户业务逻辑决定的。在我们的单词统计当中,我们输出的是单词作为key,所以类型是String
  • VALUEOUT 指用户自定义逻辑方法返回的数据中value的类型,这个是由用户业务逻辑决定的。在我们的单词统计当中,我们输出的是单词数量作为value,所以类型是Integer

※ 注:String 、Long、Integer、null 都是jdk中自带的数据类型,在序列化的时候,效率比较低。hadoop为了提高序列化的效率,他就自己自定义了一套对应的数据结构 Text、LongWritable、IntWritable、nullWritable,我们在写程序时也要利用Hadoop提供的数据类型进行封装。当然,我们也可以自定义传输对象类,但是需要实现Hadoop的序列化机制,也就是自定义类需要实现 Writable 接口,并重写 write 和 readFields方法(见第3节)。另外还需要注意,自定义类如果定义了有参构造函数,还需要显式的定义无参构造函数,因为序列化框架在反序列化的时候创建对象实例会调用无参构造,并且字段的序列化顺序和反序列化顺序必须保持一致。

※ 注:在导包的时候一定要注意,Hadoop1和Hadoop2的包是不同的。

读取到文件后,因为VALUEIN是一行的内容,所以先做分词操作,得到每个单词后,按照词频1传给reduce即可。在reduce阶段,我们只需要对map的输出按照key聚合求和即可,程序如下:

import java.io.IOException;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;

public class WordCountReducer extends Reducer<Text, IntWritable, Text, IntWritable>{
	
	@Override
	protected void reduce(Text key, Iterable<IntWritable> values,
			Context context) throws IOException, InterruptedException {
		
		int count = 0;
		for(IntWritable v :values){
			count += v.get();
		}
		context.write(key, new IntWritable(count));
	}
}

reduce的实现,同样需要继承 Reducer 类,并实现 reduce 方法,同样也有四个参数: Reducer<KEYIN, VALUEIN, KEYOUT, VALUEOUT>,其中 KEYIN、 VALUEIN分别对应 map 阶段的KEYOUT 和 VALUEOUT,因为我们的输出结果是最终的词频,所以reduce的KEYOUT、VALUEOUT和map的KEYOUT、 VALUEOUT一样。这里需要注意reduce方法的参数,因为reduce阶段是对map阶段结果的聚合,所以 map 结果中key 相同的结果会进入到同一个reduce中,reduce方法中的参数key就是map输出结果的key,reduce方法中的参数values是一个迭代器,是map输出结果相同key对应的value集合,所以我们只需遍历这个迭代器,对所有值求和即可。

※ 注:map 结果中key 相同的结果会进入到同一个reduce中通过hashcode保证:key.hashcode % numReduceTask == 本ReduceTask编号。

我们已经写好了map和reduce过程,不过还需要一个驱动类,或者叫主类,用来指定我们定义好的map个reduce类,以及配置一些运行参数,主要有:jar包位置、map类、reduce类、map阶段的输出类型、reduce阶段的输出类型、数据读取组件 、数据输出组件等。当然,有些参数可以不在程序中设置,在shell中提交任务的时候再指定也可以。

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IntWritable;
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.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;

public class WordCountDriver {
	
	public static void main(String[] args) throws Exception {
		
		Configuration conf = new Configuration();
		conf.set("fs.defaultFS", "hdfs://namenode:9000");
        
        //设置运行在yarn上,通过hadoop提交不需要设置(hadoop本身已经配置了yarn),如果是在本地调试,可以设置提交到yarn上
        //本地运行提交到yarn上需要注意用户权限问题,还需要修改环境变量的格式,也可以配置文件使用hdfs,计算使用local
//		conf.set("mapreduce.framework.name", "yarn");
//		conf.set("yarn.resourcemanager.hostname", "mini1");
		Job job = Job.getInstance(conf);
		
	
		//设置程序所在jar包的位置,但是一般不直接设置(不灵活),而是通过下面的job.setJarByClass自动搜索主类
//		job.setJar("/root/wordcount.jar");
		job.setJarByClass(WordCountDriver.class);
		
		//设置程序所用的mapper类和reducer类
		job.setMapperClass(WordCountMapper.class);
		job.setReducerClass(WordCountReducer.class);
		
		//设置map阶段输出的数据类型,如果map阶段输出类型和下面reduce阶段的输出类型一致,可以不设置map的输出类型
		job.setMapOutputKeyClass(Text.class);
		job.setMapOutputValueClass(IntWritable.class);
		
        //设置reduce阶段输出的数据类型
		job.setOutputKeyClass(Text.class);
		job.setOutputValueClass(IntWritable.class);
		
		//设置数据读取组件和结果输出组件,默认就是Text,可以不设置
		//TextInputFormat是mapreduce程序中内置的一种读取数据组件,用来读取文本文件的输入组件
		job.setInputFormatClass(TextInputFormat.class);
		job.setOutputFormatClass(TextOutputFormat.class);
		
		//设置输入文件的路径
		FileInputFormat.setInputPaths(job, new Path("/wordcount/input"));
		
		//设置输出结果路径
		FileOutputFormat.setOutputPath(job, new Path("/wordcount/output"));
		
		boolean res = job.waitForCompletion(true);
		
		System.exit(res?0:1);	
	}
}

将程序打包以后,在shell中通过 hadoop jar wordcount.jar 主类名路径,即可提交任务,在yarn的resourcemanage的8088端口地址可以看到执行进度。

wordcount是一个比较简单的任务,我们通过一个mapreduce就可以完成,有些复杂逻辑任务可能通过一次mapreduce很难完成,可以设计多个mapreduce串联计算完成任务。

2. mapreduce执行过程

本节我们以上节的wordcount为例,介绍mapreduce的执行过程。如下图,假设我们的输入文件在/wordcount/input目录下,maptask数为3,reducetask数为2,多个maptask和reducetask也是Hadoop并行处理的实现方式,具体执行过程如下:

  1. 每个maptask首先通过inputformat(我们使用的是默认实现TextInputFormat,key、value分别是每行行首的文本偏移量和每行内容)读取指定的输入数据,一般每个maptask会根据就近原则尽量分配处理保存在和自己处于同一节点的block数据块,因为可以减少数据传输通信成本。
  2. 读取到数据后,maptask开始调用用户实现的WordCountMapper类map方法,在map方法中执行自定义的处理逻辑。
  3. map方法通过context.write将每一次的处理结果写入环形缓冲区。环形缓冲区是一块内存空间,默认大小是100M,一旦环形缓冲区的空间即将耗尽,环形缓冲区就会把溢出文件写入到磁盘中保存为溢出文件。关于环形缓冲区的介绍见后面内容。
  4. map阶段结束后,每个reducetask会首先去各个maptask所在节点上拉取数据(maptask的处理结果),保存到自己所在节点的磁盘中。数据拉取过程通过 map输出的key % reducetask个数  进行选择性拉取,这样首先保证了key相同的数据进入到同一个reducetask中(注意每个reducetask并不是只处理一个key数据,只要模运算结果相同的key都会进入到同一个reducetask),其次也一定程度上维护了数据均衡,不至于让每个reducetask处理的数据量差异太大。但是,这里还会存在一个问题,如果某个key的数据量真的很大,例如文件中有 999999 个hello,只有一个word,那么很显然处理hello这个key数据的reducetask一定会比处理word的reducetask执行时间要久。这是一个非常常见的数据不均衡问题,例如,后面我们介绍hive的时候,两个表 join ,如果join字段的频率分布极不均匀,那么很容易就导致数据倾斜问题,这条sql的执行时间也就会比较久。这里我们只是先抛出问题,自行思考,在hive篇再介绍解决方法。
  5. reducetask获取到数据后,会对key排序(可以观察最终的输出结果中,同一个文件内的key都是有序的),并分组,key相同的k-v对分入同一组,并调用用户自定义的WordCountReducer类的reduce方法,即统计计数。
  6. reducetask通过outputformat(我们使用的是默认实现TextOutputFormat,key、value分别是单词以及对应的词频)将结果写入到指定的输出目录中,输出文件数量和reducetask个数相同,外加一个隐藏的成功标识文件,TextOutputFormat的输出格式为每行一个k、v对,k、v之间通过 \t 分割。

3. 倒序输出

现在,我们对第一节的wordcount任务再加一个需求,要求对最终的结果按照词频倒序输出。我们知道,reduce在拉取到数据后,会对k-v对按照key正序排序,我们基于第1节的输出结果文件,如果定义map在写数据的时候,把词频作为key,reduce阶段不做处理,只要把接收到的k-v对按照v-k输出就可以了(保证单词在前,词频在后),就可以保证输出结果是有序的。但是还有一个问题,就是这个结果是正序的,为了倒序输出,我们只需要修改hadoop的比较逻辑即可。为了进行更普适性的开发,我们对词频结果按照类封装。第1节中,我们提到了Writable接口,Hadoop还提供了WritableComparable接口,其实WritableComparable就是比Writable多了一个比较方法。定义封装词频的类如下:

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;

import org.apache.hadoop.io.Writable;
import org.apache.hadoop.io.WritableComparable;

public class CountBean implements WritableComparable<CountBean>{
	
	private long wordcount;
	
	//序列化框架在反序列化的时候创建对象的实例会去调用我们的无参构造函数
	public CountBean() {
		
	}

	public CountBean(long wordcount) {
		super();
		this.wordcount = wordcount;
	}
	
	public void set(long wordcount) {
		this.wordcount = wordcount;
	}

	public long get() {
		return wordcount;
	}

	public int compareTo(CountBean o) {
		return (int)(this.wordcount - o.get());
	}

	public void write(DataOutput out) throws IOException {
        out.writeLong(wordcount);
	}

	public void readFields(DataInput in) throws IOException {
        this.wordcount = in.readLong();
	}
    
    @Override
    public String toString() {
        return wordcount + '\t';
    }
}

这里再次提醒,在序列化和反序列化的时候,顺序一定要一致。

在map方法中定义读取第一节的输出文件,分词后把词频通过上面定义的CountBean类封装作为key,单词作为value输出,reduce收到CountBean后,会按照自定义的compareTo逻辑比较排序(倒序),这样reduce不需要处理,只要把收到的value(单词)作为key,key(词频)作为value输出即可。当然,因为排序是全局操作,所以输出结果只有一个文件(一个reduce汇总)。

至此,我们通过两个mapreduce完成了wordcount的倒序输出,是否可以通过一个mapreduce完成呢?其实也有方法,不过要注意在实际业务开发中,一步完成的未必是好的,多步完成的也未必是不好的,好的程序一定是首先保证逻辑清晰,易维护。

为了保证在一个mapreduce中完成汇总排序工作,我们就要修改第一节的程序,map需要读文件转换为k-v不能改,只能修改reduce操作,也就是说我们不能每完成一个key聚合操作就输出,需要在reduce中缓存所有数据结果,然后按照词频排序输出。因此,我们可以在Reducer类中的reduce方法外面定义一个类全局集合变量(例如 TreeMap),保存所有汇总结果,然后再对集合进行排序,最后输出所有结果。因为原来的reduce是每处理一组k-v就输出,所以现在就要删掉 context.write 操作,但是怎么输出最终的结果呢?我们查看Hadoop的源码可以发现,是 run 方法循环调用了reduce方法,然后再调用cleanup方法,所以我们可以重写cleanup方法,在cleanup方法中输出TreeMap中的结果即可。cleanup定义如下:

@Override
protected void cleanup(Context context) throws IOException, InterruptedException {
    
    Set<Entry<CountBean, Text>> entrySet = treeMap.entrySet();
    for(Entry<CountBean, Text> ent : entrySet){
        context.write(ent.getValue(), ent.getKey());
    }
}

虽然我们通过一个mapreduce就完成了汇总排序输出,然而在实际应用中也并不建议这样做,尤其是当数据量很大的时候。

4. 自定义Partition和Combiner

上面是一个排序输出逻辑,我们还会遇到一些特殊需求,例如把结果按照分组文件输出,例如前两位一样的单词输出到同一个文件中,地址属于同一个省级行政区的输出到同一个文件中等等。因为map到reduce是按照key汇总的,所以我们是否可以通过定义key来进行分组呢?比如,基于第一节的输出结果,map读取文件后取单词的前两位作为key,完整单词和词频都封装到value中,在reduce中再把完整的单词还原出来,这样就可以保证前两位相同的单词进入到同一个reducetask中了,也即输出到同一个文件中。但是这样还有一个问题,我们虽然保证了前两位相同的单词输出到同一个文件,但是前两位不同的单词也可能会进入到同一个文件中,因为我们知道maptask到reducetask的传输是通过key.hashcode % reducetask数 分组的,不同的字符串计算结果也可能相同。这里我们就需要用到自定义分区技术。

Hadoop的mapreduce也为我们暴露了分区配置接口,我们可以自定义分区类,我们以地址为例,自定义分区代码如下:

import java.util.HashMap;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Partitioner;

public class ProvivcePartitioner extends Partitioner<Text, CountBean>{
	private static HashMap<String, Integer> provinceMap = new HashMap<String, Integer>();
	
	static{
		provinceMap.put("上海市", 0);
		provinceMap.put("江苏省", 1);
		provinceMap.put("浙江省", 2);
		provinceMap.put("安徽省", 3);
	}

	@Override
	public int getPartition(Text key, CountBean value, int numPartitions) {
            Integer code = provinceMap.get(key.toString().substring(0, 3));
            if(code != null){
            	return code;
            }
		return 4;
	}
}

自定义分区类,继承 Partitioner 类并重写 getPartition 方法,getPartition方法的参数 key、value分别是map输出的key、value。然后在主程序中设置我们自定义的分区类以及reducetask个数即可:

job.setPartitionerClass(ProvivcePartitioner.class);
job.setNumReduceTasks(5);

这里要注意默认一个partition分区对应一个reduce task,也就是一个输出文件,如果我们的Reduce task个数小于 partition 分区数  就会报错Illegal partition,如果我们的Reduce task个数 大于 partition 分区数,不会报错,不过会有空文件产生,如果我们的Reduce task个数为 1,则 partitoner 组件就无效了,也就是不存在分区。

在第一节中我们开头就提到了,实现wordcount有两种方法,第一节的程序实现了第二种方法,即每一个单词都单独传到reduce中,这种方法虽然简单,但是毫无疑问会带来大量的IO传输。现在我们来看第一种方法,在map阶段先局部统计一次处理文件的词频,然后再把局部统计结果一次性发送给reduce,这样显然可以减少IO传输,mapreduce也确实支持这样的操作。

Hadoop提供了一种Combiner操作,允许用户在map阶段自定义局部reduce操作,自定义Combiner类的形式和定义Reduce一样,只需要继承Reducer类即可,对于wordcount案例,其实combiner的处理逻辑和reduce阶段的处理逻辑是一致的,所以我们可以直接复用定义好的 WordCountReducer 类,只需要在主程序中设置Combiner操作即可:

job.setCombinerClass(WordCountReducer.class);

5. 环形缓冲区

在第2节中我们提到map阶段的数据会写到环形缓冲区中,并保存到磁盘中形成溢出文件。现在,我们剖析环形缓冲区的内部机制。

在map端业务逻辑走完后,会调用MapOutputCollector.collect()输出结果,其中MapOutputCollector这个接口有两个实现类MapOutputBuffer和DirectMapOutputCollector,后者是在没有ReduceTask时调用的,map结果直接写入HDFS,而前者就是环形缓冲区所在地。环形缓冲区是一块默认大小100M的内存空间,其中80%(溢出比)存储文件,另外20%保留(用于排序)。也就是说,当环形缓冲区中的文件达到80M时,文件开始溢出到磁盘中,此时Hadoop会调用溢出组件 spiller.spill() 将环形缓冲区中的数据按照分区排序写到溢出文件中,可能会有多个溢出文件,分区通过 partitioner.getPartition(k,v,m) 实现,通过key.compareTo实现快速排序(在剩下的保留区中进行),所以溢出文件是分区的,例如:分区0对应a,分区1对应b等。maptask在处理完数据后,会依次merge(合并)所有的溢出文件为一个文件,如果设置了Combiner操作,Combiner过程就是在合并操作中完成的,同时,合并后的文件还会附带一个索引文件用于记录每个分区(key)的偏移量,这样每个 reducetask 在拉取数据的时候,根据索引文件就可以快速获取数据了。

reducetask拉取所有maptask的对应数据到自己节点上,并对所有数据进行归并排序分区,所有key相同的数据就汇聚到同一个分区中,并且分区按照key排序。

介绍完环形缓冲区,我们还有一个问题没有解释清楚,在第2节中我们提到reducetask会到每个maptask所在节点上的溢出文件中拉取数据,比如 reducetask0 会拉取溢出文件分区0的数据,reducetask1 会拉取溢出文件分区1的数据等等,那么每个reducetask怎么知道自己应该拉取哪些数据呢?这就涉及到我们mapreduce作业的管理员了—— MRAppMaster,MRAppMaster 负责管理MapReduce作业的生命周期,包括创建MapReduce作业,向ResourceManager申请资源,与NodeManage通信要求其启动Container,监控作业的运行状态,当任务失败时重新启动任务等。map阶段结束后,MRAppMaster会根据分区数启动相应个数的 reducetask ,并告知每个reducetask应该去拉取(http下载)哪些数据,如果用户指定了reducetask数量,则 MRAppMaster 就按照用户设置启动相应数量的reducetask。

从maptask往环形缓冲区写文件开始到reducetask将数据拉取到自己所在节点上完成文件的归并排序,然后调用自定义的reduce操作处理逻辑之前的整个过程也称之为 shuffle。整个 shuffle 过程的核心操作包括数据的缓存、分区、排序、分发传输等。

6. maptask并行机制

上面我们介绍了 reducetask 的启动机制,应该启动多少个reducetask,那么我们不免还有一个疑问:maptask 的数量是怎么确定的呢?又是谁来管理的呢?这里就要引出maptask的并行度概念了。

在前面的内容中,我们为了便于理解,介绍的时候是说maptask会去读对应的block数据块,其实实际情况并非完全如此。如下图,假设我们的单词文件在 /wordcount/input 目录下,分别有 a.log、b.log、c.log三个文件,文件大小分别为 300M、260M、100M。maptask的并行机制过程如下:

  1. mapreduce程序客户端开始遍历目录下的所有文件,对每一个文件,根据文件大小和切片大小(split size,默认等于block size,所以前面介绍maptask读文件的时候一直使用block这个概念)对文件进行切片。注意这里的切片只是逻辑上的切片,即只计算切片大小和信息,并不进行真正的切片操作。然后,把切片信息序列化到job.split文件中。这段逻辑及形成的切片规划描述文件,由JobSubMite调用FileInputFormat实现类(getSplits()方法)完成。
  2. 客户端把jar包、配置信息、job.split文件等信息提交给 MrAppMaster ,MrAppMaster根据切片数量向集群申请机器启动对应数量的maptask,所以切片操作是在maptask启动之前完成的。

在上面3个文件中,b.log是比较特殊的,可以发现b的大小是260M,如果按照block size为128M计算,b.log应该切片为3,但是第三个文件大小只有4M。如果为4M的文件再单独分配一个maptask,显然比较浪费资源,因此,Hadoop的内部机制规定,如果剩余文件大小不超过split size的1.1倍,就作为一个切片处理。因此,mapredue如果需要处理大量小文件,还可以修改split的逻辑,把多个小文件作为一个文件处理,当然这种方式是非常不推荐的,建议还是在把文件上传到hdfs之前,先合并小文件。

前面我们介绍split size的大小默认等于block size,这个参数也是可设置的,split size的计算方式为:  max(minSize, min(maxSize, blockSize))

  • minSize的设置参数为:mapreduce.input.fileinputformat.split.minsize,默认为1,可以发现,minSize的值在小于blockSize时设置是没有意义的。
  • maxSize的设置参数为:mapreduce.input.fileinputformat.split.maxsize,默认是Long.MAXValue,可以发现,maxSize的值在大于blockSize时设置是没有意义的。

因此,如果想调小split size的值,则可以设置 mapreduce.input.fileinputformat.split.maxsize 小于 blockSize 即可,如果想调大split size的值,则可以设置 mapreduce.input.fileinputformat.split.minsize 大于 blockSize 即可。正常情况下,每个map的执行时间最少一分钟,如果job的每个map或者 reduce task的运行时间都只有30-40秒钟,那么就减少该job的map或者reduce数,如果每个task都非常快就跑完了,就会在task的开始和结束的时候浪费太多的时间。另外,对于task运行较短的问题,还可以配置task的JVM重用来改善。配置参数为:mapred.job.reuse.jvm.num.tasks,默认是1,表示一个JVM上最多可以顺序执行的task数目(属于同一个Job)是1,也就是说一个task启一个JVM,如果设置大于1,表示一个JVM可以运行多个task。

至此,我们就介绍完了整个mapreduce过程,关于mapreduce的大管家 MrAppMaster 的介绍本文介绍的并不多,因为具体过程还和 yarn 关系比较密切,在下一篇介绍yarn的文章中我们会再介绍。虽然现在很少需要自己去手动编写mapreduce程序,但是理解mapreduce的思想仍然有很重要的借鉴意义,而且使用非常广泛的数仓数据库hive的底层处理逻辑使用的也是mapreduce,理解mapreduce的思想对我们优化hive sql会有很大帮助。

7. 分布式缓存

在Hadoop程序中,我们可能需要将一些配置信息或者属性表给其他MR任务使用,如果是少量的参数问题,我们可以使用配置信息或者以静态变量的形式定义一个Map等集合保存。但是当数据量很大的时候就不太合适了,此时,就需要引入MR任务的分布式缓存了。分布式缓存可以将用户指定的文件加载到缓存中,并分发到maptask所在节点中供maptask使用。下面以一个map端的表join为例介绍使用方式:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.net.URI;
import java.util.HashMap;

import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

public class MapJoinDistributedCacheFile {
	private static final Log log = LogFactory.getLog(MapJoinDistributedCacheFile.class);
	public static class MapJoinDistributedCacheFileMapper extends Mapper<LongWritable, Text, Text, NullWritable>{
	
		FileReader in = null;
		BufferedReader reader = null;
		HashMap<String,String[]> b_tab = new HashMap<String, String[]>();
		
		@Override
		protected void setup(Context context)throws IOException, InterruptedException {
			// 此处加载的是产品表的数据
			in = new FileReader("pdts.txt");
			reader = new BufferedReader(in);
			String line =null;
			while(StringUtils.isNotBlank((line=reader.readLine()))){
				String[] split = line.split(",");
				String[] products = {split[0],split[1]};
				b_tab.put(split[0], products);
			}
			IOUtils.closeStream(reader);
			IOUtils.closeStream(in);
		}
		
		@Override
		protected void map(LongWritable key, Text value,Context context)
				throws IOException, InterruptedException {
			String line = value.toString();
			String[] orderFields = line.split(",");
			String pdt_id = orderFields[1];
			String[] pdtFields = b_tab.get(pdt_id);
			String ll = orderFields[0] + "\t" + pdtFields[1] + "\t" + orderFields[1] + "\t" + orderFields[2] ;
			context.write(new Text(ll), NullWritable.get());
		}
	}
	
	public static void main(String[] args) throws Exception {
		Configuration conf = new Configuration();
		Job job = Job.getInstance(conf);
		
		job.setJarByClass(MapJoinDistributedCacheFile.class);
		job.setMapperClass(MapJoinDistributedCacheFileMapper.class);
		
		job.setOutputKeyClass(Text.class);
		job.setOutputValueClass(NullWritable.class);
		
		FileInputFormat.setInputPaths(job, new Path("D:/mapjoin/input"));
		FileOutputFormat.setOutputPath(job, new Path("D:/mapjoin/output"));
			
		job.setNumReduceTasks(0);
		
        //指定缓存文件
		job.addCacheFile(new URI("file:/D:/pdts.txt"));
//		job.addCacheFile(new URI("hdfs://namenode:9000/cachefile/pdts.txt"));
		
		job.waitForCompletion(true);
	}
}

在主程序中指定缓存文件,然后重写Map类的setup方法,在setup方法中读取缓存文件保存在HashMap中,在map方法逻辑中就可以直接使用了。

8. 补充知识

本节是一些不常用的小知识点,作为补充介绍,但是在特殊情况下,可以满足特殊需求。

(1)全局计数器

在我们的mapreduce作业中,读取数据的时候,可能某些数据字段是不完整的,或者数据是脏数据,不符合业务逻辑甚至导致异常,对于这些数据我们肯定是跳过不处理,但是我们又想知道有多少这样的记录,mapreduce就为我们提供了一个全局计数器,使用如下:

//定义在mapreduce类外面
enum MyCounter{MALFORORMED,NORMAL}

@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
	Counter counter = context.getCounter(MyCounter.MALFORORMED);
	String line = value.toString();

	String[] fields = StringUtils.split(line, "\t");
	try {
		    ......
		    context.write(new Text(result), NullWritable.get());
		} catch (Exception e) {
			counter.increment(1);
            //context.getCounter(MyCounter.MALFORORMED).increment(1);
		}
}

在执行程序的log中,我们就会看到我们定义的全局计数器输出值。

(2)job串联

前面我们已经提到过,有些任务我们通过一个mapreduce可能是无法完成的,需要编写多个mapreduce程序,在执行的时候,我们怎么把这些mapreduce串联起来执行呢?如果我们自己实现,显然我们可以通过编写一个shell脚本实现,根据前一个任务是否执行成功来决定是否启动下一个任务。当然,如果你熟悉Oozie工作流,那么通过hue配置Oozie工作流就更方便了。此外,如果你是用的计算引擎是Tez,那么Tez可以将多个有依赖的作业转换为一个作业,而且性能更高,当然关于Tez的介绍就是后话了。

其实,Hadoop也我们的多mapreduce任务提供了工具类,程序如下:

ControlledJob cJob1 = new ControlledJob(job1.getConfiguration());
ControlledJob cJob2 = new ControlledJob(job2.getConfiguration());
ControlledJob cJob3 = new ControlledJob(job3.getConfiguration());

cJob1.setJob(job1);
cJob2.setJob(job2);
cJob3.setJob(job3);

// 设置作业依赖关系
cJob2.addDependingJob(cJob1);
cJob3.addDependingJob(cJob2);

JobControl jobControl = new JobControl("RecommendationJob");
jobControl.addJob(cJob1);
jobControl.addJob(cJob2);
jobControl.addJob(cJob3);

// 新建一个线程来运行已加入JobControl中的作业,开始进程并等待结束
Thread jobControlThread = new Thread(jobControl);
jobControlThread.start();
while (!jobControl.allFinished()) {
    Thread.sleep(500);
}
jobControl.stop();

return 0;

(3)数据压缩

数据压缩是mapreduce的一种优化策略:通过压缩编码对mapper或者reducer的输出进行压缩,以减少磁盘IO,提高MR程序运行速度(但相应增加了cpu运算负担)。所以对于运算密集型的job,少用压缩,对于IO密集型的job,可以多用压缩,压缩特性运用得当能提高性能,但运用不当也可能降低性能。MR支持的压缩编码格式如下:

在配置参数或在代码中都可以设置压缩方法,map的输出压缩设置:

##配置参数设置
mapreduce.map.output.compress=false
mapreduce.map.output.compress.codec=org.apache.hadoop.io.compress.DefaultCodec
//程序设置
conf.setBoolean(Job.MAP_OUTPUT_COMPRESS, true);
conf.setClass(Job.MAP_OUTPUT_COMPRESS_CODEC, GzipCodec.class, CompressionCodec.class);

reduce输出压缩:

mapreduce.output.fileoutputformat.compress=false
mapreduce.output.fileoutputformat.compress.codec=org.apache.hadoop.io.compress.DefaultCodec
mapreduce.output.fileoutputformat.compress.type=RECORD
FileOutputFormat.setCompressOutput(job, true);
FileOutputFormat.setOutputCompressorClass(job, (Class<? extends CompressionCodec>) Class.forName(""));

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值