有段时间我在做销售数据报表,天天要算"每个门店每个月累计销售额"这类需求。第一版我老老实实写了40多行子查询嵌套,跑一次几十秒,后来被同事提了一嘴:你怎么不用窗口函数?我去查了SQL窗口函数是什么,看完第一个例子才反应过来,原来这种"既要保留明细、又要同时拿到汇总结果"的需求,天生就是给窗口函数准备的。这篇文章想把窗口函数讲透:它解决了什么问题、语法骨架怎么拆、三类常用函数怎么选、框架子句容易踩哪些坑,以及生产环境里真正用得上的优化建议。适合正在学SQL、准备数据岗面试、或者日常写报表经常被这类需求折磨的同学。
1. 为什么说窗口函数是"行级计算"的救星
1.1 没有窗口函数之前,我们是怎么硬写的
先感受一个最常见的需求:有一张销售流水表,字段是门店、月份、销售额,我要算每个门店从年初到当前月份的累计销售额。没有窗口函数的时候,大多数人的第一反应是子查询。
sql复制SELECT a.store_id,
a.month,
a.amount,
(SELECT SUM(b.amount)
FROM sales b
WHERE b.store_id = a.store_id
AND b.month <= a.month) AS cumulative_amount
FROM sales a;
这段SQL能跑,但问题很多。第一,这是一个相关子查询,外层每返回一行,内层就要把整个表扫一遍,数据量一旦过了百万级,性能直接崩。第二,month字段如果存的是字符串,比如"2024-01",你用<=比较字符串,结果不一定符合日期直觉;如果存的是日期,又要考虑格式、时区这些细枝末节。第三,这还只是"累计"这一个需求,如果同时还要算"每个门店当月销售额占全年比例""每个门店按销售额排名",SQL会膨胀成一个没人愿意维护的怪物。
另一个经典场景是分组TopN。比如"每个部门工资最高的三个人",不用窗口函数就得先自连接计算排名,再看名次小于等于3,逻辑绕,而且因为JOIN产生中间结果,性能也差。我最早写这类查询时,经常被自连接搞晕,排名字段稍不注意就重复统计了。
1.2 分组不合并行:和GROUP BY的本质区别
窗口函数和普通聚合函数最根本的区别,在于它不减少返回的行数。
GROUP BY把同一个分组的很多行压成一行,比如SELECT dept_id, AVG(salary) FROM employees GROUP BY dept_id,最后每个部门只能看到一行平均工资,员工的姓名、工资、入职时间这些明细全丢了。窗口函数则是在保留每一行明细的同时,给每一行"挂"上一个计算出来的值。
我经常用一个打比方的方式帮新手理解:全班一张成绩表,GROUP BY是小组成员坐在一起,最后只留一张写满平均分的便利贴;窗口函数是在成绩表右侧加一列"本组平均分",每个学生的名字和分数还在原位,只是旁边多了个汇总数字。
| 对比点 | GROUP BY | 窗口函数 |
|---|---|---|
| 返回行数 | 每个分组一行 | 原表每行保留 |
| 明细信息 | 丢失 | 完整保留 |
| 聚合结果展示 | 单独一行显示 | 跟随每一行显示 |
| 是否支持排名、前后取值 | 不支持 | 原生支持 |
| 典型场景 | 分组汇总报表 | 明细加汇总、排名、累计 |
所以判断该用哪个其实很简单:如果你的报表只需要分组后的汇总数字,GROUP BY够用且更快;如果你既要明细行,又要明细行旁边的汇总值、排名值、或者上一行的值,那基本就是窗口函数的主场。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 窗口函数的三层语法骨架:把OVER拆开看
2.1 函数名 + OVER() 的固定结构
窗口函数的语法看着唬人,其实骨架很固定:
sql复制函数名(参数) OVER (
[PARTITION BY 列1, 列2]
[ORDER BY 列3 ASC/DESC]
[ROWS/RANGE 框架子句]
)
最外层函数名可以是聚合函数(SUM、AVG、COUNT、MAX、MIN),也可以是专门为窗口设计的函数(ROW_NUMBER、RANK、LAG等)。关键是后面跟着的OVER(),它才是"窗口"的开关。
OVER() 里面三个部分都可以省略,但含义完全不同。空括号OVER()表示把整张表当作一个窗口,所有行共用同一个聚合结果。写几行代码看下就明白了:
sql复制SELECT employee_id,
dept_id,
salary,
AVG(salary) OVER() AS company_avg,
AVG(salary) OVER(PARTITION BY dept_id) AS dept_avg
FROM employees;
这个查询里,company_avg列每一行都是全公司的平均工资,不管你属于哪个部门;dept_avg列则是按照部门分组后,每个员工看到的自己部门平均工资。两列都不影响原表的行数,每条员工记录照样原样返回。
2.2 PARTITION BY 和 ORDER BY 分别控制什么
PARTITION BY负责划定窗口的分区边界,也就是"在哪些行组成的组内做计算";ORDER BY负责决定分区内行的排列顺序,以及逐行计算时"推进"的方向。
举个例子,我想看每个部门内部,按工资从低到高的累计工资。
sql复制SELECT dept_id,
emp_name,
salary,
SUM(salary) OVER(PARTITION BY dept_id ORDER BY salary) AS cum_salary
FROM employees
ORDER BY dept_id, salary;
PARTITION BY dept_id把数据按部门切开,各个部门互不干扰;ORDER BY salary让工资从小到大排,SUM(salary)随着每一行往下走,累加值越来越大。第一行的累计值就是自己的工资,第二行是前两个人工资之和,到部门最后一行时,累计值等于整个部门工资总和。
要注意,PARTITION BY不是GROUP BY。它不会把多行并成一行,只是给计算划定了一个看不见的边界。如果你只写了PARTITION BY dept_id而不写ORDER BY,那每一行看到的都是整个部门的工资总和,不是累计值。这是刚入门时最容易弄混的地方。
2.3 窗口函数在SQL执行顺序中的位置:为什么WHERE里不能用它
理解窗口函数,还必须知道它在SQL执行流程里处于什么位置。一个常见的SQL逻辑执行顺序大致是:
FROM -> WHERE -> GROUP BY -> HAVING -> 窗口函数 -> SELECT -> DISTINCT -> ORDER BY -> LIMIT
这意味着窗口函数是在WHERE之后才被计算出来的。所以你不能直接在WHERE里过滤窗口函数的结果。比如想找"部门平均工资大于5000的员工",下面这种写法是错误的:
sql复制-- 错误示例:WHERE 阶段窗口函数还没算出来
SELECT employee_id, dept_id, salary,
AVG(salary) OVER(PARTITION BY dept_id) AS dept_avg
FROM employees
WHERE AVG(salary) OVER(PARTITION BY dept_id) > 5000;
正确做法是套一层子查询或者用CTE,让窗口函数先算完,再在外面过滤:
sql复制SELECT employee_id, dept_id, salary, dept_avg
FROM (
SELECT employee_id, dept_id, salary,
AVG(salary) OVER(PARTITION BY dept_id) AS dept_avg
FROM employees
) t
WHERE dept_avg > 5000;
这个坑我在生产环境里踩过不止一次。记住:窗口结果要过滤,先包子查询,不要在同一个SELECT层里直接引用。
3. 聚合、排名、取值:三类窗口函数的选型思路
3.1 聚合窗口函数:SUM、AVG、COUNT的滑动累计
聚合窗口函数就是把SUM、AVG、COUNT、MAX、MIN放进OVER()里。它们的威力在于,配合ORDER BY之后,能产生"移动计算"的效果。
比如月度销售表里,想看每个月的当月销售额和从年初到现在的累计销售额:
sql复制SELECT month_id,
total_amount,
SUM(total_amount) OVER(ORDER BY month_id) AS running_total
FROM monthly_sales;
这个running_total就是逐月累加的销售额,不需要自连接,也不需要子查询。凡是"累计、滚动、分区内求和"这类词,基本都可以用聚合窗口函数解决。
AVG也常用在移动平均场景,比如算7日平均销售额:
sql复制SELECT day,
amount,
AVG(amount) OVER(ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS avg_7d
FROM daily_sales;
这里的ROWS BETWEEN 6 PRECEDING AND CURRENT ROW是框架子句,意思是"从前面第6行到当前行"这个范围参与计算,也就是一个7天的滑动窗口。框架子句是窗口函数进阶的重点,我放到下一章专门说。
用COUNT时还要注意一个问题:COUNT(amount)只统计amount为非NULL的行数,COUNT(*)统计所有行数。你在窗口里到底要哪个,需要想清楚,否则统计结果会和你预期差一行两行。
3.2 排名窗口函数:ROW_NUMBER、RANK、DENSE_RANK的差异
排名类的窗口函数是面试题里的常客,也是最容易搞混的一组:ROW_NUMBER、RANK、DENSE_RANK。
sql复制SELECT student_id,
course_id,
score,
ROW_NUMBER() OVER(PARTITION BY course_id ORDER BY score DESC) AS rn,
RANK() OVER(PARTITION BY course_id ORDER BY score DESC) AS rk,
DENSE_RANK() OVER(PARTITION BY course_id ORDER BY score DESC) AS dr
FROM scores;
它们的差别集中在"遇到相同分数怎么处理"。
| 函数 | 相同分数时 | 后续排名 | 典型用途 |
|---|---|---|---|
| ROW_NUMBER | 不给相同名次,顺序编号 | 1, 2, 3, 4 | 分组去重只留一条 |
| RANK | 相同分数并列 | 1, 1, 3 | 体育比赛式排名,有并列就跳过 |
| DENSE_RANK | 相同分数并列 | 1, 1, 2 | 需要连续名次的排名 |
ROW_NUMBER看似简单,实际有个隐藏风险:如果你分区内排序的字段有重复值,数据库无法保证哪一行排在前面,每次执行结果可能不一样。所以做去重时,ORDER BY最好带一个唯一字段,比如时间倒序后又拼一个主键。
RANK和DENSE_RANK的区别在于要不要"留空"。同样是前两名并列90分,RANK会给出1、1、3,因为第三名实际上是第四个位置;DENSE_RANK则会给出1、1、2。具体用哪个,取决于业务上"第三名"这个说法是不是要给并列之后的人。
3.3 取值窗口函数:LAG、LEAD、FIRST_VALUE、LAST_VALUE
第三类窗口函数用来"取其他位置的值"。LAG取当前行之前的第N行,LEAD取当前行之后的第N行,FIRST_VALUE取窗口内第一行,LAST_VALUE取窗口内最后一行。
最典型的应用是环比计算。比如我想算每天的销售额比前一天涨了多少:
sql复制SELECT day,
amount,
LAG(amount, 1, 0) OVER(ORDER BY day) AS prev_amount,
ROUND(
(amount - LAG(amount, 1, 0) OVER(ORDER BY day))
/ NULLIF(LAG(amount, 1, 0) OVER(ORDER BY day), 0) * 100,
2) AS growth_rate
FROM daily_sales;
LAG(amount, 1, 0)的三个参数分别是:要取的列、往前第几行、没有前一行时返回的默认值。如果没写第三个参数,第一行因为前面没有数据,返回的是NULL,你后面再拿NULL做四则运算,结果就全是NULL,所以用COALESCE或默认值处理空值几乎成了必写步骤。
FIRST_VALUE和LAST_VALUE比较容易踩坑。很多人在OVER(PARTITION BY dept_id ORDER BY salary)里直接写LAST_VALUE(salary),以为能拿到分区最后一个值,结果发现返回的居然是当前行的工资。原因就是默认窗口范围只到当前行,LAST_VALUE看到的最后一行就是当前行自己。想拿到真正的分区最后一行,必须显式声明框架:
sql复制LAST_VALUE(salary) OVER(
PARTITION BY dept_id
ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
关于框架子句为什么有这种影响,下面详细展开。
4. 框架子句才是真正的分水岭:ROWS与RANGE的差别
4.1 什么是窗口框架:当前行能"看"到多远
框架子句是窗口函数里最容易被忽略、却又最影响结果的部分。它解决的问题是:当前行在计算时,究竟把窗口内的哪些行纳入计算范围。
一条完整的框架子句通常长这样:
sql复制ROWS BETWEEN 起始边界 AND 结束边界
边界有5种常用写法:
UNBOUNDED PRECEDING:分区内第一行n PRECEDING:当前行的前n行CURRENT ROW:当前行n FOLLOWING:当前行的后n行UNBOUNDED FOLLOWING:分区内最后一行
比如ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING,就是取当前行、前一行、后一行这三行参与计算,适合算小幅度的局部对比。ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW是指从分区第一行累加到当前行,这也是"累计"的底层逻辑。
4.2 ROWS和RANGE的区别:重复值导致的隐藏雷区
很多人不知道,OVER(ORDER BY salary)如果没写框架子句,数据库默认用的是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,不是按物理行,而是按排序键的值来确定范围。
ROWS按物理行数计算,说前6行就是前6行;RANGE按值相等来扩展范围,排序键相同的一批行会被看作一个整体。这个区别在处理重复值时差异巨大。
看个例子,有三个人工资都是5000,然后是一个工资8000的人,在ORDER BY salary升序下累加工资:
sql复制SELECT emp_name, salary,
SUM(salary) OVER(ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum_rows,
SUM(salary) OVER(ORDER BY salary
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum_range
FROM employees;
ROWS版本会按行累加:第一个5000累加值为5000,第二个5000累加值为10000,第三个5000累加值为15000,到8000时累加值为23000。RANGE版本则因为三个5000并列,会一次性把三行都纳入,所以三个5000行的累加值都是15000,到8000时才变成23000。
很难说哪个更"正确",关键是你要知道默认行为是RANGE。如果你的目的是严格的逐行累计,最好明确写成ROWS,别把结果交给默认值,否则线上数据一出现重复值,报表数字就和预期对不上了。
提示:写
OVER(ORDER BY ...)时,如果你要的是逐行累计,建议显式加上ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,避免默认RANGE把你绊倒。
4.3 移动平均这类滑动窗口怎么写才稳
移动平均是滑动窗口最典型的应用。以7天移动平均为例:
sql复制SELECT day,
amount,
AVG(amount) OVER(
ORDER BY day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS avg_7d
FROM daily_sales;
这里有几个坑。第一个是数据缺失问题:如果中间某天没有销售记录,那ROWS按物理行数往前数,会把"实际存在的6行"当作7天来平均,而不是按日期差值补零。如果你想做严格的按日期滑动平均,需要先补全日期,比如用一张日历表左连接,把没有销售的日期填成0,再做窗口计算。
第二个是边界问题:前6行时窗口内只有不足7行,AVG会基于实际的3行或5行计算,也就是"不足7天就用已有天数平均",通常业务上可接受,但也要明确告知看报表的人。
第三个是RANGE配合日期类型的移动平均,在某些数据库里会按日期值做范围扩展,如果同一天有多条记录,结果又和ROWS不同。凡是涉及移动计算的,我建议一律显式写ROWS。
5. 四个高频实战场景:从TopN到环比计算
5.1 分组TopN:每个部门薪资前3名
这是窗口函数在面试题里的保留节目。假设employees表有dept_id、emp_name、salary字段。
sql复制SELECT dept_id, emp_name, salary, rn
FROM (
SELECT dept_id,
emp_name,
salary,
RANK() OVER(PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM employees
) t
WHERE rn <= 3;
用RANK表示:如果第三名有并列,两个人都会进结果。如果业务上明确说"只要3个人,就算并列也不要多给名额",那应该用ROW_NUMBER。这个选择不是技术问题,是业务口径问题,写之前一定要和需求方确认清楚。
5.2 用LAG求环比:本月销售额相比上月变化
月度销售表monthly_sales(month_id, amount),求每月环比增长率:
sql复制SELECT month_id,
amount,
LAG(amount, 1) OVER(ORDER BY month_id) AS prev_amount,
COALESCE(
ROUND(
(amount - LAG(amount, 1) OVER(ORDER BY month_id))
/ NULLIF(LAG(amount, 1) OVER(ORDER BY month_id), 0) * 100,
2),
0
) AS growth_rate
FROM monthly_sales
ORDER BY month_id;
这里的NULLIF和COALESCE都是防御性写法。第一个月没有上月数据,LAG返回NULL,NULLIF(prev_amount, 0)把除数为0的情况转成NULL,COALESCE再把计算结果中的NULL显示成0,避免报表里出现刺眼的NULL。
有同学问能不能用LEAD代替LAG,当然可以,只是语义不同。LAG是从当前行往前看,LEAD是往后看,所以"算环比"用LAG更自然。
5.3 分组去重只保留最新一条记录
DISTINCT能去重,但它是按整行去重,没法做"每个用户只保留最近一条订单"这种精细去重。窗口函数配合ROW_NUMBER才是标准解法。
假设订单表orders(order_id, user_id, order_time, status),要每个用户最新状态的订单:
sql复制SELECT order_id, user_id, order_time, status
FROM (
SELECT *,
ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY order_time DESC) AS rn
FROM orders
) t
WHERE rn = 1;
这一步是很多数据清洗场景的底子。比如日志表里同一个设备有多条记录,按采集时间倒序取最新的那条,就是这套写法。重点还是刚才提醒过的:order_time如果同一用户有重复值,需要再拼一个唯一字段作为次级排序,否则取到哪一条不可控。
5.4 组内累计:每个用户的下单金额累计
最后看一个稍复杂一点的应用,按用户和时间累计下单金额,观察每个用户从第一次下单到现在的累计贡献。
sql复制SELECT user_id,
order_time,
amount,
SUM(amount) OVER(
PARTITION BY user_id
ORDER BY order_time
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS user_cum_amount
FROM orders;
这个结果配上order_time,可以在明细里看到每个用户每一单以后累计花了多少钱,比如"第3单累计突破1000元"。如果后续要查"累计金额超过1000元的首单时间",就可以在这个查询外面再包一层,取user_cum_amount >= 1000且行号最小的那一条。
这类问题用自连接写会非常痛苦,因为你要把一个用户的所有订单和自己做笛卡尔积,再判断时间先后累加,数据量大一点就直接卡死。窗口函数把这个过程变成了一个OVER子句的事。
6. 生产环境里的性能与兼容性建议:窗口函数不是银弹
6.1 最常见的五个坑,逐个排雷
第一,WHERE里不能用窗口函数,前面已经强调过,必须包子查询。
第二,OVER()里不能用SELECT别名。比如SELECT salary * 1.1 AS new_salary, SUM(new_salary) OVER()这种写法,在大多数数据库里会报错。别跟ORDER BY子句可以用别名混淆,OVER内部用的是表达式或原始列,不是别名。
第三,排序字段有NULL时注意差异。ORDER BY salary升序时,MySQL里NULL默认排最前面,PostgreSQL里默认排最后面,这些默认行为会直接影响ROW_NUMBER和累加结果。如果业务上有要求,最好显式写NULLS FIRST或NULLS LAST。
第四,ROW_NUMBER在不稳定排序下结果不确定。分区内排序字段重复时,数据库按物理存储顺序返回,这个顺序对用户不可控。解决办法是补一个主键或时间戳字段做第二排序键。
第五,别把窗口函数当GROUP BY用。只是要一个分组合计,GROUP BY更快,也比窗口函数少占内存。窗口函数最大的价值是在明细行旁边挂汇总结果,如果没有保留明细的需求,没必要动用它。
6.2 性能优化:先把数据变小,再开窗口
窗口函数性能开销的大头是排序。PARTITION BY和ORDER BY组合后,数据库要在内存或临时文件里对每个分区排序。数据量很大时,分区数多、排序字段复杂,都可能拖慢查询。
我的习惯是先缩小数据范围再进行窗口计算。比如你只需要近三个月的Top10,先WHERE把三个月的数据捞出来,再开窗口,尽量不让窗口函数对全表几千万行做排序。
看执行计划也很重要。在MySQL里如果执行计划出现Using temporary; Using filesort,并且这部分耗时占比很高,就要考虑能不能减少排序字段、能不能建联合索引,或者把计算拆到之前的子查询层提前过滤。
另外,尽量避免在同一个查询里叠加五六个不同的窗口函数,尤其是每个窗口函数都有各自的PARTITION BY和ORDER BY。多个窗口排序会发生多次,SQL写起来爽,跑起来可能很感人。能复用就复用一个窗口计算,或者拆成多个CTE,让每层各司其职。
6.3 数据库兼容性:MySQL 8.0、SQL Server、PostgreSQL的差异
窗口函数现在是SQL标准的一部分,主流数据库的语法基本统一。但版本差异是实际工程里的硬约束。
| 数据库 | 窗口函数支持情况 |
|---|---|
| MySQL | 8.0开始原生支持;5.7及以下只能用用户变量模拟,复杂场景极易出错 |
| PostgreSQL | 很早就支持,语法规范,默认行为可预期 |
| SQL Server | 2005开始支持,语法同样是标准写法 |
| Oracle | 很早就支持,功能齐全 |
如果你是老项目还在用MySQL 5.7,看到网上教ROW_NUMBER的代码先别急着复制,检查一下线上版本。我以前就遇到过开发环境是8.0,线上是5.7,窗口函数上线直接语法报错的尴尬。老版本想实现分组去重只能靠用户变量,写法绕且难维护,这种时候更建议推动升级。
SQL Server和PostgreSQL等产品虽然基本语法一致,但默认值、NULL排序、字符串比较规则仍有差异,跨库迁移时不能只把SQL原样搬,至少要在目标库里跑一遍核心用例。
6.4 我的使用习惯:复杂窗口逻辑先写进CTE
最后分享一个让我少踩很多坑的习惯:窗口函数逻辑一旦超过一层,我就会把结果先放进CTE里,再在外面过滤、聚合或join。比如前面TopN的例子,写成下面这样,可读性明显高很多:
sql复制WITH ranked AS (
SELECT dept_id,
emp_name,
salary,
RANK() OVER(PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM employees
)
SELECT dept_id, emp_name, salary
FROM ranked
WHERE rn <= 3;
CTE不只是一层"电梯",它还能让后面直接复用窗口计算结果,不用把一堆OVER子句重复粘贴。
我自己写窗口函数时还有个固定动作:先在几行数据上手工算一遍预期结果,再去对SQL输出。尤其是涉及ROWS框架、重复值、NULL排序这三件事时,手工验证几行数据,比跑完大表再回头排查快得多。
