上一篇确立了 Spark 的核心是一张 DAG,而 DAG 的节点就是 RDD。这一篇进入 RDD 本身。

RDD 容易被理解成"分布式的数组"或者"分布在多台机器上的数据集合"。更准确的说法是:RDD 是一份计算配方,记录了数据从哪来、经过什么变换、丢失一个分区后怎么重算。

本文只抓一个问题:RDD 的五大属性分别控制了什么,以及这五个属性如何支撑 Spark 的调度和容错。

五大属性总览

Spark 源码中 RDD 的抽象类定义了五个方法,每个方法对应一个核心属性:

1
2
3
4
5
6
RDD[T]
├── getPartitions: Array[Partition] ← 数据怎么切分
├── getDependencies: Seq[Dependency[_]] ← 上游是谁
├── compute(split, context): Iterator[T] ← 一个分区怎么算
├── partitioner: Option[Partitioner] ← 按什么规则分区
└── getPreferredLocations(split): Seq[String] ← 数据在哪台机器上

前三个属性是每个 RDD 必须实现的。后两个是可选的,有默认值(None 和空列表)。

这五个属性的分工可以用一句话概括:partitions 决定并行度,dependencies 决定 Stage 划分和容错路径,compute 决定每个分区的计算逻辑,partitioner 决定 Shuffle 后数据的分布规则,preferredLocations 决定 Task 优先调度到哪台机器。

属性一:partitions

partitions 返回一个 Partition 数组,每个 Partition 对象包含一个 index(分区编号)。分区数量决定了这个 RDD 可以被多少个 Task 并行处理。

对于 textFile 创建的 RDD,分区数由 HDFS 文件的 Block 数量决定。一个 128MB 的 Block 对应一个分区。对于 parallelize 创建的 RDD,分区数由用户指定或者默认等于集群的 CPU 核心数。

分区数不是越多越好。每个分区对应一个 Task,Task 有调度开销(序列化、网络传输、反序列化)。如果分区太小(每个分区只有几 KB 数据),调度开销可能超过计算时间。如果分区太大(每个分区几 GB 数据),单个 Task 可能耗尽 Executor 的内存。

经验值:每个分区的数据量在 64MB-256MB 之间通常是合理的。

属性二:dependencies

dependencies 返回一个 Dependency 列表,描述当前 RDD 和父 RDD 之间的关系。Dependency 分为两种:

NarrowDependency:父 RDD 的每个分区最多被子 RDD 的一个分区使用。典型算子是 map、filter、flatMap。窄依赖的特点是子分区可以只从一个父分区计算出来,不需要跨分区的数据交换。

ShuffleDependency:父 RDD 的每个分区可能被子 RDD 的多个分区使用。典型算子是 groupByKey、reduceByKey、join。Shuffle 依赖意味着数据需要按 key 重新分配到不同分区,必须等待上游所有分区完成后才能开始。

这个区分直接决定了 DAGScheduler 的 Stage 切分逻辑(下一篇展开):遇到 ShuffleDependency 就切一刀,窄依赖链条内的变换合并到同一个 Stage 中 pipeline 执行。

dependencies 同时也是容错的基础。当一个分区丢失时,Spark 沿着 dependencies 链回溯,找到最近的可用数据(缓存的分区或原始数据源),重新计算丢失的分区。这就是 Lineage(血统)容错机制。

属性三:compute

compute 接收一个 Partition 和一个 TaskContext,返回该分区数据的迭代器 Iterator[T]。这是 RDD 的核心计算逻辑。

不同类型的 RDD 有不同的 compute 实现:

1
2
3
HadoopRDD.compute     → 调用 InputFormat 从 HDFS 读一个 Split
MapPartitionsRDD.compute → 对父 RDD 的迭代器应用一个函数
ShuffledRDD.compute → 从 Shuffle 文件中读取属于该分区的数据

compute 的返回值是 Iterator,而不是一个完整的集合。这意味着数据是惰性拉取的:下游算子从迭代器中逐条读取记录,处理完一条再拉取下一条。在 Stage 内部,多个变换的 compute 函数通过迭代器串联,实现了 pipeline 执行。

这和数据库查询引擎的 Volcano 迭代器模型是同一种模式:每个算子实现 next() 接口,上层算子调用下层算子的 next() 逐条拉取数据。

属性四:partitioner

partitioner 返回一个 Option[Partitioner],描述 RDD 的数据按什么规则分布在各个分区中。大多数 RDD 的 partitioner 是 None,表示没有特定的分区规则。

只有经过 Shuffle 的 RDD(如 reduceByKey、groupByKey 的输出)才有 partitioner。Spark 内置两种 Partitioner:

HashPartitioner:对 key 取 hashCode,然后对分区数取模。这是默认的分区器。

RangePartitioner:按 key 的范围划分分区。sortByKey 使用这种分区器,保证全局有序。

partitioner 的存在让 Spark 可以做一个重要的优化:如果两个 RDD 有相同的 Partitioner,join 操作可以避免 Shuffle,直接在同一个分区内做本地 join。这被称为 co-partition 优化。

属性五:preferredLocations

preferredLocations 返回一个分区的首选计算位置列表(通常是主机名)。TaskScheduler 在分配 Task 时优先把 Task 发送到数据所在的节点,减少网络传输。

对于从 HDFS 读取的 RDD,preferredLocations 就是对应 Block 所在的 DataNode 地址。对于已经缓存(persist)的 RDD,preferredLocations 是缓存数据所在的 Executor 地址。

这就是 Spark 的数据本地性(data locality)调度策略。TaskScheduler 会按优先级尝试以下调度级别:

1
2
3
4
PROCESS_LOCAL → 数据在同一个 Executor 的内存中
NODE_LOCAL → 数据在同一台机器的磁盘或另一个 Executor 中
RACK_LOCAL → 数据在同一机架的另一台机器上
ANY → 数据在任意位置

如果最优级别的资源暂时不可用,TaskScheduler 会等待一段时间(默认 3 秒),超时后降级到下一个级别。

实验:观察 RDD 的五大属性

在 spark-shell 中执行以下代码,逐一检查每个属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
val rdd = sc.parallelize(Seq(("a", 1), ("b", 2), ("a", 3)), 2)

// 属性一:分区
println(s"分区数: ${rdd.partitions.length}") // 2
println(s"分区列表: ${rdd.partitions.map(_.index).mkString(",")}") // 0,1

// 属性二:依赖
println(s"依赖: ${rdd.dependencies}") // List(...)

// 属性四:分区器
println(s"分区器: ${rdd.partitioner}") // None

// 做一次 reduceByKey,引入 Shuffle
val reduced = rdd.reduceByKey(_ + _)
println(s"Shuffle 后分区器: ${reduced.partitioner}") // Some(HashPartitioner)
println(s"依赖类型: ${reduced.dependencies.map(_.getClass.getSimpleName)}")
// List(ShuffleDependency)

// 属性五:首选位置(parallelize 创建的 RDD 通常无首选位置)
println(s"首选位置: ${rdd.preferredLocations(rdd.partitions(0))}")

注意 reduceByKey 之后,partitioner 从 None 变成了 HashPartitioner,dependencies 中出现了 ShuffleDependency。这正是 Shuffle 操作的特征。

模式提炼

RDD 的五大属性体现了一个设计模式:计算描述与执行分离。

RDD 本身不存储数据,只描述如何产生数据(compute)、数据如何分片(partitions)、数据之间的依赖关系(dependencies)。实际的计算和数据传输由调度器根据这些描述来编排。

这种分离让调度器可以在不修改用户逻辑的情况下做出全局优化决策:把 Task 调度到数据本地(利用 preferredLocations)、合并窄依赖链中的变换(利用 dependencies 类型)、跳过已经缓存的分区(利用 partitions 的缓存状态)。

同样的模式出现在编译器设计中:中间表示(IR)描述计算逻辑,后端根据 IR 和目标硬件特性生成优化后的机器代码。

工程迁移表

RDD 属性 MapReduce 对应 Flink 对应 数据库对应
partitions InputSplit 列表 Parallelism / SubTask 数量 表的分区(Partition)
dependencies 无显式表示,Job 之间靠 HDFS 串联 DataStream 之间的 Transformation 边 查询计划中算子之间的边
compute Mapper.map() / Reducer.reduce() 用户的 MapFunction / ProcessFunction 算子的 next() 方法
partitioner Partitioner 接口(HashPartitioner 等) KeySelector + KeyGroupRange 表的分区键 + 分区函数
preferredLocations InputSplit.getLocations() InputSplitAssigner 分区亲和性(partition affinity)

常见误解

误解一:“RDD 存储了数据”。RDD 不存储数据。调用 persist() 或 cache() 只是给 RDD 打一个标记,告诉 BlockManager 在第一次计算后把结果缓存起来。未缓存的 RDD 在每次 action 触发时都会从头重算。

误解二:“分区数等于 Executor 数”。分区数和 Executor 数是独立的。分区数决定并行度(同时运行的 Task 数),Executor 数决定资源量(CPU 和内存)。分区数可以远大于 Executor 数,此时 Task 排队执行。

误解三:“Shuffle 依赖一定比窄依赖慢”。Shuffle 依赖引入了网络传输和磁盘 I/O,通常确实更慢。但"慢"的根源不是依赖类型本身,而是数据跨节点移动的物理代价。如果 Shuffle 的数据量很小(比如 distinct 值很少的 reduceByKey),Shuffle 的开销可能微不足道。

练习

  1. 创建一个 parallelize RDD,分别用 2、10、100 个分区,对同样的数据执行 count()。观察 Spark UI 中 Task 数量和总执行时间的变化。分区数到多少时调度开销开始明显?

  2. 对一个 textFile RDD 调用 partitions.length,然后用 repartition(n) 改变分区数后再观察。repartition 产生的是窄依赖还是宽依赖?用 dependencies 验证。

  3. 对两个 RDD 分别调用 reduceByKey(使用相同的分区数),然后 join。用 toDebugString 观察 join 是否引入了额外的 Shuffle。再试试让两个 RDD 使用不同的分区数,观察结果有何不同。

系列导航

序号 主题 状态
00 导读:为什么 Spark 的核心是一张 DAG 已完成
01 RDD:弹性分布式数据集的五大属性 本篇
02 宽依赖与窄依赖:Stage 是怎样划分出来的 下一篇
03 DAG Scheduler 与 Task Scheduler:从逻辑计划到物理执行
04 Shuffle 机制:数据跨分区交换的代价与优化
05 内存管理与 Tungsten:堆外内存、序列化与代码生成
06 存储体系:Block Manager、广播变量与累加器
07 容错机制:Lineage、Checkpoint 与推测执行
08 Spark SQL 与 Catalyst:从 SQL 文本到物理算子树
09 DataFrame 与 Dataset:类型安全与性能的折中
10 Adaptive Query Execution:运行时改写执行计划
11 数据源 API 与谓词下推:让存储层帮忙过滤
12 Structured Streaming:把流当成无界表
13 水位线与状态管理:流处理的时间语义
14 Spark on YARN / K8s:资源管理与部署模式
15 动态资源分配与作业调度:多租户集群的资源博弈
16 性能诊断:Spark UI、Event Log 与调优思路
17 Spark 与 Flink:两种计算引擎的设计选择

参考资料