从聚合到透视:SQL 窗口函数的系统解读
目录
引言
1. 窗口函数的基本概念与语法骨架
1.1 语法框架
1.2 执行顺序
2. 排名三兄弟:ROW_NUMBER、RANK、DENSE_RANK
2.1 语义差异
2.2 运行示例
2.3 结果解读
3. 偏移双雄:LAG 与 LEAD
3.1 语法与参数
3.2 环比增长率计算示例
3.3 结果验证
4. 综合案例:Top-N 与同比环比一次查
4.1 数据准备
4.2 查询实现
4.3 结果
5. 性能优化与执行计划
5.1 索引策略
5.2 执行计划剖析(以 PostgreSQL 为例)
6. 常见误区与最佳实践
7. 小结与展望
引言
SQL 自 1974 年诞生以来,其查询能力经历了从简单投影、选择、连接到 OLAP 多维分析的多轮进化。2003 年,ANSI SQL 正式将“窗口函数(Window Function)”纳入规范,使分析型查询可以在不引入子查询或自连接的情况下,完成排序、累计、同比、环比、Top-N 等复杂计算。本文聚焦于最常用的五类窗口函数——ROW_NUMBER、RANK、DENSE_RANK、LAG 与 LEAD——从语法、语义、性能、实战四个维度进行系统梳理,并辅以可以复制运行的示例与对比表格,帮助读者在真实业务场景中迅速落地。
1. 窗口函数的基本概念与语法骨架
窗口函数的核心思想是:在结果集的“窗口”内执行聚合或排序计算,但不减少输出行数。这与传统的 GROUP BY 形成鲜明对比——GROUP BY 会把多行聚合成一行,而窗口函数保留明细行,仅在其旁增加一列计算结果。
1.1 语法框架
window_function_name ( [expression [, ...]] )
OVER ([PARTITION BY partition_expression [, ...]][ORDER BY sort_expression [ASC|DESC] [NULLS {FIRST|LAST}] [, ...]][frame_clause]
)
-
PARTITION BY
类似于 GROUP BY,定义窗口边界。 -
ORDER BY
定义窗口内行的逻辑顺序,直接影响排名函数与偏移函数的行为。 -
frame_clause
指定“滑动窗口”范围,如ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
。
1.2 执行顺序
一条 SELECT 语句的逻辑执行顺序为:
FROM → WHERE → GROUP BY → HAVING → SELECT (含窗口函数) → DISTINCT → ORDER BY → LIMIT。
窗口函数在 SELECT 阶段执行,因此它可以引用 SELECT 列表中定义的列别名,却无法在 WHERE、GROUP BY 中直接使用。
2. 排名三兄弟:ROW_NUMBER、RANK、DENSE_RANK
在 OLAP 报表中,“取每个分组的前 N 名”是最常见的诉求。三种排名函数的区别在于对并列值的处理。
2.1 语义差异
函数名 | 并列值是否占用名次 | 名次是否连续 | 典型场景示例 |
---|---|---|---|
ROW_NUMBER() | 否 | 是 | 去重、唯一序号 |
RANK() | 是 | 否 | 学生成绩排名,允许并列第 2 |
DENSE_RANK() | 是 | 是 | 等级划分,如 A/B/C 档 |
2.2 运行示例
假设存在销售表 sales(order_id, sales_date, shop_id, amount)
,计算每个门店按月销售额排名:
SELECT shop_id,DATE_TRUNC('month', sales_date) AS mon,SUM(amount) AS mon_amt,ROW_NUMBER() OVER (PARTITION BY shop_id ORDER BY SUM(amount) DESC) AS rn,RANK() OVER (PARTITION BY shop_id ORDER BY SUM(amount) DESC) AS rnk,DENSE_RANK() OVER (PARTITION BY shop_id ORDER BY SUM(amount) DESC) AS drnk
FROM sales
GROUP BY shop_id, DATE_TRUNC('month', sales_date);
2.3 结果解读
-
rn 列始终唯一,即使金额相同也会给出 1、2、3…
-
rnk 列出现并列时跳过后续名次,例如两个 1 名后紧跟 3 名。
-
drnk 列在并列后名次连续,例如两个 1 名后仍是 2 名。
3. 偏移双雄:LAG 与 LEAD
排名函数回答“我是谁”,而偏移函数回答“我的邻居是谁”。LAG/LEAD 通过向前/向后抓取第 N 行的值,实现同比、环比、差分计算。
3.1 语法与参数
LAG(column, offset, default) OVER (PARTITION BY ... ORDER BY ...)
LEAD(column, offset, default) OVER (PARTITION BY ... ORDER BY ...)
-
column:要取值的列。
-
offset:偏移量,正整数,默认为 1。
-
default:当偏移后越界时的回退值,省略则为 NULL。
3.2 环比增长率计算示例
继续用 sales
表,计算每个门店的月度环比增长率:
WITH month_sum AS (SELECT shop_id,DATE_TRUNC('month', sales_date) AS mon,SUM(amount) AS amtFROM salesGROUP BY shop_id, DATE_TRUNC('month', sales_date)
),
lag_tab AS (SELECT *,LAG(amt, 1) OVER (PARTITION BY shop_id ORDER BY mon) AS prev_amtFROM month_sum
)
SELECT shop_id,mon,amt,prev_amt,CASEWHEN prev_amt IS NULL THEN NULLELSE ROUND( (amt - prev_amt) * 100.0 / prev_amt, 2)END AS mom_rate
FROM lag_tab
ORDER BY shop_id, mon;
3.3 结果验证
-
第一条记录 prev_amt 为 NULL,环比率为空,符合业务直觉。
-
通过 LEAD 亦可计算“未来第 N 天”的预测值,只需把 LAG 改为 LEAD 即可。
4. 综合案例:Top-N 与同比环比一次查
真实报表常要求“每个品类下销售额 Top3 店铺,并展示其本月、上月、去年同期销售额”。传统写法需多层子查询,窗口函数可将逻辑浓缩至一层。
4.1 数据准备
为聚焦核心逻辑,本节使用 CTE 构造简化表:
CREATE TEMP TABLE sales_demo AS
SELECT *
FROM (VALUES(202407, 'A', 'S1', 100),(202407, 'A', 'S2', 90),(202407, 'A', 'S3', 80),(202407, 'A', 'S4', 70),(202406, 'A', 'S1', 95),(202406, 'A', 'S2', 85),(202406, 'A', 'S3', 75),(202406, 'A', 'S4', 65),(202307, 'A', 'S1', 80),(202307, 'A', 'S2', 70),(202307, 'A', 'S3', 60),(202307, 'A', 'S4', 50)
) AS t(ym, category, shop_id, amt);
4.2 查询实现
WITH ranked AS (SELECT *,ROW_NUMBER() OVER (PARTITION BY ym, category ORDER BY amt DESC) AS rnFROM sales_demo
),
current_top AS (SELECT * FROM ranked WHERE ym = 202407 AND rn <= 3
),
joined AS (SELECT c.ym AS cur_ym,c.category,c.shop_id,c.amt AS cur_amt,LAG(c.amt, 1) OVER (PARTITION BY c.shop_id ORDER BY c.ym) AS last_m,LAG(c.amt, 12) OVER (PARTITION BY c.shop_id ORDER BY c.ym) AS last_yFROM ranked cWHERE c.rn <= 3
)
SELECT * FROM joined WHERE cur_ym = 202407;
SQL 的总体思路
“先排名 → 再筛选 → 再偏移” 三步走:(1) ranked:给 每个 ym、category 组合内部 按 amt 降序打上序号 rn。
(2) current_top:把 202407 且 rn<=3 的行抽出来,得到“本月 Top3”。
(3) joined:
- 先把 ranked 里 所有月份、但只保留 Top3 店铺 的行留下来(即每个 ym 的 Top3)。
- 然后按 shop_id 分区,按 ym 排序,用 LAG 把上一行(上月)和上 12 行(去年同月)的 amt 抓过来。
(4) 最后再用 WHERE cur_ym = 202407 把非 202407 的行过滤掉,只留下 3 行结果。“先给所有月份排座次,留下每个座次里的尖子生(Top3),再用时间轴把他们的历史成绩抄过来,最后只取最新一期的报告。”
4.3 结果
cur_ym | category | shop_id | cur_amt | last_m | last_y |
---|---|---|---|---|---|
202407 | A | S1 | 100 | 95 | 80 |
202407 | A | S2 | 90 | 85 | 70 |
202407 | A | S3 | 80 | 75 | 60 |
5. 性能优化与执行计划
窗口函数虽优雅,但在大数据量下仍需关注性能。主流引擎(PostgreSQL、MySQL 8+、SQL Server、Oracle、BigQuery、Snowflake)均已实现基于排序或哈希的窗口算子。
5.1 索引策略
-
ORDER BY 列与 PARTITION BY 列共同构成排序键,建立复合索引可减少排序开销。
-
若仅使用 ROW_NUMBER 而无 ORDER BY,则优化器退化为任意排序,结果不稳定,应避免。
5.2 执行计划剖析(以 PostgreSQL 为例)
EXPLAIN (ANALYZE, BUFFERS)
SELECT shop_id,RANK() OVER (PARTITION BY shop_id ORDER BY amount DESC)
FROM sales;
典型输出:
WindowAgg (cost=... rows=...)-> Sort (cost=... rows=...)Sort Key: shop_id, amount DESC-> Seq Scan on sales ...
当数据量达到千万级,可考虑使用物化视图或增量刷新策略,将窗口结果缓存至每日离线任务,线上直接查询。
6. 常见误区与最佳实践
误区描述 | 正确做法 |
---|---|
在 WHERE 子句中直接引用窗口函数别名 | 使用子查询或 CTE 先计算窗口列再过滤 |
忽略 NULLS FIRST/LAST 导致排序不稳定 | 显式指定 NULL 顺序,确保结果可复现 |
认为 DENSE_RANK 一定优于 RANK | 依据业务需求选择,等级划分用 DENSE_RANK,真实排名用 RANK |
在 MySQL 5.x 使用窗口函数 | 升级至 8.0+ 或使用变量模拟(性能差) |
7. 小结与展望
窗口函数将 SQL 从“集合查询语言”推进到“数据分析语言”。本文通过五类高频函数的语法、对比、实战、性能、误区五个角度进行剖析,辅以可直接运行的示例,旨在让读者不仅“会用”,更能“用好”。未来,随着 ANSI SQL 引入 WINDOW
子句命名窗口以及 GROUPS
模式,窗口函数将在流式计算(Flink、Kafka Streams)与机器学习特征工程领域发挥更大作用。希望读者在业务实践中继续深挖,将窗口函数与索引、物化视图、增量计算相结合,打造高性能、低延迟的实时分析系统。