☰
ROW_NUMBER与GROUP BY:窗口函数和分组聚合的本质区别与实战应用
2026/10/8 20:08:57 网站建设 项目流程

你是不是也遇到过这种情况:需求是“取每个部门薪资最高的员工”,你上来就写了一刀GROUP BY department,结果 SELECT 列表里除了部门名和聚合函数,别的字段全报错,或者数据能跑通但拿到的根本不是你要的那一行。这时候有经验的同事会告诉你:用ROW_NUMBER() OVER(PARTITION BY ...)。很多初学者在这一步直接卡住,因为这两个东西看起来都跟“分组”有关,但行为完全不同。

这篇文章专门把ROW_NUMBER() OVER(PARTITION BY ...)和GROUP BY的本质区别讲透。核心关键词就两个:窗口函数和分组聚合。它们是 SQL 里两条完全不同的工作路线,搞清楚之后,去重、分组取 TopN、保留明细、配合聚合统计这些场景你都能一眼判断该用哪个。本文适合刚接触 SQL 窗口函数的人,也适合准备 SQL 面试、或者工作中经常写统计报表但总被这两个语法绕晕的开发者和数据分析师。

1. 一次搞懂两者的核心定位:窗口函数与分组聚合是两条路线

先说结论:ROW_NUMBER() OVER(PARTITION BY ...)是一个窗口函数,而GROUP BY是一个分组聚合语句。它们都能把数据“按某个字段归类”,但归类之后干的事完全不一样。

1.1ROW_NUMBER()到底做了什么

ROW_NUMBER()的意思是“行号”。OVER(PARTITION BY 字段)指定了“在哪个范围内编号”,这个范围就叫窗口。它的工作方式是:一行一行地扫描数据,给每一行按照窗口内定义的顺序编一个递增序号,但数据行的总数不变,每一行都原样保留。

你可以把它理解成给全班同学按身高排座位号——每个人都会有一个号码,但没有任何人被从名单里删掉。PARTITION BY相当于“按班级分组”,ORDER BY相当于“按身高排序”,排完之后每个班内部从 1 开始编号。这个编号结果是附加在每一行旁边的额外列,不是把多行合并成一行。

全程行不丢失,这是和GROUP BY最根本的差异。

1.2GROUP BY做了什么

GROUP BY的工作方式完全相反:把多行按照指定字段折叠成一组,一组只保留一行。聚合函数比如COUNT()、SUM()、AVG()、MAX()、MIN()就是在这个折叠过程中对组内多行做计算。

还是拿学生举例,GROUP BY 班级就相当于“每个班只留下一张汇总卡片”,卡片上可以写“这个班有 45 人”“平均身高 168cm”“最高的人 185cm”,但卡片上没有“张三、李四分别多高”这种明细信息。所有不是分组字段、也不是聚合函数包住的字段,都没资格出现在最终结果里,这在 MySQL 里直接报错,在 SQL Server、Oracle 里也明确不合法。

1.3 一张对照表看清差异

对比维度ROW_NUMBER() OVER(PARTITION BY ...)GROUP BY
所属类别窗口函数(分析函数)分组聚合语句
是否改变行数不改变,每一行都保留会折叠,一组只留一行
作用方式逐行编号,编号附加在原行旁边多行合成一行,丢失明细
能否单独输出非分组字段可以,原字段都在不行,非分组字段必须套聚合函数
是否必须配合聚合函数不需要通常配合 COUNT/SUM/AVG/MAX/MIN 使用
典型用途行号、去重、分组 TopN、相邻行比较统计汇总、报表聚合
是否影响原始数据顺序不改变物理顺序,只在窗口内逻辑排序结果自带分组排序特征

这张表是全文的地图。后面所有场景、代码和踩坑,都是围绕这张表的两行展开的。

2. 使用场景拆解:什么时候该用ROW_NUMBER,什么时候该用GROUP BY

“能不能用”和“该不该用”是两回事。有些场景两种写法都能出结果,但结果的语义不同、性能也不同。

2.1 去重场景:ROW_NUMBER是首选,GROUP BY也能做但有缺点

最常见的误用发生在数据去重。假设订单表里同一个 order_id 因为数据同步问题出现了多行,你想保留每个订单的一条记录。

用GROUP BY去重:

SELECT order_id, MAX(create_time) AS create_time FROM orders GROUP BY order_id;

这样能去重,但问题很明显:想保留的其他字段必须一个个用聚合函数包起来,比如MAX(create_time)只是“碰巧”取到了最后一条的时间,如果还想保留金额、用户ID,就得写成MAX(amount)、MAX(user_id),如果这些字段逻辑上应该来自同一行,那MAX组合出的结果可能根本不是同一行的数据。

用ROW_NUMBER()去重才是正确姿势:

SELECT order_id, user_id, amount, create_time FROM ( SELECT orders.*, ROW_NUMBER() OVER(PARTITION BY order_id ORDER BY create_time DESC) AS rn FROM orders ) t WHERE rn = 1;

这段 SQL 的逻辑是:先把所有行按 order_id 分区,在分区内按创建时间倒序编号,时间最新的那条编号为 1,然后外层只取 rn=1 的行。这样拿到的完完整整就是最新那一条记录的所有字段,不会出现字段来自不同行的问题。

我实际开发里遇到一个真实案例:一个日新增用户表,因为上游重复推送,同一个用户一天出现了三条记录。用GROUP BY user_id去重后,发现给用户发的券金额来自第一条,而券类型来自第三条,完全错乱。后来改成ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY update_time DESC)之后才拿到真正的“最新一条”。

热词里提到的“sql语句去重”“sql语句去重查询”,十有八九指的都是这个场景。记住一个口诀:要去重且要保留完整明细字段,用 ROW_NUMBER;要去重且只需要做聚合计算,用 GROUP BY。

2.2 分组取 TopN:ROW_NUMBER的绝对主场

“每个部门工资最高的前 3 名”“每个商品品类销量最高的 5 个店铺”“每天登录时长最长的用户”,这类需求用GROUP BY只能做到一件事:算出每个组里最高值是多少,但拿不到“哪一行是这个最高值”。

GROUP BY的极限是:

SELECT department, MAX(salary) AS max_salary FROM employees GROUP BY department;

结果只有部门和最高工资数。谁是那个挣最多的人?名字、工号、入职时间统统拿不到。

ROW_NUMBER()可以一次拿到完整行:

SELECT department, emp_name, salary, rn FROM ( SELECT employees.*, ROW_NUMBER() OVER(PARTITION BY department ORDER BY salary DESC) AS rn FROM employees ) t WHERE rn <= 3;

注意这里用了rn <= 3,就是取前三名。窗口公式只有 1 和 3 的区别。倒序ORDER BY salary DESC控制“谁排在前面”。

有个细节需要注意:并列排名的问题。如果两个人的工资完全一样,ROW_NUMBER()会随机分配 1 和 2,这会造成不公平。如果需要并列名次,应该换成RANK()或DENSE_RANK()。这是另一个经典的 SQL 面试考点,和本主题连在一起考。

2.3 纯聚合统计:GROUP BY的领域无人能替代

如果你想算每个品类的总销售额、总订单数、平均单价,这就是GROUP BY的看家本领:

SELECT category, COUNT(*) AS order_cnt, SUM(amount) AS total_amount, AVG(amount) AS avg_amount FROM orders GROUP BY category;

这种写法任何窗口函数都替代不了。窗口函数不会折叠行,它给 100 行数据编号后还是 100 行,无法产出 30 个品类的统计行。想用OVER(PARTITION BY)模拟聚合?可以算出每个分区里 amount 的总和并附加在每一行上,但结果还是 100 行,报表层面没法直接用。

所以两者的关系是互补而不是替代:一个要折叠出汇总,一个要留着明细编号。

3. 实操过程:完整 SQL 示例与执行逻辑推演

光讲概念不够,拿一套完整的测试数据和 SQL 走一遍,把每一步的中间结果展示出来。

3.1 准备测试数据

用 MySQL 8.0 举例,建表和插入语句如下。我用的是 Navicat 直接执行 SQL 脚本,你也可以用命令行source执行,或者复制到任意 SQL 客户端里跑。

CREATE TABLE employees ( id INT PRIMARY KEY, emp_name VARCHAR(50), department VARCHAR(50), salary DECIMAL(10,2) ); INSERT INTO employees VALUES (1, '张三', '技术部', 15000), (2, '李四', '技术部', 12000), (3, '王五', '技术部', 18000), (4, '赵六', '市场部', 11000), (5, '钱七', '市场部', 14000), (6, '孙八', '市场部', 14000), (7, '周九', '人事部', 9500), (8, '吴十', '人事部', 10000), (9, '郑一', '人事部', 8000);

3.2 核心 SQL 演示:三个场景现场跑通

场景一:查询每个部门工资最高的员工完整信息。

SELECT department, emp_name, salary FROM ( SELECT employees.*, ROW_NUMBER() OVER(PARTITION BY department ORDER BY salary DESC) AS rn FROM employees ) t WHERE rn = 1;

执行过程拆解:先扫描全表 9 行,PARTITION BY department把数据切成了三个窗口分区(技术部 3 行、市场部 3 行、人事部 3 行),每个分区内部按工资降序编号。技术部王五 18000 编号 1,市场部钱七、孙八都是 14000,但ROW_NUMBER()必须给出唯一序号,所以两人分到 1 和 2,人事部吴十 10000 编号 1。外层过滤 rn=1,返回 3 行。

场景二:对比一下用GROUP BY写同样的需求。

SELECT department, MAX(salary) AS max_salary FROM employees GROUP BY department;

返回 3 行,但每行只有部门和最高薪资数字,没有员工姓名。这就是前面说的“知道最高工资是多少,但不知道是谁”。

场景三:全局编号和分组编号的区别。

SELECT department, emp_name, salary, ROW_NUMBER() OVER(ORDER BY salary DESC) AS global_rn, ROW_NUMBER() OVER(PARTITION BY department ORDER BY salary DESC) AS dept_rn FROM employees;

结果里会出现两列编号:global_rn是全表按工资降序从 1 到 9,dept_rn是部门内部编号。这个例子特别能说明窗口是独立计算的:每一行同时挂在两个窗口里,互不干扰,两套编号可以共存。

3.3 从 SQL 执行顺序看两者差异

理解执行顺序是理解 SQL 语义的钥匙。一个完整查询的逻辑执行顺序大致是:

FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → 窗口函数(在 SELECT 阶段计算)

关键点:GROUP BY发生在聚合阶段,它先折叠行;而窗口函数在SELECT阶段才计算,此时行已经定型(或者是聚合后的分组行,或者是 FROM/WHERE 过滤后的明细行)。

这就是为什么WHERE子句里不能用ROW_NUMBER(),因为计算窗口函数时WHERE已经执行完了。同理,GROUP BY的结果可以再配合窗口函数做“聚合后的排名”,常见的写法是:

SELECT department, total_salary, RANK() OVER(ORDER BY total_salary DESC) AS dept_rank FROM ( SELECT department, SUM(salary) AS total_salary FROM employees GROUP BY department ) t;

这里子查询先完成GROUP BY聚合,外层再对聚合结果做窗口排名。两个工具协同工作,先折叠后编号,是先有鸡还是先有蛋的答案:先有GROUP BY折叠,再有窗口函数编号。

3.4 关于GROUP BY多个字段

热词里有“group by 多个字段”。这个操作在语义上就是“按多个维度联合分组”。比如按部门和入职年份统计人数:

SELECT department, YEAR(hire_date) AS hire_year, COUNT(*) AS cnt FROM employees GROUP BY department, YEAR(hire_date);

联合分组的逻辑是把(department, hire_year)作为一个组合键,同组才折叠。这在PARTITION BY里同样适用:PARTITION BY department, YEAR(hire_date)就是以同样组合键划分窗口。两者在“多字段分组”上的语法是对称的,理解一个就能理解另一个。

4. 常见问题与排查技巧实录

写 SQL 写得越多,踩的坑越有代表性。这里把最常遇到的几类问题整理成速查表。

4.1ROW_NUMBER()排序不稳定导致编号结果漂移

这是一个非常隐蔽的问题。当ORDER BY后面的字段有重复值时,比如两个人的工资都是 14000,ROW_NUMBER()给谁分 1 谁分 2 是没有保证的,数据库每次执行可能给出不同结果,因为它觉得“两个值相等,顺序无所谓”。

更麻烦的是如果分区列本身有 NULL 值,不同数据库对 NULL 的排序位置不统一。MySQL 默认 NULL 最小,Oracle 默认 NULL 最大,跨库移植时结果可能不一样。

排查技巧:在ORDER BY里加一个唯一字段作为决胜条件,比如ORDER BY salary DESC, id ASC。这样每个窗口内的排序是完全确定的,编号也会稳定下来。

4.2 误把PARTITION BY当GROUP BY用

有人写:

SELECT department, emp_name, ROW_NUMBER() OVER(PARTITION BY department) AS rn FROM employees;

这个语法本身合法,但理解上容易犯一个错误:以为结果会被折叠成每个部门一行。实际上PARTITION BY department不会折叠,它只是把同部门的人都归到同一个窗口里各自编号。最终结果依然返回 9 行。

要判断一个写法是不是在折叠行,最直接的办法是数结果行数:GROUP BY后行数明显变少,窗口函数后行数不变。这是最快的自检方式。

4.3 性能问题:为什么加了窗口函数后查询变慢了

窗口函数通常需要在内存或临时表里对每个分区排序,数据量大时性能开销很高。热词里有“慢sql优化”“并行sql优化”,窗口函数往往是重点嫌疑对象。

优化思路有三条:

第一,尽量缩小参与计算的数据集,在子查询里先用WHERE过滤掉不需要的分区和行,不要对全表做窗口计算。

第二,为PARTITION BY和ORDER BY涉及的字段建立合适的联合索引。窗口计算需要按分区字段分组、按排序字段排序,联合索引(department, salary DESC)能减少排序成本。

第三,如果确实要取分组 TopN,有些数据库(如 MySQL 8.0 以上)可以用 LATERAL JOIN 或相关子查询的方式改写,有时比窗口函数更快,但可读性会差一些,需要根据执行计划选择。

4.4 一个典型面试连环坑

面试官经常这样出题:表里有用户 ID、登录日期,让你找出每个用户最近一次登录的日期,并且要带上那天的登录时长。有人第一反应写:

SELECT user_id, MAX(login_date) AS last_date FROM user_login GROUP BY user_id;

这只拿到了日期,丢掉了登录时长。如果要时长,要么用子查询回表关联:

SELECT a.* FROM user_login a INNER JOIN ( SELECT user_id, MAX(login_date) AS last_date FROM user_login GROUP BY user_id ) b ON a.user_id = b.user_id AND a.login_date = b.last_date;

这种写法逻辑上可行,但要注意:如果同一个用户同一天有多条登录记录,连接会产生重复行。用窗口函数则完全没有这个问题:

SELECT user_id, login_date, duration FROM ( SELECT user_login.*, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_date DESC) AS rn FROM user_login ) t WHERE rn = 1;

不用自连接,不用考虑重复行问题,所有明细字段天然就在同一行上。这也是为什么窗口函数在面试题里越来越常见的原因。

4.5 在 SQL Server、Oracle 里的兼容性差异

标题和热词里都出现了 SQL Server。SQL Server、Oracle、PostgreSQL、MySQL 8.0 都支持标准窗口函数语法ROW_NUMBER() OVER(PARTITION BY ... ORDER BY ...),语法基本一致。

几个细微差别值得注意:

  • MySQL 8.0 以下版本不支持窗口函数,只能用变量模拟或者改写,这是老项目升级时经常遇到的坑。
  • SQL Server 中ORDER BY在窗口函数内是必须写的,不能省略,否则报错。而 MySQL 8.0 允许省略,表示不排序,随机编号。
  • Oracle 里窗口函数的别名不能在外层 WHERE 直接引用,需要嵌套子查询。这和 MySQL 的行为一致,是通用的标准限制。

5. 从执行计划层面追溯到根因:为什么两者看起来像“都能分组”

前面说了这么多,其实还有一个更深层的问题没有回答:为什么初学者会把这两个东西搞混?因为从 SQL 的字面上看,PARTITION BY department和GROUP BY department好像都是一回事。要彻底解开这个心结,得从数据库执行引擎的视角看一眼。

GROUP BY在物理执行上是一个HashAggregate 或 SortAggregate 算子。这个算子会扫描输入行,按照分组键计算哈希值,把相同哈希值的行丢进同一个桶里,然后对每个桶执行聚合函数,输出一行。一旦进桶,原始行的物理身份就消失了,输出行里只能有分组键和聚合结果。

ROW_NUMBER() OVER(PARTITION BY ...)在物理执行上是一个WindowAgg 算子。这个算子按分区键把输入行组织成多个窗口,然后在窗口内执行排序并计算序号,每一行在算子内部依然保持独立的行身份,编号只是附加的列。

从算子的角度说,一个是“多进一出”,另一个是“多进多出但加标签”。这就是为什么GROUP BY之后你没法再引用明细字段,而ROW_NUMBER()之后明细字段都还在。

另外一个很容易被忽略的点:GROUP BY的结果可以直接给HAVING用,因为HAVING就是专门在分组聚合后做过滤的;而窗口函数计算发生在SELECT阶段,所以不能在同一层用WHERE过滤 rn=1,必须嵌套一层子查询。这个限制也常常导致新手写出来的 SQL 报错:

SELECT department, emp_name, salary, ROW_NUMBER() OVER(PARTITION BY department ORDER BY salary DESC) AS rn FROM employees WHERE rn = 1;

这段会直接报错,因为WHERE执行时 rn 还不存在。正确做法是把带别名的查询包一层再过滤。理解了这个执行顺序问题,你就能解释为什么窗口函数永远是“先查出来,再过滤编号”。

6. 面试与实战中的几种经典考法(还有几个容易翻车的细节)

窗口函数在 SQL 面试题里的出现频率非常高。热词里就有“sql面试题”,这里把围绕ROW_NUMBER()和GROUP BY的几种考法一次性梳理清楚。

6.1 连续登录/连续签到问题

题目通常是:找出连续登录 3 天以上的用户。这题的经典解法是用窗口函数给每个用户的登录日期编号,然后用日期减去编号,差相同的日期落在同一组里。

SELECT user_id, COUNT(*) AS continuous_days FROM ( SELECT user_id, login_date, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_date) AS rn FROM user_login ) t GROUP BY user_id, DATE_SUB(login_date, INTERVAL rn DAY) HAVING COUNT(*) >= 3;

这里就出现了ROW_NUMBER()和GROUP BY的经典配合:先用窗口函数编号,再用GROUP BY折叠出连续区间,最后用HAVING过滤。两者不是互斥关系,是流水线的关系。

6.2 分组内相邻记录比较

比如要算每个用户当前登录时间和上一次登录时间的间隔。GROUP BY在这里完全无能为力,因为显然需要保留每一行。正确解法是:

SELECT user_id, login_date, DATEDIFF(login_date, LAG(login_date) OVER(PARTITION BY user_id ORDER BY login_date) ) AS day_gap FROM user_login;

LAG()和ROW_NUMBER()同属窗口函数家族,语法结构一模一样。理解了PARTITION BY的本质,其他窗口函数如LAG()、LEAD()、SUM() OVER()、RANK()都能很快上手。

6.3 保留分组内最新一条记录

这是一个非常真实的业务需求。比如商品价格历史表,要取每个商品当前最新的价格记录:

SELECT product_id, price, effective_date FROM ( SELECT product_id, price, effective_date, ROW_NUMBER() OVER(PARTITION BY product_id ORDER BY effective_date DESC) AS rn FROM product_price_history ) t WHERE rn = 1;

这里需要强调一个细节:ORDER BY effective_date DESC的字段必须是能区分新旧的时间戳。如果时间戳精确到天且同一天有多条变更记录,排序不稳定,取出来的可能不是真正最新的。更好的做法是让排序字段唯一,或者加上自增 ID 倒序作为第二排序条件。

6.4 和COUNT(*) OVER()一起使用

窗口函数不只有ROW_NUMBER()。当你在明细报表里想同时看到总数和明细时,可以写:

SELECT department, emp_name, salary, COUNT(*) OVER(PARTITION BY department) AS dept_total_cnt, SUM(salary) OVER(PARTITION BY department) AS dept_total_salary FROM employees;

这个查询返回 9 行,每一行旁边都带着所属部门的总人数和总工资。同样是“按部门分组”,GROUP BY只能折叠成 3 行,而窗口版保留了 9 行明细,这就是报表场景里常说的“明细与汇总共存”。

7. 慢 SQL 排查视角:窗口函数何时会成为性能陷阱

热词里“慢sql优化”是高频检索词。在实际生产库里,窗口函数引发慢查询的几率比GROUP BY高,原因主要在于排序。

一个 1000 万行的大表,GROUP BY department走 HashAggregate 时,如果内存足够,只需要一遍扫描加哈希计算;窗口函数则需要按部门分区,再在分区内排序,排序开销远高于哈希聚合。如果PARTITION BY列上有大量重复值,每个分区又很大,排序临时文件可能被打到磁盘,IO 飙升。

排查这类问题的方式是用EXPLAIN看执行计划:

EXPLAIN SELECT department, emp_name, salary, ROW_NUMBER() OVER(PARTITION BY department ORDER BY salary DESC) AS rn FROM employees;

看到Extra列出现Using filesort时就要警惕。优化方向前面提过:缩小数据集、建联合索引、必要时改写语句。还有一个实操技巧:如果分区字段有索引但排序字段没有,可以尝试把排序字段嵌入联合索引,让 B+ 树的物理顺序天然有序,省掉 filesort。

需要说明的是,并不是窗口函数一定比GROUP BY慢。当分组数量极大、每组行数很少时,窗口函数的执行效率可能优于GROUP BY+ 自连接的写法。任何脱离数据量谈性能都是耍流氓。

8. 我的实操习惯:写分组和编号 SQL 时的几个检查点

写多了之后,我每次写完相关 SQL 都会按固定顺序自查一遍,这里分享出来。这套检查点在面试写题时尤其有用,因为白板 SQL 没有执行机会,只能靠逻辑自查。

第一,先问自己“结果要几行”。如果需求是“每个组要一行汇总”,走向GROUP BY;如果需求是“每一行都要在,只是要分组排名或编号”,走向ROW_NUMBER()。行数是第一判断标准。

第二,看 SELECT 列表里有没有非聚合字段。如果查询目标是明细字段而不是统计值,那基本注定要窗口函数或者子查询关联。

第三,检查PARTITION BY和ORDER BY是否成对出现。ROW_NUMBER()里PARTITION BY可以省略(这时候就是全局编号),但ORDER BY强烈建议写完整且稳定,否则编号随机。SQL Server 里甚至强制要求写ORDER BY。

第四,确认外层过滤是否用对了。取每组第一条用WHERE rn = 1,取前三条用WHERE rn <= 3,这里注意必须是子查询包裹后在外层过滤,不能在同一层过滤。

第五,如果和COUNT(*) OVER()这类聚合窗口混合用,注意输出行数可能远大于预期,别拿 9 行明细数据当成 3 行分组汇总去展示。

这五个检查点是我从实际项目里拆出来的,今天一并写在这里。如果你能把这套思路内化,再遇到类似的“分组 vs 编号”问题就不会纠结语法了。

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

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

立即咨询