在业务库设计中,经常遇到这样的场景:订单表里有多个用户的多笔记录,现在要取出每个用户最早的那一笔完整订单信息。很多人第一反应是GROUP BY用户ID再取MIN时间,但这样只能得到时间和用户ID,订单金额、地址等字段就丢了。使用ROW_NUMBER()窗口函数配合PARTITION BY,可以在不破坏行结构的前提下为同组数据进行编号,从而精准拿到每种分类的最小记录。

ROW_NUMBER与PARTITION BY的底层逻辑
窗口函数与聚合函数最大的不同在于,它不会把多行压缩成一行,而是为每一行计算一个基于“窗口”的结果。PARTITION BY的作用类似GROUP BY,它把数据按指定列分成若干个区,每个区内部独立计算。ROW_NUMBER()则在每个分区内按照ORDER BY给出的规则,从1开始给每一行分配一个唯一的连续整数,相同排序值也会被分配不同序号,这是它和RANK、DENSE_RANK的核心区别。
当我们把ORDER BY设置为分类内想要“最小”的那个字段升序,比如按创建时间升序,那么编号为1的行自然就是该分类中时间最小的那条记录。数据库在执行时通常会对分区列排序,如果分区列和排序列有复合索引,排序操作可以免去或者大幅降低开销。理解这一点,就能明白为什么这种写法既清晰又容易被优化。
需要注意的是,ROW_NUMBER()必须搭配OVER子句,且OVER里不能缺少ORDER BY,否则语法会报错。有些数据库允许用ORDER BY (SELECT 1)来绕过排序需求,但那样拿到的编号顺序是未定义的,不能用于取最小记录。正确的做法永远是显式声明你认定“最小”的业务字段排序。
标准写法与完整代码示例
最常见的模式是把窗口函数放在子查询或公用表表达式(CTE)里,外层再过滤rn=1。下面以用户订单表orders为例,表中有user_id、order_id、create_time、amount等字段,我们要取每个用户最早的一笔订单。
WITH ranked_orders AS (
SELECT
user_id,
order_id,
create_time,
amount,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY create_time ASC
) AS rn
FROM orders
)
SELECT
user_id,
order_id,
create_time,
amount
FROM ranked_orders
WHERE rn = 1;
上面的代码先通过CTE给每个用户的订单按时间升序编号,然后外层只选编号等于1的记录。这样返回的结果中,每个user_id只出现一次,且附带了完整的订单字段。如果数据库不支持CTE,也可以写成派生表形式:把WITH部分放进FROM后面的括号里起别名,逻辑完全一致。
假如最小记录的定义不是时间最早,而是金额最低,只需把ORDER BY create_time ASC改成ORDER BY amount ASC即可。如果希望时间相同的情况下再按order_id升序保证稳定,可以写成ORDER BY create_time ASC, order_id ASC。这种灵活性是GROUP BY写法很难优雅实现的,因为GROUP BY无法在聚合同时决定其他字段的取舍规则。
与传统取最小记录方案的对比
在窗口函数普及之前,开发者常用两种方法:一是关联子查询,在WHERE里写create_time = (SELECT MIN(create_time) FROM orders o2 WHERE o2.user_id = o1.user_id);二是先GROUP BY拿最小时间再JOIN回原表。这两种写法在数据量大时都有明显短板。关联子查询会对外部每一行执行一次子查询,即使有索引也容易形成嵌套循环,百万数据下延迟极高。
GROUP BY再JOIN的方案虽然能把聚合结果物化,但JOIN本身需要一次哈希或归并连接,且如果同一用户同一时间有多条记录,JOIN后会返回重复行,还要额外加 DISTINCT 或再次用ROW_NUMBER处理。而直接用ROW_NUMBER一次性编号,只需一遍排序和扫描,逻辑上也避免了重复行问题。在主流数据库如 PostgreSQL、SQL Server、MySQL 8.0+ 的优化器里,这种写法通常能生成更优的执行计划。
从可维护性角度看,窗口函数把“分组”和“排序取头”的意图直接写在列定义中,后来者读代码时能立刻明白业务含义。而嵌套子查询或多次JOIN容易让SQL变得冗长,改需求时容易漏改某处条件。因此在新项目或支持窗口函数的数据库环境中,优先采用ROW_NUMBER配合分区是更合理的工程选择。
常见误区与边界情况
一个容易踩的坑是误用RANK或DENSE_RANK代替ROW_NUMBER。如果分类内最小值的字段有重复,RANK会给并列最小的行都标为1,导致最后选出多于一条记录,这在“每种分类只取一条”的需求里是错误的。只有ROW_NUMBER能严格保证每组最多一条,代价是重复值时具体取哪条由排序的后续字段或数据库实现决定,业务上应补充分隔字段消除歧义。
另一个边界是空值处理。在ORDER BY ASC时,不同数据库对NULL的排序位置不同,有的把NULL视为最小,有的视为最大。若分类字段或排序字段可能为空,应显式用COALESCE或NULLS FIRST/LAST语法固定顺序,否则“最小记录”的含义会在环境迁移时发生变化。此外,分区列如果有NULL,所有NULL会被分到同一个区,若业务上NULL代表未知而非同一类,需提前过滤或单独处理。
最后提醒,窗口函数计算结果发生在WHERE之后、SELECT之前,所以不能直接在WHERE里写ROW_NUMBER()>1这样的过滤,必须套一层查询。这也是为什么示例中用了CTE或派生表。掌握这个执行顺序,才能把ROW_NUMBER稳定用在各类取头、取尾、分页去重场景中。
SQLROW_NUMBERpartition_by修改时间:2026-08-14 14:30:29