按组筛选 TOP N 是 SQL 查询中非常经典的需求:比如从每个商品分类中找出销量最高的前 3 件商品、从每个部门中找出薪资排名前 5 的员工、或者从每个用户分组中找出最近 10 条日志。这类查询不能简单依靠 GROUP BY,因为 GROUP BY 会把每个组压缩成一行,只能返回聚合结果,无法保留组内多条明细。要实现按组取前 N 条,通常需要借助子查询配合窗口函数,或者使用相关子查询对每一行进行计数和过滤。

按组 TOP N 的查询目标拆解
以销售表 product_sales 为例,假设它包含 category_id、product_id 和 sales_amount 三个字段。如果只需要知道每个分类的总销售额,使用 GROUP BY 很快就能得到结果:
SELECT category_id, SUM(sales_amount) AS total_sales FROM product_sales GROUP BY category_id;
但这种写法只能输出每个分类的聚合信息,没有办法继续保留组内排名靠前的商品列表。按组 TOP N 的本质是先把数据按照某个维度划分成多个子集,再在每个子集内部依据指定字段排序,最后只保留每组的前 N 条记录。这个目标不能直接用普通分组完成,因为普通分组只产生一行聚合结果,而我们需要的是多行明细与排名的结合。
因此,解决方案通常分为两步:第一步计算每条记录在其分组内的排名,第二步根据排名过滤。排名可以借助窗口函数来实现,也可以借助相关子查询统计同组中有多少条记录排在当前行之前。两种方式各有优劣,下面分别展开。
窗口函数加子查询实现
现代关系型数据库普遍支持窗口函数,这让按组筛选前 N 条变得非常简洁。先用 ROW_NUMBER 为每个分组中的行编号,再把这个查询作为子查询,在外层过滤编号。
SELECT category_id, product_id, sales_amount
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
) AS ranked
WHERE rn <= 3;
这里 PARTITION BY category_id 表示按分类分组,ORDER BY sales_amount DESC 表示组内按销售额倒序排列。ROW_NUMBER 会对每个分类从 1 开始依次编号,外层 WHERE rn <= 3 就正好拿到每个分类销售额最高的前 3 条记录。整个查询逻辑清晰,执行计划通常也只需要一次排序或扫描。
不过 ROW_NUMBER 有一个特点:当排序列出现相同值时,它仍然会给每一行分配不同的序号,具体先后顺序由数据库内部决定。这样在边界位置可能漏掉销售额并列的商品。如果业务上要求并列数据必须全部保留,可以改用 DENSE_RANK 或 RANK。
SELECT category_id, product_id, sales_amount
FROM (
SELECT category_id, product_id, sales_amount,
DENSE_RANK() OVER (PARTITION BY category_id ORDER BY sales_amount DESC) AS dr
FROM product_sales
) AS ranked
WHERE dr <= 3;
DENSE_RANK 对相同销售额给出相同排名,并且不会跳过名次。例如销售额为 100、100、90、80 时,DENSE_RANK 分别给出 1、1、2、3,这样前 3 名会包含三条记录。RANK 则会在并列后跳过名次,例如 100、100、90、80 对应 1、1、3、4,使用 RANK 过滤小于等于 3 时会丢掉 80 这条记录。因此在需要严格控制每组返回固定条数时,优先使用 ROW_NUMBER;在需要体现并列逻辑时,使用 DENSE_RANK 或 RANK。
还要注意 ORDER BY 中 NULL 值的处理。不同数据库对 NULL 的默认排序不同,MySQL 默认把 NULL 当作最小值,PostgreSQL 默认升序时 NULL 在最后,降序时 NULL 在最前。如果排序列允许 NULL,建议显式使用 NULLS FIRST 或 NULLS LAST 明确语义,避免结果不符合预期。
相关子查询实现按组 TOP N
如果数据库版本较旧,不支持窗口函数,可以使用相关子查询来模拟排名。思路是逐行统计同组中排序列比当前行更大的记录数,如果这个数量小于 N,说明当前行位于该组的前 N 名。
SELECT p1.category_id, p1.product_id, p1.sales_amount
FROM product_sales p1
WHERE (
SELECT COUNT(*)
FROM product_sales p2
WHERE p2.category_id = p1.category_id
AND p2.sales_amount > p1.sales_amount
) < 3
ORDER BY p1.category_id, p1.sales_amount DESC;
这条语句对每一行 p1 执行一个相关子查询,统计同组中销售额高于当前行的记录数。如果比当前行高的记录少于 3 条,说明当前行排在前三名。相关子查询的优点是兼容性极强,几乎所有 SQL 数据库都能执行;缺点是性能较差,因为外层查询每返回一行候选数据,内层子查询就要执行一次。如果 product_sales 有几十万行,这个查询可能需要执行几十万次小统计。
为了缓解性能问题,可以在 (category_id, sales_amount) 上建立复合索引,帮助子查询快速定位同组数据并完成比较。但即使有索引,相关子查询在大数据量下仍然不如窗口函数高效。对于需要频繁运行的报表或高并发业务,建议优先升级到支持窗口函数的数据库版本。
相关子查询在并列值处理上更接近 DENSE_RANK,因为比较条件是 sales_amount 大于当前值,相同销售额的记录不会被计入更高的数量。举例来说,如果第四名和第三名的销售额相同,它们都会被保留下来,结果可能超过 3 条。如果需要严格按行数截断,可以再加入主键或唯一字段作为次级排序,例如在比较条件中加入 p2.product_id 大于 p1.product_id,但写法会更加复杂。
方案对比、索引优化与防坑建议
将两种实现方式放在一起比较,可以更清楚地看到它们在不同场景下的适用性。
| 实现方案 | 可读性 | 性能 | 并列控制 |
|---|---|---|---|
| 窗口函数加子查询 | 高,代码简洁 | 好,适合大数据量 | 精确,可选用 ROW_NUMBER、RANK、DENSE_RANK |
| 相关子查询 | 中等,嵌套较多 | 较差,逐行统计 | 接近 DENSE_RANK,需额外处理边界 |
索引设计对两种方案都非常重要。窗口函数方案的主要排序和分组发生在 PARTITION BY 和 ORDER BY 阶段,建议在 (category_id, sales_amount) 上建立复合索引,并尽量让索引顺序与查询语义一致。相关子查询同样依赖这个复合索引来加速同组比较。如果排序列还需要进行降序处理,不同数据库对降序索引的支持不同,但通常可以通过调整查询或索引方向获得优化。
除了并列值和 NULL 问题,编写这类查询时还要避免几个常见误区。一是不要试图用简单的 GROUP BY 加 LIMIT 来实现,那只会得到全局前 N 条,不会按组拆分。二是在窗口函数子查询中不要忘记给子查询取别名,否则部分数据库会报错。三是如果排序列是浮点类型,可能存在精度误差,导致排序不稳定,必要时可以使用 DECIMAL 或增加唯一主键作为 tie-breaker。最后,应明确 N 为 0 或负数时的处理策略,虽然多数场景不会出现,但良好的参数校验可以避免返回空结果或异常。