做了很多年 MySQL 的数据查询,我越来越觉得:写聚合、分组、联合这三类查询,本质上不是背语法,而是在做一次空间搭建。真正的工作场景里,它们很少单独出现,更多是同一条 SQL 里互相嵌套、彼此拼接,像搭立体的拼图——聚合负责"变",分组负责"分",联合负责"并"。这篇文章我想把这三块拼图各自的功能拆开讲透,再重点说说它们组合拼装时最容易踩的坑,适合刚接触 MySQL 的初级开发,也适合已经会写基础查询、但一遇到组合需求就翻车的朋友。
写 SQL 的人通常有个错觉:只要把单个知识点背熟,组合起来自然会。但现实是,聚合函数和 GROUP BY、UNION 放在同一条语句里的时候,会出现很多"单看都对、合起来错"的问题。我一直觉得,如果能理解聚合是怎么把行数"压扁"的、分组是怎么划分边界的、联合是怎么纵向延长结果集的,你就拥有了拼图的完整视角。下文我会用尽量通俗的比喻和能直接跑起来的例子来讲,也会告诉你哪些地方是我实际项目里真吃过亏的。
1. 聚合是第一块拼图:COUNT、SUM、AVG 到底在"坍缩"什么
1.1 聚合的直观本质:把一组行压缩成一个结论
聚合函数的最大特点,是输入很多行、输出一行。就像一支团队的年底总结:每个人是单独的一行记录,但 SUM、AVG、COUNT 这些函数会把整组人的数据"压"成一个总数、一个平均值、一个人数。
在没有 GROUP BY 的情况下,聚合函数作用于整个结果集。比如:
SELECT COUNT(*) AS total_orders, SUM(amount) AS total_amount FROM sales_order WHERE status = 'paid';这条语句把满足条件的全部订单行"坍缩"成一行统计结果。你原来在脑子里想象的"每一行输出一个结果",到这里必须切换为"整个结果集输出一个结果"。这是聚合思维和普通查询思维最大的分水岭,理解不了这一点,后面所有组合写法都会别扭。
1.2 COUNT 系列:星号、字段名、DISTINCT 的三种差异
COUNT 是最容易写错但最不容易被发现的函数。很多人以为 COUNT(*) 和 COUNT(字段) 只是写法不同,实际差别很大:
COUNT(*):统计行数,完全不管这一行里有没有 NULL。COUNT(column):统计该列非 NULL 的行数,只要某行这个字段是 NULL,就不算进去。COUNT(DISTINCT column):对该列的非 NULL 值去重后计数。
举个例子,一张订单表里有 1000 行记录,其中首单优惠码first_order_code有 300 行为 NULL,顾客 IDcustomer_id的去重值为 200 个:
SELECT COUNT(*) AS total_rows, COUNT(first_order_code) AS has_code_rows, COUNT(DISTINCT customer_id) AS unique_customers FROM sales_order;跑出来的结果很可能是 1000、700、200。如果你本意是"有多少个顾客下了单",写成COUNT(customer_id)就会得到 1000 这种毫无意义的答案——正确写法必须是COUNT(DISTINCT customer_id)。
提示:需要去重计数时,直接在 SQL 里写
COUNT(DISTINCT ...),不要先把全量数据拉回程序里再数,那样既浪费传输量又容易在代码里引入 bug。
1.3 SUM 与 AVG:NULL 的处理比想象中更隐蔽
SUM 和 AVG 都会忽略 NULL 行,但它们遇到"整组都是 NULL"时的表现不同。SUM(amount)如果这一组所有行的 amount 都是 NULL,结果是 NULL 而不是 0。这在报表里非常坑,所以实际项目中我几乎总是写:
SELECT COALESCE(SUM(amount), 0) AS total_amount FROM sales_order;AVG 有一个更隐蔽的地方:它的分母只统计非 NULL 的行数。假设某员工三天的业绩分别是 100、NULL、200,AVG(performance)算出来是 150,而不是 100——因为 NULL 那天的数据在 AVG 看来"不存在",并没有参与分母。如果你本意是"三天平均所以应该除以 3",这个结果就会误导你。
MAX 和 MIN 也不止能处理数字。日期、字符串同样可以比较大小,所以MAX(pay_time)可以直接得到"最近一次支付时间",MIN(created_at)可以得到"最早创建时间"。这个用法在查首单时间、最近活跃时间时很顺手。
1.4 GROUP_CONCAT:MySQL 特有的字符串聚合拼图块
除了数值聚合,MySQL 还提供了GROUP_CONCAT,它能把组内的多个值拼成一个字符串。比如想知道每个品类下卖过哪些商品名:
SELECT category, GROUP_CONCAT(product_name ORDER BY quantity DESC SEPARATOR '、') AS product_list FROM sales_item GROUP BY category;这里有个我第一次用就踩中的坑:GROUP_CONCAT有长度限制,默认值group_concat_max_len是 1024 字节。商品名稍微一多,拼出来的结果会被静默截断,看起来就像"答案少了一块"。遇到这种情况,可以在查询前执行:
SET SESSION group_concat_max_len = 10240;只影响当前会话,不改全局配置,适合临时查看大字符串聚合结果。
2. GROUP BY 决定答案的粒度:分组字段选错,问题就变味了
2.1 分组是"先分堆,再聚合",执行顺序有严格纪律
GROUP BY 的执行时机在 WHERE 之后、HAVING 之前。标准逻辑顺序是这样的:
FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> DISTINCT -> ORDER BY -> LIMIT我把这个顺序背得很熟,因为它能解释后面一半的坑。用生活里的话说:WHERE 是进厨房之前的"挑食材",GROUP BY 是"把挑好的食材分碗装",聚合函数是"数每碗有几颗",HAVING 是"端上桌前检查每碗合不合格"。如果你在挑食材阶段就想数数,会发现食材还没分碗,根本没法按碗统计。
看一个最基础的例子:
SELECT category, SUM(quantity) AS total_quantity, COUNT(*) AS item_lines FROM sales_item GROUP BY category;先按 category 归堆,再对每个堆做 SUM、COUNT,最终输出的是每个分类一行统计结果。理解了这个"归堆"动作,你就理解了聚合为什么是在分组之后才发生的。
2.2 分组粒度决定了答案的粗细:先想好要回答哪个层级的问题
"粒度"这个词听起来抽象,但它是分组查询的灵魂。GROUP BY category回答的是"品类层级"的问题;GROUP BY category, brand回答的是"品类+品牌层级"的问题。分组字段越多,输出行数越多,粒度就越细。
我的习惯是:动手写 SQL 之前,先用一句话说清楚要回答的问题。比如"我想看每个品类下各品牌的销量",这句话已经明确了分组字段就是category, brand:
SELECT category, brand, SUM(quantity) AS total_quantity FROM sales_item GROUP BY category, brand;粒度模糊是报表口径混乱的头号来源。同一个数字,你按品类维度统计是 A 值,按品牌维度统计是 B 值,两者都有自己的意义,但绝不能混着用。
另外一个细节:当分组列包含 NULL 时,MySQL 会把所有 NULL 分到同一组里。这不是 bug,是标准行为,但如果 NULL 在你的表里代表"未填写",记得要么用COALESCE(category, '未分类')显式归拢,要么提前在 WHERE 里排除。
2.3 ONLY_FULL_GROUP_BY:MySQL 为什么拒绝你"顺手"选的字段
从 MySQL 5.7.5 开始,ONLY_FULL_GROUP_BY默认开启。它的规则可以一句话概括:SELECT 列表里出现的每一个非聚合字段,都必须完整出现在 GROUP BY 里,否则直接报错。
-- 错误写法 SELECT category, unit_price, SUM(quantity) FROM sales_item GROUP BY category;这条语句报错的原因很朴素:同一个 category 分组里,unit_price 可能有多个不同的值,数据库到底该显示哪一个?标准 SQL 不接受"哪一行都行"这种随机答案,干脆拒绝执行。正确的修法是:如果想让 unit_price 参与统计,就把它也加入 GROUP BY;如果只是想顺便看一眼,可以用ANY_VALUE(unit_price)明确告诉 MySQL"我要这个分组里的任意值"。这个方法在维护老项目、遇到无法改 SELECT 结构的场景时很实用,但日常新写代码不建议过度使用,因为它本质上是在绕开分组的严谨性。
2.4 HAVING 是"对组的筛选",它和 WHERE 分工完全不同
WHERE 在分组前过滤行,HAVING 在分组后过滤组。这个分工直接决定了书写位置和语义。
比如要找出销量大于 100 的品类:
SELECT category, SUM(quantity) AS total_quantity FROM sales_item GROUP BY category HAVING SUM(quantity) > 100;HAVING里可以写聚合函数,因为此时分组已经完成。反过来,如果试图在 WHERE 里写SUM(quantity) > 100,MySQL 会直接报Invalid use of group function——WHERE 执行时聚合结果根本还没算出来,自然无法判断。
更常见的误区是想过滤"已支付"的订单,却把 status 写进 HAVING。status 根本不在分组字段里,一个客户的分组内有多条订单,status 就可能有多个值,这种写法在 ONLY_FULL_GROUP_BY 开启时会被拒绝,就算某些宽松模式放行了,得到的也是毫无意义的"组内任意值"。正确做法永远是把行级过滤条件放到 WHERE 里。
2.5 按时间分组时的索引问题:函数包裹字段需谨慎
按时间分组是报表里最频繁的操作,常见写法是:
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, SUM(amount) AS total_amount FROM sales_order GROUP BY DATE_FORMAT(created_at, '%Y-%m');功能上没问题,但DATE_FORMAT(created_at, ...)对字段套了函数,MySQL 通常就难以直接使用 created_at 上的索引,具体执行计划要以 EXPLAIN 为准。数据量大的时候,这个查询会扫得很吃力。
我的处理方案有两种:一是表里额外加一个created_date DATE冗余列,写入时直接存日期,再为它建索引;二是用生成列维护格式化后的月份字段。这样 GROUP BY 可以直接落在索引字段上。另一个细节:DATE_FORMAT返回的是字符串,月份排序按字典序来,'2025-02' 会排在 '2025-01' 后面还好,但跨年份时顺序就可能出问题。稳妥做法是 SELECT 里同时保留MAX(created_at),外层 ORDER BY 用真实时间。
3. UNION 不是 JOIN 的亲戚:纵向拼接的列数、类型与去重逻辑
3.1 什么时候需要 UNION:同构数据但分散在不同的桶里
UNION 解决的是"把多张结构相同的表,或者多个同构的结果集,纵向堆叠成一张大结果集"的问题。常见场景包括历史分表、线下和线上渠道分开存的两套订单表、按年度拆分的流水表。
这里必须先和 JOIN 分清:JOIN 是横向拼接,把两张表的列左右并在一起,类比是两个人并排站合影;UNION 是纵向拼接,把两张表的行上下叠在一起,类比是一群人排队集合,队伍人数变多,但每个人的"字段宽度"不变。搞混这两者,是查询思路混乱的开始。
看一个最简单例子:一张线上订单表、一张线下订单表,结构完全相同,想拿到全渠道订单明细:
SELECT order_no, amount, 'online' AS channel FROM order_online UNION ALL SELECT order_no, amount, 'offline' FROM order_offline;注意第一段 SELECT 里的'online'是常量列,作用是为结果集打一个"渠道"标记,这在后续按渠道聚合时非常有用。
3.2 列数一致、类型兼容、列名取自第一段 SELECT
UNION 的规则特别像拼积木:两边接口必须对得上。两个 SELECT 的列数必须完全一致,对应列的数据类型要兼容——MySQL 允许隐式转换,但把数字列拼到字符串列上会引发意外的转换结果。另外,最终结果集的列名由第一段 SELECT 的别名决定,第二段的别名会被忽略。所以写 UNION 时,第一段 SELECT 的别名要认真命名,它就是整张结果集对外暴露的字段名。
3.3 UNION 与 UNION ALL:默认去重的成本比你想象的高
这里是我最想强调的一点:UNION等价于UNION DISTINCT,它会对所有列做一次去重比较,数据量大时需要排序甚至动用临时表空间。而UNION ALL只是简单地把行堆在一起,不做去重判断。
大多数业务场景其实是"把分散的数据拼起来继续算",根本不需要去重。这时候用默认的 UNION 等于让数据库多做一轮无意义的工作。我的习惯是:纯拼接一律UNION ALL,真的存在"两份数据中可能有完全相同的行"这种去重需求时,再放到最外层用DISTINCT或GROUP BY显式处理。这样既明确又可控。
还有一个隐蔽点:UNION 去重时会把 NULL 视为同一个值。如果某行关键列全是 NULL,可能被合并成一行,丢失你本不想丢失的记录。
3.4 各段内部排序、限量:括号的语法细节别记错
默认情况下,ORDER BY 只能出现在整个 UNION 的最外层。如果某一段内部想先排序并 LIMIT,MySQL 里必须把这一段用括号包起来:
(SELECT order_no, amount FROM order_online ORDER BY amount DESC LIMIT 5) UNION ALL (SELECT order_no, amount FROM order_offline ORDER BY amount DESC LIMIT 5) ORDER BY amount DESC;第一段 SELECT 后面如果不加括号直接写 ORDER BY,MySQL 会直接报语法错误。我见过很多同事在这里被卡住,其实记住"括号可保平安"就够了。整体排序放在 UNION 之后,这时 ORDER BY 可以引用第一段 SELECT 定义的别名,也可以写列序号,但我强烈建议写别名——列序号在后续有人调整 SELECT 列表时,指向的列会悄悄变化,是隐形的维护炸弹。
3.5 UNION 之后再聚合:把 UNION 结果当成一张临时表
组合拼图的关键动作,是把 UNION 的结果包成一个派生表,在外层继续 GROUP BY。比如按渠道统计各渠道的订单数和总额:
SELECT channel, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM ( SELECT order_no, amount, 'online' AS channel FROM order_online UNION ALL SELECT order_no, amount, 'offline' FROM order_offline ) t GROUP BY channel;这里的t是派生表的别名,MySQL 强制要求必须写,不写直接报错。这个"包一层"的思想,是整个聚合、分组、联合组合使用的核心桥梁——UNION 负责把数据汇到一起,GROUP BY 负责在汇合后的总结果集上重新划分边界,聚合函数负责在每个边界内收拢结论。
4. 三块拼图同时出场时最容易翻车的四个接缝点
4.1 先 JOIN 再 GROUP BY:重复计数是报表里最经典的翻车点
这个坑我踩过不止一次。订单主表和订单明细表是一对多关系,一条订单对应多条明细。当你把两张表 JOIN 起来再按品类分组,订单主表里的金额字段会被明细行"放大"。
-- 错误示例:SUM(o.amount) 会把多明细订单的总金额重复累加 SELECT i.category, COUNT(DISTINCT o.order_id) AS order_cnt, SUM(o.amount) AS total_amount FROM sales_order o JOIN sales_item i ON i.order_id = o.order_id WHERE o.status = 'paid' GROUP BY i.category;这里COUNT(DISTINCT o.order_id)因为去重了,订单数是对的;但SUM(o.amount)完全错了——一个订单只要有三条明细,它的金额就会被累加三次。总额虚高的幅度取决于订单平均明细数。
正确思路是先明确口径。如果统计的是"品类级的销售额",应该用明细行小计price * quantity来聚合,因为每一行明细是唯一的:
SELECT i.category, COUNT(DISTINCT o.order_id) AS order_cnt, SUM(i.quantity * i.unit_price) AS category_amount FROM sales_order o JOIN sales_item i ON i.order_id = o.order_id WHERE o.status = 'paid' GROUP BY i.category;如果坚持要按主表金额统计,那就必须在 JOIN 之前先把订单表聚合到订单维度,再和明细关联。这种"先降维再关联"的思路,能避开绝大多数重复计数问题。
4.2 WHERE 与 HAVING 的时序混淆:行级过滤和组级过滤不是一回事
执行顺序我前面已经强调过:WHERE 在分组前,HAVING 在分组后。落到实际编码中,最常见的错误是"想过滤行却写进了 HAVING"。
-- 错误示例:status 不是分组字段,这个过滤应该发生在分组前 SELECT customer_id, SUM(amount) AS total_amount FROM sales_order GROUP BY customer_id HAVING status = 'paid';一个顾客分组内可能同时存在已支付和未支付的订单,status 是组内多值字段,拿它做过滤在语义上就是错的。在 ONLY_FULL_GROUP_BY 开启时,MySQL 会直接拒绝;就算旧库关了该模式侥幸通过,结果也是随机挑选一行的 status 来判断,根本不可信。正确写法是把 status 条件放到 WHERE:
SELECT customer_id, SUM(amount) AS total_amount FROM sales_order WHERE status = 'paid' GROUP BY customer_id;区分两者的口诀我一直在用:WHERE 是"挑哪些行参与统计",HAVING 是"挑哪些组留在结果里"。
4.3 聚合函数不能直接嵌套:想要"均值的均值"得先包一层子查询
有时候需求是"算每个品类的平均价格,再求所有品类的平均价格的均值"。新手很自然会写AVG(AVG(price)),但 MySQL 会直接报Invalid use of group function——聚合函数不能直接嵌套聚合函数。
解法还是那句老话:包一层。先在内层算出每个品类的均值,再在外层对这些均值求平均:
SELECT AVG(category_avg_price) AS overall_avg FROM ( SELECT category, AVG(unit_price) AS category_avg_price FROM sales_item GROUP BY category ) t;内层结果集对 SQL 引擎而言就是一张普通临时表,外层可以对它继续做任何聚合。这个思路和 UNION 后再聚合是同一个套路:把"中间结果"显式表达成子查询,复杂度就瞬间清晰了。
4.4 UNION 组合时的排序、限制和 NULL 合并陷阱
把 UNION 和 ORDER BY、LIMIT 组合时,最常出现的问题是"想要每个分段各自 Top N,结果却不是自己预期的样子"。
(SELECT category, SUM(quantity) AS qty, '上半年' AS period FROM sales_item WHERE created_at < '2025-07-01' GROUP BY category ORDER BY qty DESC LIMIT 3) UNION ALL (SELECT category, SUM(quantity), '下半年' FROM sales_item WHERE created_at >= '2025-07-01' GROUP BY category ORDER BY qty DESC LIMIT 3) ORDER BY period, qty DESC;这个写法依赖括号实现"每段先各自排序取前三,再整体按 period 排"。如果不加括号,第一段里的 ORDER BY 要么语法报错,要么被忽略。另外注意:UNION 的隐式去重如果同时存在,会按照整行比较去合并,包含 NULL 的行极有可能被吞掉。所以只要不是真的需要"所有列相同才合并",就用 UNION ALL 保住每一行。
5. 实战推演:从需求到 SQL 的完整构造过程
纸上谈兵再多,不如把一套完整需求从头拆到尾。假设某项目同时运营线上商城和线下门店,两张订单表结构完全一致,另有一张订单明细表。运营提了一个组合需求:"分别统计本月线上、线下两个渠道的支付订单数和销售额;再给出两个渠道各自销售额前三的品类;最后要一份全渠道的品类销售排行。"
表结构简化为:
-- 线上订单表 order_online:order_id, amount, status, pay_time -- 线下订单表 order_offline:order_id, amount, status, pay_time -- 订单明细表 order_item:id, order_id, category, quantity, unit_price第一步,定口径。时间范围取pay_time >= '2025-06-01' AND pay_time < '2025-07-01',状态取status = 1表示已支付。销售额采用明细行小计quantity * unit_price聚合,避免主表金额在 JOIN 明细后重复累加。
第二步,算各渠道头部指标。因为订单明细表在两份订单表里分别关联,先算线上再算线下,最后 UNION ALL 拼起来:
SELECT 'online' AS channel, COUNT(DISTINCT o.order_id) AS paid_orders, SUM(i.quantity * i.unit_price) AS channel_amount FROM order_online o JOIN order_item i ON i.order_id = o.order_id WHERE o.status = 1 AND o.pay_time >= '2025-06-01' AND o.pay_time < '2025-07-01' UNION ALL SELECT 'offline', COUNT(DISTINCT o.order_id), SUM(i.quantity * i.unit_price) FROM order_offline o JOIN order_item i ON i.order_id = o.order_id WHERE o.status = 1 AND o.pay_time >= '2025-06-01' AND o.pay_time < '2025-07-01';第三步,算各渠道销售额前三的品类。每个渠道单独查询、排序、限量,再用括号包起来做 UNION ALL:
(SELECT 'online' AS channel, category, SUM(quantity * unit_price) AS amount FROM order_online o JOIN order_item i ON i.order_id = o.order_id WHERE o.status = 1 AND o.pay_time >= '2025-06-01' AND o.pay_time < '2025-07-01' GROUP BY category ORDER BY amount DESC LIMIT 3) UNION ALL (SELECT 'offline', category, SUM(quantity * unit_price) FROM order_offline o JOIN order_item i ON i.order_id = o.order_id WHERE o.status = 1 AND o.pay_time >= '2025-06-01' AND o.pay_time < '2025-07-01' GROUP BY category ORDER BY amount DESC LIMIT 3) ORDER BY channel, amount DESC;注意第二段的别名其实不会生效,最终排序用的是第一段的 channel 和 amount。
第四步,最关键的组合:求全渠道品类销售排行。这里有两个策略,我强烈推荐先各渠道聚合到品类,再 UNION ALL,最后外层再聚合:
SELECT category, SUM(amount) AS total_amount FROM ( SELECT i.category AS category, SUM(i.quantity * i.unit_price) AS amount FROM order_online o JOIN order_item i ON i.order_id = o.order_id WHERE o.status = 1 AND o.pay_time >= '2025-06-01' AND o.pay_time < '2025-07-01' GROUP BY i.category UNION ALL SELECT i.category, SUM(i.quantity * i.unit_price) FROM order_offline o JOIN order_item i ON i.order_id = o.order_id WHERE o.status = 1 AND o.pay_time >= '2025-06-01' AND o.pay_time < '2025-07-01' GROUP BY i.category ) t GROUP BY category ORDER BY total_amount DESC LIMIT 10;为什么要这样写?因为 UNION 之前,两个渠道分别只在品类维度上留了十多行聚合结果,派生表 t 的数据量可能只有几十行;如果直接 UNION ALL 明细行,t 里装的是全月所有订单明细,可能是几十万行。两者的性能天差地别。这种"先聚合、后合并、再聚合"的写法,是在同一条 SQL 里同时用好聚合、分组、联合三块拼图的完整示范。
6. 用顺手之后沉淀下来的几条经验
写了这些年 SQL,真正让我效率翻倍的往往不是更高级的函数,而是几个朴素的习惯。
第一,动手前先问口径。按订单统计还是按明细统计?金额用主表还是明细小计?分组粒度到哪一级?口径不清,SQL 写多漂亮都是白搭。我自己见过太多因为口径不一致导致报表对不上账的案例,最后排查下来根本不是 SQL 写错,而是当初没定义清楚。
第二,遇到"JOIN 之后直接 GROUP BY 聚合主表字段"的写法,先停下来检查重复计数。能用COUNT(DISTINCT)解决的用COUNT(DISTINCT),解决不了就拆子查询,先在低粒度层算完再关联。
第三,写 GROUP BY 时,SELECT 里的非聚合字段全部进 GROUP BY,不依赖 MySQL 的宽松模式。版本升级、参数调整都可能让旧写法一夜之间报错,养成严谨习惯能少很多麻烦。
第四,UNION 默认是去重的,但大多数拼接场景根本不需要去重,无脑用 UNION ALL 更稳更快。真的要去重,放到外层用 GROUP BY 或 DISTINCT 显式表达,意图一目了然。
第五,派生表的行数决定性能。数据量大的时候,尽量把聚合下沉到 UNION 之前做,让临时表小一点,再小一点。
最后说个我自己的教训。早年间做报表,我用 UNION 把两个渠道的订单拼在一起,发现总订单数对不上,一直怀疑是 JOIN 的问题。排查了整整一个下午,最后发现是 UNION 的隐式去重把两个渠道里恰好相同的字段组合合并成了一行。从那之后,凡是纯拼接我一律写 UNION ALL,这个习惯救了我无数次。希望这篇东西能帮你在组合聚合、分组、联合的时候少走几个这样的弯路。