先说一个几乎每个做数据的人都会遇到的需求:我有一张订单表,一个用户下了好几单,要取出每个用户最近的一单。你写SQL写了半天,可能用自连接、用子查询、用MAX(create_time),绕来绕去还容易出错。后来我用上了ROW_NUMBER()窗口函数,一句话就搞定了,从此再也回不去。
这是一篇写给所有写SQL的人看的经验总结,不管是数据分析师、后端开发还是刚入门的新手。内容不会只讲语法,我会把执行原理、典型实战、踩过的坑、以及不同数据库的兼容差异一次说清楚。
1. ROW_NUMBER() 窗口函数是什么:从分组取最新这条需求说起
1.1 从需求出发认识这个函数
先别急着看定义,我们从一个具体需求入手。假设有一张登录日志表login_log,字段是user_id、login_time,我想知道每个用户最近一次登录是什么时候。用传统写法,大概要这样:
SELECT l1.user_id, l1.login_time FROM login_log l1 INNER JOIN ( SELECT user_id, MAX(login_time) AS max_time FROM login_log GROUP BY user_id ) l2 ON l1.user_id = l2.user_id AND l1.login_time = l2.max_time;这种写法有三个让人头疼的地方:如果同一用户在同一秒有多次登录,会因为login_time相同而查出重复记录,还得再套一层去重;如果MAX值对应的行不止一条,你拿不到“最近”之外的其他列细节;如果需求变成“取最近三笔记录”,这个写法当场废掉。
用ROW_NUMBER()之后就变成了:
SELECT user_id, login_time FROM ( SELECT user_id, login_time, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_time DESC) AS rn FROM login_log ) t WHERE rn = 1;当我在给团队做SQL培训时,我经常把窗口函数比作“给每一行发一个标签”:在保留所有原始行的前提下,按你指定的分组方式给组内的每一行编上序号。这个“标签”不会像GROUP BY那样把多行揉成一行,所以它能保留所有细节,这是窗口函数最大的价值。
1.2 语法结构与关键参数拆解
ROW_NUMBER()的语法其实非常短:
ROW_NUMBER() OVER ([PARTITION BY 分组字段] [ORDER BY 排序字段])它由三块拼成,每一块都有自己的分工:
- ROW_NUMBER():函数本体,作用是生成从1开始的连续整数编号。
- PARTITION BY:可选,指定“按什么分组”。不写它,则把整个结果集当成一个大组,全部行一起编号;写了它,则每个分组独立编号。
- ORDER BY:必填(虽然标准SQL里给了默认值,但实际几乎所有数据库都要求写),指定组内排序规则,决定了哪一行得到1。
这里有个新手经常忽略的细节:OVER子句里的ORDER BY和查询最外层的ORDER BY完全是两回事。OVER里的ORDER BY只决定窗口计算时怎样排,不会影响最终结果的输出顺序。我见过不少同事以为写了PARTITION BY和ORDER BY,输出的行就会自动排好,结果发现结果集乱糟糟。
1.3 适用场景清单
从我的使用经验看,ROW_NUMBER()最常干的活有几类:
- 分组去重,保留每组最新或最旧的一条。
- 分组TOP-N,取每组排名前几条。
- 生成行号,做分页或者按行存取。
- 结合其他窗口函数,计算相邻记录的差值、跳转次数。
- 辅助解决“取第N条记录”这类面试题。
这个函数能解决的我总结为一句:当你要对行做编号、但又不想让行数变少时,就用它。反过来,如果你要的是某个聚合指标(求和、平均值),那该用GROUP BY或者SUM() OVER(),别混着用。
2. 执行原理:窗口函数在SQL查询流程中的哪个环节起作用
2.1 SQL各步骤执行顺序回顾
很多写SQL的人其实不知道,SQL并不是按书写顺序执行的。完整执行顺序大概是FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT。窗口函数发生在哪一步?它在WHERE和GROUP BY之后,SELECT阶段之前(更准确地说是在SELECT阶段计算,但逻辑上基于WHERE之后的结果集)。
你只要记住一个关键点:WHERE是看不到窗口函数的。很多人写SQL时习惯这样:
SELECT user_id, login_time, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_time) AS rn FROM login_log WHERE rn = 1;这一定会报错,因为WHERE执行的时候,rn这个由窗口函数生成的编号还不存在。正确做法是先包一层子查询,在外面过滤:
SELECT * FROM ( SELECT user_id, login_time, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_time DESC) AS rn FROM login_log ) t WHERE t.rn = 1;这种“内层编号、外层过滤”是我在项目里写过最多的套路。它看起来多了一层嵌套,但逻辑非常清晰,而且在子查询里先把不需要的大批数据过滤掉,再在外层筛选,性能通常也能接受。
2.2 PARTITION BY和ORDER BY的分工
要理解窗口函数,可以先把它类比成Excel里的“分列汇总”。PARTITION BY相当于“先按列拆表”,ORDER BY相当于“在拆好的表里重新排顺序”。ROW_NUMBER()就在这个顺序上从1开始一颗颗发号码。
有一个值得说透的细节:PARTITION BY不写时,所有行是同一个组;ORDER BY不写时,很多数据库会有默认行为,但这个默认行为并不可靠,也不保证顺序稳定。实际开发中,我几乎从不省略ORDER BY,即使我认为某张表的物理顺序已经符合需要,也还是显式写出来。因为一旦数据量变大、分区分文件存储,物理顺序就会变得不可控。
2.3 为什么是“先排序再编号”
如果你仔细琢磨ROW_NUMBER()的结果,会发现它本质上是一个动态计算:先按指定键排好序,再给每行一个行号。这意味着,同一个查询里如果你改变了ORDER BY升降序,编号分配就会完全反转。
拿订单表举例:
SELECT order_id, user_id, amount, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY amount DESC) AS amount_rank_desc, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY amount ASC) AS amount_rank_asc FROM orders;同一行在两个窗口里得到的编号完全相反。这就是窗口计算的特性:它不修改原始数据,只是在计算时生成一列临时结果。这也是为什么在同一个SELECT里你可以同时写多个窗口函数,各自独立计算,互不干扰。
3. 高频实战:五类典型用法直接抄
这一节我把自己压箱底的实际用例贴出来,都是可以直接复制到生产环境改个表名就能用的。
3.1 分组去重,保留最新一条
这是ROW_NUMBER()在生产环境露面最多的场景。做数仓ETL时,每天要拉取增量数据,经常出现同一业务主键重复的情况,需要按主键去重并保留最新状态:
DELETE FROM target_table WHERE id IN ( SELECT id FROM ( SELECT id, updated_time, ROW_NUMBER() OVER(PARTITION BY business_id ORDER BY updated_time DESC) AS rn FROM target_table ) t WHERE t.rn > 1 );先编号,再删掉编号大于1的重复行。这套逻辑用在数据清洗上非常稳。还有一种常见写法是把它包在子查询里,然后只取rn=1的记录生成新表。无论哪种,原则都一样:用PARTITION BY定义“什么是重复”,用ORDER BY定义“哪条算最新”。
3.2 分组TOP-N排行
比如一个后台系统要展示每个品类销量前三的商品:
SELECT category_id, product_id, sales_amount, rn FROM ( SELECT category_id, product_id, sales_amount, ROW_NUMBER() OVER(PARTITION BY category_id ORDER BY sales_amount DESC) AS rn FROM product_sales ) t WHERE rn <= 3;这个需求用GROUP BY没法做,因为你需要拿到前三名的完整记录,而不是一个聚合值。又比如排行榜页面要拿每个歌手热度最高的5首歌,窗口函数都是最直接的解法。
3.3 通用分页查询
在没有OFFSET关键字或者想统一风格的数据库里,可以用ROW_NUMBER()做分页:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER(ORDER BY create_time DESC) AS row_id FROM articles ) t WHERE row_id BETWEEN 21 AND 40;这种写法在所有支持窗口函数的数据库里通用,不会因为OFFSET关键字的差异而报错。当然,大数据量下建议结合索引或游标方案,但中小规模系统完全能用。
3.4 计算相邻记录时间差
另一个冷门但实用的用法:用ROW_NUMBER()生成会话序号,再配合LAG()计算每一步之间间隔。比如分析用户在一个页面里的操作步长:
SELECT user_id, action_time, action_time - LAG(action_time) OVER(PARTITION BY session_id ORDER BY action_time) AS time_diff FROM user_actions;这里LAG()取同组上一行的值,和ROW_NUMBER()配合使用能算出每步间隔,定位那些长时间停顿的异常点。
3.5 连续登录天数判断
在用户分析里,要判断用户是否连续登录N天,这曾经是一道棘手问题。结合ROW_NUMBER()可以巧妙解决:先按用户分组、按日期排序编号,再用日期减去编号天数,如果登录是连续的,差值也连续:
SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS duration FROM ( SELECT user_id, login_date, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_date) AS rn FROM login_log ) t GROUP BY user_id, DATE_SUB(login_date, INTERVAL rn DAY);这个思路的本质是:连续序列里,日期和编号的差值保持不变。ROW_NUMBER()在这里不是直接解决问题,而是帮助我们发现数据中的隐含分组。
4. ROW_NUMBER() 与 RANK()、DENSE_RANK() 到底差在哪
4.1 三个函数的返回结果对比
这个坑我踩过,面试也没少考。先看一张模拟成绩表:
| 学生 | 科目 | 分数 |
|---|---|---|
| A | 数学 | 90 |
| B | 数学 | 85 |
| C | 数学 | 85 |
| D | 数学 | 70 |
三个函数在按分数降序编号时:
| 学生 | 分数 | ROW_NUMBER() | RANK() | DENSE_RANK() |
|---|---|---|---|---|
| A | 90 | 1 | 1 | 1 |
| B | 85 | 2 | 2 | 2 |
| C | 85 | 3 | 2 | 2 |
| D | 70 | 4 | 4 | 3 |
区别一目了然:
- ROW_NUMBER()不管分数是否重复,硬性分配1、2、3、4,行号永远不重复。
- RANK()在有相同分数时分配相同排名,但会留出空档,也就是1、2、2、4这种。
- DENSE_RANK()同样允许并列,但不留空档,也就是1、2、2、3这种。
4.2 该选哪个函数:判断标准
我从实际业务出发给大家一个选择思路:
- 如果只是“取前N条、且N条都要、不考虑并列只想要物理行号”,选ROW_NUMBER()。
- 如果要做“并列名次且要体现真实名次差距”(比如竞赛排名、奖学金评定),选RANK()。
- 如果要做“连续的名次表”(比如等级划分、段位计算),选DENSE_RANK()。
举个例子,报表里展示“销售额前十的商品”,如果两个商品销售额并列第十,用ROW_NUMBER()只会输出一个商品,另一个被挤到第十一。这种情况下如果业务方要求“并列都要”,就得用RANK()或DENSE_RANK()。我在做报表时就因为这个差异被业务方找过,后来参数化配置让业务方选排名规则,才彻底避免来回改。
5. 常见坑位与排查心得
5.1 ORDER BY不唯一导致结果不稳定
这是一个比较隐蔽的坑。如果ORDER BY的字段有重复值,ROW_NUMBER()对重复值之间的排列顺序并不保证稳定。比如:
ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY create_time)同一个时间点多条记录,它们的先后顺序由执行计划的物理顺序决定,可能每次跑都不一样。为了解决这个问题,我在排序字段后面通常会加一个绝对唯一键作为决胜条件:
ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY create_time DESC, id DESC)加了个id之后,每行的排序键组合唯一,结果就完全确定了。
5.2 PARTITION BY字段为NULL时要注意
当分区字段是NULL时,大多数数据库会把所有NULL行归为一组,而不是排除在外。有个同事在去重时没注意这一点,结果所有历史旧数据因为某个字段为空,被当成同一个用户处理,主键全被删掉了。
建议在实际操作前先用COUNT检查分区字段的空值情况,或者在WHERE中显式排除空值。
5.3 在子查询还是直接使用
窗口函数直接写在SELECT里并不会报错,但如果你想在同一个查询里按窗口结果过滤,就必须要包一层子查询。很多新手在这里反复出错。
我推荐的习惯是:不管需不需要过滤,只要用了窗口函数,就把整个查询包进子查询,外层做条件筛选。这样既符合逻辑顺序,也方便排查问题。
5.4 性能十条经验
窗口函数如果在全表上执行,代价不小。结合我调优过的案例,几个经验供参考:
- 尽量在进入窗口计算前把数据范围缩小,比如用WHERE把不需要的分区记录先滤掉。
- 分组字段要有合适的索引支持,否则每个分区都要独立排序。
- 不要在一个查询里机械地叠加多个窗口函数,能合并计算的合并计算。
- 在数据仓库场景,可以先在增量表上过滤再关联维度表,避免窗口函数跑在超大连接结果上。
- 注意排序字段的类型转换,隐式转换会破坏索引使用。
- 对于超大表,尽量用分区表配合分区裁剪,把窗口函数的扫描范围降到最低。
- 如果只是为了去重,可以先尝试用聚合去重,未必需要窗口函数。
- 涉及上亿行数据时,不要直接在OLTP库跑窗口函数,考虑在离线数仓跑。
- 将窗口函数和GROUP BY混合使用时,先确认你想要的粒度是行级还是分组级。
- 如果同一段数据既要做窗口计算又要在外层多次过滤,可以把中间结果落成临时表,避免重复计算。
6. 多平台兼容与版本迁移指南
6.1 主流数据库支持情况
窗口函数在主流数据库中已经不是新特性了,但支持程度有差异:
| 数据库 | 版本要求 | 备注 |
|---|---|---|
| MySQL | 8.0及以上 | 早期版本不支持窗口函数 |
| PostgreSQL | 8.4及以上 | 支持完整窗口函数体系 |
| SQL Server | 2005及以上 | 名为ROW_NUMBER(),语法相同 |
| Oracle | 9i及以上 | 早期称为分析函数,支持最完整 |
| SQLite | 3.25及以上 | 支持基础窗口函数 |
| Hive | 2.0及以上 | 支持,但排序字段建议显式指定 |
| Spark SQL | 2.0及以上 | 支持,注意大数据分桶下的性能 |
如果你的团队还在用MySQL 5.7,就得考虑替代方案。
6.2 MySQL 8.0之前如何模拟
在MySQL 5.7时代,模拟ROW_NUMBER()的常用方法是用户变量。要点是按分组字段排序后,用变量实现行号重置:
SELECT @rn := IF(@prev_user_id = user_id, @rn + 1, 1) AS rn, @prev_user_id := user_id AS user_id, login_time FROM login_log, (SELECT @rn := 0, @prev_user_id := NULL) vars ORDER BY user_id, login_time DESC;这里最关键的一步是:必须先确保结果是按user_id、login_time排好序的,变量重置才能生效。实际使用时,这种模拟写法比真正的窗口函数更容易出现排序问题,数据一乱,编号就全错了。所以升级到8.0是我一直推荐的方案。
6.3 大数据组件中的注意事项
在Hive或Spark SQL中使用ROW_NUMBER()时,除了语法本身,还要注意数据分布。PARTITION BY字段如果倾斜严重,某个key的数据量巨大,排序压力会集中到少数任务上。我的经验是:先做数据倾斜处理,必要时给分区键加盐,再进行窗口计算。
另一个细节是Spark SQL的全局排序问题。窗口函数的ORDER BY默认是全局的,但在分布式框架里,它会触发shuffle操作。大批量计算时,要评估shuffle的代价,如果只是为了去重,不妨先聚合一次。
新版本Spark SQL中对窗口函数的优化已经比较成熟,但历史版本中存在内存溢出风险。生产环境建议先在小数据量上验证,再调度大任务。
写在最后的实践体会
干这行写了这么多年SQL,我最大的感受是,窗口函数听起来只是一个语法点,但它其实重塑了你拆解数据问题的思维方式。遇到“取每个组前几条”“算相邻差”“判断连续性”这些需求,你不再会想用复杂的多表关联去碰运气,而是第一时间想到:给每行发个号,一切就通了。
我自己在项目里最常用到的组合是ROW_NUMBER()配合子查询过滤,这个模式几乎每周都要写好几遍,所有团队的新人我也都要求他们先把这一套练熟。这里再送一个经验:写完了窗口函数查询,先用小数据集肉眼验证编号是否符合预期,再放到全量数据上跑。因为窗口函数的语法错误在SQL编辑器里查不出来,只有数据结果会告诉你问题出在哪。