我第一次觉得窗口函数非学不可,是被一个看起来平平无奇的报表需求逼的:要把每个部门的员工按工资排名,但员工的明细行一条也不能少。当时我满脑子都是GROUP BY,一算排名就想着“先按部门汇总,再自连接对比”,结果写出半屏子查询,跑出来的数据还不对。后来看到同事用一行RANK() OVER()就解决,我才意识到自己一直绕了远路。这篇文章就是写给那些和我当时一样——会基础SQL、但没系统掌握窗口函数的读者,不需要你有数学功底,只要会SELECT、WHERE、GROUP BY,就能跟上。
窗口函数和普通聚合函数最大的区别,简单说就一句话:它能在不合并行的前提下,让每一行都“看到”一个整体范围里的计算结果。排名、累计求和、占比、同比环比、组内取前N,这些在业务报表里高频出现的需求,用窗口函数往往几行SQL就能写出来,而用老办法写不是性能差就是逻辑绕。下面我会从为什么需要它讲起,把三个核心子句的运行机制拆开,再给足排序类、聚合类、偏移类窗口函数的实战例子,最后把我自己踩过的坑和排查习惯一并交代清楚。
1. 为什么需要窗口函数:先看GROUP BY解决不了的那类问题
1.1 一个让我彻底入坑的需求:保留明细的部门内排名
假设有一张员工表employees,结构很简单,就四列:员工ID、姓名、部门、工资。数据大概长这样:
| emp_id | emp_name | dept_name | salary |
|---|---|---|---|
| E001 | 小杨 | 技术部 | 28000 |
| E002 | 小陈 | 技术部 | 26000 |
| E003 | 小周 | 技术部 | 24000 |
| E004 | 小吴 | 市场部 | 22000 |
| E005 | 小郑 | 市场部 | 22000 |
| E006 | 小冯 | 市场部 | 18000 |
| E007 | 小卫 | 运营部 | 20000 |
| E008 | 小蒋 | 运营部 | 16000 |
需求是:每个部门内部按工资从高到低排名,但最终输出的结果每一行都必须是员工级别的明细,不能把同部门的人合并成一条。用GROUP BY直接写,第一反应是这样的:
SELECT dept_name, MAX(salary) AS max_salary FROM employees GROUP BY dept_name;能拿到每个部门的最高工资,但拿不到第二名是谁,更拿不到员工的姓名。如果需求是“列出每个部门工资最高的前三名”,用GROUP BY就得写三条MAX或者用子查询嵌套,SQL会越写越长。
当时我还试过另一种经典写法,用关联子查询模拟排名:
SELECT e1.*, (SELECT COUNT(*) + 1 FROM employees e2 WHERE e2.dept_name = e1.dept_name AND e2.salary > e1.salary) AS rn FROM employees e1;这段SQL的逻辑是“数一数同部门里比我工资高的人有几个,再加一就是我的排名”。数据量小的时候能跑,但表一大,子查询对每一行都要扫一遍全表,性能很快凉掉。而且一旦出现工资并列,“比我高的人数加一”的规则其实算不出并列名次,逻辑上还得再补一层判断。这种写法不是不能实现,是很别扭。
窗口函数出来以后,这套逻辑压缩成了一行:
SELECT emp_id, emp_name, dept_name, salary, RANK() OVER(PARTITION BY dept_name ORDER BY salary DESC) AS rn FROM employees;结果里,部门和工资都保留着,每一行的rn列就是它在自己部门里的排名。技术部小杨是1,小陈是2,小周是3;市场部两个22000的人并列第2,下一个直接排第4。这种“既要做聚合级计算,又要保留行明细”的需求,就是窗口函数最典型的适用场景。
1.2 GROUP BY和窗口函数的本质区别:谁来“合并行”
很多人刚开始学窗口函数时,总想把窗口函数理解为“高级版GROUP BY”,其实两者的执行思路完全不一样。
GROUP BY做的事情,是先把符合条件的行按分组键合并成若干个组,每组只输出一行。一旦做了GROUP BY,组内其他行的详细信息就再也回不来了,除非你再回去做自连接。这就是为什么排名需求用GROUP BY写起来这么痛苦——你为了让“每个部门一个排名输出”,把员工数据压扁之后,又得想办法把员工姓名这些明细捞回来。
窗口函数则恰恰相反。它会在FROM、WHERE、GROUP BY这些阶段都跑完以后,基于当前结果集再做一次“扫描式计算”,但计算完不会合并任何行。每一行都还在原位置,只是额外多出一列或多列计算结果。这一列对每一行来说,可能是它所在分区的聚合值、可能是它排序后的名次、也可能是它上一行或下一行的某个字段。
用一个生活化的类比:GROUP BY像广播体操结束后按班级集合,每个班级派一个人向总指挥报人数,报完其他人原地解散。窗口函数像每个人戴着一块智能手环,手环会实时告诉你自己班有多少人、自己在班里排名第几,但每个人的位置一直没变。
这也是窗口函数一个非常重要的判断标准:如果你发现写窗口函数的结果里,每一行旁边的结果都是一个“组级数值”,那很正常,因为窗口函数本来就是在行明细上做组级计算;如果你发现行数莫名其妙少了,那一定不是窗口函数的锅,而是你在哪里用了GROUP BY或DISTINCT。
1.3 窗口函数的通用语法骨架:记住一张图就够了
窗口函数在SQL里的基本结构长这样:
函数名(参数) OVER( [PARTITION BY 分组列] [ORDER BY 排序列 排序方向] [ROWS BETWEEN 窗口开始 AND 窗口结束] )从左到右拆开看:
- 函数名(参数):既可以是专门为窗口设计的排序函数,比如RANK()、ROW_NUMBER(),也可以是普通聚合函数跑到窗口里用,比如SUM(列)、AVG(列)、COUNT(列)。
- PARTITION BY:负责分块,作用类似“分组”,但和GROUP BY最大的区别是它不合并行。只写PARTITION BY不写ORDER BY,窗口函数会在每个分区内部做全分区计算。
- ORDER BY:负责在分区内部排序。排序不只是为了让结果顺眼,更关键的是它会影响计算范围和部分函数的取值。
- 窗口框(ROWS BETWEEN...AND...):进一步限定“在排序之后的哪些行参与计算”。不写它就是数据库自动给你定边界,后面我会详细讲这个“默认边界”有多容易坑人。
这三个子句不是每次都要写全。比如只求“每个部门的平均工资,且不破坏行明细”,可以只写PARTITION BY。要算“每月的累计销售额”,可以只写ORDER BY。要同时分组、排序、限定计算行数,就把三段都写出来。
提示:窗口函数只能出现在SELECT列表和ORDER BY里,不能直接写在WHERE、GROUP BY、HAVING这些子句中。原因和SQL的逻辑执行顺序有关,下一章专门讲。
2. 窗口函数的执行机制:PARTITION BY、ORDER BY与窗口框的缺省行为
2.1 窗口函数在SQL逻辑执行顺序中的位置
我见过不少朋友写窗口函数报错后一脸懵,其实多数问题都出在没搞明白“窗口函数是啥时候被算出来的”。一条SQL的逻辑执行顺序,大致是这样的:
- FROM:确定数据从哪张表来,做必要的关联。
- WHERE:按条件过滤行。
- GROUP BY:按分组键聚合,如果需要的话。
- HAVING:过滤聚合之后的分组。
- SELECT:计算要输出的列,窗口函数正是在这个阶段执行。
- ORDER BY:对最终结果排序。
- LIMIT/OFFSET:截断行数。
也就是说,窗口函数能看到的数据,已经是WHERE过滤过、GROUP BY分组过之后的数据。这带来一个很实用的推论:如果你在WHERE里写“rn = 1”,数据库根本找不到rn这一列,因为窗口函数还没执行。正确的做法是先算窗口函数,再在外层子查询或CTE里对结果过滤。
举个例子。如果只想查技术部的工资排名,先写WHERE dept_name = '技术部',再写窗口函数,那么窗口函数只在技术部这一小撮数据上做分区计算,不会跑到市场部那边去。这是很多初学窗口函数的人容易忽略的点:窗口函数的PARTITION BY不是覆盖WHERE的“全局分区”,它只对你当前结果集负责。
2.2 三个核心子句的缺省行为:不写不等于没有
窗口函数三个子句最让人困惑的地方,就是它们的“不写”各有各的含义。我整理了一个我自己很常用的对照表,建议收藏:
| 写法 | 实际窗口范围 | 典型场景 |
|---|---|---|
| OVER() | 整张结果集当成一个窗口 | 算总数、总平均,然后塞到明细行里 |
| OVER(PARTITION BY dept_name) | 每个部门一个窗口,不做区内排序 | 算各部门平均工资、部门人数 |
| OVER(ORDER BY month) | 整个结果集一个窗口,但按month排序,默认窗口从第一行到当前行 | 累计求和、累计计数 |
| OVER(PARTITION BY dept_name ORDER BY salary DESC) | 每个部门一个窗口,部门内按工资排序,默认窗口从部门第一行到当前行 | 部门内排名、部门内累计 |
第一行最好理解:OVER()一个窗口管全部,每个员工旁边都会带上全表工资总额,比如所有部门总工资是172000,那每一行都有这个数。
第四行是最常用的组合,但也是误解重灾区。很多人以为OVER(PARTITION BY dept_name ORDER BY salary DESC)是“每个部门一个窗口,然后算出整个部门的总和”,其实不是。一旦出现ORDER BY,默认窗口边界就变成了“从分区第一行到当前行”。想让每一行都看到整个部门的聚合值,要么不写ORDER BY,要么明确写窗口框ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING。这个坑我第5章还会专门讲。
2.3 用一个累计求和例子,彻底吃透窗口框
窗口框这个概念比较抽象,我用一张按月收入表来说明。假设有一张sales_monthly表:
| month_id | revenue |
|---|---|
| 1 | 120000 |
| 2 | 135000 |
| 3 | 110000 |
| 4 | 150000 |
| 5 | 160000 |
| 6 | 170000 |
需求是算每个月截至当前的累计收入。窗口函数的写法非常直接:
SELECT month_id, revenue, SUM(revenue) OVER(ORDER BY month_id) AS cum_revenue FROM sales_monthly;这里只写了ORDER BY month_id,没有写PARTITION BY,也没有写窗口框。因此窗口范围是整个结果集按month_id升序排列,然后从第一行累加到当前行。结果是:第1行cum_revenue等于120000,第2行等于255000,第3行等于365000,一路累到最后一行变成所有收入的总和。
如果想把窗口改成“最近三个月的滚动求和”,就要显式声明窗口框:
SELECT month_id, revenue, SUM(revenue) OVER(ORDER BY month_id ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS rolling_3m FROM sales_monthly;这段SQL的意思是:对每一行,取当前行以及它前面的两行,一共三行,做SUM。所以第1行只有第1个月,第2行有第1、2两个月,从第3个月开始才真正稳定成三个月的滚动和。这种移动聚合在观察趋势、平滑波动时特别常用。
如果想让每一行都看到整张表的收入总和,最稳妥的写法是:
SELECT month_id, revenue, SUM(revenue) OVER(ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS total_revenue FROM sales_monthly;UNBOUNDED PRECEDING表示无限往前,UNBOUNDED FOLLOWING表示无限往后,合起来就是整个窗口,每一行拿到的值都一样,等于全表收入总和。
2.4 RANGE和ROWS的差别:同样排序,结果可能差一截
窗口框除了ROWS BETWEEN写法,还有一种写法叫RANGE BETWEEN。两者看起来只差一个单词,底层逻辑完全不同。
- ROWS是按物理行数计算边界。说“2 PRECEDING”,就是物理位置上前两行,不管ORDER BY的值长什么样。
- RANGE是按排序值的大小计算边界。说“2 PRECEDING”,是以当前行的排序值为基准,往前找到排序值在当前值减去2这个范围内的所有行。
入门阶段我不建议在RANGE上花太多时间,但要记住一个关键影响:如果ORDER BY的列存在重复值,RANGE会把所有和当前行同名次的行一起划进窗口,而ROWS不会。这会导致同样的函数、同样的排序,写ROWS和写RANGE得到不同结果。
比如按工资排序算累计人数。工资有5个28000,有8个26000,用RANGE的话,第一个28000那行会把全部28000的人都算进去,因为它们的排序值相等,处于同一个“值范围”内;用ROWS则严格按物理位置前进。实际业务里,除非你真的需要按数值区间计算,否则我建议优先用ROWS,物理边界更好预测,排查问题时也省心。
3. 排序类窗口函数实战:ROW_NUMBER、RANK、DENSE_RANK怎么选
3.1 三个函数的字面区别,一个表格说清
排序类窗口函数是窗口函数的门面,也是最容易让人混淆的一组。ROW_NUMBER、RANK、DENSE_RANK长得像三兄弟,行为却各不相同。我用一张表把它们捋清楚:
| 函数 | 名次是否重复 | 是否跳号 | 典型用途 |
|---|---|---|---|
| ROW_NUMBER() | 永不重复 | 无跳号概念,连续编号 | 去重、取前N、分页 |
| RANK() | 同名次会重复 | 会跳号 | 标准竞赛排名 |
| DENSE_RANK() | 同名次会重复 | 不跳号 | 产品销量等级、紧凑排名 |
三者的排序依据都是OVER里的ORDER BY,不同的是给号规则。ROW_NUMBER根本不管并列,按物理顺序强行编号,结果永远连续且唯一。RANK遇到并列时,并列的都占同一个名次,但计算下一名时会跳过占用掉的名次数。DENSE_RANK也认可并列,但下一名只是紧接着前一个名次加一,不跳号。
3.2 用真实成绩场景演示三种结果
假设有一组同学的成绩数据,我用通用称呼避免带入具体人名,成绩依次是95、92、92、87。分别用三个函数取成绩排名,结果差异一眼就能看出来:
| 原始分数 | ROW_NUMBER() | RANK() | DENSE_RANK() |
|---|---|---|---|
| 95 | 1 | 1 | 1 |
| 92 | 2 | 2 | 2 |
| 92 | 3 | 2 | 2 |
| 87 | 4 | 4 | 3 |
看最后一行:87分在RANK里排第4,因为前面两个92占掉了第2和第3两个名次;在DENSE_RANK里排第3,因为它只关心“有几个不同的分数比自己高”,不关心中间跳号。
实际业务中怎么选?如果只是需要唯一编号,比如给结果集加一个连续流水号,用ROW_NUMBER;如果做竞赛榜、绩效考核等级,要求并列者同为亚军且第三名不存在,用RANK;如果做产品价格带分析,想让等级尽量紧凑——最贵一档为1、次贵一档为2,不关心中间缺了多少个“理论名次”,用DENSE_RANK。
3.3 实用组合拳:组内取前N与按用户去重
排序类窗口函数最值的掌握的组合,是配合子查询做“组内取前N”。在窗口函数出现之前,取“每个部门工资前三名”要写很绕的关联子查询。现在标准做法是:
SELECT emp_id, emp_name, dept_name, salary, rn FROM ( SELECT emp_id, emp_name, dept_name, salary, ROW_NUMBER() OVER(PARTITION BY dept_name ORDER BY salary DESC) AS rn FROM employees ) t WHERE rn <= 3;这里有一个必须理解的细节:为什么不能让外层直接省略,改成WHERE rn <= 3?因为窗口函数在WHERE之后才执行,所以窗口函数算出来的rn不能在WHERE里直接用。必须包一层子查询或CTE,等窗口函数把rn算好,再到外层按rn过滤。这一步是初学窗口函数时最容易被数据库报错“Unknown column”卡住的地方。
另一个高频场景是去重。很多业务明细表里没有唯一键,比如用户行为日志、版本记录,同一主键可能有多行。取每个用户最新一条记录的写法是:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM user_logs ) t WHERE rn = 1;用ROW_NUMBER而不是RANK的原因很简单:去重要求每行只保留一条,不能出现两个并列第一。ROW_NUMBER编号唯一,无论排序值是否重复,都会强制给出一条。
3.4 NTILE分桶函数,用窗口函数做“分层”
排在榜单之外,还有一个容易被忽略但很好用的排序类窗口函数NTILE(n):它会尽可能均匀地把有序数据切成n桶,给每一行标一个桶号,从1到n。它的典型用途是给用户分层、给商品分价格带。
SELECT user_id, spend_amount, NTILE(4) OVER(ORDER BY spend_amount DESC) AS quartile FROM user_spending;这段SQL把用户按消费金额从高到低分成四层,quartile等于1的是消费最高的四分之一用户,4是最低的四分之一。相比自己写CASE WHEN,NTILE不需要你手算阈值,数据切分自动完成。需要注意,如果数据行数不能被桶数整除,不同桶之间的行数可能相差1,这是正常的,不要把它当成分组错误去Debug。
除了NTILE,窗口函数里还有PERCENT_RANK、CUME_DIST这些用于统计分布的函数,入门阶段了解即可,真正用到时再查文档效率更高。现阶段把ROW_NUMBER、RANK、DENSE_RANK、NTILE这四兄弟用熟,排序场景基本全覆盖。
4. 聚合与偏移窗口函数:累计求和、占比与同比环比
4.1 把SUM、AVG、COUNT搬进窗口:明细不丢的多层级汇总
窗口函数不只是排序专用,SUM、AVG、COUNT这些老朋友同样可以开窗。聚合函数配合窗口,最大的价值是能在同一行里同时看到明细值和多个粒度的汇总值。
继续用employees表。如果想知道每个员工的工资占部门总工资的比例,同时保留员工明细,可以这样写:
SELECT emp_id, emp_name, dept_name, salary, SUM(salary) OVER(PARTITION BY dept_name) AS dept_total_salary, ROUND(salary / SUM(salary) OVER(PARTITION BY dept_name), 4) AS dept_share FROM employees;这里PARTITION BY dept_name把数据按部门切块,SUM(salary) OVER(PARTITION BY dept_name)在每一行都返回该部门的工资总和。于是小杨那个部门的总工资是78000,小吴那个部门总工资是62000,每一行都可以拿自己的salary除以部门总和,直接得到占比,不需要额外JOIN汇总表。
更进阶一点,还可以一次算出“部门总和”和“全公司总和”两个粒度:
SELECT emp_id, dept_name, salary, SUM(salary) OVER(PARTITION BY dept_name) AS dept_total, SUM(salary) OVER() AS company_total FROM employees;这种多层汇总在没有窗口函数时,往往要靠两条GROUP BY查询再加一次全连接,代码很重。窗口函数让多粒度统计变成“加一列”的事,写报表时非常省事。
4.2 FIRST_VALUE与LAST_VALUE:小心LAST_VALUE的默认边界
FIRST_VALUE和LAST_VALUE用来在窗口里取第一个值和最后一个值。比如想看每个部门工资最高的人是谁:
SELECT emp_id, emp_name, dept_name, salary, FIRST_VALUE(emp_name) OVER(PARTITION BY dept_name ORDER BY salary DESC) AS highest_paid_emp FROM employees;PARTITION BY把部门切开,ORDER BY salary DESC保证部门内按工资从高到低排,FIRST_VALUE取的就是当前分区第一行,也就是工资最高的员工。这个逻辑顺理成章。
但如果你照着同一个模式写LAST_VALUE,想取工资最低的员工,很可能踩坑:
-- 这段结果会让你意外 SELECT emp_id, emp_name, dept_name, salary, LAST_VALUE(emp_name) OVER(PARTITION BY dept_name ORDER BY salary DESC) AS lowest_paid_emp FROM employees;问题出在默认窗口边界:当OVER里写了ORDER BY,但没有显式写窗口框时,窗口范围默认是“从分区第一行到当前行”(RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)。对技术部第一行来说,窗口只有自己这一行,所以LAST_VALUE返回它自己;对第二行来说,窗口是第一行到第二行,LAST_VALUE返回第二行。每一行看到的“最后一个值”其实都是当前累计窗口的最后一行,并不是整个部门的最后一行。
想要在整个分区内取最后一行,必须显式指定窗口框延伸到结尾:
SELECT emp_id, emp_name, dept_name, salary, LAST_VALUE(emp_name) OVER(PARTITION BY dept_name ORDER BY salary DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS lowest_paid_emp FROM employees;加了ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING,窗口才真正覆盖整个分区,LAST_VALUE才能取到分区末尾的记录。这个坑我身边至少有三位同学踩过,第5章可以归进去。
4.3 LAG与LEAD:拿到上一行和下一行的数据
LAG和LEAD是偏移类窗口函数,它们不做聚合,做的事非常朴实:把同一分区里,往前数第N行的某个字段搬过来,或者往后数第N行的字段搬过来。
- LAG(列, N, 默认值):取当前行往前数N行的值。
- LEAD(列, N, 默认值):取当前行往后数N行的值。
- 第三个参数可以不写,取不到时返回NULL。
做环比分析时,LAG就是神器。计算每个月收入环比是增长了还是下跌了:
SELECT month_id, revenue, LAG(revenue, 1) OVER(ORDER BY month_id) AS prev_month_revenue, ROUND((revenue - LAG(revenue, 1) OVER(ORDER BY month_id)) / LAG(revenue, 1) OVER(ORDER BY month_id), 4) AS mom_growth FROM sales_monthly;这里LAG(revenue, 1)把上一个月的收入取到当前行,第2个月的prev_month_revenue就是第1个月的120000,第3个月的prev_month_revenue就是第2个月的135000。拿当期减去上一期再除以上一期,环比增速就有了。第1个月因为前面没有数据,LAG返回NULL,对应的计算也是NULL,不会报错。
LEAD的用法对称,把当前月换成下一个月。判断一条记录“后面还有没有连续记录”时常用,比如分析用户连续登录天数,可以先取出下一行的登录日期,再看当前行和下一行的时间差是不是正好一天。
4.4 一次SQL写完同比环比:数据连续时的加减法
窗口函数在分析场景里最能出彩的写法之一,是同时算同比和环比。上个月对环比、去年同期对同比,都可以在一个SELECT里完成。假设sales_monthly表里有跨12个月的收入数据,结构依然是month_id和revenue两列。
环比用LAG的偏移量1,同比用LAG的偏移量12:
SELECT month_id, revenue, LAG(revenue, 1) OVER(ORDER BY month_id) AS prev_month, LAG(revenue, 12) OVER(ORDER BY month_id) AS same_month_last_year, ROUND((revenue - LAG(revenue, 1) OVER(ORDER BY month_id)) * 1.0 / LAG(revenue, 1) OVER(ORDER BY month_id), 2) AS mom_rate, ROUND((revenue - LAG(revenue, 12) OVER(ORDER BY month_id)) * 1.0 / LAG(revenue, 12) OVER(ORDER BY month_id), 2) AS yoy_rate FROM sales_monthly ORDER BY month_id;LAG的第二个参数就是偏移量。对任意一个月,往前12行就是去年同月。这样一张SQL把明细、上月值、去年同月值、环比增速、同比增速五列全输出,做经营分析月报时非常高效。
需要注意的前提:LAG按物理偏移取数,只有当表格每个月的数据都齐全、连续存在时,偏移12才准确对应去年同期。如果中间有断档,比如某月缺数据,那你需要先按月份字段自己补齐连续序列,或在查询里对月份做JOIN匹配,否则偏移会错位。这也是LAG相比日期JOIN的一个局限——它只管“行位置”,不管“日期值”。
5. 窗口函数避坑记录:四个我见过的典型翻车现场
5.1 坑之一:把窗口函数结果放进WHERE子句
这个坑我当年第一天学就踩了。想取每个部门工资最高的人,顺手就写了:
SELECT emp_id, dept_name, salary, ROW_NUMBER() OVER(PARTITION BY dept_name ORDER BY salary DESC) AS rn FROM employees WHERE rn = 1;数据库直接报错。原因前面讲过:SQL逻辑执行顺序里,WHERE发生在窗口函数执行之前。WHERE条件过滤时,rn这一列根本还不存在。解决办法是包一层:
SELECT * FROM ( SELECT emp_id, dept_name, salary, ROW_NUMBER() OVER(PARTITION BY dept_name ORDER BY salary DESC) AS rn FROM employees ) t WHERE rn = 1;记住一个口诀:窗口函数用来“算”,过滤窗口结果用“外层查”。以后看到任何“对窗口函数结果再过滤”的需求,第一反应就应该是子查询或CTE包一层。
5.2 坑之二:以为ORDER BY只是排序,忽略了默认累计窗口
有一次我帮同事看一张累计报表。他写的是:
SELECT month_id, revenue, SUM(revenue) OVER(ORDER BY month_id) AS total_revenue FROM sales_monthly;他告诉我,这张表每个月只有一行,他想让每一行都显示“全表总收入”,结果发现total_revenue在逐行变大,以为数据库出Bug了。其实数据库一点毛病没有。这就是第2章讲过的缺省行为:只要写了ORDER BY,窗口范围自动变成“第一行到当前行”,SUM自然变成累计值。
正确的全表总和写法是OVER(),或者OVER(ORDER BY month_id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)。这个坑的高发原因是“OVER(ORDER BY x)看起来只是排序了一下”,实际语义偷偷带了累计窗口。以后写窗口函数,先问自己一句:我要的是全窗口值,还是当前行之前的累计值?想清楚再落笔。
5.3 坑之三:LAST_VALUE取不到全分区最后一个值
前面讲FIRST_VALUE和LAST_VALUE时提过。这里我再强调一次故障现场的结构,因为遇到的人真的多:开发想看“每个部门工资最低的人”,写了LAST_VALUE(emp_name) OVER(PARTITION BY dept_name ORDER BY salary DESC),结果每一行返回的都不是部门最低工资员工。这不是函数拼错了,是窗口边界没写全。
LAST_VALUE和FIRST_VALUE不一样。FIRST_VALUE的默认窗口边界即使只到当前行,第一行也永远在窗口内,所以结果直觉正确;LAST_VALUE要取“末尾值”,而默认窗口末尾是当前行而非分区末尾,所以每个分区里只有最后一行看得到真正的末尾值。修复方式就是显式写ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING。
我后来养成了一个习惯:凡是使用LAST_VALUE,一律显式声明窗口框,不依赖数据库默认值。这个习惯帮我省了不少复查时间。
5.4 坑之四:试图嵌套窗口函数
窗口函数的结果能不能当另一个窗口函数的输入?SQL标准不允许直接在同一个SELECT里写类似SUM(SUM(x) OVER(...)) OVER(...)这样的嵌套。我见过有朋友想写“部门平均工资在全公司所有部门平均工资里的排名”,一口气把AVG套到RANK里,结果报错。
解决办法是分层计算,用CTE或子查询把第一步结果算出来,再拿去开第二次窗口:
WITH dept_stats AS ( SELECT dept_name, AVG(salary) AS avg_salary FROM employees GROUP BY dept_name ) SELECT dept_name, avg_salary, RANK() OVER(ORDER BY avg_salary DESC) AS avg_salary_rank FROM dept_stats;窗口函数嵌套的限制,其实是SQL设计上的一种“清洗”:一层只负责一层的事。把中间结果显式写成CTE,不仅绕开了语法限制,可读性也更好。遇到复杂的多层计算,不要指望一行SQL把地球转完,分步写反而不容易错。
5.5 我日常写窗口函数的防守习惯
窗口函数踩坑踩多了,我形成了一套固定的工作习惯,权当送给大家的额外彩蛋。
第一,优先用小数据集验证。写复杂窗口SQL之前,先造个十几行的测试数据,按预期逻辑手算两行结果,再跑SQL对答案。窗口函数一旦语义理解错,肉眼很难从几百行的结果里发现,但用小数据一对比立刻现形。
第二,习惯性写完整窗口框。凡是涉及到LAST_VALUE、FIRST_VALUE这种对边界敏感的用法,或者SUM、AVG这类聚合窗口,我至少会想一下“当前窗口到底应该覆盖到哪”,而不是直接交给数据库默认行为。多写几个单词换来的是结果确定性。
第三,注意执行计划的排序开销。窗口函数一旦写了ORDER BY,数据库往往要额外做一次排序。如果一张大表同时开四五个带ORDER BY的窗口函数,查询可能明显变慢。我会先把数据用WHERE和GROUP BY压缩到最小粒度,再在结果集上做窗口计算,或者在子查询里先过滤到只保留分析所需的列,避免窗口函数扫到太多无关字段。MySQL 8、PostgreSQL、SQL Server这类数据库对窗口函数的支持大同小异,但性能调优思路是互通的。
最后补充一个小建议:写窗口函数时,分区键和排序列最好都是索引涉及到的字段,或者至少在计算前先过滤掉不需要的数据。窗口函数要遍历整个分区来计算,分区里的行数越多、重复执行次数越多,压力就越大。我在一次几千万行的用户行为表上做排名需求时,就是靠先把数据过滤到近七天的子集,才把查询时间从分钟级压到秒级。窗口函数本身不复杂,真正的功夫在数据裁剪上。
说到底,窗口函数入门最难的不是语法,而是改变一种思维方式:从“分组后只看一组”切换到“明细行保留、同时感知整体”。第一次觉得别扭很正常,多拿真实业务需求练几次,很快就能体会到那种“原来一行OVER就能搞定”的畅快感。希望这篇基于我个人实践总结的内容,能帮你少走我当年走过的弯路。