ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spark宽窄依赖与Stage划分:从原理到性能优化实战

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和filter,Spark 不会先把所有数据 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、repartition
Stage 划分影响不触发 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 与 ResultStage

Spark 中的 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。所以最终有两个 Stage:Stage 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/O:Shuffle Write 阶段,每个 Map Task 会把输出按目标分区写到本地磁盘,产生大量小文件。第二是网络传输:Shuffle Read 阶段,Reduce Task 需要从多个 Map Task 所在的节点拉取数据,跨节点传输受带宽限制。第三是内存压力:拉取过来的数据需要缓存在内存中做排序和聚合,如果数据量大,会频繁溢写到磁盘,进一步拖慢速度。

我做过一个粗略的测试:同样一份 10GB 的日志数据,做一次groupByKey聚合,Shuffle Write 大约产生 8GB 的中间数据,Shuffle Read 也是 8GB 左右,整个作业耗时 12 分钟。而如果把逻辑改成先用reduceByKey做局部聚合再全局聚合,Shuffle 数据量降到 2GB 左右,耗时缩短到 4 分钟。差距就是这么明显。

4.2 窄依赖链的流水线优化:为什么 map+filter 比你想的快

窄依赖链中的多个算子会被 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 条记录。用groupByKey,Shuffle Write 要写 1 亿条记录;用reduceByKey,Map 端局部聚合后每个分区内每个 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.partitions(DataFrame API)或rdd.repartition(n)/rdd.coalesce(n)来调整。

repartition是宽依赖操作,会触发全量 Shuffle;coalesce是窄依赖操作,只能减少分区数,不能增加,而且不会触发 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 代码的质量会有质的飞跃。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进