MapReduce概述

1 名词解释

Job(作业) :  一个MR程序称为一个Job。

MRAppMaster(MR任务的主节点): 一个Job在运行时,会先启动一个进程,这个进程为 MRAppMaster。它负责Job中执行状态的监控,容错,和RM申请资源,提交Task等!

Task(任务):  Task是一个进程!负责某项计算!

Map(Map阶段):

Map是MapReduce程序运行的第一个阶段!Map阶段的目的是将输入的数据,进行切分。将一个大数据,切分为若干小部分!
切分后,每个部分称为1片(split),每片数据会交给一个Task(进程)进行计算!Task负责Map阶段程序的计算,称为MapTask! 

在一个MR程序的Map阶段,会启动N(取决于切片数)个MapTask。每个MapTask是并行运行!

Reduce(Reduce阶段):

Reduce是MapReduce程序运行的第二个阶段(最后一个阶段)!
Reduce阶段的目的是将Map阶段,每个MapTask计算后的结果进行合并汇总!得到最终结果!
Reduce阶段是可选的,即可以不要。
负责Reduce阶段程序的计算的Task称为ReduceTask。 一个Job可以通过设置,启动N个ReduceTask,这些ReduceTask也是并行运行!且每个ReduceTask最终都会产生一个结果!

2 MapReduce中常用的组件

  • Mapper:   map阶段核心的处理逻辑
  • Reducer:   reduce阶段核心的处理逻辑
  • InputFormat(输入格式):

MR程序必须指定一个输入目录,一个输出目录!InputFormat代表输入目录中文件的格式!
如果是普通文件,可以使用FileInputFormat.
如果是SequeceFile(hadoop提供的一种文件格式),可以使用SequnceFileInputFormat.
如果处理的数据在数据库中,需要使用DBInputFormat

  • RecordReader:  记录读取器,RecordReader负责从输入格式中,读取数据,读取后封装为一组记录(k-v)!
  • OutPutFormat: 输出格式

OutPutFormat代表MR处理后的结果,要以什么样的文件格式写出!
将结果写出到一个普通文件中,可以使用FileOutputFormat!
将结果写出到数据库中,可以使用DBOutPutFormat!
将结果写出到SequeceFile中,可以使用SequnceFileOutputFormat

  • RecordWriter: 记录写出器

RecordWriter将处理的结果以什么样的格式,写出到输出文件中!

  • Partitioner: 分区器

在Mapper将数据写出时,为每组key-value打上标记,进行分区!目的: 一个ReduceTask只会处理一个分区的数据!

3 MapReduce执行大致流程

  1. InputFormat调用RecordReader,从输入目录的文件中,读取一组数据,封装为keyin-valuein对象
  2. 将封装好的key-value,交给Mapper.map()------>将处理的结果写出 keyout-valueout
  3. ReduceTask启动Reducer,使用Reducer.reduce()处理Mapper写出的keyout-valueout,
  4. OutPutFormat调用RecordWriter,将Reducer处理后的keyout-valueout写出到文件

3.1 举例说明MapReduce大致流程

需求: 统计某目录中每个文件的单词数量,a-p开头的单词放入到一个结果文件中,q-z开头的单词放入到一个结果文件中。

mapreduce 的jar包在哪个目录 mapreduce的job_mapreduce

总结

Map阶段(MapTask):  切片(Split)-----读取数据(Read)-------交给Mapper处理(Map)------分区和排序(sort)
Reduce阶段(ReduceTask):  拷贝数据(copy)------排序(sort)-----合并(reduce)-----写出(write)

4 切片

由于默认的InputFormat接口使用的是TextInputFormat类,所以我们只需要查看该类的getSplits方法,该方法位于TextInputFormat的父类FileInputFormat中。

public List<InputSplit> getSplits(JobContext job) throws IOException {
	StopWatch sw = (new StopWatch()).start();
	// minSize从mapreduce.input.fileinputformat.split.minsize和1之间对比,取最大值
	long minSize = Math.max(this.getFormatMinSplitSize(), getMinSplitSize(job));
	// 读取mapreduce.input.fileinputformat.split.maxsize,如果没有设置使用Long.MaxValue作为默认值
	long maxSize = getMaxSplitSize(job);
	// 开始切片
	List<InputSplit> splits = new ArrayList();
	// 获取当前job输入目录中所有文件的状态(元数据)
	List<FileStatus> files = this.listStatus(job);
	boolean ignoreDirs = !getInputDirRecursive(job) && job.getConfiguration().getBoolean("mapreduce.input.fileinputformat.input.dir.nonrecursive.ignore.subdirs", false);
	Iterator var10 = files.iterator();
	while(true) {
		while(true) {
			while(true) {
				FileStatus file;
				// 如果输入目录中的文件已全部切片,没有可执行文件了,就结束循环
				do {
					if (!var10.hasNext()) {
						job.getConfiguration().setLong("mapreduce.input.fileinputformat.numinputfiles", (long)files.size());
						sw.stop();
						return splits;
					}
					file = (FileStatus)var10.next();
				} while(ignoreDirs && file.isDirectory());
				
				// 获取文件路径,开始切片逻辑
				Path path = file.getPath();
				long length = file.getLen();
				if (length != 0L) {
					// 获取文件的块信息
					BlockLocation[] blkLocations;
					if (file instanceof LocatedFileStatus) {
						blkLocations = ((LocatedFileStatus)file).getBlockLocations();
					} else {
						FileSystem fs = path.getFileSystem(job.getConfiguration());
						blkLocations = fs.getFileBlockLocations(file, 0L, length);
					}
					// 判断指定文件是否可切,如果可切,就进行切片
					if (this.isSplitable(job, path)) {
						long blockSize = file.getBlockSize();
						// 计算片大小
						long splitSize = this.computeSplitSize(blockSize, minSize, maxSize);
						// 声明待切部分数据的剩余大小
						long bytesRemaining;
						int blkIndex;
						// 如果 待切部分 / 片大小  > 1.1,先切去一片,再判断
						for(bytesRemaining = length; (double)bytesRemaining / (double)splitSize > 1.1D; bytesRemaining -= splitSize) {
							// 获取开始切片的offset是哪一个block
							blkIndex = this.getBlockIndex(blkLocations, length - bytesRemaining);
                            // 执行一次切片,并放入切片集合中
							splits.add(this.makeSplit(path, length - bytesRemaining, splitSize, blkLocations[blkIndex].getHosts(), blkLocations[blkIndex].getCachedHosts()));
						}
						// 将剩余不能继续切的部分,作为一个片
						if (bytesRemaining != 0L) {
							blkIndex = this.getBlockIndex(blkLocations, length - bytesRemaining);
							splits.add(this.makeSplit(path, length - bytesRemaining, bytesRemaining, blkLocations[blkIndex].getHosts(), blkLocations[blkIndex].getCachedHosts()));
						}
					} else {
						// 文件不可切,整个文件作为1片!
						splits.add(this.makeSplit(path, 0L, length, blkLocations[0].getHosts(), blkLocations[0].getCachedHosts()));
					}
				} else {
				   // 文件是个空文件,创建一个切片对象,这个切片从当前文件的 0 offset起,向后读取0个字节
					splits.add(this.makeSplit(path, 0L, length, new String[0]));
				}
			}
		}
	}
}

 总结:

①获取当前输入目录中所有的文件

②以文件为单位切片,如果文件为空文件,默认创建一个空的切片

③如果文件不为空,尝试判断文件是否可切(不是压缩文件,都可切)

④如果文件不可切,整个文件作为1片

⑤如果文件可切,先获取片大小(默认等于块大小),循环判断  待切部分/ 片大小 > 1.1倍,如果大于先切去一片,再判断…

⑥剩余部分整个作为1片

4.1 TextInputFormat判断文件是否可切

protected boolean isSplitable(JobContext context, Path file) {
    // 根据文件的后缀名获取文件使用的相关的压缩格式
	CompressionCodec codec = (new CompressionCodecFactory(context.getConfiguration())).getCodec(file);
	// 如果文件不是一个压缩类型的文件,默认都可以切片,否则判断是否是一个可以切片的压缩格式,默认只有Bzip2压缩格式可切片(SplittableCompressionCodec只有一个BZip2Codec子类)
	return null == codec ? true : codec instanceof SplittableCompressionCodec;
}

5 片大小的计算

/**
blockSize: 块大小
minSize: minSize从mapreduce.input.fileinputformat.split.minsize和1之间对比,取最大值
maxSize: 读取mapreduce.input.fileinputformat.split.maxsize,如果没有设置使用Long.MaxValue作为默认值
*/
protected long computeSplitSize(long blockSize, long minSize, long maxSize) {
	return Math.max(minSize, Math.min(maxSize, blockSize));
}

可看出默认的片大小就是文件的块大小。文件的块大小默认为128M,所以默认每片就是128M!

调节片大小 > 块大小:配置 mapreduce.input.fileinputformat.split.minsize > 128M

调节片大小 < 块大小:配置 mapreduce.input.fileinputformat.split.maxsize < 128M

理论上来说:如果文件的数据量是一定的话,片越大,切片数量少,启动的MapTask少,Map阶段运算慢!片越小,切片数量多,启动的MapTask多,Map阶段运算快!

6 片和块的关系

片(InputSplit):在计算MR程序时,才会切片。片在运行程序时,临时将文件从逻辑上划分为若干部分!使用的输入格式不同,切片的方式不同,切片的数量也不同!每片的数据最终也是以块的形式存储在HDFS!

块(Block): 在向HDFS写文件时,文件中的内容以块为单位存储!块是实际的物理存在!

MapTask在读取切片的内容时,需要根据切片的metainfo,获取到当前切片属于文件的哪部分! 再根据此信息去寻找对应的块,读取数据!

建议: 片大小最好等于块大小!将片大小设置和块大小一致,可以最大限度减少因为切片带来的磁盘IO和网络IO!。

原因: MR计算框架速度慢的原因在于在执行MR时,会发生频繁的磁盘IO和网络IO!

mapreduce 的jar包在哪个目录 mapreduce的job_大数据_02

可以看出当切片大小不等于块大小时,MapTask2和MapTask3需要从其他DataNode上去拷贝所需数据至自己机器上参与计算,这无疑增加了网络IO

7 InputFormat

7.1 TextInputFormat

TextInputFormat是默认的InputFormat。每条记录是一行输入。键是LongWritable类型,存储该行在整个文件中的起始字节偏移量。值是这行的内容,不包括任何行终止符(换行符和回车符)。

例如文本内容:

mapreduce 的jar包在哪个目录 mapreduce的job_数据_03

解析出来的map:

mapreduce 的jar包在哪个目录 mapreduce的job_mapreduce_04

7.2 KeyValueTextInputFormat

每一行均为一条记录,被分隔符分割为key,value。可以通过在设置conf.set(KeyValueLineRecordReader.KEY_VALUE_SEPERATOR, "\t");来设定分隔符。默认分隔符是tab(\t)。

例如文本内容,以——>进行分割:

mapreduce 的jar包在哪个目录 mapreduce的job_mapreduce_05

解析出来的map:

mapreduce 的jar包在哪个目录 mapreduce的job_临时文件_06

此时的键是每行排在制表符之前的Text序列。

7.3 NLineInputFormat

如果使用NlineInputFormat,代表每个map进程处理的InputSplit不再按Block块去划分,而是按NlineInputFormat指定的行数N来划分。即切片数 = 输入文件的总行数/N,如果不整除,切片数=商+1。

举例文本内容:

mapreduce 的jar包在哪个目录 mapreduce的job_mapreduce_07

假设N = 2, 则每个输入分片包含两行。开启2个MapTask。

mapper1:

mapreduce 的jar包在哪个目录 mapreduce的job_大数据_08

mapper2:

 

mapreduce 的jar包在哪个目录 mapreduce的job_mapreduce_09

这里的键和值与TextInputFormat生成的一样。

7.4 CombineTextInputFormat

框架默认的TextInputformat切片机制是对任务按文件规划切片,不管文件多小,都会是一个单独的切片,都会交给一个MapTask,这样如果有大量小文件,就会产生大量的MapTask,处理效率极其低下。

应用场景

CombineTextInputFormat用于小文件过多的场景,它可以将多个小文件从逻辑上规划到一个切片中,这样,多个小文件就可以交给一个MapTask处理。

RecoedReader :  LineRecordReader(将一行封装为一个key-value) key(LongWritable): 行的偏移量, value(Text):  行的内容                    

虚拟存储切片最大值设置

如下,可以根据实际的小文件大小情况来设置具体的值:

CombineTextInputFormat.setMaxInputSplitSize(job, 4194304);// 4m

也可以直接设置参数:

mapreduce.input.fileinputformat.split.maxsize

切片机制

流程:

一. 以文件为单位,划分part

①将输入目录下所有文件按照文件名称字典顺序一次读入,记录文件大小

②将每个文件划分为若干part

③判断: 文件的待切部分的大小 <=  maxSize,整个待切部分作为1part

④maxsize < 文件的待切部分的大小 <= 2* maxSize,将整个待切部分均分为2part

⑤文件的待切部分的大小 > 2* maxSize,先切去maxSize大小,作为1部分,剩余待切部分继续判断!

举例:  maxSize=2048

mapreduce 的jar包在哪个目录 mapreduce的job_临时文件_10

二. 将之前切分的若干part进行累加,累加后一旦累加的大小超过 maxSize,这些作为1片!如上图所示

8 MapTask

mapreduce 的jar包在哪个目录 mapreduce的job_数据_11

  1. 指定带有源数据,需要处理的文件
  2. 根据参数配置进行切片规划,并计算处MapTask的数量
  3. 每个MapTask使用默认的TextInputFormat和RecordReader将文本内容读取为key,value,并将key,value交给Mapper
  4. Mapper将解析出的key/value交给用户编写map()函数处理,并产生一系列所需的key/value
  5. 当数据处理完成后,一般会调用context.write()写出数据,而此方法中其实调用的是OutputCollector.collect()方法,该方法使用OutputBuffer将数据写入缓冲区,在写入缓冲区前会调用一次Partitioner为该条数据计算出一个分区号(决定了将来是由哪个ReduceTask处理这条数据)
  6. 每调用一次write方法就会调用收集线程将数据写入缓冲区,写入缓冲区时同时记录了下列信息:① index(数据写入缓冲区时的顺序) ② partition(分区号)    ③ keystart(key的偏移量) ④ valuestart(value的偏移量) ⑤ key      ⑥value
  7. 缓冲区中的数据达到缓冲区大小的80%,即溢写阈值时(缓冲区默认大小为100M,此处阈值为80M)
    ①将这些数据对key进行一次快速排序(排序时只排索引而不移动数据本身的位置,必要时对数据进行合并、压缩等操作)
    ② 按照分区编号由小到大依次将每个分区中的数据写入到一个名为spillx.out(x为溢写次数)的临时文件(每次溢写均会写出到一个单独的临时文件)
// 源码在MapTask类中的sortAndSpill方法
// this.partitions表示ReduceTask的数量(可见init方法中对该参数的赋值)
for(int i = 0; i < this.partitions; ++i) {
    // 按照分区将数据一条一条写出到指定溢写文件
    writer.append(key, value);
}
  1. ③ 最终效果:临时文件中的数据是按照分区编号由小到大,并且每个分区的数据都是按照key有序的存储
  2. 当所有数据全部溢写完后(最后一次溢写如果没有达到溢写阈值会执行flush操作),对所有溢写文件进行merge操作(将多个临时文件合并成一个文件),merge时将所有临时文件同一个分区的数据进行汇总,汇总后再排序(归并排序,因为每个临时文件内的数据其实都是有序的,那么将已有序的子序列合并,得到完全有序的序列,归并排序效率最高),最后合并成一个文件,最终效果这个文件的每个分区的数据都是有序的

mapreduce 的jar包在哪个目录 mapreduce的job_数据_12

说明:

  1. 收集线程和溢写线程互不干扰,但是当收集线程发现达到溢写阈值时就会唤醒溢写线程,同样的溢写线程写完后也会唤醒收集线程继续收集
  2. 系统执行排序的过程(即将Mapper输出作为输入传给Reducer)称为Shuffle

8.1 分区

怎么决定要多少个分区?是否需要自定义分区?

1. 分区的数量决定决定了ReduceTask的数量,所以间接决定了要生成文件的个数。简而言之,如果你最终需要将一个文件的内容按照某种规则分成N个文件,那么分区的数量就是N

2. 如果没有手动设定分区数量,那么默认采用Hash分区器,即根据key值按照hash算法算出一个分区号,分区号相同的被分到一个区(算法如下)。由此可见,默认的分区器我们是不可控制哪些数量被分到一个分区。所以如果我们有明确需求:需要将具有相同特征的数据分到一组处理,那么我们就需要使用自定义分区

public int getPartition(K2 key, V2 value, int numReduceTasks) {
  return (key.hashCode() & 2147483647) % numReduceTasks;
}

案例:

给定一个文件,该文件中每一行都有一个随机的手机号,此时我们需要将手机号136、137、138、139开头都分别放到一个独立的4个文件中,其他开头的放到一个文件中。

  1. 实现自定义分区器
/*
 * KEY, VALUE: Mapper输出的Key-value类型
 * FlowBean:自定义Mapper输出类型
 */
public class MyPartitioner extends Partitioner<Text, FlowBean>{

	// 计算分区  numPartitions为总的分区数,reduceTask的数量
	// 分区号必须为int型的值,且必须符合 0<= partitionNum < numPartitions
	@Override
	public int getPartition(Text key, FlowBean value, int numPartitions) {
		String suffix = key.toString().substring(0, 3);
		int partitionNum=0;
		
		switch (suffix) {
		case "136":
			partitionNum=numPartitions-1;
			break;
		case "137":
			partitionNum=numPartitions-2;
			break;
		case "138":
			partitionNum=numPartitions-3;
			break;
		case "139":
			partitionNum=numPartitions-4;
			break;
		default:
			break;
		}
		return partitionNum;
	}
}
  1. 主类中设置RedueTask的数量和自定义分区器
public class FlowBeanDriver{
	public static void main(String[] args) {
		//作为整个Job的配置
		Configuration conf = new Configuration();
		// ①创建Job
		Job job = Job.getInstance(conf);
		// TODO 省略一些配置
		// 设置ReduceTask的数量为5
		job.setNumReduceTasks(5);
		// 设置使用自定义的分区器
		job.setPartitionerClass(MyPartitioner.class);
	}
}

说明:自定义分区器返回的分区号并不能随意指定:0 <= 分区号 < numPartitions( 指定的ReduceTask的数量)

8.2 比较器

在MapTask流程中有排序的步骤,排序是根据key来排序的,那么按照什么进行排序呢?这就需要使用比较器来定义。

public RawComparator getOutputKeyComparator() {
  // 获取自定义的比较器
        Class<? extends RawComparator> theClass = this.getClass("mapreduce.job.output.key.comparator.class", (Class)null, RawComparator.class);
        return (RawComparator)(theClass != null ? (RawComparator)ReflectionUtils.newInstance(theClass, this) : WritableComparator.get(this.getMapOutputKeyClass().asSubclass(WritableComparable.class), this));
    }

从上述代码可以看出

  • 如果有自定义的比较器,那么就根据自定义的比较器类直接实例化比较器对象
  • 如果没有自定义,那么就根据Mapper的output的key的类型来获取。假设Map阶段你输出的key的类型是LongWritable,那么就获取LongWritable中定义的Comparator对象,像通用的Writable子类,如IntWritable,Text等都是实现了WritableComparable接口,且都是有自己的内部类Comparator,具体可查看源码,默认升序排列,如果想要降序,那么就需要自定义比较器

那么如何自定义比较器,有以下几种方式:

  • 自定义类继承WritableComparator类,并实现compare方法,最后设置job参数
public class MyDescComparator extends WritableComparator{
    // 实现Long的倒序排序
    // 如果key是LongWritable类型,降序也可以直接使用LongWritable中的DecreasingComparator
	@Override
    public int compare(byte[] b1, int s1, int l1,
                       byte[] b2, int s2, int l2) {
      long thisValue = readLong(b1, s1);
      long thatValue = readLong(b2, s2);
      return (thisValue<thatValue ? 1 : (thisValue==thatValue ? 0 : -1));
    }
}
// 创建Job
Job job = Job.getInstance(new Configuration());
// 设置使用自定义的比较器
job.setSortComparatorClass(DecreasingComparator.class);
// 运行Job
job.waitForCompletion(true);
  • 自定义比较器类实现RawComparator接口,实现其中的两个compare方法,最后设置job参数
public class MyRawComparator implements RawComparator<FlowBean>{
	private FlowBean key1=new FlowBean();
	private FlowBean key2=new FlowBean();
	private  DataInputBuffer buffer=new DataInputBuffer();

	// 负责从缓冲区中解析出要比较的两个key对象,调用 compare(Object o1, Object o2)对两个key进行对比
	@Override
	public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2) {
		try {
		      buffer.reset(b1, s1, l1);                   // parse key1
		      key1.readFields(buffer);
		      
		      buffer.reset(b2, s2, l2);                   // parse key2
		      key2.readFields(buffer);
		      
		      buffer.reset(null, 0, 0);                   // clean up reference
		    } catch (IOException e) {
		      throw new RuntimeException(e);
		    }
		return compare(key1, key2);
	}

	// Comparable的compare(),实现最终的比较
	@Override
	public int compare(FlowBean o1, FlowBean o2) {
		return -o1.getSumFlow().compareTo(o2.getSumFlow());
	}
}
// 创建Job
Job job = Job.getInstance(new Configuration());
// 设置使用自定义的比较器
job.setSortComparatorClass(DecreasingComparator.class);
// 运行Job
job.waitForCompletion(true);
  • 当你自定义了key的类型时,那么可以直接让该bean类实现WritableComparable接口,并重写compareTo 方法。这样MapTask在比较时会自动调用compareTo方法,无需在job中进行设置比较器。
@Data
public class FlowBean implements WritableComparable<FlowBean>{
	private long upFlow;
	private long downFlow;
	private Long sumFlow;
	
	// 。。。省略其他代码

	// 系统封装的比较器在对比key时,调用key的compareTo进行比较
	@Override
	public int compareTo(FlowBean o) {
		return -this.sumFlow.compareTo(o.getSumFlow());
	}
}

8.3 Combiner

Combiner实际上本质是一个Reducer类,且只有在设置了之后,才会运行!

Combiner和Reducer的区别

  1. Reducer是在reduce阶段调用,而Combiner是在shuffle阶段(既可以在MapTask的shuffle,也可以在ReduceTask的shuffle)调用!

  2. 本质都是Reducer类,作用都是对有相同key的key-value进行合并!

Combiner意义

在shuffle阶段对相同key的key-value进行提前合并,从而可以减少磁盘IO和网络IO!

使用条件

使用Combiner必须保证不能影响处理逻辑和结果!经验证,Combiner只能用在加、减操作的场景,不能用在乘、除操作的场景,用在乘、除操作的场景会导致最终计算的结果与不使用Combiner时不一致

调用时机

Combiner既有可能在MapTask端调用:

①每次溢写前会调用Combiner对溢写的数据进行局部合并

②在merge(多个溢写出的临时文件合并为一个文件)时,如果溢写的片段数>=3,(即溢写出的临时文件>=3)如果设置了Combiner,Combiner会再次对数据进行Combine!

Combiner也有可能在ReduceTask端调用:

③shuffle线程拷贝多个MapTask同一分区的数据,拷贝后执行merge和sort,

如果数据量过大,需要将部分数据先合并排序后,溢写到磁盘!

如果设置了Combiner,Combiner会再次运行!

9 ReduceTask

ReduceTask的数量取决于分区的数量,因为每个ReduceTask只会处理所有MapTask输出的相同分区的数据,具体流程如下

mapreduce 的jar包在哪个目录 mapreduce的job_mapreduce_13

  1.  ReduceTask从各个MapTask上远程拷贝一片数据,并针对某一片数据,如果其大小超过一定阈值,则写到磁盘上,否则直接放到内存中。
  2. 用户编写reduce()函数,输入数据(key,value)是按key进行聚集的一组数据。而为了将key相同的数据聚在一组,Hadoop采用了基于排序的策略,即先针对key进行一次归并排序(各个MapTask已经实现对自己的处理结果进行了局部排序),再使用GroupingComparator实现类将相同的key分到一组。
  3. reduce()函数一次读取一组数据,并使用OutputFormat将计算结果输出到磁盘或写到HDFS上

9.1 分组比较器

直接使用一个案例来解释:

有一个文件内容如下所示,orderid:订单id,pid:商品id,account: 商品金额

mapreduce 的jar包在哪个目录 mapreduce的job_大数据_14

需求:统计同一笔订单中,金额最大的商品记录输出

分析得出: 在同一笔订单中,对每条记录的金额进行降序排序,最大的排前边,那么此时就需要进行二次排序,即先按照订单号进行排序(升降都可以)再按照金额进行降序排序。而为了将同一个订单的记录分到一组,就需要使用分组比较器。而MapReduce只能针对key进行排序,所以我们又需要封装一个bean作为key,该bean中至少包含orderid和account两个字段

MapTask阶段:

OrderBean

@Data
public class OrderBean implements WritableComparable<OrderBean>{
	
	private String orderId;
	private String pId;
	private Double acount;
	// 序列化
	@Override
	public void write(DataOutput out) throws IOException {
		out.writeUTF(orderId);
		out.writeUTF(pId);
		out.writeDouble(acount);
	}
	// 反序列化
	@Override
	public void readFields(DataInput in) throws IOException {
		orderId=in.readUTF();
		pId=in.readUTF();
		acount=in.readDouble();
	}
	
	// 二次排序,先按照orderid排序(升降序都可以),再按照acount(降序)排序
	@Override
	public int compareTo(OrderBean o) {
		
		//先按照orderid排序升序排序
		int result=this.orderId.compareTo(o.getOrderId());
		if (result==0) {
			//再按照acount(降序)排序,降序前面加个负号即可
			result=-this.acount.compareTo(o.getAcount());
		}
		return result;
	}
}

OrderMapper

/*
 * LongWritable, Text: 默认使用TextInputFormat,所以LongWritable表示行号,Text表示那一行的内容
 */
public class OrderMapper extends Mapper<LongWritable, Text, OrderBean, NullWritable>{
	
	private OrderBean out_key=new OrderBean();
	private NullWritable out_value=NullWritable.get();
	
	@Override
	protected void map(LongWritable key, Text value,
			Context context)
			throws IOException, InterruptedException {
		String[] words = value.toString().split("\t");
		out_key.setOrderId(words[0]);
		out_key.setPId(words[1]);
		out_key.setAcount(Double.parseDouble(words[2]));
		context.write(out_key, out_value);
	}
}

经过MapTask的shuffle阶段后,传入Reduce的数据应是如下所示,即先按orderid升序排序,再按照金额降序排序:

mapreduce 的jar包在哪个目录 mapreduce的job_mapreduce_15

ReduceTask阶段
在此阶段会获取分组比较器,如果没设置默认使用MapTask排序时key的比较器!而在这个案例中默认的比较器比较策略不符合要求,因为它认为orderId一样且acount一样的记录才是一组。那我们就需要自定义分组比较器,只按照orderId进行对比,只要OrderId一样,认为key相等,这样可以将orderId相同的分到一个组!

实现自定义GroupingComparator

实现自定义的分组比较器和之前实现自定义比较器一样,都是两种方式,继承WritableCompartor 或 实现RawComparator

//1. 继承WritableCompartor
public class MyGroupingComparator2 extends WritableComparator{

	// 必须调用一次父类构造器,且s第三个参数要传true,否则会报空指针异常,第二个conf参数不传的话会默认构造
	public MyGroupingComparator2() {
		super(OrderBean.class,null,true);
	}
	
	public int compare(WritableComparable a, WritableComparable b) {
		OrderBean o1=(OrderBean) a;
		OrderBean o2=(OrderBean) b;
	    return o1.getOrderId().compareTo(o2.getOrderId());
	  }
}

// 或2. 实现RawComparator
public class MyGroupingComparator implements RawComparator<OrderBean>{
	
	private OrderBean key1=new OrderBean();
	private OrderBean key2=new OrderBean();
	private  DataInputBuffer buffer=new DataInputBuffer();

	@Override
	public int compare(OrderBean o1, OrderBean o2) {
		return o1.getOrderId().compareTo(o2.getOrderId());
	}

	@Override
	public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2) {
		try {
		      buffer.reset(b1, s1, l1);                   // parse key1
		      key1.readFields(buffer);
		      buffer.reset(b2, s2, l2);                   // parse key2
		      key2.readFields(buffer);
		      buffer.reset(null, 0, 0);                   // clean up reference
		    } catch (IOException e) {
		      throw new RuntimeException(e);
		    }
		return compare(key1, key2);
	}
}

Reducer

public class OrderReducer extends Reducer<OrderBean, NullWritable, OrderBean, NullWritable>{
	@Override
	protected void reduce(OrderBean key, Iterable<NullWritable> values, Context context) throws IOException, InterruptedException {
		// 由于从mapper传入reducer的数据已经按照商品价格降序排了序
		// 所以下面这行代码是获取最大的商品价格
		Double maxAcount = key.getAcount();
		
		// 这里遍历values有一个注意点:
		// 每次调用迭代器迭代下个记录时,使用反序列化器从文件中或内存中读取下一个key-value数据的值,key-value对象始终是同一个对象,即指针不变,只是在循环过程中会重新给key-value赋值。
		// 按照此案例,这一组数据如下所示,value为NULLWritable
		// 10000001	Pdt_02	222.8
		// 10000001	Pdt_01	222.8
		// 10000001	Pdt_05	25.8
		// 那么第一次遍历,key值为10000001	Pdt_02	222.8,第二次遍历key变为10000001	Pdt_01	222.8,第三次变为10000001	Pdt_05	25.8
		for (NullWritable nullWritable : values) {
			// 由于每次遍历,key会被重新赋值,所以只需要和获取的maxAcount进行比较,相同就写出。
			if (!key.getAcount().equals(maxAcount)) {
				break;
			}
			//复合条件的记录
			context.write(key, nullWritable);
		}
	}
}

启动类

public class OrderBeanDriver {
	
	public static void main(String[] args) throws Exception {
		//作为整个Job的配置
		Configuration conf = new Configuration();
		// ①创建Job
		Job job = Job.getInstance(conf);
		
		// ②设置Job
		// 设置Job运行的Mapper,Reducer类型,Mapper,Reducer输出的key-value类型
		job.setMapperClass(OrderMapper.class);
		job.setReducerClass(OrderReducer.class);
		
		// Job需要根据Mapper和Reducer输出的Key-value类型准备序列化器,通过序列化器对输出的key-value进行序列化和反序列化
		// 如果Mapper和Reducer输出的Key-value类型一致,直接设置Job最终的输出类型
		job.setOutputKeyClass(OrderBean.class);
		job.setOutputValueClass(NullWritable.class);
		
		// 设置输入目录和输出目录
		FileInputFormat.setInputPaths(job, new Path("E:\\mrinput\\groupcomparator"));
		FileOutputFormat.setOutputPath(job, new Path("e:/mroutput/groupcomparator"));
		
		// 设置自定义的分组比较器
		job.setGroupingComparatorClass(MyGroupingComparator2.class);
		// ③运行Job
		job.waitForCompletion(true);
	}
}

10 OutputFormat

OutputFormat是一个MapReduce输出数据的基类

10.1 文本输出TextOutputFormat

  默认的输出格式是TextOutputFormat,它把每条记录写为文本行。它的键和值可以是任意类型,因为TextOutputFormat调用toString()方法把它们转换为字符串。

10.2 SequenceFileOutputFormat

将SequenceFileOutputFormat输出作为后续 MapReduce任务的输入

10.3 自定义OutputFormat

步骤:

  1. 自定义一个类继承FileOutputFormat
  2. 改写RecordWriter,具体改写输出数据的方法write()。

案例说明:

需求:过滤输入的log日志,包含atguigu的网站输出到e:/atguigu.log,不包含atguigu的网站输出到e:/other.log,log日志内容如下:

mapreduce 的jar包在哪个目录 mapreduce的job_临时文件_16

Mapper

/*
 * 1.什么时候需要Reduce
 * 		①合并
 * 		②需要对数据排序
 * 2. 没有Reduce阶段,key-value不需要实现序列化
 */
public class CustomOFMapper extends Mapper<LongWritable, Text, String, NullWritable>{
	@Override
	protected void map(LongWritable key, Text value, Context context)
			throws IOException, InterruptedException {
	
		String content = value.toString();
		
		context.write(content+"\r\n", NullWritable.get());
	}
}

OutputFormat

public class MyOutPutFormat extends FileOutputFormat<String, NullWritable>{
	@Override
	public RecordWriter<String, NullWritable> getRecordWriter(TaskAttemptContext job)
			throws IOException, InterruptedException {
		return new MyRecordWriter(job);
	}
}

RecordWriter

public class MyRecordWriter extends RecordWriter<String, NullWritable> {
	
	private Path atguiguPath=new Path("e:/atguigu.log");
	private Path otherPath=new Path("e:/other.log");
	
	private FSDataOutputStream atguguOS ;
	private FSDataOutputStream otherOS ;
	
	private FileSystem fs;
	
	private TaskAttemptContext context;

	public MyRecordWriter(TaskAttemptContext job) throws IOException {
			context=job;
			Configuration conf = job.getConfiguration();
			fs=FileSystem.get(conf);
			 atguguOS = fs.create(atguiguPath);
			 otherOS = fs.create(otherPath);
	}
	
	// 负责将key-value写出到文件
	@Override
	public void write(String key, NullWritable value) throws IOException, InterruptedException {
		if (key.contains("atguigu")) {
			atguguOS.write(key.getBytes());
			context.getCounter("MyCounter", "atguiguCounter").increment(1);
			// 统计输出的含有atguigu 的key-value个数
		}else {
			otherOS.write(key.getBytes());
			context.getCounter("MyCounter", "otherCounter").increment(1);
		}
	}

	// 关闭操作
	@Override
	public void close(TaskAttemptContext context) throws IOException, InterruptedException {
		if (atguguOS != null) {
			IOUtils.closeStream(atguguOS);
		}
		if (otherOS != null) {
			IOUtils.closeStream(otherOS);
		}
		if (fs != null) {
			fs.close();
		}
	}
}

启动类

Job job = Job.getInstance(new Configuration());
job.setMapperClass(CustomOFMapper.class);
// 设置输入和输出格式
job.setOutputFormatClass(MyOutPutFormat.class);
// 取消reduce阶段
job.setNumReduceTasks(0);
// ...

11 计数器

计数器可以记录已处理的字节数和记录数,使用户可监控已处理的输入数据量和已产生的输出数据量

计数器API

1. 采用枚举的方式统计计数

context.getCounter(枚举).increment(1);

2.采用计数器组、计数器名称的方式统计,组名和计数器名称随便起,但最好有意义

context.getCounter("counterGroup", "counter").increment(1);

案例可查看自定义OutputFormat中的RecordWriter

12 Reduce Join

Reduce Join类似sql中的Join,将两个表的数据通过某个字段关联起来,返回两个表的数据。

假设有order.txt文件,字段有orderId,pid,amount,pd.txt文件:pid,pname,输出:orderId,pid,amount,pname

可见我们只需要将AB文件的pid进行关联即可。那么实现如下:

1. 在Map阶段,封装数据。 自定义的Bean需要能够封装两个切片中的所有的数据。而两种不同的数据,经过同一个Mapper的map()处理,因此需要在map()中,判断切片数据的来源,且将来源存入Bean中,以便在reduce阶段根据来源执行不同的封装策略

2. 相同pid的数据,需要分到同一个区,即以pid为条件分区,pid相同的分到一个区
3. 在reduce输出时,只需要将来自于order.txt中的数据,将pid替换为pname,而不需要输出所有的key-value(在Map阶段对数据打标记,标记哪些key-value属于order.txt,哪些属于pd.txt)

13 MapJoin

ReduceJoin需要在Reduce阶段实现Join功能,一旦数据量过大,效率低,因为需要分区和排序才能进行合并,那么可以使用MapJoin解决ReduceJoin低效的问题!即每个MapTask在map()中完成Join!

做法:

只需要将要Join的数据order.txt作为切片,让MapTask读取pd.txt(不以切片形式读入),而直接在MapTask中使用HDFS下载此文件,下载后,使用输入流手动读取其中的数据!在map()之前通常是将大文件以切片形式读取,小文件手动读取!

JoinBean

public class JoinBean implements Writable{	
	private String orderId;
	private String pid;
	private String pname;
	private String amount;
    // TODO 。。。
}
MapJoinMapper
/*
 * 1. 在Hadoop中,hadoop为MR提供了分布式缓存(job.addCacheFile(new URI("file://e:/cache/xx.txt"));)
 * 			用来缓存一些Job运行期间的需要的文件(普通文件,jar,归档文件(har))
 * 			分布式缓存会假设当前的文件已经上传到了HDFS,并且在集群的任意一台机器都可以访问到这个URI所代表的文件
 * 			分布式缓存会在每个节点的task运行之前,提前将文件发送到节点
 * 			分布式缓存的高效是由于每个Job只会复制一次文件,且可以自动在从节点对归档文件解归档
 */
public class MapJoinMapper extends Mapper<LongWritable, Text, JoinBean, NullWritable>{

	private JoinBean out_key=new JoinBean();
	private Map<String, String> pdDatas=new HashMap<String, String>();
	//在map之前手动读取pd.txt中的内容
	
	@Override
	protected void setup(Context context)
			throws IOException, InterruptedException {
		
		//从分布式缓存中读取数据
		URI[] files = context.getCacheFiles();
		for (URI uri : files) {
			BufferedReader reader = new BufferedReader(new FileReader(new File(uri)));
			String line="";
			//循环读取pd.txt中的每一行
			while(StringUtils.isNotBlank(line=reader.readLine())) {
				String[] words = line.split("\t");
				pdDatas.put(words[0], words[1]);
			}
			reader.close();
		}
	}
	
	//对切片中order.txt的数据进行join,输出
	@Override
	protected void map(LongWritable key, Text value, Context context)
			throws IOException, InterruptedException {
		String[] words = value.toString().split("\t");
		out_key.setOrderId(words[0]);
		out_key.setPname(pdDatas.get(words[1]));
		out_key.setAmount(words[2]);
		context.write(out_key, NullWritable.get());
		
	}
}

启动类

public class MapJoinDriver {
	public static void main(String[] args) throws Exception {
		// ①创建Job
		Job job = Job.getInstance(new Configuration());
		job.setJarByClass(MapJoinDriver.class);
		// 为Job创建一个名字
		job.setJobName("xxx");
		// ②设置Job
		// 设置Job运行的Mapper,Reducer类型,Mapper,Reducer输出的key-value类型
		job.setMapperClass(MapJoinMapper.class);
		// 设置输入目录和输出目录
		FileInputFormat.setInputPaths(job, inputPath);
		FileOutputFormat.setOutputPath(job, outputPath);
		// 设置分布式缓存
		job.addCacheFile(new URI("file:///e:/pd.txt"));
		//取消reduce阶段
		job.setNumReduceTasks(0);
		// ③运行Job
		job.waitForCompletion(true);
	}
}