MapReduce核心原理
MapTask 运行机制详解
MapTask 流程
详细步骤:
- 读取数据的组件 InputFormat 会通过 getSplits 方法对输入目录中文件进行逻辑切片规划得到 splits,有多少 split 就对应启动多少个 MapTask。split 与 block 的对应关系默认是一对一。
- 将输入文件切分为 splits 之后,由 RecordReader(默认是 LineRecordReader)对象进行读取,以\n 作为分隔符,读取一行数据,返回<key,value>。key 表示每行首字符偏移值,value 表示这一行文本内容
- 读取 split 返回<key,value>,进入用户自己实现的 Mapper 类中,执行用户重写的 map 函数
- map 函数执行完后,将 map 的每条结果通过 context.write 进行 collect 数据收集。在 collect 中,会先对其进行分区处理,默认使用 HashPartitioner
MapReduce 提供了 Partitioner 接口,它的作用是可以根据 key 或者 value 及 reduce 的数量来决定这对输出数据交由哪个 reduce task 处理。默认的算法是对 key 进行 hash 然后再以 reduce task 数量取模。默认的取模方式只是为了平均 reduce 的处理能力,如果用户自己对 Partitioner 有需求,可以订制并设置到 job 上。
- 接下来,会将数据写入内存,内存中这片区域叫做环形缓冲区,缓冲区的作用是批量收集 map 结果,减少磁盘 IO 的影响。我们的 key/value 对以及 Partition 的结果都会被写入缓冲区。当然写入之前,key 值和 value 值都会被序列化成字节数组。
环形缓冲区其实是一个数组,数组中存放着 key、value 的元数据信息,包括 partition、key 的起始位置、value 的起始位置、value 的长度。环形结构是一个抽象的概念。
缓冲区有大小限制,默认是 100MB。当 map task 的输出结果超过阈值(总大小的 0.8,由 spill.percent 控制),就会往磁盘写数据。这个从内存往磁盘写数据的过程被称为 Spill,中文可译为溢写。溢写操作会单独开一个线程,并锁定这 80M 内存,执行写入,而 map task 的输出结果还能往剩下的 20MB 内存中写。
- 当溢写线程启动后,需要对这 80M 空间内的 key 进行排序。
如果 job 设置了 Combiner,那么 Combiner 将对相同 key 的键值对进行合并,减少溢写到磁盘的数量。Combiner 会优化 MapReduce 的中间结果,所以在整个模型中会多次使用。
那哪些场景才能使用 Combiner 呢?Combiner 的输出是 Reducer 的输入,且 Combiner 绝不能改变最终的计算结果。Combiner 只能用于那种 Reduce 的输入和输入类型完全一致,且不影响最终结果的场景。如:累加、求最大值等等。
- 合并溢写文件:每次溢写会在磁盘上生成一个临时文件,如果 map 的输出结果真的很大,有多次溢写发生,就有产生多个临时文件。当整个数据处理结束后,会对磁盘的临时文件进行合并,因为最终的文件只有一个。而且会为这个合并后的一个文件提供一个索引文件,记录每个 reduce 对应数据的偏移量。
MapTask 的并行度
MapTask 的并发度由切片决定。
举例:如果现在有 2 个文件,文件 a 大小 300M,文件 b 大小 100M。
一个 block 块是 128M,那么文件 A 分为了 3 块,文件 B 分为了一块。
注意:如果文件 B 是 129M,它并不会分为 2 块,它是允许超一点的。
Reduce Task 运行机制详解
Reduce 大致分为 copy、sort、reduce 三个阶段。
详细步骤:
- Copy 阶段,Reduce 进程启动一些数据 copy 线程(Fetcher),通过 HTTP 方式请求 maptask 获取属于自己的文件
- Merge 阶段,这里的 merge 如 map 端的 merge 动作,只是数组中存放的是不同的 map 端 copy 来的数值。copy 过来的数据会先放入内存缓冲区中,这里的缓冲区大小比 map 端的更灵活。
merge 有三种形式:内存到内存、内存到磁盘、磁盘到磁盘。默认情况下第一种形式不启用。当内存中的数据量到达一定阈值,就启动内存到磁盘的 merge。与 map 端类似,这也是溢写的过程,这个过程中如果你设置有 Combiner,也是会启用的,然后再磁盘中生成了众多的溢写文件。第二种 merge 方式一直在运行,直到没有 map 端的数据时才结束,然后启动第三种磁盘到磁盘的 merge 方式生成最终的文件。
- 合并排序。把分散的数据合并成一个大的数据后,还会再对合并后的数据排序。
- 对排序后的键值对调用 reduce 方法,键相等的键值对调用一次 reduce 方法,每次调用产生零个或者多个键值对,最后把这些输出的键值对写入到 HDFS 文件中。
ReduceTask 并行度
ReduceTask 的并行度同样影响整个 job 的并发度和效率,ReduceTask 的数量是可以手动设置的:
job.setNumReduceTasks(4);
注意事项:
- ReduceTask 设置为 0,表示没有 Reduce 阶段,输出文件数和 MapTask 数量保持一致。
- ReduceTask 不设置就是默认为 1,输出文件数量为 1 个
- 如果数据分布不均匀,可能再 Reduce 阶段产生数据倾斜。
Shuffle 机制
map 阶段处理的数据如何传递给 reduce 阶段,是 MapReduce 框架中最关键的一个流程,这个流程叫做 shuffle。
简单的说 shuffle 就是 MapTask 的 map 方法之后,ReduceTask 的 reduce 方法之前的数据处理过程。
核心流程就是:数据分区、排序、分组、combine、合并等。
1. MapReduce 分区和 ReduceTask 的数量
再 MapReduce 中,通过我们指定分区,将同一个分区的数据发给同一个 reduce 中处理(默认是相同 key 去往同一个分区)。
如何保证相同 key 的数据去往同一个 reduce 呢?只需要保证相同 key 的数据发到同一个分区即可。
自定义分区
- 自定义类继承 Partitioner
- Driver 类设置自定义分区类
job.setPartitionerClass(CustomPartitioner.class);
实战演练:将下面不同厂商的手机的售卖数量进行汇总分别输出到不同的文件区。
iphone_12 34
iphone_13 90
iphone_10 201
xiaomi_8 44
xiaomi_9 900
代码示例:
public class PhoneMapper extends Mapper<LongWritable, Text,Text, IntWritable> {
@Override
protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, IntWritable>.Context context) throws IOException, InterruptedException {
String valueStr = value.toString();
String[] split = valueStr.split(" ");
Text outKey = new Text(split[0].split("_")[0]);
IntWritable outValue = new IntWritable();
outValue.set(Integer.valueOf(split[1]));
context.write(outKey,outValue);
}
}
public class PhonePartition extends Partitioner<Text, IntWritable> {
@Override
public int getPartition(Text text, IntWritable intWritable, int i) {
String s = text.toString();
String phoneName = s.split("_")[0];
if(phoneName.equals("xiaomi")){
return 1;
}else if(phoneName.equals("iphone")){
return 0;
}
return 0;
}
}
public class PhoneReduce extends Reducer<Text, IntWritable, Text,IntWritable> {
@Override
protected void reduce(Text key, Iterable<IntWritable> values, Reducer<Text, IntWritable, Text, IntWritable>.Context context) throws IOException, InterruptedException {
int sum=0;
for (IntWritable value : values){
sum+=value.get();
}
context.write(key,new IntWritable(sum));
}
}
public class PhoneDriver {
public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException {
Configuration conf = new Configuration();
Job job=Job.getInstance(conf,"PhoneDriver");
//指定本程序的jar包所在的路径
job.setJarByClass(PhoneDriver.class);
//指定本业务job要使用的mapper/Reducer业务类
job.setMapperClass(PhoneMapper.class);
job.setReducerClass(PhoneReduce.class);
//指定mapper输出数据的kv类型
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(IntWritable.class);
//指定reduce输出数据的kv类型
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(IntWritable.class);
//指定job的输入文件目录和输出目录
FileInputFormat.setInputPaths(job,new Path(args[0]));
FileOutputFormat.setOutputPath(job,new Path(args[1]));
job.setNumReduceTasks(2);
job.setPartitionerClass(PhonePartition.class);
boolean result = job.waitForCompletion(true);
System.exit( result ? 0: 1);
}
}
执行结果:
需要注意的是:自定义的分区数量和 reduceTask 数量要保持一致
如果分区数量不止 1 个,但是 reduceTask 数量 1 个,此时只会输出一个文件
如果 reduceTask 数量大于分区数量,但是输出多个空文件
如果 reduceTask 数量小于分区数量,有可能会报错
2. MapReduce 中的 Combiner
Combiner 组件的父类就是 Reducer,它和 Reducer 的区别在于运行的位置。
Combiner 在每一个 maptask 所在的节点运行,它的意义就是对每一个 maptask 的输出进行局部汇总,以减少网络传输量。
Combiner 能够应用的前提是不影响最终的业务逻辑,此外 Combiner 的输出 kv 应该和 reducer 输入 kv 的类型对应。
自定义 Combiner 实现
- 自定义类继承 Reducer,重写 reduce 方法
- 在 Driver 类中设置使用 Combiner
实战演练: 我们改造上面的统计手机售卖数量的程序,提前进行数据的统计,减少网络传输。
public class PhoneCombiner extends Reducer<Text, IntWritable,Text,IntWritable> {
@Override
protected void reduce(Text key, Iterable<IntWritable> values, Reducer<Text, IntWritable, Text, IntWritable>.Context context) throws IOException, InterruptedException {
int sum =0;
for (IntWritable intWritable:values){
sum += intWritable.get();
}
context.write(key,new IntWritable(sum));
}
}
在 Driver 里进行设置 Combiner 类
job.setCombinerClass(PhoneCombiner.class);
debug 跟踪发现原本 reduce 需要执行 6 次的,现在只执行了两次。说明在前面的阶段合并成功了。