目录
一、shuffle工作机制
1.shuffle简介
- 工作阶段:从map阶段传递给reduce阶段的过程,map输出后到reduce接收前,是整个MapReduce框架中最核心的部分
在map阶段:
① MapReduce对要处理的数据进行分片(split)操作 [默认按照128M切片],如果文件不可切(小于128M),将整个文件作为一个切片进行处理
②为每一个分片分配一个MapTask任务 (有几个分片就有几个MapTask)
每一个MapTask在接收到FileSplit之后按行读取,每读取一行调用一次map()方法
③ 接下来map()函数会对每一个分片中的每一行数据进行处理得到键值对(key,value)
此时得到的键值对又叫做“中间结果”
此后便进入shuffle阶段:由此可以看出shuffle阶段的作用是处理“中间结果”
- 定义:洗牌的逆过程,把一组无规则的数据尽量转换为一组具有一定规则的数据
- 默认排序:
默认的数据类型:IntWritable,LongWritable,NullWritable…Text等
默认的类型都实现了WritableComparetable接口在shuffle过程中,先按照map输出的key进行排序:① 如果key是数值类型,按大小排序
② 如果key是字符串类型,按照字典顺序升序排序
- ?MapReduce计算模型为什么需要shuffle过程?
MapReduce计算模型一般包括两个重要的阶段:
① map是映射,负责数据的过滤分发
② reduce是规约,负责数据的计算归并
reduce的数据来源于map,map的输出是reduce的输入,所以:reduce需要shuffle来获取数据
- 简而言之,将MapTask输出的处理结果数据分发给ReduceTask,并在分发的过程中,对数据按key进行了分区和排序
2.shuffle主要流程
排序(sort)、溢写(spill)、合并(merge)、拉取拷贝(copy)、合并排序(merge sort)
- shuffle的每一个处理步骤是分散在各个MapTask和ReduceTask节点上完成
整体来看,具体操作如下:
① partition (分区,必要)
② sort(根据key排序,必要)
③ combiner(进行局部value的合并,非必要)
④ GroupingComparator(分组)
3.map端的shuffle过程
!每一个MapTask执行一遍此流程!
(1)collect(收集)阶段
map()方法通过context.write()开始输出数据时,便进入MapTask的shuffle阶段
不是单纯将数据写入磁盘
- 过程:maptask会收集map()方法输出的K-V对→放到内存缓冲区中
每一个maptask有一个环形内存缓冲区,用于存储任务的输出、默认大小100M
- ?什么是环形缓存区?
是内存中的一种首尾相连的数据结构,专门用来存储K-V格式的数据,可称为kvbuffer
- ?为什么要用环形缓冲区?
便于写入缓冲区和写出缓冲区同时进行
- ?为什么不等缓冲区满了再spill?
后果:会导致溢出到磁盘的过程中
不能写入好处:保持缓冲区一直有剩余空间可以写,使读写不冲突,提高吞吐量
- 往环形缓冲区添加数据的过程(+spill)
三个变量:① kvstart 表示当前已写的数据的开始位置
② kvindex 表示下一个可写的位置
③ kvend
正常写入:一开始添加数据时kvend=kvstart → kvindex递增
准备写入:触发spill的时候:kvend=kvindex → spill值涵盖从kvstart至kvend-1区间的数据,kvindex不受影响,继续按照进入的数据递增
spill完毕:kvindex增加、kvstart移动到kvend处
由上面可以看出:kvend一般情况下不变,只有当要读取kvbuffer中的数据时发生一次改变 【即设置kvend=kindex】
分区(partition)是发生在溢写之前,即当满足溢写条件时,首先进行分区,然后分区内排序(2),并且选择性的combiner,最后溢写到磁盘(3)
(2)sort(排序)阶段
先把kvbuffer中的数据按照partition值和key值两个关键字排序 移动的只是索引数据
排序结果:kvmeta中的数据按照partition为单位聚集在一起,同一partition内的按照key有序
(如果有combiner,还要对排序后的数据执行combiner,这是进行局部的value合并)
(3)spill(溢写)阶段
当内存中的数据量达到一定的阈值80%(当默认kvbuffer大小为100MB时,即80MB),一个后台线程就会不断地将数据溢出到本地磁盘文件上,可能会溢出多个文件
刷到磁盘的过程以分区为单位:一个分区写完,写下一个分区,分区内的数据有序,最终会多次溢写,生成多个溢出文件
(4)merge(合并)阶段
磁盘上有多个溢写文件存在,但最终的文件只有一个,所以要将这些溢写文件归并到一起
- 过程
相同分区合并成一个片段(segment)
最终所有的segment组装成一个最后文件
4.reduce端的shuffle过程
!注意!:不是reduce操作开始执行,shuffle阶段reduce不会运行的!
- map完成 → 通过心跳将信息传给NodeManager → 通知ResourceManager → ReduceTask开始shuffle工作
(1)fetch copy(拉取拷贝)阶段
- reduce端默认有5个数据复制线程从map端复制数据,通过http方式得到map对应分区的输出文件
- 每个MapTask的完成时间不同,只要有一个任务完成 ReduceTask就开始复制其输出
- copy过来的这些数据默认保存在内存的缓冲区中,(这里的缓冲区大小要比 map 端的更为灵活,它基于 JVM 的 heap size 设置) → 内存的缓冲区达到一定的阈值的时候,把数据写到磁盘
- 【Reducer如何知道自己应该处理哪些数据呢?】
Map端进行partition时,就指定了每个Reducer要处理的数据(partition就对应了Reducer)
所以Reducer在拷贝数据的时候只需拷贝与自己对应的partition中的数据
每个Reducer会处理一个或者多个partition
- 【reducer如何知道要从哪台机器上的map输出接受数据呢?】
map任务完成后,它们会使用心跳机制通知它们的application master
对于指定作业,application master知道map输出和主机位置之间的映射关系
reducer中的一个线程定期询问master以便获取map输出主机的位置
(2)merge sort(合并排序)阶段
- 远程复制数据时,会在后台开启两个线程:① 内存到磁盘的合并 ② 磁盘到磁盘的合并
目的是为了对内存中和本地磁盘中的数据文件进行合并操作,防止内存使用过多或磁盘上文件过多
2.合并的同时,进行排序操作
由于MapTask阶段已经对数据进行了局部排序,所以ReduceTask只需要对所有的数据进行一次归并排序即可
- 内存到内存merge:如果内存缓冲区中能放得下这次数据的话,直接把数据写到内存中
- 内存到磁盘merge:Reduce向每个Map去拖取数据,在内存中每个Map对应一块数据 → 当内存缓存区中存储的Map数据占用空间达到一定程度 → 开始启动内存中merge,把内存中的数据merge输出到磁盘上一个文件中
与map端的溢写类似,可以在输出合并写入磁盘之前:设置combiner
- 磁盘到磁盘merge:某一个reducer的map输出全部拷贝完成,在reducer上会生成多个文件 → 开始执行合并操作:采取的排序方法跟map阶段不同,因为每个map端传过来的数据是排好序的,所以众多排好序的map输出文件在reduce端进行合并时采用的是归并排序,针对键进行归并排序。
一般Reduce是一边copy一边sort,即copy和sort两个阶段是重叠而不是完全分开的
最终Reduce shuffle过程会输出一个整体有序的数据块
(3)reduce阶段
reduce的输入文件确定后,整个shuffle操作最终结束
shuffle全过程的总结
- map阶段的输出是写入本地磁盘而不是
HDFS,但一开始数据是先缓冲在内存中 - 缓存的好处:减少磁盘I/O的开销,提高合并和排序的速度
- 内存缓冲区(kvbuffer)的大小默认是100M (原则上缓冲区越大,磁盘I/O的次数越少,执行速度就越快 → 编写map()函数的时候尽量减少内存的使用,为shuffle过程预留更多的内存,因为此过程很耗时)
二、MapReduce中的序列化
1.序列化概述
1.1 序列化的定义
- 序列化(serialization) : 把结构化对象(object)转化成字节流(ByteStream) 【把内存中的对象→ 转换成字节序列】
- 反序列化(deserialization) :序列化的逆过程
1.2 进行序列化的原因
一般来说,“活的” 对象只能生存在内存里,关机断电就没有了。 而且“活的”对象只能由本地的进程使用,不能被发送到网络上的另外一台计算机
序列化可以存储“活的”对象,可以将“活的”对象发送到远程计算机
① 永久性保存对象 保存对象的字节序列到本地文件中或者数据库中
② 通过序列化以字节流的形式使对象在网络中进行传递和接收
③ 通过序列化在进程间传递对象
1.3 不使用Java的序列化的原因
Java的序列化是一个重量级序列化框架(serializable) ,一个对象被序列化后,会附带很多额外的信息(各种校验信息, Header, 继承体系等),不便于在网络中高效传输。
1.4 Hadoop的序列化机制
Writable
Java与Hadoop基本类型对比:
Java类型 | Hadoop Writable类型 |
byte | ByteWritable |
short | ShortWritable |
int | IntWritable |
long | LongWritable |
float | FloatWritable |
double | DoubleWritable |
string | Text |
null | NullWritable |
1.5 Hadoop序列化特点
- 紧凑: 高效使用存储空间
- 快速: 读写数据的额外开销小
- 可扩展: 随着通信协议的升级而可升级
- 互操作: 支持多语言的交互
2.Java序列化
java序列化的过程与平台无关:一个java对象可以在一个平台上序列化之后传输到另一个平台上进行反序列化
需要序列化的Java类必须实现一个特殊的标记接口——serializable
- 实现java.io.Serializable接口的类可以进行序列化/反序列化
- 标记接口
不含有任何成员和方法- 作用是标记一组类,这些类有相同的特定的功能(常见的标记接口:cloneable)
2.1 实现serializable接口
一个类的对象若要序列化成功,必须满足两个条件:
① 该类必须实现java.io.Serializable对象
② 该类的所有属性必须是可序列化的
声明为 static(代表类的状态)和 transient(代表对象的临时数据)类型的成员数据不可序列化
2.2 序列化
将此类的对象序列化后保存到磁盘上的步骤:
- 创建一个ObjetOutputStream输出流oos
- 调用此输出流oos的writeObject()方法写对象
(1)ObjectOutputStream:对象的序列化流
作用:把对象转成字节数据输出到文件中保存,对象的输出过程称为序列化,可实现对象的持久存储
构造方法如下:
//创建写入指定OutputStream的ObjectOutputStream,参数out为字节输出流
ObjectOutputStream(OutputStream out)
(2)FileOutputStream: 文件字节输出流
作用:将数据写入本地文件
提供了4个常用构造方法,用于实例化FileOutputStream对象,不同的场景使用不同的构造方法:
① 场景1:使用file对象打开本地文件,从文件读取数据
② 场景2:
不使用file对象,直接传入文件路径
(3)file对象:代表磁盘中实际存在的文件和目录
序列化对象到文件:
new ObjectOutputStream(new FileOutputStream(new File(文件路径)))
2.3 反序列化
从文本文件中将此对象的字节序列恢复成Student对象
1. 创建一个ObjectInputStream输入流ois
2. 调用此输入流ois的readObject()方法读取对象
(1)ObjectInputStream:对象的反序列化流
作用:将之前使用ObjectOutputStream序列化的原始数据恢复为对象,以流的方式读取对象
构造方法如下:
//创建从指定InputStream读取的ObjectInputStream,参数in为字节输入流
ObjectInputStream(InputStream in)
(2)FileInputStream:文件字节输入流
作用:读取本地文件中的数据
在读取文件时,常用的两个场景有:
① 场景1:使用file对象打开本地文件,从文件读取数据
② 场景2:
不使用file对象,直接传入文件路径
(3)file对象:代表磁盘中实际存在的文件和目录
将文件中的对象读取出来:
new ObjectInputStream(new FileInputStream(new File(文件路径)));
3.实现MapReduce框架的序列化
Hadoop自身的序列化存储格式就是实现了writable接口的类,此接口定义了两个方法:
① 使用write(DataOutput out)方法将数据写入二进制数据流中
② 使用readFields(DataInput in)方法从二进制数据流中读取数据
自定义bean对象实现序列化接口(Writable)
具体实现bean对象序列化步骤如下:
① 必须实现Writable接口 (!bean对象要是想能够传输,必须实现可序列化接口!)
② 反序列化时:需要反射调用空参构造函数,所以必须有空参构造
③ 重写序列化方法
④ 重写反序列化方法
⑤ 注意反序列化的顺序和序列化的顺序完全一致
⑥ 要想把结果显示在文件中,需要重写toString(),可用”\t”分开,方便后续用
⑦ 如果需要将自定义的bean放在key中传输,则还需要实现Comparable接口,因为MapReduce框中的Shuffle过程要求对key必须能排序
package org.flowsum;
import org.apache.hadoop.io.Writable;
import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
//① 实现Writable结构
public class FlowBean implements Writable {
//自定义对象的成员变量有以下三个
private long upFlow; //①上行流量
private long downFlow; //②下行流量
private long sumFlow; //③总流量
//② 反序列化时,需要反射调用空参构造函数,必须有空参构造
public FlowBean(){
super();
}
//为了对象的数据的初始化方便,加入一个带参的构造函数
public FlowBean(long upFlow,long downFlow){
super();
this.upFlow = upFlow;
this.downFlow = downFlow;
this.sumFlow = upFlow;
this.sumFlow = upFlow + downFlow;
}
public long getUpFlow(){
return upFlow;
}
public void setUpFlow(long upFlow){
this.upFlow = upFlow;
}
public long getDownFlow(){
return downFlow;
}
public void setDownFlow(long downFlow){
this.downFlow = downFlow;
}
public long getSumFlow() {
return sumFlow;
}
public void setSumFlow(long sumFlow){
this.sumFlow = sumFlow;
}
@Override
//③ 重写序列化方法
public void write(DataOutput out) throws IOException{
out.writeLong(upFlow);
out.writeLong(downFlow);
out.writeLong(sumFlow);
}
@Override
//④ 重写反序列化方法
public void readFields(DataInput in)throws IOException{
this.upFlow = in.readLong();
this.downFlow = in.readLong();
this.sumFlow = in.readLong();
}
@Override
//重写toString(),将结果显示在文件中
public String toString(){
return upFlow + "\t" +downFlow + "\t" + sumFlow;
}
}
4.实例
三、MapReduce之自定义排序
1.WritableComparable排序
- MapTask和ReduceTask均会对数据按照key排序
- 该操作属于Hadoop的默认行为,任何应用程序中的数据均会被排序,而
不管逻辑上是否需要- 默认排序是按照字典顺序排序
- MapTask
将处理的结果暂时放到kvbuffer中,当kvbuffer使用率达到一定阈值,再对缓冲区中的数据进行一次排序,并将这些有序数据溢写到磁盘上,而当数据处理完毕后,它会对磁盘上所有文件进行归并排序
- ReduceTask
从每个MapTask上远程拷贝相应的数据文件,如果文件大小超过一定阈值 → 则溢写磁盘上,反之存储在内存中
1.如果内存中的文件大小或者数目超过一定阈值,则进行一次合并后将数据溢写到磁盘
2.如果磁盘上的文件数目达到一定阈值,则进行一次归并排序以生成一个更大文件
1.1排序分类
(1)部分排序
MapReduce根据输入记录的键对数据集排序,保证输出的每个文件内部有序
(2)全排序
最终输出结果只有一个文件,且文件内部有序
实现方式:只设置一个ReduceTask
缺点:在处理大型文件时效率极低,因为一台机器处理所有文件,
完全丧失了MapReduce提供的并行架构
(3)辅助排序(GroupingComparator分组)
在reduce端对key进行分组
应用于:在接收key为bean对象时,想让一个或几个字段相同(全部字段比较不相同)的key进入同一个reduce()方法
(4)二次排序
在自定义排序过程中,若compareTo()方法中的判断条件为两个
1.2自定义排序WritableComparable
自定义bean对象将其作为map输出的key来传输,需要实现WritableComparable接口 重写compareTo()方法,便可以实现排序
WritableComparable继承自writable和java.lang.Comparable接口,是一个writable也是一个comparable,即:可以序列化,也可以比较!
comparable可以认为是一个内比较器,实现comparable接口的类有一个特点:这些类可以和自己比较
和另一个实现comparable接口的类如何比较:依赖compareTo()方法的实现
(1)compareTo()方法语法结构:
int compareTo(T o)
① 参数:o表示要比较的对象
② 作用:比较此对象与指定对象的顺序
③ 返回值:负整数、0或正整数
- 正整数:比较者>被比较者
- 0 :比较者 = 被比较者
- 负整数 :比较者 < 被比较者
(2)实现自定义排序的步骤
① 定义一个bean类(Java标准类),实现WritableComparable接口
② 重写序列化方法:write(DataOutput out)和反序列化方法:readFields(DataInput in)
③ 重写compareTo()方法,实现自定义排序
④ 要想把结果显示在文件中,需要重写toString()方法,可用“\t"分开,方便后续用
import org.apache.hadoop.io.WritableComparable;
import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
//自定义排序:实现WritableComparable接口重写compareTo()方法
public class FlowBeanSort implements WritableComparable<FlowBeanSort> {
private long upFlow; //总上行流量
private long downFlow; //总下行流量
private long sumFlow; //总流量
//无参构造方法,目的是为了反序列化操作创建对象实例时调用无参构造器
public FlowBeanSort()
{
super();
}
//序列化方法,目的是为了对象的初始化
public FlowBeanSort(long upFlow, long downFlow, long sumFlow){
super();
this.upFlow = upFlow;
this.downFlow = downFlow;
this.sumFlow = sumFlow;
}
public long getUpFlow(){
return upFlow;
}
public void setUpFlow(long upFlow){
this.upFlow = upFlow;
}
public long getDownFlow(){
return downFlow;
}
public void setDownFlow(long downFlow) {
this.downFlow = downFlow;
}
public long getSumFlow() {
return sumFlow;
}
public void setSumFlow(long sumFlow) {
this.sumFlow = sumFlow;
}
@Override
//重写序列化方法,将对象的字段信息写入输出流
public void write(DataOutput out) throws IOException{
out.writeLong(upFlow);
out.writeLong(downFlow);
out.writeLong(sumFlow);
}
@Override
//重写反序列方法,从输入流中读取各个字段信息
//注意:字段的反序列化顺序必须与序列化保持一致,并且参数类型和个数也一致
public void readFields(DataInput in) throws IOException{
this.upFlow = in.readLong();
this.downFlow = in.readLong();
this.sumFlow = in.readLong();
}
@Override
//重写compareTo()方法,实现自定义序列,按照总流量倒序排序
public int compareTo(FlowBeanSort o){
//自定义降序排序
return this.sumFlow > o.getSumFlow()?-1:1;
}
@Override
//重写toString()方法
public String toString(){
return upFlow + "\t" + downFlow + "\t" +sumFlow;
}
}
2.实例
四、MapReduce之自定义分区
1.partitioner分区
每个maptask处理完数据➡️(若存在自定义combiner类,先进行一次本地的reduce操作)➡️把数据发送到partitioner➡️partitioner决定每条记录送往到哪个reduce节点
2.默认分区介绍
默认使用的是HashPartitioner,专门处理mapper任务输出
- 核心代码
public class HashPartitioner<K,V> extends Partitioner<K,V>{
/**Use {@link Object#hashcode()} to partition.*/
public int getPartition(K key,V value,
int numReduceTasks){
return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;}
}
- getPartition()函数
1.作用
①. 获取key的哈希值
②. 默认分发规则:根据key的hashcode%reducetask数来分发
③. 把<key,value>对均匀地分发到各个对应编号的ReduceTask节点上,达到ReduceTask节点的负载均衡
2.三个形参
key:mapper任务的键输出
value:mapper任务的值输出
numReduceTasks:设置的ReduceTask数量,默认值是1
- 方法解析
(key.hashCode() & Integer.MAX_VALUE) % numReduceTasks
①. (key.hashCode():对map输出的key取hashCode值
②. &:Java中的位运算符,在数据的二进制层面上按位与的意思(对应的两个二进位均为1:结果位为1,反之为0)
③. Integer.MAX_VALUE:int类型的最大值,最高位为0,符号位,表示是正数,任何数和0进行运算都得0,都是正数
④. %:求余运算
保证任何map输出的key在与numReduceTasks取模后决定的分区为正整数
numReduceTasks为1的时候,任何整数与1相除的余数肯定是0➡️即此方法的返回值总是0
MapTask的输出总是送给一个ReduceTask,只能输出到一个文件中【默认分区】
3.自定义分区
按照一定的规则让getPartition()方法的返回值是0,1,2,3···即可
定制自己的partition
1. 需要几个分区输出➡️设置几个reduce
2. 自定义partition:分区要根据需求进行具体的分区,不是
根据key的hash码来分区
具体步骤
1.第一步
① 创建一个类继承抽象类partitioner ,此key- value——map端的输出数据的key-value类型
② 重写getPartition()方法
2.第二步
在driver端给任务设置需要执行的分区类:
job.setPartitionerClass(建的分区类);
3.第三步
根据自定义分区逻辑设置相应数量的ReduceTask
job.setNumReduceTasks(相应数量);
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Partitioner;
import org.sort.FlowBeanSort;
//输入的是自定义序列化里面:<封装的{总上行流量、总下行流量、总流量},手机号>
public class TelePartitioner extends Partitioner<FlowBeanSort,Text> {
//重写自定义分区方法
@Override
public int getPartition(FlowBeanSort key, Text value, int numPartitions) {
//获取手机号的前3位
String preNum = value.toString().substring(0,3);
//定义分区号,从0开始,最大的分区号为6
int partition = 6;
//使用if...else判断,将手机号的前三位和分区号进行对应
if("134".equals(preNum)) {
partition = 0;
}else if("135".equals(preNum)) {
partition = 1;
}else if("136".equals(preNum)) {
partition = 2;
}else if("137".equals(preNum)) {
partition = 3;
}else if("138".equals(preNum)) {
partition = 4;
}else if("139".equals(preNum)) {
partition = 5;
}
//返回分区号
return partition;
}
}
⚠️注意
1.ReduceTask数量>getPartition的结果数:多产生几个空的输出文件part-r-000xx
2.1<ReduceTask数量<getPartition的结果数:一部分分区数据无法安放,会exception
3.ReduceTask=1:无论MapTask输出几个分区文件,最终结果全交给这一个ReduceTask,也只会产生一个结果文件part-r-00000