如何用 SQL 查询每组 Top N?
简化版
每组 Top N 通常用窗口函数:按分组字段 PARTITION BY,按排名规则 ORDER BY,生成 ROW_NUMBER/RANK/DENSE_RANK,外层过滤排名小于等于 N。
详细版
典型写法:
SELECT *
FROM (
SELECT t.*,
ROW_NUMBER() OVER (
PARTITION BY category_id
ORDER BY score DESC, id DESC
) AS rn
FROM products t
) x
WHERE rn <= 3;
PARTITION BY category_id 表示每个分类单独排名,ORDER BY score DESC, id DESC 表示按分数从高到低,同分时按 id 兜底。ROW_NUMBER 会强行给每行唯一序号;如果要并列名次,可以考虑 RANK 或 DENSE_RANK。
面试中要讲清三点:每组 Top N 不是全局 LIMIT N;排序必须稳定;并列分数时要根据业务选择 ROW_NUMBER/RANK/DENSE_RANK。
完整版教学
一、每组 Top N 和全局 Top N 不一样
全局 Top N 是整个结果集只取前 N 行。
SELECT *
FROM products
ORDER BY score DESC
LIMIT 3;
每组 Top N 是每个分组都取 N 行。例如 10 个分类,每个分类取前 3 个商品,最多返回 30 行。
分类 A -> Top 3
分类 B -> Top 3
分类 C -> Top 3
这类需求常见于“每个部门薪资前三”“每个分类销量前十”“每个用户最近三条记录”。
记忆钩子:全局 Top N 只有一个排行榜;每组 Top N 是每个分组各有一张排行榜。
二、窗口函数是最清晰写法
窗口函数可以在不压缩明细行的情况下,为每行计算组内排名。
SELECT *
FROM (
SELECT e.*,
ROW_NUMBER() OVER (
PARTITION BY department_id
ORDER BY salary DESC, id DESC
) AS rn
FROM employees e
) t
WHERE rn <= 3;
执行逻辑:
按 department_id 分区
-> 每个分区内部按 salary DESC, id DESC 排序
-> 给每行编号 rn
-> 外层保留 rn <= 3
这样既保留员工明细,又能按部门分别取前三。
三、ROW_NUMBER、RANK、DENSE_RANK 怎么选
并列分数会影响结果数量,所以排名函数选择非常重要。
| 函数 | 并列处理 | 示例分数 100,100,90 | 取 <=2 的结果 |
|---|---|---|---|
ROW_NUMBER | 每行唯一序号 | 1,2,3 | 2 行 |
RANK | 并列同名次,跳号 | 1,1,3 | 2 行 |
DENSE_RANK | 并列同名次,不跳号 | 1,1,2 | 3 行 |
如果业务说“每个部门取 3 个人”,用 ROW_NUMBER。如果业务说“取排名前三,允许并列”,可能用 DENSE_RANK 或 RANK 更符合语义。
代码示例:
SELECT *
FROM (
SELECT e.*,
DENSE_RANK() OVER (
PARTITION BY department_id
ORDER BY salary DESC
) AS rk
FROM employees e
) t
WHERE rk <= 3;
四、排序规则必须稳定
如果用 ROW_NUMBER,同分数据的顺序会决定谁进入 Top N。只写 ORDER BY score DESC 时,同分行的先后可能不稳定。
ROW_NUMBER() OVER (
PARTITION BY category_id
ORDER BY score DESC, id DESC
)
这里 id DESC 是兜底排序键,让同分行也有确定顺序。比如第 3 名边界有 5 个同分商品,ROW_NUMBER 必须选出其中一部分,兜底键能让结果可复现。
如果业务要求同分都展示,就不要用 ROW_NUMBER 硬截断,而是换成 RANK 或 DENSE_RANK。
五、为什么 GROUP BY 不能直接解决
GROUP BY 会把每组压成一行,而 Top N 需要保留组内多行明细。
SELECT department_id, MAX(salary)
FROM employees
GROUP BY department_id;
上面只能得到每个部门最高工资,拿不到对应员工的完整信息,更拿不到前三名。
如果你再把 name 放进 SELECT,会遇到非分组列来源不明确的问题。窗口函数的优势是“不合并行,只给行加组内指标”。
GROUP BY:多行 -> 每组一行
窗口函数:多行 -> 仍是多行,但多一列排名
六、老版本数据库的替代方案
如果数据库不支持窗口函数,可以用相关子查询或自连接模拟,但可读性和性能通常较差。
一种思路是计算“比当前行分数高的行数”:
SELECT p.*
FROM products p
WHERE (
SELECT COUNT(*)
FROM products p2
WHERE p2.category_id = p.category_id
AND (
p2.score > p.score
OR (p2.score = p.score AND p2.id > p.id)
)
) < 3;
这相当于保留组内排在前 3 的行。若每组有 1000 行,这种相关子查询可能很重,因此有窗口函数时应优先使用窗口函数。
七、常见误区与追问
- 误区:
LIMIT 3就是每组前三。LIMIT默认作用于全局结果集,不会按组重置。 - 误区:
GROUP BY可以直接返回每组前三条明细。GROUP BY会压缩行,不适合保留多条明细。 - 误区:同分场景不用考虑。 同分会影响 Top N 边界,必须选择合适排名函数。
- 误区:
ROW_NUMBER和RANK完全一样。ROW_NUMBER不保留并列,RANK会保留并列名次并跳号。 - 追问:每个用户最近一条记录怎么写? 用
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC, id DESC),取rn=1。 - 追问:如何让结果稳定? 在排序中追加唯一键,例如
ORDER BY score DESC, id DESC。
八、加强记忆
每组 Top N 的公式是:分区、排序、编号、过滤。PARTITION BY 定义“每组”,ORDER BY 定义“Top 的规则”,排名函数定义“并列怎么处理”,外层 WHERE rn <= N 才是真正取结果。