在大数据技术的浩瀚星空中,Hadoop 无疑是最为璀璨的星座之一。而 MapReduce,作为 Hadoop 的核心计算引擎,更是奠定了整个大数据处理领域的基石。它以一种简洁而强大的方式,将复杂的分布式计算问题抽象化,使得开发者能够专注于业务逻辑,而无需深陷于分布式系统编程的泥潭。今天,就让我们一起深入 MapReduce 的世界,探寻其核心原理与编程之美。
Hadoop 相关知识与文章参考:
1. MapReduce概述
1.1. MapReduce简介
MapReduce 是一种用于大规模数据集(通常是TB、PB级别)并行处理的编程模型和计算框架。它的核心思想源自函数式编程中的map和reduce操作,由 Google 在2004年的经典论文中首次提出,并由 Hadoop 社区开源实现。
简单来说,MapReduce 将一个大任务分解成两个主要阶段:
- Map(映射)阶段:负责将输入数据切分成若干个独立的“键-值”对,并对每个“键-值”对执行用户自定义的逻辑,生成一组中间“键-值”对。
- Reduce(归约)阶段:负责将Map阶段输出的、具有相同中间键的所有值聚合在一起,执行另一套用户自定义的逻辑,最终生成结果。
1.2. MapReduce优缺点
1.2.1. 优点
- 易于编程:MapReduce 极大地简化了分布式编程的复杂性。开发者只需要实现 Mapper 和 Reducer 两个接口,框架会自动处理数据分片、任务调度、节点通信、容错等底层细节。
- 良好扩展性:MapReduce 可以轻松地扩展到数千个节点组成的集群。通过增加节点,可以线性地提升集群的处理能力,以应对不断增长的数据量。
- 高容错性:这是 MapReduce 的一大亮点。在任务执行过程中,如果某个节点发生故障,框架会自动将失败的任务重新调度到其他健康的节点上执行,整个过程对开发者透明,保证了作业的可靠完成。
- 数据本地化:为了减少昂贵的网络I/O开销,MapReduce 会尽量将计算任务分配到存储有该任务所需数据的节点上执行,即“移动计算而非移动数据”,从而大幅提升处理效率。
1.2.2. 缺点
- 不适合实时和低延迟计算:MapReduce 是为高吞吐量的批处理而设计的。任务的启动、Shuffle 过程中的磁盘I/O等都带来了较高的延迟,因此它不适合对响应时间要求高的场景。
- 不适合迭代计算:在处理机器学习等需要多轮迭代计算的算法时,MapReduce 的性能不佳。因为每一轮迭代的结果都需要写入 HDFS,下一轮再从 HDFS 读取,产生了大量的磁盘I/O开销。
- 编程模型相对僵化:MapReduce 的“Map-Shuffle-Reduce”模型虽然简洁,但也限制了其表达能力。对于复杂的、非线性的数据流(如多表连接、有向无环图DAG任务),用 MapReduce 实现起来会非常繁琐。
1.3. MapReduce核心思想
MapReduce 的强大之处在于其背后的两个精妙的核心思想,就是:“分而治之”和“移动计算”。
1.3.1. 分而治之
这是 MapReduce 最根本的哲学。面对一个海量的数据处理任务,比如统计1TB文本中每个单词的出现次数,单台机器显然无法在合理时间内完成。MapReduce 的策略是:
- 分(Divide):将1TB的文本文件在逻辑上切分成成千上万个更小的数据块(InputSplit)。
- 治(Conquer):将这些数据块分发到集群中的不同节点上,每个节点上的 Map 任务并行地处理自己负责的数据块,完成局部的单词统计。
- 合(Combine):所有 Map 任务完成后,通过 Shuffle 过程将相同单词的统计结果汇集到同一个 Reduce 任务中,进行最终的全局汇总。
这个过程就像组织一群人统计一堆扑克牌中“黑桃A”的数量。与其让一个人一张张数,不如把牌平均分给每个人,大家同时数自己手里的牌,最后把每个人报出的数量加起来即可。
1.3.2. 移动计算
在分布式系统中,网络传输的带宽和延迟往往是性能瓶颈。MapReduce 深谙此道,它采取了“移动计算”的策略。
当 MapReduce 作业提交后,框架会感知到输入数据在 HDFS 上的存储位置,并优先将 Map 任务调度到存储有对应数据块的 DataNode 节点上执行。这样,计算逻辑直接在最靠近数据的地方运行,避免了将海量数据通过网络传输到计算节点的巨大开销。这是一种以计算换I/O的智慧,对于I/O密集型的大数据处理任务至关重要。
1.4. 常用序列化类
Hadoop 没有使用 Java 原生的序列化机制(java.io.Serializable),因为它过于臃肿且效率不高。Hadoop 实现了一套轻量级、高效的序列化框架Writable。在编写 MapReduce 程序时,我们需要使用 Hadoop 提供的专用数据类型:
| Java类型 | Hadoop Writable类型 |
|---|---|
| Boolean | BooleanWritable |
| Byte | ByteWritable |
| Integer | IntWritable |
| Float | FloatWritable |
| Long | LongWritable |
| Double | DoubleWritable |
| String | Text |
| Map | MapWritable |
| Array | ArrayWritable |
这些Writable类型不仅序列化后体积更小,而且支持快速的比较操作,非常适合在网络中传输和磁盘上存储。
2. MapReduce实战
2.1. MapReduce编程规范
用户编写程序分为三个阶段:Mapper、Reducer、Driver。
2.1.1. Mapper阶段
- 用户自定义的 Mapper 要继承自己的父类
org.apache.hadoop.mapreduce.Mapper,重写map()方法。 - Mapper 的输入数据是 KV 对的形式(KV的类型可自定义)。
- Mapper 中的业务逻辑写在 map() 方法中。
- Mapper 的输出数据是 KV 对的形式(KV的类型可自定义)。
- map() 方法(MapTask进程)对每一个<K, V>调用一次。
2.1.2. Reducer阶段
- 用户自定义的 Reducer 要继承自己的父类
org.apache.hadoop.mapreduce.Reducer,重写reduce()方法。 - Reducer 的输入数据类型对应 Mapper 的输入数据类型,也是KV。
- Reducer 的业务逻辑写在 reduce() 方法中。
- ReducerTask 进程对每一组相同 key 的 <k, v>组调用一次 reduce() 方法。
2.1.3. Driver阶段
相当于 YARN 集群的客户端,用于提交我们整个程序到 YARN 集群,提交的是封装了 MapReduce 程序相关运行参数的 job 对象。
- 创建
job实例。 - 设置作业的 jar 包位置。
- 关联自定义
Mapper和Reducer类。 - 设置 Map 和 Reduce 阶段的输出“键-值”对的类型。
- 揎输入和输出数据的路径。
- 提交作业等待完成。
2.2. WordCount示例
2.2.1. 创建项目
-
创建一个 Maven 项目,项目名为
MapReduceDemo。 -
添加依赖。
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-client</artifactId>
<version>3.1.3</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.36</version>
</dependency>
- 创建
log4j.properties配置文件。
log4j.rootLogger=INFO, stdout, logfile
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] - %m%n
log4j.appender.logfile=org.apache.log4j.FileAppender
log4j.appender.logfile.File=./log/MapReduceDemo/log-all.log
log4j.appender.logfile.layout=org.apache.log4j.PatternLayout
log4j.appender.logfile.layout.ConversionPattern=%d %p [%c] - %m%n
2.2.2. 编写代码
当前 WordCount 程序的代码放到com.example.hadoop.mapreduce.wordcount包下。
- 编写 Mapper 类
WordCountMapper。
package com.example.hadoop.mapreduce.wordcount;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import java.io.IOException;
/**
* @Description TODO wordcount示例,Mapper类
*
* 输入为多行单词的文件,并且单词之间使用空格间隔
* MapTask输出格式为:(单词, 1)
*/
public class WordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> {
//存储单词变量
private Text word = new Text();
//存储1变量
private IntWritable one = new IntWritable(1);
/**
* Map方法是我们 MapTask 任务的核心逻辑,对于每组输入的 key-value 都执行一次
*
* @param key 输入key,默认为输入文件内容行的偏移量
* @param value 当前行偏移量对应的行内容
* @param context mr任务上下文
* @throws IOException
* @throws InterruptedException
*/
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
//1.将每行数据内容按空格切分
String[] words = value.toString().split(" ");
//2.遍历,并使用 context 输出
for (String word : words) {
this.word.set(word);
//输出格式:key为单词,value固定为1
context.write(this.word, this.one);
}
}
}
- 编写 Reducer 类
WordCountReducer。
package com.example.hadoop.mapreduce.wordcount;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
import java.io.IOException;
/**
* @Description TODO wordcount示例,Reducer类
*/
public class WordCountReducer extends Reducer<Text, IntWritable, Text, IntWritable> {
private IntWritable result = new IntWritable();
/**
* ReducerTask的核心计算逻辑
*
* @param key 单词
* @param values 当前单词对应的一组 1
* @param context mr上下文
* @throws IOException
* @throws InterruptedException
*/
@Override
protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
//1。定义变量存储统计结果
int sum = 0;
//2.遍历当前单词对应的一组1列表,累加
for (IntWritable value : values) {
sum += value.get();
}
//3.将累加结果设置到公共变量
this.result.set(sum);
//4.输出
context.write(key, this.result);
}
}
- 编写 Deiver 类
WordCountDriver。
package com.example.hadoop.mapreduce.wordcount;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IntWritable;
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.output.FileOutputFormat;
import java.io.IOException;
/**
* @Description TODO wordcount示例,Driver驱动类
*/
public class WordCountDriver {
public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException {
//1.生成一个job实例
Job job = Job.getInstance(new Configuration(), "wordcount");
//设置类路径
job.setJarByClass(WordCountDriver.class);
//2.设置job的mapper和reducer
job.setMapperClass(WordCountMapper.class);
job.setReducerClass(WordCountReducer.class);
//3.设置输入输出数据类型
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(IntWritable.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(IntWritable.class);
//4.设置程序的输入输出
FileInputFormat.setInputPaths(job, new Path("D:/input/input.txt"));
FileOutputFormat.setOutputPath(job, new Path("D:/output"));
//5.提交任务
boolean b = job.waitForCompletion(true);
System.exit(b ? 0: 1);
}
}
- 在目录
D:/input/input.txt文件中,准备输入的数据。
hadoop hdfs hadoop yarn
mapreduce hadoop
yarn hdfs
mapreduce yarn
yarn
hdfs mapreduce
2.2.3. 本地执行测试
直接在 IDEA 中执行即可,以下是程序输出。
hadoop 3
hdfs 3
mapreduce 3
yarn 4
2.2.4. 集群测试
- 修改 Driver 类。
// 添加集群连接信息
Configuration configuration = new Configuration();
configuration.set("fs.defaultFS", "hdfs://hadoop102:9000");
Job job = Job.getInstance(configuration, "wordcount");
// 修改输入输出地址
FileInputFormat.setInputPaths(job, new Path("/input/input.txt"));
FileOutputFormat.setOutputPath(job, new Path("/output"));
- 在 pom.xml 中,添加打包插件配置。
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.6.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
- 执行 Maven 打包操作,并将不带依赖的 jar 包,拷贝到 Hadoop 集群
/opt/module/hadoop-3.1.3/test目录下。 - 执行 WordCount 程序。
hadoop jar test/wordcount.jar com.example.hadoop.mapreduce.wordcount.WordCountDriver /test/input /test/output
- 执行完成后,可以到 HDFS 上查看输出文件是否存在,以及计算是否正确。
3. Hadoop序列化
3.1. 什么是序列化
序列化,本质上是一种将内存中的对象转换为字节序列(或其他数据传输协议)的技术。这一过程使得对象能够脱离内存环境,被存储到磁盘(持久化)或通过网络传输到远程节点。
与之对应的是反序列化,即将接收到的字节序列或磁盘上的持久化数据,重新还原为内存中的对象。
在 Hadoop MapReduce 框架中,无论是 Map 阶段的输出(K2, V2),还是Reduce阶段的输入(K3, V3),数据都需要在不同节点间传输,因此序列化无处不在。它是分布式系统中进程间通信(IPC)和远程过程调用(RPC)的基础。
3.2. 为什么要序列化
在单机程序中,对象的生命周期通常与进程绑定,关机断电即消失。但在分布式集群中,我们需要解决两个核心问题:
- 网络传输:集群中的节点通常分布在不同的物理机上。一个节点上的对象(如 Map 任务的输出结果)需要发送给另一个节点(如 Reduce 任务)。内存中的对象无法直接在网线上传输,必须转化为字节流。
- 持久化存储:为了容错和中间结果的保存,MapReduce 会将中间计算结果写入磁盘。只有序列化后的字节流才能被文件系统接纳。
简而言之,序列化赋予了对象“离开内存”的能力,使其能够跨越时空限制,在分布式系统中自由移动。
3.3. 为什么不使用Java原生序列化
既然 Java 已经提供了java.io.Serializable接口,为什么 Hadoop 还要另起炉灶,实现一套Writable接口呢?
根本原因在于:Java 原生序列化是一个“重量级”框架。
当一个 Java 对象被原生序列化时,它不仅仅包含对象本身数据,还会附带大量的额外信息:
- 类元数据:包含类的描述符、版本ID(SerialVersionUID)。
- 继承体系信息:父类的字段、实现的接口等。
- 校验信息:用于保证反序列化时的类型安全。
- 循环引用处理:Java 序列化会记录对象图中的引用关系,导致数据膨胀。
这些附加信息虽然在通用场景下,保证了类型安全和灵活性,但是在大数据处理场景下却是致命的。Hadoop 处理的数据是 TB、PB级别的,如果使用 Java 序列化,网络带宽和磁盘空间将被大量无用的元数据占用,导致传输效率极其低下。
3.4. Hadoop序列化特点
- 紧凑 :高效使用存储空间;
- 快速:读写数据的额外开销小;
- 可扩展:随着通信协议的升级而可升级;
- 互操作:支持多语言的交互;
4. Hadoop序列化实战
上一章节我们探讨了Hadoop序列化的理论。现在,让我们通过一个实际案例——统计手机用户的总流量,来看看如何在代码中具体实现它。
4.1. 核心步骤
- 自定义 Bean:创建一个包含多个字段(上行流量、下行流量)的 Java 对象。
- 实现序列化:让这个 Bean 实现 Hadoop 的
Writable接口。 - 编写 MapReduce 程序:在 Mapper 和 Reducer 中使用这个自定义 Bean。
4.2. 场景描述
假设有一批用户的上网日志,每一行代表一条记录,包含手机号、上行流量、下行流量等信息。
目标:统计每个手机号的总上行流量、总下行流量和总流量。
输入数据示例:
1 13736230513 192.196.100.1 www.baidu.com 2481 24681 200
2 13846544121 192.196.100.2 264 0 200
3 13956435636 192.196.100.3 132 1512 200
4 13966251146 192.168.100.1 240 0 404
5 18271575951 192.168.100.2 www.qq.com 1527 2106 200
6 84188413 192.168.100.3 www.tenant.com 4116 1432 200
... ...
数据格式:
编码[\t]手机号[\t]IP[\t]网址[\t]上行流量[\t]下行流量[\t]状态。
期望输出:
13560436666 1316 1104 2420
15910133277 315 296 611
... ...
数据格式:
手机号[\t]总上行流量[\t]总下行流量[\t]总流量
4.3. 编码实现
当前手机流量统计程序的代码放到com.example.hadoop.mapreduce.writable包下。
- 自定义 Bean 对象 FlowBean。
package com.example.hadoop.mapreduce.writable;
import org.apache.hadoop.io.Writable;
import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
/**
* @Description TODO 实体类,封装上下行流量和总流量
*/
public class FlowBean implements Writable {
private long upFlow;
private long downFlow;
private long sumFlow;
public FlowBean() {
}
public long getUpFlow() {
return upFlow;
}
public void setUpFlow(long upFlow) {
this.upFlow = upFlow;
}
public long getDownFlow() {
return downFlow;
}
public void setDownFlow(long downFlow) {
this.downFlow = downFlow;
}
public long getSumFlow() {
return sumFlow;
}
public void setSumFlow(long sumFlow) {
this.sumFlow = sumFlow;
}
/**
* 重写 toString 方便打印结果
*/
@Override
public String toString() {
return upFlow + "\t" + downFlow + "\t" + sumFlow;
}
/**
* 序列化方法
*
* @param dataOutput
* @throws IOException
*/
@Override
public void write(DataOutput dataOutput) throws IOException {
dataOutput.writeLong(upFlow);
dataOutput.writeLong(downFlow);
dataOutput.writeLong(sumFlow);
}
/**
* 反序列化方法
* 注意:读取顺序与序列化顺序一致
*
* @param dataInput
* @throws IOException
*/
@Override
public void readFields(DataInput dataInput) throws IOException {
this.upFlow = dataInput.readLong();
this.downFlow = dataInput.readLong();
this.sumFlow = dataInput.readLong();
}
}
- 编写 Mapper 类 FlowMapper。
package com.example.hadoop.mapreduce.writable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import java.io.IOException;
/**
* @Description TODO 手机上下行流量统计示例,Mapper类
*/
public class FlowMapper extends Mapper<LongWritable, Text, Text, FlowBean> {
private Text phone = new Text();
private FlowBean flowBean = new FlowBean();
@Override
protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
//1.将输入数据按空格切分
String[] infos = value.toString().split("\t");
//2.获取数据,对封装成对象
phone.set(infos[1]);
flowBean.setUpFlow(Long.parseLong(infos[infos.length - 3]));
flowBean.setDownFlow(Long.parseLong(infos[infos.length - 2]));
//3.输出
context.write(this.phone, this.flowBean);
}
}
- 编写 Reducer 类 FlowReducer。
package com.example.hadoop.mapreduce.writable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
import java.io.IOException;
/**
* @Description TODO 手机上下行流量统计示例,Reducer类
*/
public class FlowReducer extends Reducer<Text, FlowBean, Text, FlowBean> {
private FlowBean flowBean = new FlowBean();
/**
* ReducerTask的核心计算逻辑
*
* @param key 手机号
* @param values 自定义FlowBean对象
* @param context mr上下文
* @throws IOException
* @throws InterruptedException
*/
@Override
protected void reduce(Text key, Iterable<FlowBean> values, Context context) throws IOException, InterruptedException {
//1.定义变量累加
long sumUpFlow = 0;
long sumDownFlow = 0;
//2.遍历累加上下行流量
for (FlowBean flowBean : values) {
sumUpFlow += flowBean.getUpFlow();
sumDownFlow += flowBean.getDownFlow();
}
//3.封装数据
this.flowBean.setUpFlow(sumUpFlow);
this.flowBean.setDownFlow(sumDownFlow);
this.flowBean.setSumFlow(sumUpFlow + sumDownFlow);
//4.输出
context.write(key, flowBean);
}
}
- 编写 Driver 类 FlowDriver。
package com.example.hadoop.mapreduce.writable;
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.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import java.io.IOException;
/**
* @Description TODO 手机上下行流量统计示例,Driver驱动类
*/
public class FlowDriver {
public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException {
//1.生成一个job实例
Job job = Job.getInstance(new Configuration(), "flow bean");
//设置类路径
job.setJarByClass(FlowDriver.class);
//2.设置job的mapper和reducer
job.setMapperClass(FlowMapper.class);
job.setReducerClass(FlowReducer.class);
//3.设置输入输出数据类型
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(FlowBean.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(FlowBean.class);
//4.设置程序的输入输出
FileInputFormat.setInputPaths(job, new Path("D:/input/a.txt"));
FileOutputFormat.setOutputPath(job, new Path("D:/output"));
//5.提交任务
boolean b = job.waitForCompletion(true);
System.exit(b ? 0: 1);
}
}
5. 写在后面
鉴于 Hadoop MapReduce 涉及的知识点繁多且细节琐碎,为了便于消化和理解,本系列将分多篇文章进行详细拆解。本文主要介绍了 MapReduce 基本概念与核心思想,以及实现了简单的代码示例,后续更多内容请期待下篇。