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

资讯详情

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

SQL聚合函数、分组与联合查询实战:理解行粒度,避开统计陷阱

SQL聚合函数、分组与联合查询实战:理解行粒度,避开统计陷阱 写报表、做看板、导数据、核对账目这几件事基本构成了日常 SQL 的大部分工作。而这些工作中真正决定产出质量的就是聚合函数、分组和联合查询这三板斧。很多人能把单表查询写得很顺一到多表、分组、统计就动不动出重复行、漏数据核心原因不是不会写语法而是没理解“每一行是怎么产生的”以及“分组后每一行代表什么”。这篇文章我想把这三块内容串起来讲一遍结合我实际写 SQL 时踩过的坑尽量把细节和原理都说透让你看完之后能直接套用到自己的报表需求和接口查询里。这次内容适合谁一是刚学完 MySQL 基础语法、准备写真实业务查询的同学二是已经写了段时间 SQL但经常被分组统计和联表结果搞到怀疑人生的开发或数据分析师。我会从原理讲到实操再把常见报错和排查思路整理成速查表尽量让不同基础的人都能找到自己需要的部分。1. 需求定位与分析思路先别急着写代码。拿到一个查询需求我习惯先做一次“需求拆解”结果集里每一行到底是什么粒度数据要从哪些表来指标怎么算。这个步骤看起来多余但能避免后面大量返工。1.1 哪些业务场景一定要用聚合和分组先举几个实际工作中常遇到的场景。第一个是订单统计。老板要看“今天每个销售员的成交金额”数据源通常是一张订单明细表一个订单一行。你要对销售员做分组然后对金额求和本质就是 GROUP BY 加 SUM。第二个是渠道质量评估。运营给你一份用户访问日志表想对比不同来源渠道的独立访客数用 COUNT(DISTINCT user_id) 就很直接。第三个是库存与价格分析需要查看每个仓库的最大库存、最小库存、平均库存这就要用 MAX、MIN、AVG 配合 GROUP BY 一起用。除了报表分组统计还有一个高频用途是数据核对。导出 Excel 之前我习惯先写一条分组统计 SQL把目标维度的行数统计出来再和明细行数比对。比如SELECT dept_code, COUNT(*) FROM emp GROUP BY dept_code;如果明细里某个部门有 20 行统计出来也是 20再去导明细这个核对步骤就完成了。如果对不上说明数据本身有重复或脏数据直接导出 Excel 后期处理会非常痛苦。1.2 分组和联合查询的组合套路单表分组只是第一步真正复杂的是多表之后还要分组。比如“每个分类下销量前五的商品”“每个部门里工资最高的人”这些需求同时用到了分组、排序和关联。我通常把问题拆成三步先确定结果集的粒度也就是每一行到底代表什么再确定要保留哪些维度列最后确定指标列怎么算出来。粒度这一步很多人不做就上手写 SQL写完一数发现行数不对再去查原因浪费的时间比写 SQL 本身多得多。联合查询解决的则是“数据分散在不同表里怎么拼在一起看”。典型场景包括商品表和分类表要一起展示、订单表和用户表要一起展示、两张结构相同的历史表要合并统计。这时候你需要 JOIN 或 UNION具体用哪个取决于你是横向补列还是纵向加行。2. 聚合函数基础用法与容易忽略的细节聚合函数是分组查询的计算器。MySQL 的聚合函数不止五个但日常打天下的是 COUNT、SUM、AVG、MAX、MIN。这几个函数单独用都很简单真正容易翻车的是它们对 NULL 和去重的处理方式。2.1 五个常用聚合函数的行为差异COUNT 用于计数但 COUNT() 和 COUNT(列名) 的结果可能不一样。COUNT() 统计的是行数包括所有列都为 NULL 的行COUNT(列名) 只统计该列非 NULL 的行。比如用户表的 email 字段允许为空统计总用户数用 COUNT(*)统计有邮箱的用户数用 COUNT(email)两者相差的就是没填邮箱的用户数。SUM 只对数值列有意义求和时自动忽略 NULL。NULL 加进 SUM 不会改变结果因为 MySQL 的聚合处理是直接跳过。但要特别注意如果所有参与求和的行都是 NULLSUM 返回的是 NULL 而不是 0。这个差异在页面展示时非常容易出问题表现为某个统计数字突然变成空白。我一般会习惯性写成 IFNULL(SUM(amount), 0)从根上避免这种情况。AVG 是平均值它的分母不受 NULL 影响。比如五个学生中有一个人缺考没有成绩AVG(score) 算的是四个有成绩学生的平均分而不是把 NULL 当 0 分计入。如果业务上要求“缺考按 0 分算”就得自己先处理数据写成 AVG(IFNULL(score, 0))。MAX 和 MIN 除了用于数值还常用于日期和字符串。取最近下单时间 MAX(create_time)、取最早日期 MIN(birthday)、按字典序取最大或最小字符串这些都是非常常见的逻辑。很多人不知道 MAX 和 MIN 可以直接作用在日期列上遇到这类需求就去写子查询绕了一大圈。2.2 NULL 和 DISTINCT统计数字出错的两大源头我见过最多的统计错误就是没搞清楚 NULL 的语义。整理一个容易混的点COUNT(列名) 不统计 NULLCOUNT(*) 统计所有行。SUM 忽略 NULL全 NULL 时返回 NULL。AVG 的分母不含 NULL。GROUP BY 会把 NULL 单独分成一组。排序时 NULL 默认在最前还是最后取决于排序方向。另一个高频错误是统计时忘了去重。比如统计“有购买行为的用户数”如果直接写 COUNT(user_id)同一个用户买了三单就会算出 3。正确写法是SELECT COUNT(DISTINCT user_id) FROM orders;DISTINCT 可以放在 COUNT 里也可以和多个列组合使用比如 COUNT(DISTINCT user_id, product_id)统计的是这两个字段组合维度下去重后的行数。这里要特别注意组合去重的语义它去重的是组合而不是先分别去重再相乘。如果你想统计“有多少个不同的用户分别买了多少个不同的商品”这个表达式给不出你要的答案需要想清楚业务口径。DISTINCT 还有一个使用场景是聚合前的精确去重。比如查看某段时间内有多少不同客户下了单一条 SQL 就够SELECT COUNT(DISTINCT customer_id) FROM orders WHERE create_time 2024-01-01;2.3 WHERE 和 HAVING 到底怎么分工WHERE 是在分组之前对原始行做过滤HAVING 是在分组聚合之后对分组结果做过滤。说直白一点WHERE 里不能写聚合函数HAVING 里可以写。比如查“销售额超过 10000 的部门”必须先 GROUP BY 部门、SUM 金额然后用 HAVING SUM(amount) 10000。如果你写成 WHERE SUM(amount) 10000MySQL 会直接报语法错误。实际写的时候我建议优先用 WHERE 把不需要的行先过滤掉再做分组聚合。原因很简单分组前过滤能大幅减少参与分组和聚合的数据量查询性能会好很多。HAVING 尽量只用来写聚合后的条件这样语义清晰性能也更可控。还有一个容易踩的点是 HAVING 里直接写 SELECT 中的别名。MySQL 在某些模式下允许 HAVING 使用别名但 WHERE 不支持。这个差异导致不少人把条件从 WHERE 挪到 HAVING 后发现行为变了。我的习惯是能用原始列名和表达式写清楚就不依赖别名。3. 分组查询从入门到排坑分组查询的语法本身不复杂GROUP BY 后面跟分组维度列SELECT 里出现的非聚合列必须要么在 GROUP BY 中要么被聚合函数包住。这个规则在 MySQL 5.7 及以上默认开启的 only_full_group_by 模式下是强制要求但很多人写的 SQL 还是会在升级之后突然报错。3.1 GROUP BY 的正确写法和多字段分组最简单的分组是单字段分组SELECT gender, COUNT(*) FROM user GROUP BY gender;结果每个性别一行后面跟数量。多字段分组则是 GROUP BY 后面放多个列MySQL 会按照这些列的组合来分组。比如SELECT province, city, COUNT(*) FROM user GROUP BY province, city;这里每一行代表一个“省 市”的组合。多字段分组有个坑是顺序。GROUP BY province, city 和 GROUP BY city, province 结果集的行内容是一样的但行的排序不一样。如果你对输出顺序有要求应该显式加 ORDER BY不要依赖 GROUP BY 的隐含顺序。MySQL 8.0 之前 GROUP BY 有时会按照分组列排序8.0 之后不再保证排序请交给 ORDER BY。分组时 SELECT 列表的选择也很讲究。假如按 gender 分组还想看年龄那 age 必须用 MAX(age)、MIN(age) 或 AVG(age) 这类聚合表达不能直接写裸的 age因为组内可能有多个年龄直接取一个没有明确语义。只有在 only_full_group_by 关闭时MySQL 才会上面的裸列但取到的是哪一行完全不确定这种不确定性会让结果不可复现排查问题时非常痛苦。3.2 分组后每组取一条别掉进排序子查询的坑“每个分组取最新一条”是报表开发里的高频需求。比如查每个用户最近一次登录记录、每个商品的最近价格。很多新手写出来的 SQL 是SELECT user_id, login_time, ip FROM login_log GROUP BY user_id;结果发现返回的 ip 根本不是“最新一次登录的 ip”只是组内随机的一行。这个写法在 only_full_group_by 关闭时能用但结果完全不可控。正确做法主要有三种。第一种是子查询配合 JOIN。先查出每组最大的时间或 ID再回到原表关联取出完整行。比如查每个部门工资最高的员工SELECT e.* FROM emp e JOIN ( SELECT dept_id, MAX(salary) AS max_sal FROM emp GROUP BY dept_id ) t ON e.dept_id t.dept_id AND e.salary t.max_sal;这种写法的问题是一个组内可能两人工资并列最高会返回多行。如果业务接受并列没问题不接受的话还需要进一步按员工 ID 去重。第二种是利用窗口函数 ROW_NUMBER()这是 MySQL 8.0 才支持的能力SELECT dept_id, emp_name, salary FROM ( SELECT dept_id, emp_name, salary, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM emp ) t WHERE rn 1;窗口函数写法更清晰性能也通常优于关联子查询。如果公司还在用 MySQL 5.7我通常会用派生表 JOIN 的方案同时注意最终排序要写在外层。第三种是用 GROUP_CONCAT 做“假取首条”适合只要一个字段、不需要整行的场景SELECT dept_id, SUBSTRING_INDEX(GROUP_CONCAT(emp_name ORDER BY salary DESC), ,, 1) FROM emp GROUP BY dept_id;这个方法能省一次 JOIN但 GROUP_CONCAT 有默认长度限制 1024 字节超长会被截断。拼接很长的文本时要小心。3.3 only_full_group_by 模式下的兼容写法MySQL 5.7 及以上默认开启 only_full_group_bySELECT 中出现的非聚合列必须全部出现在 GROUP BY 中。这本是个好规范倒逼你把 SQL 写严谨但很多老项目从 5.6 迁上来时遗留 SQL 会直接报错提示类似Expression #2 of SELECT list is not in GROUP BY clause...如果你维护旧系统短期想兼容可以在 session 级别临时修改SET SESSION sql_mode (SELECT REPLACE(sql_mode, ONLY_FULL_GROUP_BY, ));但长期建议还是把 SQL 改规范。MySQL 提供了 ANY_VALUE() 函数可以快速绕开严格模式的限制SELECT dept_id, ANY_VALUE(emp_name), MAX(salary) FROM emp GROUP BY dept_id;ANY_VALUE 的意思是“这一行选哪个都行我不关心”。实际业务里选哪个往往不是“都行”所以用之前一定要确认语义上真的无所谓否则就是埋雷。还有一个很常见的错误是分组维度没控制好。你以为按 user_id 分组SELECT 里却不小心多带了一个 order_id然后 order_id 被顺手加进 GROUP BY结果集瞬间膨胀。分组查询写完一定要先数行数再抽查几行明细验证分组维度是不是自己心里想的那一个。4. 联合查询多表数据如何拼在一起联合查询分两类。一类是纵向合并结果集用 UNION / UNION ALL另一类是横向关联多个表用 JOIN。它们解决的问题完全不同很多人一上来就 JOIN结果发现行数膨胀就是因为场景选错了。4.1 UNION 与 UNION ALL 的取舍UNION 用于纵向合并两个查询的结果集比如把今年和去年的销售数据合并成一个结果集。规则是两个 SELECT 的列数和顺序必须一致列的数据类型要能兼容。UNION 和 UNION ALL 的唯一区别是 UNION 会去重UNION ALL 不会。去重的代价是排序或哈希数据量大时非常明显。业务上如果确认两个结果集不可能重复或者重复也无所谓就用 UNION ALL更快也更准确。因为 UNION 的去重会悄悄干掉重复行有时候这个去重不是你要的。比如统计明细总数时两条完全相同的订单被合并掉总数就错了。UNION 还有一个限制每个 SELECT 内部的 ORDER BY 通常不能直接生效。你要把 ORDER BY 放到整个 UNION 的最后对整个合并结果排序像这样SELECT name FROM table_a UNION ALL SELECT name FROM table_b ORDER BY name DESC;这里的 ORDER BY 对整个结果生效。如果你想对第一个 SELECT 单独排序后再合并只能写成子查询。UNION ALL 配合聚合可以完成多维度汇总。比如同时统计总数、成功数、失败数可以分别统计后 UNION ALL 成三行也可以写成三个聚合结果再拼。后者在报表工具里往往更友好也更容易逐行检查。4.2 JOIN 家族的使用边界JOIN 是横向拼列。INNER JOIN 只保留两边都匹配的行LEFT JOIN 保留左表全部行右表没有匹配就补 NULLRIGHT JOIN 刚好相反CROSS JOIN 是笛卡尔积基本只有特殊场景才会用。日常建议多写 LEFT JOIN少写 RIGHT JOIN。原因很简单阅读 SQL 时大多数人习惯从左往右看主表RIGHT JOIN 会把主表放在右边语义容易混乱。如果发现自己想写 RIGHT JOIN通常可以通过调换表顺序改成 LEFT JOIN。JOIN 的条件写反是个隐蔽问题。订单表 o 和用户表 u 关联应该写 ON o.uid u.id。假如关联字段写反返回的行数可能是笛卡尔积也可能是错误匹配。写完以后先看行数是否符合预期是一个很快的 sanity check。LEFT JOIN 的一个大坑是右表匹配到多行。比如订单表和订单明细表关联一个订单有三条明细LEFT JOIN 之后订单主表的字段会重复三次。很多人没注意这一点最后统计金额时导致虚高。正确的是先想清楚粒度我要的是订单级还是明细级如果是订单级先把明细表按订单聚合出一行再 JOIN如果粒度就是明细级那重复就是天然该有的。4.3 联表去重与性能要点联表去重最常见的问题是多个表都含冗余数据JOIN 后行数变多然后用 SELECT DISTINCT 去兜底。这里我建议从源头避免而不是 JOIN 完再 DISTINCT。因为 DISTINCT 会做全行去重代价高而且掩盖了 JOIN 条件设计的缺陷。举个例子要查“购买了 A 商品或 B 商品的用户”如果两个商品可能出现在同一张订单的不同行直接 JOIN 可能让同一用户出现两次。这种情况用 UNION 合并两个查询通常比 JOIN 加 DISTINCT 更清晰性能也更好。联表性能上有几个要点值得记住。第一关联字段尽量有索引JOIN 的 ON 条件字段如果没索引MySQL 需要对右表做全表扫描数据量一大就慢。第二尽量减少 JOIN 右表的行数能用子查询先过滤再 JOIN往往比先 JOIN 再 WHERE 快。第三多表 JOIN 时尽量让驱动表的过滤条件前置也就是先用 WHERE 缩小主表范围再关联。我做过一次典型的优化两张百万级表 LEFT JOIN最初跑 30 秒给 ON 字段补上索引把 WHERE 条件里的大范围过滤放进子查询提前收缩之后跑到 0.2 秒。索引和过滤下推的重要性怎么强调都不过分。5. 高频问题排查与经验沉淀这部分我把实际工作中踩过的坑整理成一张速查表方便直接对照排查。5.1 常见错误速查表现象根本原因解决思路查出来的行数比想象多很多JOIN 导致一对多行数放大先聚合明细再 JOIN或改用子查询COUNT 数量比实际少COUNT(列名) 忽略了 NULL按业务决定用 COUNT(*) 或 COUNT(DISTINCT expr)GROUP BY 查询报 only_full_group_by 错非聚合列未包含在 GROUP BY 中用 ANY_VALUE 或调整 SQL 严格性每个分组取到的“最新”不准确误用了 GROUP BY 下的裸列用窗口函数或子查询取最新 ID 再关联UNION 结果集行数无故减少UNION 默认去重确认无需去重时改用 UNION ALLLEFT JOIN 后右表字段出现 NULL右表确实无匹配数据业务确认后用 IFNULL 补默认值SUM 结果展示为空白SUM 返回 NULL页面未处理使用 IFNULL(SUM(x), 0)执行速度突然变慢关联字段无索引或数据量膨胀检查 EXPLAIN补充索引缩小数据范围这个表不是死的。实际排查时第一步永远是看执行计划也就是 EXPLAIN SELECT ...确认 type 是不是 ALL、rows 估算大不大、有没有 Using filesort再决定从哪下手优化。5.2 三类容易反复踩的细节坑第一类是分组聚合后丢失原始明细信息。很多人写SELECT user_id, MAX(login_time) FROM login_log GROUP BY user_id;还想顺便拿到这次登录对应的 IP结果发现直接 SELECT ip 不被允许在非严格模式下拿到的是随机 IP。正确做法是用窗口函数、自关联或两条 SQL 分段解决指望 SQL 一步到位反而不稳妥。第二类是时间字段参与分组时的“隐形坑”。按天分组要写 DATE(create_time)按小时分组要写 HOUR(create_time)。如果忘了加MySQL 会把完整时间戳精度用于分组导致“看起来同一天”的数据被分到很多组里。我写过按日统计登录数的看板结果行数异常多最后定位到的原因就是直接按 create_time 分组而不是按 DATE(create_time) 分组。第三类是 GROUP_CONCAT 长度被截断。默认长度 1024 字节要拼接大量数据时可以临时调长SET SESSION group_concat_max_len 10240;要注意这只是 session 级别新连接会恢复默认值。写“某个分类下所有标签”这类查询时先预估长度再决定是否调整可以避免输出莫名其妙被截断。5.3 设计查询前的三个自问我写复杂 SQL 的固定流程是先回答三个问题回答完再动手。第一个问题结果集的每一行代表什么是用户、订单还是“用户 商品”的组合第二个问题数据要从哪几张表取哪张是主表哪张是补充表第三个问题要算的指标是在当前粒度上直接算还是必须先缩到更细的粒度再聚合这三个答案清楚了SQL 基本就成型了。很多人没有这一步就直接 JOIN等结果出来发现多了一倍行才回去改。只要养成这个习惯大部分 SQL 调试时间都能省下来。同样一段逻辑先想清楚再写通常只要几分钟边写边试可能要来回折腾半小时。我个人在实际写 MySQL 查询时最大的感触是聚合、分组、联合查询不是三个孤立的知识点而是同一套“先定粒度再定计算方式”的思维模型。把这个思维模型记住再复杂的 SQL 也能拆成一步步验证的小查询。回到最开始的报表场景无论你要的是“每个分类的销售 Top 5”还是“今天新用户中每个省份的注册量”写法都逃不开分组加聚合遇到跨表就加 JOIN遇到多份结果要汇总就 UNION ALL。希望这篇笔记能把你在 SQL 里绕的远路缩短一点踩坑之后也别忘了回头看看是不是粒度定义出了问题。
返回列表