栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 前沿技术 > 大数据 > 大数据系统

MapReduce学习4:框架原理详解

MapReduce学习4:框架原理详解

    • 1 MapReduce流程
    • 2 InputFormat数据输入
      • 2.1 数据切片和数据块概念
      • 2.2 数据切片和MapTask并行度决定机制
      • 2.3 数据块与数据切片的关系
      • 2.4 源码上的切片大小计算策略
      • 2.5 源码上的小切片处理策略
    • 3 InputFormat解析
      • 3.1 FileInputFormat和TextInputFormat
      • 3.2 CombineTextInputFomat处理大量小文件场景
        • 3.2.1 CombineTextInputFomat切片最大值设置
        • 3.2.2 CombineTextInputFomat切片机制
        • 3.2.3 CombineTextInputFomat实践案例(基于官方wordcount案例需求)
    • 4 Shuffle机制
      • 4.1 Shuffle整体概述
      • 4.2 Shuffle处理过程详解【sort(map阶段) → copy(reduce阶段) 】
        • 4.2.1 Map输出到环形缓冲区后的排序、合并以及溢出过程
          • 4.2.1.1 环形缓冲区
          • 4.2.1.2 分区概念
          • 4.2.1.3 分区案例(自定义分区器)
          • 4.2.1.4 默认分区号0的出现原因和默认分区器
          • 4.2.1.4 分区规则
        • 4.2.2 溢出前的分区排序
        • 4.2.3 分区排序后的合并
        • 4.2.4 溢出后的归并
        • 4.2.5 溢出后的压缩
      • 4.3 Shuffle处理过程详解【copy(reduce阶段) → sort(reduce阶段)】
        • 4.3.1 copy(reduce阶段)
        • 4.3.2 归并
        • 4.3.3 分组处理

1 MapReduce流程


上述就是一个MapReduce处理数据的流程:经由:数据输入→map阶段→Shuffle阶段→数据输出。以下将根据这整个流程解析MapReduce的框架原理

2 InputFormat数据输入 2.1 数据切片和数据块概念
  • 数据块:Block是HDFS物理上把数据分成一块一块
  • 数据切片:数据切片只是在逻辑上对输入进行分片,并不会在磁盘上将其切分成片进行存储。也就是通过文件是完整的文件而不是像数据块去直接在物理层面上划分为多个块,而是使用指针在源文件中置顶处理的范围

切片和数据块都是按照一定的单位处理的,例如切分数据块在hadoop3.x中是按照128M进行切分,200M的数据,就会被分为128M和72M两个块

2.2 数据切片和MapTask并行度决定机制

一个切片是由一个MapTask负责处理,有多少个切片就启用多少个MapTask,并且MapTask是并行处理的,切片的个数影响MapTask并行度。MapTask并行度不是越高越好,他也由切片的数据量决定

2.3 数据块与数据切片的关系

上述概念中,数据切片是对整个文件的逻辑上进行划分,并且一个切片由一个MapTask负责。实际在服务器集群中,文件的存储默认是使用的副本策略,也就是说MapReduce程序在集群中输入的数据其实就是服务器上的数据块

那么数据块在一定程度上就影响数据的切片,因为输入的数据与服务器的副本机制的问题,那么输入的数据最好是一个块的大小,并且默认情况下,切片大小的值=块大小的值,块大小计算是通过一个公式的,切片也同样,并且使用到了块大小的量,默认情况下计算出来切片大小跟块大小是相同的,而不是直接取块的大小作为切片的大小

为什么使用块大小作为一个切片的大小呢?例如一个200M的数据,分成128M和72M的块,那么两个块就根据副本的选择策略,副本相关的块分散到不同的DataNode中,那么对于一个副本的128M和72M在不同机器上例如分别是d1和d2,如果切片大小是100M,那么就会从d1的数据上得到切片是0~100M,那么剩下部分就是100M~128M,不够100M,那么就会跨服务器到d2进行读取,处理过程相对复杂。如果切片刚好是块的大小就能避免这种情况

  1. MapReduce对切片的处理是对基于整个文件的,而不是数据的整体,例如一个200M的文件和100M的文件同时输入,那么切片仅仅是相对于200M和100M文件本身,而不是整体的数据流,也就是默认情况下最终200M的文件只会被切分为128M和72M两个切片,而100M也是一个单独的切片
  2. 切片大小是可以设置的,默认情况下切片大小的值=块大小的值

上述策略在大文件处理过程中是很有效的,但是也不是一直适用的,例如大量的小文件,例如一个10M,那么如果按照默认情况下切片,那就有多少个文件就有多少个切片,同时启用同等数量的MapTask,虽然MapTask是并行处理,但是大量的并行调度处理小文件,其中的调度过程就会耗费资源,而这种资源的耗费仅仅是处理一些小文件,这是得不偿失的,所以通过设置切片大小并配合一定策略处理这种的大量小文件的场景

2.4 源码上的切片大小计算策略

从FileInputFormat可以看到getSplits方法

经过一系列的计算与配置会在getSplits方法中调用computeSplitSize方法,也就是切片的计算方法,其中传入了三个参数blockSize, minSize, maxSize

computeSplitSize方法源码如下,事实就是如下的一个比较方法

maxSize的获取如下

可以看到通过job对象获取他的配置并通过getLong方法获取参数值

SPLIT_MAXSIZE和Long.MAX_VALUE如下

  public static final String SPLIT_MAXSIZE = 
    "mapreduce.input.fileinputformat.split.maxsize";
    
  public static final long MAX_VALUE = 0x7fffffffffffffffL; // 2^63-1

通过getLong方法,第一个参数就是获取mapred-site.xml或者mapred-default.xml配置文件的mapreduce.input.fileinputformat.split.maxsize配置属性,而第二个出参数是一个默认值,也就是当配置文件的参数没有配置时会使用第二个参数,这里也就是Long类型的最大值

获取blockSize公式如下,这里blockSize可以是集群配置文件配置的blockSize,如果在本地执行,如果没有定义,那么默认是32MB

这里做的意义是通过maxSize和blockSize进行比较,如果maxSize没有定义或者比blockSize大,那么就取blockSize

minSize获取过程类似

getFormatMinSplitSize和getMinSplitSize方法以及相关字段如下

  protected long getFormatMinSplitSize() {
    return 1;
  }

  public static long getMinSplitSize(JobContext job) {
    return job.getConfiguration().getLong(SPLIT_MINSIZE, 1L); /
  }
  
  public static final String SPLIT_MINSIZE = 
    "mapreduce.input.fileinputformat.split.minsize";

结合上述computeSplitSize方法,当minSize相关的属性没有配置的时候,会返回1。上述例子中maxSize相关配置没有配置,那么就返回blockSize,当minSize也没有配置,那么1和blockSize取最大值,最终就拿到了blockSize,也就是最终切片大小等于blokcSize

2.5 源码上的小切片处理策略

小切片处理,就例如配有额外配置的情况下,对于128.1MB的文件,如果blokcSize=128MB,那么就切分成128MB和0.1MB的切片,切片过小造成资源浪费。hadoop的处理是通过一个比例进行限制的

其中SPLIT_SLOP定义如下

private static final double SPLIT_SLOP = 1.1;   // 10% slop

splitSize是上述computeSplitSize方法的返回值,也就是切片的大小,bytesRemaining)/splitSize,表示剩余文件与切片大小的值的比例,如果大于1.1那么就允许切片,小于等于就不允许,也就是允许10%的溢出

3 InputFormat解析 3.1 FileInputFormat和TextInputFormat

InputFormat类用以处理输入以及切片,如下两个抽象方法。这里是为了在源码角度概要解析之前数据输入处理的流程

如果使用IDEA查看源码,可以通过快捷键ctrl+h查看他的实现类

  // 获取切片
  public abstract 
    List getSplits(JobContext context
                               ) throws IOException, InterruptedException;
  // 创建RecordReader对象,负责读取
  public abstract 
    RecordReader createRecordReader(InputSplit split,
                                         TaskAttemptContext context
                                        ) throws IOException, 
                                                 InterruptedException;

}

上述方法是一个抽象的方法,要进一步了解就需要通过他的实现类,这里首先是介绍这个FileInputFormat类,该方法主要是实现了getSplits和isSplitable方法

  1. getSplits:默认的切片规则的实现
  2. isSplitable:判断一个文件是否可切片,统一的实现,返回的是true

切片大小相关逻辑如上述,可查看上述标题2.3,在FileInputFormat类中是对isSplitable做了一个统一的处理,也就是返回true

切片相关的大小获取逻辑如2.3标题,其他这里先不介绍

在我们默认的输入流程中,默认使用的是FileInputFormat类的实现类TextInputFormat类

其中重要的方法如下:

  1. createRecordReader:创建LineRecordReader对象,所以初始对文件的处理,都是一行一行输入到map方法处理的
  2. isSplitable:重写了该方法,对各种压缩文件进行了判断是否可切分。切片的规则用的是FileInputFormat中的getSplits方法实现

其中重写的isSplitable方法中对于压缩文件的编解码处理,普通文件我们都是可以直接切分的

  @Override
  protected boolean isSplitable(JobContext context, Path file) {
    final CompressionCodec codec =
      new CompressionCodecFactory(context.getConfiguration()).getCodec(file);
    if (null == codec) {
      return true;
    }
    return codec instanceof SplittableCompressionCodec;
  }
3.2 CombineTextInputFomat处理大量小文件场景

框架默认的TextInptFormat切片机制是对任务按文件规划切片,不管文件多小,都会是一个单独的切片,都会交给一个MapTask,这样如果有大量小文件,就会产生大量的MapTask,处理效率极其低下

3.2.1 CombineTextInputFomat切片最大值设置

CombineTextInputFomat是通过设置虚拟切片来处理小文件问题,该处理机制重要配置之一是虚拟切片的设置,如下为设置最大虚拟切片大小的方法

// 一个参数设置的job对象,第二个参数是设置最大虚拟切片的值,单位是字节,下述例子是1024*1024*4
CombineTextInputFormat.setMaxInputSplitSize(job, 4194304) // 这里就是4MB
3.2.2 CombineTextInputFomat切片机制

CombineTextInputFomat是通过设置虚拟切片机制来处理小文件,也就是在生成切片之前会有一个虚拟的过程,然后再到切片过程,处理过程如下

1、首先准备假设输入数据如下(缺省字节B后缀),并且最大虚拟切片是4MB

2、数据切片前会先经过虚拟过程

处理过程大致如下:

  1. 文件大小 ≤ 4MB:划分为1块
  2. 4MB < 文件大小 < 8MB:文件对半分,例如上述5.1MB的文件就是符合该范围,那么就分成2.55MB两个块
  3. 文件大小 ≥ 8MB:那么首先按顺序切分出4MB,例如9MB的内容,首先切分4MB,然后剩下的5MB内容符合2,那么对半分两块是2.5MB,最终得到三块:4MB、2.5MB、2.5MB

总的而言,就是最后划分的块不能比设置的最大虚拟切片大,这里是4MB

最后切分的块大小如上图所示,也就是最终切分剩下的文件大小

3、最后是切片阶段,切片阶段主要是一下

  1. . 判断上述划分的块是否等于设置的最大虚拟存储的值,如果等于,那么就会作为一个切片
  2. 如果块大小不等于设置的最大虚拟切片(上述在设置的是4MB),那么就会与其他块进行合并,直到切片大小比设置的最大虚拟切片的值要大,那么上述存储的文件,最终会分为以下三个切片,这个时候就相对于4个小文件生成一个单独的切片要少。这里仅仅是相对于设置的4MB的最大虚拟切片,根据实际情况设置相应的值

  1. 最大的虚拟切片的大小,最好是趋于一个块的大小
  2. 上述构成产生的分块是按文件的输入顺序的,上述例子在虚拟过程中产生的块,都是按照这个文件的输入顺序,例如上述是a.txt~b.txt,按照文件ASCII值排序,那么最先输入的就是a.txt,最后输入的是d.txt,块的产生也是按这个顺讯,最后按照上述规则进行组合
3.2.3 CombineTextInputFomat实践案例(基于官方wordcount案例需求)

基本的客户端编写wordcount程序以及项目配置可以参考这里:MapReduce学习2-1:以官方wordcount实例为例的MapReduce程序学习的本地实操案例中(本次主要是本地测试,用于学习比较方便)

1、输入案例文件准备

直接案例测试(无小文件处理),如果需要打印以下日志信息,可以参考:Hadoop学习9:Maven项目跟中进行HDFS客户端测试(hadoop3.1.2)中POM.xml的配置(我是JDK 1.8)

这里我是本地进行测试,没有设置块大小,本地测试默认是32MB(集群默认128MB)。如上述箭头所示,可以看到是切片是4个

按照上述理论,如果使用CombineTextInputFormat处理,那么就是3个切片

2、在WordcountDriver.class中添加CombineTextInputFormat相关的配置

package com.ctfwc.maven;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.CombineTextInputFormat;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;

public class WordcountDriver {

    public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {

        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        job.setJarByClass(WordcountDriver.class);
        job.setMapperClass(WordcountMapper.class);
        job.setReducerClass(WordcountReducer.class);

        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(IntWritable.class);

        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(IntWritable.class);



        FileInputFormat.setInputPaths(job, new Path("E:\bigdata\study\test_files\combineinput"));
        FileOutputFormat.setOutputPath(job, new Path("E:\bigdata\study\test_files\combineoutput"));
        
        job.setInputFormatClass(CombineTextInputFormat.class);
        CombineTextInputFormat.setMaxInputSplitSize(job, 4194304);

        job.waitForCompletion(true);
    }
}

上述的设置了最大的虚拟切片是4MB

3、结果
上述可以看到结果是3,也就是切片是3,符合上述

4 Shuffle机制

Map方法之后,Reduce方法之前的数据处理过程称之为Shuffle

4.1 Shuffle整体概述

Shffle阶段Map方法之后,Reduce方法之前,主要是包含两次排序以及一次数据的拷贝

1、在源码中MapTask.class中的启动方法run(),可以看到

// 是否为MapTask
 if (this.isMapTask()) {
     // 判断是否有reduce阶段
     if (this.conf.getNumReduceTasks() == 0) {
         // 没有reduce阶段,就只有map阶段,阶段占总进程为100%
         this.mapPhase = this.getProgress().addPhase("map", 1.0F);
     } else {
         // 有reduce阶段,map阶段占用总进程的66.7%,sort阶段占用33.3%
         this.mapPhase = this.getProgress().addPhase("map", 0.667F);
         this.sortPhase = this.getProgress().addPhase("sort", 0.333F);
     }
 }

2、ReduceTask.class可以看到以下

if (this.isMapOrReduce()) {
     // 数据拷贝阶段
     this.copyPhase = this.getProgress().addPhase("copy");
     // 排序阶段
     this.sortPhase = this.getProgress().addPhase("sort");
     // reduce阶段
     this.reducePhase = this.getProgress().addPhase("reduce");
 }

3、总的Shuffle阶段就是:sort(map阶段) → copy(reduce阶段) → sort(reduce阶段)

4.2 Shuffle处理过程详解【sort(map阶段) → copy(reduce阶段) 】

上述概要分析,该过程就是排序,但是其中还涉及更为详细的分区、排序、分组以及合并

4.2.1 Map输出到环形缓冲区后的排序、合并以及溢出过程 4.2.1.1 环形缓冲区

Map阶段输出首先是输出到Shuffle阶段上的环形缓冲区,也就是在客户端通过context.write进行写出的数据就是输入到该缓冲区中

环形缓冲区是默认大小是100MB,如果按数据结构分类就是一个数组,但是其上逻辑比较复杂,用来缓冲数据,避免频繁的I/O操作降低效率。如上图,他是被分为80%和20%两个部分,其中80%的部分,也就是80M,这是一个默认的设置比例

  1. 可以通过在mapred-site.xml文件中配置mapreduce.map.io.sort.spill.percent属性,默认值是0.8,也就是80%
  2. 100MB是默认的配置,可以通过mapred-site.xml文件中配置mapreduce.task.io.sort.mb属性的属性值来配置该值,默认值是100,也就是100MB

被分为80%和20%两个部分,解析这两个就涉及到一个溢出的流程,当数据写入到环形缓冲区的时候,达到设置的这个阈值(默认是80%,也就是80MB),就会被锁定,然后写入到磁盘中,也就是溢出(spill),溢出过程是由后台进程处理,生成临时的溢出文件。在数据溢出到磁盘过程中,数据会继续写入,那么剩下的20%的内存就继续接受数据,因为是环形的,所以如果溢出跟数据和写入是符合一定的速度,那么理论上是可以不间断工作,直到最后的数据全部都被处理

  1. 溢出写过程按轮询方式将缓冲区中的内容写到mapreduce.cluster.local.dir属性指定的目录中,同时也会处理最后达不到溢出条件的数据,该属性可在mapred-site.xml文件中配置
  2. 每次溢写是产生一个溢写文件而不是不同分区产生不同的文件,而是同一个文件上存在多个分区
4.2.1.2 分区概念

1中介绍了一个大概的过程,接下来是期间更为详细的介绍,首先溢出过程之前还存在分区的概念

分区是什么呢?默认分区数是1,简单一点的就是每次默认运行,都会产生下述文件

这里就是一般直接运行,就是产生这么一个00000代号的分区文件,所有处理结果都会丢到这里边,这么一个文件,其实就算是一个分区,后边的代号可以通过特定的方法进行配置,他是跟设置的reduceTask的数量有关,接下来配合案例和源码详细介绍

4.2.1.3 分区案例(自定义分区器)

为了更好理解分区,首先介绍一个案例解析为什么需要分区。有时候需要有一些需求,例如根据一组数据手机号的输入样例,根据前三位,输出到不同的文件中。这个需求就可以使用到分区,根据前三位判断输出到不同的分区并且设定合适的reduceTask数就可以了

上述产生一个形如part-r-******的文件,可以看做是一个分区的输出,这样的文件的数量与设置的reduceTask的数量是相等的

那么如何输出到不同的分区?在MapReduce学习2-1:以官方wordcount实例为例的MapReduce程序学习的基础上扩展。可以新建一个Partitioner的实现类并实现getPartition方法,通过getPartition方法的返回值的不同,从而分发到不同的分区,最后被不同的reduce处理。这就可以通过继承Partitioner类并重写getPartition方法进行分区,其返回的数值,就是形如part-r-******最后的数值

ProvincePartitioner 类

package com.partition.maven;

import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Partitioner;

public class ProvincePartitioner extends Partitioner {

    @Override
    public int getPartition(Text key, Text value, int numPartitions) {
        String preNum = key.toString().substring(0, 3);
        int partition=4;
        if("136".equals(preNum)){
            partition=0;
        }else if("137".equals(preNum)){
            partition=1;
        }else if("138".equals(preNum)){
            partition=2;
        }else if("139".equals(preNum)){
            partition=3;
        }
        return partition;
    }
}

PartMapper类

package com.partition.maven;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

import java.io.IOException;



public class PartMapper extends Mapper {

    private Text outK = new Text();


    

    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
//        super.map(key, value, context);  因为是自定义,所以不需要调用父类构造函数

        String line = value.toString();
        String[] words = line.trim().split("\W+");

        String phoneNum = words[1];
        outK.set(phoneNum);

        context.write(outK, value);




    }



}

PartReducer类

package com.partition.maven;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;

import java.io.IOException;



public class PartReducer extends Reducer {
    private IntWritable outV = new IntWritable();
    
    @Override
    protected void reduce(Text key, Iterable values, Context context) throws IOException, InterruptedException {
//        super.reduce(key, values, context);

        context.write(key, values.iterator().next());
    }
}

PartDriver类

package com.partition.maven;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.CombineTextInputFormat;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;

public class PartDriver {

    public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {

        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        job.setJarByClass(PartDriver.class);
        job.setMapperClass(PartMapper.class);
        job.setReducerClass(PartReducer.class);

        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(Text.class);

        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(Text.class);
        
        // 配置分区类
        job.setPartitionerClass(ProvincePartitioner.class);
        
        // 配置ReduceTask的数目,该数决定输出的文件数
        job.setNumReduceTasks(5);


        FileInputFormat.setInputPaths(job, new Path("E:\bigdata\study\test_files\partinput"));
        FileOutputFormat.setOutputPath(job, new Path("E:\bigdata\study\test_files\partoutput"));

        job.setInputFormatClass(CombineTextInputFormat.class);
        CombineTextInputFormat.setMaxInputSplitSize(job, 4194304);

        job.waitForCompletion(true);
    }
}

并设置reduceTask的数量也就是对应 getPartition方法的第三个参数的值,通过第三个参数可以获取

上述相关实例也可以参考这里:MapReduce学习2-1:以官方wordcount实例为例的MapReduce程序学习以及序列化实现案例:MapReduce学习3:序列化

就对如下输入数据

1	13736230513	192.196.100.1	www.dev1.com	2481	24681	200
2	13836230513	192.196.100.1	www.dev1.com	2481	24681	200
3	13636230513	192.196.100.1	www.dev1.com	2481	24681	200
4	13936230513	192.196.100.1	www.dev1.com	2481	24681	200
5	13136230513	192.196.100.1	www.dev1.com	2481	24681	200

那么最终结果是产生5个分区文件(数量同设置的reduceTask的数量),其中仅有0~3代号是分别对应上述不同归属省份的手机号的集合,而代号为4的是一个空文件,为什么是空文件呢?在接下来的规则介绍中详细解析

4.2.1.4 默认分区号0的出现原因和默认分区器

以下解析都是基于没有在客户端自定义分区类的基础上,具体如果自定义分区类,参考上述的案例

上述中有一个问题是:为什么平常我什么都没配置,但是最终会产生一个分区号是0的文件?这里从源码进行解析,我们直接找到源头,打开MapTask.class文件,找到如下方法,该方法是分区的源头

通过上述红色箭头的方法可以返回reduceTask数目,如果我们什么都没设置,那么他的默认值是1,这里可以在源码可以看到。如果使用IDEA,可以通过快捷键:Ctrl+Alt+B,查看他的实现类

查看JobContextImpl类,并一路到conf.getNumReduceTasks()方法中,可以看到
查看getInt方法可以看到,当参数执行的为null的时候,就返回默认的1

查看第一个参数JobContext.NUM_REDUCES,就可以看到下边的对应关系,也就是在配置文件(mapred-site.xml)中配置下边的参数,就可以配置的默认值。而默认的mapred-default.xml是没有配置相关的参数的值,所以在该属性没有定义的情况下返回第二个参数,也就是返回值就是1

public static final String NUM_REDUCES = "mapreduce.job.reduces";

一般我们创建项目直接运行返回的分区数是1,那么经过下边的逻辑,就到了else里边

其主要逻辑就是新建内部的Partitioner类,定义getPartition方法,返回this.partitions - 1,因为上述知道this.partitions 就是1,那么他总是返回0,那么分区相关返回总是0,所以我们之前在没有任何配置的情况下总是产生分区号为0的文件

上述初始状态下,没有任何配置,初始的reduceTask是1的情况下,分区文件的分区号产生的规则,接下来介绍reduceTask数目>1的情况

首先是介绍一个默认分区器,如果使用IDEA,可以通过快捷键查看HashPartitioner类,如下:

其中getPartition方法是用来获取分区的方法,这是重写Partitioner的方法而来。上述HashPartitioner类是默认设置分区的类,也被称为默认分区器

上述默认分区器HashPartitioner类是当设置的ReduceTask的数目>1的时候就会使用,那么为什么他是默认的分区器呢?

假设我们设置的分区数是2并且没有自定义分区类,那么就会调用上述方法,判断分区数 > 1,那么就进入第一层逻辑

就是通过ReflectionUtils反射工具类去创建一个实例,而这个实例所属的类是通过getPartitionerClass获取,实际他是一个抽象的方法,如下

如果使用IDEA,可以通过快捷键:Ctrl+Alt+B,查看他的实现类

这里查看JobContextImpl类,可以看到实现的部分出现了具体的逻辑

其中通过conf.getClass获取类,通过快捷方式点击第一个参数可以看到他的定义

  public static final String PARTITIONER_CLASS_ATTR = "mapreduce.job.partitioner.class";

明显该分区的类可以通过配置文件配置mapreduce.job.partitioner.class属性,就能指定对应的默认分区器,默认配置文件是没有配置该属性的值,如果我们没有自行定义,那么他就会使用第二个参数,也就是HashPartitioner.class,这就是上述的默认分区类。该判断逻辑可以在conf.getClass方法可以看到

如果使用默认分区器,设置reduceTask的数量,例如设置为2,直接运行,同一个程序,会发现原来一个文件的内容被拆分为2个,2个分区文件的内容的总和跟原来一个输出是一样的,并且每次运行,两个文件内容都是相同的,这就是默认分区器计算而来的,因为每个key的hashcode是相同的,Interger的最大值也是不变的,所以无论运行多少次,只要设置的reduceTask的数量不变,都会在同一个特定的文件

上述默认分区器是一个与运算,这么设计是由于整型的最大值使用二进制表示,如果是4字节共32位,它就是0后边31个1,因为hashcode可能是一个负数,但是明显我们的文件名是需要一个正整数的,所以二者与运算,能保证最后返回的余数是一个正整数

从上述源码可知,如果reduceTask是1,那么就不会走默认的分区器或者在配置文件中配置的分区类

4.2.1.4 分区规则

可以看到产生的分区文件是跟设置的reduce数相对应的并且跟getPartition方法的返回值息息相关(返回的数字,就是最后的代号),其中关系如下:

  1. 如果reduceTask的数量>= getPartition 的结果数,则会多产生几个空的输出文件 part-r-000xx ,如上述例子中4个分区,但是设置的reuceTask为5,但是并没有匹配到代码为4的分区,所以即使生成了对应的文件,但是并没有写入内容,这样只会产生几个“空跑”的reduceTask任务,浪费系统资源

  2. 如果 1 < reduceTask 的数量 < getPartition 的结果数,则有一部分分区数据无处安放,会报错

  3. 如果reduceTask 的数量 =1 ,则不管 mapTask端输出多少个分区文件,最终结果都交给这一个 reduceTask ,最终也就只会产生一个结果文件 part-r-00000 (默认也是hashpatitioner分区,只是最终分区到同一文件里了)

分区号必须是一定集合的䛾,也就是如果设置reduceTask数量为2,那么分区号只能是0和1(也就是getPartition方法返回值只能是这两个),而不能是其他

4.2.2 溢出前的分区排序

MapTask和ReduceTask均会对数据按照key进行排序。该操作属于Hadoop的默认行为。任何应用程序中的数据均会被排序,而不管逻辑上是否需要

默认排序是按字典顺序排序,且排序方法是快速排序

分区排序是就分区内进行排序,也就是在一个分区内进行排序,而不是扩展到其他分区,这样做的原因是一个效率的优化的问题以及为输入到reduceTask处理做优化,从实际案例中可以知道,最后在reduce阶段,reduce方法传入的数据是按字典顺序,也就是a~z A~z,也就是例如就官方的wordcount案例的需求而言,输入文件中abc这个单词在第三行bac这个单词在第一行,如果这两个单词被一个reduceTask处理并且是一个分区,那么首先被处理的就是第三行的abc而不是bac,即使从map阶段首先输出的是bac,这个传入的数据的顺序的结果开始就是从这里开始的(因为后续的排序也是这基础上的)

分区排序是在溢出(spill)到磁盘之前,在分区内进行排序。数据在环形缓冲区上的数据量达到一定的阈值后,就会对分区进行一次快速排序。每个数据被getPartition方法处理后返回一个标识号,那么原有处理的键值对的基础上再添加一个partition属性值用以标识分区,也就是最后处理成一个三元组,包括,为分区进行排序以及为后续不同的reduceTask找到属于自己负责的partition做准备

从上述知道map输出的内容,首先是分配到环形缓冲区,而环形缓冲区就其本质而言就是一个数组,那么他的内存是连续的,如果对整体内容进行排序,无疑是增加额外的开销,hadoop中的排序是不移动实际的位置,而仅仅是记录排序的下边,并且仅对key进行排序,例如输入到环形缓冲区属于同一分区中开始的三个位置分别是、、,也就是他们的索引号分别是0、1、2,那么排序过程是不进行移动他们的位置,而是仅仅记录排序后的索引,最终记录索引序列是1、0、2,那么在溢出到指定目录的时候,就将索引对应位置的内容按索引序列依次输出

4.2.3 分区排序后的合并

分区排序之后还可以进行合并,合并是什么呢?就官方的wordcount案例,对于同一个单词,例如这个单词是abc,在两个不同的行都存在1次,在map阶段就会在处理abc单词所在的行的时候,就会分别输这个键值对,如果不做合并,那么分区排序后(假设就是默认一个分区,并且只有abc是a开头的),那么就是两个键值对就在前边

合并就例如上述案例中可以将两个,处理成,这样做的意义是缩小数据量,方便后续的处理、提高后续的处理速率以及减少后续的网络传输的开销(后续reduce阶段可以是通过Http GET请求进行请求数据的),那么这个过程就需要排序,在排序的基础上,前后进行对比,就能更好地合并

合并是可选的,从实际案例中可以知道reduce方法最后同一个键的值会被处理成一个迭代器对象,也就是对于上述的举例,abc单词对应两个1的值,都被放到迭代器里边,对应的是同一个key被一次reduce处理

如果需要合并,只需要自定义Combiner,在MapReduce学习2-1:以官方wordcount实例为例的MapReduce程序学习需要在WordcountDriver.class添加以下代码,其余不变,也就是将重写的Reducer进行作为自定义的Combiner,具体可以参考这里MapReduce快速入门系列(9) | Shuffle之Combiner合并

job.setCombinerClass(WordcountReducer.class)
4.2.4 溢出后的归并

上述经过分区、排序、合并(可选)后输出一系列的临时溢出文件,然后就到归并的阶段,归并阶段的意义是处理多个临时的溢出文件到一个统一的整体,而不是直接提交到reduce,这样会增加reduce的压力,因为通常情况下reduce的数量是比较少的,最后如果临时文件数量比较多,提交到reduce处理,那么最终还是要进行合并,所以该过程需要内部进行消化,也就是归并的过程

归并是对临时溢出文件进一步整理,最终整理成同一个分区的数据在一个部分,不同分区按分区标识排序,各个分区内再排序,生成数据中key和对应的value-list,最终整理输出一个正式的已分区、已排序的大文件,溢出写文件归并完毕后,将删除所有的临时溢出写文件,并告知NodeManager任务已完成。关于NodeManager相关体系结构内容可以参考:Hadoop的体系结构

归并过程排序是使用的归并排序,因为对于不同的临时溢出文件而言,每个分区都是一个部分有序的,使用归并排序,效率更高

如果溢写文件数量超过参数min.num.spills.for.combine的值(默认为3)时,在归过程中可以再次进行合并

综上,如果在自定义了Combiner类,那么就会在以下两个时机被调用

  1. 当为作业设置Combiner类后,缓存溢出线程将缓存存放到磁盘时,就会调用
  2. 在数据归并过程中, 临时溢出文件的数量超过mapreduce.map.combine.minspills(默认3)时会调用
4.2.5 溢出后的压缩

数据序列化后写磁盘前可以压缩map端的输出(如上图),因为这样会让写磁盘的速度更快,节约磁盘空间,并减少传给reducer的数据量。默认情况下,输出是不压缩的

在mapred-site.xml设置mapreduce.map.output.compress设置为true即可启动

4.3 Shuffle处理过程详解【copy(reduce阶段) → sort(reduce阶段)】 4.3.1 copy(reduce阶段)

溢出写文件归并完毕后,将删除所有的临时溢出写文件,并告知NodeManager任务已完成,只要其中一个mapTask完成,reduceTask就开始复制它的输出(Copy阶段分区输出文件通过Http GET的方式提供给reducer)

reduce会启动一些copy相关的进程,用来复制map端的输出,而获取map端的输出如果通过网络传输的方式,那么就是通过Http GET的方式进行获取数据。map端输出以及reduce的copy进程接收复制,中间是通过ApplicationMaster进行处理的,期间信息的交互是基于hadoop的心跳机制,也就是3秒(默认)就会进行询问一次ApplicationMaster,提供自己的状态,同时ApplicationMaster也会给进程返回操作命令

map端完成信息的输出后,会在下一次心跳的时候通知ApplicationMaster并提供相关的信息,例如这个map端在哪个主机及其输出的内容的信息,这个过程ApplicationMaster也能通过心跳机制被reduce的线程访问,总体就完成了一个ApplicationMaster的资源与任务调度的过程,同时ApplicationMaster监控各个任务的状态

不同的reduceTask根据负责的分区(根据分区标识),去复制不同的map的输出内容的对应分区的内容

copy相关的进程数,默认是5,可以在mapred-site.xml配置mapreduce.reduce.shuffle.parallelcopies来改变该数值

map端处理程序输出可能存在差异,所以多个copy线程就在知道map端产生了数据的时候就进行复制所需要的内容

4.3.2 归并

经过copy阶段的数据,首先并不是直接写入到磁盘中,而是首先写入到内存缓冲区中,缓冲区的大小是就由JVM的heap size设定的,并且是heap size的一定比例,而根据Oracle官方文档的说法,JVM的默认堆(heap size)大小如果未指定,它将会根据服务器物理内存计算而来。heap size相关参考这里:闲谈JVM(一):浅析JVM Heap参数配置

内存缓冲区可通过mapred.job.shuffle.input.buffer.percent配置,默认是0.7,也就是JVM的heap size的70%

请求而来的每个map端的输出数据是以一块数据存在内存缓冲区中。对于reduceTask,它从每个mapTask上远程考贝相应的数据文件,如果文件大小超过一定阈值,则溢写磁盘上,否则存储在内存中。如果磁盘上文件数目达到定阈值,则进行—次归并排序以生成一个更大文件;如果内存中文件大小或者数目超过一定阈值,如果自定义了Combiner则进行一次合并后将数据溢写到磁盘上(该过程可选)。当所有数据拷贝完毕后,reduceTask统—对内存和磁盘上的所有数据进行一次归并排序

当数据全部拷贝完全在写入磁盘之后会进行过归并排序

假设reduce1需要分区1的内容,那么就复制分区1的内容),但是分区1的内容来自不同的map输出,并且分区1在之前也是一个局部有序的状态,那么使用归并排序更有效率

4.3.3 分组处理

这里再引用上图例子,经过归并排序后整个分区1相关的文件就整体有序了,然后再按照相同的key进行分组,分组也就是·相同的key·会进入到·一个reduce方法,针对已根据键排好序的Key构造对应的Value迭代器

默认的根据key分组,自定义的可是使用job.setGroupingComparatorClass()方法设置分组函数类

同一个键的vlaue就进入一个迭代器中,并且按排序的顺序(按的字典排序,也就是a-z A-Z),通过遍历可以获取值

以上是整个Shuffle过程

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/679551.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号