MapReduce源码解析
核心代码是job.waitForCompletion(true)->submit()->submitter.submitJobInternal(Job.this, cluster)
/**
* 用于向系统提交作业的内部方法。
* 工作提交过程包括:
* 1.检查作业的输入和输出规范
* 2.计算作业的InputSplits
* 3.如有必要,为作业的DistributedCache设置必要的账户信息
* 4.将作业的jar和配置复制到分布式文件系统上的map-reduce系统目录
* 5.将作业提交给JobTracker,并可选择监视其状态。
*/
JobStatus submitJobInternal(Job job, Cluster cluster)
throws ClassNotFoundException, InterruptedException, IOException {
// 校验输出目录是否存在
checkSpecs(job);
Configuration conf = job.getConfiguration();
addMRFrameworkToDistributedCache(conf);
// 初始化临时目录并返回路径。它还跟踪所有必要的所有权和权限
Path jobStagingArea = JobSubmissionFiles.getStagingDir(cluster, conf);
// ...
// 提交job的目录
Path submitJobDir = new Path(jobStagingArea, jobId.toString());
JobStatus status = null;
try {
// 设置用户
conf.set(MRJobConfig.USER_NAME,
UserGroupInformation.getCurrentUser().getShortUserName());
conf.set("hadoop.http.filter.initializers",
"org.apache.hadoop.yarn.server.webproxy.amfilter.AmFilterInitializer");
// 设置job目录
conf.set(MRJobConfig.MAPREDUCE_JOB_DIR, submitJobDir.toString());
LOG.debug("Configuring job " + jobId + " with " + submitJobDir
+ " as the submit dir");
// 下面为目录访问生成令牌
// 获取该目录的委托令牌
TokenCache.obtainTokensForNamenodes(job.getCredentials(),
new Path[] { submitJobDir }, conf);
populateTokenCache(conf, job.getCredentials());
// 生成一个秘密来验证shuffle传输
if (TokenCache.getShuffleSecretKey(job.getCredentials()) == null) {
// ...
}
// ...
// 与MapReduce作业相关的资源从客户端上传到HDFS,libjars, files, archives 和 jobjars
copyAndConfigureFiles(job, submitJobDir);
// job配置文件路径(待创建)
Path submitJobFile = JobSubmissionFiles.getJobConfPath(submitJobDir);
// 为作业创建splits,split的数量即为map的数量,split是为了解耦存储层和计算层
LOG.debug("Creating splits at " + jtFs.makeQualified(submitJobDir));
int maps = writeSplits(job, submitJobDir);
// ...
// 下面是一些权限配置
// 将"正在向其提交作业的队列管理员"写入作业文件。
String queue = conf.get(MRJobConfig.QUEUE_NAME,
JobConf.DEFAULT_QUEUE_NAME);
AccessControlList acl = submitClient.getQueueAdmins(queue);
conf.set(toFullPropertyName(queue,
QueueACL.ADMINISTER_JOBS.getAclName()), acl.getAclString());
// 在将jobconf复制到HDFS之前删除jobtoken引用,因为任务不需要这个设置,实际上它们可能会因此而中断,因为当前引用可能指向不同的作业
TokenCache.cleanUpTokenReferral(conf);
// ...
// 将job配置写到hdfs中
writeConf(conf, submitJobFile);
printTokens(jobId, job.getCredentials());
// 提交作业
status = submitClient.submitJob(
jobId, submitJobDir.toString(), job.getCredentials());
// ...
}
// ...
}
计算作业的InputSplits
writeSplits(job, submitJobDir)->writeNewSplits(job, jobSubmitDir)
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);
// 通过InputFormat计算出所有split,支撑计算向数据移动
List<InputSplit> splits = input.getSplits(job);
T[] array = (T[]) splits.toArray(new InputSplit[splits.size()]);
// 将splits按大小倒序排序
Arrays.sort(array, new SplitComparator());
JobSplitWriter.createSplitFiles(jobSubmitDir, conf,
jobSubmitDir.getFileSystem(conf), array);
return array.length;
}
// FileInputFormat
public List<InputSplit> getSplits(JobContext job) throws IOException {
StopWatch sw = new StopWatch().start();
// 获取配置的最小split数量,默认为1
long minSize = Math.max(getFormatMinSplitSize(), getMinSplitSize(job));
// 获取配置的最大split数量,默认为Long.MAX_VALUE
long maxSize = getMaxSplitSize(job);
// generate splits
List<InputSplit> splits = new ArrayList<InputSplit>();
// 列出输入目录中的所有文件,可配置是否执行递归
List<FileStatus> files = listStatus(job);
// 是否忽略目录,只有在不允许递归且在非递归时忽略子目录(这两个条件都可以配置),才会忽略目录
boolean ignoreDirs = !getInputDirRecursive(job)
&& job.getConfiguration().getBoolean(INPUT_DIR_NONRECURSIVE_IGNORE_SUBDIRS, false);
for (FileStatus file: files) {
if (ignoreDirs && file.isDirectory()) {
continue;
}
Path path = file.getPath();
long length = file.getLen();
if (length != 0) {
// 文件对应的所有块信息
BlockLocation[] blkLocations;
// ...
// 给定的文件名是否可拆分,通常为true,但如果文件是流压缩的,则不会。FileInputFormat中的默认实现总是返回true
if (isSplitable(job, path)) {
long blockSize = file.getBlockSize();
// 计算切片大小,默认split大小等于block大小,Math.max(minSize, Math.min(maxSize, blockSize)) 调大split改小,调小split改大,如果想得到一个比block大的split调整minSize
long splitSize = computeSplitSize(blockSize, minSize, maxSize);
long bytesRemaining = length;
// 将文件所有字节数,按照切片大小,逻辑切割,即创建切片放到splits列表中
// SPLIT_SLOP=1.1,剩余的字节数小于切片大小的1.1倍,则剩余的字节数不再切割直接放到一个切片中
while (((double) bytesRemaining)/splitSize > SPLIT_SLOP) {
// 计算split的起始字节所在的block的索引
int blkIndex = getBlockIndex(blkLocations, length-bytesRemaining);
// 创建FileSplit(文件路径,起始偏移量,总大小,第一个block所在的host列表,第一个block所在的host缓存列表),添加到splits列表中,host列表是计算向数据移动的关键
splits.add(makeSplit(path, length-bytesRemaining, splitSize,
blkLocations[blkIndex].getHosts(),
blkLocations[blkIndex].getCachedHosts()));
bytesRemaining -= splitSize;
}
// ...
} else { // not splitable
// ...
}
} else {
// 为零长度文件创建空主机数组
splits.add(makeSplit(path, 0, length, new String[0]));
}
}
// ...
return splits;
}
// 列出输入目录中的所有文件(可能包括子目录),支持正则表达式,可配置是否执行递归
protected List<FileStatus> listStatus(JobContext job
) throws IOException {
// 支持多个输入路径
Path[] dirs = getInputPaths(job);
if (dirs.length == 0) {
throw new IOException("No input paths specified in job");
}
// get tokens for all the required FileSystems..
TokenCache.obtainTokensForNamenodes(job.getCredentials(), dirs,
job.getConfiguration());
// 是否需要递归查看目录结构
boolean recursive = getInputDirRecursive(job);
// 使用hiddenFileFilter和用户提供的过滤器(如果有)创建一个MultiPathFilter,用于对路径进行过滤
List<PathFilter> filters = new ArrayList<PathFilter>();
filters.add(hiddenFileFilter);
PathFilter jobFilter = getInputPathFilter(job);
if (jobFilter != null) {
filters.add(jobFilter);
}
PathFilter inputFilter = new MultiPathFilter(filters);
List<FileStatus> result = null;
// 线程数
int numThreads = job.getConfiguration().getInt(LIST_STATUS_NUM_THREADS,
DEFAULT_LIST_STATUS_NUM_THREADS);
StopWatch sw = new StopWatch().start();
if (numThreads == 1) {
result = singleThreadedListStatus(job, dirs, inputFilter, recursive);
} else {
// ...
}
// ...
return result;
}
private List<FileStatus> singleThreadedListStatus(JobContext job, Path[] dirs,
PathFilter inputFilter, boolean recursive) throws IOException {
List<FileStatus> result = new ArrayList<FileStatus>();
List<IOException> errors = new ArrayList<IOException>();
for (int i=0; i < dirs.length; ++i) {
Path p = dirs[i];
FileSystem fs = p.getFileSystem(job.getConfiguration());
// 可能是正则会匹配多个
FileStatus[] matches = fs.globStatus(p, inputFilter);
if (matches == null) {
errors.add(new IOException("Input path does not exist: " + p));
} else if (matches.length == 0) {
errors.add(new IOException("Input Pattern " + p + " matches 0 files"));
} else {
// 遍历匹配的所有路径
for (FileStatus globStat: matches) {
if (globStat.isDirectory()) {
RemoteIterator<LocatedFileStatus> iter =
fs.listLocatedStatus(globStat.getPath());
while (iter.hasNext()) {
LocatedFileStatus stat = iter.next();
// 过滤器过滤,默认是下划线或点开头的文件会被过滤掉,过滤逻辑可以自定义
if (inputFilter.accept(stat.getPath())) {
// 目录是否需要递归处理,如果不需要递归目录也会被添加到返回列表中(最终处理数据时如果有目录会报错)
if (recursive && stat.isDirectory()) {
addInputPathRecursively(result, fs, stat.getPath(),
inputFilter);
} else {
result.add(stat);
}
}
}
} else {
result.add(globStat);
}
}
}
}
// ...
}
提交作业
这里分析本地提交作业
// LocalJobRunner
public org.apache.hadoop.mapreduce.JobStatus submitJob(
org.apache.hadoop.mapreduce.JobID jobid, String jobSubmitDir,
Credentials credentials) throws IOException {
// Job继承了Thread,重点在run方法中
Job job = new Job(JobID.downgrade(jobid), jobSubmitDir);
job.job.setCredentials(credentials);
return job.status;
}
public void run() {
// ...
try {
// 构建切片的元数据列表
TaskSplitMetaInfo[] taskSplitMetaInfos =
SplitMetaInfoReader.readSplitMetaInfo(jobId, localFs, conf, systemJobDir);
// ...
// 通过切片元数据等信息,构建map的执行列表
List<RunnableWithThrowable> mapRunnables = getMapTaskRunnables(
taskSplitMetaInfos, jobId, mapOutputFiles);
initCounters(mapRunnables.size(), numReduceTasks);
ExecutorService mapService = createMapExecutor();
// 执行map,使用线程池调用列表中每个Map任务的MapTaskRunnable.run方法
runTasks(mapRunnables, mapService, "map");
try {
if (numReduceTasks > 0) {
// 构建reduce的执行列表
List<RunnableWithThrowable> reduceRunnables = getReduceTaskRunnables(
jobId, mapOutputFiles);
ExecutorService reduceService = createReduceExecutor();
// 执行reduce,使用线程池调用列表中每个Reduce任务的ReduceTaskRunnable.run方法
runTasks(reduceRunnables, reduceService, "reduce");
}
} finally {
for (MapOutputFile output : mapOutputFiles.values()) {
output.removeAll();
}
}
// 删除输出目录中的临时目录,同时在输出目录中创建_SUCCESS文件
outputCommitter.commitJob(jContext);
// ...
} catch (Throwable t) {
// ...
} finally {
// ...
}
}
// MapTaskRunnable.run
public void run() {
try {
// ...
// map任务执行的核心类,核心方法是其run方法(yarn上执行是也是如此)
MapTask map = new MapTask(systemJobFile.toString(), mapId, taskId,
info.getSplitIndex(), 1);
// 进行相关配置
// ...
try {
map_tasks.getAndIncrement();
myMetrics.launchMap(mapId);
// map任务执行的核心方法
map.run(localConf, Job.this);
myMetrics.completeMap(mapId);
} finally {
map_tasks.getAndDecrement();
}
LOG.info("Finishing task: " + mapId);
} catch (Throwable e) {
this.storedException = e;
}
}
// ReduceTaskRunnable.run
public void run() {
try {
// ...
// reduce任务执行的核心类,核心方法是其run方法(yarn上执行是也是如此)
ReduceTask reduce = new ReduceTask(systemJobFile.toString(),
reduceId, taskId, mapIds.size(), 1);
// 进行相关配置
// ...
if (!Job.this.isInterrupted()) {
// ...
try {
reduce_tasks.getAndIncrement();
myMetrics.launchReduce(reduce.getTaskID());
// reduce任务执行的核心方法
reduce.run(localConf, Job.this);
myMetrics.completeReduce(reduce.getTaskID());
} finally {
reduce_tasks.getAndDecrement();
}
LOG.info("Finishing task: " + reduceId);
} else {
throw new InterruptedException();
}
} catch (Throwable t) {
// 将其存储在初始线程上下文中,以便重新抛出
this.storedException = t;
}
}
MapTask
map阶段整体概述
input file通过split被逻辑切分为多个split文件,通过LineReader按行读取内容给map(用户自己实现的)进行处理,数据被map处理结束之后交给NewOutputCollector收集器,然后写入buffer,每个map task都有一个内存缓冲区,存储着map的输出结果。内存缓冲区中存储了key/value原始数据和元数据(key/value 位置、长度、分区号),当缓冲区快满的时候需要将缓冲区数据以一个临时文件的方式溢写到磁盘,溢写磁盘时会先按照分区排序再按照key排序,这样溢写文件是分区有序且分区内key有序,还会同时生成索引文件,记录数据文件中分区信息。当整个map task结束后再对磁盘中这个map task产生的所有临时文件做合并,生成最终的正式输出文件,最终合并时也会先按照分区排序再按照key排序,最终生成的文件是分区有序且分区内key有序(这都是为reduce阶段做准备,reduce只会拉取指定分区的数据,reduce计算是按分组计算,分组的语义是相同的key排在了一起),同时会生成最终的索引文件,记录数据文件中分区信息,然后等待reduce task来拉数据。分区数量可配置默认为1,分区数量就是reduce任务的数量(数据分区时对key进行分区,默认使用hash分区)。如果配置了combine,map阶段还会执行combine(就是提前执行reduce),这样可以大幅减少map阶段的输出,减少了磁盘溢写的IO和reduce网络拉取的IO,可以大大提高效率,combine会在内存缓冲区溢写磁盘时以及磁盘文件合并时触发;注意combine的执行必须是幂等的,即提前执行的reduce不会影响最终的结果
public void run(final JobConf job, final TaskUmbilicalProtocol umbilical)
throws IOException, ClassNotFoundException, InterruptedException {
this.umbilical = umbilical;
if (isMapTask()) {
// 如果没有reduce,则不需要sort。map的输出就是程序最终的输出,这样的话,就没有必要进行shuffle了
if (conf.getNumReduceTasks() == 0) {
mapPhase = getProgress().addPhase("map", 1.0f);
} else {
// 如果有reduce的话,map阶段占据67%,sort阶段占据33%
mapPhase = getProgress().addPhase("map", 0.667f);
sortPhase = getProgress().addPhase("sort", 0.333f);
}
}
// 通信线程处理与父进程(任务跟踪器)的通信。如果有进展或者任务需要让父进程知道它还活着,它会发送进度更新。它还会ping父进程,看看它是否还活着
TaskReporter reporter = startReporter(umbilical);
boolean useNewApi = job.getUseNewMapper();
// 初始化MapTask,进行一些设置
initialize(job, getJobID(), reporter, useNewApi);
// ...
if (useNewApi) {
// 核心方法
runNewMapper(job, splitMetaInfo, umbilical, reporter);
} else {
runOldMapper(job, splitMetaInfo, umbilical, reporter);
}
done(umbilical, reporter); // 更新一些统计信息
}
public void initialize(JobConf job, JobID id,
Reporter reporter,
boolean useNewApi) throws IOException,
ClassNotFoundException,
InterruptedException {
// 任务上下文(在任务运行时提供给任务的job的只读视图)
jobContext = new JobContextImpl(job, id, reporter);
// 任务尝试的上下文
taskContext = new TaskAttemptContextImpl(job, taskId, reporter);
// 设置任务状态
if (getState() == TaskStatus.State.UNASSIGNED) {
setState(TaskStatus.State.RUNNING);
}
if (useNewApi) {
if (LOG.isDebugEnabled()) {
LOG.debug("using new api for output committer");
}
// 设置输出格式化类,可自定义,默认为TextOutputFormat
outputFormat =
ReflectionUtils.newInstance(taskContext.getOutputFormatClass(), job);
// 设置此输出格式化类的输出提交器,这负责确保正确提交输出
// 1.在初始化期间设置作业;例如,在作业初始化期间为作业创建临时输出目录。2.作业完成后清理作业;例如,在作业完成后删除临时输出目录。3.设置任务临时输出。4.检查任务是否需要提交;这是为了避免在任务不需要提交时执行提交过程。5.提交任务输出。6.丢弃任务提交
committer = outputFormat.getOutputCommitter(taskContext);
} else {
committer = conf.getOutputCommitter();
}
// ...
// FileOutputCommitter的setupTask不做任何事情,因为临时任务目录是在任务写入时按需创建的
committer.setupTask(taskContext);
// 获取进程资源使用情况的接口类
Class<? extends ResourceCalculatorProcessTree> clazz =
conf.getClass(MRConfig.RESOURCE_CALCULATOR_PROCESS_TREE,
null, ResourceCalculatorProcessTree.class);
pTree = ResourceCalculatorProcessTree
.getResourceCalculatorProcessTree(System.getenv().get("JVM_PID"), clazz, conf);
LOG.info(" Using ResourceCalculatorProcessTree : " + pTree);
if (pTree != null) {
// 用最新状态更新进程树。对该函数的每次调用都应该增加进程树中已经存在的正在运行的进程的年龄。Age用于该接口的其他API
pTree.updateProcessTree();
// 获取自创建进程树以来进程树中所有进程所使用的CPU时间(以毫秒为单位)
initCpuCumulativeTime = pTree.getCumulativeCpuTime();
}
}
private <INKEY,INVALUE,OUTKEY,OUTVALUE>
void runNewMapper(final JobConf job,
final TaskSplitIndex splitIndex,
final TaskUmbilicalProtocol umbilical,
TaskReporter reporter
) throws IOException, ClassNotFoundException,
InterruptedException {
// 创建一个任务上下文,这样我们就可以获得相关配置类
org.apache.hadoop.mapreduce.TaskAttemptContext taskContext =
new org.apache.hadoop.mapreduce.task.TaskAttemptContextImpl(job,
getTaskID(),
reporter);
// 创建自定义的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);
// 创建输入格式化类,可自定义,默认为TextInputFormat
org.apache.hadoop.mapreduce.InputFormat<INKEY,INVALUE> inputFormat =
(org.apache.hadoop.mapreduce.InputFormat<INKEY,INVALUE>)
ReflectionUtils.newInstance(taskContext.getInputFormatClass(), job);
// 构建输入切片详细信息,包括切片的位置,切片的起始偏移量
org.apache.hadoop.mapreduce.InputSplit split = null;
split = getSplitDetails(new Path(splitIndex.getSplitLocation()),
splitIndex.getStartOffset());
LOG.info("Processing split: " + split);
// 创建RecordReader,用于实际读取数据
// NewTrackingRecordReader是对RecordReader的封装,其构造函数中的核心步骤是this.real = inputFormat.createRecordReader(split, taskContext),inputFormat默认是TextInputFormat,createRecordReader方法会创建LineRecordReader对象(实际干活的RecordReader对象)
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;
// 创建RecordWriter对象,用于map处理结果的输出
if (job.getNumReduceTasks() == 0) {
// 没有reduce,直接调用最终的输出器(即reduce的输出器),直接输出到文件
output =
new NewDirectOutputCollector(taskContext, job, umbilical, reporter);
} else {
// 有reduce,map处理结果会先被收集到环形缓冲区(正常情况都有reduce,会进入这里)
output = new NewOutputCollector(taskContext, job, umbilical, reporter);
}
// 创建map上下文
// ...
try {
// MapTask初始化的时候调用一次,默认实现会调用LineRecordReader.initialize,描述了如何从切片读取数据
input.initialize(split, mapperContext);
// Mapper.run MapTask真正运行的方法,每当LineRecordReader有键值对输入进来,就调用一次用户编写的map方法进行一次业务逻辑处理
mapper.run(mapperContext);
mapPhase.complete();
setPhase(TaskStatus.Phase.SORT);
statusUpdate(umbilical);
// MapTask.NewTrackingRecordReader.close 关闭LineRecordReader
input.close();
input = null;
// 调用MapTask.NewOutputCollector.close方法,close方法中调用MapTask.MapOutputBuffer.flush方法,flush方法会将没有溢写磁盘的数据溢写到磁盘,然后会将所有溢写文件合并成一个文件,合并之后的文件仍然是分区有序且分区内key有序
output.close(mapperContext);
output = null;
} finally {
closeQuietly(input);
closeQuietly(output, mapperContext);
}
}
输入
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();
// 打开文件并跳到split的开始位置
final FileSystem fs = file.getFileSystem(job);
fileIn = fs.open(file);
CompressionCodec codec = new CompressionCodecFactory(job).getCodec(file);
if (null!=codec) { // 是压缩文件
// ...
} else { // 非压缩文件
fileIn.seek(start);
// 用于非压缩文件的SplitLineReader,即使分隔符是多个字节,这个类也可以正确地分割文件
in = new UncompressedSplitLineReader(
fileIn, job, this.recordDelimiterBytes, split.getLength());
filePosition = fileIn;
}
// 如果这不是第一个split,我们总是丢弃第一条记录,因为我们总是(除了最后一个split)在LineRecordReader.nextKeyValue方法中多读取一行,这个是将split最后一行(可能被切割开了)拼接成完整一行的关键(上一个split多读取一行,下一个split抛弃第一行)
if (start != 0) {
start += in.readLine(new Text(), 0, maxBytesToConsume(start));
}
this.pos = start;
}
Mapper.run
public void run(Context context) throws IOException, InterruptedException {
setup(context);
try {
// 是否还有下一个键值对,最终干活的是LineRecordReader.nextKeyValue
// WrappedMapper.Context.nextKeyValue->MapContextImpl.nextKeyValue->NewTrackingRecordReader.nextKeyValue->LineRecordReader.nextKeyValue
while (context.nextKeyValue()) {
// 继续调用map方法处理,该map方法通常在用户自定义的mapper中被重写,也就是整个map阶段业务逻辑实现的地方
// 这里分析的是自定义的MyMapper.map
map(context.getCurrentKey(), context.getCurrentValue(), context);
}
} finally {
cleanup(context);
}
}
// LineRecordReader.nextKeyValue方法用于判断是否还有下一行数据并定义了按行读取数据的逻辑,一行一行读取,返回键值对类型数据,默认,key是每行起始位置的offset偏移量,value为这一行的内容
public boolean nextKeyValue() throws IOException {
// 不管这里读取多少行数据,key和value对象只创建一个,读取到的数据会设置到key/value对象中,这样可以避免创建大量的对象
if (key == null) {
key = new LongWritable();
}
// 起始位置偏移量
key.set(pos);
if (value == null) {
value = new Text();
}
int newSize = 0;
// 除了最后一个split,我们总是读取一个额外的行,in.readLine方法可以正常处理被切割开的行,in.needAdditionalRecordAfterSplit处理的是最后一行没有被切割开的情况以及多字节换行符被切割开的情况。经过这些处理不管最后一行有没有被切割开,都会多读取下一个split的一行,读取下一个split总会抛弃第一行,这样就非常优雅地解决了split的最后一行被切割开的情况(不管是物理上的block还是逻辑上的split的最后一行被切割开的情况都被解决了)
while (getFilePosition() <= end || in.needAdditionalRecordAfterSplit()) {
// 起始位置为0的话 跳过文本的UTF-8 BOM头信息
if (pos == 0) {
newSize = skipUtfByteOrderMark();
} else {
// UncompressedSplitLineReader.readLine,读取一行数据
newSize = in.readLine(value, maxLineLength, maxBytesToConsume(pos));
// 更新偏移量
pos += newSize;
}
if ((newSize == 0) || (newSize < maxLineLength)) {
break;
}
// line too long. try again
LOG.info("Skipped line of size " + newSize + " at pos " +
(pos - newSize));
}
if (newSize == 0) {
key = null;
value = null;
return false;
} else {
return true;
}
}
// UncompressedSplitLineReader.readLine
public int readLine(Text str, int maxLineLength, int maxBytesToConsume)
throws IOException {
int bytesRead = 0;
if (!finished) {
// 只允许在流报告split结束后再读取最多一条记录
if (totalBytesRead > splitLength) {
finished = true;
}
// LineReader.readLine,从InputStream中读取一行到给定的Text中,默认使用\n或\r\n作为换行符读取一行数据,也可以自定义换行符。读取一行数据的逻辑是很复杂的,里面涉及到缓冲区、自定义换行符、多字节换行符、数据内容被切割、换行符被切割等多种情况,这里不再深入讨论了,只需要知道该方法能够读取完整的一行数据即可
bytesRead = super.readLine(str, maxLineLength, maxBytesToConsume);
}
return bytesRead;
}
// MyMapper.map,Mapper.run每次调用这个自定义map方法时,传入的key/value都是同一个对象只是内容不一样(参考LineRecordReader.nextKeyValue),map方法的输出也是复用相同的对象word和one,map方法调用的次数是很多的,这样可以避免创建大量的对象,减少GC
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());
// 需求是统计各个单词出现的数量,因此输入reduce的是"单词-数量",reduce的输入就是map的输出,最终是调用MapOutputBuffer.collect将map的输出写到环形缓冲区
// WrappedMapper.Context.write->TaskInputOutputContextImpl.write->MapTask.NewOutputCollector.write->MapTask.MapOutputBuffer.collect
context.write(word, one);
}
}
输出
NewOutputCollector
NewOutputCollector(org.apache.hadoop.mapreduce.JobContext jobContext,
JobConf job,
TaskUmbilicalProtocol umbilical,
TaskReporter reporter
) throws IOException, ClassNotFoundException {
// 创建map输出收集器
collector = createSortingCollector(job, reporter);
// 获取分区数量,即ReduceTask个数
partitions = jobContext.getNumReduceTasks();
if (partitions > 1) {
// 分区数量大于1,通过反射创建分区器对象,默认为HashPartitioner(哈希分区器)
partitioner = (org.apache.hadoop.mapreduce.Partitioner<K,V>)
ReflectionUtils.newInstance(jobContext.getPartitionerClass(), job);
} else {
// 分区数量等于1,创建始终返回1号分区的分区器
partitioner = new org.apache.hadoop.mapreduce.Partitioner<K,V>() {
@Override
public int getPartition(K key, V value, int numPartitions) {
return partitions - 1;
}
};
}
}
private <KEY, VALUE> MapOutputCollector<KEY, VALUE>
createSortingCollector(JobConf job, TaskReporter reporter)
throws IOException, ClassNotFoundException {
// 上下文
MapOutputCollector.Context context =
new MapOutputCollector.Context(this, job, reporter);
// 获取MapOutputCollector类,默认为MapOutputBuffer,可自定义
Class<?>[] collectorClasses = job.getClasses(
JobContext.MAP_OUTPUT_COLLECTOR_CLASS_ATTR, MapOutputBuffer.class);
int remainingCollectors = collectorClasses.length;
Exception lastException = null;
for (Class clazz : collectorClasses) {
try {
// ...
// 通过反射创建MapOutputCollector
MapOutputCollector<KEY, VALUE> collector =
ReflectionUtils.newInstance(subclazz, job);
// 数据收集器初始化,默认为MapTask.MapOutputBuffer.init
collector.init(context);
LOG.info("Map output collector class = " + collector.getClass().getName());
return collector;
} catch (Exception e) {
// ...
}
}
// ...
}
MapOutputBuffer
public static class MapOutputBuffer<K extends Object, V extends Object>
implements MapOutputCollector<K, V>, IndexedSortable {
private int partitions; // 分区总数
private JobConf job; // job配置
private TaskReporter reporter; // 通信线程
private Class<K> keyClass; // map输出key类型
private Class<V> valClass; // map输出value类型
private RawComparator<K> comparator; // map输出key的比较器
// k/v 序列化,用于调用key/value的Writable.write方法将将key/value的字节数据写到环形缓冲区(就是内存中字节数组的拷贝)
private SerializationFactory serializationFactory;
private Serializer<K> keySerializer;
private Serializer<V> valSerializer;
// combiner
private CombinerRunner<K,V> combinerRunner;
private CombineOutputCollector<K, V> combineCollector;
// 压缩
private CompressionCodec codec;
// k/v accounting
// kvmeta只是kvbuffer中索引存储部分的一个视角,因为索引是按照整型4字节存储的,kvmeta用于操作4字节数据
private IntBuffer kvmeta; // metadata overlay on backing store
// 溢写时索引的起始位置
int kvstart; // marks origin of spill metadata
// 溢写时索引的结束位置(准备溢写时才会设置)
int kvend; // marks end of spill metadata
// 下次要写入的索引位置,kvstart、kvend、kvindex是操作kvmeta时对应的各个位置,对应到底层kvbuffer数组中需要乘以4
int kvindex; // marks end of fully serialized records
// 赤道,缓冲区的分割线,用来分割数据和元数据(数据的索引信息)
int equator; // marks origin of meta/serialization
// 溢写时序列化数据(原始kv数据)的起始位置
int bufstart; // marks beginning of spill
// 溢写时序列化数据的结束位置(准备溢写时才会设置)
int bufend; // marks beginning of collectable
// 标记序列化数据结束的位置,等于上一次bufindex,key跨越数组边界时需要用到
int bufmark; // marks end of record
// 下次要写入的序列化数据的位置
int bufindex; // marks end of collected
// 标志着应该停止的地方,即缓冲区的末尾,一般为缓冲区的长度,但是由于存储key/value序列化数据时,key不能跨越缓冲区边界(数组边界),当缓冲区末尾不能存储一个key时,则会在缓冲区头部存储这个key,缓冲区末尾会留下空隙,此时bufvoid等于缓冲区长度减去空隙,这种情况需要使用bufmark
int bufvoid; // marks the point where we should stop
// reading at the end of the buffer
// 字节数组,数据和数据的索引都会存在该数组中
byte[] kvbuffer; // main output buffer
private final byte[] b0 = new byte[0];
// 存储4字节元数据时,通过偏移量标识每个元数据分别表示什么,依次是序列化数据中的value偏移量、key偏移量、分区偏移量、value长度(key的长度等于value偏移量减去key偏移量,因此不需要单独记录)
private static final int VALSTART = 0; // val offset in acct
private static final int KEYSTART = 1; // key offset in acct
private static final int PARTITION = 2; // partition offset in acct
private static final int VALLEN = 3; // length of value
// 存储一条记录即一条序列化数据,需要存储4个元数据
private static final int NMETA = 4; // num meta ints
// 一条记录对应的元数据占用16个字节
private static final int METASIZE = NMETA * 4; // size in bytes
// spill accounting
private int maxRec; // kvmeta能存储的最大数量(最大记录数),实际是只有key没有value时的最大记录数
private int softLimit; // 溢写阈值,超出后就溢写
boolean spillInProgress; // 是否正在溢写
int bufferRemaining; // 剩余空间,以字节为单位
volatile Throwable sortSpillException = null;
int numSpills = 0; // 溢写次数,即溢写文件的数量
// 执行combine时的最小溢写数量,当溢写数量大于minSpillsForCombine并配置了combiner才会执行combine
private int minSpillsForCombine;
private IndexedSorter sorter; // 排序方法,默认使用快速排序,可配置
final ReentrantLock spillLock = new ReentrantLock();
final Condition spillDone = spillLock.newCondition(); // 用于溢写完成
final Condition spillReady = spillLock.newCondition(); // 用于准备溢写
final BlockingBuffer bb = new BlockingBuffer();
volatile boolean spillThreadRunning = false; // 用于标识溢写线程是否在执行
final SpillThread spillThread = new SpillThread(); // 溢写线程
private FileSystem rfs; // 本地文件系统,用于溢写文件到本地
// Counters
private Counters.Counter mapOutputByteCounter; // 处理一条map数据计数增加输出的序列化数据的字节大小,即map输出的总字节大小
private Counters.Counter mapOutputRecordCounter; // 处理一条map数据计数增加1次,即map执行的总次数
private Counters.Counter fileOutputByteCounter;
// 记录所有的溢写文件索引(元数据),当内存中的溢写文件索引大小(totalIndexCacheMemory)超过阈值(indexCacheMemoryLimit)时,接下来的溢写文件索引将直接写入磁盘;在map阶段的后期合并溢写文件(mergeParts方法)时会将写入磁盘的溢写文件索引对应的文件恢复到内存中(恢复成SpillRecord添加到indexCacheList中)
final ArrayList<SpillRecord> indexCacheList =
new ArrayList<SpillRecord>();
private int totalIndexCacheMemory; // 在内存中已使用的溢写文件索引大小
private int indexCacheMemoryLimit; // 内存中溢写文件索引大小的限制,默认1Mb,可配置
private static final int INDEX_CACHE_MEMORY_LIMIT_DEFAULT = 1024 * 1024;
private MapTask mapTask;
private MapOutputFile mapOutputFile;
private Progress sortPhase; // 排序阶段
private Counters.Counter spilledRecordsCounter; // spill计数器
// ...
public void init(MapOutputCollector.Context context
) throws IOException, ClassNotFoundException {
job = context.getJobConf(); // job配置
reporter = context.getReporter(); // 通信线程
mapTask = context.getMapTask();
mapOutputFile = mapTask.getMapOutputFile();
sortPhase = mapTask.getSortPhase(); // 排序阶段
spilledRecordsCounter = reporter.getCounter(TaskCounter.SPILLED_RECORDS); // spill计数器
partitions = job.getNumReduceTasks(); // 分区总数
rfs = ((LocalFileSystem)FileSystem.getLocal(job)).getRaw(); // 本地文件系统对象
// 环形缓冲区达到80%(可配置)就会溢写
final float spillper =
job.getFloat(JobContext.MAP_SORT_SPILL_PERCENT, (float)0.8);
// 缓冲区大小,默认100Mb
final int sortmb = job.getInt(MRJobConfig.IO_SORT_MB,
MRJobConfig.DEFAULT_IO_SORT_MB);
// 索引缓存大小,默认1Mb,可配置
indexCacheMemoryLimit = job.getInt(JobContext.INDEX_CACHE_MEMORY_LIMIT,
INDEX_CACHE_MEMORY_LIMIT_DEFAULT);
// ...
// 一些校验
// 获取排序方法,默认使用快速排序,可配置
sorter = ReflectionUtils.newInstance(job.getClass(
MRJobConfig.MAP_SORT_CLASS, QuickSort.class,
IndexedSorter.class), job);
// 将mb转化成byte,sortmb<<20就是sortmb*104*1024
int maxMemUsage = sortmb << 20;
// 缓冲区大小调整为16字节的整数倍
maxMemUsage -= maxMemUsage % METASIZE;
// 创建环形缓冲区,实际是一个字节数组
kvbuffer = new byte[maxMemUsage];
bufvoid = kvbuffer.length; // 标志着应该停止的地方,即缓冲区的末尾(缓冲区的长度)
// kvmeta只是kvbuffer中索引存储部分的一个视角,因为索引是按照整型4字节存储的,kvmeta用于操作4字节数据
kvmeta = ByteBuffer.wrap(kvbuffer)
.order(ByteOrder.nativeOrder())
.asIntBuffer();
// 设置赤道(分割线)为0(同时设置了kvindex),即索引(元数据)与数据的分割线
setEquator(0);
// bufstart 溢写时序列化数据的起始位置,bufend 溢写时序列化数据的结束位置(准备溢写时才会设置),bufindex 下次要写入的序列化数据的位置
bufstart = bufend = bufindex = equator;
// kvstart 溢写时索引的起始位置,kvend 溢写时索引的结束位置(准备溢写时才会设置),kvindex 下次要写入的索引位置
kvstart = kvend = kvindex;
maxRec = kvmeta.capacity() / NMETA; // 计算kvmeta能存储的最大数量(最大记录数),实际是只有key没有value时的最大记录数
softLimit = (int)(kvbuffer.length * spillper); // 溢写阈值,超出后就溢写
bufferRemaining = softLimit; // 剩余空间,以字节为单位
// ...
comparator = job.getOutputKeyComparator(); // map输出key的比较器,用于对map的结果进行排序
// k/v 序列化
// ...
// output 计数器
// ...
// 压缩
// ...
// combiner
// ...
spillInProgress = false; // 是否正在溢写
// 执行combine时的最小溢写数量,当溢写数量大于minSpillsForCombine并配置了combiner才会执行combine
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();
}
// ...
}
// 将键、值序列化到中间存储(内存缓冲区)。当此方法返回时,kvindex必须引用足够的未使用存储来存储一个METADATA
public synchronized void collect(K key, V value, final int partition
) throws IOException {
// 一些校验
// ...
bufferRemaining -= METASIZE; // 可用字节先减去元数据占用的字节
if (bufferRemaining <= 0) {
// 如果溢写线程未运行(在等待,没有正在执行溢写)并且已达到软限制,则开始溢写
spillLock.lock();
try {
do {
if (!spillInProgress) {
// kvindex、kvend是以4字节为单位的,操作底层数组时需要乘4
final int kvbidx = 4 * kvindex;
final int kvbend = 4 * kvend;
// 序列化的、未溢写的字节总是位于kvindex和bufindex之间,横跨赤道。注意,由重置创建的任何空白空间都必须包含在“已使用”字节中
final int bUsed = distanceTo(kvbidx, bufindex);
final boolean bufsoftlimit = bUsed >= softLimit; // 已使用的字节是否大于等于溢写阈值
if ((kvbend + METASIZE) % kvbuffer.length !=
equator - (equator % METASIZE)) { // 条件满足说明已经发生了溢写(进行溢写时,kvend会调整,equator会重新划分),而程序能够进来,说明溢写操作已经结束
resetSpill(); // 溢写完成,回收空间
// 释放锁之前,计算剩余的可用空间,如果是distanceTo(bufindex, kvbidx) - 2 * METASIZE较小,说明是阈值设置的过大(阈值之外的空间小于2 * METASIZE),此时应该始终保留至少2 * METASIZE空间(这样依然可以保证溢写与缓冲区写入同时进行,个人理解)。后面的- METASIZE,表示减去本次写入的元数据空间(与上面的bufferRemaining -= METASIZE对应,即回收空间后重新计算出bufferRemaining再减去METASIZE)
bufferRemaining = Math.min(
distanceTo(bufindex, kvbidx) - 2 * METASIZE,
softLimit - bUsed) - METASIZE;
continue; // 因为while (false),这个continue没有用
} else if (bufsoftlimit && kvindex != kvend) { // 已使用空间大于阈值且kvindex != kvend(这个后面的判断不知道有什么用,可能是多线程环境必须的???),则进行溢写
// 为溢写做准备(设置kvend、bufend、spillInProgress),然后唤醒溢写线程执行溢写
// 下面会设置新的赤道,map向缓冲区写入数据并没有被阻塞
startSpill();
// 根据已经写入的kv得出每个record的平均长度
final int avgRec = (int)
(mapOutputByteCounter.getCounter() /
mapOutputRecordCounter.getCounter());
// 至少保留一半的分割缓冲区用于序列化数据
final int distkvi = distanceTo(bufindex, kvbidx); // 剩余空间大小,
// 计算新的赤道(分割线),下面计算应该存储元数据的空间大小。为了有更多的空间存储kv,则最多拿出distkvi的一半来存储元数据,并且利用avgRec估算distkvi能存放多少个record和meta对,根据record和meta对的个数估算meta所占空间的大小,从distkvi/2和估算的meta所占空间的大小中取最小值;又因为distkvi中最少得存放一个meta,所占空间为METASIZE,equator与kvindex之间可能会有空隙,最大空隙为METASIZE - 1,因此元数据空间不能小于2 * METASIZE - 1。bufindex加上计算出来的元数据空间再对kvbuffer.length取余即为新赤道,取余是为了保证新赤道在缓冲区中(因为是环形缓冲区)
final int newPos = (bufindex +
Math.max(2 * METASIZE - 1,
Math.min(distkvi / 2,
distkvi / (METASIZE + avgRec) * METASIZE)))
% kvbuffer.length;
setEquator(newPos); // 设置新赤道(同时设置了kvindex),可能会产生空隙
bufmark = bufindex = newPos; // 设置bufmark和bufindex等于新赤道
final int serBound = 4 * kvend; // 元数据在底层数组中的结束位置
// 释放锁之前,计算剩余的可用空间,取可用元数据空间、可用序列化空间和阈值的最小值,然后再减去2 * METASIZE(当前record要写入buffer,元数据占用一个METASIZE,equator与kvindex之间可能会有空隙,再减去一个METASIZE)
bufferRemaining = Math.min(
// metadata max
distanceTo(bufend, newPos),
Math.min(
// serialization max
distanceTo(newPos, serBound),
// soft limit
softLimit)) - 2 * METASIZE;
}
}
} while (false); // while (false)在这里没有意义
} finally {
spillLock.unlock();
}
}
try {
// 将key字节序列化到缓冲区
int keystart = bufindex;
keySerializer.serialize(key);
if (bufindex < keystart) {
// key跨越数组边界,则空出一部分跳过边界,使key在数组中是连续的
bb.shiftBufferedKey();
keystart = 0;
}
// 将value字节序列化到缓冲区
final int valstart = bufindex;
valSerializer.serialize(value);
// 记录的长度可能为零,即序列化器将不执行写操作。要确保检查边界条件并维护kvindex不变性,请对缓冲区执行零长度写入。监视此操作的逻辑可以移到collect中,但这样更简洁,成本更低。目前,这是可以接受的。(当上面计算的bufferRemaining<=0且spillInProgress=true,则不会回收空间;接着当key和value都为空,不会执行写入,也不会回收空间;当这两个条件都满足时,执行到这里bufferRemaining<=0且没有回收空间,此时需要回收空间,否则没有足够的空间供下面的元数据写入,因为下面元数据是通过kvmeta直接写入的并没有检查边界情况)
bb.write(b0, 0, 0);
// 上面的key/value序列化、bb.write(b0, 0, 0),最终都是调用MapTask.MapOutputBuffer.Buffer#write(byte[], int, int)
int valend = bb.markRecord(); // 该记录必须标记在前一次写入之后,因为该记录的元数据尚未写入
// 统计map执行的总次数和map输出的总字节大小,在设置新赤道时使用
mapOutputRecordCounter.increment(1);
mapOutputByteCounter.increment(
distanceTo(keystart, valend, bufvoid));
// 写入元数据到底层字节数组
kvmeta.put(kvindex + PARTITION, partition);
kvmeta.put(kvindex + KEYSTART, keystart);
kvmeta.put(kvindex + VALSTART, valstart);
kvmeta.put(kvindex + VALLEN, distanceTo(valstart, valend));
// 更新kvindex
kvindex = (kvindex - NMETA + kvmeta.capacity()) % kvmeta.capacity();
} catch (MapBufferTooSmallException e) {
LOG.info("Record too large for in-memory buffer: " + e.getMessage());
spillSingleRecord(key, value, partition); // 记录太大直接溢写磁盘
mapOutputRecordCounter.increment(1);
return;
}
}
// 设置元数据和序列化数据分隔的点。元索引与缓冲区对齐,因此元数据永远不会跨越循环缓冲区的末端
private void setEquator(int pos) {
equator = pos; // 设置赤道,即分割线
// 在第一个条目之前设置索引,在元边界对齐,因此equator与kvindex之间可能会有空隙(空隙小于METASIZE)
final int aligned = pos - (pos % METASIZE);
// 将其中一个操作数强制转换为long以避免整数溢出
// kvindex是下次要插入的索引位置,kvindex在环形缓冲区中逆时针增长,表现在字节数组中往索引减少的方向,kvindex用于操作4字节索引数据
kvindex = (int)
(((long)aligned - METASIZE + kvbuffer.length) % kvbuffer.length) / 4;
LOG.info("(EQUATOR) " + pos + " kvi " + kvindex +
"(" + (kvindex * 4) + ")");
}
// 溢写已经完成,因此将缓冲区数据和元数据下标设置等于新赤道,以腾出空间继续收集。注意,当kvindex==kvend==kvstart时,缓冲区为空
private void resetSpill() {
final int e = equator;
bufstart = bufend = e;
// 参考setEquator方法,equator与kvindex之间可能会有空隙
final int aligned = e - (e % METASIZE);
// 将开始/结束设置为指向第一个元数据下标
kvstart = kvend = (int)
(((long)aligned - METASIZE + kvbuffer.length) % kvbuffer.length) / 4;
LOG.info("(RESET) equator " + e + " kv " + kvstart + "(" +
(kvstart * 4) + ")" + " kvi " + kvindex + "(" + (kvindex * 4) + ")");
}
// 准备溢写
private void startSpill() {
assert !spillInProgress;
kvend = (kvindex + NMETA) % kvmeta.capacity(); // 设置元数据结束的位置
bufend = bufmark; // 设置序列化数据结束的位置
spillInProgress = true; // 设置正在溢写
LOG.info("Spilling map output");
LOG.info("bufstart = " + bufstart + "; bufend = " + bufmark +
"; bufvoid = " + bufvoid);
LOG.info("kvstart = " + kvstart + "(" + (kvstart * 4) +
"); kvend = " + kvend + "(" + (kvend * 4) +
"); length = " + (distanceTo(kvend, kvstart,
kvmeta.capacity()) + 1) + "/" + maxRec);
spillReady.signal(); // 唤醒溢写线程,MapTask.MapOutputBuffer.SpillThread.run中spillReady.await()这里被唤醒
}
// 排序并溢写
private void sortAndSpill() throws IOException, ClassNotFoundException,
InterruptedException {
// 将输出文件的长度近似为缓冲区的长度+分区的标头长度,实际长度比这大,输出文件中包括每个key/value的长度和真实的数据,key/value的长度是压缩存储的,占用的空间不是很大。计算出来的size用于创建临时文件全路径(使用剩余空间大于size的目录)
final long size = distanceTo(bufstart, bufend, bufvoid) +
partitions * APPROX_HEADER_LENGTH;
FSDataOutputStream out = null; // 溢写文件输出流
FSDataOutputStream partitionOut = null; // 是对上面out的包装
try {
// 记录溢写文件索引,记录了溢写文件中每个分区的大小和开始位置
final SpillRecord spillRec = new SpillRecord(partitions);
final Path filename =
mapOutputFile.getSpillFileForWrite(numSpills, size);
out = rfs.create(filename); // 创建溢写文件
// 计算环形缓冲区的起始位置和结束位置(不包括),相对于底层数组的下标,kvend和kvstart已经除以过NMETA,这里再除以NMETA(NMETA等于4),因为一条元数据是4个每个占用4字节,这样两次除以NMETA之后相当于变成了每一条元数的下标
final int mstart = kvend / NMETA;
final int mend = 1 + // 这里加1,是为了不包括结束位置
(kvstart >= kvend
? kvstart
: kvmeta.capacity() + kvstart) / NMETA;
// 对环形缓冲区中的元数据进行排序(默认是快速排序),每条记录的元数据占用的空间是固定的,对元数据进行排序只需要交换底层数组中的元数据即可(比较方便,直接对序列化数据进行排序则比较麻烦)。QuickSort.sort方法最终会调用MapTask.MapOutputBuffe.compare完成排序,compare方法会先按照分区排序再按照key排序。溢写文件时按照元数据的顺序找到序列化数据依次写入文件,这样溢写的文件就是分区有序且分区内key有序
sorter.sort(MapOutputBuffer.this, mstart, mend, reporter);
int spindex = mstart;
final IndexRecord rec = new IndexRecord(); // 记录分区的索引,最终被合并到溢写文件索引spillRec
// DataInputBuffer是一个可重复使用的java.io.DataInput实现,它从内存缓冲区中读取数据。这节省了每次读取数据时创建新的DataInputStream和ByteArrayInputStream的内存。InMemValBytes继承自DataInputBuffer,用于处理value跨越内存缓冲区边界的情况,key不会跨越内存缓冲区,所以下面的key直接使用DataInputBuffer
final InMemValBytes value = new InMemValBytes();
for (int i = 0; i < partitions; ++i) { // 处理每个分区
IFile.Writer<K, V> writer = null;
try {
long segmentStart = out.getPos();
// 用于在溢写中间文件时包装辅助对象,设置SpillCallBackInjector有助于:添加回调以捕获溢写文件的路径、启用中间加密时验证加密。这里一般用不到
partitionOut =
IntermediateEncryptedStream.wrapIfNecessary(job, out, false,
filename);
writer = new Writer<K, V>(job, partitionOut, keyClass, valClass, codec,
spilledRecordsCounter);
if (combinerRunner == null) { // 没有combiner
DataInputBuffer key = new DataInputBuffer();
// 遍历内存缓冲区中当前分区的元数据(已排序),取出每条元数据对应的key/value溢写磁盘
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); // 设置key所在的数组(内存缓冲区)、起始位置、长度
getVBytesForOffset(kvoff, value); // 设置value所在的数组(没有跨越边界是内存缓冲区)、起始位置、长度
// 调用IFile.Writer.append方法,将key/value溢写磁盘;先溢写key、value的长度(长度会进行压缩,-112 <= i <= 127时i只占用一个字节),接着溢写key、value
writer.append(key, value);
++spindex;
}
} else { // 有combiner
int spstart = spindex; // 当前分区元数据开始的位置
// 遍历内存缓冲区中当前分区的元数据(已排序),遍历结束时spindex是当前分区元数据结束的位置
while (spindex < mend &&
kvmeta.get(offsetFor(spindex % maxRec)
+ PARTITION) == i) {
++spindex;
}
// 注意:如果一个分区的记录少于某个阈值,我们希望避免使用combiner
if (spstart != spindex) {
// 设置combine(reduce)执行的结果写入到writer(map阶段的输出)
combineCollector.setWriter(writer);
// 执行combine(reduce)的数据源(迭代器),MRResultIterator可以从环形缓冲区中迭代出指定元数据区间对应的key/value(有序)
RawKeyValueIterator kvIter =
new MRResultIterator(spstart, spindex);
// 执行combine(reduce)执行的结果会写入到writer
combinerRunner.combine(kvIter, combineCollector);
}
}
// 调用IFile.Writer.close方法关闭writer,该方法中会写入两个1字节的-1作为结束标志,然后更新decompressedBytesWritten(实际写入的所有字节数不包括校验和);接着会执行checksumOut.finish()写入校验和(一般是4个字节),然后更新compressedBytesWritten(实际写入的所有字节数包括校验和)
writer.close();
if (partitionOut != out) {
partitionOut.close();
partitionOut = null;
}
// 更新分区索引,最终被合并到溢写文件索引spillRec
rec.startOffset = segmentStart; // 分区起始位置
// CryptoUtils.cryptoPadding用于数据加密后多出来的长度(一般用不到)
// writer.getRawLength方法返回decompressedBytesWritten,是分区原始数据长度不包括校验和
rec.rawLength = writer.getRawLength() + CryptoUtils.cryptoPadding(job);
// writer.getCompressedLength方法返回compressedBytesWritten,是分区原始数据长度加上校验和长度
rec.partLength = writer.getCompressedLength() + CryptoUtils.cryptoPadding(job);
// 溢写文件索引中有一个ByteBuffer,大小等于分区数量乘以24,可以存储3个long,分别是分区起始位置、分区原始数据长度、分区原始数据长度加上校验和长度,各个分区索引从起始位置向后依次写入ByteBuffer;这里将分区索引写入到溢写文件索引,即将上面的三个值写入到ByteBuffer相应的位置上
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; // 更新溢写次数
} finally {
if (out != null) out.close();
if (partitionOut != null) {
partitionOut.close();
}
}
}
// 溢写之前的缓冲区数据排序会调用这个方法,先按照分区排序,再按照key排序
@Override
public int compare(final int mi, final int mj) {
final int kvi = offsetFor(mi % maxRec);
final int kvj = offsetFor(mj % maxRec);
// 取出分区
final int kvip = kvmeta.get(kvi + PARTITION);
final int kvjp = kvmeta.get(kvj + PARTITION);
// 按照分区排序
if (kvip != kvjp) {
return kvip - kvjp;
}
// 使用map输出key比较器对key进行排序,该比较器按照字节进行排序
return comparator.compare(kvbuffer,
kvmeta.get(kvi + KEYSTART), // key起始位置
kvmeta.get(kvi + VALSTART) - kvmeta.get(kvi + KEYSTART), // key长度
kvbuffer,
kvmeta.get(kvj + KEYSTART),
kvmeta.get(kvj + VALSTART) - kvmeta.get(kvj + KEYSTART));
}
// 内部类管理序列化记录到磁盘的溢写,真正执行溢写的是其包装的Buffer
protected class BlockingBuffer extends DataOutputStream {
public BlockingBuffer() {
super(new Buffer());
}
// 标记记录结束。用于在key跨越数组边界时使用,最终使key是连续的
public int markRecord() {
bufmark = bufindex;
return bufindex;
}
// 设置从最后一个标记到可写缓冲区结束的位置,然后重写最后一个标记和kvindex之间的数据。这将处理键环绕缓冲区的特殊情况。如果要将key传递给RawComparator,那么它必须在缓冲区中连续。这将缓冲区中的数据复制回自身,但从缓冲区的开头开始。请注意,只有在检测到这种情况后才应立即调用此方法。在任何其他时间调用它都是未定义的,并且可能导致数据丢失或损坏
protected void shiftBufferedKey() throws IOException {
// spillLock unnecessary; both kvend and kvindex are current
int headbytelen = bufvoid - bufmark;
bufvoid = bufmark;
final int kvbidx = 4 * kvindex;
final int kvbend = 4 * kvend;
// 下面判断移动key之后是否有足够的空间存储,相当于将key从0下标处开始存储,因此剩余空间应该为distanceTo(0, kvbidx),这里不知道为什么???
final int avail =
Math.min(distanceTo(0, kvbidx), distanceTo(0, kvbend));
// 此时bufindex已经跨越了数组边界(数组末尾),需要移动key使其连续,则需要跳过数组末尾headbytelen个字节,因此这里判断是否有足够的空间存储headbytelen个字节
if (bufindex + headbytelen < avail) {
// 有足够的空间,分两次copy,先将首部的部分key复制到headbytelen的位置,然后将末尾的部分key复制到首部,移动bufindex,重置bufferRemaining的值
System.arraycopy(kvbuffer, 0, kvbuffer, headbytelen, bufindex);
System.arraycopy(kvbuffer, bufvoid, kvbuffer, 0, headbytelen);
bufindex += headbytelen;
bufferRemaining -= kvbuffer.length - bufvoid; // 这里应该等价于bufferRemaining-=headbytelen???
} else {
// 没有足够的空间,则先将首部的部分key写入keytmp中,然后调用Buffer.write分两次写入。从0位置先写key在数组尾部的部分,再写key在数组首部的部分,即从0位置重新写入这个完整的key
byte[] keytmp = new byte[bufindex];
System.arraycopy(kvbuffer, 0, keytmp, 0, bufindex);
bufindex = 0;
out.write(kvbuffer, bufmark, headbytelen); // 从0位置,先写入key在数组尾部的部分
out.write(keytmp); // 接着写入key在数组首部的部分
}
}
}
public class Buffer extends OutputStream {
private final byte[] scratch = new byte[1];
@Override
public void write(int v)
throws IOException {
scratch[0] = (byte)v;
write(scratch, 0, 1);
}
/**
* 尝试将字节序列写入缓冲区。如果溢写线程正在运行并且无法写入,则此方法将阻塞
* @throws MapBufferTooSmallException 如果记录太大,无法反序列化到缓冲区中,则抛出异常
*/
@Override
public void write(byte b[], int off, int len)
throws IOException {
// 必须始终验证不变量,即在kvindex之外至少有METASIZE字节可用,即使len==0也是如此
bufferRemaining -= len;
if (bufferRemaining <= 0) {
// 写入这些字节可能会耗尽可用的缓冲区空间或将缓冲区填充到软限制(阈值)。检查是否有必要溢写或阻塞
boolean blockwrite = false;
spillLock.lock();
try {
do {
checkSpillException();
final int kvbidx = 4 * kvindex;
final int kvbend = 4 * kvend;
// ser distance to key index
final int distkvi = distanceTo(bufindex, kvbidx); // 整个缓冲区的剩余空间大小
// ser distance to spill end index
final int distkve = distanceTo(bufindex, kvbend);
// 如果kvindex比kvend更接近,那么溢写既不在进行中,也没有完成,则锁定后重置。只有当没有足够的空间来完成当前写入、写入此记录的元数据以及写入下一个记录的元数据时,写入才应阻塞。如果kvend更接近,那么如果针对元数据或当前数据写入的空间太小,则写入应该被阻塞。请注意,collect 通过零长度写入确保其元数据需求。(这里的逻辑是明确的,但是多线程中kvindex、kvend、bufindex的修改使得条件满足还不了解???)
blockwrite = distkvi <= distkve
? distkvi <= len + 2 * METASIZE
: distkve <= len || distanceTo(bufend, kvbidx) < 2 * METASIZE;
if (!spillInProgress) { // 溢写线程没有正在执行,则执行
if (blockwrite) {
if ((kvbend + METASIZE) % kvbuffer.length !=
equator - (equator % METASIZE)) {
// 溢写完成,回收空间
resetSpill(); // resetSpill doesn't move bufindex, kvindex
// 这里的逻辑参考,collect方法中resetSpill()后面的逻辑(都是溢写完成,回收空间)
bufferRemaining = Math.min(
distkvi - 2 * METASIZE,
softLimit - distanceTo(kvbidx, bufindex)) - len;
continue;
}
// 我们有可以溢写的记录,只在阻塞时溢写
if (kvindex != kvend) {
startSpill();
// 此次写入被阻塞,等待刚刚启动的溢写完成。我们没有重新定位标记并复制部分记录,而是将记录起点设为新赤道
setEquator(bufmark);
} else {
// 没有缓冲的记录(即此时缓冲区为空),而且这条记录太大,无法写入缓冲区。必须直接从collect处溢写(即collect方法中捕获了这里抛出的异常,直接溢写)
final int size = distanceTo(bufstart, bufindex) + len;
setEquator(0);
bufstart = bufend = bufindex = equator;
kvstart = kvend = kvindex;
bufvoid = kvbuffer.length;
throw new MapBufferTooSmallException(size + " bytes");
}
}
}
if (blockwrite) { // 阻塞,等待溢写线程溢写
try {
while (spillInProgress) {
reporter.progress();
spillDone.await();
}
} catch (InterruptedException e) {
throw new IOException(
"Buffer interrupted while waiting for the writer", e);
}
}
} while (blockwrite); // 需要阻塞的,则继续循环等待
} finally {
spillLock.unlock();
}
}
// 在这里,我们知道我们有足够的空间来写
// write方法将key/value写入kvbuffer中,如果bufindex+len超过了bufvoid(写入的内容跨越了数组边界),则将写入的内容分开存储,将一部分写入bufindex和bufvoid之间,然后重置bufindex,再将剩余的部分写入,这里不区分key和value,写入key之后会在collect方法中判断bufindex < keystart,当bufindex小时,则key被分开存储,执行bb.shiftBufferedKey(),value则直接写入,不用判断是否被分开存储,key不能分开存储是因为要对key进行排序
if (bufindex + len > bufvoid) {
final int gaplen = bufvoid - bufindex;
System.arraycopy(b, off, kvbuffer, bufindex, gaplen);
len -= gaplen;
off += gaplen;
bufindex = 0;
}
System.arraycopy(b, off, kvbuffer, bufindex, len);
bufindex += len;
}
}
// flush方法会将没有溢写磁盘的数据溢写到磁盘,然后会将所有溢写文件合并成一个文件,合并之后的文件仍然是分区有序且分区内key有序
public void flush() throws IOException, ClassNotFoundException,
InterruptedException {
LOG.info("Starting flush of map output");
if (kvbuffer == null) { // 保证flush方法只执行一次
LOG.info("kvbuffer is null. Skipping flush.");
return;
}
spillLock.lock();
try {
while (spillInProgress) { // 等待正在进行中的溢写完成
reporter.progress();
spillDone.await();
}
checkSpillException(); // 检查溢写过程中是否发生过错误
final int kvbend = 4 * kvend;
if ((kvbend + METASIZE) % kvbuffer.length !=
equator - (equator % METASIZE)) {
// 溢写已经完成(上次溢写),回收空间(参考collect方法),回收空间也是为下次溢写做准备
resetSpill();
}
if (kvindex != kvend) { // 缓冲区中还有需要溢写的数据(这个MapTask的最后一次缓冲区溢写)
kvend = (kvindex + NMETA) % kvmeta.capacity();
bufend = bufmark;
// ...
sortAndSpill();
}
} catch (InterruptedException e) {
throw new IOException("Interrupted while waiting for the writer", e);
} finally {
spillLock.unlock();
}
assert !spillLock.isHeldByCurrentThread();
// 关闭溢写线程并等待其退出。由于前面的内容确保了它完成了工作(sortAndSpill没有抛出异常),我们选择使用中断而不是设置标志
try {
spillThread.interrupt(); // 打断溢写线程让其停止
spillThread.join(); // 等待溢写线程停止
} catch (InterruptedException e) {
throw new IOException("Spill failed", e);
}
kvbuffer = null; // 在合并前释放排序缓冲区
mergeParts(); // 合并所有的缓冲区溢写文件
Path outputPath = mapOutputFile.getOutputFile();
fileOutputByteCounter.increment(rfs.getFileStatus(outputPath).getLen());
// 如有必要,使输出足够允许shuffling(这里就是给输出文件设置权限,一般不需要)
if (!SHUFFLE_OUTPUT_PERM.equals(
SHUFFLE_OUTPUT_PERM.applyUMask(FsPermission.getUMask(job)))) {
Path indexPath = mapOutputFile.getOutputIndexFile();
rfs.setPermission(outputPath, SHUFFLE_OUTPUT_PERM);
rfs.setPermission(indexPath, SHUFFLE_OUTPUT_PERM);
}
}
// 合并所有的缓冲区溢写文件
private void mergeParts() throws IOException, InterruptedException,
ClassNotFoundException {
// 获取最终输出/索引文件的大致大小
long finalOutFileSize = 0;
long finalIndexFileSize = 0;
final Path[] filename = new Path[numSpills]; // 溢写文件路径列表
final TaskAttemptID mapId = getTaskID();
for(int i = 0; i < numSpills; i++) {
filename[i] = mapOutputFile.getSpillFile(i); // 溢写文件全路径
finalOutFileSize += rfs.getFileStatus(filename[i]).getLen(); // 统计所有溢写文件大小
}
if (numSpills == 1) { // 溢写文件是最终的输出
Path indexFileOutput = // 最终的索引文件名称,output/file.out.index
mapOutputFile.getOutputIndexFileForWriteInVolume(filename[0]);
// 直接将溢写文件修改为最终的输出文件(重命名),output/spill0.out -> output/file.out
sameVolRename(filename[0],
mapOutputFile.getOutputFileForWriteInVolume(filename[0]));
if (indexCacheList.size() == 0) { // 溢写文件索引已经在磁盘上,直接修改文件名为indexFileOutput
Path indexFilePath = mapOutputFile.getSpillIndexFile(0);
IntermediateEncryptedStream.validateSpillIndexFile(
indexFilePath, job);
sameVolRename(indexFilePath, indexFileOutput);
} else { // 将溢写文件索引写到磁盘上,文件名为indexFileOutput
indexCacheList.get(0).writeToFile(indexFileOutput, job);
}
IntermediateEncryptedStream.addSpillIndexFile(indexFileOutput, job);
sortPhase.complete();
return;
}
// 读取磁盘中溢写文件索引对应的文件(如果有的话),恢复到内存中
for (int i = indexCacheList.size(); i < numSpills; ++i) {
Path indexFileName = mapOutputFile.getSpillIndexFile(i);
IntermediateEncryptedStream.validateSpillIndexFile(indexFileName, job);
indexCacheList.add(new SpillRecord(indexFileName, job));
}
// 对长度进行校正,以包括每个分区的序列文件头长度
finalOutFileSize += partitions * APPROX_HEADER_LENGTH;
finalIndexFileSize = partitions * MAP_OUTPUT_INDEX_RECORD_LENGTH; // 合并之后的索引文件长度
Path finalOutputFile =
mapOutputFile.getOutputFileForWrite(finalOutFileSize); // 最终输出文件的全路径
Path finalIndexFile =
mapOutputFile.getOutputIndexFileForWrite(finalIndexFileSize); // 最终输出文件对应的索引文件的全路径
IntermediateEncryptedStream.addSpillIndexFile(finalIndexFile, job);
FSDataOutputStream finalOut = rfs.create(finalOutputFile, true, 4096); // 最终单个输出文件的输出流
FSDataOutputStream finalPartitionOut = null;
if (numSpills == 0) {
// 创建伪文件
// ...
return;
}
{
sortPhase.addPhases(partitions); // 将排序阶段划分为子阶段
IndexRecord rec = new IndexRecord(); // 分区索引,最终会合并到溢写文件索引中
final SpillRecord spillRec = new SpillRecord(partitions); // 溢写文件索引
for (int parts = 0; parts < partitions; parts++) { // 合并文件时,按照分区顺序依次合并每个分区的数据
// 创建要合并的Segment
List<Segment<K,V>> segmentList =
new ArrayList<Segment<K, V>>(numSpills);
for(int i = 0; i < numSpills; i++) {
IndexRecord indexRecord = indexCacheList.get(i).getIndex(parts);
// Segment封装了对溢写文件的读(这里会读指定的分区),其reader字段是IFile.Reader,可以读取溢写文件时IFile.Writer写入的数据,即可以读取每个key/value、识别读取到分区的末尾、比较校验和等
Segment<K,V> s =
new Segment<K,V>(job, rfs, filename[i], indexRecord.startOffset,
indexRecord.partLength, codec, true);
segmentList.add(i, s);
if (LOG.isDebugEnabled()) {
LOG.debug("MapId=" + mapId + " Reducer=" + parts +
"Spill =" + i + "(" + indexRecord.startOffset + "," +
indexRecord.rawLength + ", " + indexRecord.partLength + ")");
}
}
int mergeFactor = job.getInt(MRJobConfig.IO_SORT_FACTOR,
MRJobConfig.DEFAULT_IO_SORT_FACTOR); // 合并因子默认为10,即每次最多合并10个文件,然后接着合并中间文件
// 仅当存在中间合并时(溢写文件大于合并因子)对段进行排序,按照段的大小(溢写文件中相应分区大小)升序排序
boolean sortSegments = segmentList.size() > mergeFactor;
// merge,最终调用Merger.MergeQueue.merge方法
@SuppressWarnings("unchecked")
RawKeyValueIterator kvIter = Merger.merge(job, rfs,
keyClass, valClass, codec,
segmentList, mergeFactor,
new Path(mapId.toString()),
job.getOutputKeyComparator(), reporter, sortSegments,
null, spilledRecordsCounter, sortPhase.phase(),
TaskType.MAP);
// 将合并输出写入磁盘(与Merger.MergeQueue.merge方法中合并产生中间文件是类似的);这里是当前分区的最终合并(Merger.MergeQueue.merge中可能会有中间合并)
long segmentStart = finalOut.getPos(); // 合并文件中当前分区的起始位置,最终用于更新合并文件索引
finalPartitionOut = IntermediateEncryptedStream.wrapIfNecessary(job,
finalOut, false, finalOutputFile);
Writer<K, V> writer =
new Writer<K, V>(job, finalPartitionOut, keyClass, valClass, codec,
spilledRecordsCounter);
// 最终合并与中间合并的最大区别在这里,中间合并combinerRunner不会参与,最终合并combinerRunner不为null的话会参与
if (combinerRunner == null || numSpills < minSpillsForCombine) {
Merger.writeFile(kvIter, writer, reporter, job);
} else {
// 设置combine(reduce)执行的结果写入到writer(最终的合并文件,map阶段的最终输出)
combineCollector.setWriter(writer);
// 执行combine(reduce)执行的结果会写入到writer
combinerRunner.combine(kvIter, combineCollector);
}
writer.close();
if (finalPartitionOut != finalOut) {
finalPartitionOut.close();
finalPartitionOut = null;
}
sortPhase.startNextPhase();
// 更新当前分区合并之后的溢写索引
rec.startOffset = segmentStart;
rec.rawLength = writer.getRawLength() + CryptoUtils.cryptoPadding(job);
rec.partLength = writer.getCompressedLength() + CryptoUtils.cryptoPadding(job);
spillRec.putIndex(rec, parts); // 更新最终合并文件的索引
}
// 最终合并(溢写)文件对应的索引写到磁盘文件
spillRec.writeToFile(finalIndexFile, job);
finalOut.close();
if (finalPartitionOut != null) {
finalPartitionOut.close();
}
for(int i = 0; i < numSpills; i++) { // 删除合并之前的所有溢写文件
rfs.delete(filename[i],true);
}
}
}
// 溢写线程
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
bufvoid = kvbuffer.length;
}
// kvstart和bufstart的修改,好像没什么用???
kvstart = kvend;
bufstart = bufend;
spillInProgress = false; // 标记本次溢写结束
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
spillLock.unlock();
spillThreadRunning = false;
}
}
}
}
Merger
// Merger是Map和Reduce任务用于合并其内存和磁盘段(Segment)的实用程序类,PriorityQueue是优先级队列,这里是用小根堆实现的,元素入队列时会调整成小根堆结构(元素是通过key比较器比较的,见Merger.MergeQueue.lessThan方法)。从优先级队列中弹出的元素是最小的元素
@InterfaceAudience.Private
@InterfaceStability.Unstable
public class Merger {
// ...
// 遍历MergeQueue,将优先级队列中每个Segment中的数据按照key比较器的顺序写入合并文件(这里不用考虑分区,因为Segment是指定分区的数据)
public static <K extends Object, V extends Object>
void writeFile(RawKeyValueIterator records, Writer<K, V> writer,
Progressable progressable, Configuration conf)
throws IOException {
long progressBar = conf.getLong(JobContext.RECORDS_BEFORE_PROGRESS,
10000);
long recordCtr = 0;
// 调用Merger.MergeQueue.next,看看是否还有下一个数据
while(records.next()) {
writer.append(records.getKey(), records.getValue()); // 数据写入合并文件
if (((recordCtr++) % progressBar) == 0) {
progressable.progress();
}
}
}
// ...
private static class MergeQueue<K extends Object, V extends Object>
extends PriorityQueue<Segment<K, V>> implements RawKeyValueIterator {
// ...
RawComparator<K> comparator; // 输出key的排序器
private long totalBytesProcessed; // 已处理的字节数
private float progPerByte; // 辅助更新合并进度:1.0/待合并段的总字节数,待合并段包括合并期间产生的新段
private Progress mergeProgress = new Progress(); // 合并进度
Progressable reporter; // 通信线程
DataInputBuffer key; // key数据
final DataInputBuffer value = new DataInputBuffer(); // value数据,从内存段读取value数据时可以直接写到这个字段
// 从磁盘段读取value数据时先写到这个字段,再转写到value字段
final DataInputBuffer diskIFileValue = new DataInputBuffer();
// 布尔变量,用于包含/考虑最终合并是否作为排序阶段的一部分。在map任务中为true,在reduce任务中为false。用于计算mergeProgress(合并进度)
private boolean includeFinalMerge = false;
// 将布尔变量includeFinalMerge设置为true。在调用merge()之前从map任务调用,以便map任务的最终合并也被视为排序阶段的一部分
private void considerFinalMergeForProgress() {
includeFinalMerge = true;
}
// ...
// 调整优先级队列
private void adjustPriorityQueue(Segment<K, V> reader) throws IOException{
long startPos = reader.getReader().bytesRead;
boolean hasNext = reader.nextRawKey(); // 是否还有下一个key,即本次处理的key
long endPos = reader.getReader().bytesRead;
// 更新已处理的字节和更新进度
totalBytesProcessed += endPos - startPos;
mergeProgress.set(Math.min(1.0f, totalBytesProcessed * progPerByte));
if (hasNext) {
// 调整队列仍然为优先级队列,即调整成小根堆
adjustTop();
} else { // 该Segment中没有数据了,弹出该Segment并关闭
pop(); // 该方法会调整优先级队列
reader.close();
}
}
// 重置key、value、diskIFileValue
private void resetKeyValue() {
key = null;
value.reset(new byte[] {}, 0);
diskIFileValue.reset(new byte[] {}, 0);
}
// 优先级队列中是否有下一个key/value
public boolean next() throws IOException {
if (size() == 0) { // 优先级队列中没有数据时,返回false表示本次合并结束了
resetKeyValue(); // 重置key/value
return false;
}
// 上次拿走优先级队列中最小的key,并没有调整优先级队列
if (minSegment != null) {
// minSegment对于除第一次调用之外的next的所有调用都不是null。对于第一次调用,优先级队列已准备好使用,但对于后续调用,请首先调整队列,以维持依然是优先级队列
adjustPriorityQueue(minSegment);
if (size() == 0) {
minSegment = null;
resetKeyValue();
return false;
}
}
minSegment = top(); // 从该优先级队列中取出第一个元素,是最小的元素
long startPos = minSegment.getReader().bytesRead;
key = minSegment.getKey(); // 读取key
// 从Segment中读取value
if (!minSegment.inMemory()) {
// 当我们从内存段中加载value时,我们将这个类中的"value"DIB(DataInputBuffer)重置为内存段的byte[]。当我们从磁盘加载value字节时,我们不应该使用相同的byte[],因为它会破坏内存段中的数据。因此,我们为从磁盘获得的value字节维护一个显式的DIB,如果当前段是一个磁盘段,我们将"value"DIB重置为其中的byte[](因此,每当我们考虑磁盘段时,我们都会重用磁盘段DIB)。
minSegment.getValue(diskIFileValue);
value.reset(diskIFileValue.getData(), diskIFileValue.getLength());
} else {
minSegment.getValue(value);
}
long endPos = minSegment.getReader().bytesRead;
totalBytesProcessed += endPos - startPos; // 更新已处理的字节数
mergeProgress.set(Math.min(1.0f, totalBytesProcessed * progPerByte)); // 更新合并进度
return true;
}
// 确定此优先级队列中对象的顺序,最终调用key比较器实现,这里通过维护小根堆结构实现优先级队列的,当key比较器是按照key倒序的时候,堆实际为大根堆(用小根堆的逻辑反转比较器实现出来的就是大根堆)
@SuppressWarnings("unchecked")
protected boolean lessThan(Object a, Object b) {
DataInputBuffer key1 = ((Segment<K, V>)a).getKey();
DataInputBuffer key2 = ((Segment<K, V>)b).getKey();
int s1 = key1.getPosition();
int l1 = key1.getLength() - s1;
int s2 = key2.getPosition();
int l2 = key2.getLength() - s2;
return comparator.compare(key1.getData(), s1, l1, key2.getData(), s2, l2) < 0;
}
// ...
RawKeyValueIterator merge(Class<K> keyClass, Class<V> valueClass,
int factor, int inMem, Path tmpDir,
Counters.Counter readsCounter,
Counters.Counter writesCounter,
Progress mergePhase)
throws IOException {
LOG.info("Merging " + segments.size() + " sorted segments");
// 如果内存中有段,则它们首先出现在段列表中,然后是已排序的磁盘段。否则(如果只有磁盘段),则如果段列表中段的数量大于合并因子,则它们是已排序的段。段是Segment
int numSegments = segments.size(); // 段数量
int origFactor = factor; // 合并因子
int passNo = 1; // 第几次合并
if (mergePhase != null) {
mergeProgress = mergePhase;
}
// 计算待合并段的总大小(包括生成的中间段),inMem是内存中段的数量。reduce阶段computeBytesInMerges可能会返回0,在下面的if (!includeFinalMerge)代码分支中会计算reduce阶段的totalBytes
long totalBytes = computeBytesInMerges(factor, inMem);
if (totalBytes != 0) {
progPerByte = 1.0f / (float)totalBytes;
}
// 如果待合并的段不大于合并因子,则这里返回待合并段的迭代器(优先级队列自身)。否则先执行合并,每次合并的段数量不大于合并因子,直到待合并的段(包括中间文件对应的段,即合并产生的文件)不大于合并因子,然后返回最终的待合并段的迭代器
do { // 这里do while循环与computeBytesInMerges中的循环遍历Segment的顺序是一致的(包括中间生成的Segment)
// 获取这次合并的因子。我们假设内存中的段是段列表中的第一个条目,并且传递的因子不适用于它们
factor = getPassFactor(factor, passNo, numSegments - inMem);
if (1 == passNo) {
factor += inMem;
}
List<Segment<K, V>> segmentsToMerge =
new ArrayList<Segment<K, V>>();
int segmentsConsidered = 0;
int numSegmentsToConsider = factor;
long startBytes = 0; // 本次合并,遍历段阶段读取的字节数(遍历段时会读取段中第一个key的长度,判断段是否为空)
while (true) { // 这里的while循环,是为了跳过空的Segment,依然收集指定数量的Segment(如果有的话)
// 提取段的最小"因子"数,对空段调用cleanup(无键/值数据)
List<Segment<K, V>> mStream =
getSegmentDescriptors(numSegmentsToConsider); // 获取本次处理的Segment列表,并移除segments列表中对应的元素
for (Segment<K, V> segment : mStream) {
// 在最后可能的时刻初始化段;这有助于确保我们在需要缓冲区之前不会使用它们
segment.init(readsCounter); // 初始化reader(IFile.Reader)
long startPos = segment.getReader().bytesRead;
boolean hasNext = segment.nextRawKey(); // 判断是否有下一个元数时,会读取下一个key的长度
long endPos = segment.getReader().bytesRead;
if (hasNext) {
startBytes += endPos - startPos; // 已经读取的字节,上面判断元素是否存在时,会读取字节
segmentsToMerge.add(segment); // 添加到待合并的Segment列表中,空的Segment会被忽略
segmentsConsidered++;
}
else {
// 忽略这个Segment的合并,并关闭这个Segment
segment.close();
numSegments--;
}
}
// 如果我们有所需数量的分段,或者已经查看所有可用的分段,则跳出循环
if (segmentsConsidered == factor ||
segments.size() == 0) {
break;
}
// 下次循环(内层while循环)时收集的Segment数量(因为本次循环有跳过的Segment)
numSegmentsToConsider = factor - segmentsConsidered;
}
initialize(segmentsToMerge.size()); // 初始化优先级队列
clear(); // 清空优先级队列
for (Segment<K, V> segment : segmentsToMerge) {
put(segment); // 将本次(最外层的do while循环)待处理的Segment元素添加到优先级队列中
}
// 如果剩余的Segment(剩余的待合并文件,包括中间文件)大于合并因子,则执行一次合并,否则返回迭代器
if (numSegments <= factor) {
if (!includeFinalMerge) { // for reduce task
// 重置totalBytesProcessed并从剩余的段中重新计算totalBytes,以跟踪最终合并的进度。最终合并被认为是reduce任务的第三阶段reducePhase的进度。
totalBytesProcessed = 0;
totalBytes = 0;
for (int i = 0; i < segmentsToMerge.size(); i++) {
totalBytes += segmentsToMerge.get(i).getRawDataLength();
}
}
// 执行到这里,说明剩余的待合并段(包括中间产生的段)数量已经小于等于合并因子了,接着更新合并进度,然后返回迭代器(优先级队列)
if (totalBytes != 0) //being paranoid
progPerByte = 1.0f / (float)totalBytes;
totalBytesProcessed += startBytes;
if (totalBytes != 0)
mergeProgress.set(Math.min(1.0f, totalBytesProcessed * progPerByte));
else
mergeProgress.set(1.0f); // Last pass and no segments left - we're done
LOG.info("Down to the last merge-pass, with " + numSegments +
" segments left of total size: " +
(totalBytes - totalBytesProcessed) + " bytes");
return this;
} else { // 待合并段大于合并因子,执行一次合并
LOG.info("Merging " + segmentsToMerge.size() +
" intermediate segments out of a total of " +
(segments.size()+segmentsToMerge.size()));
// 记录已经合并的总字节数,下面会与本次合并之后的总字节数对比,以修正总的字节数
long bytesProcessedInPrevMerges = totalBytesProcessed;
totalBytesProcessed += startBytes; // 判断段是否为空时读取的字节数,也是已处理的字节数
// 如果在空间限制下可用,我们希望将临时文件的创建分散到多个磁盘上
long approxOutputSize = 0; // 输出文件大概的大小
for (Segment<K, V> s : segmentsToMerge) {
approxOutputSize += s.getLength() +
ChecksumFileSystem.getApproxChkSumLength(
s.getLength());
}
Path tmpFilename =
new Path(tmpDir, "intermediate").suffix("." + passNo); // 合并时的临时中间文件
Path outputFile = lDirAlloc.getLocalPathForWrite(
tmpFilename.toString(),
approxOutputSize, conf);
FSDataOutputStream out = fs.create(outputFile);
out = IntermediateEncryptedStream.wrapIfNecessary(conf, out,
outputFile);
Writer<K, V> writer = new Writer<K, V>(conf, out, keyClass, valueClass,
codec, writesCounter, true);
writeFile(this, writer, reporter, conf); // 执行段的合并,写入临时的中间文件
// IFile.Writer.close 关闭本次的合并文件,会往文件结尾写入结束标识,并做一些清理
writer.close();
// 我们完成了一个单级合并;现在清理优先级队列(关闭队列中剩余的Segment)
this.close();
// 将新创建的段添加到要合并的段列表中
Segment<K, V> tempSegment =
new Segment<K, V>(conf, fs, outputFile, codec, false);
// 在排序列表中插入新的合并段
int pos = Collections.binarySearch(segments, tempSegment,
segmentComparator);
if (pos < 0) {
// 二进制搜索失败。所以要插入的位置是 -pos-1
pos = -pos-1;
}
segments.add(pos, tempSegment);
numSegments = segments.size(); // 更新待合并段的数量
// 从totalBytes中减去新段的预期大小和新段的实际大小之间的差值(新段的预期大小是inputBytesOfThisMerge)。如果在合并中没有调用combine,预期大小和实际大小将(几乎)匹配。
long inputBytesOfThisMerge = totalBytesProcessed -
bytesProcessedInPrevMerges;
totalBytes -= inputBytesOfThisMerge - tempSegment.getRawDataLength(); // 修正totalBytes
if (totalBytes != 0) {
progPerByte = 1.0f / (float)totalBytes;
}
passNo++;
}
// 我们只需要考虑第一轮的合并因子。所以重置把它变成原来的样子
factor = origFactor;
} while(true);
}
// 确定在给定的遍历中要合并的段的数量,以最小化合并的数量
private int getPassFactor(int factor, int passNo, int numSegments) {
if (passNo > 1 || numSegments <= factor || factor == 1)
// 不是第一次遍历 或 待处理段的数量小于合并因子 或 合并因子等于1,则返回合并因子
return factor;
// 合并的中间文件也会参与最终的合并,即合并一次会产生一个待合并的文件(待合并的段),这里就是为了让最终的合并数量最小。这里的算法,不理解???
int mod = (numSegments - 1) % (factor - 1);
if (mod == 0)
return factor;
return mod + 1;
}
// ...
// 计算要合并的输入字节的预期大小,将用于计算合并进度。这模拟了上面的merge()方法,并试图获得所有要合并的字节数包括合并的中间文件(假设合并时没有调用combine),inMem是内存中段的数量。map阶段,如果溢写文件的数量小于合并因子,结果等于所有溢写文件大小的和;如果溢写文件的数量大于合并因子,结果等于所有溢写文件大小加上所有中间文件大小;reduce阶段,该方法可能会返回0
long computeBytesInMerges(int factor, int inMem) {
int numSegments = segments.size(); // 待合并段的数量
List<Long> segmentSizes = new ArrayList<Long>(numSegments);
long totalBytes = 0; // 待合并的总字节数(包括中间文件)
int n = numSegments - inMem;
// factor for 1st pass
int f = getPassFactor(factor, 1, n) + inMem; // 第一次的合并因子,实际就是第一次合并的段的数量
n = numSegments; // 待处理段的数量
for (int i = 0; i < numSegments; i++) {
// 这里不处理空段,假设它不会对mergeProgress(合并进度)的计算产生太大影响
segmentSizes.add(segments.get(i).getRawDataLength());
}
// 如果includeFinalMerge为true(map阶段),则允许下面的while循环再进行1次迭代。这是为了将最终合并作为合并的预期输入字节的计算的一部分(Map阶段溢写文件的合并,可能会产生中间文件)
boolean considerFinalMerge = includeFinalMerge;
// 待处理的段的数量大于合并因子或considerFinalMerge为true循环继续。这里循环加待合并段的字节数,与merge方法中待合并段的合并时遍历段的顺序是一样的,即在合并之前就计算合并过程中合并段的总字节数。因为合并过程中可能会产生中间文件,中间文件大小也需统计到合并段总字节数中(中间文件会作为新的段添加到待合并段的列表中)
while (n > f || considerFinalMerge) {
if (n <=f ) {
considerFinalMerge = false;
}
// 这里遍历Segment的顺序与merge方法中遍历Segment的顺序一致
long mergedSize = 0; // 本次循环待合并的字节数
f = Math.min(f, segmentSizes.size()); // 本次循环处理的待合并段数量
for (int j = 0; j < f; j++) {
mergedSize += segmentSizes.remove(0); // 移除本次循环处理的所有Segment,并统计其大小
}
totalBytes += mergedSize; // 统计总大小
// 在排序的列表中插入新段的大小(即中间文件的大小),插入之后segmentSizes依然是有序的
int pos = Collections.binarySearch(segmentSizes, mergedSize);
if (pos < 0) {
pos = -pos-1;
}
segmentSizes.add(pos, mergedSize);
n -= (f-1); // 剩余的待合并段数量,包括中间文件对应的段;因为合并f个段会生成一个待合并的中间文件(新段),所以是f-1
f = factor; // 重置合并因子(除了第一次合并,之后的合并合并因子都等于配置的值)
}
return totalBytes;
}
// ...
}
}