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(合并) → 输出文件
详细步骤:
- Read阶段:通过InputFormat获取InputSplit,用RecordReader读取数据
- Map阶段:对每条记录执行用户自定义的map()函数
- Collect阶段:将结果写入环形缓冲区(默认100MB)
- Spill阶段:当缓冲区达到阈值(默认80%),将数据溢写到磁盘,溢写前进行分区和排序
- Merge阶段:多个溢写文件合并为一个大文件
3.3 ReduceTask工作原理
ReduceTask的工作流程:
Copy(拉取Map输出) → Merge(合并) → Sort(排序/归并) → reduce() → 输出
详细步骤:
- Copy阶段:从各个MapTask上远程拷贝属于自己的数据
- Merge阶段:将多个MapTask的输出文件进行归并排序
- Sort阶段:对归并后的数据进行排序(归并排序),保证相同key的数据连续
- Reduce阶段:对每组相同key的数据执行reduce()函数
- 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集合 |
| 倒排索引 | 统计每个词出现在哪些文件中,搜索引擎核心数据结构 |