SQL 中获取前 N 条数据通常被简化为 ORDER BY 加 LIMIT 或 TOP,但一旦查询条件包含分组、多维度排名或需要保留并列名次,这种简化写法就会暴露问题。例如,在员工薪资表中查询全公司前 5 名可以直接排序截断;而查询每个部门薪资前 3 名时,LIMIT 只能截断全局结果,无法按部门分别截断。此时需要使用子查询或窗口函数构建更细粒度的比较逻辑。本文围绕子查询与嵌套逻辑展开,介绍如何用纯 SQL 实现稳定的 Top N。
一、相关子查询实现分组 Top N 的原理
相关子查询的核心特点是外层查询的每一行,都会传入内层查询作为条件执行一次。对于每组取前 N 条数据,可以把前 N 条转化为比当前行更优的行数量小于 N。也就是说,统计同一部门中薪资高于当前行的记录数,如果该数量小于 3,则说明当前行属于部门薪资前 3 名。这个思路不需要预先排序,但依赖正确的关联条件和比较方向。
下面以 employee 表为例,表中包含 id、name、department、salary 四个字段,查询每个部门薪资前 3 名的写法如下:
SELECT e1.*
FROM employee e1
WHERE (
SELECT COUNT(*)
FROM employee e2
WHERE e2.department = e1.department
AND e2.salary > e1.salary
) < 3
ORDER BY e1.department, e1.salary DESC;
上面的子查询统计同部门中薪资更高的人数,如果这个人数小于 3,当前行就进入结果集。这里使用的是大于号而不是大于等于号,目的是让并列薪资的所有员工都保留下来。如果业务要求严格只截取 3 行,则需要再加入唯一键作为次级排序条件,否则并列薪资会导致结果不确定。
这种写法的优点是兼容性很强,在 MySQL 5.7、SQL Server 2000 以及一些旧版本数据库中都可以使用。缺点是相关子查询可能造成外层每一行都执行一次内层聚合,数据量大时性能明显下降。实际使用时应优先考虑给 department 和 salary 建立联合索引,同时观察执行计划是否出现多次全表扫描。
二、ROW_NUMBER 窗口函数的写法与排序细节
窗口函数是解决分组 Top N 更直观的方式。ROW_NUMBER() 会为每个分区内按指定排序生成从 1 开始的唯一序号,外部查询再过滤序号小于等于 N 即可。相比相关子查询,窗口函数通常只需要扫描一次或有限次,可读性和性能都更好,尤其在分组较多、数据量较大的场景下优势明显。
使用 ROW_NUMBER() 实现每个部门薪资前 3 名的 SQL 如下:
SELECT *
FROM (
SELECT e.*,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC, id ASC
) AS rn
FROM employee e
) t
WHERE t.rn <= 3;
内层查询通过 PARTITION BY department 按部门分组,再按薪资降序、id 升序生成排名。增加 id 作为次级排序是为了在薪资并列时仍然有确定顺序,避免结果随机变化。外层查询直接过滤 rn <= 3 得到每组前 3 条。
ROW_NUMBER() 会为每行强制生成不同序号,因此当薪资并列时,并列者会被拆成不同排名,取前 3 条时可能只保留部分并列人员。如果业务要求并列名次全部保留,应改用 RANK() 或 DENSE_RANK()。RANK() 给相同值相同名次,下一名次跳号;DENSE_RANK() 不跳号。两种函数的区别会直接影响结果行数。
SELECT *
FROM (
SELECT e.*,
RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS rnk
FROM employee e
) t
WHERE t.rnk <= 3;
使用 RANK() 时,如果同组薪资第 1 名有 2 人,他们都会得到 rnk 为 1,下一人 rnk 为 3。此时过滤 rnk <= 3 会保留所有排名为 1、2、3 的行,包括并列的第 3 名,结果行数可能超过 3 条。这正是保留并列名次的正确处理方式。
三、不同数据库方言下的 Top N 写法差异
SQL 标准支持窗口函数,但不同数据库在基础 Top N 和分页语法上差异明显。SQL Server 使用 SELECT TOP N,MySQL 使用 LIMIT N,Oracle 12c 之前使用 ROWNUM,PostgreSQL 使用 LIMIT 或 FETCH FIRST N ROWS ONLY。当需求从全局 Top N 变成每组前 N 条时,窗口函数在多数现代数据库中写法统一,但旧版本仍然需要相关子查询或用户变量模拟。
下面是几种数据库原生获取全局 Top 5 的写法:
-- SQL Server SELECT TOP 5 * FROM employee ORDER BY salary DESC; -- MySQL / PostgreSQL SELECT * FROM employee ORDER BY salary DESC LIMIT 5; -- PostgreSQL / SQL 标准 SELECT * FROM employee ORDER BY salary DESC FETCH FIRST 5 ROWS ONLY;
在分组场景下,不同数据库对窗口函数的支持版本也要特别注意。MySQL 从 8.0 开始支持 ROW_NUMBER()、RANK()、DENSE_RANK();SQL Server 从 2005 开始支持;PostgreSQL 从 8.4 开始支持;Oracle 从 8i 开始提供分析函数,但语法略有差异。如果运行环境无法使用窗口函数,应优先考虑相关子查询或用连接聚合的方式替代。
四、性能优化与索引设计
子查询和窗口函数虽然能解决功能问题,但数据量增大后性能可能急剧下降。相关子查询通常导致外层每一行都执行内层聚合,如果 employee 表有 100 万行,同一部门薪资比较可能产生大量随机 IO。改进方式之一是把相关子查询改为 JOIN 聚合,例如先按部门计算每个薪资的排名,再关联回原表,这样数据库可以基于临时结果集进一步过滤,减少重复扫描。
窗口函数版本通常在排序和分区上更高效,但仍需要合理索引。对于 PARTITION BY department ORDER BY salary DESC,可以建立 (department, salary DESC, id) 复合索引,帮助数据库减少排序和回表。不同数据库对降序索引支持不同,MySQL 8.0 支持降序索引,PostgreSQL 也支持,SQL Server 则通过索引定义控制键方向。需要避免在排序列上使用函数或表达式,例如 WHERE YEAR(salary_date) = 2024 会导致索引失效。
如果只需要每组前 1 条,还可以使用自连接或 LEFT JOIN 方式,找出同部门中薪资比自己高的行不存在的情况。但这种方式也需要处理并列薪资,否则可能出现重复或遗漏。通用性能优化还包括限制分区大小、对高频数据预聚合、使用物化视图等。实际生产中应根据数据分布和查询频率决定实现方案。
五、常见错误与边界场景
常见错误之一是在子查询中把比较条件写成 e2.salary >= e1.salary,但计数仍使用小于 N。这样当第 3 名有多人并列时,计数会超过 3,导致所有并列者都被排除,最终该组结果不足 3 条。反之,如果业务需要严格只取 3 行,却使用了 > 比较,又可能在第 3 名并列时多返回行。因此必须根据并列策略确定比较方向。
另一个问题是只按 ORDER BY salary DESC 取前 N,没有加稳定排序字段,分页时同一薪资在不同页重复出现。例如 ORDER BY salary DESC LIMIT 10 OFFSET 20 可能因为并列薪资排序不稳定导致重复或遗漏。解决方法是增加唯一键 id 作为次级排序,保证全排序唯一,这样分页结果才能稳定。
空值排序也容易被忽略。不同数据库对 NULL 的默认排序不同:PostgreSQL 默认 ASC 时 NULL 在末尾,DESC 时 NULL 在开头;MySQL 默认 NULL 视为最小值;Oracle 默认 NULL 在末尾。若薪资字段允许 NULL,前 N 条可能被 NULL 占据,与业务预期不符。此时需要显式使用 NULLS FIRST 或 NULLS LAST,或者用 COALESCE 设置默认值。
子查询内部使用 LIMIT 或 TOP 时也容易碰到限制。例如 MySQL 某些版本在子查询中限制 LIMIT 不能使用变量,复杂嵌套时可能报语法错误。编写跨数据库 SQL 时应先确认目标数据库对窗口函数、子查询排序和分页语法的支持程度,避免在测试环境正常、上线后出现版本兼容问题。