在MySQL里并没有一个名字叫“统计分组数”的内置函数,所谓分组数统计,通常是围绕GROUP BY、COUNT、DISTINCT这几个语法组合出来的。比如订单表orders里有一个user_id字段,想统计每个用户下了多少单,这是一回事;想统计一共有多少个用户下过单,这又是另一回事。很多统计结果对不上,往往是因为把“每组有多少行”和“一共有多少组”两个口径搞混了。

一、GROUP BY 配合 COUNT 统计每组记录数
最基础的分组统计,是把数据按某个字段归类,然后对每组做聚合计算。假设我们有这样一张订单表:
CREATE TABLE orders (
order_id INT PRIMARY KEY,
user_id INT,
amount DECIMAL(10,2),
order_date DATE
);
INSERT INTO orders VALUES
(1, 101, 89.00, '2024-01-05'),
(2, 102, 120.00, '2024-01-06'),
(3, 101, 56.50, '2024-01-07'),
(4, 103, 200.00, '2024-01-08'),
(5, 102, 75.00, '2024-01-09');
如果想看每个用户的下单次数,可以使用COUNT(*)配合GROUP BY:
SELECT user_id, COUNT(*) AS order_count FROM orders GROUP BY user_id;
执行时,MySQL会先根据user_id把行分到不同组里,然后COUNT(*)对每个组内的行数进行累计。这里COUNT(*)表示统计行数,不关心具体是哪个字段,也不忽略NULL。因此只要一行存在,就会被计数。
需要特别注意的是COUNT(*)和COUNT(列名)的行为差异。如果把某个可能为NULL的列传给COUNT,例如COUNT(amount),那么该列值为NULL的行不会被计数。对于这张订单表,amount没有NULL,所以结果一样,但如果业务上允许某些订单金额为空,统计数据就会明显偏小。一般情况下,统计每组行数优先使用COUNT(*),语义最清晰,性能也稳定。
除单列分组外,多列分组也很常见。例如用户表里既有省份也有城市,想统计每个城市有多少用户,需要把province和city同时放进GROUP BY:
SELECT province, city, COUNT(*) AS user_count FROM users GROUP BY province, city;
这意味着只有province和city完全相同,才会被归到同一组。如果只按city分组,不同省份的同名城市会被错误合并。这个细节在地区统计中尤其容易踩坑。
二、统计分组后的总组数
如果需求不是看每组有多少条记录,而是想知道“一共分成了多少组”,就不能直接在外面包一层COUNT。例如有人会写成下面这样:
SELECT COUNT(*) FROM orders GROUP BY user_id;
这条语句返回的并不是组数,而是每组行数,如果有三个用户,就会返回三行,每行是该用户的订单数。要拿到真正的组数,正确思路是先把分组结果去重,再计数。MySQL提供了两种常见写法。第一种是使用COUNT(DISTINCT 列名):
SELECT COUNT(DISTINCT user_id) AS total_groups FROM orders;
这条语句会先找出user_id的所有不重复值,然后统计个数,逻辑上等价于先GROUP BY再统计组数。第二种是使用子查询:
SELECT COUNT(*) AS total_groups
FROM (
SELECT user_id
FROM orders
GROUP BY user_id
) AS t;
两者在绝大多数场景下结果相同,但有一个容易忽略的差异:COUNT(DISTINCT user_id)会忽略NULL值,而子查询里GROUP BY会把所有NULL分到同一组,外层COUNT(*)会把它算作一组。假设orders表里有一条user_id为NULL的脏数据,第一种写法返回的组数会少1,第二种会多1。因此使用前要明确业务上NULL分组是否应该计入总组数。
多列分组统计总组数时,COUNT(DISTINCT col1, col2)这种写法在MySQL中可以用,例如:
SELECT COUNT(DISTINCT province, city) AS total_city_groups FROM users;
它会根据province和city的组合去重。如果MySQL版本较旧,或者组合的表达式比较复杂,也可以使用派生表来完成:
SELECT COUNT(*) AS total_city_groups
FROM (
SELECT province, city
FROM users
GROUP BY province, city
) AS t;
这几种写法没有绝对优劣,主要看可读性和执行计划。数据量较小时差异可以忽略,但数据量很大时,建议用EXPLAIN观察是否有效利用了索引。
三、结合 HAVING 做条件分组统计
分组统计中经常需要过滤掉不符合条件的组。例如,统计“下单次数超过三次的用户有多少”,这就要先按用户分组并计算每组行数,再筛选出行数大于三的组,最后统计满足条件的组数。SQL可以这样写:
SELECT COUNT(*) AS active_user_count
FROM (
SELECT user_id
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 3
) AS filtered_groups;
这里的内层查询先完成分组和过滤,外层查询只负责统计组数。HAVING的作用对象是分组后的结果,而WHERE是在分组前对原始行进行过滤,两者不能混用。比如想排除测试用户的无效订单,可以在WHERE里先过滤user_id范围,然后再GROUP BY和HAVING。
除了子查询,也可以使用条件聚合的方式统计满足条件的组数。比如想统计订单金额超过100元的用户数,可以这样表达:
SELECT COUNT(DISTINCT CASE WHEN amount > 100 THEN user_id END) AS high_value_user_count FROM orders;
CASE WHEN会判断每一行的金额,满足条件的保留user_id,不满足的返回NULL,而COUNT(DISTINCT ...)会忽略NULL,因此最终得到的是至少有一笔订单金额超过100元的用户数。这种写法避免了子查询,但可读性稍差,适合需要在一个SELECT里同时统计多个口径的场景。
另外,HAVING中可以使用聚合函数,也可以使用SELECT里定义的别名。MySQL对别名比较宽容,但为了SQL的可移植性,建议在HAVING中直接写聚合表达式,例如HAVING COUNT(*) > 3,而不是HAVING order_count > 3。这样在切换数据库时不容易出错。
四、窗口函数在分组统计中的应用
从MySQL 8.0开始,窗口函数为分组统计提供了另一种思路。窗口函数不会像GROUP BY那样压缩行数,而是可以在保留原始明细的同时,给每一行附加分组统计结果。比如想在订单明细后面直接显示该用户的累计下单次数,可以这样写:
SELECT order_id, user_id, amount,
COUNT(*) OVER (PARTITION BY user_id) AS user_order_count
FROM orders;
这条语句会为每一行返回该行所属user_id分组的总行数。它的优势是保住了order_id、amount这些明细字段,而普通GROUP BY写法必须在SELECT中列出非聚合字段,否则容易报错或丢失信息。对于需要同时展示明细和汇总值的报表,窗口函数往往更简洁。
不过窗口函数在“统计总组数”这个目标上并不比COUNT(DISTINCT)更直接。想得到分组总数,仍然可以配合DISTINCT使用,例如:
SELECT COUNT(DISTINCT user_id) AS total_groups FROM orders;
或者先DISTINCT再使用窗口函数计算总行数,但实际意义不大。窗口函数更适合需求里既有明细又有分组统计值的场景,而统计唯一组数这件事本身,交给COUNT(DISTINCT)或派生表会更简单。
还需要注意性能差异。GROUP BY在聚合后会减少返回行数,而窗口函数不会减少行数,它需要为每一行计算窗口结果,在数据量非常大的时候,内存和CPU消耗可能明显高于GROUP BY。实际使用时应根据报表形态选择,而不是盲目用窗口函数替代所有分组统计。
五、常见的统计口径与错误排查
在真实的业务统计中,分组数对不上往往不是语法错误,而是口径不一致。例如用户表有十个用户,订单表里只有七个用户有订单记录。如果用COUNT(*) FROM users得到十,用COUNT(DISTINCT user_id) FROM orders得到七,这两个数本身没有谁对谁错,只是统计对象不同。因此在写SQL之前,先明确统计的是“所有用户”还是“有订单的用户”。
另一个常见问题是GROUP BY与SELECT列表不一致。在默认的sql_mode包含ONLY_FULL_GROUP_BY时,SELECT中的非聚合字段必须出现在GROUP BY中,否则会报错。比如SELECT user_id, amount, COUNT(*) FROM orders GROUP BY user_id; 会因为amount没有出现在GROUP BY而报错。解决方式是只SELECT需要的分组字段,或者使用ANY_VALUE()函数,或者调整sql_mode,但推荐规范写法。
还有人在统计分组数时误用COUNT(GROUP BY字段),比如COUNT(user_id)配合GROUP BY user_id,这只能返回每组内user_id不为NULL的行数,与COUNT(*)在user_id没有NULL时结果相同,但如果user_id允许NULL,COUNT(user_id)会漏掉NULL组。归根结底,统计每组行数用COUNT(*),统计总组数用COUNT(DISTINCT ...),这是最稳妥的习惯。
如果遇到查询速度慢,优先检查分组字段是否建立了索引。GROUP BY在没有索引时可能需要创建临时表或进行文件排序,这在百万级数据上会明显变慢。对于COUNT(DISTINCT user_id)这样的查询,user_id上的普通索引通常可以被利用,执行计划里出现Using index for group-by就是一个较好的信号。