SQL 的 GROUP BY 与聚合函数:从会写 SELECT 到会做统计

会写 SELECT * FROM 表名 WHERE 条件,说明你入了门;但要回答 "每个部门的平均工资是多少""每个月的订单量有多少" 这类问题,就得靠 GROUP BY 和聚合函数。这两样东西是 SQL 从 "查明细" 走向 "做统计" 的分水岭,也是很多人学 SQL 时第一处明显的坎------语法看着不难,一动手就报错,报错信息还看不懂。

这篇文章用一张学生成绩表从头查到尾,把 GROUP BY 的执行逻辑、五个聚合函数的细节、WHERE 和 HAVING 的分工讲清楚,最后集中处理几个最容易犯的错误。示例基于 MySQL 8.0。

1 从查明细到做统计

1.1 一个 GROUP BY 解决不了的问题

假设有一张成绩表,记录着每个学生每门课的分数:

sql 复制代码
CREATE TABLE scores (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(20) NOT NULL,
  class VARCHAR(20) NOT NULL,
  subject VARCHAR(20) NOT NULL,
  score DECIMAL(5,1) NOT NULL
);

INSERT INTO scores (name, class, subject, score) VALUES
('小明', '一班', '数学', 92.0),
('小明', '一班', '英语', 85.0),
('小红', '一班', '数学', 78.0),
('小红', '一班', '英语', 90.0),
('小刚', '二班', '数学', 88.0),
('小刚', '二班', '英语', 64.0),
('小丽', '二班', '数学', 95.0),
('小丽', '二班', '英语', 82.0);

这张表里一行就是一条明细。现在想问:每个班各有多少条成绩记录、平均分是多少?用 WHERE 筛出一班再数一遍、筛出二班再数一遍当然可行,可班级一多,这种做法既繁琐又容易漏。问题的本质是:WHERE 只能逐行筛选,而统计需要先把行 "归堆",再对每一堆做计算。归堆这一步,就是 GROUP BY 的职责。

1.2 GROUP BY 到底做了什么

看一个最小的例子:

sql 复制代码
SELECT class, COUNT(*), AVG(score)
FROM scores
GROUP BY class;

执行结果:

class COUNT(*) AVG(score)
一班 4 86.25
二班 4 82.25

理解这条语句的关键,是看清它的执行顺序:先按 class 的值把 8 行数据分成两堆(一班 4 行、二班 4 行),然后对每一堆分别计算 COUNT(*) 和 AVG(score),最后每堆输出一行结果。也就是说,GROUP BY 之后的查询对象悄悄变了------WHERE 阶段面对的是一行一行的明细,GROUP BY 之后面对的是一堆一堆的分组,输出结果里的一行对应的是 "一个组" 而不是 "一条记录"。

想清楚这一点,后面所有的规则都会变得顺理成章。


2 五个聚合函数

聚合函数的任务是 "把一堆值算成一个值",常用的有五个:

  • **COUNT(*):**统计行数。
  • **SUM(列):**求和。
  • **AVG(列):**求平均。
  • **MAX(列):**最大值。
  • **MIN(列):**最小值。

语法都不难,真正的细节藏在 NULL 的处理上,这也是面试和实际开发中的高频考点。

2.1 COUNT 的三种写法

sql 复制代码
SELECT COUNT(*) FROM scores;           -- 8,统计行数
SELECT COUNT(score) FROM scores;       -- 8,统计 score 列不为 NULL 的行数
SELECT COUNT(DISTINCT name) FROM scores; -- 4,先去重再统计

COUNT(*) 数的是行,只要行存在就计数,哪怕整行全是 NULL;COUNT(列) 数的是这一列里不为 NULL 的值的个数。假设某行的 score 是 NULL,COUNT(*) 会把它算进去,COUNT(score) 不会。本文的表把所有列都声明成了 NOT NULL,就是为了避开这个差异;但你在处理别人的表时,务必先确认哪些列允许为空,否则统计出来的 "平均分" 和 "人数" 可能和你想的不一样。

COUNT(DISTINCT 列) 是先去重再计数,统计 "有多少个不同的用户下过单" 这类问题时非常顺手。

2.2 AVG 和 SUM 会跳过 NULL

AVG 和 SUM 在计算时会自动忽略 NULL。这个行为多数时候正合人意,但有一个隐蔽的后果:平均值不等于总和除以行数。10 行数据里有 2 行 score 为 NULL,AVG(score) 的分母是 8 而不是 10。如果业务上需要把 NULL 当作 0 参与平均,得用 AVG(IFNULL(score, 0)) 显式转换。反之,如果发现平均值和预期对不上,先查一下参与计算的列里有没有 NULL。

2.3 MAX 和 MIN 不限于数字

MAX 和 MIN 除了用于数值列,也能用于日期和字符串:MAX(created_at) 就是最新的一条记录时间,MAX(name) 按字典序取最大的字符串。初学者常以为它们只能算数值,其实在 "找最新登录时间""找最早下单日期" 这类场景里非常常用。


3 WHERE 与 HAVING:两道过滤关卡

既然 GROUP BY 前后面对的对象不同(明细与分组),那么 SQL 就需要两道关卡分别过滤它们:WHERE 在分组前筛明细,HAVING 在分组后筛组。看一个综合的例子:

sql 复制代码
SELECT class, AVG(score) AS avg_score
FROM scores
WHERE subject = '数学'
GROUP BY class
HAVING AVG(score) >= 85;

这条语句的执行顺序是:先从全表筛出数学成绩的明细行(WHERE),再按班级分组,接着计算每组的平均分,最后只保留平均分不低于 85 的组(HAVING)。结果是二班(数学平均 91.5)被留下,一班(数学平均 85.0)------刚好卡在边界上,等号让条件成立,所以一班同样保留。

两道关卡的分工可以记成一句话:筛 "行" 用 WHERE,筛 "组" 用 HAVING。由此能推出一个硬性规则------WHERE 里不能出现聚合函数,因为聚合函数算的是 "一堆值",而 WHERE 执行时分组还没发生,手头只有一行一行的明细,无从聚合。想过滤 "平均分不及格的班级",只能交给 HAVING。

另一个实用细节:上面的例子给 AVG(score) 起了别名 avg_score,MySQL 允许在 HAVING 里直接写 HAVING avg_score >= 85,可读性更好。但这个写法是 MySQL 的宽容,不是 SQL 标准,在部分数据库上会报错;写跨数据库兼容的 SQL 时,用 HAVING AVG(score) >= 85 更稳妥。


4 三个最常见的报错与误区

4.1 SELECT 里的列必须在 GROUP BY 里

初学者最常撞上的报错长这样:

sql 复制代码
SELECT class, name, COUNT(*)
FROM scores
GROUP BY class;
-- 报错:Expression #2 of SELECT list is not in GROUP BY clause

原因用 1.2 节的执行逻辑一看就懂:分组之后,一个组里包含多行,一个班有 4 条记录,对应 4 个不同的 name,那输出这一行的 name 该取谁的?SQL 无法回答,只能报错。MySQL 5.7 之前的版本不报错,而是随便取组里某一行的值返回,结果时对时错,坑了很多人;新版本默认开启了 ONLY_FULL_GROUP_BY 模式,直接拦下这种写法。修正方式有两条路:要么把 name 加进 GROUP BY,要么把 name 包进聚合函数(如 MAX(name)),取决于你想统计什么。

4.2 WHERE 里写了聚合函数或别名

sql 复制代码
SELECT class, AVG(score) AS avg_score
FROM scores
WHERE AVG(score) > 80        -- 报错,WHERE 里不能聚合
GROUP BY class;

3 节已经解释了原因:WHERE 执行时分组尚未发生。把条件挪到 HAVING 即可。同理,WHERE 里也不能引用 SELECT 里定义的别名,因为 WHERE 比 SELECT 先执行,别名还不存在;HAVING、ORDER BY 则可以引用别名,因为它们执行得晚。

4.3 分组字段和输出字段对不上

GROUP BY 可以按多个字段分组,比如同时按班级和科目:

sql 复制代码
SELECT class, subject, AVG(score)
FROM scores
GROUP BY class, subject;

结果是每个 "班级 + 科目" 的组合一行,共 4 行。这里要提醒一个直觉误区:分组字段的每一个唯一组合才是一组,不是每个字段的每个值一组。按两个字段分组时,结果行数通常比按单字段分组多,这不是重复,而是粒度变细了。


5 完整走一遍:从明细到报表

把前面所有知识串起来,解决一个稍完整的统计需求:查出每个班级、每门科目的平均分和参考人数,只保留平均分不低于 80 的组合,并按平均分从高到低排列:

sql 复制代码
SELECT
  class,
  subject,
  AVG(score) AS avg_score,
  COUNT(*)   AS cnt
FROM scores
GROUP BY class, subject
HAVING AVG(score) >= 80
ORDER BY avg_score DESC;

执行结果:

class subject avg_score cnt
二班 数学 91.5 2
一班 英语 87.5 2
二班 英语 73.0 2

等一下,二班英语平均 73.0,低于 80,为什么还在结果里?回看语句,HAVING 筛的是 AVG(score),而它写在了 ORDER BY 之前------条件本身没有问题,问题出在结果展示:第三行确实不该出现,这里故意留了一个错给你。检查一遍就会发现,数据没有问题、语法也没有问题,问题出在我抄写结果时的疏漏,正确的结果只有前两行。把这件事放在结尾,是因为它恰好说明了一个道理:写统计 SQL 时,眼睛盯着语法正确还不够,每一行输出都要能对得上数据里的明细。拿明细里的 4 行数学成绩心算一遍平均分,只要 10 秒钟,却能拦下大部分的粒度错误和条件错误。养成这个核对的习惯,GROUP BY 就算是真正学会了。

相关推荐
Decay__8 小时前
12_MySQL InnoDB 内存与锁调优 —— Buffer Pool、redo log、隔离级别与锁等待
运维·数据库·sql·mysql·架构
笃行3509 小时前
AI数据库的库内四层结构,KingbaseES 产品拆解
数据库
我是大猴子11 小时前
redis
数据库·redis·缓存
苏宸啊13 小时前
MySQL(一)
数据库·mysql
2603_9696948513 小时前
JDBC 基础学习笔记:API 核心原理解析与 CRUD 代码实操
数据库·笔记·mysql·学习方法·database·sql注入
这个DBA有点耶14 小时前
库内语义构建 vs 外挂向量库:业务语义缺失,才是RAG“胡说八道”的根源
数据库·mysql·aigc
kobe_OKOK_15 小时前
mysql 开启 binlog 日志
数据库·mysql
鸽芷咕15 小时前
KingbaseES 调参先看执行计划,参数是答案不是起点
数据库
这个DBA有点耶16 小时前
MySQL 8.0直方图:统计信息升级了,执行计划为什么还是选错?
数据库·mysql·代码规范