1. 初识窗口函数:它到底解决了什么问题
先聊一个我在实际工作中反复遇到的场景。某天运营同事甩过来一张表,让我按部门给员工算工资排名。平时我们习惯用GROUP BY分组聚合,但分组之后每个部门只保留一行汇总数据,原始的行明细全被压缩掉了。可需求方偏偏要的是:既能看到每一行的完整信息,又能在旁边多出一列“这个人在部门内排第几”。
用传统 SQL 写,要么自联结、要么写相关子查询。能跑,但 SQL 写得又臭又长,数据量一大性能还扛不住。那时候我就在想,能不能有一种方式,既保留每一行的原始数据,又能在行与行的“邻域”里做计算?
窗口函数(Window Function)就是为这种场景准备的。它在 SQL 标准里属于 OLAP 函数那一类,核心思想非常直白:在“不合并行”的前提下,对每一行周围的若干行做聚合、排序、偏移等计算。很多人第一次听到“窗口”这个词觉得玄乎,其实你把它理解成一个“滑动取景框”就行——每一行都有一扇自己的窗户,窗户里装着它所在的那个分组,函数在当前这扇窗内做运算,结果直接写到这一行的旁边。
这套东西最早是给数据分析场景准备的,比如财务要算累计销售额、HR 要看工龄分段统计、运营要做用户行为漏斗。但后来 OLTP 系统里的复杂报表、分页排名、同环比对比也大量使用窗口函数。到了现在,主流的关系型数据库(包括常用开源库和商业数据库)都已经原生支持窗口函数,甚至一些大数据查询引擎也把它作为标配能力。
这篇文章我打算从最实用的角度切入,不讲太多教科书式的语法陈列,而是把窗口函数的执行逻辑、常见场景、踩过的坑一次说清楚。适合三类人看:刚接触 SQL 不久、被各种OVER语法绕晕的新手;已经会用ROW_NUMBER()但说不清其中原理的日常使用者;以及写复杂报表时总觉得代码不优雅、想提升查询性能和可读性的开发者。
2. 核心语法与执行逻辑拆解
2.1 窗口函数的“三件套”:窗口函数、OVER、窗口定义
几乎所有的窗口函数长这样:
窗口函数名(...) OVER ( PARTITION BY 分组列 ORDER BY 排序列 ROWS / RANGE 窗口范围 )OVER是灵魂,它负责告诉你“从哪一行到哪一行算一个窗口”。PARTITION BY负责分区,类似GROUP BY但不会压缩行;ORDER BY负责决定窗口内行与行的顺序;第三个部分(窗口框架)负责精确框定当前行参与计算的范围。这三个部分可以自由组合使用,并不是每次都写全。
我习惯用一个生活化的例子来理解:把一张表想象成全校学生的成绩册。PARTITION BY 班级就相当于按班级把成绩册拆成一摞一摞的;ORDER BY 分数是每一摞内部按分数从高到低排好;ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW则是说“我看成绩的时候,只从这摞最上面看到当前这位同学为止”。累积求和、累积计数这些需求,靠这个框架就能实现。
还有一个关键认知:窗口函数是在WHERE、GROUP BY、HAVING这些过滤和聚合都执行完之后才运行的。这意味着你不能在WHERE里直接写ROW_NUMBER()的别名做过滤——因为它执行得太晚了。很多新手在这里栽过跟头,后面我会专门讲怎么处理。
2.2 执行顺序的“暗坑”:为什么不能直接过滤窗口结果
举一个我真实遇到的例子。有一张订单表,我想找出每个用户最近一单的金额。很多人的第一反应是:
SELECT 用户ID, 订单金额, ROW_NUMBER() OVER (PARTITION BY 用户ID ORDER BY 下单时间 DESC) AS rn FROM 订单表 WHERE rn = 1;这个 SQL 一执行就会报错,或者在某些数据库里直接忽略掉这个条件。原因就是执行顺序:WHERE在窗口函数之前执行。等窗口函数算完rn的时候,数据已经过滤过了,你只能在窗口外面再包一层子查询,比如:
SELECT 用户ID, 订单金额 FROM ( SELECT 用户ID, 订单金额, ROW_NUMBER() OVER (PARTITION BY 用户ID ORDER BY 下单时间 DESC) AS rn FROM 订单表 ) t WHERE rn = 1;这种“先算窗口,再在外面过滤”的模式,在分组取 Top N、去重保留最新记录等场景里几乎是万能钥匙。理解了执行顺序,就不会觉得这层子查询是多余的——它是逻辑上的必然。
2.3 窗口范围 ROWS 与 RANGE 的细微差别
这是最容易混淆的一对概念。ROWS是物理行号的概念,比如ROWS BETWEEN 2 PRECEDING AND CURRENT ROW就是“从当前行往上数两行,到当前行结束”。它是硬性的行数约束,不管这些行的值长什么样。
RANGE则是值域的概念,比如RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW就是在时间维度上取“最近7天”的数据。它不关心具体有几行,只关心值是否落在范围内。用一个简单的类比:ROWS像是抓阄,抓几张就是几张;RANGE像是按成绩划线,过线的不管几个人都算。
如果只写ORDER BY不写窗口范围,默认使用的是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(从分区开头到当前行)。注意,这里不是物理上的行,而是按排序列的值框定的范围。所以当你写SUM(金额) OVER (ORDER BY 日期)时,算的是截至当前日期的累计金额,而不是“当前行和上一行的和”。这个默认行为既是很多累计需求的方便之处,也是不少人计算结果不对劲的根源。
3. 常用窗口函数逐一解剖
3.1 排名三兄弟:ROW_NUMBER、RANK、DENSE_RANK
这三个函数几乎出现在每一个窗口函数的入门教程里,但很多人只是背了结论,真到自己写的时候就不知道该用哪个。
ROW_NUMBER()就是按顺序给每一行发一个不重复的序号,哪怕两行分数完全相同,序号也不一样。它适合用来做“物理行号”的场景,比如分页取数、去重保留第一行。
RANK()则是标准竞赛排名:分数相同的人排名并列,但下一个名次会跳过。比如考了 100、100、90,排名分别是 1、1、3。DENSE_RANK()也是并列排名,但不跳号,排名是 1、1、2。
我建议用一个最简单的记忆法:ROW_NUMBER像排队拿号,先到先得,号码不重复;RANK像比赛排行榜,并列的人占了多个名次;DENSE_RANK像压缩过的排行榜,并列的人不占额外坑位。实际工作中,如果只是“取前三名”,我一般用RANK,因为它更符合业务直觉;如果要做“按顺序编号”的流水号,就用ROW_NUMBER。
3.2 聚合窗口函数:SUM、AVG、COUNT 也可以 OVER
聚合函数加上OVER,就不再是“把一组数据合并成一行”了,而是“把聚合结果写到每一行旁边”。我最常用的场景是计算累计值。比如统计每天累计销售额:
SELECT 日期, 销售额, SUM(销售额) OVER (ORDER BY 日期 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS 累计销售额 FROM 日销售表;这段 SQL 的每一行都会带上“从第一天到当天”的累计金额。注意我在这里显式写了ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,如果用默认的RANGE,在日期字段存在重复值的时候,累计范围可能瞬间变成“同一天的所有行”,结果往往不是你想要的。这是我在实际报表里踩过最隐蔽的坑之一。
另一个典型的用途是计算“移动平均”,比如最近 3 天的平均销售额。只要把窗口范围改成ROWS BETWEEN 2 PRECEDING AND CURRENT ROW即可。它天然比“手写自连接 + 分组”简洁得多,最重要是性能通常更好——一次扫描完成,不需要反复访问同一张表。
3.3 偏移函数:LAG 与 LEAD,让同环比不再痛苦
LAG 和 LEAD 的作用是“把上一行或下一行的某个字段值搬到当前行”。比如我想算每日销售额的日环比,传统写法需要自连接,还要处理边界值,麻烦得很。用LAG几行就搞定:
SELECT 日期, 销售额, LAG(销售额, 1) OVER (ORDER BY 日期) AS 前一日销售额, 销售额 - LAG(销售额, 1) OVER (ORDER BY 日期) AS 环比变化 FROM 日销售表;LAG(销售额, 1)的意思是“取按日期排序后,当前行之前第 1 行的销售额”。如果当前行是第一行,没有前一天,结果就是NULL。这里有一个很多人忽略的三参数版本:LAG(销售额, 1, 0),第三个参数表示“当没有前置行时,默认填充值”。把它设为 0 或者某种业务默认值,可以避免后面计算时被NULL传染。
LEAD 则是相反方向,取“后一行”的值,适合用来做“当前行与下一条记录的对比”,比如计算两条记录之间的时间间隔。
3.4 其他值得一提的函数:FIRST_VALUE、LAST_VALUE、NTILE
FIRST_VALUE和LAST_VALUE返回窗口内第一个或最后一个值。比如我想知道每个部门工资最高的人是谁,就可以用FIRST_VALUE(员工姓名) OVER (PARTITION BY 部门 ORDER BY 工资 DESC)。不过要小心LAST_VALUE的默认窗口范围只到当前行,想真正取到分区末尾的值,必须显式定义窗口范围到UNBOUNDED FOLLOWING。
NTILE(n)则是把每个分区均匀切成 n 段,返回每行属于第几段。它常用来做“四分位分析”、“用户分层”。比如把用户按消费额分成 5 档,直接NTILE(5) OVER (ORDER BY 消费额),结果 1 就是最高档,5 就是最低档。这个函数特别适合初步探查数据的分布形态,比手动写CASE WHEN加百分位判断舒服多了。
4. 实操场景:三步学会用窗口函数解决问题
4.1 场景一:分组 Top N,每个分类取前几条
业务上非常常见的需求:每个部门工资最高的三个人、每个商品类目销量前五的商品、每个用户最近三笔订单。统一解法就是先打序号,再在外面过滤。
假设有一张员工表emp(emp_id, dept_name, salary),我想取每个部门工资前三的员工,完整 SQL 可以这样写:
SELECT dept_name, emp_id, salary FROM ( SELECT dept_name, emp_id, salary, RANK() OVER (PARTITION BY dept_name ORDER BY salary DESC) AS rk FROM emp ) t WHERE rk <= 3 ORDER BY dept_name, rk;这里选RANK()而不是ROW_NUMBER(),是因为如果第三名有并列,业务上通常希望把并列的人都展示出来。如果业务明确说“只取 3 条,并列随便选”,那用ROW_NUMBER()更合适。注意这个选择背后的业务含义,选错了,结果集的行数可能就差很多。
4.2 场景二:累计/滚动统计,一段窗口搞定
拿电商数据举例,每天记录订单量,我想看“当月累计订单量”。这里有个典型的需求演进:一开始是累计到当天,后来想看“最近 7 天滚动订单量”,再后来想看“去年同期对比”。这些需求在窗口函数里都是调整窗口范围的事。
SELECT 日期, 每日订单量, SUM(每日订单量) OVER (ORDER BY 日期 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS 年初至今累计, SUM(每日订单量) OVER (ORDER BY 日期 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS 最近7天合计 FROM 日订单表;注意ROWS BETWEEN 6 PRECEDING AND CURRENT ROW,算上当前行正好 7 行。很多人在这里把数字搞错,写成BETWEEN 7 PRECEDING,结果变成了 8 天的数据。排查这类问题时,先数一数窗口里的行数是不是符合预期。
4.3 场景三:去重取最新,一个 ROW_NUMBER 轻松拿捏
数据仓库里最常见的脏数据问题:同一业务主键出现多条记录,我想保留最新的一条。虽然ROW_NUMBER加子查询是个老套路,但每次写的时候都有人忘记正确的分区排序方式。
DELETE FROM 订单表 WHERE (订单ID, 更新时间) IN ( SELECT 订单ID, 更新时间 FROM ( SELECT 订单ID, 更新时间, ROW_NUMBER() OVER (PARTITION BY 订单ID ORDER BY 更新时间 DESC) AS rn FROM 订单表 ) t WHERE rn > 1 );这种写法的前提是订单ID + 更新时间能唯一定位一条记录。如果更新时间也有重复,那就需要再加一个唯一字段参与排序。还要注意:如果表特别大,直接DELETE可能会造成锁竞争或性能问题,生产环境通常建议先找出需删除的 ID 列表,再分批删除。
4.4 场景四:同环比计算,LAG 让你不写自连接
以订单表为例,每个月统计销售额,同时想算环比增长率和同比增长率:
SELECT DATE_FORMAT(下单时间, '%Y-%m') AS 月份, SUM(金额) AS 月销售额, LAG(SUM(金额), 1) OVER (ORDER BY DATE_FORMAT(下单时间, '%Y-%m')) AS 上月销售额, LAG(SUM(金额), 12) OVER (ORDER BY DATE_FORMAT(下单时间, '%Y-%m')) AS 去年同期销售额 FROM 订单表 GROUP BY DATE_FORMAT(下单时间, '%Y-%m');注意这里窗口函数作用在GROUP BY之后的结果上,所以LAG拿到的是“上一个月的聚合值”。如果直接用LAG(金额)而不先聚合,得到的是“每一条原始订单的上一行金额”,逻辑完全不对。先分组再窗口,是我在写这类统计 SQL 时特别强调的顺序。
5. 性能调优与常见误区
5.1 别让小数据量误导你的优化判断
窗口函数的性能开销主要来自两部分:排序和分区。ORDER BY在窗口内意味着需要排序,如果数据量特别大,排序的代价会被放大;PARTITION BY也会触发数据重分布。在一些分布式查询引擎里,PARTITION BY的字段如果选择不当,可能会引发大量的数据shuffle,比传统GROUP BY还会慢。
我自己的经验是:先用小数据量把 SQL 逻辑跑通,再在真实数据量下看执行计划。执行计划里如果出现了明显的排序或重分区算子,就需要考虑:排序列上是否有合适的索引?能不能提前用子查询缩小数据范围?窗口函数的PARTITION BY列是否和表的分布键一致?
5.2 窗口函数与 GROUP BY 的共存规则
前面已经提过,窗口函数在GROUP BY之后执行。所以窗口函数里可以直接引用聚合结果,比如:
SELECT 类别, SUM(金额) AS 总金额, SUM(SUM(金额)) OVER (ORDER BY 类别) AS 累计金额 FROM 订单表 GROUP BY 类别;这个SUM(SUM(金额))看起来有点像套娃,实际意思是“先按类别聚合得到每个类的总金额,再对这个总金额做窗口累计”。理解执行顺序之后,这种写法就顺理成章了。窗口内不能用原始行级别的列,因为那些列在分组之后已经不存在了。
5.3 常见误区速查表
| 误区 | 错误写法 | 正确做法 |
|---|---|---|
| WHERE 过滤窗口结果 | WHERE rn = 1 | 用子查询包一层再过滤 |
| OVER 里不写 ORDER BY 就做累计 | SUM(x) OVER (PARTITION BY y) | 明确业务需要的窗口范围和顺序 |
| LAG 不处理 NULL 边界 | 直接减LAG(...) | 使用三参数版本或COALESCE处理 |
| 混淆 ROWS 与 RANGE | 用RANGE做物理行移动平均 | 需要固定行数时用ROWS |
| 忘记 PARTITION BY 导致全局计算 | SUM(x) OVER (ORDER BY y)却没分区 | 确认业务是按全局累计还是分组累计 |
| 排序字段重复时结果不稳定 | ORDER BY 金额无唯一列 | 补时间或 ID 字段让排序稳定 |
5.4 我用过的几条性能心得
一,如果只需要分组内排名,且排序键上有索引,部分数据库能直接利用索引顺序跳过额外排序,但并不是所有数据库都这么智能,所以生产环境一定要看执行计划。
二,能先过滤再窗口就不要先窗口再过滤。把数据量尽量缩小,是性能优化的第一原则。比如可以先在子查询里WHERE 日期 >= '2024-01-01',然后再去算排名。
三,当窗口函数嵌套使用时,内层的窗口结果外层可以直接引用,但每层之间不能跨越执行顺序。有些复杂的“分组内再分组”场景,拆成多个 CTE 写反而更清晰,而且 SQL 优化器往往能自动合并,不用害怕 CTE 影响性能。
6. 实战案例:从需求到 SQL 的完整拆解
6.1 需求描述与建表
假设某公司有一张客户订单表,结构大致如下:
CREATE TABLE orders ( order_id INT, customer_id INT, order_date DATE, amount DECIMAL(10,2) );需求有三条:找出每个客户的最近三笔订单;计算每个客户的累计消费金额;把客户按消费总额分成高、中、低三档。这三个需求单独看都不难,但要一次性整合成一份报表,窗口函数就是最顺手的工具。
6.2 先解决“每个客户最近三笔订单”
这个需求要求的是明细行,不是汇总行,所以自然不能用GROUP BY硬压。用ROW_NUMBER给每个客户内部的订单排号:
SELECT customer_id, order_id, order_date, amount, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC, order_id DESC) AS rn FROM orders排序时加上order_id DESC,是为了让同一天的订单也有一个稳定顺序,避免排序不稳定导致每次结果不一样。
6.3 再来算“累计消费金额”
累计消费通常指“按订单时间顺序,到目前为止的累计值”,写成:
SELECT customer_id, order_id, order_date, amount, SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date, order_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum_amount FROM orders;这里我选ROWS而不是默认的RANGE,原因和前面一样:如果同一个客户同一天有多笔订单,RANGE会把同一天的所有订单视为同一窗口行,可能全部计入同一累计点,结果和逐单累计不一致。加order_id到ORDER BY也是为了让窗口顺序和业务顺序完全一致。
6.4 最后做“高中低三档分档”
客户分层需要先把每个客户的消费总额算出来,再用NTILE分档:
WITH customer_stats AS ( SELECT customer_id, SUM(amount) AS total_amount FROM orders GROUP BY customer_id ) SELECT customer_id, total_amount, NTILE(3) OVER (ORDER BY total_amount DESC) AS tier FROM customer_stats;这里NTILE(3)会把客户按消费额从高到低均匀分成 3 份,档位 1 是最高档。注意NTILE的档位是“数量均分”,不是“金额区间均分”,所以档位与档位之间的金额边界不会整齐。如果业务希望“金额超过 1 万算高,超过 5 千算中”,那就该用CASE WHEN而不是NTILE。分清“数量分桶”和“金额分桶”,是写出符合业务需求 SQL 的关键一步。
6.5 把三个需求合并成一份报表
三个需求考察的是不同维度:明细排名、逐单累计、客户分层。如果硬要合并在一张大宽表里,逻辑会很拧巴。我最后采用的是两个 CTE:一个在订单级别做明细和累计,一个在客户级别做分档,最后用LEFT JOIN关联。这样每段 SQL 的意图都很纯粹,排查问题时也能单段验证。
WITH order_level AS ( SELECT customer_id, order_id, order_date, amount, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC, order_id DESC) AS rn, SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date, order_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum_amount FROM orders ), customer_level AS ( SELECT customer_id, SUM(amount) AS total_amount, NTILE(3) OVER (ORDER BY SUM(amount) DESC) AS tier FROM orders GROUP BY customer_id ) SELECT o.customer_id, o.order_id, o.order_date, o.amount, o.rn, o.cum_amount, c.total_amount, c.tier FROM order_level o LEFT JOIN customer_level c ON o.customer_id = c.customer_id WHERE o.rn <= 3 ORDER BY o.customer_id, o.rn;我尤其想说一下第 11 行:NTILE(3) OVER (ORDER BY SUM(amount) DESC)直接对聚合结果做窗口计算,这是窗口函数和GROUP BY共存时的高级写法。很多人会先写一个中间表再JOIN回来,但其实GROUP BY之后窗口函数可以直接作用在聚合值上,省一层嵌套。
7. 经验总结与最后的实操建议
7.1 别把窗口函数神话,也别低估它的价值
窗口函数不是万能的。数据量极大且需要实时计算的场景,它不一定是最优解;需要多次复杂关联的统计,可能还是要回归传统的GROUP BY加子查询。但在绝大多数报表、分析、数据清洗场景里,窗口函数能把 SQL 从“绕来绕去的自连接”里解放出来,让代码短一半、可读性强一倍。我见过太多同事在GROUP BY和子查询里绕了半天,最后发现一个LAG或ROW_NUMBER就能解决问题的案例。掌握它,绝对是一笔性价比极高的技能投资。
7.2 遇到窗口函数先问自己三个问题
第一,这个计算需要“看到”其他行吗?如果需要看到全局最大、前一行、后一行、分组累计,那就要用窗口函数。第二,计算是分组内做还是全局做?PARTITION BY的字段就是答案的一半。第三,窗口框架的范围是什么?是全局到当前行,还是往前 N 行,还是只有当前行?把这三点想清楚,99% 的窗口函数 SQL 都能一次写对。
7.3 必须养成的调试习惯
我自己的调试套路是:先用SELECT *加上窗口函数,看每一行的计算结果是否符合直觉;再把窗口条件逐步改小,比如先只看一个分区的数据;最后才把子查询、过滤条件全部拼回去。窗口函数和普通 SQL 最大的不同是“你无法从单行结果中验证整体逻辑”,它天然是面向多行的计算。所以,与其看一两行输出就下结论,不如把所有中间列都输出出来,人工检查几条边界数据——尤其是每个分区的第一行和最后一行。它们最容易暴露LAG边界、累计首行、排序并列处理等问题。
7.4 收个尾:一个小技巧能让你的 SQL 更像“正规军”
我最后再分享一个个人特别喜欢的小技巧:在写窗口函数时,每一行字段名都用表别名.字段名的形式,并且OVER子句里的ORDER BY字段一定和最后业务展示顺序保持一致。这样做有两个原因:一是当查询里同时存在子查询和多表关联时,字段归属一目了然,不用翻来覆去找这个字段是哪张表的;二是排序字段一致的话,执行计划里可能复用同一次排序,省一次不必要的开销。
我刚开始接触窗口函数时也走过不少弯路,印象最深的就是执行顺序那关。当时查“最近一笔订单”怎么都取不对,后来明白WHERE过滤在窗口计算之前,才恍然大悟。这个坑我估计不少人都踩过,所以这篇文章我特意把它放在最前面讲。如果你正被窗口函数绕得头疼,我的建议很简单:找一张自己熟悉的业务表,每天写三五个窗口查询,无脑练习ROW_NUMBER、SUM OVER、LAG、NTILE这四个核心函数,一周之后,你再看复杂报表 SQL,会觉得它们其实就那点套路。