1. 为什么Spark程序员都要跟Shuffle打交道
先讲一个我经常在线上看到的现象:某个离线任务跑了一两周都挺正常,某天上游数据量翻了一倍,于是你顺手把默认并行度调大,从200变成500,然后整个任务反而从半小时跑成了一个半小时。打开Spark UI一看,DAG图里某个Stage的Shuffle Read规模大得吓人,Executor日志里全是心跳超时和GC告警。这种问题十有八九出在Shuffle环节。
Shuffle这个词,字面意思是“洗牌”。在Spark里,它的含义是把分散在各个Executor、各个分区里的数据,按照某种规则重新打散再聚合到目标分区。整个过程涉及磁盘读写、网络传输、序列化反序列化、内存缓冲和排序归并,几乎每一环都是性能杀手。可以说,Spark任务大部分的性能黑洞,都隐藏在某个Shuffle背后。
哪些操作会触发Shuffle?一句话总结:凡是产生宽依赖(Wide Dependency)的算子都会。常见的包括groupByKey、reduceByKey、aggregateByKey、join、distinct、repartition、coalesce(高并行度降到低并行度时)等等。它们的共同特点是:父RDD的同一个分区数据,需要被分发到多个不同的子RDD分区去,分区之间产生了交叉依赖。
理解了Shuffle,你才算真正理解了Spark任务为什么会慢、怎么调优、为什么某个参数改了之后效果天差地别。这篇文章我会从底层机制讲起,把Map侧和Reduce侧的数据流拆开,讲清楚Sort Shuffle和Hash Shuffle的历史选择,然后结合参数、内存模型和线上排查经验,给你一套可落地的分析思路。无论你是刚接触Spark的新人,还是已经被数据倾斜折磨过几轮的老人,这篇文章都能帮你把Shuffle这块硬骨头啃下来。
2. Spark Shuffle的底层数据流拆解
2.1 从DAG到Stage的划分逻辑
Spark把一个计算任务先转化成DAG(有向无环图),DAG里的每个点就是RDD,每条边就是RDD之间的依赖关系。依赖关系分成两种:窄依赖和宽依赖。窄依赖下,父RDD的每个分区最多只被子RDD的一个分区使用,比如map、filter、union;宽依赖下,父RDD的一个分区会被多个子RDD分区使用,典型就是groupByKey、join这类算子。
窄依赖和宽依赖的区别,直接决定了Spark的调度策略。DAG调度器会从后往前回溯,一旦遇到宽依赖就在那里划一道“界”,把前后切分成不同的Stage。同一个Stage内部,所有任务可以流水线式地在内存里完成计算,不需要落盘;而Stage与Stage之间,就必须通过Shuffle来传递数据。所以你看到的DAG图里,Stage之间的那条粗箭头,就是一次完整的Shuffle过程。
这里有个很容易忽略的点:Stage数量的多少,基本等于Shuffle次数的多少。如果一个任务有10个Stage,那中间至少有9次Shuffle。每一次Shuffle都意味着网络传输和落盘。所以做Spark调优时,第一个要问的问题就是:这个任务能不能减少Stage数量?如果能用一次Shuffle解决的问题,绝不用两次。
2.2 Map侧:ShuffleMapTask做了三件事
我先说Map侧。每个Stage在Executor上都被拆成若干个任务,其中负责产生Shuffle数据的任务叫ShuffleMapTask。它的输出会被下游Stage的Reduce任务拉取,所以它的工作质量直接决定了下游的读取压力。
ShuffleMapTask在Shuffle阶段的工作,大致三步:计算分区、写数据、管理索引。
第一步,计算分区。上游RDD的每条记录进入ShuffleMapTask后,会交给分区器(Partitioner)判断它应该去下游的哪个分区。默认分区器是HashPartitioner,分区号由key.hashCode() % numPartitions决定,但hash碰撞和取模的均匀性问题比较突出。如果用RangePartitioner,则会对key做采样并构建区间边界,数据集偏斜严重时更稳一些。当然你也可以实现自定义Partitioner,后面优化章节我会细说。
第二步,写数据。分区号确定后,这条记录并不会直接落到磁盘,而是先进入一个内存缓冲区。缓冲区有两种位置:如果spark.shuffle.spill开启且内存足够,记录会先存在内存数据结构里,等达到一定阈值再溢写(spill)到磁盘;也可以选择直接写入基于磁盘的临时文件。现代Spark默认用的Sort Shuffle,会在内存里先按分区号和key排序,排序结果分批溢写,最后再合并出最终文件。这一步涉及大量排序和序列化操作,是最吃CPU和内存的地方。
第三步,管理索引。Map任务不会真的给每个下游分区写一个单独文件,而是把所有分区的数据写到同一个物理文件里,同时生成一个索引文件(.index)记录每个分区的起始偏移量。下游Reduce任务拉数据时,只要读取索引文件,就能精准定位到自己要的那一段数据范围。这种一个文件加一个索引的设计,是Spark解决“小文件爆炸”问题的关键一步。
2.3 Reduce侧:从拉取数据到聚合输出
Reduce侧的任务叫ShuffleMapTask的下游消费者,一般叫 ShuffleRDD 里的任务,或者是普通RDD上接Shuffle依赖的任务。它要做的事情也很清晰:发现数据位置、建立连接、拉取、反序列化、聚合或输出。
拉取数据时,Reduce任务首先通过MapOutputTracker拿到“每个Map任务输出的数据块分别在哪个Executor”的元数据。然后由ShuffleBlockFetcherIterator统一发起远程拉取。为了控制并发,Spark会用spark.reducer.maxSizeInFlight限制同时拉取的总字节数,避免把网络带宽瞬间打满;默认值通常是48MB,可以适当调大,但别盲目翻很多倍。
几个Map任务的数据到达Reduce端之后,并不会立即合并。如果是聚合类算子,Reduce端会把这些数据放进一个新的内存哈希表里,key相同的记录不断执行聚合函数,比如累加、拼接、取最大值;如果是纯排序场景,则会走类似于外部排序的流程,把数据归并成有序输出。当内存里的数据量超过spark.shuffle.spill的阈值时,同样会溢写到磁盘,再做多路归并。
这里有一个常见的误解:很多人以为Shuffle就是把数据从Map端挪到Reduce端,中间不做任何处理。实际上,Reduce端的读取过程里还有一组非常重要的优化,叫“读时合并”(Combiner)。比如reduceByKey在Map侧就已经做了一次本地合并,把同key的value先reduce一遍,大大减少了跨节点传输的数据量。这也是我后面会反复强调的一点:能用reduceByKey就别用groupByKey。
2.4 外部Shuffle Service:一个容易忽略的长寿组件
Spark的Shuffle数据不会永远保存在Executor里。一个Executor一旦退出,它磁盘上存放的Shuffle中间文件可能就丢掉了。如果某个ShuffleMapTask失败需要重算,或者下游Reduce任务一直拉不到某个数据块,Spark必须让上游重新执行任务来生成数据。
外部Shuffle Service就是为此存在的独立进程。它常驻在每台Worker节点上,和Executor分离。Executor写好的Shuffle文件,注册到本地Shuffle Service上;即使Executor崩溃,Shuffle Service依然能继续为下游提供这些文件。这在动态资源分配、Executor频繁增减的场景下特别重要。线上如果发现频繁出现“ExecutorLostFailure”然后紧跟FetchFailed,优先检查Shuffle Service是否配好、端口是否通了。
3. Shuffle的两种底层实现:Hash与Sort
3.1 早期Hash Shuffle为什么被淘汰
在Spark 0.x和1.0时代,默认的Shuffle实现是Hash Shuffle。它的思路非常朴素,每个Map任务为下游的每个Reduce分区单独写一个文件。当Map任务数和Reduce分区数都很大时,产生的中间文件数量就是M × R。假设上游有200个Map任务,下游有200个分区,那就是4万个文件;如果并行度再高,比如1000×1000,就是100万个文件。
文件数量爆炸的直接后果是:磁盘上到处都是小文件,文件系统inode被耗尽,随机IO增加,磁盘读取效率大幅下降;同时,Map端需要为每个分区都保留一个文件句柄和缓冲区,内存开销极高;Reduce端拉取数据时,连接数也是同数量级,网络握手和线程调度都会成为瓶颈。
后来Spark引入了Consolidate机制,只把同一个Core上连续执行的多个Map任务写到同一组文件里,减少了文件数量,但并没有从根上解决问题。所以Hash Shuffle现在基本只作为历史知识被提及,默认管理器早已换成Sort Shuffle。
3.2 Sort Shuffle的翻身仗
Sort Shuffle的设计理念是:每个Map任务最终只产出一个数据文件(加一个索引文件),下游按偏移量去读。这样文件数量从M × R降到了M × 2,小文件问题被大幅缓解。
具体流程是:Map任务先把记录写进内存数据结构,然后按“分区号 + 分区内key”进行排序。排序后的数据分批溢写,溢写出的多个磁盘小文件最后会通过归并算法合并成一个最终文件。由于数据在写入时就带有分区信息,下游Reduce任务只要读取最终文件里属于自己那个分区的偏移区间即可。
Sort Shuffle在排序方面的开销确实比Hash Shuffle大,因为Hash方式不需要排序,直接按分区写入就行。但现代CPU对比较、排序的吞吐量很高,相比文件系统小文件带来的灾难性影响,那点排序开销完全值得。这也是为什么Spark后来甚至把Sort方式用到了不需要排序的场景里——用一种统一模型换取稳定性和可预测性。
3.3 BypassMergeSortShuffle:某些场景下的特殊通道
针对“不需要排序”的场景,Spark还保留了一条优化路径,叫BypassMergeSortShuffle。它的触发条件有三个:Map端没有定义聚合(Aggregator)、Reduce分区数小于spark.shuffle.sort.bypassMergeThreshold(默认200)、当前不是map-side combine场景。满足这三个条件时,Map任务会直接按分区号写多个文件,不排序,最后再把它们拼接成一个大文件。
为什么要保留这种看似“退化”的机制?因为当Reduce分区数比较少(比如200以内)时,每个Map任务产生的文件数量有限,排序带来的CPU开销反而是肉眼可见的浪费。用拼接代替排序,能省下不少时间。不过如果你把分区数调得很大,这个Bypass路径会自动失效,回到完整的Sort流程。
3.4 Tungsten-Sort:借助堆外内存的优化实现
Spark 1.5之后引入的Tungsten计划,对Shuffle排序做了深度优化。传统排序发生在堆内对象层面,需要维护大量对象引用,GC压力很大;Tungsten-Sort则把数据序列化后存在堆外的二进制内存块里,排序时直接操作二进制指针,避免了反序列化和大量对象创建。
这种“以字节为单位直接操作”的方式,让排序吞吐量显著提升。代价是它不适用于所有场景:如果你的key或value是复杂对象、或者聚合函数需要在排序期间做大量Java对象操作,Tungsten-Sort可能帮不上忙。所以Spark会根据配置自动选择实现,而不是让用户手动指定一个“最牛的”。你把spark.shuffle.manager设成tungsten-sort只对老版本有意义,在新版本里Manager已经被统一管理了。
4. 关键参数与内存模型:别再把所有锅甩给代码
4.1 核心参数速查
Shuffle相关的配置参数很多,但真正值得你花时间去调的,其实就那么十几个。我把它们分成三大类列出来,方便对照排查。
| 分类 | 参数名 | 默认值(Spark 3.x) | 作用 | 调优建议 |
|---|---|---|---|---|
| 内存 | spark.shuffle.file.buffer | 32KB | Map端每个输出文件的内存缓冲区大小 | 如果Map端写盘慢,可调大至64KB~128KB |
| 内存 | spark.reducer.maxSizeInFlight | 48MB | Reduce端同时拉取的最大数据量 | 网络带宽充足时可调大,但不要超过几百MB |
| 内存 | spark.shuffle.memoryFraction | 0.6(老版本) | Shuffle内存占总堆内存比例 | 新版由Unified Memory管理,一般不手调 |
| 内存 | spark.shuffle.spill | true | 是否允许溢写 | 默认开启,关闭会大幅增加OOM风险 |
| 网络 | spark.shuffle.io.retryWait | 5s | 拉取失败后的重试等待间隔 | 网络抖动严重时适当调大 |
| 网络 | spark.shuffle.io.maxRetries | 3 | 最大重试次数 | 建议改为5~10次 |
| 文件 | spark.shuffle.sort.bypassMergeThreshold | 200 | Bypass路径的触发阈值 | 不需要排序且分区少时,可调大 |
| 压缩 | spark.shuffle.compress | true | 是否对Shuffle文件压缩 | 默认开启,压缩率高但增加CPU开销 |
| 压缩 | spark.io.compression.codec | lz4 | 具体压缩算法 | 可换zstd,CPU便宜时可省大量磁盘空间 |
4.2 内存模型:Shuffle的数据到底放在哪里
Spark 1.6之后引入了统一内存管理,把Executor堆内Java Heap分成Storage内存和Execution内存两部分,两者之间可以互相借用。Shuffle主要吃的是Execution内存。
Map侧排序时,数据会以“记录数组”或“二进制分区对象”的形式占用Execution内存。如果一条数据占用的字节数估算超过预设阈值(比如spark.shuffle.spill.initialThreshold默认5MB,老版本),或者内存不够了,就会触发一次溢写。溢写的时候,当前内存里的数据被排序、序列化并写入磁盘,然后清空内存继续接收新数据。这个过程产生的磁盘小文件数量,会和溢写次数直接相关。
Reduce侧聚合时,默认也是把所有到来的记录先放进一个哈希表。哈希表占用的是Execution内存;如果内存压力太大,就会触发溢写。这里最怕出现“哈希表反复扩容”和“大量key哈希碰撞”,两种情况都会加剧内存抖动。
一个很简单的心算方法:如果你的Executor堆内存是4GB,减掉预留的300MB,再减去RDD缓存可能占用的部分,真正能给Shuffle用的也就1GB多。当一次Shuffle的输入数据远超这个量级时,必然走上溢写道路。所以不要天真地以为“我把所有数据都放在内存里,Shuffle就变快了”。有时候你缺的根本不是内存,而是合理设计分区和聚合策略。
4.3 磁盘与网络IO的开销估算
假设一个任务要从1000个Map分区读取10GB数据,Reduce分区数是200。理想情况下,每个Reduce任务要拉的数据量就是50MB。如果reducer.maxSizeInFlight是48MB,差不多一个批次就能拉完;但如果Reduce任务本身的并发线程不够,或者上游数据分布极度不均匀,某个Reduce任务可能要拉几百MB,那它就要经历多轮拉取。
网络传输时间大致可以估算:单次拉取时间 ≈ 数据量 / 有效带宽。100MB数据在万兆网卡上的理想时间是0.1秒,但TCP拥塞、序列化开销、GC停顿都会让实际时间翻几倍。这也是为什么maxSizeInFlight不是越大越好——调大了虽然减少了拉取轮次,但也可能把网络带宽占满,影响其他Executor的心跳和Shuffle数据传输。
磁盘方面,Map侧溢写时的随机写最伤硬盘。如果spark.shuffle.spill频繁触发,你会发现节点上的磁盘IO util飙到90%以上,同时GC时间也在上升。这种情况的解法不是单纯换SSD,而是减少溢写次数,比如调大executor内存、降低单task处理的数据量、或者减少无意义的并行度。
5. 线上问题排查实录:数据倾斜与拉取失败
5.1 从Spark UI指标锁定Shuffle端问题
排查Shuffle问题,我永远先看Spark UI上的两个地方:Stage页面的“Shuffle Read/Write”列,以及每个Task的统计信息。
打开一个Stage,你会看到每个Task对应的Shuffle Write bytes和Shuffle Read bytes。如果某个Task的Shuffle Write bytes是其他Task的几十倍,基本可以断定这是数据倾斜;如果所有Task的Shuffle Read都正常,但Shuffle Write差异巨大,问题出在Map端;如果Write正常但某个Task的Read奇高,问题出在Reduce端的分区分配不均。
另外注意Task的调度时长(Duration)。如果某个Task的Duration异常高,且GC Time也很高,说明它在做大量聚合或排序;如果Show Whitelist里频繁出现“FetchFailed”,说明某个上游节点的中间文件丢了或者网络异常。这两种情况的排查路径完全不同,先定位问题侧再动手,比盲目调参靠谱得多。
5.2 数据倾斜的经典表现与解决思路
数据倾斜的典型场景是reduceByKey或者join:某一个key的数据量特别大,导致某个Reduce任务要处理的数据量远超平均值,任务慢得像蜗牛,其他任务早就跑完了。
处理思路有几种,按成本从低到高排列。
第一种是增加随机前缀并做两阶段聚合。对倾斜的key加一个随机数前缀,先局部聚合,再把前缀去掉做全局聚合。代码大概是:
rdd.map { case (k, v) => val prefix = Random.nextInt(100) ((prefix + "_" + k, v)) }.reduceByKey(_ + _) .map { case (k, v) => val originalKey = k.substring(k.indexOf("_") + 1) (originalKey, v) }.reduceByKey(_ + _)这种方式能解决聚合型倾斜,但对join型倾斜不适用。join型可以考虑把大key的数据单独拆出来,用broadcast广播小表,避免Shuffle;或者用随机前缀分别膨胀小表和拆分大表,再做两次join。
第二种是调整分区器。如果倾斜key只有少数几个,而且你有业务知识能判断哪些key是大key,可以为它们单独指定分区数,让大key散到更多分区。
第三种是被很多资料忽略的:检查上游是否真的需要全量Shuffle。很多“倾斜”其实是无效数据太多,比如大量空字符串或NULL值作为key。如果业务允许,先过滤掉这些无效key,整个Shuffle的数据量能直接砍掉一大半。
5.3 FetchFailed重试与Executor丢失的连锁反应
FetchFailed出现时,先别急着改IO参数。你要区分三种可能:
- 上游Executor崩溃,导致已经写好的Shuffle文件丢失。如果外部Shuffle Service没有部署或者配置失效,Spark只有重新执行上游Map任务来重建数据。
- 网络抖动或超时,导致数据还没传输完连接就被断开了。
- 磁盘故障,某个节点的本地磁盘读不了已经写好的中间文件。
处理上,第一步确保外部Shuffle Service在所有Worker上正常运行,这是避免Executor崩溃后FetchFailed的最有效手段。第二步再考虑spark.shuffle.io.maxRetries和spark.shuffle.io.retryWait。重点提醒:maxRetries增大之后,任务的总等待时间可能指数级上升。比如每次retryWait 5s,重试10次就是50s;如果同一批数据有多个块都在重试,叠加起来很可观。所以配置时,你要结合自身任务允许的最大SLA来算,别把重试调到一个“看起来安全”的数字就完事。
我曾经遇到过一个案例:某个凌晨跑批任务频繁FetchFailed,去查Worker节点发现有两块磁盘处于只读状态。程序本身没问题,参数也没问题,纯粹是磁盘物理故障。这类环境的锅,不要指望靠调Spark参数去兜底,还是得先修机器。
6. 优化Shuffle的几条可落地路径
6.1 合理设置并行度与分区策略
很多人的第一反应是把并行度调到“很大”,期望更多Task并行处理来提速。但并行度提高后,Shuffle的Map侧文件数量、Reduce侧连接数量、微小的调度开销都会增加。太高的并行度不但不划算,甚至会让任务更容易被磁盘和网络IO限制。
给一个方法论:分区数尽量控制在“核数 × 2~4”这个范围内,并保证每个分区的数据处理时间相对均衡。调整并行度时用explicit分区的算子,比如repartition、coalesce和聚合类算子的第二个参数,比全局setspark.sql.shuffle.partitions更精准。
coalesce和repartition的选择也常被搞混。coalesce只能减少分区数,而且默认不触发Shuffle,它通过合并同一个Executor上的分区来减少文件数,速度快但容易造成数据倾斜;repartition会重新做一次Shuffle,适合在真正需要重新分布数据时使用。如果你只是想减少输出文件数量,优先用coalesce,但如果目标是解决下游读取时的数据不均,就得用repartition。
6.2 利用AQE:让Spark自己调Shuffle分区数
Spark 3.0引入的Adaptive Query Execution(AQE),是我个人最推荐的Shuffle优化手段之一。开启方式:
spark.conf.set("spark.sql.adaptive.enabled", "true") spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true") spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "128MB")AQE可以在每次Shuffle结束后,根据实际统计到的数据分区大小,动态合并小的分区。举个真实例子:默认spark.sql.shuffle.partitions是200,但上游某个阶段的输出只有几十MB,那么200个分区里大部分都是空分区或极小分区,不但浪费调度资源,还会让下游产生大量空任务。开启AQE后,它会基于“目标分区大小”(advisoryPartitionSizeInBytes,默认64MB)重新计算实际需要的分区数,把多余分区合并掉。
AQE还能做Join策略调整和倾斜分区拆分。spark.sql.adaptive.skewJoin.enabled开启后,Spark会在运行时检测倾斜的join分区,把大分区拆成多个小分区去和小表匹配。这个功能对无序的倾斜处理特别友好。不过要注意,AQE的优化发生在每次Shuffle边界,如果任务里一个Stage包含多个Shuffle,调试时还是得结合Stage的“Shuffle Read Size”一起看,不能盲目看最终结果。
6.3 从算子层面避免不必要的Shuffle
绝大多数Shuffle是可以从源头消掉的。我归纳成三个方向。
方向一:用map-side合并减少数据量。reduceByKey在Map端先做一次combined,再把合并结果发给Reduce端;groupByKey则是把所有原始记录都传到Reduce端再聚合。同样是聚合,前者的网络传输量能少一到两个数量级。像aggregateByKey也支持map-side合并。写代码的时候多用这些算子,少用groupByKey + 自写聚合。
**方向二:用广播替代大表Join。**如果一张表比较小(比如参考数据、配置表),用broadcast把它广播到所有Executor,就能让join变成窄依赖,完全跳过Shuffle。判断标准:小表的序列化大小最好在几十MB以内,否则广播本身反而成为新的瓶颈。开启spark.sql.autoBroadcastJoinThreshold(默认10MB)可以自动让Spark选择broadcast join,但超过阈值后还是要手动确认。
**方向三:缓存和持久化复用中间结果。**很多Shuffle是因为同一个RDD被多次使用而重复计算的。如果某个数据集在多个Action里都会用到,用cache()或persist(StorageLevel.MEMORY_AND_DISK)把它存起来,避免每次都要从源头重算并触发Shuffle。这里要注意,缓存也会占用Executor内存,缓存过多会让Shuffle可用的Execution内存变小,两者之间需要平衡。
7. 我的一些经验总结与思考
做了这么多年Spark调优,我对Shuffle的态度经历了从“深恶痛绝”到“理解并利用”的转变。最早遇到慢任务,第一反应就是加资源、调并行度,后来踩过几次坑才明白,Shuffle的设计本身不是糟糕的,糟糕的是我们根本不了解数据在中间经历了什么。
我现在拿到一个慢任务,第一步永远是开Spark UI,看DAG里有几个Shuffle,每个Shuffle两边分别有多少数据量;第二步是看单个Executor的GC时间和磁盘IO,确认瓶颈到底是内存还是IO还是CPU;第三步才是动手改代码或参数。我会先尝试最简单、改动最小的方案,比如把groupByKey改成reduceByKey,或者开启AQE,然后再考虑重写分区逻辑。绝大多数情况下,数据量和网络传输量的缩减,比单纯把并行度翻倍更有效。
最后分享一个我常用的调优小技巧:当你怀疑某个参数的影响时,不要只在一个数据规模下测。Shuffle相关参数往往对数据量的变化非常敏感,比如maxSizeInFlight在100GB数据下和10GB数据下的最优值很可能不同。线上验证时,从最小改动开始,观察两个指标:一是全程GC时间占比,二是Shuffle Read的平均拉取时长。这两个指标稳定了,任务再慢也就是时间问题,至少不会出现让人半夜爬起来处理的突发故障。
Shuffle这块水很深,我也还在不断踩新的坑。如果你在调优过程中遇到什么有趣的案例,或者上面某些参数在你的业务场景下表现完全不同,欢迎一起交流,毕竟真实环境里的经验,永远比文档里的默认值更有价值。