初看“Spark shuffle 和 MapReduce shuffle 的区别”这个题目大多数人第一反应是Spark 更快是因为它在内存里跑MapReduce 慢是因为它在磁盘上跑。如果只是应付聊天这个答案够用了但你真拿这个结论去调优线上任务大概率会踩坑。我印象很深的一次是把一套稳定跑了两年的 MapReduce 批处理逻辑迁到 Spark 上executor 内存调大了、并行度也翻倍了结果部分 stage 反而频繁 spill磁盘 IO 被打满耗时还不如原地不动。后来我把整个 shuffle 的写侧、读侧流程从头拆了一遍才发现内存大小只是表面因素真正的分水岭在于两者的“排序义务”和“中间文件组织方式”完全不同。这篇文章就把这几条关键链路摊开讲清楚适合要动手做引擎迁移、做数仓任务优化或者单纯想彻底理解两套 shuffle 原理的开发者。1. 一张流传最广的对比图恰恰最容易把人带偏1.1 从一次“越调越慢”的任务说起我改造的那套老批处理每天凌晨跑全量汇总MapReduce 模式下稳定在一个半小时。迁移到 Spark 后我按惯性思维把spark.sql.shuffle.partitions从默认 200 调到了 800executor 内存也给了 16G满心以为能把时间压到半小时以内。第一次试跑确实快跑到 52 分钟第二次我把并行度提到 1200内存加到 24G结果变成了 1 小时 10 分钟比上一版还慢。当时第一反应是数据倾斜于是又加了repartition均衡分区仍然没有本质改善。后来我到 executor 的日志里翻 GC 和 spill 记录发现大量的数据在 shuffle read 阶段被读出来后又因为分区数据量不均衡被再次溢写到磁盘。这里有个很容易被忽略的事实Spark 在 shuffle 过程中照样会大量落盘所谓“内存优先”并不是把数据永远留在内存里而是在条件允许时尽量走内存缓冲。你把并行度盲目调大反而让每个 map 输出块变小文件碎片变多读侧的开销全砸在随机 IO 上。从这里开始我才意识到两套 shuffle 的根本差别不能简单用“内存还是磁盘”去解释。你加再多的内存如果写侧产出的是碎文件、读侧还要做额外的归并性能一样会被拖垮。1.2 真正的分水岭排序义务与中间文件组织方式把两套 shuffle 从头到尾走一遍之后我总结出一个更接近本质的说法MapReduce 的 shuffle 在写入阶段几乎逃不掉完整的排序和归并而 Spark 的 shuffle 把排序从“制度性义务”降级成了“可选动作”同时用分区索引文件替代了散落的分段结构。这句话拆开来理解才是两者真正的区别。MapReduce 为什么必须排序因为它在 map 输出时把不同分区键值写进溢写文件到 reduce 端拉取数据时依赖键的全局有序性进行归并分组。如果没有排序reduce 端就需要维护大量随机子区间不仅定位困难内存成本更是不可控。换句话说MR 的排序不是“为了快而排序”而是为了给后续的分组、合并、reduce 提供一种稳定的物理组织方式这是一种设计层面的强制约束。Spark 的 shuffle 走的是另一条路线它把目标分区相同的记录尽量在一个连续区间里积累最后写入一个独立的 shuffle 文件并在文件末尾记录每个分区的偏移量索引。读数据时按索引直接跳到目标段根本不需要先对整个数据集排序。于是数据量大时你需要平衡的是内存、段数量和溢写时机而不是一张贯穿始终的排序表。这个差异往深了说会直接影响写侧的 IO 放大、读侧的随机访问能力以及面对极端数据倾斜时的存活率。后面两章我就按写侧和读侧分别拆开讲。2. 写侧深挖一边是“排序税加多次归并”一边是“分区文件加索引”2.1 MapReduce 为何宁可缴纳高额且几乎不可免的“排序税”先看 MapReduce 的 map 输出过程。你以为 map 函数执行完数据会直接排队传到 reduce 端不是。它先写入一个环形缓冲区缓冲区默认大小在io.sort.mb里控制数据写满到一定比例后触发一次“溢写”先把分区号和 key 做排序再以临时文件的形式写到本地磁盘。这里排序的对象是当前缓冲区里这批数据所以它做的是分区内按键有序。如果 map 输出的总量很大缓冲会反复写满产生多个溢写文件之后还要对它们做一个多路归并把相同分区的数据归并到一起才算形成一个相对有序的中间结果。这套流程里“排序税”体现在两个层面。第一个层面是 CPU 和序列化的消耗每一条 map 输出的 key 都要参与比较这是一个完全无法绕开的成本除非你用非常特殊的 map-only 场景绕开 shuffle。第二个层面是 IO 放大溢写文件本身要落盘一次归并时又要重新读入、排好序再写一次多个溢写文件一多放大倍数相当可观。你可能听说 MR 有 combiner 做 map 端预聚合但它同样依赖这条有序链路因为只有相邻键聚在一起combiner 才能真正合并连续相同键的值。有人会问MR 为什么不用索引文件直接跳过排序因为它的下游 reduce 数量是动态的但中间文件的分区段之间没有统一的索引结构reducer 拉取数据时只能将属于自己的所有分段拉过来再在内存里按 key 排序后才能交给 reduce 函数。MR 不是没有能力写索引而是它从一开始就把“键的有序性”当作整个处理模型的核心减少排序反而会让模型失去支点。理解这一步很重要MR 慢并不是慢在磁盘比内存慢而是慢在它必须在写侧完成一次大规模的程度很高的排序这个排序是一个结构性的、必须支付的代价。2.2 Spark 如何把排序从“制度性义务”降级为“可选动作”Spark 的 shuffle 写侧在默认排序相关配置下map 端以分区为单位累积数据最终写出的不是“一堆按 key 排序的文件段”而是“一个分区一个连续区域、再配一份索引”的结构。你在 Spark UI 的 shuffle 指标里能看到几个数据比如写的 shuffle 文件大小、shuffle 溢写记录数这些溢出同样会产生临时块但设计目标一直是不排序。具体过程是每个 map 任务把输出数据先在内存里按 partition 放入不同的 bucket如果内存不足再对这些 bucket 做一次溢写溢写文件的组织方式仍然是按分区连续排布。最终所有溢写段会被合并成一个 shuffle 文件同时写出 index 文件记录各个分区对应数据在 shuffle 文件中的起始 offset 和长度。下游读取时拿到的直接是那块连续数据不需要重新构建全局有序结构。这里要强调一点我在生产环境里遇到太多人把 Spark 默认的 sorted shuffle 和“必须全量排序”划等号。实际上Spark 默认的 Hash Shuffle 老版本也走过一段低效时代每个 map 任务给每个分区写一个小文件文件数量是“map 数量乘分区数量”一旦执行器实例很多整个节点就会出现严重的文件句柄压力。后续版本改成“同一个 map 输出集中到一个文件加索引”这才从根上解决了大量小文件的问题。你现在看 Spark 稳定版本几乎默认走合并文件路线排序只发生在你需要显式sortByKey或某些聚合算子需要时。这一对比就很明显MR 的中间产物天然要求有序所以写侧做了大量排序和归并Spark 的中间产物只要求可分区分段所以写侧能够尽量做到“按分区攒数据、落盘时只做局部有序的合并”。这个差异最直接的收益是写侧节省了排序的 CPU 开销以及归并过程中的读写放大。3. 读侧深挖一条是“顺序扫描加持续归并”一条是“按索引定位加批量读取”3.1 取数路径的差异决定了 shuffle read 的上限到了读侧两者的差异比写侧更直观。MapReduce 的 reduce 端要先从各个 map 输出位置拉取属于自己分区的数据拉到本地后进入一个归并缓冲区多个分段在这里通过多路归并排成有序流然后才交给 reduce 函数。也就是说reduce 端哪怕只需要一小部分数据仍然要把所有分段读出来并参与归并排序。磁盘读路径虽然可能是顺序的但归并阶段的比较次数非常多所以 MR 的读侧压力常常不在 IO 带宽上而在 CPU 与内存的归并操作上。Spark 的 shuffle read 则完全不同。它通过 index 文件直接定位目标分区对应的那块数据区然后作为一个连续块被读取进来。如果你仔细看 Spark 执行计划会发现 shuffle 读阶段的很多任务其实是在“拿段”而不是“拿一堆需要再次归并的碎片”。这也解释了为什么 Spark 在处理大量分区数据时可以轻松做到目标分区的数据“一次拿到手”不需要为全量有序付出额外代价。我举一个实际的场景一个 join 操作左表很大右表经过分桶且数量较小。在 Spark 里通过良好的分桶设计shuffle 读可以精准地只读取命中的桶段整体耗时能压到极低。同样逻辑的 MR 任务reducer 必须把属于该分区路径的全部 map 输出段拿过来归并即使其中大量数据与本次 join 无关。这种结构性差异比“内存快还是磁盘快”的解释要有说服力得多。3.2 倾斜数据与大分区场景下的放大效应分区倾斜在所有分布式引擎里都是老大难问题但在两套 shuffle 下失败的方式完全不同。MR 的排序归并模型决定了它不仅受数据量影响还受键的分布影响。当某个 key 的数据特别多时reduce 端要处理的分段数量不变但每个段的长度畸高归并过程的内存占用和比较次数都快速上升。极端情况下整个 reducer 长时间不结束甚至触发 heap 溢写。整体上MR 对倾斜是“按计划被拖慢”任务不会立刻 OOM但会慢得让人失去耐心。Spark 的 indexed 读取方式在数据相对均匀时性能很好可一旦某个分区数据量远超其他分区这个分区对应的存储段会特别大。如果多个 map 任务同时向一个热点分区写数据最终这个分区的 shuffle 文件段会非常庞大读取和聚合时内存压力集中在少数任务上。典型的结局不是缓慢变慢而是某个执行器 OOM 或长时间 GC。换句话说MR 的倾斜困境在归并端Spark 的倾斜困境在内存集中度和读取热点上。这里给一个常见优化思路Spark 处理倾斜时除了加广播等手段还可以把 key 做加盐拆分或者在写入阶段就通过repartition打散热点分区。但要注意这种打散必须与下游读取模式配套否则会产生更多小分段反而放大索引数量。MR 对倾斜的思路则更多体现在人为控制 reducer 的 key 分割边界或者用 combiner 在 map 端先削一波峰值。不同的失败路径决定了不同的排查方向。4. 代价清单什么情况下该选哪套系统又该注意什么4.1 当内存撑不住时谁反而更抗风险很多人在对比时默认 Spark 一定更快实际情况没那么简单。Spark 的优势建立在内存能够容纳中间数据或至少溢写不能过于频繁的前提上。如果你的集群内存资源吃紧executor 又小又慢Spark 的 shuffle 写侧会在“攒数据段”和“反复溢写”之间来回挣扎由此产生大量小段文件读侧还要通过索引去定位这些碎片性能可能断崖式下跌。此时 MapReduce 的必排模型虽然笨重但它对所有中间数据都做好了落盘的设计“io.sort.mb”一配溢写与归并节奏稳定反而能用一种不太好看但可预测的速度跑完。所以选择不能只看引擎名气要看任务形态与资源条件。数据量中等、集群内存充沛、需要交互式迭代或 SQL 化的处理Spark 几乎是毫无疑问的选择任务数据总量极大但对延迟不敏感、节点内存配置又普遍不高MR 的稳定溢出模型仍是一种保守但可靠的方案。我见过不少团队把 Spark 的 executor memory 压得极低还要求跑几十 GB 以上的 join结果执行计划里全是 spill再牛的 index 文件设计也救不回来。4.2 两套体系的常见调优落点基于上面的拆解做优化时你就知道该往哪里下手了。MapReduce 侧优先关注io.sort.factor一次归并时同时打开的输入流数和io.sort.mbmap 输出缓冲区大小。这两个参数实质上是调整“排序税”的摊还方式段数越多越碎归并轮数越多IO 放大越明显段数量少但每个段更大内存压力又会上升。Spark 侧首先关注spark.sql.shuffle.partitions与分区数据是否均衡。分区数不是越大越好数据量不大时强行拆成几千个分区只是制造大量小 shuffle 段读侧索引文件满天飞性能反而恶化。如果写侧溢写不可避免要开启中间数据的压缩并调整合适的压缩块大小减少磁盘 IO 上的字节数这比单纯调大 executor 内存更直接。处理高重复 key 的数据时Spark 提前使用 map 端聚合或reduceByKey能大幅减少跨节点传输数据量但必须小心聚合对象对内存的占用MR 则用 combiner 做类似的事区别在于它天然带有排序好的局部 buffer聚合命中率更容易做出好效果。4.3 我踩过后才愿意写出来的两个反向教训第一个经验是关于 MR 的io.sort.factor。有一段时间我为了减少归并轮数把这个值从默认的 10 一路调大到 100结果任务不仅没有变快反而 CPU 飙得很高。原因是同时打开的输入流越多归并树需要维护的比较堆就越大每条记录都要在堆里做多次比较序列化和比较的 CPU 开销超过 IO 减少带来的收益。这类参数真的不是越大越好必须结合你的平均 map 输出段数来定。第二个经验是关于 Spark 的倾斜缓解。我曾试着用一个全局 repartition 把热点 key 打散从读写两侧看分区段确实均衡了不少但下游 join 时逻辑上需要同一个 key 聚合打散后的数据又得再次 shuffle整体反而多了几个 stage。后来改成对热点 key 单独加盐只有盐化后的子键走额外分区非热点 key 保持原逻辑才真正把耗时压了下来。这个过程中我对“shuffle 目标是减少不必要的数据移动而不是把分区切均匀”有了更具体的认识。我现在的习惯是不管是 MR 还是 Spark遇到 shuffle 性能问题先不看参数范文而是打开执行计划或任务计数器看数据落盘时的段数量、溢写轮次、读侧是否需要二次归并。这两套引擎的代码和参数会不断迭代但写侧怎么组织文件、读侧怎么取得数据这个底层逻辑非常稳定。把这条主线想明白很多优化选项都不用背自己做个小实验就能推导出答案。