MapReduce Shuffle及调优

本文深入探讨了MapReduce中的Shuffle过程,包括Map端的分区、排序、溢出写磁盘以及Reduce端的复制和排序。Shuffle是MapReduce的核心,涉及到了环形Buffer、SortAndSpill、数据压缩等多个环节。文章还讨论了性能调优,如通过调整内存分配、减少spill文件数量、优化合并策略等方法提高效率。

关于MapReduce详细解释参见博客

  Mapper的输出排序、然后传送到Reducer的过程,称为Shuffle,Shuffle过程是MapReduce的核心内容。为什么需要Shuffle?Map是映射,负责数据的过滤分发;Reduce需要将具有共同特征的数据汇聚到一个计算节点上进行计算。Reduce的数据来源于Map,Map的输出即是Reduce的输入,Reduce需要通过 Shuffle来获取数据。

Map端Shuffle

  在Map端的Shuffle过程是对Map的结果进行分区、排序、溢出写磁盘,然后将属于同一分区的输出合并在一起并写在磁盘上,最终得到一个分区有序的文件。分区有序的含义是Map输出的键值对按分区进行排列,具有相同partition值的键值对存储在一起,每个分区里面的键值对又按key值进行升序排列(默认)。流程图如下:
在这里插入图片描述
Partition
  对于Map输出的每一个键值对,系统都会给定一个partition,partition值默认是通过计算key的hash值后对Reduce task的数量取模获得。如果一个键值对的partition值为1,意味着这个键值对会交给第一个Reducer处理。每一个Reduce的输出都是有序的,但是将所有Reduce的输出合并到一起却并非是全局有序的
  如何做到全局有序?只设置一个Reduce task,但是这样无法发挥集群的优势,而且能应对的数据量也很有限。最佳的方式是定义一个Partitioner,用输入数据的最大值除以系统Reduce task数量的商作为分割边界,即分割数据的边界为此商的1倍、2倍至numPartitions-1倍,就能保证执行partition后的数据是整体有序的。
  另一种需要定义一个Partitioner的情况是各个Reduce task处理的键值对数量极不平衡。对于某些数据集,由于很多不同的key的hash值都一样,导致这些键值对都被分给同一个Reducer处理,而其他的Reducer处理的键值对很少,导致数据倾斜。

环形Buffer数据结构——maptask.MapOutputBuffer
  每一个Map任务有一个环形Buffer,Map将输出写入到这个Buffer。环形Buffer是内存中的一种首尾相连、专门用来存储Key-Value格式数据的数据结构,使用环形数据结构是为了更有效地使用内存空间,在内存中放置尽可能多的数据。
在这里插入图片描述
Hadoop中,环形缓冲其实就是一个字节数组kvbuffer:

private byte[] kvbuffer;  // main output buffer
kvbuffer = new byte[maxMemUsage - recordCapacity]; 

在kvbuffer的一块区域上穿了一个IntBuffer(字节序采用的是平台自身的字节序)的马甲。kvbuffer包含数据区和索引区kvmeta,这两个区是相邻不重叠的区域,用一个分界点来标识。分界点不是不变的,而是每次Spill之后都会更新一次。初始分界点为0,数据存储方向为向上增长,索引数据的存储方向向下增长:
在这里插入图片描述
kvbuffer的存放指针bufindex是一直往上增长,比如bufindex初始值为0,写入一个int类型的key之后变为4,写入一个int类型的value之后变成8。索引是对key-value在kvbuffer中的索引,是个四元组,占用四个Int长度,包括:

  • value的起始位置
  • key的起始位置
  • partition值
  • value的长度
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
private static final int NMETA = 4;       // num meta ints
private static final int METASIZE = NMETA * 4; // size in bytes
 // write accounting info
kvmeta.put(kvindex + PARTITION, partition);
kvmeta.put(kvindex + KEYSTART, keystart);
kvmeta.put(kvindex + VALSTART, valstart);
kvmeta.put(kvindex + VALLEN, distanceTo(valstart, valend));

kvmeta的存放指针kvindex每次都是向下跳四个“格子”,然后再向上一个格子一个格子地填充四元组的数据。比如kvindex初始位置是-4,当第一个key-value写完之后,(kvindex+0)的位置存放value的起始位置、(kvindex+1)的位置存放key的起始位置、(kvindex+2)的位置存放partition的值、(kvindex+3)的位置存放value的长度,然后kvindex跳到-8位置,等第二个键值对和索引写完之后,kvindex跳到-12位置。缓冲区的大小默认为100M,可以通过mapreduce.task.io.sort.mb来配置。

Sort
  当spill触发后,SortAndSpill先把kvbuffer中的数据按照partition值和key两个关键字升序排序,移动的只是索引数据,排序结果是kvmeta中数据按照partition为单位聚集在一起,同一partition内的按照key有序。

Spill
  Map将输出不断写入到这个缓冲区中,当缓冲区使用量达到一定比例之后,spill线程开始把缓冲区的数据写入磁盘,这个过程叫SortAndSpill,即在spill之前还有sort操作。开始spill的Buffer比例默认为0.80,可以通过mapreduce.map.sort.spill.percent配置。在后台线程写入的同时,Map继续将输出写入这个环形缓冲,如果缓冲池写满了,Map会阻塞直到spill过程完成,而不会覆盖缓冲池中的已有的数据。
  在写入之前,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类:

public class IndexRecord {
  public long startOffset;
  public long rawLength;
  public long partLength;
  public IndexRecord() { }
  public IndexRecord(long startOffset, long rawLength, long partLength) {
    this.startOffset = startOffset;
    this.rawLength = rawLength;
    this.partLength = partLength;
  }
}

每个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类)维护一个这样的列表:

final ArrayList<SpillRecord> indexCacheList = new ArrayList<SpillRecord>();

创建一个SpillRecord时,会分配(Number_Of_Reducers * 24)Bytes缓冲:

public SpillRecord(int numPartitions) {
  buf = ByteBuffer.allocate(
      numPartitions * MapTask.MAP_OUTPUT_INDEX_RECORD_LENGTH);
  entries = buf.asLongBuffer();
}

numPartitions是Partition的个数,其实也就是Reducer的个数:

public static final int MAP_OUTPUT_INDEX_RECORD_LENGTH = 24;
partitions = jobContext.getNumReduceTasks();
final SpillRecord spillRec = new SpillRecord(partitions);

默认的索引缓冲为1KB,即10241024 Bytes,假设有2个Reducer,则每个Spill文件的索引大小为224=48 Bytes,当Spill文件超过21845.3时,索引文件就需要写入磁盘。
索引及spill文件如下图示意:
在这里插入图片描述
Spill的过程至少需要运行一次,因为Mapper的输出结果必须要写入磁盘,供Reducer进一步处理。

合并Spill文件
  在整个Map任务中,一旦缓冲达到设定的阈值,就会触发spill操作,写入spill文件到磁盘,Map任务如果输出数据量很大,可能会进行好几次spill,out文件和Index文件会产生很多,分布在不同的磁盘上。
  Merge过程如何获取spill文件的位置?从所有的本地目录上扫描得到产生的spill文件,然后把路径存储在一个数组里。Merge过程又如何获取spill的索引信息?从所有的本地目录上扫描得到Index文件,然后把索引信息存储在一个列表里。在spill过程中为什么不直接把这些信息存储在内存中?因为kvbuffer占用的不再使用的内存可以回收,因此有内存空间来装这些数据。
在这里插入图片描述
  为Merge过程创建一个file.out文件和一个file.out.Index文件用来存储最终的输出和索引,按每个partition进行合并输出。对于某个partition而言,从索引列表中查询该partition对应的所有索引信息,每个对应一个段插入到段列表中。即这个partition对应一个段列表,记录所有的spill文件中对应的这个partition那段数据的文件名、起始位置、长度等等。
  对这个partition对应的所有的segment进行合并,目标是合并成一个segment。当这个partition对应很多个segment时,会分批地进行合并:先从segment列表中把第一批取出来,以key为关键字放置成最小堆,然后从最小堆中每次取出最小的输出到一个临时文件中,这样就把这一批段合并成一个临时的段,把它加回到segment列表中;再从segment列表中把第二批取出来合并输出到一个临时segment,把其加入到列表中;这样往复执行,直到剩下的段是一批,输出到最终的文件中。最终的索引数据仍然输出到Index文件中。
  另外,如果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端的工作就算完毕:
在这里插入图片描述

Reduce端Shuffle

  在Reduce端,Shuffle主要分为复制Map输出、排序合并两个阶段。

Copy

  每个Map任务运行完之后,输出写入运行任务的机器磁盘中。Reducer需要从各Map任务中提取对应的partition。每个Map任务的完成时间可能不同,Reduce在Map任务结束之后会尽快取走输出结果,这个阶段叫copy。
  Reducer是如何知道要去哪些机器取数据?Reduce任务通过HTTP向各个Map任务拖取它所需要的数据。Map任务成功完成后,会通知父TaskTracker状态已经更新,TaskTracker进而通知JobTracker(这些通知在心跳机制中进行)。所以,对于指定作业来说,JobTracker能记录Map输出和TaskTracker的映射关系。Reduce会定期向JobTracker获取Map的输出位置,一旦拿到输出位置,Reduce任务就会从此输出对应的TaskTracker上复制输出到本地,而不会等到所有的Map任务结束。
  数据被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的机器磁盘上。

Merge Sort

内存中合并
  当缓冲中数据达到配置的阈值时,这些数据在内存中被合并、写入机器磁盘。阈值有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过程分配尽可能大的内存。因此在实现Mapper和Reducer时,应该尽量减少内存的使用,例如避免在Map中不断地叠加。
  运行Map和Reduce任务的JVM,内存通过mapred.child.java.opts属性设置,尽可能设大内存。容器的内存大小通过mapreduce.map.memory.mbmapreduce.reduce.memory.mb设置,默认为1024M。

通用优化

  Hadoop默认使用4KB作为缓冲,这个算是很小的,可以通过io.file.buffer.size来调高缓冲池大小。

Map优化

  在Map端,避免写入多个spill文件可能达到最好的性能,一个spill文件是最好的。通过估计Map的输出大小,设置合理的mapreduce.task.io.sort.*属性,使得spill文件数量最小。尽可能调大mapreduce.task.io.sort.mb。Map端相关的属性如下表:

属性名值类型默认值说明
mapreduce.task.io.sort.mbint100用于map输出排序的内存大小
mapreduce.map.sort.spill.percentfloat0.80开始spill的缓冲池阈值
mapreduce.task.io.sort.factorint10合并文件数最大值,与reduce共用
mapreduce.map.combine.minspillsint3运行combiner的最低spill文件数
mapreduce.map.out.compressbooleanfalse输出是否压缩
mapreduce.map.out.compress类名DefaultCodec压缩算法
mapreduce.shuffle.max.threadsint0服务于reduce提取结果的线程数量

Reduce优化

  在Reduce端,如果能够让所有数据都保存在内存中,可以达到最佳的性能。通常情况下,内存都保留给Reduce函数,但是如果Reduce函数对内存需求不是很高,将mapreduce.reduce.merge.inmem.threshold(触发合并的Map输出文件数)设为0,mapreduce.reduce.input.buffer.percent(用于保存map输出文件的堆内存比例)设为1.0,可以达到很好的性能提升。Reduce端相关属性:

属性名值类型默认值说明
mapreduce.reduce.shuffle.parallelcopiesint5提取map输出的copier线程数
mapreduce.reduce.shuffle.maxfetchfailuresint10提取map输出最大尝试次数,超出后报错
mapreduce.task.io.sort.factorint10合并文件数最大值,与map共用
mapreduce.reduce.shuffle.input.buffer.percentfloat0.70copy阶段用于保存map输出的堆内存比例
mapreduce.reduce.shuffle.merge.percentfloat0.66开始spill的缓冲池比例阈值
mapreduce.reduce.shuffle.inmem.thresholdint1000开始spill的map输出文件数阈值,小于等于0表示没有阈值,此时只由缓冲池比例来控制
mapreduce.reduce.input.buffer.percentfloat0.0reduce函数开始运行时,内存中的map输出所占的堆内存比例不得高于这个值,默认情况内存都用于reduce函数,也就是map输出都写入到磁盘
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值