说出来有点不好意思,我学SQL的顺序是先学了GROUP BY,然后膨胀得不行,觉得自己已经会“分组汇总”了。直到某天看到一道题:查出每个部门工资最高的员工,工资并列怎么办?我憋了半天,写出来的玩意儿要么把员工明细弄丢,要么只能返回一行。后来才知道,这种需求根本不该用GROUP BY硬怼,而是要用窗口函数。
窗口函数(Window Function)就是那种你早该学、但一直逃避的东西。它不算新,各个主流数据库都有,可网上教程要么讲得太浅,要么直接甩一堆语法让人背。我学这个过程堪称“命苦”:看一遍觉得懂了,一写就报错,一报错就回查概念,来来回回折腾了好几周。这篇笔记就是把当时的血泪过程整理成我自己能看懂的样子,顺便给同样在坑里的你一点参考。
我是谁、这篇写给谁:一个半路出家学数据的普通学习者。如果你正在学SQL,已经会SELECT、GROUP BY、JOIN,但一碰窗口函数就懵,那这篇就是写给你的。
1. 为什么窗口函数让我觉得SQL白学了
1.1 一次面试被问倒
事情是这样的。有阵子我刷题刷到“查找每个部门薪资最高的员工”,当时满脑子都是GROUP BY。我写出来的第一版是:
SELECT dept_id, MAX(salary) FROM employee GROUP BY dept_id;这确实把每个部门的最高薪资拿出来了,可问题来了——工资最高的那个员工叫啥、工号是多少、入职日期是哪天?我拿不到。因为GROUP BY把部门内的多行压缩成了一行,除了分组键和聚合结果,其他字段全被丢掉了。
我又试JOIN把自己 join 回去,勉强能查出来。可如果需求变成“每个部门薪资排名前3的员工分别是谁”,我直接傻了。LIMIT只能限制总行数,没法在每个组内各取3条。那一瞬间我意识到:我的SQL知识里缺了一块,这块东西能让你在“不破坏明细行的前提下,对每一行及其所在分组做计算”。
这块东西就是窗口函数。
1.2 窗口函数到底改了什么
窗口函数和普通聚合函数长得有点像,都是SUM、AVG、COUNT这些,但行为完全不一样。普通聚合函数是“多进一出”,GROUP BY把一组行并成一行;窗口函数是“多进多出”,它把每一行都保留,同时附加一个计算结果。可以这样理解:普通聚合是榨汁机,一堆水果进去一杯汁出来;窗口函数是给每一颗水果贴标签,告诉它“你在这一堆里排第几”“你们这一堆总重量是多少”。
所以窗口函数解决的,是那种“既要明细,又要汇总”的问题。比如算每个月的销售额占总销售额的比例,比如给每笔订单打上客户累计消费额,再比如“每门课前三名”这类分组取TopN需求。
我真正被点醒的瞬间,是接受了这句话:窗口函数是在每一行上“开一个小窗户”,站在当前行朝窗口内的其他行看,拿窗口里的数据做计算。窗口是动的——每一行都有自己对应的窗口。这就是它名字里“窗口”两个字的来历。
2. 先搭语法骨架:OVER、PARTITION BY、ORDER BY各管各的
2.1 一个订单表的例子
学窗口函数第一件事不是背函数,而是搞懂OVER()这个核心语法。几乎所有的窗口函数都长这样:
函数名(...) OVER ( [PARTITION BY 列名] [ORDER BY 列名] [ROWS/RANGE 窗口框架] )括号里三样东西可以单独出现,也可以组合出现,也可以一个都不写。我现在拿一个最简单的订单表来说事。
假设有张表叫orders,字段是:order_id、city、order_date、amount。现在我想算“每笔订单金额占所在城市总金额的百分比”。如果没有窗口函数,你得先按城市聚合一次,再 join 回去。有了窗口函数,直接这样:
SELECT order_id, city, amount, amount / SUM(amount) OVER (PARTITION BY city) AS city_ratio FROM orders;我盯着这段代码看了很久才明白:PARTITION BY city就是在每个城市内开了个“窗口组”,SUM(amount)是针对这个组算的,但结果没有把行合并,而是每行都带着一个“本组总金额”。所以它能拿amount直接除以这个组总和,得到占比。
写到这里顺便说一句,PARTITION BY和GROUP BY的关键区别,我自己的记忆方法是:GROUP BY会“合并行”,你的结果行数等于分组个数;PARTITION BY只是“划地盘”,它把行分成若干组,但行数一条不少,每一行仍然输出。这个区别是我踩了无数次坑才记住的。
2.2 三个省略写法分别是什么意思
OVER()括号里写不写东西,结果差别巨大。
只写空括号OVER()时,表示把所有行当作一个窗口,算出来的值对所有行都一样。比如:
SELECT order_id, amount, SUM(amount) OVER () AS total_amount FROM orders;这个场景适合算“每一行占总数的比例”。
写PARTITION BY但不写ORDER BY时,窗口是“整个分区”。上面的城市占比例子就是这样。
写ORDER BY但不写PARTITION BY时,窗口从第一行一直“滚”到当前行。这句话有点绕,我举个实际例子:
SELECT order_date, amount, SUM(amount) OVER (ORDER BY order_date) AS running_total FROM orders;这个running_total不是所有订单的总额,而是“按日期排序,截止到当前日期的累计销售额”。这是窗口函数最经典也最容易理解错的地方。我第一次写的时候,以为OVER (ORDER BY order_date)只是“顺便按日期排个序”,结果跑出来的数一个比一个大,还以为是bug。
后来我才懂,只要OVER()里出现ORDER BY,那就不是在给整个表排序,而是在给窗口定义“顺序”。顺序一旦定义,聚合窗口就不再是“整个分区”,而是默认从起点滚动到当前行。这就是为什么我会看到那些“递增的”合计值——它本来就是在做累计。
记住:OVER里的ORDER BY不是“表最终的排序方式”,它是窗口内部的滚动方向。这个误解不纠正,后面框架子句你根本学不下去。
3. 三个排名函数:ROW_NUMBER、RANK、DENSE_RANK
3.1 同分时的差别
窗口函数家族里除了SUM、AVG这些聚合函数,另一大主力就是排名函数。三个最常见的放一起看:
ROW_NUMBER():纯粹的给行编个号,1、2、3、4……永远不重复。RANK():按排序字段给排名,有并列时下一个名次会跳。比如两个人并列第一,那第三个人显示第3名,而不是第2名。DENSE_RANK():并列不跳号。两个人并列第一,第三个人还是第2名。
我用一个成绩表做例子。表叫exam_score,只有三列:student_id、subject、score。假设数学这门课有四个学生成绩如下:A 95,B 95,C 90,D 85。
三个函数跑出来的结果:
SELECT student_id, score, ROW_NUMBER() OVER (ORDER BY score DESC) AS rn, RANK() OVER (ORDER BY score DESC) AS rk, DENSE_RANK() OVER (ORDER BY score DESC) AS drk FROM exam_score WHERE subject = '数学';| student_id | score | rn | rk | drk |
|---|---|---|---|---|
| A | 95 | 1 | 1 | 1 |
| B | 95 | 2 | 1 | 1 |
| C | 90 | 3 | 3 | 2 |
| D | 85 | 4 | 4 | 3 |
看到差别了吗?A和B并列第1,RANK里C直接变第3名,把第2名空出来了;DENSE_RANK里C是第2名,名次是连续的。ROW_NUMBER不在乎并列,纯粹按物理扫描顺序编号。
可以看一下这个例子,同时有个小问题:极限长度的表格在手机端可能滑动,但实际使用中这个表格不算大,没问题。
实际业务里怎么选?我做数据清洗时,ROW_NUMBER()用得最多,因为它在去重场景里特别好使。比如同一用户重复注册,我想保留最早的一条,就按用户分组给每条记录编号,然后只取编号为1的行。排榜单的时候用RANK(),比如“并列冠军”天然就该跳号;需要给奖金分档、并且档位连续的时候用DENSE_RANK()。
3.2 为什么取前N名非要套一层子查询
我一开始写“每门课成绩前三名”,特别自然就想这么写:
SELECT * FROM exam_score WHERE ROW_NUMBER() OVER (PARTITION BY subject ORDER BY score DESC) <= 3;结果数据库直接甩给我一个报错。原因不是语法写错,而是SQL的执行顺序问题。你可以简单理解成:WHERE是在最开始过滤行的,它执行的时候窗口函数还没被算出来。窗口函数在SELECT阶段才生成,WHERE根本等不到它。
所以想要“取组内前N名”,标准做法是先在一个子查询里把排名算出来,然后在外面过滤:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY subject ORDER BY score DESC) AS rnk FROM exam_score ) AS t WHERE t.rnk <= 3;这个坑我踩了不止一次。后来我把SQL的执行顺序简化成:FROM→WHERE→GROUP BY→HAVING→SELECT→ORDER BY。窗口函数在SELECT这一层才生效,所以WHERE、GROUP BY、HAVING里都别指望能用它——你只能在SELECT里用它,或者在外面再套一层。
很多教程会直接给“子查询取TopN”这个答案,但不会解释为什么非要套子查询。我在这里把它解释清楚,因为一旦明白了执行顺序,你很多报错都能自己推断了。
4. 移动视线:LAG、LEAD与FIRST_VALUE的坑
4.1 环比场景
排名和聚合算是窗口函数的左膀,那右臂就是“取值函数”。最常用的两个是LAG()和LEAD()。
LAG(列名, n, 默认值)是向上看n行,取那一行的值;LEAD是向下看n行。如果那一行不存在,就返回你给的默认值(不写默认值的话通常返回NULL)。
我最常用的场景是环比。比如有个每日销售表daily_sales,字段是sale_date和amount,我要算“今天比昨天增长了多少”,写法是:
SELECT sale_date, amount, LAG(amount, 1) OVER (ORDER BY sale_date) AS prev_amount, amount - LAG(amount, 1) OVER (ORDER BY sale_date) AS diff FROM daily_sales;LAG之所以好用,是因为它不用自连接。自己 join 自己也能算,但代码又长又慢,窗口函数一行搞定。我刚开始学LAG的时候又犯了一个低级错误:我以为表里天然有“上一行”的概念。其实SQL表是无序集合,没有“上一行”这个说法。你必须在OVER (ORDER BY sale_date)里明确告诉数据库“按日期排完序之后,往上一行去取”。不写ORDER BY,数据库都不知道上一行是谁。
顺便说个真实教训:如果ORDER BY的字段有重复值,LAG取到的“上一行”可能不是你想象的那一条。所以做这种前后行比较时,排序字段最好能唯一确定顺序,比如日期加订单号,而不是光一个日期。
4.2 别对LAST_VALUE抱错期待
FIRST_VALUE()和LAST_VALUE()这俩函数,看名字特别直白:取窗口内第一行/最后一行的某个字段值。
但是!LAST_VALUE简直是我学窗口函数时最想骂人的一个函数。它的逻辑是:在窗口框架范围内取最后一行,而窗口框架在ORDER BY出现时,默认范围是“从分区起点到当前行”。也就是说,默认情况下LAST_VALUE取的是当前行自己,而不是整个分区的最后一行!
我用个例子展示一下这个坑。假设有张表按日期排序,我想取“截至当前日期,每天的最低价格和最新价格”:
SELECT sale_date, price, FIRST_VALUE(price) OVER (ORDER BY sale_date) AS first_price, LAST_VALUE(price) OVER (ORDER BY sale_date) AS last_price FROM daily_price;这个first_price没问题,它一直是整个序列的第一个价格,因为窗口起点固定在分区开始。但last_price算出来每行都等于当前行的price,根本不是我要的“最新价”……哦不对,仔细想想,“截至当前日期的最新价”确实就是当前价,所以好像也没错?但如果我要的是“整个时间序列里最后一天的价格”,那就必须手动把窗口框架改成整个分区:
LAST_VALUE(price) OVER ( ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_price_overall这里我把窗口框架明确成“从起点到终点”,它才是我想要的值。这个教训让我彻底记住了框架子句的存在和威力。也是从这时候开始,我才正儿八经去学ROWS和RANGE。
5. 框架子句:ROWS和RANGE是学习过程中最想哭的部分
5.1 默认框架和累计
先说一个我反复用到的概念:窗口框架(frame)。它是在分区内再切一段范围,决定“当前窗口里到底包含哪些行”。
有三条铁律似的规则:
- 写了
ORDER BY,默认窗口是从分区起点到当前行。 - 不写
ORDER BY,默认窗口是整个分区。 - 不写
PARTITION BY、只写ORDER BY,整个表是一整个分区,但还是默认框架“起点到当前行”。
这就是为什么第2章的累计求和能跑起来——SUM(amount) OVER (ORDER BY order_date),窗口是从最早日期滚动到当前日期,每一行的结果就是“截至这一行的累计和”。
说到框架写法,两种最常见的:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW意思是窗口从分区第一行开始,到当前行结束。看到UNBOUNDED PRECEDING就是要从无限远的最前面开始,CURRENT ROW表示截止到当前行。
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW这个表示窗口只包含当前行和它前面两行,一共三行。我算移动平均的时候特别常用,比如“近3天平均销售额”:
SELECT sale_date, amount, AVG(amount) OVER ( ORDER BY sale_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS avg_3day FROM daily_sales;注意,头两天前面不足两行,窗口就只有实际存在的行,所以头两天的平均数是按1天、2天算的。这个“边界自动收缩”的特性很合理,但也容易让初学的人误以为数据算错了。
5.2 RANGE和ROWS的致命区别
我这部分学得最痛苦,因为RANGE和ROWS长得像,行为差很多。
ROWS是按物理行数划窗口。说包含前2行,就是实打实的前2行。RANGE是按排序字段的值划窗口。它会把所有和当前行“排序值相同”的行都圈进来。
举个具体例子。还是销售表,假设order_date有重复,三行都是同一天。这时候用:
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW严格只包含物理上的前一行加当前行,就算前一行日期和当前行一模一样,也只算一行。
但如果改成:
RANGE BETWEEN 1 PRECEDING AND CURRENT ROW那就要把排序字段值小于等于当前值、大于等于当前值减1的所有行全包含进来。如果排序字段是日期,那就是“从昨天到今天”的所有行;如果字段是数值且一天有多行,那几行全都会被框进来。
我记忆的方式是:ROWS是数人头,RANGE是按数值范围圈边界。做移动平均、滑动窗口汇总这类需求,ROWS最直观;涉及时间区间的,比如“算过去30天”,RANGE更贴合语义。
但是这里有个大坑:不同数据库对RANGE的支持程度不一样。有的数据库支持RANGE带数值偏移,有的只支持RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW这种最简单写法。我自己就在某个数据库上写过RANGE BETWEEN INTERVAL 30 DAY PRECEDING AND CURRENT ROW,换到另一个数据库直接报语法错误。所以跨库写SQL的时候,框架子句这块一定要查一下目标库的文档,别默认都一样。
6. 写完窗口函数后的自查清单
我学窗口函数最大的问题,是写的时候很爽,写完跑出来一个错,又得从头检查。后来我给自己列了一个自查清单,每次写完都过一遍。现在分享给你:
6.1 我的六个检查项
第一个检查项:窗口函数有没有被放在不该放的位置?前面提过,WHERE、GROUP BY、HAVING里放窗口函数一定会报错。我现在的习惯是写之前就在脑子里过一遍执行顺序,只要发现窗口函数出现在不该出现的位置,马上改写成子查询。
第二个检查项:语句里有没有别名到处引用的毛病?窗口函数在SELECT里生成了别名,那外面一层能不能引用这个别名,不同数据库行为不一样。稳妥的做法是不要指望同层SELECT里另一个窗口函数能用你刚定义的别名,必要时再套一层子查询。
第三个检查项:ORDER BY到底是不是真的必要?我经常为了“看起来整齐”在OVER()里乱加ORDER BY,结果聚合行为突然从“整个分区总和”变成“累计值”。如果我想算的是一整组的和,不是滚动累计,就别写ORDER BY,或者显式指定ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING,把窗口固定成整个分区。这个错我犯过很多次,每次都怪SQL“神经病”,其实是我没说清楚要什么。
第四个检查项:三个排名函数选对了吗?我自己记忆法是:只想编个号去重,用ROW_NUMBER;比赛排名并列跳号,用RANK;要连续名次给奖金分档,用DENSE_RANK。输出数字到底是几,自己心算一次再跑,基本不会错。
第五个检查项:LAG/LEAD的步长和默认值给了吗?只写LAG(amount)默认往上1行,取不到就是NULL。如果要找“前7天”的数据,记得在第2个参数写7。如果没有数据时希望它显示0,第三个参数写0。这个坑不算大,但会让结果里全是NULL,刚开始容易被吓到。
第六个检查项:大表上开窗会不会很慢?窗口函数本质上是给每一行算一遍它窗口里的聚合,数据量上来之后开销不小。尤其是PARTITION BY多个列、又带重排序的时候,很容易慢。实际操作中我经常先看一下执行计划,再决定是加索引还是把预聚合拆出去。
6.2 报错信息里最值得盯住的提示
说实话,窗口函数相关的报错信息写得往往比较抽象。你可能会看到类似“window function is not allowed in WHERE”的报错,这反而是最好解决的,直接套子查询就行。主要是要注意语句里“窗口函数的位置”和“框架的边界”这两个点。如果看到的是结果不对、不报错,那多半是ORDER BY乱加导致窗口变成了滚动累计,或者RANK和DENSE_RANK搞混了。
7. 如果你也在学:我的练习路径
写到最后,我想把这几周摸索出来的练习顺序整理一下。这不是什么权威路线,只是如果让我重新学一遍,我会用这个顺序,少走弯路。
第一阶段:先练SUM/COUNT加OVER()。场景是“每行占比”“累计求和”。这个阶段的目标是彻底搞清楚PARTITION BY和ORDER BY在OVER里的作用,尤其是那个“不写ORDER BY是整个分区,写了就变成滚动累计”的差别。练熟之后你就能理解为什么窗口函数叫“窗口”了。
第二阶段:练排名三兄弟。找一份成绩表,跑ROW_NUMBER、RANK、DENSE_RANK,把同一个分组结果并排放一起看。再练“取每组的TopN”,记住必须套子查询。
第三阶段:练LAG/LEAD和FIRST_VALUE/LAST_VALUE。做环比、同比,做“当前行对比前一行”的分析。记住LAST_VALUE默认只看到当前行,需要整个分区时改框架。
第四阶段:练ROWS和RANGE框架。用“近3天移动平均”练手,再尝试用RANGE做“过去7天累计”。这个阶段可以看看目标数据库文档,确认RANGE支持哪些写法,顺便把“不同数据库差别大”这个认知刻进脑子里。
第五阶段:拿真实业务题练。任何你能想到的分析场景都可以:“每个客户最近一次订单”“每个门店当月累计销售额”“每个商品最近30天的平均销量”“每个员工与其部门平均工资的差异”。这些题在网上一搜一大把,重要的是每个都动手写一遍,不要只看别人写的答案。
我个人的体会是:窗口函数不是一个“看懂”就能会的东西,它需要你靠几十条SQL“喂”出直觉。一开始报错报得让人想摔电脑,但摔完再看一眼报错信息,翻一下执行顺序,基本就能定位问题。等你坚持写了几周,回头看那些曾经让你头疼的“求组内占比”“求前N名”报表,会发现思路前所未有的清晰——遇到这类需求,第一反应不再是写一坨又臭又长的自连接子查询,而是自然而然地想到“这里可以开个窗”。那种感觉,值得你把这套“命苦”熬过去。