MapReduce shuffle过程剖析及调优
来源:互联网 发布:python开源代码下载 编辑:程序博客网 时间:2024/06/03 20:00
转:http://blog.csdn.net/bingduanlbd/article/details/51933914
版权声明:本文为博主原创文章,未经博主允许不得转载。
目录(?)[+]
- 更新记录
- MapReduce简介
- Mapper端
- 环形Buffer数据结构
- Spill
- 合并Spill文件
- 压缩
- 通过HTTP暴露输出结果
- Reducer端
- 内存中合并
- Copy过程中磁盘合并
- 最终磁盘中合并
- MemToMem合并
- 最后一次合并后传递给reduce方法
- 性能调优
- map优化
- reduce优化
- 通用优化
- 参考
更新记录
- 2017-07-18 初稿
MapReduce简介
在Hadoop MapReduce中,框架会确保reduce收到的输入数据是根据key排序过的。数据从Mapper输出到Reducer接收,是一个很复杂的过程,框架处理了所有问题,并提供了很多配置项及扩展点。一个MapReduce的大致数据流如下图:
更详细的MapReduce介绍参考Hadoop MapReduce原理与实例。
Mapper的输出排序、然后传送到Reducer的过程,称为shuffle。本文详细地解析shuffle过程,深入理解这个过程对于MapReduce调优至关重要,某种程度上说,shuffle过程是MapReduce的核心内容。
Mapper端
当map函数通过context.write()
开始输出数据时,不是单纯地将数据写入到磁盘。为了性能,map输出的数据会写入到缓冲区,并进行预排序的一些工作,整个过程如下图:
环形Buffer数据结构
每一个map任务有一个环形Buffer,map将输出写入到这个Buffer。环形Buffer是内存中的一种首尾相连的数据结构,专门用来存储Key-Value格式的数据:
Hadoop中,环形缓冲其实就是一个字节数组:
<code class="language-java hljs has-numbering"><span class="hljs-comment">// MapTask.java</span><span class="hljs-keyword">private</span> <span class="hljs-keyword">byte</span>[] kvbuffer; <span class="hljs-comment">// main output buffer</span>kvbuffer = <span class="hljs-keyword">new</span> <span class="hljs-keyword">byte</span>[maxMemUsage - recordCapacity]; </code><ul class="pre-numbering"><li>1</li><li>2</li><li>3</li><li>4</li></ul><div class="save_code tracking-ad" data-mod="popu_249"><a target=_blank href="javascript:;"><img src="http://static.blog.csdn.net/images/save_snippets.png" alt="" /></a></div>
kvbuffer包含数据区和索引区,这两个区是相邻不重叠的区域,用一个分界点来标识。分界点不是永恒不变的,每次Spill之后都会更新一次。初始分界点为0,数据存储方向为向上增长,索引存储方向向下:
bufferindex一直往上增长,例如最初为0,写入一个int类型的key之后变为4,写入一个int类型的value之后变成8。
索引是对key-value在kvbuffer中的索引,是个四元组,占用四个Int长度,包括:
- value的起始位置
- key的起始位置
- partition值
- value的长度
<code class="hljs java has-numbering"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> VALSTART = <span class="hljs-number">0</span>; <span class="hljs-comment">// val offset in acct</span><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> KEYSTART = <span class="hljs-number">1</span>; <span class="hljs-comment">// key offset in acct</span><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> PARTITION = <span class="hljs-number">2</span>; <span class="hljs-comment">// partition offset in acct</span><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> VALLEN = <span class="hljs-number">3</span>; <span class="hljs-comment">// length of value</span><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> NMETA = <span class="hljs-number">4</span>; <span class="hljs-comment">// num meta ints</span><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> METASIZE = NMETA * <span class="hljs-number">4</span>; <span class="hljs-comment">// size in bytes</span> <span class="hljs-comment">// write accounting info</span>kvmeta.put(kvindex + PARTITION, partition);kvmeta.put(kvindex + KEYSTART, keystart);kvmeta.put(kvindex + VALSTART, valstart);kvmeta.put(kvindex + VALLEN, distanceTo(valstart, valend));</code><ul class="pre-numbering"><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li><li>7</li><li>8</li><li>9</li><li>10</li><li>11</li></ul><div class="save_code tracking-ad" style="display: none;" data-mod="popu_249"><a target=_blank href="javascript:;"><img src="http://static.blog.csdn.net/images/save_snippets.png" alt="" /></a></div>
kvmeta的存放指针kvindex每次都是向下跳四个“格子”,然后再向上一个格子一个格子地填充四元组的数据。比如kvindex初始位置是-4,当第一个key-value写完之后,(kvindex+0)的位置存放value的起始位置、(kvindex+1)的位置存放key的起始位置、(kvindex+2)的位置存放partition的值、(kvindex+3)的位置存放value的长度,然后kvindex跳到-8位置。
缓冲区的大小默认为100M,但是可以通过mapreduce.task.io.sort.mb
这个属性来配置。
Spill
map将输出不断写入到这个缓冲区中,当缓冲区使用量达到一定比例之后,一个后台线程开始把缓冲区的数据写入磁盘,这个写入的过程叫spill。开始spill的Buffer比例默认为0.80,可以通过mapreduce.map.sort.spill.percent
配置。在后台线程写入的同时,map继续将输出写入这个环形缓冲,如果缓冲池写满了,map会阻塞直到spill过程完成,而不会覆盖缓冲池中的已有的数据。
在写入之前,后台线程把数据按照他们将送往的reducer进行划分,通过调用Partitioner
的getPartition()
方法就能知道该输出要送往哪个Reducer。默认的Partitioner使用Hash算法来分区,即通过key.hashCode() mode R
来计算,R为Reducer的个数。getPartition
返回Partition事实上是个整数,例如有10个Reducer,则返回0-9的整数,每个Reducer会对应到一个Partition。map输出的键值对,与partition一起存在缓冲中(即前面提到的kvmeta中)。假设作业有2个reduce任务,则数据在内存中被划分为reduce1和reduce2:
并且针对每部分数据,使用快速排序算法(QuickSort)对key排序。
如果设置了Combiner,则在排序的结果上运行combine。
排序后的数据被写入到mapreduce.cluster.local.dir
配置的目录中的其中一个,使用round robin fashion的方式轮流。注意写入的是本地文件目录,而不是HDFS。Spill文件名像sipll0.out,spill1.out等。
不同Partition的数据都放在同一个文件,通过索引来区分partition的边界和起始位置。索引是一个三元组结构,包括起始位置、数据长度、压缩后的数据长度,对应IndexRecord类:
<code class="language-java hljs has-numbering"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IndexRecord</span> {</span> <span class="hljs-keyword">public</span> <span class="hljs-keyword">long</span> startOffset; <span class="hljs-keyword">public</span> <span class="hljs-keyword">long</span> rawLength; <span class="hljs-keyword">public</span> <span class="hljs-keyword">long</span> partLength; <span class="hljs-keyword">public</span> <span class="hljs-title">IndexRecord</span>() { } <span class="hljs-keyword">public</span> <span class="hljs-title">IndexRecord</span>(<span class="hljs-keyword">long</span> startOffset, <span class="hljs-keyword">long</span> rawLength, <span class="hljs-keyword">long</span> partLength) { <span class="hljs-keyword">this</span>.startOffset = startOffset; <span class="hljs-keyword">this</span>.rawLength = rawLength; <span class="hljs-keyword">this</span>.partLength = partLength; }}</code><ul class="pre-numbering"><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li><li>7</li><li>8</li><li>9</li><li>10</li><li>11</li><li>12</li><li>13</li></ul><div class="save_code tracking-ad" style="display: none;" data-mod="popu_249"><a target=_blank href="javascript:;"><img src="http://static.blog.csdn.net/images/save_snippets.png" alt="" /></a></div>
每个mapper也有对应的一个索引环形Buffer,默认为1KB,可以通过mapreduce.task.index.cache.limit.bytes
来配置,索引如果足够小则存在内存中,如果内存放不下,需要写入磁盘。
Spill文件索引名称类似这样 spill110.out.index, spill111.out.index。
Spill文件的索引事实上是 org.apache.hadoop.mapred.SpillRecord的一个数组,每个Map任务(源码中的MapTask.java类)维护一个这样的列表:
<code class="language-java hljs has-numbering"><span class="hljs-keyword">final</span> ArrayList<SpillRecord> indexCacheList = <span class="hljs-keyword">new</span> ArrayList<SpillRecord>();</code><ul class="pre-numbering"><li>1</li></ul><div class="save_code tracking-ad" data-mod="popu_249"><a target=_blank href="javascript:;"><img src="http://static.blog.csdn.net/images/save_snippets.png" alt="" /></a></div>
创建一个SpillRecord时,会分配(Number_Of_Reducers * 24)Bytes缓冲:
<code class="language-java hljs has-numbering"><span class="hljs-keyword">public</span> <span class="hljs-title">SpillRecord</span>(<span class="hljs-keyword">int</span> numPartitions) { buf = ByteBuffer.allocate( numPartitions * MapTask.MAP_OUTPUT_INDEX_RECORD_LENGTH); entries = buf.asLongBuffer();}</code><ul class="pre-numbering"><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li></ul><div class="save_code tracking-ad" data-mod="popu_249"><a target=_blank href="javascript:;"><img src="http://static.blog.csdn.net/images/save_snippets.png" alt="" /></a></div>
numPartitions是Partition的个数,其实也就是Reducer的个数:
<code class="language-java hljs has-numbering"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-keyword">int</span> MAP_OUTPUT_INDEX_RECORD_LENGTH = <span class="hljs-number">24</span>;<span class="hljs-comment">// ---</span>partitions = jobContext.getNumReduceTasks();<span class="hljs-keyword">final</span> SpillRecord spillRec = <span class="hljs-keyword">new</span> SpillRecord(partitions);</code><ul class="pre-numbering"><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li></ul><div class="save_code tracking-ad" data-mod="popu_249"><a target=_blank href="javascript:;"><img src="http://static.blog.csdn.net/images/save_snippets.png" alt="" /></a></div>
默认的索引缓冲为1KB,即1024*1024 Bytes,假设有2个Reducer,则每个Spill文件的索引大小为2*24=48 Bytes,当Spill文件超过21845.3时,索引文件就需要写入磁盘。
索引及spill文件如下图示意:
Spill的过程至少需要运行一次,因为Mapper的输出结果必须要写入磁盘,供Reducer进一步处理。
合并Spill文件
在整个map任务中,一旦缓冲达到设定的阈值,就会触发spill操作,写入spill文件到磁盘,因此最后可能有多个spill文件。在map任务结束之前,这些文件会根据情况合并到一个大的分区的、排序的文件中,排序是在内存排序的基础上进行全局排序。下图是合并过程的简单示意:
相对应的索引文件也会被合并,以便在Reducer请求对应Partition的数据的时候能够快速读取。
另外,如果spill文件数量大于mapreduce.map.combiner.minspills配置的数,则在合并文件写入之前,会再次运行combiner。如果spill文件数量太少,运行combiner的收益可能小于调用的代价。
mapreduce.task.io.sort.factor属性配置每次最多合并多少个文件,默认为10,即一次最多合并10个spill文件。最后,多轮合并之后,所有的输出文件被合并为唯一一个大文件,以及相应的索引文件(可能只在内存中存在)。
压缩
在数据量大的时候,对map输出进行压缩通常是个好主意。要启用压缩,将mapreduce.map.output.compress
设为true,并使用mapreduce.map.output.compress.codec
设置使用的压缩算法。
通过HTTP暴露输出结果
map输出数据完成之后,通过运行一个HTTP Server暴露出来,供reduce端获取。用来相应reduce数据请求的线程数量可以配置,默认情况下为机器内核数量的两倍,如需自己配置,通过mapreduce.shuffle.max.threads
属性来配置,注意该配置是针对NodeManager配置的,而不是每个作业配置。
同时,Map任务完成后,也会通知Application Master,以便Reducer能够及时来拉取数据。
通过缓冲、划分(partition)、排序、combiner、合并、压缩等过程之后,map端的工作就算完毕:
Reducer端
各个map任务运行完之后,输出写入运行任务的机器磁盘中。Reducer需要从各map任务中提取自己的那一部分数据(对应的partition)。每个map任务的完成时间可能是不一样的,reduce任务在map任务结束之后会尽快取走输出结果,这个阶段叫copy。
Reducer是如何知道要去哪些机器去数据呢?一旦map任务完成之后,就会通过常规心跳通知应用程序的Application Master。reduce的一个线程会周期性地向master询问,直到提取完所有数据(如何知道提取完?)。
数据被reduce提走之后,map机器不会立刻删除数据,这是为了预防reduce任务失败需要重做。因此map输出数据是在整个作业完成之后才被删除掉的。
reduce维护几个copier线程,并行地从map任务机器提取数据。默认情况下有5个copy线程,可以通过mapreduce.reduce.shuffle.parallelcopies
配置。
如果map输出的数据足够小,则会被拷贝到reduce任务的JVM内存中。mapreduce.reduce.shuffle.input.buffer.percent
配置JVM堆内存的多少比例可以用于存放map任务的输出结果。如果数据太大容不下,则被拷贝到reduce的机器磁盘上。
内存中合并
当缓冲中数据达到配置的阈值时,这些数据在内存中被合并、写入机器磁盘。阈值有2种配置方式:
- 配置内存比例: 前面提到reduce JVM堆内存的一部分用于存放来自map任务的输入,在这基础之上配置一个开始合并数据的比例。假设用于存放map输出的内存为500M,
mapreduce.reduce.shuffle.merger.percent
配置为0.80,则当内存中的数据达到400M的时候,会触发合并写入。 - 配置map输出数量: 通过
mapreduce.reduce.merge.inmem.threshold
配置。
在合并的过程中,会对被合并的文件做全局的排序。如果作业配置了Combiner,则会运行combine函数,减少写入磁盘的数据量。
Copy过程中磁盘合并
在copy过来的数据不断写入磁盘的过程中,一个后台线程会把这些文件合并为更大的、有序的文件。如果map的输出结果进行了压缩,则在合并过程中,需要在内存中解压后才能给进行合并。这里的合并只是为了减少最终合并的工作量,也就是在map输出还在拷贝时,就开始进行一部分合并工作。合并的过程一样会进行全局排序。
最终磁盘中合并
当所有map输出都拷贝完毕之后,所有数据被最后合并成一个排序的文件,作为reduce任务的输入。这个合并过程是一轮一轮进行的,最后一轮的合并结果直接推送给reduce作为输入,节省了磁盘操作的一个来回。最后(所以map输出都拷贝到reduce之后)进行合并的map输出可能来自合并后写入磁盘的文件,也可能来及内存缓冲,在最后写入内存的map输出可能没有达到阈值触发合并,所以还留在内存中。
每一轮合并并不一定合并平均数量的文件数,指导原则是使用整个合并过程中写入磁盘的数据量最小,为了达到这个目的,则需要最终的一轮合并中合并尽可能多的数据,因为最后一轮的数据直接作为reduce的输入,无需写入磁盘再读出。因此我们让最终的一轮合并的文件数达到最大,即合并因子的值,通过mapreduce.task.io.sort.factor
来配置。
假设现在有50个map输出文件,合并因子配置为10,则需要5轮的合并。最终的一轮确保合并10个文件,其中包括4个来自前4轮的合并结果,因此原始的50个中,再留出6个给最终一轮。所以最后的5轮合并可能情况如下:
前4轮合并后的数据都是写入到磁盘中的,注意到最后的2格颜色不一样,是为了标明这些数据可能直接来自于内存。
MemToMem合并
除了内存中合并和磁盘中合并外,Hadoop还定义了一种MemToMem合并,这种合并将内存中的map输出合并,然后再写入内存。这种合并默认关闭,可以通过reduce.merge.memtomem.enabled
打开,当map输出文件达到reduce.merge.memtomem.threshold
时,触发这种合并。
最后一次合并后传递给reduce方法
合并后的文件作为输入传递给Reducer,Reducer针对每个key及其排序的数据调用reduce函数。产生的reduce输出一般写入到HDFS,reduce输出的文件第一个副本写入到当前运行reduce的机器,其他副本选址原则按照常规的HDFS数据写入原则来进行,详细信息请参考这里。
通过从map机器提取结果,合并,combine之后,传递给reduce完成最后工作,整个过程也就差不多完成。最后再感受一下下面这张图:
性能调优
如果能够根据情况对shuffle过程进行调优,对于提供MapReduce性能很有帮助。相关的参数配置列在后面的表格中。
一个通用的原则是给shuffle过程分配尽可能大的内存,当然你需要确保map和reduce有足够的内存来运行业务逻辑。因此在实现Mapper和Reducer时,应该尽量减少内存的使用,例如避免在Map中不断地叠加。
运行map和reduce任务的JVM,内存通过mapred.child.java.opts
属性来设置,尽可能设大内存。容器的内存大小通过mapreduce.map.memory.mb
和mapreduce.reduce.memory.mb
来设置,默认都是1024M。
map优化
在map端,避免写入多个spill文件可能达到最好的性能,一个spill文件是最好的。通过估计map的输出大小,设置合理的mapreduce.task.io.sort.*
属性,使得spill文件数量最小。例如尽可能调大mapreduce.task.io.sort.mb
。
map端相关的属性如下表:
reduce优化
在reduce端,如果能够让所有数据都保存在内存中,可以达到最佳的性能。通常情况下,内存都保留给reduce函数,但是如果reduce函数对内存需求不是很高,将mapreduce.reduce.merge.inmem.threshold
(触发合并的map输出文件数)设为0,mapreduce.reduce.input.buffer.percent
(用于保存map输出文件的堆内存比例)设为1.0,可以达到很好的性能提升。在2008年的TB级别数据排序性能测试中,Hadoop就是通过将reduce的中间数据都保存在内存中胜利的。
reduce端相关属性:
通用优化
Hadoop默认使用4KB作为缓冲,这个算是很小的,可以通过io.file.buffer.size
来调高缓冲池大小。
参考
- 《Hadoop权威指南》
- http://ercoppa.github.io/HadoopInternals/AnatomyMapReduceJob.html
- http://www.csdn.net/article/2014-05-19/2819831-TDW-Shuffle/1
- https://hadoopabcd.wordpress.com/2015/06/29/how-mapreduce-works/
- http://grepalex.com/2012/09/24/map-partition-sort-spill/
- MapReduce shuffle过程剖析及调优
- MapReduce shuffle过程剖析及调优
- MapReduce shuffle过程剖析及调优
- MapReduce shuffle过程剖析及调优
- MapReduce中Shuffle过程剖析及性能优化
- MapReduce优化----Shuffle过程剖析及性能优化
- MapReduce优化----Shuffle过程剖析及性能优化
- Hadoop 学习研究(四):MapReduce shuffle过程剖详解及参数配置调优
- Shuffle过程剖析及性能优化
- mapreduce原理全剖析map+shuffle+reducer 全部过程
- MapReduce:详解Shuffle过程
- MapReduce:详解Shuffle过程
- 详解MapReduce shuffle 过程
- MapReduce:详解Shuffle过程
- MapReduce:详解Shuffle过程
- MapReduce:详解Shuffle过程
- MapReduce:详解Shuffle过程
- MapReduce:详解Shuffle过程
- 这里是起点
- Mysql报Got error 28 from storage engine错误问题
- JavaScript DOM编程艺术笔记
- linux下TFTP服务器配置
- java Volatile你以为你用对了吗?从原理上告诉你该怎么用!
- MapReduce shuffle过程剖析及调优
- android-传感器的简单运用
- [Lintcode]First Bad Version 第一个错误的代码版本
- RecyclerView系列详解(视频)
- C#中Dictionary的用法
- 三个问题
- Android 流行的网络图片加载库 (概述)
- MapReduce 过程详解
- 顺序表应用4-2:元素位置互换之逆置算法(数据改进)