mr流程
在我们提交完MR程序之后,MR程序会先后经历map,reduce阶段,下面我们详细的来解析一下各个阶段
1、map阶段,在这个阶段主要分如下的几个步骤read,map,collect,溢写,combine阶段
(1)、在read阶段,maptask会调用用户自定义的RecordReader方法,在splitInput中解析出一个个的key-value对
(2)、在map阶段,maptask会接受由前面读取来的数据,然后按照所需的逻辑对数据进行加工处理,形成新的key-value对
(3)、在collect阶段,map在数据处理完成之后会调用OutputCollector.collect()方法把数据写入环形缓冲区中,这个环形缓冲区被分为了两部分,一半是用来存储数据的索引,一半是用来存储数据,在分区中首先是按照分区号进行排序,在分区里面在按照key进行排序,在环形缓冲区中默认的采用的是哈希分区,如果想自定义分区可以重写一个类继承partition在重新分区方法即可。
(4)、溢写阶段,当环形缓冲区中的数据到达整个缓冲区的百分之八十的时候(环形缓冲区默认大小是100M),就会把数据写入本地的临时文件,但是为了提高性能在这儿可以调用combiner首先把数据合并之后再把数据写入零时文件,在环形缓冲区上的数据读写方法时索引存储数据和存储索引占到总大小的百分之八十多的时候,双方在结束的位置同时向开始的位置读取数据,这样循环往复。
(5)、合并阶段,在前面产生的所有的临时文件,maptask采用轮转的方式进行合并,并且在合并之后的分区中进行排序,这样这个map就会产生一个数据的输出文件。
(6)如下图所示
2、reduce阶段主要包括如下的阶段,cpoy阶段,merge阶段,sort阶段,reduce阶段
- copy阶段主要是reducer阶段从远程的map的输出去拷贝数据到本地,同一个reducer的节点会把不同的map的输出的同一个分区拷贝到本地
- merge阶段,在开始拷贝数据的时候。reduceTask会启动两个后台线程,合并内存中的数据和磁盘中的数据,防止使用过多的内存和磁盘
- sort阶段,由于在reduce的数据是按照key进行聚合排序的,但是在map的输出的时候就已经进行排序,所以在这儿只需要简单的归并排序即可
- reduce阶段 ,把已经按照key聚合的数据输出给reducer按照我们的业务逻辑进行处理
- 几个partition对应几个reduce task,每个partition对应一个输出文件
- 具体如下图所示
源码解析
- 获取Block 块大小
文件上传到HDFS中,第一步就是数据的划分,这个是真实物理上的划分,数据文件上传到HDFS后,要把文件划分成一块一块,每块的大小按照dfs.block.size配置进行。getSpits方法中通过如下方式获得:long blockSize = file.getBlockSize(); - InputFormat计算Split,会判断文件是否可切分,以及块大小与split limit限制参数。
- 获取最小设置
getSplits方法:long minSize = Math.max(getFormatMinSplitSize(), getMinSplitSize(job));
//从配置文件和job设置中寻找最大的数据作为minSize
默认大小为1
可以在mr主类中进行设置,设置每个task处理的文件大小,但是这个大小只有在大于block_size的时候才会生效。
FileInputFormat.setMinInputSplitSize(conf, 1048576); //1M - 获取最大设置
getSplits方法: long maxSize = getMaxSplitSize(job);
默认大小为long类型数据的MAX_VALUE-- 9223372036854775807
可以在mr主类中进行设置,暂无具体优化作用
FileInputFormat.setMaxInputSplitSize(conf, 1048576); //1M - 计算分片大小
FileInputFormat.class中的computeSplitSize - 文件的Split切分
getSplits方法:,输入的文件路径下的文件对象作循环后,按照单个文件内的状态等因素进行Split处理的。
在处理这个文件之前,需要判断该文件是否大小为0,文件状态是否为LocatedFileStatus的实例,同时还需要判断该文件是否可以切块
- 获取最小设置
getSplits方法中的如下代码说明,先通过computeSplitSize方法计算splitsize。在通过循环,如果当剩余文件大小/splitsize>SPLIT_SLOP (private static final double SPLIT_SLOP = 1.1; // 10% slop),则从当前文件上切分出split大小为splitsize的分块。当若干次循环后,剩余文件大小/splitsize<1.1 则将剩余的部分作为一个split
MapReduce中如何处理跨行的Block和InputSplit
Map最小输入数据单元是InputSplit。比如对于那么对于一个记录行形式的文本大于128M时,HDFS将会分成多块存储(block),同时分片并非到每行行尾。这样就会产生两个问题
- Hadoop的一个Block默认是128M,那么对于一个记录行形式的文本,会不会造成一行记录被分到两个Block当中?
- 在把文件从Block中读取出来进行切分时,会不会造成一行记录被分成两个InputSplit,如果被分成两个InputSplit,这样一个InputSplit里面就有一行不完整的数据,那么处理这个InputSplit的Map会不会得出不正确的结果?
对于上面的两个问题,必须明确两个概念:Block和InputSplit:
- Block是HDFS存储文件的单位(默认是128M)
- InputSplit是MapReduce对文件进行处理和运算的输入单位,只是一个逻辑概念,每个InputSplit并没有对文件实际的切割,只是记录了要处理的数据的位置(包括文件的path和hosts)和长度(由start和length决定)
因此以行记录形式的文本,可能存在一行记录被划分到不同的Block,甚至不同的DataNode上去。通过分析FileInputFormat里面的getSplits方法,可以得出,某一行记录同样也可能被划分到不同的InputSplit
对文件进行切分其实很简单:获取文件在HDFS上的路径和Block信息,然后根据splitSize对文件进行切分,splitSize = computeSplitSize(blockSize, minSize, maxSize);maxSize,minSize,blockSize都可以配置,默认splitSize 就等于blockSize的默认值(128m)
FileInputFormat对文件的切分是严格按照偏移量来的,因此一行记录比较长的话,可能被切分到不同的InputSplit。但这并不会对Map造成影响,尽管一行记录可能被拆分到不同的InputSplit,但是与FileInputFormat关联的RecordReader被设计的足够健壮,当一行记录跨InputSplit时,其能够到读取不同的InputSplit,直到把这一行记录读取完成。我们拿最常见的TextInputFormat源码分析如何处理跨行InputSplit的,TextInputFormat关联的是LineRecordReader,下面先看LineRecordReader的的nextKeyValue方法里读取文件的代码
public boolean nextKeyValue() throws IOException {
if (key == null) {
key = new LongWritable();
}
key.set(pos);
if (value == null) {
value = new Text();
}
int newSize = 0;
// We always read one extra line, which lies outside the upper
// split limit i.e. (end - 1)
while (getFilePosition() <= end) {
newSize = in.readLine(value, maxLineLength,
Math.max(maxBytesToConsume(pos), maxLineLength));
pos += newSize;
if (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;
}
}
- 其读取文件是通过LineReader(in就是一个LineReader实例)的readLine方法完成的。关键的逻辑就在这个readLine方法里,这个方法主要的逻辑归纳起来是3点:
-
- 总是从buffer里读取数据,如果buffer里的数据读完了,先加载下一批数据到buffer。
-
- 在buffer中查找"行尾",将开始位置至行尾处的数据拷贝给str(也就是最后的Value)。若为遇到"行尾",继续加载新的数据到buffer进行查找。
-
- 关键点在于:给到buffer的数据是直接从文件中读取的,完全不会考虑是否超过了split的界限,而是一直读取到当前行结束为止。
- 按照readLine的上述行为,在遇到跨split的行时,会将下一个split开始行数据读取出来构成一行完整的数据,那么下一个split怎么判定开头的一行有没有被上一个split的LineRecordReader读取过从而避免漏读或重复读取开头一行呢?这方面LineRecordReader使用了一个简单而巧妙的方法:既然无法断定每一个split开始的一行是独立的一行还是被切断的一行的一部分,那就跳过每个split的开始一行(当然要除第一个split之外),从第二行开始读取,然后在到达split的结尾端时总是再多读一行,这样数据既能接续起来又避开了断行带来的麻烦.以下是相关的源码:
if (start != 0) {//非第一个InputSplit忽略掉第一行
start += in.readLine(new Text(), 0, maxBytesToConsume(start));
}
this.pos = start;
- 相应地,在LineRecordReader判断是否还有下一行的方法:org.apache.hadoop.mapreduce.lib.input.LineRecordReader.nextKeyValue()中,while使用的判定条件保证了InputSplit读取跨界的问题:当前位置小于或等于split的结尾位置,也就说:当前已处于split的结尾位置上时,while依然会执行一次,这一次读到显然已经是下一个split的开始行了。