广告位 · 页头(后台-模块管理-新建模块后替换此区域)

SQL如何获取分组中第二名的数值?窗口函数配合子查询两种实现思路

分组查询是SQL里非常基础的操作,配合聚合函数能快速拿到每组的最大值、最小值、平均值。但如果需求变成“取每组第二名”,事情就稍微复杂了一点。排名不像聚合那样直接,而且不同数据库对窗口函数的支持程度也不一样。这篇文章会把两种常见方案拆开讲清楚,一种是纯粹用子查询加比较条件,另一种是利用窗口函数生成排名序号再筛选,两种思路各有适用场景。

SQL如何获取分组中第二名的数值?窗口函数配合子查询两种实现思路

方案一:子查询配合聚合与比较条件

在不支持窗口函数的旧版本数据库里,想取每组第二名通常得靠子查询。核心逻辑是:对于目标表的每一行,统计同组内比它大的值有多少个。如果比它大的值恰好只有一个,那这一行就是第二名。这个思路可以用相关子查询实现。

比如有一张员工表 employee,包含 name、department、salary 三个字段。要找出每个部门工资第二高的员工,可以这样写:

SELECT e1.department, e1.name, e1.salary
FROM employee e1
WHERE (
    SELECT COUNT(DISTINCT e2.salary)
    FROM employee e2
    WHERE e2.department = e1.department
      AND e2.salary > e1.salary
) = 1;

这段代码里,外层查询遍历 employee 的每一行,内层子查询统计同部门中工资比当前行高的人数。用 COUNT(DISTINCT e2.salary) 是为了处理并列工资的情况,如果两个人都拿同样的工资且都是第二高,那 COUNT 会正确返回 1,表示只有一种更高的工资值。如果直接 COUNT(*) 会把并列高工资的人数也算进去,导致误判。当统计结果等于 1 时,说明当前行的工资在部门内排名第二。

这种写法的优点是对数据库版本没有要求,MySQL 5.x、SQL Server 2008、Oracle 老版本都能跑。但缺点也很明显:相关子查询会对每一行都执行一次内部统计,数据量大的时候性能非常差。如果 employee 表有十万行,子查询就要扫描十万次,时间复杂度接近 O(n²)。另外,如果某个部门只有一个人,或者所有人工资都一样,这种写法会直接返回空集,因为不满足“比它高的值恰好一个”的条件,这取决于业务上是否希望显示第二名,通常来说没有第二名就不显示是合理的。

方案二:窗口函数生成排名序号再筛选

从 MySQL 8.0、PostgreSQL 8.4、SQL Server 2005 开始,主流数据库都支持了窗口函数。窗口函数最大的好处是可以在不折叠分组的情况下计算排名,然后外层查询直接按排名序号过滤,逻辑清晰得多。

先用 ROW_NUMBER() 给每个部门内部的员工按工资降序编号:

SELECT
    department,
    name,
    salary,
    ROW_NUMBER() OVER (
        PARTITION BY department
        ORDER BY salary DESC
    ) AS rn
FROM employee;

这个查询会给每个部门单独编号,工资最高的员工 rn=1,第二高的 rn=2,以此类推。有了这个中间结果,只要再套一层查询把 rn=2 的行取出来就行:

SELECT department, name, salary
FROM (
    SELECT
        department,
        name,
        salary,
        ROW_NUMBER() OVER (
            PARTITION BY department
            ORDER BY salary DESC
        ) AS rn
    FROM employee
) AS ranked
WHERE rn = 2;

这种写法比方案一快得多,因为窗口函数的计算是在一次扫描中完成的,数据库优化器会使用排序或哈希分组来高效处理,不需要对每一行做独立子查询。尤其是数据量达到几十万行以上时,性能差异可能达到几十倍甚至上百倍。

不过要注意 ROW_NUMBER 和 RANK、DENSE_RANK 的区别。ROW_NUMBER 对并列值会随机分配不同序号,比如两个工资相同且都是部门内最高,ROW_NUMBER 会让其中一个得1,另一个得2,这样取 rn=2 就只返回一个人,而不是两个并列第二。如果业务上希望并列第二名都显示出来,应该使用 DENSE_RANK:

SELECT department, name, salary
FROM (
    SELECT
        department,
        name,
        salary,
        DENSE_RANK() OVER (
            PARTITION BY department
            ORDER BY salary DESC
        ) AS rnk
    FROM employee
) AS ranked
WHERE rnk = 2;

DENSE_RANK 在遇到相同排序值时不会跳号,排名连续。如果最高工资有两个人,他们 rnk 都是1,第二高的工资 rnk 就是2,所有拿第二高工资的人都会返回。如果希望并列第一名之后立刻是第三名而不是第二名,则用 RANK,它会跳号。三种排名函数的选择直接决定了结果集的形状,写之前最好先明确业务对并列值的处理规则。

另外,窗口函数和 GROUP BY 的配合也要留意。窗口函数是在 GROUP BY 之后、SELECT 阶段之前计算的,所以如果查询里既有分组聚合又有窗口函数,窗口函数的 PARTITION BY 和 ORDER BY 要基于分组后的结果来写。例如想按部门统计总工资后再取总工资第二高的部门,就不能直接在 employee 表上用窗口函数按 department 排名,而是要先 GROUP BY 再在子查询里套窗口函数。

两种方案对比与避坑指南

方案一依赖相关子查询和 COUNT 比较,写法上比较绕,但胜在通用性极强,适合维护老系统或者数据量不大的临时查询。方案二利用窗口函数,代码可读性和执行效率都明显占优,但要求数据库版本至少支持窗口函数。如果你的生产环境还停留在 MySQL 5.7 或更低版本,方案一几乎是唯一选择;如果已经升级到 8.0 及以上,强烈建议用方案二。

实际使用中还有几个常见的坑。第一个坑是并列值处理,上一节已经讲过 ROW_NUMBER 和 DENSE_RANK 的区别,选错函数会导致结果漏掉或重复。第二个坑是空分组问题,如果某个分组内只有一条记录,窗口函数方案中 rn=2 就不存在该组,结果集不会包含这个部门;如果业务上要求即使没有第二名也要补一行空值,需要额外用 LEFT JOIN 配合分组基准表来实现,不能直接靠窗口函数解决。第三个坑是排序规则,ORDER BY salary DESC 里如果 salary 有 NULL 值,不同数据库的排序行为不同,PostgreSQL 默认 NULL 最大,MySQL 默认 NULL 最小,这会影响排名结果,建议用 ORDER BY salary DESC NULLS LAST(如果数据库支持)或者用 COALESCE 把 NULL 转成明确值。

还有一个性能上的细节:窗口函数方案虽然整体扫描一次,但如果 PARTITION BY 的列基数特别高且数据量巨大,排序会消耗大量内存和临时磁盘空间。这时可以考虑为 department 和 salary 建立组合索引,让优化器使用索引排序代替全表排序。索引顺序最好和窗口函数的 PARTITION BY、ORDER BY 一致,例如建 (department, salary DESC) 的索引。不过要注意,窗口函数中的排序方向在索引里使用 DESC 时,不同数据库对降序索引的支持程度不同,MySQL 8.0 开始支持降序索引,但 5.7 不支持,需要额外验证。

最后提一下复杂场景的扩展:如果要取每组第 N 名,只需要把 WHERE 条件里的 rn 或 rnk 改成 N 即可;如果要取每组前 N 名,就把等于号改成小于等于。窗口函数方案在这种动态取值需求下扩展非常方便,方案一则要改动子查询里的计数条件,相对笨重。因此从长期维护角度出发,能上窗口函数就尽量上窗口函数,把排名结果物化成临时表或 CTE 后再做业务过滤,代码会更清晰。

广告位 · 文章底部(后台-模块管理替换此区域)