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 的执行计划。
createOrReplaceTempView把 DataFrame 注册成 SQL 表,会话级视图够用。spark.sql(...)接收 SQL 字符串,返回 DataFrame,能继续用 DataFrame API 操作。- 程序化 SQL 用代码生成 SQL 字符串,但要白名单 + 转义防注入。
saveAsTable把表持久化到spark-warehouse/,分区表能进一步省 IO。- JDBC 读写通过
format("jdbc")接入外部数据库;并行读靠partitionColumn,并行写靠repartition。 - 缓存用
cache()/persist(StorageLevel),多查询共享时省重算,用完要unpersist()。
🔜 下篇预告
这一篇我们讲了 SQL 把 DataFrame 嵌套地狱变一行流——但 SQL 写出来的是 Dataset[Row],无类型。
下一篇讲 Dataset 与 Encoder:当 DataFrame 也有了 Scala 类型签名,df.getString(0) 拼错字段名会被编译器在编译期抓住——而不是运行时 NullPointerException。我会用一个真实的”字段名拼错线上事故”开场,把 Encoder 的本质、Option 与 null 的对决讲透。