Spark宽窄依赖与Stage划分:从原理到性能优化实战
1. 从一个真实的翻车现场说起为什么宽窄依赖值得你花时间刚接触 Spark 那会儿我对 RDD 的依赖关系完全没概念觉得能跑出结果就行。直到有一次处理一份日志数据代码逻辑看起来毫无问题但跑起来慢得离谱——一个简单的聚合操作集群资源占了一大半任务卡在某个 Stage 死活不动。打开 Spark UI 一看密密麻麻的 Shuffle Write 和 Shuffle Read数据量比原始输入膨胀了好几倍。后来一位老同事看了一眼代码说了句“你把两个需要宽依赖的操作串在一起了中间还没做任何收敛。”那一刻我才意识到宽窄依赖不是面试八股它直接决定了你的作业是跑十分钟还是跑两小时。这篇文章就是想把这件事讲透。我会从 RDD 依赖关系的本质出发拆解宽依赖和窄依赖在底层到底有什么区别为什么 Spark 要用这套机制来划分 Stage以及在实际写代码时怎么利用这些知识来避免性能陷阱。不管你是刚开始学 Spark 的新手还是已经写过一些作业但总觉得“能跑但不够快”的开发者这篇内容都能帮你建立起一套判断标准——看到一段 Transformation 链脑子里就能自动画出 Stage 边界知道哪里会产生 Shuffle哪里可以流水线执行。关键词里提到了“Spark”“RDD”“宽窄依赖”“Shuffle”“Stage 划分”这些概念是环环相扣的。我不会孤立地讲定义而是把它们串成一条线依赖关系决定了能不能流水线执行流水线执行的中断点就是 Stage 边界而 Stage 边界处必然伴随 Shuffle。理解了这条线你再看 Spark 的调度逻辑就会觉得顺理成章。2. 宽窄依赖的底层逻辑从 RDD 的血缘关系说起2.1 RDD 为什么需要记录依赖关系RDD 的全称是 Resilient Distributed Dataset中文叫弹性分布式数据集。很多人只关注“分布式数据集”这部分忽略了“弹性”二字。弹性体现在哪里核心就是容错。Spark 不像 Hadoop MapReduce 那样把中间结果落盘它把数据放在内存里做流水线计算速度快但风险也大——某个节点挂了内存里的数据就没了。那怎么恢复靠的就是 RDD 的血缘关系Lineage。每个 RDD 都记录了自己是怎么从父 RDD 转换来的这个转换关系就是依赖。当某个分区的数据丢失时Spark 不需要重新跑整个作业只需要根据依赖关系找到丢失分区的父分区重新计算那一个分区的数据就行。但这里有个关键问题如果父 RDD 的一个分区被多个子 RDD 的分区依赖恢复时就要重算多个子分区如果父 RDD 的多个分区共同贡献给子 RDD 的一个分区恢复时就要重算多个父分区。这两种情况的恢复代价完全不同这就是宽窄依赖要解决的核心问题。2.2 窄依赖的精确定义与典型算子窄依赖Narrow Dependency的定义很简洁父 RDD 的每个分区最多被一个子 RDD 的分区使用。注意“最多一个”这个限定它意味着父分区和子分区之间是一对一或多对一的关系。一对一很好理解比如map、filter、flatMap这类算子父分区里的每条记录独立转换后还是落在同一个子分区里。多对一的情况稍微特殊一点比如union操作多个父 RDD 的分区合并到一个子 RDD 的分区里但每个父分区仍然只对应一个子分区。窄依赖的最大好处是可以在同一个 Stage 内流水线执行。什么意思假设你对一个 RDD 连续做了map和filterSpark 不会先把所有数据 map 完再 filter而是对每条记录依次执行 map 和 filter中间不产生临时数据集。这种流水线执行把多个操作合并成一个任务链减少了内存占用和调度开销。我用一个生活化的类比来解释窄依赖就像流水线上的装配工位每个工位只处理自己面前的那份零件处理完直接传给下一个工位不需要等其他工位的零件凑齐。所以整条流水线可以一直运转效率很高。2.3 宽依赖的本质Shuffle 的根源宽依赖Wide Dependency的定义正好相反父 RDD 的每个分区可能被多个子 RDD 的分区使用。注意这里说的是“可能被多个”典型场景就是groupByKey、reduceByKey、join这类需要按 key 重新分组的操作。假设父 RDD 有 100 个分区子 RDD 也有 100 个分区但数据是按 key 的哈希值重新分布的那么父分区 1 的数据可能分散到子分区 3、17、58 等多个分区里。这种情况下子 RDD 的每个分区都依赖于所有父 RDD 的分区或者至少是多个父分区必须等所有父分区的数据都准备好并重新分发之后子分区才能开始计算。这个“重新分发”的过程就是 Shuffle。Shuffle 是 Spark 中最昂贵的操作之一因为它涉及磁盘 I/O、网络传输和序列化/反序列化。数据要先写到磁盘Shuffle Write然后通过网络拉取到目标节点Shuffle Read中间还可能涉及排序和合并。还是用流水线类比宽依赖就像要把所有工位生产的零件按颜色重新分类红色零件送到 A 区蓝色零件送到 B 区。在分类完成之前A 区和 B 区都没法开始组装。而且分类过程本身需要额外的场地磁盘和运输工具网络成本自然就上去了。2.4 一张表看清宽窄依赖的核心差异对比维度窄依赖宽依赖分区对应关系父分区最多对应一个子分区父分区可能对应多个子分区是否产生 Shuffle否是能否流水线执行能同一 Stage 内合并操作不能必须等 Shuffle 完成容错恢复代价低只需重算丢失分区的父分区高可能需要重算多个父分区典型算子map、filter、flatMap、union、mapPartitionsgroupByKey、reduceByKey、join、repartitionStage 划分影响不触发 Stage 切分触发 Stage 切分这张表建议你记在脑子里。每次写代码时看到这些算子就要条件反射地判断这里会不会产生 Shuffle如果会能不能避免或者优化3. Stage 划分机制DAGScheduler 是怎么切蛋糕的3.1 从 DAG 到 Stage 的切分逻辑Spark 的调度核心是 DAGScheduler它拿到用户提交的作业后会先根据 RDD 的依赖关系构建一个有向无环图DAG。然后它从最后一个 RDD通常是触发 Action 的那个开始反向遍历依赖链遇到窄依赖就继续往前找遇到宽依赖就切一刀把宽依赖之前的部分划成一个 Stage。为什么是反向遍历因为 Stage 的边界是由宽依赖决定的而宽依赖意味着“子 Stage 需要等父 Stage 的输出”。从后往前找第一个宽依赖就把整个 DAG 分成了“前面的 Stage”和“后面的 Stage”。后面的 Stage 依赖前面的 Stage 的输出所以前面的必须先执行。具体来说DAGScheduler 会从触发 Action 的 RDD 开始沿着依赖链向上游走。如果遇到窄依赖就把当前 RDD 和父 RDD 划入同一个 Stage如果遇到宽依赖就以这个宽依赖为边界把当前部分划为一个 Stage然后继续从宽依赖的父 RDD 开始向上游走重复这个过程。最终形成的 Stage 序列中前面的 Stage 是后面 Stage 的输入执行顺序自然就是从前往后。3.2 为什么要这样切宽依赖是天然的屏障宽依赖之所以能作为 Stage 的边界是因为它阻断了流水线执行。在窄依赖链中数据可以从父分区直接流到子分区一条记录处理完立刻进入下一个算子不需要等待其他分区。但宽依赖要求所有父分区的数据按 key 重新分布子分区必须等所有父分区的数据都到达之后才能开始计算。这个“等待所有父分区完成”的点就是天然的 Stage 边界。你可以把 Stage 想象成一道工序。窄依赖链是一道工序内部的连续操作工人可以一直干宽依赖是两道工序之间的交接必须等上一道工序的所有产品都检验完毕、分类摆放好下一道工序才能开始。Spark 把每道工序打包成一个 TaskSet提交给 TaskScheduler 去执行。一个 Stage 内的所有 Task 可以并行执行因为它们之间没有依赖不同 Stage 之间必须串行因为后面的 Stage 需要前面 Stage 的输出。3.3 Stage 类型ShuffleMapStage 与 ResultStageSpark 中的 Stage 分为两种类型。ShuffleMapStage是中间阶段的 Stage它的输出会被下一个 Stage 作为输入所以它的任务叫 ShuffleMapTask执行完的结果需要写到磁盘供下游拉取。ResultStage是最后一个 Stage它直接对应一个 Action 操作输出的是最终结果任务叫 ResultTask执行完把结果返回给 Driver 或写入外部存储。一个作业中可能有多个 ShuffleMapStage但只有一个 ResultStage。比如你写了rdd.map(...).reduceByKey(...).map(...).collect()那么reduceByKey之前的map和reduceByKey本身会形成一个 ShuffleMapStage因为 reduceByKey 是宽依赖reduceByKey之后的map和collect形成 ResultStage。如果后面还有join之类的宽依赖就会再多一个 ShuffleMapStage。3.4 一个具体例子手动画出 Stage 边界假设有这样一段代码rdd sc.textFile(data.txt) words rdd.flatMap(lambda line: line.split( )) pairs words.map(lambda word: (word, 1)) counts pairs.reduceByKey(lambda a, b: a b) filtered counts.filter(lambda x: x[1] 10) result filtered.collect()从后往前分析collect是 Action触发作业。filter是窄依赖和counts划入同一个 Stage。reduceByKey是宽依赖在这里切一刀。reduceByKey之前的map、flatMap、textFile都是窄依赖划入前一个 Stage。所以最终有两个 StageStage 0 包含textFile→flatMap→map→reduceByKey的 Shuffle Write 部分Stage 1 包含reduceByKey的 Shuffle Read 部分 →filter→collect。Stage 0 是 ShuffleMapStage它的任务是读取文件、分词、映射成键值对、按 key 做局部聚合然后把结果写到磁盘。Stage 1 是 ResultStage它的任务是从磁盘拉取 Stage 0 的输出、完成全局聚合、过滤、收集结果返回 Driver。这个例子中reduceByKey之所以是宽依赖是因为它需要把相同 key 的数据拉到同一个分区做聚合。如果换成groupByKey同样是宽依赖但性能会差很多原因后面会细说。4. 宽窄依赖对性能的实际影响不只是理论4.1 Shuffle 的代价到底有多大Shuffle 的代价体现在三个层面。第一是磁盘 I/OShuffle Write 阶段每个 Map Task 会把输出按目标分区写到本地磁盘产生大量小文件。第二是网络传输Shuffle Read 阶段Reduce Task 需要从多个 Map Task 所在的节点拉取数据跨节点传输受带宽限制。第三是内存压力拉取过来的数据需要缓存在内存中做排序和聚合如果数据量大会频繁溢写到磁盘进一步拖慢速度。我做过一个粗略的测试同样一份 10GB 的日志数据做一次groupByKey聚合Shuffle Write 大约产生 8GB 的中间数据Shuffle Read 也是 8GB 左右整个作业耗时 12 分钟。而如果把逻辑改成先用reduceByKey做局部聚合再全局聚合Shuffle 数据量降到 2GB 左右耗时缩短到 4 分钟。差距就是这么明显。4.2 窄依赖链的流水线优化为什么 mapfilter 比你想的快窄依赖链中的多个算子会被 Spark 合并成一个 Pipeline每条记录依次经过所有算子中间不落地。这意味着rdd.map(f).filter(g).map(h)不会产生三个中间 RDD 的完整数据集而是对每条记录连续执行 f、g、h 三个函数。这种优化叫流水线执行Pipelining。流水线执行的好处是减少了内存分配和垃圾回收。如果没有流水线每个算子都要遍历一遍完整数据集并生成新的 RDD三次操作就是三遍遍历、三次内存分配。有了流水线只需要一遍遍历中间结果直接在寄存器或栈上传递。对于数据量大、算子链长的作业这个优化能带来数倍的性能提升。但要注意流水线执行只对窄依赖有效。一旦遇到宽依赖流水线就被打断必须等 Shuffle 完成才能继续。所以优化的一条基本原则是尽量把窄依赖操作放在宽依赖之前让它们在同一个 Stage 内流水线执行减少 Shuffle 的数据量。4.3 宽依赖的连锁反应一个 Shuffle 引发多个 Stage 串行宽依赖不仅本身昂贵还会导致后续 Stage 必须等待。假设你有三个连续的宽依赖操作reduceByKey→join→sortByKey那么 Spark 会划分出四个 Stage它们必须串行执行。Stage 0 完成 Shuffle Write 后Stage 1 才能开始 Shuffle Read 和计算Stage 1 完成后再触发 Stage 2 的 Shuffle以此类推。这种串行执行意味着集群资源无法充分利用。在 Stage 0 执行时Stage 1 的任务只能等待Stage 1 执行时Stage 0 的资源已经释放但 Stage 2 还没法开始。如果每个 Stage 的数据量都很大整个作业的耗时就是各 Stage 耗时之和没有任何重叠。优化的思路是减少宽依赖的数量或者把多个宽依赖操作合并成一个。比如能用reduceByKey完成的不需要先groupByKey再map能用aggregateByKey一次完成的不需要多次聚合。另外如果两个数据集都很大且需要 join可以考虑用广播变量把小表广播出去把宽依赖的 join 变成窄依赖的 map-side join。4.4 数据倾斜宽依赖最怕的场景宽依赖还有一个致命问题数据倾斜。因为宽依赖需要按 key 重新分区如果某些 key 的数据量远大于其他 key这些 key 对应的 Reduce Task 就会成为瓶颈其他 Task 早就完成了它们还在慢慢处理。Spark UI 上表现为少数 Task 的 Shuffle Read 数据量特别大执行时间特别长。数据倾斜的根源往往在业务数据本身比如某个热门商品的点击日志、某个大 V 的粉丝关系。解决思路有几种加盐打散给 key 加随机前缀先局部聚合再去掉前缀全局聚合、广播小表如果 join 的一方是小表、提高并行度增加 Shuffle 的分区数让每个分区处理的数据量更小。具体用哪种要看场景但核心思路都是把大 key 拆成多个小 key让它们分散到不同的 Task 上。5. 实战中怎么利用宽窄依赖做优化5.1 算子选型reduceByKey 和 groupByKey 的本质区别很多人知道reduceByKey比groupByKey快但说不清为什么。从依赖关系角度看两者都是宽依赖都会产生 Shuffle区别在于Shuffle 之前有没有做局部聚合。reduceByKey会在 Map 端先对每个分区内的相同 key 做一次聚合然后把聚合后的结果写到磁盘groupByKey则直接把所有键值对原样写到磁盘不做任何合并。假设有 1 亿条记录其中 100 万个不同的 key平均每个 key 有 100 条记录。用groupByKeyShuffle Write 要写 1 亿条记录用reduceByKeyMap 端局部聚合后每个分区内每个 key 只剩一条记录假设有 100 个分区Shuffle Write 最多写 100 万 × 100 1 亿条但实际上每个分区内相同 key 会被合并所以实际写入量远小于 1 亿。如果 key 分布均匀可能只有几百万条。这个差距在数据量大时非常可观。所以原则很简单能用 reduceByKey 就不用 groupByKey能用 aggregateByKey 就不用 groupByKey。aggregateByKey更灵活可以指定不同的聚合函数和初始值适合求和、求平均、求最大值等场景。5.2 分区策略怎么让宽依赖的代价更小宽依赖的代价和 Shuffle 的数据量成正比而 Shuffle 的数据量又和分区数有关。分区数太少每个分区数据量大容易 OOM 和倾斜分区数太多Task 数量多调度开销大小文件多。怎么选一个经验法则是每个分区的数据量控制在 100MB 到 200MB 之间。假设 Shuffle 数据总量是 10GB那么分区数设为 50 到 100 比较合适。你可以通过spark.sql.shuffle.partitionsDataFrame API或rdd.repartition(n)/rdd.coalesce(n)来调整。repartition是宽依赖操作会触发全量 Shufflecoalesce是窄依赖操作只能减少分区数不能增加而且不会触发 Shuffle。如果你需要增加分区数只能用repartition如果只是减少分区数且不想 Shuffle用coalesce。但要注意coalesce减少分区时可能导致数据分布不均如果后续有宽依赖操作最好还是用repartition重新均匀分布。5.3 广播变量把宽依赖变成窄依赖的经典手法两个 RDD 做 join 时如果其中一个是小表比如几 MB 到几十 MB可以用广播变量把小表分发到每个 Executor然后在 Map 端直接做 join。这样就不需要 Shuffle 大表了宽依赖变成了窄依赖性能提升非常明显。具体做法是用sc.broadcast(smallRDD.collectAsMap())把小表收集到 Driver 并广播出去然后对大表的每个分区用mapPartitions遍历在本地查广播变量做关联。这种模式叫Map-Side Join或Broadcast Join。Spark SQL 中可以通过/* BROADCAST(smallTable) */提示来强制使用广播 join或者调整spark.sql.autoBroadcastJoinThreshold参数来控制自动广播的阈值。但广播变量也不是万能的。如果小表太大比如超过几百 MB广播本身会占用大量网络带宽和 Executor 内存反而拖慢速度。一般来说小表在 100MB 以内用广播 join 比较合适超过这个量级就要考虑其他方案了。5.4 提前聚合与过滤把数据量降下来再 Shuffle宽依赖的代价和数据量直接相关所以在 Shuffle 之前尽量把数据量降下来是最有效的优化手段。两个常用手法提前过滤和提前聚合。提前过滤是指在宽依赖操作之前先用filter把不需要的数据剔除。比如你只需要统计某个时间段的日志就应该在reduceByKey之前先filter掉其他时间段的数据而不是先聚合再过滤。过滤是窄依赖可以在 Map 端流水线执行成本很低而如果放到 Shuffle 之后就要多传输和计算大量无用数据。提前聚合是指在宽依赖之前先做一次局部聚合。比如你要统计每个用户的访问次数可以先用map把每条记录变成(user, 1)然后用reduceByKey做局部聚合最后再全局聚合。这样 Shuffle 的数据量从原始记录数降到了用户数可能差几个数量级。5.5 用 Spark UI 验证你的优化效果优化不是凭感觉要用数据说话。Spark UI 是最重要的诊断工具重点看几个地方Stage 页面的 Shuffle Write 和 Shuffle Read 大小如果这两个数字很大说明宽依赖代价高Task 页面的 Duration 分布如果少数 Task 特别慢说明有数据倾斜Storage 页面的内存使用如果频繁溢写磁盘说明内存不够或分区不合理。我通常的做法是先跑一遍原始作业记录 Spark UI 上的 Shuffle 数据量和各 Stage 耗时然后做优化再跑一遍对比数据。如果 Shuffle 数据量下降了但耗时没降可能是分区数不合理或者有倾斜如果 Shuffle 数据量没变但耗时降了可能是并行度提高了。每次只改一个变量才能准确判断优化效果。6. 几个容易踩的坑和我的个人经验6.1 误判依赖类型不是所有 join 都是宽依赖很多人以为只要看到join就一定是宽依赖其实不一定。如果两个 RDD 在 join 之前已经按相同的分区器分好区并且分区数相同那么 Spark 可以做到窄依赖的 join——每个分区的数据只需要和对应分区的数据做关联不需要跨分区拉取。这种优化叫Co-Partitioned Join。要触发这个优化两个 RDD 必须满足使用相同的分区器比如都是HashPartitioner且分区数相同或者其中一个 RDD 的分区器是另一个的父分区器。实际中比较难满足因为大多数 RDD 的分区器是默认的除非你显式调用了partitionBy。但如果你确实需要频繁 join 两个数据集可以考虑先对它们做partitionBy统一分区方式后续的 join 就能避免 Shuffle。6.2 缓存时机在宽依赖之前还是之后cache或persist的时机很关键。如果某个 RDD 会被多次使用应该缓存它。但缓存在宽依赖之前还是之后我的经验是如果宽依赖之后的 RDD 会被多次使用缓存之后的如果宽依赖之前的 RDD 会被多次使用缓存之前的。更常见的情况是一个 RDD 经过宽依赖 Shuffle 后得到的结果会被多个下游操作使用这时候缓存 Shuffle 后的结果最划算因为避免了重复 Shuffle。但要注意缓存会占用内存。如果数据量太大缓存会导致频繁 GC 甚至 OOM。这时候可以考虑用persist(StorageLevel.MEMORY_AND_DISK_SER)把数据序列化后存内存内存不够时溢写到磁盘。序列化会降低访问速度但节省空间适合数据量大且内存有限的场景。6.3 分区数设置的玄学与科学分区数设置是个老生常谈的问题但很多人还是凭感觉。我的经验是分场景输入阶段分区数应该和 HDFS 块数对齐通常每个块一个分区这样每个 Task 处理一个块避免跨节点读取Shuffle 阶段分区数应该是集群核心数的 2 到 3 倍保证每个核心都有活干且不会太碎输出阶段如果输出到 HDFS分区数应该和块大小匹配避免产生大量小文件。另外spark.default.parallelism这个参数控制默认分区数如果不设置Spark 会根据集群规模自动推断。但在实际生产中我建议显式设置避免不同环境行为不一致。设置的值可以参考集群总核心数乘以 2 到 3。6.4 我踩过的一个真实坑连续宽依赖导致的雪崩有一次我写了一个作业逻辑是读取数据 → 按用户聚合 → 按商品聚合 → join 两个结果 → 排序输出。代码写得很顺但跑起来特别慢。分析后发现按用户聚合和按商品聚合是两个独立的宽依赖它们之间没有依赖关系但 Spark 默认会串行执行它们因为都在同一个作业里。更糟的是join又是一个宽依赖导致三个 Shuffle 串在一起每个都要等前一个完成。优化方案是把两个聚合操作拆成两个独立的作业并行提交或者用union合并后再聚合。但更根本的问题是我在join之前没有对数据进行过滤和聚合导致 Shuffle 的数据量非常大。后来我在每个聚合之后加了filter去掉低频数据Shuffle 数据量降了 60%耗时从 40 分钟降到 15 分钟。这个坑给我的教训是写代码时要时刻问自己这个操作会不会产生 Shuffle如果会能不能把数据量先降下来宽依赖不可怕可怕的是在数据量最大的时候做宽依赖。6.5 关于宽窄依赖我常被问到的几个问题问题一窄依赖一定比宽依赖快吗不一定。窄依赖本身很快但如果窄依赖链很长每个算子都有计算开销累积起来也可能很慢。而且窄依赖不触发 Shuffle但如果数据量太大单个分区的计算压力也会很大。所以关键不是依赖类型本身而是数据量和计算复杂度。问题二能不能把所有宽依赖都消除不能。宽依赖是分布式计算的本质需求——当你需要按 key 重新组织数据时就必然涉及跨节点传输。你能做的是减少宽依赖的数量和数据量而不是完全消除。问题三Stage 越多越慢吗不一定。Stage 多意味着 Shuffle 多但每个 Stage 的数据量可能很小。如果 Stage 少但每个 Stage 数据量巨大反而更容易 OOM 和倾斜。关键是看总 Shuffle 数据量和各 Stage 的数据分布。问题四怎么判断一个算子是不是宽依赖最简单的判断方法看这个算子是否需要知道所有分区的数据才能计算当前分区的结果。如果需要就是宽依赖如果只需要当前分区的数据就是窄依赖。比如map只看当前记录是窄依赖reduceByKey需要把所有相同 key 的记录拉到一起是宽依赖。7. 把宽窄依赖变成你的直觉写了这么多核心其实就一句话窄依赖是流水线宽依赖是屏障。窄依赖让数据在 Stage 内顺畅流动宽依赖强制数据重新分布并划分 Stage 边界。你写下的每一行 Transformation 代码都在悄悄决定 Spark 会怎么切分你的作业、会产生多少次 Shuffle、会消耗多少资源。我的建议是下次写 Spark 作业时先别急着提交。在纸上或者脑子里把 RDD 的依赖链画出来标出哪些是窄依赖、哪些是宽依赖数一数有几个 Stage。然后问自己这些宽依赖都是必须的吗能不能提前过滤或聚合来减少 Shuffle 数据量分区数设置合理吗有没有数据倾斜的风险养成这个习惯之后你会发现很多性能问题在写代码阶段就能避免而不是等到跑起来才发现。最后分享一个我常用的检查清单提交作业前过一遍宽依赖操作之前有没有做过滤或局部聚合两个大表 join 时有没有考虑广播小表分区数是否合理每个分区 100MB 到 200MB被多次使用的 RDD 有没有缓存有没有连续多个宽依赖可以合并或拆分的Spark UI 上的 Shuffle 数据量和 Task 耗时分布是否正常这套方法帮我省下了大量调优时间希望对你也有用。宽窄依赖不是什么高深的理论但把它变成直觉之后你写 Spark 代码的质量会有质的飞跃。