去年帮忙调了一个跑了四十分钟还跑不完的离线ETL任务,换成Spark重写之后,九分钟出头就跑完了。盯着Spark UI上密密麻麻的Stage记录,我突然觉得有必要把Spark的核心原理好好写一遍——因为网上能找到的资料大多是概念堆砌,真到了要解释“为什么快”“为什么OOM”“为什么Stage卡住”的时候,很多人根本说不清。这篇不打算写成面面俱到的完整教材,而是从原理出发,把RDD/DAG/Stage、Spark SQL的优化机制、集群部署和日常排错串起来,顺便带上我这些年实际踩过的坑,适合刚学Spark的初学者,也适合准备面试的人当复习提纲。
1. 在讲原理之前,先回答一个问题:Spark到底为什么快
1.1 MapReduce给大家留的阴影:每算一步,都要把中间结果写一遍磁盘
讲Spark之前,得先回忆一下Hadoop MapReduce时代什么样。单次WordCount还好说,但一旦遇到K-means、PageRank、协同过滤这类迭代式算法,每一轮迭代都要把中间结果落到HDFS上,下一轮再重新读回来。一次迭代的map结果要写磁盘,shuffle的数据要写磁盘,reduce输出还要写一份。这种做法可靠性极好,但速度是真慢,IO开销几乎成了所有计算任务的瓶颈。当时团队里跑一个三层的机器学习实验,一半时间都消耗在等待MapReduce任务的写入和复制上,改参数调优基本靠等。
我印象最深的是,每次调整完模型参数重新跑任务,中间部分都要重新落盘、重新读。磁盘IO成了整个数据链路的常量开销,CPU反而一直在等数据。这种“每一步都存盘”的设计在容错上有它的道理,但对于数据挖掘、图计算这类需要反复迭代的场景,代价实在太大。
1.2 Spark的设计思路:能不落盘就不落盘
Spark核心贡献在于把计算模型从“每一步都落盘”改成“尽可能在内存里完成全链路”。它定义了一套RDD(弹性分布式数据集)抽象,用DAG(有向无环图)把完整计算过程描述出来;同一个RDD被多个算子使用时,可以复用内存缓存,迭代计算时不再需要反复写HDFS。除此之外,Spark还有两个关键设计:
- 惰性求值:transform算子不会立刻执行,只是记录血缘关系,遇到action算子才真正触发任务。
- 基于内存的shuffle机制:Map阶段的输出优先留在内存,只有内存放不下时才溢写磁盘。
把这两个机制结合到一起,结果就是:大多数离线批处理任务直接比MapReduce快一个数量级,在有缓存和复用场景里提升更夸张。比如我前面说的那个ETL任务,原来MapReduce每轮迭代都要把中间表写一遍,Spark把中间结果直接放在内存里,后面几轮复用同一份RDD缓存,整个计算链路的IO开销一下子降下来了。
1.3 Spark到底是什么,以及它的适用边界
很多人刚接触时会搞混几个概念。Spark本质是一个通用分布式计算引擎,不是存储系统,也不负责资源管理(除非用Standalone模式跑)。它默认可以读取HDFS、S3、本地文件,也可以读取各种数据库,但它本身不存数据。它提供的核心能力包括:
- 批处理:RDD/DataFrame的离线ETL;
- 交互式查询:通过Spark SQL对结构化数据做即时分析;
- 流处理:Structured Streaming,用批处理的方式处理流式数据;
- 图计算:GraphX;
- 机器学习:MLlib。
现在Spark 3.x已经是大数据领域默认的引擎之一,很多公司的数据平台把Spark当作执行引擎,上层用Hive、Flink跑任务时也经常会看到Spark的调度和执行。搞清楚它的原理,对你理解整个大数据生态也特别有帮助。不过也要注意边界:Spark不适合做低延迟的在线查询(在线场景用ClickHouse、Doris这类OLAP引擎更合适),也不适合做毫秒级的事件驱动流处理(那是Flink的主场)。Spark的定位始终是“大规模并行计算”,这句话理解了,选型时就不会跑偏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RDD、DAG与Spark执行链路:从提交到Stage划分的完整过程
2.1 RDD的三层身份:数据集合、计算模型、容错凭证
RDD全称是Resilient Distributed Dataset,这个名字里每个词都有意义。Resilient指的是容错能力强,通过血缘关系可以重算丢失的partition;Distributed说明它是分布式的,数据被划分到多个partition上,分布在多个Executor中;Dataset表示它代表一个数据集合。它同时承担三个角色:
- 数据容器:持有分区信息和依赖关系;
- 算子载体:map、filter、flatMap这些转换操作都作用在RDD上;
- 容错凭证:记录血缘(lineage),某个分区丢了可以直接根据血缘重算。
我记得第一次看RDD源码的时候,最困惑的是为什么RDD允许重复读。后来才意识到,正是这种不变性和可重算性,让Spark不需要在每个阶段都做数据备份,天然适合数据量大、节点可能故障的分布式环境。一个RDD一旦创建,它表达的数据转换关系就是固定的;数据丢了不是从备份恢复,而是顺着血缘重新算一遍,这才叫“弹性”。
2.2 行动算子触发的调度流程
RDD的惰性求值是Spark执行模型里最容易被忽略的设计。写一遍map、filter,任务不会立即执行,只是往DAG上加了节点和边;只有遇到collect、count、saveAsTextFile这类action时,才会上报DAGScheduler,开始真正的调度。
调度过程大致是:
- SparkContext向Driver申请执行环境;
- DAGScheduler解析整个DAG,根据宽依赖切分Stage;
- 每个Stage内部生成一组Task,TaskScheduler把Task分发到Executor;
- Executor端启动TaskRunner,执行具体计算逻辑;
- 最终结果回传Driver(或直接写入外部存储)。
这里有个常见误解:Stage是“一批算子”,而不是“一个算子”。一个Stage里可能包含多个连续的窄依赖算子,比如rdd.map(...).filter(...).map(...),只要中间没有宽依赖,就都归并在同一个Stage里,这样可以减少任务调度开销。新手最容易犯的错误是以为每写一个算子就会生成一个Stage,其实不是,Stage的边界只看shuffle。
2.3 宽依赖和窄依赖:为什么Stage划分的边界一定是shuffle
窄依赖是指父RDD的每个partition最多被子RDD的一个partition使用,典型算子是map、filter、union;宽依赖是指父RDD的每个partition可能被子RDD的多个partition使用,典型算子是groupByKey、reduceByKey、join(非广播)。宽依赖必须发生shuffle,也就是跨节点传输数据,因此是Stage划分
