Hadoop MapReduce

MapReduce的运行机制到底是怎么样的?一起了解一下吧

一、MapReduce的整体架构

Client(客户端)
↓(提交Job)
ResourceManager(资源管理器)
↓(分配容器)
NodeManager(节点管理器)→ 启动AppMaster(作业管家)
↓(管理任务执行)
MapTask(多个) → Shuffle → ReduceTask(多个)

Output(输出到HDFS)

阶段1:作业提交与初始化

1.1 客户端准备
// 客户端配置作业参数
Job job = Job.getInstance(conf, "wordcount");
job.setJarByClass(WordCount.class);
job.setMapperClass(TokenizerMapper.class);
job.setReducerClass(IntSumReducer.class);

// 提交作业到ResourceManager
job.submit();  // 或 job.waitForCompletion(true)

客户端执行步骤

  1. 向RM请求新的作业ID(job_000001)
  2. 检查输出路径是否存在(避免覆盖)
  3. 计算输入分片(InputSplit)信息
  4. 将作业资源(JAR、配置、分片信息)上传到HDFS
1.2 ResourceManager分配AppMaster
// RM收到请求后的处理逻辑
class ResourceManager {
    public void submitJob(JobSubmitRequest request) {
        // 1. 选择空闲NodeManager
        NodeManager targetNM = selectAvailableNode();
        
        // 2. 分配容器启动AppMaster
        ContainerLaunchContext ctx = createAMContainer(request);
        targetNM.startContainer(ctx);  // 启动作业管家
    }
}

阶段2:AppMaster调度执行

2.1 AppMaster初始化

AppMaster启动后立即执行:
1. 解析分片信息:根据输入数据创建Map任务列表
2. 资源计算:确定需要多少Map/Reduce容器
3. 任务调度优化

class ApplicationMaster {
    public void run() {
        // 计算需要多少个Map任务
        int numMaps = calculateNumMaps(inputSplits);
        
        // 向RM申请Map任务容器
        for (int i = 0; i < numMaps; i++) {
            allocateMapContainer(i);
        }
    }
}
2.2 Map任务调度策略

数据本地优先级:

  1. NODE_LOCAL(节点本地):数据与计算在同一节点
  2. RACK_LOCAL(机架本地):同一机架不同节点
  3. OFF_SWITCH(跨机架):不同机架,网络运输

阶段3:Map任务调度执行

3.1 Map任务调度生命周期
容器启动 → 任务初始化 → Map执行 → 分区排序 → 溢出写入 → 合并清理
3.2 核心处理逻辑
public class WordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> {
    
    // 每个Map任务处理一个InputSplit
    protected void map(LongWritable key, Text value, Context context) {
        String line = value.toString();
        String[] words = line.split(" ");
        
        for (String word : words) {
            context.write(new Text(word), new IntWritable(1));
            // 输出示例:("hello", 1), ("world", 1)
        }
    }
}
3.3 Map端的Shuffle准备

关键数据结构:

// 内存中的环形缓冲区
class MapOutputBuffer {
    private byte[] kvbuffer;      // KV对存储区
    private int[] kvindices;      // 索引数组
    private int bufindex = 0;     // 当前写入位置
    private int bufmark = 0;      // 缓冲区标记
    
    // 当缓冲区达到阈值(默认80%)时触发溢出
    public synchronized void collect(K key, V value) {
        // 1. 序列化KV对到缓冲区
        // 2. 维护分区索引
        // 3. 检查是否需要溢出到磁盘
    }
}

阶段4:Shuffle机制深度解析

4.1 Map端Shuffle流程

解释:Map输出时候的海量数据不是一次性怼到内存中,会先放入一个环形内存缓存区,默认是100MB,并设定了一个阈值(内存的80%),同时Map还会启动一个守护线程,如果缓存区达到了阈值的80%,守护线程会把内存上的内容写到磁盘里,这个过程叫做spill,另外的20%的内存支持继续写入要写进磁盘的数据,写入磁盘和写入内存是互不干扰的,如果缓存区撑满了,Map会阻塞写入内存的操作,让写入磁盘的操作完成后,再继续执行写入内存的操作。
写入磁盘前会有个排序的操作,这个操作在内容溢出到磁盘的时候,不是写入内存的时候。

内存收集 → 分区排序 → 溢出文件 → 磁盘合并

详细步骤:
1. 环形缓冲区收集:

  • 每个KV对先写入内存缓冲区
  • 同时写入元数据(分区号、key起始位置等)

2. 溢出到(Spill)磁盘

class MapTask {
    public void spillAndMerge() {
        // 当缓冲区达到80%时启动溢出线程
        if (bufindex >= softLimit) {
            startSpillThread();
        }

        // 溢出过程:
        // 1. 对缓冲区内的数据按分区、key排序
        // 2. 每个分区生成一个溢写文件
        // 3. 合并多个小文件为大文件
    }
}

3. Combiner优化操作(可选)

// 在Map端先进行本地聚合,减少网络传输
job.setCombinerClass(IntSumReducer.class);
// 比如:("hello", [1,1,1]) → ("hello", 3)
4.2 Reduce端的Shuffle流程

Reduce任务的三阶段:

Copy Phase → Sort Phase → Reduce Phase

Copy阶段详解:

class ReduceTask {
    public void copyMapOutputs() {
        // 1. 从所有完成的Map任务拉取数据
        for (MapTask mapTask : completedMaps) {
            // 通过HTTP从Map节点拉取对应分区的数据
            Fetcher fetcher = new Fetcher(mapTask.getOutputLocations());
            fetcher.fetchPartition(partitionId);
        }
        
        // 2. 数据先存入内存,再溢出到磁盘
        // 3. 所有数据拉取完成后进入排序阶段
    }
}

这里有个小插曲,Map的shuffle流程和Reduce的shuffle流程到底区别在哪?

Map端Shuffle:
Map输出 → 内存缓冲区 → 分区排序 → 溢写磁盘 → 磁盘合并
     (本地操作)    (本地操作)   (本地IO)    (本地IO)

Reduce端Shuffle:
网络拉取 → 内存缓冲 → 溢写磁盘 → 归并排序 → Reduce输入
(网络IO)   (内存管理)  (本地IO)  (CPU密集)  (计算)

阶段5:Reduce任务执行

5.1 归并排序(Merge Sort)
磁盘文件1 + 磁盘文件2 + ... + 内存数据 → 排序后统一输入

二次排序机制:

// Reduce收到的输入已经是按key分组排序的
// 输入格式:("apple", [1,1,1]), ("banana", [1,1]), ("cherry", [1])
public class WordCountReducer extends Reducer<Text, IntWritable, Text, IntWritable> {
    
    protected void reduce(Text key, Iterable<IntWritable> values, Context context) {
        int sum = 0;
        for (IntWritable val : values) {
            sum += val.get();  // 对同一key的所有value求和
        }
        context.write(key, new IntWritable(sum));
        // 输出:("apple", 3), ("banana", 2), ("cherry", 1)
    }
}

阶段6:输出与清理

6.1 结果写入
// Reduce结果写入HDFS
class ReduceTask {
    private void writeOutput(RecordWriter output, 
                           Iterator<KeyValuePair> iter) {
        while (iter.hasNext()) {
            KeyValuePair pair = iter.next();
            output.write(pair.key, pair.value);
        }
        output.close(context);
    }
}

输出目录结构

/output/wordcount/
    ├── part-r-00000  # Reduce任务0的输出
    ├── part-r-00001  # Reduce任务1的输出
    ├── _SUCCESS       # 成功标记文件
    └── _logs/         # 作业日志

二、MapReduce通俗解释

🍕 披萨店比喻

想象你开了一家披萨店,要统计一天中哪种披萨最受欢迎。

传统方式(没有MapReduce):

  • 你一个人翻看所有订单小票

  • 一张一张数:玛格丽特×1,海鲜×1,玛格丽特×1...

  • 数到晚上才完成,累死了

MapReduce方式:

1. Map阶段(分工)

你把所有订单小票分给5个员工:

  • 员工A:统计1-100号订单

  • 员工B:统计101-200号订单

  • 员工C:统计201-300号订单

  • ...

每个员工并行工作,快速统计自己那堆小票:

员工A的统计结果:

玛格丽特: 15份
海鲜: 8份
培根: 12份

员工B的统计结果:

玛格丽特: 18份
海鲜: 10份
蔬菜: 5份

2. Shuffle阶段(整理归类)

现在把相同类型的披萨统计结果放在一起:

  • 把所有"玛格丽特"的统计放一堆
  • 把所有"海鲜"的统计放一堆
  • 把所有"培根"的统计放一堆

3. Reduce阶段(汇总)

让专门的员工进行最终汇总:

  • 员工X专门汇总"玛格丽特":15 + 18 = 33份
  • 员工Y专门汇总"海鲜":8 + 10 = 18份
  • 员工Z"专门汇总"培根":12份

💻 技术对应关系

生活比喻 MapReduce技术术语
订单小票 输入数据块
5个统计员工 Map任务
按披萨类型分类 Shuffle排序
3个汇总员工 Reduce任务
最终销量统计 输出结果

🔄 完整流程

原始数据 → 拆分数据块 → Map处理 → 排序分组 → Reduce汇总 → 最终结果
↓ ↓ ↓ ↓ ↓ ↓
所有订单 → 分给5个员工 各自统计 按披萨分类 专人汇总 销量报表

⚡ 为什么快?

  1. 并行处理:5个员工同时统计,比一个人快5倍
  2. 专业化:每个员工只做自己擅长的那部分
  3. 可扩展:如果订单更多,就雇更多员工

📊 实际大数据例子

统计网站访问日志中的热门页面:
• Map:每个服务器统计自己日志中的页面访问次数
• Shuffle:把相同页面的统计结果分组
• Reduce:汇总每个页面的总访问量

这就是为什么MapReduce能快速处理TB级数据——"分而治之,并行处理"!

posted @ 2025-10-30 11:18  -dokingone-  阅读(7)  评论(0)    收藏  举报