从一条 SQL 语句到分布式任务执行,经历 6 个核心阶段。点击下方流程节点或展开卡片查看各阶段的内部机制。
这是整个流程的第一步。Spark SQL 使用 ANTLR4(Another Tool for Language Recognition) 作为其语法解析器。当你提交一条 SQL 语句时,ANTLR4 会根据 Spark 定义的 SQL 语法规则 SqlBase.g4 将文本解析为一棵抽象语法树(AST)。
随后,Spark 的 AstBuilder(继承自 ANTLR4 生成的 Visitor) 遍历 AST,将其转换为 Spark 内部的 Unresolved Logical Plan。 这个阶段的计划是"未解析"的——表名、列名等标识符仅是字符串,没有与实际数据源绑定。
| 组件 | 职责 |
|---|---|
| SparkSqlParser | 入口类,持有 ANTLR4 Lexer/Parser 实例,协调整个解析流程 |
| SqlBase.g4 | ANTLR4 语法文件,定义了 Spark SQL 的完整语法规则(SELECT / INSERT / CREATE / ALTER 等) |
| AstBuilder | 遍历 ANTLR4 生成的 ParseTree,构建 Spark 内部的 LogicalPlan 节点树 |
| UnresolvedRelation | 表示一个尚未绑定的表引用(此时只有表名字符串) |
| UnresolvedAttribute | 表示一个尚未绑定的列引用(此时只有列名字符串) |
df.filter(...).groupBy(...) 时,
Spark 也会在内部构建等价的 Unresolved Logical Plan,然后进入后续优化流程。SQL 和 API 最终殊途同归。
Analysis 阶段由 Analyzer 负责,它通过查询 Catalog(元数据目录) 来解析上一步留下的未绑定引用。这一步回答的核心问题是: "这些名字到底指的是什么?类型是否匹配?"
Analyzer 采用规则批处理(Rule Batches)的方式工作。每个 Batch 包含一组规则, 会被反复应用(固定点迭代),直到计划不再变化或达到最大迭代次数。这保证了所有依赖关系都被正确解析。
AnalysisException,说明语法正确但语义有问题。
这是 Catalyst Optimizer 的核心阶段。优化器通过一系列 基于规则的优化(RBO)和基于代价的优化(CBO), 对逻辑计划进行等价变换,在不改变语义的前提下大幅提升执行效率。
Spark 2.2+ 引入了 CBO,利用表和列的统计信息(行数、去重基数、NULL 比例、直方图等) 来做更智能的决策。典型场景:
| 优化 | 机制 |
|---|---|
| Join 排序 | 将小表放到 Join 的构建侧(Build Side),大表放到探测侧(Probe Side) |
| Join 策略选择 | 根据表大小选择 BroadcastHashJoin / SortMergeJoin / ShuffledHashJoin |
| 聚合策略 | 根据 distinct 值数量决定 Hash Agg vs Sort Agg |
ANALYZE TABLE t COMPUTE STATISTICS
和 ANALYZE TABLE t COMPUTE STATISTICS FOR COLUMNS c1, c2。
没有统计信息时会回退到 RBO。
物理计划阶段由 SparkPlanner 负责,它将逻辑算子映射为具体的物理执行算子(SparkPlan)。 一个逻辑算子可能有多种物理实现方式——这个阶段就是选出最优的那个。
| 逻辑算子 | 物理算子候选 | 选择依据 |
|---|---|---|
| Join |
BroadcastHashJoinExec SortMergeJoinExec ShuffledHashJoinExec BroadcastNestedLoopJoinExec |
表大小、Join 类型、是否有等值条件。小表 < spark.sql.autoBroadcastJoinThreshold(默认 10MB)时选 BroadcastHashJoin |
| Aggregate |
HashAggregateExec SortAggregateExec ObjectHashAggregateExec |
聚合函数是否支持 partial merge、数据类型是否可 mutable |
| Sort | SortExec | 使用 Tungsten UnsafeRow 的内存排序,支持溢写到磁盘 |
| Scan |
FileSourceScanExec InMemoryTableScanExec |
数据源类型(Parquet/ORC/CSV vs 缓存表) |
物理计划生成后,EnsureRequirements 规则会检查每个算子对输入数据的 分区要求(Distribution)和排序要求(Ordering)。 如果子节点的输出不满足要求,就自动插入 ShuffleExchangeExec(重分区) 或 SortExec(排序)。
Spark 3.0+ 引入了 AQE,允许在运行时根据 Shuffle 阶段的实际统计数据 动态调整物理计划。这是一个重大突破——计划不再是一成不变的。
这是 Spark 2.x 引入的一项颠覆性优化。传统的 Volcano 执行模型(每个算子拉取一行、逐行处理) 会在算子之间产生大量虚函数调用开销。Whole-Stage Code Generation 将 整个 Stage 内的多个算子融合成一个 Java 方法,直接编译为 JVM 字节码执行。
| 对比维度 | Volcano 迭代器模型 | Whole-Stage CodeGen |
|---|---|---|
| 执行方式 | 逐行拉取:每个算子的 next() 调用子算子的 next() | 将多算子融合为一个紧凑循环(tight loop) |
| 函数调用 | 每行 × 每算子 = 大量虚函数调用 | 单次方法调用处理整批数据 |
| CPU 缓存 | 频繁跳转,缓存命中率低 | 连续内存访问,缓存友好 |
| 性能提升 | 基线 | 通常提升 2-10x |
与 CodeGen 配合的是 Project Tungsten,它绕过 JVM 的对象模型, 使用 UnsafeRow(紧凑二进制行格式)存储数据:
*(如 *(1) Filter)
表示该算子已被 Whole-Stage CodeGen 融合。没有星号的算子(如 Exchange)是融合边界。
最终的物理计划被转换为 RDD(Resilient Distributed Dataset)的 DAG, 然后交给 Spark Core 的调度引擎执行。这是真正"动起来"的阶段。
DAGScheduler 将 RDD DAG 按宽依赖(Shuffle 依赖)切分为多个 Stage。 同一 Stage 内的算子通过窄依赖(Pipeline)连接,不需要数据交换,可以在单个 Task 中流水线执行。
| 依赖类型 | 特征 | 示例 |
|---|---|---|
| 窄依赖 | 父 RDD 的每个分区最多被一个子 RDD 分区使用 | map, filter, union(对齐分区时) |
| 宽依赖 | 父 RDD 的分区被多个子 RDD 分区使用,需要 Shuffle | groupByKey, reduceByKey, join(非 broadcast) |
每个 Stage 被进一步拆分为与分区数相等的 Task,分发到集群的 Executor 上并行执行。 调度策略考虑数据本地性(Data Locality):
Shuffle 是整个执行中最昂贵的操作。数据需要在网络上重新分布—— Map 端将数据按 key 分桶写入本地磁盘(Shuffle Write),Reduce 端从所有 Map 端拉取对应分区的数据(Shuffle Read)。
spark.sql.shuffle.partitions、
利用 Bucket 表预分区、以及 AQE 的动态分区合并。