窗口函数是SQL中非常强大的分析工具,但它的执行时机有个特殊之处:窗口函数在SELECT阶段才计算,而WHERE、GROUP BY等子句在它之前执行。这就导致一个常见问题——当我们想根据窗口函数的计算结果做进一步筛选时,直接在WHERE里引用会报错。本文将详细介绍如何通过嵌套查询和CTE两种方式获取窗口函数计算后的结果集,并结合实际场景给出完整示例。

为什么窗口函数不能直接放在WHERE条件里
很多初学者会写出这样的SQL:SELECT name, salary, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn FROM emp WHERE rn = 1,然后发现数据库直接报错,提示列不存在。要理解这个问题的根源,需要了解SQL语句的逻辑执行顺序。
一条完整的SELECT语句,其逻辑执行顺序大致是:FROM、WHERE、GROUP BY、HAVING、SELECT、ORDER BY。窗口函数的计算发生在SELECT阶段,也就是说,当WHERE在筛选数据时,rn这一列根本还不存在,自然无法作为筛选条件。这不是某个数据库的bug,而是SQL标准规定的执行模型,MySQL、PostgreSQL、Oracle、SQL Server都遵循这个规则。
同理,如果你想在GROUP BY或者HAVING中引用窗口函数的结果,也会遇到同样的问题。解决办法只有一个思路:先把窗口函数的结果物化成一个临时的结果集,然后在这个结果集之上再做筛选。实现这个思路有两种主流写法:嵌套子查询和CTE。
方法一:使用嵌套子查询包装窗口函数
最直接的方式是把包含窗口函数的查询作为内层查询,外面再套一层查询做过滤。内层查询先算出窗口函数的值并给它一个别名,外层查询就可以正常引用这个别名了。下面以经典的“取每个部门薪资最高的员工”为例:
SELECT dept, name, salary
FROM (
SELECT
dept,
name,
salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employee
) t
WHERE t.rn = 1;这里的t是派生表的别名,大多数数据库要求必须写。如果需求改成“每个部门薪资前两名”,只需把条件改为WHERE t.rn <= 2即可,非常灵活。需要注意的是,如果并列排名也要保留,应该用RANK或DENSE_RANK替代ROW_NUMBER,三者的差异要心里有数:ROW_NUMBER严格递增不重复,RANK遇到并列会跳号,DENSE_RANK并列不跳号。
再看一个累计求和的场景。假设有一张订单流水表,想找出累计消费金额超过1000元的记录,从哪一条开始“超标”,可以这样写:
SELECT *
FROM (
SELECT
order_id,
customer_id,
amount,
order_time,
SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_time) AS running_total
FROM orders
) t
WHERE t.running_total > 1000;这种写法在所有主流数据库上都能运行,兼容性最好。缺点是嵌套层级一多,可读性会下降,尤其是窗口函数套窗口函数的场景,几层括号看下来容易晕。
方法二:使用CTE让逻辑更清晰
CTE也就是公共表表达式,通过WITH关键字把一段查询临时命名,之后像操作普通表一样引用它。对于窗口函数结果的再处理,CTE写法结构上更扁平。还是取每组前N条的例子,用CTE改写如下:
WITH ranked AS (
SELECT
dept,
name,
salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employee
)
SELECT dept, name, salary
FROM ranked
WHERE rn = 1;CTE最大的优势是可读性和可维护性。当查询逻辑复杂时,你可以把不同的处理步骤拆成多个CTE,每一步做一件事,逐步推进。比如先算排名,再算累计值,最后统一筛选,每段逻辑都有清晰的名字,排查问题时一目了然。
另一个实用场景是数据去重。表里存在重复记录,只想保留每组最新的一条,传统写法要用自关联,性能和可读性都不理想,用CTE加窗口函数则简洁得多:
WITH dedup AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY user_id, order_no
ORDER BY update_time DESC
) AS rn
FROM user_orders
)
DELETE FROM user_orders
WHERE order_id IN (SELECT order_id FROM dedup WHERE rn > 1);需要注意,不同数据库对“在DELETE或UPDATE中直接使用CTE”的支持程度不同。SQL Server和PostgreSQL支持得比较彻底,MySQL从8.0开始也支持在DELETE前使用WITH,但一些老版本数据库可能只允许在SELECT中使用,遇到兼容性问题时要根据具体数据库调整写法。
两种方法的对比与选择建议
从执行效率上看,嵌套子查询和CTE在绝大多数数据库中会被优化器生成相同的执行计划,性能上没有实质差异,选择哪种主要是团队风格和可读性层面的考量。下面对比两者的特点:
| 对比维度 | 嵌套子查询 | CTE |
|---|---|---|
| 可读性 | 层级深时较难读 | 结构扁平,逻辑清晰 |
| 复用性 | 不可复用,需重复写 | 可在一条语句中多次引用 |
| 兼容性 | 所有主流数据库支持 | MySQL 8.0以上支持,老版本不支持 |
| 递归查询 | 不支持 | 支持WITH RECURSIVE |
| 调试体验 | 拆解困难 | 可分步验证每个CTE |
实际开发中可以遵循这样的原则:简单的单层包装,两种写法随意;涉及多步骤加工、窗口函数结果需要被后续逻辑多次引用时,优先用CTE;如果项目里还在用MySQL 5.7这类不支持CTE的版本,就只能老老实实写嵌套子查询。
还有一点经验值得分享:无论是子查询还是CTE,都只保留外层真正需要的列。有些优化器不会主动裁剪派生表中未被引用的窗口函数计算,多余的开窗操作会带来不必要的排序开销。把SELECT写成明确的列清单而不是星号,往往能省下不少计算资源。掌握嵌套查询处理窗口函数结果的思路后,诸如分组TopN、连续登录分析、环比同比计算这类经典问题都能迎刃而解。