RDD(Resilient Distributed Dataset,弹性分布式数据集)是 Spark 中最基本的数据抽象,是理解 Spark 分布式计算模型的关键。它代表一个不可变、可分区、支持并行操作的分布式数据集,具备 "弹性" 和 "分布式" 两大核心特性。
RDD 的核心特性
-
弹性(Resilient)
- 容错性:数据丢失时可通过血统(Lineage)信息重新计算恢复
- 动态内存管理:可根据需要将数据溢出到磁盘或从磁盘加载
- 可伸缩性:支持动态调整分区数量以适应集群资源
-
分布式(Distributed)
- 数据分散存储在集群的多个节点上
- 支持并行处理,每个分区可在不同节点上独立计算
-
不可变性(Immutable)
- 一旦创建不能被修改,只能通过转换算子生成新的 RDD
- 保证了计算的确定性和可重复性
RDD 的核心结构组成
一、分区列表(Partitions):分布式计算的最小单元
分区是 RDD 数据存储和并行计算的基础单元,本质是将大规模数据集拆分为若干个小规模子数据集,每个分区可独立在集群的一个节点上进行计算。
-
核心作用:
- 实现并行计算:集群中每个分区对应一个 Task,多个分区可在不同节点同时处理
- 控制计算粒度:分区数量直接影响并行度(通常建议分区数是集群总核数的 2-4 倍)
- 减少数据传输:合理的分区策略可让计算更靠近数据存储节点(数据本地性)
-
关键特性:
- 分区是逻辑概念:不强制要求数据物理存储在分区对应的节点,仅定义数据分片规则
- 分区数量可动态调整:通过
repartition或coalesce算子修改,适应不同计算需求 - 分区索引唯一:每个分区有唯一标识(如 0、1、2...),用于任务调度和数据定位
-
示例:
// 创建RDD时指定4个分区 val rdd = sc.parallelize(1 to 100, 4) // 获取分区数量 println(rdd.partitions.length) // 输出:4
二、依赖关系(Dependencies):容错与计算链的核心
RDD 的依赖关系记录了其与父 RDD 之间的血缘(Lineage),描述了 "当前 RDD 如何从父 RDD 转换而来"。这种设计是 RDD 实现容错的核心机制 —— 当某个分区数据丢失时,无需重新计算整个数据集,只需通过依赖关系追溯到父 RDD,重新计算对应分区即可。
-
依赖类型:
-
窄依赖(Narrow Dependency) :
- 定义:每个子 RDD 分区仅依赖父 RDD 的少数(通常是 1 个)分区
- 示例:
map、filter、union等算子会产生窄依赖 - 优势:支持流水线计算(Pipeline),父分区计算完成后可直接传递给子分区,无需 Shuffle
-
宽依赖(Wide Dependency) :
- 定义:子 RDD 分区依赖父 RDD 的多个分区(跨节点数据传输)
- 示例:
groupByKey、join、sortBy等算子会产生宽依赖 - 特点:会触发 Shuffle 操作,数据需跨节点传输,性能开销较大
-
-
实际价值:
- 容错优化:通过 Lineage 实现高效故障恢复,无需冗余存储全量数据
- 任务调度:Spark 根据依赖类型划分 Stage(窄依赖在同一 Stage,宽依赖为 Stage 边界)
- 性能优化:窄依赖可合并计算步骤,减少中间数据落地
三、计算函数(Compute Function):数据处理的逻辑定义
计算函数是作用于 RDD 分区的具体处理逻辑,定义了 "如何从父 RDD 分区计算得到当前 RDD 分区的数据"。
-
核心特点:
- 惰性执行:计算函数仅在行动算子触发时才会真正执行
- 分区级计算:函数针对单个分区独立运行,避免节点间数据依赖
- 序列化传输:计算函数会被序列化后发送到 Executor 节点执行
-
示例解析:
val numbers = sc.parallelize(1 to 5) // map算子定义的计算函数:x => x * 2 val doubled = numbers.map(x => x * 2)上述代码中,
x => x * 2就是作用于每个分区的计算函数,当执行doubled.collect()时,该函数会在每个分区上并行执行,将元素翻倍。
四、分区器(Partitioner):键值对数据的分布规则
分区器仅针对键值对 RDD(RDD[(K, V)])有效,用于定义数据如何在分区之间分布,决定了 "哪个 Key 应该存储在哪个分区"。
-
常见实现:
-
HashPartitioner:
- 原理:通过 Key 的哈希值对分区数取模(
hash(key) % numPartitions) - 适用场景:大多数键值对聚合场景(如
reduceByKey)
- 原理:通过 Key 的哈希值对分区数取模(
-
RangePartitioner:
- 原理:根据 Key 的范围划分分区(如 Key 为 1-100 的放分区 0,101-200 放分区 1)
- 适用场景:需要按 Key 排序的场景(如
sortByKey)
-
-
核心作用:
- 控制数据分布:避免数据倾斜(某分区数据量过大)
- 优化 Shuffle 性能:相同 Key 的分区规则一致时,可减少跨节点数据传输
- 支持自定义:可通过实现
Partitioner接口定义业务专属分区逻辑
-
示例:
scala
import org.apache.spark.HashPartitioner val pairs = sc.parallelize(List(("a", 1), ("b", 2), ("c", 3))) // 使用哈希分区器,指定4个分区 val partitioned = pairs.partitionBy(new HashPartitioner(4))
五、首选位置(Preferred Locations):数据本地性的优化依据
首选位置记录了每个分区的最佳计算节点(通常是数据所在的节点),Spark 调度任务时会优先将分区计算任务分配到这些节点,最大限度减少数据传输开销。
-
数据本地性级别(从高到低):
- PROCESS_LOCAL:数据在当前 Executor 进程内存中
- NODE_LOCAL:数据在当前节点的内存或磁盘中
- RACK_LOCAL:数据在同一机架的其他节点
- ANY:数据在任意位置(跨机架)
-
核心作用:
- 减少网络 IO:优先在数据所在节点计算,避免大规模数据传输
- 提升计算效率:本地数据访问速度比网络传输快 1-2 个数量级
-
典型来源:
- 从 HDFS 读取数据时,首选位置是存储数据块(Block)的 DataNode 节点
- 从数据库读取数据时,首选位置是数据库所在节点
RDD 的工作原理
- 创建:从外部数据源(文件、数据库等)创建或从其他 RDD 转换而来
- 转换:通过转换算子构建新的 RDD,形成依赖关系链
- 行动:触发计算,按照依赖关系从源头 RDD 开始计算
- 容错:若某个分区数据丢失,利用 Lineage 信息重新计算
转换算子和行为算子梳理
| 算子类型 | 子类别 | 算子名称 | 功能描述 | 示例代码 | 特点 / 注意事项 |
|---|---|---|---|---|---|
| 转换算子 | 单 RDD 操作 | map(f) | 对每个元素应用函数 f,返回新 RDD | rdd.map(x => x * 2) | 一对一转换,不改变分区数,无 Shuffle |
flatMap(f) | 对每个元素应用函数 f(返回集合),并扁平化结果 | rdd.flatMap(line => line.split(" ")) | 一对多转换,常用于分词、拆分集合 | ||
filter(f) | 保留满足条件 f 的元素 | rdd.filter(x => x > 10) | 筛选数据,不改变分区数 | ||
distinct() | 去除重复元素 | rdd.distinct() | 触发 Shuffle,可指定分区数优化性能 | ||
sortBy(f) | 按函数 f 的返回值排序 | rdd.sortBy(x => x._2) | 触发全局排序(Shuffle),可指定升序 / 降序 | ||
repartition(n) | 重新分区(增减分区) | rdd.repartition(5) | 触发 Shuffle,适合调整并行度 | ||
coalesce(n) | 减少分区(默认不 Shuffle) | rdd.coalesce(3) | 比 repartition 高效,适合大数据量缩减分区 | ||
| 双 RDD 操作 | union(other) | 合并两个同类型 RDD,保留重复元素 | rdd1.union(rdd2) | 分区数为两者之和,不触发 Shuffle | |
intersection(other) | 求两个 RDD 的交集 | rdd1.intersection(rdd2) | 触发 Shuffle,自动去重 | ||
subtract(other) | 求差集(当前 RDD 独有的元素) | rdd1.subtract(rdd2) | 触发 Shuffle | ||
join(other) | 内连接两个 (K,V) 型 RDD,返回 (K,(V,W)) | rdd1.join(rdd2) | 触发 Shuffle,需 Key 类型一致 | ||
leftOuterJoin(other) | 左外连接,保留左 RDD 所有 Key | rdd1.leftOuterJoin(rdd2) | 触发 Shuffle,右 RDD 无匹配则为 None | ||
| Key-Value 专属 | groupByKey() | 按 Key 分组,Value 聚合成迭代器 | rdd.groupByKey() | 触发 Shuffle,性能低于 reduceByKey(无局部聚合) | |
reduceByKey(f) | 按 Key 聚合 Value,函数 f 两两计算 | rdd.reduceByKey(_ + _) | 触发 Shuffle,Map 端先局部聚合,减少数据传输 | ||
aggregateByKey(zero) | 自定义初始值和聚合逻辑(Map 端 + Reduce 端) | rdd.aggregateByKey(0)(_ + _, _ + _) | 灵活支持复杂聚合(如求和 + 计数) | ||
mapValues(f) | 仅对 Value 应用函数 f,保留 Key | rdd.mapValues(x => x * 2) | 比 map 高效(无需处理 Key) | ||
sortByKey() | 按 Key 排序(Key 需可比较) | rdd.sortByKey(ascending=false) | 触发 Shuffle,全局排序 | ||
| 行动算子 | 结果收集 | collect() | 将所有元素收集到 Driver,返回数组 | rdd.collect() | 仅用于小数据集,避免 Driver 内存溢出 |
take(n) | 返回前 n 个元素 | rdd.take(5) | 高效获取样本,不触发全量计算 | ||
first() | 返回第一个元素(等价于 take (1).head) | rdd.first() | 快速获取首元素 | ||
| 聚合操作 | reduce(f) | 用函数 f 聚合所有元素 | rdd.reduce(_ + _) | 返回单一结果,适用于简单聚合 | |
aggregate(zero) | 带初始值的聚合,支持不同返回类型 | rdd.aggregate(0)(_ + _, _ + _) | 可自定义 Map 端和 Reduce 端聚合逻辑 | ||
count() | 返回元素总数 | rdd.count() | 触发全量计算,返回 Long 类型 | ||
countByKey() | 按 Key 统计元素个数 | rdd.countByKey() | 返回 Map [K, Long],适用于小结果集 | ||
| 输出操作 | saveAsTextFile(path) | 将元素写入文本文件(每个分区一个文件) | rdd.saveAsTextFile("hdfs://path") | 支持本地文件系统或分布式存储(如 HDFS) | |
foreach(f) | 对每个元素应用函数 f(无返回值) | rdd.foreach(println) | 执行在 Executor 端,Driver 端看不到输出 | ||
| 持久化操作 | cache() | 缓存 RDD 到内存(默认 MEMORY_ONLY 级别) | rdd.cache() | 惰性执行,需行动算子触发;可减少重复计算 | |
persist(storageLevel) | 按指定级别持久化 RDD(内存 / 磁盘 / 序列化等) | rdd.persist(StorageLevel.MEMORY_AND_DISK) | 灵活控制存储策略,适合迭代计算 |
关键区别总结:
- 转换算子:惰性执行(仅定义逻辑),返回新 RDD,可链式调用,可能触发 Shuffle。
- 行动算子:立即执行(触发所有前置转换),返回非 RDD 结果或输出,是计算的 "触发器"。
核心:spark和RDD之间的关系?
Spark 的整个工作流程,本质上是对 RDD 的创建、转换、行动的管理过程:
举例:用 Spark 分析用户访问日志(深入版)
假设有一个 10GB 的日志文件(分布式存储在 HDFS 的 10 个块中),内容格式为:用户ID,访问时间,页面URL。需求:统计每个页面的访问次数,并按次数降序排列。
from pyspark import SparkContext
sc = SparkContext("local[*]", "LogAnalysis") # 初始化 Spark 上下文
# 步骤1:创建 RDD(从分布式存储加载数据)
# Spark 会将文件按 HDFS 块拆分,创建 10 个分区的 RDD
log_rdd = sc.textFile("hdfs://path/to/logs") # RDD 分区数=10,对应 10 个数据块
# 步骤2:RDD 转换(构建计算血缘)
# 转换1:提取页面URL(每个分区独立执行,无数据 shuffle)
url_rdd = log_rdd.map(lambda line: line.split(",")[2]) # 惰性操作,仅记录转换逻辑
# 转换2:标记每个URL为(URL, 1)(继续构建血缘,仍不执行)
pair_rdd = url_rdd.map(lambda url: (url, 1))
# 转换3:按URL聚合次数(会触发 shuffle,跨分区传输数据)
count_rdd = pair_rdd.reduceByKey(lambda a, b: a + b) # 仍为惰性操作
# 转换4:按次数降序排序(再次 shuffle)
sorted_rdd = count_rdd.sortBy(lambda x: x[1], ascending=False)
# 步骤3:行动操作(触发实际计算)
result = sorted_rdd.collect() # 此时 Spark 才会执行上述所有转换
关键细节:Spark 如何通过 RDD 工作?
-
RDD 是「不可变的分区集合」
log_rdd被分为 10 个分区,每个分区对应 HDFS 上的一个数据块,分布在不同节点;- 所有转换操作(
map/reduceByKey)都是对分区的操作,Spark 自动调度不同节点并行处理。
-
Spark 引擎负责「翻译」RDD 逻辑
- 当调用
collect()时,Spark 会解析 RDD 的血缘关系(Lineage),生成「有向无环图(DAG)」; - DAG 调度器将任务拆分为 Stage(阶段),例如
reduceByKey会触发 Shuffle,将计算分为 Shuffle 前和 Shuffle 后两个 Stage; - 任务调度器将 Stage 中的任务分发到集群节点,执行实际计算。
- 当调用
-
RDD 让开发者无需关注底层细节
- 开发者只需调用
map/reduceByKey等 API,无需编写分布式通信、故障恢复代码; - 这些 API 本质上是对 RDD 分区的操作定义,由 Spark 引擎自动转化为分布式执行计划。
- 开发者只需调用
总结:
- RDD 是 Spark 的「数据模型」:定义了分布式数据的存储和转换方式;
- Spark 是「执行引擎」:负责解析 RDD 定义的计算逻辑,优化并在分布式集群上执行。
可以类比:
- RDD 就像「乐谱」,定义了数据如何被「演奏」;
- Spark 就像「乐队指挥」,负责按照乐谱,协调不同乐器(集群节点)完成演奏。
RDD 本质上是 “不存储实际数据” 的抽象,它存储的是 “数据的位置信息” 和 “计算逻辑”
sparksql的整个流程
假设执行如下 SQL(基于 HDFS 上的 user_orders 表,该表对应 HDFS 路径 /user/hive/warehouse/user_orders,底层有 5 个 HDFS 块):
sql
SELECT user_id, SUM(amount)
FROM user_orders
WHERE order_date > '2023-10-01'
GROUP BY user_id
1. 数据读取与 RDD 分区的对应关系
- 底层 HDFS 块与初始 RDD 分区:Spark SQL 读取 HDFS 路径时,默认会根据 HDFS 块自动划分 RDD 分区(1 个 HDFS 块 ≈ 1 个 RDD 分区,除非手动指定)。例如:5 个 HDFS 块 → 初始 RDD 有 5 个分区,每个分区对应 1 个 HDFS 块的数据(分布在存储该块的节点上)。
- 用户指定分区的影响:若通过
repartition(n)或coalesce(n)手动设置分区数,Spark 会在初始 RDD 基础上进行重分区(合并或拆分),最终 RDD 分区数为n,与底层 HDFS 块数无关。例如:5 个 HDFS 块 → 手动repartition(3)→ 最终 3 个 RDD 分区(可能包含多个 HDFS 块的数据)。
2. SQL 到 DAG 的转换过程
SQL 语句会被 Spark SQL 逐步转换为可执行的 DAG,核心步骤如下:
-
解析与优化:如前所述,SQL 经 Catalyst 优化器处理,生成优化后的逻辑计划(例如将
WHERE条件下推到数据读取阶段)。 -
转换为物理计划:逻辑计划被转换为具体的物理操作(如
Filter过滤、Aggregate聚合),这些操作对应 RDD 的转换算子(filter、reduceByKey等)。 -
生成 DAG:物理计划中的操作按依赖关系组成 DAG(有向无环图),每个节点代表一个 RDD 或一个转换操作。
以上述 SQL 为例,DAG 会包含以下步骤:
- 从 HDFS 读取数据生成初始 RDD(5 个分区);
- 执行
Filter操作(过滤order_date > '2023-10-01'),生成新 RDD; - 执行
Map操作(提取user_id和amount); - 执行
GroupBy+Sum操作(可能触发 Shuffle,重新分区); - 最终生成结果 RDD。
3. DAG 的执行机制
-
Stage 划分:DAG 调度器会根据 Shuffle 操作(如
GROUP BY会触发 Shuffle)将 DAG 拆分为多个 Stage(阶段),Shuffle 前为一个 Stage,Shuffle 后为另一个 Stage。例如上述 SQL 的 DAG 会被分为 2 个 Stage:- Stage 1:读取数据 → 过滤 → 映射(无 Shuffle);
- Stage 2:分组聚合(有 Shuffle,依赖 Stage 1 的结果)。
-
任务分发与执行:每个 Stage 包含多个 Task(任务),Task 数量与该 Stage 最后一个 RDD 的分区数一致(例如 Stage 1 有 5 个 Task,对应 5 个分区)。任务调度器会遵循 “数据本地化” 原则,将 Task 分配到数据所在节点的 Executor 上执行,最终通过行动操作(如
collect、show)触发整个 DAG 的计算。