很多人写 SQL,一碰到“分组后再取明细”“每组 Top N”“累计求和”“同比环比”“取上一条/下一条记录”这类需求,就开始疯狂套子查询。
结果通常是这样的:
• SQL 越写越长
• 逻辑一层套一层
• 自己第二天都不想看
• 性能还未必好
说得难听点,很多子查询不是业务复杂,而是写法原始。
如果你还在靠子查询硬凑这些分析型需求,那窗口函数大概率就是你一直没认真用过的那把刀。
窗口函数的价值,不是“语法高级”,而是它能把“统计结果”和“原始明细”同时留在一张结果集里。
这件事,看起来只是写法变化,实际会直接改变你组织 SQL 的方式。
先说人话。
普通聚合函数,比如:
select dept_id, avg(salary) from emp group by dept_id;
它会把多行压成一行。你得到了部门平均工资,但员工明细没了。
而窗口函数做的是:
不压缩行数,但允许你在每一行上看到某个“窗口范围内”的统计结果。
比如:
select emp_name, dept_id, salary, avg(salary) over(partition by dept_id) as dept_avg_salary from emp;
你会得到这样的效果:
• 每个员工一行还在
• 但每行都多了一个“本部门平均工资”
这就是窗口函数的核心爽点:
明细不丢,统计也有。
很多以前必须靠子查询、临时表、自连接绕出来的需求,到这里基本就通了。
二、先记住这个骨架:over()
窗口函数的灵魂在 over()。
常见结构长这样:
函数名() over( partition by 分组字段 order by 排序字段 rows between ... and ... )
你可以把它粗暴理解成:
• partition by:先按什么维度分组看
• order by:组内按什么顺序算
• rows between:窗口范围到底覆盖哪些行
不是每次都要写全。
你完全可以从这三个层次去理解:
1)只有 partition by
表示“按组统计,但不丢明细”。
2)partition by + order by
表示“组内有顺序”,于是可以做排名、累计值、上一条下一条。
3)再加窗口范围
表示“精确限定计算范围”,比如最近 3 行、当前行到首行之类。
这套脑子一旦装上,很多 SQL 会突然变简单。
三、最常见的 6 类场景,窗口函数几乎是降维打击
下面这几类,是窗口函数最值回票价的地方。
场景 1:每个分组内取 Top N
这是最经典的一个。
需求
每个部门里,找出工资最高的 3 个人。
很多人第一反应:子查询硬套
比如先求一个排序,再在外层筛。
用窗口函数的标准写法
select * from ( select emp_id, emp_name, dept_id, salary, row_number() over( partition by dept_id order by salary desc ) as rn from emp ) t where rn <= 3;
这里真正厉害的点
row_number() 不是全表排,而是:
• 先按 dept_id 分组
• 每组内按 salary desc 排序
• 然后重新编号
这样每个部门就都有自己的 1、2、3、4……
最后只保留 rn <= 3 即可。
相关函数顺手记一下
row_number() rank() dense_rank()
它们的区别在于并列名次怎么处理:
• row_number():不管并列,强行唯一编号
• rank():并列占同一名次,后面跳号
• dense_rank():并列占同一名次,但后面不跳号
如果你连“组内 Top N”都还在拿子查询硬拼,那窗口函数确实该补了。
场景 2:在明细里直接看到部门平均值 / 最大值 / 最小值
需求
想看每个员工工资,同时知道:
• 所在部门平均工资
• 所在部门最高工资
• 自己是否高于部门平均
传统写法
通常是先 group by dept_id 算一张部门统计表,再 join 回员工表。
能写,但绕。
窗口函数写法
select emp_id, emp_name, dept_id, salary, avg(salary) over(partition by dept_id) as dept_avg_salary, max(salary) over(partition by dept_id) as dept_max_salary, min(salary) over(partition by dept_id) as dept_min_salary from emp;
如果还想继续做判断:
select emp_id, emp_name, dept_id, salary, avg(salary) over(partition by dept_id) as dept_avg_salary, case when salary > avg(salary) over(partition by dept_id) then '高于均值' when salary < avg(salary) over(partition by dept_id) then '低于均值' else '等于均值' end as salary_level from emp;
为什么这比子查询爽
因为你根本不用先造一张中间表再回连。
统计信息直接贴在每条明细上。
读起来更顺,维护也更轻。
场景 3:累计求和、累计人数、累计销售额
这类需求在报表、增长、交易分析里极其常见。
需求
按日期看每天销售额,同时想知道累计销售额。
窗口函数写法
select dt, amount, sum(amount) over( order by dt rows between unbounded preceding and current row ) as cumulative_amount from sales_daily;
这句的意思是:
• 按日期排序
• 从第一行一直累计到当前行
于是你每一行都能看到“截至当天的累计值”。
如果按城市分别累计
select city, dt, amount, sum(amount) over( partition by city order by dt rows between unbounded preceding and current row ) as city_cumulative_amount from sales_daily;
这就是窗口函数真正像神器的地方:
既保留每天明细,又直接拿到累计结果。
而且语义非常直白,不需要外层再套一堆相关子查询。
场景 4:取上一条、下一条记录
很多业务分析,本质就是“和前一个状态比一下”。
比如:
• 用户这次下单距离上次下单隔了多久
• 股票今天比昨天涨了多少
• 某个设备本次读数和上次读数差多少
窗口函数写法
select user_id, order_time, amount, lag(amount, 1) over( partition by user_id order by order_time ) as prev_amount, lead(amount, 1) over( partition by user_id order by order_time ) as next_amount from orders;
这里:
• lag() 取前一条
• lead() 取后一条
如果你要算和上一单的差值:
select user_id, order_time, amount, amount - lag(amount, 1) over( partition by user_id order by order_time ) as diff_from_prev from orders;
这种需求如果用子查询或者自连接,不是不能写,而是写出来通常又臭又长。
窗口函数则是原地解决。
场景 5:同比、环比、增长率
做经营分析、用户增长、GMV 报表时,这类需求几乎天天见。
需求
按月统计销售额,并计算相较上月的增长率。
select month, revenue, lag(revenue, 1) over(order by month) as prev_revenue, round( (revenue - lag(revenue, 1) over(order by month)) / nullif(lag(revenue, 1) over(order by month), 0), 4 ) as mom_growth from monthly_revenue;
虽然这里看着重复写了几次 lag(),但逻辑非常清楚:
• 当前月收入
• 上月收入
• 当前月相较上月的增长率
你甚至可以先包一层,让 SQL 更干净:
select month, revenue, prev_revenue, round((revenue - prev_revenue) / nullif(prev_revenue, 0), 4) as mom_growth from ( select month, revenue, lag(revenue, 1) over(order by month) as prev_revenue from monthly_revenue ) t;
注意,这种“先用窗口函数算,再外层做过滤或二次计算”的写法,和“用子查询硬拼分析逻辑”不是一回事。
窗口函数是先把分析字段算出来,外层只是消费结果。
这比一层层去模拟顺序关系,干净太多。
场景 6:找每组的第一条 / 最后一条记录
需求
每个用户,取第一次下单记录。
窗口函数写法
select * from ( select user_id, order_id, order_time, amount, row_number() over( partition by user_id order by order_time asc ) as rn from orders ) t where rn = 1;
如果要最后一次下单,只要把排序改成 desc。
这个需求在用户首购、首登、首次付费、最后活跃时间等分析里非常常见。
以前很多人会这么干:
• 先 group by user_id 求 min(order_time)
• 再 join 回原表拿整行明细
当然能做。
但窗口函数写法更自然,因为它本来就是“在组内排顺序,再取第几条”。
四、窗口函数不是万能药,但它特别适合“分析型 SQL”
这里要说句实在话。
窗口函数不是所有 SQL 都该上。
如果你的需求只是简单聚合:
select dept_id, count(*) from emp group by dept_id;