☰
SQL窗口函数实战:用OVER子句解决分组排名、累计统计与移动平均
2026/10/10 15:29:45 网站建设 项目流程

写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,看看三种函数的结果:

员工salaryROW_NUMBERRANKDENSE_RANK
员工丙9000111
员工丁9000211
员工戊7200332

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. 找出每个区域销售额排名前三的订单,如果销售额并列,并列的都要保留;
  2. 计算每个订单金额占所在区域订单总金额的比例;
  3. 按日期累计每个区域的销售额。

逐个翻译:

需求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这些函数,就会觉得它们都是顺理成章的事。

窗口函数的坑虽多,但每一个坑背后,都是对“窗口范围”和“执行顺序”的理解不够深。我踩过的那些雷,写出来也只是帮你少走几步弯路。真正想熟练,还是多拿自己的业务数据试几次,试多了,自然就稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询