
上一篇我们聊了派生表、
LATERAL、CTE 和递归查询,理解了"分层"工具对优化器的不同约束力。这一篇,我们进入 SQL 高级查询的核心:窗口函数、条件表达式和空值处理。其中窗口函数是 PostgreSQL 真正拉开与普通数据库差距的地方,也是面试中区分"会用 SQL"和"懂 SQL"的分水岭。
一、窗口函数与聚合函数:本质区别
先回答核心问题:窗口函数和聚合函数到底差在哪里?
聚合函数把多行"折叠"成一行:
sql
SELECT status, count(*) FROM orders GROUP BY status;
-- 结果:每个 status 一行
窗口函数保留每一行,同时在每行旁边附加一个基于"窗口"计算出的值:
sql
SELECT order_id, status, amount,
sum(amount) OVER (PARTITION BY status) AS status_total
FROM orders;
-- 结果:每行都保留,外加该行所属 status 的总金额
本质区别:聚合函数改变行数,窗口函数不改变行数。 聚合是"多行变一行",窗口是"每行都带上分组信息"。
这个区别带来的实际影响是:窗口函数可以在不丢失明细数据的前提下,做分组排名、累计、同行比较。 而聚合函数必须配合 GROUP BY,丢失明细。
窗口的三个组成部分
窗口函数由 OVER() 子句定义,内部可以包含三部分:
sql
function_name(...) OVER (
PARTITION BY 分组列 -- 可选:分组
ORDER BY 排序列 -- 可选:组内排序
ROWS/RANGE BETWEEN ... -- 可选:帧范围
)
PARTITION BY:把数据分成多个窗口,类似GROUP BY但不折叠行;ORDER BY:定义窗口内的顺序,排名函数和偏移函数必须依赖它;- 帧子句 :定义窗口内参与计算的行范围,默认是
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。
二、排名函数:ROW_NUMBER、RANK、DENSE_RANK
这三个函数最容易混淆,区别在于遇到并列值时如何处理。
sql
SELECT username, score,
ROW_NUMBER() OVER (ORDER BY score DESC) AS row_num,
RANK() OVER (ORDER BY score DESC) AS rank,
DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank
FROM players;
假设分数为 100, 100, 90, 80:
| score | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 100 | 1 | 1 | 1 |
| 100 | 2 | 1 | 1 |
| 90 | 3 | 3 | 2 |
| 80 | 4 | 4 | 3 |
ROW_NUMBER:始终连续编号,即使值相同也给不同编号(1,2,3,4);RANK:并列值给相同排名,但跳过后续名次(1,1,3,4);DENSE_RANK:并列值给相同排名,不跳过后续名次(1,1,2,3)。
选择原则: 需要"唯一序号"用 ROW_NUMBER;需要"并列同名次且后续跳号"(如体育排名)用 RANK;需要"并列同名次且不跳号"(如等级划分)用 DENSE_RANK。
NTILE:把数据分成 N 桶
sql
SELECT username, score,
NTILE(4) OVER (ORDER BY score DESC) AS quartile
FROM players;
NTILE(4) 把结果集按顺序分成 4 等份,返回每行所属的桶号(1-4)。适合做"前 25%""后 25%"这类分位分析。如果行数不能被 N 整除,前面的桶会多一行。
三、偏移函数:LAG、LEAD、FIRST_VALUE、LAST_VALUE
这三个函数用来访问当前行之外的行,是做同环比的核心工具。
sql
SELECT month, revenue,
LAG(revenue, 1) OVER (ORDER BY month) AS prev_month,
LEAD(revenue, 1) OVER (ORDER BY month) AS next_month,
FIRST_VALUE(revenue) OVER (ORDER BY month
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS first_revenue,
LAST_VALUE(revenue) OVER (ORDER BY month
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_revenue
FROM monthly_revenue;
LAG(col, n, default):取当前行往前第 n 行的值,默认 n=1;越界时返回default(默认NULL);LEAD(col, n, default):取当前行往后第 n 行的值;FIRST_VALUE/LAST_VALUE:取窗口帧内第一行/最后一行的值。
LAST_VALUE 的经典陷阱: 默认帧是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,这意味着 LAST_VALUE 取的是"当前行",而不是"整个分区的最后一行"。要取真正的最后一行,必须显式指定帧为 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING。这是窗口函数中最常见的错误之一。
四、用窗口函数做同环比
同环比是窗口函数最经典的应用场景。
环比:与上一期比较
sql
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS mom_change,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0) * 100, 2
) AS mom_pct
FROM monthly_revenue
ORDER BY month;
几个关键点:
NULLIF(..., 0)防止除零错误;- 首月没有上一期 ,
LAG返回NULL,mom_pct自然为NULL,符合业务语义; ROUND(..., 2)保留两位小数。
同比:与去年同期比较
如果数据是按月存储的,同比就是"往前第 12 行":
sql
SELECT month, revenue,
LAG(revenue, 12) OVER (ORDER BY month) AS yoy_prev,
ROUND(
(revenue - LAG(revenue, 12) OVER (ORDER BY month))
/ NULLIF(LAG(revenue, 12) OVER (ORDER BY month), 0) * 100, 2
) AS yoy_pct
FROM monthly_revenue
ORDER BY month;
前提是数据必须连续,没有缺失月份。 如果某个月没有数据,LAG(..., 12) 取到的就不是去年同月。这时需要先用 generate_series 补全月份,或者改用 RANGE 帧按时间偏移。
累计值:移动求和
sql
SELECT month, revenue,
sum(revenue) OVER (ORDER BY month) AS cumulative_revenue,
avg(revenue) OVER (ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3m
FROM monthly_revenue;
- 累计求和 :默认帧
UNBOUNDED PRECEDING AND CURRENT ROW,天然就是"从第一行到当前行"的累计; - 移动平均 :
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW表示"当前行及前两行",即 3 个月移动平均。
分组内排名:PARTITION BY 的威力
sql
SELECT department, employee, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
FROM employees;
PARTITION BY department 让排名在每个部门内部独立计算 。这是窗口函数相对于聚合函数的核心优势:一次查询,既保留明细,又拿到分组内的排名。
五、CASE WHEN:SQL 中的条件分支
CASE WHEN 是 SQL 的"if-else",有两种形式。
搜索式 CASE
sql
SELECT order_id, amount,
CASE
WHEN amount >= 1000 THEN '大额'
WHEN amount >= 100 THEN '中额'
WHEN amount > 0 THEN '小额'
ELSE '异常'
END AS amount_level
FROM orders;
CASE 按顺序从上到下匹配,第一个满足条件的 WHEN 生效,后面的不再判断。所以条件的顺序很重要------把更具体的条件放在前面。
简单式 CASE
sql
SELECT status,
CASE status
WHEN 'paid' THEN '已支付'
WHEN 'shipped' THEN '已发货'
WHEN 'done' THEN '已完成'
ELSE '其他'
END AS status_cn
FROM orders;
简单式 CASE 只做等值比较,不能写范围条件。搜索式更灵活,实际使用中更常见。
CASE 与聚合结合:行转列
sql
SELECT
count(*) FILTER (WHERE status = 'paid') AS paid_count,
count(*) FILTER (WHERE status = 'shipped') AS shipped_count,
count(*) FILTER (WHERE status = 'done') AS done_count
FROM orders;
FILTER 是 PostgreSQL 对聚合的扩展,比 CASE WHEN 更清晰。但 CASE WHEN 的写法跨数据库兼容性更好:
sql
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_amount
注意:CASE WHEN 中的 ELSE 0 不能省略。 如果省略,不匹配的行返回 NULL,SUM 会忽略它们,但 count 会得到不同的结果。
六、空值处理:COALESCE、NULLIF、GREATEST、LEAST
COALESCE:返回第一个非 NULL 值
sql
SELECT coalesce(phone, mobile, email, '未知') AS contact
FROM users;
COALESCE 接收任意数量的参数,返回第一个非 NULL 的值。如果所有参数都是 NULL,返回 NULL。它常用于:
- 多字段兜底 :
coalesce(phone, mobile, email); - 给 NULL 设默认值 :
coalesce(amount, 0); - 配合外连接 :
LEFT JOIN后右表字段可能为NULL,用coalesce兜底。
注意:COALESCE 的类型必须兼容。 coalesce(int_col, 'N/A') 会报类型错误,因为 int 和 text 不能统一。需要显式转换:coalesce(int_col::text, 'N/A')。
NULLIF:相等时返回 NULL
sql
NULLIF(value1, value2)
如果 value1 = value2,返回 NULL;否则返回 value1。最典型的用途是防止除零:
sql
revenue / NULLIF(cost, 0)
如果 cost 为 0,NULLIF(cost, 0) 返回 NULL,整个除法结果为 NULL,而不是报错。这比用 CASE WHEN cost = 0 THEN NULL ELSE ... 简洁得多。
NULLIF 的另一个用途是把特定值"归一化"为 NULL:
sql
-- 把空字符串转为 NULL
NULLIF(trim(name), '')
GREATEST 和 LEAST
sql
SELECT GREATEST(1, 5, 3), -- 5
LEAST(1, 5, 3); -- 1
GREATEST 返回最大值,LEAST 返回最小值。注意它们与 max()/min() 聚合函数的区别:GREATEST/LEAST 是行内多列比较 ,max()/min() 是多行聚合。
NULL 陷阱: PostgreSQL 的 GREATEST/LEAST 会忽略 NULL(与其他数据库可能不同):
sql
SELECT GREATEST(1, NULL, 3); -- 3,NULL 被忽略
如果所有参数都是 NULL,返回 NULL。如果业务上希望 NULL 参与比较(比如任何参数为 NULL 结果就为 NULL),需要显式处理。
七、实战:一个完整的报表查询
把窗口函数、CASE、COALESCE 组合起来,写一个"各部门薪资排名 + 同部门环比 + 空值兜底"的查询:
sql
WITH ranked AS (
SELECT department, employee, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank,
LAG(salary) OVER (PARTITION BY department ORDER BY salary DESC) AS prev_salary
FROM employees
WHERE salary IS NOT NULL
)
SELECT department,
employee,
salary,
dept_rank,
coalesce(salary - prev_salary, 0) AS salary_diff,
CASE
WHEN dept_rank = 1 THEN '部门最高'
WHEN dept_rank <= 3 THEN '部门前三'
ELSE '普通'
END AS level_tag
FROM ranked
ORDER BY department, dept_rank;
这个查询展示了三个工具的分工:窗口函数负责"行间比较和排名",CASE 负责"分类打标",COALESCE 负责"空值兜底"。 三者组合,一条 SQL 就能完成原本需要在应用层多次处理才能得到的报表。
八、总结
这一篇覆盖了 SQL 高级查询的三组核心工具:
| 能力 | 关键函数 | 核心要点 |
|---|---|---|
| 排名 | ROW_NUMBER、RANK、DENSE_RANK、NTILE |
并列值处理方式不同,按业务语义选择 |
| 行间访问 | LAG、LEAD、FIRST_VALUE、LAST_VALUE |
LAST_VALUE 默认帧有陷阱,需显式指定 |
| 同环比 | LAG + 算术 + NULLIF |
数据必须连续,除零用 NULLIF 防护 |
| 条件分支 | CASE WHEN |
搜索式按顺序匹配,简单式只做等值 |
| 空值处理 | COALESCE、NULLIF、GREATEST、LEAST |
COALESCE 类型必须兼容,GREATEST 忽略 NULL |
一句话:窗口函数让你在不丢失明细的前提下做分组计算,CASE 让你在 SQL 内完成条件分支,COALESCE/NULLIF 让你优雅地处理空值和边界。三者组合,很多原本需要应用层循环的逻辑,一条 SQL 就能搞定。
下一篇,我们进入索引的世界:PostgreSQL 基本索引原理与创建------B-tree 到底是怎么组织数据的?什么情况下索引会失效?
思考题:
你用过窗口函数做同环比或排名吗?有没有踩过 LAST_VALUE 默认帧的坑,或者因为数据不连续导致 LAG 取错行?另外,你平时处理 NULL 是用 COALESCE 还是 CASE WHEN?欢迎在评论区聊聊。