☰
MySQL分组查询全解析:GROUP BY、聚合函数与HAVING实战
2026/10/22 5:14:58 网站建设 项目流程

连着写了几篇MySQL基本查询,从最简单的SELECT单表查询,到WHERE条件过滤、常用运算符组合筛选,再到期中涉及的字段运算、去重、排序,基础的东西基本过了一遍。照这个篇章顺序,这次该聊分组聚合了——也就是GROUP BY那一整套东西。

说实话,分组查询是“基本查询”里相对难啃但特别重要的一环。笔试面试高频出题,日常工作里更是躲不开:统计每个月的销售额、按分类汇总库存、算每个用户的订单数……全是GROUP BY加聚合函数的组合拳。如果你只会在SELECT后面加个星号、在WHERE里面写等值条件,那SQL的能力半径大概只发挥了两成。这一篇我把分组查询的核心逻辑、聚合函数的正确姿势、HAVING和WHERE的取舍、排序分页与分组怎么配合,一条线串起来讲透,用的例子也是一张销售记录表,照着敲一遍就能上手。

这篇文章适合两类人看:一类是刚把SELECT、WHERE这些单表查询搞明白、准备进阶的初学者,另一类是写SQL写了一两年但遇到分组统计还是习惯性网上搜“GROUP BY用法”的开发者。看完不敢说让你封神,但至少写分组查询不再发怵,遇到报错也知道往哪个方向排查。

1. 分组复盘:先弄懂GROUP BY到底在干什么

1.1 没分组之前,我们写的查询是查“行”,分组之后查的是“类”

先做个简单的思维切换。前面几篇讲的查询,不管条件多复杂,返回结果的每一行都对应表里的一行原始记录。你在WHERE里写了什么条件,无非是决定哪些原始行能留下、哪些被过滤掉,但返回的永远是明细。

分组查询不一样,它的核心是“把多行合并成一类”。举个例子来说,一张订单明细表,里面有商品分类字段,有销量字段。你现在想看“每个分类的总销量”,这时候如果还按原始行去一条条看,数据一多眼睛就花了。你要的答案是苹果类一共卖了多少、香蕉类一共卖了多少——这就需要一个机制,把相同分类的行归到同一组,再对组内多条行的销量做加法、求个数、取最大值等等,最后每组只输出一行汇总结果。

GROUP BY干的就是这件事。它按照你指定的字段,把表里的数据先切成一块一块的,相同的值分到同一块,然后再配合聚合函数对块内数据进行加工。所以记住一句话:GROUP BY是用来“压缩行数、提升维度”的,用得好,一条SQL能从几千行明细里直接读出业务结论。

1.2 GROUP BY的基础语法和完整执行链路

先看标准语法模板,后面全部围绕这个来走:

SELECT 分组字段, 聚合函数... FROM 表名 [WHERE 过滤条件] GROUP BY 分组字段 [HAVING 过滤分组] [ORDER BY 排序字段] [LIMIT 限制条数];

注意中括号里的部分都是可选的,但有一条核心规则:只要写了GROUP BY,SELECT后面的字段就只能是两类——要么是“分组字段本身”,要么是“聚合函数的结果”。别的普通字段不允许出现。什么原理?分组过后,组内可能有多行,普通字段在组内每一行的值是不同的,数据库没法决定到底显示哪一个,所以干脆不允许你查。

再拆一下SQL的执行顺序,这个理解透了,很多报错根本不用搜就能自己定位出来。实际执行时不是按照书写顺序来的,完整顺序是:

  1. FROM:先确定从哪张表取数据
  2. WHERE:对每一行原始记录做条件过滤,此时还没分组
  3. GROUP BY:把过滤后剩下的行按字段分组
  4. 聚合函数:对每组内的数据进行计算
  5. HAVING:对分组后的结果做过滤,可以把不满足条件的分组去掉
  6. SELECT:投影输出最终字段
  7. ORDER BY:对输出结果排序
  8. LIMIT:截取指定行数

这段链路一定要记牢,因为后面几乎所有坑都出在这上面。比如有人问“为什么WHERE里面不能写COUNT(*) > 1”,原因就在这——WHERE执行的时候还没分组的,更没做聚合计算,聚合结果根本不存在,你拿一个不存在的东西去过滤,数据库肯定报错。

1.3 多字段分组:不是只能按一个字段分组

GROUP BY后面可以跟多个字段,比如“GROUP BY category, month”。多字段分组的含义是:多个字段都相同的数据行才算同一组。

我习惯用一个组合键的类比来理解:想象每个人有“部门”和“职级”两个属性,现在要统计“每个部门里每个职级的人数”,只按部门分组不够,因为一个部门里有多个职级;只按职级分组也不够,因为同一个职级分散在多个部门。只有部门和职级两个值都一样,才定位到唯一的一个格子。多字段分组相当于把多个字段用一个“联合分组键”去看,组合值相同的行才会被归到一组。

实际上这种需求在工作中极其常见,比如“按月统计每个品类的销售额”“按门店和班次统计接待人数”“按地区和渠道统计订单量”,全是两个以上维度的组合统计。写法和单字段没差别,就是在GROUP BY后面逗号隔开列出来就行了,MySQL会先按第一个字段粗分组,再在组内按第二个字段细分,以此类推。

2. 聚合函数:分组统计的核心武器

2.1 五大常用聚合函数逐个拆解

前面多次提到“聚合函数”这个词,这里正式介绍一下。聚合函数是一类接收一组值、返回一个汇总结果的函数,名字里的“聚合”很形象——把多条数据聚合成一条结论。最常用的有五个:

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

后面三个比较好理解,小学数学内容,不展开。重点说一下COUNT和SUM的坑。

拿之前的订单场景来举例。假设我建了一张这样的销售明细表,包含订单ID、商品分类、销售数量、单件金额、下单日期几个字段:

CREATE TABLE sales_record ( id INT AUTO_INCREMENT PRIMARY KEY, category VARCHAR(20) COMMENT '商品分类', quantity INT COMMENT '销售数量', amount DECIMAL(10,2) COMMENT '单件金额', sale_date DATE COMMENT '下单日期' );

往里面插几条数据之后,我想统计每个分类的订单笔数、总销量、总销售额、平均单价:

SELECT category, COUNT(*) AS order_cnt, SUM(quantity) AS total_qty, SUM(quantity * amount) AS total_amount, AVG(amount) AS avg_price FROM sales_record GROUP BY category;

这里面有两个关键信息点。第一,SUM(quantity * amount)说明聚合函数里可以写表达式,不一定是单个字段,先算每行的乘积再求和,就得到了每组的销售总额。第二,COUNT()统计的是组内行数,也就是订单笔数,不管字段里有没有空值,只要有这一行就算一条。这是COUNT()和其他COUNT写法最大的差别。

2.2 COUNT(*)、COUNT(1)与COUNT(字段)的区别

写代码的人大概率都被问过这个问题:COUNT()、COUNT(1)、COUNT(id)到底有什么区别?实际结果上,COUNT()和COUNT(1)完全等价,都是统计满足条件的行数,区别只在实现层面的细节,实操中不用纠结。真正有坑的是COUNT(某个普通字段)。

COUNT(字段)统计的是“该字段不为NULL的行数”。如果组内某行的这个字段是NULL,这一行就不会被算进去。也就是说,COUNT(字段)实际是“非空值的行数”,而COUNT(*)是“总行数”。

什么时候这俩结果会不一样?就发生在字段有NULL值的时候。比如统计订单时,单独一个“优惠券编号”字段可能很多行都是NULL——没使用优惠券的订单这个字段就是空的。如果你想统计“使用了优惠券的订单有多少笔”,用COUNT(coupon_code)是合适的;但如果想统计“总订单量”,用COUNT(coupon_code)就会少算,这时候必须用COUNT(*)。

顺便说一句COUNT(DISTINCT 字段)这个形式也很常用,它的含义是“去重后的非空值个数”。比如想统计“有多少个不同分类产生了订单”,可以写COUNT(DISTINCT category),这个在报表场景里非常实用。

2.3 聚合函数与NULL值的相处法则

接着上面的NULL值话题,统一说一下SUM、AVG、MAX、MIN这几个函数对NULL的处理逻辑。原则非常简单:聚合计算时直接忽略NULL值。SUM求和时NULL不参与加总,AVG求平均时NULL行不参与分子也不参与分母,MAX/MIN也一样跳过NULL行。

这意味着什么?一个门道是:如果某一组的所有行的某个字段都是NULL,SUM不会返回0,而是返回NULL,AVG也返回NULL。这跟直觉不一样,很多人一看到NULL就以为是0,实际上是“值不存在”,MySQL的表达就是NULL。如果业务上要求显示为0,需要自己用IFNULL或COALESCE包一层,比如IFNULL(SUM(quantity), 0)。这种细节在写报表导出的时候特别容易翻车,导出的Excel里突然冒出一个空白的汇总单元格,查半天往往就是忘了处理NULL。

另外一个冷门但面试喜欢问的细节:AVG的实现。AVG实际上等于SUM除以COUNT,但分母是不为NULL的行数而非总行数。有人会误以为AVG是“总和除以全部行数”,这是错误的。如果某组有3行,其中一行金额为NULL,AVG的结果是两行非空金额相加除以2,而不是除以3。

3. HAVING过滤:分组之后的条件筛选

3.1 WHERE与HAVING的边界划分

这个问题的本质在前面的执行顺序里已经埋了伏笔:WHERE先执行,过滤的是分组前的原始行;HAVING后执行,过滤的是分组后的结果组。用大白话说,WHERE在前端把不想参与统计的行挡掉,HAVING在后端把不想展示的分组结果丢掉。

实际应用里,凡是你想根据“明细行某个字段的值”做筛选,条件都应该放WHERE里;凡是你想根据“聚合计算的结果”做筛选,条件只能放HAVING里。后者是WHERE的禁区,因为WHERE执行时聚合结果还没算出来。

举个例子。我想统计每个商品的销售总量,只要销售数量大于10的订单参与统计,且最终的分类总销量大于60才输出:

SELECT category, SUM(quantity) AS total_qty FROM sales_record WHERE quantity > 10 GROUP BY category HAVING SUM(quantity) > 60;

第一层WHERE把单笔订单数量不超过10的明细行排除了,剩下的大单才参与统计。第二层HAVING在分组完之后,把总销量不超过60的分类去掉。这两个条件虽然都跟“数量”相关,但作用阶段完全不同,位置完全不同。如果把WHERE那句硬塞进HAVING里也能执行,但逻辑就不对了,因为它需要先聚合再判断“组合之后大于10”,效果完全是两回事,而且会白白增加分组计算的开销。

3.2 HAVING的合法用法与限制

写HAVING条件时有几条经验规则,记住能少踩不少坑:

  • HAVING后面可以使用聚合函数,比如HAVING COUNT(*) > 5、HAVING SUM(amount) < 1000,这是HAVING存在的核心意义。
  • HAVING后面也可以使用SELECT中出现的分组字段,比如HAVING category = '饮料',虽然效果上可以用WHERE category = '饮料'替代,但两种写法执行阶段不同。性能优先的话,能用WHERE的先放WHERE。
  • HAVING条件里引用的字段,最好也是分组字段或聚合函数结果,尽量避免使用非分组非聚合的普通字段,否则逻辑上容易出错或者语义混乱。
  • 如果HAVING里想用别名,某些数据库产品允许,但为了最大兼容性和可读性,建议直接写完整的聚合表达式。

顺带提一句,现在MySQL版本默认开启了ONLY_FULL_GROUP_BY模式(后面第5章细说),这给写HAVING也加了约束。你在SELECT里没列出的分组字段,在HAVING里引用,有些版本会直接报错。碰到这种报错先别慌,把字段补进GROUP BY里是最常见的解决办法。

3.3 WHERE与HAVING叠加的调优思路

两者叠加的时候有个性能上的基本原则:能在WHERE阶段就过滤掉的,绝对不要留到HAVING阶段。

原因不复杂,参与分组的数据量直接决定了分组操作的内存消耗和耗时。WHERE先把数据行压下来,GROUP BY需要处理的行数就少了,后面的聚合计算也就轻快很多。HAVING是在分组完成后做过滤,如果条件本可以放在WHERE阶段,却写成HAVING,等于把一堆不该参与分组的行硬生生分了个组,等聚合算完再丢弃,白费功夫。

打个比方:你要统计某个班级各科平均分,如果只想关注成绩表里“分数非空”的记录(空值不参与总分计算),最好在WHERE里先过滤掉空行;如果等分组之后再排除,显然多处理了没意义的数据。数据量小时感知不明显,几百万行的时候差异就是秒级和毫秒级的差距了。

4. ORDER BY与LIMIT:排序和分页的收尾工作

4.1 ORDER BY配合分组结果的排序

很多时候分组统计完,还需要把结果按某个指标排个序,比如“找出销量最高的分类”“统计销售额最高的月份”。这就轮到ORDER BY出场了。它的位置在GROUP BY和HAVING之后、LIMIT之前,注意这个顺序不能乱。

排序字段可以是分组字段本身,也可以是聚合函数的结果,还可以用SELECT里定义的别名。看个典型用法:

SELECT category, SUM(quantity * amount) AS total_sales FROM sales_record GROUP BY category ORDER BY total_sales DESC;

我直接用了别名total_sales做排序,MySQL支持这样做,简洁又直观。如果想按多个维度排序,和前面单表查询一样,ORDER BY后面逗号分隔多个字段即可,每个字段可以单独指定ASC或DESC。比如先按总销量降序,销量相同再按分类名升序:

ORDER BY total_sales DESC, category ASC;

排序里的一个细节是NULL值的排位。MySQL默认排序时NULL最小,升序排在前面,降序排在最后。如果你业务上希望空值排在最后,可以用ORDER BY ISNULL(字段), 字段 ASC这种写法,或者根据不同版本用NULLS LAST语法,但注意兼容性,MySQL 8.0.30之前的版本并不原生支持。

4.2 LIMIT分页的基础写法与深分页隐患

LIMIT在分组查询里的常见用途是“取Top N”。比如刚才算出各分类销售总额后,我只想取前三名:

SELECT category, SUM(quantity * amount) AS total_sales FROM sales_record GROUP BY category ORDER BY total_sales DESC LIMIT 3;

LIMIT的基本语法有两种写法。第一种LIMIT n,表示返回前n行;第二种LIMIT offset, n,表示跳过offset行后返回n行,也就是分页时经常用的“偏移量+每页行数”。比如每页显示10条,第一页LIMIT 0, 10,第二页LIMIT 10, 10,第三页LIMIT 20, 10,以此类推。

这里必须说一个深分页的性能问题。当offset的值非常大的时候,比如LIMIT 100000, 10,MySQL并不是直接跳过十万行,而是先把前十万行全读出来再丢弃,只留下最后十行,前面的读取和排序开销全部被浪费了。数据量一大,深分页查询会明显变慢。优化的常见思路是利用覆盖索引、记录上次查询的最后一条ID作为游标来翻页,或者对排序字段建立索引以减少不必要的排序量。这些属于后续优化章节的内容,但这篇先把警示立住,免得以后踩坑时一头雾水。

4.3 分组、过滤、排序、限制的完整配合

把这几个子句全部串起来,就是一条非常典型的统计SQL。比如“查询销售数量大于10的单笔订单参与统计,按分类聚合,过滤掉总销量低于60的分类,最后按总销量降序,取前两条”:

SELECT category, SUM(quantity) AS total_qty FROM sales_record WHERE quantity > 10 GROUP BY category HAVING SUM(quantity) > 60 ORDER BY total_qty DESC LIMIT 2;

如果你已经理解了前面讲的执行顺序,这条SQL的执行过程应该是这样的:

  1. 先全表扫描,把quantity <= 10的行踢掉
  2. 剩余行按category分组,同时累加每组的总销量
  3. HAVING检查每组的总销量是否大于60,不满足的分组丢弃
  4. SELECT输出分类和总销量
  5. 按总销量降序排序
  6. LIMIT截取前两行

配合前面的执行链路图,这个流程就变成了一道流水线,每一步的输入输出都很清晰。这也是我为什么一开始花精力强调执行顺序——它是整条SQL的主心骨,后面任何进阶用法都绕不开它。

5. 综合案例:从建表到完整分组统计

5.1 构造一张可复现的演示表

光说不练是假把式。我这边用一个完整的模拟场景来演示一下,这个场景没有用任何真实数据,但结构完全贴近实际业务。建一张销售记录表,然后插入若干条数据,再逐级跑分组查询。

CREATE TABLE sales_record ( id INT AUTO_INCREMENT PRIMARY KEY, category VARCHAR(20) COMMENT '商品分类', quantity INT COMMENT '销售数量', amount DECIMAL(10,2) COMMENT '单件金额', sale_date DATE COMMENT '下单日期' ); INSERT INTO sales_record (category, quantity, amount, sale_date) VALUES ('水果', 12, 8.50, '2024-11-01'), ('水果', 25, 7.20, '2024-11-02'), ('水果', 8, 9.00, '2024-11-03'), ('饮料', 30, 3.50, '2024-11-01'), ('饮料', 15, 4.00, '2024-11-02'), ('饮料', 6, 5.00, '2024-11-04'), ('零食', 40, 12.00, '2024-11-01'), ('零食', 20, 15.50, '2024-11-03'), ('零食', 5, 10.00, '2024-11-05'), ('日用品', 9, 20.00, '2024-11-02');

这张表一共10行数据,4个分类,数据量不大,但足以覆盖这一篇涉及的所有查询形态。接下来每一段SQL我都直接给结果解读,你跑出来的结果应该和我说的是一致的。

5.2 各种查询需求的落地SQL与结果解读

先来一个最基础的分组统计,按分类统计订单笔数、总销量、总销售额、平均单价:

SELECT category, COUNT(*) AS order_cnt, SUM(quantity) AS total_qty, SUM(quantity * amount) AS total_sales, AVG(amount) AS avg_price FROM sales_record GROUP BY category;

结果解读:水果分类3笔订单,总销量45,总销售额是8.5×12+7.2×25+9×8=366,平均单价算出来是8.23左右。饮料分类3笔,总销量51,总销售额105+60+30=195。零食分类3笔,总销量65,总销售额480+310+50=840。日用品1笔,总销量9,总销售额180。这个结果里可以看到,零食虽然只有3笔订单,但总销售额最高,因为单价和数量都高。

接着加上HAVING,只看总销量超过30的分类:

SELECT category, SUM(quantity) AS total_qty FROM sales_record GROUP BY category HAVING SUM(quantity) > 30;

返回结果是水果、饮料、零食三行,日用品因为总销量只有9,低于30被过滤掉了。

再加排序和分页,按总销售额降序取前三名:

SELECT category, SUM(quantity * amount) AS total_sales FROM sales_record GROUP BY category ORDER BY total_sales DESC LIMIT 3;

结果顺序是零食(840)、水果(366)、日用品(180),饮料(195)排第四,被LIMIT 3截掉了。这里你可能会发现日用品180比饮料195小,但为什么日用品排在第三而不是饮料?排序是按total_sales降序,零食840最大,水果366其次,饮料195第三,日用品180第四,所以被截掉的应该是日用品。我上面手滑写错了一处,实际应当这样才对:零食840第一,水果366第二,饮料195第三,日用品180第四,LIMIT 3留下的就是零食、水果、饮料。

5.3 按时间维度分组的场景演练

分组的维度不一定是分类这种定性字段,日期字段同样可以分组。如果我想按月统计销售额,SQL可以这样写:

SELECT DATE_FORMAT(sale_date, '%Y-%m') AS month, SUM(quantity * amount) AS total_sales FROM sales_record GROUP BY DATE_FORMAT(sale_date, '%Y-%m') ORDER BY month;

这里用到了DATE_FORMAT函数把日期格式化成“年-月”的形式,然后直接对格式化后的结果分组,就能按月份聚拢数据。这种处理技巧在工作中很常用,因为原始数据里通常存的是精确日期,但报表需要的是月份、季度、年份级别的汇总。

再进一步,我想统计每个分类在每一天的销售额,就需要两个字段同时分组:

SELECT category, sale_date, SUM(quantity * amount) AS daily_sales FROM sales_record GROUP BY category, sale_date ORDER BY category, sale_date;

返回结果会是每个分类每天一行,只有当日有订单的日期才会出现。这种“分类+日期”的组合在零售行业看板里特别常见,本质就是多字段分组。

5.4 常见报错与实战排查技巧

写到这里,介绍一下我这个系列反复提到的实测经验。以下这几个问题,是初学者到进阶者都会碰到的,整理了排查思路。

报错1:ER_WRONG_FIELD_WITH_GROUP。错误大意是“SELECT列表的表达式包含非聚合列,且这个列不在GROUP BY子句中”。这是开启ONLY_FULL_GROUP_BY模式后的典型报错。为什么会触发?因为你在分组查询的SELECT里写了既不是分组字段、也不是聚合函数的普通字段。比如:

SELECT category, amount FROM sales_record GROUP BY category;

amount不是分组字段,又不是聚合函数结果,报错非常合理。解决办法有两种:要么把amount也加到GROUP BY后面(如果业务上确实需要按category+amount的分组键),要么把amount改成聚合函数比如SUM(amount)、MAX(amount)。注意,如果是老版本MySQL且没开ONLY_FULL_GROUP_BY,这条SQL可能“能跑”,但返回的amount是组内随机一行的值,逻辑上根本不可控。别依赖这种侥幸行为,保持好习惯。

报错2:WHERE中误用聚合函数。比如:

SELECT category, SUM(quantity) FROM sales_record WHERE SUM(quantity) > 50 GROUP BY category;

MySQL会直接报“Invalid use of group function”。原因前面说过了:WHERE执行阶段聚合结果还不存在。正确的做法是HAVING SUM(quantity) > 50。这类报错只要理解了执行顺序就不会再犯。

问题3:COUNT计数结果莫名偏小。排查思路很直接:先确认你用的是COUNT(*)还是COUNT(字段)。如果用的是后者,把NULL值行数查一下,大概率对上了。这类问题在真实业务中很阴,因为字段名看起来人畜无害,实际却包含大量NULL记录。

问题4:分组统计结果与业务预期不一致。常见原因是WHERE条件放错了位置,或者在GROUP BY里误加了多余字段。比如你想按分类统计总销量,却把分类、子类两个字段都加进GROUP BY,结果就变成“分类+子类”的细粒度统计,总数字自然对不上。排查时先简化GROUP BY字段,再逐步加回去,很快能定位问题。

5.5 一张查询速查表

为了方便日常查阅,我把这一篇涉及的关键写法列成一张速查表:

需求描述关键写法
按单个字段分组统计SELECT field, COUNT(*) FROM t GROUP BY field
按多个字段分组统计SELECT a, b, SUM(x) FROM t GROUP BY a, b
分组后过滤组HAVING 聚合条件(WHERE不能做这个)
分组前过滤行WHERE 普通字段条件
统计非空值个数COUNT(field),只计非NULL
统计不去重行数COUNT(*) 或 COUNT(1)
统计去重后的个数COUNT(DISTINCT field)
按聚合结果排序ORDER BY 聚合表达式或别名 DESC/ASC
取每组Top NGROUP BY后配合ORDER BY+LIMIT
按日期维度分组GROUP BY DATE_FORMAT(date_col, '%Y-%m-%d')

这张表可以直接截图保存,写到一半想不起来语法时候翻一眼,比重新翻文档快得多。

结语

一个习惯可以分享给大家:我每次遇到分组查询需求,第一步永远先想清楚“最终输出粒度是什么”。是按分类输出一行,还是按分类+日期输出多行,还是按月输出多行——粒度定了,GROUP BY字段也就定了;分组字段定了,SELECT里能不能出现某个普通字段也就判断出来了。这个思维顺序比背任何语法都管用。

另一个小技巧是把常见的分组统计SQL整理成一套标准模板存在本地,业务上遇到“按XX统计YY”的需求,直接套模板改字段名。我这些年写报表统计,最耗时间的从来不是SQL本身,而是需求的颗粒度理解,模板化之后效率高了不少。希望这篇能把分组查询这条路上的坑替你填掉一些,真正用到的时候少走几趟弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询