Hadoop学习教程,从入门到精通, MapReduce分布式计算框架 — 完整知识点与代码案例(4)

MapReduce分布式计算框架 --- 完整知识点与代码案例


一、MapReduce概述

1.1 什么是MapReduce

MapReduce是Hadoop的核心组件之一,是一种分布式并行编程模型 ,用于大规模数据集(大于1TB)的并行运算。它将复杂的、运行于大规模集群上的并行计算过程高度抽象为两个函数:Map(映射)Reduce(归约)

1.2 MapReduce的核心思想

  • 分而治之:将一个大任务拆分成多个小任务,分发给集群中的多个节点并行处理
  • 计算向数据移动:将计算程序发送到数据所在的节点执行,减少数据传输开销
  • 最终合并:将各个小任务的计算结果进行汇总,得到最终结果

1.3 MapReduce的特点

特点 说明
易于编程 用户只需实现Map和Reduce两个接口
良好的扩展性 可通过增加节点横向扩展
高容错性 自动处理节点失败,重新调度任务
适合PB级以上海量数据的离线处理 不适合实时计算

二、MapReduce编程模型

2.1 编程模型概述

MapReduce将计算过程抽象为两个阶段:

复制代码
输入数据 → [Map阶段] → 中间结果(键值对) → [Shuffle阶段] → [Reduce阶段] → 最终输出
  • Map阶段:接收一个键值对(key1, value1),经过处理后输出一系列中间键值对(key2, value2)
  • Shuffle阶段:框架自动完成分区、排序、分组等操作
  • Reduce阶段:接收中间键值对(key2, list(value2)),经过合并处理后输出最终结果(key3, value3)

2.2 编程模型的数据流

复制代码
Input:    (key1, value1)    →  例如:(行偏移量, "hello world")
                                    ↓ Map
Map:      (key2, value2)    →  例如:("hello", 1), ("world", 1)
                                    ↓ Shuffle(排序、分组)
Reduce:   (key2, list<value2>)  →  例如:("hello", [1,1,1]), ("world", [1,1])
                                    ↓ Reduce
Output:   (key3, value3)    →  例如:("hello", 3), ("world", 2)

三、MapReduce工作原理与工作过程

3.1 MapReduce工作过程总体流程

复制代码
1. 客户端提交作业(Job)到ResourceManager
2. ResourceManager分配资源,在NodeManager上启动ApplicationMaster
3. ApplicationMaster向ResourceManager申请资源
4. ResourceManager分配Container资源
5. ApplicationMaster在NodeManager的Container中启动MapTask和ReduceTask
6. MapTask读取输入数据,执行Map函数
7. Shuffle过程:分区、排序、溢写、合并
8. ReduceTask拉取MapTask的输出,执行Reduce函数
9. 结果写入HDFS

3.2 MapTask工作原理

MapTask的工作流程:

复制代码
InputSplit → RecordReader → map() → Collect(环形缓冲区) → 
Spill(溢写:分区、排序) → Merge(合并) → 输出文件

详细步骤:

  1. Read阶段:通过InputFormat获取InputSplit,用RecordReader读取数据
  2. Map阶段:对每条记录执行用户自定义的map()函数
  3. Collect阶段:将结果写入环形缓冲区(默认100MB)
  4. Spill阶段:当缓冲区达到阈值(默认80%),将数据溢写到磁盘,溢写前进行分区和排序
  5. Merge阶段:多个溢写文件合并为一个大文件

3.3 ReduceTask工作原理

ReduceTask的工作流程:

复制代码
Copy(拉取Map输出) → Merge(合并) → Sort(排序/归并) → reduce() → 输出

详细步骤:

  1. Copy阶段:从各个MapTask上远程拷贝属于自己的数据
  2. Merge阶段:将多个MapTask的输出文件进行归并排序
  3. Sort阶段:对归并后的数据进行排序(归并排序),保证相同key的数据连续
  4. Reduce阶段:对每组相同key的数据执行reduce()函数
  5. Write阶段:通过OutputFormat将结果写入HDFS

3.4 Shuffle工作原理

Shuffle是MapReduce中最核心、最复杂的机制,横跨Map端和Reduce端:

Map端Shuffle:

复制代码
map()输出 → 环形缓冲区(默认100MB) → 达到80%阈值 → 
溢写前分区(Partitioner) → 溢写前排序(快速排序) → 
溢写到磁盘文件 → 多个溢写文件归并排序合并

Reduce端Shuffle:

复制代码
多个MapTask输出 → Copy线程拉取数据 → 内存中合并 → 
内存满则溢写到磁盘 → 磁盘上多文件归并排序 → 
输出给reduce()函数

Shuffle关键配置参数:

参数 默认值 说明
mapreduce.task.io.sort.mb 100 Map端环形缓冲区大小(MB)
mapreduce.map.sort.spill.percent 0.80 环形缓冲区溢写阈值
mapreduce.map.combine.minspills 3 触发Combiner的最少溢写文件数
mapreduce.reduce.shuffle.parallelcopies 5 Reduce端并行拷贝线程数

四、MapReduce编程组件

4.1 InputFormat组件

作用: 负责数据的输入,包括数据切分(生成InputSplit)和读取数据(提供RecordReader)。

常用InputFormat类:

InputFormat类 说明
TextInputFormat 默认格式,按行读取,key为行偏移量,value为行内容
KeyValueTextInputFormat 按分隔符将每行分为key和value
NLineInputFormat 按指定行数进行切分
CombineTextInputFormat 合并小文件,解决大量小文件的切分问题
SequenceFileInputFormat 读取SequenceFile格式文件
DBInputFormat 从数据库中读取数据
案例代码1:自定义InputFormat------将小文件合并

需求: 将多个小文件合并为一个SequenceFile输出,key为文件名,value为文件内容。

java 复制代码
package com.hadoop.mapreduce.inputformat;

import java.io.IOException;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.BytesWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.JobContext;
import org.apache.hadoop.mapreduce.RecordReader;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;

/**
 * 自定义InputFormat类
 * 继承FileInputFormat,将每个文件作为一个整体读取
 * Key类型:Text(文件名)
 * Value类型:BytesWritable(文件内容的字节数组)
 */
public class WholeFileInputFormat extends FileInputFormat<Text, BytesWritable> {

    /**
     * 重写isSplitable方法,返回false表示不对文件进行切分
     * 这样每个文件会作为一个完整的InputSplit处理
     * @param context 作业上下文
     * @param filename 文件路径
     * @return false 表示不切分文件
     */
    @Override
    protected boolean isSplitable(JobContext context, Path filename) {
        // 返回false,每个文件作为一个整体处理,不进行切分
        return false;
    }

    /**
     * 重写createRecordReader方法,创建自定义的RecordReader
     * @param split 输入切片
     * @param context 任务上下文
     * @return 自定义的WholeFileRecordReader实例
     */
    @Override
    public RecordReader<Text, BytesWritable> createRecordReader(
            InputSplit split, TaskAttemptContext context) throws IOException, InterruptedException {
        // 创建自定义RecordReader实例
        WholeFileRecordReader reader = new WholeFileRecordReader();
        // 初始化RecordReader
        reader.initialize(split, context);
        // 返回RecordReader实例
        return reader;
    }
}
java 复制代码
package com.hadoop.mapreduce.inputformat;

import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FSDataInputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.BytesWritable;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.RecordReader;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.input.FileSplit;

/**
 * 自定义RecordReader类
 * 负责读取一个完整的文件,将文件名作为key,文件内容作为value
 */
public class WholeFileRecordReader extends RecordReader<Text, BytesWritable> {

    private FileSplit fileSplit;        // 文件切片信息
    private Configuration conf;          // 配置信息
    private Text key = new Text();       // 当前的key(文件名)
    private BytesWritable value = new BytesWritable(); // 当前的value(文件内容)
    private boolean isRead = false;      // 标记是否已经读取过该文件
    private float progress = 0f;         // 读取进度

    /**
     * 初始化方法,在RecordReader使用前调用
     * @param split 输入切片
     * @param context 任务上下文
     */
    @Override
    public void initialize(InputSplit split, TaskAttemptContext context)
            throws IOException, InterruptedException {
        // 将InputSplit转换为FileSplit以获取文件路径信息
        this.fileSplit = (FileSplit) split;
        // 获取Hadoop配置
        this.conf = context.getConfiguration();
    }

    /**
     * 读取下一条记录
     * @return true表示还有记录可读,false表示已读完
     */
    @Override
    public boolean nextKeyValue() throws IOException, InterruptedException {
        // 如果还没有读取过文件
        if (!isRead) {
            // 获取文件路径
            Path filePath = fileSplit.getPath();
            // 将文件路径设置为key
            key.set(filePath.toString());

            // 获取文件系统的实例
            FileSystem fs = filePath.getFileSystem(conf);
            // 打开文件输入流
            FSDataInputStream inputStream = null;
            try {
                // 打开文件
                inputStream = fs.open(filePath);
                // 创建字节数组,大小为文件长度
                byte[] buffer = new byte[(int) fileSplit.getLength()];
                // 读取文件全部内容到字节数组
                IOUtils.readFully(inputStream, buffer, 0, buffer.length);
                // 将字节数组设置为value
                value.set(buffer, 0, buffer.length);
            } finally {
                // 关闭输入流,释放资源
                IOUtils.closeStream(inputStream);
            }
            // 标记已读取
            isRead = true;
            // 设置进度为完成
            progress = 1.0f;
            return true;
        }
        // 已经读取过了,返回false
        return false;
    }

    /**
     * 获取当前key
     */
    @Override
    public Text getCurrentKey() throws IOException, InterruptedException {
        return key;
    }

    /**
     * 获取当前value
     */
    @Override
    public BytesWritable getCurrentValue() throws IOException, InterruptedException {
        return value;
    }

    /**
     * 获取当前读取进度
     */
    @Override
    public float getProgress() throws IOException, InterruptedException {
        return progress;
    }

    /**
     * 关闭资源
     */
    @Override
    public void close() throws IOException {
        // 无需额外清理
    }
}
案例代码2:使用CombineTextInputFormat处理小文件
java 复制代码
package com.hadoop.mapreduce.combineinput;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
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;

/**
 * 演示CombineTextInputFormat的使用
 * 解决大量小文件导致产生过多MapTask的问题
 */
public class CombineSmallFileDemo {

    /**
     * Mapper类:读取合并后的小文件内容
     * 输入key: LongWritable(行偏移量)
     * 输入value: Text(行内容)
     * 输出key: Text(单词)
     * 输出value: LongWritable(计数1)
     */
    public static class CombineMapper extends Mapper<LongWritable, Text, Text, LongWritable> {

        private Text outKey = new Text();           // 输出的key对象,复用以减少GC
        private LongWritable outValue = new LongWritable(1); // 输出的value,固定值1

        /**
         * 对每一行输入执行map操作
         * @param key 行偏移量
         * @param value 行内容
         * @param context 上下文,用于输出结果
         */
        @Override
        protected void map(LongWritable key, Text value, Context context)
                throws IOException, InterruptedException {
            // 获取一行文本内容
            String line = value.toString();
            // 按空格切分单词
            String[] words = line.split("\\s+");
            // 遍历每个单词,输出<单词, 1>键值对
            for (String word : words) {
                // 设置输出的key为单词
                outKey.set(word);
                // 输出键值对
                context.write(outKey, outValue);
            }
        }
    }

    public static void main(String[] args) throws Exception {
        // 创建配置对象
        Configuration conf = new Configuration();
        // 创建Job对象
        Job job = Job.getInstance(conf, "CombineSmallFileDemo");
        // 设置Jar包的主类
        job.setJarByClass(CombineSmallFileDemo.class);

        // ====== 关键设置:使用CombineTextInputFormat ======
        // 设置InputFormat为CombineTextInputFormat
        job.setInputFormatClass(CombineTextInputFormat.class);
        // 设置虚拟存储切片大小为4MB(根据实际文件大小调整)
        // 每个切片最大为4MB,多个小文件会合并到一个切片中
        CombineTextInputFormat.setMaxInputSplitSize(job, 4 * 1024 * 1024);

        // 设置Mapper类
        job.setMapperClass(CombineMapper.class);
        // 设置Map输出的key类型
        job.setMapOutputKeyClass(Text.class);
        // 设置Map输出的value类型
        job.setMapOutputValueClass(LongWritable.class);

        // 设置输入路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        // 设置输出路径
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        // 提交作业并等待完成
        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}

4.2 Mapper组件

作用: MapReduce计算的第一个阶段,负责将输入数据转换为中间键值对。

Mapper的生命周期方法:

方法 说明 调用次数
setup() 任务初始化时调用 每个MapTask调用1次
map() 对每条记录调用 每条记录调用1次
cleanup() 任务结束时调用 每个MapTask调用1次
run() 控制上述方法的调用流程 每个MapTask调用1次
案例代码3:Mapper组件详解------单词计数
java 复制代码
package com.hadoop.mapreduce.wc;

import java.io.IOException;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

/**
 * 单词计数Mapper类
 * 
 * Mapper四个泛型参数说明:
 * KEYIN    - map输入的key类型:LongWritable(行偏移量,即该行在文件中的起始位置)
 * VALUEIN  - map输入的value类型:Text(一行文本内容)
 * KEYOUT   - map输出的key类型:Text(单词)
 * VALUEOUT - map输出的value类型:LongWritable(单词出现次数,固定为1)
 */
public class WordCountMapper extends Mapper<LongWritable, Text, Text, LongWritable> {

    /**
     * 输出的key对象,定义为成员变量以便复用
     * 避免在map方法中频繁创建对象,减少垃圾回收(GC)开销
     */
    private Text word = new Text();

    /**
     * 输出的value对象,固定值1,表示该单词出现一次
     */
    private LongWritable one = new LongWritable(1);

    /**
     * setup方法:在MapTask开始时调用一次
     * 常用于初始化资源,如建立数据库连接、读取配置文件等
     * @param context MapReduce上下文对象
     */
    @Override
    protected void setup(Context context) throws IOException, InterruptedException {
        // 可以在此进行初始化操作
        // 例如:读取分布式缓存中的数据
        // 例如:建立数据库连接
        System.out.println("MapTask开始...");
    }

    /**
     * map方法:对输入的每一条记录调用一次
     * 核心业务逻辑在此实现
     * 
     * @param key     输入的key:该行在文件中的偏移量(LongWritable类型)
     * @param value   输入的value:该行的文本内容(Text类型)
     * @param context 上下文对象,用于输出键值对和获取配置信息
     */
    @Override
    protected void map(LongWritable key, Text value, Context context)
            throws IOException, InterruptedException {

        // 第1步:获取一行文本内容,将Text类型转换为String类型
        String line = value.toString();

        // 第2步:按空格(正则表达式\\s+匹配一个或多个空白字符)切分为单词数组
        String[] words = line.split("\\s+");

        // 第3步:遍历单词数组
        for (String w : words) {
            // 设置输出的key为当前单词
            word.set(w);
            // 输出键值对<单词, 1>
            // 例如:输入"hello world hello"
            // 输出:<hello, 1>、<world, 1>、<hello, 1>
            context.write(word, one);
        }
    }

    /**
     * cleanup方法:在MapTask结束时调用一次
     * 常用于释放资源,如关闭数据库连接、清理临时文件等
     * @param context MapReduce上下文对象
     */
    @Override
    protected void cleanup(Context context) throws IOException, InterruptedException {
        // 可以在此进行资源清理操作
        // 例如:关闭数据库连接
        System.out.println("MapTask结束...");
    }
}

4.3 Reducer组件

作用: 对Map阶段输出的中间结果进行归约处理,生成最终结果。

Reducer的生命周期方法:

方法 说明 调用次数
setup() 任务初始化时调用 每个ReduceTask调用1次
reduce() 对每组相同key的数据调用 每个不同的key调用1次
cleanup() 任务结束时调用 每个ReduceTask调用1次
run() 控制上述方法的调用流程 每个ReduceTask调用1次
案例代码4:Reducer组件详解------单词计数
java 复制代码
package com.hadoop.mapreduce.wc;

import java.io.IOException;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;

/**
 * 单词计数Reducer类
 * 
 * Reducer四个泛型参数说明:
 * KEYIN    - reduce输入的key类型:Text(单词,必须与Mapper的KEYOUT类型一致)
 * VALUEIN  - reduce输入的value类型:LongWritable(单词计数值,必须与Mapper的VALUEOUT类型一致)
 * KEYOUT   - reduce输出的key类型:Text(单词)
 * VALUEOUT - reduce输出的value类型:LongWritable(该单词的总出现次数)
 */
public class WordCountReducer extends Reducer<Text, LongWritable, Text, LongWritable> {

    /**
     * 用于存储单词总出现次数的变量
     * 定义为成员变量以便复用
     */
    private LongWritable result = new LongWritable();

    /**
     * setup方法:在ReduceTask开始时调用一次
     * @param context 上下文对象
     */
    @Override
    protected void setup(Context context) throws IOException, InterruptedException {
        // 初始化操作
        System.out.println("ReduceTask开始...");
    }

    /**
     * reduce方法:对每一组相同key的数据调用一次
     * 
     * Shuffle阶段会将Mapper输出的<key, value>按key分组
     * 例如:Mapper输出了 <hello, 1>、<hello, 1>、<hello, 1>
     * Shuffle分组后变成:key="hello", values=[1, 1, 1]
     * 
     * @param key     输入的key:一个单词
     * @param values  输入的value集合:该单词对应的所有计数值(可迭代对象)
     * @param context 上下文对象,用于输出最终结果
     */
    @Override
    protected void reduce(Text key, Iterable<LongWritable> values, Context context)
            throws IOException, InterruptedException {

        // 第1步:初始化计数器
        int sum = 0;

        // 第2步:遍历该单词的所有计数值,累加求和
        for (LongWritable value : values) {
            // 将LongWritable转换为long并累加
            sum += value.get();
        }

        // 第3步:设置输出的value为累加结果
        result.set(sum);

        // 第4步:输出最终结果<单词, 总次数>
        // 例如:输出<hello, 3>
        context.write(key, result);
    }

    /**
     * cleanup方法:在ReduceTask结束时调用一次
     * @param context 上下文对象
     */
    @Override
    protected void cleanup(Context context) throws IOException, InterruptedException {
        // 清理操作
        System.out.println("ReduceTask结束...");
    }
}

4.4 Partitioner组件

作用: 决定Mapper输出的每条记录应该发送到哪个Reducer。默认使用HashPartitioner,通过key.hashCode() % numReduceTasks计算分区号。

案例代码5:自定义Partitioner------按省份分区

需求: 手机号前三位代表运营商/地区,要求将不同地区的数据输出到不同文件中。

java 复制代码
package com.hadoop.mapreduce.partition;

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

/**
 * 自定义Partitioner类
 * 
 * 泛型参数与Mapper输出的键值对类型一致
 * @param <K> key类型
 * @param <V> value类型
 * 
 * Partitioner的作用:
 * 决定Mapper输出的每条记录应该被哪个ReduceTask处理
 * 分区号从0开始,范围为[0, numReduceTasks-1]
 */
public class ProvincePartitioner extends Partitioner<Text, Text> {

    /**
     * 获取分区号的方法
     * 
     * @param key          Mapper输出的key(手机号)
     * @param value        Mapper输出的value(上行/下行流量)
     * @param numPartitions 分区总数(即ReduceTask的数量)
     * @return 分区号(整数,范围在0到numPartitions-1之间)
     */
    @Override
    public int getPartition(Text key, Text value, int numPartitions) {
        // 获取手机号字符串
        String phone = key.toString();
        // 获取手机号前三位(区号),用于判断地区
        String prefix = phone.substring(0, 3);

        // 根据前三位手机号判断地区,返回不同的分区号
        // 可以使用Map存储映射关系,这里用switch演示
        int partition;
        switch (prefix) {
            case "136":
                partition = 0;  // 地区1:北京
                break;
            case "137":
                partition = 1;  // 地区2:上海
                break;
            case "138":
                partition = 2;  // 地区3:广州
                break;
            case "139":
                partition = 3;  // 地区4:深圳
                break;
            default:
                partition = 4;  // 其他地区
                break;
        }
        // 返回分区号
        return partition;
    }
}
java 复制代码
package com.hadoop.mapreduce.partition;

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.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;

/**
 * 流量统计并按地区分区输出
 * 
 * 输入数据格式:手机号\t上行流量\t下行流量
 * 输出数据格式:手机号\t总流量
 * 不同前缀的手机号输出到不同文件
 */
public class FlowPartitionerDriver {

    /**
     * Mapper:解析每行数据,输出<手机号, 流量信息>
     */
    public static class FlowMapper extends Mapper<Object, Text, Text, Text> {
        private Text phoneKey = new Text();   // 输出key:手机号
        private Text flowValue = new Text();  // 输出value:上行+下行流量

        @Override
        protected void map(Object key, Text value, Context context)
                throws IOException, InterruptedException {
            // 按制表符切分每行数据
            String[] fields = value.toString().split("\t");
            // 获取手机号
            String phone = fields[0];
            // 获取上行流量
            String upFlow = fields[1];
            // 获取下行流量
            String downFlow = fields[2];

            // 设置key为手机号
            phoneKey.set(phone);
            // 设置value为"上行流量\t下行流量"
            flowValue.set(upFlow + "\t" + downFlow);
            // 输出键值对
            context.write(phoneKey, flowValue);
        }
    }

    /**
     * Reducer:汇总每个手机号的流量
     */
    public static class FlowReducer extends Reducer<Text, Text, Text, Text> {
        private Text result = new Text();

        @Override
        protected void reduce(Text key, Iterable<Text> values, Context context)
                throws IOException, InterruptedException {
            long totalUp = 0;    // 累计上行流量
            long totalDown = 0;  // 累计下行流量

            // 遍历该手机号的所有流量记录
            for (Text val : values) {
                String[] parts = val.toString().split("\t");
                // 累加上行流量
                totalUp += Long.parseLong(parts[0]);
                // 累加下行流量
                totalDown += Long.parseLong(parts[1]);
            }

            // 设置输出结果:手机号\t上行总流量\t下行总流量\t总流量
            result.set(totalUp + "\t" + totalDown + "\t" + (totalUp + totalDown));
            // 输出结果
            context.write(key, result);
        }
    }

    /**
     * 驱动方法:配置并提交作业
     */
    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf, "Flow Partitioner Demo");
        job.setJarByClass(FlowPartitionerDriver.class);

        // 设置Mapper类
        job.setMapperClass(FlowMapper.class);
        // 设置Reducer类
        job.setReducerClass(FlowReducer.class);

        // 设置Map输出key类型
        job.setMapOutputKeyClass(Text.class);
        // 设置Map输出value类型
        job.setMapOutputValueClass(Text.class);

        // ====== 关键:设置自定义Partitioner ======
        job.setPartitionerClass(ProvincePartitioner.class);
        // 设置ReduceTask数量(必须与Partitioner返回的分区号范围匹配)
        // 这里设置5个分区:0-3对应四个地区,4对应其他地区
        job.setNumReduceTasks(5);

        // 设置输入输出路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        // 提交作业
        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}

4.5 OutputFormat组件

作用: 负责MapReduce作业的输出,决定输出文件的格式和写入位置。

常用OutputFormat类:

OutputFormat类 说明
TextOutputFormat 默认格式,将结果以文本形式写入文件
SequenceFileOutputFormat 输出为SequenceFile格式(二进制)
MultipleOutputs 允许输出到多个文件或目录
DBOutputFormat 将结果写入数据库
NullOutputFormat 不输出任何数据(用于某些中间处理场景)
案例代码6:自定义OutputFormat------按条件输出到不同文件

需求: 将日志中包含"error"的记录输出到error.log,其余输出到other.log。

java 复制代码
package com.hadoop.mapreduce.outputformat;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FSDataOutputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.RecordWriter;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;

/**
 * 自定义OutputFormat类
 * 根据日志内容将数据输出到不同的文件
 * 继承FileOutputFormat,泛型为输出的key和value类型
 */
public class LogOutputFormat extends FileOutputFormat<Text, NullWritable> {

    /**
     * 创建自定义的RecordWriter实例
     * @param context 任务上下文
     * @return 自定义的LogRecordWriter实例
     */
    @Override
    public RecordWriter<Text, NullWritable> getRecordWriter(TaskAttemptContext context)
            throws IOException, InterruptedException {
        // 创建自定义RecordWriter并返回
        return new LogRecordWriter(context);
    }

    /**
     * 自定义RecordWriter内部类
     * 根据内容决定写入error日志文件还是other日志文件
     */
    public static class LogRecordWriter extends RecordWriter<Text, NullWritable> {

        private FSDataOutputStream errorOut;   // error日志输出流
        private FSDataOutputStream otherOut;    // other日志输出流

        /**
         * 构造方法:初始化输出流
         * @param context 任务上下文,用于获取配置和文件系统信息
         */
        public LogRecordWriter(TaskAttemptContext context) throws IOException {
            // 获取配置
            Configuration conf = context.getConfiguration();
            // 获取文件系统
            FileSystem fs = FileSystem.get(conf);

            // 创建error日志文件的输出流
            // 路径为输出目录下的error.log
            errorOut = fs.create(new Path(FileOutputFormat.getOutputPath(context), "error.log"));

            // 创建other日志文件的输出流
            // 路径为输出目录下的other.log
            otherOut = fs.create(new Path(FileOutputFormat.getOutputPath(context), "other.log"));
        }

        /**
         * 写入一条记录
         * @param key   输出的key(日志行内容)
         * @param value 输出的value(NullWritable,无实际意义)
         */
        @Override
        public void write(Text key, NullWritable value) throws IOException, InterruptedException {
            // 获取日志内容
            String log = key.toString();

            // 判断日志是否包含"error"关键字(不区分大小写)
            if (log.toLowerCase().contains("error")) {
                // 包含error,写入error日志文件
                errorOut.writeBytes(log + "\n");
            } else {
                // 不包含error,写入other日志文件
                otherOut.writeBytes(log + "\n");
            }
        }

        /**
         * 关闭所有输出流,释放资源
         * @param context 任务上下文
         */
        @Override
        public void close(TaskAttemptContext context) throws IOException, InterruptedException {
            // 关闭error输出流
            if (errorOut != null) {
                errorOut.close();
            }
            // 关闭other输出流
            if (otherOut != null) {
                otherOut.close();
            }
        }
    }
}

4.6 MapReduce驱动类(Driver)

作用: 驱动类是MapReduce程序的入口,负责配置和提交作业。

案例代码7:完整的WordCount驱动类
java 复制代码
package com.hadoop.mapreduce.wc;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;
import org.apache.hadoop.mapreduce.lib.reduce.LongSumReducer;

/**
 * WordCount驱动类
 * 负责配置MapReduce作业的各项参数并提交执行
 * 这是整个MapReduce程序的入口
 */
public class WordCountDriver {

    public static void main(String[] args) throws Exception {

        // 第1步:获取配置信息
        // Configuration会自动加载core-default.xml和core-site.xml
        Configuration conf = new Configuration();
        // 可以手动设置配置项
        // conf.set("mapreduce.framework.name", "yarn");

        // 第2步:获取Job实例
        // 参数1:配置信息
        // 参数2:作业名称(会显示在YARN的Web UI上)
        Job job = Job.getInstance(conf, "WordCount Job");

        // 第3步:设置Jar包路径
        // 指定包含Mapper、Reducer等类的Jar包
        // 本地运行时可以设置,集群运行时必须设置
        job.setJarByClass(WordCountDriver.class);

        // 第4步:设置Mapper类
        job.setMapperClass(WordCountMapper.class);

        // 第5步:设置Combiner(可选)
        // Combiner在Map端进行局部聚合,减少网络传输量
        // 注意:Combiner的输入输出类型必须与Mapper一致
        // 本例中使用Hadoop自带的LongSumReducer作为Combiner
        job.setCombinerClass(LongSumReducer.class);

        // 第6步:设置Reducer类
        job.setReducerClass(WordCountReducer.class);

        // 第7步:设置Map输出的key和value类型
        // 如果Map输出和最终输出类型不同,必须分别设置
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(LongWritable.class);

        // 第8步:设置最终输出的key和value类型
        // 如果与Map输出相同,可以省略
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(LongWritable.class);

        // 第9步:设置输入格式(可选,默认为TextInputFormat)
        job.setInputFormatClass(TextInputFormat.class);

        // 第10步:设置输出格式(可选,默认为TextOutputFormat)
        job.setOutputFormatClass(TextOutputFormat.class);

        // 第11步:设置ReduceTask的数量(可选,默认为1)
        // 设置为0表示只有Map阶段,没有Reduce阶段
        // 设置为1表示只有1个ReduceTask
        // 设置为N表示有N个ReduceTask(输出N个结果文件)
        job.setNumReduceTasks(1);

        // 第12步:设置输入路径
        // 可以设置多个输入路径
        FileInputFormat.addInputPath(job, new Path(args[0]));

        // 第13步:设置输出路径
        // 输出路径不能已存在,否则会报错
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        // 第14步:提交作业并等待完成
        // 参数true表示打印作业执行的进度信息
        // 返回true表示作业成功,false表示失败
        boolean result = job.waitForCompletion(true);

        // 第15步:根据作业执行结果退出程序
        // 成功返回0,失败返回1
        System.exit(result ? 0 : 1);
    }
}

五、MapReduce性能优化策略

5.1 数据输入优化

案例代码8:使用CombineTextInputFormat优化小文件输入
java 复制代码
// 在Driver类中设置CombineTextInputFormat
job.setInputFormatClass(CombineTextInputFormat.class);
// 设置虚拟存储切片大小(根据实际情况调整)
CombineTextInputFormat.setMaxInputSplitSize(job, 64 * 1024 * 1024); // 64MB

5.2 Map阶段优化

案例代码9:自定义Combiner减少Map输出
java 复制代码
package com.hadoop.mapreduce.combiner;

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

import java.io.IOException;

/**
 * 自定义Combiner类
 * 
 * Combiner的作用:
 * 1. 在Map端进行局部汇总,减少Map输出到Reduce的数据量
 * 2. 减少网络传输开销和磁盘IO
 * 
 * 注意事项:
 * 1. Combiner的输入输出类型必须与Mapper的输出类型一致
 * 2. Combiner不能改变最终的业务逻辑(例如求平均值时不能使用Combiner)
 * 3. Combiner在Map端可能执行0次、1次或多次
 * 
 * 本例中Combiner与Reducer逻辑完全相同(单词计数求和)
 * 可以直接在Driver中设置:job.setCombinerClass(WordCountReducer.class)
 */
public class WordCountCombiner extends Reducer<Text, LongWritable, Text, LongWritable> {

    private LongWritable result = new LongWritable();

    /**
     * reduce方法:在Map端进行局部聚合
     * @param key     单词
     * @param values  该单词在当前MapTask中的所有计数值
     * @param context 上下文
     */
    @Override
    protected void reduce(Text key, Iterable<LongWritable> values, Context context)
            throws IOException, InterruptedException {
        // 局部累加
        long sum = 0;
        for (LongWritable val : values) {
            sum += val.get();
        }
        result.set(sum);
        // 输出局部聚合结果
        context.write(key, result);
    }
}

5.3 Reduce阶段优化

java 复制代码
// 1. 合理设置ReduceTask数量
//    - 过少:导致ReduceTask处理数据量过大,执行时间长
//    - 过多:产生大量小文件,增加额外开销
//    经验值:每个ReduceTask处理的数据量在1-5GB为宜
job.setNumReduceTasks(10);

// 2. 设置Reduce端缓冲区大小(默认4.8%的堆内存)
conf.setFloat("mapreduce.reduce.shuffle.input.buffer.percent", 0.7f);

// 3. 设置Reduce端并行拷贝的Map输出数量
conf.setInt("mapreduce.reduce.shuffle.parallelcopies", 10);

5.4 Shuffle优化

java 复制代码
// 1. 增大环形缓冲区大小(默认100MB → 256MB)
conf.setInt("mapreduce.task.io.sort.mb", 256);

// 2. 增大溢写阈值(默认0.80 → 0.90)
conf.setFloat("mapreduce.map.sort.spill.percent", 0.90f);

// 3. 增大合并因子(默认10)
conf.setInt("io.sort.factor", 20);

// 4. 启用Map端输出压缩(减少磁盘IO和网络传输)
conf.setBoolean("mapreduce.map.output.compress", true);
conf.setClass("mapreduce.map.output.compress.codec",
    org.apache.hadoop.io.compress.SnappyCodec.class,
    org.apache.hadoop.io.compress.CompressionCodec.class);

5.5 数据倾斜优化

案例代码10:通过自定义分区解决数据倾斜
java 复制代码
package com.hadoop.mapreduce.skew;

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

import java.util.HashMap;
import java.util.Map;

/**
 * 数据倾斜解决方案:自定义分区器
 * 
 * 思路:将热点key(出现频率极高的key)分散到多个ReduceTask中
 * 例如:将"the"这个热点单词随机分配到3个不同的ReduceTask
 * 通过给热点key添加随机后缀,使其映射到不同分区
 */
public class SkewPartitioner extends Partitioner<Text, Text> {

    // 定义热点key集合
    private static final Map<String, Integer> HOT_KEYS = new HashMap<>();
    static {
        // 将热点key"the"标记为热点
        HOT_KEYS.put("the", 1);
        // 可以添加更多热点key
        HOT_KEYS.put("a", 1);
        HOT_KEYS.put("is", 1);
    }

    @Override
    public int getPartition(Text key, Text value, int numPartitions) {
        String word = key.toString();
        // 判断是否为热点key
        if (HOT_KEYS.containsKey(word)) {
            // 热点key:使用随机分区,分散到多个ReduceTask
            // hashCode的低4位取模,保证在同一MapTask中多次出现的
            // 相同热点key被分散到不同ReduceTask
            return (word.hashCode() & Integer.MAX_VALUE) % numPartitions;
        }
        // 非热点key:使用Hash分区,与默认行为一致
        return (word.hashCode() & Integer.MAX_VALUE) % numPartitions;
    }
}

5.6 其他优化策略

java 复制代码
// 1. 开启推测执行(当某个Task明显慢于其他Task时,启动备份Task)
conf.setBoolean("mapreduce.map.speculative", true);
conf.setBoolean("mapreduce.reduce.speculative", true);

// 2. 设置JVM重用(避免频繁启动JVM的开销)
// 注意:JVM重用会一直占用槽位直到所有Task执行完毕
conf.setInt("mapreduce.job.jvm.numtasks", 10);

// 3. 启用输出压缩(减少输出文件大小)
FileOutputFormat.setCompressOutput(job, true);
FileOutputFormat.setOutputCompressorClass(job,
    org.apache.hadoop.io.compress.BZip2Codec.class);

// 4. 开启Uber模式(小作业在单个JVM中运行,避免YARN资源分配开销)
conf.setBoolean("mapreduce.job.ubertask.enable", true);
conf.setInt("mapreduce.job.ubertask.maxmaps", 9);
conf.setInt("mapreduce.job.ubertask.maxreduces", 1);

六、YARN资源管理框架

6.1 YARN基本架构

YARN(Yet Another Resource Negotiator)是Hadoop的资源管理框架,采用主从架构

复制代码
┌─────────────────────────────────────────────────────┐
│                    ResourceManager                     │
│  ┌──────────────┐  ┌──────────────────────────────┐  │
│  │  Scheduler    │  │  ApplicationsManager         │  │
│  │ (资源调度器)   │  │  (作业管理器)                 │  │
│  └──────────────┘  └──────────────────────────────┘  │
└─────────────────────┬───────────────────────────────┘
                      │
         ┌────────────┼────────────┐
         ↓            ↓            ↓
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ NodeManager │ │ NodeManager │ │ NodeManager │
│  (从节点)    │ │  (从节点)    │ │  (从节点)    │
│ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │
│ │Container│ │ │ │Container│ │ │ │Container│ │
│ │(AppMstr)│ │ │ │         │ │ │ │         │ │
│ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │
│ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │
│ │Container│ │ │ │Container│ │ │ │Container│ │
│ │(MapTask)│ │ │ │(MapTask)│ │ │ │(RdcTask)│ │
│ └─────────┘ │ │ └─────────┘ │ │ └─────────┘ │
└─────────────┘ └─────────────┘ └─────────────┘

核心组件:

组件 角色 说明
ResourceManager (RM) 主节点 负责整个集群的资源管理和调度
├── Scheduler 负责资源分配,不负责作业监控
└── ApplicationsManager 负责作业提交、启动ApplicationMaster
NodeManager (NM) 从节点 负责单个节点的资源管理和任务执行
ApplicationMaster (AM) 每个应用一个,负责向RM申请资源、与NM通信
Container 资源抽象,封装了一定的CPU和内存

6.2 YARN工作流程

复制代码
步骤1:客户端向ResourceManager提交应用程序(Job)
       - 包括:ApplicationMaster程序、启动命令、用户程序等

步骤2:ResourceManager接收请求,分配一个Container
       在某个NodeManager上启动ApplicationMaster

步骤3:ApplicationMaster向ResourceManager注册自己
       并周期性地发送心跳,报告存活状态

步骤4:ApplicationMaster向ResourceManager申请资源
       - 根据输入数据的分片数确定MapTask数量
       - 根据配置确定ReduceTask数量

步骤5:ResourceManager根据调度策略分配Container资源
       返回Container信息(所在节点、资源大小)给ApplicationMaster

步骤6:ApplicationMaster与对应的NodeManager通信
       要求在分配的Container中启动MapTask和ReduceTask

步骤7:NodeManager启动Container,执行MapTask和ReduceTask
       任务定期向ApplicationMaster报告进度和状态

步骤8:所有任务执行完毕后
       ApplicationMaster向ResourceManager注销并释放资源
案例代码11:YARN相关配置示例
java 复制代码
package com.hadoop.yarn.config;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.yarn.conf.YarnConfiguration;

/**
 * YARN资源配置示例
 * 演示如何通过代码设置YARN相关参数
 */
public class YarnConfigDemo {

    public static void main(String[] args) {
        // 创建配置对象
        Configuration conf = new YarnConfiguration();

        // ========== ResourceManager配置 ==========
        // ResourceManager所在的主机名
        System.out.println("ResourceManager地址: " +
            conf.get(YarnConfiguration.RM_ADDRESS));  // 默认: 0.0.0.0:8032

        // ResourceManager的Web UI端口
        System.out.println("ResourceManager Web地址: " +
            conf.get(YarnConfiguration.RM_WEBAPP_ADDRESS));  // 默认: 0.0.0.0:8088

        // 调度器类型:capacity(容量调度器)或 fair(公平调度器)
        System.out.println("调度器类型: " +
            conf.get(YarnConfiguration.RM_SCHEDULER));  // 默认: org.apache.hadoop.yarn.server.resourcemanager.scheduler.capacity.CapacityScheduler

        // ========== NodeManager配置 ==========
        // NodeManager可用内存(MB)
        System.out.println("NodeManager可用内存: " +
            conf.getInt(YarnConfiguration.NM_PMEM_MB, 8192) + " MB");  // 默认: 8192MB

        // NodeManager可用虚拟CPU核心数
        System.out.println("NodeManager可用CPU: " +
            conf.getInt(YarnConfiguration.NM_VCORES, 8));  // 默认: 8

        // ========== Container配置 ==========
        // Container最小内存
        System.out.println("Container最小内存: " +
            conf.getInt(YarnConfiguration.RM_SCHEDULER_MINIMUM_ALLOCATION_MB, 1024) + " MB");

        // Container最大内存
        System.out.println("Container最大内存: " +
            conf.getInt(YarnConfiguration.RM_SCHEDULER_MAXIMUM_ALLOCATION_MB, 8192) + " MB");
    }
}

七、MapReduce经典案例

7.1 经典案例一:数据去重

案例分析:

数据去重的目的是对大量冗余数据进行清理,保留唯一值。核心思想是利用MapReduce框架的Shuffle机制------相同key的数据会被分组到同一个Reduce中,只需将每条记录的key设为数据本身,value设为空即可。

输入数据示例(duplicates.txt):

复制代码
20240101 hello
20240102 world
20240101 hello
20240103 hadoop
20240102 world
20240104 spark
20240103 hadoop
20240101 hello
20240105 flink
20240105 flink

预期输出(去重后):

复制代码
20240101 hello
20240102 world
20240103 hadoop
20240104 spark
20240105 flink
案例代码12:数据去重完整实现
java 复制代码
package com.hadoop.mapreduce.dedup;

import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

/**
 * MapReduce数据去重案例
 * 
 * 核心思想:
 * Map阶段:将每行内容作为key输出,value设为空
 * Shuffle阶段:框架自动对key进行分组,相同的key会被合并
 * Reduce阶段:每组只输出一条记录(key本身),实现去重
 */
public class DataDedup {

    /**
     * Mapper类:数据去重Mapper
     * 
     * 输入: <行偏移量, 行内容>
     * 输出: <行内容, NullWritable>  -- 将整行作为key,value为空
     * 
     * 关键点:利用MapReduce框架按key分组的特性
     * 相同的行内容会被分到同一个Reduce中
     */
    public static class DedupMapper extends Mapper<Object, Text, Text, NullWritable> {

        // 输出的key对象,复用以提高性能
        private Text outKey = new Text();

        /**
         * map方法:对每一行输入,将行内容作为key输出
         * @param key     行偏移量(LongWritable类型,由Object接收)
         * @param value   行内容(Text类型)
         * @param context 上下文
         */
        @Override
        protected void map(Object key, Text value, Context context)
                throws IOException, InterruptedException {
            // 获取一行文本内容
            String line = value.toString();
            // 去除首尾空白字符
            line = line.trim();
            // 如果行不为空
            if (!line.isEmpty()) {
                // 将整行内容设置为输出的key
                outKey.set(line);
                // value设为空(NullWritable不占空间)
                // 输出:<"20240101 hello", NullWritable>
                context.write(outKey, NullWritable.get());
            }
        }
    }

    /**
     * Reducer类:数据去重Reducer
     * 
     * 输入: <行内容, [NullWritable, NullWritable, ...]>
     * 输出: <行内容, NullWritable>
     * 
     * 关键点:Shuffle已经将相同的行内容分到了同一个组
     * 只需输出一次key即可完成去重
     */
    public static class DedupReducer extends Reducer<Text, NullWritable, Text, NullWritable> {

        /**
         * reduce方法:对每组相同key,只输出一次
         * @param key     去重后的行内容
         * @param values  空值列表(有多少个重复就有多少个NullWritable)
         * @param context 上下文
         */
        @Override
        protected void reduce(Text key, Iterable<NullWritable> values, Context context)
                throws IOException, InterruptedException {
            // 不需要遍历values,直接输出key一次即可去重
            // 因为只要输出一次,就表示该内容存在且唯一
            context.write(key, NullWritable.get());
        }
    }

    /**
     * 驱动类:配置并提交作业
     */
    public static void main(String[] args) throws Exception {
        // 获取配置信息
        Configuration conf = new Configuration();
        // 创建Job实例
        Job job = Job.getInstance(conf, "Data Deduplication");
        // 设置主类
        job.setJarByClass(DataDedup.class);

        // 设置Mapper类
        job.setMapperClass(DedupMapper.class);
        // 设置Reducer类
        job.setReducerClass(DedupReducer.class);

        // 设置Map输出key类型
        job.setMapOutputKeyClass(Text.class);
        // 设置Map输出value类型
        job.setMapOutputValueClass(NullWritable.class);

        // 设置最终输出key类型
        job.setOutputKeyClass(Text.class);
        // 设置最终输出value类型
        job.setOutputValueClass(NullWritable.class);

        // 设置ReduceTask数量为1(去重后输出为单个文件,方便查看)
        job.setNumReduceTasks(1);

        // 设置输入路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        // 设置输出路径
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        // 提交作业并等待完成
        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}

7.2 经典案例二:TopN

案例分析:

TopN问题即从大量数据中找出最大(或最小)的N个值。核心思路是利用Reduce端数据的有序性(Shuffle后的数据按key排序),同时在Reducer中维护一个固定大小的有序集合。

输入数据示例(scores.txt):

复制代码
张三 85
李四 92
王五 78
赵六 95
孙七 88
周八 65
吴九 99
郑十 72
冯十一 90
陈十二 83

预期输出(成绩Top3):

复制代码
吴九 99
赵六 95
李四 92
案例代码13:TopN完整实现
java 复制代码
package com.hadoop.mapreduce.topn;

import java.io.IOException;
import java.util.TreeMap;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

/**
 * MapReduce TopN案例
 * 
 * 功能:找出成绩最高的前N名学生
 * 
 * 思路:
 * 1. Map阶段:解析每行数据,输出<成绩, 姓名>
 *    以成绩为key,利用MapReduce的自动排序功能
 * 2. Reduce阶段:使用TreeMap维护TopN的有序集合
 *    TreeMap会按key的自然顺序(升序)排列
 *    当TreeMap大小超过N时,移除最小的元素
 * 3. 最终TreeMap中保留的就是TopN
 */
public class ScoreTopN {

    /**
     * N的值------取前N名
     * 在实际应用中,应通过Configuration传递
     */
    private static final int N = 3;

    /**
     * Mapper类:将成绩作为key,姓名作为value输出
     * 
     * 设计思路:将成绩作为key,利用框架的排序功能
     * Mapper输出: <成绩, 姓名>
     */
    public static class TopNMapper extends Mapper<Object, Text, IntWritable, Text> {

        // 输出的key:成绩
        private IntWritable scoreKey = new IntWritable();
        // 输出的value:姓名
        private Text nameValue = new Text();

        /**
         * map方法:解析每行数据
         * @param key     行偏移量
         * @param value   行内容,格式为 "姓名 成绩"
         * @param context 上下文
         */
        @Override
        protected void map(Object key, Text value, Context context)
                throws IOException, InterruptedException {
            // 获取一行内容
            String line = value.toString();
            // 去除首尾空白
            line = line.trim();
            // 跳过空行
            if (line.isEmpty()) {
                return;
            }

            // 按空格切分字段
            String[] fields = line.split("\\s+");
            // 至少包含姓名和成绩两个字段
            if (fields.length >= 2) {
                String name = fields[0];    // 第一个字段:姓名
                int score = Integer.parseInt(fields[1]); // 第二个字段:成绩

                // 设置输出key为成绩
                scoreKey.set(score);
                // 设置输出value为姓名
                nameValue.set(name);
                // 输出:<成绩, 姓名>
                context.write(scoreKey, nameValue);
            }
        }
    }

    /**
     * Reducer类:在所有数据中找出TopN
     * 
     * 使用TreeMap维护TopN:
     * TreeMap按key升序排列,始终保留最大的N个
     * 当大小超过N时,移除第一个(最小的)元素
     */
    public static class TopNReducer extends Reducer<IntWritable, Text, Text, IntWritable> {

        /**
         * TreeMap用于保存TopN结果
         * key: 成绩(Integer)
         * value: 姓名(Text)
         * TreeMap按key自然升序排列
         */
        private TreeMap<Integer, String> topNMap = new TreeMap<>();

        /**
         * reduce方法:处理每组数据,维护TopN
         * @param key     成绩
         * @param values  该成绩对应的所有姓名
         * @param context 上下文
         */
        @Override
        protected void reduce(IntWritable key, Iterable<Text> values, Context context)
                throws IOException, InterruptedException {
            // 获取当前成绩
            int score = key.get();
            // 遍历该成绩对应的所有姓名
            for (Text val : values) {
                // 将成绩和姓名放入TreeMap
                // TreeMap按key(成绩)升序排列
                topNMap.put(score, val.toString());

                // 如果TreeMap大小超过N,移除最小的元素
                // firstKey()返回最小的key
                while (topNMap.size() > N) {
                    topNMap.remove(topNMap.firstKey());
                }
            }
        }

        /**
         * cleanup方法:在所有数据处理完毕后输出TopN结果
         * 只在ReduceTask结束时调用一次
         */
        @Override
        protected void cleanup(Context context) throws IOException, InterruptedException {
            // 以降序遍历TreeMap(lastKey到firstKey)
            // 使用while循环从大到小输出TopN
            while (!topNMap.isEmpty()) {
                // lastKey()返回最大的key
                int maxScore = topNMap.lastKey();
                // 获取该成绩对应的姓名
                String name = topNMap.get(maxScore);
                // 输出:<姓名, 成绩>
                context.write(new Text(name), new IntWritable(maxScore));
                // 移除已输出的最大元素
                topNMap.remove(maxScore);
            }
        }
    }

    /**
     * 驱动类
     */
    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf, "Score TopN");
        job.setJarByClass(ScoreTopN.class);

        // 设置Mapper和Reducer
        job.setMapperClass(TopNMapper.class);
        job.setReducerClass(TopNReducer.class);

        // 设置Map输出类型
        job.setMapOutputKeyClass(IntWritable.class);
        job.setMapOutputValueClass(Text.class);

        // 设置最终输出类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(IntWritable.class);

        // 设置1个ReduceTask
        job.setNumReduceTasks(1);

        // 输入输出路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        // 提交作业
        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}
案例代码14:分组TopN------每个类别取TopN
java 复制代码
package com.hadoop.mapreduce.topn;

import java.io.IOException;
import java.util.TreeMap;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;

/**
 * 分组TopN Reducer
 * 
 * 需求:每个科目的成绩前2名
 * 输入数据格式:科目 姓名 成绩
 * 例如:语文 张三 85
 *       数学 李四 92
 *       语文 王五 95
 * 
 * Mapper输出:<"语文_85", "张三"> 
 *   利用Shuffle的排序功能,使同一科目的数据按成绩排序
 * 
 * 本类只展示Reducer部分的实现
 */
public class GroupTopNReducer extends Reducer<Text, Text, Text, Text> {

    // 每个类别取前2名
    private static final int N = 2;
    // 按类别分别维护TopN
    // key: "类别_排名" value: "姓名_成绩"
    private TreeMap<String, String> topNMap = new TreeMap<>();

    // 记录当前处理的类别
    private String currentCategory = "";

    @Override
    protected void reduce(Text key, Iterable<Text> values, Context context)
            throws IOException, InterruptedException {
        // key的格式为 "类别_成绩",如 "语文_95"
        String keyStr = key.toString();
        // 按下划线切分,获取类别
        String category = keyStr.split("_")[0];
        // 获取成绩
        String scoreStr = keyStr.split("_")[1];

        // 如果类别发生变化,先输出上一个类别的TopN,然后清空Map
        if (!category.equals(currentCategory) && !currentCategory.isEmpty()) {
            // 输出上一个类别的TopN结果
            outputTopN(currentCategory, context);
            // 清空Map,准备处理新类别
            topNMap.clear();
        }

        // 更新当前类别
        currentCategory = category;

        // 遍历该成绩对应的所有姓名
        for (Text val : values) {
            // TreeMap中key为"成绩_姓名",保证相同成绩时按姓名排序
            topNMap.put(scoreStr + "_" + val.toString(), val.toString());

            // 保持TreeMap大小不超过N
            while (topNMap.size() > N) {
                // 移除最小的(firstKey())
                topNMap.remove(topNMap.firstKey());
            }
        }
    }

    /**
     * cleanup方法:处理最后一个类别的TopN
     */
    @Override
    protected void cleanup(Context context) throws IOException, InterruptedException {
        // 输出最后一个类别的TopN
        if (!currentCategory.isEmpty()) {
            outputTopN(currentCategory, context);
        }
    }

    /**
     * 输出某个类别的TopN结果
     * @param category 类别名称
     * @param context  上下文
     */
    private void outputTopN(String category, Context context)
            throws IOException, InterruptedException {
        // 降序输出
        while (!topNMap.isEmpty()) {
            // 获取最大的key
            String maxKey = topNMap.lastKey();
            // 切分出成绩和姓名
            String[] parts = maxKey.split("_");
            String score = parts[0];
            String name = parts[1];
            // 输出:<"类别:姓名", "成绩">
            context.write(new Text(category + ":" + name), new Text("成绩:" + score));
            // 移除已输出的元素
            topNMap.remove(maxKey);
        }
    }
}

7.3 经典案例三:倒排索引

倒排索引介绍:

倒排索引是搜索引擎中最常用的数据结构。它记录了每个单词出现在哪些文件中以及出现的次数。

正排索引 vs 倒排索引:

复制代码
正排索引:文件 → 包含哪些单词
  file1.txt → {hello, world, hadoop}
  file2.txt → {hello, spark, world}

倒排索引:单词 → 出现在哪些文件中
  hello   → {file1.txt:1, file2.txt:1}
  world   → {file1.txt:1, file2.txt:1}
  hadoop  → {file1.txt:1}
  spark   → {file2.txt:1}

输入数据示例:

file1.txt:

复制代码
hello world hello
hadoop mapreduce

file2.txt:

复制代码
hello spark
world spark hadoop

file3.txt:

复制代码
mapreduce spark
hadoop hello

预期输出:

复制代码
hadoop     file1.txt:1,file2.txt:1,file3.txt:1
hello      file1.txt:2,file2.txt:1,file3.txt:1
mapreduce  file1.txt:1,file3.txt:1
spark      file2.txt:2,file3.txt:1
world      file1.txt:1,file2.txt:1
案例代码15:倒排索引完整实现(两次MapReduce)

第一阶段:统计每个单词在每个文件中出现的次数

java 复制代码
package com.hadoop.mapreduce.invertedindex;

import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.FileSplit;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

/**
 * 倒排索引案例------第一阶段
 * 
 * 功能:统计每个单词在每个文件中出现的次数
 * 输出格式:单词\t文件名:次数
 * 例如:hello\tfile1.txt:2
 */
public class InvertedIndexStep1 {

    /**
     * Mapper类
     * 
     * 关键点:通过FileSplit获取当前处理的文件名
     * 输出:<"单词##文件名", 1>
     * 用"##"作为分隔符,避免文件名和单词中的字符冲突
     */
    public static class Step1Mapper extends Mapper<LongWritable, Text, Text, LongWritable> {

        private Text outKey = new Text();           // 输出key
        private LongWritable outValue = new LongWritable(1); // 输出value,固定为1
        private String fileName;                     // 当前处理的文件名

        /**
         * setup方法:获取当前处理的文件名
         * 在MapTask开始时调用一次
         */
        @Override
        protected void setup(Context context) throws IOException, InterruptedException {
            // 从InputSplit中获取文件切片信息
            FileSplit split = (FileSplit) context.getInputSplit();
            // 获取文件路径的文件名部分(不含路径)
            fileName = split.getPath().getName();
            // 例如:fileName = "file1.txt"
        }

        /**
         * map方法:读取每一行,切分单词,输出<单词##文件名, 1>
         */
        @Override
        protected void map(LongWritable key, Text value, Context context)
                throws IOException, InterruptedException {
            // 获取一行文本
            String line = value.toString();
            // 按空白字符切分
            String[] words = line.split("\\s+");
            // 遍历每个单词
            for (String word : words) {
                // 去除空白
                word = word.trim();
                if (!word.isEmpty()) {
                    // 拼接key格式:"单词##文件名"
                    // 例如:"hello##file1.txt"
                    outKey.set(word + "##" + fileName);
                    // 输出:<"hello##file1.txt", 1>
                    context.write(outKey, outValue);
                }
            }
        }
    }

    /**
     * Reducer类
     * 
     * 输入:<"单词##文件名", [1, 1, ...]>
     * 输出:<"单词\t文件名", 次数>
     * 
     * Shuffle阶段会将相同key的数据分组
     * 相同的"单词##文件名"组合会被归为一组
     * 累加即可得到每个单词在每个文件中出现的总次数
     */
    public static class Step1Reducer extends Reducer<Text, LongWritable, Text, LongWritable> {

        private Text outKey = new Text();
        private LongWritable outValue = new LongWritable();

        @Override
        protected void reduce(Text key, Iterable<LongWritable> values, Context context)
                throws IOException, InterruptedException {
            // 获取key字符串,如"hello##file1.txt"
            String keyStr = key.toString();
            // 按"##"切分,获取单词和文件名
            String[] parts = keyStr.split("##");
            String word = parts[0];      // 单词,如"hello"
            String fileName = parts[1];  // 文件名,如"file1.txt"

            // 累加该单词在该文件中的出现次数
            long count = 0;
            for (LongWritable val : values) {
                count += val.get();
            }

            // 设置输出key:"单词\t文件名"
            // 使用制表符分隔,方便下一阶段处理
            outKey.set(word + "\t" + fileName);
            // 设置输出value:出现次数
            outValue.set(count);
            // 输出:<"hello\tfile1.txt", 2>
            context.write(outKey, outValue);
        }
    }

    /**
     * 驱动类
     */
    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf, "Inverted Index Step 1");
        job.setJarByClass(InvertedIndexStep1.class);

        // 设置Mapper和Reducer
        job.setMapperClass(Step1Mapper.class);
        job.setReducerClass(Step1Reducer.class);

        // 设置输出类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(LongWritable.class);
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(LongWritable.class);

        // 设置输入输出路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}

第二阶段:将同一单词的所有文件信息合并到一行

java 复制代码
package com.hadoop.mapreduce.invertedindex;

import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

/**
 * 倒排索引案例------第二阶段
 * 
 * 功能:将第一阶段的输出合并,生成最终的倒排索引
 * 输入:第一阶段的输出(单词\t文件名\t次数)
 * 输出:单词\t文件名1:次数1,文件名2:次数2,...
 */
public class InvertedIndexStep2 {

    /**
     * Mapper类
     * 
     * 输入:第一阶段输出的 <偏移量, "单词\t文件名\t次数">
     * 输出:<单词, "文件名:次数">
     * 
     * 利用Shuffle将相同单词的数据分到同一个Reduce中
     */
    public static class Step2Mapper extends Mapper<LongWritable, Text, Text, Text> {

        private Text outKey = new Text();    // 输出key:单词
        private Text outValue = new Text(); // 输出value:"文件名:次数"

        @Override
        protected void map(LongWritable key, Text value, Context context)
                throws IOException, InterruptedException {
            // 获取一行内容
            String line = value.toString();
            // 按制表符切分:单词、文件名、次数
            String[] parts = line.split("\t");

            // 验证字段数量
            if (parts.length == 3) {
                String word = parts[0];      // 单词
                String fileName = parts[1];  // 文件名
                String count = parts[2];     // 出现次数

                // 输出key为单词
                outKey.set(word);
                // 输出value为"文件名:次数"
                // 例如:"file1.txt:2"
                outValue.set(fileName + ":" + count);
                // 输出:<"hello", "file1.txt:2">
                context.write(outKey, outValue);
            }
        }
    }

    /**
     * Reducer类
     * 
     * 输入:<单词, ["file1.txt:2", "file2.txt:1", ...]>
     * 输出:<单词, "file1.txt:2,file2.txt:1,...">
     * 
     * 将同一个单词的所有文件信息用逗号连接
     */
    public static class Step2Reducer extends Reducer<Text, Text, Text, Text> {

        private Text outValue = new Text();

        @Override
        protected void reduce(Text key, Iterable<Text> values, Context context)
                throws IOException, InterruptedException {
            // 使用StringBuilder拼接所有文件信息
            StringBuilder sb = new StringBuilder();

            // 遍历该单词在各个文件中的信息
            for (Text val : values) {
                // 如果不是第一个,添加逗号分隔
                if (sb.length() > 0) {
                    sb.append(",");
                }
                // 追加"文件名:次数"
                sb.append(val.toString());
            }

            // 设置输出value为拼接后的字符串
            outValue.set(sb.toString());
            // 输出最终倒排索引结果
            // 例如:<"hello", "file1.txt:2,file2.txt:1,file3.txt:1">
            context.write(key, outValue);
        }
    }

    /**
     * 驱动类
     */
    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf, "Inverted Index Step 2");
        job.setJarByClass(InvertedIndexStep2.class);

        // 设置Mapper和Reducer
        job.setMapperClass(Step2Mapper.class);
        job.setReducerClass(Step2Reducer.class);

        // 设置输出类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(Text.class);
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(Text.class);

        // 设置1个ReduceTask,保证输出全局有序
        job.setNumReduceTasks(1);

        // 注意:输入路径是第一阶段的输出路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        // 输出最终结果路径
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}
案例代码16:倒排索引------单次MapReduce实现
java 复制代码
package com.hadoop.mapreduce.invertedindex;

import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.FileSplit;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

/**
 * 倒排索引------单次MapReduce实现
 * 
 * 核心思路:
 * Mapper输出<单词, 文件名:1>
 * Shuffle按单词分组后,Reducer遍历所有值,拼接为完整索引
 */
public class InvertedIndexSinglePass {

    /**
     * Mapper类
     * 输出:<单词, "文件名:1">
     */
    public static class IndexMapper extends Mapper<LongWritable, Text, Text, Text> {

        private Text outKey = new Text();     // 输出key:单词
        private Text outValue = new Text();  // 输出value:"文件名:1"
        private String fileName;              // 当前文件名

        /**
         * 初始化:获取文件名
         */
        @Override
        protected void setup(Context context) throws IOException, InterruptedException {
            // 从输入切片中获取文件名
            FileSplit split = (FileSplit) context.getInputSplit();
            fileName = split.getPath().getName();
        }

        /**
         * map方法:逐行处理,输出<单词, "文件名:1">
         */
        @Override
        protected void map(LongWritable key, Text value, Context context)
                throws IOException, InterruptedException {
            // 获取一行内容
            String line = value.toString();
            // 切分单词
            String[] words = line.split("\\s+");
            // 遍历每个单词
            for (String word : words) {
                word = word.trim().toLowerCase(); // 转小写,统一处理
                if (!word.isEmpty()) {
                    // 设置key为单词
                    outKey.set(word);
                    // 设置value为"文件名:1"
                    outValue.set(fileName + ":1");
                    // 输出
                    context.write(outKey, outValue);
                }
            }
        }
    }

    /**
     * Reducer类
     * 
     * 输入:<"hello", ["file1.txt:1", "file1.txt:1", "file2.txt:1", "file3.txt:1"]>
     * 输出:<"hello", "file1.txt:2,file2.txt:1,file3.txt:1">
     * 
     * 在Reduce端进行汇总:先统计每个文件的出现次数,再拼接输出
     */
    public static class IndexReducer extends Reducer<Text, Text, Text, Text> {

        private Text outValue = new Text();

        @Override
        protected void reduce(Text key, Iterable<Text> values, Context context)
                throws IOException, InterruptedException {
            // 使用Java HashMap(或直接字符串处理)统计每个文件中该单词的出现次数
            // 这里使用简单的字符串拼接+手动计数方式
            java.util.Map<String, Integer> fileCountMap = new java.util.HashMap<>();

            // 遍历所有value,格式为"文件名:1"
            for (Text val : values) {
                String valStr = val.toString();
                // 切分文件名和计数
                String[] parts = valStr.split(":");
                String file = parts[0];    // 文件名
                int count = Integer.parseInt(parts[1]); // 计数(每次都是1)

                // 在Map中累加该文件的计数
                fileCountMap.put(file,
                    fileCountMap.getOrDefault(file, 0) + count);
            }

            // 拼接最终结果
            StringBuilder sb = new StringBuilder();
            for (java.util.Map.Entry<String, Integer> entry : fileCountMap.entrySet()) {
                if (sb.length() > 0) {
                    sb.append(",");  // 逗号分隔不同文件
                }
                // 格式:"文件名:次数"
                sb.append(entry.getKey()).append(":").append(entry.getValue());
            }

            // 设置输出value
            outValue.set(sb.toString());
            // 输出最终倒排索引
            context.write(key, outValue);
        }
    }

    /**
     * 驱动类
     */
    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf, "Inverted Index Single Pass");
        job.setJarByClass(InvertedIndexSinglePass.class);

        // 设置Mapper和Reducer
        job.setMapperClass(IndexMapper.class);
        job.setReducerClass(IndexReducer.class);

        // 设置输出类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(Text.class);
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(Text.class);

        // 设置ReduceTask数量
        job.setNumReduceTasks(1);

        // 输入输出路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}

八、补充知识点与案例

8.1 Writable序列化接口

案例代码17:自定义Writable类型
java 复制代码
package com.hadoop.mapreduce.writable;

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import org.apache.hadoop.io.WritableComparable;

/**
 * 自定义Writable类型:FlowBean(流量Bean)
 * 
 * 实现WritableComparable接口,用于在MapReduce中作为key使用
 * 如果只作为value使用,实现Writable接口即可
 * 
 * 序列化注意事项:
 * 1. 写入和读取的字段顺序必须一致
 * 2. 必须提供无参构造方法(反序列化时需要)
 * 3. 实现compareTo方法以便排序
 */
public class FlowBean implements WritableComparable<FlowBean> {

    private long upFlow;     // 上行流量(字节)
    private long downFlow;   // 下行流量(字节)
    private long totalFlow;  // 总流量

    /**
     * 无参构造方法(必须提供,反序列化时使用)
     */
    public FlowBean() {
    }

    /**
     * 带参构造方法
     * @param upFlow   上行流量
     * @param downFlow 下行流量
     */
    public FlowBean(long upFlow, long downFlow) {
        this.upFlow = upFlow;
        this.downFlow = downFlow;
        this.totalFlow = upFlow + downFlow;
    }

    /**
     * 序列化方法:将对象的字段写入输出流
     * 注意:写入顺序决定了读取顺序,必须与readFields一致
     * @param out 数据输出流
     */
    @Override
    public void write(DataOutput out) throws IOException {
        // 按顺序写入字段到输出流
        out.writeLong(upFlow);      // 写入上行流量
        out.writeLong(downFlow);    // 写入下行流量
        out.writeLong(totalFlow);   // 写入总流量
    }

    /**
     * 反序列化方法:从输入流读取字段值
     * 注意:读取顺序必须与write方法的写入顺序一致
     * @param in 数据输入流
     */
    @Override
    public void readFields(DataInput in) throws IOException {
        // 按顺序从输入流读取字段
        upFlow = in.readLong();      // 读取上行流量
        downFlow = in.readLong();    // 读取下行流量
        totalFlow = in.readLong();   // 读取总流量
    }

    /**
     * 比较方法:用于排序
     * 按总流量降序排列
     * @param other 另一个FlowBean对象
     * @return 负数:this < other,0:相等,正数:this > other
     */
    @Override
    public int compareTo(FlowBean other) {
        // 按总流量降序排列(大的在前)
        return Long.compare(other.totalFlow, this.totalFlow);
    }

    /**
     * toString方法:用于输出到文件
     * @return 格式化的字符串
     */
    @Override
    public String toString() {
        return upFlow + "\t" + downFlow + "\t" + totalFlow;
    }

    // Getter和Setter方法
    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 getTotalFlow() { return totalFlow; }
    public void setTotalFlow(long totalFlow) { this.totalFlow = totalFlow; }

    /**
     * 设置流量值并自动计算总流量
     * @param upFlow   上行流量
     * @param downFlow 下行流量
     */
    public void set(long upFlow, long downFlow) {
        this.upFlow = upFlow;
        this.downFlow = downFlow;
        this.totalFlow = upFlow + downFlow;
    }
}

8.2 计数器(Counter)

案例代码18:自定义计数器
java 复制代码
package com.hadoop.mapreduce.counter;

import java.io.IOException;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

/**
 * 自定义计数器示例
 * 
 * 计数器用于统计MapReduce作业执行过程中的各种信息
 * 如:处理了多少条记录、多少条有效记录、多少条无效记录等
 * 计数器的值会在作业完成后显示在控制台
 */
public class CounterMapper extends Mapper<LongWritable, Text, Text, NullWritable> {

    /**
     * 定义自定义枚举计数器
     * 每个枚举值代表一个计数器
     */
    enum MyCounter {
        VALID_RECORD,     // 有效记录数
        INVALID_RECORD,   // 无效记录数
        EMPTY_RECORD      // 空行记录数
    }

    @Override
    protected void map(LongWritable key, Text value, Context context)
            throws IOException, InterruptedException {
        // 获取一行内容
        String line = value.toString().trim();

        // 判断行内容类型
        if (line.isEmpty()) {
            // 空行:递增空行计数器
            context.getCounter(MyCounter.EMPTY_RECORD).increment(1);
        } else if (line.length() < 5) {
            // 无效行(长度小于5):递增无效记录计数器
            context.getCounter(MyCounter.INVALID_RECORD).increment(1);
        } else {
            // 有效行:递增有效记录计数器
            context.getCounter(MyCounter.VALID_RECORD).increment(1);
            // 输出有效记录
            context.write(new Text(line), NullWritable.get());
        }

        // 也可以使用内置的Hadoop计数器
        // 例如:统计Map输入记录数
        context.getCounter("MyCounters", "total_input_records").increment(1);
    }
}

8.3 MultipleOutputs多路输出

案例代码19:使用MultipleOutputs输出到多个文件
java 复制代码
package com.hadoop.mapreduce.multipleoutput;

import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.mapreduce.lib.output.MultipleOutputs;

/**
 * MultipleOutputs多路输出案例
 * 
 * 需求:将不同类别的数据输出到不同文件
 * 例如:将数据分为"正常"和"异常"两类,分别输出到不同文件
 */
public class MultipleOutputDemo {

    /**
     * Mapper类:解析日志行,输出<类型, 日志内容>
     */
    public static class LogMapper extends Mapper<LongWritable, Text, Text, Text> {
        private Text outKey = new Text();
        private Text outValue = new Text();

        @Override
        protected void map(LongWritable key, Text value, Context context)
                throws IOException, InterruptedException {
            String line = value.toString();
            // 根据日志级别分类
            if (line.contains("ERROR")) {
                outKey.set("error");
            } else if (line.contains("WARN")) {
                outKey.set("warn");
            } else {
                outKey.set("info");
            }
            outValue.set(line);
            context.write(outKey, outValue);
        }
    }

    /**
     * Reducer类:使用MultipleOutputs输出到不同文件
     */
    public static class LogReducer extends Reducer<Text, Text, Text, NullWritable> {

        // 声明MultipleOutputs对象
        private MultipleOutputs<Text, NullWritable> multipleOutputs;

        /**
         * setup方法:初始化MultipleOutputs
         */
        @Override
        protected void setup(Context context) throws IOException, InterruptedException {
            // 创建MultipleOutputs实例
            // 参数:上下文
            multipleOutputs = new MultipleOutputs<>(context);
        }

        /**
         * reduce方法:根据key将数据写入不同的命名输出
         * @param key     日志类型(error/warn/info)
         * @param values  该类型的所有日志行
         * @param context 上下文
         */
        @Override
        protected void reduce(Text key, Iterable<Text> values, Context context)
                throws IOException, InterruptedException {
            // 遍历该类型的所有日志行
            for (Text val : values) {
                // 根据key(日志类型)写入不同的命名输出
                // 参数:命名输出名称,key名称后缀,key,value
                // 输出文件名格式:命名输出名称-键后缀-r-00000
                multipleOutputs.write(key.toString(), val, NullWritable.get());
            }
        }

        /**
         * cleanup方法:关闭MultipleOutputs
         */
        @Override
        protected void cleanup(Context context) throws IOException, InterruptedException {
            // 必须关闭MultipleOutputs,否则数据不会写入
            multipleOutputs.close();
        }
    }

    /**
     * 驱动类
     */
    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf, "Multiple Output Demo");
        job.setJarByClass(MultipleOutputDemo.class);

        job.setMapperClass(LogMapper.class);
        job.setReducerClass(LogReducer.class);

        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(Text.class);
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(NullWritable.class);

        // 添加命名输出
        // 参数1:命名输出名称
        // 参数2:输出格式类
        // 参数3:key类型
        // 参数4:value类型
        MultipleOutputs.addNamedOutput(job, "error",
            org.apache.hadoop.mapreduce.lib.output.TextOutputFormat.class,
            Text.class, NullWritable.class);
        MultipleOutputs.addNamedOutput(job, "warn",
            org.apache.hadoop.mapreduce.lib.output.TextOutputFormat.class,
            Text.class, NullWritable.class);
        MultipleOutputs.addNamedOutput(job, "info",
            org.apache.hadoop.mapreduce.lib.output.TextOutputFormat.class,
            Text.class, NullWritable.class);

        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}

8.4 MapReduce中的排序------二次排序

案例代码20:二次排序完整实现
java 复制代码
package com.hadoop.mapreduce.secondarysort;

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.RawComparator;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.io.WritableComparable;
import org.apache.hadoop.io.WritableComparator;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Partitioner;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

/**
 * 二次排序案例
 * 
 * 需求:对(年份, 温度)数据,先按年份升序,同年份按温度降序排列
 * 输入数据格式:年份\t温度
 * 例如:
 * 2020  35
 * 2020  28
 * 2021  30
 * 2020  32
 * 2021  25
 * 
 * 预期输出:
 * 2020  35
 * 2020  32
 * 2020  28
 * 2021  30
 * 2021  25
 * 
 * 实现思路:
 * 1. 自定义组合键(年份+温度),实现自定义排序规则
 * 2. 自定义分区器:只按年份分区(同年份数据到同一Reducer)
 * 3. 自定义分组比较器:只按年份分组(同年份数据进入同一reduce调用)
 */
public class SecondarySort {

    // ===================== 自定义组合键 =====================

    /**
     * 自定义组合键:包含年份和温度两个字段
     * 实现WritableComparable接口,支持序列化和排序
     */
    public static class CompositeKey implements WritableComparable<CompositeKey> {
        private int year;      // 年份
        private int temperature; // 温度

        // 无参构造
        public CompositeKey() {}

        // 带参构造
        public CompositeKey(int year, int temperature) {
            this.year = year;
            this.temperature = temperature;
        }

        /**
         * 序列化
         */
        @Override
        public void write(DataOutput out) throws IOException {
            out.writeInt(year);         // 先写年份
            out.writeInt(temperature);  // 再写温度
        }

        /**
         * 反序列化
         */
        @Override
        public void readFields(DataInput in) throws IOException {
            year = in.readInt();         // 先读年份
            temperature = in.readInt();  // 再读温度
        }

        /**
         * 默认比较方法:先按年份升序,同年份按温度降序
         */
        @Override
        public int compareTo(CompositeKey other) {
            // 先比较年份
            int cmp = Integer.compare(this.year, other.year);
            if (cmp != 0) {
                return cmp;  // 年份不同,按年份升序
            }
            // 年份相同,按温度降序(注意参数顺序)
            return Integer.compare(other.temperature, this.temperature);
        }

        // Getter方法
        public int getYear() { return year; }
        public int getTemperature() { return temperature; }

        @Override
        public String toString() {
            return year + "\t" + temperature;
        }
    }

    // ===================== 自定义分区器 =====================

    /**
     * 自定义分区器:只按年份分区
     * 确保同年份的数据发送到同一个Reducer
     */
    public static class YearPartitioner extends Partitioner<CompositeKey, Text> {
        @Override
        public int getPartition(CompositeKey key, Text value, int numPartitions) {
            // 只按年份计算分区号
            // 使用&运算避免负数
            return (key.getYear() & Integer.MAX_VALUE) % numPartitions;
        }
    }

    // ===================== 自定义分组比较器 =====================

    /**
     * 自定义分组比较器:只按年份分组
     * 确保同年份的所有数据进入同一个reduce()调用
     * 
     * 继承WritableComparator,重写compare方法
     */
    public static class YearGroupComparator extends WritableComparator {

        /**
         * 构造方法:注册比较器关联的key类型
         */
        protected YearGroupComparator() {
            super(CompositeKey.class, true);  // true表示创建实例
        }

        /**
         * 比较方法:只比较年份字段
         * @param a 第一个key的字节数组
         * @param b 第二个key的字节数组
         * @return 比较结果
         */
        @Override
        public int compare(byte[] a, int startA, int lengthA,
                          byte[] b, int startB, int lengthB) {
            // 从字节数组中读取年份(前4个字节,即一个int)
            int yearA = readInt(a, startA);
            int yearB = readInt(b, startB);
            // 只比较年份
            return Integer.compare(yearA, yearB);
        }
    }

    // ===================== Mapper =====================

    /**
     * Mapper类
     * 输入:<偏移量, "年份\t温度">
     * 输出:<CompositeKey(年份, 温度), 温度Text>
     */
    public static class SortMapper extends Mapper<LongWritable, Text, CompositeKey, Text> {

        private CompositeKey compositeKey = new CompositeKey();
        private Text tempValue = new Text();

        @Override
        protected void map(LongWritable key, Text value, Context context)
                throws IOException, InterruptedException {
            String line = value.toString().trim();
            if (line.isEmpty()) return;

            String[] parts = line.split("\\s+");
            if (parts.length >= 2) {
                int year = Integer.parseInt(parts[0]);
                int temperature = Integer.parseInt(parts[1]);

                // 设置组合键
                compositeKey = new CompositeKey(year, temperature);
                tempValue.set(String.valueOf(temperature));

                // 输出:<CompositeKey(年份, 温度), "温度">
                context.write(compositeKey, tempValue);
            }
        }
    }

    // ===================== Reducer =====================

    /**
     * Reducer类
     * 输入:<CompositeKey, [温度, 温度, ...]>
     * 由于使用了分组比较器,同年份的所有数据会进入同一个reduce调用
     * 且按温度降序排列(由CompositeKey的compareTo决定)
     */
    public static class SortReducer extends Reducer<CompositeKey, Text, Text, Text> {

        private Text outKey = new Text();

        @Override
        protected void reduce(CompositeKey key, Iterable<Text> values, Context context)
                throws IOException, InterruptedException {
            // 遍历该年份的所有温度值(已按降序排列)
            for (Text temp : values) {
                // 输出年份和温度
                outKey.set(String.valueOf(key.getYear()));
                context.write(outKey, temp);
            }
        }
    }

    // ===================== Driver =====================

    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf, "Secondary Sort");
        job.setJarByClass(SecondarySort.class);

        // 设置Mapper和Reducer
        job.setMapperClass(SortMapper.class);
        job.setReducerClass(SortReducer.class);

        // 设置Map输出类型为自定义组合键
        job.setMapOutputKeyClass(CompositeKey.class);
        job.setMapOutputValueClass(Text.class);

        // 设置最终输出类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(Text.class);

        // 设置自定义分区器(按年份分区)
        job.setPartitionerClass(YearPartitioner.class);

        // 设置自定义分组比较器(按年份分组)
        job.setGroupingComparatorClass(YearGroupComparator.class);

        // 设置ReduceTask数量
        job.setNumReduceTasks(2);

        // 输入输出路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}

九、本章小结

知识点 核心内容
MapReduce概述 分布式并行编程模型,核心思想是分而治之
编程模型 Map → Shuffle → Reduce,输入输出都是键值对
工作原理 MapTask和ReduceTask分工协作,Shuffle是核心机制
MapTask Read → Map → Collect → Spill → Merge
ReduceTask Copy → Merge → Sort → Reduce → Write
Shuffle 分区、排序、溢写、合并、归并,是性能关键
InputFormat 负责数据切分和读取,默认TextInputFormat按行读取
Mapper setup → map → cleanup,处理每条输入记录
Reducer setup → reduce → cleanup,对分组后的数据进行归约
Partitioner 决定数据发往哪个Reducer,默认HashPartitioner
OutputFormat 负责数据输出,默认TextOutputFormat
Driver 配置和提交Job的入口程序
性能优化 小文件合并、Combiner、Shuffle参数调优、数据倾斜处理
YARN架构 ResourceManager + NodeManager + ApplicationMaster + Container
YARN工作流程 提交作业 → 启动AM → 申请资源 → 启动任务 → 执行 → 完成
数据去重 利用Shuffle分组特性,相同key只输出一次
TopN 利用Shuffle排序特性 + TreeMap维护TopN集合
倒排索引 统计每个词出现在哪些文件中,搜索引擎核心数据结构
相关推荐
skywalk81631 小时前
记录段言的开发过程
开发语言·学习·编程
YM52e1 小时前
鸿蒙HarmonyOS ArkTS 实战:教师座椅出入记录 APP 从零到一
学习·华为·harmonyos·鸿蒙系统
踏着七彩祥云的小丑2 小时前
嵌入式测试第 32 天:升级测试:固件OTA升级、断点续传、回滚测试
单片机·嵌入式硬件·学习
小陈phd2 小时前
Text2SQL智能体学习笔记(二)——NL2SQL落地的隐形基石:元数据库
数据库·笔记·学习
踏着七彩祥云的小丑2 小时前
Go学习第4天:条件、循环语句+函数
学习·golang·go
tedcloud1232 小时前
Supermemory部署教程:打造Agent记忆与RAG环境
服务器·人工智能·学习·自动化·powerpoint
骑士雄师3 小时前
18.1 星系案例:多智能体宇宙探索系统(学习langgraph 的存储知识)
windows·python·学习
lizhihai_993 小时前
股市学习心得-六月的股市怎么应对
大数据·人工智能·科技·学习·区块链
数智工坊3 小时前
机器人控制总线深度解析:CAN与EtherCAT,谁在决定机器人的稳定性?
嵌入式硬件·学习·机器人