☰
彻底讲透Spark Shuffle与MapReduce Shuffle的区别
2026/10/17 16:42:28 网站建设 项目流程

刚开始接触大数据那几年,我被一个问题反复折磨过:Spark shuffle 和 MapReduce shuffle 到底差在哪。面试会问,日常排查作业慢也会问,但很多时候只能说出“Spark快、MapReduce慢”这种结论,一旦被追问“为什么”,就有点答不上来。后来我自己维护过几套离线计算集群,把这层窗户纸捅破之后才发现,表面上是 Shuffle 实现不同,背后其实是两套执行引擎的设计哲学完全不一样:MapReduce 追求稳定可靠,宁愿把中间结果落得明明白白;Spark 追求计算效率,能留在内存就绝不写盘,能合并 IO 就绝不多开文件。这篇文章就按我自己的理解,把这层区别彻底拆开讲清楚,适合正在准备面试的同学,也适合已经被 Shuffle 问题困扰的开发者。

1. 先搞清楚:Shuffle到底在解决什么问题

1.1 一个“重新分班”的动作

在分布式计算里,数据是分散在不同节点上的。做 group by、join 这类操作时,系统需要把相同 key 的数据拉到同一个节点上,才能进行后续聚合或关联。这个“按 key 把数据重新组织并跨节点搬运”的过程,就是 Shuffle。

你可以把它想象成全校重新分班:每个学生在不同教室,但最终要按照“兴趣爱好”把所有人重新编组,那教务处就必须把所有学生名单收集起来,按兴趣归类,再分发到对应教室。这个步骤本身不产生新的业务价值,却是绕不开的物理动作。由于涉及网络传输和磁盘读写,Shuffle 是分布式计算中最昂贵、也最容易出问题的环节。

我见过不少新手把 Shuffle 和排序混为一谈,总觉得 Shuffle 就一定要先排序。其实 Shuffle 的核心是“按 key 分组定位”,排序只是某些框架为了实现分组或文件合并而采用的手段。这也是后面两种框架差异的第一个伏笔。

1.2 MapReduce和Spark都把Shuffle放在关键位置,但出场次数不一样

MapReduce 是严格的两阶段模型:Map 阶段处理输入,然后进入 Shuffle,最后 Reduce 阶段汇总。无论业务多复杂,整个计算过程都被切成一连串的 Map 作业和 Reduce 作业,中间需要多次落盘、多次读写 HDFS。

Spark 则把计算描述成一个 DAG(有向无环图),Shuffle 只会出现在“宽依赖”的边界处。同一个 Stage 内部的多个算子可以流水线执行,一个算子的输出直接作为下一个算子的输入,不需要立刻写盘。也就是说,同一个复杂的 Group By + Join + 过滤 + 排序逻辑,用 MapReduce 可能需要串联四五个作业,而 Spark 可能只需要两三个 Stage,Shuffle 次数少了一个量级。

理解了这一点,再看 Spark Shuffle 和 MapReduce Shuffle 的区别,就不是简单背参数,而是能想明白它们各自的设计取舍。

2. MapReduce Shuffle的排序与落盘设计,究竟牺牲了什么

2.1 Map端的环形缓冲区与溢写合并

MapReduce 的 Map 端 Shuffle 是整个框架的基石。MapTask 每处理一条记录,会把 key-value 写进一个环形缓冲区,这个缓冲区的大小默认在 100MB 左右,由mapreduce.task.io.sort.mb控制。当缓冲区使用率达到阈值(默认 0.8,对应 80MB)时,后台线程开始溢写(spill)。

溢写并不是简单地倒数据,它在此之前会做两件事:按分区(Partition)划分数据,每个分区内再按 key 排序。为什么要排序?因为 Map 阶段可能溢写多次,每次都会生成一个临时文件,最后任务结束时,需要把这些临时文件合并成一个最终输出文件。如果每个临时文件内部都是有序的,合并时只需要多路归并,复杂度会低很多。

当配置了 Combiner 时,溢写过程中还会先做一轮本地合并,减少写盘数据量。Combiner 是 MapReduce 提供的 map 端预聚合手段,但要保证操作满足交换律和结合律,且不能改变最终结果语义。实际生产中,很多人为了省事只写一次 Combiner,可能还会踩坑。

完成合并后,Map 端最终输出一个已分区且每个分区有序的数据文件,以及一个索引文件。这个文件放在本地磁盘,不是 HDFS。Reduce 任务会按索引去定位属于自己分区的数据段。整个过程给我的感觉就是:所有中间状态都被安排得整整齐齐,数据一旦落盘就是有序、可索引的,后续 Reduce 无论如何出现,都能按部就班地工作。

2.2 Reduce端的Copy、Merge、Sort三件套

Reduce 端拿到 Map 输出的位置信息后,会启动多个并发线程去拉取属于自己分区的数据,默认并发数由mapreduce.reduce.shuffle.parallelcopies控制,默认值是 5。拉过来的数据先放在内存缓冲,缓冲占堆内存的比例可以通过mapreduce.reduce.shuffle.input.buffer.percent调整,超过了阈值就会写到本地磁盘。

所有从不同 Map 任务拉来的数据,最终还要经历一次“合并 + 排序”的过程。由于每个 Map 输出在分区内已经是有序的,Reduce 端只需要做多路归并,就能得到一个按 key 有序的输入流。MapReduce 随后通过比较器对 key 进行分组,同一个 key 的所有 value 会连续递给一次 reduce 调用。

这意味着 MapReduce 天然具备“按 key 排序”的输出结果。如果你需要做全排序,只要 Reduce 数量为 1,或者使用自定义分区器保证分区间的 key 范围有序,就能直接拿到全局有序结果。这个特性在按 key 范围查找、有序导出报表时非常有用,但代价就是为了这份有序性,每个阶段都在做额外排序。

2.3 这套设计牺牲了什么:IO放大与长尾

我拆过很多 MapReduce 作业的 Counter,发现一个规律:当数据量稍大时,Spilled Records(溢写记录数)往往会达到输入记录的几十倍。环形缓冲的溢写、合并、Reduce 端的再次溢写和归并,把本来只需一次读写的中间数据放大了很多倍。每条数据可能经历“内存写入 -> 磁盘 spill -> 多路归并 -> 网络传输 -> 内存 -> 再次 spill -> 再次归并”的漫长路径。

在硬件资源有限的年代,这种设计换来了极高的稳定性。每个阶段都落盘,计算节点的故障可以通过重新调度来恢复,Reduce 端对数据来源的依赖也被拆得很细。但是成也落盘,败也落盘,IO 放大直接拉高了作业延迟,Reduce 端 copy 某个慢节点数据时还会形成典型的长尾。这些痛点,正是 Spark 选择打破常规的直接原因。

3. Spark Shuffle的演进路线:从Hash到Sort再到Tungsten

3.1 第一代Hash Shuffle:快但文件爆炸

Spark 早期版本默认使用 Hash Shuffle。它的思路非常直白:每个 Map 任务(或者说每个 ShuffleMapTask)为下游每个 Reduce 任务生成一个文件,MapTask 处理数据时按分区直接写入对应文件,不做排序。由于少了一步全局排序,在小规模作业里 Hash Shuffle 确实比 MapReduce 快不少。

但这个设计有一个致命问题:文件数量是 M 乘 R。假设有 10000 个 Map 任务、5000 个 Reduce 任务,那就是五千万个小文件。大量文件句柄直接把节点磁盘和操作系统拖垮,即使后来引入了 Consolidation 机制,将同一个 Core 上连续执行的多个 Map 任务合并写入同一组文件,文件数量也只是从 M*R 降到了“Core 数 × R”,在大作业里依然不够看。这个坑后来成了 Spark 演进的重要动力:快很重要,但不能为了快把整个集群搞崩。

3.2 Sort Shuffle:用排序换文件数量,但排序目的不一样

从 Spark 1.2 开始,Sort Shuffle 成为默认实现。每个 ShuffleMapTask 最终只写一个数据文件和一个索引文件,数据文件内部按照分区编号排列,相同分区的数据连成一段,段内是否有序看具体场景。Reduce 任务拉数据时,只需要根据索引文件定位到自己的偏移区间,顺序读取即可。

这时 Sort Shuffle 里的排序,和 MapReduce 的排序目的已经不一样了。MapReduce 排序是为了让 Reduce 端能通过归并得到有序输入;而 Spark 排序更多是为了把多个分区的数据紧凑地组织到一个文件里,减少文件数量、方便索引。排序的对象也不是所有数据,而是分区边界的信息。

如果分区数不多,并且不需要 map 端聚合,Spark 还留了一个 bypass 开关:当分区数小于spark.shuffle.sort.bypassMergeThreshold(默认 200)时,直接走类似 Hash Shuffle 的写文件路径,不排序,直接把每个分区的数据写到同一个文件的不同段。这是 Sort Shuffle 对简单场景的妥协,也是和 MapReduce“无论如何都排序”最大的差别:排序是可选的,不是必须的。

3.3 内存优先与map端聚合:省掉大量IO

默认情况下,reduceByKey、aggregateByKey这类算子会在 map 端做一个聚合操作,也就是先把同一个 key 的多个 value 在内存里合并成一条,再考虑是否溢写。这个能力在 MapReduce 里需要手动写 Combiner,而且 Combiner 并不保证一定执行;Spark 则直接从算子设计上内置了 map 端聚合,数据量可以大幅压缩后再落盘。

配合 Unified Memory(统一内存模型),Spark 可以让执行内存和存储内存之间动态借用,同一个任务的内存充足时,shuffle 数据可以完全不落盘;只有内存压力过大,才通过溢写来保证不 OOM。我在实践中见过一个 1TB 左右的 Group By 作业,输入记录有几十亿条,但由于 key 的基数不高,reduceByKey 的 map 端聚合把输出压缩到了输入的几十分之一,Shuffle 写的数据量比 MapReduce 少两个量级。这就是“能留内存就绝不写盘”策略的真实收益。

再往后,Tungsten 项目又做了缓存友好优化,直接操作二进制数据,减少序列化和反序列化开销,在排序和聚合过程中尽量避免对象创建。这些优化叠加起来,让 Spark Shuffle 在大数据量下依然能保持相对 MapReduce 的高性能和低 IO 放大。

4. Spark shuffle 和 MapReduce shuffle 在工程实现上的硬核差异

看完演进路线,很多人心里已经有了一些碎片。我再从工程实现层面把两者放在一起,逐项对比。

对比项MapReduce ShuffleSpark Shuffle
排序策略Map 端强制分区内排序,Reduce 端归并排序默认 Sort Shuffle 文件内部按分区排布,可走 bypass 跳过排序
文件组织每个 Map 输出一个合并后的分区文件 + 索引文件每个 ShuffleMapTask 写一个 data 文件 + 一个 index 文件
map 端聚合可选 Combiner,需手动配置,不一定执行reduceByKey/aggregateByKey 原生支持 map 端聚合,内存优先
中间数据落地spill 一次写一个临时文件,多次 spill 多次落盘尽量保留在内存,内存不足才溢写,溢写后也尽量合并较少文件
默认调优目标稳定性和可预测性优先,容错靠重计算 + 磁盘缓冲性能和资源利用优先,容错靠血缘和重算分区
阶段模型固定两阶段:Map -> Shuffle -> Reduce,一个复杂作业串多个 jobDAG 划分 Stage,Shuffle 只在宽依赖边界发生,Stage 内管道化

这张表看起来清晰,但真正决定性能的,是下面几个隐藏在细节里的取舍。

4.1 排序定位:MapReduce的强制特性,Spark的可选手段

MapReduce 在排序这件事上没有让步空间。即使你的业务完全不需要按 key 排序,框架依然会在 map 端对每个分区排序,再在 reduce 端归并。这个机制带来两个结果:一是任何 MapReduce 作业的 shuffle 数据都是有序的,二是排序开销和额外的 IO 是“固定税”,不管你需不需要都得交。

Spark 则把排序还原成了工具。默认 Sort Shuffle 用排序来整理文件结构,但排序的对象是“分区段”,不是给下游的“全局有序流”。当分区数较少、不需要 map 端预聚合、又显式关闭排序需求时,Spark 可以直接绕过排序,按分区顺序把数据写进文件段。这个灵活的“可选排序”机制,让 Spark 在面对不同 shuffle 场景时都能找到近似最优的路径,而 MapReduce 只有一条路走到底。

4.2 内存与磁盘:MapReduce“满了再落”,Spark“能留就留”

MapReduce 的内存管理是固定模型的,环形缓冲区只有写满阈值才触发溢写;reduce 端的内存缓冲有限,一旦超过阈值就落盘。这种设计和当时 JVM 内存普遍较小、GC 能力有限的环境是匹配的,缺点是中间数据生命周期非常短,内存利用率并不高。

Spark 的内存模型经历过一次重要演进,后来采用 Unified Memory:Execution Memory 和 Storage Memory 之间的边界是弹性的,同一块堆内内存可以在执行和缓存之间流转。shuffle 的聚合数据会尽量放进 Execution Memory,内存不够才 spill,spill 的数据还会做压缩以减少磁盘占用。换句话说,MapReduce 是“到了阈值就必须落盘”,Spark 是“先想办法在内存里做完,实在不行才落盘”。这种差异在内存足够大的集群上会被放大,Spark 的 Shuffle 读和 Shuffle 写往往比 MapReduce 小一个量级。

4.3 阶段与流水线:为什么Spark的shuffle次数更少

MapReduce 的一个作业只有 Map 和 Reduce 两个阶段。如果你的逻辑是“过滤 -> join -> group by -> 排序”,它会被拆成多个 MR 作业,前一个作业的输出写到 HDFS,后一个作业重新读取。每次作业切换都是一次完整的 shuffle,而且中间数据还会写三副本。

Spark 在 DAG 里遇到窄依赖算子时会直接放进同一个 Stage,多个算子连续执行,不产生 shuffle。只有当遇到宽依赖(比如 group by、join),才切开 Stage。这样可以做到:同一个 Stage 内,一个算子的输出直接作为下一个算子的输入,不落盘,不序列化,不走网络。最终一个复杂任务往往只需要在两三次 shuffle 边界处落数据,而 MapReduce 同样的逻辑可能要经历五六次完整落盘。这个“减少 shuffle 次数”的能力,是 Spark 在复杂 ETL 场景下明显快于 MapReduce 的重要原因。

5. 用同一个Group By作业对比两种Shuffle的实际表现

5.1 构造一个可复现的模拟场景

为了更直观地理解两套 Shuffle 的差异,我假设一个通用场景:某离线数仓有一张用户订单明细表,大约 100GB,近 2 亿条记录,需要按“用户ID”统计消费总额。集群规模约为 10 个数据节点,每个节点 16 核、64GB 内存,没有人为做特别调优,只是默认配置。

这个场景不算极端,却能非常典型地反映两种框架在排序、落盘和聚合上的不同表现。

5.2 MapReduce侧的配置和调参思路

用 MapReduce 跑这个统计,典型的调参方式有三步:

  • 把mapreduce.task.io.sort.mb从默认 100MB 适当调大,比如 200MB,减少 map 端溢写次数。
  • 把mapreduce.reduce.shuffle.parallelcopies从默认 5 调大到 10 或 20,加快 reduce 端数据拉取速度。
  • 合理设置 Reduce 数量,一般取节点数和数据量的平衡点。如果只为了求全局排序,Reduce 数量设为 1,但会带来严重长尾,所以这里不追求全局有序。

在实际作业里,Map 端会频繁溢写,每个 MapTask 结束时还会做一次多路归并;Reduce 端再从十几个节点的本地磁盘并发拉数据,最后归并排序。整个过程中,磁盘 IO 会非常高,Counter 里Spilled Records通常膨胀到输入记录的数倍。

5.3 Spark侧的配置和调参思路

同一份数据用 Spark SQL 跑,可以这样配置:

  • 设置spark.sql.shuffle.partitions为 200,让每个分区数据量更均匀,避免某个分区过大。
  • 内存充足时,可以适当调大 Executor 内存,比如 32GB,让 map 端聚合尽量在内存完成。
  • 确认spark.shuffle.sort.bypassMergeThreshold默认行为:当分区数少于 200 时,如果不需要 map 端聚合,可以跳过排序;这里虽然分区数等于 200,但因为用了reduceByKey/SQL 聚合,还是会走完整的 Sort Shuffle。

在 Spark UI 的 Shuffle 信息里,能看到Shuffle Write和Shuffle Read的量。由于 map 端聚合生效,往往写入量远小于输入数据量,溢写比例很低,GC 时间也比 MapReduce 少很多。

5.4 从结果反推原理差异

在相似集群规模下,这个场景的 MapReduce 版本可能需要二三十分钟,Spark 版本一般可以在十分钟以内跑完。快的原因不是简单的“Spark 比 MapReduce 快”一句话,而是:

  • 排序不再是强制税,Spark 只在必要的时候做分区段整理;
  • map 端聚合把同用户的记录先合并,Shuffle 数据量大幅减少;
  • 同一个 Stage 内的过滤和投影结果直接流向聚合,不需要多次落盘;
  • DAG 只需一次 Shuffle,MapReduce 则要把整个查询拆成至少两三个 MR 作业串联。

这种差距在作业越复杂时越明显,尤其是多表 Join、多级聚合的场景。

6. 两类Shuffle的调优与排查经验实录

6.1 从监控里快速发现Shuffle异常

排查 Shuffle 问题,先学会看监控指标,比盲目调参靠谱得多。

MapReduce 作业里,重点关注Shuffle Bytes、Spilled Records、Reduce Shuffle Bytes这几个 Counter。如果Spilled Records是输入记录的几十倍,说明内存配置太小,或者溢写合并策略不合理;如果 Reduce 的Shuffle Bytes巨大,说明 map 端 combiner 没有生效,或者 partitioner 选择有问题,导致数据分配不均。

Spark 作业里,打开 4040 页面看每个 Stage 的Shuffle Write和Shuffle Read。正常情况下 Shuffle Write 不应远大于预期输出,如果出现Shuffle Write接近输入数据量,多半是 map 端聚合没生效,比如groupByKey被误用;如果Shuffle Read特别大且伴随大量磁盘溢写,就要检查 Executor 内存和分区数设置。

另外,FetchFailedException是 Spark 里最常见的 Shuffle 异常之一。它看起来像是网络问题,但很多时候根因是某个 Executor 崩溃或 GC 过久导致 shuffle 文件丢失。这时不是简单地重试,而是先看失败的 Executor 日志,确认是否存在内存溢出或节点负载过高。

6.2 数据倾斜与长尾问题的解法差异

数据倾斜是所有 Shuffle 场景的噩梦。表现是某个 key 的记录特别多,导致一个 Reduce/分区拖慢整个作业。

MapReduce 的经典解法是加盐两阶段聚合:先在 map 端给 key 拼接随机前缀,做一次局部聚合,再去掉前缀,做第二次全局聚合。这种方法在 Spark 里同样适用。但 MapReduce 没有运行期感知能力,Reduce 数量一旦确定,就无法在运行中动态调整。

Spark 3 之后引入 AQE(Adaptive Query Execution),可以在 Shuffle 结束后根据统计数据动态合并小分区,对倾斜的 Join 自动拆分倾斜分区的数据,并用多个 Task 并行处理。我曾在一次倾斜比较严重的 Join 作业里,靠开启 AQE 把作业时间从四十分钟压到十几分钟。这是 MapReduce 很难做到的运行时自适应能力。

如果你还在用 Spark 2.x 且无法升级,也可以手动加盐聚合,或者用广播变量把小表广播到大表侧,避免 Shuffle。这类经验在两类框架里都通用,但 Spark 的算子粒度更细,处理起来手段更多。

6.3 常用参数速查表

参数所属框架作用我的使用建议
mapreduce.task.io.sort.mbMapReduceMap 输出缓冲区大小,默认 100MB大作业调大到 200-400MB,减少 spill
mapreduce.map.sort.spill.percentMapReduce缓冲区溢写阈值,默认 0.8内存充足可适当调高,减少溢写次数
mapreduce.reduce.shuffle.parallelcopiesMapReduceReduce 端并发拉取线程数,默认 5集群网络稳定时可调到 10-20
mapreduce.task.io.sort.factorMapReduce一次合并的最大文件数,默认 10调大可以减少 merge 轮数
spark.sql.shuffle.partitionsSparkSQL 作业 Shuffle 分区数,默认 200根据数据量调,一般 200-500 足够
spark.default.parallelismSparkRDD Shuffle 默认并行度没有配置时容易过小,需手动设
spark.memory.fractionSparkSpark Memory 占 Executor 堆内存比例默认约 0.6,如缓存少可调高
spark.shuffle.sort.bypassMergeThresholdSpark分区数小于该值且无聚合时走 bypass,默认 200小文件场景可调高,减少排序
spark.shuffle.compressSparkShuffle 数据压缩开关默认开启,CPU 不紧张就保留
spark.reducer.maxSizeInFlightSparkReduce 端拉取缓冲上限默认 48MB 或 96MB,增大可提速但占内存

这个表不需要背,但建议在出问题的时候对照着排查,比瞎试参数高效很多。

7. 选型建议与实操体会

7.1 哪些场景继续留在MapReduce更省心

虽然 Spark 在很多场景下更快,但 MapReduce 在一些特定场景仍有价值。如果你的作业体量很小、调度频率很低,不想引入复杂的 DAG 和内存管理,MapReduce 的稳定性依然让人放心。尤其是历史遗留系统里依赖大量 MR 作业,短期内迁移成本大于性能收益,继续使用也是合理选择。

另外,如果业务要求严格的全局有序输出,MapReduce 的 sort-reduce 模型天生支持,Spark 想要做到同样效果反而要额外控制分区键范围和 reducer 数量。我见过不少团队为了“Spark 一定更快”而强行迁移这类作业,结果在排序细节上调试了很长时间,性价比并不高。

7.2 哪些场景换成Spark收益最大

反过来,迭代式计算、多阶段 ETL、复杂 Join、交互式 SQL、机器学习特征工程这些场景,Spark 的优势几乎是碾压级的。只要内存资源允许,shuffle 次数更少、map 端聚合更强、AQE 还能动态调优,整体运维效率会明显提升。

如果已经决定迁移,建议先做一次“小数据量对比 + 大数据量压测”,不要一上来就全量切。重点观察 Spark UI 中 Shuffle Write/Read 与输入数据量的比例,确认聚合生效;再对比历史 MR 作业的耗时,用数据说话。

7.3 我个人总结的三个排查习惯

这几年踩过不少 Shuffle 的坑,最后沉淀成三个习惯。第一个习惯是拿到一个慢作业,先看 Shuffle 数据量与输入数据量的比值。如果比值异常高,我会怀疑 map 端聚合失效,或者分区逻辑不合理,而不是急着加资源。第二个习惯是优先检查算子选型,能用reduceByKey就不要用groupByKey,能走 SQL 聚合就不要写程序收集 map,很多所谓“Spark 慢”的问题,其实只是算子选错。第三个习惯是调分区数之前先确认瓶颈是长尾还是磁盘 IO。如果是长尾,加分区可能更糟;如果是 IO,加并行度或者调大 reduce 拉取缓冲才有效。

Shuffle 是分布式计算里最容易出性能问题的地方,希望这篇文章能让你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询