窗口函数 (Window Functions) 在与查询结果集相关的“窗口”上执行计算,并为结果集的每一行返回一个值。
啥是“窗口”?为什么我查询几条数据还有窗口啊。带着这个问题读完看看能明白吗
为什么需要窗口函数?
在传统 SQL 中,我们使用 GROUP BY 和聚合函数(如 SUM, AVG)进行数据汇总。但这种方式存在一个核心局限:聚合后行数会减少。比如按部门计算平均销售额,原始的员工个体销售记录就“消失”了。
想象以下场景:你想查看每位员工的销售额,并在旁边同时显示该员工所在部门的平均销售额。传统的 GROUP BY 无法一次完成这个任务,您可能需要复杂的自连接或子查询。
窗口函数 允许在不减少原始行数的情况下,进行分组、排序和计算聚合,实现个体数据和聚合数据的并行观察。
它的核心特性就是不会折叠行,它将聚合计算的结果“投影”回每一行,而不是将多行合并成一行。
下面的书写上关键字都用的大写,但小写也是可以的
语法
窗口函数通用形式:
| function(args) OVER ( |
| [PARTITION BY ...] |
| [ORDER BY ...] |
| [frame_clause] |
| ) |
组成部分:
function:SUM, AVG, MAX, ROW_NUMBER, LAG 等
OVER():定义窗口范围
PARTITION BY:按分组计算(不会减少行)
ORDER BY:定义分组内排序
frame_clause:帧,用于指定计算所覆盖的行(后续详讲)
示例:
| SELECT |
| user_id, |
| amount, |
| SUM(amount) OVER (PARTITION BY user_id ORDER BY create_time) |
| AS running_total |
| FROM orders; |
SUM(amount) OVER (PARTITION BY user_id ORDER BY create_time) 含义:
每个用户内部独立计算
按时间排序
累计求和
结果同时包含每条订单和累计结果。
OVER()
如果 OVER() 为空:
表示全表为一个分区,全局平均值会出现在每一行。
PARTITION BY
分组但不减少行。示例:
| SUM(amount) OVER (PARTITION BY user_id) |
与 GROUP BY 不同:
| GROUP BY | PARTITION BY |
|---|
| 压缩行,只返回一行 | 不压缩行,保留所有明细 |
| 聚合后无法看到明细 | 明细和统计值可共存 |
| 不支持排名/序列分析 | 能跨行分析、排名、累计 |
ORDER BY
窗口函数中的排序。用于:
如果 ORDER BY 为空:
对排名函数基本无意义(顺序不可控)
对 SUM/AVG 等聚合窗口会让结果不稳定
窗口定义复用(WINDOW)
| SELECT |
| SUM(amount) OVER w, |
| AVG(amount) OVER w |
| FROM orders |
| WINDOW w AS (PARTITION BY user_id ORDER BY create_time); |
减少重复代码,还更清晰,建议总是使用这种写法。
分类
接下来是窗口函数的重点。
窗口函数不是特别多,熟悉了语法以后也不会再嫌写得长。
排名类
1. ROW_NUMBER()
行号这个用得最多
| ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) |
2.RANK()
排名,这个用得也不少。排名字段如果值一样,排名也会一样,后面的排名会顺延(跳号)
| RANK() OVER (ORDER BY sales DESC) |
3. DENSE_RANK()
也是排名,但是如果前面出现了一样的排名,不会跳号,而是紧挨着往下排。
4. NTILE(n)
分位,就是(近似)平均分组。比如分4组
| NTILE(4) OVER (ORDER BY score) |
分析类
1. LAG()
排序后取上一行的字段值
| SELECT |
| sale_date, |
| amount, |
| LAG(amount) OVER (ORDER BY sale_date) AS previous_day_amount |
| FROM sales |
| ORDER BY sale_date; |
它最多接受三个参数,第二个参数表示往前取第几个,默认往前一个;第三个参数是取不到的时候的默认值。
2. LEAD()
同前,取后面一行的字段值。
3. FIRST_VALUE()
返回窗口分区中的第一行的值
| FIRST_VALUE(salary) OVER ( |
| PARTITION BY department |
| ORDER BY hire_date |
| ) AS first_salary_in_dept |
4. LAST_VALUE
不说你也知道了吧。
聚合类
将传统的聚合函数(SUM, AVG, COUNT, MAX, MIN)与 OVER 子句结合使用
|
| SELECT |
| employee_name, |
| department, |
| sale_amount, |
| |
| AVG(sale_amount) OVER (PARTITION BY department) AS dept_avg_sale, |
| |
| sale_amount / SUM(sale_amount) OVER (PARTITION BY department) * 100 AS sales_percentage |
| FROM sales; |
又或者简单点的
| MAX(amount) OVER (PARTITION BY user_id) |
分布类
用于计算当前行值在整个窗口或分区中的相对分布
1. PERCENT_RANK()
百分比排名, 计算当前行在分区中的百分比排名。结果范围 0≤X<1。
| PERCENT_RANK() OVER (ORDER BY amount) |
2. CUME_DIST()
累积分布,计算当前行值小于或等于分区中所有值的比例。结果范围 0<X≤1。
窗口帧 (Window Frame)
这是入门窗口函数的最后一块关键拼图了。
窗口帧是 OVER 子句的第三部分,用来定义当前行计算时需要考虑的物理行或逻辑值范围。
- ROWS (基于行数): 关注物理行数。例如,“当前行之前的 2 行”。
- RANGE (基于值域): 关注排序键的值域。例如,“当前行之前 N 个值”或“与当前行值相同的所有行”。当需要处理并列值时,RANGE 更有用。
前面的使用上我们好像没接触到窗口帧,是因为它有默认值:当 OVER 子句中包含 ORDER BY 但没有显式定义窗口帧时,PgSQL 使用的默认帧是: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。
窗口帧的边界
- CURRENT ROW,当前行
- UNBOUNDED PRECEDING,分区内的第一行
- UNBOUNDED FOLLOWING,分区内的最后一行
- N PRECEDING,当前行之前 N 行/值
- N FOLLOWING,当前行之后 N 行/值
跟分析类的4个函数很像是吧
通过这些边界我们看看默认值的含义:基于值的, 从分区第一行开始到当前行。
你能看到这里已经非常不容易,还要让你发现这里面的问题简直难于上青天!
这里有什么问题呢?
1. 同值同行帧问题
默认值的范围按排序字段的“值”划定的,不是按行!
如果当前行值 = 100,那之前所有 value <= 100 的都是帧内。
那咋了
| SUM(amount) OVER (ORDER BY price) |
你这样写可能是想一行一行的累加,但是由于是基于值的,如果数据如下
在计算第一行的累积值时就已经包含了第二行的值,导致第一行和第二行都是200。
所以需要改成基于行的:
| SUM(amount) |
| OVER (ORDER BY price ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) |
做为最佳实践:在 PostgreSQL 中尽量使用 ROWS,而非 RANGE
2. LAST_VALUE()恒等于CURRENT ROW
由于默认窗口帧是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,如果不显式定义帧,LAST_VALUE() 看到的窗口截止到当前行,因此它通常只会返回当前行的值。
正确获取分区内最后一个值的方法:
| SELECT |
| employee_name, |
| sale_amount, |
| FIRST_VALUE(sale_amount) OVER w AS first_sale, |
| LAST_VALUE(sale_amount) OVER ( |
| PARTITION BY department |
| ORDER BY sale_date |
| |
| ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
| ) AS final_sale_in_dept |
| FROM sales |
| WINDOW w AS (PARTITION BY department ORDER BY sale_date); |
做为最佳实践:在使用LAST_VALUE时总是强制指定
常用帧写法
(1)运行累计(running sum)
| ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
(2)滚动窗口(移动平均)
过去 2 行 + 当前行:
| ROWS BETWEEN 2 PRECEDING AND CURRENT ROW |
(3)只看上一行(环比/差值)
| ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING |
(4)整组统计(等同于不写)
| ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
last but not least
最后说一些性能考量方面的东西。
PG 对窗口函数优化很强:
一般建议:
能用 ROWS 不用 RANGE
尽量只使用需要的帧,不要一律写 "UNBOUNDED"
仅在必要时排序: 如果不需要排名或偏移函数,可以省略 ORDER BY 子句,这会避免昂贵的排序操作。
使用 FILTER 子句减少聚合窗口函数的计算量: COUNT(sale_amount) FILTER (WHERE sale_amount > 10000) OVER (PARTITION BY department)
使用 WINDOW 子句避免重复定义。当多个窗口函数使用相同的分区和排序定义时,应使用 WINDOW 子句。这不仅提高了可读性和维护性,还允许 PgSQL 优化器更好地重用分区和排序结果,避免多次重复的昂贵操作。
为啥叫“窗口”
回到开头的问题。
我觉得这个叫法并不清晰,当然也可能是翻译不够好。但是英语就叫"window"。
简单点说,窗口就是一个个的分组,或者叫分区。但是分组group,分区partition都有含义了,叫分段函数吧,歧义也大。所以只能使用“窗口”来统一语言了。