Spark SQL 与 Catalyst:从 SQL 文本到物理算子树
上一篇解决了 Spark 的容错机制。这一篇进入 SQL 引擎的核心——Catalyst 优化器。
Spark SQL 不只是"在 Spark 上跑 SQL"。它是一个完整的查询编译器,把 SQL 文本或 DataFrame API 调用翻译成优化后的物理执行计划。这个翻译过程由 Catalyst 优化器驱动,经过四个阶段:解析、分析、优化、物理计划生成。
本文只抓一个问题:一条 SQL 从文本到可执行的物理算子树,中间经历了什么变换。
四阶段流水线
1 | |
DataFrame API 不经过 Parsing 阶段——它直接构建 Resolved Logical Plan(因为列名和类型在 API 调用时已经确定)。这也是 DataFrame 和 SQL 在底层共享同一套优化器的基础。
Tree + Rule 架构
Catalyst 的核心设计是两个抽象:TreeNode 和 Rule。
TreeNode 是所有计划节点的基类。LogicalPlan、Expression、SparkPlan(物理计划)都继承自 TreeNode。每个节点可以有零到多个子节点,构成一棵树。
1 | |
Rule 是对树的变换函数。每个 Rule 接收一棵树,返回一棵变换后的树。变换通过模式匹配实现——匹配到特定的节点模式时替换为优化后的节点。
1 | |
优化器就是一组 Rule 的有序集合(Batch)。每个 Batch 包含若干 Rule,按顺序对计划树反复应用,直到树不再变化(达到不动点)或达到最大迭代次数。
阶段一:Parsing
Spark SQL 使用 ANTLR4 生成的解析器把 SQL 文本解析成 Unresolved Logical Plan。这个阶段纯粹是语法分析,不涉及任何语义检查。
1 | |
此时列名 “name” 和表名 “users” 都还是字符串,没有绑定到实际的表定义和数据类型。如果 SQL 有语法错误(比如 SELEC 拼错),这个阶段就会报 ParseException。
阶段二:Analysis
Analyzer 通过查询 Catalog(元数据目录)把 Unresolved Plan 中的引用绑定到实际的表和列。
1 | |
每个列被分配一个唯一的 ID(如 name#12),避免同名列的歧义。如果引用了不存在的表或列,这个阶段会报 AnalysisException。
Analysis 阶段还会做隐式类型转换。比如 WHERE age > ‘18’ 中 age 是 INT 而 ‘18’ 是 STRING,Analyzer 会插入一个 Cast 节点把 ‘18’ 转为 INT。
阶段三:Optimization
Optimizer 是 Catalyst 的核心,包含数十个优化规则。主要的优化类别:
谓词下推(Predicate Pushdown):把 Filter 节点尽量推到数据源附近,提前过滤掉不需要的行。
1 | |
列裁剪(Column Pruning):只读取查询实际需要的列,不读取多余的列。对于列式存储(Parquet/ORC),这可以减少大量 I/O。
常量折叠(Constant Folding):在编译期计算常量表达式。WHERE year > 2020 + 5 变为 WHERE year > 2025。
Join 重排序(Join Reordering):当多表 Join 时,选择代价最小的 Join 顺序。小表优先 Join 可以减少中间结果的大小。
简化表达式(Simplify Expressions):WHERE NOT (age <= 18) 简化为 WHERE age > 18。
阶段四:Physical Planning
Physical Planner 把优化后的逻辑计划转化为可执行的物理计划。核心决策是为每个逻辑算子选择一个物理实现。
Join 策略选择是最典型的例子。同一个逻辑 Join,Spark 可以用不同的物理算法:
1 | |
Physical Planner 根据表的大小统计信息(来自 Catalog 或运行时统计)选择最优策略。如果统计信息不准确,选择可能不是最优的——这也是 AQE(第 10 篇)要解决的问题。
实验:用 explain 观察四阶段变换
1 | |
在输出中可以看到:Parsed Plan 中 1 = 1 还存在;Optimized Plan 中 1 = 1 被常量折叠规则消除了(AND true 等价于直接去掉);Physical Plan 中显示了实际使用的 Scan 和 Filter 物理算子。
模式提炼
Catalyst 的 Tree + Rule 架构体现了编译器前端的经典设计:把变换分解为一组独立的规则,每个规则只负责一种模式匹配和替换,通过反复应用达到全局最优。
这个架构的优势是可扩展性:增加一种新的优化只需要添加一个 Rule,不需要修改现有代码。Spark 的扩展点 SparkSessionExtensions 允许第三方库注入自定义的 Analyzer Rule 和 Optimizer Rule。
同样的架构出现在编译器的 pass 管理器(LLVM 的 PassManager)、数据库的查询改写器(PostgreSQL 的 rewrite rules)、甚至 CSS 的层叠规则(多条规则按优先级依次应用到 DOM 节点)。
工程迁移表
| Spark/Catalyst 概念 | 传统数据库对应 | Flink 对应 | 编译器对应 |
|---|---|---|---|
| Parsing(SQL → AST) | SQL Parser | Flink SQL Parser(Calcite) | Lexer + Parser |
| Analysis(绑定名称和类型) | Semantic Analysis | Calcite Validator | 语义分析(符号表查询) |
| Optimization(规则重写) | Query Optimizer(RBO + CBO) | Calcite Optimizer | 优化 Pass(常量传播、死代码消除) |
| Physical Planning(策略选择) | Plan Generator(执行计划生成) | Flink Planner | 指令选择(ISel) |
| Catalog | 系统目录(pg_catalog) | Flink Catalog | 符号表 |
| Rule | Rewrite Rule | Calcite RelOptRule | Optimization Pass |
常见误解
误解一:“DataFrame API 比 SQL 快”。DataFrame API 和 SQL 在 Analysis 之后进入完全相同的优化和执行路径。性能没有差异。区别只在于书写方式和编译期类型检查。
误解二:“Catalyst 只做基于规则的优化”。Catalyst 同时支持 RBO(Rule-Based Optimization)和 CBO(Cost-Based Optimization)。从 Spark 2.2 起,Join 重排序和 Aggregate 策略选择可以基于表的统计信息(行数、列的不同值数量等)做代价估算。
误解三:“explain 显示的计划就是实际执行的计划”。AQE 开启后(Spark 3.0+ 默认开启),实际执行计划可能在运行时被修改。explain 显示的是编译期的计划,运行时可能因为 Shuffle 后的统计信息变化而切换 Join 策略或合并分区。
练习
-
构造一条包含子查询的 SQL(如 WHERE id IN (SELECT id FROM orders)),用 explain(true) 观察 Optimizer 是否把子查询改写成了 Join。
-
对两个表做 Join,分别设置 spark.sql.autoBroadcastJoinThreshold 为 10MB 和 -1(禁用广播),用 explain 观察物理计划中 Join 策略的变化。
-
用 spark.sql(“ANALYZE TABLE users COMPUTE STATISTICS FOR ALL COLUMNS”) 收集统计信息后,再次 explain 一条涉及该表的查询。对比收集统计信息前后的物理计划是否变化。
系列导航
| 序号 | 主题 | 状态 |
|---|---|---|
| 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:两种计算引擎的设计选择 |
参考资料
- Michael Armbrust et al. Spark SQL: Relational Data Processing in Spark. SIGMOD 2015.
- Apache Spark 源码:
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/https://github.com/apache/spark - Apache Spark 官方文档:Spark SQL Guide. https://spark.apache.org/docs/latest/sql-programming-guide.html
- Databricks Blog: Deep Dive into Spark SQL’s Catalyst Optimizer. https://www.databricks.com/blog/2015-04-13/deep-dive-into-spark-sqls-catalyst-optimizer.html
