MapReduce Shuffle 原理:Map 端与 Reduce 端全流程拆解

Shuffle,说白了就是 Map 和 Reduce 之间的「数据搬运工」。很多新手觉得它就是个简单的数据传输,其实不然。我当年第一次调优 MR 任务时,80% 的时间都耗在 Shuffle 上。你想想看,几百个 Map 任务同时往几十个 Reduce 写数据,网络、磁盘、内存全在打架——搞不定 Shuffle,你的集群再大也白搭。

这一章,咱们把 Shuffle 的 Map 端和 Reduce 端流程彻底拆开,看看每个环节到底在干什么。

Map 端 Shuffle:数据从产生到就绪

Map 任务写完自己的业务逻辑后,输出的数据并不会直接发给 Reduce。它要经过四个步骤:分区、排序、溢写、合并。我习惯把这四个步骤叫做「Map 端的四板斧」。

1. 分区(Partition)—— 数据该去哪个 Reduce

Map 输出的每一条记录,都得先决定它属于哪个 Reduce。默认情况下,系统会根据 key 的哈希值对 Reduce 数量取模。比如你有 3 个 Reduce,key 的哈希值 mod 3,结果 0 去第一个,1 去第二个,2 去第三个。

核心要点:分区决定了数据的归属。同一个 key 的所有记录,必须进入同一个 Reduce,否则聚合结果会出错。

我在项目中遇到过一个问题:某个业务 key 分布极不均匀,90% 的数据都哈希到了同一个分区。结果那个 Reduce 跑了 2 小时,其他 Reduce 几分钟就结束了。后来我们自定义了分区器,把热点 key 打散,整个任务时间缩短了 70%。

2. 排序(Sort)—— 让数据有序起来

分区之后,Map 会立即对每个分区内的数据进行排序。注意,这里的排序是按 key 的字典序进行的。为什么要排序?因为 Reduce 端做归并排序时,需要输入数据本身已经部分有序。如果 Map 端不排序,Reduce 端就得全量排序,内存根本扛不住。

小技巧:如果你的业务不需要全局有序,可以设置 mapreduce.job.reduce.shuffle.merge.sort 为 false 来跳过排序。但大多数场景下,排序的开销远小于不排序带来的混乱。

3. 溢写(Spill)—— 内存装不下了怎么办

Map 的输出会先写入一个内存缓冲区,默认大小是 100MB。当缓冲区数据达到阈值(默认 80%)时,后台线程就会启动溢写操作:把当前缓冲区的内容写入磁盘上的一个临时文件。

为什么会这样?因为内存是有限的。如果一直往内存里塞,迟早会 OOM。溢写就是「内存放不下,先写到磁盘暂存」的机制。

我曾经调优过一个任务,Map 阶段频繁溢写,磁盘 I/O 飙升。检查后发现是缓冲区太小,Map 输出又特别大。把缓冲区从 100MB 调到 200MB,溢写次数从 30 次降到了 5 次,任务快了 40%。

4. 合并(Merge)—— 把零散文件整合起来

一个 Map 任务可能会产生多个溢写文件。在 Map 任务结束前,系统会把这些零散的文件合并成一个有序的大文件。合并的过程就是多路归并排序:同时读取多个文件,按 key 顺序输出。

合并后的文件还会附带一个索引文件,记录每个分区在文件中的起始位置和长度。这样 Reduce 来拉数据时,就能直接定位到自己的那部分,不用全量扫描。

注意:合并时如果文件数量太多,可能会触发多次合并。默认情况下,一次合并最多合并 10 个文件。如果溢写文件超过 10 个,就会分批次合并。这个参数 mapreduce.task.io.sort.factor 值得关注。

Reduce 端 Shuffle:数据从拉取到聚合

Map 端准备好了,Reduce 端就要开始干活了。Reduce 端的流程也分三步:拉取、合并、归并排序。

1. 拉取(Fetch)—— 从各个 Map 拿数据

Reduce 任务启动后,会启动多个拉取线程,并发地从所有 Map 任务所在节点拉取属于自己分区的数据。拉取的数据会先写入 Reduce 端的内存缓冲区,如果缓冲区满了,也会溢写到磁盘。

这里有个关键点:Reduce 并不是等所有 Map 都完成才开始拉取。实际上,只要有一个 Map 完成了,Reduce 就可以开始拉取它的数据。这叫「边完成边拉取」,能有效减少等待时间。

我记得有一次线上任务,Reduce 拉取阶段特别慢。排查发现是网络带宽被其他任务占满了。后来我们限制了拉取线程数,从默认的 5 个降到 3 个,虽然单个线程慢了,但整体网络不拥堵了,任务反而更快。

2. 合并(Merge)—— 把拉来的数据整理好

拉取到的数据可能是多个小文件(来自不同 Map 的溢写文件)。Reduce 端会把这些小文件合并成更大的文件,减少后续排序时的文件数量。合并策略和 Map 端类似,也是多路归并。

合并过程中,Reduce 会尽量在内存中完成。只有当内存不足时,才会写到磁盘。我个人习惯把 mapreduce.reduce.shuffle.input.buffer.percent 设为 0.7,给合并留足内存空间。

3. 归并排序(Merge Sort)—— 最终的有序输入

所有数据拉取并合并完成后,Reduce 会进行一次全局归并排序。这次排序的输入是多个已经部分有序的文件,输出是一个完全有序的数据流。这个有序流会直接喂给 Reduce 函数。

为什么需要这次排序?因为 Reduce 函数处理数据时,是按 key 分组处理的。只有数据按 key 有序,才能高效地识别出「同一个 key 的所有记录」。如果数据无序,Reduce 就得把所有数据都缓存起来,等遇到下一个 key 才知道当前 key 结束了——那内存就炸了。

一句话总结:Map 端排序 + Reduce 端归并排序 = 全局有序。这个有序性保证了 Reduce 函数能流式处理数据,不用全量缓存。

Shuffle 全流程可视化

下面这张图把 Map 端和 Reduce 端的完整流程串起来了。你可以对照着看,每个环节的数据流向和转换关系。

MapReduce Shuffle 全流程 Map 端 Shuffle ① 分区 (Partition) ② 排序 (Sort) ③ 溢写 (Spill) ④ 合并 (Merge) 数据通过网络传输 Reduce 端 Shuffle ⑤ 拉取 (Fetch) ⑥ 合并 (Merge) ⑦ 归并排序 ⑧ 输入 Reduce 💡 关键点: • Map 端:分区 → 排序 → 溢写 → 合并,最终生成一个有序的大文件 + 索引文件 • Reduce 端:拉取 → 合并 → 归并排序,最终生成有序数据流喂给 Reduce 函数

Map 端与 Reduce 端流程对比

为了方便你对比记忆,我把两端的核心差异整理成了表格:

环节 Map 端 Reduce 端
触发时机 Map 函数输出每条记录时 任意 Map 任务完成后
核心操作 分区、排序、溢写、合并 拉取、合并、归并排序
数据来源 Map 函数的内存缓冲区 多个 Map 节点的磁盘文件
排序目的 为 Reduce 端归并做准备 生成全局有序的输入流
内存压力 中等(缓冲区 + 溢写) 高(拉取 + 合并 + 排序)
磁盘 I/O 高(多次溢写 + 合并) 中(合并时可能溢写)

个人经验:调优 Shuffle 时,我一般先看 Map 端的溢写次数。如果溢写次数超过 10 次,说明缓冲区太小或 Map 输出太大。Reduce 端则重点看拉取线程数和合并时的内存占比。这两个参数调好了,Shuffle 性能能提升 30% 以上。

嗯,到这里 Shuffle 的完整流程就讲清楚了。你可能会问:这些环节里哪些参数最值得调?别急,后面几章我会专门讲参数调优和实战案例。先把流程刻在脑子里,调优才有方向。