☰
ROW_NUMBER()窗口函数详解:从原理到实战
2026/10/21 15:50:38 网站建设 项目流程

从事数据库开发这些年,窗口函数是我用得最勤的一类语法,尤其是 ROW_NUMBER()。它解决的不是“能不能查出来”,而是“怎么查得更有条理”的问题。很多初学者觉得它只是个自动编号工具,实际上它背后承载的是排序、分组、去重、分页乃至复杂数据分析的核心逻辑。这篇就围绕 ROW_NUMBER() 窗口函数,从原理、语法、实际场景到底层执行逻辑,完整拆给你看。


1. ROW_NUMBER() 到底是什么

1.1 一句话说清它的核心行为

ROW_NUMBER() 是一个窗口函数,作用是对查询结果集中的每一行生成一个从 1 开始的连续整数编号。这个编号不是表里存的字段,而是查询执行时临时计算出来的“行号”。编号的顺序由你指定的排序规则决定,编号的范围可以被 PARTITION BY 限定在某个分组内部。

举一个最直观的例子。假设有一张订单表,里面有客户编号、订单金额、下单时间三个字段。我想给每个客户的订单按金额从高到低排个顺序,得到每个客户内部的排名号,SQL 可以这么写:

SELECT 客户编号, 订单金额, 下单时间, ROW_NUMBER() OVER ( PARTITION BY 客户编号 ORDER BY 订单金额 DESC ) AS 客户内部序号 FROM 订单表;

执行结果里,每个客户的第一条记录序号是 1,第二条是 2,以此类推。一旦客户编号切换,序号重新从 1 开始。这就是它最核心的行为特征:整体排序,分组编号,序数重置。

我在实际工作中常把 ROW_NUMBER() 类比成“给一组人按身高排队发号码牌”。号码牌本身不改变任何人的属性,只是为了让你快速知道谁在第几位。数据库里它做的也是这件事,只是“排队”的规则完全由你写在 ORDER BY 里的字段决定。

1.2 基本语法和参数含义

标准写法如下:

ROW_NUMBER() OVER ( [PARTITION BY 分区域字段] ORDER BY 排序字段 [ASC | DESC] )

这里有几个关键点需要认真理解:

PARTITION BY 是可选参数。它控制的是“行号在哪个范围内连续编号”。不写 PARTITION BY,意味着整个结果集作为一个大区,编号从 1 一直排到最后一行;写了 PARTITION BY,编号会在每个分区内部重新计算。

ORDER BY 是必填参数。它决定编号分配的顺序依据。你按什么规则排序,就按什么规则编号。需要注意,ORDER BY 这里写的是参与排序的表达式,可以是一个字段,也可以是多个字段的组合,还可以是函数计算结果,比如按日期字段截取到月份后排序。

OVER() 括号里的内容才是窗口函数的灵魂。没有 OVER(),ROW_NUMBER() 就失去了窗口意义;OVER() 里没有 ORDER BY,部分数据库会报语法错误,部分数据库虽然不报错但编号顺序不可控,这在生产环境是大忌。

提示:我见过不少新手把 ROW_NUMBER() 和 MySQL 里的 @rownum := @rownum + 1 这种老式变量写法混为一谈。后者确实能模拟实现类似效果,但需要额外声明变量、控制顺序,复杂度高且容易出错。窗口函数是 SQL 标准特性,可读性和维护性都高出好几个级别。

1.3 为什么需要这个“临时编号”

你可能会问:表里直接加个自增 ID 不是更省事吗?问题是自增 ID 是固定的、物理存在的属性,而 ROW_NUMBER() 生成的编号是依赖查询上下文动态计算的。同一个表,换一种排序规则,行号完全不一样;同一个查询,换个分区条件,编号逻辑跟着变。这种灵活性是物理字段永远给不了的。

举一个具体场景。运营同事要求:“每个品类下销量排名前 10 的商品,我需要拉出来做专题活动。”你不可能为了这个需求去给商品表加一个“品类内销量排名”字段,因为品类变了、时间范围变了、统计口径变了,排名就得重算。ROW_NUMBER() 正好就是为这类“动态排名”需求设计的。


2. 窗口函数执行逻辑拆解

2.1 一次查询中窗口函数是什么时候计算的

很多开发者容易把窗口函数和 GROUP BY 聚合函数混在一起理解,实际上它们的执行阶段完全不同。SQL 的执行顺序大致是:FROM → WHERE → GROUP BY → HAVING → 窗口函数 → DISTINCT → ORDER BY → LIMIT。

也就是说,窗口函数在 WHERE 过滤完成之后、最终排序和分页之前执行。这个顺序极其重要,它解释了为什么你不能在 WHERE 里直接用 ROW_NUMBER() 的结果来做条件过滤。

-- 这段 SQL 会报错或行为异常 SELECT 客户编号, 订单金额, ROW_NUMBER() OVER (ORDER BY 订单金额 DESC) AS 序号 FROM 订单表 WHERE 序号 = 1;

因为 WHERE 执行的时候,序号这个字段根本还不存在。你需要把带 ROW_NUMBER() 的结果做成子查询或 CTE,再在外面过滤。这就是很多场景下 ROW_NUMBER() 需要“套一层壳”的根本原因。

2.2 排序键的选择会直接影响编号结果

ROW_NUMBER() 给出的编号不仅取决于有没有写 ORDER BY,还取决于 ORDER BY 后面的字段是否足够“稀罕”。如果排序字段有很多重复值(比如订单状态只有“成功/失败”两种),那么相同状态内的行号分配顺序是不确定的,数据库会按某种内部物理顺序来排,可能是插入顺序,也可能是索引顺序,但你不能依赖它。

为了保证编号可复现、可预测,我的习惯是:排序字段尽量使用唯一列或组合唯一列。比如按金额降序排,金额相同的情况下再按下单时间升序,下单时间还相同就按订单 ID 升序。这样每一行的位置就是确定的,编号也就确定了。

ROW_NUMBER() OVER ( PARTITION BY 客户编号 ORDER BY 订单金额 DESC, 下单时间 ASC, 订单编号 ASC ) AS 客户内部序号

三个字段层层递进,最终达到唯一排序,这在数据处理场景里非常重要。特别是后续要基于行号做数据抽样、对账、批量更新时,编号不稳定会直接导致结果无法复现。


3. ROW_NUMBER() 五大高频使用场景

3.1 分组内取前 N 条记录

这个大概是 ROW_NUMBER() 最经典、最常用、面试题里出现频率最高的用法。需求描述永远是那句:“找出每个分类下排名前 N 的商品/用户/订单。”

假设有张销售明细表,字段包括:区域、销售员、销售额。我想找出每个区域销售额最高的前 3 名销售员。SQL 这样写:

WITH 带序号数据 AS ( SELECT 区域, 销售员, 销售额, ROW_NUMBER() OVER ( PARTITION BY 区域 ORDER BY 销售额 DESC ) AS 区域内排名 FROM 销售明细表 ) SELECT 区域, 销售员, 销售额 FROM 带序号数据 WHERE 区域内排名 <= 3;

核心思路分两步:第一步用 PARTITION BY 区域把数据分成若干个独立小组,组内按销售额降序编行号;第二步用 CTE 或子查询包一层,在外层过滤排名小于等于 N 的记录。整个过程逻辑清晰,代码可读性很好。

这里有个细节要注意:如果销售额相同,这个写法可能会漏掉并列排名的记录。比如某个区域第二名和第三名销售额一样,那第三名会排到第四,导致过滤后只返回两个销售员。如果业务上接受这种“排挤并列”的处理,那没问题;如果要求并列都保留,就得改用 RANK() 或 DENSE_RANK()。后面我会专门对比这几个函数的差异。

3.2 全表去重,保留每组最早或最新的一条

数据仓库同步、业务表合并、日志表清洗,几乎每周都会遇到“重复数据”的问题。用 GROUP BY + MIN/MAX 可以拿到一组里的最早时间,但如果你想拿到“最早时间对应的那条完整记录”,GROUP BY 就不好使了,得麻烦地做自连接。而 ROW_NUMBER() 可以让这件事变得无比简洁。

假设某张用户登录日志表重复记录了多次登录事件,我想保留每个用户最早的一条:

WITH 标记重复 AS ( SELECT 用户ID, 登录时间, 登录IP, ROW_NUMBER() OVER ( PARTITION BY 用户ID ORDER BY 登录时间 ASC ) AS 序号 FROM 登录日志表 ) DELETE FROM 登录日志表 WHERE (用户ID, 登录时间) IN ( SELECT 用户ID, 登录时间 FROM 标记重复 WHERE 序号 > 1 );

这里的逻辑是给每个用户的多条记录按时间从早到晚编号,序号为 1 的就是最早的一条,其余全是重复项。如果业务定义“重复”的标准是多字段组合,比如用户 ID + 设备 ID + 日期三个字段都相同才算重复,那把这三个字段一起放进 PARTITION BY 即可。

去重操作我特别提醒一句:DELETE 大表前务必先 SELECT 检查重复数量,并且加好备份。线上删除不可逆,这个习惯能救命。

3.3 替代传统分页写法,统一查询逻辑

很多数据库支持 LIMIT/OFFSET 语法来做分页,但这类写法在窗口函数出现之前,不同数据库的实现差异很大。SQL Server 用 OFFSET FETCH,Oracle 老版本还得用 ROWNUM 嵌套三层子查询,可读性非常差。而 ROW_NUMBER() 提供了一套相对统一的分页思路。

WITH 分页数据 AS ( SELECT 文章ID, 标题, 发布时间, ROW_NUMBER() OVER ( ORDER BY 发布时间 DESC ) AS 全局序号 FROM 文章表 ) SELECT 文章ID, 标题, 发布时间 FROM 分页数据 WHERE 全局序号 BETWEEN 11 AND 20;

上面这段查询返回的是按发布时间倒序排列后的第 11 到第 20 条记录,也就是第二页数据。配合前端传入的页码和页大小,可以封装成一个通用 SQL 模板:

WHERE 全局序号 BETWEEN (:页码 - 1) * :页大小 + 1 AND :页码 * :页大小

这种分页方式虽然也能用,但我个人在项目里更推荐使用数据库原生的 LIMIT/OFFSET 或键集分页,性能更好。ROW_NUMBER() 分页的优势在于跨数据库迁移时语法一致性高,但在某些大偏移量场景下性能并不理想。分页这块要根据具体数据库特性权衡。

3.4 给明细数据标记“第几条”,辅助业务排序展示

运营后台经常需要展示“某个活动下用户购买商品的顺序”,比如“前 100 名下单用户享受优惠”。这时候订单表里可能没有直接记录“这是该用户第几笔订单”,但用 ROW_NUMBER() 可以轻松算出来。

SELECT 用户ID, 下单时间, ROW_NUMBER() OVER ( PARTITION BY 用户ID ORDER BY 下单时间 ASC ) AS 该用户第几笔订单 FROM 订单表 WHERE 下单时间 >= '2024-01-01';

得到这个编号后,你可以很容易统计首单用户数、复购用户数、第三单平均间隔时间等等。这类“按用户维度标记事件次序”的分析需求,在用户行为分析、漏斗转化、RFM 模型构建中频繁出现。

3.5 数据对比与差异计算

ROW_NUMBER() 还常被用来做相邻行对比。比如我要算用户两次登录之间的时间间隔,就得先把用户的登录记录按时间排序编号,然后把第 N 条和第 N+1 条做关联对比。这种场景用 ROW_NUMBER() 生成序号后,再自关联就能轻松实现。

WITH 登录序列 AS ( SELECT 用户ID, 登录时间, ROW_NUMBER() OVER ( PARTITION BY 用户ID ORDER BY 登录时间 ASC ) AS 登录序号 FROM 登录记录表 ) SELECT a.用户ID, a.登录时间 AS 本次登录时间, b.登录时间 AS 下次登录时间, DATEDIFF(b.登录时间, a.登录时间) AS 间隔天数 FROM 登录序列 a LEFT JOIN 登录序列 b ON a.用户ID = b.用户ID AND a.登录序号 = b.登录序号 - 1;

这种“序列自关联”的模式在处理连续事件间隔、会话划分、路径分析时非常好用。它比遍历游标高效得多,也是窗口函数相对传统写法的又一个显著优势。


4. ROW_NUMBER()、RANK()、DENSE_RANK() 的辨析与选型

4.1 三兄弟到底差在哪

这三个窗口函数长得很像,参数结构完全一样,但编号逻辑却有本质差异。很多初学者把它们搞混,面试和笔试也特别喜欢出这类对比题。

我用一个简单例子来说明差异。假设有四个学生的成绩分别是 95、90、90、85,按成绩排名:

  • ROW_NUMBER():结果为 1、2、3、4。它不考虑并列,两个人都是 90 分,也会被强行分出先后。
  • RANK():结果为 1、2、2、4。成绩相同的人排名相同,但下一个名次会跳过,形成“跳号”。
  • DENSE_RANK():结果为 1、2、2、3。成绩相同的人排名相同,下一个名次不跳号,是紧凑的连续排名。

三者的核心区别可以用一句话概括:ROW_NUMBER 是物理排序后的位置编号,RANK 是带间隔的竞赛排名,DENSE_RANK 是不带间隔的竞赛排名。

4.2 选型建议

在业务开发里,怎么选这三个函数完全取决于业务语义:

ROW_NUMBER()适合场景是“必须选出 N 条记录,不能多不能少”。比如取每个分类下的前 10 个商品,你希望恰好返回 10 条,即使第 10 和第 11 个商品销售额一样,也只能忍痛割爱。这种场景 ROW_NUMBER() 是最合适的。

RANK()适合“奖金池要按名次发”的场景。比如第一名奖金 10000,第二名奖金 5000,第三名奖金 3000,两个并列第二就会占掉两个第二的名额,第三名就没人了。这种“占位式”排名逻辑就要用 RANK()。

DENSE_RANK()适合“只要有相同成绩,就享受同等待遇,且后续名次连续”的场景。比如游戏赛季段位、会员等级,只要达到某个区间就算同一档,后面的名次还是要连续算下去。

我见过有团队做排行接口时直接默认用 ROW_NUMBER(),结果产品经理发现两个相同分数的用户被强制分出先后,引发投诉。选错排名函数是业务事故,不是技术错误。接到排行榜需求,第一件事就是问清楚:“相同值怎么处理?”

4.3 同分场景下的补充排序策略

如果业务明确要求“相同分数按时间先后排”,那 ROW_NUMBER() 反而能派上用场。比如积分排行榜中,积分相同的情况下先达成的用户排前面,SQL 可以写成:

ROW_NUMBER() OVER ( ORDER BY 积分 DESC, 达成时间 ASC ) AS 排名

这种处理方式既保留了 ROW_NUMBER() 确定性编号的特性,又满足了业务对并列数据的细分排序规则。在实际开发中,通过往 ORDER BY 里追加辅助字段来打破并列,是让 ROW_NUMBER() 发挥价值的关键技巧。


5. 性能分析与优化实践

5.1 窗口函数为什么“慢”

很多开发者第一次在几百万行的大表上跑 ROW_NUMBER() 时会被执行时间吓到。原因在于,窗口函数需要在内存或临时磁盘空间中对数据做一次全量排序。即使你只需要每组的前 3 条,数据库也得先把所有行的排序键算出来,再按分区分配编号。这种“先算全量,再取局部”的特性,注定了它比简单的 WHERE 过滤要昂贵得多。

举个直观对比:一条普通带 WHERE 的查询,只要索引命中,可能只需扫描几千行。但一条带 ROW_NUMBER() 的查询,如果 WHERE 过滤后还有 50 万行,就必须对 50 万行做排序。排序的复杂度大约是 O(n log n),数据量翻倍,耗时可能不是翻倍,而是接近四倍。

5.2 索引设计的关键作用

想要加快 ROW_NUMBER() 的执行,核心思路是让排序操作尽可能利用索引的有序性,而不是数据库临时排序。索引本身是有序的数据结构,如果 ORDER BY 的字段顺序能和某个索引的键顺序匹配,数据库就可以直接从索引中按顺序读取数据,省掉显式排序这一步。

比如经常要执行:

SELECT 用户ID, 订单金额, ROW_NUMBER() OVER ( PARTITION BY 用户ID ORDER BY 下单时间 DESC ) FROM 订单表 WHERE 用户ID = 10086;

那么可以建立一个 (用户ID, 下单时间 DESC) 的复合索引。查询时先按用户 ID 定位到对应分区,再按下单时间逆序扫描,数据库就能高效地逐行分配编号。分区键放前面,排序键放后面,这是窗口函数索引设计的通用口诀。

5.3 尽量避免在大结果集上直接套窗口函数

有些糟糕的 SQL 写法会把 ROW_NUMBER() 套在一个非常宽泛的查询上,比如先关联两张几百万行的表,再在关联结果集上编号。这种写法不仅占用大量临时空间,还会让排序时间急剧上升。我的优化习惯是:先尽可能通过 WHERE、JOIN 条件把数据规模压缩,再去编号。

如果业务需要的是“每个用户最近一条订单”,除了 ROW_NUMBER(),还可以考虑用关联子查询、LATERAL JOIN、甚至数据库特有的 DISTINCT ON 语法来实现。不同方案性能差异可能很大,特别是当用户量极大且每个用户订单量极不均衡时,ROW_NUMBER() 的全量排序方案未必是最优解。

经验之谈:我曾经处理过一个慢查询,线上耗时 20 多秒。分析后发现,最内层有个关联查询把一份宽表扩大了三倍,再对扩大后的数据做 ROW_NUMBER()。后来把关联条件前移、先对明细表做分区编号再关联,整个查询降到 2 秒以内。优化窗口函数的核心永远不只是看窗口函数本身,而是看它处理的数据集有多大。

5.4 并行度与临时空间

排序操作吃内存,内存不够就 spill 到磁盘,磁盘排序速度比内存慢一个量级。如果你的服务器配置了较大的 work_mem(PostgreSQL)或内存授权(SQL Server),窗口函数性能会有明显改善。反之,如果看到执行计划里有 Sort 操作且伴随大量磁盘写入,就要考虑增大内存参数或拆分查询逻辑。

实际工作中,我会用 EXPLAIN 查看执行计划里的 Sort 行。如果 Sort 的 actual time 占整个查询执行时间的一半以上,就值得针对它做优化。通过调整排序列顺序、增加合适索引、缩小分区数据量,通常能很快把 Sort 开销降下来。


6. 常见坑位与排查经验

6.1 序号从 1 开始还是从 0 开始

这个是新人必踩的坑。ROW_NUMBER() 的编号永远从 1 开始,不是 0。有没有 OFFSET 又不同?我告诉你,标准 ROW_NUMBER() 没有指定起始值的能力,也不支持跳过指定行数。如果你需要从 0 开始,可以在外层手动减 1:

ROW_NUMBER() OVER (ORDER BY 发布时间 DESC) - 1 AS 从零开始的序号

某些数据库提供了其他函数来精细控制,比如有的数据库有ROW_NUMBER的变种或附带START WITH子句,但这类用法可移植性差,我建议少用。保持标准写法,后续换数据库时能省很多事。

6.2 WHERE 过滤窗口函数结果导致的错误

前面提到过,WHERE 执行阶段窗口函数还没算出来,所以不能直接在 WHERE 里用别名过滤。常见错误写法:

SELECT 用户ID, ROW_NUMBER() OVER (PARTITION BY 用户ID ORDER BY 订单时间 DESC) AS 用户订单序号 FROM 订单表 WHERE 用户订单序号 = 1;

这个写法在大多数数据库里会直接报“找不到列”之类的错误。正确做法是包一层子查询或 CTE:

WITH 临时表 AS ( SELECT 用户ID, ROW_NUMBER() OVER (PARTITION BY 用户ID ORDER BY 订单时间 DESC) AS 用户订单序号 FROM 订单表 ) SELECT * FROM 临时表 WHERE 用户订单序号 = 1;

这个“先编号,再过滤”的两层结构是窗口函数最经典的用法,一定要记牢。

6.3 PARTITION BY 和 GROUP BY 混淆

PARTITION BY 不会改变查询返回的行数,它只是在每一行旁边附加一个编号;GROUP BY 会把多行压缩成一行。这个区别特别容易在写复杂报表时搞混。比如想求每个用户的订单总数:

  • GROUP BY 写法:SELECT 用户ID, COUNT(*) FROM 订单表 GROUP BY 用户ID,返回每个用户一行。
  • PARTITION BY 写法:SELECT 用户ID, COUNT(*) OVER (PARTITION BY 用户ID) FROM 订单表,返回每一行订单都附带该用户的订单总数。

它们返回的行数完全不同。如果你不想要明细行,只想看每个用户的总数,用 GROUP BY;如果你需要明细行同时还要知道总数占比,用 PARTITION BY。两种语法服务于不同场景,不冲突,但思路要清晰。

6.4 排序字段含 NULL 值导致排序异常

数据库对 NULL 的排序规则并不统一,有的默认 NULL 最大,有的默认 NULL 最小。当排序字段允许为 NULL 时,ROW_NUMBER() 的编号结果可能不是你预期的那样。比如按“审批时间 DESC”排序,未审批的记录审批时间是 NULL,不同数据库里它们可能排在最前面还是最后面,行为不一致。

我的解决方式是:显式控制 NULL 的位置。多数数据库提供了 NULLS FIRST / NULLS LAST 语法:

ROW_NUMBER() OVER ( ORDER BY 审批时间 DESC NULLS LAST )

如果没有这类语法,可以通过表达式转换,比如用 COALESCE 把 NULL 替换成一个极大或极小的日期值来控制位置。这个坑在数据统计口径容易出问题,要提前想好业务上 NULL 应该放在哪里。

6.5 大偏移量分页效率低下

用 ROW_NUMBER() 做分页时,如果页码很大,比如要取第 100000 页的数据,编号依然会从头排到第 100000 页的位置,前面那么多行全都白算了。这种场景下,ROW_NUMBER() 分页性能很差。更优方案是键集分页,即记住上一页最后一条数据的排序键值,用 WHERE 条件直接定位。

-- 上一页最后一条的排序键是 发布时间='2024-01-15 10:00:00' SELECT 文章ID, 标题, 发布时间 FROM 文章表 WHERE 发布时间 < '2024-01-15 10:00:00' ORDER BY 发布时间 DESC LIMIT 10;

这种方式天然跳过了前面的所有行,时间复杂度低得多。如果你的分页场景需要支持深度翻页,建议优先考虑键集分页,而不是依赖 ROW_NUMBER()。

6.6 维护性:别名和可读性

窗口函数写在 SQL 里,如果 OVER() 内的表达式特别长,SQL 会变得又长又难读。我的习惯是把窗口定义提取出来,使用 WINDOW 子句(部分数据库支持):

SELECT 用户ID, 订单时间, ROW_NUMBER() OVER w AS 序号, SUM(订单金额) OVER w AS 累计金额 FROM 订单表 WINDOW w AS (PARTITION BY 用户ID ORDER BY 订单时间);

这样既避免了重复写一长串 OVER() 表达式,也让后续维护窗口定义变得集中可控。遇到复杂的多个窗口函数共用同一分区排序规则时,WINDOW 子句能显著提升代码整洁度。


7. 几个高级应用的思维扩展

7.1 间隙与岛屿问题

ROW_NUMBER() 最经典的高级应用之一就是解决“连续区间”问题。比如要找出用户连续登录的天数区间,思路是:先把登录日期按用户分组排序得到行号,再用登录日期减去行号得到一个新的日期值,如果日期是连续的,减出来的结果是同一个日期。

WITH 登录数据 AS ( SELECT 用户ID, 登录日期, ROW_NUMBER() OVER ( PARTITION BY 用户ID ORDER BY 登录日期 ) AS 连续编号 FROM 登录记录表 GROUP BY 用户ID, 登录日期 ), 分组标记 AS ( SELECT 用户ID, 登录日期, DATE_SUB(登录日期, INTERVAL 连续编号 DAY) AS 分组标识 FROM 登录数据 ) SELECT 用户ID, MIN(登录日期) AS 连续开始日期, MAX(登录日期) AS 连续结束日期, COUNT(*) AS 连续天数 FROM 分组标记 GROUP BY 用户ID, 分组标识;

这段 SQL 在计算连续登录天数时非常经典。核心洞察就是:日期减去连续编号,连续区间会映射到同一个分组标识。这是 ROW_NUMBER() 帮助解决时序分析问题的典型案例。

7.2 数据采样与均匀抽样

在 AB 测试、模型训练样本抽取中,经常需要从每个类别里等比例抽样。ROW_NUMBER() 可以给每个类别内的样本编号,然后按编号比例筛选。

比如用户分为 A、B、C 三类,希望每类各取 10% 的样本。可以先在每个类别内随机排序编号,再取编号落在前 10% 的行:

WITH 编号样本 AS ( SELECT 用户ID, 用户类别, ROW_NUMBER() OVER ( PARTITION BY 用户类别 ORDER BY RAND() ) AS 随机序号, COUNT(*) OVER (PARTITION BY 用户类别) AS 类别总数 FROM 用户表 ) SELECT 用户ID, 用户类别 FROM 编号样本 WHERE 随机序号 <= 类别总数 * 0.1;

这种写法的好处是抽取比例随时可调,并且保证每个类别都有代表样本,不会出现纯随机抽样中某类样本过少或缺失的情况。

7.3 配合聚合函数做“移动计算”

窗口函数可以同时使用多个,比如 ROW_NUMBER() 和 SUM() 配合,可以实现“分组内累计求和”。在订单分析中,累计金额曲线、按时间推进的累计用户数,都可以通过窗口函数一行实现,不需要复杂的自连接。

SELECT 月份, 销售额, SUM(销售额) OVER (ORDER BY 月份) AS 累计销售额, ROW_NUMBER() OVER (ORDER BY 月份) AS 月份序号 FROM 月度销售表;

这里的 SUM() 窗口函数配合 ROW_NUMBER() 的行号,组合起来就能生成一张带累计逻辑的分析宽表。多个窗口函数叠加是报表开发中非常实用的技巧,值得花时间熟悉。


8. 一次完整的实战复盘

8.1 需求描述

有次我接到一个数据分析需求:某公司运营部门需要给每个门店的员工按“当月销售额”排出名次,并提取每个门店销售排名前 5 的员工名单,用于月底奖励。同时输出每个员工的当月销售额、门店内排名、全公司排名三个指标。

8.2 实现过程

业务表结构大概是:员工表(员工ID、员工姓名、门店ID)、销售流水表(员工ID、销售日期、销售额)。需要先按月份聚合出每个员工的月销售额,再做门店内排名和全公司排名。

WITH 月度销售 AS ( SELECT 员工ID, 门店ID, DATE_FORMAT(销售日期, '%Y-%m') AS 月份, SUM(销售额) AS 月销售额 FROM 销售流水表 WHERE 销售日期 >= '2024-01-01' AND 销售日期 < '2024-02-01' GROUP BY 员工ID, 门店ID, DATE_FORMAT(销售日期, '%Y-%m') ), 排名数据 AS ( SELECT 员工ID, 门店ID, 月销售额, ROW_NUMBER() OVER ( PARTITION BY 门店ID ORDER BY 月销售额 DESC ) AS 门店内排名, ROW_NUMBER() OVER ( ORDER BY 月销售额 DESC ) AS 全公司排名 FROM 月度销售 ) SELECT 员工姓名, 门店ID, 月销售额, 门店内排名, 全公司排名 FROM 排名数据 LEFT JOIN 员工表 USING (员工ID) WHERE 门店内排名 <= 5 ORDER BY 门店ID, 门店内排名;

这里用到了两个 ROW_NUMBER():一个带 PARTITION BY 做门店内排名,一个不带 PARTITION BY 做全公司排名。同一个查询里可以多次使用窗口函数,各自独立计算,互不干扰。这是窗口函数另一个重要特性:一个查询可以定义多个窗口,每个窗口有自己的分区和排序规则。

8.3 踩过的坑与后续优化

这个需求踩过的坑主要有两个:

第一个是月份数据没做过滤,导致同一员工在不同月份的数据混在一起,排名计算整体错乱。后来在子查询里先过滤日期区间,再做聚合和排名,问题解决。

第二个是员工表有几条无效员工记录,直接 LEFT JOIN 后会出现 NULL 姓名。后来在最终输出前加了一层 NULL 过滤,排除了这些脏数据。

性能方面,销售流水表当时有 800 多万行。聚合后留下的数据量并不大(几千个员工),所以排名本身很快。真正耗时的是月度聚合步骤,靠的是 (销售日期, 员工ID, 门店ID) 复合索引,以及只扫描一月份数据的分区裁剪。排名部分并没有成为瓶颈。


9. 我在实践中的总体体会

ROW_NUMBER() 这个函数语法简单,但应用面极广。从最初的分页、去重,到后来的连续区间分析、数据抽样、排行榜计算,它的身影几乎出现在我处理过的每一个数据需求中。真正理解它之后,你再回头看很多复杂 SQL,会发现底层逻辑都离不开“分区、排序、编号”这三板斧。

如果让我给刚开始学窗口函数的人一个学习路径建议,我会说:先死磕 ROW_NUMBER() 的各种用法,把它玩熟;再用同样的思路去理解 RANK()、DENSE_RANK()、LAG()、LEAD() 等兄弟函数。你会发现它们的语法结构完全一致,只是编号和取值逻辑不同罢了。窗口函数家族的规律,远比想象中统一。

最后分享一个小技巧:写复杂窗口函数 SQL 的时候,先不要一上来就追求一条 SQL 搞定。用 CTE 分步拆解,每一步验证数据量、排序结果、编号结果,再进入下一步。我这些年调试 SQL 的效率提升,很大程度就来自这种“分步推进、逐层验证”的习惯。代码分步,思路清晰,排查问题也就快得多。

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

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

立即咨询