1 MapReduce流程
上述就是一个MapReduce
处理数据的流程:经由:数据输入→map
阶段→Shuffle
阶段→数据输出。以下将根据这整个流程解析MapReduce
的框架原理
2 InputFormat数据输入
2.1 数据切片和数据块概念
- 数据块:
Block
是HDFS物理上把数据分成一块一块 - 数据切片:数据切片只是在
逻辑上
对输入进行分片,并不会在磁盘上将其切分成片进行存储。也就是通过文件是完整的文件而不是像数据块去直接在物理层面上划分为多个块,而是使用指针在源文件中置顶处理的范围
切片和数据块都是按照
一定的单位处理
的,例如切分数据块
在hadoop3.x
中是按照128M
进行切分,200M
的数据,就会被分为128M
和72M
两个块
2.2 数据切片和MapTask并行度决定机制
一个切片是由一个
MapTask
负责处理,有多少个切片就启用多少个MapTask
,并且MapTask
是并行处理
的,切片的个数影响MapTask并行度
。MapTask
并行度不是越高越好,他也由切片的数据量决定
2.3 数据块与数据切片的关系
上述概念中,数据切片是对整个文件
的逻辑上
进行划分,并且一个切片由一个MapTask
负责。实际在服务器集群中,文件的存储默认是使用的副本策略
,也就是说MapReduce
程序在集群中输入的数据其实就是服务器上的数据块
那么数据块在一定程度上就影响数据的切片,因为输入的数据与服务器的副本机制的问题,那么输入的数据最好是一个块的大小,并且默认情况
下,切片大小的值=块大小的值
,块大小计算是通过一个公式
的,切片也同样,并且使用到了块大小的量,默认情况下计算出来切片大小跟块大小是相同的,而不是直接取块的大小作为切片的大小
为什么使用块大小作为一个切片的大小呢?例如一个200M
的数据,分成128M
和72M
的块,那么两个块就根据副本的选择策略,副本相关的块分散到不同的DataNode
中,那么对于一个副本的128M
和72M
在不同机器上例如分别是d1
和d2
,如果切片大小是100M
,那么就会从d1
的数据上得到切片是0~100M
,那么剩下部分就是100M~128M
,不够100M
,那么就会跨服务器到d2
进行读取,处理过程相对复杂。如果切片刚好是块的大小就能避免这种情况
MapReduce
对切片的处理是对基于整个文件
的,而不是数据的整体,例如一个200M
的文件和100M
的文件同时输入,那么切片仅仅是相对于200M
和100M
文件本身,而不是整体的数据流,也就是默认情况下最终200M
的文件只会被切分为128M
和72M
两个切片,而100M
也是一个单独的切片- 切片大小是可以设置的,默认情况下
切片大小的值=块大小的值
上述策略在大文件处理过程中是很有效的,但是也不是一直适用的,例如大量的小文件,例如一个10M
,那么如果按照默认情况下切片,那就有多少个文件就有多少个切片,同时启用同等数量的MapTask
,虽然MapTask
是并行处理,但是大量的并行调度处理小文件,其中的调度过程就会耗费资源,而这种资源的耗费仅仅是处理一些小文件,这是得不偿失的,所以通过设置切片大小并配合一定策略处理这种的大量小文件的场景
2.4 源码上的切片大小计算策略
从FileInputFormat
可以看到getSplits
方法
经过一系列的计算与配置会在getSplits
方法中调用computeSplitSize
方法,也就是切片的计算方法,其中传入了三个参数blockSize
, minSize
, maxSize
computeSplitSize
方法源码如下,事实就是如下的一个比较方法
maxSize
的获取如下
可以看到通过job
对象获取他的配置并通过getLong
方法获取参数值
SPLIT_MAXSIZE
和Long.MAX_VALUE
如下
public static final String SPLIT_MAXSIZE =
"mapreduce.input.fileinputformat.split.maxsize";
public static final long MAX_VALUE = 0x7fffffffffffffffL; // 2^63-1
通过getLong
方法,第一个参数就是获取mapred-site.xml
或者mapred-default.xml
配置文件的mapreduce.input.fileinputformat.split.maxsize
配置属性,而第二个出参数是一个默认值,也就是当配置文件的参数没有配置时会使用第二个参数,这里也就是Long
类型的最大值
获取blockSize
公式如下,这里blockSize
可以是集群配置文件配置的blockSize
,如果在本地执行,如果没有定义,那么默认是32MB
这里做的意义是通过maxSize
和blockSize
进行比较,如果maxSize
没有定义或者比blockSize
大,那么就取blockSize
minSize
获取过程类似
getFormatMinSplitSize
和getMinSplitSize
方法以及相关字段如下
protected long getFormatMinSplitSize() {
return 1;
}
public static long getMinSplitSize(JobContext job) {
return job.getConfiguration().getLong(SPLIT_MINSIZE, 1L); /
}
public static final String SPLIT_MINSIZE =
"mapreduce.input.fileinputformat.split.minsize";
结合上述computeSplitSize
方法,当minSize
相关的属性没有配置的时候,会返回1
。上述例子中maxSize
相关配置没有配置,那么就返回blockSize
,当minSize
也没有配置,那么1
和blockSize
取最大值,最终就拿到了blockSize
,也就是最终切片大小等于blokcSize
2.5 源码上的小切片处理策略
小切片处理,就例如配有额外配置的情况下,对于128.1MB
的文件,如果blokcSize=128MB
,那么就切分成128MB
和0.1MB
的切片,切片过小造成资源浪费。hadoop
的处理是通过一个比例进行限制的
其中SPLIT_SLOP
定义如下
private static final double SPLIT_SLOP = 1.1; // 10% slop
splitSize
是上述computeSplitSize
方法的返回值,也就是切片的大小,bytesRemaining)/splitSize
,表示剩余文件与切片大小的值的比例,如果大于1.1
那么就允许切片,小于等于就不允许
,也就是允许10%
的溢出
3 InputFormat解析
3.1 FileInputFormat和TextInputFormat
InputFormat
类用以处理输入以及切片,如下两个抽象方法。这里是为了在源码角度概要解析之前数据输入处理的流程
如果使用
IDEA
查看源码,可以通过快捷键ctrl+h
查看他的实现类
// 获取切片
public abstract
List<InputSplit> getSplits(JobContext context
) throws IOException, InterruptedException;
// 创建RecordReader对象,负责读取
public abstract
RecordReader<K,V> createRecordReader(InputSplit split,
TaskAttemptContext context
) throws IOException,
InterruptedException;
}
上述方法是一个抽象的方法,要进一步了解就需要通过他的实现类,这里首先是介绍这个FileInputFormat类
,该方法主要是实现了getSplits
和isSplitable
方法
getSplits
:默认的切片规则的实现isSplitable
:判断一个文件是否可切片,统一的实现,返回的是true
切片大小相关逻辑如上述,可查看上述标题2.3
,在FileInputFormat类
中是对isSplitable
做了一个统一的处理,也就是返回true
切片相关的大小获取逻辑如
2.3标题
,其他这里先不介绍
在我们默认的输入流程中,默认使用的是FileInputFormat类
的实现类TextInputFormat类
其中重要的方法如下:
createRecordReader
:创建LineRecordReader对象
,所以初始对文件的处理,都是一行一行输入到map
方法处理的isSplitable
:重写了该方法,对各种压缩文件进行了判断是否可切分。切片的规则用的是FileInputFormat
中的getSplits
方法实现
其中重写的isSplitable
方法中对于压缩文件的编解码处理,普通文件我们都是可以直接切分的
@Override
protected boolean isSplitable(JobContext context, Path file) {
final CompressionCodec codec =
new CompressionCodecFactory(context.getConfiguration()).getCodec(file);
if (null == codec) {
return true;
}
return codec instanceof SplittableCompressionCodec;
}
3.2 CombineTextInputFomat处理大量小文件场景
框架默认的TextInptFormat
切片机制是对任务按文件规划切片,不管文件多小
,都会是一个单独的切片
,都会交给一个MapTask
,这样如果有大量小文件,就会产生大量的MapTask
,处理效率极其低下
3.2.1 CombineTextInputFomat切片最大值设置
CombineTextInputFomat
是通过设置虚拟切片来处理小文件问题,该处理机制重要配置之一是虚拟切片的设置,如下为设置最大虚拟切片大小的方法
// 一个参数设置的job对象,第二个参数是设置最大虚拟切片的值,单位是字节,下述例子是1024*1024*4
CombineTextInputFormat.setMaxInputSplitSize(job, 4194304) // 这里就是4MB
3.2.2 CombineTextInputFomat切片机制
CombineTextInputFomat
是通过设置虚拟切片
机制来处理小文件,也就是在生成切片之前会有一个虚拟的过程,然后再到切片过程
,处理过程如下
1、首先准备假设输入数据如下(缺省字节B后缀),并且最大虚拟切片是4MB
2、数据切片前
会先经过虚拟过程
处理过程大致如下:
- 文件大小 ≤
4MB
:划分为1块 4MB
< 文件大小 <8MB
:文件对半分,例如上述5.1MB
的文件就是符合该范围,那么就分成2.55MB
两个块- 文件大小 ≥
8MB
:那么首先按顺序切分出4MB
,例如9MB
的内容,首先切分4MB
,然后剩下的5MB
内容符合2
,那么对半分两块是2.5MB
,最终得到三块:4MB
、2.5MB
、2.5MB
总的而言,就是最后划分的块不能比设置的最大虚拟切片大,这里是
4MB
最后切分的块大小如上图所示,也就是最终切分剩下的文件大小
3、最后是切片阶段,切片阶段主要是一下
- . 判断上述划分的块是否等于设置的最大虚拟存储的值,如果
等于
,那么就会作为一个切片
- 如果块大小不等于设置的最大虚拟切片(上述在设置的是
4MB
),那么就会与其他块进行合并,直到切片大小比设置的最大虚拟切片的值要大,那么上述存储的文件,最终会分为以下三个切片,这个时候就相对于4个小文件生成一个单独的切片要少。这里仅仅是相对于设置的4MB
的最大虚拟切片,根据实际情况设置相应的值
- 最大的虚拟切片的大小,最好是趋于一个
块的大小
- 上述构成产生的分块是按
文件的输入顺序
的,上述例子在虚拟过程中产生的块,都是按照这个文件的输入顺序,例如上述是a.txt~b.txt
,按照文件ASCII值
排序,那么最先输入的就是a.txt
,最后输入的是d.txt
,块的产生也是按这个顺讯,最后按照上述规则进行组合
3.2.3 CombineTextInputFomat实践案例(基于官方wordcount案例需求)
基本的客户端编写wordcount程序以及项目配置可以参考这里:MapReduce学习2-1:以官方wordcount实例为例的MapReduce程序学习的本地实操案例中(本次主要是本地测试,用于学习比较方便)
1、输入案例文件准备
直接案例测试(无小文件处理),如果需要打印以下日志信息,可以参考:Hadoop学习9:Maven项目跟中进行HDFS客户端测试(hadoop3.1.2)中POM.xml
的配置(我是JDK 1.8
)
这里我是本地进行测试,没有设置块大小,本地测试默认是32MB
(集群默认128MB
)。如上述箭头所示,可以看到是切片是4个
按照上述理论,如果使用CombineTextInputFormat
处理,那么就是3
个切片
2、在WordcountDriver.class
中添加CombineTextInputFormat
相关的配置
package com.ctfwc.maven;
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.CombineTextInputFormat;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import java.io.IOException;
public class WordcountDriver {
public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
Configuration conf = new Configuration();
Job job = Job.getInstance(conf);
job.setJarByClass(WordcountDriver.class);
job.setMapperClass(WordcountMapper.class);
job.setReducerClass(WordcountReducer.class);
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(IntWritable.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(IntWritable.class);
FileInputFormat.setInputPaths(job, new Path("E:\\bigdata\\study\\test_files\\combineinput"));
FileOutputFormat.setOutputPath(job, new Path("E:\\bigdata\\study\\test_files\\combineoutput"));
job.setInputFormatClass(CombineTextInputFormat.class);
CombineTextInputFormat.setMaxInputSplitSize(job, 4194304);
job.waitForCompletion(true);
}
}
上述的设置了最大的虚拟切片是4MB
3、结果
上述可以看到结果是
3
,也就是切片是3
,符合上述
4 Shuffle机制
Map
方法之后
,Reduce
方法之前
的数据处理过程
称之为Shuffle
4.1 Shuffle整体概述
Shffle
阶段Map
方法之后
,Reduce
方法之前
,主要是包含两次排序
以及一次数据的拷贝
1、在源码中MapTask.class
中的启动方法run()
,可以看到
// 是否为MapTask
if (this.isMapTask()) {
// 判断是否有reduce阶段
if (this.conf.getNumReduceTasks() == 0) {
// 没有reduce阶段,就只有map阶段,阶段占总进程为100%
this.mapPhase = this.getProgress().addPhase("map", 1.0F);
} else {
// 有reduce阶段,map阶段占用总进程的66.7%,sort阶段占用33.3%
this.mapPhase = this.getProgress().addPhase("map", 0.667F);
this.sortPhase = this.getProgress().addPhase("sort", 0.333F);
}
}
2、ReduceTask.class
可以看到以下
if (this.isMapOrReduce()) {
// 数据拷贝阶段
this.copyPhase = this.getProgress().addPhase("copy");
// 排序阶段
this.sortPhase = this.getProgress().addPhase("sort");
// reduce阶段
this.reducePhase = this.getProgress().addPhase("reduce");
}
3、总的Shuffle
阶段就是:sort
(map
阶段) → copy
(reduce
阶段) → sort
(reduce
阶段)
4.2 Shuffle处理过程详解【sort(map阶段) → copy(reduce阶段) 】
上述概要分析,该过程就是排序,但是其中还涉及更为详细的分区
、排序
、分组
以及合并
4.2.1 Map输出到环形缓冲区后的排序、合并以及溢出过程
4.2.1.1 环形缓冲区
Map
阶段输出首先是输出到Shuffle
阶段上的环形缓冲区
,也就是在客户端通过context.write
进行写出的数据就是输入到该缓冲区中
环形缓冲区是默认大小是100MB
,如果按数据结构分类就是一个数组
,但是其上逻辑比较复杂,用来缓冲数据,避免频繁的I/O
操作降低效率。如上图,他是被分为80%
和20%
两个部分,其中80%
的部分,也就是80M
,这是一个默认的设置比例
- 可以通过在
mapred-site.xml
文件中配置mapreduce.map.io.sort.spill.percent
属性,默认值是0.8
,也就是80%
100MB
是默认的配置,可以通过mapred-site.xml
文件中配置mapreduce.task.io.sort.mb
属性的属性值来配置该值,默认值是100
,也就是100MB
被分为80%
和20%
两个部分,解析这两个就涉及到一个溢出
的流程,当数据写入
到环形缓冲区
的时候,达到设置的这个阈值
(默认是80%
,也就是80MB
),就会被锁定,然后写入到磁盘中,也就是溢出(spill)
,溢出过程是由后台进程处理,生成临时的溢出文件
。在数据溢出到磁盘过程中,数据会继续写入,那么剩下的20%
的内存就继续接受数据,因为是环形的,所以如果溢出跟数据和写入是符合一定的速度,那么理论上是可以不间断工作,直到最后的数据全部都被处理
- 溢出写过程按
轮询方式
将缓冲区中的内容写到mapreduce.cluster.local.dir
属性指定的目录中,同时也会处理最后达不到溢出条件的数据,该属性可在mapred-site.xml
文件中配置- 每次溢写是产生一个溢写文件而
不是不同分区产生不同的文件
,而是同一个文件上存在多个分区
4.2.1.2 分区概念
1
中介绍了一个大概的过程,接下来是期间更为详细的介绍,首先溢出过程之前还存在分区
的概念
分区
是什么呢?默认分区数是1
,简单一点的就是每次默认运行,都会产生下述文件
这里就是一般直接运行,就是产生这么一个00000
代号的分区文件,所有处理结果都会丢到这里边,这么一个文件,其实就算是一个分区,后边的代号可以通过特定的方法进行配置,他是跟设置的reduceTask
的数量有关,接下来配合案例和源码详细介绍
4.2.1.3 分区案例(自定义分区器)
为了更好理解分区,首先介绍一个案例解析为什么需要分区。有时候需要有一些需求,例如根据一组数据手机号的输入样例,根据前三位,输出到不同的文件中。这个需求就可以使用到分区,根据前三位判断输出到不同的分区并且设定合适的reduceTask
数就可以了
上述产生一个形如
part-r-******
的文件,可以看做是一个分区的输出,这样的文件的数量与设置的reduceTask
的数量是相等的
那么如何输出到不同的分区?在MapReduce学习2-1:以官方wordcount实例为例的MapReduce程序学习的基础上扩展。可以新建一个Partitioner
的实现类并实现getPartition
方法,通过getPartition
方法的返回值的不同
,从而分发到不同的分区,最后被不同的reduce
处理。这就可以通过继承Partitioner类
并重写getPartition
方法进行分区,其返回的数值,就是形如part-r-******
最后的数值
ProvincePartitioner 类
package com.partition.maven;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Partitioner;
public class ProvincePartitioner extends Partitioner<Text, Text> {
@Override
public int getPartition(Text key, Text value, int numPartitions) {
String preNum = key.toString().substring(0, 3);
int partition=4;
if("136".equals(preNum)){
partition=0;
}else if("137".equals(preNum)){
partition=1;
}else if("138".equals(preNum)){
partition=2;
}else if("139".equals(preNum)){
partition=3;
}
return partition;
}
}
PartMapper类
package com.partition.maven;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import java.io.IOException;
/**
* KEYIN:输入数据的key的类型 Longwritable ,用来表示偏移量(从文件的哪个位置读取数据)
* VALUEIN:输入数据的vaLue的类型 Text,从文件中读取到的一行数据
*
* KEYOUT:输出数据的key的类型 Text,表示一个单词
* VALUEOUT:输出数据的value的类型 Intwritable ,表示这个单词出现的次数
*/
public class PartMapper extends Mapper<LongWritable, Text, Text, Text> {
private Text outK = new Text();
/**
*
* @param key 表示的是偏移量,机器对文件的处理是不知道你是哪一行的
* 所以每次标识行是通过偏移量进行指定
* @param value 表示一行的文本的值
* @param context 表示的是上下文,负责调度Mapper类中的方法
* @throws IOException
* @throws InterruptedException
*/
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
// super.map(key, value, context); 因为是自定义,所以不需要调用父类构造函数
String line = value.toString();
String[] words = line.trim().split("\\W+");
String phoneNum = words[1];
outK.set(phoneNum);
context.write(outK, value);
}
}
PartReducer类
package com.partition.maven;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
import java.io.IOException;
/**
* KEYIN:输入数据的key的类型 Text(Mapper输出的类型相对应)
* VALUEIN:输入数据的value的类型 IntWritable(Mapper输出的类型相对应,按之前的逻辑,这里传入就是1)
* KEYOUT:输出数据的key 的类型 Text(因为输出要求是这里是某个单词)
* VALUEOUT:输出数据的value的类型 IntWritable(输出单词的个数)
*/
public class PartReducer extends Reducer<Text, Text, Text, Text> {
private IntWritable outV = new IntWritable();
/**
*
* @param key 传入的键值,也就是Mapper的输出的单词,键只会有一个
* @param values 值的集合。因为键只有一个,所以这里是值的集合,这里也就是同一个单词的1的集合,统计1就可以统计单词数
* @param context 上下文,通过它可以使用Reducer的方法
* @throws IOException
* @throws InterruptedException
*/
@Override
protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException {
// super.reduce(key, values, context);
context.write(key, values.iterator().next());
}
}
PartDriver类
package com.partition.maven;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.CombineTextInputFormat;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import java.io.IOException;
public class PartDriver {
public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
Configuration conf = new Configuration();
Job job = Job.getInstance(conf);
job.setJarByClass(PartDriver.class);
job.setMapperClass(PartMapper.class);
job.setReducerClass(PartReducer.class);
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(Text.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(Text.class);
// 配置分区类
job.setPartitionerClass(ProvincePartitioner.class);
// 配置ReduceTask的数目,该数决定输出的文件数
job.setNumReduceTasks(5);
FileInputFormat.setInputPaths(job, new Path("E:\\bigdata\\study\\test_files\\partinput"));
FileOutputFormat.setOutputPath(job, new Path("E:\\bigdata\\study\\test_files\\partoutput"));
job.setInputFormatClass(CombineTextInputFormat.class);
CombineTextInputFormat.setMaxInputSplitSize(job, 4194304);
job.waitForCompletion(true);
}
}
并设置reduceTask
的数量也就是对应 getPartition
方法的第三个参数的值
,通过第三个参数可以获取
上述相关实例也可以参考这里:MapReduce学习2-1:以官方wordcount实例为例的MapReduce程序学习以及序列化实现案例:MapReduce学习3:序列化
就对如下输入数据
1 13736230513 192.196.100.1 www.dev1.com 2481 24681 200
2 13836230513 192.196.100.1 www.dev1.com 2481 24681 200
3 13636230513 192.196.100.1 www.dev1.com 2481 24681 200
4 13936230513 192.196.100.1 www.dev1.com 2481 24681 200
5 13136230513 192.196.100.1 www.dev1.com 2481 24681 200
那么最终结果是产生5个分区文件
(数量同设置的reduceTask
的数量),其中仅有0~3
代号是分别对应上述不同归属省份的手机号的集合,而代号为4
的是一个空文件
,为什么是空文件呢?在接下来的规则介绍中详细解析
4.2.1.4 默认分区号0的出现原因和默认分区器
以下解析都是基于没有在客户端自定义分区类的基础上,具体如果自定义分区类,参考上述的案例
上述中有一个问题是:为什么平常我什么都没配置,但是最终会产生一个分区号是0
的文件?这里从源码进行解析,我们直接找到源头,打开MapTask.class
文件,找到如下方法,该方法是分区的源头
通过上述红色箭头的方法可以返回reduceTask
数目,如果我们什么都没设置,那么他的默认值是1
,这里可以在源码可以看到。如果使用IDEA
,可以通过快捷键:Ctrl+Alt+B
,查看他的实现类
查看JobContextImpl类
,并一路到conf.getNumReduceTasks()
方法中,可以看到
查看getInt方法
可以看到,当参数执行的为null
的时候,就返回默认的1
查看第一个参数JobContext.NUM_REDUCES
,就可以看到下边的对应关系,也就是在配置文件(mapred-site.xml
)中配置下边的参数,就可以配置的默认值。而默认的mapred-default.xml
是没有配置相关的参数的值,所以在该属性没有定义的情况下返回第二个参数,也就是返回值就是1
public static final String NUM_REDUCES = "mapreduce.job.reduces";
一般我们创建项目直接运行返回的分区数是1
,那么经过下边的逻辑,就到了else
里边
其主要逻辑就是新建内部的Partitioner
类,定义getPartition
方法,返回this.partitions - 1
,因为上述知道this.partitions
就是1
,那么他总是返回0
,那么分区相关返回总是0
,所以我们之前在没有任何配置的情况下总是产生分区号为0
的文件
上述初始状态下,没有任何配置,初始的reduceTask
是1
的情况下,分区文件的分区号产生的规则,接下来介绍reduceTask数目>1
的情况
首先是介绍一个默认分区器
,如果使用IDEA
,可以通过快捷键查看HashPartitioner类
,如下:
其中getPartition
方法是用来获取分区
的方法,这是重写Partitioner
的方法而来。上述HashPartitioner类
是默认设置分区的类,也被称为默认分区器
上述默认分区器HashPartitioner类
是当设置的ReduceTask的数目>1
的时候就会使用,那么为什么他是默认的分区器呢?
假设我们设置的分区数是2
并且没有自定义分区类,那么就会调用上述方法,判断分区数 > 1
,那么就进入第一层逻辑
就是通过ReflectionUtils反射工具类
去创建一个实例,而这个实例所属的类是通过getPartitionerClass
获取,实际他是一个抽象的方法,如下
如果使用IDEA
,可以通过快捷键:Ctrl+Alt+B
,查看他的实现类
这里查看JobContextImpl类
,可以看到实现的部分出现了具体的逻辑
其中通过conf.getClass
获取类,通过快捷方式点击第一个参数
可以看到他的定义
public static final String PARTITIONER_CLASS_ATTR = "mapreduce.job.partitioner.class";
明显该分区的类可以通过配置文件配置mapreduce.job.partitioner.class
属性,就能指定对应的默认分区器,默认配置文件是没有配置该属性的值,如果我们没有自行定义,那么他就会使用第二个参数,也就是HashPartitioner.class
,这就是上述的默认分区类。该判断逻辑可以在conf.getClass
方法可以看到
如果使用默认分区器,设置reduceTask
的数量,例如设置为2
,直接运行,同一个程序,会发现原来一个文件的内容被拆分为2
个,2
个分区文件的内容的总和
跟原来一个输出是一样的
,并且每次运行,两个文件内容都是相同的,这就是默认分区器计算而来的,因为每个key
的hashcode
是相同的,Interger
的最大值也是不变的,所以无论运行多少次,只要设置的reduceTask
的数量不变,都会在同一个特定的文件
上述默认分区器是一个与运算
,这么设计是由于整型的最大值使用二进制表示,如果是4字节共32位
,它就是0后边31个1
,因为hashcode可能是一个负数
,但是明显我们的文件名是需要一个正整数
的,所以二者与运算
,能保证最后返回的余数是一个正整数
从上述源码可知,如果
reduceTask是1
,那么就不会走默认的分区器或者在配置文件中配置的分区类
4.2.1.4 分区规则
可以看到产生的分区文件是跟设置的reduce
数相对应的并且跟getPartition
方法的返回值息息相关(返回的数字,就是最后的代号),其中关系如下:
-
如果
reduceTask
的数量>=
getPartition
的结果数,则会多产生几个空的输出文件 part-r-000xx
,如上述例子中4
个分区,但是设置的reuceTask为5
,但是并没有匹配到代码为4
的分区,所以即使生成了对应的文件,但是并没有写入内容,这样只会产生几个“空跑”的reduceTask任务,浪费系统资源 -
如果
1
<
reduceTask
的数量< getPartition
的结果数,则有一部分分区数据无处安放,会报错
-
如果
reduceTask
的数量=1
,则不管mapTask
端输出多少个分区文件,最终结果都交给这一个reduceTask
,最终也就只会产生一个结果文件part-r-00000
(默认也是hashpatitioner分区
,只是最终分区到同一文件里了)
分区号必须是一定集合的䛾,也就是如果设置
reduceTask数量为2
,那么分区号只能是0和1
(也就是getPartition方法
返回值只能是这两个),而不能是其他
4.2.2 溢出前的分区排序
MapTask
和ReduceTask
均会对数据按照key进行排序
。该操作属于Hadoop
的默认行为
。任何应用程序中的数据均会被排序,而不管逻辑上是否需要
默认排序是按字典顺序排序,且排序方法是快速排序
分区排序是就分区内
进行排序,也就是在一个分区内进行排序,而不是扩展到其他分区,这样做的原因是一个效率的优化的问题以及为输入到reduceTask
处理做优化,从实际案例中可以知道,最后在reduce阶段
,reduce方法
传入的数据是按字典顺序
,也就是a~z A~z
,也就是例如就官方的wordcount
案例的需求而言,输入文件中abc
这个单词在第三行bac
这个单词在第一行,如果这两个单词被一个reduceTask
处理并且是一个分区,那么首先被处理的就是第三行的abc
而不是bac
,即使从map
阶段首先输出的是bac
,这个传入的数据的顺序的结果开始就是从这里开始的(因为后续的排序也是这基础上的)
分区排序是在溢出(spill
)到磁盘之前,在分区内进行排序。数据在环形缓冲区上的数据量达到一定的阈值后,就会对分区进行一次快速排序
。每个数据被getPartition
方法处理后返回一个标识号
,那么原有处理的键值对的基础上再添加一个partition属性值
用以标识分区
,也就是最后处理成一个三元组,包括<key,value,partition>
,为分区进行排序以及为后续不同的reduceTask
找到属于自己负责的partition
做准备
从上述知道map
输出的内容,首先是分配到环形缓冲区
,而环形缓冲区就其本质而言就是一个数组
,那么他的内存是连续的
,如果对整体内容进行排序,无疑是增加额外的开销,hadoop
中的排序是不移动实际的位置
,而仅仅是记录排序的下边,并且仅对key
进行排序,例如输入到环形缓冲区属于同一分区中开始的三个位置分别是<bac,1>、<abc,1>、<dca,1>
,也就是他们的索引号分别是0、1、2
,那么排序过程是不进行移动他们的位置,而是仅仅记录排序后的索引,最终记录索引序列是1、0、2
,那么在溢出到指定目录的时候,就将索引对应位置的内容按索引序列依次输出
排序相关可以也可以看这里:MapReduce学习4-1:排序
4.2.3 分区排序后的合并
分区排序之后还可以进行合并
,合并是什么呢?就官方的wordcount
案例,对于同一个单词,例如这个单词是abc
,在两个不同的行都存在1次
,在map
阶段就会在处理abc
单词所在的行的时候,就会分别输<abc,1>
这个键值对,如果不做合并,那么分区排序后(假设就是默认一个分区,并且只有abc是a开头的
),那么就是两个<abc,1>
键值对就在前边
合并就例如上述案例中可以将两个<abc,1>
,处理成<abc,2>
,这样做的意义是缩小数据量,方便后续的处理、提高后续的处理速率以及减少后续的网络传输的开销(后续reduce
阶段可以是通过Http GET请求
进行请求数据的),那么这个过程就需要排序,在排序的基础上,前后进行对比,就能更好地合并
合并是可选的,从实际案例中可以知道
reduce方法
最后同一个键的值会被处理成一个迭代器对象
,也就是对于上述的举例,abc
单词对应两个1
的值,都被放到迭代器里边,对应的是同一个key
被一次reduce
处理
如果需要合并,只需要自定义Combiner
,在MapReduce学习2-1:以官方wordcount实例为例的MapReduce程序学习需要在WordcountDriver.class
添加以下代码,其余不变,也就是将重写的Reducer
进行作为自定义的Combiner
,具体可以参考这里MapReduce学习4-2:Combiner解析与实例
job.setCombinerClass(WordcountReducer.class)
4.2.4 溢出后的归并
上述经过分区、排序、合并(可选)后输出一系列的临时溢出文件,然后就到归并的阶段,归并阶段的意义是处理多个临时的溢出文件到一个统一的整体,而不是直接提交到reduce
,这样会增加reduce
的压力,因为通常情况下reduce
的数量是比较少的,最后如果临时文件数量比较多,提交到reduce
处理,那么最终还是要进行合并,所以该过程需要内部进行消化,也就是归并的过程
归并是对临时溢出文件进一步整理,最终整理成同一个分区的数据在一个部分,不同分区按分区标识排序,各个分区内再排序,生成数据中key
和对应的value-list
,最终整理输出一个正式的已分区、已排序的大文件
,溢出写文件归并完毕后,将删除所有的临时溢出写文件,并告知NodeManager
任务已完成。关于NodeManager
相关体系结构内容可以参考:Hadoop的体系结构
归并过程排序是使用的
归并排序
,因为对于不同的临时溢出文件而言,每个分区都是一个部分有序
的,使用归并排序,效率更高
如果溢写文件数量超过参数
min.num.spills.for.combine
的值(默认为3
)时,在归过程中可以再次进行合并
综上,如果在自定义了Combiner类
,那么就会在以下两个时机被调用
- 当为作业设置
Combiner类
后,缓存溢出线程将缓存存放到磁盘时,就会调用- 在数据归并过程中, 临时溢出文件的数量超过
mapreduce.map.combine.minspills
(默认3
)时会调用
4.2.5 溢出后的压缩
数据序列化后写磁盘前可以压缩map
端的输出(如上图),因为这样会让写磁盘的速度更快,节约磁盘空间,并减少传给reducer
的数据量。默认情况下,输出是不压缩的
在
mapred-site.xml
设置mapreduce.map.output.compress
设置为true
即可启动
4.3 Shuffle处理过程详解【copy(reduce阶段) → sort(reduce阶段)】
4.3.1 copy(reduce阶段)
溢出写文件归并完毕后,将删除所有的临时溢出写文件,并告知NodeManager
任务已完成,只要其中一个mapTask
完成,reduceTask
就开始复制它的输出(Copy
阶段分区输出文件通过Http GET
的方式提供给reducer
)
reduce
会启动一些copy
相关的进程,用来复制map
端的输出,而获取map
端的输出如果通过网络传输的方式,那么就是通过Http GET
的方式进行获取数据。map
端输出以及reduce
的copy
进程接收复制,中间是通过ApplicationMaster
进行处理的,期间信息的交互是基于hadoop
的心跳机制
,也就是3秒(默认)
就会进行询问一次ApplicationMaster
,提供自己的状态,同时ApplicationMaster
也会给进程返回操作命令
map
端完成信息的输出后,会在下一次心跳的时候通知ApplicationMaster
并提供相关的信息,例如这个map
端在哪个主机及其输出的内容的信息,这个过程ApplicationMaster
也能通过心跳机制被reduce
的线程访问,总体就完成了一个ApplicationMaster
的资源与任务调度的过程,同时ApplicationMaster
监控各个任务的状态
不同的reduceTask
根据负责的分区(根据分区标识),去复制不同的map
的输出内容的对应分区的内容
copy
相关的进程数,默认是5
,可以在mapred-site.xml
配置mapreduce.reduce.shuffle.parallelcopies
来改变该数值
map
端处理程序输出可能存在差异,所以多个copy线程
就在知道map
端产生了数据的时候就进行复制所需要的内容
4.3.2 归并
经过copy
阶段的数据,首先并不是直接写入到磁盘中,而是首先写入到内存缓冲区中,缓冲区的大小是就由JVM
的heap size
设定的,并且是heap size
的一定比例,而根据Oracle
官方文档的说法,JVM
的默认堆(heap size
)大小如果未指定,它将会根据服务器物理内存
计算而来。heap size
相关参考这里:闲谈JVM(一):浅析JVM Heap参数配置
内存缓冲区可通过
mapred.job.shuffle.input.buffer.percent
配置,默认是0.7
,也就是JVM的heap size的70%
请求而来的每个map
端的输出数据是以一块数据存在内存缓冲区中。对于reduceTask
,它从每个mapTask
上远程考贝相应的数据文件,如果文件大小超过一定阈值,则溢写磁盘上,否则存储在内存中。如果磁盘上文件数目达到定阈值,则进行—次归并排序以生成一个更大文件;如果内存中文件大小或者数目超过一定阈值,如果自定义了Combiner
则进行一次合并
后将数据溢写到磁盘上(该过程可选)。当所有数据拷贝完毕后,reduceTask
统—对内存和磁盘上
的所有数据进行一次归并排序
当数据全部拷贝完全在写入磁盘之后会进行过归并排序
。但是不进行像Map阶段的快速排序整理各个分区内的数据,因为从Map阶段的数据传递过来已经经过排序了,所以传递到reduce阶段时不同的mapTask已经是局部有序
假设reduce1
需要分区1
的内容,那么就复制分区1
的内容),但是分区1
的内容来自不同的map
输出,并且分区1
在之前也是一个局部有序的状态,那么使用归并排序
更有效率
4.3.3 分组处理
这里再引用上图例子,经过归并排序后整个分区1
相关的文件就整体有序了,然后再按照相同的key
进行分组,分组也就是·相同的key·会进入到·一个reduce方法,针对已根据键排好序的Key
构造对应的Value迭代器
默认的根据key分组
,自定义的可是使用job.setGroupingComparatorClass()
方法设置分组函数类
同一个键的vlaue
就进入一个迭代器中,并且按排序的顺序(按的字典排序,也就是a-z A-Z
),通过遍历可以获取值
以上是整个Shuffle
过程
5 OutputFormat数据输出解析
5.1 两个重要方法
这里是最后的阶段:处理reduce阶段
的输出
与OutputFormat
相关的重要方法:
getRecordWriter方法
:获取RecoredWriter对象
,通过该对象写出数据流checkOutputSpecs方法
:检查输出文件夹是否存在
1、getRecordWriter方法
:获取RecoredWriter对象
,通过该对象写出数据流。在OutputFormat类
中可以定义的该抽象方法
2、checkOutputSpecs方法
:检查输出文件夹是否存在。在客户端写Driver
文件的时候,会配置输出路径,通过该方法可以检查文件夹是否存在
可以在OutputFormat.class
文件中找到该方法,该方法是抽象方法
很多类继承OutputFormat.class
,其中包括FileOutputFormat类
,他是一个抽象类
,可以用来进行配置hadoop
的输出文件夹。如同InputFormat
,他一个默认的实现类是TextOutputFormat
,输出的数据是以字符串形式的,也就是我们默认创建项目输出的文件内容:是一行行的字符串
FileOutputFormat类
实现了checkOutputSpecs方法
,提供给子类用以检查输出文件是否存在
5.2 TextOutputFormat类
TextOutputFormat类
是FileOutputFormat类
的默认具体的实现类。其中的LineRecordWriter内部类
负责数据的输出
其中负责输出的方法是:writeObject
。reduce阶段
输出的k-v
实际就是一个对象,所以他会通过toString方法
转换成序列化字符串,并转换字符串数据的格式然后通过数据输出流out
的write方法
进行写出
因而如果输出某个对象(无论是
key
还是value
),都可以自定义他们的toString方法
进行格式化输出
5.3 自定义OutputFormat
无论是OutputFormat
还是InputFormat
,都是可以自定义的
但是一般而言自定义OutputFormat
更多,因为有时候更需要一些自定义的输出
自定义主要步骤如下:
- 自定义类继承
FileOutputFormat
- 重
getRecordWriter方法
- 自定义
xxxRecordwriter类
,继承Recordwriter类
,重写write方法
5.4 OutputFormat实例
1、需求:过滤输入的 log日志
,包含baidu
的网站输出到baidu.log
,不包含baidu
的网站输出到other.log
2、解析:跟分区输出不同的是,分区输出的是一个个分区文件,分区文件只有分区号,而不是指定文件名的文件
3、输入数据
http://www.baidu.com
http://www.aaadu.com
http://www.babdu.com
http://www.cacdu.com
http://www.daidu.com
http://www.eaddu.com
http://www.faidu.com
http://www.baidu.com
4、OutformatMapper.class
package com.outpurformat.maven;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import java.io.IOException;
public class OutformatMapper extends Mapper<LongWritable, Text, Text, NullWritable> {
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
// super.map(key, value, context);
// 这里测试数据比较简单,直接将数据行输出就行了,value就设置为空
context.write(value, NullWritable.get());
}
}
5、OutformatReducer.class
package com.outpurformat.maven;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
import java.io.IOException;
public class OutformatReducer extends Reducer<Text, NullWritable, Text, NullWritable> {
@Override
protected void reduce(Text key, Iterable<NullWritable> values, Context context) throws IOException, InterruptedException {
// super.reduce(key, values, context);
for (NullWritable value: values){
// 因为对于baidu的网址会有多个,而Map阶段直接输出的,而相同的key会进行分组,因而需要迭代输出
context.write(key,value);
}
}
}
6、MyOutputFormat.class
:自定义InputFormat
文件
package com.outpurformat.maven;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.RecordWriter;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import java.io.IOException;
public class MyOutputFormat extends FileOutputFormat<Text, NullWritable> {
@Override
public RecordWriter<Text, NullWritable> getRecordWriter(TaskAttemptContext job) throws IOException, InterruptedException {
/**
* 按之前的源码分析,OutputFormat是通过RecordWriter进行处理的
* 我们需要自定义输出规则,那么就得自定义RecordWriter并通过
* 该方法进行返回
*/
MyRecordWriter mw = new MyRecordWriter(job);
return mw;
}
}
7、MyRecordWriter.class
package com.outpurformat.maven;
import org.apache.hadoop.fs.FSDataOutputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.RecordWriter;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import java.io.IOException;
public class MyRecordWriter extends RecordWriter<Text, NullWritable> {
private String baiduPath = "E:\\bigdata\\study\\test_files\\outputformatresult\\baidu.log";
private String otherPath = "E:\\bigdata\\study\\test_files\\outputformatresult\\other.log";
private FileSystem fs ;
private FSDataOutputStream baiduOutputStream;
private FSDataOutputStream otherOutputStream;
public MyRecordWriter(TaskAttemptContext context) throws IOException {
/**
* 通过文件系统创建输出流,是FileSystem会根据环境获取对应的文件系统
* 然后根据不同的文件系统创建不同的输出流
*/
fs = FileSystem.get(context.getConfiguration());
/**
* 创建数据输出流,因为MR程序一般是运行在服务器节点上
* 数据是以流的形式进行传递
*/
baiduOutputStream = fs.create(new Path(baiduPath));
otherOutputStream = fs.create(new Path(otherPath));
}
/**
* 写出数据的方法
* @param key 对应redece写出的key
* @param value 对于redcue写出的value
* @throws IOException
* @throws InterruptedException
*/
@Override
public void write(Text key, NullWritable value) throws IOException, InterruptedException {
String web = key.toString();
if(web.contains("baidu")){
baiduOutputStream.writeBytes(web+"\r");
}else{
otherOutputStream.writeBytes(web+"\r");
}
}
/**
* 关闭方法,可以将一些资源关闭的操作放到这里
* @param context
* @throws IOException
* @throws InterruptedException
*/
@Override
public void close(TaskAttemptContext context) throws IOException, InterruptedException {
// 关闭流
IOUtils.closeStream(baiduOutputStream);
IOUtils.closeStream(otherOutputStream);
}
}
8、OutformatDriver.class
package com.outpurformat.maven;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.CombineTextInputFormat;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import java.io.IOException;
public class OutformatDriver {
public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
Configuration conf = new Configuration();
Job job = Job.getInstance(conf);
job.setJarByClass(OutformatDriver.class);
job.setMapperClass(OutformatMapper.class);
job.setReducerClass(OutformatReducer.class);
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(NullWritable.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(NullWritable.class);
FileInputFormat.setInputPaths(job, new Path("E:\\bigdata\\study\\test_files\\outputformatinput"));
/**
* 输出路径是需要存在的,因为实现FileOutputFormat并重写write方法仅仅是对输出文件
* 进行了配置,但是一些例如_SUCCESS这些成功标志文件还是需要其他方法处理的
* 所以配置一个输出目录去接受这些文件
*/
FileOutputFormat.setOutputPath(job, new Path("E:\\bigdata\\study\\test_files\\outputformatoutput"));
// 配置格式化类
job.setOutputFormatClass(MyOutputFormat.class);
job.waitForCompletion(true);
}
}
9、结果
-
outputformatoutput目录
-
outputformatresult目录