分析MapReduce模型源码

本文,并非讲述shuffle的文章,只是对MapReduce内部的几个细节性问题解答一下:

  1. 提交的Mapper中,传入的参数context究竟是什么?
  2. map端数据,是如何传输到reduce端的?
  3. 我们写好的那些代码,到底是如何被加载并且使用的?

本文,基于Hadoop-1.0.0的源码进行分析,2.0以上的源码最大变动在于yarn。

在MapReduce编程模型中,有一个东西吸引了我的兴趣:代码如下:

public static class TokenizerMapper extends
			Mapper<Object, Text, Text, IntWritable> {

		private final static IntWritable one = new IntWritable(1);
		private Text word = new Text();

		public void map(Object key, Text value, Context context)
				throws IOException, InterruptedException {
			StringTokenizer itr = new StringTokenizer(value.toString());
			while (itr.hasMoreTokens()) {
				word.set(itr.nextToken());
				context.write(word, one);
			}
		}
	}

我们可以在Map方法中,看到一个Context:

public static class IntSumReducer extends
			Reducer<Text, IntWritable, Text, IntWritable> {
		private IntWritable result = new IntWritable();

		public void reduce(Text key, Iterable<IntWritable> values,
				Context context) throws IOException, InterruptedException {
			int sum = 0;
			for (IntWritable val : values) {
				sum += val.get();
			}
			result.set(sum);
			context.write(key, result);
		}
	}

而同样,我们在reduce方法中,也看到了一个Context。

那么,问题来了,这个神奇地接洽了Map的结果,并且作为Reduce最终结果写入地方的Context,究竟是个什么东东?

本文,基于Hadoop-1.0.0的MapReduce源码,琢磨下这个Context的神奇之处:

1:我们先从Mapper的Context开始看起:

public class Context extends MapContext<KEYIN, VALUEIN, KEYOUT, VALUEOUT> {
		public Context(Configuration conf, TaskAttemptID taskid,
				RecordReader<KEYIN, VALUEIN> reader,
				RecordWriter<KEYOUT, VALUEOUT> writer,
				OutputCommitter committer, StatusReporter reporter,
				InputSplit split) throws IOException, InterruptedException {
			super(conf, taskid, reader, writer, committer, reporter, split);
		}
	}

我们直接指向了Mapper的代码,发现其中有一个内部类,如上,其继承了MapContext,我们看下:

public MapContext(Configuration conf, TaskAttemptID taskid,
			RecordReader<KEYIN, VALUEIN> reader,
			RecordWriter<KEYOUT, VALUEOUT> writer, OutputCommitter committer,
			StatusReporter reporter, InputSplit split) {
		super(conf, taskid, writer, committer, reporter);
		this.reader = reader;
		this.split = split;
	}

对于任何一个Task来说,默认是指向一个split的,这个split通常来说是一个文件块的大小;reader即RecordReader将会把文件块拆分成一行一行的文本(这里是针对TextInputFormat)而言,FileInputFormat有很多的子类,各自的Reader各有不同:

我们继续看其父类的构造器:

public abstract class TaskInputOutputContext<KEYIN, VALUEIN, KEYOUT, VALUEOUT>
		extends TaskAttemptContext implements Progressable {
	private RecordWriter<KEYOUT, VALUEOUT> output;
	private StatusReporter reporter;
	private OutputCommitter committer;
	public TaskInputOutputContext(Configuration conf, TaskAttemptID taskid,
			RecordWriter<KEYOUT, VALUEOUT> output, OutputCommitter committer,
			StatusReporter reporter) {
		super(conf, taskid);
		this.output = output;
		this.reporter = reporter;
		this.committer = committer;
	}

这时候还没到最根本的地方,但是有了新的发现,我们实际继承的是一个TaskInputOutputFormat,而其再度继承了个TaskAttemptContext。

说到TaskAttemptContext,我们可以想一下TaskAttempt,对于MapReduce提交的每个任务,为了避免一次性失败,所以会有多次的重试,而每次的尝试,就叫做TaskAttempt,每一个TaskAttempt都有自己的id。

public class TaskAttemptContext extends JobContext implements Progressable {
	private final TaskAttemptID taskId;
	private String status = "";

	public TaskAttemptContext(Configuration conf, TaskAttemptID taskId) {
		super(conf, taskId.getJobID());
		this.taskId = taskId;
	}

感觉还要在向上走,看看JobContext:

public JobContext(Configuration conf, JobID jobId) {
		this.conf = new org.apache.hadoop.mapred.JobConf(conf);
		this.credentials = this.conf.getCredentials();
		this.jobId = jobId;
		try {
			this.ugi = UserGroupInformation.getCurrentUser();
		} catch (IOException e) {
			throw new RuntimeException(e);
		}
	}

追根溯源,到了这里,发现的确没什么东西可追溯了,Mapper中的Context实际上就是来自于JobContext的实现,现在,我们动态地追踪下,这个JobContext到底是如何诞生的:

此处,不从WordCount开始分析了,因为整个过程比较复杂,我直接从每个Mapper启动的过程来分析,其对应的代码在MapTask中:

@SuppressWarnings("unchecked")
	private <INKEY, INVALUE, OUTKEY, OUTVALUE> void runNewMapper(
			final JobConf job, final TaskSplitIndex splitIndex,
			final TaskUmbilicalProtocol umbilical, TaskReporter reporter)
			throws IOException, ClassNotFoundException, InterruptedException 

我们可以看到其中的参数,job不说了,读取的Job的相关配置;splitIndex,我们通过这个index找到分配给对应Task的数据块的元数据信息;umbilical则是taskAttempt与Task交流的RPC接口,汇报自身状态;reporter则是相应的报告者:

// make a 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
		org.apache.hadoop.mapreduce.InputFormat<INKEY, INVALUE> inputFormat = (org.apache.hadoop.mapreduce.InputFormat<INKEY, INVALUE>) ReflectionUtils
				.newInstance(taskContext.getInputFormatClass(), job);

这两句代码很明显,我们从提交的TaskContext中,使用反射的方式,获取了传入的Mapper与InputFormat的主体逻辑:

这里,我们分析的是reduceNumTask!=0的情况:

// get an output object
			if (job.getNumReduceTasks() == 0) {
				output = new NewDirectOutputCollector(taskContext, job,
						umbilical, reporter);
			} else {
				output = new NewOutputCollector(taskContext, job, umbilical,
						reporter);
			}

对应于Mapper的输出,创建了一个OutputCollector,顾名思义,很好理解:

mapperContext = contextConstructor.newInstance(mapper, job,
					getTaskID(), input, output, committer, reporter, split);

而这个,就是我们新建的一个mapperContext,其实质就是我们mapper中使用到的Context,这里,主要是committer看似有点陌生,我们看看来源:

if (useNewApi) {
      if (LOG.isDebugEnabled()) {
        LOG.debug("using new api for output committer");
      }
      outputFormat =
        ReflectionUtils.newInstance(taskContext.getOutputFormatClass(), job);
      committer = outputFormat.getOutputCommitter(taskContext);
    } else {
      committer = conf.getOutputCommitter();
    }

可以看出来,也是使用反射方式,从TaskContext中获取到的outputFomat,构造出来的committer:

public synchronized OutputCommitter getOutputCommitter(
			TaskAttemptContext context) throws IOException {
		if (committer == null) {
			Path output = getOutputPath(context);
			committer = new FileOutputCommitter(output, context);
		}
		return committer;
	}

构造代码如上,不予赘述了:

到了这里,我们思考下mapperContext的write代码:

/**
	 * Generate an output key/value pair.
	 */
	public void write(KEYOUT key, VALUEOUT value) throws IOException,
			InterruptedException {
		output.write(key, value);
	}

这个output是何方神圣:其实就是上面封装在mapperContext中的NewOutputCollector,换言之,我们Mapper逻辑的运行,其实完全是通过反射的方式,从提交的JobConf文件中,加载到对应的类,在TaskTracker端新建出来,并且完成我们原先的逻辑的,所以,在Java中,反射实在是非常强大的功能,不仅在Spring中应用的炉火纯青,在Hadoop中也是随处可见;我们可以看到,不仅是在Hadoop内部的RPC中用到,在Mapper和Reducer真正运行的时候,都是离不开的:

接下来,我们认真看下这个NewOutputCollector:

NewOutputCollector(org.apache.hadoop.mapreduce.JobContext jobContext,
				JobConf job, TaskUmbilicalProtocol umbilical,
				TaskReporter reporter) throws IOException,
				ClassNotFoundException {
			collector = new MapOutputBuffer<K, V>(umbilical, job, reporter);
			partitions = jobContext.getNumReduceTasks();
			if (partitions > 0) {
				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 -1;
					}
				};
			}
		}

我们可以看到,对于每一个Task来说,内部使用的collector都是自己内部所有的,所以不会存在冲突;同时,在这里也会根据NumTask来确定分区数量。

@Override
		public void write(K key, V value) throws IOException,
				InterruptedException {
			collector.collect(key, value,
					partitioner.getPartition(key, value, partitions));
		}

这里,就是重点了,我们要看看这个collector究竟是如何完成收集的,而收集得到的数据,又是存在什么地方的,在这之前,看看其中的参数:

public class HashPartitioner<K, V> extends Partitioner<K, V> {
	/** Use {@link Object#hashCode()} to partition. */
	public int getPartition(K key, V value, int numReduceTasks) {
		return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
	}

}

从表面来看,collector在收集数据的时候,会根据key来指定该数据会收集到哪个分区之内:

			collector = new MapOutputBuffer<K, V>(umbilical, job, reporter);

其中牵涉到一个环形缓冲区,代码分析比较复杂,在此不讨论了,将来另外开文再讨论;下文将挑出其中的一些重点来说:

sorter = ReflectionUtils.newInstance(job.getClass("map.sort.class",
					QuickSort.class, IndexedSorter.class), job);

所以,为什么说我们默认情况下是快速排序了,很清楚,一般来说,默认是快速排序,我们也可以指定堆排序来进行排序

在collect数据,输出环形缓冲区的时候,并未进行任何排序的操作,而实际上,排序的操作是在spill的时候进行排序的,这一点,我们可以追溯到SpillThread中的sortAndSpill中的代码:

final Path filename = mapOutputFile.getSpillFileForWrite(
						numSpills, size);
				out = rfs.create(filename);

很明显,这里的刷盘,写入的并不是本地磁盘,而是写入到HDFS中,当然,Reducer获取数据的时候,也是从HDFS中下载数据的,所以,HDFS作为大数据系统的基础,是重中之重:

sorter.sort(MapOutputBuffer.this, kvstart, endPosition,
						reporter);

我们可以看到,利用前面生成的快速排序类,对缓冲区的数据进行排序:这里就不多说了:

总之,最后写到本地磁盘中的Map数据,是按照partition分区好,而且在各自分区之内都是有序的:最终,这些有序数据存入到了本地文件系统中:

接下来,我们看看reducer端的实现:

			reduceCopier = new ReduceCopier(umbilical, job, reporter);

具体代码在ReduceTask内,在此挑选一些重要的内容进行分析:

		private class GetMapEventsThread extends Thread {

该类是reduceTask内负责从Map端进行数据收集的类,我们仔细看看其run方法,并追溯到getMapCompetionEvents,其含义在于收集那些进行完毕的Map的output结果:

case SUCCEEDED: {
						URI u = URI.create(event.getTaskTrackerHttp());
						String host = u.getHost();
						TaskAttemptID taskId = event.getTaskAttemptId();
						URL mapOutputLocation = new URL(
								event.getTaskTrackerHttp() + "/mapOutput?job="
										+ taskId.getJobID() + "&map=" + taskId
										+ "&reduce=" + getPartition());
						List<MapOutputLocation> loc = mapLocations.get(host);
						if (loc == null) {
							loc = Collections
									.synchronizedList(new LinkedList<MapOutputLocation>());
							mapLocations.put(host, loc);
						}
						loc.add(new MapOutputLocation(taskId, host,
								mapOutputLocation));
						numNewMaps++;
					}

看得出来,重点还是在于对mapLocations的处理,其key是对应dataNode的host,而loc则是需要收集的一系列的数据的MapOutputLocation:

/**
			 * Copies a a map output from a remote host, via HTTP.
			 * 
			 * @param currentLocation
			 *            the map output location to be copied
			 * @return the path (fully qualified) of the copied file
			 * @throws IOException
			 *             if there is an error copying the file
			 * @throws InterruptedException
			 *             if the copier should give up
			 */
			private long copyOutput(MapOutputLocation loc) 

注释很清晰明了,我们认真看看该方法究竟是如何实现数据读取的,先看看其参数:MapOutputLocation:

public MapOutputLocation(TaskAttemptID taskAttemptId,
					String ttHost, URL taskOutput) {
				this.taskAttemptId = taskAttemptId;
				this.taskId = this.taskAttemptId.getTaskID();
				this.ttHost = ttHost;
				this.taskOutput = taskOutput;
			}

该类追溯了每个Map的output,封装在内,交付给reduce端进行处理:

		MapOutput mapOutput = getMapOutput(loc, tmpMapOutput, reduceId.getTaskID().getId());

我们发现,其实真正拷贝数据的是getMapOutput的方法:

/**
			 * Get the map output into a local file (either in the inmemory fs or on the
			 * local fs) from the remote server. We use the file system so that we generate
			 * checksum files on the data.
			 * 
			 * @param mapOutputLoc
			 *            map-output to be fetched
			 * @param filename
			 *            the filename to write the data into
			 * @param connectionTimeout
			 *            number of milliseconds for connection timeout
			 * @param readTimeout
			 *            number of milliseconds for read timeout
			 * @return the path of the file that got created
			 * @throws IOException
			 *             when something goes wrong
			 */
			private MapOutput getMapOutput(MapOutputLocation mapOutputLoc, Path filename, int reduce)

这样看起来,在TaskTracker所在的节点,应该是建立了一个可以处理http请求的服务,其接收来自于reduceTask的请求,并且发送数据,这就是map端和reduce端交互的重点:

这是很重要的,我们必须清楚,MapTask与ReduceTask之间的交互,才能更加清楚我们的代码逻辑为什么是这么实现的:

				URL url = mapOutputLoc.getOutputLocation();
				URLConnection connection = url.openConnection();

				InputStream input = setupSecureConnection(mapOutputLoc, connection);

重点在这里,我们读取数据的时候,与TaskTracker建立了一个连接,通过HTTP获取数据流的方式,读取TaskTracker端本地的数据;毫无疑问,我们需要看看TaskTracker的代码,在哪里建立了一个httpServer:

String infoAddr = NetUtils.getServerAddress(conf, "tasktracker.http.bindAddress", "tasktracker.http.port",
				"mapred.task.tracker.http.address");
		InetSocketAddress infoSocAddr = NetUtils.createSocketAddr(infoAddr);
		String httpBindAddress = infoSocAddr.getHostName();
		int httpPort = infoSocAddr.getPort();
		this.server = new HttpServer("task", httpBindAddress, httpPort, httpPort == 0, conf,
				aclsManager.getAdminsAcl());
		workerThreads = conf.getInt("tasktracker.http.threads", 40);
		server.setThreads(1, workerThreads);

我们在TaskTracker初始化的时候,可以看到这段代码,发现内部构建了一个httpServer,实际上,这就是处理reduceTask拉取数据的server:

		server.addInternalServlet("mapOutput", "/mapOutput", MapOutputServlet.class);

httpServer内置的servlet处理器,只有一个,即MapOutputServlet类,我们看下其代码:

/**
	 * This class is used in TaskTracker's Jetty to serve the map outputs to other
	 * nodes.
	 */
	public static class MapOutputServlet extends HttpServlet

注释清晰明了:

String mapId = request.getParameter("map");
			String reduceId = request.getParameter("reduce");
			String jobId = request.getParameter("job");

reduce端传入的参数:

String intermediateOutputDir = TaskTracker.getIntermediateOutputDir(userName, jobId, mapId);
				String indexKey = intermediateOutputDir + "/file.out.index";
				Path indexFileName = fileIndexCache.get(indexKey);
				if (indexFileName == null) {
					indexFileName = lDirAlloc.getLocalPathToRead(indexKey, conf);
					fileIndexCache.put(indexKey, indexFileName);
				}
String fileKey = intermediateOutputDir + "/file.out";
Path mapOutputFileName = fileCache.get(fileKey);
				mapOutputIn = SecureIOUtils.openForRead(new File(mapOutputFileName.toUri().getPath()), runAsUserName);

建立索引文件,并且打开文件,形成outputStream输出流:

						shuffleMetrics.outputBytes(len);
						outStream.write(buffer, 0, len);
						outStream.flush();
					

写入outStream中:

				outStream = response.getOutputStream();

而实际上,outStream是response的文件流,就这样,我们把本地文件写入到reduceTask的请求响应中:

下面做一个总结:

  1. 实际上,我们编写的任务提交之初,相关的配置信息,都会序列化为JobConf的文件,上传到HDFS中;而具体的那些文件,jar,xml等,也会上传到文件系统中。
  2. 我们编写的代码,具体使用的时候,会从配置中获取name,以反射的方式获取到class来得到执行
  3. map中的context,实际上是一个MapOutBuffer,是一个环形缓冲区,将文本写入到内存,并且在必要的时候进行刷盘操作
  4. taskTracker内部有一个httpServer,其基于jetty来完成对reduce端拉取数据的操作。

猜你喜欢

转载自blog.csdn.net/u013384984/article/details/80443303