RDD弹性分布式数据集

222 阅读13分钟

RDD(Resilient Distributed Dataset,弹性分布式数据集)是 Spark 中最基本的数据抽象,是理解 Spark 分布式计算模型的关键。它代表一个不可变、可分区、支持并行操作的分布式数据集,具备 "弹性" 和 "分布式" 两大核心特性。

RDD 的核心特性

  1. 弹性(Resilient)

    • 容错性:数据丢失时可通过血统(Lineage)信息重新计算恢复
    • 动态内存管理:可根据需要将数据溢出到磁盘或从磁盘加载
    • 可伸缩性:支持动态调整分区数量以适应集群资源
  2. 分布式(Distributed)

    • 数据分散存储在集群的多个节点上
    • 支持并行处理,每个分区可在不同节点上独立计算
  3. 不可变性(Immutable)

    • 一旦创建不能被修改,只能通过转换算子生成新的 RDD
    • 保证了计算的确定性和可重复性

RDD 的核心结构组成

一、分区列表(Partitions):分布式计算的最小单元

image.png

分区是 RDD 数据存储和并行计算的基础单元,本质是将大规模数据集拆分为若干个小规模子数据集,每个分区可独立在集群的一个节点上进行计算。

  • 核心作用

    • 实现并行计算:集群中每个分区对应一个 Task,多个分区可在不同节点同时处理
    • 控制计算粒度:分区数量直接影响并行度(通常建议分区数是集群总核数的 2-4 倍)
    • 减少数据传输:合理的分区策略可让计算更靠近数据存储节点(数据本地性)
  • 关键特性

    • 分区是逻辑概念:不强制要求数据物理存储在分区对应的节点,仅定义数据分片规则
    • 分区数量可动态调整:通过repartitioncoalesce算子修改,适应不同计算需求
    • 分区索引唯一:每个分区有唯一标识(如 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,重新计算对应分区即可

  • 依赖类型

    1. 窄依赖(Narrow Dependency)

      • 定义:每个子 RDD 分区仅依赖父 RDD 的少数(通常是 1 个)分区
      • 示例:mapfilterunion等算子会产生窄依赖
      • 优势:支持流水线计算(Pipeline),父分区计算完成后可直接传递给子分区,无需 Shuffle image.png
    2. 宽依赖(Wide Dependency)

      • 定义:子 RDD 分区依赖父 RDD 的多个分区(跨节点数据传输)
      • 示例:groupByKeyjoinsortBy等算子会产生宽依赖
      • 特点:会触发 Shuffle 操作,数据需跨节点传输,性能开销较大 image.png
  • 实际价值

    • 容错优化:通过 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 应该存储在哪个分区"。

  • 常见实现

    1. HashPartitioner

      • 原理:通过 Key 的哈希值对分区数取模(hash(key) % numPartitions
      • 适用场景:大多数键值对聚合场景(如reduceByKey
    2. 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 的工作原理

  1. 创建:从外部数据源(文件、数据库等)创建或从其他 RDD 转换而来
  2. 转换:通过转换算子构建新的 RDD,形成依赖关系链
  3. 行动:触发计算,按照依赖关系从源头 RDD 开始计算
  4. 容错:若某个分区数据丢失,利用 Lineage 信息重新计算

转换算子和行为算子梳理

算子类型子类别算子名称功能描述示例代码特点 / 注意事项
转换算子单 RDD 操作map(f)对每个元素应用函数 f,返回新 RDDrdd.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 所有 Keyrdd1.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,保留 Keyrdd.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 工作?

  1. RDD 是「不可变的分区集合」

    • log_rdd 被分为 10 个分区,每个分区对应 HDFS 上的一个数据块,分布在不同节点;
    • 所有转换操作(map/reduceByKey)都是对分区的操作,Spark 自动调度不同节点并行处理。
  2. Spark 引擎负责「翻译」RDD 逻辑

    • 当调用 collect() 时,Spark 会解析 RDD 的血缘关系(Lineage),生成「有向无环图(DAG)」;
    • DAG 调度器将任务拆分为 Stage(阶段),例如 reduceByKey 会触发 Shuffle,将计算分为 Shuffle 前和 Shuffle 后两个 Stage;
    • 任务调度器将 Stage 中的任务分发到集群节点,执行实际计算。
  3. 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 的转换算子(filterreduceByKey 等)。

  • 生成 DAG:物理计划中的操作按依赖关系组成 DAG(有向无环图),每个节点代表一个 RDD 或一个转换操作。

    以上述 SQL 为例,DAG 会包含以下步骤:

    1. 从 HDFS 读取数据生成初始 RDD(5 个分区);
    2. 执行 Filter 操作(过滤 order_date > '2023-10-01'),生成新 RDD;
    3. 执行 Map 操作(提取 user_id 和 amount);
    4. 执行 GroupBy + Sum 操作(可能触发 Shuffle,重新分区);
    5. 最终生成结果 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 上执行,最终通过行动操作(如 collectshow)触发整个 DAG 的计算。