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”。判断步骤如下:

  1. 找出所有Action,每个Action对应一个Job。
  2. 从Action反向追溯,遇到宽依赖就切分Stage。
  3. 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原理”类面试题,就能从属性讲到依赖,从算子讲到执行,形成一条完整的回答链路,而不是零散地背定义。