MapReduce主要分为三个阶段
- mapper
- shuffle
- reduce
MAPPER阶段
以下是 Mapper 阶段的详细工作流程(以 Hadoop 2.x/3.x 的 YARN 架构为例):
1. 输入分片(Input Split)
-
问题: 如何将超大文件(如 1TB 日志)分发给多个 Mapper 并行处理?
-
解决方案:
InputFormat
(如TextInputFormat
)将文件逻辑切分为 分片(Split) (如 128MB / 片)。- 关键点: 分片是逻辑划分(不实际切割文件),记录每个分片的文件偏移量和长度。
-
协同机制:
ResourceManager 根据分片数量动态分配 Mapper 任务到不同 NodeManager。
2. RecordReader 读取数据
-
问题: 如何从分片中逐条读取记录?
-
解决方案:
RecordReader
(如LineRecordReader
)将分片解析为一条条 <Key, Value> (如<行偏移量, 行内容>
)。- 示例:
输入"192.168.1.1 - - [01/Jan/2023:00:00:01] GET /home"
→ 输出<0, "192.168.1.1 ...">
-
技术细节:
通过
FileInputStream
按偏移量读取 HDFS 块,避免全文件加载。
3. Map 函数处理
-
问题: 如何执行用户自定义逻辑?
-
解决方案:
开发者实现
Mapper.map()
方法,对每条记录生成 中间键值对 。
示例(统计单词):scss// 输入:<行偏移量, "hello world"> map(LongWritable key, Text value, Context context) { String[] words = value.toString().split(" "); for (String word : words) { context.write(new Text(word), new IntWritable(1)); // 输出:<"hello", 1>, <"world", 1> } }
4. 环形缓冲区(Circular Buffer)
-
问题: Map 输出如何暂存?直接写磁盘效率低。
-
解决方案:
-
内存中开辟 环形缓冲区(默认 100MB)。
-
Map 输出先写入缓冲区,包含:
- Key/Value 数据
- 分区信息(Partition,决定数据归属哪个 Reducer)
- 元数据(Key/Value 的起始位置、长度)
-
-
技术细节:
使用序列化(如
Writable
接口)减少内存占用。
5. 溢写(Spill)
-
问题: 缓冲区满了怎么办?
-
解决方案:
-
当缓冲区使用率 > 阈值(默认 80%)时,启动 后台溢写线程。
-
溢写流程:
- 分区排序: 按分区分区内按 Key 排序(快速排序)
- Combiner 优化(可选): 本地聚合(如合并相同 Key 的 Value)
- 写磁盘: 生成临时文件(
spill1.out, spill2.out...
)
-
-
关键点:
溢写与 Map 处理 并行执行,避免阻塞。
6. 归并合并(Merge)
-
问题: 多个溢写文件如何整合?
-
解决方案:
-
Map 任务结束时,将所有溢写文件 归并排序 为一个大文件。
-
归并策略:
- 多路归并(默认一次合并 10 个文件)
- 保持分区内 Key 有序(为 Reduce 阶段做准备)
-
-
输出文件:
最终生成一个 分区且排序 的文件(如
map_out_0.index
+map_out_0.data
)。
核心问题及解决策略
核心问题 | 解决方案 | 技术实现 |
---|---|---|
数据倾斜 | 自定义分区器(Partitioner) | 重写 getPartition() 分散热点 Key |
内存溢出 | 环形缓冲区 + 溢写机制 | 阈值触发异步磁盘写入 |
中间数据过大 | Combiner 本地聚合 | 在 Map 端预合并相同 Key 的数据 |
分片不均 | 自定义 InputFormat | 重写 getSplits() 控制分片逻辑 |
容错机制 | Task 重试机制 | NodeManager 监控 Task,失败后重新调度 |
shuffle
核心目标是 高效、可靠地将 Map 的输出数据分组、排序并传输给对应的 Reduce 任务。
一、Shuffle 阶段的核心目标(本质)
-
分组 (Grouping) :将 Map 任务输出的键值对,按照 Key 的哈希值 (或自定义分区器)分配到不同的 分区 (Partition) 中。每个 Partition 对应一个 Reduce 任务【分区数量由reduce数量决定,有8个reduce就有8个partition】。
-
排序 (Sorting) :确保发送给同一个 Reduce 任务的所有数据(即同一个 Partition 内),是 按键 (Key) 排序 的。
-
数据传输 (Transfer) :将每个 Map 任务【map任务数量由文件类型和】输出的多个 Partition 数据,高效、可靠地传输到各个运行 Reduce 任务的节点上。
map任务数量由输入的数据类型和大小决定。例子 :如果你有1个1GB的文件存储在HDFS上,而HDFS的块大小设置为128MB,那么Hadoop会尝试创建
1024MB / 128MB = 8
个InputSplit,从而启动8个Map任务】 -
合并 (Optional Merge) :在 Reduce 端拉取到来自多个 Map 任务的对应 Partition 数据后,进行合并排序,形成最终的、完全排序的输入流。
二、Shuffle 阶段的技术实现细节(分 Map 端和 Reduce 端)
(一) Map 端 Shuffle
一句话总结:
-
流入数据处理:数据进入mapper阶段的缓冲区之前,会先通过hash算出未来该数据要进入的partition (partitionId会随着元数据一起写入)。
-
内存缓冲区溢出:当内存缓冲区满了,会写到文件(溢出写文件的时候,缓冲区禁止数据进入)。
-
文件过多就合并,文件过大就压缩。最终合并出来2个文件,一个file.out一个file.index(index就是索引,避免从头查询。记录的是partitionId在out文件中的起始位置和offset)
这是 Shuffle 的起点,发生在每个 Map 任务内部。
-
内存缓冲区 (In-Memory Buffer)
-
目的 :Map 输出键值对不是直接写磁盘或网络,而是先写入一个 环形内存缓冲区 (
mapreduce.task.io.sort.mb
参数控制大小,默认 100MB)。 -
优点:避免频繁的小 I/O 操作,极大提升效率。
-
内部结构:缓冲区本质是一个字节数组。写入时:
- 序列化
<Key, Value>
成字节。 - 同时写入数据的 元数据 (Metadata):包括 Key 的起始位置、Value 的起始位置、分区号 (Partition ID)、Value 的长度。元数据占固定大小(通常 16 bytes)。
- 数据和元数据在缓冲区中分开存放。
- 序列化
-
-
分区 (Partitioning)
- 写入缓冲区时,会立即调用 分区器 (
Partitioner
) 。默认是HashPartitioner
:partition = (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks
。 - 元数据中就包含了这个计算出的
Partition ID
。
也就是这个键值对该去哪个分区,在map的shuffle阶段就已经确定好了。
- 写入缓冲区时,会立即调用 分区器 (
-
溢出 (Spill)
-
触发条件:
- 当内存缓冲区使用量达到一个阈值 (
mapreduce.map.sort.spill.percent
,默认 80% = 80MB 满时)。 - 或者 Map 任务结束时(清空最后的数据)。
- 当内存缓冲区使用量达到一个阈值 (
-
过程:
-
锁定缓冲区:暂停新的写入。
-
后台线程执行溢出:
- 排序 (Sort) :对要溢出的 元数据区 进行排序。排序依据是:先按
Partition ID
,然后在同一个 Partition 内按Key
。 - 合并 (Combine - Optional) :如果用户配置了
Combiner
,且溢出的次数 >=min.num.spills.for.combine
(默认 3),则在排序后、写入磁盘前,对同一个 Partition 内、同一 Key 的 Value 执行 Combiner 进行本地聚合。 - 写入磁盘 :根据排序后的元数据顺序,从缓冲区数据区读取对应的序列化字节块,写入到 Map 任务本地工作目录的一个 溢出文件 (Spill File) 中。一个溢出过程产生一个文件。
- 排序 (Sort) :对要溢出的 元数据区 进行排序。排序依据是:先按
-
解锁缓冲区:继续接收新的 Map 输出。
-
-
-
文件合并 (Merge)
-
当 Map 任务结束前(所有数据处理完),或者达到合并条件时。
-
过程:
- 将之前产生的 所有 Spill File 和 内存缓冲区中剩余的数据(如果有)一起读取。
- 使用 多路归并排序 (Multi-way Merge Sort) 算法,将这些文件合并成一个最终的、分区且分区内有序 的大文件 (
file.out
),通常还有一个对应的索引文件 (file.out.index
)。 - 索引文件的作用 :记录每个
Partition ID
在最终输出文件file.out
中的 起始偏移量 (Start Offset) 和 结束偏移量 (End Offset) 。这极大加速了 Reduce 端拉取对应分区的数据。
-
最终输出 :这个
file.out
和file.index
就是 Map 任务最终的输出结果,存储在 Map 任务所在节点的本地磁盘上。
-
(二) Reduce 端 Shuffle
一句话总结
- 拉数据:向AM询问Mapper地址,然后多线程从mapper拉文件(rpc或者特定协议),拉到的文件放在Reduce的内存缓冲区
- 内存缓冲区溢出处理: 排序合并->写到临时文件
- 合并所有临时文件和缓冲区数据成为一个排序好的大文件(如果太多太多合并不过来,需要分轮次处理)
Reduce 任务启动后,主动从各个 Map 任务节点拉取属于自己的数据。
-
复制阶段 (Copy Phase / Fetch Phase)
- 获取位置信息 :Reduce 任务启动后,会向 ApplicationMaster (AM) 询问所有已完成的 Map 任务的主机位置 (
NodeManager
地址) 及其输出文件信息(包含文件位置和索引文件位置)。 - 拉取线程 (Fetcher Threads) :Reduce 任务启动多个 HTTP/Fetcher 线程 (
mapreduce.reduce.shuffle.parallelcopies
控制线程数,默认 5)。每个线程负责从一个或多个 Map 节点拉取数据。 - 拉取目标 :每个 Fetcher 线程会去对应的 Map 节点,读取其最终输出文件 (
file.out
) 中 属于本 Reduce 任务负责的 Partition 的数据块。它利用索引文件 (file.index
) 快速定位到该 Partition 在file.out
文件中的精确偏移范围。 - 传输方式:数据通过 HTTP 协议传输(早期版本)。更现代的框架可能使用更高效的 RPC 或专用协议。
- 内存缓冲区 (Shuffle Buffer) :拉取到的数据并非直接写入 Reduce 的输入文件,而是先放入 Reduce 任务所在节点的一个 内存缓冲区 (
mapreduce.reduce.shuffle.input.buffer.percent
控制占堆内存比例,默认 70%)。
- 获取位置信息 :Reduce 任务启动后,会向 ApplicationMaster (AM) 询问所有已完成的 Map 任务的主机位置 (
-
内存管理与溢出 (Memory Management & Spill)
-
触发条件 :当 Reduce 端内存缓冲区的数据量达到一定阈值 (
mapreduce.reduce.shuffle.merge.percent
,默认 66%)。 -
后台线程执行溢出:
- 将缓冲区中的数据 排序 (Sort) 并 合并 (Merge) 后,写入 Reduce 任务本地磁盘的一个临时文件中。
-
目的:避免内存溢出 (OOM),并开始进行数据的初步聚合 / 排序。
-
-
合并阶段 (Merge Phase)
-
输入来源:
- 来自内存缓冲区的数据(当缓冲区没满,但所有 Map 任务都完成,或需要最后合并时)。
- 之前内存溢写到磁盘的临时文件。
-
过程 :同样使用 多路归并排序 (Multi-way Merge Sort) 算法。
-
轮次 (Passes) :如果文件太多,可能进行多轮合并,每轮合并多个小文件成少量大文件。
-
最终输出 :当所有 Map 输出的数据都拉取并合并完成后,会得到一个或多个 在 Reduce 本地磁盘上、按键完全排序 的文件。如果进行了多轮合并,最终通常是一个大文件。
-
可选操作 :在合并过程中,如果配置了
Combiner
,且合并轮次满足条件,也会执行 Combiner。
-
-
最终输入 (Reduce Input)
- 合并完成后得到的最终排序好的文件,就是 Reduce 任务的输入。
- Reduce 任务此时可以开始执行用户定义的
reduce()
函数。reduce()
函数接收一个Key
和这个Key
对应的 已排序好的所有Value
的迭代器 (Iterable<Value>)
。这个分组和排序的能力,正是前面的 Shuffle 阶段辛苦工作的结果。
三、关键优化技术 & 问题解决
-
内存缓冲区:核心优化点,减少昂贵的磁盘 I/O 次数。设置不当会导致频繁溢出或 OOM。
-
排序算法:高效的内存排序 (QuickSort) 和磁盘归并排序 (MergeSort) 是关键。Hadoop 采用优化过的排序器。
-
索引文件:避免 Reduce 拉取整个 Map 输出文件,只拉取所需的分区数据,节省网络带宽和磁盘 I/O。
-
Combiner :在 Map 端和 Reduce 端 Shuffle 过程中 本地聚合 相同 Key 的 Value,大幅减少需要传输和处理的数据量。解决数据膨胀问题。
-
并行拉取 (Fetcher Threads) :多个线程同时从不同 Map 节点拉取数据,加速复制阶段。解决网络传输瓶颈。
-
压缩 (Compression) :
- Map 输出压缩 (
mapreduce.map.output.compress
) : 在 Map 端输出到磁盘或网络前压缩数据 (e.g., Snappy, LZ4, Gzip)。显著减少磁盘写入量和网络传输量。 - Shuffle 拉取压缩:如果 Map 输出压缩了,Reduce 拉取后需要先解压。
- Map 输出压缩 (
-
自定义分区器 (Partitioner) :解决数据倾斜问题 (Data Skew),避免某些 Reduce 任务负载过重。例如,对
UserId
分区而不是默认的Hash
。 -
直接内存传输 (Shuffle Plugin / Optimized Fetch) :较新版本 (如 Hadoop 3.1+) 支持通过 RPC 或共享内存等更高效方式传输数据,减少 HTTP 开销和序列化 / 反序列化成本。
-
错误处理:
- Map 任务失败:Reduce 会重新从其他成功的副本拉取数据。
- Reduce 任务失败:需要重新启动该 Reduce 任务,重新拉取所有数据(因为数据在本地磁盘)。
总结
Shuffle 的本质是 大规模分布式排序分组传输。Hadoop MapReduce 的实现核心在于:
-
内存优先:用大缓冲区减少 I/O。
-
分区排序:在 Map 端按 Key 和 Partition 排序,为传输和 Reduce 输入做好准备。
-
索引定位:用索引文件精确定位目标数据块。
-
并行传输:多线程拉取加速。
-
多级归并:处理海量中间数据。
-
本地聚合 (Combiner) :大幅减少数据量。
理解这些细节对于 调优 MapReduce 作业性能 (如调整缓冲区大小、启用压缩、优化分区器、设计 Combiner) 和 排查 Shuffle 相关的问题 (如 OOM、数据倾斜、网络瓶颈) 至关重要。