SQL Server开发中,经常会遇到这样的查询需求:一张表中存在多条属于同一业务分组的数据,但最终只希望每个分组返回一条记录,例如每个用户最新的一条订单、每个商品最新的一条价格记录、每个部门入职时间最早的一名员工等。
这里的关键并不是简单使用 GROUP BY,而是需要先明确“第一条”的判断标准。通常可以按照时间、编号、排序字段等确定记录顺序。下面结合常见业务场景,介绍 SQL Server 中分组取第一条记录的四种实现方式。
一、使用 ROW_NUMBER() 分组取第一条记录
ROW_NUMBER() 是 SQL Server 中处理“每组取一条记录”最常用的方法之一。它可以按照指定字段对每个分组内部的数据重新编号,然后只保留编号为 1 的记录。
假设存在订单表:
CREATE TABLE Orders
(
Id INT PRIMARY KEY,
UserId INT,
OrderNo VARCHAR(50),
CreateTime DATETIME,
Amount DECIMAL(10, 2)
);现在要求每个用户只查询最新的一条订单,可以这样写:
SELECT Id, UserId, OrderNo, CreateTime, Amount
FROM
(
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY UserId
ORDER BY CreateTime DESC, Id DESC
) AS rn
FROM Orders
) t
WHERE rn = 1;这里最重要的是:
PARTITION BY UserId它表示按照 UserId 对数据进行分组。
而:
ORDER BY CreateTime DESC, Id DESC表示每个用户内部按照订单创建时间倒序排列,最新的数据排在最前面。
最后:
WHERE rn = 1只保留每个分组中的第一条记录。
如果需求变成“每个用户最早的一笔订单”,只需要调整排序方向:
ROW_NUMBER() OVER (
PARTITION BY UserId
ORDER BY CreateTime ASC, Id ASC
)为什么推荐增加 Id 作为第二排序条件
实际项目中,时间字段并不一定唯一。例如同一个用户可能在同一秒创建多条订单。如果只写:
ORDER BY CreateTime DESC那么时间相同的记录之间没有明确的先后关系,最终哪一条被分配为 rn = 1 可能并不稳定。
因此更推荐:
ORDER BY CreateTime DESC, Id DESC先按照业务字段排序,再使用具有唯一性的字段作为兜底排序条件。
二、使用 RANK() 或 DENSE_RANK() 保留并列第一条
有些场景下,“第一条”的含义并不是只返回一条记录,而是希望保留排序字段相同的所有并列第一名。
例如查询每个用户最新时间对应的所有订单,可以使用 RANK():
SELECT Id, UserId, OrderNo, CreateTime, Amount
FROM
(
SELECT *,
RANK() OVER (
PARTITION BY UserId
ORDER BY CreateTime DESC
) AS rk
FROM Orders
) t
WHERE rk = 1;假设用户 1001 存在以下数据:
Id UserId CreateTime
1 1001 2026-09-28 10:00:00
2 1001 2026-09-29 11:00:00
3 1001 2026-09-29 11:00:00使用 ROW_NUMBER() 后,只会得到 Id 为 2 或 3 中的一条。
而使用 RANK():
RANK() OVER (
PARTITION BY UserId
ORDER BY CreateTime DESC
)Id 为 2 和 3 的记录都会获得排名 1,因此两条都会被查询出来。
RANK() 和 DENSE_RANK() 有什么区别
两者都会保留并列排名,但后续排名的编号规则不同。
例如某个分组的排序值为:
100
100
90
80RANK() 得到:
1
1
3
4DENSE_RANK() 得到:
1
1
2
3如果只查询:
WHERE rank_num = 1两者通常都可以满足“保留并列第一名”的需求。
因此,如果业务要求每个分组严格只返回一条数据,应优先考虑 ROW_NUMBER();如果业务要求保留所有并列第一的数据,则可以考虑 RANK() 或 DENSE_RANK()。
三、使用 CROSS APPLY 实现每组 TOP 1
SQL Server 还可以使用 CROSS APPLY 配合 TOP 1 实现分组取第一条记录。
例如先获取所有用户:
SELECT DISTINCT UserId
FROM Orders;然后针对每个用户查询最新的一条订单:
SELECT u.UserId,
o.Id,
o.OrderNo,
o.CreateTime,
o.Amount
FROM
(
SELECT DISTINCT UserId
FROM Orders
) u
CROSS APPLY
(
SELECT TOP 1
Id,
OrderNo,
CreateTime,
Amount
FROM Orders
WHERE Orders.UserId = u.UserId
ORDER BY CreateTime DESC, Id DESC
) o;CROSS APPLY 的特点是,外层每得到一个 UserId,内部查询都会针对当前用户执行一次逻辑上的 TOP 1 查询。
其中:
SELECT TOP 1负责限制返回数量,而:
WHERE Orders.UserId = u.UserId负责限制当前分组。
最后通过:
ORDER BY CreateTime DESC, Id DESC确定哪一条记录是当前用户的第一条记录。
CROSS APPLY 的另一个优势
如果需要从主表关联其他数据,CROSS APPLY 有时会写得更加直观。
例如有用户表:
CREATE TABLE Users
(
Id INT PRIMARY KEY,
UserName VARCHAR(100)
);查询所有用户以及他们最新的一笔订单:
SELECT
u.Id,
u.UserName,
o.OrderNo,
o.CreateTime,
o.Amount
FROM Users u
OUTER APPLY
(
SELECT TOP 1
OrderNo,
CreateTime,
Amount
FROM Orders o
WHERE o.UserId = u.Id
ORDER BY o.CreateTime DESC, o.Id DESC
) o;这里使用的是 OUTER APPLY,而不是 CROSS APPLY。
两者的区别与 INNER JOIN、LEFT JOIN 有一定相似之处:
CROSS APPLY:内部查询没有匹配结果时,外层对应记录不会返回。OUTER APPLY:即使内部查询没有结果,外层记录仍然保留,内部字段显示为NULL。
因此,如果要求“所有用户都必须显示,即使用户没有订单”,通常使用 OUTER APPLY 更合适。
四、使用聚合函数结合 JOIN 实现分组取第一条
传统 SQL 写法中,还可以先通过 GROUP BY 和 MIN() 或 MAX() 找出每个分组的目标值,再与原表进行关联。
例如查询每个用户最新订单:
SELECT o.*
FROM Orders o
INNER JOIN
(
SELECT UserId, MAX(CreateTime) AS MaxCreateTime
FROM Orders
GROUP BY UserId
) t
ON o.UserId = t.UserId
AND o.CreateTime = t.MaxCreateTime;内部查询:
SELECT UserId, MAX(CreateTime) AS MaxCreateTime
FROM Orders
GROUP BY UserId;负责找到每个用户最大的订单时间。
外层再根据:
o.UserId = t.UserId
AND o.CreateTime = t.MaxCreateTime找到对应的完整订单记录。
这种写法比较容易理解,而且适合很多传统 SQL 场景。
不过它存在一个需要特别注意的问题:如果同一个用户存在多条订单具有完全相同的最大时间,那么查询结果会返回多条记录。
例如:
UserId CreateTime
1001 2026-09-29 12:00:00
1001 2026-09-29 12:00:00MAX(CreateTime) 只能确定最大时间,却无法进一步确定究竟应该保留哪一条。
如果业务要求严格返回一条,就需要增加唯一字段参与判断,或者直接使用 ROW_NUMBER()。
五、四种方法的实际区别
这四种实现方式虽然都能够解决分组取第一条记录的问题,但适用场景并不完全相同。
ROW_NUMBER()
适合大多数“每组严格取一条”的业务需求。
典型场景包括:
每个用户最新订单
每个商品最新价格
每个设备最新状态
每个部门最新员工记录
每个客户最后一次操作记录
通用模板如下:
SELECT *
FROM
(
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY 分组字段
ORDER BY 排序字段 DESC
) AS rn
FROM 表名
) t
WHERE rn = 1;RANK() / DENSE_RANK()
适合需要保留并列第一记录的情况。
例如:
SELECT *
FROM
(
SELECT *,
RANK() OVER (
PARTITION BY 分组字段
ORDER BY 排序字段 DESC
) AS rk
FROM 表名
) t
WHERE rk = 1;如果相同排序值对应多条记录,查询结果会全部保留。
CROSS APPLY
适合 SQL Server 环境中需要针对每个外层数据执行 TOP 1 查询的场景,尤其是在与主表关联时比较灵活。
典型写法:
SELECT ...
FROM 主表 a
CROSS APPLY
(
SELECT TOP 1 ...
FROM 明细表 b
WHERE b.GroupId = a.Id
ORDER BY b.CreateTime DESC
) b;如果外层数据必须全部保留,可以改成:
OUTER APPLYGROUP BY + MAX/MIN + JOIN
这种方式结构传统、容易理解,适合根据某个最大值或最小值反查原始记录。
例如:
SELECT o.*
FROM Orders o
JOIN
(
SELECT UserId, MAX(CreateTime) AS MaxCreateTime
FROM Orders
GROUP BY UserId
) t
ON o.UserId = t.UserId
AND o.CreateTime = t.MaxCreateTime;不过需要注意并列值导致多行返回的问题。
六、如何选择合适的实现方法
如果只是需要“每个分组严格取一条记录”,通常可以优先考虑 ROW_NUMBER()。
例如:
ROW_NUMBER() OVER (
PARTITION BY UserId
ORDER BY CreateTime DESC, Id DESC
)这种写法表达清晰,而且能够明确指定分组和排序规则。
如果业务定义的“第一条”允许并列,则可以使用:
RANK()如果查询逻辑本身就是“针对每一个外层对象取 TOP 1”,例如查询每个用户的最新订单,那么 CROSS APPLY 或 OUTER APPLY 往往更加自然。
如果只是根据某个最大时间或最小值反查数据,并且能够保证目标字段唯一,那么 GROUP BY + MAX/MIN + JOIN 也非常实用。
七、分组取第一条时最容易忽略的问题
1. 没有明确排序条件
下面这种写法存在问题:
ROW_NUMBER() OVER (
PARTITION BY UserId
)虽然部分 SQL 场景可以执行,但没有指定排序规则,就无法准确表达“哪一条是第一条”。
必须明确业务规则,例如:
ORDER BY CreateTime DESC代表最新一条。
或者:
ORDER BY CreateTime ASC代表最早一条。
2. 排序字段存在重复值
例如:
ORDER BY CreateTime DESC如果多个记录的 CreateTime 相同,就可能出现不确定的记录选择。
更稳妥的写法是:
ORDER BY CreateTime DESC, Id DESC通过唯一字段进一步确定顺序。
3. 不要把 GROUP BY 当成取第一条记录的方法
很多初学者会写:
SELECT UserId, MAX(CreateTime)
FROM Orders
GROUP BY UserId;这只能得到每个用户的最大时间,并不能直接得到该时间对应的其他字段。
例如不能简单写成:
SELECT UserId, OrderNo, MAX(CreateTime)
FROM Orders
GROUP BY UserId;因为 OrderNo 没有参与聚合,也没有出现在 GROUP BY 中。
正确的处理方式可以是窗口函数,或者先聚合再 JOIN。
4. TOP 1 必须配合 ORDER BY
单独使用:
SELECT TOP 1 *
FROM Orders;并不能表达“最新一条”或“最早一条”。
如果需要最新记录,应写:
SELECT TOP 1 *
FROM Orders
ORDER BY CreateTime DESC, Id DESC;TOP 1 负责限制数量,ORDER BY 才负责确定哪条记录排在第一位。
八、性能优化建议
数据量较大时,分组取第一条的性能会明显受到索引设计影响。
以:
ROW_NUMBER() OVER (
PARTITION BY UserId
ORDER BY CreateTime DESC, Id DESC
)为例,可以结合实际查询条件考虑创建索引:
CREATE INDEX IX_Orders_UserId_CreateTime_Id
ON Orders(UserId, CreateTime DESC, Id DESC);如果查询还需要返回 OrderNo、Amount 等字段,可以根据实际情况考虑使用 INCLUDE:
CREATE INDEX IX_Orders_UserId_CreateTime_Id
ON Orders(UserId, CreateTime DESC, Id DESC)
INCLUDE (OrderNo, Amount);不过索引并不是越多越好。创建之前应该结合数据量、查询频率、写入压力以及执行计划进行分析。
对于 CROSS APPLY + TOP 1 场景,合理的分组字段和排序字段索引同样重要。例如:
CREATE INDEX IX_Orders_UserId_CreateTime
ON Orders(UserId, CreateTime DESC, Id DESC);能够帮助 SQL Server 更快定位指定用户对应的最新记录。
九、一个完整的实际案例
假设订单表中包含:
Id
UserId
OrderNo
Status
CreateTime
Amount现在需要查询每个用户最后创建的一笔订单,并且如果创建时间相同,则取 Id 最大的记录。
推荐使用:
WITH OrderRank AS
(
SELECT
Id,
UserId,
OrderNo,
Status,
CreateTime,
Amount,
ROW_NUMBER() OVER
(
PARTITION BY UserId
ORDER BY CreateTime DESC, Id DESC
) AS rn
FROM Orders
)
SELECT
Id,
UserId,
OrderNo,
Status,
CreateTime,
Amount
FROM OrderRank
WHERE rn = 1;这种写法把“分组”“排序”“取第一条”三个逻辑拆分得非常清楚。
如果需要查询每个用户最早的一笔订单,只需要修改排序:
ORDER BY CreateTime ASC, Id ASC如果需要查询每个用户最新时间下的所有订单,则可以改成:
RANK() OVER
(
PARTITION BY UserId
ORDER BY CreateTime DESC
)然后筛选:
WHERE rk = 1十、总结
SQL Server 中分组取第一条记录,核心在于先确定分组字段,再明确“第一条”的排序规则。常见实现方式主要有四种:ROW_NUMBER()、RANK()/DENSE_RANK()、CROSS APPLY 以及 GROUP BY + MAX/MIN + JOIN。
对于绝大多数“每组只需要一条记录”的业务,ROW_NUMBER() 是非常通用的方案:
ROW_NUMBER() OVER (
PARTITION BY 分组字段
ORDER BY 排序字段 DESC, 唯一字段 DESC