SQL Server中分组取第一条记录的四种实现方法

5 次阅读

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
80

RANK() 得到:

1
1
3
4

DENSE_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:00

MAX(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 APPLY

GROUP 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