拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL GROUP BY原理与实战:避开HAVING和ONLY_FULL_GROUP_BY的坑

SQL GROUP BY原理与实战:避开HAVING和ONLY_FULL_GROUP_BY的坑

前两天帮同事排查一个线上报表问题,SQL长这样:

SELECT province, COUNT(*) FROM orders GROUP BY province;

听着像是最基础的分组统计,结果导出来的数据怎么都不对——省份对不上、订单总数差了好几万。查了半天,发现他把GROUP BY当成去重来用,SELECT里还混着一堆非分组字段。这类问题我在工作里见得实在太多,哪怕是写了三四年SQL的人,碰到多字段分组、HAVING过滤、ONLY_FULL_GROUP_BY这些细节,一样容易翻车。

坦白说,SELECT .. GROUP BY在SQL里几乎和SELECT *一样常用,但真正能把它的执行语义、边界条件讲清楚的人并不多。这篇文章我不打算写手册式的说明,而是按我实际排查问题的思路,把GROUP BY从原理到实战完整过一遍:分组到底怎么分、多字段分组有哪些细节、HAVING的正确姿势、MySQL 5.7以后默认开启的ONLY_FULL_GROUP_BY为什么老让人报错,以及线上排查时怎么利用GROUP BY快速定位账号问题和优化慢查询。刚入门的朋友可以当复习,写了几年SQL的也可以对照看看自己有没有踩过类似的坑。

1. GROUP BY不是去重工具:分组、聚合与执行顺序的本质

1.1 "分拣豆子"模型:GROUP BY到底做了什么

很多人对GROUP BY的认知停留在"查出来的结果没有重复行",因此会写出这样的语句:

SELECT id, name FROM user GROUP BY name;

这本质上误解了分组语义。GROUP BY做的事其实更像分拣豆子:把一把混合的黄豆、绿豆、红豆倒在一张桌上,按颜色分别拣到不同的碗里。原来每一粒豆子的独立性没有了,碗里只剩"这个碗属于哪一类"和"这一类有多少粒"。对应到SQL里,GROUP BY把满足WHERE条件的所有行,按照分组字段的值归并成若干个组,每组在最终结果里只输出一行。所有原本属于同一组的行,在被合并时丢失了个体特征——除非你用聚合函数把它们的某些属性汇总起来。

那SELECT id ... GROUP BY name的问题出在哪?id是行级别的信息,同一组里可能有多个不同的id,但结果只输出一行,你说这个id取谁的?SQL标准对这种行为的态度很明确:不允许,因为结果有歧义。MySQL 5.7以后默认开启了ONLY_FULL_GROUP_BY,这种情况下直接报错;在老版本里不报错,只是悄悄返回一个不确定的值。很多人被这个"不确定"坑过,后面第4章我会专门展开。

为了讲清楚顺序,可以先看一条完整的SQL执行过程:

SELECT department, COUNT(*) AS cnt FROM employee WHERE status = 'active' GROUP BY department HAVING cnt > 50 ORDER BY cnt DESC LIMIT 10;

这条SQL实际的执行顺序不是从SELECT开始的,大致是:

  1. FROM:先确定数据源employee表。
  2. WHERE:过滤status = 'active'的行,把不在职的提前扔掉。
  3. GROUP BY:按department把剩下的行分组。
  4. HAVING:过滤掉分组后cnt <= 50的组。
  5. SELECT:计算并输出department和COUNT(*),生成最终列。
  6. ORDER BY、LIMIT:对整个结果排序并截取前10行。

这个顺序能解释几乎所有GROUP BY相关的报错和逻辑错误。比如WHERE里不能写COUNT() > 50,因为聚合还没发生;而HAVING里能用COUNT()取别名cnt,因为HAVING在SELECT阶段之后。实测下来,很多人记不住这个顺序导致的问题包括:在WHERE里写聚合条件被MySQL直接报错、HAVING里过滤普通字段导致性能骤降、ORDER BY用SELECT别名在旧版本MySQL不识别。这些我在第3章和第4章会继续展开。

1.2 聚合函数与NULL的恩怨

分组后剩下的"这一类有多少粒"是靠聚合函数来算的。常用的五个是COUNT、SUM、AVG、MAX、MIN,但细节上有几个大坑值得说:

  • COUNT(*)统计的是组内的行数,不管某列是否为NULL;COUNT(column)只统计该列不为NULL的行数。两种写法都能过,结果却可能差很多。
  • SUM和AVG会自动忽略NULL值,但如果你自己写除法,比如SUM(column) / COUNT(*),NULL会直接影响分母,导致平均值偏小。
  • MAX和MIN也会忽略NULL,但空表时返回NULL,这个值传到下游程序里经常引发空指针或显示异常。

举一个我实际见过的错误:统计每个用户的付款金额,有人写SELECT user_id, SUM(COALESCE(amount, 0)) AS total FROM payment GROUP BY user_id。逻辑上看似没问题,但如果某个用户当天只有一笔退款且amount为负数,SUM结果可能是负数,下游对账逻辑直接炸了。后来确认需求是只看正常金额的流水,改成在WHERE里提前过滤。这说明写GROUP BY之前,先想清楚"聚合的语义"比语法更重要。

2. 多字段分组与字段顺序:粒度是个组合概念

2.1 真正需要的往往是"多维分组"

你搜"group by 多个字段",说明实际工作中很少只用单字段分组。比如统计每个部门每个职级的平均薪资,你要的不是"部门维度"也不是"职级维度",而是"部门 + 职级"这个组合维度。这是多字段分组最典型的场景:

SELECT department, job_level, AVG(salary) AS avg_salary FROM employee GROUP BY department, job_level;

分组的粒度是department和job_level的笛卡尔组合。执行时数据库先把所有(department, job_level)取值相同的行归成一组,比如("研发部", "P7")一组,("研发部", "P8")另一组,("产品部", "P7")又一组。有多少种不同的组合,结果就有多少行。

很多人忽略的一点是:GROUP BY后面的字段顺序会影响结果集的排列习惯,但不影响分组本身。GROUP BY department, job_level的结果在多数数据库里会先按department排列,同一department内再按job_level排列,因为分组过程通常伴随排序或哈希。你写成GROUP BY job_level, department,输出顺序可能就变了。如果你只关心分组结果是否准确,顺序无所谓;如果你关心展示顺序,建议单独加ORDER BY,不要把思路建立在"GROUP BY默认排序"这种副作用上,MySQL 8.0里某些场景下分组排序的默认行为已经调整过,依赖它迟早踩坑。

2.2 多字段分组时SELECT的合法范围

多字段分组比单字段分组更容易写出"古怪"的SQL,因为很多人以为只要把其中一个字段放进SELECT就算分组了。比如:

SELECT department, job_level, name FROM employee GROUP BY department, job_level;

这条SQL在MySQL 5.7默认模式下直接报错:name列没有包含在GROUP BY中,又不是聚合列。你想想看,同一组("研发部", "P7")里可能有几十个员工,几十个不同的name,结果只输出一行,到底显示哪个name?数据库没法替你决定。除非你用的是MySQL特有的ANY_VALUE(name)(取组内随便一个值),或者用MIN(name)、MAX(name)这种明确表达"取最小/最大值"的写法,否则这就是一条语义不完整的SQL。

我在实际项目里见过一种很危险的变通办法:把ONLY_FULL_GROUP_BY直接关掉。因为MySQL 5.6及以前版本默认不开启这个模式,很多老项目SQL本身就带着"SELECT非分组字段"的习惯,升级到5.7后突然全线报错。关掉模式确实能让老SQL跑起来,但结果不可控——同一组内到底取了哪个name,MySQL不做任何保证。如果这个值恰好被后续程序用来做关联或者展示,出问题会非常隐蔽。这一点第4章我会详细讲。

3. HAVING的正确用法:分组后的过滤器不是WHERE的替补

3.1 WHERE与HAVING的分工边界

"group by having"被反复搜索,说明很多人对HAVING的使用场景不清晰。简单一句话:WHERE在分组之前过滤行,HAVING在分组之后过滤组。

举例:找出2024年下单超过100笔的用户。

SELECT user_id, COUNT(*) AS order_cnt FROM orders WHERE order_year = 2024 GROUP BY user_id HAVING order_cnt > 100;

这里的WHERE负责把2024年以外的订单先删掉,减少进入分组的数据量;分组后统计每个用户的订单数;最后HAVING只保留超过100笔的组。如果把年份条件写进HAVING:

SELECT user_id, COUNT(*) AS order_cnt FROM orders GROUP BY user_id HAVING order_year = 2024 AND order_cnt > 100;

很多数据库会直接报错,因为order_year既不在GROUP BY中也不是聚合函数。即使某些数据库允许这么写,逻辑上也很浪费——所有年份的数据都参与了分组,2024年之外的大量订单白白消耗了分组和排序的开销,最后才被HAVING丢掉。

原则就是:能用WHERE提前过滤的行,绝不留到HAVING。HAVING只处理"分组之后才有的信息",典型就是聚合结果,比如COUNT(*)、SUM(amount)、AVG(score)。

3.2 HAVING里用别名的坑与数据库差异

HAVING order_cnt > 100这种写法,SELECT里定义了别名order_cnt,HAVING里直接用,MySQL和PostgreSQL都允许,但SQL Server和Oracle老版本不允许(HAVING不能引用SELECT别名)。为了兼容性,最稳妥的方式是在HAVING里重复写聚合表达式:

HAVING COUNT(*) > 100

虽然看着啰嗦,但这是所有主流数据库都认的写法。我个人的习惯是优先用标准写法,除非确定整个项目只跑在MySQL上。

另一个坑是HAVING搭配NULL值。分组字段为NULL的行会单独成组,COUNT(*)也会把它算进去。比如:

SELECT status, COUNT(*) FROM task GROUP BY status;

如果task表里有status为NULL的行,结果里会多出一行status为NULL的组。你可能觉得NULL和"未知状态"合并成一组没毛病,但下游程序如果再用status是否为NULL做合计,就会重复计数。遇到这种情况,最好在分组前用COALESCE处理:

SELECT COALESCE(status, 'unknown') AS status, COUNT(*) FROM task GROUP BY COALESCE(status, 'unknown');

注意SELECT和GROUP BY里要写一致的表达式,不然又触发"非分组字段"报错。为了更直观,我给一个WHERE和HAVING的分工对照:

场景SQL写法执行位置
过滤原始行WHERE status = 'active'分组前
过滤聚合结果HAVING COUNT(*) > 100分组后
过滤普通列(非分组字段)优先用WHERE分组前

4. ONLY_FULL_GROUP_BY引发的连锁反应:从报错到数据错误

4.1 为什么MySQL 5.7之后突然全线报错

如果你维护过老项目升级MySQL,大概率见过这个报错:

ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'db.employee.name' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by

MySQL 5.7开始默认把ONLY_FULL_GROUP_BY加进了sql_mode。它的核心要求是:SELECT后面每一个非聚合字段,要么出现在GROUP BY中,要么和分组字段存在函数依赖关系(比如按主键分组)。这是SQL标准的要求,MySQL 5.6及之前默认不开启,所以老项目里大量"SELECT id, name FROM user GROUP BY name"这种SQL能跑,5.7以后全炸了。

先说清楚为什么标准要这么规定。回到第1章的分豆子模型:GROUP BY之后每组只剩一行,非分组字段的取值不确定。让数据库返回一个不确定的值,意味着同样的SQL今天跑出一个id,明天可能跑出另一个id,结果不可复现,对统计、报表、接口调用方都是灾难。ONLY_FULL_GROUP_BY不是故意折腾人,它是在帮你挡掉语义含糊的SQL。

4.2 面对报错的正规解法

遇到这种报错,正规的解法按优先级排序是:

  1. 审视需求:你真的需要那个非分组字段吗?很多时候只是多写了,删掉即可。
  2. 明确聚合语义:你要的是组内最小的、最大的还是平均的?用MIN、MAX、AVG、SUM显式表达。
  3. 用ANY_VALUE:明确告诉数据库"我就要组内随便一个值"。这个函数在MySQL 5.7.5之后提供,适合取备注字段且不需要确定性语义的场景。
  4. 用子查询或窗口函数:先算出每个分组要展示的那一行,再关联回原表取完整信息,这是"每组取一条"最可控的方案。

第4种方案在取"每个用户最近一笔订单"这种需求时比GROUP BY更合适:

SELECT o.* FROM orders o JOIN ( SELECT user_id, MAX(order_time) AS max_time FROM orders GROUP BY user_id ) t ON o.user_id = t.user_id AND o.order_time = t.max_time;

这样既避开了"SELECT非分组字段"的报错,又能拿到订单的完整字段。缺点是如果同一用户同一时间有多笔订单,会产生重复关联,这时候还需要在子查询里加ID去重,或者直接用窗口函数ROW_NUMBER()。

不建议的做法是直接SET sql_mode=''或者去掉ONLY_FULL_GROUP_BY。我在生产环境亲眼见过代价:关掉模式后SQL不报错了,但同一个分组因为执行计划选择的不同索引、缓存淘汰、数据插入顺序变化,返回的"那一个"值随机漂移,最后导致对账不平。这种问题比报错可怕多了,报错至少你知道要修。

5. 从mysql.user表说起:用GROUP BY做线上排查的实战套路

5.1 权限账号梳理:一条SQL看清所有重复

做运维和DBA的同学经常要执行类似于SELECT user, host FROM mysql.user的查询。mysql.user表存的是MySQL账号,user是用户名,host是允许登录的来源IP段。一个常见的排查需求是:找出重复的账号定义、空密码账号、或者某个用户在不同host下的权限分布。

直接全表SELECT会把所有行列出来,几百行看着眼晕。更高效的做法是分组统计:

SELECT user, host, COUNT(*) AS cnt FROM mysql.user GROUP BY user, host HAVING cnt > 1;

如果查出来有cnt大于1的行,说明mysql.user表里出现了完全重复的账号定义,这通常是不该出现的,可能是INSERT语句重复执行导致的。MySQL 8.0里user表结构做过调整,认证信息拆到了mysql.global_priv表,但user、host作为账号主键的用法没变。

再比如排查"哪些账号完全没有权限":

SELECT user, host FROM mysql.user WHERE user NOT IN ('mysql.infoschema', 'mysql.session', 'mysql.sys') GROUP BY user, host;

mysql.user表本身主键就是(user, host),正常不会有重复,所以GROUP BY在这里起的是去重展示的作用,核心价值在于:用GROUP BY把账号梳理输出压缩成一个可读的清单,配合HAVING做异常检测。这个思路可以平移到一切"维度表"排查场景,比如查配置表重复项、查接口调用日志里同维度的异常次数。

5.2 慢查询排查:GROUP BY为什么慢,索引怎么救

线上慢查询里,GROUP BY相关的占比相当高。原因在于分组通常要做排序或哈希。如果分组字段没有索引,MySQL可能先把数据全部加载到临时表,再在临时表里排序或建哈希,执行计划里会看到Using temporary; Using filesort。数据量一上来,这条SQL直接就慢了。

举个例子,统计订单表:

SELECT user_id, COUNT(*) FROM orders GROUP BY user_id;

如果orders表有几十万行而user_id没有索引,这次分组基本逃不掉全表扫描加临时表。解决思路是让分组字段能走索引,依靠索引的有序性直接把相邻的相同key归成一组,省掉排序步骤。

先加一个覆盖查询的索引:

ALTER TABLE orders ADD INDEX idx_user_time (user_id, order_time);

这个索引不仅让GROUP BY user_id能按user_id有序扫描,还顺便覆盖了"查某个用户某个时间段内订单"这类高频查询。如果还需要带WHERE过滤,比如只统计最近30天:

SELECT user_id, COUNT(*) FROM orders WHERE order_time >= '2024-12-01' GROUP BY user_id;

理想情况下WHERE里的order_time是索引前面的列,MySQL通过索引先定位到满足时间条件的范围,再在范围内按user_id分组。所以设计索引时要注意等值条件放前面、范围条件放后面,GROUP BY字段往往放在范围条件后面:

ALTER TABLE orders ADD INDEX idx_time_user (order_time, user_id);

这条索引下,WHERE order_time >= '2024-12-01'可以直接用索引范围扫描,且每一行已按user_id顺序排列,分组无需额外临时表和排序。实测同样的数据量,这个调整能把查询时间从3秒压到0.1秒以内。当然,实际效果还取决于数据分布和表结构,这里给出的是我常用的优化套路。

6. GROUP BY的"平替":窗口函数何时更合适

6.1 既要汇总又要明细的矛盾

GROUP BY有个先天的副作用:结果集的行数等于组数,原始行被压扁。但实际业务里经常遇到"既要看明细,又要看汇总"的需求,例如给每个订单打上"该用户总订单数"的标签。如果硬用GROUP BY,你得先把user_id分组汇总,再JOIN回原表。写法并不算复杂,但表扫描次数和临时表都会增加。

如果数据库支持窗口函数(MySQL 8.0、PostgreSQL、SQL Server、Oracle都支持),可以直接写:

SELECT order_id, user_id, COUNT(*) OVER (PARTITION BY user_id) AS user_order_cnt FROM orders;

这里PARTITION BY user_id的作用在语义上等同于按user_id分组,但结果仍然保留每一行明细,只是在每行上附加了该用户的分组计数。相比GROUP BY + JOIN,窗口函数通常只需扫描一遍原表,写法也更直白。

6.2 我给自己定的选择标准

用了这么多年,我给自己定了个简单的选择标准:

  • 只需要每组一个汇总值,且不需要明细:用GROUP BY,配合HAVING过滤组。
  • 需要每组取一条"最有代表性的记录"(比如每组最新一条):优先用窗口函数ROW_NUMBER(),比GROUP BY + 子查询更简洁,MySQL 8.0里尤其明显。
  • 需要在每一行追加分组汇总值:直接上窗口函数,别绕GROUP BY + JOIN。
  • 对MySQL 5.7及以下兼容有硬性要求:回到GROUP BY + 子查询方案。

选错方案最典型的后果,是写出来的SQL在5.7上能跑,一上8.0发现可以大幅简化,但没人敢动;反过来也有从Oracle迁到MySQL的同事,习惯了窗口函数,发现目标MySQL是5.7不支持,只能硬改成GROUP BY JOIN,性能掉了个数量级。先确认数据库版本,再决定写法,比纠结"哪种方案更优雅"重要得多。

最后分享一个我自己的习惯:任何一条GROUP BY SQL上线前,我都会看一眼EXPLAIN。Extra里如果出现Using temporary或者Using filesort,一定停下来想清楚能不能用索引化解;出现Using index或者直接用索引分组,基本可以放心。

被问得最多的另一个问题是:"为什么我的SQL加了GROUP BY之后变慢了?"——十有八九是SELECT里带了非分组字段,导致数据库不得不做更多临时表操作来满足输出。删掉多余字段,或者把聚合逻辑写清楚,往往比加索引更快。写SQL和写代码一样,先想清楚语义,再优化性能,顺序反了就是在给你的同事埋雷。

返回列表