聚合函数配上GROUP BY做分组计算,这大概是SQL里出现频率最高的一组搭档。我见过很多刚入行的同事,单写一句SELECT COUNT(*) FROM table没问题,单写SELECT * FROM table GROUP BY column也"能跑",但只要把两件事揉在一起,就开始踩各种奇奇怪怪的坑:比如查询结果莫名其妙少了很多行、报错提示"不是GROUP BY表达式"、或者同一个字段换了个数据库查询结果就不一样。这篇文章就把聚合函数和分组计算的完整逻辑梳理一遍,从执行顺序到实战细节,从NULL值陷阱到性能优化,把我这些年积累的经验和踩坑记录一次讲透。
1. 分组计算到底在算什么:先理解它的业务价值
1.1 没有分组,聚合函数只会算全表总账
在学习聚合函数之前,先要搞清楚一个基础概念:聚合函数本身的作用范围是"整个结果集"。
SELECT COUNT(*) FROM orders,这句告诉你订单表里一共有多少条记录;SELECT SUM(amount) FROM orders,算的是所有订单金额的合计。当你没有加任何限制条件时,这个"结果集"就是全表数据。
这个逻辑本身很简单,但到了业务场景里就暴露问题了。假设你是电商平台的运营,老板现在想知道"每个省份分别卖了多少单、合计多少钱"。你如果只写SELECT SUM(amount) FROM orders,得到的只是全国总数,根本拆不到省份维度。这时候就需要GROUP BY出场:它的作用是把一张大表按指定的列切分成多个小组,然后聚合函数在每个小组内部单独计算。这就是"分组计算"的本质——GROUP BY province会把订单按省份拆成若干堆,SUM(amount)在每个省自己的数据堆里各自求和,最终每个省得到一行结果。
1.2 分组计算解决的是一类典型的"按类汇总"问题
"分组"这个动作,说穿了就是"找出数据之间的共同特征,然后按类别归堆"。这一类需求在数据分析中的出现频率极高,几乎每天都躲不开:
- 按时间维度汇总:统计每个月的销售额、每天的用户注册量、每小时的接口调用次数。
- 按分类维度汇总:统计每个品类的库存总量、每个部门的平均薪资、每台机器的CPU使用率峰值。
- 按多条件组合汇总:统计每个省份每个月的订单量、每个仓库每种商品的库存数。
有一次我接手一个报表需求,业务方要"每个销售大区下每个产品线的季度回款率",这就是典型的多列分组场景,一个GROUP BY后面跟了两个分组列(大区、产品线),再配合时间维度的表达式分组,一条SQL就搞定了。如果你是掌握不好GROUP BY的人,碰到这种需求大概率会写一堆子查询再手动用Excel拼表,麻烦不说,还容易出错。
GROUP BY的价值,在于把"分类+汇总"这件事下沉到数据库引擎去完成,少传数据、少写代码、结果可靠。这也是它成为SQL核心语法之一的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 到底什么时候执行GROUP BY:别再用"从上到下"的思维看SQL
很多初学者想不明白一个问题:既然GROUP BY是对数据分组,那我能不能直接从FROM table WHERE condition GROUP BY column一路从左读到右?其实SQL的执行顺序和书写顺序完全不同,而理解这个顺序,是搞懂分组计算的关键前提。
2.1 SQL逻辑执行顺序
一条标准的分组查询语句,书写的顺序是:
sql复制SELECT 类别列, 聚合函数(数值列)
FROM 表名
WHERE 过滤条件
GROUP BY 类别列
HAVING 聚合后的过滤条件
ORDER BY 排序列;
但数据库引擎实际执行的逻辑顺序是:
- FROM:先确定数据来源,取出一张表或临时表。
- WHERE:对FROM得到的每一行记录做过滤,把不满足条件的行直接丢弃。
- GROUP BY:把WHERE过滤后剩余的行按分组列的值分成若干组。
- 聚合:在每个组内分别执行聚合函数计算。
- HAVING:对聚合完成后的分组结果做过滤,不满足条件的分组被丢弃。
- SELECT:投影出最终要返回的列。
- ORDER BY:对返回结果做排序。
看到没有,GROUP BY是在WHERE之后执行的。这意味着,凡是写在WHERE里的条件,都是在分组之前就过滤掉了;凡是写在HAVING里的条件,都是在分组聚合之后才过滤的。这个顺序上的差异,直接决定了SQL语句的语义和性能。
2.2 用执行顺序解释一个经典报错
执行顺序还能解释一个几乎每个SQL初学者都会遇到的报错:SELECT column_name FROM table GROUP BY other_column,系统直接提示列名无效或者"不是GROUP BY表达式"。
原因就在于执行顺序:SELECT是第六步,GROUP BY是第三步。你SELECT里出现了某个普通列,可是这个列既没有出现在GROUP BY中,也没有被包裹在聚合函数里,那数据库就不知道从组里哪一行取这个值。每个组里这个列可能有多个不同的值,你让数据库输出哪一个?所以大部分数据库(MySQL的ONLY_FULL_GROUP_BY模式、PostgreSQL、Oracle、SQL Server)都会直接报错。
这里多说一句:MySQL默认关闭了ONLY_FULL_GROUP_BY的话,上面这种"不规范写法"也能跑通,它只会随机取组内某一行的值。这个行为非常坑,因为它往往不报错,但结果不可控。我见过有人因此得出错误的统计结果,排查半天才发现是这个原因。建议在你的MySQL配置里打开sql_mode='ONLY_FULL_GROUP_BY',从根源上避免这种隐患。
2.3 一条SQL走完整个分组流程
为了让你对执行顺序有个直观的印象,我拿一个真实的订单场景走一遍流程:
表结构:订单表orders,包含order_id(订单号)、city(城市)、amount(金额)、order_date(下单日期)。
sql复制SELECT city, COUNT(*) AS order_cnt, SUM(amount) AS total_amount
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY city
HAVING COUNT(*) > 10
ORDER BY total_amount DESC;
执行流程是这样的:
- FROM阶段,拿到orders表全部数据。
- WHERE阶段,过滤掉2024年1月1日之前的订单。
- GROUP BY阶段,剩下的订单按city分组,北京一组、上海一组、广州一组……
- 聚合阶段,每组分别统计订单数和金额合计。
- HAVING阶段,丢掉订单数不超过10的城市。
- SELECT阶段,只取城市、订单数、总金额三列。
- ORDER BY阶段,按总金额倒序排列。
写SQL的时候脑子里要始终带着这条执行链路,很多语法限制和历史遗留问题都能顺着它想通。
3. 常用聚合函数的细节差异:这些坑我都替你踩过
聚合函数并不只有COUNT、SUM、AVG、MAX、MIN这几个基础款。每个函数在不同的数据形态、不同的业务含义下,行为差异很大。我一个个展开讲,每个都附上实战中容易踩的坑。
3.1 COUNT族:*、1、列名、DISTINCT,四种写法四种结果
先看最常见的COUNT。下面四种写法,结果可能完全不同:
COUNT(*):统计组内的总行数,包括NULL值行。COUNT(1):和COUNT(*)效果等价,统计总行数。关于它和COUNT(*)谁更快的问题,不同数据库引擎有差异,在现代数据库上两者并没有本质性能区别,不用纠结。COUNT(column):统计该列中非NULL值的行数。如果这一列有100行,其中30行是NULL,那结果就是70。COUNT(DISTINCT column):统计该列去重后的非NULL值数量。如果这一列有100行,其中有20行NULL,剩下80行里有30个不同值,那结果就是30。
最容易出问题的是第三种。我遇到过一位同事统计"参与消费的用户数",他写的是COUNT(user_id),看似合理,但user_id在订单表里是可空的,有些线下补录订单并没有挂用户ID,结果凭空少算了几条。正确写法应该是COUNT(DISTINCT user_id),既去重又排除NULL。
顺带提醒一句:COUNT(DISTINCT a, b)这种多列去重写法在部分数据库里支持,在部分数据库里不支持,跨库迁移时要特别注意。还有一种更隐蔽的情况:COUNT(DISTINCT a) + COUNT(DISTINCT b)和COUNT(DISTINCT a, b)表达的是完全不同的语义,前者是两列各自去重后数量之和,后者是两列组合(a, b)一起去重后的数量,不要混淆。
3.2 SUM与AVG:NULL值不影响结果,但会影响平均
SUM(column)的规则是:组内该列所有非NULL值求和。如果组内有10行数据,其中3行amount是NULL,SUM(amount)只算剩下7行的和;如果7行全是NULL,结果为NULL,不是0。
AVG(column)的逻辑是:非NULL值的平均值,等于SUM(column) / COUNT(column)。注意这里的分母是非NULL值个数,不是组内总行数。这就是一个高频坑点:你本以为AVG(amount)是"总金额除以订单数",但如果有些订单的amount是NULL(比如退款订单金额被置空),那平均金额其实偏高,因为你把NULL订单从分母里剔除了。
还有一点容易被忽略:SUM在整数类型的列上求和,如果数据量很大,可能超出整数类型的上限。MySQL中int类型最大约21亿,订单金额表几千万行累加很容易超界。我在做订单分析时遇到过求和结果变成负数的情况,最后排查发现是整型溢出,改成CAST(amount AS DECIMAL(20,2))或者把列类型改成BIGINT/DECIMAL才解决。在PostgreSQL里SUM(int)返回的是bigint,这个问题相对少一些;但涉及小数金额,还是显式转型更稳妥。
3.3 MAX与MIN:不只是数值,还能处理日期和字符串
MAX(column)和MIN(column)从语义上讲是"取组内最大值/最小值",它们不仅作用于数值列,也适用于日期类型(比如取组内最后一次下单时间)、字符串类型(按字典序取最大/最小)。
在分组计算中,它们的应用场景很广:
MAX(order_date):每个客户最近一次下单日期。MIN(created_at):每个渠道最早一条记录的产生时间。- 在字符串上执行
MIN(name):取字典序最小的名字。
MAX和MIN在遇到NULL时,会忽略NULL值。如果整组全是NULL,结果也是NULL。这里我提一个有用的技巧:MAX配合CASE WHEN,可以取"组内满足某条件的最近日期"。比如你要统计每个客户"最近一次成功支付的时间",可以写成MAX(CASE WHEN status = 'paid' THEN pay_time END),这个技巧在GROUP BY里非常常用,后面还会展开讲。
3.4 进阶聚合函数:GROUP_CONCAT、STRING_AGG、ARRAY_AGG
很多人以为聚合函数只有最基础的五个,其实现代数据库还提供了一批非常实用的"进阶聚合函数",它们在分组计算中能解决很多文本拼接、数组聚合的需求。
在MySQL中,GROUP_CONCAT可以把组内多行的值拼成一个字符串。比如你要查每个订单关联的多个商品名,就可以用:
sql复制SELECT order_id, GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR ';')
FROM order_items
GROUP BY order_id;
在PostgreSQL里,对应的是STRING_AGG(column, delimiter);在SQL Server里是STRING_AGG(column, delimiter)(SQL Server 2017+)或者老的FOR XML PATH方案;Oracle则用LISTAGG。功能相似,函数名不同,跨库迁移需要逐一对应。
这种函数在报表拼接明细、生成逗号分隔的ID列表时非常省事,但也别滥用。如果组内的值特别多,拼接结果会非常长,影响查询性能和结果可读性。GROUP_CONCAT在MySQL里有默认长度限制(默认1024字节),超长部分会被截断,这是一个非常隐蔽的坑。需要加长时,可以用SET SESSION group_concat_max_len = 1000000;临时调整。
4. 分组粒度设计:多列分组、表达式分组和条件聚合
GROUP BY后面跟的表达式,直接决定了分组的粒度。很多统计需求不是简单的"按省份分组",而是"按省份+月份组合分组",这时就需要多列分组。比多列分组更灵活的是按表达式分组,比如按日期字段的年月分组。
4.1 多列分组:分组列不是越多越好
语法上,多列分组只需要在GROUP BY后写多个列,用逗号分隔:
sql复制SELECT province, city, COUNT(*) AS store_cnt
FROM stores
GROUP BY province, city;
执行时,数据库会把(province, city)相同的所有行归为一组。注意分组粒度是"组合值":哪怕province相同、city不同,也会被分成不同的组。反过来说,如果你只需要"省"级别,却加了一个"城市"分组列,那么同样一个省会被拆成很多组,汇总口径就变细了。
我在帮业务方做报表时,经常遇到"多加一列导致统计数字被拆散"的问题。所以接到需求时,第一件事是确认统计粒度的核心维度;多列分组增加一个维度,结果的粒度就细一级,行数也会多出很多,这个变化要在设计阶段心里有数。
4.2 表达式分组:按年、按月、按小时汇总
分组列不一定要是表里的原始列,它可以是任何表达式。最常见的表达式就是时间截取。
MySQL按月统计销售额:
sql复制SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(amount)
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m');
PostgreSQL按月统计:
sql复制SELECT TO_CHAR(order_date, 'YYYY-MM') AS month, SUM(amount)
FROM orders
GROUP BY TO_CHAR(order_date, 'YYYY-MM');
SQL Server则常用FORMAT(order_date, 'yyyy-MM') 或 CONVERT(CHAR(7), order_date, 120)。
这里要提醒一个细节:GROUP BY后面需要能直接引用SELECT里的别名。在多数数据库里,GROUP BY不能用SELECT列别名,但有一些数据库对某些情况做了扩展。不同数据库行为不一致,最稳妥的做法是GROUP BY和SELECT使用完全相同的表达式,不要写别名。你可能会觉得重复写一遍表达式很啰嗦,但换来的是跨数据库都能跑,值得。
另一种常见的表达式分组是按数值区间分组。比如把商品按价格分档,统计每个价格带的商品数量:
sql复制SELECT
CASE
WHEN price < 50 THEN '0-50'
WHEN price < 100 THEN '50-100'
WHEN price < 200 THEN '100-200'
ELSE '200+'
END AS price_band,
COUNT(*) AS product_cnt
FROM products
GROUP BY
CASE
WHEN price < 50 THEN '0-50'
WHEN price < 100 THEN '50-100'
WHEN price < 200 THEN '100-200'
ELSE '200+'
END;
这种写法在数据报表里经常用到。唯一麻烦的是CASE表达式写两遍,SQL会显得很长,但执行时并没有额外开销。MySQL和PostgreSQL都支持GROUP BY后面直接跟SELECT中的序号(比如GROUP BY 1),但滥用序号会让语句可读性变差,我不建议在复杂查询中用。
4.3 条件聚合:用一个分组实现多指标统计
条件聚合是我在工作中使用频率最高的一项技巧,也是"分组计算"最灵活的部分。它的核心是在聚合函数里套CASE WHEN,让同一组数据可以按不同条件分别统计。
假设订单表里每一行是一个订单,字段有order_id、city、amount、pay_status(paid/pending/refunded)。我想统计每个城市的总订单数、已支付订单数、待支付订单数、退款订单数,一般人的第一反应是写四个子查询再JOIN起来。但其实一条SQL就够了:
sql复制SELECT
city,
COUNT(*) AS order_cnt,
SUM(CASE WHEN pay_status = 'paid' THEN 1 ELSE 0 END) AS paid_cnt,
SUM(CASE WHEN pay_status = 'pending' THEN 1 ELSE 0 END) AS pending_cnt,
SUM(CASE WHEN pay_status = 'refunded' THEN 1 ELSE 0 END) AS refunded_cnt
FROM orders
GROUP BY city;
这个写法把"按城市分组"和"组内按条件计数"揉在一起,不需要子查询,不需要多次扫描表,又快又直观。同样,如果想统计"每个城市已支付订单的总金额",和"全部订单(包含待支付)的总金额",可以这样:
sql复制SELECT
city,
SUM(amount) AS all_amount,
SUM(CASE WHEN pay_status = 'paid' THEN amount ELSE 0 END) AS paid_amount
FROM orders
GROUP BY city;
条件聚合的思路,本质上就是"聚合函数的参数可以是表达式,而表达式可以用CASE WHEN按条件生成不同的值"。掌握了这个思路,很多看起来需要多层嵌套子查询的报表,都能用一条SQL搞定。
这里有一个细节想提醒:在SUM(CASE WHEN ... THEN amount ELSE 0 END)里,ELSE 0很重要,因为SUM会处理NULL值,如果你写成SUM(CASE WHEN ... THEN amount END),那么不满足条件的时候结果是NULL,SUM会忽略NULL,两种写法在数字求和场景里结果一样;但如果你想把"不满足条件的行"也作为分母参与COUNT,写法就不同了,需要根据你的统计口径仔细判断。
5. 分组后的过滤:HAVING的定位以及和WHERE的边界
分组计算做到一半,常常需要过滤分组结果。最常见的需求是"找出订单数超过100的城市""找出平均分低于60的班级"。这时候该用HAVING还是WHERE?很多初学者会在这里犯迷糊。
5.1 HAVING是"对组"做过滤,WHERE是对"行"做过滤
回到第2章的执行顺序:
- WHERE在GROUP BY之前执行,所以它能过滤的是"原始行"。WHERE里不能写聚合函数,因为执行WHERE时聚合还没算出来。
- HAVING在聚合之后执行,过滤的是"分组结果"。HAVING里可以写聚合函数。
举个例子:我要统计每个城市的订单数,但只想看2024年之后的订单,并且只看订单总数超过100的城市:
sql复制SELECT city, COUNT(*) AS order_cnt
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY city
HAVING COUNT(*) > 100;
WHERE order_date >= '2024-01-01'先于分组执行,保证了每个城市只统计2024年之后的订单;HAVING COUNT(*) > 100在分组后把订单数不足100的城市丢掉。两句话各干各的,缺少任何一个结果都不对。
如果误把HAVING用在行级过滤上,比如:
sql复制SELECT city, COUNT(*) AS order_cnt
FROM orders
GROUP BY city
HAVING order_date >= '2024-01-01';
绝大多数数据库会直接报错:HAVING中的列必须是聚合函数或者GROUP BY中的列。就算某些数据库不报错,这也是一个逻辑错误——因为HAVING执行时,原始的行级数据已经不存在了,你没法用它过滤原始行。
5.2 不该用HAVING的地方别硬用
HAVING能过滤分组结果,但不代表该用它去过滤所有东西。如果一个过滤条件既可以用WHERE也可以用HAVING,性能上通常WHERE更快,因为WHERE在分组前就把行数缩小了,参与分组的行越少,分组和聚合的代价越小。
比如"统计每个城市2024年的订单数",你可以在WHERE里过滤,也可以在HAVING里过滤(HAVING MIN(order_date) >= '2024-01-01'这种写法很别扭),但前者明显更高效。实际操作中,把尽可能多的行级过滤条件放在WHERE里,是一个基本优化原则。
5.3 HAVING和聚合嵌套的查询顺序
HAVING支持复杂的聚合条件,比如:
sql复制SELECT city
FROM orders
GROUP BY city
HAVING COUNT(*) > 100 AND AVG(amount) > 500;
这个SQL的逻辑是:先按城市分组,然后保留"订单数超过100且平均金额超过500"的城市。HAVING里可以有多个条件,条件之间用AND/OR连接。
有人会问:HAVING里能不能引用SELECT里的别名?比如:
sql复制SELECT city, COUNT(*) AS order_cnt
FROM orders
GROUP BY city
HAVING order_cnt > 100;
这个写法在大多数数据库里(MySQL、PostgreSQL)是允许的,但在SQL Server里有时不认别名,报错"列名order_cnt无效"。跨平台兼容的写法还是用HAVING COUNT(*) > 100,别偷懒。
5.4 分组后排序和分页
分组计算经常配合ORDER BY和LIMIT。比如"2024年销量Top 10的品类":
sql复制SELECT category, SUM(quantity) AS total_qty
FROM sales
WHERE sale_date >= '2024-01-01'
GROUP BY category
ORDER BY total_qty DESC
LIMIT 10;
这里LIMIT是在分组、聚合、排序之后才做的,和"分组前过滤行"的WHERE完全不是一回事。有一个常见的坑是:如果ORDER BY的列既不是分组列也没有套聚合函数,大部分数据库会报错,原因和SELECT列限制同理。
6. 分组查询的性能问题:不是所有GROUP BY都慢在没索引
这是我最想展开讲的一部分。很多开发者在数据量上来之后,发现带GROUP BY的报表查询越来越慢,第一反应就是"加索引"。但GROUP BY慢的原因往往不止一个,索引只是其中一环。
6.1 分组查询慢的三类常见原因
第一类是扫描行数过多。没有WHERE条件或者WHERE过滤性差,GROUP BY就得处理全表几千万行,再分组聚合,这个基础成本避不开。应对策略是优先优化WHERE,让参与分组的数据量尽量小。比如统计历史报表时,能不能按时间维度先只取近一个月的数据?如果业务上不需要全量,就不要全量扫。
第二类是分组操作本身导致的文件排序。在很多数据库里,GROUP BY的实现依赖于排序(Oracle里还可以用Hash Group By,MySQL 8.0之前GROUP BY基本靠排序文件),排序的数据量越大,性能越差。如果分组列的基数(distinct值数量)很高,比如按UUID分组,排序的成本会非常高。
第三类是聚合函数里嵌套了复杂计算或函数调用,导致每一行都要做函数运算。比如GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d'),如果数据量很大,这个格式化函数要跑几百万次,代价不低。
6.2 从执行计划看分组慢在哪
以MySQL为例,一条慢分组SQL可以用EXPLAIN查看执行计划。重点关注type列(全表扫描是ALL还是索引扫描是range/ref)、Extra列(出现Using temporary; Using filesort基本坐实了临时表和文件排序)。
比如执行:
sql复制EXPLAIN SELECT city, COUNT(*) FROM orders GROUP BY city;
如果看到type=ALL和Using temporary; Using filesort,那就说明这条SQL需要全表扫描,并且使用临时表+文件排序来完成分组。
优化方向:
- 在分组列上建索引。因为多数数据库可以借助索引的有序性,直接顺序扫描并按索引顺序分组,从而省掉文件排序。注意索引只对"分组列"有效,如果SQL里同时有WHERE过滤,最好把WHERE列和GROUP BY列一起设计成联合索引。
- 减少参与聚合的数据量。能加过滤条件就加过滤条件。
- 避免对分组列做函数运算。
WHERE DATE(created_at) = '2024-01-01'这种写法会导致索引失效,应改写为范围条件created_at >= '2024-01-01' AND created_at < '2024-01-02'。GROUP BY表达式也一样,如果经常按月份分组,可以增加一个冗余的month列并建索引,用空间换时间。
6.3 一个日常报表的真实优化案例
我之前处理过一个线上问题:某张销售明细表接近2亿行,业务方要按“销售员+产品线”统计月度业绩,SQL偶尔会慢到几十秒。EXPLAIN一看,Using temporary; Using filesort,分组列上没有可用索引。
优化过程分了三步:
- 第一步,确认业务能不能接受按分区裁剪——表本身按月份做了RANGE分区,SQL里加上了月份条件,让查询只扫最近3个月的分区,扫描量直接少了90%。
- 第二步,在(sales_id, product_line, sale_date)上建联合索引,让WHERE和GROUP BY都能用同一个索引。
- 第三步,把
DATE_FORMAT改成对已有的month_key列直接分组,避免每行都做日期格式化。
三步做完,单次查询从几十秒降到一秒以内。由此我想强调:走查执行计划这个习惯,真的比背各种"优化口诀"管用得多。你每写一条分组SQL,都应该养成跑一下EXPLAIN的习惯,特别是那种要上生产的报表。
6.4 大数据量下还有哪些可用的思路
如果单表数据量实在太大,即使优化索引和过滤条件,聚合计算仍然很重,这时候可以考虑:
- 预聚合 / 物化视图:把常用的分组汇总结果每天定时算好存成汇总表,查询直接读汇总表。很多报表系统都是这么干的,查询速度可以到毫秒级。
- 如果业务能接受近似值,也可以利用一些数据库原生的近似聚合函数,比如HyperLogLog估算基数,在超大规模数据下换取数量级的速度提升。
- 把聚合任务搬到OLAP引擎里,比如ClickHouse、Doris这些列式存储的数据库,对于GROUP BY这类聚合计算有天然优势。
但这些都是后话。大多数中小规模项目,第一条索引优化加WHERE裁剪已经能解决绝大多数问题。
7. NULL值、去重和空表:分组计算里最容易出错的细节
分组计算的逻辑本身不难,真正让结果出错的大多是边界情况。我把这些细节集中讲一遍,建议你每一条都对照自己的表结构想一想。
7.1 分组列是NULL时,NULL会自成一组
假如订单表里有些订单没有填写城市,city为NULL。执行GROUP BY city时,所有city为NULL的行会被归为一组,输出一行city为NULL的结果。
我之前遇到过一种情况:业务方在统计省份分布时,突然发现报表里多了一行空白的省份行,排查半天才发现是NULL值。处理方式是在GROUP BY之前用COALESCE把NULL替换成业务上的"未知",比如GROUP BY COALESCE(city, '未知城市'),这样报表上显示的就是"未知城市",而不是空白。
7.2 去重计数的正确姿势
在分组计算中统计"有多少个不同的XX",一直是高频需求。前面说过COUNT(DISTINCT column)是标准做法,但它在大数据量下的性能开销比较大,因为数据库必须维护一个去重集合。如果不需要完全精确的去重数,有些场景可以换成近似计数函数,但日常写业务SQL,还是COUNT(DISTINCT ...)最可靠。
还要注意COUNT(DISTINCT)对NULL的处理:NULL不会计入统计。如果你希望NULL也算作一个单独的类别,需要先COALESCE(column, '未知')再DISTINCT。这个细节很容易被忽略,我也踩过:统计"有多少种支付渠道"时,支付渠道填NULL的订单没被算进去,结果比业务方的台账少了一种。
7.3 分组聚合后返回空结果的情况
当WHERE条件过滤后没有数据时,GROUP BY会返回空结果集,而不是返回一行NULL。比如你统计2023年某张新上线表的月销售额,如果这个表2023年还没有数据,SELECT ... GROUP BY month会得到0行。
但注意,如果你不写GROUP BY,直接SELECT SUM(amount) FROM table,即使表是空的,MySQL、PostgreSQL等数据库也会返回一行结果,这一行中SUM的结果是NULL,而不是0。这两个行为不一样,写报表逻辑时如果不做空值兜底,前端展示可能出现空白或报错。
建议在代码层面对聚合结果做处理:SELECT COALESCE(SUM(amount), 0) FROM table,让空表返回0,避免上层逻辑踩坑。
7.4 警惕NaN和极大值对聚合函数的影响
如果是数值型字段,还有一类边界值要小心:NaN(非数字)在部分数据库中被视为NULL处理,在另外一些数据库里会被当作正常值参与SUM计算,导致结果变成NaN。这类问题不常见,但一旦出现,非常难排查。我通常会在ETL层就做数据清洗,把非法的数值挡在业务表外面,从根源上规避。
8. 从"会写"到"写得对":分组计算的检查清单
收尾之前,我把自己日常写GROUP BY聚合查询会过的"检查清单"列出来,相当于一个自查流程。
- 确认分组粒度:这条SQL需要按什么维度出结果,组合维度是否精确?多写一个分组列,粒度就细一级,结果行数就会翻倍。
- 确认聚合口径:每个指标的统计口径是不是业务方要的?比如"用户数"是用
COUNT(DISTINCT user_id)还是COUNT(*),"平均金额"的分母是否要包含NULL。 - 确认过滤条件的位置:行级过滤条件都放进WHERE了吗?有没有把本该在WHERE里的条件误写成HAVING?
- 检查SELECT列规范:SELECT的普通列是否都在GROUP BY里,或者被聚合函数包裹?避免依赖MySQL的非ONLY_FULL_GROUP_BY模式随机取值。
- 跑EXPLAIN:看有没有Using temporary / Using filesort,看扫描行数大不大。
- 边界数据验证:空表、全NULL分组列、COUNT(DISTINCT)的结果是否符合预期。
这六条做完,基本上不会出现"报错倒是没报错,结果却不对"的情况。
9. 最后一个心得:分组计算的核心是"先想清楚统计口径"
写SQL写了这么多年,我有一个很深的体会:GROUP BY和聚合函数的语法并不难,真正难的是搞清楚业务方的统计口径。同样的一个"成交金额",有的统计口径要包含退款金额,有的不要;同样的"用户数",有的要按用户ID去重,有的只是订单数。如果口径没对齐,SQL写得再漂亮,结果也是错的。
所以接到报表需求时,我会先花时间把业务方的问题翻译成"分组维度+聚合规则+过滤条件"三个要素,确认无误后再动手写:
- 分组维度:需要按哪几个字段、哪种时间粒度出结果?
- 聚合规则:组内要计算哪些指标,谁做分子、谁做分母,NULL怎么处理?
- 过滤条件:哪些条件在分组前过滤(WHERE),哪些条件在分组后过滤(HAVING)?
翻译清楚之后,SQL基本就是顺水推舟的事了。这个习惯帮我避免过无数次返工,每次被临时拉过去救火处理错误的报表,最后发现90%的问题根本不在语法,而在口径。
聚合函数加GROUP BY,是数据分析的基本功,也是从"会写SQL"走向"能写出可靠SQL"的必修课。希望这篇文章能帮你把基础打牢,少踩几个坑,少熬几个半夜排查数据的夜。
