一、MapReduce提交方式
1、提交jar到集群节点,使用命令运行的方式
上一次我们进行开发的mapReduce入门程序wordCount中,我们是通过以下步骤提交到集群中运行的。
- 首先完成mapReduce主程序、map计算方法、Reduce计算方法的开发
- 将开发完成后的代码打jar包
- 将jar上传到集群中的某一个节点
- 使用命令
hadoop jar xx.jar [mainClass] in out
提交
2、把程序嵌入【linux、windows】(非hadoop jar)的集群方式。
客户端在不同的平台上运行(也就是说MR的主程序可以在Windows上跑,也可以在linux上跑),对应的Map和Reduce程序,让它在集群上运行。
不管是第一种提交jar使用命令行运行MR,还是第二种把程序嵌入集群的方式运行MR,都要有一步client去找RM ,然后RM再生成container映射成APPmaster再创建不同Container跑我们的MapTask和ReduceTask的流程。
注意方式一和方式二都是属于在集群中运行MR,需要在mapred-site.xml配置文件中指定
mapreduce.framework.name
的value为yarn
接下来我们尝试着直接在windows上启动我们写的wordCount程序,查看是否运行成功
从RM检测的ui界面上发现,运行失败了,我们可以观察对应的运行日志
运行失败的原因是,windows和linux系统的结构完全不一样。
上面的日志说明Shell终端的一些问题,可能与windows产生了一些冲突,比如目录接口单反斜杠转换等问题。
要解决客户端在windows上运行,同时将MapTask和ReduceTask提交到linux集群上运行产生的冲突问题,就要修改下mapReduce的一些默认配置:
将mapreduce.app-submission.cross-platform
对应的值修改为true即可
修改代码配置:
public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
Configuration conf = new Configuration(true);
conf.set("mapreduce.app-submission.cross-platform","true");
//获取Job实例,用于提交任务
Job job = Job.getInstance(conf);
再启动,你又会发现,提示找不到MyMapper的类,说明classpath下没有这个类的信息。实际上我们之前说过,当用户要运行它的map和reduce程序的时候,必须要将运行程序的jar包上传到hdfs中,然后yarn中的container再去来去执行对应的map/Reduce计算程序。在方式一中,我们是将jar上传到linux然后通过hadoop jar命令将我们的计算程序上传到集群然后再跑mapReduce任务的。而方式二中,压根就没有jar包什么事,我们只是简简单单在windows直接跑我们的主程序,并没有做任何上传jar操作。
我们只需要再对上面的程序加上下面的配置:
public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
Configuration conf = new Configuration(true);
conf.set("mapreduce.app-submission.cross-platform","true");
//获取Job实例,用于提交任务
Job job = Job.getInstance(conf);
/**当客户端在windows平台上裸运行时,又想让Map 和Reduce程序在集群上跑,就必须要设置jar包的路径,
上传到hdfs集群供container拉取加载到自己的classpath,否则将找不到我们自己写的Map和Reduce类!
**/
job.setJar("E:\\idea代码\\hdfs\\target\\hdfs-1.0-SNAPSHOT.jar");
我们明确的指明了应用程序jar包在windows的位置,当客户端在windows启动时,就把jar上传到hdfs即可。
凡是集群环境,必要上传jar
此时我们的程序客户端就可以在windows上跑,mapReduce计算程序在集群跑了。
3、local、单机运行mapReduce
有时候我们开发完程序之后,需要在本地单机进行测试,此时就不用和方法二一样上传jar到hdfs中,我们需要在mapred-site种指定mapreduce.framework.name
的value为local。
单机运行mapReduce需做的前置配置:
具体配置参考:https://blog.csdn.net/u010963948/article/details/74640187
二、MapReduce程序动态接收用户参数
在之前的wordcount程序里面,我们并没有通过命令行输入的方式确定mapReduce的输入输出文件地址,而是写死在程序中。那么如果我们希望在运行jar包的时候,动态的的接收用户输入的参数需要怎么去做呢?
我们使用hadoop命令时,它的格式如下:
[root@node2 ~]# hdfs dfs
Usage: hadoop fs [generic options]
[-appendToFile <localsrc> ... <dst>]
[-cat [-ignoreCrc] <src> ...]
[-checksum <src> ...]
[-chgrp [-R] GROUP PATH...]
[-chmod [-R] <MODE[,MODE]... | OCTALMODE> PATH...]
[-chown [-R] [OWNER][:[GROUP]] PATH...]
... 略去
#框架提供的基本参数
Generic options supported are
-conf <configuration file> specify an application configuration file
-D <property=value> use value for given property
-fs <local|namenode:port> specify a namenode
-jt <local|resourcemanager:port> specify a ResourceManager
-files <comma separated list of files> specify comma separated files to be copied to the map reduce cluster
-libjars <comma separated list of jars> specify comma separated jar files to include in the classpath.
-archives <comma separated list of archives> specify comma separated archives to be unarchived on the compute machines.
The general command line syntax is
bin/hadoop command [genericOptions] [commandOptions]
我们关注到最后一行,实际上你执行hdfs dfs加参数的时候,是会帮你自动转换成bin/hadoop command [genericOptions] [commandOptions]
这种格式来执行命令。在运行hadoop命令时,其中存在两类参数:第一个是genericOptions ,也就是和hadoop框架关联的参数,第二个是commandOptions 表示用户自定义的输入参数。
比如当我们执行下面的命令:
hadoop jar xx.jar xx -Dproperty=value inpath outpath
其中-D执行的property=value则是和hadoop框架设置有关的参数,而inpath、outpath并不属于hadoop框架相关的参数,而是用户自定义运行这个jar包的时候,动态接收的执行参数。
上面提到的这两类参数,在MapReduce程序中可以通过main方法的args数组获取到,不过麻烦的地方就是,我们需要自己去手动的对参数进行切割分类。比如当遍历args数组的时候,需要判断是不是hadoop框架提供的设置参数(比如-D开头的),如果是,还需要手动的设置到Configuration这些配置类中去。
不过庆幸的是,解析参数这个步骤,hadoop框架也为我们提供了一个GenericOptionsParser
工具类来进行参数解析:
public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
Configuration conf = new Configuration(true);
//将args输入参数数组进行分析,比如把-D后面接的属性直接set到Configuration中,并且删除掉这个参数,会溜下commandOption留给我们自定义设置
GenericOptionsParser genericOptionsParser = new GenericOptionsParser(conf,args);
//拿到经过筛选之后,剩余的,我们用户自定义的参数
String[] remainingArgs = genericOptionsParser.getRemainingArgs();
}
三、MapReduce源码分析
mapReduce源码分析分为3个环节
-
Client 客户端
客户端并没有发生计算的逻辑,但是很重要。客户端触发执行MapReduce程序。最重要的环节就是客户端确认了split清单!客户端支撑了计算向数据移动和计算的并行度!
分布式计算的3个环节,追求:计算向数据移动,并行度、分支,数据本地化读取。
-
MapTask
mapTask是执行map程序运行的核心类。控制了map任务从input -> map -> output的运作流程。
-
ReduceTask
reduceTask则是运行reduce程序的核心类。
1、客户端源码分析
wordCount客户端代码示例:
public class MyWordCount {
public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
Configuration conf = new Configuration(true);
//让框架知道是windows异构平台运行的
conf.set("mapreduce.app-submission.cross-platform","true");
//将args输入参数数组进行分析,比如把-D等等的属性直接set到conf,会溜下commandOption留给我们自定义设置
GenericOptionsParser genericOptionsParser = new GenericOptionsParser(conf,args);
//拿到经过筛选之后,剩余的,我们用户自定义的参数
String[] remainingArgs = genericOptionsParser.getRemainingArgs();
//获取Job实例,用于提交任务
Job job = Job.getInstance(conf);
/**当客户端在windows平台上裸运行时,又想让Map 和Reduce程序在集群上跑,就必须要设置jar包的路径,
上传到hdfs集群供container拉取加载到自己的classpath,否则将找不到我们自己写的Map和Reduce类!
**/
job.setJar("E:\\idea代码\\hdfs\\target\\hdfs-1.0-SNAPSHOT.jar");
//必写!这里会根据反射机制来得知你的这个jar包要如何找到入口类,写当前程序的启动类
job.setJarByClass(MyWordCount.class);
//随意写,这里标志任务的名称
job.setJobName("mywordcount");
/* 这两种填写map输入输出文件路径的方式已经淘汰,因为参数固定死只能传path
不方便于扩展
job.setInputPath(new Path("in"));
job.setOutputPath(new Path("out"));
*/
//传入map的文件路径
Path in = new Path("/data/wc/input");
//可以接收多个Path路径,也就是map可以接收多个输入文件来源
TextInputFormat.addInputPath(job,in);
// TextInputFormat.addInputPath(job,in2);
Path out = new Path("/data/wc/output");
//注意mapReduce的输出,要求输出目录不存在任何数据,所以先检查是否存在目录,如果存在则递归删除即可。
FileSystem fs = out.getFileSystem(conf);
if(fs.exists(out)) {
fs.delete(out,true);
}
TextOutputFormat.setOutputPath(job,out);
//以下配置Map执行程序的逻辑的类
job.setMapperClass(MyMapper.class);
//这一要告知map输出给reduce的Key/Vlaue的类型,reduce需要用这个类型进行返程成具体的对象,然后再进行反序列化为该对象赋值
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(IntWritable.class);
//以下配置Reduce执行程序的逻辑类
job.setReducerClass(MyReducer.class);
// Submit the job, then poll for progress until the job is complete
//设置默认的mapReduce数量
job.setNumReduceTasks(2);
job.waitForCompletion(true);
}
}
客户端主要设置了MapReduce任务运行的一些前置参数,比如运行读取哪个hdfs的文件,运行最终结果输出到hdfs的哪个目录下,运行输入输出的类型等等。我们还可以自定义一些hdfs的默认配置。比如自定义map任务的分区器等。不过客户端的重点还是在确认split清单上。这也是支撑计算向数据移动的核心要点。
我们观测到上面的代码,其中客户端的入口在 job.waitForCompletion(true)
, 当调用到这段代码时,就开始触发客户端的执行流程了:
/**
* Submit the job to the cluster and wait for it to finish.
* @param verbose print the progress to the user
* @return true if the job succeeded
提交job任务到集群中,等待结束。其中传入verbose参数可以监控任务job执行的整个流程
* @throws IOException thrown if the communication with the
* <code>JobTracker</code> is lost
*/
public boolean waitForCompletion(boolean verbose
) throws IOException, InterruptedException,
ClassNotFoundException {
if (state == JobState.DEFINE) {
//触发异步提交任务
submit();
}
if (verbose) {
//检测并打印job的运行日志,比如我们看到job运行时统计出的,运行了百分之多少mapTask的进度等这些信息。
monitorAndPrintJob();
} else {
// get the completion poll interval from the client.
int completionPollIntervalMillis =
Job.getCompletionPollInterval(cluster.getConf());
while (!isComplete()) {
try {
Thread.sleep(completionPollIntervalMillis);
} catch (InterruptedException ie) {
}
}
}
return isSuccessful();
}
waitForCompletion做了两件事情:
- 首先异步的提交job任务
- 其次判断是否需要开启线程监控job的输出状态,如果不需要,则阻塞当前线程,不断轮询job任务是否完成,直到完成为止。
扩展,判断job是否完成,需要job返回以下的状态
public synchronized boolean isJobComplete() { return (runState == JobStatus.State.SUCCEEDED || runState == JobStatus.State.FAILED || runState == JobStatus.State.KILLED); }
最终返回任务是否执行成功给客户端。我们只需要关注submit()这个异步提交job任务都做了什么事情。
/**
* Submit the job to the cluster and return immediately.
* @throws IOException
*/
public void submit()
throws IOException, InterruptedException, ClassNotFoundException {
ensureState(JobState.DEFINE);
setUseNewAPI();
connect();
final JobSubmitter submitter =
getJobSubmitter(cluster.getFileSystem(), cluster.getClient());
status = ugi.doAs(new PrivilegedExceptionAction<JobStatus>() {
public JobStatus run() throws IOException, InterruptedException,
ClassNotFoundException {
return submitter.submitJobInternal(Job.this, cluster);
}
});
state = JobState.RUNNING;
LOG.info("The url to track the job: " + getTrackingURL());
}
其中submitter.submitJobInternal()
方法是核心,描述了job是如何提交,以及split清单是如何确认出来的,我们最最主要关注的就是split清单方法,其他步骤都可以略去:
/**
* Internal method for submitting jobs to the system.
*
* <p>The job submission process involves:
* <ol>
* <li>
* Checking the input and output specifications of the job.
* </li>
* <li>
* Computing the {@link InputSplit}s for the job.
* </li>
* <li>
* Setup the requisite accounting information for the
* {@link DistributedCache} of the job, if necessary.
* </li>
* <li>
* Copying the job's jar and configuration to the map-reduce system
* directory on the distributed file-system.
* </li>
* <li>
* Submitting the job to the <code>JobTracker</code> and optionally
* monitoring it's status.
* </li>
* </ol></p>
* @param job the configuration to submit
* @param cluster the handle to the Cluster
* @throws ClassNotFoundException
* @throws InterruptedException
* @throws IOException
*/
JobStatus submitJobInternal(Job job, Cluster cluster)
throws ClassNotFoundException, InterruptedException, IOException {
//...前面略去n行
// Create the splits for the job
LOG.debug("Creating splits at " + jtFs.makeQualified(submitJobDir));
int maps = writeSplits(job, submitJobDir);
conf.setInt(MRJobConfig.NUM_MAPS, maps);
LOG.info("number of splits:" + maps);
//...后面略去n行
}
这个方法的注释也非常的清晰,描述了这个方法最主要做的步骤:
-
确认job的input和output路径
-
计算job的split切片清单
-
如果需要,为job的DistributedCache设置必要的信息。
-
复制job运行的jar包和配置文件信息到hdfs中的mapreduce的系统路径
-
提交job到JobTracker中,并且开始监控这个job的运行状态。
这次我们只关注客户端的核心,计算job的split切片清单 ,也就是writeSplits()
方法。
private int writeSplits(org.apache.hadoop.mapreduce.JobContext job,
Path jobSubmitDir) throws IOException,
InterruptedException, ClassNotFoundException {
JobConf jConf = (JobConf)job.getConfiguration();
int maps;
if (jConf.getUseNewMapper()) {
maps = writeNewSplits(job, jobSubmitDir);
} else {
maps = writeOldSplits(jConf, jobSubmitDir);
}
return maps;
}
这个方法会取判断是否需要采用新的Mapper类,hadoop2.x中使用最新的Mapper类运行。所以我们继续查看writeNewSplits(job, jobSubmitDir)
看看是怎么运行的。
writeNewSplits方法分析
private <T extends InputSplit>
int writeNewSplits(JobContext job, Path jobSubmitDir) throws IOException,
InterruptedException, ClassNotFoundException {
Configuration conf = job.getConfiguration();
InputFormat<?, ?> input =
ReflectionUtils.newInstance(job.getInputFormatClass(), conf);
List<InputSplit> splits = input.getSplits(job);
T[] array = (T[]) splits.toArray(new InputSplit[splits.size()]);
// sort the splits into order based on size, so that the biggest
// go first
Arrays.sort(array, new SplitComparator());
JobSplitWriter.createSplitFiles(jobSubmitDir, conf,
jobSubmitDir.getFileSystem(conf), array);
return array.length;
}
关注到InputFormat是通过ReflectionUtils,也就是反射机制从job的配置文件conf中构造出来的。但是JobContext是个接口,并没有做getInputFormatClass方法的实现,那么最终调用的是实现了JobContext接口的JobContextImpl类的getInputFormatClass方法:
protected final org.apache.hadoop.mapred.JobConf conf;
public Class<? extends InputFormat<?,?>> getInputFormatClass()
throws ClassNotFoundException {
return (Class<? extends InputFormat<?,?>>)
conf.getClass(INPUT_FORMAT_CLASS_ATTR, TextInputFormat.class);
}
我们可以看出,JobContextImpl它使用的是JobConf去查看用户是否自定义过输入格式化类,如果是,就获取用户指定的,如果不是则使用默认的TextInputFormat。那么JobContextImpl的JobConf是从哪里传进来呢?我们看到上面的WordCount客户端代码,我们创建Job对象的时候是通过Job.getInstance(conf)
创建的,传入了Configuration的对象。Job类实际上是继承了JobContext
JobContext可以理解成贯穿整个MapReduce声明周期的一个上下文,我们可以根据这个上下文去获取一些用户自定义的属性等。
当确认了InputFormat的input对象时,writeNewSplit方法继续通过调用input.getSplits(job);
来获取split清单,这个方法是十分重要的!
/**
* Logically split the set of input files for the job.
*
* <p>Each {@link InputSplit} is then assigned to an individual {@link Mapper}
* for processing.</p>
*
* <p><i>Note</i>: The split is a <i>logical</i> split of the inputs and the
* input files are not physically split into chunks. For e.g. a split could
* be <i><input-file-path, start, offset></i> tuple. The InputFormat
* also creates the {@link RecordReader} to read the {@link InputSplit}.
*
* @param context job configuration.
* @return an array of {@link InputSplit}s for the job.
*/
public abstract
List<InputSplit> getSplits(JobContext context
) throws IOException, InterruptedException;
从注释上可以看出,这个方法主要是从逻辑上切割job任务的所有输入文件,并且一个split关联一个独立的Mapper,用以运行map程序。与此同时,InputFormat的getSplits方法还会去创建一个RecordReader用以读取每个split关联的文件记录。
那么这个方法具体使用哪个实现呢?那就要看客户端是否有指定某个输入格式化类型了(必须要为InputFormat子类)。由于我们的代码没有显示的指定输入格式化类型,所以默认是使用TextInputFormat。
扩展: 客户端可以通过mapreduce.job.inputformat.class 属性配置job任务的输入格式化类。
但是我们点进去这个方法的实现类中,并没有看到TextInputFormat有做任何的实现,但却看到其中有个FileInputFormat的抽象类做了实现,它继承了InputFormat类。它的继承关系是:
我们发现TextInputFormat实现了FileInputFormat,故此沿用了父类FileInputFormat的getSplits方法:
/**
* Generate the list of files and make them into FileSplits.
* @param job the job context
* @throws IOException
*/
public List<InputSplit> getSplits(JobContext job) throws IOException {
Stopwatch sw = new Stopwatch().start();
//获取split切片的最小大小
long minSize = Math.max(getFormatMinSplitSize(), getMinSplitSize(job));
//获取split切片最大的大小
long maxSize = getMaxSplitSize(job);
// generate splits
List<InputSplit> splits = new ArrayList<InputSplit>();
List<FileStatus> files = listStatus(job);
for (FileStatus file: files) {
Path path = file.getPath();
long length = file.getLen();
if (length != 0) {
BlockLocation[] blkLocations;
if (file instanceof LocatedFileStatus) {
blkLocations = ((LocatedFileStatus) file).getBlockLocations();
} else {
FileSystem fs = path.getFileSystem(job.getConfiguration());
blkLocations = fs.getFileBlockLocations(file, 0, length);
}
if (isSplitable(job, path)) {
long blockSize = file.getBlockSize();
long splitSize = computeSplitSize(blockSize, minSize, maxSize);
long bytesRemaining = length;
while (((double) bytesRemaining)/splitSize > SPLIT_SLOP) {
int blkIndex = getBlockIndex(blkLocations, length-bytesRemaining);
splits.add(makeSplit(path, length-bytesRemaining, splitSize,
blkLocations[blkIndex].getHosts(),
blkLocations[blkIndex].getCachedHosts()));
bytesRemaining -= splitSize;
}
if (bytesRemaining != 0) {
int blkIndex = getBlockIndex(blkLocations, length-bytesRemaining);
splits.add(makeSplit(path, length-bytesRemaining, bytesRemaining,
blkLocations[blkIndex].getHosts(),
blkLocations[blkIndex].getCachedHosts()));
}
} else { // not splitable
splits.add(makeSplit(path, 0, length, blkLocations[0].getHosts(),
blkLocations[0].getCachedHosts()));
}
} else {
//Create empty hosts array for zero length files
splits.add(makeSplit(path, 0, length, new String[0]));
}
}
// Save the number of input files for metrics/loadgen
job.getConfiguration().setLong(NUM_INPUT_FILES, files.size());
sw.stop();
if (LOG.isDebugEnabled()) {
LOG.debug("Total # of splits generated by getSplits: " + splits.size()
+ ", TimeTaken: " + sw.elapsedMillis());
}
return splits;
}
这个方法首先会去确认split切片的大小
long minSize = Math.max(getFormatMinSplitSize(), getMinSplitSize(job));
long maxSize = getMaxSplitSize(job);
getFormatMinSplitSize()方法永远返回1,表示split最小大小不能小于1;而getMinSplitSize则是根据用户指定的split最小大小。getMaxSplitSize则是根据用户指定的split大小设置最大值。
我们可以配置mapreduce.input.fileinputformat.split.minsize,来指定split的最小值。配置mapreduce.input.fileinputformat.split.maxsize来指定split的最大值。
其次获取这次mapReduce任务运行时所需要使用的输入文件路径,并把文件路径信息封装成FileStatus对象,这个类中包括了文件的一些元信息,比如文件块的大小length、副本数量等。
然后遍历文件,获取文件的blockLocations,hdfs的namenode会根据拓扑排序,返回每一个文件对应的文件块路径以及文件块所在的机器(按照离客户端的远近排序)。
然后判断文件是否可以被split切割,如果可以被切割,就进入到计算split真实大小的方法!也就是computeSplitSize():
protected long computeSplitSize(long blockSize, long minSize,
long maxSize) {
return Math.max(minSize, Math.min(maxSize, blockSize));
}
这个方法内部也是判断上面计算出的切片最小值和切片最大值以及当前文件块大小做一个对比。
计算出split大小之后,就开始计算split清单了,也就是这段代码,它才是整个客户端代码中的重中之重:
//没有被split切割的文件字节数,一开始等于整个文件
long bytesRemaining = length;
//当剩余的未分配split的文件内容,除上split大小大于设定的SPLIT_SLOP
while (((double) bytesRemaining)/splitSize > SPLIT_SLOP) {
//根据当前文件的所有文件块路径,以及文件块的起始offset=length-bytesRemaining,获取这个文件块的索引,也就是确定当前split分配给该文件的哪个block块。
int blkIndex = getBlockIndex(blkLocations, length-bytesRemaining);
//调用makeSplit,计算该split的清单。
splits.add(makeSplit(path, length-bytesRemaining, splitSize,
blkLocations[blkIndex].getHosts(),
blkLocations[blkIndex].getCachedHosts()));
//将分配完split这部分的文件字节数减去。剩下的进入下一轮循环
bytesRemaining -= splitSize;
}
其中getBlockIndex也就是确定当前split分配给该文件的哪个block块:
protected int getBlockIndex(BlockLocation[] blkLocations,
long offset) {
for (int i = 0 ; i < blkLocations.length; i++) {
// is the offset inside this block?
if ((blkLocations[i].getOffset() <= offset) &&
(offset < blkLocations[i].getOffset() + blkLocations[i].getLength())){
return i;
}
}
BlockLocation last = blkLocations[blkLocations.length -1];
long fileLength = last.getOffset() + last.getLength() -1;
throw new IllegalArgumentException("Offset " + offset +
" is outside of file (0.." +
fileLength + ")");
}
这个方法是根据每个文件block块的offset信息,判断是否小于传入的需要分配split的文件offset,并且需要分配split的文件offset需要同时小于这个文件block块的整个大小。这才表明这个split需要分配到这个block块。这也就明确了使用这个split记录作为输入的mapTask,需要移动到哪个文件块所处的节点上进行计算。实现了计算向数据移动!
split重要的属性
1、file归属哪个文件 2、offset偏移量 3、length块大小 4、hosts文件块所在的主机列表,支撑的计算向数据移动,决定mapTask需要移动到哪台hosts上去执行代码。
split的最主要作用就是 解耦 存储层和计算层,如果没有split,并行度就不能调整了,你定义上传的块多大,并行度就是多大。
客户端最核心的就是split的划分
2、MapTask源码
上面我们大致讲了下客户端代码,当然客户端并没有涉及到MapReduce的任务运行这一部分,故此,map任务是如何被调用运行起来的,这就要追溯到MapTask的源码分析。
MapTask的流程是: input -> map -> output
一、MapTask–Input流程
其中MapTask的启动代码就是它的run方法
@Override
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.
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%).
mapPhase = getProgress().addPhase("map", 0.667f);
sortPhase = getProgress().addPhase("sort", 0.333f);
}
}
TaskReporter reporter = startReporter(umbilical);
boolean useNewApi = job.getUseNewMapper();
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) {
runNewMapper(job, splitMetaInfo, umbilical, reporter);
} else {
runOldMapper(job, splitMetaInfo, umbilical, reporter);
}
done(umbilical, reporter);
}
我们看到run方法,首先会判断是否为MapTask,如果是,就会去判断是不是只存在map操作,没有reduce操作。如果没有reduce操作,就没有必要去做排序,以及分区号这些设置了。那么cpu100%的工作就是用于处理map的流程。而如果存在reduce操作,需要划分出33%的程序执行时间用于排序这些地方。
然后调用initialize方法,进行上下文初始化,以及确定map输出的outputFormat格式类。
然后run方法最终启动一个Mapper,是根据判断用户是否希望使用新的API启动Mapper,hadoop2.x默认使用新的API启动Mapper,那么最终就会调用 runNewMapper(job, splitMetaInfo, umbilical, reporter);
@SuppressWarnings("unchecked")
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
org.apache.hadoop.mapreduce.TaskAttemptContext taskContext =
new org.apache.hadoop.mapreduce.task.TaskAttemptContextImpl(job,
getTaskID(),
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);
// rebuild the input split
org.apache.hadoop.mapreduce.InputSplit split = null;
split = getSplitDetails(new Path(splitIndex.getSplitLocation()),
splitIndex.getStartOffset());
LOG.info("Processing split: " + split);
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 {
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 {
input.initialize(split, mapperContext);
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任务传递过来Mapper目标对象。与此同时,根据taskContext可以构造从job传入的输入map的格式化类的对象。
然后通过调用getSplitDetails方法,创建这个MapTask需要使用到的split对象。这个方法内部是会根据当前这个mapTask传入的split对应的文件块地址,去hdfs中获取这个文件,然后调用seek方法跳转到这个split对应的读取文件块的offset坐标。
确定好split对象后,最重要的一步就是构造RecordReader,也就是从split切片中读取一条条记录,格式化成RecordReader传给map处理:
org.apache.hadoop.mapreduce.RecordReader<INKEY,INVALUE> input =
new NewTrackingRecordReader<INKEY,INVALUE>
(split, inputFormat, reporter, taskContext);
我们看下NewTrackingRecordReader对象内部大概做了什么操作:
static class NewTrackingRecordReader<K,V>
extends org.apache.hadoop.mapreduce.RecordReader<K,V> {
private final org.apache.hadoop.mapreduce.RecordReader<K,V> real;
//..省略部分代码
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 {
//..省略部分代码
this.real = inputFormat.createRecordReader(split, taskContext);
//..省略部分代码
}
}
上面我把无关紧要的一些代码省略了,其中最重要的就是设置了NewTrackingRecordReader里的real对象,将来我们的map任务调用读取split记录、写出map处理结果所使用的的目标对象起始就是这个real对象。
real对象是通过传入的inputFormat的createRecordReader方法创建的,当我们点进这个方法的时候,实现类有很多,因为我们这次的wordCount使用的输入格式化类是TextInputFormat,故此我们看下它对应这个方法的具体实现:
@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);
}
发现它内部返回了LineRecordReader的对象。也就是real对象的类
好了,现在让我们重新回到MapTask的run方法继续往下分析,我们先调过output对象的一些处理流程,定位到下面初始化LineRecordReader这里:
input.initialize(split, mapperContext);
我们看下这个方法在LineRecordReader的具体实现:
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 FileSystem fs = file.getFileSystem(job);
fileIn = fs.open(file);
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 {
in = new SplitLineReader(codec.createInputStream(fileIn,
decompressor), job, this.recordDelimiterBytes);
filePosition = fileIn;
}
} else {
fileIn.seek(start);
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
// next() method.
if (start != 0) {
start += in.readLine(new Text(), 0, maxBytesToConsume(start));
}
this.pos = start;
}
这个方法最主要的地方,在于确认split开始读取的位置。也就是方法的倒数第4行。我们都知道,hdfs存储一个大文件的时候,是按照块去划分的,实际上是按照字节去做划分的,当你有一行数据,刚刚好划分一部分,就到达了块的大小,那剩下的这行数据的部分,就会存入到下一个块里面。当我们需要完整读取这行数据怎么办呢? 这也就是在LineRecordReader的initialize方法给出了答案。上面代码可以知道,LineRecordReader还需要进行init初始化,这里会获取文件的输入流并且seek到指定的offset中。除了第一个切片对应的map,之后的切片都在init环节中,从切片包含的数据中让出第一行,并把切片的起始offset更新为切片的第二行,换而言之,前一个map会多读取一行,目的是用来弥补hdfs把数据切割的问题。
input : (split+format)来自于我们的输入格式化类(默认TextInputFormat) 来给我们实际返回的记录读取器对象。TextInputFormat->LineRecordReader,而LineRecordReader用到了split的file、offset、length信息。
当初始化LineRecordReader完成了之后,回到MapTask,紧接着就是启动我们写的Mapper方法了
//mapper是通过我们传入config文件的Mapper自定义类,通过反射机制构造
mapper.run(mapperContext);
进入到这个方法,就可以看到:
/**
* Expert users can override this method for more complete control over the
* execution of the Mapper.
* @param context
* @throws IOException
*/
public void run(Context context) throws IOException, InterruptedException {
setup(context);
try {
while (context.nextKeyValue()) {
map(context.getCurrentKey(), context.getCurrentValue(), context);
}
} finally {
cleanup(context);
}
}
实际上这里的context.nextKeyValue() 等价于直接调用LineRecordReader的nextKeyValue()方法做如下操作
LineRecordReader的nextKeyValue()方法做如下操作:
- 读取数据中的一条记录,并对key,value赋值
- 返回一个布尔值给调用者,声明是否还有数据。
LineRecordReader调用完nextKeyValue()方法后,就可以调用getCurrentKey()和getCurrentValue()这两个方法,分别拿到当前读取的行在面向文件中的offset值(Key的值),以及当前行的记录(value)的值。然后返回给我们自己写的Map方法中进行处理。
到此MapTask的Input处理流程就结束了。下面介绍下map处理后,output的流程。
二、MapTask–Ouput流程
回到MapTask的run方法,继续看完我们上面调过的output设置流程
// get an output object
if (job.getNumReduceTasks() == 0) {
output =
new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
} else {
output = new NewOutputCollector(taskContext, job, umbilical, reporter);
}
这里进行判断是否需要ReduceTask处理,如果单单只是Mapper操作,那就没必要做什么分区排序了。如果存在ReduceTask继续处理,那就要进行分区,按key排序之类的操作,也就是构造生成NewOutputCollector对象。
NewOutputCollector(org.apache.hadoop.mapreduce.JobContext jobContext,
JobConf job,
TaskUmbilicalProtocol umbilical,
TaskReporter reporter
) throws IOException, ClassNotFoundException {
collector = createSortingCollector(job, reporter);
partitions = jobContext.getNumReduceTasks();
//如果reduceTask大于1,就通过反射机制获取分区器,可以自定义分区器!
//如果只有一个reduceTask,那就直接返回0,因为只会被那一个ReduceTask拉取。
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;
}
};
}
}
这个NewOutputCollector对象确定了排序器,分区器。而我们先关注下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);
//查看是否用户传入了自定义的排序器,如果没有就返回默认的MapOutputBuffer类作为排序器
Class<?>[] collectorClasses = job.getClasses(
JobContext.MAP_OUTPUT_COLLECTOR_CLASS_ATTR, MapOutputBuffer.class);
//统计所有的排序器列表
int remainingCollectors = collectorClasses.length;
for (Class clazz : collectorClasses) {
try {
//如果当前排序其并不是MapOutputCollector的子类,那就报错
if (!MapOutputCollector.class.isAssignableFrom(clazz)) {
throw new IOException("Invalid output collector class: " + clazz.getName() +
" (does not implement MapOutputCollector)");
}
//强制转换成MapOutputCollector的子类型
Class<? extends MapOutputCollector> subclazz =
clazz.asSubclass(MapOutputCollector.class);
LOG.debug("Trying map output collector class: " + subclazz.getName());
//通过反射机制创建这个排序器对象
MapOutputCollector<KEY, VALUE> collector =
ReflectionUtils.newInstance(subclazz, job);
//初始化当前排序器
collector.init(context);
LOG.info("Map output collector class = " + collector.getClass().getName());
return collector;
} catch (Exception e) {
String msg = "Unable to initialize MapOutputCollector " + clazz.getName();
if (--remainingCollectors > 0) {
msg += " (" + remainingCollectors + " more collector(s) to try)";
}
LOG.warn(msg, e);
}
}
throw new IOException("Unable to initialize any output collector");
}
上面方法指明,如果用户并没有自定义排序器,那么就会默认给定MapOutputBuffer作为排序器,我们就以默认为例子,看到有一部是调用了排序器的init方法进行初始化:
@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();
//当这个排序缓冲区空间被map输出记录占用一定比例的时候,发生排序并溢写到磁盘
final float spillper =
job.getFloat(JobContext.MAP_SORT_SPILL_PERCENT, (float)0.8);
//当前缓冲区的大
final int sortmb = job.getInt(JobContext.IO_SORT_MB, 100);
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("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
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);
}
}
关于排序器,核心类是MapOutputBuffer:
它的init()方法有一个配置spiller,叫做缓冲区溢写的比例。并且存在一个sortmb的局部变量,表示当前缓冲区的大小。当缓冲区在溢写时,需要空出缓冲区一些的容量(单位MB),提供给map任务继续往缓冲区写数据。这里的配置表明,并不是要等缓冲区为满的时候,没有空间剩余的时候才往磁盘溢写数据,而是需要根据spiller来参考,当缓冲区使用空间占用百分比为spiller的时候,才进行溢写。这个过程有点类似于CMS垃圾收集器,到达一定百分比空间占用时,进行垃圾收集。而预留一部分空间给用户对象继续分配。
init初始化方法还定义了排序器sorter,默认使用快排算法对map输出的key进行进行排序。当然存在排序器,一定要有配合它使用的比较器Comparator一起使用。
job.getOuputKeyComparator()
这个方法确定比较器的时候,存在两个顺序:
- 优先取用户覆盖的自定义排序比较器
- 如果用户没定义,则取这个key的类型自身的排序比较器
支持自定义的比较器:
job.setSortComparatorClass(xxx.class)
这时候上面的key比较器就会拿到你设置的这个xxx.class对象了。
init方法声明了combiner。其中combiner相当于map端的一个小型reduce。作用是对数据进行压缩,将多条相同的数据reduce成几条,这样做的目的时减少网络传输的消耗,但是与此同时需要map端消耗更多的处理时间。不过reduce端的处理时间也是相对来说减少很多。
combiner框架默认是没有的,你必须要设置combiner。
SpillThread溢写线程,线程内部的run方法会调用方法sortAndSpill(),这个方法除了会做溢写排序(对每条map处理记录按照分区排序,然后再按照key排序,再溢写),还会判断是否含有CombineRunner。
扩展
通过mapReduce的map处理完数据之后,一般都会生成K,V,P标志一个键值对,其中需要将这些生成的键值对,对key,value序列化后写到buffer数组中,也就是上面我们提到的MapOutputBuffer。那么现在问题来了,如果只是简简单单的存入到buffer中,我们能够从buffer数组中拿到指定组数的key,value值吗?
显然是不可以的,buffer是个字节数组,就相当于直接在内存中操作,我们必须要通过一个索引进行定位每对key,value所在buffer数组中的位置,才可以获取到指定的kv。索引其实就是16byte大小的结构,记录了分区号P,Key的起始位置,Value的起始位置,以及Value的长度这些信息。每当我们向buffer数组存入序列化后的KV,再将对应的这些位置信息存入到索引中,就可以根据索引定位到KV的具体在buffer数组中的位置了。
当然这里又会有一个问题,索引是和KV存入同一个buffer数组中呢,还是单独使用一个字节数组存索引呢?
起始hadoop1.x是使用单独一个字节数组来存放索引的信息,但是这会产生一个问题,虽然索引的大小是16byte固定不变,但是每一对KV序列化后的字节大小则是会动态改变的,也就是说又的KV大,有的KV小。那么如果大的KV存在很多,很快就把buffer占用满了,此时存放索引的buffer空间采用了1/5。当然,如果是小的KV,会出现索引数组空间满的时候,KV所在的数组才使用了一半不到的空间就要做溢写操作。这就会比较浪费存储空间。
所以在hadoop2.x的时候,就出现将索引结构和kv存放到同一个buffer数组中。map每序列化完一对KV,存入buffer数组中时,同时也会创建一个索引,将这对KV所在的buffer数组中的位置以及分区号记录好,然后追加到buffer数组中。那么当我们想要取某一对KV的时候,最简单的办法就是从buffer数组末尾遍历到这对kv对应的索引。然后读取索引中记录的Key在buffer数组的起始位置,以及value的起始位置(同时也是key的结束位置),以及value的长度。就可以拿到这对kv的完整数据了!
图中红色代表一对KV中的Key,绿色代表Value、蓝色表示空闲区域、紫色表示索引
按照前面所说的,默认当KV数组空间使用率达到80%的时候,就会出现溢写操作。这个溢写排序操作由SpillThread发起。当进行溢写的时候,会将当前的KV以及其对应的索引所占用数组这段空间锁住。这样SpillThread这个线程就可以放心的对这部分区域做排序溢写,而数组空闲的地方仍然可以提供给写线程继续往里追加map处理后格式化的KV和对应的索引,其大概的工作图如下:
那到底如何使用这个20%的可用空间呢?也就是从数组的哪里开始分配索引,哪里分配KV对。 如果我们继续使用原先的分配策略,当进来一个KV,就往数组头部开始往尾部逐个追加,对其索引就往数组尾部开始向头部方向追加,利用这20%的数组空间,就会产生一个问题,当这20%的空间使用还剩一点点,不足以容纳一个Value的时候,这时再进来一个KV,就会导致Value出现"撞车"的问题,要处理这个问题也有办法,就是将出问题的那部分Value数据,从数组的头部开始做追加,也就相当于折返操作。这个时候又必须得在索引上,划出一个字段用来记录Value的折返位置这些信息,会比较繁琐。
更好的解决办法就是,在这20%的空闲空间中,画一条分割线,分割线左边开始追加索引、右边则追加KV:
这样如果我们的调优做的很好,当我们这20%的空间没用完的时候,溢写线程已经将数据都写出并释放了LOCK,那么数组的其他空间就可以继续被使用了。这时候,如果追加KV的时候,发现追加到数组尾部了,尾部剩下的一点点空间又容纳不了Value了,这该如何处理?
实际上出现这种情况,还是让剩下那部分Value折返到数组头部,假设在数据尾部还剩下10个字节长度空间时,你进来的Value需要占用30个字节空间,这个时候,索引就先记录到你的Value的起始坐标,假设为90,并且记录Value的长度VL=30。此时会将装不进的20个Value字节折返到数组头部。我们并不需要为此折返操作在索引结构上加任何字段。因为当你需要获取这个特殊的KV的时候,会去读索引,发现数组容量是100个字节,而索引的Value起始坐标VS=90,长度VL=30。VS+VL>100,这时你肯定是会去数组头部拉取20个字节长度的Value补全!后续的KV从头部开始进行追加,又回到了之前的模式。
实际上这就是环形缓冲区的操作流程,我们可以将上面的数组,折成一个环来看:
上面说完了KV和索引在数组存放时的策略。那么下面我们介绍下当数组空间到80%的时候,会触发SpillThread排序溢写的过程。那么这个排序溢写是如何操作的呢?
在做排序溢写的时候,有两种办法:
-
第一种:获取数组的两个Key到内存做比较,比较结果小的Key放在数组前面,大的放在数组后面。这就会出现Key交换位置的情况。因为KV的大小是不确定的,当一个大的key和小Key做交换位置的时候,大Key就不够空间存放了。所以这种方法不推荐。
-
第二种:获取数组的两个Key到内存做比较,当然这次交换的不是Key的位置,而是交换索引的位置。也就是将小的索引和大的索引做交换。因为索引的大小是固定的,故此交换索引是不会发生撞车现象。
这里我们就以第二种作为推挤的排序方式。当排好序之后,就按照索引的顺序开始读KV对,将KV以及对应分区号溢写到磁盘中。
MapOutputBuffer总结
map输出的KV会序列化成字节数组,算除P,最终是一个3元组KVP。
buffer是使用的环形缓冲区:
-
本质还是线性字节数组。
-
环形缓冲区引入了赤道这一概念,往赤道两端方向放入KeyValue、索引
-
索引是固定16byte长度的数据结构,用于记录以下信息:
- P 分区号
- KS Key的开始位置
- VS Value的开始位置
- VL Value的长度
-
如果数据填充达到阈值(80%数组空间),启动线程SpillThread,它的处理流程:
- 快速排序这80%的数据,同时map的输出线程继续向剩余的空间写。当然这里的快速排序的过程相当于比较Key进行排序,但最终移动的是Key对应的索引。
-
最终溢写时,只需要按照排序的索引,写下的文件就是有序的。注意:排序是二次排序:分区有序(最后Reduce拉取是按照分区拉取的)、分区内Key有序(因为Reduce计算式按照分组计算,分组的语义就是相同的key排在了一起)。目的是减少io。
buffer实际上还可以进行调优,调优使用combiner进行:
-
combiner实际上就是一个map里的reduce,按组进行统计
-
combiner操作发生在内存溢写数据之前,KV、分区排完序之后。好处是溢写的io会变少!
-
combiner存在一个最少的溢写合并的次数,也就是minSpillsForCombine默认为3。 表示最终map输出结束,这个过程中,buffer溢写出多个小文件,小文件的规律是内部有序的,此时map最终会把溢写出来的小文件合并成一个大文件,避免小文件的碎片化对未来reduce拉取数据造成的随机读写
map将溢写的多个小文件合并成大文件的目的就是减少机械磁盘的随机读写,尽可能的发生磁盘的顺序读写。因为小的文件是随机分散在磁盘的各个磁道上,磁盘机械指针去读取的时候,寻址是通过指针一个个磁道划到哪读到哪,这样就会出现指针飘忽不定。消耗大量的io寻址时间。如果是一个大文件,它在磁盘中存放就是线性的,不是散布在不同的磁道,线性的读写速度肯定时快于随机读写。其实我们也可以验证上面所说的,比如你在D盘随机生成n个小文件,对比你将这些小文件不做任何处理拷贝到E盘和将这些小文件进行压缩放,再拷贝到E盘。比较两者的速度,你会发现压缩后再拷贝非常块。这也是磁盘发生顺序读写的优势,io速度显著提高。
这也是为什么一些框架底层也在做将多个小文件合并成大文件的操作,比如Kafka和ES。它们的数据存在磁盘中,但是速度很快,就是使用了上面提到原理。
-
使用combiner需要注意:要求combiner计算逻辑必须要是幂等的。因为combiner会被执行多次。比如下面两个例子:
1、 求和计算: 当map处理完数据往缓冲区刷的时候,满80%数据的时候,combiner就会拉取这80%的数据,进行一次reduce,得到这些数据的总和(多条记录压缩成几条),然后再溢写成一个小文件。当小文件总数为minSpillsForCombine的时候,再发生一次小文件合并成大文件的combiner操作,这个时候就会将小文件的记录又作reduce,然后统计总和。最终利用了两次combiner进行计算,总和得到的结果和不用combiner计算(直接交给reduce计算),得到的总和一直。也就是保证幂等性。
2、 平均数计算:map发生溢写的时候,调用combiner进行计算每个分组的数据的平均数,相当于将要溢写的数据做求和再除以这些求和数据总数。然后将求的平均数结果溢写到一个小文件中。当合并小文件为一个大文件的时候,又会做一次combiner求平均数的流程,而这次是将这些小文件按其分组的数据做平均和统计(注意,这里的分组的数据是前面算好的平均数),那经过这两次combiner求平均数,和这些数据加起来除于总数的平均数肯定不一样,这就是不保证幂等性的运算。
当然也可以让combiner做平均数计算幂等的支持,就是在第一次combine的时候,不进行计算平均数,而是计算分组数据总和以及分组数据的个数。第二次combine计算的时候(merge小文件的时候),也只是将小文件的数据和分组总数做加法求和,不发生实际上的平均数求和操作,最终交给reduce去拉取,做这个平均数求和的操作。
3、ReduceTask
reduce的处理流程是: input - > reduce - >output
我们编写ReduceTask的时候,只需要继承Reducer并编写reduce方法即可。框架帮我们屏蔽了分布式计算的复杂性。
其中Reducer和Mapper都有run方法,用于启动各自的任务。其中不同的是Context的获取记录的方法:
在Mapper的run方法中是这样判断输入split是否还有下一条记录的:
while(context.nextKeyValue()){..},
而在Reducer的run方法中是这样判断是否还有下一组记录的:
while (context.nextKey()) {}
3.1、ReduceTask执行流程
从ReduceTask的官方文档写到,它主要做了以下的步骤:
第一步:shuffle: 也叫做洗牌,即将相同的key被拉到一个分区中,拉取数据。
第二步:sort: 整个MR框架中只有map端是无序到有序的过程,它用的是快排,reduce这里的所谓的sort实际上是一个对着map排好序的一堆小文件做归并排序。换句话说,reduce的sort不能够再改变kv的顺序了。reduce的sort过程实际上是使用grouping comparator分组排序器来进行的,这里解释下分组排序器和map端的排序器区别:
假设有个需求,要求按月统计气温最高的地区。那么原始数据给出的格式如下:
1970-1-22 33 bj
1970-1-8 23 shanghai
分别表示 日期、温度、地区。
那么mapTask对这些数据进行按月分组,除此之外,按照温度高低进行排序,也就是相当于将年月拼接上温度,然后再按温度做倒叙进行sort,得到map的分组输出结果。这个最终的顺序是由map的排序器确认的。
而reduce端只需要按分区号拉取各个map分组完后的文件。然后将拉取到的这些文件(内部有序,外部无序),进行按Key分组,也就是grouping comparator,它只负责将相同key的数据收集起来,并不会改变map分完组后的顺序。这样获取最高气温的时候,就只需要获取每个分组文件的第一条记录即可。最终再执行reduce计算,筛选某个月的最高气温。
第三步:reduce方法执行
我们简单的看下ReduceTask的源码,还是从run方法看起:
@Override
@SuppressWarnings("unchecked")
public void run(JobConf job, final TaskUmbilicalProtocol umbilical)
throws IOException, InterruptedException, ClassNotFoundException {
job.setBoolean(JobContext.SKIP_RECORDS, isSkipping());
if (isMapOrReduce()) {
//reduceTask工作分为拷贝、分组排序、reduce执行 三个阶段
copyPhase = getProgress().addPhase("copy");
sortPhase = getProgress().addPhase("sort");
reducePhase = getProgress().addPhase("reduce");
}
// start thread that will handle communication with parent
TaskReporter reporter = startReporter(umbilical);
boolean useNewApi = job.getUseNewReducer();
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;
}
// Initialize the codec
codec = initCodec();
RawKeyValueIterator rIter = null;
ShuffleConsumerPlugin shuffleConsumerPlugin = null;
Class combinerClass = conf.getCombinerClass();
CombineOutputCollector combineCollector =
(null != combinerClass) ?
new CombineOutputCollector(reduceCombineOutputCounter, reporter, conf) : null;
Class<? extends ShuffleConsumerPlugin> clazz =
job.getClass(MRConfig.SHUFFLE_CONSUMER_PLUGIN, Shuffle.class, ShuffleConsumerPlugin.class);
shuffleConsumerPlugin = ReflectionUtils.newInstance(clazz, job);
LOG.info("Using ShuffleConsumerPlugin: " + shuffleConsumerPlugin);
ShuffleConsumerPlugin.Context shuffleContext =
new ShuffleConsumerPlugin.Context(getTaskID(), job, FileSystem.getLocal(job), umbilical,
super.lDirAlloc, reporter, codec,
combinerClass, combineCollector,
spilledRecordsCounter, reduceCombineInputCounter,
shuffledMapsCounter,
reduceShuffleBytes, failedShuffleCounter,
mergedMapOutputsCounter,
taskStatus, copyPhase, sortPhase, this,
mapOutputFile, localMapFiles);
shuffleConsumerPlugin.init(shuffleContext);
//上面是shuffle的初始化过程,这里会进行shuffle的拉取map记录的操作,最终会返回迭代器。
rIter = shuffleConsumerPlugin.run();
// free up the data structures
mapOutputFilesOnDisk.clear();
sortPhase.complete(); // sort is complete
setPhase(TaskStatus.Phase.REDUCE);
statusUpdate(umbilical);
Class keyClass = job.getMapOutputKeyClass();
Class valueClass = job.getMapOutputValueClass();
RawComparator comparator = job.getOutputValueGroupingComparator();
if (useNewApi) {
runNewReducer(job, umbilical, reporter, rIter, comparator,
keyClass, valueClass);
} else {
runOldReducer(job, umbilical, reporter, rIter, comparator,
keyClass, valueClass);
}
shuffleConsumerPlugin.close();
done(umbilical, reporter);
}
源码中我们不需要关注shuffle的初始化过程,只需要知道shuffle最终拉取map记录,并返回一个迭代器。也就是reduce拉取回属于自己的数据,并包装成迭代器。
包装返回迭代器,是因为数据可能非常大,不能一次性加载到内存。而是通过迭代器一条一条的读磁盘的数据加载到内存。
除此之外,源码中还提到排序比较器的设置:RawComparator comparator = job.getOutputValueGroupingComparator();
我们点进去看下具体是怎么设置的:
public RawComparator getOutputValueGroupingComparator() {
Class<? extends RawComparator> theClass = getClass(
JobContext.GROUP_COMPARATOR_CLASS, null, RawComparator.class);
if (theClass == null) {
return getOutputKeyComparator();
}
return ReflectionUtils.newInstance(theClass, this);
}
发现首先是会去获取用户自定义的分组排序器,我们可以通过setOutputValueGroupingComparator(Class)
去进行设置自己的分组排序器。如果分组排序器为空,就调用getOutputKeyComparator,我们具体看下这个方法:
public RawComparator getOutputKeyComparator() {
Class<? extends RawComparator> theClass = getClass(
JobContext.KEY_COMPARATOR, null, RawComparator.class);
if (theClass != null)
return ReflectionUtils.newInstance(theClass, this);
return WritableComparator.get(getMapOutputKeyClass().asSubclass(WritableComparable.class), this);
}
这里首先会去获取mapreduce.job.output.key.comparator.class
,也就是用户在map端自定义的key排序器。
如果没有定义,就去获取默认的Key这个类型自身的比较器。
上面的过程提到如果用户没有自定义排序比较器(也就是加map端用于排序的比较器),那就获取key自身的比较器。那么分组比较器可不可以复用排序比较器呢?
答案是可以的。但是在此之前我们要明确:
- 什么叫做排序比较器:排序比较器通常返回:-1、0、1这三个只来代表小于、等于、大于。
- 什么叫做分组比较器:它的返回值通常可以使用一个布尔值来代替,也就是false和true。要么判断为同组,要不不同组。
其实将排序比较器的返回值用这个角度来看:如果判断返回0,表示两个key相同,即为同组,相当于分组比较器返回true。 如果判断返回不是0,就表示两个key不同,即非同组,相当于分组比较器返回false;这样就可以通过排序比较器代替分组比较器了。
总结排序比较器和分组比较器的组合方式
它们的组合方式如下:
-
不设置排序比较器和分组比较器
对于mapTask:取key自身的排序比较器
对于ReduceTask:取key自身的排序比较器
-
设置了排序比较器,没指定分组比较器
对于mapTask:用户自定义的排序比较器
对于reduceTask:用户自定义的排序比较器
-
只设置了分组比较器
对于mapTask:取key自身的排序比较器
对于reduceTask:用户自定义的排序比较器
-
设置了排序和分组比较器
对于mapTask:用户自定义的排序比较器
对于reduceTask:用户自定义的分组排序比较器
结论:mapReduce框架很灵活,支持用户自定义比较器。
我们继续回到ReduceTask的run方法,在设置完迭代器和比较器之后,通过下面的方法用来运行reduce任务
runNewReducer(job, umbilical, reporter, rIter, comparator, keyClass, valueClass);
我们看下这个方法的主要运行机制:
private <INKEY,INVALUE,OUTKEY,OUTVALUE>
void runNewReducer(JobConf job,
final TaskUmbilicalProtocol umbilical,
final TaskReporter reporter,
RawKeyValueIterator rIter,
RawComparator<INKEY> comparator,
Class<INKEY> keyClass,
Class<INVALUE> valueClass
) throws IOException,InterruptedException,
ClassNotFoundException {
// wrap value iterator to report progress.
final RawKeyValueIterator rawIter = rIter;
//初始化reduce输入迭代器
rIter = new RawKeyValueIterator() {
public void close() throws IOException {
rawIter.close();
}
public DataInputBuffer getKey() throws IOException {
return rawIter.getKey();
}
public Progress getProgress() {
return rawIter.getProgress();
}
public DataInputBuffer getValue() throws IOException {
return rawIter.getValue();
}
public boolean next() throws IOException {
boolean ret = rawIter.next();
reporter.setProgress(rawIter.getProgress().getProgress());
return ret;
}
};
// 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);
// make a reducer
org.apache.hadoop.mapreduce.Reducer<INKEY,INVALUE,OUTKEY,OUTVALUE> reducer =
(org.apache.hadoop.mapreduce.Reducer<INKEY,INVALUE,OUTKEY,OUTVALUE>)
ReflectionUtils.newInstance(taskContext.getReducerClass(), job);
// 注意这个是写出reduce记录使用的Writer
org.apache.hadoop.mapreduce.RecordWriter<OUTKEY,OUTVALUE> trackedRW =
new NewTrackingRecordWriter<OUTKEY, OUTVALUE>(this, taskContext);
job.setBoolean("mapred.skip.on", isSkipping());
job.setBoolean(JobContext.SKIP_RECORDS, isSkipping());
//创建reduce的上下文,设置reduce运行时的一些参数。
org.apache.hadoop.mapreduce.Reducer.Context
reducerContext = createReduceContext(reducer, job,
getTaskID(),
rIter, reduceInputKeyCounter,
reduceInputValueCounter,
trackedRW,
committer,
reporter, comparator, keyClass,
valueClass);
try {
reducer.run(reducerContext);
} finally {
trackedRW.close(reducerContext);
}
}
这个方法中,定义了迭代器它对应的实现。同时设置了reduce的记录写出器NewTrackingRecordWriter。最后的createReduceContextz而是设置这些reduce运行时参数,实际上创建了ReduceContextImpl对象并设置属性,比如设置了reduce运行时的输入,输出所用的类。输入则是使用到了rIter,即最开始的迭代器。输出则是trackedRW。
这里重点说的是reduce输入使用的对象,也就是RawKeyValueIterator类的对象rIter,它是Reduce输入文件的真正的迭代器。
在讲解之前,回到ReduceTask的run方法,最后会触发reducer.run(reducerContext);
并传递上面设置好的reducerContext,运行Reduce程序。我们追踪到其中:
public void run(Context context) throws IOException, InterruptedException {
//初始化,可能设置一些连接之类的
setup(context);
try {
//判断是否存在下一组数据
while (context.nextKey()) {
//获得当前组的key和value,进行reduce运算,注意这里并不是将整组数据都加载进来,而是使用迭代器模式,一条一条的进行加载。
reduce(context.getCurrentKey(), context.getValues(), context);
// If a back up store is used, reset it
Iterator<VALUEIN> iter = context.getValues().iterator();
if(iter instanceof ReduceContext.ValueIterator) {
((ReduceContext.ValueIterator<VALUEIN>)iter).resetBackupStore();
}
}
} finally {
cleanup(context);
}
}
上面的方法我们主要的关注点在于context.nextKey()、context.getCurrentKey()、context.getValues()中。其中context即为ReduceContextImpl的对象。它具有的几个重要成员属性如下:
public class ReduceContextImpl<KEYIN,VALUEIN,KEYOUT,VALUEOUT> //继承实现的类省略
{
//重要,input就是前面提到的reduce输入文件的真正的迭代器
private RawKeyValueIterator input;
//用于计数
private Counter inputValueCounter;
private Counter inputKeyCounter;
//分组比较器
private RawComparator<KEYIN> comparator;
private KEYIN key; // current key
private VALUEIN value; // current value
private boolean firstValue = false; // first value in key
//重要:主要用来判断下一个迭代到的key是否和当前的key相同,如果相同,就认为是同一组数据
private boolean nextKeyIsSame = false; // more w/ this key
//判断是否还有更多的数据
private boolean hasMore; // more in file
protected Progressable reporter;
private Deserializer<KEYIN> keyDeserializer;
private Deserializer<VALUEIN> valueDeserializer;
private DataInputBuffer buffer = new DataInputBuffer();
private BytesWritable currentRawKey = new BytesWritable();
//重要,他是虚假的迭代器,实际上它使用了嵌套迭代器,即ValueIterable内部是使用input(真正的迭代器)进行迭代操作的。它获取的数据是依赖于input的。
private ValueIterable iterable = new ValueIterable();
private boolean isMarked = false;
private BackupStore<KEYIN,VALUEIN> backupStore;
private final SerializationFactory serializationFactory;
private final Class<KEYIN> keyClass;
private final Class<VALUEIN> valueClass;
private final Configuration conf;
private final TaskAttemptID taskid;
private int currentKeyLength = -1;
private int currentValueLength = -1;
}
上面使用了注释标注了几个比较重要的成员属性。首先我们看下这个类提到的几个重要的方法:
- ReduceContextImpl中的nextKey()
/** Start processing next unique key. */
public boolean nextKey() throws IOException,InterruptedException {
while (hasMore && nextKeyIsSame) {
nextKeyValue();
}
if (hasMore) {
if (inputKeyCounter != null) {
inputKeyCounter.increment(1);
}
return nextKeyValue();
} else {
return false;
}
}
方法的逻辑一进来就进行while循环,判断reduce输入文件是否还有下一条KV记录,同时判断下一条记录是否和当前迭代到的Key相同(是否为同一组数据)。很显然,当第一次调用nextKey方法的时候,nextKeyIsSame默认为false,因为还没有开始迭代,当然当前的Key是空的。所以会跳出while循环。
继续往下,如果存在多条记录的话,就会调用nextKeyValue方法,这里我们继续追踪这个方法的源码
- ReduceContextImpl中的nextKeyValue()
@Override
public boolean nextKeyValue() throws IOException, InterruptedException {
//如果reduce文件没有数据了,就返回false
if (!hasMore) {
key = null;
value = null;
return false;
}
//判断是否为某一组的第一条记录
firstValue = !nextKeyIsSame;
//获得记录的key
DataInputBuffer nextKey = input.getKey();
currentRawKey.set(nextKey.getData(), nextKey.getPosition(),
nextKey.getLength() - nextKey.getPosition());
buffer.reset(currentRawKey.getBytes(), 0, currentRawKey.getLength());
key = keyDeserializer.deserialize(key);
//获取当前记录的value
DataInputBuffer nextVal = input.getValue();
buffer.reset(nextVal.getData(), nextVal.getPosition(), nextVal.getLength()
- nextVal.getPosition());
value = valueDeserializer.deserialize(value);
currentKeyLength = nextKey.getLength() - nextKey.getPosition();
currentValueLength = nextVal.getLength() - nextVal.getPosition();
if (isMarked) {
backupStore.write(nextKey, nextVal);
}
//读完当前的kv对的时候,默认继续往下迭代下一条记录!同时返回是否存在下一条记录,更新hasMore!
hasMore = input.next();
if (hasMore) {
//如果读到下一条记录,就获取下一条记录的key
nextKey = input.getKey();
//使用比较器比较,当前的key是否和获取到的下一条记录的key是否为同一组,并更新nextKeyIsSame属性!
nextKeyIsSame = comparator.compare(currentRawKey.getBytes(), 0,
currentRawKey.getLength(),
nextKey.getData(),
nextKey.getPosition(),
nextKey.getLength() - nextKey.getPosition()
) == 0;
} else {
nextKeyIsSame = false;
}
inputValueCounter.increment(1);
return true;
}
注释已经很清楚了,大概做了这样的事情,首先获取当前迭代到的KV对,并设置到ReduceCOntextImpl的key、value成员属性中,表示当前需要reduce任务处理的KV。 紧接着,调用input.next() ,让真正的迭代器继续取reduce输入文件的下一行数据,并使用比较器比较,当前的key是否和获取到的下一条记录的key是否为同一组,并更新nextKeyIsSame属性!
- ReduceContextImpl中的context.getCurrentKey()、context.getCurrentValue()
public KEYIN getCurrentKey() {
return key;
}
@Override
public VALUEIN getCurrentValue() {
return value;
}
这里就返回ReduceContextImpl中的两个成员变量key、value的值,也即是nextKeyValue()中设置的当前KV的值.
Reducer每执行完一组中的所有KV之后就会重新调用context.nextKey(),获取下一组的kv
- ReduceContextImpl中的context.getValues()
/**
* Iterate through the values for the current key, reusing the same value
* object, which is stored in the context.
* @return the series of values associated with the current key. All of the
* objects returned directly and indirectly from this method are reused.
*/
public
Iterable<VALUEIN> getValues() throws IOException, InterruptedException {
return iterable;
}
发现这个方法仅仅返回了一个ValueIterable 的iterable对象,并且ValueIterable 还是ReduceContextImpl的内部类。
protected class ValueIterable implements Iterable<VALUEIN> {
private ValueIterator iterator = new ValueIterator();
@Override
public Iterator<VALUEIN> iterator() {
return iterator;
}
}
又一个惊奇的发现,ValueIterable的iterator方法,返回的最终对象是ValueIterator类对应的iterator对象。 而ValueIterator也是ReduceContextImpl的内部类,我们继续看ValueIterator的几个重点方法:
protected class ValueIterator implements ReduceContext.ValueIterator<VALUEIN> {
private boolean inReset = false;
private boolean clearMarkFlag = false;
@Override
//判断是否存在这一组的下一条数据,或是否是这一组的第一条key
public boolean hasNext() {
try {
if (inReset && backupStore.hasNext()) {
return true;
}
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("hasNext failed", e);
}
return firstValue || nextKeyIsSame;
}
@Override
//获取下一个value
public VALUEIN next() {
if (inReset) {
try {
if (backupStore.hasNext()) {
backupStore.next();
DataInputBuffer next = backupStore.nextValue();
buffer.reset(next.getData(), next.getPosition(), next.getLength()
- next.getPosition());
value = valueDeserializer.deserialize(value);
return value;
} else {
inReset = false;
backupStore.exitResetMode();
if (clearMarkFlag) {
clearMarkFlag = false;
isMarked = false;
}
}
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException("next value iterator failed", e);
}
}
// if this is the first record, we don't need to advance
if (firstValue) {
firstValue = false;
return value;
}
// if this isn't the first record and the next key is different, they
// can't advance it here.
if (!nextKeyIsSame) {
throw new NoSuchElementException("iterate past last value");
}
// otherwise, go to the next key/value pair
try {
nextKeyValue();
return value;
} catch (IOException ie) {
throw new RuntimeException("next value iterator failed", ie);
} catch (InterruptedException ie) {
// this is bad, but we can't modify the exception list of java.util
throw new RuntimeException("next value iterator interrupted", ie);
}
}
}
上面列出了ValueIterator最最重要的两个方法hasNext() 和next()
-
haseNext()方法主要用来 判断是否存在这一组的下一条数据,或这是否是这一组的第一条key。如果是就返回true
-
next()方法返回值就是KV对中的Value,它主要的流程如下:
-
是否为某一组的第一条记录,如果是,就返回这条记录的Value。
-
如果是这一组中的记录,就调用nextKeyValue(),而nextKeyValue()最终使用的是真正的迭代器,也即是ReduceContextImpl的input成员属性去进行迭代下一条数据,并判断是否和当前遍历到的数据为同一组数据,重置nextKeyIsSame的值。
最终返回当前KV的value
-
3.2、回头看向我们写的reduce处理逻辑
分析完上面的ReduceTask的具体执行逻辑,此时返回头看像之前写的wordcount的reduce方法
public class MyReducer extends Reducer<Text, IntWritable, Text, IntWritable> {
//每组key统计的结果是IntWritable类型。表示单词对应的统计总数
private IntWritable result = new IntWritable();
/**
* reduce计算方法
* @param key map文件输出的Key,也就是分组key
* 例如reduce计算拉取的分组如下
* hello 1
* hello 1
* hello 1
* hello 1
* hello 1
* 则Key就是hello,以hello为分组
* @param values 每个分组所对应的value列表,如上的列子,values=[1,1,1,1,1] 当然values是个迭代器。
* @param context 上下文,做最终key/value对的记录输出
* @throws IOException
* @throws InterruptedException
*/
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方法中为什么这样做,就很清晰了。关注到reduce方法中的参数 Iterablevalues
在程序中使用增强for循环迭代遍历它,实际上是调用了Iterable的iterator方法,也就是ValueIterator类的对象,然后调用iterator.hasNext方法判断是否存在同一组的下一个,如果存在下一个就调用iterator.next获取当前的Value元素(同时会调用nextKeyValue获取下一个这一组的KV准备下一次返回)。
3.3小结
reduceTask拉取回的数据被包装成一个迭代器,reduce方法被调用的时候,并没有把一组数据真的加载到内存,而是传递了一个迭代器作为values。在reduce方法中使用这个迭代器的时候
- hasNext方法判断nextKeyIsSame:判断下一条记录是不是还是一组。
- next方法:负责调取nextKeyValue方法,从reduceTask级别的迭代器并同时更新nextKeyIsSame
以上的设计艺术: 充分利用了迭代器模式:规避了内存数据OOM的问题,并且之前说了MapTask输出后的记录是做了Key排序的,所以真/假迭代器它们只需要协作,一次I/0(文件的每条记录只被读一次)即可线性的处理完每一组数据。