文章目录
MapRdeuce的执行逻辑图
一个MapReduce作业是客户端需要执行的一个工作单元:它包括输入数据,MapReduce程序和配置信息。Hadoop将作业分为若干个task来执行,其中主要包括两类:map任务和reduce任务。这些任务运行在集群的节点上,并通过YARN进行调度。一个完整的MapReduce程序由client,map,reduce这三大块组成。
Map任务概述
-
由上图我们知道,有多少一个Input Split就会对应多少个map,hadoop会为每一个input split创建一个map任务,所以map的数量是由input split的数量决定的。
-
Map任务主要就是对输入的数据进行一个处理,并输出一个K V键值对。
-
Map任务的输出是写入本地磁盘的,而非HDFS。因为Map的输出是一个中间结果,由reduce任务处理后才是最终的输出结果,而一旦作业完成,这些中间数据就可以删除,所以将这些文件存储到HDFS中就有些小题大做了。另外如果运行map任务的节点将map中间结果传输给reduce任务之前失败,Hadoop将在另一个节点上重新运行这个map任务以再次构建map中间结果。
Map任务的执行过程详解
Map函数开始产生输出时,并不是简单的将数据写入到磁盘中,实际上这是一个更加复杂的过程。
-
1.如上图所示,每个map任务都有一个对应的内存缓冲区,(默认情况下缓冲区的大小是100M,可以通过改变mapreduce.task.io.sort.mb属性来调整),map将数据处理的结果写入到内存缓冲区中(Memory Buffer)。
-
2.每个map任务都有一个后台溢写线程,当内存缓冲区达到了阈值(默认是80%,可以通过改变),就会将内存缓冲区中的内容spill到文件中。在写入到文件之前,会先对数据进行按照reduce的数量进行分区,并且按照数据的键进行排序。
-
2.2如果定义了combinner函数,那么在排序号会运行一次或者多次combinner函数,以减少map输出文件的大小。
-
3.后台线程在写文件时,缓冲区继续接收结果,如果在这过程中缓冲区满了map任务就会阻塞,直到数据写入到磁盘中文件中。
-
4.每次内存缓冲区达到阈值,后台线程就会spill一个新的文件,所以如图中所示,一个map任务可能有会产生多个spill文件。如果至少存在3个spill溢出文件,则combinner会在输出文件写入到磁盘前再次运行。(combinner可以反复运行,不会影响最终结果)如果spill文件较少(比如少于3个),那么map任务直接合并spill文件,写入到磁盘中。合并后的文件也是按照KEY进行排好序的。
上面的过程中提到了Combiner,这只是对map任务的一个优化手段,用于减少map输出的大小。另外还可以通过对输出文件进行压缩的方式进行优化。
对照源码解读
Map的Input部分的解读
Map Task的入口类是org.apache.hadoop.mapred.MapTask,map task任务运行时,会先调用该类的run方法
org.apache.hadoop.mapred.MapTask
public class MapTask extends Task {
@Override
public void run(final JobConf job, final TaskUmbilicalProtocol umbilical)
throws IOException, ClassNotFoundException, InterruptedException {
this.umbilical = umbilical;
……
if (useNewApi) {
runNewMapper(job, splitMetaInfo, umbilical, reporter);
} else {
runOldMapper(job, splitMetaInfo, umbilical, reporter);
}
done(umbilical, reporter);
}
}
我们可以看到在run方法中会调用到runNewMapper,在这个方法中有一个很重要的是构建了一个input,它是NewTrackingRecordReader的对象,代码如下:
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
org.apache.hadoop.mapreduce.TaskAttemptContext taskContext =
new org.apache.hadoop.mapreduce.task.TaskAttemptContextImpl(job,
getTaskID(),
reporter);
// 1.make a mapper
// 2.make the input format
// 3.构建分片处理对象
org.apache.hadoop.mapreduce.RecordReader<INKEY,INVALUE> input =
new NewTrackingRecordReader<INKEY,INVALUE>
(split, inputFormat, reporter, taskContext);
//初始化输入文件处理对象和上下文
input.initialize(split, mapperContext);
//执行用户定义的Mapper,会迭代调用到用户自定义的Mapper中的map方法
mapper.run(mapperContext);
//map阶段完成
mapPhase.complete();
}
}
NewTrackingRecordReader对象中封装了一个real迭代器(真迭代器),是一个RecordReader对象,关键代码如下:
org.apache.hadoop.mapred.MapTask.NewTrackingRecordReader#NewTrackingRecordReader
NewTrackingRecordReader(org.apache.hadoop.mapreduce.InputSplit split,
org.apache.hadoop.mapreduce.InputFormat<K, V> inputFormat,
TaskReporter reporter,
org.apache.hadoop.mapreduce.TaskAttemptContext taskContext)
throws InterruptedException, IOException {
//真迭代器,由它真正的迭代文件的内容,自定义的Mapper类中是假迭代器,逻辑上的迭代数据
this.real = inputFormat.createRecordReader(split, taskContext);
}
由上述代码我们可知,它实际上调用的是TextInputFormat类中的createRecordReader方法,然后进一步的调用到LineRecordReader的构造方法。所以本质上input是一个LineRecordReader对象。
public class TextInputFormat extends FileInputFormat<LongWritable, Text> {
@Override
public RecordReader<LongWritable, Text>
createRecordReader(InputSplit split,
TaskAttemptContext context) {
String delimiter = context.getConfiguration().get(
"textinputformat.record.delimiter");
byte[] recordDelimiterBytes = null;
if (null != delimiter)
recordDelimiterBytes = delimiter.getBytes(Charsets.UTF_8);
return new LineRecordReader(recordDelimiterBytes);
}
然后在MapTask的runNewMapper中我们知道会先调用input.initialize方法先进行初始化,由上面分析我们不难得知,input的本质上是一个LineRecordReader对象,所以也就会调用到LineRecordReader的initialize方法,那么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();
// 1.打开文件,然后将文件句柄移动到分片的起始位置,就可以真正读取文件内容了
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());
// 如果不是第一个分片,总是先读取文件的第一行,然后丢弃,将句柄位置移动到第二行
if (start != 0) {
start += in.readLine(new Text(), 0, maxBytesToConsume(start));
}
this.pos = start;
}
自此,map中的对文件进行实际迭代的真迭代器的分析就比较清晰了。我们知道,自定义的Mapper会继承Mapper类,在Mapper类中的run方法中会调用context.nextKeyValue()和context.getCurrentKey(), context.getCurrentValue()方法。
while (context.nextKeyValue()) {
map(context.getCurrentKey(), context.getCurrentValue(), context);
}
这就是我们说的假迭代器,一步步溯源我们就会发下,实际上最终调用的还是我们上面分析的真迭代器LineRecordReader中的nextKeyValue,getCurrentKey,getCurrentValue方法。
思考:由上述代码我们可以看到,map在读取非第一个分片文件的内容时会丢掉第一行,从第二行开始处理,那么为什么map任务处理会采用这种设计呢?
答:其实这和HDFS对文件的处理有关,我们知道HDFS存储一个很大的文件的时候,并不是存储的一个完整的文件,而是将一个很大的文件切分成一个个等长的数据块BLOCK,是严格按照字节数切分的。那么也就非常有可能存在某些完整的内容被截断分布在两个不同的数据块中的情况。而每一分片的大小默认是和数据块的大小相同,也就意味着除了第一个分片以外的其他分片的第一行数据可能是不完整的,所以map任务就将第一行舍弃,从第二行开始处理,这样处理的数据都是完整的,而不会因数据被截断导致结果错误。那么对于暂时被丢弃的第一行怎么处理呢?Hadoop的设计是由交由上一个map来处理,上一个map读取完自己分片的数据后会再读取下一个分片的第一行和自己数据的最后一行拼接在一起,然后进行判断是完整的一行还是两行进行处理。我们之前说过,如果分片过大导致数据分布在不同的数据块中可能会需要网络传输引发效率问题,但是这种设计Hadoop只需要使用网络拉取一行数据,只是一个很小的数据传输量,所以并不会因此造成太大的影响。由此可见,我们并不需要担心Hadoop将数据切分成不同的block导致数据被截断造成的影响。
Map Output的解读
map任务在对分片的每一行调用map函数处理后会输出,让我们一起来看看map的输出是怎么实现的。在runNewMapper类中有一段关键代码定义了map任务的输出对象,如下:
org.apache.hadoop.mapreduce.RecordWriter output = null;
// get an output object
if (job.getNumReduceTasks() == 0) {
output =
new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
} else {
output = new NewOutputCollector(taskContext, job, umbilical, reporter);
}
它调用了MapTask类中的私有内部类NEWOutputCollector的构造方法,它由一个分区器
partitioner、MapOutputCollector、和分区数量partitions组成,分区的个数是由reduce任务的个数决定的。下面代码中体现出了这一点。(分区下标由0开始,所以是reduce的个数减1)
private class NewOutputCollector<K,V>
extends org.apache.hadoop.mapreduce.RecordWriter<K,V> {
private final MapOutputCollector<K,V> collector;
private final org.apache.hadoop.mapreduce.Partitioner<K,V> partitioner;
private final int partitions;
@SuppressWarnings("unchecked")
NewOutputCollector(org.apache.hadoop.mapreduce.JobContext jobContext,
JobConf job,
TaskUmbilicalProtocol umbilical,
TaskReporter reporter
) throws IOException, ClassNotFoundException {
collector = createSortingCollector(job, reporter);
partitions = jobContext.getNumReduceTasks();
if (partitions > 1) {
partitioner = (org.apache.hadoop.mapreduce.Partitioner<K,V>)
ReflectionUtils.newInstance(jobContext.getPartitionerClass(), job);
} else {
partitioner = new org.apache.hadoop.mapreduce.Partitioner<K,V>() {
@Override
public int getPartition(K key, V value, int numPartitions) {
return partitions - 1;
}
};
}
}
}
collector是MapOutputCollector对象,调用了是MapOutputCollecto的createSortingCollector方法创建的
@SuppressWarnings("unchecked")
private <KEY, VALUE> MapOutputCollector<KEY, VALUE>
createSortingCollector(JobConf job, TaskReporter reporter)
throws IOException, ClassNotFoundException {
MapOutputCollector.Context context =
new MapOutputCollector.Context(this, job, reporter);
collector.init(context);
}
}
在该方法中调用了collector.init方法,实际调用的是MapOutputBuffer类中的init方法,在init方法中会创建一个默认大小是100M的内存缓冲区,创建一个排序器(也可以获取到用户设定的排序器),以及获取用户设定的Combinner。
public static class MapOutputBuffer<K extends Object, V extends Object>
implements MapOutputCollector<K, V>, IndexedSortable {
@SuppressWarnings("unchecked")
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();
//溢写阈值,如果没有指定,默认是0.8也就是80%,可以设置mapreduce.map.sort.spill.percent来改变大小
final float spillper =
job.getFloat(JobContext.MAP_SORT_SPILL_PERCENT, (float)0.8);
//内存缓冲区大小,默认是100M,可以通过设置mapreduce.task.io.sort.mb改变
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;
//创建一个100M的内存缓冲区
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;
// k/v serialization K/V序列化器
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
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);
}
}
在我们自定的Mapper类中会调用,context.write方法,将map的结果数据进行输出,这个最终会调用到MapOutputBuffer的collect方法往内存缓冲区中写数据。然后溢写线程会在后台扫描内存缓冲区,一旦大道阈值,就会对内存缓冲区中的数据进行排序,分区,Combinner(如果定义了就会执行Combinner),写文件到磁盘。
SpillThread的代码如下,在run方法中会执行sortAndSpill()方法。
protected class SpillThread extends Thread {
@Override
public void run() {
spillLock.lock();
spillThreadRunning = true;
try {
while (true) {
spillDone.signal();
while (!spillInProgress) {
spillReady.await();
}
try {
spillLock.unlock();
sortAndSpill();
} catch (Throwable t) {
sortSpillException = t;
} finally {
spillLock.lock();
if (bufend < bufstart) {
bufvoid = kvbuffer.length;
}
kvstart = kvend;
bufstart = bufend;
spillInProgress = false;
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
spillLock.unlock();
spillThreadRunning = false;
}
}
}
SortAndSpill()的代码如下:
private void sortAndSpill() throws IOException, ClassNotFoundException,
InterruptedException {
//approximate the length of the output file to be the length of the
//buffer + header lengths for the partitions
final long size = distanceTo(bufstart, bufend, bufvoid) +
partitions * APPROX_HEADER_LENGTH;
FSDataOutputStream out = null;
FSDataOutputStream partitionOut = null;
try {
// 创建溢写文件
final SpillRecord spillRec = new SpillRecord(partitions);
final Path filename =
mapOutputFile.getSpillFileForWrite(numSpills, size);
out = rfs.create(filename);
//对数据进行排序,相同的Key为一组进行排序
sorter.sort(MapOutputBuffer.this, mstart, mend, reporter);
int spindex = mstart;
final IndexRecord rec = new IndexRecord();
final InMemValBytes value = new InMemValBytes();
for (int i = 0; i < partitions; ++i) {
IFile.Writer<K, V> writer = null;
try {
long segmentStart = out.getPos();
partitionOut =
IntermediateEncryptedStream.wrapIfNecessary(job, out, false,filename);
writer = new Writer<K, V>(job, partitionOut, keyClass, valClass, codec,spilledRecordsCounter);
//如果定义了combiner,执行combiner操作
if (combinerRunner == null) {
// spill directly
DataInputBuffer key = new DataInputBuffer();
while (spindex < mend &&
kvmeta.get(offsetFor(spindex % maxRec) + PARTITION) == i) {
final int kvoff = offsetFor(spindex % maxRec);
int keystart = kvmeta.get(kvoff + KEYSTART);
int valstart = kvmeta.get(kvoff + VALSTART);
key.reset(kvbuffer, keystart, valstart - keystart);
getVBytesForOffset(kvoff, value);
writer.append(key, value);
++spindex;
}
} else {
int spstart = spindex;
while (spindex < mend &&
kvmeta.get(offsetFor(spindex % maxRec)
+ PARTITION) == i) {
++spindex;
}
// Note: we would like to avoid the combiner if we've fewer
// than some threshold of records for a partition
//如果数据量很小就不需要做combiner操作
if (spstart != spindex) {
combineCollector.setWriter(writer);
RawKeyValueIterator kvIter =
new MRResultIterator(spstart, spindex);
combinerRunner.combine(kvIter, combineCollector);
}
}
// close the writer
writer.close();
if (partitionOut != out) {
partitionOut.close();
partitionOut = null;
}
// 数据集
rec.startOffset = segmentStart;
rec.rawLength = writer.getRawLength() + CryptoUtils.cryptoPadding(job);
rec.partLength = writer.getCompressedLength() + CryptoUtils.cryptoPadding(job);
spillRec.putIndex(rec, i);
writer = null;
} finally {
if (null != writer) writer.close();
}
}
if (totalIndexCacheMemory >= indexCacheMemoryLimit) {
//创建溢出索引文件
Path indexFilename =
mapOutputFile.getSpillIndexFileForWrite(numSpills, partitions
* MAP_OUTPUT_INDEX_RECORD_LENGTH);
IntermediateEncryptedStream.addSpillIndexFile(indexFilename, job);
spillRec.writeToFile(indexFilename, job);
} else {
indexCacheList.add(spillRec);
totalIndexCacheMemory +=
spillRec.size() * MAP_OUTPUT_INDEX_RECORD_LENGTH;
}
LOG.info("Finished spill " + numSpills);
++numSpills;
}
}
最后在map任务执行完毕时会调用output.close方法,追踪链条发现:output.close—>NewOutputCollector.close()—>MapOutputBuffer.flush()方法,将内存中的数据刷入到溢出文件中,关键代码如下:
public void flush() throws IOException, ClassNotFoundException,
InterruptedException {
……
//将内存中的数据刷入到溢出文件
sortAndSpill();
//合并文件
mergeParts();
}
合并文件的逻辑如下:
注意,如果溢出文件的数量很多,在合并之前会继续调用commbiner方法将数据进行进一步处理,减少最终输出文件的大小
private void mergeParts() throws IOException, InterruptedException,
ClassNotFoundException {
// get the approximate size of the final output/index files
//如果溢出文件的数量小于3个,直接做合并操作
if (combinerRunner == null || numSpills < minSpillsForCombine) {
Merger.writeFile(kvIter, writer, reporter, job);
} else {
combineCollector.setWriter(writer);
combinerRunner.combine(kvIter, combineCollector);
}
}