MapReduce案例之TopN实现

基于上次WordCounter 案例,我在此基础上实现了TopN 处理逻辑。在大数据场景实现TopN,跟我们平时处理方式不一样,需要考虑数据量的问题,一般情况下我们只要在内存中做一个全排序,然后取出前N个数据即可。由于大数据中的数据都是上千万甚至上亿的数据量,在这样的场景下,全排序要么内存不支持,要么耗时太久。那么怎么解决呢?
其实思路也不难,就像比赛拿名次一样,我们在分布式计算环境中,可以先执行小组赛,在小组赛中拿到N之前的名次的团队,再取参加决赛。这样每个节点计算量就会可控,执行效率也大大提高了。

首先我们先说实现思路:

  • 1.根据比赛的逻辑,MaperTask 相当于小组,在这个阶段筛选出各个小组的前N名.ReduceTask 相当于决赛,所以ReduceTask 只能有一个,哈。
  • 2.在Mapper阶段选出前N名,这个时候需要用到Combiner 规约函数了。Combiner 与Reducer 类似,都是做聚合操作的。只是阶段不同,Combiner在MaperTask 阶段执行聚合操作,是局部的汇总操作。Reducer是全局。所以Reduce 只能设置一个,否则是分区内的TopN
  • 3.重写 cleanUp 方法,统一释放资源
  • 4.Combiner和 Reducer 中设置集合类,存储排名结果,并获取前N作为结果进行输出

案例说明

本次案例基于WordCounter 统计,本次实现的需求是,取出文件中单词个数在前N的单词以及出现的个数。为了更接近实际应用场景,输入的文件个数为多个,这样就会有多个MapperTask任务了。每个MapperTask计算出自己文件中的前N个单词,并传给ReduceTask。ReducerTask不用设置,默认就是1.ReduceTask 汇总各个MapperTask提交上来的结果,再进行排序,取出前N名

废话不多说,上代码:

首先是Mapper类,该类负责将文本中的单词转化成KV结构,key是单词,Value 是单词出现的次数。

public class MyMapper extends Mapper<LongWritable, Text,Text, IntWritable> {
    /**
     * 继承Mapper 后复写 map 方法,每读取一行数据 都会调用一次map 方法
     * @param key 对应 k1
     * @param value 对应 v1
     * @param context 上下文对象
     * @throws IOException
     * @throws InterruptedException
     */
    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
        String line =  value.toString();
        String[] words = line.split(",");
        Text text = new Text();// 这个定义在循环外部,所有的指引都用一个是否存在问题?
        IntWritable intWritable = new IntWritable(1);
        for(String word : words){
            text.set(word);
            context.write(text,intWritable);
        }
    }
}

接下来是Maper 节点的排序和取TopN的过程Combiner类的实现。
Combiner 本质也是Reducer ,所以Combiner继承 Reducer ,且输入和输出与Reducer一致。
在实现Combiner类之前,我们需要定义一个类,准确说是一个Bean。该Bean 存放 单词以及单词在当前MapperTask中出现的次数,便于后续的排序统计
定义如下:

public class TextCounter {

    /**
     * 单词
     */
    private String world;

    /**
     * 数量
     */
    private int num;


    public String getWorld() {
        return world;
    }

    public void setWorld(String world) {
        this.world = world;
    }

    public int getNum() {
        return num;
    }

    public void setNum(int num) {
        this.num = num;
    }

}

接下来是Combiner类的实现,该类中包含的一个集合类,我用的ArrayList .用于MapperTask中聚合的结果。其次覆盖Reducer父类的SetUp方法和CleanUp方法。Setup 方法获取配置的N的数量。CleanUp 用于统一释放资源。之前我们是在Reducer中计算出结果,就直接通过Context 传给下游程序了。这里需要统计出前N名,并且仅将前N名传给下游程序。
代码如下:

/**
 * <p>
 * 单词数量规约类
 * </p>
 *
 * @author eric song
 * @since 2024/7/17
 */
public class WorldCounterTopNCombiner extends Reducer<Text, IntWritable,Text, IntWritable> {
	/**
     * 存在前N名结果数据
     */
    private  List<TextCounter> list = new LinkedList<>();

    /**
     * 前几个
     */
    private int threshold;
    /**
     * 获取配置中的N
     */
    @Override
    protected void setup(Context context) {
        threshold = context.getConfiguration().getInt("topn.threshold", 10);
    }

    /**
     * 相同 key 数据 发送到同一个 reduce 里面去 ,相同key 合并,value行程一个集合
     * @param key k2
     * @param values v2
     * @param context 上下文
     * @throws IOException
     * @throws InterruptedException
     */
    @Override
    protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        int result = 0;
        for(IntWritable intWritable: values){
            result += intWritable.get();
        }
        TextCounter textCounter = new TextCounter();
        textCounter.setWorld(key.toString());
        textCounter.setNum(result);
        if (list.size() < threshold) {
            list.add(textCounter);
        }else if (list.size() == threshold) {
            list.sort((o1,o2)-> {
                if(o1.getNum() > o2.getNum()){
                    return -1;
                }else if(o1.getNum() == o2.getNum()){
                    return 0;
                }else{
                    return 1;
                }
            });
            TextCounter lastE = list.get(list.size()-1);
            if(lastE.getNum() < result){
                list.remove(list.size()-1);  // 移除最后一个
                list.add(textCounter);
            }
        }
    }

    /**
     * 通过 cleanUp 执行最终输出
     * @param context
     * @throws IOException
     * @throws InterruptedException
     */
    @Override
    protected void cleanup(Context context) throws IOException, InterruptedException {
        for (TextCounter entry : list) {
            Text text = new Text();
            text.set(entry.getWorld());
            IntWritable intWritable = new IntWritable(entry.getNum());
            context.write(text, intWritable);
        }
    }
}

在Combiner中的Reduce方法,计算出单词的出现次数后,先对集合的元素进行从大到小排序,再与集合中排名最差的(即最后一名)进行比较,如果大于最后一名,则替换。替换的元素不一定是最差的,所以每次替换之前都需要先排序。这样集合始终只存放了N个元素,不存在内存溢出的问题。

接下来看Reducer

/**
 * <p>
 * 自定义 Reducer 相同 key 数据 发送到同一个 reduce 里面去 ,相同key 合并,value行程一个集合
 * </p>
 *
 *
 *
 * @author eric song
 * @since 2023/4/28
 */
public class MyReducer extends Reducer<Text, IntWritable,Text,IntWritable> {

    private  List<TextCounter> list = new LinkedList<>();

    /**
     * 前几个
     */
    private int threshold;

    @Override
    protected void setup(Context context) {
        threshold = context.getConfiguration().getInt("topn.threshold", 10);
    }

    /**
     * 相同 key 数据 发送到同一个 reduce 里面去 ,相同key 合并,value行程一个集合
     * @param key k2
     * @param values v2
     * @param context 上下文
     * @throws IOException
     * @throws InterruptedException
     */
    @Override
    protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        int result = 0;
        for(IntWritable intWritable: values){
            result += intWritable.get();
        }
        TextCounter textCounter = new TextCounter();
        textCounter.setWorld(key.toString());
        textCounter.setNum(result);
        list.add(textCounter);
    }

    /**
     * 通过 cleanUp 执行最终输出
     * @param context
     * @throws IOException
     * @throws InterruptedException
     */
    @Override
    protected void cleanup(Context context) throws IOException, InterruptedException {
        if (list.size() <= threshold) {
            list.sort((o1,o2)->{
                if(o1.getNum() > o2.getNum()){
                    return -1;
                }else if(o1.getNum() == o2.getNum()){
                    return 0;
                }else{
                    return 1;
                }
            });
        }else{
            Map<String, List<TextCounter>> resultMap =
                    list.stream().collect(Collectors.groupingBy(TextCounter::getWorld));
            List<TextCounter> newList = new ArrayList<>();
            for(String word : resultMap.keySet()){
                List<TextCounter> textCounters = resultMap.get(word);
                TextCounter textCounter = new TextCounter();
                textCounter.setWorld(word);
                textCounter.setNum(textCounters.stream().map(TextCounter::getNum).reduce(0,(a,b)-> a+b));
                newList.add(textCounter);
            }
            newList.sort((o1,o2)->{
                if(o1.getNum() > o2.getNum()){
                    return -1;
                }else if(o1.getNum() == o2.getNum()){
                    return 0;
                }else{
                    return 1;
                }
            });
            if(newList.size() > threshold){
                list = newList.subList(0,6);
            }else{
                list = newList;
            }
        }
        for (TextCounter entry : list) {
            Text text = new Text();
            text.set(entry.getWorld());
            IntWritable intWritable = new IntWritable(entry.getNum());
            context.write(text, intWritable);
        }
    }
}

在Reducer中Reduce方法只负责汇总所有MapperTask提交上的统计结果,并进行汇总,然后存放到集合类中。考虑到数量不多,可以在内存中进行全排序。
最终的排序结果以及TopN的提出,在Reducer的CleanUp方法中完成。

MapReduce 入口类

/**
 * <p>
 * 单词统计 mr 程序的入口程序
 * </p>
 *
 * @author eric song
 * @since 2024/7/4
 */
public class WordCounterTopNLocal extends Configured implements Tool {

    /**
     * 这个Run 方法用于组装我们的程序的逻辑,主要是八大步
     * @param args
     * @return
     * @throws Exception
     */
    @Override
    public int run(String[] args) throws Exception {
        //获取Job对象,组装我们的八大步骤
        Configuration configuration = super.getConf();
        Job job = Job.getInstance(configuration,"topNRunner");
        //本地运行
        configuration.set("mapreduce.framework.name","local");
        configuration.set("yarn.resourcemanager.hostname","local");

        //在实际工作中,我们需要打包成一个jar 到集群上面去运行
        //如果打包 jar 到集群需要做以下设置
        job.setJarByClass(WordCounterTopNLocal.class);

        //第一步:读取文件,解析成key,value 对,k1 行偏移量,value 一行文本内容
        job.setInputFormatClass(TextInputFormat.class);
        TextInputFormat.addInputPath(job,new Path("file:///E:\\testData\\demo1\\input"));
        //第二步 自定义 mapper 逻辑
        job.setMapperClass(MyMapper.class);
        job.setCombinerClass(WorldCounterTopNCombiner.class);
        //设置mapper 输出的 key value 类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(IntWritable.class);

        //第三步到第六步 分区、排序、规约、分组都省略
        /**
         * 第三步:分区。相同key的数据发送到同一个reduce里面去,key合并,value形成一个集合
         * 第四步:排序对key2进行排序。字典顺序排序
         * 第五步:规约 combiner过程调优步骤可选
         * 第六步:分组
         */
        //第七步:自定义Reduce 逻辑
        job.setReducerClass(MyReducer.class);
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(IntWritable.class);

        //第八步 设置输出,并进行保存
        job.setOutputFormatClass(TextOutputFormat.class);
        TextOutputFormat.setOutputPath(job,new Path("file:///E:\\testData\\demo1\\output"));

        //提交作业 Job
        boolean b =  job.waitForCompletion(true);
        return b? 1: 0;
    }

    /**
     * 入口程序
     * @param args
     */
    public static void main(String[] args) throws Exception{
        Configuration configuration = new Configuration();
        configuration.setInt("topn.threshold", 6);
        int run  = ToolRunner.run(configuration,new WordCounterTopNLocal(),args);
        System.exit(run);
    }

}

设置TopN的个数

configuration.setInt("topn.threshold", 6);

设置规约类:

job.setCombinerClass(WorldCounterTopNCombiner.class);

测试数据:

wordCount.txt

hello,hello
world,world
hadoop,hadoop
hello,world
hello,flume
hadoop,hive
hive,kafka
flume,storm
hive,oozie
Eric,Eric,Eric

wordCount21.txt

hello,hello
world,world
hadoop,hadoop
hello,world
hello,flume
hadoop,hive
hive,kafka
flume,storm
hive,oozie
Eric,Eric,Eric

wordCount31.txt

hello,hello
world,world
hadoop,hadoop
hello,world
hello,flume
hadoop,hive
hive,kafka
flume,storm
hive,oozie
Eric,Eric,Eric

执行结果

在这里插入图片描述
在这里插入图片描述
重点说明:

在 Cleanup 中 对集合的元素进行按单词进行分组的处理,代码如下

 Map<String, List<TextCounter>> resultMap =
                    list.stream().collect(Collectors.groupingBy(TextCounter::getWorld));
            List<TextCounter> newList = new ArrayList<>();
            for(String word : resultMap.keySet()){
                List<TextCounter> textCounters = resultMap.get(word);
                TextCounter textCounter = new TextCounter();
                textCounter.setWorld(word);
                textCounter.setNum(textCounters.stream().map(TextCounter::getNum).reduce(0,(a,b)-> a+b));
                newList.add(textCounter);
            }

原因是:

进入reduce方法中key 并不唯一,存在重复key的现象,即相同key ,Value值本来会合并成一个集合,再传入Reduce方法的。这里确失效了。所以需要将集合中同名的单词执行二次合并计算。

分析出现该问题的原因是Reduce 在Sort阶段,ReduceTask的归并排序失败导致,ReduceTask 在Sort阶段 对所有数据只进行一次归并排序,因为它认为MapTask 在输出前已经进行局部的归并排序。我们在引入Combiner 规约类的时候,因为要进行TopN 的排序选择,破坏了MapTask的排序规则。导致ReduceTask Sort 阶段失效。

为了验证这个问题,我调整Combiner类的处理逻辑。在Combiner 阶段不进行排序操作,仅做汇总操作,执行结果证明,Reduce方法接收的数据就不存在key重复的问题了。不过这样就达不到之前的设计思路了。代码如下

 /**
     * 相同 key 数据 发送到同一个 reduce 里面去 ,相同key 合并,value行程一个集合
     * @param key k2
     * @param values v2
     * @param context 上下文
     * @throws IOException
     * @throws InterruptedException
     */
    @Override
    protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        int result = 0;
        for(IntWritable intWritable: values){
            result += intWritable.get();
        }
        IntWritable intWritable = new IntWritable(result);
        context.write(key,intWritable);
    }

Combiner 仅做数据汇总,输出顺序按照输入顺序输出。这样也是可以降低网络传输量的。但并不是TopN,而是所有单词以及对应的出现的次数都传给了Reducer。其实Combiner 并不是MapTask 必选操作,MapReduce框架在执行的时候会根据资源的情况,选择性的使用Combiner,这样我们在使用Combiner 也需要考虑到在没有Combiner的时候,程序执行依然是正确的

按照这个思路Reduce 也做了调整,TopN的操作在Reduce中执行,应为Reducer接收到的Key 不存在重复,这样就不需要再收集所有单词的出现次数,在执行排序,筛选出TopN了,内存的使用和代码逻辑也简单了很多。具体代码如下:

public class MyReducer extends Reducer<Text, IntWritable,Text,IntWritable> {

    private  List<TextCounter> list = new LinkedList<>();

    /**
     * 前几个
     */
    private int threshold;

    @Override
    protected void setup(Context context) {
        threshold = context.getConfiguration().getInt("topn.threshold", 10);
    }

    /**
     * 相同 key 数据 发送到同一个 reduce 里面去 ,相同key 合并,value行程一个集合
     * @param key k2
     * @param values v2
     * @param context 上下文
     * @throws IOException
     * @throws InterruptedException
     */
    @Override
    protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        int result = 0;
        for(IntWritable intWritable: values){
            result += intWritable.get();
        }
        TextCounter textCounter = new TextCounter();
        textCounter.setWorld(key.toString());
        textCounter.setNum(result);
        if (list.size() < threshold) {
            list.add(textCounter);
        }else if (list.size() == threshold) {
            list.sort((o1,o2)-> {
                if(o1.getNum() > o2.getNum()){
                    return -1;
                }else if(o1.getNum() == o2.getNum()){
                    return 0;
                }else{
                    return 1;
                }
            });
            TextCounter lastE = list.get(list.size()-1);
            if(lastE.getNum() < result){
                list.remove(list.size()-1);  // 移除最后一个
                list.add(textCounter);
            }
        }
    }

    /**
     * 通过 cleanUp 执行最终输出
     * @param context
     * @throws IOException
     * @throws InterruptedException
     */
    @Override
    protected void cleanup(Context context) throws IOException, InterruptedException {
        list.sort((o1,o2)-> {
            if(o1.getNum() > o2.getNum()){
                return -1;
            }else if(o1.getNum() == o2.getNum()){
                return 0;
            }else{
                return 1;
            }
        });
        for (TextCounter entry : list) {
            Text text = new Text();
            text.set(entry.getWorld());
            IntWritable intWritable = new IntWritable(entry.getNum());
            context.write(text, intWritable);
        }
    }
}

这两种方式,我觉得各有各的好处,主要是看场景需要吧

本次分享让我对Reduce的工作机制以及各个阶段有了深入了解,也希望对志同道合的朋友有帮助,一起学习一起成长。

文章有不对的地方请指出,我也会及时调整,非常感谢~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值