AI时代首先数据库-PostgreSQL实战笔记 | 17 PostgreSQL SQL高级查询:窗口函数、CASE、COALESCE

上一篇我们聊了派生表、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?欢迎在评论区聊聊。

相关推荐
叶子丶苏2 小时前
提示词工程格式四段式与少样本-VibeCoding入门笔记06
人工智能·笔记
远方16092 小时前
119-Oracle CDB 下修改和重置 SYS SYSTEM密码记录
大数据·数据库·sql·oracle·database
橙光成长2 小时前
Context Mode 深度解析:用沙盒、SQLite 与钩子路由,把 AI 编码 Agent 的上下文消耗削减 98%
数据库·人工智能·sqlite
数据库小学妹2 小时前
MySQL时区问题排查:TIMESTAMP和DATETIME的区别与跨时区对账踩坑
数据库·mysql·datetime·时区·timestamp·跨时区对账
问心无愧05132 小时前
[极客大挑战 2019]HardSQL
笔记
m0_646429972 小时前
BCrypt 与 JWT 认证流程学习笔记
笔记·学习
创世虚拟世界2 小时前
把 3D 虚拟世界拉到本地:vibe coding 的手感,是从看见自己那一行改动开始的
javascript·数据库·人工智能·3d·gitee·虚拟现实
俊昭喜喜里3 小时前
Springboot的Mybatis中的条件构造器
数据库·mybatis
倔强的石头_3 小时前
列存储到底快在哪 在金仓KES上把原理和实操跑了一遍
数据库