资讯动态

Spark期末复习:从RDD五大属性到算子与依赖机制全攻略

发布时间:2026/10/23 4:54:09 来源:尧图企业网站定制
期末周的钟声一响后台总有人问同一个问题Spark 那么多知识点到底从哪里开始复习我的答案一直很固定——先把 RDD 这张网拉起来。原因很简单RDD 贯穿 Spark 课程的每一个角落五大属性撑起概念题算子和依赖撑起简答题编程题几乎都是 RDD 算子组合出来的套卷。很多人复习时容易犯一个毛病捧着算子 API 列表死记背了一堆名词真考到reduceByKey 为什么通常比 groupByKey 快这样带一点原理的题反而答不出有层次的答案。这篇文章按我当年准备期末的思路把 RDD 的底层逻辑、算子分界、依赖机制、经典练习题和易错细节串成一条线尽量用考场上的答法来讲适合马上要考 Spark 期末、或者想快速拿到一份 RDD 浓缩复习资料的同学。1. 摸底第一步先把 RDD 的“弹性、分布式、数据集”拆开讲清楚1.1 五大属性就是你的答题素材库RDD 全称 Resilient Distributed Dataset弹性分布式数据集。期末概念题最常见的一种问法就是让你解释这三个词。这里不要只翻译完就停笔老师想听到的是你真正理解了三层含义第一层是“分布式”数据被切成若干分区分散到集群不同节点上每个节点只处理自己那一份第二层是“弹性”主要指存储上的内存磁盘自动切换、计算失败后能按血缘重算第三层是“数据集”它本质是一个逻辑集合抽象底层可能对应 HDFS 文件、本地文件或内存集合。RDD 源码里其实只暴露了五个核心方法每一个都对应一套机制这五个方法可以说是期末简答题的万能素材库第一分区列表。一个 RDD 由多个分区组成分区是并行计算的最小单元数据如何切分由分区决定。第二计算函数。每个分区需要通过 compute 函数算出内容体现在对父 RDD 分区的转换逻辑上。第三依赖列表。它记录当前 RDD 从哪些父 RDD 而来构成血缘关系。第四分区器。只有键值对型 RDD 有决定 key 被分到哪个分区。第五优先位置列表。Spark 根据它把任务调度到数据所在的节点上实现移动计算而不是移动数据。用个生活化的类比RDD 像一张施工图分区列表是施工地块清单计算函数是每个地块的施工方案依赖列表是材料来源清单分区器是建材分配规则优先位置是建材堆放点。五个信息齐了才能既知道干什么、也知道怎么干、还知道干坏了找谁重新领料。1.2 “弹性”是三个具体机制不是一个形容词很多同学背了“弹性分布式数据集”七个字但被问到“弹性体现在哪里”就卡住了。弹性不是抽象的形容词它至少对应三个具体机制。第一个机制是存储弹性RDD 的持久化可以选择只放内存也可以选择内存不足时溢写到磁盘比如 MEMORY_AND_DISK 这个持久化级别就是“优先内存放不下就落磁盘”。第二个机制是容错弹性某个分区计算失败或丢了不需要整个程序重来Spark 会沿着血缘关系找到父 RDD只重算丢失的那条链路。第三个机制是计算弹性同一个 RDD 可以被反复使用也可以根据资源情况调整分区数量任务调度可以跟随数据位置的改变而改变。期末如果出“RDD 相比普通分布式数据集的优势是什么”你把这些机制写出来比单纯默写定义要得分得多。尤其是“通过血缘实现容错而不需要真做副本”这一点是 RDD 设计的灵魂也是很多后续概念的基础。1.3 分区数量决定并行度但别把分区数等同于任务数关于分区期末至少有三种考法一问分区的作用二问分区数怎么来的三问分区数怎么调。先说作用分区数量决定 RDD 的并行度因为一个分区对应一个计算任务的最小处理单位理论上有多少个分区就可以有多少个并行任务。但这里有个容易翻车的地方分区数不等于实际并行任务数。实际并行度还受 executor 个数和每个 executor 的可用核心数约束。比如一个 RDD 有 100 个分区但集群只有 10 个可用的 CPU 核心那同一时刻最多只能有 10 个任务在跑只是这 10 个任务可以逐个换分区继续执行。所以答题时要说“分区数是并行度的上限因素之一实际并行度由可用核心数共同决定”。分区数的来源也要记住用 spark.parallelize 创建集合时可以直接指定分区数用 textFile 读 HDFS 文件时默认情况下每个文件块大致对应一个分区也可以通过 spark.default.parallelism 参数调整默认并行度。如果你想增大并行度常见做法是重新分区或者直接调高 spark.default.parallelism这在选择题里已经出现过很多次。2. 算子考点的分界线先分清“动手的”和“动嘴的”2.1 转化算子四大阵营速查Spark 的算子数量看起来很多但期末真正高频考核的转化算子就那二十多个按用途可以分成四大阵营这样记忆负担小很多。映射类map、flatMap、mapValues、mapPartitions。这类算子的特点是元素级或分区级变形重点考 map 和 flatMap 的区别、mapPartitions 与 map 的开销差异。过滤类filter、distinct、sample。filter 只保留满足条件的元素distinct 去重sample 按比例抽样各自都是窄依赖或宽依赖的经典例子。分组聚合类groupByKey、reduceByKey、aggregateByKey、combineByKey、foldByKey。这是期末的绝对重点几乎所有性能对比题都从这里出。分区调整类coalesce、repartition、partitionBy。考的是“是否发生 shuffle”和“什么时候用哪个”这类问题。用表格整理一下常见转化算子类别代表算子高频考点映射类map、flatMap、mapValues、mapPartitionsmap 与 flatMap 返回值差异mapPartitions 批量初始化开销更低过滤类filter、distinct、sampledistinct 需要 shufflefilter 不改变分区结构分组聚合类groupByKey、reduceByKey、aggregateByKey是否预聚合复合状态如何维护分区调整类coalesce、repartition、partitionBy是否使用 shuffle扩大分区只能用 repartition集合类union、intersection、subtract、cartesian依赖类型判断union 窄、cartesian 宽2.2 行动算子不触发执行前面的活都白干转化算子和行动算子最大的区别是前者返回 RDD后者不返回 RDD而是返回结果、打印数据或保存文件。那期末怎么快速判断一个算子到底是转化还是行动我常用一个很直接的技巧只要返回值是 RDD它就是转化算子返回值是普通对象、集合、或者写了文件它就是行动算子。行动算子需要熟到能默写collect、count、take、first、reduce、fold、aggregate、foreach、saveAsTextFile、countByKey、takeOrdered。其中 collect 是把全量数据拉到 driver 端并返回列表take(n) 是只取前 n 个reduce 是在分区内先算、再把分区结果汇总foreach 是对每个元素执行一段函数而不返回结果。有一个常见误区要提前避雷foreach 虽然看起来像 map但它的返回值是 Unit属于行动算子。很多同学在判断题里把 foreach 当转化算子这是白送分丢得最可惜的一种。2.3 惰性机制、DAG 与上机调试的路径Spark 的惰性机制是 RDD 最反直觉、也最常考的特性。转化算子只是把计算步骤记下来形成一个“待办清单”并不会立刻读取数据只有遇到行动算子Spark 才会把整条链路上的步骤提交执行。这个性质和“点菜单”很像你列了一长串菜名厨房不会开工等你真正下单的那一瞬间所有菜才会开始做。上机时最容易踩的坑就是写了一段转换逻辑然后直接 print。比如from pyspark import SparkContext sc SparkContext(local, Demo) rdd sc.parallelize([1, 2, 3, 4, 5]) filtered rdd.map(lambda x: x * 2).filter(lambda x: x 4) print(filtered) # 这里不会执行任何计算只会打印出一个 RDD 对象 print(filtered.collect()) # 到这里才开始真正计算并返回 [6, 8, 10]期末简答题如果问“为什么我的 map 结果没有输出”大概率就是在考惰性机制。更完整的说法是转化算子构建 DAG 的有向无环图行动算子触发 DAG 调度执行调度器会在宽依赖处切分 stage。理解了这条链路后面做练习题的时候你就知道为什么每个 RDD 都要在最后用 collect 或 take 才能看到东西了。3. 简答题重灾区窄宽依赖、shuffle、持久化与检查点3.1 用“一个父亲被几个孩子使用”判断依赖类型窄依赖和宽依赖是 Spark 面试和期末都绕不开的考点。定义不复杂窄依赖指父 RDD 的每个分区最多被子 RDD 的一个分区使用宽依赖指父 RDD 的一个分区会被子 RDD 的多个分区使用一般伴随 shuffle。我自己的判断方法更简单看父分区里的数据需不需要被拆分成多份分发给下游不同的分区。如果需要按 key 重新分组、排序、汇总那基本都是宽依赖。举几个经典例子map、filter、union 是窄依赖因为每个父分区只管生成自己的子分区数据不跨节点乱跑groupByKey、reduceByKey、sortByKey 是宽依赖因为相同 key 的数据可能原本散落在不同父分区必须经过网络重组;默认的 join 通常也是宽依赖因为两个 RDD 的相同 key 数据要先经过 shuffle 才能聚到同一分区。有个细节容易被误判coalesce 在不启用 shuffle 时是窄依赖它只是把相邻分区原地合并repartition 因为强制 shuffle是宽依赖。一旦题目里写“coalesce(10, shuffleFalse)”你要立刻反应出它不会发生 shuffle。3.2 shuffle 慢在哪里stage 边界为什么画在这里简答题若考“为什么 shuffle 是 Spark 的性能瓶颈”很多人只会写“因为要网络传输”。能拿高分的答案至少包含四个环节上游 map 端要把结果写到本地磁盘这个过程涉及落盘和序列化下游 reduce 端要通过网络拉取数据产生节点间 IO拉过来的数据还要反序列化并按 key 聚合排序最后还有大量临时文件的清理。任何一步在大数据量下都可能放大所以 shuffle 是高性能计算的敌人不是简单一句“传输慢”能概括的。为什么要画 stage 边界因为 stage 内部的算子可以流水线执行不落盘、不 shuffle而宽依赖是天然的切分点Spark 必须在这里把数据打乱重构。所以描述调度时你可以说窄依赖尽可能放在同一个 stage 内串联宽依赖处拆出新 stage。这个点几乎每年都会出现在简答题或画图题里。3.3 cache/persist 与 checkpoint血缘到底切不切持久化相关题目期末最爱问“cache 和 checkpoint 的区别”。先记住各自特点。cache 本质是 persist 的一种特殊形式默认存储在内存懒执行在第一次 action 时把结果缓存下来。关键点是cache 不切断血缘。缓存一旦丢失Spark 照样可以根据血缘重算所以它更像一个“加速器”。checkpoint 不一样它会额外触发一个 job把 RDD 的数据写到可靠存储比如 HDFS并且会切断血缘。数据丢了以后直接读检查点文件恢复不需要再从最初的父 RDD 一路算回来。用表格对比会很清楚比较维度cache / persistcheckpoint存储位置内存或本地磁盘可靠存储如 HDFS是否切断血缘否可重算是直接读文件恢复触发时机第一次 action 时懒执行会额外执行一次专门 job适用场景同一 RDD 被多次 action 复用血缘链很长重算代价极大还有一个经典坑要记住如果要 checkpoint 一个 RDD最好先把它 cache 一下否则 checkpoint 触发 job 时会把该 RDD 从头按血缘重新算一遍重复计算代价很高。这个细节在上机题里出现时最能让批卷老师觉得你是真的理解原理而不是背概念。4. 经典练习题实战把考点焊死在代码里4.1 题型一WordCount 变体——统计词频后还要过滤先来一道几乎每年都出现的 WordCount 变体。题干给定文本文件 poem.txt每行若干个英文单词用空格分隔。请统计每个单词出现次数并输出出现次数不少于 2 的单词及次数。from pyspark import SparkContext sc SparkContext(local, WordCount) lines sc.textFile(poem.txt) words lines.flatMap(lambda line: line.split( )) pairs words.map(lambda word: (word, 1)) counts pairs.reduceByKey(lambda a, b: a b) result counts.filter(lambda item: item[1] 2).collect() print(result)这道题的考点很密集。第一textFile 拿到的是行组成的 RDD必须把每一行拆成单词。split 后得到的是列表用 flatMap 才能把多个列表展平成单词流如果这里误用 map得到的将是 RDD[list]后续没法直接对单词做 key 聚合。第二reduceByKey 自带 map 端预聚合shuffle 量小。第三最后一层 filter 是“变体”的关键很多同学会漏掉这个条件。如果换成 groupByKey 写代码是这样的counts words.map(lambda word: (word, 1)).groupByKey().mapValues(len)语法没错、结果也对但性能差。为什么groupByKey 不会在每个分区内先做累加而是把所有相同 key 的 value 原封不动搬到同一个分区再一次性数出来。数据量大时网络开销会明显放大。期末编程题里看到简单的求和场景却用 groupByKey往往会被扣分。4.2 题型二map 和 flatMap 的分工题干有一个整数 RDD [1, 2, 3]希望得到每个整数和它的平方依次展开也就是 [1, 1, 2, 4, 3, 9]。请分别用 map 和 flatMap 实现并说明两者输出有什么不同。rdd sc.parallelize([1, 2, 3]) map_result rdd.map(lambda x: [x, x * x]).collect() flat_result rdd.flatMap(lambda x: [x, x * x]).collect() print(map_result) # [[1, 1], [2, 4], [3, 9]] print(flat_result) # [1, 1, 2, 4, 3, 9]区别就在返回结构的层数。map 对每个元素执行函数后把函数返回值作为一个整体放进新的 RDD所以每个元素对应一个 list结果是“嵌套列表”。flatMap 先把每个元素展开成若干个元素再把这些元素全部摊平到同一个层级所以结果是“一维列表”。考场速记法map 输出元素个数和输入个数相同只是值可以变化flatMap 输出元素个数可以增加而且会被压平。拆单词、拆字符、展平嵌套结构这场景一律优先 flatMap。4.3 题型三reduceByKey 与 groupByKey 对比题怎么答满题干有一个键值对 RDD内容是 (k, v)现在要按 key 求和。请分别用 reduceByKey 和 groupByKey 实现并解释差异说明优先选哪个。rdd sc.parallelize([(a, 1), (b, 2), (a, 3), (b, 5), (a, 8)]) by_reduce rdd.reduceByKey(lambda a, b: a b).collect() by_group rdd.groupByKey().mapValues(lambda vals: sum(vals)).collect()简答的答题要点至少三条第一reduceByKey 在 shuffle 之前会先在每个分区内对相同 key 做一次本地聚合相当于提前合并了局部结果第二groupByKey 不会做任何预聚合它把每个 key 的全部 value 通过网络搬到同一个分区再做后续处理shuffle 的数据量明显更大第三本题只是求和两者结果等价但性能上 reduceByKey 明显占优网络 IO 更小也更不容易把一个分区撑爆。如果题目改成求每个 key 的平均值这时候只有当 value 的类型是自定义对象或者你想同时维护“总和”和“个数”两个状态再考虑用 aggregateByKey 或 combineByKey。不要背成一个结论“永远不能用 groupByKey”正确的说法是“能用预聚合算子解决问题时尽量别用 groupByKey”。4.4 题型四coalesce 与 repartition 的取舍题干当前 RDD 有 200 个分区想减少到 10 个分区再保存到磁盘。应该选 coalesce 还是 repartition为什么如果反过来想从 10 个分区扩大成 200 个分区呢rdd.coalesce(10).saveAsTextFile(hdfs://.../out) # rdd.repartition(200).saveAsTextFile(hdfs://.../out)第一问选 coalesce。因为它默认 shuffleFalse直接把相邻分区合并不做全量网络重排效率高适合从多分区降到少分区。第二问选 repartition或者写 coalesce(200, shuffleTrue)。因为从少到多必须重新洗牌不 shuffle 就无法凭空增加真正独立的分区。有个关联考点无论 coalesce 还是 repartition 改变分区后如果这个 RDD 后续会被多个 action 反复使用建议马上 persist。否则每个 action 都会再次执行重分区操作白做一次 shuffle。这个细节在“为什么我的程序跑得很慢”的排查题里经常出现。4.5 题型五TopN 的“局部聚合 全局聚合”题干已经得到词频统计结果 counts类型是 RDD[(String, Int)]现在要找出出现次数最高的前 3 个单词。先写一个简单能跑的版本再写一个数据量很大时更优的版本。简单版本top3 counts.sortBy(lambda kv: kv[1], ascendingFalse).take(3)这个版本没毛病但 sortBy 是全局排序会把整个 RDD 的所有数据在网络里洗一遍只为拿前 3 个成本很高。数据量大时更优的做法是先做局部 TopN再做全局 TopNlocal_top counts.mapPartitions( lambda it: sorted(it, keylambda kv: kv[1], reverseTrue)[:3] ) global_top local_top.sortBy(lambda kv: kv[1], ascendingFalse).take(3)这里一定要理解“局部聚合 全局聚合”这个套路。每个分区里只保留这个分区的前 3分区之间互不干扰然后把这批“各分区的局部前三”再汇集起来做一次全局排序。shuffle 的数据量从全量数据锐减到“分区数 × 3”个性能提升非常明显。在 mapPartitions 里初始化连接对象、窗口对象等也是同一个思想期末考试如果出现优化题这个思路能拿印象分。4.6 题型六平均数题目用 aggregateByKey 维护复合状态题干RDD 中有 (姓名, 分数) 这样的键值对要求计算每个人的平均分用 aggregateByKey 实现并解释 zeroValue、seqOp、combOp 的作用。rdd sc.parallelize([ (Alice, 90), (Bob, 80), (Alice, 100), (Bob, 60) ]) def seq_op(acc, score): return (acc[0] score, acc[1] 1) def comb_op(acc1, acc2): return (acc1[0] acc2[0], acc1[1] acc2[1]) avg rdd.aggregateByKey((0, 0), seq_op, comb_op).mapValues( lambda t: t[0] / t[1] ) print(avg.collect())为什么不能用 reduceByKey 简单解决因为平均值需要同时维护“总分”和“人数”两个信息一个简单 reduce 函数只能合并一个值。aggregateByKey 把每个 key 的聚合状态定义成一个二元组 (总分, 人数)。zeroValue 是每个分区聚合的初始状态seqOp 负责分区内把一个新分数合并进状态combOp 负责把两个分区的状态合并起来。这里的关键理解seqOp 在每个分区内部执行combOp 在跨分区的 shuffle 阶段执行。如果分区内先把自己人的总分和人数加好了shuffle 时传输的只是每个分区每个 key 的一个状态而不是这个 key 的所有原始分数。这就是为什么 aggregateByKey 比 groupByKey 更适合做复杂聚合。期末如果考 combineByKey思路完全一样只是参数写法不同。4.7 题型七join 场景下如何减少 shuffle题干有两个较大的键值对 RDDuserRDD 是 (userId, userInfo)orderRDD 是 (userId, orderInfo)要按 userId 做 join。数据量大join 很慢请给出至少两种优化思路。第一种思路是“共同分区”让两个 RDD 使用相同的分区数和分区函数重新分区再 join。userRDD userRDD.partitionBy(100, lambda k: hash(k) % 100).persist() orderRDD orderRDD.partitionBy(100, lambda k: hash(k) % 100).persist() joined userRDD.join(orderRDD)为什么有效当两个 RDD 的分区器一致、分区数一致时相同 userId 已经保证落在相同分区join 阶段就不需要再对这两个 RDD 做全量 shuffle可以直接在分区内匹配。但要注意partitionBy 本身会产生一次 shuffle而且 partition 后的结果如果不 persist每次 action 都会重新洗牌所以代码里必须加 persist。第二种思路是“广播小表”如果其中一个 RDD 很小把它的数据收集成字典并广播到各 executor大表只做 map 操作完全避免分布式 join 的 shuffle。这个思路的本质是“用广播变量换掉网络洗牌”。考试时写出思路比写完整代码更重要如果能说出“两阶段先广播小表再 map 端用字典匹配”已经能拿到大部分分。4.8 题型八综合日志清洗题题干本地日志 sales.log每行格式为“日期,商品,金额”例如 2024-12-01,手机,2999。请统计每种商品的销售总额并输出销售额最高的 3 种商品。lines sc.textFile(sales.log) fields lines.map(lambda line: line.split(,)) valid fields.filter(lambda f: len(f) 3) sales valid.map(lambda f: (f[1], float(f[2]))) total sales.reduceByKey(lambda a, b: a b) top3 total.sortBy(lambda kv: kv[1], ascendingFalse).take(3) for item in top3: print(item)这道题把清洗、转换、聚合、排序全部串起来了。需要注意的坑有三个一是金额字段如果在原文件里是字符串必须 float 转换否则两个“2999”字符串会被拼成“2999.02999.0”整道题直接错二是脏数据行要先用 filter 兜底这也是编程题里常见的“加分动作”三是最终结果用 take 而不是 collect体现你没有把全量数据拉到 driver 的坏习惯。如果题目再加一层要求比如“统计每天的每种商品销售额”只需要把 map 阶段的 key 从 f[1] 改成 (f[0], f[1])变成复合 key 聚合思路是一样的。这种扩展在考场上经常出现属于“换汤不换药”。5. 考前必看的易错清单与速记表5.1 别把 collect 当打印工具collect 的作用是把整个 RDD 的数据从集群拉到 driver 端转换成数组返回。在小数据集上它是调试神器但数据量一旦大起来driver 内存会直接被压爆。期末上机和实际项目里都默认遵守一个原则只看前几条用 take(n)需要输出到文件用 saveAsTextFile只有明确需要把全量数据拿到驱动端做后续处理时才用 collect。如果题目问“这段代码有什么问题”而代码里有 rdd.collect() 且 rdd 可能很大你就要敏锐地指出它可能导致 driver 内存溢出建议改 take 或抽样。5.2 同一个 RDD 被多个 action 使用却忘了缓存惰性机制带来的一个连锁问题就是重复计算。一个 RDD 没有缓存却被后续 3 个行动算子分别引用Spark 会沿着血缘把从源头到它的整条计算链重新执行 3 遍。这在代码逻辑上不报错但运行时间会成倍增加。上机调试时我见过不少同学的程序“越跑越慢”就是因为中间结果没有被缓存。解决办法很简单确认某个 RDD 会被多次复用就在它后面加 .cache() 或 .persist(StorageLevel.MEMORY_AND_DISK)。但也要注意持久化不是越多越好如果 RDD 只被用一次缓存反而浪费资源。5.3 闭包序列化集群模式下最容易爆的错在算子函数里引用外部变量时Spark 需要把整个闭包序列化后发给 executor。如果闭包里捕获了不可序列化的对象运行时会直接报 Task not serializable 或类似的序列化异常。期末改错题里常会故意在 map 函数里用到一个驱动端定义的大对象或普通类实例让你找问题。正确思路是函数内部尽量只依赖传入参数或者把外部大对象用广播变量分发必须捕获变量时尽量选择可序列化的基本类型和字符串。能意识到“算子里的代码运行在远端不是运行在本地”这类题就不会丢分。5.4 期末高频考点速记表考点考场答法常见失分点RDD 概念分布式 弹性存储/容错 数据集 五大属性只翻译全称不给机制reduceByKey 与 groupByKey预聚合 vs 不预聚合shuffle 量差异只说“reduceByKey 更快”却不解释窄依赖与宽依赖父分区被子分区复用情况 是否 shuffle误判 union 或 coalesce惰性机制转化算子不执行行动算子触发以为 print RDD 能出数据持久化cache 懒执行不切血缘checkpoint 切血缘把两者混为一谈coalesce 与 repartitioncoalesce 默认不 shufflerepartition 强制 shuffle认为减少分区也一定需要 shuffle最后分享一个我自己的备考习惯考前最后一晚与其再翻一遍算子表不如拿张白纸从 RDD 五大属性画到 DAG 调度主线就是“分区 → 算子转换 → 宽依赖切 stage → shuffle → action 触发”再把 reduceByKey/groupByKey、cache/checkpoint、coalesce/repartition 这三组对比默写一遍。这三组如果闭眼都能写清楚RDD 这块的简答题基本不会慌。这个方法帮我稳过期末也帮我后面做实际任务时少踩了很多坑希望也能帮你把 Spark 的最后一轮复习理顺。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑