返回

SQL分组统计:子查询能否取代GROUP BY?(含替代方案)

mysql

避开 GROUP BY?用子查询统计各大洲人口超千万国家数量,行得通吗?

写 SQL 时,碰到需要按类别分组统计的需求,比如“统计每个大洲人口超过 1000 万的国家数量”,第一反应通常是 GROUP BY。但有时可能会想:能不能换个思路,比如用子查询来搞定,从而绕开 GROUP BY

假设我们有这样一个 world 表:

-- world 表结构示例
-- continent (VARCHAR): 大洲名称
-- population (BIGINT): 国家人口
-- name (VARCHAR): 国家名称

问题是:为每个大洲(continent)显示其名称以及人口至少为 1000 万的国家数量。

有人尝试了下面这个使用子查询的 SQL 语句,试图不使用 GROUP BY 来解决:

SELECT x.continent,
       count(x.name) as Total
from world AS x
WHERE 10000000 <= ALL  (select y.population
                        from world AS y
                        WHERE y.continent=x.continent
                        )

这段代码看着似乎有点关联查询的意思,但它真的能实现我们的目标吗?咱们来仔细分析分析。

一、这个子查询错在哪了?

这段 SQL 的核心问题出在 WHERE 10000000 <= ALL (...) 这部分逻辑上。

  1. ALL 的作用:
    ALL 关键字要求左侧的值(这里是 10000000)必须满足与子查询返回的 所有 值的比较关系。在这个例子里,10000000 <= ALL (subquery) 意味着 1000 万必须小于或等于子查询 (select y.population from world AS y WHERE y.continent=x.continent) 返回的 每一个 人口数。

  2. 子查询返回什么?
    对于外层查询 world AS x 的每一行,内层子查询会选出与 x 所在大洲 (x.continent) 相同的所有国家的人口 (y.population)。

  3. WHERE 条件的实际效果:
    结合起来看,WHERE 10000000 <= ALL (...) 这个条件只有在一种情况下才会为真:当前处理的国家 x 所属的大洲里,所有国家 的人口都 大于或等于 1000 万。只要该大洲有一个国家的人口少于 1000 万,那么 ALL 条件就不成立,这个国家 x 所在的行就不会被选中。

  4. 聚合函数 COUNT() 的问题:
    即使 WHERE 条件筛选出了一些行(也就是那些所有国家人口都 >= 1000万的大洲对应的国家行),外层的 SELECT x.continent, count(x.name) as Total 也有问题。在没有 GROUP BY 子句的情况下使用聚合函数 COUNT(),它通常会计算满足 WHERE 条件的 所有 行的总数,并且只返回 一行 结果。至于 x.continent 会显示哪个大洲,这取决于具体的 SQL 数据库实现,通常是这些满足条件的行中的任意一个大洲,并不能按预期那样为每个大洲分别计数。

总结一下: 这个查询并不能统计“每个大洲人口超千万的国家数量”。它实际上是在尝试筛选出那些“所有国家人口都超过千万的大洲”,并且统计方式也有误,无法得到分大洲的计数结果。

二、正确的解法:经典的 GROUP BY

要准确解决这个问题,最直接、清晰且高效的方法就是使用 GROUP BY

1. 使用 WHERE + GROUP BY

这是最常见的做法。先用 WHERE 过滤出人口大于等于 1000 万的国家,然后按大洲分组计数。

  • 原理:

    • WHERE population >= 10000000:首先筛选出所有人口达到千万级别的国家记录。
    • GROUP BY continent:将筛选后的结果按照 continent 字段进行分组,每个大洲形成一个独立的组。
    • SELECT continent, COUNT(name):对每个分组,计算该组内的国家数量(COUNT(name)COUNT(*) 都可以),并选出对应的大洲名称。
  • 代码示例:

SELECT continent, COUNT(name) AS NumberOfCountries
FROM world
WHERE population >= 10000000
GROUP BY continent;

这种方法逻辑清晰,执行效率通常也比较高,数据库系统对 GROUP BY 操作有专门的优化。

2. 使用 GROUP BY + SUM(CASE WHEN ...) (更灵活)

有时,我们可能想在一个查询里同时展示总国家数和满足条件的国家数。这时可以用条件聚合。

  • 原理:

    • GROUP BY continent:按大洲分组。
    • SUM(CASE WHEN population >= 10000000 THEN 1 ELSE 0 END):对每个分组内的每一行进行判断。如果人口满足条件,CASE 表达式返回 1,否则返回 0。SUM() 将这些 1 和 0 加起来,就得到了该大洲满足条件的国家数量。
  • 代码示例:

SELECT
    continent,
    COUNT(name) AS TotalCountriesInContinent, -- 该大洲总国家数(如果需要)
    SUM(CASE WHEN population >= 10000000 THEN 1 ELSE 0 END) AS CountriesOver10M
FROM world
GROUP BY continent;

这种方式更灵活,可以在一次分组查询中完成多种条件的统计。

三、不用 GROUP BY 的替代方案?

虽然 GROUP BY 是标准解法,但如果硬要寻找不显式使用 GROUP BY 的替代方案(比如出于学习目的或特定场景约束),确实存在一些方法,但它们往往更复杂或效率较低。

1. 使用相关子查询 (Correlated Subquery) 在 SELECT 子句中

可以在 SELECT 语句中使用一个相关子查询来为每个大洲计算满足条件的国家数。但这需要先获取所有不重复的大洲列表。

  • 原理:

    • SELECT DISTINCT continent FROM world: 先获取一个不重复的大洲列表。
    • 对于这个列表中的每一个大洲 x.continent,执行一个相关的子查询:(SELECT COUNT(*) FROM world y WHERE y.continent = x.continent AND y.population >= 10000000)。这个子查询会专门去 world 表里查找当前大洲 x.continent 下,人口超过 1000 万的国家,并返回其数量。
  • 代码示例:

SELECT
    x.continent,
    (SELECT COUNT(y.name)
     FROM world AS y
     WHERE y.continent = x.continent AND y.population >= 10000000
    ) AS NumberOfCountries
FROM (SELECT DISTINCT continent FROM world) AS x;

或者,不使用派生表 x

SELECT DISTINCT -- 确保每个大洲只输出一次
    w1.continent,
    (SELECT COUNT(w2.name)
     FROM world AS w2
     WHERE w2.continent = w1.continent AND w2.population >= 10000000
    ) AS NumberOfCountries
FROM world AS w1;

  • 注意事项:
    • 性能: 相关子查询可能会对性能产生较大影响,尤其是当 world 表非常大时。对于外层查询的每一行(或每个 distinct continent),子查询都可能需要重新执行一次。数据库优化器可能会尝试优化,但效果不一。
    • 可读性: 对比 GROUP BY,这种写法的意图可能不是那么一目了然。
    • 需要 DISTINCT 如果直接从 worldSELECT continent 并加上子查询,不加 DISTINCT 会导致每个国家输出一行,重复显示其所属大洲的统计结果,因此需要 DISTINCT continent 或用子查询 (SELECT DISTINCT continent FROM world) 来确保每个大洲只输出一次。

2. 使用窗口函数 (Window Functions)

窗口函数提供了另一种在不“折叠”行的情况下进行聚合计算的方式,也可以用来避免显式的 GROUP BY 聚合。

  • 原理:

    • COUNT(*) OVER (PARTITION BY continent):这是一个窗口函数。PARTITION BY continent 的作用类似于 GROUP BY continent,它将数据按大洲分区。但与 GROUP BY 不同,它不会将行合并,而是在每个分区内计算,并将结果附加到分区内的 每一行 上。
    • 首先,我们可能需要过滤数据或在窗口函数内部进行条件计数。SUM(CASE WHEN ... ) OVER() 或者先过滤再 COUNT() OVER()
    • 因为窗口函数为分区内的每一行都添加了计数值,所以最后需要用 DISTINCT 来获取每个大洲唯一的计数值。
  • 代码示例 (先过滤,再用窗口函数计数):

-- 使用 CTE (Common Table Expression) 使其更清晰
WITH FilteredCountries AS (
    SELECT continent, name
    FROM world
    WHERE population >= 10000000
)
SELECT DISTINCT -- 关键:获取每个大洲唯一的统计结果
    continent,
    COUNT(name) OVER (PARTITION BY continent) AS NumberOfCountries
FROM FilteredCountries;

或者,不使用 CTE,直接在子查询中使用

SELECT DISTINCT
    continent,
    CountPerContinent
FROM (
    SELECT
        continent,
        COUNT(name) OVER (PARTITION BY continent) AS CountPerContinent
    FROM world
    WHERE population >= 10000000 -- 先过滤
) AS Sub;

  • 代码示例 (使用条件窗口函数 SUM):
SELECT DISTINCT -- 同样需要 DISTINCT
    continent,
    SUM(CASE WHEN population >= 10000000 THEN 1 ELSE 0 END) OVER (PARTITION BY continent) AS NumberOfCountries
FROM world;
  • 注意事项:
    • 数据库支持: 需要数据库系统支持窗口函数(大多数现代关系型数据库都支持)。
    • 性能: 窗口函数通常比相关子查询效率高,性能表现接近于 GROUP BY,有时甚至更优(尤其是在需要同时获取聚合值和原始行数据的场景下),但具体取决于数据库实现和数据量。
    • 需要 DISTINCT 和相关子查询方法类似,由于窗口函数会为分区内的每行都计算结果,因此最终需要 DISTINCT 来获得每个大洲一行结果。
    • 理解成本: 窗口函数对于初学者来说可能比 GROUP BY 稍微难理解一些。

四、小结

回到最初的问题:那段使用 ALL 的子查询代码,无法正确解决“统计各大洲人口超千万国家数量”的问题。它的逻辑理解就是错误的。

解决这类分组统计问题:

  • 首选方案: GROUP BY。它清晰、直接、高效,是 SQL 中处理聚合的标准方式。
  • 替代方案:
    • SELECT 中的相关子查询 :可行,但要注意潜在的性能问题和可读性。
    • 窗口函数 (COUNT/SUM OVER(PARTITION BY ...)) :功能强大且性能较好,是现代 SQL 中 GROUP BY 的一个有力补充,尤其适合需要在保留原始行细节的同时进行聚合计算的场景。在纯粹的分组统计场景下,需要配合 DISTINCT 使用。

选择哪种方法取决于具体需求、团队的 SQL 熟悉程度以及对性能的要求。但对于这个问题本身,GROUP BY 无疑是最简单、最符合直觉的解决方案。