本章目标:掌握子查询、CASE WHEN、CTE、日期函数和集合运算,能独立拆解复购率、占比、环比等复杂业务题。 前置知识:SQL 基础、聚合与分组、多表查询与 JOIN。 预计用时:60 分钟。
1. 为什么需要它
前几章的查询是一条直线:筛行、分组、关联。真实需求经常不是直线:
- 找出价格高于平均价的商品,先算平均价,再拿每一行去比。
- 找出从没下过有效订单的客户,需要判断"不存在"。
- 把按月、按状态竖着排的结果,变成横着的报表。
- 算复购率,先把订单压到用户粒度,再算比例。
这些都需要把一段查询嵌进另一段查询,或者在一行里做条件判断。
本章继续用玩具库 project/sql_demo.duckdb 的九张 demo_* 小表。
2. 核心概念
2.1 子查询的四种形态
标量(scalar)子查询:返回一行一列,可以当普通数值用。
SELECT ROUND(AVG(unit_price), 2) AS 全站均价 FROM demo_order_items;
SELECT product_id, unit_price
FROM demo_order_items
WHERE unit_price > (SELECT AVG(unit_price) FROM demo_order_items)
ORDER BY unit_price DESC
LIMIT 5;
全站均价 319.91,高于均价的明细有 4 行;最高单价是 1,299.00。如果子查询返回多行,数据库会报"子查询返回多行"。
IN 子查询:返回一列多行,用来做集合判断。
SELECT COUNT(DISTINCT o.customer_id) AS 买过电子产品的客户数
FROM demo_orders o
JOIN demo_order_items i ON i.order_id = o.order_id
JOIN demo_products p ON p.product_id = i.product_id
WHERE p.category = '电子';
结果 6 人。等价写法是把客户列表放进 IN (...);IN 适合"值在某个列表里"。
EXISTS 子查询:不关心返回什么值,只关心有没有行。
SELECT COUNT(*) AS 没下过有效订单的客户
FROM demo_customers c
WHERE NOT EXISTS (
SELECT 1
FROM demo_orders o
WHERE o.customer_id = c.customer_id AND o.status = 'paid'
);
结果 1 人(客户 7)。EXISTS 找到第一行就停,通常比把整列算出来再 IN 更高效。
相关子查询:内层查询引用外层的列,外层每行执行一次。
SELECT COUNT(*) AS 高于本品类均价的明细行数
FROM (
SELECT i.product_id, i.unit_price, p.category
FROM demo_order_items i
JOIN demo_products p ON p.product_id = i.product_id
) x
WHERE unit_price > (
SELECT AVG(i2.unit_price)
FROM demo_order_items i2
JOIN demo_products p2 ON p2.product_id = i2.product_id
WHERE p2.category = x.category
);
结果 2 行,都来自电子品类。内层的 x.category 来自外层,所以不能把子查询单独拎出来跑。
注意:
NOT IN的子查询如果返回 NULL,整个条件会失效。找"不存在"优先用NOT EXISTS,语义清晰,也不会被 NULL 坑。
2.2 CASE WHEN、IF、COALESCE、NULLIF
CASE WHEN 是 SQL 里的 if-else,从上往下判断,命中第一个就返回:
SELECT order_id,
quantity * unit_price AS 明细金额,
CASE
WHEN quantity * unit_price >= 500 THEN '大额'
WHEN quantity * unit_price >= 100 THEN '中额'
ELSE '小额'
END AS 档位
FROM demo_order_items
LIMIT 5;
条件聚合是它的高频用法:配合 SUM,一次算出多个指标。
IF 是两分支简写,DuckDB、Hive、Spark 都支持 IF(条件, 真值, 假值);标准写法仍是 CASE WHEN,可移植性最好。
COALESCE 返回第一个非 NULL 值。用推荐关系做标签:
SELECT COALESCE(CAST(referrer_id AS VARCHAR), '无推荐人') AS 来源,
COUNT(*) AS 人数
FROM demo_customers
GROUP BY 来源
ORDER BY 人数 DESC;
结果:有推荐人 4 人、无推荐人 3 人。如果某列保留原始 NULL,可以写 COALESCE(列, '未知') 把空值统一成可读标签。
NULLIF(a, b):a = b 时返回 NULL,否则返回 a。常用来防除零:
SELECT ROUND(SUM(i.quantity * i.unit_price) / NULLIF(COUNT(DISTINCT o.order_id), 0), 2) AS aov
FROM demo_orders o
JOIN demo_order_items i ON i.order_id = o.order_id
WHERE o.status = 'paid';
结果 441.92。分母为 0 时结果是 NULL,而不是报错。
2.3 行列转换:用 CASE WHEN 做 PIVOT
原始结果是一行一个"月 + 状态",报表想要一行一月、每个状态一列:
SELECT strftime(created_at, '%Y-%m') AS 月份,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) AS refunded,
SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END) AS canceled
FROM demo_orders
GROUP BY strftime(created_at, '%Y-%m')
ORDER BY 月份;
| 月份 | paid | refunded | canceled |
|---|---|---|---|
| 2024-03 | 6 | 1 | 1 |
| 2024-04 | 4 | 0 | 0 |
这就是"行转列":分组键做行,条件做列,SUM(CASE WHEN ... THEN 1 ELSE 0 END) 填格子。DuckDB 和 Spark 2.4+ 支持 PIVOT 语法,Hive 没有;需要跨引擎时用 CASE 写法最稳。
2.4 CTE 与递归 CTE
普通 CTE 用 WITH 名字 AS (...),把复杂查询拆成积木:
WITH order_amount AS (
SELECT o.order_id, SUM(i.quantity * i.unit_price) AS amt
FROM demo_orders o
JOIN demo_order_items i ON i.order_id = o.order_id
WHERE o.status = 'paid'
GROUP BY o.order_id
)
SELECT COUNT(*) AS 订单数, ROUND(AVG(amt), 2) AS aov
FROM order_amount;
递归 CTE 多一个 RECURSIVE,由"起点"和"从上一轮生成下一轮"两部分组成:
WITH RECURSIVE dates(d) AS (
SELECT DATE '2024-03-01'
UNION ALL
SELECT d + INTERVAL 1 DAY FROM dates WHERE d < DATE '2024-03-07'
)
SELECT d FROM dates;
返回 7 天。用它可以生成日期骨架、补全没有数据的日期、展开层级关系。
注意:递归 CTE 在 DuckDB、PostgreSQL、MySQL 8 可用;Hive 不支持,Spark 3.5 也不支持
WITH RECURSIVE(Spark 4.0 才加入)。这些引擎里常用迭代任务或窗口函数替代,使用前先确认版本。
2.5 日期函数与方言差异
SELECT date_trunc('month', DATE '2024-03-15'); -- 2024-03-01 00:00:00
SELECT strftime(DATE '2024-03-15', '%Y-%m'); -- '2024-03'
SELECT datediff('day', DATE '2024-03-01', DATE '2024-03-31'); -- 30
SELECT DATE '2024-03-15' - INTERVAL 1 DAY; -- 2024-03-14
| 需求 | DuckDB | Hive | Spark SQL |
|---|---|---|---|
| 截断到月 | date_trunc('month', d) |
trunc(d, 'MM') |
date_trunc('month', d) |
| 格式化 | strftime(d, '%Y-%m') |
date_format(d, 'yyyy-MM') |
date_format(d, 'yyyy-MM') |
| 日期差 | datediff('day', a, b) |
datediff(b, a) |
datediff(b, a) |
| 加一天 | d + INTERVAL 1 DAY |
date_add(d, 1) |
date_add(d, 1) |
2.6 集合运算
UNION 合并两个结果并去重;UNION ALL 保留重复;EXCEPT 取差集;INTERSECT 取交集。要求列数相同、类型兼容。
玩具库没有登录/浏览行为表,这里比较"有有效购买的客户"和"有评价的客户":
WITH buyers AS (
SELECT DISTINCT customer_id
FROM demo_orders
WHERE status = 'paid'
),
reviewers AS (
SELECT DISTINCT o.customer_id
FROM demo_orders o
JOIN demo_reviews r ON r.order_id = o.order_id
)
SELECT
(SELECT COUNT(*) FROM (SELECT customer_id FROM buyers INTERSECT SELECT customer_id FROM reviewers) t) AS 交集,
(SELECT COUNT(*) FROM (SELECT customer_id FROM buyers UNION SELECT customer_id FROM reviewers) t) AS 并集,
(SELECT COUNT(*) FROM (SELECT customer_id FROM buyers UNION ALL SELECT customer_id FROM reviewers) t) AS 直接拼接,
(SELECT COUNT(*) FROM (SELECT customer_id FROM buyers EXCEPT SELECT customer_id FROM reviewers) t) AS 只在买家;
买家 6 人,评价客户 5 人;交集 5 人,并集 6 人,UNION ALL 直接拼接 11 行,只在买家侧、没有评价的 1 人(客户 5)。
注意:
UNION要去重,通常比UNION ALL多一次排序或哈希,数据量大时优先想清楚是否真的需要去重。
2.7 复杂业务题的拆法
复购率:先聚合到用户粒度,再算比例。
WITH per_user AS (
SELECT customer_id, COUNT(DISTINCT order_id) AS cnt
FROM demo_orders
WHERE status = 'paid'
GROUP BY customer_id
)
SELECT COUNT(*) AS 下单用户数,
SUM(CASE WHEN cnt >= 2 THEN 1 ELSE 0 END) AS 复购用户数,
ROUND(SUM(CASE WHEN cnt >= 2 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) AS 复购率
FROM per_user;
结果:6 人下单,3 人复购,复购率 50.00%。
品类占比:分子是品类销售额,分母是全站销售额。
SELECT p.category,
ROUND(SUM(i.quantity * i.unit_price), 2) AS 销售额,
ROUND(SUM(i.quantity * i.unit_price) * 100.0
/ (SELECT SUM(i2.quantity * i2.unit_price)
FROM demo_order_items i2
JOIN demo_orders o2 ON o2.order_id = i2.order_id
WHERE o2.status = 'paid'), 2) AS 占比
FROM demo_order_items i
JOIN demo_orders o ON o.order_id = i.order_id
JOIN demo_products p ON p.product_id = i.product_id
WHERE o.status = 'paid'
GROUP BY p.category
ORDER BY 销售额 DESC;
电子 92.37%、家居 4.03%、文具 3.60%。
月度环比:本月相对上月的增长率,需要窗口函数 LAG:
WITH monthly AS (
SELECT strftime(o.created_at, '%Y-%m') AS 月份,
SUM(i.quantity * i.unit_price) AS gmv
FROM demo_orders o
JOIN demo_order_items i ON i.order_id = o.order_id
WHERE o.status = 'paid'
GROUP BY strftime(o.created_at, '%Y-%m')
)
SELECT 月份, ROUND(gmv, 2) AS gmv,
ROUND((gmv / LAG(gmv) OVER (ORDER BY 月份) - 1) * 100, 2) AS 环比
FROM monthly
ORDER BY 月份;
2024-03 是首月,没有上月,环比为 NULL;2024-04 环比 -15.36%。窗口函数的完整用法见窗口函数。
3. 动手示例
3.1 用 CASE 给订单分档
WITH order_amount AS (
SELECT o.order_id, SUM(i.quantity * i.unit_price) AS amt
FROM demo_orders o
JOIN demo_order_items i ON i.order_id = o.order_id
WHERE o.status = 'paid'
GROUP BY o.order_id
)
SELECT CASE
WHEN amt < 100 THEN '100以下'
WHEN amt < 300 THEN '100-300'
WHEN amt < 500 THEN '300-500'
ELSE '500以上'
END AS 档位,
COUNT(*) AS 订单数
FROM order_amount
GROUP BY 档位
ORDER BY MIN(amt);
结果合计 10 单:
| 档位 | 订单数 |
|---|---|
| 100以下 | 2 |
| 100-300 | 4 |
| 300-500 | 2 |
| 500以上 | 2 |
先聚合到订单粒度再分档,否则一个多明细订单会被分进多个档位。
4. 常见坑
- 标量子查询返回多行。
WHERE x = (SELECT ...)要求子查询只返回一行,否则报错。 NOT IN遇到 NULL。结果可能为空,改用NOT EXISTS。- 相关子查询在大表上很慢。每行执行一次,能改成 JOIN 或先聚合就改。
- CASE 顺序写反。从上往下命中第一个,宽条件放前面会把后面的分支吃掉。
- 忘了 ELSE。不匹配时返回 NULL,报表里会多出空档位。
UNION和UNION ALL混用。前者去重、后者不去重,行数会不同。- 递归 CTE 没有终止条件。
WHERE条件写错会一直递归,直到报错或耗尽资源。 - 日期当字符串比较。能自动转换时不报错,但格式一变就出错,统一用
DATE '...'。
5. 在线练习
| 题目 | 考点 |
|---|---|
| 176. 第二高的薪水 | 子查询 + NULL |
| 177. 第 N 高的薪水 | 变量/窗口 |
| 184. 部门工资最高的员工 | 分组极值 + JOIN |
| 185. 部门工资前三高的所有员工 | 窗口排名 |
| 196. 删除重复的电子邮箱 | 自连接删除 |
自测:
- 能写出“每组 Top N”的两种解法:窗口函数、相关子查询。
- 能处理“找不到结果返回 NULL”的边界。
- 能解释
DENSE_RANK和ROW_NUMBER的区别。
6. 面试延伸
面试题:UNION 和 UNION ALL 有什么区别?
是什么:UNION 合并结果并去重,UNION ALL 直接拼接、保留重复。两者都要求列数相同、类型兼容。
解决什么问题:确定没有重复或业务允许重复时,用 UNION ALL 更快;需要唯一结果时才用 UNION。写报表时先想清楚重复行是否应该存在。
我的实操边界:做多来源汇总时会先确认是否存在重叠用户或订单,再用 UNION ALL 加最终去重,避免中间步骤反复排序。
面试题:IN 和 EXISTS 怎么选?
是什么:IN 判断值是否在子查询结果集中;EXISTS 判断子查询是否至少返回一行,找到就停。
解决什么问题:子查询结果可能有 NULL 时,NOT IN 会失效,应改用 NOT EXISTS。相关条件下 EXISTS 往往更快,非相关的小结果集用 IN 也直观。
我的实操边界:找"没有发生过"的实体时固定用 NOT EXISTS;写完后会用行数和对账验证结果。
面试题:递归 CTE 能解决什么问题?
是什么:递归 CTE 由起点和递归步组成,重复执行直到不满足条件。
解决什么问题:生成日期序列、展开组织层级、沿父子关系向上或向下查找、补全没有数据的日期。
我的实操边界:本地用 DuckDB 练过日期骨架和简单层级;生产环境的 Hive / Spark 版本支持情况不同,我了解原理,未在大规模集群上使用。
7. 本章验收
- 能分辨标量、IN、EXISTS、相关子查询,并各写出一条。
- 能说清
NOT IN的 NULL 陷阱,知道用NOT EXISTS替代。 - 能用 CASE WHEN 做条件聚合和行转列,知道
ELSE的作用。 - 会用
COALESCE补空值、用NULLIF防除零。 - 能写出普通 CTE 和简单的递归 CTE,并说明终止条件。
- 能说出 DuckDB 与 Hive / Spark 在日期函数上的至少 3 个差异。
- 能区分
UNION、UNION ALL、EXCEPT、INTERSECT。 - 能独立算出复购率 50.00%、品类占比和月度环比,并说清每步的粒度。
- 5 道在线练习全部提交通过。
下一章
窗口函数:不减少行数做排名、占比、环比和连续下单,把"每个分组的前 N 名"一次写完。