写在前面: 本文主要介绍了MapTask的工作流程,即:input-map-output。
MapTask input流程
源码分析
首先看input的入口,即MapTask的run方法
public void run(final JobConf job, final TaskUmbilicalProtocol umbilical)
throws IOException, ClassNotFoundException, InterruptedException {
this.umbilical = umbilical;
if (isMapTask()) {
// If there are no reducers then there won't be any sort. Hence the map
// phase will govern the entire attempt's progress.
// 如果没有reducer,则所有的资源都给map方法
if (conf.getNumReduceTasks() == 0) { // 不需要排序
mapPhase = getProgress().addPhase("map", 1.0f);
} else {
// If there are reducers then the entire attempt's progress will be
// split between the map phase (67%) and the sort phase (33%).
// 如果有reducer,则分66.7%的资源给map方法,33.3%的资源给排序
mapPhase = getProgress().addPhase("map", 0.667f);
sortPhase = getProgress().addPhase("sort", 0.333f);
}
}
TaskReporter reporter = startReporter(umbilical);
boolean useNewApi = job.getUseNewMapper();
// 进行上下文初始化,以及确定map输出的outputFormat格式类。
initialize(job, getJobID(), reporter, useNewApi);
// check if it is a cleanupJobTask
if (jobCleanup) {
runJobCleanupTask(umbilical, reporter);
return;
}
if (jobSetup) {
runJobSetupTask(umbilical, reporter);
return;
}
if (taskCleanup) {
runTaskCleanupTask(umbilical, reporter);
return;
}
if (useNewApi) {
// hadoop2.x默认使用新的API启动Mapper
runNewMapper(job, splitMetaInfo, umbilical, reporter);
} else {
runOldMapper(job, splitMetaInfo, umbilical, reporter);
}
done(umbilical, reporter);
}
run方法中会先判断后续是否需要reduce,若不需要则所有资源都用于map流程,若需要则分配33.3%的资源用于partition和key的排序。接着initialize方法进行上下文初始化,以及确定map输出的OutputFormat格式类。最终调用runNewMapper方法。
private <INKEY,INVALUE,OUTKEY,OUTVALUE>
void runNewMapper(final JobConf job,
final TaskSplitIndex splitIndex,
final TaskUmbilicalProtocol umbilical,
TaskReporter reporter
) throws IOException, ClassNotFoundException,
InterruptedException {
// make a task context so we can get the classes
// 构造TaskAttemptContext的对象taskContext
org.apache.hadoop.mapreduce.TaskAttemptContext taskContext =
new org.apache.hadoop.mapreduce.task.TaskAttemptContextImpl(job,
getTaskID(),
reporter);
// make a mapper
// 通过taskContext,经过反射机制拿到job任务传递过来Mapper目标对象。
org.apache.hadoop.mapreduce.Mapper<INKEY,INVALUE,OUTKEY,OUTVALUE> mapper =
(org.apache.hadoop.mapreduce.Mapper<INKEY,INVALUE,OUTKEY,OUTVALUE>)
ReflectionUtils.newInstance(taskContext.getMapperClass(), job);
// make the input format
// 通过taskContext可以构造从job传入的输入map的格式化类的对象。
org.apache.hadoop.mapreduce.InputFormat<INKEY,INVALUE> inputFormat =
(org.apache.hadoop.mapreduce.InputFormat<INKEY,INVALUE>)
ReflectionUtils.newInstance(taskContext.getInputFormatClass(), job);
// rebuild the input split
// 创建这个MapTask需要使用到的split对象。
// 这个方法内部是会根据当前这个mapTask传入的split对应的文件块地址,去hdfs中获取这个文件,
// 然后调用seek方法跳转到这个split对应的读取文件块的offset坐标。
org.apache.hadoop.mapreduce.InputSplit split = null;
split = getSplitDetails(new Path(splitIndex.getSplitLocation()),
splitIndex.getStartOffset());
LOG.info("Processing split: " + split);
// 构造RecordReader,也就是从split切片中读取一条条记录,格式化成RecordReader传给map处理
org.apache.hadoop.mapreduce.RecordReader<INKEY,INVALUE> input =
new NewTrackingRecordReader<INKEY,INVALUE>
(split, inputFormat, reporter, taskContext);
job.setBoolean(JobContext.SKIP_RECORDS, isSkipping());
org.apache.hadoop.mapreduce.RecordWriter output = null;
// get an output object
if (job.getNumReduceTasks() == 0) {
output =
new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
} else {
// NewOutputCollector对象确定了排序器,分区器
output = new NewOutputCollector(taskContext, job, umbilical, reporter);
}
org.apache.hadoop.mapreduce.MapContext<INKEY, INVALUE, OUTKEY, OUTVALUE>
mapContext =
new MapContextImpl<INKEY, INVALUE, OUTKEY, OUTVALUE>(job, getTaskID(),
input, output,
committer,
reporter, split);
org.apache.hadoop.mapreduce.Mapper<INKEY,INVALUE,OUTKEY,OUTVALUE>.Context
mapperContext =
new WrappedMapper<INKEY, INVALUE, OUTKEY, OUTVALUE>().getMapContext(
mapContext);
try { // 核心方法一般在try里面
input.initialize(split, mapperContext);
// mapper就是我们自己的mapper类
mapper.run(mapperContext);
mapPhase.complete();
setPhase(TaskStatus.Phase.SORT);
statusUpdate(umbilical);
input.close();
input = null;
output.close(mapperContext);
output = null;
} finally {
closeQuietly(input);
closeQuietly(output, mapperContext);
}
}
该方法首先构造TaskAttemptContext的对象taskContext,再通过taskContext构造从job传入的输入map的格式化类的对象。然后通过getSplitDetails方法创建split对象,这个方法会根据MapTask传入的split对应的block location去HDFS中获取这个文件,然后在inFile.seek(offset);中跳转到这个split对应block的offset坐标。
接着会构造RecordReader,也就是从split切片中读取一条条记录,格式化成RecordReader传给map处理。可以看到NewTrackingRecordReader的构造方法中,this.real = inputFormat.createRecordReader(split, taskContext);,RecordReader real就是map任务读取split使用的对象,也是写出map处理结果所使用的对象。
由于inputFormat对象默认为TextInputFormat类,所以返回的是LineRecordReader对象。进入该对象的initialize方法看一看:
public void initialize(InputSplit genericSplit,
TaskAttemptContext context) throws IOException {
FileSplit split = (FileSplit) genericSplit;
Configuration job = context.getConfiguration();
this.maxLineLength = job.getInt(MAX_LINE_LENGTH, Integer.MAX_VALUE);
start = split.getStart();
end = start + split.getLength();
final Path file = split.getPath();
// open the file and seek to the start of the split
final FutureDataInputStreamBuilder builder =
file.getFileSystem(job).openFile(file);
FutureIOSupport.propagateOptions(builder, job,
MRJobConfig.INPUT_FILE_OPTION_PREFIX,
MRJobConfig.INPUT_FILE_MANDATORY_PREFIX);
fileIn = FutureIOSupport.awaitFuture(builder.build()); // 面向文件的输入流
CompressionCodec codec = new CompressionCodecFactory(job).getCodec(file);
if (null!=codec) {
isCompressedInput = true;
decompressor = CodecPool.getDecompressor(codec);
if (codec instanceof SplittableCompressionCodec) {
final SplitCompressionInputStream cIn =
((SplittableCompressionCodec)codec).createInputStream(
fileIn, decompressor, start, end,
SplittableCompressionCodec.READ_MODE.BYBLOCK);
in = new CompressedSplitLineReader(cIn, job,
this.recordDelimiterBytes);
start = cIn.getAdjustedStart();
end = cIn.getAdjustedEnd();
filePosition = cIn;
} else {
if (start != 0) {
// So we have a split that is only part of a file stored using
// a Compression codec that cannot be split.
throw new IOException("Cannot seek in " +
codec.getClass().getSimpleName() + " compressed stream");
}
in = new SplitLineReader(codec.createInputStream(fileIn,
decompressor), job, this.recordDelimiterBytes);
filePosition = fileIn;
}
} else {
fileIn.seek(start); // 每个map不可能读全文,需要从具体某个offset开始读取
in = new UncompressedSplitLineReader(
fileIn, job, this.recordDelimiterBytes, split.getLength());
filePosition = fileIn;
}
// If this is not the first split, we always throw away first record
// because we always (except the last split) read one extra line in
// 确认split开始读取的位置
if (start != 0) {
start += in.readLine(new Text(), 0, maxBytesToConsume(start));
}
this.pos = start;
}
开始start被赋值为第一个split第一行数据对应block的offset,并且fileIn作为面向文件的输入流会通过seek方法从start开始读。然后该方法最有意思一段出现了:
if (start != 0) {
start += in.readLine(new Text(), 0, maxBytesToConsume(start));
}
由于HDFS用于划分文件的block是通过字节来划分的,一行数据可能会被分割在两个block中,map计算肯定需要读取完整的一行。所以这时候会去判断start是否不为0,也就是判断该split是否是第一个split。如果不是则丢弃第一行,并把起始的offset即start更新为该split的第二行。简单来说,除了第一个split,都会丢弃第一行数据,除了最后一个split,都会多读取一行数据。这样可以弥补HDFS的block切割数据的问题。
当LineRecordReader初始化完成后,执行mapper.run(mapperContext),也就是我们自己写的Mapper方法。
public void run(Context context) throws IOException, InterruptedException {
setup(context);
try {
// 最终调用LineRecordReader的nextKeyValue方法
// 1 读取数据中的一条记录,并对key,value赋值 2 返回一个布尔值给调用者,声明是否还有数据。
while (context.nextKeyValue()) {
map(context.getCurrentKey(), context.getCurrentValue(), context);
}
} finally {
cleanup(context);
}
}
这里的context是MapContextImpl,context.nextKeyValue()方法相当于LineRecordReader的nextKeyValue方法。该方法读取数据中的一条记录,对key和value赋值,并返回是否还有数据。然后通过getCurrentKey和getCurrentValue分别获取key和value。
MapTask output流程
源码分析
继续看MapTask中的run方法。
if (job.getNumReduceTasks() == 0) {
output =
new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
} else {
// NewOutputCollector对象确定了排序器,分区器
output = new NewOutputCollector(taskContext, job, umbilical, reporter);
}
这里查看需要分区排序的情况,进入NewOutputCollector构造方法:
NewOutputCollector(org.apache.hadoop.mapreduce.JobContext jobContext,
JobConf job,
TaskUmbilicalProtocol umbilical,
TaskReporter reporter
) throws IOException, ClassNotFoundException {
// buffer缓冲区相关逻辑
collector = createSortingCollector(job, reporter);
partitions = jobContext.getNumReduceTasks(); // 有多少reduce任务就有多少分区
//如果reduceTask大于1,就通过反射机制获取分区器,可以自定义分区器!
//如果只有一个reduceTask,那就直接返回0,因为只会被那一个ReduceTask拉取。
if (partitions > 1) {
partitioner = (org.apache.hadoop.mapreduce.Partitioner<K,V>)
ReflectionUtils.newInstance(jobContext.getPartitionerClass(), job);
} else { // partitions == 1 的情况
partitioner = new org.apache.hadoop.mapreduce.Partitioner<K,V>() {
@Override
public int getPartition(K key, V value, int numPartitions) {
return partitions - 1; // 返回0
}
};
}
}
NewOutputCollector对象确定排序器collector和分区器partitioner。首先查看创建排序器的方法createSortingCollector,该方法中默认给定MapOutputBuffer作为排序器。我们查看关键方法,初始化排序器collector.init(context)
public void init(MapOutputCollector.Context context
) throws IOException, ClassNotFoundException {
job = context.getJobConf();
reporter = context.getReporter();
mapTask = context.getMapTask();
mapOutputFile = mapTask.getMapOutputFile();
sortPhase = mapTask.getSortPhase();
spilledRecordsCounter = reporter.getCounter(TaskCounter.SPILLED_RECORDS);
partitions = job.getNumReduceTasks();
rfs = ((LocalFileSystem)FileSystem.getLocal(job)).getRaw();
//sanity checks
// 当这个排序缓冲区空间被map输出记录占用一定比例的时候,发生排序并溢写到磁盘
final float spillper =
job.getFloat(JobContext.MAP_SORT_SPILL_PERCENT, (float)0.8); // 溢写的默认值,80%而不是100%是为了放置map往buffer写的时候阻塞
// 当前缓冲区的大小
final int sortmb = job.getInt(MRJobConfig.IO_SORT_MB,
MRJobConfig.DEFAULT_IO_SORT_MB);
indexCacheMemoryLimit = job.getInt(JobContext.INDEX_CACHE_MEMORY_LIMIT,
INDEX_CACHE_MEMORY_LIMIT_DEFAULT);
if (spillper > (float)1.0 || spillper <= (float)0.0) {
throw new IOException("Invalid \"" + JobContext.MAP_SORT_SPILL_PERCENT +
"\": " + spillper);
}
if ((sortmb & 0x7FF) != sortmb) {
throw new IOException(
"Invalid \"" + JobContext.IO_SORT_MB + "\": " + sortmb);
}
// 排序器,默认快排
sorter = ReflectionUtils.newInstance(job.getClass(
MRJobConfig.MAP_SORT_CLASS, QuickSort.class,
IndexedSorter.class), job);
// buffers and accounting
int maxMemUsage = sortmb << 20;
maxMemUsage -= maxMemUsage % METASIZE;
kvbuffer = new byte[maxMemUsage];
bufvoid = kvbuffer.length;
kvmeta = ByteBuffer.wrap(kvbuffer)
.order(ByteOrder.nativeOrder())
.asIntBuffer();
setEquator(0);
bufstart = bufend = bufindex = equator;
kvstart = kvend = kvindex;
maxRec = kvmeta.capacity() / NMETA;
softLimit = (int)(kvbuffer.length * spillper);
bufferRemaining = softLimit;
LOG.info(JobContext.IO_SORT_MB + ": " + sortmb);
LOG.info("soft limit at " + softLimit);
LOG.info("bufstart = " + bufstart + "; bufvoid = " + bufvoid);
LOG.info("kvstart = " + kvstart + "; length = " + maxRec);
// k/v serialization
// 比较器
comparator = job.getOutputKeyComparator();
keyClass = (Class<K>)job.getMapOutputKeyClass();
valClass = (Class<V>)job.getMapOutputValueClass();
serializationFactory = new SerializationFactory(job);
keySerializer = serializationFactory.getSerializer(keyClass);
keySerializer.open(bb);
valSerializer = serializationFactory.getSerializer(valClass);
valSerializer.open(bb);
// output counters
mapOutputByteCounter = reporter.getCounter(TaskCounter.MAP_OUTPUT_BYTES);
mapOutputRecordCounter =
reporter.getCounter(TaskCounter.MAP_OUTPUT_RECORDS);
fileOutputByteCounter = reporter
.getCounter(TaskCounter.MAP_OUTPUT_MATERIALIZED_BYTES);
// compression
if (job.getCompressMapOutput()) {
Class<? extends CompressionCodec> codecClass =
job.getMapOutputCompressorClass(DefaultCodec.class);
codec = ReflectionUtils.newInstance(codecClass, job);
} else {
codec = null;
}
// combiner
// 如果map阶段重复key很多没有必要,可以做一次小的reduce进行一次压缩
final Counters.Counter combineInputCounter =
reporter.getCounter(TaskCounter.COMBINE_INPUT_RECORDS);
combinerRunner = CombinerRunner.create(job, getTaskID(),
combineInputCounter,
reporter, null);
if (combinerRunner != null) {
final Counters.Counter combineOutputCounter =
reporter.getCounter(TaskCounter.COMBINE_OUTPUT_RECORDS);
combineCollector= new CombineOutputCollector<K,V>(combineOutputCounter, reporter, job);
} else {
combineCollector = null;
}
spillInProgress = false;
minSpillsForCombine = job.getInt(JobContext.MAP_COMBINE_MIN_SPILLS, 3);
spillThread.setDaemon(true);
spillThread.setName("SpillThread");
spillLock.lock();
try {
spillThread.start();
while (!spillThreadRunning) {
spillDone.await();
}
} catch (InterruptedException e) {
throw new IOException("Spill thread failed to initialize", e);
} finally {
spillLock.unlock();
}
if (sortSpillException != null) {
throw new IOException("Spill thread failed to initialize",
sortSpillException);
}
}
浏览一下该方法,有几个关键点:
spillper:当缓冲区被map的输出占用到一定比例时,则发生分区、排序并溢写到磁盘。默认80%;sortmb:缓冲区大小;sorter:发生排序时使用的排序器,默认快排;comparator:排序器所需的比较器,优先使用用户自定义的排序比较器,默认使用key自身的比较器;combinerRunner:默认不合并,需要手动改设置。用于map阶段对重复的key进行reduce操作,合并次数默认为3;SpillThread:该线程中的sortAndSpill方法实现了排序和溢写。
再回到Mapper中。run方法调用了map方法。
protected void map(KEYIN key, VALUEIN value,
Context context) throws IOException, InterruptedException {
context.write((KEYOUT) key, (VALUEOUT) value);
}
这里的context是MapContextImpl,进入write方法,发现MapContextImpl中没有该方法,则向他的父类寻找。随后在TaskInputOutputContextImpl中发现了该实现:
public void write(KEYOUT key, VALUEOUT value
) throws IOException, InterruptedException {
output.write(key, value);
}
这里的output就是上文提到的NewOutputCollector,可以看到它的write方法如下:
public void write(K key, V value) throws IOException, InterruptedException {
// map进入buffer时,参数为k v p
collector.collect(key, value,
partitioner.getPartition(key, value, partitions));
}
而partitions是通过getPartition方法获取的:
public int getPartition(K key, V value,
int numReduceTasks) {
// key.hashCode() & Integer.MAX_VALUE得到一个非负整数
// % numReduceTasks 取模 相同的key会进入同一个分区
return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
}
最终将key value partitions从map存入了buffer中。
图解MapOutputBuffer流程
buffer本质是线性的字节数组,存储key value后还需要相应的索引才能方便查询。关于索引,它固定16并且包含一下内容:
P:partitionKS:key在buffer中的起始位置VS:value在buffer中的起始位置,也可以计算出key结束的位置VL:value的长度,也可以计算出value的结束位置
在Hadoop 1.x的版本中,索引是存放在一个独立的字节数组中的。试想这样一种情况,key value非常小,但是数量较多,在buffer中占用的空间较小。与此同时存放索引的空间缺满了,这样不得不进行溢写,从而浪费了buffer的空间。
到了Hadoop 2.x后,key value和索引存放在同一个字节数组中,且key value按序从左向右存储,索引按需从右向左存储。这样就解决了buffer空间浪费的问题。
假设现在是默认情况,buffer的占用空间已达到80%,会将当前key value和索引占用的空间锁住,然后启动SpillThread线程将80%的数据进行快速排序,同时map向剩余空间写数据。此时的排序是二次排序,先通过索引中的P进行排序,再在相同的P中通过key排序,最终达到分区有序以及分区内的key有序。
需要注意的是,排序涉及到内存数据的移动,由于key value的大小不一致,数据移动会很复杂。所以这里移动的只是索引,因为索引大小是固定的。最终,溢写时只要按照排序的索引,写入磁盘文件中的数据就是有序的。
接下来需要关注溢写的同时map如何向buffer中写数据。
在空闲的内存中进行一次分割,分割线左侧追加索引,分割线右边追加key value,这样就可以将buffer看作是环形缓冲区。
combiner TODO 待完善
combiner当于提前对数据进行reduce操作,该操作发生在buffer排序后以及溢写前。
map最终会把一些出来的小文件合并成一个大文件,避免小文件碎片化导致reduce在拉取数据时造成随机读写。