DD 数据开发学习站DATA ENGINEERING
模块 04 · 离线计算

Spark 基础

Spark 架构、RDD/DataFrame、惰性求值、DAG 与宽窄依赖。

难度 · 进阶 约 150 分钟 必修 练习 8 项 计划内
建议先学:Hadoop 生态
本页目录 · 28 节

本章目标:能讲清 Spark 的 Driver/Executor、Job/Stage/Task、RDD/DataFrame、惰性求值和 shuffle,并在本地跑通第一个 PySpark 程序。 前置知识:读完 Hadoop 基础;会一点 Python(变量、函数、列表)即可。 预计用时:2.5 小时。

1. 为什么需要它

上一章说过 MapReduce 慢在四处:中间结果反复落盘、复杂任务要串多个作业、固定只有 Map/Reduce 两阶段、API 表达力弱。Spark 针对这四点做了改进:

MapReduce Spark
中间结果 主要落磁盘 优先放内存,放不下再溢写磁盘
执行模型 固定 Map/Reduce 阶段 DAG,多个转换可以连成一条流水线
编程接口 Java Map/Reduce 类 RDD、DataFrame、SQL、Streaming
迭代计算 每轮重新读盘写盘 可以 cache 中间结果反复用
延迟 分钟级起步 批处理秒到分钟,交互查询更快

关键认知:Spark 快,不是“因为内存所以不读磁盘”。shuffle 仍然要落盘、走网络,数据一大照样慢。Spark 的真正优势是:

  1. 惰性求值,能看到完整计算链后再优化。
  2. DAG 把多个操作合并,减少中间落盘和作业启动。
  3. DataFrame/SQL 共用 Catalyst 优化器,能自动做列裁剪、谓词下推、join 重排。
  4. 同一套 API 覆盖批、SQL、流、机器学习。

Spark 也不是万能的:几百行数据用 pandas 更快;毫秒级点查用 MySQL/Redis;严格事务用数据库;常驻毫秒级流处理通常选 Flink。Spark 的主战场是大规模离线批处理和数仓 ETL。

2. 核心概念

2.1 架构:Driver、Executor、Cluster Manager

Driver(main 进程,SparkSession/DAG Scheduler)

   | 申请资源、下发任务
   v
Cluster Manager(local / YARN / K8s / Standalone)

   | 分配 Executor
   v
Executor(JVM 进程,多个 core 跑多个 Task,负责 cache 和 shuffle 读写)

Driver 是“大脑”:运行 main(),创建 SparkSession,把代码翻译成执行计划,按 shuffle 切 Stage,调度 Task 并收集结果。Driver 挂了,应用就结束。

Executor 是“工人”:每个 Executor 是一个 JVM 进程,包含若干 core,一个 core 同时跑一个 Task;它执行 Task、缓存数据、读写 shuffle 文件。应用结束后被回收。

Cluster Manager 只负责资源分配:local[*] 用本机线程模拟,YARN 用 Container,Kubernetes 用 Pod,Standalone 是 Spark 自带模式。

注意:local[*] 不是集群。Driver 和 Executor 在同一个 JVM 里,只是用多个线程模拟并行。它的作用是学习语法和验证逻辑,不能用来推断真实集群的性能。

2.2 Job、Stage、Task

这三个词是 Spark 执行模型的核心,关系是“包含”:

Action -> Job ->(按 shuffle 边界切)Stage ->(按分区切)Task

例子:df.filter(...).groupBy("order_status").count().show()。

show() 是 Action,触发一个 Job。groupBy 需要把相同状态的数据拉到一起,产生 shuffle,所以 Job 至少被切成两个 Stage:shuffle 前做过滤和局部聚合,shuffle 后做最终聚合。每个 Stage 有多少分区,就发多少个 Task。

面试高频:Task 数不等于数据行数,而等于分区数。分区太少,机器闲着;分区太多,调度开销大。

2.3 RDD、DataFrame、Dataset

RDD DataFrame Dataset
是什么 不可变的分布式对象集合 带 schema 的分布式表 带类型的分布式数据集
schema 无 有 有
优化器 无,靠开发者 Catalyst + Tungsten Catalyst + Tungsten
API 风格 map、filter、reduceByKey select、filter、groupBy DataFrame + 强类型对象
PySpark 支持 支持 不直接支持

RDD 是最底层抽象:数据被切成分区,分布在不同机器上;不可变,每次转换生成新 RDD;靠 lineage(血缘)记录“从哪来、经过哪些转换”,丢了可以重算。RDD 适合非结构化数据、需要精细控制分区和算子的场景。

DataFrame 是“分布式的表”,有列名和类型,接口像 pandas。你写 filter、groupBy,Spark 先记成逻辑计划,再由 Catalyst 优化。生产里 90% 的场景用 DataFrame 或 Spark SQL。

Dataset 是 DataFrame 的强类型版本,主要在 Scala/Java 中使用;PySpark 没有真正的 Dataset,DataFrame 就等价于 Dataset[Row]。面试被问到,照实说“PySpark 里主要用 DataFrame”。

2.4 惰性求值:转换与行动

Spark 把操作分成两类:

  • 转换(Transformation):select、filter、withColumn、join、groupBy、orderBy、distinct、repartition。它们只记录“要做什么”,不立刻计算。
  • 行动(Action):show、count、collect、first、take、write。它们才触发真正执行。
df2 = df.filter(F.col("price") > 100).select("order_id", "price")  # 什么都没发生
df2.show()   # 到这里才提交 Job、读数据、计算

这种设计叫惰性求值。好处是 Spark 能看到完整链条再优化:比如你先 select 三列再 filter,优化器可能把过滤下推到读取阶段,只读需要的列;如果走一步算一步,就没有这种全局视角。

代价是调试时容易误判:df.count() 写在循环里,每次循环都重算一遍;df.cache() 之后不调用 Action,缓存也不会真正发生。用 df2.explain(True) 可以看逻辑计划、优化后计划和物理计划。

2.5 宽窄依赖、DAG 与 shuffle

窄依赖:父分区的数据只流向一个子分区,不需要跨机器搬数据。map、filter、union、coalesce、广播 join 都属于窄依赖,可以在一个 Stage 内流水线执行。

宽依赖:一个子分区依赖多个父分区,必须把数据按 key 重新分发,产生 shuffle。groupBy、distinct、repartition、orderBy、非广播 join 都会触发宽依赖。

read -> filter -> select -> [shuffle] -> groupBy -> agg -> show

|<------ Stage 1 ------>|            |<---- Stage 2 ---->|
        窄依赖流水线                      宽依赖边界

shuffle 的过程:Map 端按 key 的 hash 把数据分成若干份写到本地磁盘;Reduce 端从所有 Map 端拉取属于自己的那一份,再归并、排序、聚合。它贵在磁盘 I/O、网络传输、序列化和排序。Spark SQL 默认 spark.sql.shuffle.partitions=200,这个值对 GB 级和 TB 级数据不可能都合适,后面调优章会讲怎么改。

DAG 就是这些转换组成的有向无环图。Stage 的边界就是宽依赖;同一个 Stage 内的窄依赖可以合并成流水线,减少中间数据。某个分区丢失时,Spark 沿 lineage 重算该分区,不需要重跑整个作业。

2.6 Spark SQL 与 Catalyst

Spark SQL 不只是“写 SQL 的接口”,它是一套查询优化和执行引擎。DataFrame API 和 SQL 最终都会变成同一棵逻辑计划树:

SQL/DataFrame -> 未解析逻辑计划 -> Analyzer(目录、列名、类型)
  -> 优化后逻辑计划 -> Optimizer(谓词下推、列裁剪、join 重排)
  -> 物理计划(按代价选)-> Whole-stage codegen -> 执行

常见优化:

  • 谓词下推:能早点过滤就早点过滤,少读数据。
  • 列裁剪:只读用到的列,Parquet/ORC 列式存储收益明显。
  • 常量折叠:1 + 2 在编译期算成 3。
  • join 重排/广播:小表广播,避免大表 shuffle。
  • 代码生成:把多个算子合成一段 Java 字节码,减少虚函数调用。

所以“DataFrame 比手写 RDD 快”通常不是因为 API 本身,而是因为 Catalyst 能自动做这些优化。你用 spark.sql() 和 DataFrame API 写同一个查询,执行计划往往一样。

2.7 容错:丢了就重算

Spark 的容错主要靠 lineage:每个 RDD/DataFrame 都记得自己从哪来、做过哪些转换。某个分区所在的机器挂了,Driver 重新调度这个分区,沿血缘从可用的父分区重算。

血缘太长时重算很贵,解决办法是 checkpoint:把中间结果写到可靠存储(HDFS/S3),切断血缘。checkpoint 会真正物化并丢弃部分 lineage,代价比 cache 高;cache 只是为了复用,血缘还在。此外还有 Task 重试和推测执行:慢 Task 可能被复制到另一台机器,谁先完成用谁的结果,但这不能替代真正的倾斜处理。

3. 动手示例

3.1 安装 Java 17 和 PySpark

Spark 跑在 JVM 上,先确认 Java:

java -version

课程统一用 Java 17 + Spark 3.5.5:Java 17 是当前 LTS,Spark 3.5 对它的兼容性已经稳定。没装就去 Adoptium 下载 JDK 17,安装时勾选设置 JAVA_HOME 和 PATH;没勾选就手动配好,再关掉终端重开。

安装 PySpark 时锁定版本,避免不同机器装到不同大版本:

pip install pyspark==3.5.5
python -c "import pyspark; print(pyspark.__version__)"

注意:PySpark 3.5.5 需要 Python 3.8+。如果 Python 3.13 装完启动报 Python worker 错误,换 Python 3.12。版本不匹配时不要硬调,先换版本。

3.2 第一个程序:spark_hello.py

在项目根目录新建 scripts/spark_hello.py:

# -*- coding: utf-8 -*-
from pyspark.sql import SparkSession

spark = (SparkSession.builder
         .appName("hello")
         .master("local[*]")
         .getOrCreate())
spark.sparkContext.setLogLevel("ERROR")

# RDD 版 WordCount
rdd = spark.sparkContext.parallelize([
    "hello hadoop", "hello spark", "hadoop spark",
])
counts = (rdd.flatMap(lambda line: line.split())
             .map(lambda word: (word, 1))
             .reduceByKey(lambda a, b: a + b))
print("WordCount:", sorted(counts.collect()))

# DataFrame 版读 CSV
df = (spark.read.option("header", True)
      .option("inferSchema", True)
      .csv("data/sample/orders_demo.csv"))
df.printSchema()
df.show(5)
print("订单行数:", df.count())

spark.stop()

运行:

python scripts/spark_hello.py

WordCount 输出固定为:

WordCount: [('hadoop', 2), ('hello', 2), ('spark', 2)]

订单行数应为 12。第一次启动要起 JVM,慢 30 秒到 1 分钟是正常的。

3.3 用 explain 看惰性求值

把下面几行加到程序里:

from pyspark.sql import functions as F

filtered = (df.filter(F.col("status") != "canceled")
              .select("order_id", "status", "order_date"))
filtered.explain(True)   # 只打印计划,不读全表
filtered.show(5)         # 这里才真正计算

explain 会打印逻辑计划和物理计划,不需要背每个节点,先找三样东西:

  • Filter 是否下推到 FileScan 附近(谓词下推)。
  • FileScan 的 schema 是否只剩用到的列(列裁剪)。
  • 是否有 Exchange(shuffle 标志);看到 BroadcastExchange 说明小表被广播。

3.4 local 和真实集群的差别

local[*] 只用于学习:分区在本机线程里跑,没有网络传输、副本和资源队列。真实提交通常长这样(概念示例,本地不执行):

spark-submit \
  --master yarn \
  --deploy-mode cluster \
  --executor-memory 8g \
  --executor-cores 4 \
  scripts/job.py

本地调通的代码不代表集群上能直接跑:路径要从本地文件换成 HDFS/对象存储,资源参数要按集群配,日志要去 YARN 上看。这些差异在调优章展开。

4. 常见坑

坑 表现 原因与处理
在循环里调 count()/show() 越跑越慢,日志刷屏 每次 Action 都触发一个 Job;先算一次再复用,或写临时表
collect() 拉全量 Driver OOM 结果大时用 write 落盘,或先聚合再 show
以为 cache() 立刻生效 内存没变化 cache 是惰性的,要等第一个 Action;用完 unpersist()
以为 local[*] 是集群 性能判断失真 它只是本机多线程,不能模拟网络和资源竞争
分区数一直用默认 200 小数据任务过多,大数据单任务过重 按数据量调 spark.sql.shuffle.partitions,或开 AQE
Python UDF 写复杂逻辑 比内置函数慢很多 优先用 pyspark.sql.functions;必要时用 pandas UDF/Arrow
版本不匹配 Python worker failed、JVM 启动失败 Java 17 + 匹配的 PySpark/Python 版本,先换版本再调代码
Windows 写文件报 winutils.exe 读写本地 Parquet 失败 只读练习可继续;要写文件用 Hive 兼容目录、WSL2 或 Docker

5. 练习

练习 1:区分转换和行动

下面哪些是 Transformation,哪些是 Action?

df.select("order_id")
df.filter(F.col("price") > 100)
df.count()
df.groupBy("order_status").count()
df.write.parquet("data/out/orders")
df.orderBy("order_date")
df.first()
参考答案

Transformation:select、filter、groupBy(...).count()、orderBy;groupBy("x").count() 返回新 DataFrame,仍是转换。

Action:df.count()、df.write.parquet(...)、df.first()。区分方法:返回值是不是新的 DataFrame/RDD;把结果交给 Driver 或写出存储的,就是 Action。

练习 2:数 Job、Stage、Task

一段代码只有一个 Action,源数据有 4 个分区,中间做了一次 groupBy,shuffle 后仍是 4 个分区。至少有几个 Stage?大致多少个 Task?

参考答案

一个 Action 触发一个 Job。groupBy 产生一次 shuffle,所以至少 2 个 Stage:shuffle 前一个,shuffle 后一个。

Task 数按 Stage 内的分区数算:前后都是 4 个分区时,大约 4 + 4 = 8 个 Task。实际还受 map 端预聚合、AQE 合并分区、文件分片影响,不能只背固定数字。关键:Stage 看 shuffle 边界,Task 看分区数。

练习 3:用 RDD 写 WordCount

用 spark.sparkContext.parallelize 造三行文本,统计每个单词出现次数,按单词排序输出。

参考答案
rdd = spark.sparkContext.parallelize([
    "hello hadoop", "hello spark", "hadoop spark",
])
result = (rdd.flatMap(lambda line: line.split())
             .map(lambda word: (word, 1))
             .reduceByKey(lambda a, b: a + b)
             .sortBy(lambda x: x[0])
             .collect())
print(result)

输出:[('hadoop', 2), ('hello', 2), ('spark', 2)]。

flatMap 把一行拆成多个单词;map 打标;reduceByKey 先在 Map 端局部聚合再 shuffle,比 groupByKey 更省网络。sortBy 是宽依赖,会再触发一次 shuffle。

练习 4:解释为什么没有立刻读数据

你写了:

df2 = df.filter(F.col("order_status") == "delivered").select("order_id")

运行后什么输出都没有,也没有报错。为什么?怎样让它真正执行?

参考答案

filter 和 select 都是 Transformation,只构建执行计划,不触发计算。Spark 采用惰性求值,等 Action 才提交 Job。

加一个 Action 即可,例如 df2.show(5)、df2.count() 或 df2.write.parquet(...)。调试时可以用 df2.explain(True) 看计划,但 explain 本身不会读全表。

练习 5:什么时候该广播小表

大表 order_items 要和 32,951 行的 products(约 2.4 MB)按 product_id join。应该用普通 join 还是广播 join?怎么确认广播生效?

参考答案

products 约 3.3 万行、2.4 MB,远小于默认广播阈值(10 MB),适合广播:

from pyspark.sql.functions import broadcast
result = items.join(broadcast(products), "product_id", "left")
result.explain(True)

在物理计划里找 BroadcastHashJoin 和 BroadcastExchange。如果仍是 SortMergeJoin,可能是统计信息不准、表超过阈值或 hint 被限制。广播表会复制到每个 Executor,不要对几 GB 的表强行广播。

练习 6:给一条 ETL 标出 shuffle 点

需求:读订单和明细,过滤取消单,按订单汇总金额,再按品类算 GMV,最后写 Parquet。列出主要步骤,并标出哪些步骤会 shuffle。

参考答案
  1. 读 orders、order_items、products,过滤取消单和脏明细:读文件 + 窄依赖,不 shuffle。
  2. 明细 join 订单、join 商品:商品表小且广播时不 shuffle,否则 join 会 shuffle。
  3. 按 order_id 汇总金额,再按 category 汇总 GMV:两次 groupBy,都会 shuffle。
  4. 写出 Parquet:按分区写、写前重分区会 shuffle。

优化方向:小表广播、先过滤再 join、能合并的聚合尽量合并、写前控制分区数。广播 join 和预聚合能减少搬数据,但聚合本身通常仍需要 shuffle。

6. 面试延伸

面试题:Driver 和 Executor 的区别是什么?

是什么:Driver 运行 main(),负责创建 SparkSession、生成 DAG、切 Stage、调度 Task、收集结果;Executor 是工作进程,执行 Task、缓存数据、读写 shuffle 文件。

解决什么问题:把“指挥”和“干活”分开。Driver 只做计划和协调,不碰全量数据;Executor 可以横向扩展,数据分片并行处理。

我的实操边界:我在本地模式写过 PySpark,Driver 和 Executor 在同一个 JVM 里;理解集群模式下由 Cluster Manager 分配 Executor,但没有在生产 YARN 上排查过 Driver OOM 和 Executor 丢失。

面试题:惰性求值有什么好处?

是什么:Transformation 只记录计划,Action 才触发执行。

解决什么问题:Spark 能看到完整计算链,统一做谓词下推、列裁剪、join 重排和流水线合并,避免产生不必要的中间结果;也方便把多个窄依赖合并进一个 Stage。

我的实操边界:我用 explain(True) 看过过滤和列裁剪是否下推,也踩过在循环里反复 count() 的坑;没有做过 Catalyst 源码级开发。

面试题:shuffle 为什么慢?

是什么:shuffle 是把数据按 key 重新分发到不同分区的过程,发生在 groupBy、join、distinct、repartition、orderBy 等宽依赖处。

解决什么问题:它让相同 key 的数据聚到一起,才能做聚合和 join;代价是 Map 端写磁盘、Reduce 端走网络拉取,还要序列化、反序列化和排序。

我的实操边界:我在本地小数据上观察过 Exchange 节点和 Stage 切分,理解 shuffle 的读写过程;没有处理过 TB 级 shuffle 的磁盘和网络瓶颈,调优参数只了解常用项,见 Spark 调优。

7. 本章验收

  • 能画出 Driver、Executor、Cluster Manager 的关系,并说出各自职责
  • 能解释 Job、Stage、Task 的包含关系,以及 Stage 按什么切分
  • 能说出 RDD、DataFrame、Dataset 的区别,知道 PySpark 主要用哪个
  • 能各举 5 个 Transformation 和 Action,并解释惰性求值
  • 能说出窄依赖和宽依赖的区别,指出哪些操作会触发 shuffle
  • 能运行 spark_hello.py,看到 WordCount 结果和 12 行示例订单
  • 能用 explain(True) 找到 Filter、FileScan、Exchange 三类关键节点
  • 完成 6 道练习,至少 4 道能独立写出答案

下一章

PySpark 实战:用 DataFrame 和 Spark SQL 把电商数据真正算成指标,并与 DuckDB 对账。

本章任务0 / 8
本页目录