Spark 常被介绍为"比 MapReduce 快 100 倍的计算引擎"。这个说法把重点放错了地方。速度差异只是结果,不是原因。真正的区别在于执行模型:MapReduce 是一条固定的 Map-Shuffle-Reduce 三段流水线,而 Spark 是一个可以表达任意有向无环图(DAG)的执行引擎。

本篇只抓一个问题:Spark 的 DAG 执行模型到底和 MapReduce 的线性管道有什么本质区别,以及这个区别如何贯穿整个系列。

MapReduce 的线性管道

MapReduce 的执行模型可以用一句话概括:读数据 → Map → 写磁盘 → Shuffle → 写磁盘 → Reduce → 写数据。

1
HDFS → Map Task → Local Disk → Shuffle → Local Disk → Reduce Task → HDFS

这条管道有两个结构性约束:

第一,每个 MapReduce 作业只有一轮 Map 和一轮 Reduce。如果业务逻辑需要多轮聚合(比如先按用户分组,再按地区汇总),就必须把逻辑拆成多个 MapReduce 作业串联执行,每个作业的中间结果都要落盘到 HDFS。

第二,Map 阶段的输出必须全部写入本地磁盘,Reduce 阶段再从磁盘拉取。即使下游算子可以立即消费上游输出,中间数据仍然要经历一次完整的磁盘写入和读取。

这两个约束叠加的效果是:迭代算法(如 PageRank、K-Means)的每一轮迭代都要付出一次完整的 HDFS 读写和 Shuffle 磁盘 I/O。对于需要 10-20 轮迭代的机器学习算法,这个开销是不可接受的。

Spark 的 DAG 模型

Spark 把计算表达成一张 RDD(Resilient Distributed Dataset)之间的依赖图。每个 RDD 记录了三件事:数据从哪来、经过什么变换、丢了怎么重算。

1
2
3
4
5
6
7
8
9
10
11
12
13
textFile(RDD-0)
│ flatMap

RDD-1
│ map

RDD-2
│ reduceByKey ← Shuffle 边界,Stage 在这里切开

RDD-3
│ filter

RDD-4

与 MapReduce 的关键区别有三点:

任意拓扑:RDD 之间的依赖关系不局限于 Map-Reduce 两阶段。一个 RDD 可以依赖多个父 RDD(如 join),一个 RDD 也可以被多个子 RDD 复用(如 cache 后的共享中间结果)。整个计算构成一张有向无环图,而不是一条线性管道。

惰性求值:调用 map、filter、flatMap 这些变换(transformation)时,Spark 不执行任何计算,只在内部记录一条依赖边。只有遇到 count、collect、saveAsTextFile 这样的行动(action)时,Spark 才从 DAG 的末端回溯,构建出完整的执行计划并触发计算。

内存优先:Stage 内部的多个变换可以在同一个 Task 内 pipeline 执行,中间数据留在内存中传递,不需要落盘。只有跨 Stage 的 Shuffle 数据需要物化到磁盘。

从 DAG 到物理执行:三步模型

Spark 把一次作业从逻辑到物理的转换分成三步:

1
2
3
4
5
6
7
8
9
10
用户代码
│ 惰性求值,记录 transformation

逻辑 DAG(RDD 依赖图)
│ 按 Shuffle 边界切分

Stage 划分(物理 DAG)
│ 每个 Stage 内部按分区生成 Task

Task 集合 → 发送到 Executor 执行

逻辑 DAG 是用户用 RDD API 或 DataFrame API 写出来的变换链。DAGScheduler 在遇到 Shuffle 依赖时把这张图切成若干个 Stage。每个 Stage 包含一组可以 pipeline 执行的窄依赖变换。TaskScheduler 再把每个 Stage 拆成与分区数相同的 Task,发送给集群中的 Executor 执行。

这三步模型是整个系列的主线。后续每一篇文章都在展开其中某一步的内部机制。

实验:用 toDebugString 观察 DAG

在 spark-shell 中执行以下代码,可以直接观察 Spark 构建的 RDD 依赖图:

1
2
3
4
5
6
7
val lines = sc.textFile("README.md")
val words = lines.flatMap(_.split(" "))
val pairs = words.map(w => (w, 1))
val counts = pairs.reduceByKey(_ + _)

// 不触发计算,只打印 DAG
println(counts.toDebugString)

输出类似:

1
2
3
4
(2) ShuffledRDD[3] at reduceByKey at <console>:26 []
+-(2) MapPartitionsRDD[2] at map at <console>:25 []
| MapPartitionsRDD[1] at flatMap at <console>:24 []
| README.md MapPartitionsRDD[0] at textFile at <console>:23 []

缩进表示依赖关系:ShuffledRDD 依赖 MapPartitionsRDD,后者又依赖 flatMap 产生的 RDD。括号内的数字(2)表示分区数。ShuffledRDD 的出现标志着一个 Shuffle 边界,DAGScheduler 会在这里切出一个新 Stage。

注意,执行 toDebugString 时没有触发任何计算。counts 这个 RDD 只是一个计算配方,记录了如何从 README.md 出发,经过 flatMap、map、reduceByKey 三步变换得到最终结果。只有调用 counts.collect() 或 counts.count() 时,Spark 才会真正读取文件、执行变换、完成 Shuffle。

模式提炼

Spark 的 DAG 模型体现了一个通用的设计模式:声明式计算图 + 延迟执行。

用户用 API 描述"要做什么",系统在内部构建一张计算图,推迟到必须产出结果时才一次性优化并执行。这个模式不只出现在 Spark 中。数据库的查询优化器对 SQL 做同样的事情:解析 SQL 文本 → 构建逻辑计划 → 优化 → 生成物理计划 → 执行。TensorFlow 1.x 的静态计算图也是同一个思路:用 tf.placeholder 和 tf.matmul 构建图,调用 session.run() 时才执行。

延迟执行的好处是系统有机会在执行前做全局优化。Spark 的 Catalyst 优化器(后续文章会展开)正是利用了这一点:在逻辑计划上做谓词下推、列裁剪、join 重排序等优化,然后再生成物理执行计划。

工程迁移表

Spark 概念 MapReduce 对应 Flink 对应 数据库对应
RDD(计算配方) 无直接对应,每个 MR Job 是独立的 DataStream / DataSet 逻辑计划中的关系表达式
Transformation(惰性变换) 无,Map/Reduce 立即执行 Transformation(同样惰性) SQL 语句(未执行)
Action(触发执行) Job 提交即执行 execute() / print() 查询执行(EXPLAIN → 执行)
DAG(依赖图) 单轮 Map-Reduce,多轮需链式串联 JobGraph → ExecutionGraph 查询计划树
Stage(Shuffle 边界切分) 固定的 Map 阶段 + Reduce 阶段 Operator Chain(以 Shuffle 为界) 查询计划的子树
内存优先,Shuffle 才落盘 中间结果全部落盘 Network Buffer + 状态后端 Buffer Pool 缓存页面

常见误解

误解一:“Spark 快是因为用了内存”。内存只是 DAG 模型的一个推论,不是原因。DAG 模型允许 Stage 内部的变换 pipeline 执行而不落盘,这才是减少 I/O 的根本。如果每个变换之间都强制落盘,即使数据在内存中,也没有比 MapReduce 快多少。

误解二:“Spark 取代了 MapReduce”。Spark 取代的是 MapReduce 的执行引擎,不是 Hadoop 生态。Spark 仍然可以运行在 YARN 上,读写 HDFS 数据,使用 Hive Metastore 管理元数据。

误解三:“DAG 就是有多个 Stage”。DAG 不只是"多个阶段"。关键在于 Stage 之间和 Stage 内部的依赖关系可以是任意拓扑,而不是固定的两阶段模型。一个 Stage 可以有多个父 Stage(如 join 的两侧),也可以有多个子 Stage。

练习

  1. 在 spark-shell 中对同一个 RDD 调用两次不同的 action(如 count 和 collect),观察是否触发了两次独立的作业。用 Spark UI 的 Jobs 页面验证。

  2. 构造一个需要两次 Shuffle 的计算链(如先 groupByKey 再 reduceByKey),用 toDebugString 观察 Stage 数量。然后把中间结果 cache 住,再执行一次,对比两次的 Stage 划分有无变化。

  3. 思考:如果把 MapReduce 的中间结果从磁盘改为内存缓存(不改变执行模型),能达到 Spark 的性能吗?为什么?

系列导航

序号 主题 状态
00 导读:为什么 Spark 的核心是一张 DAG 本篇
01 RDD:弹性分布式数据集的五大属性 下一篇
02 宽依赖与窄依赖:Stage 是怎样划分出来的
03 DAG Scheduler 与 Task Scheduler:从逻辑计划到物理执行
04 Shuffle 机制:数据跨分区交换的代价与优化
05 内存管理与 Tungsten:堆外内存、序列化与代码生成
06 存储体系:Block Manager、广播变量与累加器
07 容错机制:Lineage、Checkpoint 与推测执行
08 Spark SQL 与 Catalyst:从 SQL 文本到物理算子树
09 DataFrame 与 Dataset:类型安全与性能的折中
10 Adaptive Query Execution:运行时改写执行计划
11 数据源 API 与谓词下推:让存储层帮忙过滤
12 Structured Streaming:把流当成无界表
13 水位线与状态管理:流处理的时间语义
14 Spark on YARN / K8s:资源管理与部署模式
15 动态资源分配与作业调度:多租户集群的资源博弈
16 性能诊断:Spark UI、Event Log 与调优思路
17 Spark 与 Flink:两种计算引擎的设计选择

参考资料

  • Matei Zaharia et al. Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing. NSDI 2012.
  • Matei Zaharia et al. Spark: Cluster Computing with Working Sets. HotCloud 2010.
  • Apache Spark 官方文档:RDD Programming Guide. https://spark.apache.org/docs/latest/rdd-programming-guide.html
  • Jeffrey Dean, Sanjay Ghemawat. MapReduce: Simplified Data Processing on Large Clusters. OSDI 2004.