Spark SQL 是 Apache Spark 用于处理结构化与半结构化数据的核心模块。它提供了一个称为 DataFrame / Dataset 的编程抽象,并在底层通过 Catalyst 优化器 和 Tungsten 高性能执行引擎,将声明式的 SQL 语句或 DSL(领域特定语言)调用编译为高度优化的物理执行计划,使得大规模分布式结构化数据查询能够兼具表达力与执行性能。

核心特性概览:

  • 双重编程接口: 支持纯 SQL 语法查询以及基于 Scala/Python 的链式 DSL(DataFrame API)操作。
  • 统一数据访问: 能够以一致的接口无缝读取 Parquet、ORC、JSON、CSV、JDBC 及 Hive 表等多种异构数据源。
  • 端到端全链路优化: 依托 Catalyst 优化器实现谓词下推、列裁剪、常量折叠与 CBO(基于代价的优化);依托 Tungsten 引擎实现内存字节码级全阶段代码生成(Whole-Stage Code Generation)。

一、Spark SQL 核心架构与 Catalyst 优化引擎

Spark SQL 之所以能在多种数据源上实现超高性能计算,关键在于其统一的解析与优化框架——Catalyst 优化器。

1.1 执行计划演进流程

一个 SQL 语句或 DataFrame 表达式从输入到最终在集群上执行,经历以下四个阶段:

  1. Analysis(分析阶段): 借助 Catalog 查找表名、字段名及函数定义,将抽象语法树解析为合法的 Analyzed Logical Plan。
  2. Logical Optimization(逻辑优化): 应用标准的代数优化规则:
    • 谓词下推(Predicate Pushdown): 将 WHERE / filter 条件尽可能推迟到扫描底层存储时执行(如利用 Parquet 行组统计信息提前跳过数据块)。
    • 列裁剪(Column Pruning): 仅拉取查询中实际使用的列,避免无关宽表列加载到内存。
    • 常量折叠(Constant Folding): 提前计算常数表达式(如 1 + 1 直接替换为 2)。
  3. Physical Planning(物理规划): 逻辑算子被映射为一个或多个物理算子组合(如将逻辑 Join 映射为 BroadcastHashJoin、ShuffleHashJoin 或 SortMergeJoin),并结合 CBO(基于代价的优化) 选择成本最小的路径。
  4. Code Generation(代码生成): Tungsten 引擎将多个物理算子压平到一个 Java 函数中执行,消除了虚函数调用开销,实现原生指令级的内存操作。

二、基础操作:数据抽象与统一加载/写出

Spark SQL 提供了统一的 I/O 门面接口:DataFrameReader 与 DataFrameWriter。

2.1 结构化数据的读写规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.spark.sql

import org.apache.spark.sql.{SaveMode, SparkSession}

object DataIOMaster {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder()
.appName("SparkSQL-IO")
.master("local[*]")
.config("spark.sql.shuffle.partitions", "4")
.getOrCreate()

// 1. 读取 Parquet / JSON / CSV 数据源
val df = spark.read
.format("parquet")
// .option("mergeSchema", "true") // 按需开启 Schema 合并
.load("data/users.parquet")

// 2. 打印 Schema 元数据与数据抽样
df.printSchema()
df.show(5, truncate = false)

// 3. 写出数据并进行目录物理分区
df.write
.mode(SaveMode.Overwrite)
.partitionBy("country", "city") // 存储层按列建立分层目录
.parquet("output/partitioned_users")

spark.stop()
}
}

三、DataFrame DSL 核心操作实战

DSL(Domain Specific Language)提供了比拼装纯字符串 SQL 更安全、更易重构的函数式编程风格。

3.1 投影、过滤与条件派生

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example.spark.sql

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._

object DataFrameDslOps {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder()
.appName("DataFrame-DSL")
.master("local[*]")
.getOrCreate()

import spark.implicits._

val rawData = Seq(
("order_1", "electronics", 1299.99, "CN"),
("order_2", "clothing", 49.50, "US"),
("order_3", "electronics", 89.00, "CN"),
("order_4", "books", 15.20, "US"),
("order_5", "groceries", 30.00, "UK")
).toDF("order_id", "category", "price", "country")

// 1. 投影与算术派生
// 使用 when-otherwise 实现条件分支,并使用 expr 执行局部 SQL 表达式
val processedDF = rawData
.filter($"price" > 20.0 && $"country".isin("CN", "US"))
.select(
$"order_id",
$"category",
$"price",
// 条件分支转换
when($"price" >= 1000.0, "HIGH")
.when($"price" >= 50.0, "MEDIUM")
.otherwise("LOW").as("tier"),
// 动态计算折扣价
($"price" * 0.9).as("discount_price")
)

processedDF.show(false)

spark.stop()
}
}

四、高级分析操作:聚合、分组与窗口函数

4.1 多维聚合与透视(Pivot)

Spark SQL 支持基础的分组聚合(groupBy)以及高级透视分析(pivot):

1
2
3
4
5
6
// 统计各国家在各类别下的总销售额
val pivotSummary = rawData
.groupBy("country")
.pivot("category")
.agg(sum("price"))
.na.fill(0.0) // 处理透视后产生的 NULL 值

4.2 窗口函数(Window Specification)

窗口函数允许在不改变结果集行数的前提下,对分区数据进行滑动窗口聚合、累计计算或组内排名。

数学模型上,窗口函数可形式化为:

$$f_{\text{window}}(r_i) = \mathcal{G}\left({r_j \mid r_j \in \text{Partition}(r_i) \land r_j \in \text{Frame}(r_i)}\right)$$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.example.spark.sql

import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.expressions.Window
import org.apache.spark.sql.functions._

object WindowOperationsDemo {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder()
.appName("SparkSQL-Window")
.master("local[*]")
.getOrCreate()

import spark.implicits._

val salesRecords = Seq(
("2024-01-01", "Dept_A", 5000.0),
("2024-01-02", "Dept_A", 7000.0),
("2024-01-03", "Dept_A", 4000.0),
("2024-01-01", "Dept_B", 3000.0),
("2024-01-02", "Dept_B", 3500.0)
).toDF("sale_date", "dept", "amount")

// 1. 定义按部门分组、按日期排序的窗口
val deptWindow = Window.partitionBy("dept").orderBy("sale_date")

// 2. 定义包含前后滑动范围的 Frame(例如:计算当前行及前1天的滚动累计额)
val slidingWindow = Window.partitionBy("dept")
.orderBy("sale_date")
.rowsBetween(Window.currentRow - 1, Window.currentRow)

// 3. 执行分析型窗口操作
val windowedDF = salesRecords
.withColumn("dense_rank", dense_rank().over(deptWindow))
// 累计到当前日期的总额
.withColumn("running_total", sum($"amount").over(deptWindow.rowsBetween(Window.unboundedPreceding, Window.currentRow)))
// 2日滑动平均
.withColumn("moving_avg_2d", avg($"amount").over(slidingWindow))
// 环比前一日差异:利用 lag 算子
.withColumn("prev_day_amount", lag($"amount", 1, 0.0).over(deptWindow))
.withColumn("diff_from_prev", $"amount" - $"prev_day_amount")

windowedDF.show(false)

spark.stop()
}
}

五、纯 SQL 与元数据视图交互

Spark SQL 支持直接在动态注册的临时视图或持久化 Hive Metastore 上运行标准 ANSI SQL。

5.1 视图注册与 SQL 混编

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 将已有 DataFrame 注册为当前会话可见的临时视图(内存虚表)
rawData.createOrReplaceTempView("v_sales")

// 执行纯 SQL 查询,包含 CTE(公共表表达式)
val sqlResult = spark.sql(
"""
|WITH RankedSales AS (
| SELECT
| category,
| country,
| price,
| ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) as rk
| FROM v_sales
|)
|SELECT category, country, price
|FROM RankedSales
|WHERE rk = 1
|""".stripMargin
)

sqlResult.show()

5.2 自定义函数(UDF)的注册与使用

当内置函数无法满足需求时,可通过 udf 将 Scala 函数注册进 SQL 解析器:

1
2
3
4
5
6
7
8
9
10
11
// 1. 定义 Scala 业务函数
val maskSensitiveData: String => String = id => {
if (id == null || id.length < 4) "***"
else id.substring(0, 2) + "****" + id.substring(id.length - 2)
}

// 2. 注册为 Spark SQL UDF(注册到 Catalog 中)
spark.udf.register("maskId", maskSensitiveData)

// 3. 在纯 SQL 中直接调用该 UDF
spark.sql("SELECT order_id, maskId(order_id) as masked_id FROM v_sales").show()

性能提示: 原生 Scala UDF 会破坏 Tungsten 的 Whole-Stage CodeGen,且涉及 JVM 对象装箱/拆箱开销。因此应优先使用 org.apache.spark.sql.functions 下的原生内置函数,仅在逻辑极度复杂时才引入 UDF。


六、Spark SQL Join 物理执行策略与性能调优

Join 操作是分布式计算中最昂贵的计算形态之一。Spark SQL 针对不同场景提供五种主要物理 Join 策略:

物理 Join 策略 触发条件 是否需要 Shuffle 适用场景 / 优缺点
Broadcast Hash Join (BHJ) 一侧表体积小于阈值(默认 10MB) 否 性能最高。将小表广播到全集群,消除 Shuffle。
Shuffle Hash Join (SHJ) 单表均过大无法广播,但构建侧表切分后可放入单分区内存 是 需对 Join Key 进行 Hash Shuffle,按分区分散建表匹配。
Sort Merge Join (SMJ) 两个大表关联,Join Key 可排序(Spark 默认首选) 是 稳定且不易 OOM。先 Shuffle 重分区,后在分区内排序再归并。
Broadcast Nested Loop (BNLJ) 条件非等值关联(a.val > b.val)且一侧较小 否 相当于分布式双重循环,时间复杂度高,慎用。
Cartesian Product Join (CPJ) 笛卡尔积(无关联条件),全连接 是 复杂度为 $\mathcal{O}(M \times N)$,应严防意外触发。

6.1 强制提示广播 Join(Broadcast Hint)

当优化器由于元数据统计不全未自动开启广播时,可通过显式 Hint 或函数介入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.apache.spark.sql.functions.broadcast

val largeDF = spark.table("fact_orders")
val dimDF = spark.table("dim_products")

// 方式 1:利用 DataFrame broadcast 算子
val joined1 = largeDF.join(broadcast(dimDF), "product_id")

// 方式 2:使用 SQL Hint 注解
val joined2 = spark.sql(
"""
|SELECT /*+ BROADCAST(p) */ *
|FROM fact_orders o
|JOIN dim_products p ON o.product_id = p.product_id
|""".stripMargin
)

// 打印物理计划,确认包含 BroadcastHashJoinExec 算子
joined1.explain(mode = "formatted")

6.2 自适应查询执行(AQE - Adaptive Query Execution)

自 Spark 3.0 起,默认(或建议开启)AQE 引擎(spark.sql.adaptive.enabled = true),它会在 Stage 运行期间依据真实的 Shuffle 统计元信息,动态修正执行计划:

  1. 动态合并 Shuffle 分区(Coalescing Shuffle Partitions): 作业运行中若发现 Shuffle 后的小文件过多,自动将多个相邻的微小分区合并为一个合适大小的分区。
  2. 动态调整 Join 策略(Dynamic Join Switching): 若大表经过 filter 过滤后体积骤降至广播阈值内,AQE 可以在运行时将原定的 SortMergeJoin 动态升格为 BroadcastHashJoin。
  3. 动态处理数据倾斜(Skew Join Optimization): 自动检测出现倾斜的数据倾斜分区(Skew Partition),将其拆分成多个子任务分别与对侧数据进行关联,彻底解决个别 Task 运行超时导致的木桶效应。

七、总结

Spark SQL 不仅仅是一个提供关系型查询语法的接口层,它代表了 Spark 计算引擎向结构化、自动化与代码生成的范式转移。在日常开发中:

  • 业务表达上,合理权衡 DSL 链式调用(保证模块化与重构安全)与 纯 ANSI SQL(提高跨技术栈共享效率);
  • 算子与函数选型上,严格遵循内置函数优先准则,尽量压低自定义 UDF 的比例;
  • 物理性能上,应熟练阅读 explain() 打印的物理执行计划树,借助 广播连接、谓词下推与 AQE 机制,从根本上压减宽依赖混洗量与 I/O 吞吐开销。