3. RDD核心原理与算子体系
RDD(Resilient Distributed Dataset)是Spark最基础的数据抽象。面试中围绕RDD的提问往往从“五大属性”切入,再延伸到依赖关系、算子分类和惰性求值。如果只背概念,很容易在追问“为什么这样设计”“这段代码会生成几个Stage”时卡壳。本节按“属性→依赖→算子→执行”的顺序,把原理和面试高频辨析串起来。
一、RDD五大属性:不是背诵,而是理解它为什么能容错
任何一个RDD都具备五个核心属性,它们共同支撑了Spark的分布式计算与容错能力:
- 分区列表(Partitions):数据被切分成若干分区,每个分区是计算的最小并行单元。分区数决定Task数量,也决定并行度。
- 分区计算函数(Compute):给定一个分区,如何计算出该分区的数据。这是RDD“可计算”的本质。
- 依赖关系(Dependencies):当前RDD依赖哪些父RDD。依赖关系决定了血缘(Lineage),也决定了Stage如何划分。
- 分区器(Partitioner):仅对Key-Value类型的RDD有效,决定数据按Key如何分区,常见有HashPartitioner和RangePartitioner。
- 首选位置(Preferred Locations):每个分区优先在哪些节点计算,用于数据本地性调度,减少网络传输。
面试常问:“RDD为什么能容错?”答案不是“因为它有副本”,而是“因为它记录了血缘”。当某个分区丢失时,Spark可以根据依赖关系重新计算该分区,而不需要像HDFS那样保存多份数据。这也解释了为什么RDD是不可变的:只有不可变,血缘才能被稳定地重放。
二、宽窄依赖:Stage划分的分水岭
依赖关系分为两类,这是面试中区分“会写代码”和“懂执行”的关键点:
| 对比项 | 窄依赖(Narrow Dependency) | 宽依赖(Wide Dependency / Shuffle Dependency) |
|---|---|---|
| 分区对应关系 | 父RDD的每个分区最多被子RDD的一个分区使用 | 父RDD的每个分区可能被子RDD的多个分区使用 |
| 典型算子 | map、filter、flatMap、union、mapPartitions | groupByKey、reduceByKey、join(非co-partitioned)、repartition |
| 是否产生Shuffle | 否 | 是 |
| 容错代价 | 只重算丢失的分区 | 需要重算多个父分区,代价高 |
| 对Stage的影响 | 同一Stage内可流水线执行 | 划分Stage的边界 |
判断依据很简单:看父RDD的一个分区是否只流向子RDD的一个分区。如果是,就是窄依赖;如果父分区被多个子分区“瓜分”,就是宽依赖。宽依赖意味着必须等待所有父分区数据就绪才能开始Shuffle,因此Spark以宽依赖为界划分Stage。
三、Transformation与Action:惰性求值的两条边界
Transformation是“定义计算”,Action是“触发计算”。这是Spark惰性求值机制的核心。
- Transformation:map、filter、flatMap、reduceByKey、groupByKey、join、repartition等。它们返回一个新的RDD,但不会立即执行。
- Action:count、collect、take、first、reduce、foreach、saveAsTextFile等。它们触发Job提交,返回非RDD结果或写出数据。
惰性求值的价值在于:Spark可以在Action触发时,把多个Transformation合并成一个DAG,进行优化后再执行。例如下面这段代码:
val rdd = sc.textFile("hdfs:///data/access.log")
val filtered = rdd.filter(_.contains("ERROR"))
val mapped = filtered.map(line => (line.split(" ")(0), 1))
val result = mapped.reduceByKey(_ + _)
result.collect()
前四行只是构建DAG,直到collect()才真正提交Job。面试中常问:“如果去掉collect(),程序会执行吗?”答案是:不会,因为没有Action触发。
四、高频算子辨析:面试中最容易踩的坑
以下三组算子是面试高频考点,必须能说清区别和适用场景。
1. map vs mapPartitions
map对每条记录调用一次函数;mapPartitions对每个分区调用一次函数,函数接收一个迭代器。当需要在每条记录处理前建立昂贵资源(如数据库连接)时,用mapPartitions可以避免每条记录都创建连接。但要注意:mapPartitions一次处理整个分区,如果分区数据量大,容易OOM。
// map:每条记录一次
rdd.map(x => x * 2)
// mapPartitions:每个分区一次,适合批量操作
rdd.mapPartitions(iter => iter.map(_ * 2))
2. reduceByKey vs groupByKey
两者都能按Key聚合,但执行方式完全不同。groupByKey会把所有相同Key的Value拉到一起形成迭代器,Shuffle数据量大,容易OOM。reduceByKey会在Map端先做本地聚合(Combiner),再Shuffle,网络传输量大幅减少。面试标准回答:能用reduceByKey就不用groupByKey,除非确实需要拿到某个Key的全部Value列表。
// 推荐:Map端预聚合
rdd.reduceByKey(_ + _)
// 不推荐:全量Shuffle
rdd.groupByKey().mapValues(_.sum)
3. coalesce vs repartition
coalesce默认不产生Shuffle,用于减少分区数,适合过滤后数据量变小的情况。repartition会产生Shuffle,可以增加或减少分区,适合需要重新均衡数据的场景。面试追问:“coalesce能增加分区吗?”答案:默认不能,因为不Shuffle就无法把数据重新打散;如果传入shuffle=true,则可以。
五、从代码到执行:一个完整的判断过程
面试中常给一段代码,问“有几个Stage、几个Task”。判断步骤如下:
- 找出所有Action,每个Action对应一个Job。
- 从Action反向追溯,遇到宽依赖就切分Stage。
- Stage内最后一个RDD的分区数就是Task数。
例如:
val rdd = sc.parallelize(1 to 100, 4)
val r1 = rdd.map(_ * 2)
val r2 = r1.filter(_ > 10)
val r3 = r2.reduceByKey(...) // 假设r2是KV类型
val r4 = r3.mapValues(_ + 1)
r4.count()
这里reduceByKey是宽依赖,因此划分为两个Stage:Stage0执行map和filter,Stage1执行reduceByKey和mapValues。Task数分别为4和默认分区数(通常等于父RDD分区数或由Shuffle决定)。
六、可直接使用的面试速查清单
以下清单可用于面试前快速复盘,也可作为回答时的结构参考:
| 问题 | 回答要点 |
|---|---|
| RDD五大属性 | 分区列表、计算函数、依赖关系、分区器、首选位置 |
| 宽窄依赖区别 | 父分区是否只对应一个子分区;宽依赖产生Shuffle,是Stage边界 |
| Transformation与Action | Transformation返回RDD、惰性;Action触发Job、返回非RDD结果 |
| reduceByKey与groupByKey | reduceByKey有Map端预聚合,Shuffle数据量小;groupByKey全量Shuffle |
| coalesce与repartition | coalesce默认不Shuffle、减少分区;repartition有Shuffle、可增可减 |
| Stage划分依据 | 从Action反向追溯,遇宽依赖切分Stage |
掌握以上内容后,面对“RDD原理”类面试题,就能从属性讲到依赖,从算子讲到执行,形成一条完整的回答链路,而不是零散地背定义。