61.2 Spark 计算模型

一句话

Spark 的核心只有两条:RDD 血统(lineage)记录 “怎么算出来”、DAG 按宽依赖切 stage;同一个需求,性能差异几乎全部来自 “shuffle 了多少字节”——写法只差一行,代价可能差 25 倍。

窄依赖与宽依赖

依赖 含义 是否 shuffle 是否切 stage 例子
窄依赖 每个父分区最多被一个子分区使用 否 否 map、filter、union
宽依赖 子分区依赖多个父分区 是 是 groupByKey、join(未广播)、repartition

stage 的边界就是宽依赖:一个 stage 内部可以流水线执行(不落盘),跨 stage 必须 shuffle(落盘 + 网络传输)。

实测:一行写法差 25 倍 shuffle

5000 万条记录、每条 100B:

写法 shuffle 字节 stage 数
map / filter(窄依赖) 0 1
groupByKey 5.0GB 2
reduceByKey(map 端预聚合) 0.2GB 2

reduceByKey 与 groupByKey 语义相近,但前者在 map 端先做局部聚合,shuffle 只剩 “每个 key 一个值” 而不是 “每个 key 的所有值”。25 倍的差距就是这么来的。

// ❌ 全量 shuffle:每个 key 的所有 value 都要过网络
rdd.groupByKey().mapValues(_.sum)
// ✅ map 端预聚合:先局部求和,再 shuffle
rdd.reduceByKey(_ + _)

三个决定性能的选择

选择 默认 何时改
分区数 由父 RDD 决定 shuffle 后分区太少 → 单 task 数据量大;太多 → 小文件与调度开销
join 策略 大表 join 大表 = shuffle join 小表 → broadcast join(不发 shuffle)
缓存 不缓存 同一 RDD 被多次使用 → persist()(内存/磁盘)

反模式

反模式 后果
groupByKey 做聚合 shuffle 全量数据,网络与磁盘爆炸
collect() 拉全量结果到驱动 OOM,驱动成瓶颈
小表 join 大表不用 broadcast 白做一次大 shuffle
频繁 repartition 每次都全量 shuffle
缓存所有中间结果 内存被占满,反而频繁 spill
分区数固定写死 数据量变化后 task 过大/过小

三条铁律

🔑 性能问题先看 shuffle 字节,再看 CPU。

🔑 能在 map 端聚合的,绝不留到 reduce 端。

🔑 小表广播、大表分区;join 策略比 “调参” 影响大。

自检

Q1 为什么 reduceByKey 比 groupByKey 快这么多?

▸ 答案:因为 reduceByKey 在 map 端先做局部聚合(combine):每个分区先算出 “每个 key 的局部和”,shuffle 只传 “key + 局部和”;groupByKey 则把 “key 的所有 value” 原样传过去。实测差距 5.0GB → 0.2GB,约 25 倍。

Q2 为什么 “宽依赖” 必须切 stage?

▸ 答案:因为宽依赖需要按 key 重新分布数据:下游分区要拿到所有上游分区中对应 key 的数据,必须等 shuffle 完成。stage 边界就是 “必须落盘/等网络” 的地方,也是失败重算的边界。

Q3 分区数怎么定?

▸ 答案:按 “每个 task 处理的数据量” 定:经验值是每分区 100–200MB(可配),太小则调度开销大、小文件多;太大则单 task 慢、易 OOM。另外要考虑核数:分区数最好是 “总核数的 2–4 倍”,让 task 能并行填满。

Q4 broadcast join 为什么能省掉 shuffle?

▸ 答案:因为它把小表完整复制到每个 executor,大表的每个分区在本地与小表做 join——不需要按 key 重分布大表,所以没有 shuffle。前提是小表能放进内存(通常几十 MB 到几百 MB),否则广播本身会成为瓶颈。

Q5 cache() 与 persist() 的区别,什么时候必须用?

▸ 答案:cache() 等价于 persist(MEMORY_AND_DISK)(版本略有差异);persist() 可选存储级别。必须用的场景是 “同一个 RDD 被多个 action 复用”(如反复迭代的机器学习、多次查询的中间结果),否则每次 action 都会从头重算血统。

Q6 Spark 为什么比 MapReduce 快,又不总是快?

▸ 答案:快在 DAG + 内存流水线:窄依赖不落盘、多 stage 一次调度;MapReduce 每个 job 都要落盘。但 “不总是快” 是因为:shuffle 仍要落盘、内存不足会 spill、JVM 与 GC 开销、以及 “小作业启动开销” 相对更高。

Q7 血统(lineage)丢了会怎样?

▸ 答案:血统是容错机制:分区丢失时按血统重算。如果链路太长(几百个转换),重算代价极大,所以要 checkpoint() 截断血统;如果血统被截断又没有 checkpoint,丢失分区就无法恢复,只能重跑整个作业。

关联