1. 为什么说窗口函数能让明细和汇总同时留在结果里
1.1 一段最常见的SQL经历:想到排名第一反应是group by和子查询
我最早开始做数据相关工作时,写SQL几乎全靠 GROUP BY + 子查询硬扛。当时接到的需求很常见:把每个部门里工资最高的员工捞出来。第一版写法大概是先 GROUP BY department_id 算出每个部门 max(salary),再把这个结果和员工表做 JOIN,最后才能把对应的员工姓名带出来。
这种写法能跑通,但代码会膨胀得很厉害。如果你把需求改成“每个部门工资前三名”,就得在 JOIN 里带上排名逻辑,或者用相关子查询逐行判断“比我工资高的人有多少个”。相关子查询写法如下:
sql复制SELECT e1.employee_id, e1.employee_name, e1.department_id, e1.salary
FROM employee e1
WHERE (
SELECT COUNT(*)
FROM employee e2
WHERE e2.department_id = e1.department_id
AND e2.salary > e1.salary
) < 3;
这种写法的缺点是相当明显的:它要对 employee 表的每一行执行一次子查询,数据量稍微上来一点,执行计划就比较难看。而且业务含义藏得很深,读代码的人需要想半天才明白这是在找部门前三。
后来换用 SQL窗口函数,同样需求只需要在 SELECT 里加一列:
sql复制SELECT employee_id, employee_name, department_id, salary,
ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn
FROM employee;
再把结果包一层 WHERE rn <= 3,完事。语句从“子查询嵌套子查询”变成了一条主查询加一个过滤条件。这个体验上的差距,做过报表的人应该都能理解。
1.2 窗口函数与GROUP BY的本质区别:不折叠明细
很多人刚接触窗口函数时的困惑点在于:它和 GROUP BY 有什么区别?为什么 SELECT 里既可以出现原始行字段,又可以出现聚合结果?
GROUP BY 的逻辑是“分组后折叠”,每个分组只会保留一行,明细数据直接消失。比如你按部门求总工资,GROUP BY department_id 之后,部门下面的员工明细就看不到了。如果你想在明细旁边看到每个部门的汇总值,以前的常规做法是用一个子查询先聚合再 JOIN 回来。
窗口函数不一样。它做的是“分组后计算,但是不折叠明细”。每一行原始数据都还在,只是在行旁边多出了聚合结果。比如:
sql复制SELECT employee_id, employee_name, department_id, salary,
SUM(salary) OVER (PARTITION BY department_id) AS dept_total_salary
FROM employee;
这条 SQL 返回的行数和 employee 表原行数完全一致。每个员工行旁边都带上了自己部门的工资总额。你可以直接拿 salary / dept_total_salary 算员工工资占部门总工资的比例,整个操作只需要一个语句,不需要 JOIN 自己,也不需要用临时表中间过渡。
我经常用一句话给新手讲这个区别:GROUP BY 是把很多行压缩成一行,窗口函数是让每行都看见自己所在的组整体长什么样。这个“看见整体”的能力,才是后面各种排名、累计、移动平均、同比环比能够简化写法的根本原因。
1.3 OVER语法中最值得先记住的三大要素
窗口函数的骨架是 OVER(),里面可以放三个东西:PARTITION BY、ORDER BY、滑窗范围。理解它们的分工,比死记函数名重要。
PARTITION BY 负责把数据切分成若干分区,作用上可以理解为“分组”,但是不合并行。不写 PARTITION BY,就是整张表作为一个分区处理。
ORDER BY 决定窗口内数据的排序。注意它和普通 ORDER BY 有一个很大的差异:窗口里的 ORDER BY 不只是控制输出顺序,还会影响窗口的计算范围。
滑窗范围是很多人忽略的部分。当你在窗口函数里写了 ORDER BY,但没有指定窗口边界时,数据库默认的行为是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,也就是从分区起点到当前行。这种默认边界在聚合类窗口函数里特别容易引起误解,后面我会专门展开讲。
排在最前面的典型窗口函数大概分三类:排名函数(ROW_NUMBER、RANK、DENSE_RANK、NTILE)、聚合函数加 OVER(SUM、AVG、COUNT、MIN、MAX 的窗口用法)、偏移函数(LAG、LEAD)。下面几章我拆开讲,每一类都配上实际业务场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排名类窗口函数实战:ROW_NUMBER、RANK、DENSE_RANK、NTILE应该怎么选
2.1 四类排名函数的实际差异,比想象中更重要
先看结果差异。假设有一张成绩表,里面的分数为 98、95、95、90、88,如果用不同的排名函数,结果分别如下:
| 分数 | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 98 | 1 | 1 | 1 |
| 95 | 2 | 2 | 2 |
| 95 | 3 | 2 | 2 |
| 90 | 4 | 4 | 3 |
| 88 | 5 | 5 | 4 |
三者的核心区别在于处理“并列值”的方式:
- ROW_NUMBER:不管有没有并列,都强行给出行号。两条 95 分,一条编号 2,一条编号 3,不存在并列概念。
- RANK:遇到并列值时给出相同名次,但会跳过后续编号。95 分并列第 2,下一条 90 分直接跳到第 4。
- DENSE_RANK:遇到并列值时给出相同名次,且不跳过后续编号。95 分并列第 2,90 分是第 3。
我遇到很多实际需求,问题就出在选错函数。比如需求是“每个部门工资前三名,并列也要保留”,这时候选 ROW_NUMBER 会把并列的第三名挤掉一个,应该用 DENSE_RANK。如果需求是“每一行都要有唯一编号”,比如给明细行加序号,那就只能用 ROW_NUMBER。
所以写 SQL 之前不要急着套函数,先问自己一个问题:需求里允不允许并列?如果允许,要不要留空位?这两个问题的答案决定你到底用哪个。
2.2 分组取TopN的开窗写法
分组 TopN 是排名窗口函数最经典的应用。以员工表为例,每个部门取工资最高的 3 个人:
sql复制WITH dept_rank AS (
SELECT employee_id, employee_name, department_id, salary,
DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS dr
FROM employee
)
SELECT *
FROM dept_rank
WHERE dr <= 3;
注意一点:窗口函数产生的列不能在 WHERE 里直接过滤。因为 SQL 的执行顺序是 FROM -> WHERE -> GROUP BY -> HAVING -> 窗口函数 -> SELECT -> ORDER BY,WHERE 执行时窗口结果还没算出来。所以需要先用子查询或者 CTE 包一层,再在外面过滤。
这个 CTE 包一层的写法几乎成了我的肌肉记忆。遇到所有“取前 N 名”的需求,都是先开窗给一个 rn 列,再在外面 where rn <= N。不要试图在同一个 WHERE 里面直接写 ROW_NUMBER() OVER(...) <= 3,很多数据库根本不支持,而且语义上也说不通。
2.3 删除重复数据场景里的ROW_NUMBER
还有一类高频需求和数据清洗有关。业务表里因为重复导入、缺少唯一约束等原因,产生了大量重复记录,需要按某个业务键去重并保留一条。
最稳妥的思路是:按业务键分区,按保留优先级排序,给每组行编号,然后删除编号大于 1 的行。比如保留每笔订单里 id 最小的一条:
sql复制WITH t AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY order_no, product_id
ORDER BY id
) AS rn
FROM order_detail
)
DELETE FROM order_detail
WHERE id IN (SELECT id FROM t WHERE rn > 1);
这个场景只能用 ROW_NUMBER,不能用 RANK 或 DENSE_RANK。因为去重只需要“唯一编号”,不需要它处理并列,一旦有并列反而会删多删错。这个细节值得写进自己的避坑清单里。
2.4 NTILE分桶:把结果切成N份做分层
NTILE(N) 的意思是把分区内的数据按顺序尽量平均地分成 N 个桶,然后给每行标上桶号。
一个常见的业务是用户分层。比如按消费金额把客户分成 4 层,分别代表高、中、低、潜力客户:
sql复制SELECT customer_id, total_amount,
NTILE(4) OVER (ORDER BY total_amount DESC) AS customer_group
FROM customer_summary;
分桶后可以进一步按桶号做 GROUP BY 统计均值、人数。需要说明的是,NTILE 只能保证“尽量平均”,当总行数不能被 N 整除时,靠前的桶会多一行。我在实际分析里很少直接拿它做精确的业务规则,更多是拿来做初步分层或者抽样切分。
3. 聚合窗口函数实战:运行累计和移动平均可以写得像普通聚合一样直接
3.1 一个页面同时展示本月销售额和截至本月累计额
聚合函数配合 OVER 使用,最常见的场景是“既要明细,又要累计”。比如订单表按月统计了销售额,你看报表时需要同时知道当月金额和截止当月的累计金额。
假设已经有一张月份汇总表 month_sales(stat_month, amount),stat_month 是 '2024-01' 这种格式的文本:
sql复制SELECT stat_month,
amount,
SUM(amount) OVER (ORDER BY stat_month) AS cumulative_amount
FROM month_sales
ORDER BY stat_month;
这里没有写 PARTITION BY,所以整张表作为一个大分区。OVER 里写了 ORDER BY stat_month,这时候默认的累计范围就是从分区起点到当前行。每一行的 cumulative_amount 会越滚越大,正好满足“截至当月累计”的需求。
使用文本月份字段时,有一个隐性前提:月份格式必须统一并且可以按字典序排序。'2024-01'、'2024-02' 这种格式没问题,但如果你存的月份是 '2024-1'、'2024-2',字典序就会出错。我在实际项目里吃过这种亏,后来统一规定统计月份的存储格式必须是 YYYY-MM,这算一个非常实用的坑前提醒。
3.2 三个月移动平均怎么写
移动平均在很多场景里比单月数值更稳定。比如做销售趋势分析,单月波动可能很大,需要看三个月的移动平均值。
sql复制SELECT stat_month,
amount,
AVG(amount) OVER (
ORDER BY stat_month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM month_sales
ORDER BY stat_month;
这里的 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW 指定了滑窗范围:从当前行往前数两行,到当前行结束。前两个月没有足够数据时,AVG 会基于现有行计算,结果不会报错。如果你希望前两个月显示 NULL,可以再加一层判断,但多数业务场景里不额外处理也能接受。
我自己的习惯是,只要聚合窗口函数里写了 ORDER BY,并且业务含义是“逐行累计”,就尽量显式写出 ROWS 的范围,不要只依赖 ORDER BY 加上默认窗口。
3.3 默认窗口的坑:RANGE和ROWS的区别
为什么我强调“显式写范围”?因为窗口函数里写了 ORDER BY 之后,默认统计范围不是从当前行开始,而是从分区起点开始,并且终止条件是“当前排序值相同的所有行”。
看例子。假设一个订单表里有两条记录,下单日期完全一样:
| order_id | amount | order_date |
|---|---|---|
| 1 | 100 | 2024-05-06 |
| 2 | 200 | 2024-05-06 |
| 3 | 300 | 2024-05-07 |
如果执行:
sql复制SELECT order_id,
amount,
SUM(amount) OVER (ORDER BY order_date) AS cumulative_amount
FROM orders;
结果中第一行和第二行因为 order_date 相同,累计值都会是 300,而不是第一行 100、第二行 300。也就是说,默认的 RANGE 模式会把 order_date 相同的数据当成“同一批”处理,一起纳入累计。
如果你希望严格按照行顺序累计,比如第一行累计 100,第二行累计 300,就需要把窗口模式显式修改为 ROWS:
sql复制SUM(amount) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
这个差异在实际报表里非常容易踩坑。如果表里每一行的排序列都唯一,那 RANGE 和 ROWS 的结果往往一致,你根本感觉不到问题。一旦排序列出现重复值,结果就悄悄变了,而且还不容易一眼发现。
4. 用LAG和LEAD做跨行比较,比自连接清爽太多
4.1 环比增长:拿当月和上月比
做经营分析时,最常见的跨行比较需求是“这个月和上个月比涨了多少”。如果用普通的表连接,你得先把月份错位拼一次,再做个 NULL 判断。但 LAG 函数可以直接取上一行的值。
sql复制WITH t AS (
SELECT stat_month,
amount,
LAG(amount, 1) OVER (ORDER BY stat_month) AS prev_amount
FROM month_sales
)
SELECT stat_month,
amount,
prev_amount,
amount - prev_amount AS growth_amount,
ROUND((amount - prev_amount) / prev_amount * 100, 2) AS growth_rate
FROM t
ORDER BY stat_month;
LAG(amount, 1) 表示取当前行之前的第 1 行 amount 值。第一行前面没有数据,所以 prev_amount 是 NULL。外层算差值时,NULL 参与运算结果还是 NULL,通常我会用 COALESCE 处理成 0,或者在报表层对第一行做特殊说明。
顺便说一句,这里我用 CTE 而不是直接在 SELECT 里多次写 LAG,是为了避免一个 SELECT 层内部反复调用同一函数。LAG 虽然是窗口函数,但语法上它是逐行计算的,直接多写几次同样能跑,但代码会变得重复,阅读成本高。用 CTE 先算一次,外层再引用,逻辑更清晰。
4.2 同比、跨部门比较、时间间隔计算
LAG 和 LEAD 的第二个参数可以用任意正整数。拿月度数据看,LAG(amount, 3) 就是取三个月前的值,可用来算季度同比。LEAD 则是往后看,比如想看“当前订单之后下一笔同客户订单的下单时间”,用来算下单间隔。
sql复制SELECT order_id,
customer_id,
order_time,
LEAD(order_time, 1) OVER (
PARTITION BY customer_id
ORDER BY order_time
) AS next_order_time,
TIMESTAMPDIFF(
DAY,
order_time,
LEAD(order_time, 1) OVER (
PARTITION BY customer_id
ORDER BY order_time
)
) AS gap_days
FROM orders;
这里 PARTITION BY customer_id 非常关键。如果漏掉,LEAD 就只能在整个结果集里找下一行,不同客户的订单就会互相串,下一单时间完全错误。我见过不少线上报表出现这种离谱的数据,排查时发现都是漏了分区条件。
4.3 偏移函数的几个容易忽略的边界
LAG 和 LEAD 在边界上要特别注意三点。
第一,排序键必须唯一或者能代表业务顺序。如果排序键存在大量并列值,函数取到的“前一行”不一定符合你的业务直觉。
第二,默认偏移量是 1,默认缺省值是 NULL。如果你不想看到 NULL,可以在第三个参数指定默认值,比如 LAG(amount, 1, 0)。但要注意,如果上一行本身金额就是 0,这个默认值和真实值会混淆,所以根据场景决定是否使用。
第三,LAG 和 LEAD 要求 OVER 内必须有 ORDER BY。没有 ORDER BY,就不存在“前一行”和“后一行”的概念,语义本身就不成立。这一点和排名函数、聚合窗口函数不太一样,聚合窗口函数没有 ORDER BY 时还能按整个分区聚合,偏移函数直接报错。
5. 连续登录、累计占比这类经典问题的完整套解
5.1 连续登录天数:用日期减排名制造分组标记
“计算每个用户最长连续登录天数”是个经典的 SQL 面试题,也是我帮业务部门做用户活跃分析时经常要用到的逻辑。最精妙的解法是“日期-排名”法。
先说思路。把用户每天登录记录去重后,对每个用户按登录日期排序编号,再用登录日期减去编号,得到一个新日期。如果登录日期是连续的,减出来的日期就会完全相同;一旦中间断档,减出来的日期就会变化。所以这个差值可以当作一组连续日期的分组标识。
参考 SQL:
sql复制WITH login_dedup AS (
SELECT user_id,
DATE(login_time) AS login_date
FROM login_log
GROUP BY user_id, DATE(login_time)
),
t AS (
SELECT user_id,
login_date,
DATE_SUB(
login_date,
INTERVAL DENSE_RANK() OVER (
PARTITION BY user_id
ORDER BY login_date
) DAY
) AS grp
FROM login_dedup
)
SELECT user_id,
MIN(login_date) AS start_date,
MAX(login_date) AS end_date,
COUNT(*) AS continuous_days
FROM t
GROUP BY user_id, grp
HAVING COUNT(*) >= 5;
这里我特意用了 DENSE_RANK 而不是 ROW_NUMBER,因为前面去重时可能因为时区、日志刷入方式等因素造成同一天仍然存在多条记录,虽然第一步已经 GROUP BY 了日期,但保险起见用 DENSE_RANK 更稳。如果确实保证每天只有一条记录,ROW_NUMBER 也没问题,但 DENSE_RANK 在日期连续场景下更能抵抗重复数据干扰。
这个思路的精髓在于把“连续性判断”转化为“分组相等判断”。把连续序列问题变成普通聚合问题,可比一遍遍写自连接优雅太多。
5.2 找出贡献了80%销售额的核心客户
另一个经常出现的问题是“看看排名前多少的客户贡献了80%销售额”。传统做法是先按客户聚合销售额,再算每个客户占总销售额的比例,最后从高到低累计,判断累计达到 80% 的位置。
窗口函数能一气呵成。先按客户聚合,再用两个窗口函数分别算“累计金额”和“总金额”,最后用累计金额除总金额。
sql复制WITH customer_sales AS (
SELECT customer_id,
SUM(order_amount) AS amount
FROM orders
GROUP BY customer_id
),
t AS (
SELECT customer_id,
amount,
SUM(amount) OVER (ORDER BY amount DESC) AS running_amount,
SUM(amount) OVER () AS total_amount
FROM customer_sales
)
SELECT customer_id,
amount,
ROUND(running_amount / total_amount, 4) AS cum_ratio
FROM t
WHERE running_amount <= total_amount * 0.8
ORDER BY amount DESC;
解释一下执行过程:customer_sales 先算出每个客户的销售额;t 里第一个窗口 SUM(amount) OVER (ORDER BY amount DESC) 算出从最大客户开始的累计销售额,第二个窗口没有 PARTITION BY、没有 ORDER BY,直接算出全表总金额;外层只保留累计销售额不超过总额 80% 的行。
写这类 SQL 时容易犯的错误是试图在 WHERE 里直接使用 running_amount,但窗口函数的结果晚于 WHERE 生成,所以只能包一层 CTE。这个写法和 TopN 的套路完全一样,其实都是“窗口先算,外层再过滤”。
5.3 用窗口函数一次完成分组累计与最终占比
还有一种需求是“既要看每个部门人数,又要看全公司总人数,还要看各部门占全公司比例”。一个窗口函数就能全带上:
sql复制SELECT department_id,
COUNT(*) AS dept_people,
SUM(COUNT(*)) OVER () AS total_people,
COUNT(*) / SUM(COUNT(*)) OVER () AS people_ratio
FROM employee
GROUP BY department_id;
这里有个很妙的细节:GROUP BY 先按部门聚合出每个部门的人数,然后窗口函数再把这个人数结果当成输入做全表统计。所以窗口里面可以直接写 SUM(COUNT(*)),也就是对聚合结果的再聚合。这个语法第一次见到可能觉得绕,但它是合法的,而且能省掉一层子查询。
这种写法提醒我们:窗口函数既可以作用在原始行上,也可以作用在 GROUP BY 之后的聚合结果上。理解这一点,很多看起来麻烦的“组内套组”需求都能找到简化解法。
6. 我把窗口函数写到线上慢SQL之后,学到的几条性能红线
6.1 窗口函数不是魔术,它背后大概率有排序
如果窗口函数里有 ORDER BY,数据库就很可能需要做一次排序操作。多个窗口函数如果排序列各不相同,就可能产生多次排序。我见过一条业务 SQL 里写了 6 个不同的窗口函数,执行计划拉出来一大串排序节点,慢得不能用。
优化思路是尽量让多个窗口函数复用同一个分区和排序规则。比如都需要按部门分区并按工资排序,就尽量保持写法一致,让优化器有机会复用排序结果。
如果确实需要不同排序规则,优先拆成多个 CTE,把数据量先降下来再做二次窗口计算,而不是一张大表上同时堆多个昂贵排序。
6.2 先用WHERE缩小数据范围,再开窗
窗口函数在逻辑上是在 FROM、WHERE、GROUP BY 都执行完之后才工作的。这意味着,如果 SQL 里先通过 WHERE 过滤掉了 90% 的数据,窗口函数需要处理的数据量就已经大幅缩小了。
反过来,如果你把大范围的数据一次性拉出来,再在 CTE 里做窗口计算,那窗口函数必须面对整张大表。虽然最终报表层可能只显示前几行,但中间的计算成本一点都不会少。
所以有一个实际建议:每写一条带窗口函数的 SQL,先问自己 WHERE 条件能不能前置,能不能先把待分析群体缩小到合理范围。很多“窗口函数特别慢”的案例,根子不在窗口函数本身,而在过滤条件放晚了。
6.3 避免窗口函数里的大范围无界滑窗
如果一个窗口函数写成 SUM(amount) OVER (ORDER BY create_time),默认是 RANGE 模式,会把所有时间相同的数据当成一个批次处理。如果某一天数据特别多,实际累计涉及的行数会因为重复时间被放大。这种写法在统计“每行累计”时通常不是你想要的,改成显式的 ROWS 模式后,计算语义明确,资源开销也更容易预估。
滑窗范围越长,内存中需要维护的数据越多。虽然数据库不一定会物理复制所有行,但像 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 这种全窗口计算,本质上和整组聚合差不多,只是输出维度不同。用之前最好确认一下业务是否真的需要。
6.4 不要在窗口函数返回的结果上继续套窗口函数
窗口函数的结果不能直接再做窗口函数,这在很多数据库里会报语法错误。我见过有人试图写 SUM(SUM(salary) OVER (PARTITION BY dept)) OVER () 这种嵌套写法,直接被数据库拒绝。
正确的做法是分层处理:先用 CTE 算第一层窗口结果,再在外面用普通聚合或者另一个窗口继续处理。分层写法虽然多了一层,但每一步逻辑都很清晰,出问题时也方便排查。
6.5 索引对窗口函数的帮助有限但真实存在
当窗口函数的分区和排序列正好能匹配索引时,数据库有可能直接从索引顺序上扫数据,免去额外的排序步骤。比如 PARTITION BY department_id ORDER BY salary DESC,如果表上建了 (department_id, salary DESC) 的复合索引,执行计划中排序节点的概率会降低。
不过不要把宝全押在索引上。窗口函数的 ORDER BY 方向、NULL 排序方式、过滤条件是否能在索引上命中,都会影响最终执行计划。更可靠的做法是先用 EXPLAIN 看执行计划,再决定要不要调整索引,而不是凭感觉堆索引。
6.6 先说结论:实际项目中我如何控制窗口函数的使用节奏
把上面的经验收敛成几句实操准则:
- 只有确认窗口函数能明显简化逻辑时再用,不要为了炫技而用。
- 优先保证窗口函数的 PARTITION BY 和 ORDER BY 简单明确。
- 多个窗口函数尽量保持排序规则一致。
- 永远记得窗口结果不能直接放 WHERE,统一用 CTE 包一层。
- 在线分析或者定时任务跑大表时,先小规模数据验证执行计划,再全量运行。
我自己的习惯是写完后把 SQL 丢到 EXPLAIN 里过一遍,确认关键表没有出现可怕的扫描行数,再放到正式任务里。窗口函数确实强大,但它不是银弹,理解了它的执行逻辑,才能真正把它用得又快又稳。
