Spark RDD 核心知识详解:算子、依赖与执行模型

上一篇我们聊了 Spark 是什么、它有哪些模块、整体架构长什么样。这篇继续往里钻一层,专门讲 Spark 的核心抽象------RDD。你会搞清楚 RDD 到底是个什么东西、有哪些算子可以用、Spark 又是怎么把一个作业拆开来并行执行的。

一、RDD 是什么?五大特性再回顾一遍

RDD(Resilient Distributed Dataset,弹性分布式数据集)是 Spark Core 的核心抽象,代表一个不可变、可分区、可并行计算的分布式数据集。理解它的五个特性,就等于拿到了 Spark 的使用说明书。

  1. 可分区:RDD 在逻辑上被划分为多个分区(Partition),每个分区的数据在计算时通过函数获取。分区是并行计算的最小抓手。
  2. 只读性:RDD 是只读的,想"改数据"只能通过转换算子创建一个全新的 RDD,原 RDD 始终不变。这是容错能成立的前提。
  3. 血缘依赖:RDD 之间通过转换算子连接成一张血缘图,数据丢失时可以按血缘关系重建。
  4. 分区策略:RDD 的分区方式决定了数据如何分布,直接影响计算效率。
  5. 首选位置(数据本地性):计算时优先在数据所在的节点上执行,利用本地性减少网络传输。

二、RDD 是从哪来的?三种创建方式

  • 从集合创建:用 SparkContext 的 makeRDD(),把 Python 列表、元组等集合转成 RDD,适合测试小数据。
  • 从外部存储创建:用 textFile() 等函数,从 HDFS、本地文件系统、MySQL 等外部系统读取数据创建 RDD,这是生产中最常见的来源。
  • 从其他 RDD 创建:对已有 RDD 施加转换算子(map、filter 等)生成新的 RDD。

sc = SparkContext() # 创建 Spark 上下文
rdd1 = sc.parallelize(1, 2, 3, 4, 5) # 从集合创建
rdd2 = sc.textFile("hdfs://node1/input.txt") # 从外部存储创建
rdd3 = rdd1.map(lambda x: x * 2) # 从其他 RDD 创建

三、最关键的一组概念:转换算子 vs 行动算子

Spark 的所有算子分成两大类,区分它们是理解 Spark 的第一道坎。

|---------|--------------------------|---------------------------|
| 对比项 | 转换算子(Transformation) | 行动算子(Action) |
| 作用 | 创建新的 RDD | 触发计算并返回结果 / 写入存储 |
| 是否立即执行 | 不执行(惰性执行) | 立即触发整条链路计算 |
| 典型代表 | map、filter、flatMap、join | collect、count、take、reduce |
| 结果 | 还是 RDD,可以继续串联 | 最终数据或写入结果 |

所谓"惰性求值",就是转换算子被调用时并不真的算,只是把这一步记下来、连同血缘关系一起攒着;只有当程序碰到行动算子时,Spark 才回头把之前记录的所有转换一次性执行掉。

rdd = sc.textFile("hdfs://node1/input.txt")
words = rdd.flatMap(lambda line: line.split(" ")) # 转换:不触发计算
result = words.map(lambda w: (w, 1)).reduceByKey(lambda a, b: a + b)
print(result.collect()) # 行动:到这里才真正开始算

四、转换算子详解

1. 基础型转换算子

  • map():对 RDD 中的每个元素应用一个函数,返回新的 RDD。函数一次只处理单个元素。
  • mapPartitions():对每个分区应用一个函数,函数接收一个可迭代对象(如该分区内所有元素)并返回一个可迭代对象。它比 map() 更高效,适合需要一次性处理整个分区的场景。
  • mapPartitionsWithIndex():和 mapPartitions() 类似,但会额外把分区的索引信息传进来,方便区分数据来自哪个分区。
  • flatMap():对每个元素应用一个可以返回多个元素的函数,再把所有结果展平合并成新的 RDD。常用于数据预处理,比如把字符串按分隔符切开。

2. value 类型转换算子

  • map:逐元素转换,只改值不改结构。
  • groupBy:按指定函数的返回值分组,把 key 相同的元素归为一组,返回分组后的 RDD。
  • filter:按布尔条件过滤,只保留满足条件的元素。
  • sortBy:按指定规则排序,升序或降序都行。
  • union:对两个 RDD 求并集,返回包含所有元素的新 RDD。

3. key-value 类型算子(重点)

这一组算子要求输入的元素是元组形式的键值对 (key, value),是实际业务里用得最多的部分。

|----------------|---------------------------|-----------------------------------|
| 算子 | 作用 | 特点 / 注意事项 |
| groupByKey | 把相同 key 的 value 聚成一个列表 | 先分组再聚合,shuffle 数据量大 |
| reduceByKey | 对相同 key 的 value 直接聚合(求和等) | shuffle 前先做预聚合,通常比 groupByKey 快 |
| aggregateByKey | 自定义分区内 + 分区间聚合逻辑 | 上下游聚合逻辑不同时的最优选择 |
| sortByKey | 按 key 排序(可升序可降序) | 本身也会触发一次 shuffle |
| join | 把两个 KV RDD 按相同 key 连接 | 相当于分布式 Hash Join,返回 (key, v1, v2) |
| mapValues | 只对 value 做映射,key 保持不变 | KV RDD 专用,语义更清晰 |

这里有个高频考点:reduceByKey 比 groupByKey 更高效。原因是 reduceByKey 在数据"搬家"(shuffle)之前,会先在每个分区内做一次预聚合(类似 MapReduce 里的 Combiner),把数据量压小后再传输;而 groupByKey 是先把所有同 key 的数据全搬到一起再处理。

五、行动算子:真正把结果拿出来

  • reduce():用指定的二元运算符(如加法)对所有元素聚合,先在分区内部聚合,再做全局聚合,最终返回一个确定结果。
  • collect():在驱动程序里以列表形式返回 RDD 的全部元素(数据量大时慎用,容易把内存撑爆)。
  • count():返回元素个数。
  • take(n):返回前 n 个元素组成的数组,适合只想看个大概的场景。
  • collectAsDictionary():把键值对类型的 RDD 收集成字典形式返回。
  • foreach():对每个元素执行一次副作用操作,常用于把结果打印或写回外部存储。

六、宽依赖与窄依赖:理解 Shuffle 的关键

RDD 之间通过转换算子建立的依赖关系,分成窄依赖和宽依赖两种。这两种依赖决定了 Spark 如何切分任务,也直接决定了性能好坏。

|---------|------------------|-----------------------------|
| 对比项 | 窄依赖(Narrow) | 宽依赖(Wide) |
| 分区关系 | 父分区 → 子分区 一对一 | 父分区 → 子分区 一对多 |
| 数据传递 | 在内存中直接传递,不走网络 | 需要跨节点传输,触发 Shuffle |
| 典型算子 | map、filter、union | reduceByKey、groupByKey、join |
| 风险 | 基本没有 | 可能产生数据倾斜 |

简单记忆:窄依赖是"流水线",数据像水一样接着往下流;宽依赖是"分拣中心",数据必须重新洗牌(Shuffle)才能继续,既耗时又是数据倾斜的常见来源。

七、Stage 是怎么切出来的?

Spark 会把 DAG(有向无环图)中的转换过程划分成多个 Stage,划分规则非常直白:遇到宽依赖就切一刀。也就是说,Stage 的数量等于宽依赖的个数加一。

顺着这个规则,Spark 的任务执行就形成了清晰的四层结构:

|-------------|--------------------|-----------------|
| 层级 | 由什么产生 | 含义 |
| Application | 一个 SparkContext | 一个完整的 Spark 应用 |
| Job | 一个 Action 算子 | 一次完整的计算任务 |
| Stage | 宽依赖划分 | 一个执行阶段,含多个 Task |
| Task | Stage 分发给 Executor | 执行的最小单位 |

一个任务的完整执行流程是:程序从上到下依次经过多个 Stage,每个 Stage 内部包含多个 Task 并行执行;一旦遇到宽依赖,就启动一个新的 Stage,从而形成一组新的 Task。

Application (SparkContext)
└─ Job ← 由 collect() 等 Action 算子触发
├─ Stage 1 ← 窄依赖阶段:map / filter
│ ├─ Task 1 (在 Executor 上并行执行)
│ └─ Task 2
└─ Stage 2 ← 遇到宽依赖(reduceByKey)后切分
├─ Task 1
└─ Task 2

八、性能优化两件套:缓存与 Checkpoint

1. 缓存(Cache)

如果一个 RDD 在程序里被反复使用,每次都重新算一遍就太浪费。缓存的思路是:第一次使用时按血缘关系算出来并缓存在内存中,后续使用时直接读缓存,避免重复计算,从而加速后续的行动算子。

rdd = sc.textFile("hdfs://node1/input.txt").flatMap(lambda l: l.split(" "))
rdd.cache() # 标记为缓存
print(rdd.count()) # 第一次:真实计算并写入缓存
print(rdd.count()) # 第二次:直接读缓存,不再重算

2. Checkpoint(检查点)

在长时间的迭代应用里,血缘链会越拉越长,一旦中途出故障,恢复起来非常吃力。Checkpoint 就是在这条长链上打断一个"断点":调用 checkpoint() 把该节点的状态持久化到外部存储(如 HDFS),之后的故障恢复只需从这个断点往下重算,而不用从头重建整条血缘链。

  • 它相当于给长血缘链"截断",从而提升容错能力。
  • 注意:只有在触发行动算子之后,当前 RDD 的状态才会真正被写入 checkpoint 目录,形成持久化的血缘记录。

九、跑在 YARN 上时,Spark 是怎么干活的

当 Spark 以 YARN 模式运行(企业里最常见的部署方式)时,整个流程可以串成一条线:

  1. 环境启动:先启动 YARN 环境,其中包含 ResourceManager(资源管理者)和 NodeManager(节点管理者)。
  2. 资源申请:Spark 的 Driver 向 ResourceManager 申请计算资源,并创建 Executor;Executor 运行在 NodeManager 管理的计算节点上。
  3. 任务生成:Driver 负责创建 RDD,并根据 RDD 的分区数量生成相应数量的 Task。
  4. 调度执行:ResourceManager 把 Task 集合分发给各个 Executor 节点执行;在这一过程中,Spark 的缓存机制会在 Executor 端生效,提升后续计算效率。

十、两个高级特性:广播变量与累加器

1. 广播变量(Broadcast Variable)

当一份较大的只读数据需要在每个 Task 里反复使用时,如果每个 Task 都自己去加载一次,就是巨大的浪费。广播变量专门解决这个痛点:把它一次性分发到集群中所有 Executor 节点,避免重复加载,节省网络和内存开销。

  • 典型场景:算子函数里频繁要用到的大型静态数据,比如配置表、字典表。
  • 使用方式:用 sc.broadcast() 广播出去,各 Executor 节点会各持有一份副本。
  • 注意一:广播变量本身不能是 RDD,但可以是某个 RDD 的计算结果。
  • 注意二:只能在 Driver 端定义,Executor 端使用时是复制过去的一份。
  • 注意三:它是只读的,Executor 端不能修改其值。

config = {("a", "1"), ("b", "2")} # 一份较大的只读字典
bc = sc.broadcast(config) # 广播到所有 Executor
rdd.map(lambda kv: kv1 + bc.value.get(kv0, "")).collect()

2. 累加器(Accumulator)

累加器和广播变量是一对"黄金搭档":广播变量负责"只读地下发",累加器负责"从下往上汇总"。它在 Executor 端做累加计数,程序结束时 Driver 端再统一读取结果,适合统计条数、求和这类需求。

  • 只能在 Driver 端创建,在 Executor 端更新。
  • 只支持"累加"这种合并操作,不支持读取中间结果------这是它能高效并发累加的前提。

十一、扩展:把 PySpark 的结果写回 MySQL

除了写文件,PySpark 也能把数据写回关系型数据库。基本思路是:先准备数据和连接信息,再定义一个处理函数,用 foreachPartition 行动算子把数据分批写入 MySQL。

def write_to_mysql(rows):
conn = pymysql.connect(host=..., user=..., password=...,
database=..., port=...)
cursor = conn.cursor()
for row in rows:
cursor.execute("insert into table(col) values(%s)", row)
conn.commit()
conn.close()

rdd.foreachPartition(write_to_mysql) # 按分区批量写入,避免每个元素都建连接

  • 连接 MySQL 需要提供 host、user、password、database、port 等信息。
  • 操作完成后记得 commit 提交事务并 close 关闭连接。
  • 用 foreachPartition 而不是 foreach,是为了按分区批量写入,避免每条数据都新建一次连接。

十二、一句话记住 RDD

RDD 是 Spark 的"不可变、可分区、带血缘"的核心抽象:算子分转换与行动两类、转换惰性执行行动才触发;依赖分窄与宽两种、遇到宽依赖就切一个 Stage;缓存和 Checkpoint 是应对重复计算和长血缘链的两种优化手段。把这一串串起来,Spark 的执行模型就通了。

相关推荐
Aloudata1 小时前
Data Agent vs AI Native BI:企业数据分析的下一代入口到底是什么?
大数据·人工智能·数据分析·data·语义层
大鳥1 小时前
JSON 解析最佳实践:从入门到生产级优化
hive·sql·spark·json
pjj198541 小时前
Spark SQL 核心知识详解:DataFrame、DSL 与数据源
大数据·sql·spark
凤山老林1 小时前
授权模型只会RBAC?深入 ABAC 授权模型,实现权限判断做成策略引擎
大数据·数据库·算法
starzy19901 小时前
KafkaProducer 源码深度剖析:支撑高吞吐的数据结构设计
大数据·数据结构·kafka
饼饼学习空间智能1 小时前
机器人规模化部署平台怎么选?Fleet、VDA 5050、多机器人调度与运营闭环解析
大数据·深度学习·机器学习
geovindu1 小时前
sql:SQL Views and Materialized Views using mysql 9.0
大数据·数据库·数据仓库·sql·mysql·数据库开发·数据库架构
浔溺2 小时前
Spark核心基石:RDD全方位深度解析(原理、算子、实战、优化)
大数据·分布式·spark
Sayai2 小时前
Elasticsearch Rollup 汇算索引实战:监控指标多粒度预聚合查询,附 painless 时间窗取每日最新值
大数据·运维·elasticsearch·搜索引擎·性能优化·监控