资讯动态

MySQL分组查询全解析:GROUP BY、聚合函数与HAVING实战

发布时间:2026/10/22 5:14:59 来源:尧图企业网站定制
连着写了几篇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 BYSELECT后面的字段就只能是两类——要么是“分组字段本身”要么是“聚合函数的结果”。别的普通字段不允许出现。什么原理分组过后组内可能有多行普通字段在组内每一行的值是不同的数据库没法决定到底显示哪一个所以干脆不允许你查。再拆一下SQL的执行顺序这个理解透了很多报错根本不用搜就能自己定位出来。实际执行时不是按照书写顺序来的完整顺序是FROM先确定从哪张表取数据WHERE对每一行原始记录做条件过滤此时还没分组GROUP BY把过滤后剩下的行按字段分组聚合函数对每组内的数据进行计算HAVING对分组后的结果做过滤可以把不满足条件的分组去掉SELECT投影输出最终字段ORDER BY对输出结果排序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行。这意味着什么一个门道是如果某一组的所有行的某个字段都是NULLSUM不会返回0而是返回NULLAVG也返回NULL。这跟直觉不一样很多人一看到NULL就以为是0实际上是“值不存在”MySQL的表达就是NULL。如果业务上要求显示为0需要自己用IFNULL或COALESCE包一层比如IFNULL(SUM(quantity), 0)。这种细节在写报表导出的时候特别容易翻车导出的Excel里突然冒出一个空白的汇总单元格查半天往往就是忘了处理NULL。另外一个冷门但面试喜欢问的细节AVG的实现。AVG实际上等于SUM除以COUNT但分母是不为NULL的行数而非总行数。有人会误以为AVG是“总和除以全部行数”这是错误的。如果某组有3行其中一行金额为NULLAVG的结果是两行非空金额相加除以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, 10MySQL并不是直接跳过十万行而是先把前十万行全读出来再丢弃只留下最后十行前面的读取和排序开销全部被浪费了。数据量一大深分页查询会明显变慢。优化的常见思路是利用覆盖索引、记录上次查询的最后一条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的执行过程应该是这样的先全表扫描把quantity 10的行踢掉剩余行按category分组同时累加每组的总销量HAVING检查每组的总销量是否大于60不满足的分组丢弃SELECT输出分类和总销量按总销量降序排序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×127.2×259×8366平均单价算出来是8.23左右。饮料分类3笔总销量51总销售额1056030195。零食分类3笔总销量65总销售额48031050840。日用品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 常见报错与实战排查技巧写到这里介绍一下我这个系列反复提到的实测经验。以下这几个问题是初学者到进阶者都会碰到的整理了排查思路。报错1ER_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后面如果业务上确实需要按categoryamount的分组键要么把amount改成聚合函数比如SUM(amount)、MAX(amount)。注意如果是老版本MySQL且没开ONLY_FULL_GROUP_BY这条SQL可能“能跑”但返回的amount是组内随机一行的值逻辑上根本不可控。别依赖这种侥幸行为保持好习惯。报错2WHERE中误用聚合函数。比如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。这类报错只要理解了执行顺序就不会再犯。问题3COUNT计数结果莫名偏小。排查思路很直接先确认你用的是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 BYLIMIT按日期维度分组GROUP BY DATE_FORMAT(date_col, %Y-%m-%d)这张表可以直接截图保存写到一半想不起来语法时候翻一眼比重新翻文档快得多。结语一个习惯可以分享给大家我每次遇到分组查询需求第一步永远先想清楚“最终输出粒度是什么”。是按分类输出一行还是按分类日期输出多行还是按月输出多行——粒度定了GROUP BY字段也就定了分组字段定了SELECT里能不能出现某个普通字段也就判断出来了。这个思维顺序比背任何语法都管用。另一个小技巧是把常见的分组统计SQL整理成一套标准模板存在本地业务上遇到“按XX统计YY”的需求直接套模板改字段名。我这些年写报表统计最耗时间的从来不是SQL本身而是需求的颗粒度理解模板化之后效率高了不少。希望这篇能把分组查询这条路上的坑替你填掉一些真正用到的时候少走几趟弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑