Spark RDD 五大特性及弹性分布式容错原理深度剖析
SparkCore 之 Spark RDD 五大特性及弹性分布式容错原理剖析
摘要:深入 RDD 源码级五大特性(Partitions、Compute、Dependency、Partitioner、PreferredLocations),系统拆解 Lineage 血统机制、窄宽依赖计算图、Partition 并行模型和 Checkpoint 容错策略。配有 4 张原创架构图、源码对照和完整代码示例。面向 Java、大数据及 AI 开发工程师。
一、RDD 的设计哲学:为什么需要 RDD?
2012 年,Matei Zaharia 在 NSDI 发表 RDD 论文时,大数据世界还在被 Hadoop MapReduce 主宰。MR 的核心问题不是"算得慢",而是迭代计算时中间结果强制写 HDFS,磁盘 I/O 成了不可逾越的瓶颈。
RDD 的设计回答了三个问题:
| 问题 | MR 的答案 | RDD 的答案 |
|---|---|---|
| 数据怎么分区? | HDFS Block(128MB) | Partition(用户可控) |
| 数据丢了怎么办? | HDFS 3 副本(空间换可靠性) | Lineage 重算(计算换可靠性) |
| 中间结果存哪里? | 强制写 HDFS 磁盘 | **内存优先 + 有选择持久化** |
RDD 论文的核心贡献在于:提出了一种既能容错、又避免数据复制的分布式内存抽象。
二、RDD 的五大特性(源码视角)
Spark 源码中 org.apache.spark.rdd.RDD 是一个抽象类,定义了五个核心方法。理解这五个方法,就理解了 RDD 的全部能力。
2.1 特性一:分区列表 — getPartitions
// RDD.scala 源码定义
protected def getPartitions: Array[Partition]
每个 RDD 由一组 Partition 组成,Partition 是 Spark 并行计算的最小单位。一个 Partition 对应一个 Task。
// 查看分区
val rdd = sc.parallelize(1 to 100, 4)
println(rdd.getNumPartitions) // 4
rdd.glom().collect() // Array[Array[Int]] — 每个内层Array是一个Partition
// HadoopRDD 的分区由 InputSplit 决定
val hdfsRDD = sc.textFile("hdfs://data/1G.txt")
// 分区数 ≈ HDFS Block 数量(1G/128M ≈ 8 个分区)
面试重点:分区数太少 → 并行度不足 → CPU 浪费。分区数太多 → Task 调度开销大 → 小文件问题。经验法则:分区数 = Executor Cores 总数的 2-3 倍。
2.2 特性二:计算函数 — compute
// RDD.scala 源码定义
def compute(split: Partition, context: TaskContext): Iterator[T]
每个 Partition 的计算逻辑。所有 Transformation 算子内部都是通过重写 compute 方法实现的。
// MapPartitionsRDD 的 compute 实现(简化)
override def compute(split: Partition, context: TaskContext): Iterator[U] = {
f(firstParent[T].iterator(split, context)) // f = map 函数
}
// 所以 map/filter/flatMap 的本质:
// map: 分区迭代器上逐元素应用映射函数
// filter:分区迭代器上逐元素应用过滤函数
// flatMap:分区迭代器上逐元素应用展开函数
设计精髓:compute 返回 `Iterator` 而非具体集合,意味着**数据可以流式处理**(惰性求值),不需要一次性把所有数据加载到内存。
2.3 特性三:依赖关系 — getDependencies
// RDD.scala 源码定义
protected def getDependencies: Seq[Dependency[_]]
这是整个 DAG 调度的理论基础。返回父 RDD 的依赖关系,分为两大类:
依赖类型层级:
Dependency (抽象类)
├── NarrowDependency (窄依赖)
│ ├── OneToOneDependency ← map / filter (1:1)
│ └── RangeDependency ← union (范围对应)
└── ShuffleDependency (宽依赖) ← reduceByKey / groupByKey / join
窄依赖与宽依赖的本质区别:
| 窄依赖 | 宽依赖 | |
|---|---|---|
| 数据移动 | 不跨节点 | 跨节点 Shuffle |
| Stage 边界 | **同一 Stage** | **Stage 边界** |
| 故障恢复 | 只重算丢失 Partition 的父 | 可能重算所有父 Partition |
| Pipeline | ✅ 内存 Pipeline 执行 | ❌ 磁盘 + 网络 |
// 窄依赖示例:textFile → map → filter(同一 Stage,Pipeline 执行)
val rdd = sc.textFile("data.txt") // HadoopRDD
.map(_.toLowerCase) // MapPartitionsRDD(窄依赖)
.filter(_.length > 5) // MapPartitionsRDD(窄依赖)
// 宽依赖示例:reduceByKey(Stage 边界)
val wordCounts = rdd
.flatMap(_.split("\\s+"))
.map((_, 1))
.reduceByKey(_ + _) // ShuffledRDD(宽依赖!Stage 切分点)
2.4 特性四:分区器 — Partitioner(可选)
// RDD.scala 源码定义
@transient val partitioner: Option[Partitioner] = None
仅对 Key-Value 类型的 RDD 有效(如 RDD[(K, V)]),决定了数据在 Shuffle 时如何分布到不同分区。
// HashPartitioner:key.hashCode % numPartitions(默认)
val rdd = sc.parallelize(Seq(("a",1),("b",2),("c",3)))
val partitioned = rdd.partitionBy(new HashPartitioner(3))
// 查看每个分区有哪些 key
partitioned.mapPartitionsWithIndex((idx, iter) =>
Iterator(s"Partition $idx: ${iter.map(_._1).mkString(",")}")
).collect()
// RangePartitioner:按 key 范围分区(sortByKey 使用)
// 先采样确定范围边界,保证分区均衡
val sorted = rdd.sortByKey()
partitionBy 的性能影响:
// ❌ 低效:每次 join 都触发 Shuffle
val rdd1 = sc.parallelize(data1).map((_, 1))
val rdd2 = sc.parallelize(data2).map((_, 1))
rdd1.join(rdd2) // Shuffle rdd1 + Shuffle rdd2 → 2 次 Shuffle
// ✅ 高效:预先 partitionBy,join 时避免 Shuffle
val rdd1 = sc.parallelize(data1).map((_, 1)).partitionBy(new HashPartitioner(10))
val rdd2 = sc.parallelize(data2).map((_, 1)).partitionBy(new HashPartitioner(10))
rdd1.join(rdd2) // co-partitioned → 0 次 Shuffle!
2.5 特性五:首选位置 — getPreferredLocations(可选)
// RDD.scala 源码定义
protected def getPreferredLocations(split: Partition): Seq[String] = Nil
Spark 调度优化的关键:返回每个 Partition 数据所在的节点列表,TaskScheduler 据此将 Task 调度到数据所在节点,避免跨网络读取数据。
// HadoopRDD 的 getPreferredLocations 实现
override def getPreferredLocations(split: Partition): Seq[String] = {
val inputSplit = split.asInstanceOf[HadoopPartition].inputSplit
// 返回 HDFS Block 所在的 DataNode 列表
inputSplit.value.getLocations
}
数据本地性优先级(从高到低):
| 级别 | 含义 | 网络开销 |
|---|---|---|
| `PROCESS_LOCAL` | 数据已在 Executor 进程内存中 | 0 |
| `NODE_LOCAL` | 数据在本地磁盘(HDFS DataNode 同节点) | 磁盘读取 |
| `RACK_LOCAL` | 数据在**同机架**其他节点 | 同机架网络 |
| `ANY` | 数据在任意节点 | 跨机架网络 |
三、弹性容错机制(分布式容错原理)
3.1 Lineage(血统)— 计算换可靠性
RDD 容错的核心不是数据备份,而是记录计算过程。
// 这段代码构建了一条血统链
val rddA = sc.textFile("hdfs://data/log.txt") // Lineage Step 0
val rddB = rddA.map(line => parseLog(line)) // Lineage Step 1
val rddC = rddB.filter(log => log.level == "ERROR") // Lineage Step 2
val rddD = rddC.map(log => (log.service, 1)) // Lineage Step 3
val rddE = rddD.reduceByKey(_ + _) // Lineage Step 4(宽依赖)
// 查看血统链
println(rddE.toDebugString)
// (2) ShuffledRDD[4] at reduceByKey
// +-(2) MapPartitionsRDD[3] at map
// +-(2) MapPartitionsRDD[2] at filter
// +-(2) MapPartitionsRDD[1] at map
// +-(2) HadoopRDD[0] at textFile
3.2 故障恢复流程详解
Executor 3 宕机 → Partition P_5 数据丢失
│
▼
DAGScheduler 检测到 Task 失败
│
▼
查找 P_5 所属 RDD 的血统链:
RDD D (MapPartitionsRDD) 的 P_5
← 依赖 RDD C 的 P_5(窄依赖,只需重算 C 的 P_5)
← 依赖 RDD B 的 P_5(窄依赖,只需重算 B 的 P_5)
← 依赖 RDD A 的 P_5(HadoopRDD,从 HDFS 重新读取)
若 RDD B 做了 cache → 直接从内存恢复,跳过回溯
若 RDD C 做了 checkpoint → 直接从 HDFS 读取,跳过全部回溯
// 故障恢复实战示例
val rdd = sc.textFile("hdfs://logs/").map(parseLine)
// 方案1:不做持久化 → 所有丢失分区从 HDFS 重新读取+计算
rdd.filter(_.error).count()
// 方案2:cache → 丢失分区从缓存恢复(内存中)
rdd.cache()
rdd.filter(_.error).count()
// 方案3:checkpoint → 丢失分区直接从 HDFS checkpoint 文件恢复
sc.setCheckpointDir("hdfs://namenode:8020/spark-checkpoint")
rdd.checkpoint()
rdd.count() // 先触发 checkpoint 写入
rdd.filter(_.error).count() // 后续直接读 checkpoint
3.3 窄依赖 vs 宽依赖的容错差异
| 场景 | 窄依赖故障恢复 | 宽依赖故障恢复 |
|---|---|---|
| 丢失 1 个 Partition | 重算**1 个**父 Partition | 可能需要重算**全部**父 Partition |
| 恢复代价 | O(1) | O(N) |
| 优化手段 | cache 该 Partition | **checkpoint 截断血统链** |
这就是为什么 Spark 官方建议对复杂 Shuffle 操作后做 checkpoint:
// 复杂迭代计算(如 PageRank 10 轮迭代)
var ranks = sc.textFile("graph.txt").map(parseEdge)
for (i <- 1 to 10) {
ranks = ranks.join(links).map(updateRank) // 每轮一个宽依赖
}
// 如果第 10 轮失败,从第 1 轮重算?→ 代价巨大!
// ✅ 正确做法:定期 checkpoint 截断血统
sc.setCheckpointDir("hdfs:///checkpoint")
for (i <- 1 to 10) {
ranks = ranks.join(links).map(updateRank)
if (i % 3 == 0) ranks.checkpoint() // 每 3 轮截断一次
}
四、弹性容错全链路对比
4.1 MapReduce 容错模型
数据 → HDFS 3副本写入 → Map Task处理 → 中间结果落盘(MR Shuffle 3次IO)
→ Reduce Task处理 → HDFS 3副本输出
故障恢复 = 从 HDFS 副本重新读取 → 重新执行 Task
优点:简单可靠
缺点:3倍存储开销 + 每次迭代写3副本 = 大量冗余I/O
4.2 Spark RDD 容错模型
数据 → HDFS 读取 → RDD Lineage记录 → 内存Pipeline传递 → 选择性cache
→ 必要时checkpoint(HDFS)
故障恢复 = 沿Lineage回溯到最近可恢复点(cache/checkpoint/数据源) → 重算
优点:零存储冗余 + 内存迭代快 + 可选择性持久化
缺点:长血统链故障恢复代价大(需checkpoint截断)
4.3 两种模型的量化对比(10 轮 Logistic Regression)
| MapReduce | Spark RDD (无checkpoint) | Spark RDD (有checkpoint) | |
|---|---|---|---|
| 磁盘写入次数 | 30 次(每轮 3 次) | 0 次(内存迭代) | 3 次(每 3 轮 checkpoint) |
| HDFS 副本数 | 30 × 3 = 90 份 | 0 | 3 × 3 = 9 份 |
| 故障恢复 RTO | 从 HDFS 副本读(分钟级) | 从头重算(分钟-小时) | 从最近 checkpoint 恢复(秒-分钟) |
| 总运行时间 | 基准线 | 10-100x 更快 | 10-100x 更快 |
五、五大特性联动 — 一次完整的作业执行
val conf = new SparkConf().setAppName("RDD-Demo")
val sc = new SparkContext(conf)
// ① Partitions: textFile 根据 HDFS Block 自动确定分区数
val rdd1 = sc.textFile("hdfs://data/logs") // 8个Partition
// ② Dependency: map 产生窄依赖 → 同 Stage Pipeline
val rdd2 = rdd1.map(parseLog) // NarrowDependency
// ② Dependency: filter 产生窄依赖 → 同 Stage Pipeline(继续)
val rdd3 = rdd2.filter(_.level == "ERROR") // NarrowDependency
// ④ Partitioner: groupByKey 产生宽依赖 → Stage 边界
// HashPartitioner 决定 key 到 Partition 的映射
val rdd4 = rdd3.map(log => (log.service, 1))
.reduceByKey(_ + _) // ShuffleDependency + HashPartitioner
// ③ Compute: saveAsTextFile 触发 compute 方法执行
// ⑤ PreferredLocations: TaskScheduler 将 Task 调度到数据所在节点
rdd4.saveAsTextFile("hdfs://output/error_count") // Action → DAG 启动
这段代码背后,五个特性协同工作:
Partitions 决定了"有多少个并行任务"
↓
Dependency 决定了"哪些任务可以流水线执行,哪里需要 Shuffle"
↓
Partitioner 决定了"Shuffle 时数据如何分布"
↓
PreferredLocations 决定了"任务分配到哪个节点最快"
↓
Compute 决定了"每个任务具体做什么计算"
写在最后
RDD 论文获奖不是因为它"比 MR 快",而是它提出了一种新的分布式计算抽象——通过五个特性的精心设计,在容错、并行、数据本地性、内存计算四个维度上找到了最优平衡点。
- Partitions → 并行粒度
- Dependency → Stage 划分基础
- Partitioner → Shuffle 分布策略
- PreferredLocations → 数据本地性保证
- Compute + Lineage → 惰性求值 + 计算容错
RDD 五大特性不是孤立的知识点,而是一个有机整体。理解它们如何联动,才算真正理解了 Spark Core。
starzy · AI Data Engineer · blog.starzy.cn · GitHub: starzy1990.github.io

浙公网安备 33010602011771号