Hadoop MapReduce:分布式计算的基石与编程实践

67 阅读14分钟

在大数据技术的浩瀚星空中,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 的策略是:

  1. 分(Divide):将1TB的文本文件在逻辑上切分成成千上万个更小的数据块(InputSplit)。
  2. 治(Conquer):将这些数据块分发到集群中的不同节点上,每个节点上的 Map 任务并行地处理自己负责的数据块,完成局部的单词统计。
  3. 合(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类型
BooleanBooleanWritable
ByteByteWritable
IntegerIntWritable
FloatFloatWritable
LongLongWritable
DoubleDoubleWritable
StringText
MapMapWritable
ArrayArrayWritable

这些Writable类型不仅序列化后体积更小,而且支持快速的比较操作,非常适合在网络中传输和磁盘上存储。

2. MapReduce实战

2.1. MapReduce编程规范

用户编写程序分为三个阶段:Mapper、Reducer、Driver。

2.1.1. Mapper阶段

  1. 用户自定义的 Mapper 要继承自己的父类org.apache.hadoop.mapreduce.Mapper,重写map()方法。
  2. Mapper 的输入数据是 KV 对的形式(KV的类型可自定义)。
  3. Mapper 中的业务逻辑写在 map() 方法中。
  4. Mapper 的输出数据是 KV 对的形式(KV的类型可自定义)。
  5. map() 方法(MapTask进程)对每一个<K, V>调用一次。

2.1.2. Reducer阶段

  1. 用户自定义的 Reducer 要继承自己的父类org.apache.hadoop.mapreduce.Reducer,重写reduce()方法。
  2. Reducer 的输入数据类型对应 Mapper 的输入数据类型,也是KV。
  3. Reducer 的业务逻辑写在 reduce() 方法中。
  4. ReducerTask 进程对每一组相同 key 的 <k, v>组调用一次 reduce() 方法。

2.1.3. Driver阶段

相当于 YARN 集群的客户端,用于提交我们整个程序到 YARN 集群,提交的是封装了 MapReduce 程序相关运行参数的 job 对象。

  1. 创建job实例。
  2. 设置作业的 jar 包位置。
  3. 关联自定义Mapper和Reducer类。
  4. 设置 Map 和 Reduce 阶段的输出“键-值”对的类型。
  5. 揎输入和输出数据的路径。
  6. 提交作业等待完成。

2.2. WordCount示例

2.2.1. 创建项目

  1. 创建一个 Maven 项目,项目名为MapReduceDemo。

  2. 添加依赖。

<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>
  1. 创建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包下。

  1. 编写 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);
        }
    }
}
  1. 编写 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);
   }
}
  1. 编写 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);
    }
}
  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. 集群测试

  1. 修改 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"));
  1. 在 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>
  1. 执行 Maven 打包操作,并将不带依赖的 jar 包,拷贝到 Hadoop 集群/opt/module/hadoop-3.1.3/test目录下。
  2. 执行 WordCount 程序。
hadoop jar test/wordcount.jar com.example.hadoop.mapreduce.wordcount.WordCountDriver /test/input /test/output
  1. 执行完成后,可以到 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序列化特点

  1. 紧凑 :高效使用存储空间;
  2. 快速:读写数据的额外开销小;
  3. 可扩展:随着通信协议的升级而可升级;
  4. 互操作:支持多语言的交互;

4. Hadoop序列化实战

上一章节我们探讨了Hadoop序列化的理论。现在,让我们通过一个实际案例——统计手机用户的总流量,来看看如何在代码中具体实现它。

4.1. 核心步骤

  1. 自定义 Bean:创建一个包含多个字段(上行流量、下行流量)的 Java 对象。
  2. 实现序列化:让这个 Bean 实现 Hadoop 的Writable接口。
  3. 编写 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包下。

  1. 自定义 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();
    }
}
  1. 编写 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);
    }
}
  1. 编写 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);
    }
}
  1. 编写 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 基本概念与核心思想,以及实现了简单的代码示例,后续更多内容请期待下篇。