SQL聚合思维:MySQL分组查询与联合查询的性能优化实战

📅 发布时间:2026/10/5 15:44:40
SQL聚合思维:MySQL分组查询与联合查询的性能优化实战
1. 别再用程序循环统计数据了真正该学会的SQL聚合思维先聊个我经常看到的场景。很多刚接触数据库的同学接到“统计一下每个分类的商品数量”这种需求第一反应是写一段Python或Java代码把整张表查出来然后在内存里for循环加if判断最后手动组装结果。表里几百条数据时看不出问题等数据涨到几十万、上百万条这种写法会把应用服务器的内存直接打爆响应时间从毫秒级变成秒级甚至超时。这就是聚合查询、分组查询、联合查询存在的意义。它们不是SQL里的“高级玩法”而是关系型数据库处理数据的基本功。用一条SQL搞定的事情根本不需要把数据搬到应用层去算。MySQL作为最常用的开源关系型数据库这几个查询能力几乎是每个后端开发每天都要用的。这篇文章我从实际业务出发把聚合函数怎么用、分组查询的执行逻辑、联合查询的适用场景以及这三个东西组合起来该注意什么系统性地过一遍。内容会覆盖不少真实项目中容易踩的坑比如COUNT(1)和COUNT(*)到底有没有区别、HAVING为什么不能随便替换WHERE、UNION和UNION ALL选错了会出什么事故这些都是面试高频题也是生产环境里真正会咬人的地方。不管你是刚入门SQL的新手还是写了几年代码但查询优化全靠猜的开发者这篇文章都值得花十分钟看完。我会用实际的表结构和数据来演示你可以直接复制到自己的MySQL里跑一遍。2. 五个聚合函数背后的计算逻辑与使用边界2.1 COUNT、SUM、AVG、MAX、MIN从一道统计题说起先建一张销售订单表后面的例子基本都围绕它展开CREATE TABLE sales_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, customer_id INT NOT NULL, category VARCHAR(20) NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-有效 0-取消, created_at DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;插入一批模拟数据大概几十条就行覆盖多个分类和日期。然后看第一个需求统计总订单数、总销售额、平均订单金额、最大单笔金额、最小单笔金额。SELECT COUNT(*) AS total_orders, SUM(amount) AS total_amount, AVG(amount) AS avg_amount, MAX(amount) AS max_amount, MIN(amount) AS min_amount FROM sales_order WHERE status 1;这一条SQL就把五个聚合函数全用上了。执行结果会返回一行数据这就是聚合查询的核心特征多行输入单行输出。每一行原始记录经过聚合函数压缩成一个汇总值数据量从N行变成1行。聚合函数的计算逻辑可以理解为“把一列的所有值收集起来按特定规则算出一个结果”COUNT数的是行数SUM做加法AVG先加再除MAX和MIN在遍历过程中记录极值。它们都忽略NULL值除了COUNT(*)这一点在后面单独说。2.2 COUNT(*) 与 COUNT(1)、COUNT(列名) 的真相与争议这是个老生常谈但很多人依然搞混的问题。先说结论在MySQL的InnoDB引擎下COUNT()和COUNT(1)性能上没有显著差异它们都会遍历主键索引或二级索引进行计数MySQL官方也明确说过COUNT()没有被做特殊优化但它已经被优化器处理为直接统计行数。关键区别在于COUNT(列名)。比如COUNT(amount)它只统计amount这一列中非NULL的行数如果某行的amount是NULL这一行不会被计入。而COUNT(*)和COUNT(1)统计的是“行数”不管某一列是不是NULL只要行存在就算。看个实际案例-- 假设有一行 amount NULL SELECT COUNT(*) FROM sales_order; -- 返回总行数包含NULL行 SELECT COUNT(amount) FROM sales_order; -- 返回amount非NULL的行数真实项目中我发现很多人用COUNT(字段)去统计行数如果那个字段恰好允许NULL计数就会偏小而且这种错误很难被察觉因为数据量一大少几十行根本看不出来。用COUNT(*)或者COUNT(1)最稳妥。另外补充一点以前有老说法是COUNT(1)比COUNT(*)快那是MyISAM时代的经验。InnoDB下两者执行计划基本一样别被过时经验带偏了。2.3 SUM与AVG的精度隐患和NULL陷阱SUM和AVG处理的是数值列有两个容易出问题的地方。第一个是精度。amount字段如果定义成DECIMAL(10,2)SUM的结果没问题。但如果列是FLOAT或DOUBLE累加大量小数后会出现浮点误差比如0.1 0.2算出来是0.30000000000000004这种。金额相关的统计一定要用DECIMAL而且AVG的返回类型在MySQL 5.7及以后会遵循一个规则如果参数是DECIMAL返回DECIMAL如果是整数类型返回DECIMAL(20, 4)左右的样子不会丢精度。这是MySQL做得比较合理的地方。第二个是NULL。SUM遇到NULL不会报错而是跳过它继续累加。但如果某一列全是NULLSUM返回的是NULL而不是0。这个细节很坑因为Java程序里用BigDecimal接收还好如果用基本类型double接收拆箱时直接NullPointerException。建议遇到可能全空的统计用IFNULL兜底SELECT IFNULL(SUM(amount), 0) AS total_amount FROM sales_order WHERE ...;AVG同理如果所有值都是NULL返回NULL。逻辑上没问题但程序端要做好防空处理。2.4 MIN和MAX不只是数字字符串和日期也能比大小很多人以为MIN和MAX只能用于数值列其实字符串和日期类型同样适用。字符串按字典序比较日期按时间先后比较。实际业务中有个很典型的用法查每个用户最早的下单时间。用MIN(created_at)就能实现不用ORDER BY再加LIMIT。还有人用它配合GROUP BY来取“每组最早的一条记录”这个后面讲分组时会展开。还有一个冷门但实用的写法——用MAX(id)来判重。比如同步数据时想知道某张表最大自增ID到哪了直接SELECT MAX(id)就行比SELECT * ORDER BY id DESC LIMIT 1要高效因为MAX可以直接走索引的末尾节点不需要排序。提一句容易被误解的聚合函数能不能和普通列一起出现在SELECT里比如SELECT customer_id, MAX(amount) FROM sales_order;这条SQL在MySQL的默认配置下能执行但结果是不确定的customer_id不一定对应amount最大的那一行。在严格模式下ONLY_FULL_GROUP_BYMySQL 5.7及以后会直接报错。这是一个必须养成的习惯SELECT中的普通列必须出现在GROUP BY子句中或者被聚合函数包裹。3. 分组查询GROUP BY不只是“加一个分组条件”那么简单3.1 GROUP BY的执行顺序决定了你的SQL能不能跑对分组查询的核心是GROUP BY它的执行逻辑在SQL语句的整体流程中处于一个很关键的位置。一条完整的SQL执行顺序大致是FROM → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT这个顺序解释了三个常见疑问第一WHERE是在分组之前过滤的。所以WHERE里不能使用聚合函数作为条件比如“筛选出订单金额大于100的顾客”这个条件是在分组前对原始行进行过滤的。而HAVING是在分组之后对组进行过滤的所以HAVING里可以使用聚合函数。第二SELECT的别名不能在WHERE中使用但可以在ORDER BY中使用因为SELECT在ORDER BY之前执行。第三GROUP BY之后SELECT能出现什么列取决于分组依据。如果按customer_id分组那么每组有唯一的customer_id但amount有很多个值直接SELECT amount没有意义。现在看一个完整的例子统计每个客户的订单总额和下单次数只保留有效订单最后按金额倒序。SELECT customer_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM sales_order WHERE status 1 GROUP BY customer_id HAVING total_amount 1000 ORDER BY total_amount DESC;执行流程拆解FROM sales_order加载表数据WHERE status 1筛掉无效订单GROUP BY customer_id把同一个客户的记录归为一组聚合计算COUNT和SUM针对每组分别计算HAVING total_amount 1000筛掉总额不足1000的组SELECT输出取需要的列ORDER BY按总额排序这里有个注意点HAVING里用了SELECT别名total_amountMySQL允许HAVING引用SELECT别名但标准SQL里更稳妥的写法是用聚合函数本身即HAVING SUM(amount) 1000。两种写法MySQL都支持但理解它们的作用位置才是关键。3.2 WHERE与HAVING谁先执行、谁走索引、谁拖慢全表对于初学者最容易犯的错误就是把HAVING当成“能用在WHERE后面的条件”在WHERE里写SUM(amount) 1000直接语法报错。反之把本来应该在WHERE里过滤的条件放到HAVING里SQL能跑但性能可能会差很多。为什么性能会差因为WHERE在分组前过滤减少了进入分组的数据量HAVING在分组后过滤意味着所有数据都必须先分组、聚合然后才轮到它起作用。举个例子-- 推荐先过滤状态再分组 SELECT customer_id, COUNT(*) FROM sales_order WHERE status 1 GROUP BY customer_id; -- 不推荐所有状态都分组后再过滤 SELECT customer_id, COUNT(*) FROM sales_order GROUP BY customer_id HAVING ...;能放到WHERE的条件绝不放HAVING。这是分组查询优化的第一条铁律。还有一个细节WHERE条件如果在索引列上比如status字段有索引过滤阶段就能走索引大幅减少需要分组的数据量。HAVING里的条件通常是对聚合结果的判断无法走索引只能内存中过滤。3.3 GROUP BY与DISTINCT去重这件事选错就出事GROUP BY可以用来去重DISTINCT也可以。两者结果在某些场景相同但使用场景完全不同。比如查询所有不重复的客户IDSELECT DISTINCT customer_id FROM sales_order; SELECT customer_id FROM sales_order GROUP BY customer_id;结果一样但语义不同DISTINCT是“去重后显示”GROUP BY是“按这个字段分组聚合”。如果只是去重而没有聚合需求用DISTINCT更直观。如果还需要统计每个客户的订单数那必须用GROUP BY。有个经典问题GROUP BY和DISTINCT的性能哪个好没有一个绝对答案。在MySQL 8.0里如果GROUP BY不需要聚合计算优化器有时会把GROUP BY转换为DISTINCT来执行。如果你只需要去重直接写DISTINCT语义更清晰如果你需要分组聚合就别想用DISTINCT实现了。另外DISTINCT和聚合函数可以组合比如COUNT(DISTINCT customer_id)用来统计去重后的客户数。这也是个非常常用的写法但注意它会对DISTINCT字段做排序或哈希操作数据量大时成本不低不要滥用。3.4 分组后的排序问题为什么ORDER BY有时候会失效分组查询里一个常见的困扰是我想取分组后每组最新的一条记录直接GROUP BY ORDER BY created_at DESC却拿不到预期结果。看这个例子-- 意图查出每个客户最近一笔订单 SELECT customer_id, order_no, MAX(created_at) FROM sales_order GROUP BY customer_id;这条SQL的问题是MAX(created_at)只返回了日期order_no不一定是对应最新订单的订单号。因为分组后每一组只有一行order_no如果不在GROUP BY里MySQL只会从这一组里随机挑一个值返回在非严格模式下。也就是说order_no和MAX(created_at)之间没有关联关系。正确的解法有几种。最简单的是子查询先查出每个客户的最大时间再用JOIN关联回原表取完整记录。SELECT s.customer_id, s.order_no, s.created_at FROM sales_order s JOIN ( SELECT customer_id, MAX(created_at) AS max_time FROM sales_order GROUP BY customer_id ) t ON s.customer_id t.customer_id AND s.created_at t.max_time;如果同一客户在同一秒下了多笔订单这个写法可能返回多行需要根据业务加上其他条件比如用MAX(id)替代MAX(created_at)。这是一个非常典型的“分组取最新”问题在订单系统、消息系统里极其常见后面联合查询部分还会再提一次。3.5 多列分组与空值分组GROUP BY的边界场景GROUP BY可以接多个列比如按分类和状态两个维度统计SELECT category, status, COUNT(*), SUM(amount) FROM sales_order GROUP BY category, status;多列分组时MySQL把多个列的值当作一个组合来看待只有所有列的值都相同才归为一组。这在统计报表中非常有用比如“统计每个分类下有效和取消订单各有多少”。另一个容易忽略的边界GROUP BY列包含NULL。MySQL会把所有NULL值归为一组显示在结果里。如果你不希望NULL单独成组可以先用IFNULL或COALESCE把NULL替换成具体值再分组SELECT IFNULL(category, 未知), COUNT(*) FROM sales_order GROUP BY IFNULL(category, 未知);这个细节在数据清洗场景中很实用因为数据库里很多字段允许NULL统计时如果不处理报表上会莫名其妙多出一行NULL分组。4. 联合查询UNION把结果拼起来JOIN把表拼起来4.1 UNION与UNION ALL一字之差一次全表扫描的代价联合查询UNION是把多个SELECT的结果纵向拼接在一起要求每个SELECT的列数相同且对应列的数据类型兼容。UNION和UNION ALL的区别最核心的一句话UNION会去重UNION ALL不会。看个实际例子。假设有两张结构相同的表一张是2024年的订单历史表一张是2025年的订单表我要合并查询两年的客户ID列表SELECT customer_id FROM sales_order_2024 UNION SELECT customer_id FROM sales_order_2025;如果用UNION结果里的重复客户会被合并成一个如果业务上需要“每个客户出现次数”就必须用UNION ALL因为UNION的隐式去重会破坏计数。性能上UNION比UNION ALL多一步去重操作MySQL需要为结果集排序或建临时表来消除重复行数据量大时差异很明显。所以一个优化经验是如果你确定两个子查询的结果不会有重复或者重复不影响业务一律用UNION ALL。反过来如果你确实需要去重但可以用DISTINCT在子查询内部完成也比UNION去重更可控。还有一个排序的坑如果要对UNION后的整体结果排序ORDER BY必须放在最后一个SELECT后面而不是每个子查询里单独写ORDER BY。而且单独用ORDER BY配合LIMIT时要搞清楚LIMIT是对单个子查询还是对最终结果生效。MySQL的规则是子查询里写ORDER BY会被优化器忽略除非配合LIMIT才有意义。(SELECT customer_id, amount FROM sales_order_2024 ORDER BY amount DESC LIMIT 10) UNION ALL (SELECT customer_id, amount FROM sales_order_2025 ORDER BY amount DESC LIMIT 10) ORDER BY amount DESC LIMIT 20;上面这个写法表示分别取两张表金额最高的10条合并后再按金额排序取前20条。这种写法在排行榜场景非常实用但一定要注意两边括号的使用和LIMIT的位置。4.2 JOIN在此文中的定位和UNION互补别混为一谈严格来说JOIN连接查询属于多表查询的范畴和UNION的“纵向拼接”不同JOIN是“横向拼接”——把两张表的列按关联条件合在一起。虽然热搜词里单列了联合查询但实际业务中UNION经常和JOIN组合使用。一个典型场景要查每个客户的最新一笔订单同时显示客户姓名。这时需要先GROUP BY找出每个客户最新订单时间再JOIN客户表取姓名再JOIN订单表取完整订单信息。三张表联动SQL会复杂不少但逻辑上是清晰的分步SELECT c.customer_name, t.order_no, t.amount, t.created_at FROM customer c JOIN ( SELECT customer_id, order_no, amount, created_at FROM sales_order s JOIN ( SELECT customer_id, MAX(created_at) AS max_time FROM sales_order GROUP BY customer_id ) m ON s.customer_id m.customer_id AND s.created_at m.max_time ) t ON c.customer_id t.customer_id;第一层子查询负责找出每个客户的最新订单第二层子查询负责把这笔订单的具体信息带出来最后JOIN客户表补上客户姓名。这种“分组 子查询 JOIN”的链式写法在处理报表类需求时几乎绕不开。JOIN连接本身还需要注意连接类型INNER JOIN只保留两表匹配的行LEFT JOIN保留左表全部行、右表不匹配的补NULL。很多人在统计时用错连接类型导致计数虚高或数据缺失。比如统计每个分类的订单数如果用INNER JOIN关联分类表某个一直没有订单的分类会被丢掉报表上就少了一个分类。这种问题在业务里很难一眼发现所以写之前要想清楚从哪个表作为主体出发用哪种连接方式。4.3 IN与EXISTS联合查询的另一种“联动”思路在讨论联合查询时很多人忽略了一个替代方案IN和EXISTS子查询。它们也能实现“根据另一个集合来判断当前行是否满足条件”的需求但执行方式不同。比较两个写法-- 查询有订单的客户的姓名 SELECT customer_name FROM customer WHERE customer_id IN (SELECT customer_id FROM sales_order); -- 等价写法EXISTS SELECT customer_name FROM customer c WHERE EXISTS ( SELECT 1 FROM sales_order s WHERE s.customer_id c.customer_id );IN子查询会把子查询结果集缓存起来然后逐行判断EXISTS是逐行对外层表做循环每次判断子查询是否有返回。MySQL优化器对两者都会改写实际性能差别在特定场景下存在但最靠谱的方法是看执行计划EXPLAIN。一个相对通用的经验子查询结果集小几百几千行用IN没问题外层表小、子查询表大且有索引用EXISTS往往更优。但这只是经验之谈真实项目还是以执行计划为准。4.4 UNION与JOIN组合的真实业务案例月度报表拼接把三个查询能力放在一个案例里看。一个常见需求按月汇总每个分类的销售总额把1月和2月的数据拼接成一张报表同时补充分类名称。步骤拆解子查询A统计1月各分类销售额子查询B统计2月各分类销售额UNION ALL合并两个结果JOIN分类表补分类名称SELECT c.category_name, t.month_label, t.total_amount FROM category c JOIN ( SELECT category, 2025-01 AS month_label, SUM(amount) AS total_amount FROM sales_order WHERE created_at 2025-01-01 AND created_at 2025-02-01 GROUP BY category UNION ALL SELECT category, 2025-02, SUM(amount) FROM sales_order WHERE created_at 2025-02-01 AND created_at 2025-03-01 GROUP BY category ) t ON c.category_id t.category ORDER BY t.month_label, t.total_amount DESC;这个案例把聚合SUM、分组GROUP BY、联合UNION ALL、连接JOIN全部串在一起同时也是月度报表的一种标准范式。我在实际项目中写过很多类似的SQL一个通用的优化思路是能用一条SQL完成的报表统计就不要拆成多次查询在应用层拼接。数据库擅长集合运算把计算下推到SQL里不仅能减少网络开销还能利用索引加速。5. 组合查询的底层优化从执行计划到索引利用5.1 EXPLAIN看到查询是如何被执行的而不是猜前面讲了这么多最终落到性能上离不开一个工具——EXPLAIN。在SQL前面加EXPLAINMySQL会返回执行计划标明用了哪张表、走了哪个索引、扫描了多少行、有没有使用临时表或文件排序。看一个分组查询的执行计划EXPLAIN SELECT customer_id, COUNT(*), SUM(amount) FROM sales_order WHERE status 1 GROUP BY customer_id;结果里重点看几列type访问类型从好到差依次是system、const、eq_ref、ref、range、index、ALL。出现ALL说明全表扫描需要警惕。possible_keys可能用到的索引。key实际用到的索引。rows预估扫描行数。Extra额外信息如果出现Using temporary或Using filesort说明查询有临时表或文件排序通常是性能瓶颈。对于GROUP BY查询MySQL 8.0之前最典型的优化是如果GROUP BY的列上有索引优化器可以直接利用索引的有序性完成分组避免临时表和文件排序。比如在sales_order表上建一个(customer_id, status)的联合索引上面的查询性能会好很多。ALTER TABLE sales_order ADD INDEX idx_customer_status (customer_id, status);注意联合索引的列顺序customer_id在前status在后这与查询中WHERE和GROUP BY的使用顺序有关。联合索引的基本原则是“最左前缀”查询条件里使用索引列的顺序需要和索引定义顺序一致才能充分利用。5.2 临时表与文件排序分组查询的两大隐形杀手分组查询性能差的根源通常集中在两个地方临时表和文件排序。MySQL做GROUP BY时如果无法利用索引的有序性就需要把中间结果放进内存临时表数据量大时临时表会溢出到磁盘性能急剧下降。文件排序同理GROUP BY结果需要排序时如果数据量超过sort_buffer_size的设置就会用磁盘文件排序。这两个问题的直接应对手段就是上面说的索引。以GROUP BY customer_id为例customer_id上有索引MySQL扫描索引时数据本身就是按customer_id排好序的分组操作可以直接顺序完成既不需要临时表也不需要排序。这就是为什么“分组字段建索引”能带来明显收益。另外补充一个容易被忽视的细节WHERE条件中的过滤字段如果能走索引可以大幅减少GROUP BY要处理的数据量。所以联合索引的设计要同时考虑WHERE和GROUP BY两个环节。这也是为什么很多生产环境里的慢查询最终优化方案都是加一个联合索引而不是改写SQL。5.3 聚合查询与索引COUNT和SUM能走覆盖索引吗聚合查询的索引利用有一个很实用的场景覆盖索引。如果查询只需要从索引中就能取到所有数据不需要回表性能会有数量级的提升。比如上面的分组统计SELECT customer_id, COUNT(*), SUM(amount) FROM sales_order GROUP BY customer_id;如果有一个(customer_id, amount)的联合索引MySQL可以从这个索引中直接读取customer_id和amount两列完成分组和聚合完全不需要回表查主键。在EXPLAIN的Extra列会显示Using index这就是覆盖索引的标志。因此在设计报表类查询的索引时一个常见套路是把WHERE过滤字段、GROUP BY分组字段、SELECT聚合字段全部塞进一个联合索引。索引占用空间会增加但查询性能收益远大于写放大成本。5.4 数据量增大时聚合结果过大怎么办当GROUP BY分组数量达到数万甚至数十万时即使索引利用得当聚合计算本身的开销也不小。这时候可以考虑分层聚合先在明细表上做一次粗粒度汇总存到统计表里查询时直接读统计表。比如订单表按天存储每天有几十万行。如果要看“每个客户每月的总订单额”可以每天凌晨跑一个定时任务把当天的数据按客户天聚合好存到汇总表。查询时直接对汇总表做GROUP BY数据量从百万级降到几千级速度自然快很多。这种“预聚合”的思路在BI报表、数据仓库场景里非常常见。MySQL本身虽然不像ClickHouse那样为分析而生但通过合理的分层聚合和索引设计也能支撑千万级数据的日常报表查询。6. 写在后面几条我踩过坑总结出来的SQL习惯最后分享几条我在实际项目中总结的经验不一定写在教科书里但绝对能帮你少走弯路。第一写聚合查询时永远先想清楚是“对行过滤”还是“对组过滤”。WHERE干前者HAVING干后者位置错了要么报错要么性能白丢。第二能用UNION ALL就别用UNION。除非业务明确要求去重否则默认不加去重操作。等我排查一个慢SQL时发现仅仅是UNION去重导致的临时表排序就让查询从200ms变成了3秒。第三分组取最新记录别总想着ORDER BY和LIMIT。正确姿势是子查询先取MAX再关联回原表。这个坑在面试里出现频率极高在实际业务里也几乎每周都能遇到。第四聚合函数返回NULL这件事代码里要防。IFNULL(SUM(x), 0)这种写法看着啰嗦但能帮你避免线上空指针告警。第五每次写完组合查询养成跑EXPLAIN的习惯。看看有没有Using temporary、Using filesort有没有走全表扫描。一次执行计划的检查可能比调半天代码都管用。SQL不是写出来能跑就算完能跑得对、跑得快才是合格的水平。聚合、分组、联合这三个能力属于那种“看起来简单用好了能顶半边天”的数据库基本功值得多花时间打磨。