写SQL时间久了,绕不开一个痛点:既要看到明细,又要看到分组统计。比如“每个部门工资最高的员工是谁”,如果只用GROUP BY,拿到的是部门和一个聚合值,员工的其他字段全丢了。我最早的办法是子查询关联,代码又长又绕。后来系统学了SQL窗口函数,才发现这个困扰多年的问题,一句OVER就能解决,而且还能把排名、累计值、移动平均一股脑塞进一条查询里。
这篇文章不是把窗口函数搬出来罗列语法,而是从实际需求出发,把它拆成“分区、排序、窗口范围”三个概念,配合可以直接跑的SQL,带你把门口的路走通。无论你之前只写过SELECT和GROUP BY,还是已经被子查询绕怕了,都可以照着后面的例子推一遍。
1. 窗口函数到底解决什么问题
1.1 一个让我想换掉子查询的业务场景
先看一个最经典的需求:查询每个部门工资最高的员工,并且要返回完整行信息。
只用GROUP BY可以这样写:
SELECT dept_id, MAX(salary) AS max_salary FROM employees GROUP BY dept_id;结果没问题,但只有“部门编号”和“最高工资”两列。如果你想看这个人是谁、入职时间是什么时候,还得再把这张结果关联回原表。我在早期项目里就是这么干的,子查询嵌套三层,稍微复杂一点,自己过两天回头看都皱眉。
子查询版本大概是这样的:
SELECT * FROM employees e WHERE salary = ( SELECT MAX(salary) FROM employees m WHERE m.dept_id = e.dept_id );这个写法能工作,但有两个不好受的地方:第一,同一个员工表要反复访问,可读性差;第二,如果需求变成“每个部门工资排名前两名的员工”,括号里的子查询几乎没法直接扩展。
换成窗口函数后,代码是另一种画风:
SELECT * FROM ( SELECT e.*, RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rk FROM employees e ) t WHERE rk = 1;先用RANK函数给每个部门内部的员工按工资从高到低排号,再在外面过滤出排名为1的人。所有明细字段都还在,不用再做一次关联。这样的写法,才是窗口函数真正擅长的事。
1.2 和GROUP BY的分工差异
很多人第一次接触窗口函数,脑子里最大的疑问是:这不就是GROUP BY吗?
我刚学时也有同样的疑惑,后来想明白了一个关键区别:GROUP BY会把多行合并成一行,结果集的行数会变少;窗口函数不合并行,它只是在一张“原样保留”的表上,额外算出一列分组统计或编号。换句话说,GROUP BY像是把同一组的试卷收起来只给一个平均分,窗口函数则像在每个学生的答卷角落盖一个“全组平均分”的章,答卷本身还是每一份都在。
举一个具体例子:
SELECT employee_id, employee_name, dept_id, salary, AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg_salary FROM employees;这一条SQL返回多少行?答案是原表有多少行就返回多少行。每个员工的右边都多了一列“dept_avg_salary”,是该员工所在部门的平均工资。
同样一个AVG,如果去掉OVER:
SELECT dept_id, AVG(salary) FROM employees GROUP BY dept_id;结果立刻压缩成每个部门一行。
所以判断用GROUP BY还是窗口函数,最简单的标准就是问自己:结果集行数会变吗?需要保留明细行,就选窗口函数;只需要分组汇总,就选GROUP BY。
2. 窗口函数的语法骨架:OVER子句里藏了什么
2.1 三块积木:PARTITION BY、ORDER BY、窗口框架
窗口函数的语法结构看着唬人,实际上一句话就能说清:
函数() OVER ( [PARTITION BY 列] [ORDER BY 列] [ROWS | RANGE BETWEEN ... AND ...] )括号里的三块内容,就像三块积木,可以自由组合。
第一块是PARTITION BY,用于分区。它决定窗口函数在哪些行组成的小组里计算。你可以把PARTITION BY理解成“把一张大表横向切成若干个小桌”,每个小桌内部单独算。比如PARTITION BY dept_id,就是把员工表按部门切桌,每个部门一桌。
第二块是ORDER BY,用于排序。它决定同一分区内按什么顺序计算。比如ORDER BY salary DESC,就是让同一部门的人按工资从高到低排队。很多窗口函数,比如ROW_NUMBER、LAG、LEAD,只有排了序才有意义。
第三块是窗口框架,也是初学者最容易忽略的。它负责在“已经排好队的那个分区”里,再画一个更小的范围,通常用ROWS或RANGE配合BETWEEN ... AND ...来写。举个例子:ROWS BETWEEN 2 PRECEDING AND CURRENT ROW表示从当前行往前数两行到当前行,这个小小范围参与计算。移动平均就是靠这个写出来的。
2.2 默认窗口框架的“隐藏规则”
这是窗口函数入门阶段最大的坑,我在这里摔倒过不止一次。
很多人以为写了SUM(amount) OVER (PARTITION BY region ORDER BY order_date),得到的是整个分区的总额。实际上,在很多主流数据库里,只要写了ORDER BY,默认的窗口框架是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,也就是从分区第一行到当前行。这会导致什么呢?会让你写“累计值”时很容易,但想写“分组总额”时却悄悄变了味。
举个例子:
SELECT dept_id, hire_date, salary, SUM(salary) OVER (PARTITION BY dept_id ORDER BY hire_date) AS running_total FROM employees;这条SQL里的running_total不是“部门总工资”,而是“按入职日期排序后的累计工资”。如果你只想要部门总工资,有两种改法:要么去掉ORDER BY,要么明确把窗口框架写全:
SUM(salary) OVER ( PARTITION BY dept_id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS dept_total第一种简单,第二种更严谨。我个人的习惯是:只要窗口范围不是默认情况,就显式把ROWS或RANGE写出来,避免依赖数据库默认行为,也让读代码的人一眼看到边界。
另外要记住SQL的执行顺序。一个查询会先走FROM、WHERE,再到SELECT,窗口函数是在SELECT阶段计算的。所以你在WHERE子句里想直接引用窗口函数的别名,通常是行不通的,会报“unknown column”或“window function not allowed here”。最稳妥的做法是套一层子查询或者CTE。
3. 常见窗口函数逐一拆解
3.1 排序编号:ROW_NUMBER、RANK、DENSE_RANK
窗口函数里最常用、也最容易被搞混的三个,就是ROW_NUMBER、RANK和DENSE_RANK。它们都用来生成序号,但遇到排序值并列时行为完全不同。
假设部门20里有三个员工的工资分别是9000、9000、7200,看看三种函数的结果:
| 员工 | salary | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| 员工丙 | 9000 | 1 | 1 | 1 |
| 员工丁 | 9000 | 2 | 1 | 1 |
| 员工戊 | 7200 | 3 | 3 | 2 |
ROW_NUMBER不管有没有并列,都强行给出1、2、3这样的连续序号,所以并列的两个人也会分出前后。
RANK遇到并列时,两个9000都排第1,下一个7200直接跳到第3,中间的第2空出来了。这种带“空位”的排名,接近现实中的体育比赛排名。
DENSE_RANK同样允许并列,但后面不跳号,7200排第2,名次连续。
实操中最容易犯的错是:想要“每个部门工资前两名”,随手写了ROW_NUMBER() = 1或2,结果漏掉了并列工资的员工。如果业务逻辑要求“并列的人都要保留”,应该用RANK或DENSE_RANK,再配合外层过滤:
SELECT * FROM ( SELECT e.*, RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rk FROM employees e ) t WHERE rk <= 2;这里用RANK,两个并列第一都能保留,接下来的那个人按第3名算,也会被外面的rk <= 2排除。如果你的目标不是“保留并列”,而是“只要两个指标”,那用ROW_NUMBER也没错。
3.2 位移取值:LAG、LEAD与FIRST_VALUE、LAST_VALUE
LAG和LEAD是一对左右邻居函数。LAG用来取当前行前面第N行的值,LEAD用来取当前行后面第N行的值。最常见的场景是算环比:求当月销售额比上个月多多少。
SELECT order_date, amount, LAG(amount, 1, 0) OVER (ORDER BY order_date) AS prev_amount, amount - LAG(amount, 1, 0) OVER (ORDER BY order_date) AS diff_amount FROM sales ORDER BY order_date;LAG的第三个参数0是默认值。如果当前行是分区第一行,前面没有数据,就用0补上,避免出现NULL。写成LAG(amount, 1)不写默认值时,第一行会返回NULL,后续计算要小心NULL传播。
FIRST_VALUE和LAST_VALUE则是取窗口内第一个或最后一个值。比如想知道每个部门入职最早和最晚的员工:
SELECT dept_id, employee_name, hire_date, FIRST_VALUE(employee_name) OVER ( PARTITION BY dept_id ORDER BY hire_date ) AS first_hire_name, LAST_VALUE(employee_name) OVER ( PARTITION BY dept_id ORDER BY hire_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_hire_name FROM employees;注意LAST_VALUE这里必须显式写出ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING,否则默认窗口只到当前行,你会拿到“当前行自己”而不是“分区最后一行”。我第一次用LAST_VALUE就被这个坑摆了一道,排查了半天才发现是默认窗口框架在捣乱。
3.3 聚合窗口与移动计算:SUM、AVG、COUNT
聚合函数加上OVER,直接从一个“压缩结果”变成“一行行追加结果”。这组能力非常实用。
第一个用途,算占比。比如每个员工的工资占部门的比例:
SELECT employee_id, employee_name, dept_id, salary, ROUND(salary / SUM(salary) OVER (PARTITION BY dept_id) * 100, 2) AS salary_pct FROM employees;这里没有ORDER BY,所以SUM的窗口是整个部门全部行,每个员工行的分母都是同一个部门总工资。
第二个用途,算累计。加上ORDER BY后,SUM就变成从分区起点到当前行的累计和:
SUM(salary) OVER (PARTITION BY dept_id ORDER BY hire_date) AS running_salary第三个用途,算移动平均。要算出“当前行、前一行、后一行”三行的平均值,用窗口框架圈范围:
AVG(salary) OVER ( ORDER BY hire_date ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING ) AS moving_avg这个写法在股票均线、销售趋势分析里特别常用。脑子里可以有个画面:窗口框架像一个可以滑动的取景框,框住哪几行,函数就算哪几行的值。
4. 实操过程:从业务描述到稳定SQL
4.1 第一步,把需求翻译成分区、排序和窗口范围
窗口函数虽然强大,但不是一上来就写代码。我习惯先把业务需求拆成三问:按什么分区?按什么排序?计算范围是什么?
举一个综合场景。假设我有一张销售明细表sales,一行是一个订单,字段有order_id、region、sales_name、amount、order_date。业务提出了三个需求:
- 找出每个区域销售额排名前三的订单,如果销售额并列,并列的都要保留;
- 计算每个订单金额占所在区域订单总金额的比例;
- 按日期累计每个区域的销售额。
逐个翻译:
需求1:分区是region,排序是amount DESC,考虑并列所以用DENSE_RANK或RANK,外层过滤排名小于等于3。
需求2:分区是region,不排序,用SUM(amount) OVER (PARTITION BY region)作为分母,然后用当前行amount除以分母。
需求3:分区是region,排序是order_date,用SUM(amount) OVER (PARTITION BY region ORDER BY order_date)做累计。
这三步翻译过来后,SQL的样子已经出来了。不用背语法,主要是把业务语言映射到窗口函数的三个关键词。
4.2 第二步,选择正确的窗口函数
针对不同类型的需求,我整理了一个快速选择表。遇到类似场景直接对号入座,能省不少时间。
| 需求 | 推荐窗口函数 |
|---|---|
| 唯一连续序号,不关心并列 | ROW_NUMBER |
| 并列排名,且允许跳号 | RANK |
| 并列排名,要求名次连续 | DENSE_RANK |
| 取上一行/下一行记录值 | LAG / LEAD |
| 取窗口内第一行/最后一行值 | FIRST_VALUE / LAST_VALUE |
| 分组内累计求和 | SUM + PARTITION BY + ORDER BY |
| 分组占比 | SUM + PARTITION BY,不写ORDER BY |
| 移动平均 | AVG + ROWS BETWEEN ... AND ... |
选择时别只看函数名,要看业务到底需不需要保留并列。项目里最常见的返工原因,就是把RANK和ROW_NUMBER用反了。
4.3 第三步,写SQL并验证边界数据
造一小份模拟数据,把三个需求合并到一条查询里演示,数据量虽然小,但句句能跑:
WITH sales_raw AS ( SELECT 10001 AS order_id, '华东' AS region, 'S001' AS sales_name, 1200.00 AS amount, DATE '2024-01-01' AS order_date UNION ALL SELECT 10002, '华东', 'S002', 980.50, DATE '2024-01-02' UNION ALL SELECT 10003, '华东', 'S003', 1500.00, DATE '2024-01-03' UNION ALL SELECT 10004, '华南', 'S004', 2000.00, DATE '2024-01-01' UNION ALL SELECT 10005, '华南', 'S005', 2000.00, DATE '2024-01-02' UNION ALL SELECT 10006, '华南', 'S006', 450.00, DATE '2024-01-03' ) SELECT order_id, region, sales_name, amount, DENSE_RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS region_rank, ROUND(amount / SUM(amount) OVER (PARTITION BY region) * 100, 2) AS region_ratio, SUM(amount) OVER ( PARTITION BY region ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total FROM sales_raw ORDER BY region, region_rank;跑出来的结果里,华东区域按金额排名前三的订单都被选出来,华南区域有两个2000元的并列第一,因为用了DENSE_RANK,两个都保留了。占比列加起来是100,说明分母用的是整区总额而不是累计值。
验证这种事,别只看一眼就过。我一般会单独跑一个GROUP BY版本对比总数:
SELECT region, SUM(amount) AS total_amount FROM sales_raw GROUP BY region;然后拿这个结果去对比每条查询里的running_total最后一行,应该完全相等。如果不相等,多半是窗口范围或排序字段设置有问题。
5. 常见问题与排查技巧实录
5.1 为什么在WHERE里引用窗口函数报错?
这是刚上手时最频繁遇到的一个报错。原因在于SQL的逻辑执行顺序:WHERE在窗口函数计算之前执行。你还没算出来的排名,当然不能拿去过滤。
-- 这段会报错 SELECT *, ROW_NUMBER() OVER (ORDER BY amount DESC) AS rn FROM sales WHERE rn = 1;解决方式很统一:先算出来,再过滤。用CTE或者子查询包一层:
WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (ORDER BY amount DESC) AS rn FROM sales ) SELECT * FROM ranked WHERE rn = 1;养成这个习惯之后,你就不会在WHERE里硬写窗口函数了。
5.2 加了ORDER BY后,SUM结果从总额变成了累计值
这个坑在前面讲默认窗口框架时提过。再强调一次:SUM(amount) OVER (PARTITION BY region ORDER BY order_date)不是分组总额,而是累计总额。
如果你确实需要“分组总额”,有两种改法:
-- 方式一:去掉ORDER BY SUM(amount) OVER (PARTITION BY region) -- 方式二:显式写满整个分区 SUM(amount) OVER ( PARTITION BY region ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING )我更喜欢方式二,因为它明确表达了“我要整个分区”,不受数据库默认规则影响。调试的时候,逐条确认窗口框架,比肉眼猜结果可靠得多。
5.3 排序字段重复时,RANK和ROW_NUMBER的结果对不上
排序字段重复是业务里很常见的现象。两个订单金额都是2000,ROW_NUMBER会强行给一个第1、一个第2;RANK则会给两个第1,然后下一个跳成第3。
如果你的目的是“Top N且允许并列”,不要用ROW_NUMBER。如果既要并列又不想跳号,用DENSE_RANK。如果并列时还想分出先后,可以给ORDER BY加一个次要排序字段:
ROW_NUMBER() OVER (ORDER BY amount DESC, order_date ASC)这样并列金额时,按日期更早的排前面,结果稳定,也能定制“并列时的先后规则”。
5.4 窗口函数一定慢吗?
很多人在项目里听到窗口函数就下意识说“是不是很占内存”。确实,窗口函数尤其是带ORDER BY的,往往需要全量分区排序,数据量一大,计算量会明显增加。但“慢”不是窗口函数的原罪,而是不分场合全量计算造成的。
我常用的优化思路有三个。第一,在进窗口函数之前,尽量通过WHERE、JOIN、GROUP BY把数据量降下来,让窗口函数只处理必要的数据。第二,给ORDER BY和PARTITION BY涉及的字段建合适的索引,能减少排序时间。第三,如果只需要分组极值而不需要其他字段,有时改用GROUP BY或关联子查询反而更快,没必要为了“用窗口函数”而用。
实际项目里,窗口函数的可读性优势非常明显。几百行条理清晰的窗口SQL,往往比三层嵌套子查询更容易维护。性能够用的情况下,优先考虑代码可读性。
5.5 LAST_VALUE取不到“最后一行”?
这个坑我在3.2里提过。LAST_VALUE的默认窗口范围通常只到当前行,所以每次取到的都是当前行自己。想真正取到窗口最后一行,必须把窗口范围写成:
LAST_VALUE(字段) OVER ( ORDER BY 排序字段 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING )否则排查半天,只会觉得“这个函数是不是坏了”。其实函数没坏,是窗口的边界没给它画对。
6. 一点个人使用心得
窗口函数是我接触SQL以来“投入产出比”最高的语法之一。刚开始我总喜欢拿它和GROUP BY打架,后来养成一个习惯:写完SQL先问自己“结果集行数会变吗”。如果不变,优先考虑窗口函数;如果变了,考虑GROUP BY。这一句判断,能帮我把两类场景分得清清楚楚。
如果你只能记一句话,那就记这句:OVER()里的PARTITION BY相当于分组,ORDER BY相当于组内排队,默认窗口会从组头排到当前行。先把这句话想明白,再回看LAG、RANK、SUM OVER这些函数,就会觉得它们都是顺理成章的事。
窗口函数的坑虽多,但每一个坑背后,都是对“窗口范围”和“执行顺序”的理解不够深。我踩过的那些雷,写出来也只是帮你少走几步弯路。真正想熟练,还是多拿自己的业务数据试几次,试多了,自然就稳了。