一、需求分析
有以下的数据:
数据的每列的意思是:年月日、码值(代表一个中文意思,比如1表示北京等地域信息)、温度。
需要计算出每个月气温最高的两天。
1、最初的解决方案
如何将这一批数据按组划分?
实际上需要使用到map去进行分组。那么怎么去划分key呢?我们的需求是需要知道气温最高的两天,那能否取日期的“年-月”充当key,而把具体某一天去掉? 答案是不行的,因为这样算出的数据可能会出现最高气温的两天是同一天的记录,因为没有“日”,也就是没有精确到某一天作为key,故此数据是不精确的。
所以我们需要将"年-月"作为一个key,然后利用map按key的这种格式进行分组,与此同时,我们的”日“也是不可以丢掉的,我们可以将日拼接到value前面,用逗号隔开。
2018-4 23,22
# 这一组数据表达的就是2018-4-23 气温22度。
map无疑是只做了一个按key分组的动作,然后使用分区器partitioner确定每一个key-value的分区号,放进环形缓冲区中,等待SpillThread
(溢写线程)按key和分区号进行排序生成内部有序外部无序的小文件,然后reduce再拉取对应分区号已排好序的分组记录。
reduce拉取到对应的分组记录后,它需要对拉取过来的分组记录,每一组内的所有气温数据进行遍历比较,使用两个变量,遍历气温,对比日期是否为同一天,如果是,还需要做去重过滤等复杂的逻辑。因此,reduce端的计算压力就比较大。
2、更好的key设计方案
能否对上面这个逻辑进行调优呢?
首先我们要知道,一个map是否合理的对key划分并且排序,对reduce端的计算性能影响是十分重要的。我们的切入点应该在map端key的设计。你想下,能否将气温也做成key中的一员,然后“日”充当value呢?也就是下面的构造:
key = "年-月-气温" value="日"
答案是ok的,除此之外,我们需要对key做一个排序处理,以及分区计算的处理。因为默认的情况下,map端的分区器(partitioner)是 “使用整个key的hash值取模reducer数量” 用来确定分区号的。
因此在分区器上,我们需要自定义,按照以下的逻辑对key进行分区号计算:
hash("年-月")%reducer_num
也就是刻意不加上温度,而是将""年-月"的hash做分区处理。这样相同"年-月"就肯定会被份到同一个区号,也就是被同一reudcer处理。
除此之外,对key的排序处理也是需要自定义的:
我们将key按照其中的气温值做一个倒序比较。
最终经过mapTask处理后,会得到同一个月的气温倒序的key-value对,例如:
key(年-月-气温) value(日) 分区号
2019-6-50 2 0
2019-6-40 3 0
2019-6-39 4 0
2019-5-50 5 1
2019-5-40 7 1
2019-5-39 4 1
那么reducer0进行拉取的时候,取得下面的分组数据:
2019-6-50
2019-6-40
2019-6-39
在reduce端会将这三条记录视为3组,因为reduce会默认按照map拉取回来的整个key做比较,判断是否为同一组,故此会将不同温度但同月的key视作不同组。因此我们需要在reducer端自定义实现一个分组比较器,因为我们最终目的是找到某个月的最高两天气温。
由于map端已经按"年-月-气温"这个key其中的气温倒序来排列key-value了,故此分组比较器拉过来同一个月的气温数据时,这些数据已经是按照气温倒叙来排好顺序了,这样这些数据的第一条就是当月气温的最高值,我们仅仅需要使用两个变量,一个用来记录气温最高值,一个用来记录气温第二高的值。而且,reduce分组比较器并不需要遍历这个月份的所有气温,只需要拿到最高气温中value记录的”日期“信息,然后取下一条数据做日期对比,只要和最高气温的”日“不一样,那就是次高温!
3、另一种key的设计方案
我们还可以将key设置成如下格式:
key = "年-月-日-温度"
实际上原理和上面将的一样,也是需要我们自定义key的排序比较器、分区器、以及reduce端的分组比较器这些组件,来按照温度的倒序进行排序、获取。 这样设计的key相当于一串字符串,字符串在jvm来说是最最复杂的东西;在分析的时候,我们需要对这个key的字符串进行切割,拿出年-月、温度、日,这几个维度信息,然后做比较等操作。
当然我们也可以自定义一种key的类型,也就是不用hadoop为我们提供的远程key类型,比如TextForamat等。但是自定义Key类型的时候,我们也要定义好 key的序列化/反序列化方法、key的排序比较器。因为我们的key是通过map进行分组确认的,最终需要溢写到磁盘存放,这就涉及到了序列化,然后reduce端会拉取这部分文件再将key加载到内存中,这就涉及到反序列化。我们的key也是需要实现按哪些维度去排序,也就涉及到了key的排序器。
注意:当用户在map端指定了sorter排序器的时候,就会覆盖掉key本身的排序器!
二、功能实现
代码实现上面的需求,我们采用key = ”年-月-日-温度“
这种格式处理。首先我们需要编写下Job任务的入口启动类
1、Job启动类
package com.haizhang.hadoop.mapredcue.topn;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;
import org.apache.hadoop.util.GenericOptionsParser;
import java.io.IOException;
public class MyTopN {
public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
//默认加载我们放在classpath下的配置文件
Configuration conf = new Configuration(true);
//构造一个job实例
Job job = Job.getInstance(conf);
//接收外部传来的参数,如果是-D的参数,设置到hadoop配置文件key-value中,其余的是用户自定义传参,比如读取文件的hdfs路径
String[] customArgs = new GenericOptionsParser(conf, args).getRemainingArgs();
//设置job的启动配置类,和job名字
job.setJarByClass(MyTopN.class);
job.setJobName("TopN");
//接下来,mapReduce任务必须要设置的,mapTask和ReduceTask的执行逻辑。(核心步骤)
//1.MapTask
//input 输入设置 、output map输出配置
//这里默认customArgs用户传入自定义的第一个参数是mapTask的输入路径
TextInputFormat.addInputPath(job,new Path(customArgs[0]));
//第二个参数是mapTask的输出路径
Path outputPath = new Path(customArgs[1]);
//如果存在之前的输出路径,就递归删除。其实这里应该要报错的,只不过为了方便任务重跑
if(outputPath.getFileSystem(conf).exists(outputPath)){
outputPath.getFileSystem(conf).delete(outputPath,true);
}
//设置mapTask的hdfs输出路径
TextOutputFormat.setOutputPath(job,outputPath);
//key自定义格式类,需要实现序列化方法和key默认比较方法
job.setOutputKeyClass(TKey.class);
//job输出的value就填温度的即可。
job.setOutputValueClass(IntWritable.class);
//map的jar包地址
job.setMapperClass(TMapper.class);
//partition map输出key-value还需要经过分区器计算区号,变成kvp才可以写道缓冲区
job.setPartitionerClass(TPartitioner.class);
//缓冲区(用户暂时没能力实现,大牛忽略)
//sortComparator 缓冲区满了,spillThread会对key进行排序,然后溢写到磁盘。
job.setSortComparatorClass(TSortComparator.class);
//2.reduceTask
//groupingComparator分组比较器,一个key可能存在多个维度,按照key的那几个维度视为一组,这就需要用到分组比较器!
job.setGroupingComparatorClass(TGroupingComparator.class);
//reduce方法的jar
job.setReducerClass(TReduce.class);
//一定要有这个,提交job,且让控制台输出job的详细信息
job.waitForCompletion(true);
}
}
根据上面的代码,下面梳理以下这个Job所需要用到的一些类:
1、 MapTask所需要定制的类
- 自定义Key类型,实现序列化、反序列化、默认的key排序器,实际上只需要实现WritableComparable接口即可。
- Mapper类,定义map方法进行分组输出,map方法实际上做的工作就是将原始的数据格式,转换成用户自定义的Key的格式,比如将样例数据定制成”年-月-日-温度“这样格式的key,就可以在map方法中实现。
- 自定义分区器Partitioner,因为我们需要按照”年-月“为标准进行分区(需求是求每个月份的最高温的两天)。
- 自定义排序器SortComparator, 因为我们需要对key按照温度的倒叙排,故此需要自己手写排序的标准。
2、ReduceTask所需要定制的类
- 自定义分组比较器GroupingComparator,主要的目的在于,key是需要按照"年-月"这两个维度来算作一组的,而我们map输出的格式的key是以”年-月-日-温度“作为key,故此为了防止reduce使用默认的比较规则(判断key是否相等则为一组),我们需要自己实现分组比较器。
- Reducer类, 定义reduce方法,方法的处理逻辑就是计算同一组key("年-月"为基准划分),迭代这一组key-value值,然后获取这一组key的前两个不同日期的记录,即为当前组(年-月)的最高两天气温,作为reduce的输出。
下面我们就来展示上面提到的这几个定制类。
2、MapTask
2.1、自定义Key类型
这里使用TKey表示TopN的格式化类。也就是map处理后将原始数据封装成Tkey对象,再序列化到磁盘中。
/**
* 自定义类型必须实现接口:序列化和反序列化和比较器
*/
public class TKey implements WritableComparable<TKey> {
private int year;
private int month;
private int date;
private int temperature;
public int getYear() {
return year;
}
public void setYear(int year) {
this.year = year;
}
public int getMonth() {
return month;
}
public void setMonth(int month) {
this.month = month;
}
public int getDate() {
return date;
}
public void setDate(int date) {
this.date = date;
}
public int getTemperature() {
return temperature;
}
public void setTemperature(int temperature) {
this.temperature = temperature;
}
/**
* 比较器,我们的topN是根据年月日按照温度的倒叙比较。但是这里为了演示api的时候
* 只对时间进行正序的排序,不是按照温度。后面会重写sortComparator按温度倒叙排
* @param that
* @return
*/
@Override
public int compareTo(TKey that) {
//首先比较年份大小,同年的话比较月份
int result = Integer.compare(this.year,that.year);
if(result == 0 ){
result = Integer.compare(this.month,that.month);
return result == 0?Integer.compare(this.date,that.date):result;
}
return result;
}
/**
* 序列化
* 直接按照 年月日温度 作为序列化顺序写到磁盘即可
* @param out
* @throws IOException
*/
@Override
public void write(DataOutput out) throws IOException {
out.writeInt(year);
out.writeInt(month);
out.writeInt(date);
out.writeInt(temperature);
}
/**
* 反序列化
* 按照序列化设置的顺序来读取。
* @param in
* @throws IOException
*/
@Override
public void readFields(DataInput in) throws IOException {
this.year = in.readInt();
this.month = in.readInt();
this.date = in.readInt();
this.temperature = in.readInt();
}
}
2.2、自定义Partitioner分区器
/**
* 自定义分区器
* 当我们的记录通过map继续分成key-Value输出的时候,需要使用分区器变成kvp,再输出到环形缓冲区
* 这里采用的分区规则只是简简单单按照年取余reduce来计算
*/
public class TPartitioner extends Partitioner<TKey, IntWritable> {
@Override
public int getPartition(TKey tKey, IntWritable intWritable, int numPartitions) {
return tKey.getYear()% numPartitions;
}
}
2.3、自定义key的排序比较器
public class TSortComparator extends WritableComparator {
public TSortComparator(){
super(TKey.class,true);
}
/**
* 自定义比较器覆盖我们Tkey指定的默认比较方法compareTo
* @param a
* @param b
* @return
*/
@Override
public int compare(WritableComparable a, WritableComparable b) {
TKey key1 = (TKey) a;
TKey key2 = (TKey) b;
//按照年-月为一组,且组内以温度的倒叙排排序
int result = Integer.compare(key1.getYear(), key2.getYear());
//如果年份相同,比较月
if(result == 0){
result = Integer.compare(key1.getMonth(), key2.getMonth());
return result == 0? Integer.compare(key2.getTemperature(),key1.getTemperature()):result;
}
return result;
}
}
WritableComparator实现了RawComparator接口,当我们希望自定义比较器里面的排序比较规则时,比如使用自定义的SortComparator而不是使用Key类型自身的Comparator,那么我们就可以去继承WritableComparator,改写它的比较方法。不过WritableComparator的比较方法有好几个,这里列出它的核心两个比较方法如下:
/**
* 为什么有这个compare方法,并且最终会调用下面 compare(WritableComparable a, WritableComparable b)这个方法传入对象去比较?
因为在map对KV序列化后,会存到环形缓冲区中,当发生溢写的时候,溢写线程就会去读取获取key的指定的字节数组长度,传入到这个compare方法进行反序列化成key真实的类型对象,最终发生排序比较利用的还是对象的排序比较方法!
*/
@Override
public int compare(byte[] b1, int s1, int l1, byte[] b2, int s2, int l2) {
try {
buffer.reset(b1, s1, l1); // parse key1
key1.readFields(buffer);
buffer.reset(b2, s2, l2); // parse key2
key2.readFields(buffer);
} catch (IOException e) {
throw new RuntimeException(e);
}
return compare(key1, key2); // compare them
}
/**
* 两个WritableComparable的比较(也就是Key的实际类型自身override WritableComparable的比较方法)
*/
public int compare(WritableComparable a, WritableComparable b) {
return a.compareTo(b);
}
上面的注解很详细了,其中第一个compare方法间接调用的是第二个compare方法,发生真实的key比较行为的方法是第二个compare方法!
那么我们继承WritableComparator的时候,也是重写第二个compare方法,并使用我们自定义的排序比较器来进行key的比较排序!
还有一个特别需要注意的地方:当我们自定义比较器的时候,必须要调用父类WritableComparator的构造器方法(指定key的类型,并且要求创建key的实例)! 否则在上面第一个compare方法调用的时候,key1、key2就是null,会出现空指针异常!
2.4、Mapper类
/**
* Mapper的泛型含义
* LongWritable mapper读取输入文件行的首字母的offset
* Text, mapper读取的行记录
* TKey, map输出的key格式
* IntWritable map输出的value格式,这里value就只是温度而已
*/
public class TMapper extends Mapper<LongWritable, Text,TKey, IntWritable> {
TKey tkey = new TKey();
IntWritable res = new IntWritable();
ThreadLocal<SimpleDateFormat> simpleDateFormatThreadLocal = ThreadLocal.withInitial(()-> new SimpleDateFormat("yyyy-MM-dd"));
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
//首先通过传入的记录行进行拆分,行的内容按照制表符切割 2020-07-26 1 35
String[] split = StringUtils.split(value.toString(), '\t');
SimpleDateFormat simpleDateFormat = simpleDateFormatThreadLocal.get();
try {
Date dateTime = simpleDateFormat.parse(split[0]);
Calendar cal = Calendar.getInstance();
cal.setTime(dateTime);
tkey.setYear(cal.get(Calendar.YEAR));
tkey.setMonth(cal.get(Calendar.MONTH)+1);
tkey.setDate(cal.get(Calendar.DATE));
tkey.setTemperature(Integer.valueOf(split[2]));
} catch (ParseException e) {
e.printStackTrace();
}
res.set(Integer.valueOf(split[2]));
context.write(tkey,res);
}
}
3、ReduceTask
3.1、自定义分组比较器
/**
* reduce分组比较器,这里按照年月划分为一组
*/
public class TGroupingComparator extends WritableComparator {
public TGroupingComparator(){
super(TKey.class,true);
}
@Override
public int compare(WritableComparable a, WritableComparable b) {
TKey key1 = (TKey) a;
TKey key2 = (TKey) b;
//按照年-月为一组,且组内以温度的倒序排序
int result = Integer.compare(key1.getYear(), key2.getYear());
//如果年份相同,比较月
if(result == 0){
return Integer.compare(key1.getMonth(), key2.getMonth());
}
return result;
}
}
3.2 Reducer
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
import java.io.IOException;
import java.util.Iterator;
public class TReduce extends Reducer<TKey, IntWritable, Text,IntWritable> {
@Override
protected void reduce(TKey key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
Iterator<IntWritable> iterator = values.iterator();
//判断是否找到第一个温度最高的时间
int flag = 0 ;
//最高温的日期
int day = 0 ;
while(iterator.hasNext()){
if(flag == 0 ){
//value存放的是温度
IntWritable temprature = iterator.next();
//找到了第一个最高温(因为每一组数据已经是按温度倒叙拍好的,只需要遍历到不同天的气温即可)
flag ++ ;
//输出的key的格式为"年-月-日"
Text outKey = new Text();
day = key.getDate();
outKey.set(key.getYear()+"-"+key.getMonth()+"-"+day);
context.write(outKey,temprature);
continue;
}
//寻找次高温
if(flag != 0 && day != key.getDate()){
//value存放的是温度
IntWritable temprature = iterator.next();
//输出的key的格式为"年-月-日"
Text outKey = new Text();
day = key.getDate();
outKey.set(key.getYear()+"-"+key.getMonth()+"-"+day);
context.write(outKey,temprature);
break;
}
}
}
}
其中这里会出现一个现象,就是当前这组数据只是读到前面两个气温最高值的日期就跳出循环了,此时这组数据实际上有可能没有被调用完全,那么当退出reduce方法之后,又会进行context.nextKey()的获取。
/**
* Advanced application writers can use the
* {@link #run(org.apache.hadoop.mapreduce.Reducer.Context)} method to
* control how the reduce task works.
*/
public void run(Context context) throws IOException, InterruptedException {
setup(context);
try {
while (context.nextKey()) {
reduce(context.getCurrentKey(), context.getValues(), context);
//..
}
} finally {
cleanup(context);
}
}
那么这里的nextKey()方法实际上并不会还是拿上次没有遍历完的key再调用一次reduce方法,当遇到上一组数据没完全迭代完的时候,就会继续调用nextKeyValue()方法迭代所有剩下的这一组数据:
/** Start processing next unique key. */
public boolean nextKey() throws IOException,InterruptedException {
while (hasMore && nextKeyIsSame) {
nextKeyValue();
}
if (hasMore) {
if (inputKeyCounter != null) {
inputKeyCounter.increment(1);
}
return nextKeyValue();
} else {
return false;
}
}
直到遇到nextKeyIsSame判断不等的时候才会去继续调用nextKeyValue获取下一组的第一条数据,最终返回true或false。然后再调用reduce方法计算下一组数据。
三、结语
现在我们已经把TopN的功能实现了,但我们还可以做的更好,比如在Mapper端使用Combiner进行对多条记录的合并操作等。尽量减少网络消耗和计算耗时。