上一篇从 QueryExecution 里看到了“懒执行”的代码真相。但逻辑计划最终怎么变成一堆真正并行跑的任务?这就引出执行模型的下半场:依赖、Shuffle 和 Stage。
先给一句话全局:Spark 的任务执行,就是从“数据”出发,画一张依赖图,在图里切出若干段,每段内部并行算、段与段之间靠 Shuffle 衔接。
01三个实体与并行度
- Partition(分区):数据被切成的小块,并行计算的基本单位
- Task(任务):处理一个 Partition 的工作单元
- Executor(执行器):真正跑 Task 的进程
关系:一个 Executor 上可以跑多个 Task,每个 Task 负责一个 Partition。
并行度 = 有多少个 Partition。分区多,任务就多,能同时算的越多。
这个等式是后面所有判断的出发点。
02窄依赖 vs 宽依赖:全部的核心
Spark 把算子作用在很多 Partition 上,每个下游 Partition 要“看”几个上游 Partition 才能算出来,决定了两种依赖。
窄依赖(Narrow Dependency):一个下游分区只看一个上游分区
上游: P0 P1 P2
| | |
下游: P0' P1' P2' ← 一一对应filter、select、map 都是。每个分区分头算,互不干扰——大户人家各自扫门前雪,一个分区算完就完事,不需要等其他分区。
宽依赖(Wide Dependency):一个下游分区要看多个上游分区
上游: P0 P1 P2
\ | /
\ | / ← 下游每个分区都要收上游所有分区里的同类数据
v v v
P0' P1' ...groupBy、join 都是。要把分散在不同分区里的“同一类”数据聚到一起——比如 delivered 状态的订单散落在 P0、P1、P2,要统计总量,就得把三个分区的 delivered 全捞到同一个下游分区。这就发生了数据的跨分区搬家。
03Shuffle = 宽依赖引发的“搬家”
Shuffle 就是数据因为需要重新分组,被跨分区(甚至跨机器)重新分发的过程。
凡是产生宽依赖的算子(groupBy、join、repartition 等)都会触发 Shuffle。它贵,是因为搬家不免费:
- 上游分区把数据按 key 打散,写成一堆临时 Shuffle 文件
- 下游分区去读上游各机器产生、属于自己的那些文件
- 涉及网络传输、磁盘读写、序列化
一句话记:窄依赖“原地算”,宽依赖“要搬家”,搬家 = Shuffle,搬家最费钱。
补充一个概念细节:Shuffle 不是瞬间动作,而是一条“上游写文件 → 下游读文件”的流水线。写端(Map 侧)按 key 哈希把数据归桶、每个桶落成临时文件;读端(Reduce 侧)启动后去抓取属于自己分区的分片,合并、排序后继续算。上游 3 分区、下游 2 分区,老式实现会产生 3×2 = 6 个分片文件——这也是“小文件问题”的源头之一。
04Stage:被 Shuffle 切开的“段”
Stage 是一串中间没有 Shuffle 的计算步骤。Shuffle 是 Stage 之间的分界线。
为什么这么切?
- 一个 Stage 内部全是窄依赖 → 各 Partition 互不依赖 → 可以并行、连续地算,不用等别人
- 一旦碰到 Shuffle → 下游 Stage 必须等上游 Stage 所有数据都“搬完”才能开始
看一段链式操作:
orders.filter(...) # 窄依赖
.select(...) # 窄依赖
.groupBy(...).count() # 宽依赖 → Shuffle!
.orderBy(...) # 又一次重排 → 又一次 Shuffle切出来就是:
Stage 0: 读 → filter → select ──Shuffle──▶ Stage 1: groupBy+count ──Shuffle──▶ Stage 2: orderBy记法:你看到多少个 Shuffle,基本就有“Shuffle 数 + 1”个 Stage。 Shuffle 就是切蛋糕的刀。
05Job / Stage / Task:三个层级
层级关系:
Application(应用)
└── Job ① (一个 Action 触发一个 Job)
├── Stage 0
├── Stage 1 ← 被 Shuffle 切开
└── Stage 2
└── Task 若干(= 该 Stage 的分区数)Job 的触发单位是 Action(show() / count() / write() / collect())。你调一次 action,就产生一个 Job——前面攒了多少 transformation,action 一来全部一口气执行,这就是一个 Job。
Stage 是 Job 内部被 Shuffle 切成的段,段内全是窄依赖。
Task 是真正被 Executor 执行的最小单元,一个 Partition 对应一个 Task。
核心区别一句话:
Job 是用户(Action)划的界;Stage 是系统(Shuffle)划的界。
在 Spark UI(driver 的 4040 端口)里看得最清楚:最上面是 Jobs 列表,每个 Action 一个;点进一个 Job 看到 Stage DAG 图,被箭头(Shuffle)分成几段;每段里是 Tasks,一堆并行的小方块。
记忆链:Action 数 = Job 数;Job 里 Stage 数 ≈ Shuffle 数 + 1;Stage 里 Task 数 = 分区数。
06repartition vs coalesce:两个“调分区”的工具
repartition(n):重排成 n 个分区,因为要重新分配数据 → 产生 Shuffle。适用:增加并行度、或按 key 归拢数据(repartition(8, "customer_id"))。
coalesce(n):把相邻分区拼一起 → 尽量不产生 Shuffle。适用:减少分区、少输出文件。
关键坑:coalesce(1)
df.coalesce(1).write.parquet(...) # ⚠️把所有分区并成 1 个 → 最后只剩 1 个 Task 在算,其他机器全闲着,变成单线程瓶颈。大数据很忌讳这个。
repartition = 打散重排(贵,有 Shuffle);coalesce = 就近合并(省,尽量不 Shuffle)。
07综合例题:把这条链走一遍
a = orders.filter("order_status = 'delivered'") # 窄依赖(原地过滤)
b = a.select("order_id", "customer_id") # 窄依赖(选列)
c = b.repartition(8, "customer_id") # ⚠️ 宽依赖 → Shuffle
d = c.groupBy("customer_id").count() # ⚠️ 再次 Shuffle
e = d.coalesce(1) # 合并成 1 个分区 → 单 Task 瓶颈拆解:
- 窄依赖:filter、select
- 产生 Shuffle:repartition(8)、groupBy(两次)
- repartition(8, "customer_id") 做了什么:按 customer_id 哈希,把相同客户的数据集中到 8 个分区,为 groupBy 做准备
- coalesce(1) 的问题:只剩 1 个 Task,其他 Executor 空闲 → 性能瓶颈
- Stage 边界:在 repartition 和 groupBy 两次 Shuffle 处切 → 大约 3 个 Stage
一张图收尾:
orders(3个分区)
│
├─ filter ── 窄依赖,原地算
├─ select ── 窄依赖,原地算
│ ┌─ Stage 0(不用等别人,并行跑)
│
├─ repartition(8) ─ 宽依赖 → 搬家 → ● Shuffle ①
│ ┌─ Stage 1
├─ groupBy ─────── 宽依赖 → 搬家 → ● Shuffle ②
│ ┌─ Stage 2
└─ coalesce(1) ── 就近合并(几乎不 Shuffle)→ 单 Task08小结
这条线到这里就通了:Partition → 窄/宽依赖 → Shuffle → Stage → repartition/coalesce → Job。
核心一句话:窄依赖原地算、宽依赖要搬家(Shuffle)、Stage 以 Shuffle 为界翻篇、coalesce(1) 是单线程陷阱。
下一篇计划顺着“Stage 是系统划的界”继续往下钻:DAGScheduler 到底怎么把一张 DAG 切成 Stage、Shuffle 在源码里对应哪个类——把这篇的概念在 SparkPlan 和 ShuffleManager 的代码里对上号。