Spark 2023-06-25 · 20 min 阅读

Spark SQL 实战:从 DataFrame 嵌套到第 5 层,到 SQL 一行流

DataFrame 写复杂查询时,嵌套到第 4、5 层就开始看不清——df.filter(...).join(...).withColumn(...).groupBy(...).agg(...) 像俄罗斯套娃。Spark SQL 让你用 SQL 写 DataFrame,多表 JOIN、子查询、CTE 全部用字符串表达,复杂逻辑反而一目了然。这一篇我们从 createOrReplaceTempView 讲起,到程序化 SQL、Warehouse、JDBC——把 DataFrame 嵌套地狱变成 SQL 一行流的完整路径。


序:30 张 Postgres 表搬家的那个夏天

2018 年夏天,我接手一个老项目,要把公司 30 张 Postgres 表搬到 Spark Warehouse 做统一查询。

带我的架构师扔下一句话:

“业务方下周一要看到 50 个查询的样例,每个查询平均 5 个表 JOIN。”

我第一反应是用 DataFrame API——这是 Spark 的”高级语言”嘛。但写到第 3 个查询我就崩溃了:一个 5 表 JOIN 的 DataFrame 代码嵌套到了第 5 层:

// 5 层嵌套的 DataFrame——你以为这是 SQL?不,这是噩梦
val result = df1.join(df2, Seq("user_id"), "inner")                // <-- 第 1 层
                .filter($"status" === "active")                     // <-- 第 2 层
                .join(df3, Seq("user_id"), "left")                  // <-- 第 3 层
                .select($"user_id", $"name", $"amount")             // <-- 第 4 层
                .join(df4, Seq("user_id"), "inner")                  // <-- 第 5 层
                .groupBy($"region")
                .agg(sum($"amount").as("total"))

写完抬头看一眼,我已经在数左括号了。

第二天我把所有查询改写成 SQL:

SELECT t1.user_id, t1.name, SUM(t1.amount) AS total
FROM table1 t1
INNER JOIN table2 t2 ON t1.user_id = t2.user_id     -- <-- 5 表 JOIN 写得清清楚楚
LEFT  JOIN table3 t3 ON t1.user_id = t3.user_id
INNER JOIN table4 t4 ON t1.user_id = t4.user_id
WHERE t1.status = 'active'
GROUP BY t1.user_id, t1.name, t4.region

50 个查询,3 天写完。同样的 DataFrame 嵌套,2 周都没写完。

那一刻我才理解:Spark SQL 不是 DataFrame 的替代品,是 DataFrame 的一种表达形式——编译器会把它翻译回 DataFrame 的执行计划。

这一篇讲清楚:怎么把 DataFrame 注册成表、用 SQL 写查询、动态拼接 SQL、落地到 Warehouse、与 JDBC 数据库对话——这是一条”嵌套地狱变一行流”的完整路径。

一、createOrReplaceTempView:把 DataFrame 注册成表

第一步:让 DataFrame 能被 SQL 访问。

val df = spark.read.parquet("path/to/data.parquet")

// 注册成临时视图
df.createOrReplaceTempView("users") // <-- 现在 users 表可以在 SQL 里用了

// 现在可以用 SQL 查了
spark.sql("SELECT * FROM users WHERE age > 30").show()

createOrReplaceTempView 是两个方法合一的版本:

方法 行为
createTempView(name) 同名视图已存在则报错
createOrReplaceTempView(name) 已存在则替换(推荐)
createGlobalTempView(name) 全局视图,跨 Session 可见

临时视图的生命周期跟着 SparkSession——session 结束,视图就没了。如果你想要持久化的表,用 saveAsTable(...) 写到 Warehouse 目录(后面第四节讲)。

我当年犯过的错:忘了视图是 session 级的

有次我注册了一个视图,关掉 Zeppelin 重新打开,发现视图没了——当时还以为是 Bug,后来才知道:

临时视图是 session 级,重启 SparkSession 就要重新注册。

那次教训之后,凡是”重要”的中间结果,我都用 saveAsTable 落盘,临时视图只用于”当下 session 用一用”的场景。

全局视图 vs 会话视图

// 会话视图:只在当前 spark session 可见
df.createOrReplaceTempView("users")

// 全局视图:跨 session 可见,访问时加 global_temp 前缀
df.createGlobalTempView("users")
spark.sql("SELECT * FROM global_temp.users").show()      // <-- 注意 global_temp. 前缀

实际项目里 99% 用 createOrReplaceTempView 就够了——createGlobalTempView 在多 SparkSession 共享数据时才用。比如我那个 BI 工具项目,多个 session 同时跑查询,用全局视图就能避免重复注册。

二、spark.sql(...):用 SQL 写 DataFrame

spark.sql(...) 接收 SQL 字符串,返回 DataFrame。返回值可以继续用 DataFrame API 操作:

// 纯 SQL
val activeUsers = spark.sql("""
  SELECT user_id, name, age
  FROM users
  WHERE status = 'active'
""")

// SQL + DataFrame API 混合:SQL写聚合,DataFrame 写过滤
val summary = spark.sql("""
  SELECT region, AVG(amount) AS avg_amount
  FROM orders
  GROUP BY region
""").filter($"avg_amount" > 1000).orderBy(desc("avg_amount"))   // <-- 混合用法

SQL 字符串里能写完整的 ANSI SQL:

-- CTE(Common Table Expression)
WITH top_users AS (
  SELECT user_id, SUM(amount) AS total                       -- <-- 子查询:消费前 100 用户
  FROM orders
  GROUP BY user_id
  HAVING SUM(amount) > 10000
)
SELECT u.name, tu.total
FROM users u
JOIN top_users tu ON u.user_id = tu.user_id                   -- <-- 跟主查询 join
ORDER BY tu.total DESC
LIMIT 100

所有主流方言都支持:CTE、窗口函数、子查询、JOIN(含 LEFT SEMI / ANTI / CROSS)、CASE WHEN、regexp_extract 等 Spark SQL 内置函数。

一个反直觉的细节:SQL 字符串里的参数化

// ❌ 千万别这么写——SQL 注入风险
val region = "Asia"
spark.sql(s"SELECT * FROM users WHERE region = '$region'")
// 如果 region 是 "'; DROP TABLE users; --" 呢?

// ✅ 安全写法:白名单 + 转义,或直接用 DataFrame API
val filteredDF = df.filter($"region" === region)        // <-- DataFrame API 自动转义

Spark SQL 本身不防止 SQL 注入——它把整个字符串当 SQL 解析。所以涉及外部输入的参数化,必须在应用层处理(白名单、转义、或用 DataFrame API 替代)。下一节讲程序化 SQL 时会专门展开。

三、程序化 SQL:用代码生成 SQL

当你有几十个查询要写,且条件经常变——比如 BI 工具让用户选过滤条件——程序化 SQL 是必备技能。

def buildQuery(
    table: String,
    filters: Map[String, Any],
    groupBy: Seq[String],
    orderBy: Seq[String]
): String = {
    val whereClause = if (filters.isEmpty) "1=1" else {
      filters.map { case (k, v) =>
        val escaped = v.toString.replace("'", "''")          // <-- 单引号转义:把 ' 换成 ''
        s"$k = '$escaped'"
      }.mkString(" AND ")
    }
    val selectClause = if (groupBy.isEmpty) "*" else groupBy.mkString(", ")
    val groupByClause = if (groupBy.isEmpty) "" else s"GROUP BY $selectClause"
    val orderByClause = if (orderBy.isEmpty) "" else s"ORDER BY ${orderBy.mkString(", ")}"

    s"""
    SELECT $selectClause, COUNT(*) AS cnt
    FROM $table
    WHERE $whereClause
    $groupByClause
    $orderByClause
    """
}

val sql = buildQuery(
    table = "orders",
    filters = Map("status" -> "active", "region" -> "Asia"),
    groupBy = Seq("region", "product"),
    orderBy = Seq("cnt DESC")
)
spark.sql(sql).show()

我当年差点上生产事故的一次

有次写完程序化 SQL,兴冲冲上线,凌晨 3 点被叫醒——线上查询挂了。

排查半小时才发现:用户输入的 region 字段含有一个单引号(”A’sia”),拼接 SQL 直接语法错误。我那晚立了一个规矩:所有程序化 SQL 必须白名单 + 转义。

一个值得记住的细节:白名单 + 转义

// 用白名单限制可用的列名
val allowedColumns = Set("status", "region", "product")

def safeFilter(key: String, value: Any): String = {
  require(allowedColumns.contains(key), s"Column $key is not in whitelist")
  // 单引号转义:把 ' 替换成 ''
  val escaped = value.toString.replace("'", "''")
  s"$key = '$escaped'"
}

白名单 + 转义是程序化 SQL 的安全底线。任何允许外部传入 SQL 片段的场景,永远要做这两件事。白名单防”意料之外的列”,转义防”意料之外的值”。

四、Spark SQL Warehouse:把表落地

临时视图 session 结束就没了。如果你想要”重启 Spark 还能查到”的表,用 saveAsTable(...):

df.write
  .mode("overwrite")                                       // <-- 覆盖写模式
  .partitionBy("year", "month")                            // <-- 分区列
  .saveAsTable("warehouse.orders")                          // <-- 写到 spark-warehouse/

saveAsTable 默认写到 spark-warehouse/ 目录(在 spark 安装目录下的同名子目录)。表是 Parquet 格式,自带 schema,可以被任何 SparkSession 访问。

三种模式

模式 行为
append 追加数据(默认)
overwrite 覆盖整个表
ignore 表已存在则忽略
error / errorifexists 表已存在则报错(默认)

持久表 vs 临时表

// 临时表(session 级)
df.createOrReplaceTempView("orders")

// 持久表(跨 session)
df.write.saveAsTable("orders")                            // <-- 写到 spark-warehouse/orders

// 删除持久表
spark.sql("DROP TABLE IF EXISTS orders")

持久表本质上是 spark-warehouse/<table_name>/ 下的 Parquet 文件 + Hive metastore(元数据库)。Spark 默认带一个内置 metastore,存于 spark-warehouse/metastore_db/。

一个反直觉的细节:分区表的 partitionBy

// 按 year 和 month 分区写入
df.write
  .partitionBy("year", "month")                           // <-- 这两列会成为目录结构
  .mode("overwrite")
  .parquet("path/to/table")

物理上会生成 path/to/table/year=2022/month=01/ 这种目录结构。读的时候 Spark 会做 partition pruning:

spark.read.parquet("path/to/table").filter($"year" === 2022 && $"month" === 1)
// <-- 只读 year=2022/month=01/ 这一份,省 IO

分区列不能出现在数据文件本身——它是”目录元信息”。如果你用 partitionBy("year") 写,但数据里也有 year 列,写出来会有两份数据(一份在 path,一份在子目录)。我第一次写分区表的时候踩过这个坑:分区列有数据、目录结构里也有,导致查询出两倍结果。

五、JDBC 读写:与外部数据库对话

JDBC 数据源让你把任意支持 JDBC 的数据库(Postgres、MySQL、Oracle、SQL Server……)当成 Spark 的”数据源”。

读取

val jdbcDF = spark.read
  .format("jdbc")
  .option("url", "jdbc:postgresql://localhost:5432/mydb")
  .option("dbtable", "public.users")
  .option("user", "myuser")
  .option("password", "mypassword")
  .option("numPartitions", "4")        // <-- 并行读 4 个 partition
  .option("partitionColumn", "id")     // <-- 按哪一列分区
  .option("lowerBound", "1")           // <-- id 最小值
  .option("upperBound", "1000000")     // <-- id 最大值
  .load()

partitionColumn + lowerBound + upperBound 让 Spark 按列范围并行读多个 partition——如果你的表有几千万行不带谓词的读取,并行能省很多时间。

我那 30 张表的项目里,有一张 2 亿行的表。不加 partitionColumn 时读了 40 分钟;加上后 8 分钟——4 个 partition 一起读就是这么快。

写入

df.write
  .format("jdbc")
  .option("url", "jdbc:postgresql://localhost:5432/mydb")
  .option("dbtable", "public.results")
  .option("user", "myuser")
  .option("password", "mypassword")
  .mode("append")          // <-- append 或 overwrite
  .save()

一个反直觉的细节:JDBC 的写性能

默认 JDBC 写是单 executor 串行写——即使你集群有 100 个 executor,也只有一个在写。要并行写,必须预先 partition:

// 并行写:先按 partitionColumn 分区
df.repartition(4, $"partitionColumn")                  // <-- 4 个 partition = 4 个 JDBC 连接
  .write
  .format("jdbc")
  .option("url", "jdbc:postgresql://localhost:5432/mydb")
  .option("dbtable", "public.results")
  .option("user", "myuser")
  .option("password", "mypassword")
  .mode("append")
  .save()

repartition(4, $"col") 之后,每个 partition 一个 task——4 个 task 同时写 4 个 JDBC 连接。

六、视图与缓存:createOrReplaceTempView + cache

当同一个 DataFrame 被多次访问时,缓存能省大量重算:

val users = spark.read.parquet("path/to/users").cache()  // <-- 第一次 action 时缓存
users.createOrReplaceTempView("users")                    // <-- 把缓存的 DF 注册成视图

// 多次查询都走缓存
spark.sql("SELECT count(*) FROM users").show()
spark.sql("SELECT region, count(*) FROM users GROUP BY region").show()
spark.sql("SELECT * FROM users WHERE age > 30").show()

我那个 30 张表的项目里,users 表被 50 个查询轮番访问,不缓存就要重读 50 次 parquet。缓存后只读一次——第二次查询从 8 秒降到 0.2 秒。

缓存的几个细节

import org.apache.spark.storage.StorageLevel

// 等价于 cache()
df.persist(StorageLevel.MEMORY_ONLY)

// 也可以 DISK_ONLY / MEMORY_AND_DISK / MEMORY_ONLY_SER 等
df.persist(StorageLevel.MEMORY_AND_DISK)
StorageLevel 内存 磁盘 序列化 副本
MEMORY_ONLY ✅ ❌ ❌ 1
MEMORY_ONLY_SER ✅ ❌ ✅ 1
MEMORY_AND_DISK ✅ ✅ ❌ 1
DISK_ONLY ❌ ✅ ❌ 1
MEMORY_ONLY_2 ✅ ❌ ❌ 2(容错)

默认 MEMORY_ONLY——适合 DataFrame,不适合大 RDD。后者建议用 MEMORY_ONLY_SER 或 MEMORY_AND_DISK。

一个值得记住的细节:缓存的释放

df.unpersist()                  // <-- 释放缓存
df.unpersist(blocking = true)   // <-- 同步等缓存释放完

不释放的话,缓存会一直占着 executor 内存——session 结束才释放。所以长生命周期的 DataFrame,要在使用完后 unpersist()。我有次 cache 了一个 50GB 的 DataFrame 又忘了释放,整个集群内存被吃光、其他作业全部排队——那次教训之后我养成了”用完即释放”的习惯。

本篇小结

这一篇我们覆盖了 Spark SQL 的 6 个话题——临时视图、spark.sql()、程序化 SQL、Warehouse、JDBC、缓存——它们都围绕一个中心思想:

Spark SQL 不是 DataFrame 的替代品,是 DataFrame 的一种表达形式——编译器会把它翻译回 DataFrame 的执行计划。

🔜 下篇预告

这一篇我们讲了 SQL 把 DataFrame 嵌套地狱变一行流——但 SQL 写出来的是 Dataset[Row],无类型。

下一篇讲 Dataset 与 Encoder:当 DataFrame 也有了 Scala 类型签名,df.getString(0) 拼错字段名会被编译器在编译期抓住——而不是运行时 NullPointerException。我会用一个真实的”字段名拼错线上事故”开场,把 Encoder 的本质、Option 与 null 的对决讲透。

# Spark