目录
Mapper类
Mapper的输入
InputFormat
文件输入FileInputFormat & 输入分片InputSplit
文本输入TextInputFormat & 行记录阅读器LineRecordReader
Mapper的输出
收集器Collector
分区器Partitioner
案例:分别计算奇数行和偶数行之和
Hadoop的代码中为作业的操作流程定义了几个阶段
enum Phase{
STARTING,MAP,SHUFFLE,SORT,REDUCE,CLEANUP;
}
宏观上来说,MR框架由map和reduce这两个阶段组成,但实际上这两个阶段都可以进一步划分成多个更微观的阶段,比如Mapper的输出端有个由框架提供的局部排序阶段,而Reducer输入端的收取(Fetch)和合并(Merge),以至汇合(Combine)阶段又带有排序的成分等。
public class Mapper {
public abstract class Context
implements MapContext {
}
protected void setup(Context context
) throws IOException, InterruptedException {
// NOTHING
}
protected void map(KEYIN key, VALUEIN value,
Context context) throws IOException, InterruptedException {
context.write((KEYOUT) key, (VALUEOUT) value);
}
protected void cleanup(Context context
) throws IOException, InterruptedException {
// NOTHING
}
public void run(Context context) throws IOException, InterruptedException {
setup(context);
try {
while (context.nextKeyValue()) {
map(context.getCurrentKey(), context.getCurrentValue(), context);
}
} finally {
cleanup(context);
}
}
}
Mapper将输入的键值对转换成一组中间键值对;转换后的中间记录不必与输入记录的类型相同,指定的输入对可以映射为多个或零个输出对;
MR框架为作业中的每一个InputSplit(输入分片)生成一个独立的map任务,每个分片经过框架处理会变成多个键值对,这些键值对都存储在Context对象中,Context类实现了MapContext界面,具有nextKeyValue()、getCurrentKey()、getCurrentValue()、write方法;
可见Map任务真正的执行逻辑位于run()方法中,setup()和cleanup()都是空的,分别在任务执行前后进行调用;然后Mapper的核心就是一个while循环,其中对每一个键值对执行了map()方法。默认的map()方法中其实没进行说明处理,只是将输入的键值对原封不动地通过context.write()出去了,所以通常我们都会重写map()方法。
MR框架的数据源可以来自HDFS文件,也可以是例如查询数据库的输出等。文件的类型也没有规定特定的格式,例如也可以是网页。那么MR框架是如何读出不同类型的数据,然后形成键值对并作为Mapper(map()方法)的输入呢,这就是InputFormat的作用。不同的数据源,不同的数据格式,就需要采用不同的InputFormat类型,所以InputFormat是一个抽象类
public abstract class InputFormat {
public abstract
List getSplits(JobContext context
) throws IOException, InterruptedException;
public abstract
RecordReader createRecordReader(InputSplit split,
TaskAttemptContext context
) throws IOException,
InterruptedException;
}
InputFormat描述了MR程序的输入规则,其中getSplits()返回一个InputSplit的列表,一个分片对应一个Mapper。如果给定Mapper的数量,那么分片的数量也就随之确认了。但如果不给定Mapper的数量,如何进行分片就是不同类型的InputFormat需要考虑的问题了。InputSplit是一个接口:
public interface InputSplit extends Writable {
long getLength() throws IOException;
String[] getLocations() throws IOException;
}
InputSplit包含了以字节为长度的长度和一组存储位置(主机名)。分片并不包含数据本身,而是指向数据的引用。存储位置供MR框架使用以便将map任务尽量放在分片数据的附近,而分片大小用来排序分片,以便处理最大的分片,从而最小化作业时间。
map任务将输入分片传给InputFormat中的createRecordReader来获得这个分片的RecordReader,RecordReader即记录阅读器,所以实际上是RecordReader来将原始数据转换成输入map()方法的键值对。同样RecordReader中也不存放数据本身,而是指向数据的引用。每种InputFormat都有配套的RecordReader,Mapper中对于context.nextKeyValue()等方法的调用实际上最终是由RecordReader来实现的。
如果是基于对文件的操作,通常使用的是FileInputFormat子类。
//FileInputFormat的指定泛型类型与map()的输入键值对类型一致,即与k1与v1类型一致
public abstract class FileInputFormat implements InputFormat
在Driver类中通过addInputPath()可以指定一个文件或者是一个目录,若指定了一个目录则默认其内容不会被递归处理,因为若包含子目录,也会被认为是文件从而产生错误,但是也可以通过将mapreduce.input.fileinputformat.input.recursive设置为true以强制对输入目录进行递归读取。
//默认为false
public static final String INPUT_DIR_RECURSIVE =
org.apache.hadoop.mapreduce.lib.input.FileInputFormat.INPUT_DIR_RECURSIVE;
另外FileInputFormat会使用一个默认的过滤器来排除隐藏文件(文件名以' . ' 、' _ '开头的文件)
private static final PathFilter hiddenFileFilter = new PathFilter(){
public boolean accept(Path p){
String name = p.getName();
return !name.startsWith("_") && !name.startsWith(".");
}
};
FileInputFormat会根据文件大小进行切分逻辑的InputSplit,默认情况下与块大小一致。在getSplit()中可见计算分片大小的部分逻辑:
//默认最小分片大小为1,可以通过mapreduce.input.fileinputformat.split.minsize设置
public static final String SPLIT_MINSIZE =
"mapreduce.input.fileinputformat.split.minsize";
//默认最大分片大小为Long的最大值,可以通过mapreduce.input.fileinputformat.split.maxsize设置
public static final String SPLIT_MAXSIZE =
"mapreduce.input.fileinputformat.split.maxsize";
public List getSplits(JobContext job) throws IOException {
//...
//若指定文件可分片
if (isSplitable(job, path)) {
//文件块大小,HDFS默认为128M
long blockSize = file.getBlockSize();
//分片大小为块大小(128M),最小分片大小(1B),最大分片大小(Long.MAX_VALUE B)的中间值,即128M
long splitSize = computeSplitSize(blockSize, minSize, maxSize);
若要增加分片大小,可以提供更大的HDFS块(通过dfs.blocksize来设置),或者使最小分片大小的值大于块大小,但代价是增加了本地操作;若要减小分片大小,可以通过使最大分片大小的值小于块大小实现。
FileInputFormat声明了一个叫SPLIT_SLOP的常量,值为1.1,这个常量的意义在于避免生成过小的分片
SPLIT_SLOP决定了实际上分片的大小并不一定为块大小,在getSplits()中有明确的划分方法
if (isSplitable(job, path)) {
long blockSize = file.getBlockSize();
long splitSize = computeSplitSize(blockSize, minSize, maxSize);
//bytesRemaining为剩余还未被分片的字节数,最初即为文件长度
long bytesRemaining = length;
//当剩余未被分数字节数与默认分片大小之比>1.1
while (((double) bytesRemaining)/splitSize > SPLIT_SLOP) {
int blkIndex = getBlockIndex(blkLocations, length-bytesRemaining);
//生成一个大小为块大小(128M)的分片
splits.add(makeSplit(path, length-bytesRemaining, splitSize,
blkLocations[blkIndex].getHosts(),
blkLocations[blkIndex].getCachedHosts()));
bytesRemaining -= splitSize;
}
//当剩余未被分数字节数与默认分片大小之比<1.1
if (bytesRemaining != 0) {
int blkIndex = getBlockIndex(blkLocations, length-bytesRemaining);
//最后一个分片大小即为剩余字节数,最后一个分片大小最大可能是128M*1.1
splits.add(makeSplit(path, length-bytesRemaining, bytesRemaining,
blkLocations[blkIndex].getHosts(),
blkLocations[blkIndex].getCachedHosts()));
}
//若不能分片,则生成一个分片,分片长度即文件字节数
} else {
splits.add(makeSplit(path, 0, length, blkLocations[0].getHosts(),
blkLocations[0].getCachedHosts()));
}
不过InputSplit是个抽象类,就数据文件而言,其InputSplit是FileSplit
public class FileSplit extends InputSplit implements Writable {
private Path file; //文件路径
private long start; //该split在文件中的起点
private long length; //该split的长度
private String[] hosts; //该split内容所在的节点,一个分片可能涉及多个节点
private SplitLocationInfo[] hostInfos; //所涉及节点的信息
FileInputFormat同样也是个抽象类,一般默认使用的是它的子类TextInputFormat。TextInputFormat每条记录是一行输入。键是LongWriteble类型,存储该行在整个文件中的字节偏移量,值是Text类型,存储这行的内容,不包括换行符和回车符。
FileInputFormat中没有对createRecordReader方法的实现,因为不同的FileInputFormat对应着不同的RecordReader;TextInputFormat对应的是LineRecordReader。
在map函数中,context.nextKeyValue()、context.getCurrentKey()等实则是通过RecordReader来实现的,所以RecordReader中有对应的配套方法
public boolean nextKeyValue() throws IOException {
if (key == null) {
key = new LongWritable();
}
//pos为字节偏移量
key.set(pos);
if (value == null) {
value = new Text();
}
int newSize = 0;
//end为分片字节数,当还没有读到分片末尾时
while (getFilePosition() <= end || in.needAdditionalRecordAfterSplit()) {
if (pos == 0) {
//跳过分片的字节顺序标记,位于文件开始部分,内容只支持utf8,我们需要检查三个字节的(0xEF,0xBB,0xBF)以跳过
newSize = skipUtfByteOrderMark();
} else {
//读取一行,返回该行字节数,以计算新的偏移量
newSize = in.readLine(value, maxLineLength, maxBytesToConsume(pos));
pos += newSize;
}
if ((newSize == 0) || (newSize < maxLineLength)) {
break;
}
// 行太长,尝试重试
LOG.info("Skipped line of size " + newSize + " at pos " +
(pos - newSize));
}
//读完所有内容,则无下一个键值对,返回false
if (newSize == 0) {
key = null;
value = null;
return false;
} else {
return true;
}
}
//key为LongWritable 类型
public LongWritable getCurrentKey() {
return key;
}
//value为Text类型
public Text getCurrentValue() {
return value;
}
调用Mapper类中的map()方法实则是调用了MapTask.runNewMapper(),在runNewMapper中
//创建了与具体InputFormat对应的RecordReader
RecordReader input =
new NewTrackingRecordReader(split, inputFormat, reporter, taskContext);
NewTrackingRecordReader是具有可跟踪行动功能的RecordReader
mr程序ReduceTask的数量默认是1个,可以通过job.setNumReduceTasks(num)去修改个数。跟输出有关的主要是collector,是数据的收集器,context.write()追中是通过RecordWriter落实到collector.collect()。RecordWriter与RecordReader有些类似,它也是一个抽象类,具体实现类有NewDirectOutputCollector和NewOutputCollector,不过RecordWriter负责Mapper的输出
runNewMapper(final JobConf job,final TaskSplitIndex splitIndex,
final TaskUmbilicalProtocol umbilical,TaskReporter reporter){
//如果没有Reducer,则直接输出
if (job.getNumReduceTasks() == 0) {
//创建直接输出的collector
output =
new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
} else {
//不然则创建通往reducer的collector
output = new NewOutputCollector(taskContext, job, umbilical, reporter);
}
输出记录在发送给reducer之前,会被MR框架进行排序,其实是通过collector中的缓冲输出流MapOutputBuffer实现排序的,MapOutputBuffer是MapTask的一个内部类
如果Reducer的数量设置为0,那就没有Reducer阶段,Mapper的输出就是整个MR框架的输出。如果有Reducer则使用的是NewOutputCollector,它是MapTask的一个内部类,其中有另一重要成分:分区器。可以说collector负责收集Mapper输出并将其交付给Reducer,而partitioner决定了应该讲具体的输出交付给哪一个Reducer。
private class NewOutputCollector
extends org.apache.hadoop.mapreduce.RecordWriter {
private final MapOutputCollector collector;
private final org.apache.hadoop.mapreduce.Partitioner partitioner; //负责Mapper输出的分区
private final int partitions; //分发目标的个数,即分区数
//...
//可以通过job.setNumReduceTasks()设置分区数
partitions = jobContext.getNumReduceTasks();
默认情况ReduceTask的个数为1,默认partitioner是HashPartitioner
public class HashPartitioner extends Partitioner {
public int getPartition(K key, V value,
int numReduceTasks) {
return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
}
}
每个分区由一个reducer处理,所以分区数默认等于作业的reducer数。比如有5个ReduceTask,即numReduceTasks=5,通过HashPartitioner可能会有5个返回值,即5个分区,分区号分别为0,1,2,3,4。在自定义分区器时需要注意:
synchronized void collect(K key, V value, final int partition){
//...
//自定义分区器的返回值,即分区号不能<0或>分区数量
if (partition < 0 || partition >= partitions) {
throw new IOException("Illegal partition for " + key + " (" +
partition + ")");
}
由于我们可以手动设置reducer的数量,当分区数>ReduceTask数时,程序会报错;当分区数<ReduceTask数时,输出结果会有空文件生成;默认情况下分区数=ReduceTask,则没有空文件,且文件名为part-r-xxxx,其中r表示完成了reduce阶段;当ReduceTask为0时,即没有分区,map阶段的输出数据直接输出到MR程序指定的输出路径下,生成文件的个数是MapTask的数目,文件名为part-m-xxxx,结果是无排序的。所以MR程序是可以没有reduce阶段的,但一定要要map阶段。
若
文件中每一行为一个数字,分别计算奇偶数行之和
实现的方式有多种,可以使map阶段输出的key为奇偶数的标识(1和2),再在reduce阶段进行累加
首先需要获取行号,可以自定义RecordReader,将key值设置为行号
import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FSDataInputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.RecordReader;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.input.FileSplit;
import org.apache.hadoop.util.LineReader;
public class MyRecordReader extends RecordReader {
private long start;
private long LineNum;
private long end;
private LineReader in;
private FSDataInputStream fileIn;
private LongWritable key;
private Text value;
@Override
public void initialize(InputSplit split, TaskAttemptContext context) throws IOException, InterruptedException {
FileSplit filesplit = (FileSplit) split;
Path file = filesplit.getPath();
start = filesplit.getStart();
end = start+filesplit.getLength();
Configuration conf = context.getConfiguration();
FileSystem fs = file.getFileSystem(conf);
fileIn =fs.open(file);
fileIn.seek(start);
in = new LineReader(fileIn);
LineNum = 1;
}
@Override
public boolean nextKeyValue() throws IOException, InterruptedException {
if (key ==null) {
key = new LongWritable();
}
key.set(LineNum);
if(value == null) {
value = new Text();
}
if(in.readLine(value)==0) {
return false;
}
LineNum++;
return true;
}
@Override
public LongWritable getCurrentKey() throws IOException, InterruptedException {
// TODO Auto-generated method stub
return key;
}
@Override
public Text getCurrentValue() throws IOException, InterruptedException {
// TODO Auto-generated method stub
return value;
}
@Override
public float getProgress() throws IOException, InterruptedException {
// TODO Auto-generated method stub
return 0;
}
@Override
public void close() throws IOException {
in.close();
}
}
MyRecordReader的实例在TextInputFormat类中创建:
import java.io.IOException;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.JobContext;
import org.apache.hadoop.mapreduce.RecordReader;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
public class MyTextInputFormat extends FileInputFormat {
@Override
public RecordReader createRecordReader(InputSplit split, TaskAttemptContext context)
throws IOException, InterruptedException {
return new MyRecordReader();
}
@Override
protected boolean isSplitable(JobContext context, Path filename) {
return false;
}
}
随后就是自定义分区器来将奇偶行分区输出到reducer中
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.mapreduce.Partitioner;
public class MyPartitioner extends Partitioner {
public int getPartition(LongWritable key, IntWritable value, int numPartitions) {
//返回值,即分区号不能<0或>分区数
if (key.get() % 2 == 0) {
key.set(2);
return 0;
} else {
key.set(1);
return 1;
}
}
}
Mapper为默认的
import java.io.IOException;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
public class MyMapper extends Mapper{
@Override
protected void map(LongWritable key, Text value, Mapper.Context context)
throws IOException, InterruptedException {
context.write(key, value);
}
}
Reducer中进行累加
import java.io.IOException;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
public class MyReducer extends Reducer{
private int sum1 = 0;
@Override
protected void reduce(LongWritable key, Iterable vs,
Context context) throws IOException, InterruptedException {
for(Text t:vs) {
sum1+=Integer.parseInt(t.toString());
}
Text t = new Text();
t.set(sum1+"");
context.write(key, t);
}
}
Driver类
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
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.output.FileOutputFormat;
public class MyDriver {
public static void main(String[] args) throws Exception {
Job job = Job.getInstance();
job.setJobName("num count");
job.setJarByClass(MyDriver.class);
//设置自定义的InputFormat类
job.setInputFormatClass(MyTextInputFormat.class);
job.setMapperClass(MyMapper.class);
//当前有2两个分区,ReduceTask需要≥2
job.setNumReduceTasks(2);
//设置自定义的分区器类
job.setPartitionerClass(MyPartitioner.class);
job.setReducerClass(MyReducer.class);
job.setOutputKeyClass(LongWritable.class);
job.setOutputValueClass(Text.class);
FileInputFormat.addInputPath(job, new Path(arg[0]));
FileOutputFormat.setOutputPath(job, new Path(arg(1)));
System.exit(job.waitForCompletion(true)?0:1);
}
}
注意:setOutputKeyClass和setOutputValueClass默认是要求K2与K3,V2与V3的类型是相同的,若不同,需要通过setMapOutputKeyClass和setMapOutputValueClass来设置map阶段输出的K2与V2的类型
参考资料
《Hadoop权威指南 第4版》
《大数据处理系统 Hadoop源代码情景分析》