1. 聚合函数:SQL 里的"一键统计"到底是怎么算的
先别急着上手写 GROUP BY,聚合函数是这一整套逻辑的基石。所谓聚合,就是把多行数据按照某种规则压缩成一行结果,最常见的五个函数是 COUNT、SUM、AVG、MAX、MIN。它们的输入是一组值,输出是一个标量值——这个"多进一出"的特征,就是聚合和普通标量函数最本质的区别。
举个最直观的例子。假设有一张学生成绩表,结构大概是 student_id、class_id、subject、score,里面存着某个年级所有学生的各科成绩。如果直接执行:
SELECT COUNT(*) FROM score_table;返回的就是这张表的总行数,比如 1200 行。这个*的意思是"统计所有行数",不管是空值还是非空值,只要行存在就计入。而如果写:
SELECT COUNT(score) FROM score_table;它就只统计 score 这一列中非空的行数。这个差异在真实业务里非常容易踩坑——比如某些学生缺考,score 字段是 NULL,COUNT(*)和COUNT(score)的结果就会不一样。你要统计的是"有多少条考试记录"还是"有多少个实际分数",必须先想清楚。
SUM 的用法也好理解,就是对一列数值求和。但要注意,SUM 会自动忽略 NULL,相当于把 NULL 当成 0 处理,不过它不会改变分母——也就是说,如果一列里全是 NULL,SUM 的结果是 NULL 而不是 0。AVG 同理,它只对非 NULL 的值求平均,不是用总数除以行数。这个细节在算"缺考人数拉低平均分"这类问题时特别关键。
MAX 和 MIN 除了能处理数字,还能处理字符串和日期。比如查"最早考试日期",可以直接MIN(exam_date),查"姓氏最靠后的学生",可以用MAX(student_name)。字符串和日期的排序规则虽然看起来有点"另类",但聚合函数本身不挑数据类型。
为了把五个函数一次说清,我用一张表来总结:
| 函数 | 作用 | 对 NULL 的处理 | 典型返回示例 |
|---|---|---|---|
| COUNT(*) | 统计行数 | 计入 | 1200 |
| COUNT(列名) | 统计该列非 NULL 行数 | 忽略 | 1180 |
| SUM(列名) | 对非 NULL 值求和 | 忽略 | 84213 |
| AVG(列名) | 对非 NULL 值求平均 | 忽略 | 71.4 |
| MAX(列名) | 取最大值 | 忽略 | 99 |
| MIN(列名) | 取最小值 | 忽略 | 23 |
看到 MAX 和 MIN 那边多出来的两行了吗?虽然标题说"五个函数",但 COUNT 有两种常见写法,所以实际要掌握的是六个用法。我经常在模拟面试里问一个问题:SELECT COUNT(*) FROM t和SELECT COUNT(1) FROM t有什么区别?标准答案是没区别,MySQL、PostgreSQL 这些主流数据库在优化器层面都会把它当成同一件事来处理,但如果你的面试官是那种爱抠底层实现的人,可以补充一句:在有些数据库里,COUNT(*)会被优化成直接扫描索引行数,而COUNT(1)走的是表达式计算,性能理论上略有差异,但实际几乎无感知。
2. GROUP BY 的分组逻辑:先把行"分堆",再对每堆做聚合
聚合函数单独用的时候,是把整张表当成一个组。但现实中更多需求是"按某个维度分组后,再对每组分别统计",这就是 GROUP BY 存在的意义。
GROUP BY 的执行顺序可以这样理解:先把表里的行按照指定列的值分成若干堆,相同值的行进同一堆,然后对每一堆分别执行 SELECT 子句里的聚合函数。所以,SELECT class_id, AVG(score) FROM score_table GROUP BY class_id的结果就是:每个班级一行,后面跟着该班所有学生的平均分。
这里有一个新手极易忽略的规则:SELECT 子句中出现的列,要么被聚合函数包裹,要么就必须出现在 GROUP BY 里。比如:
SELECT student_id, class_id, AVG(score) FROM score_table GROUP BY class_id;这条 SQL 在很多数据库里会直接报错,或者在某些宽松模式的数据库里返回一个毫无意义的 student_id。为什么?因为分组后,每个班级这一堆里可能有 40 个不同的 student_id,数据库不知道该选哪一个展示。这是逻辑上的矛盾,不是语法层面的巧合。MySQL 在默认配置下开了ONLY_FULL_GROUP_BY,会严格拦截这种写法;如果没开,虽然能跑出结果,但那个 student_id 是随机取的,属于典型的"能跑但结果不对"。
我在实际带人的时候,发现很多人对"哪些列能出现在 SELECT 里"这件事记不住规则,只靠报错来反复试。其实核心就一句:没被聚合的列,必须出现在 GROUP BY 里。反过来,GROUP BY 里的列不一定都要出现在 SELECT 里——比如按班级分组,但只统计人数,SELECT COUNT(*) FROM score_table GROUP BY class_id,CLASS_ID 没出现在 SELECT 里,完全合法。
还有一个容易被忽略的点:GROUP BY 后面可以跟多个列。比如GROUP BY class_id, subject的意思是:先把同一个班级的放到一起,再在同一个班级内按科目细分,得到的是"每个班级每门科目"这一粒度的一组数据。这种多维分组能力非常常用,像"各班各科的平均分""各月各渠道的订单量"都属于这种场景。
另外,GROUP BY 的顺序在语法上也有讲究。它在 WHERE 之后、HAVING 之前、ORDER BY 之前。很多人分不清 WHERE 和 HAVING 的执行顺序,我直接给一个执行顺序速记:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。注意,虽然 SQL 语句的书写顺序是先 SELECT 再 FROM,但数据库实际执行的时候是先拿到数据源,再逐层过滤和加工。理解这个顺序,后面 HAVING 那部分就顺理成章了。
3. HAVING 与 WHERE 的本质区别:一个管行,一个管组
先说结论:WHERE 在分组之前过滤原始行,HAVING 在分组之后过滤聚合结果。这个"先后"关系不是人为规定,而是由 SQL 的执行顺序天然决定的。
有一个很典型的面试题:查"平均分大于 80 分的班级"。如果写成:
SELECT class_id, AVG(score) AS avg_score FROM score_table WHERE AVG(score) > 80 GROUP BY class_id;这一定是错的,因为 WHERE 子句在 GROUP BY 之前执行,而 AVG(score) 此时根本还没算出来,数据库解析阶段就会报错:"Invalid use of group function"。正确的写法是:
SELECT class_id, AVG(score) AS avg_score FROM score_table GROUP BY class_id HAVING AVG(score) > 80;用执行顺序来理解:先拿到整张表,WHERE 如果有条件,先对每一行做检查,过滤掉一部分行;然后 GROUP BY 把剩余的行分组;接着各自算出 AVG;最后 HAVING 把那些平均分不大于 80 的组整体丢掉。这就是为什么"分组前过滤用 WHERE,分组后过滤用 HAVING"。
不过实际情况往往更复杂一点。比如查"每个班级中,分数大于 60 分的人数大于 30 人的班级"。这个需求里有两个条件:一个是行级条件"分数大于 60",一个是组级条件"人数大于 30"。正确的做法是:
SELECT class_id, COUNT(*) AS cnt FROM score_table WHERE score > 60 GROUP BY class_id HAVING COUNT(*) > 30;先把小于等于 60 分的记录在分组前用 WHERE 删掉,剩下的行再按班级分组,数出人数,最后 HAVING 筛掉人数不足 30 人的班级。这里如果错误地把score > 60写进 HAVING,虽然 SQL 不会报错(因为 score 在分组后不是聚合值,很多数据库不允许,但也有部分数据库允许这种写法),但逻辑上完全错误——HAVING 是对组做筛选,不是对行做筛选。
再补充一个容易混淆的场景:HAVING里能不能用 SELECT 里定义的别名?比如:
SELECT class_id, AVG(score) AS avg_score FROM score_table GROUP BY class_id HAVING avg_score > 80;这个问题在不同数据库里答案不一样。MySQL 允许在 HAVING 里引用 SELECT 中的别名,因为它对 HAVING 的解析比较宽松;但标准 SQL 和 PostgreSQL、SQL Server 里,HAVING 的执行顺序在 SELECT 之前,理论上不能用别名,得重新写一遍AVG(score) > 80。为了避免换数据库就踩坑,我的建议是:在 HAVING 里永远写原始聚合表达式,不要依赖别名。这个习惯能让你在各大数据库之间无缝切换。
4. 经典高频题拆解:从"单表聚合"到"分组后过滤"
基础版的聚合题,无论怎么变,核心都逃不开"分组维度 + 聚合函数 + 过滤条件"这三大件。我挑了几道出现频率最高的题型,每一道都会把写法和易错点过一遍。
先看第一道最简单的:统计每个班级的总人数和平均分。
SELECT class_id, COUNT(*) AS stu_cnt, AVG(score) AS avg_score FROM score_table GROUP BY class_id;这道题考察的是 GROUP BY 后面能不能跟多个聚合函数。答案是完全可以,一个分组维度对应多少聚合输出都行,它们各自独立计算,互不影响。唯一要注意的是 AVG 的返回值小数位问题,可以在 SELECT 里用ROUND(AVG(score), 1)控制精度。
第二道稍微加了点条件:统计每个班级中,分数大于等于 60 分的人数。
SELECT class_id, COUNT(*) AS pass_cnt FROM score_table WHERE score >= 60 GROUP BY class_id;注意这里千万不要把score >= 60写进 HAVING。因为 HAVING 是组级过滤,它不知道"这个班的 35 个及格者里面具体哪个人及格了",它只知道"这个班有 35 人及格"。条件作用在原始行上,就一定要用 WHERE 提前过滤。
第三道是典型的分组后过滤:找出平均分最高的前三个班级。
SELECT class_id, AVG(score) AS avg_score FROM score_table GROUP BY class_id ORDER BY avg_score DESC LIMIT 3;严格来说这道题没用 HAVING,但它是聚合场景里另一个常见考点——排序和限量。执行顺序是先分组算平均值,再按平均值降序排列,最后取前三条。注意这里的avg_score是 SELECT 里定义的别名,在 ORDER BY 里引用完全没问题,因为 ORDER BY 的执行顺序在 SELECT 之后。
第四道比较综合:查询至少有两个学生不及格的班级,并列出该班不及格人数。
SELECT class_id, COUNT(*) AS fail_cnt FROM score_table WHERE score < 60 GROUP BY class_id HAVING COUNT(*) >= 2;这道题就是刚才说的"行级条件 + 组级条件"组合的完整版。第一步用 WHERE 把及格的人全部丢掉,剩下全是小于 60 分的记录;第二步按班级分组;第三步用 HAVING 过滤掉不及格人数不足 2 人的组。整条链路一气呵成。
第五道题我喜欢拿来做压轴,因为它把 COUNT 和 DISTINCT 结合了起来:统计每个班级有多少个不同的学生参加了考试。
SELECT class_id, COUNT(DISTINCT student_id) AS stu_num FROM score_table GROUP BY class_id;如果不加 DISTINCT,一个学生考了三科就会出现三行记录,导致 COUNT(student_id) 虚高。加上 DISTINCT 之后,同一班级内的同一个学生只会被计数一次,这才是"参加考试的人数"。COUNT(DISTINCT 列名) 这个语法不只适用于学生场景,任何"按维度去重计数"的需求,比如"每个城市的独立访客数",都是同样套路。
这五道题如果从头到尾自己手写一遍,基本就把基础版聚合的核心覆盖全了。我建议在本地数据库上把每一道的执行计划都打开看一眼,观察 WHERE 和 GROUP BY 的先后作用,比死记硬背语法有效得多。
5. 聚合查询中的隐形坑:NULL、别名、COUNT 语义
很多人觉得会写上面五道题就万事大吉,但真正到了笔试或线上排查问题的时候,翻车的往往不是语法,而是一些不起眼的细节。我挑几个最典型的隐形坑展开讲讲。
第一个坑是 COUNT 和 NULL 的纠缠。举一个真实场景:统计每个班级的数学成绩人数。
SELECT class_id, COUNT(score) AS math_cnt FROM score_table WHERE subject = 'math' GROUP BY class_id;如果有些学生数学缺考,score 为 NULL,COUNT(score) 就不会把这部分人算进去。这本身符合预期,但问题是很多人拿到"缺考人数"以后,会写成COUNT(*) - COUNT(score)来算缺考人数。这在小数据量下没问题,但如果一行里 score 为 NULL、其他字段有值,那COUNT(*) - COUNT(score)确实等于该组 NULL 的数量,逻辑成立。不过更稳妥的写法是:
SELECT class_id, COUNT(*) AS total_cnt, COUNT(score) AS actual_cnt, COUNT(*) - COUNT(score) AS missing_cnt FROM score_table WHERE subject = 'math' GROUP BY class_id;这样看起来没毛病,但如果你在一个 WHERE 已经过滤掉部分行的基础上做同样计算,结果就变了。所以我在代码评审里一直建议大家:遇到 NULL 统计,先确认数据源头和过滤条件,再决定用哪个 COUNT。
第二个坑是 AVG 遇到全 NULL 组。比如查每个班级的平均分,考试全缺考的班级 COUNT(score) 为 0,AVG(score) 就是 NULL。在报表展示时 NULL 会被前端当成"无数据",如果你希望显示为 0,就得用COALESCE(AVG(score), 0)。但这又引入了一个语义问题:全缺考的班级平均分显示 0,容易被人误解为"这个班所有人都考了 0 分",所以我更推荐显示为 NULL,或者单独标注"缺考"。这个取舍没有标准答案,取决于业务口径。
第三个坑是 GROUP BY 子句中列的书写顺序。GROUP BY class_id, subject和GROUP BY subject, class_id在结果上完全等价,因为分组是先按第一个列分大组,再按第二个列分小组,最终分组粒度一样。唯一的性能差异在复合索引的设计上,如果索引是(class_id, subject),那么 GROUP BY 的顺序尽量和索引一致,可以走索引避免排序。但这是优化层面的问题,笔试里不需要纠结,知道即可。
第四个坑是 DISTINCT 和 GROUP BY 的混用。比如COUNT(DISTINCT subject)统计每个班考了几门科目,这个写法没问题。但如果你想"统计每个班每个科目有多少人考",把 DISTINCT 放在前面就会得到错误的维度:
SELECT class_id, COUNT(DISTINCT subject) AS subject_cnt FROM score_table GROUP BY class_id;这种写法返回的是"每个班涉及的科目数",而不是"每个班每科的人数"。要小心目标语义,DISTINCT 是给聚合函数的参数去重,不是给 GROUP BY 的维度去重。
6. 多一层嵌套:聚合结果还能继续做运算
基础版的高频题偶尔会再往上叠一层:把聚合结果作为子查询的一部分,继续做比较。最常见的题型是"找出高于全校平均分的同学"。这个问题它不是"全班平均",而是"个人分数对比整体平均",所以先得算出全校平均分,再回到原始行去筛选个人。
写法如下:
SELECT student_id, score FROM score_table WHERE score > ( SELECT AVG(score) FROM score_table WHERE subject = 'math' );这里子查询返回一个标量值,主查询拿每一行的 score 和它比较。很多人第一次看到这题会条件反射地写 GROUP BY,其实根本不用分组,因为子查询已经把全校平均算好了。理解这一类题目,重点在于区分"对行做比较"和"对组做聚合":平均分是聚合出来的一个值,但筛选动作还是作用在原始行上,所以外层依然用 WHERE。
另一种嵌套形式是把聚合结果放到 HAVING 里做比较。比如查"平均分高于全校平均分的班级":
SELECT class_id, AVG(score) AS avg_score FROM score_table GROUP BY class_id HAVING AVG(score) > ( SELECT AVG(score) FROM score_table );这个题的核心是理解子查询的执行时机。子查询先算整个学校的平均分,得到一个常数;然后主查询按班级分组,逐组算出平均分,再和这个常数比较。注意子查询返回的也是标量,所以 HAVING 里的比较是合法的。
嵌套场景里最容易犯的错是:子查询返回的结果不是一行一列,而是一组值。比如"找出分数高于任何一个十班学生成绩的同学",需要把子查询改成score > ANY (SELECT score FROM score_table WHERE class_id = 10),或者写成score > (SELECT MIN(score) ...)。虽然基础版不要求掌握 ANY,但知道子查询结果的形状决定了外围运算符,能帮你避免一脸懵的情况。
7. 做题的通用思考链路:手把手走一遍完整流程
每次拿到一道聚合题,我建议你先在脑子里走四步,顺序固定,不要跳。
第一步:确定结果集的粒度。所谓粒度,就是每一行结果代表什么。比如"每个班级的总人数"粒度是班级,"每个班级每门课的平均分"粒度是班级加科目。粒度决定了 GROUP BY 后面跟几个列,以及 SELECT 里能放哪些非聚合列。
第二步:确定要不要提前过滤行级条件。把题目里所有条件列出来,凡是作用在原始行上的,比如"分数大于 60""状态为有效",统一放进 WHERE。这一步做完,剩下的行就是参与分组计算的"干净数据"。
第三步:确定分组后要算什么。是 COUNT、SUM、AVG 还是 MAX/MIN?要不要去重?要不要做条件统计?这一步决定 SELECT 子句的聚合表达式。
第四步:确定要不要做组级过滤。凡是作用在聚合结果上的条件,比如"人数大于 3""平均分大于 80",全部放进 HAVING。这一步是"先聚合后筛组"。
用一个例子贯穿一遍:查"每个班级中,及格人数超过 20 人的班级,及其及格人数"。
粒度是班级,所以 GROUP BY class_id。行级条件是"分数大于等于 60",进 WHERE。聚合计算是 COUNT(*),因为它统计的是过滤后剩余的行。组级条件是"人数 > 20",所以进 HAVING。最后写成:
SELECT class_id, COUNT(*) AS pass_cnt FROM score_table WHERE score >= 60 GROUP BY class_id HAVING COUNT(*) > 20;每一步都有明确的判断依据,写出来就是标准答案。如果题目再加一个"按及格人数降序排列",就在末尾加上ORDER BY pass_cnt DESC;如果只要前两名,加LIMIT 2。整个链路是闭环的。
8. 写在最后:聚合功能的进阶方向
基础版聊到这里,该掌握的已经全了:聚合函数的种类和 NULL 行为、GROUP BY 的分组规则、WHERE 和 HAVING 的执行时机、组合嵌套的思考链路。这些东西构成了 SQL 数据分析的骨架,后面不管学窗口函数、CASE WHEN 聚合,还是学 JOIN 后的聚合,底层逻辑都是同一套。
我个人在实际操作中的体会是:学聚合最忌讳的是只背语法不推执行顺序。你把 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY 这条链路吃透了,遇到任何看起来"奇怪"的写法都能自己推理出结果,而不是靠试错。最后再分享一个小技巧:写复杂聚合之前,先在纸上把"结果集的每一行代表什么"写清楚,这个习惯能解决 80% 的分组维度错误。剩下的,就是多刷题,把执行顺序变成肌肉记忆。