简介:《SQL窗口函数速查表》是一份面向数据分析师、数据库管理员及开发人员的实用参考手册,系统梳理了窗口函数的基本概念、三大类型(排名、行序号、聚合函数)及完整语法结构,帮助读者在处理复杂SQL查询时快速上手。资源包内含1个PDF文件,压缩包大小841KB,体量轻巧,便于离线查阅和打印。速查表采用分类表格与示例相结合的方式,文档按排名、行序号、聚合等类别清晰分类,并配有ROW_NUMBER()、RANK()、DENSE_RANK()、LEAD()、LAG()及SUM、AVG等常用函数的示例代码和结果说明,可直观理解每个函数的分组、排序与窗口范围定义方式。无论用于数据库运维、数据探索,还是开发报表功能和学术教学,这份速查表都能显著提升窗口函数的应用效率,是系统学习与日常速查两相宜的实用工具。目前已有255人学习使用。
1. 最被低估的SQL能力:窗口函数不是聚合,是“不折叠明细的聚合”
刚接触窗口函数时,我最大的误区是把它当成聚合函数的语法糖。直到在某个电商订单库里写分组TopN,用子查询加自连接折腾了大半天,最后被一条ROW_NUMBER() OVER (PARTITION BY ...)当场解决,我才意识到窗口函数的真正价值:它在计算完聚合值之后,并不会把多行压成一行,而是把计算结果原样贴回每一行。这意味着原始数据一条不丢,分析逻辑却往前跨了一大步。这份《SQL窗口函数速查表.pdf》就是围绕这个核心差异展开的,把排名、偏移、聚合三类窗口函数和OVER语法收进同一份速查里。数据分析师、数仓开发、后端写SQL的人都能用,新手对照示例做一遍,熟手用来确认默认值和边界行为也省事。
2. 先看懂OVER子句:窗口函数的底层逻辑与语法结构
窗口函数的名字五花八门,ROW_NUMBER、RANK、SUM、LEAD各管一摊,但它们的共同骨架只有一个:函数名后面那个OVER()。速查表的第一部分就把这个核心语法单拎出来讲,这也是我觉得整份PDF最值钱的部分——函数名记不住可以现查,OVER的语义看不懂,写出来的SQL基本靠猜。
2.1 窗口函数与传统聚合的本质差异:为什么GROUP BY做不到这件事
普通聚合函数的行为是“多行进,一行出”。比如按部门统计工资总额,GROUP BY dept_id会把每个部门折叠成一条记录,部门里的员工明细全部消失。窗口函数同样是算总数,但写法变成SUM(salary) OVER (PARTITION BY dept_id),结果是每一行员工数据都还在,旁边多出一列部门总额。
SELECT emp_id, emp_name, dept_id, salary, SUM(salary) OVER (PARTITION BY dept_id) AS dept_total FROM employee ORDER BY dept_id, emp_id;这段SQL我经常拿来给刚入门的同事讲窗口函数。SUM(salary) OVER (PARTITION BY dept_id)先按部门分组,再在每组内做求和,最后把求和结果附加到组内每一行。注意观察执行结果:dept_total在同一个部门的所有员工行上是重复出现的值,这正是窗口函数的标志性特征。如果你把PARTITION BY dept_id去掉,写成SUM(salary) OVER (),那整个结果集会被当成一个大窗口,所有员工行上都会显示全公司的工资总额。
这个差异在实际报表里非常有用。比如要同时展示员工个人工资和他所属部门的平均工资,传统做法是两条SQL查完再关联,窗口函数一条SQL就带出来了。速查表里把这类聚合函数单独归成一类,其底层逻辑就是“在窗口内做聚合,但保留原始行粒度”。
2.2 OVER三件套:PARTITION BY、ORDER BY和frame的职责分工
所有窗口函数的窗口定义都由三部分组成:分区、排序、窗口范围(frame)。速查表给出的语法结构可以浓缩成下面这个模板:
function_name(expression) OVER ( [PARTITION BY col1, col2] [ORDER BY col3 [ASC | DESC]] [ROWS | RANGE BETWEEN frame_start AND frame_end] )三个可选部分的作用差异很大,我按理解顺序拆开说。
PARTITION BY负责横向切分,把整个结果集按指定列的值分成若干独立小组,各组之间互不干扰。它和GROUP BY的分组概念很像,但不做折叠。
ORDER BY负责组内排序。注意它只决定窗口内行的先后顺序,不会改变查询结果的最终输出顺序,除非你在外层再加一个ORDER BY。这是很多新手第一个翻车点:以为写了ORDER BY结果集就该排好序,实际上结果集的排序由最外层的ORDER BY决定。
frame是整个OVER子句里最容易被忽略的部分。它的作用是在已经排好序的组内,再划一条“计算边界”,决定参与计算的行到底是哪些。下面这张表是速查表里frame部分的常见边界写法:
| 边界写法 | 含义 |
|---|---|
| UNBOUNDED PRECEDING | 从分区第一行开始 |
| n PRECEDING | 从当前行往前数n行开始 |
| CURRENT ROW | 从当前行开始 |
| n FOLLOWING | 到当前行往后数n行结束 |
| UNBOUNDED FOLLOWING | 到分区最后一行结束 |
这里有个必须背下来的默认行为:写了ORDER BY没写frame时,默认是RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,即从分区起点到当前行的所有行;没写ORDER BY时,frame默认是整个分区。这两种默认值分别对应“累计值”和“组内总值”两种结果,很多线上SQL算出来的数不对,根源就在这里。
2.3 一个能直接抄的完整示例:四个窗口函数同屏对比
把以上概念放进同一个查询里,效果比分开讲直观得多。下面这个订单分析场景,我在好几个项目里都用过类似写法:
SELECT order_id, customer_id, order_date, order_amount, -- 每个客户的所有订单金额合计 SUM(order_amount) OVER (PARTITION BY customer_id) AS customer_total, -- 单笔订单金额占该客户总金额的比例 ROUND(order_amount * 1.0 / SUM(order_amount) OVER (PARTITION BY customer_id), 4) AS pct_of_customer, -- 按订单日期累加的客户累计金额 SUM(order_amount) OVER ( PARTITION BY customer_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_amount FROM orders ORDER BY customer_id, order_date;逐列看这个查询:customer_total用的就是2.1节里的分组窗口,每个客户的订单行都带着自己的总额;pct_of_customer是单笔金额除以客户总金额,算比例时我把order_amount乘以1.0,这是为了避免整数除法直接截断成0;cumulative_amount才是体现frame威力的地方——ORDER BY order_date之后,frame从分区起点到当前行,每一行算的都是“截至目前”的累计值。
如果你从速查表翻到这一页,会发现PDF里的示例和我这个基本同一套路,但顺序更细致:先演示无排序的组内聚合,再演示带排序的累计聚合。建议你拿到手后不要跳着读,按它的分类顺序跑一遍,大于等于5个函数跑完,OVER子句的直觉就建立起来了。
3. 排名与偏移函数实战:ROW_NUMBER、RANK、DENSE_RANK、LEAD、LAG
速查表把排名函数和行序号函数分成两类,但实际业务里它们经常出现在同一个查询里。区别在于:排名函数解决“这行排第几”,偏移函数解决“旁边那行是什么”。两套函数单独看不难,放一起用才是报表里的常态。
3.1 三个排名函数:并列、跳号与连续,业务含义完全不同
很多人以为RANK和DENSE_RANK只是同一个功能的两种写法,这是大错特错。用一个成绩表就能说清楚三者的差异:
SELECT student_name, score, ROW_NUMBER() OVER (ORDER BY score DESC) AS row_num, RANK() OVER (ORDER BY score DESC) AS rank_no, DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank_no FROM exam_score;假设有三名同学同分,成绩按降序排列后,ROW_NUMBER()会给它们强行分配1、2、3,互不重复;RANK()会让它们并列第1,下一个不同分数的同学直接排到第4名,数字上跳号;DENSE_RANK()同样让同分并列第1,但下一个不同分数的同学排第2名,不跳号。三种逻辑对应三种业务口径:
| 函数 | 并列时 | 排名是否跳号 | 典型业务场景 |
|---|---|---|---|
| ROW_NUMBER | 不允许并列 | 不适用 | 取前N条、分页去重 |
| RANK | 允许并列 | 跳号 | 竞赛排名,强调“后面还有多少人” |
| DENSE_RANK | 允许并列 | 不跳号 | 等级划分,如按分数段定级 |
速查表里每条函数下面都标注了返回规则,我建议你精读这一块。它还把容易混淆的ROW_NUMBER()和RANK()放在相邻位置,就是为了让你一眼看出差异:前者每行唯一,后者允许并列并跳过后续排名。
这里补一个边界行为:排序键为NULL时,三个函数的处理方式在各个数据库里并不一致。有的数据库把NULL当最大值排在最前,有的排到最后,还有的允许你通过NULLS LAST等子句手动指定。速查表引用了一些跨数据库兼容性说明,我在生产环境里都会显式声明NULL的排序方向,否则同一个SQL在两个引擎上跑出完全不同的排名,排查起来非常头疼。
3.2 LEAD与LAG:取前后行数据,计算同比环比的核心工具
LEAD和LAG是一对相反方向的偏移函数。LAG取当前行之前的第n行,LEAD取当前行之后的第n行,它们最常见的用途是算环比、同比、时间间隔这类“跟相邻记录比大小”的场景。
下面是用员工调薪记录计算每次调薪幅度的标准写法:
SELECT emp_id, salary_date, salary, LAG(salary) OVER (PARTITION BY emp_id ORDER BY salary_date) AS prev_salary, ROUND( (salary - LAG(salary) OVER (PARTITION BY emp_id ORDER BY salary_date)) * 100.0 / LAG(salary) OVER (PARTITION BY emp_id ORDER BY salary_date), 2 ) AS pct_change FROM salary_history ORDER BY emp_id, salary_date;LAG(salary)只写了一个参数,默认取上一行;PARTITION BY emp_id保证每个员工单独计算,不然不同员工的调薪记录会串行;ORDER BY salary_date决定“上一行”的方向是时间上的前一条记录。pct_change就是本条薪资相对上一条的变化率。
这两个函数的完整参数形态是LAG(expr, offset, default)和LEAD(expr, offset, default)。第二个参数offset控制偏移行数,不写默认1;第三个参数default表示越界时返回的值,比如第一行没有“上一行”,默认返回NULL。速查表把这几个参数列得特别清楚,我一开始记不住default参数的位置,全靠这张表翻。还有个冷门技巧:offset参数可以写负数,LAG(x, -1)等价于LEAD(x, 1),但可读性差很多,一般不建议这么写。
如果想算同比,比如电商订单里这个月和上个月同一天的销售额,就把它和前12个月的记录比:LAG(amount, 12) OVER (PARTITION BY product_id ORDER BY month)。这种情况下offset不再是一行一行数,而是按排序键的偏移量,前提是你的数据里每个月都有一条记录,否则要结合数据补全一起做。
3.3 拿到速查表后,怎样快速定位一个函数
速查表的结构我是这么用的:先明确业务要干什么,再去对应分类里找函数名,最后抄一个示例代码验证。完整的查找路径可以拆成三步。
第一步,看函数分类。要判断某行在整个结果集里的位次,去“排名函数”找;要看前后行的值,去“行序号函数”找;要做累计求和、移动平均,去“聚合函数”找。第二步,核对函数签名。速查表里每个函数都给了参数列表和返回规则,重点看有没有default参数、是否允许省略、排序方向怎么写。第三步,拿示例代码跑一遍。PDF里每个函数都附了输入输出对照,我会在本地数据库里把示例原样执行一次,再把表名换成自己的业务表,验证结果是否符合预期。
这套流程看着简单,真正坚持做的人不多。很多人下载速查表之后直接塞进收藏夹,等用到时才翻,结果每次查到的函数都记不牢。我的习惯是每隔一两周挑一个不常用的函数,比如FIRST_VALUE或NTILE,找业务数据试一下,试完就记住了。
4. 窗口聚合与移动计算:SUM、AVG、COUNT配合frame的完整用法
排完名、取完相邻值,窗口函数最重的应用场景其实是聚合计算。SUM、AVG、COUNT这些函数加不加OVER,表现完全是两回事。速查表的聚合函数部分,核心就是在讲frame子句怎么控制计算范围。
4.1 累计求和与移动平均:frame边界决定计算范围
先看一个交易流水表的例子,同时算累计金额和3日移动平均:
SELECT trade_date, amount, SUM(amount) OVER ( ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_amount, AVG(amount) OVER ( ORDER BY trade_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS moving_avg_3 FROM daily_trade ORDER BY trade_date;cumulative_amount这一列的含义是从第一天的交易额一直累加到当前行,因为frame起点是UNBOUNDED PRECEDING,终点是CURRENT ROW,每天一行数据。移动平均则用了ROWS BETWEEN 2 PRECEDING AND CURRENT ROW,意思是只取当前行往前数两行,一共三行做平均,这就是“3日移动平均”的实现。
frame边界的选择完全取决于业务口径。你要做的是累计值,起点就必须是UNBOUNDED PRECEDING;要做固定窗口的滑动平均值,起点就是n PRECEDING。速查表里把五种边界关键字全部列出来,并用小图标注了它们指向的位置,这张小图对新手来说比任何文字说明都直观。另外注意,frame的终点也完全可以超出当前行,比如用ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING算“从今天到未来的总和”,这在计算剩余额度时会用到。
我一般会建议团队把frame部分显式写全,不要依赖默认值。写过SUM(x) OVER (ORDER BY y)却忘了加frame的人,迟早会被默认的RANGE行为坑一次,具体坑法下面单独说。
4.2 ROWS与RANGE的区别:排序键重复时结果完全不同
这是整个窗口函数里最隐蔽的坑之一,也是速查表花了不少篇幅解释的内容。ROWS和RANGE都用来定义frame边界,但语义完全不同:ROWS严格按行号取数据,说2 PRECEDING就是物理上的前两行;RANGE按排序键的值取数据,排序键相同的行会被当成“同一行”一起纳入。
比如订单日期不是唯一的,同一天有好几笔订单。用SUM(amount) OVER (ORDER BY order_date RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)算累计值时,凡是order_date相同的行,frame都会把它们全部包含进来,每一行上算出的累计值都相同。而用ROWS版本时,每一行严格往前数行,每行的累计值都因为包含的行数不同而产生差异。
| 场景 | ROWS行为 | RANGE行为 |
|---|---|---|
| 排序键有重复 | 严格按物理行数取 | 把所有相同排序键的行一起纳入 |
| 排序键唯一 | 两者结果一致 | 两者结果一致 |
| 典型用途 | 移动平均、固定窗口 | 按日期累计、按值区间计算 |
判断自己该用哪个,只需要问一个问题:我要计算的范围是基于“物理行数”还是“排序键数值”?求3日移动平均,显然要固定取3行,用ROWS;按日期算累计值,想把同一天的数据视作一个整体,用RANGE也可以,但必须先确认自己知道这个语义。速查表的示例里两种写法都给了,我建议把两个结果并排执行一次,亲眼看一下差在哪,比背十遍理论都管用。
4.3 高频组合场景:占比、累计、连续登录的直接模板
窗口聚合在实际分析里用得最多的三个模板,速查表里都覆盖了,我在这里把它们的写法固定下来,拿来即用。
计算每个分类下的销售额占该分类总额的比例,以及截止到当前的累计占比:
SELECT category, order_date, sales_amount, SUM(sales_amount) OVER (PARTITION BY category) AS category_total, SUM(sales_amount) OVER ( PARTITION BY category ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_sales, ROUND( SUM(sales_amount) OVER ( PARTITION BY category ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) * 100.0 / SUM(sales_amount) OVER (PARTITION BY category), 2 ) AS cumulative_pct FROM sales;另一个高频场景是连续登录天数。思路是先用ROW_NUMBER()给每个用户按登录日期编号,再用“登录日期减编号”的差值分组——连续登录的日子差值相等,然后按差值计数:
WITH login_grouped AS ( SELECT user_id, login_date, DATE_SUB(login_date, INTERVAL ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY login_date ) DAY) AS group_id FROM user_login ) SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS continuous_days FROM login_grouped GROUP BY user_id, group_id;这两个模板在很多分析报表里都能直接改表名复用。速查表里每个函数单独看都不难,难的是像这样把它们组合起来解决真实问题。我的经验是:先照着模板跑通,再逐段删减、修改参数,观察结果变化,很快就能理解每段代码的职责边界。
5. 窗口函数五个高频坑:从报错到结果翻车
窗口函数写起来快,排错可不容易。下面五条是我在实际项目中撞过、也在给团队做代码评审时反复见到的坑。速查表虽然不会直接告诉你怎么排查,但理解这些坑之后,再回头读速查表的语法说明,会有一种“原来那段警告是说这个”的豁然感。
5.1 坑一:WHERE子句不能直接过滤窗口函数的结果
现象:写SELECT ..., ROW_NUMBER() OVER (...) AS rn FROM t WHERE rn = 1,数据库直接报错,说窗口函数不允许出现在WHERE里。
原因:SQL的执行顺序里,WHERE在SELECT的表达式计算之前执行。窗口函数是在SELECT阶段计算的,等它算出rn,WHERE早执行完了,逻辑上就矛盾。
解决:用子查询或CTE包一层,先算窗口函数,再在外面过滤。
WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM employee ) SELECT * FROM ranked WHERE rn = 1;这是取“每个部门工资最高员工”的经典写法,把窗口函数放进CTE,外层再用普通条件过滤。从那以后我凡是看到窗口函数外面直接套WHERE的代码,第一反应就是先给提问的人画执行顺序图。
5.2 坑二:RANK跳号被业务方当成计算错误
现象:报表展示排名时,两个并列第1名后面直接是第3名,业务方一口咬定程序算错了,少了一个第2名。
原因:RANK()的语义就是跳号。两个并列第1占用1和2两个序号,所以下一个排名是3,这是标准行为,不是bug。
解决:先和业务方确认排名口径。如果强制要求排名连续,把RANK()换成DENSE_RANK(),它能输出1、1、2而不跳号。速查表里两个函数放在一起对比,就是为了让人选之前先想清楚。
5.3 坑三:忘了写ORDER BY,累计求和变成了组内求和
现象:想算“每个客户按时间累计的订单金额”,写了SUM(amount) OVER (PARTITION BY customer_id),结果每一行的累计值都一样,等于该客户全部订单总额。
原因:frame的默认范围与有没有ORDER BY强相关。没写ORDER BY时,窗口默认是整个分区;写了才能触发从分区起点到当前行的累计逻辑。
解决:算累计值时,写法必须是SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date),并且在排序键可能重复时加上显式的ROWS边界。速查表的语法部分明确标注了这个默认行为,我把它当成必背条目。
5.4 坑四:移动平均结果和Excel手工核对不一致
现象:用AVG算3日移动平均,拿Excel手工拉了一遍,发现两边对不上,差在日期相同的记录上。
原因:AVG(amount) OVER (ORDER BY trade_date)没写ROWS时用的是默认RANGE frame,日期相同的数据会被视为同一行一起纳入计算,导致一天多条订单时窗口里的行数超过了3行。
解决:移动平均这种对物理行数有严格要求的场景,必须显式写ROWS BETWEEN 2 PRECEDING AND CURRENT ROW,明确告诉数据库“按行取,不看值”。
5.5 坑五:COUNT(列)与COUNT(*)在窗口内对NULL的处理不同
现象:同一张表,COUNT(*) OVER (PARTITION BY dept_id)返回10,COUNT(remark) OVER (PARTITION BY dept_id)返回8,少了2条。
原因:COUNT(列)只统计该列非NULL的行,COUNT(*)统计所有行。窗口函数里的行为与普通聚合完全一致,remark列里恰好有2条NULL。
解决:统计行数时确认业务口径。想数“有多少条记录”,一律用COUNT(*);想数“有多少个非空值”,再用COUNT(列)。速查表在聚合函数说明里专门标注了这个问题,我最初没当回事,直到线上数对不上才后悔没早看。
6. 进阶用法:用WINDOW子句和CTE把窗口查询写成能维护的分析模板
窗口函数用多了就会发现,一段SQL里可能同时挂着四五个OVER,每个后面跟着相同的PARTITION BY和ORDER BY,改一个分区字段就得全文搜着改,容易漏。速查表没展开讲的WINDOW子句,恰好是解决这个问题的利器。
6.1 WINDOW子句:一个窗口定义到处复用
SELECT dept_id, emp_id, salary, SUM(salary) OVER w AS dept_total, AVG(salary) OVER w AS dept_avg, MAX(salary) OVER w AS dept_max FROM employee WINDOW w AS (PARTITION BY dept_id) ORDER BY dept_id, emp_id;WINDOW w AS (PARTITION BY dept_id)定义了一个名为w的窗口,后面三个OVER子句都引用它。这样定义一次、多处使用的写法,比把PARTITION BY dept_id复制三遍清爽得多,改分区字段时只需要动WINDOW子句那一行。如果某个窗口还需要排序,就在定义里加上ORDER BY,但要注意:加了ORDER BY之后,所有引用该窗口的聚合函数的frame都会变成默认的RANGE累计模式,这时候要根据需要决定是否单独覆盖。
6.2 窗口函数与CTE组合:把复杂分析拆成可读步骤
CTE加窗口函数是我写分析SQL最顺手的组合,前面的连续登录模板已经演示过一例。核心思路是:一个CTE只做一件事,窗口函数在其中计算辅助列,外层查询负责过滤或二次加工。
取全公司每个部门工资排名前两名的员工,用两个CTE串联:
WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM employee ), top2 AS ( SELECT * FROM ranked WHERE rn <= 2 ) SELECT * FROM top2;每个CTE职责单一,排错时只需单独执行ranked那一段看结果,问题定位很快。我在做报表需求时基本都会把复杂逻辑拆成几步,比写几百行的单条SQL省心太多。
说到排错,我吃过一次亏:在某数据校验流程里,用连续登录模板统计用户活跃天数,有同事反馈结果比预期少了好几天,排查到最后发现是登录日期字段里混入了NULL,而COUNT(login_date)直接把NULL跳过去了。从那以后,我每次写完窗口函数都强制走一遍固定检查:单条SQL在3行以内的模拟数据上跑一遍,逐个核对输出值;凡是涉及COUNT的,先确认清楚要不要计NULL;涉及frame的,确认ROWS还是RANGE,每一步都拿速查表过一遍。这套流程看起来笨,但基本没再翻过车。希望这份速查表和学习路径,也能帮你少踩几个我已经踩过的坑。
本文还有配套的精品资源,点击获取