SparkSQL 数据源与底层架构深度剖析

摘要:SparkSQL 作为 Apache Spark 生态中最核心的模块之一,不仅提供了统一的 SQL 查询入口,更承载了 DataFrame/Dataset API 的全部执行能力。本文从数据源接入机制入手,深入 Catalyst 优化器四阶段流水线、WholeStageCodegen 代码生成、Tungsten 执行引擎等核心组件,结合源码级追踪,全面剖析 SparkSQL 底层架构设计。无论你是日常写 SQL 的数据工程师,还是需要调优 Spark 作业的架构师,这篇文章都会让你对 SparkSQL 有一个全新的认识。

关键词:SparkSQL、Catalyst、Tungsten、DataSource V2、谓词下推、WholeStageCodegen、DataFrame、Dataset、RDD、大数据架构


一、引言:为什么 SparkSQL 值得被深入理解?

如果你使用过 Spark,那你一定写过这些代码:

scala 复制代码
val df = spark.read.format("parquet").load("/data/events")
df.filter($"date" === "2024-01-01").groupBy("uid").count().show()

或者是等价的 SQL:

sql 复制代码
SELECT uid, COUNT(*) FROM events WHERE date = '2024-01-01' GROUP BY uid;

你可能已经知道 SparkSQL "很快"。但 它为什么快? 当我们写下 spark.read.parquet() 时,Spark 做了什么?当我们写 filter().groupBy() 时,这些操作是如何被转换成一个高效的 RDD DAG 的?

答案藏在四个核心组件之中:DataSource API (数据源接入层)、Catalyst Optimizer (优化器)、Tungsten Engine (执行引擎)以及 WholeStageCodegen(全阶段代码生成)。本文将以"数据源 → 优化器 → 执行引擎"为主线,带你一览 SparkSQL 底层架构全貌。


二、数据源全景架构:SparkSQL 如何统一接入多源异构数据?

2.1 四大数据源类别

SparkSQL 的 DataSource API 设计目标非常明确:让所有数据源都像操作关系型数据库一样简单。从 Source 类型来看,主要分为四大类:

类别 典型数据源 Format 参数 特点
结构化文件 Parquet、ORC、Avro、JSON、CSV parquet / orc / json / csv Schema 自描述,支持列裁剪
数据库与数仓 Hive、HBase、MySQL、PostgreSQL jdbc / hive 支持谓词下推,减少数据传输
流式数据 Kafka、Kinesis、Socket kafka / kinesis / socket Structured Streaming 统一 API
云对象存储 S3、ADLS Gen2、GCS、OSS 依赖文件 format Hadoop FileSystem 抽象层复用

无论底层是什么,对用户而言,接口始终是统一的 DataFrameReader / DataFrameWriter

scala 复制代码
// 统一读取接口
spark.read
  .format("parquet")    // 或 "json", "orc", "jdbc", "kafka" ...
  .option("key", "val") // 数据源特定参数
  .load("/path")

// 统一写入接口
df.write
  .mode(SaveMode.Append)
  .format("parquet")
  .partitionBy("dt")
  .save("/output")

2.2 DataSource API 演进:V1 → V2

Spark 最初使用 DataSource V1 API,基于 RelationProviderSchemaRelationProvider 等 trait 实现,支持基本的读写和谓词下推。但 V1 存在明显局限:

  • 写操作不支持事务:无法原子性地提交多个文件
  • 缺乏列式读取支持:无法精细控制需要读取哪些列
  • 流式支持不足:与 Structured Streaming 集成困难

DataSource V2 API (Spark 2.3+) 通过 ReadSupportWriteSupport 接口解决了以上问题:

java 复制代码
// DataSource V2 ReadSupport 接口(简化)
public interface ReadSupport extends DataSourceV2 {
    DataSourceReader createReader(DataSourceOptions options);
}

public interface DataSourceReader {
    List<DataPartition> planInputPartitions();  // 分区规划
    StructType readSchema();                     // Schema 获取
    // 支持列式读取、过滤下推
    List<InputPartition<InternalRow>> planInputPartitions(
        List<Filter> filters);
}

V2 的核心增强包括:列式读取(Columnar Batch Read)分区裁剪(Partition Pruning)动态过滤(Dynamic Filtering)事务性写入(如 Iceberg/Delta Lake)。


三、Catalyst 优化器:SparkSQL 的"大脑"

如果说 DataSource API 是 SparkSQL 的"感官系统",那么 Catalyst Optimizer 就是 SparkSQL 的大脑。它负责将用户书写的 SQL 或 DataFrame 操作,经过四阶段转换,最终生成高效的 RDD 执行计划。

3.1 Phase 1:Analysis------语义分析

SQL 经 ANTLR4 解析后生成 Unresolved Logical Plan。此时表名、列名尚未与 Catalog 中的元数据进行绑定,类型也未检查。

Analysis 阶段通过 SessionCatalog 查询 Hive Metastore,将 UnresolvedRelation 替换为具体的表元数据,进行类型推断和隐式转换。核心规则包括:

  • ResolveRelations ------ 解析 FROM 子句中的表引用
  • ResolveReferences ------ 解析 SELECT/WHERE 中的列引用
  • TypeCoercion ------ 类型自动转换(如 String → Int)
  • ResolveFunctions ------ 解析内置函数和 UDF

3.2 Phase 2:Logical Optimization------规则优化(RBO)

分析后的 Logical Plan 进入 70+ 条优化规则 的优化器,核心规则包括:

优化规则 作用 示例
Predicate Pushdown 将 Filter 下推到数据源 WHERE date='2024-01-01' 下推到 Parquet Reader
Column Pruning 裁剪不需要的列 SELECT name FROM t 只读 name 列
Constant Folding 编译期计算常量表达式 SELECT 1+2+3SELECT 6
Limit Pushdown 将 LIMIT 下推 SELECT * FROM t LIMIT 10 → Reader 只取前 10 行
Boolean Simplification 逻辑表达式简化 WHERE a=1 AND a=1WHERE a=1
Outer Join Elimination 消除不必要的 Outer Join 当 Right 表不需要其他列时转为 Inner Join

一条看似简单的 SQL 经过 RBO 后,执行计划可能已被大幅优化。我们可以通过 explain() 验证:

scala 复制代码
df.filter($"uid" > 1000).select("name").explain(true)

// == Parsed Logical Plan ==
// 'Project ['name]
// +- Filter (uid#0 > 1000)
//    +- Relation[id#0L,uid#1L,name#2] parquet

// == Analyzed Logical Plan ==
// Project [name#2]
// +- Filter (uid#1 > 1000)
//    +- Relation[id#0L,uid#1L,name#2] parquet

// == Optimized Logical Plan ==
// Project [name#2]        ← Column Pruning: 只读 name
// +- Filter (uid#1 > 1000)  ← Predicate Pushdown: 已在 Parquet 层过滤
//    +- Relation[id#0L,uid#1L,name#2] parquet

3.3 Phase 3:Physical Planning------代价优化(CBO)

Optimized Logical Plan 尚不能直接运行,因为 JOIN 算法、Shuffle 策略、聚合方式 都尚未确定。Physical Planning 阶段通过 Strategy + Cost Model 来选择最优的物理执行计划。

关键决策包括:

scala 复制代码
// 示例:JoinSelection 策略
if (leftSize < spark.sql.autoBroadcastJoinThreshold) {
    BroadcastHashJoinExec(left, right)  // 小表广播,避免 Shuffle
} else {
    SortMergeJoinExec(left, right)       // 大表排序归并 Join
}

启用 CBO 需要执行 ANALYZE 收集统计信息:

sql 复制代码
ANALYZE TABLE events COMPUTE STATISTICS;
ANALYZE TABLE events COMPUTE STATISTICS FOR COLUMNS uid, event_type;
SET spark.sql.cbo.enabled=true;
SET spark.sql.cbo.joinReorder.enabled=true;

3.4 Phase 4:Code Generation------WholeStageCodegen

传统关系型数据库大多采用 Volcano Iterator Model ------每个算子都有 open() / next() / close() 接口,层层嵌套调用 next()

复制代码
Project.next() → Filter.next() → Scan.next() → (virtual call overhead × millions)

SparkSQL 抛弃了这种模式,而是将 多个算子融合为一个 Java 方法,通过 Janino 编译器在运行时动态编译为字节码,从而:

  • 消除虚函数调用 ------ 所有逻辑在一个 for-loop 中完成
  • 利于 JIT 内联 ------ JVM 可以更激进地优化编译
  • CPU Cache 友好 ------ 数据在寄存器/缓存间流转,减少内存访问

在 Spark Web UI 的 SQL 页面中,WholeStageCodegen (1) 标记的 Stage 代表已启用全阶段代码生成:

复制代码
== Physical Plan ==
*(1) Project [name#2]
+- *(1) Filter (uid#1 > 1000)
   +- *(1) ColumnarToRow
      +- FileScan parquet [name#2,uid#1] Batched: true, DataFilters: [(uid#1 > 1000)]

这里的 *(1) 就是 WholeStageCodegen 标识。


四、Tungsten:大数据时代的"特制 CPU 缓存格式"

Catalyst 决定了 执行什么 ,Tungsten 决定了 如何高效执行。它的核心设计包括:

4.1 堆外内存 + 二进制格式

传统 RDD 中的对象存在 JVM Heap 上,每个 Java 对象都有 Object Header + GC 开销。Tungsten 改用 UnsafeRow 直接操作堆外内存:

复制代码
传统 Java Object (堆内):
┌──────────────┬────┬────┬──────────────┐
│ Object Header│ id │uid │    name      │
│    (12-16B)  │ 8B │ 8B │  var-length  │
└──────────────┴────┴────┴──────────────┘

Tungsten UnsafeRow (堆外):
┌─────┬────┬──────────┐
│ 0-8B│8-16│ 16-N     │  ← 紧致打包,无 Object Header
│ null│ uid│ name bytes│
│ bits│    │          │
└─────┴────┴──────────┘

4.2 缓存友好的数据结构

Tungsten 的 Sort 和 Hash Aggregation 使用 指针数组 + 二进制行 结构,数据以列式方式组织,让 CPU L1/L2 缓存更高效地预取数据:

java 复制代码
// Tungsten 内部排序的核心结构
// 排序只操作 8 字节指针,实际数据留在原位
long[] ptrArray = new long[numRows];  // 每个元素 = 8 字节指针
// ... 排序的 comparator 解引用指针来比较实际值

五、DataFrame / Dataset / RDD:三者的关系与选择

Spark 提供了三个层级的编程抽象,理解它们的关系对于写出高性能代码至关重要:

5.1 三者关系

特性 DataFrame DatasetT RDDT
类型安全 ❌ 运行时 ✅ 编译时 ✅ 编译时
Catalyst 优化 ✅ 完全 ✅ 完全 ❌ 无
Tungsten 二进制 ✅ UnsafeRow ✅ Encoder ❌ Java Heap
代码简洁度 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐
性能 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐
适用场景 ETL、SQL 分析 复杂类型变换 自定义 Partition

5.2 互转路径

scala 复制代码
// DataFrame ↔ Dataset
val df: DataFrame = spark.read.parquet("...")
val ds: Dataset[Event] = df.as[Event]             // DataFrame → Dataset
val df2: DataFrame = ds.toDF()                     // Dataset → DataFrame

// DataFrame/Dataset → RDD(损失优化)
val rdd: RDD[Row] = df.rdd                         // DataFrame → RDD (内部已是 UnsafeRow → Row)
val rdd2: RDD[Event] = ds.rdd                      // Dataset → RDD

// RDD → DataFrame/Dataset
val df3 = spark.createDataFrame(rdd, schema)        // 手动指定 Schema
val ds3 = spark.createDataset(rdd)                  // 需要 Encoder

六、全链路执行------一条 SQL 的完整旅程

让我们追踪 SELECT uid, COUNT(*) FROM events WHERE dt='2024-01-01' GROUP BY uid 这条 SQL 的完整执行过程:

Step-by-Step

  1. Parser:ANTLR4 进行词法/语法分析,生成 AST → Unresolved LogicalPlan
  2. Analyzer:查询 Hive Metastore / SessionCatalog,解析 events 表的 schema,确定 uid 类型、dt 为 partition 列
  3. Optimizer (RBO)
    • PushDownPredicate: dt='2024-01-01' → 分区裁剪,只扫描 dt=2024-01-01/ 目录
    • ColumnPruning: uid → 只读取 uid 列
    • ReplaceDistinctWithAggregate → 转换 COUNT
  4. Planner (CBO):根据 events 表大小,选择合适的 Join 和 Agg 策略(HashAggregate vs SortAggregate)
  5. WholeStageCodegen :将 Scan → Filter → Project → PartialAgg 融合为一个 Java 方法
  6. DAGScheduler:切分为 Stage 0(Map)→ Stage 1(Reduce),生成 Task
  7. Executors:执行 Task,使用 Tungsten UnsafeRow 批量处理,结果返回 Driver

七、调优实战:榨干 SparkSQL 的性能

7.1 开启关键配置

properties 复制代码
# Catalyst / CBO
spark.sql.cbo.enabled=true
spark.sql.cbo.joinReorder.enabled=true
spark.sql.statistics.histogram.enabled=true

# Adaptive Query Execution (Spark 3.0+)
spark.sql.adaptive.enabled=true
spark.sql.adaptive.coalescePartitions.enabled=true
spark.sql.adaptive.skewJoin.enabled=true

# WholeStageCodegen
spark.sql.codegen.wholeStage=true
spark.sql.codegen.maxFields=100

# Tungsten
spark.sql.tungsten.enabled=true
spark.sql.inMemoryColumnarStorage.batchSize=10000

7.2 使用 EXPLAIN 检查执行计划

sql 复制代码
-- 查看物理计划(含 WholeStageCodegen 标记)
EXPLAIN COST SELECT uid, COUNT(*) FROM events WHERE dt='2024-01-01' GROUP BY uid;

-- 查看完整的优化阶段
EXPLAIN EXTENDED SELECT ...

重点关注:

  • 是否存在 * (WholeStageCodegen 标记)------如缺失则可能 codegen 失败
  • 是否有 BroadcastHashJoin------大表之间不应出现 ShuffleHashJoin
  • Filter 是否被下推(PushedFilters)------如未下推,检查数据源是否支持

7.3 分区策略与数据源选择

  • Parquet :列式存储、丰富统计信息、谓词下推效果好------OLAP 场景首选
  • ORC:类似 Parquet,Hive 生态兼容性更好
  • Delta Lake / Iceberg / Hudi:支持 ACID、Time Travel、Schema Evolution,适合生产级数仓
  • 纯文本 (CSV/JSON):解析开销大,缺少统计信息,不适合大规模分析

八、总结

SparkSQL 的高性能并非单一技术的奇迹,而是 DataSource API、Catalyst Optimizer、Tungsten Engine、WholeStageCodegen 四层协同的成果:

  1. DataSource API 提供了统一的多源接入层,V2 API 进一步增强了列式读取和事务写入能力
  2. Catalyst Optimizer 通过四阶段(Analysis → RBO → CBO → CodeGen)将 SQL 逐步转换为高度优化的物理执行计划
  3. Tungsten 利用堆外内存和缓存友好的数据结构,将 CPU 和内存利用率提升到极致
  4. WholeStageCodegen 打破了 Volcano Iterator 模型的性能瓶颈,实现了算子级代码融合

理解这些底层机制,不仅能帮你写出更高效的 Spark 作业,更能在遇到性能瓶颈时,从执行计划中快速定位问题根源。当你下次面对一个慢查询时,不妨调出 EXPLAIN COST,对照本文的优化器管线,看看优化卡在了哪一步------这比盲目调参有效得多。


本文作者 :starzy

博客blog.starzy.cn

GitHubstarzy1990.github.io

专注 AI Agent · LangGraph · RAG · 大数据架构 · 数据工程实践

让 AI 真正落地,让数据创造价值

相关推荐
heimeiyingwang2 小时前
【架构实战】链路追踪与可观测性实践:从日志孤岛到全链路可见
架构
茫茫人海一粒沙2 小时前
Browser Automation for Coding Agents:六种架构方案深度对比
ai·架构
番茄炒鸡蛋加糖2 小时前
RabbitMQ 全套复盘 + Nacos+ES+MyBatis-Plus 梳理
分布式·rabbitmq
Elastic 中国社区官方博客2 小时前
你的 AI agent 不需要你的 API 密钥:使用 OAuth 2.1 对 Elasticsearch MCP 服务器进行身份验证
大数据·运维·人工智能·elasticsearch·搜索引擎·全文检索
Dawson Zhu3 小时前
为什么智能体 Agent 需要本体 Ontology
人工智能·语言模型·架构·制造·agi
重庆小透明3 小时前
深入探寻微服务【第四篇微服务监控实战】
微服务·云原生·架构
重庆小透明3 小时前
深入探寻微服务【第五篇微服务限流实战】
微服务·junit·架构
谢文峰4 小时前
Task Dispatch:基于 Codex 原生会话的任务分发 Skill
架构