1. 从一次被吐槽的统计需求说起:为什么要学窗口函数
先讲个真实经历。有次业务方扔给我一个需求,描述特别简单:“把每个部门的员工按薪资排名,还要保留所有员工信息”。我当时第一反应是用GROUP BY,结果写完就发现不对劲——GROUP BY一做分组,每个部门只剩下一行聚合结果,员工明细全被压扁了。为了保留明细又拿到排名,我只能用相关子查询或者临时表,一条SQL写得又臭又长,跑起来还慢,被同事笑称“用生命在写SQL”。
后来我认真啃了一遍SQL窗口函数,回头看当时写的那些嵌套子查询,简直想穿越回去给自己一巴掌。窗口函数本质上就是在不合并行的前提下,对每一行做一次“局部计算”,排名、累计、移动平均、同环比、分组TopN,都是它最擅长的领域。你只要掌握一个OVER()子句,就能在一个查询里同时拿到明细数据和统计结果,改写过去那些“做完一个聚合再JOIN回去”的绕路操作。
这篇内容适合谁?如果你是数据分析师、数据工程师,或者日常工作中要写大量复杂统计SQL的开发,窗口函数会成为你工具箱里最趁手的家伙。文章会从底层逻辑讲到语法框架,再配合我做过的真实案例,把排名、累计、占比、偏移计算这些高频场景逐个拆开。每个代码块都可以直接拿去改改字段名就能用,不玩虚的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 窗口函数是什么:一张图搞懂“窗口”与“分组”的本质区别
2.1 GROUP BY是折叠,窗口函数是切片
很多人学窗口函数时卡住的第一个点,就是搞不懂它和GROUP BY到底差在哪。我用一个特别生活化的类比来解释:
GROUP BY像把一锅汤倒进浓缩机,压出一小碗精华——你只看到每个分组的汇总数字,原始行全没了。窗口函数则像给每一行戴上一副“局部放大镜”,你依然能看到所有细节,只是每一行都额外带上了自己所属那个组的统计信息。
举例子。有一张销售记录表:
sql复制CREATE TABLE sales_records (
record_id INT,
department VARCHAR(50),
sales_person VARCHAR(50),
sales_date DATE,
amount DECIMAL(10,2)
);
如果按部门算总销售额,GROUP BY的结果是每个部门一行;而窗口函数的写法是:
sql复制SELECT
department,
sales_person,
amount,
SUM(amount) OVER(PARTITION BY department) AS dept_total
FROM sales_records;
结果每一行都在,旁边多了一列“本部门总额”。这个效果GROUP BY做不到,子查询或自联结虽然能做,但SQL又臭又长,性能还不一定好。窗口函数最核心的价值,就是把“每行自己的值”和“所属分组的状态”放在同一个查询结果里,让后续计算不用反复JOIN。
2.2 OVER()子句的三大组成:PARTITION BY、ORDER BY、ROWS/RANGE
窗口函数的核心语法就是函数名() OVER(窗口定义)。窗口定义有三大块,理解它们就理解了窗口函数八成内容。
PARTITION BY:负责把数据切成多个“小组”,逻辑上和GROUP BY的分组概念很像,但只控制窗口范围,不折叠行。不写的时候,整张表就是一个大窗口。
ORDER BY:负责定义窗口内的排序规则。它决定了排名类函数怎么排,也决定了累计类函数的计算方向——是“从分区第一行累加到我这一行”,还是“从分区末尾倒着累加”。
ROWS/RANGE:负责进一步约束窗口的“物理范围”或“逻辑范围”。比如ROWS BETWEEN 2 PRECEDING AND CURRENT ROW表示向前数两行,RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW表示按日期范围向前推一周。这是移动平均、滚动统计等场景的关键参数。
三个部件可以自由组合。光写PARTITION BY没写ORDER BY,窗口就是“整个分区”。两个都写,窗口默认是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,也就是常说的“从分区起点到当前行”。这个默认行为很关键,很多人在累计场景里漏了ORDER BY,结果SUM变成全分区求和,和预期完全不符。
3. 窗口函数四大金刚:聚合类、排名类、取值类、分布类的实战拆解
3.1 聚合类窗口函数:累计求和、移动平均、占比计算
聚合类窗口函数就是把SUM、AVG、COUNT、MAX、MIN写进OVER()里。它们解决的最典型场景是“截至当前行的累计状态”。
先看累计求和。比如我想算“每个部门每个月的累计销售额”,传统写法要么自联结,要么子查询,效率感人。窗口函数一行搞定:
sql复制SELECT
department,
DATE_FORMAT(sales_date, '%Y-%m') AS sale_month,
SUM(amount) AS month_amount,
SUM(SUM(amount)) OVER(
PARTITION BY department
ORDER BY DATE_FORMAT(sales_date, '%Y-%m')
) AS cumulative_amount
FROM sales_records
GROUP BY department, DATE_FORMAT(sales_date, '%Y-%m');
注意这里有个双层聚合的写法,先用GROUP BY算出月度销售额,再用SUM(SUM(amount))对月度结果做累计。如果不先按月GROUP BY,直接对明细行累计,得到的是“每一笔订单之间的累计”,不是月粒度累计,这两者差别很大。我见过不少人在这一步踩坑,以为窗口函数能直接替代所有聚合,结果明细行太多导致累计值重复计算。
移动平均也常用,尤其是平滑波动数据时。比如算近3天的平均销售额:
sql复制SELECT
sales_date,
amount,
AVG(amount) OVER(
ORDER BY sales_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3d
FROM sales_records
ORDER BY sales_date;
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW表示窗口覆盖“前两行加当前行”,实现真正的滑动窗口。如果数据有缺失日,想按实际日期区间滑,就用RANGE BETWEEN INTERVAL 2 DAY PRECEDING AND CURRENT ROW。
占比计算也很常见。比如算每一笔订单占部门总销售额的百分比:
sql复制SELECT
record_id,
department,
amount,
amount / SUM(amount) OVER(PARTITION BY department) AS dept_share
FROM sales_records;
这里没有ORDER BY,SUM窗口就是整个部门,比例算出来非常直观。
3.2 排名类窗口函数:ROW_NUMBER、RANK、DENSE_RANK怎么选
排名类函数是窗口函数中最容易上手、也最容易用错的一类。三个函数长得像,但业务语义有明显差异,选错会导致排名结果和业务预期对不上。
ROW_NUMBER():纯粹按顺序给行编号,相同值也会分出先后,结果是1、2、3、4。适合做去重标记或分页编号。
RANK():排名会跳号,比如两个人并列第1,下一个就是第3。适合比赛排名、销售竞赛这类“必须留出空位”的场景。
DENSE_RANK():排名不跳号,并列第1后,下一个还是第2。适合部门绩效等级这类“希望名次连续”的场景。
举个例子,给每个部门按薪资密集排名:
sql复制SELECT
department,
employee_name,
salary,
ROW_NUMBER() OVER(PARTITION BY department ORDER BY salary DESC) AS row_num,
RANK() OVER(PARTITION BY department ORDER BY salary DESC) AS rank_num,
DENSE_RANK() OVER(PARTITION BY department ORDER BY salary DESC) AS dense_rank_num
FROM employees;
两个员工薪资相同,ROW_NUMBER会随机给个先后,RANK和DENSE_RANK会并列。实际项目里,去重必须用ROW_NUMBER,取TopN通常用ROW_NUMBER或DENSE_RANK,只有需要体现“并列后空档”时才用RANK。这个选择背后是业务语义,不能拍脑袋。
NTILE()也是排名家族的一员,它把数据均分为N桶,返回每行所在桶号。比如把用户按消费金额分成5组,做RFM分析时很常用:
sql复制SELECT
user_id,
total_spend,
NTILE(5) OVER(ORDER BY total_spend DESC) AS spend_group
FROM user_summary;
3.3 取值类窗口函数:LAG、LEAD、FIRST_VALUE、LAST_VALUE
取值类函数负责在窗口内“从别的行拿数据”。其中LAG和LEAD最常用,是算环比、同比、差值的一把好手。
LAG(column, n)取往前数n行的值,LEAD(column, n)取往后数n行的值。比如算每日销售额的环比增长:
sql复制SELECT
sales_date,
amount,
LAG(amount, 1) OVER(ORDER BY sales_date) AS prev_day_amount,
amount - LAG(amount, 1) OVER(ORDER BY sales_date) AS day_over_day_diff,
(amount - LAG(amount, 1) OVER(ORDER BY sales_date)) /
LAG(amount, 1) OVER(ORDER BY sales_date) * 100 AS day_over_day_pct
FROM daily_sales
ORDER BY sales_date;
这里要特别提醒一个容易踩的坑:LAG取不到值时返回NULL,NULL参与四则运算结果还是NULL,所以如果是首行数据,环比结果会是NULL,前端展示时要处理成“-”或“0”。
FIRST_VALUE和LAST_VALUE用来拿分区内第一个和最后一个值。比如想看每个部门最高工资和当前员工工资的差距:
sql复制SELECT
department,
employee_name,
salary,
FIRST_VALUE(salary) OVER(PARTITION BY department ORDER BY salary DESC) AS top_salary
FROM employees;
注意,LAST_VALUE有点特殊,默认窗口只到当前行,所以不配ROWS/RANGE,LAST_VALUE拿到的就是当前行的值。要取真正的分区最后一行,必须明确写窗口边界。
3.4 分布类窗口函数:PERCENT_RANK、CUME_DIST的进阶用途
分布类函数在数据分析里出场率略低,但特定场景特别好用。PERCENT_RANK算的是“当前行在窗口内的百分比排名”,CUME_DIST算的是“小于等于当前值的行占比”。
比如给销售排名后,按百分比划分档次:
sql复制SELECT
department,
sales_person,
amount,
PERCENT_RANK() OVER(
PARTITION BY department ORDER BY amount DESC
) AS pct_rank,
CUME_DIST() OVER(
PARTITION BY department ORDER BY amount DESC
) AS cume_dist
FROM sales_records;
PERCENT_RANK越接近0说明排名越靠前,CUME_DIST可以用来圈定“头部20%”人群。做二八分析时,CUME_DIST特别好用,一条SQL就能算出多少比例的人贡献了多少比例的价值,不用再写复杂的多个子查询。
4. 实操案例:用窗口函数5分钟完成一份部门销售分析报告
4.1 业务需求与数据准备
我拿之前做过的一个真实需求来串一遍所有知识点。业务方需要一份月度销售分析,包含以下指标:每个部门每个月的销售总额、部门内部各销售人员的月销售排名、当月销售额占部门全年的累计比例、每月相较上一个月的环比增长。放在以前,我得写三四个子查询再JOIN起来,现在一条SQL就能搞定。
先准备一张销售订单表,模拟11条明细数据:
sql复制CREATE TABLE sales_detail (
order_id INT,
department VARCHAR(20),
sales_person VARCHAR(20),
sales_date DATE,
amount DECIMAL(10,2)
);
INSERT INTO sales_detail VALUES
(1, '华东', '张三', '2024-01-05', 12000.00),
(2, '华东', '李四', '2024-01-12', 9800.00),
(3, '华东', '张三', '2024-02-03', 15000.00),
(4, '华东', '李四', '2024-02-18', 13200.00),
(5, '华北', '王五', '2024-01-08', 20000.00),
(6, '华北', '赵六', '2024-01-20', 17500.00),
(7, '华北', '王五', '2024-02-11', 18000.00),
(8, '华北', '赵六', '2024-02-24', 22000.00),
(9, '华南', '钱七', '2024-01-15', 16500.00),
(10, '华南', '孙八', '2024-01-22', 9800.00),
(11, '华南', '钱七', '2024-02-06', 19800.00),
(12, '华南', '孙八', '2024-02-27', 14300.00);
4.2 一条SQL完成多维度统计
直接上最终查询:
sql复制SELECT
department,
DATE_FORMAT(sales_date, '%Y-%m') AS sale_month,
sales_person,
amount,
SUM(amount) OVER(
PARTITION BY department, DATE_FORMAT(sales_date, '%Y-%m')
) AS month_dept_amount,
RANK() OVER(
PARTITION BY department, DATE_FORMAT(sales_date, '%Y-%m')
ORDER BY amount DESC
) AS person_rank_in_month,
amount / SUM(amount) OVER(
PARTITION BY department
) * 100 AS pct_of_dept_total,
amount - LAG(amount, 1) OVER(
PARTITION BY sales_person
ORDER BY sales_date
) AS amount_vs_last_sale
FROM sales_detail
ORDER BY department, sale_month, person_rank_in_month;
拆解一下每个窗口:
第一个SUM窗口按“部门+月份”分组,算出当月部门总额。第二个RANK窗口同样按“部门+月份”分组,但按金额排序,得到该销售在部门内的当月排名。第三个SUM窗口只按部门分组,算出该销售这笔订单占整个部门总销售额的比例。第四个LAG窗口按销售个人分组,按日期排序,得到“这个人上一次销售和这次相差多少”。
这就是窗口函数最爽的地方:不同维度、不同粒度的计算可以同时出现在一条SQL里,各自有独立窗口,互不干扰。
4.3 结果解读与分析思路
跑出来的结果里,你能看到:华东部门2024年1月部门总额是21800,张三的12000排第一,占部门全年总销售额的20.05%;到了2月,华东总额28200,张三15000仍然第一,但他的环比增长是3000。华北李四在2月达到22000,是当月整个公司单笔最大销售额。这些结论全部是从同一张明细表里出来的,不需要任何中间表或二次JOIN。
我当时的分析思路是:先看部门月总额判断整体趋势,再看人员排名判断内部贡献差异,最后看同比增长判断个体动力。过去这一套流程要跑三四张报表,现在一份明细数据全搞定,而且还能继续往下钻取。
5. 窗口函数性能调优与常见坑:从能跑到跑好
5.1 为什么窗口函数有时比GROUP BY慢:排序与内存开销
窗口函数虽然写起来舒服,但不是没有代价。最核心的开销来自排序。每个PARTITION BY + ORDER BY组合,数据库都要对分区内数据做一次排序,如果表特别大、分区特别多,排序带来的CPU和内存消耗会很可观。我见过一个极端案例,在千万级表上用三个窗口函数分别排序,查询直接跑了40多秒,优化后降到3秒。关键在于把重复排序干掉。
调整思路有两个:一是尽量复用同一个窗口定义,比如三个指标都按“部门+月份”分组,那就在一条SQL里用同一个窗口写多个函数,让数据库有机会共用排序结果;二是提前用WHERE把无关数据过滤掉,窗口函数是在结果集上计算,先缩小数据集永远是最直接有效的优化。
5.2 窗口内过滤的陷阱:为什么WHERE不能直接过滤窗口结果
很多人学窗口函数后会想:“我能不能在WHERE里直接过滤排名小于3的行?”不行,因为WHERE是在窗口函数计算之前执行的,此时根本没有排名结果可用。SQL的执行顺序大致是:FROM -> WHERE -> GROUP BY -> HAVING -> 窗口函数 -> SELECT -> ORDER BY。
所以想实现“每个部门Top2”,不能直接写WHERE rank <= 2,需要把窗口函数放在子查询或CTE里,外层再过滤:
sql复制WITH ranked AS (
SELECT
department,
sales_person,
amount,
ROW_NUMBER() OVER(
PARTITION BY department ORDER BY amount DESC
) AS rn
FROM sales_detail
)
SELECT department, sales_person, amount
FROM ranked
WHERE rn <= 2;
部分数据库支持QUALIFY关键字,能在窗口函数后直接过滤,比如Snowflake、BigQuery里可以直接写QUALIFY rn <= 2,但MySQL、PostgreSQL、SQL Server都不支持。为保证可移植性,用子查询是最稳妥的方式。
5.3 常见坑位速查:重复累加、NULL传播、LAST_VALUE失效
结合我自己的踩坑经历,整理一个高频问题速查表:
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 累计值远大于预期 | 忘了写ORDER BY,窗口变成整个分区求和 | 累计场景必须写ORDER BY |
| 排名结果全是1 | PARTITION BY字段选错或没写 | 检查分组字段是否正确 |
| LAG/LEAD结果全是NULL | 没设置默认值,或分区内行数不足 | 用LAG(col, 1, 0)指定默认值 |
| LAST_VALUE取到当前行 | 窗口默认到CURRENT ROW,没覆盖全分区 | 显式写ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
| 窗口函数在大表上慢到超时 | 排序过多、数据未过滤 | 加WHERE、复用窗口、必要时用物化视图预处理 |
| 子查询过滤排名后行数不对 | 窗口函数与WHERE执行顺序混乱 | 先理解执行顺序,窗口结果放子查询后再过滤 |
还有一个容易忽略的坑:窗口函数和SELECT DISTINCT一起用时,DISTINCT会对最终结果去重,可能把窗口计算的结果重复行也压掉。如果发现去重后数据变少,先确认是不是DISTINCT和窗口函数叠加导致的。
5.4 我的一点心得体会:什么时候不用窗口函数
窗口函数很强,但不是万能药。如果只是简单分组汇总,GROUP BY依然是最高效的选择,窗口函数在纯聚合上没有性能优势。而且要特别注意,MySQL 8.0以下版本不支持窗口函数,有些老项目还跑在5.7,想用还得先升级或用临时表代替。PostgreSQL、SQL Server、Oracle、SQLite 3.25+都支持窗口函数,语法基本通用,但细节扩展上有些差异,换库时最好看一下官方文档。
我在实际使用中还有一个习惯:写复杂报表前,会先用一个窗口函数逐步验证每个指标,确认窗口定义正确后再合并成一条大SQL。这样排错成本低很多,也方便同事review。窗口函数掌握之后,很多原先要写四五层的查询变得简单直接,这也是我为什么在团队里反复推荐所有人花一下午学透这批语法——性价比实在太高了。
