适用版本:MySQL 8.0.x
如果说单表查询和多表 JOIN 解决的是“把哪些行查出来”,那么聚合函数与分组统计解决的就是另一个更贴近业务决策的问题:
- 一共有多少条订单?
- 今日支付金额总和是多少?
- 每个部门有多少人?
- 哪个城市的客户平均客单价最高?
- 每个月新增多少用户?
- 某类商品的退款率是多少?
这些问题都不是简单地返回明细,而是在做汇总、统计、对比、分析。这类能力在后台报表、运营数据面板、BI 查询、监控指标统计中极其常见。
很多人刚接触聚合时,会先学会 COUNT(*)、SUM(amount)、GROUP BY department_id,然后很快就遇到新的困惑:
- 为什么有些字段能写在
SELECT中,有些不行? WHERE和HAVING到底有什么区别?- 为什么
COUNT(*)和COUNT(column)结果不一样? - 为什么一分组后,结果行数突然变少?
- 为什么某些统计 SQL 看起来能跑,但实际上结果是错的?
这篇文章就围绕这些问题展开,把 MySQL 8.0 中最常用的聚合与分组统计讲清楚。
一、什么是聚合函数
聚合函数(Aggregate Function)是指:
对多行数据进行汇总计算,并返回一个统计结果的函数。
最常见的聚合函数有:
| 函数 | 作用 | 常见用途 |
|---|---|---|
COUNT() |
计数 | 订单数、用户数、记录数 |
SUM() |
求和 | 销售额、支付金额、库存总数 |
AVG() |
平均值 | 平均工资、平均客单价 |
MAX() |
最大值 | 最高价格、最新时间 |
MIN() |
最小值 | 最低价格、最早时间 |
GROUP_CONCAT() |
组内拼接 | 合并标签、拼接明细 |
如果没有 GROUP BY,聚合函数通常会对整个结果集做汇总;如果有 GROUP BY,聚合函数会对每个分组分别计算。
二、示例表结构
后文继续以订单场景举例。假设有如下表:
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(50) NOT NULL,
customer_id BIGINT NOT NULL,
city VARCHAR(50) NOT NULL,
order_status VARCHAR(20) NOT NULL,
pay_amount DECIMAL(10,2) NOT NULL,
discount_amount DECIMAL(10,2) DEFAULT 0,
created_at DATETIME NOT NULL,
paid_at DATETIME DEFAULT NULL
);
如果你想象自己正在做一个运营后台,这张表已经足够支撑大量常见统计需求。
三、没有 GROUP BY 时:对整个结果集做汇总
1. 统计总订单数
SELECT COUNT(*) AS total_orders
FROM orders;
2. 统计已支付订单总金额
SELECT SUM(pay_amount) AS total_paid_amount
FROM orders
WHERE order_status = 'PAID';
3. 查询最高支付金额与最低支付金额
SELECT MAX(pay_amount) AS max_amount,
MIN(pay_amount) AS min_amount
FROM orders;
4. 查询平均支付金额
SELECT AVG(pay_amount) AS avg_amount
FROM orders
WHERE order_status = 'PAID';
这类写法的特点是:
- 结果通常只返回一行
- 统计对象是筛选后的整个结果集
四、COUNT(*)、COUNT(1)、COUNT(column) 有什么区别
这是聚合里非常经典的问题。
1. COUNT(*)
表示统计行数,不关心具体列值是否为 NULL。
SELECT COUNT(*)
FROM orders;
2. COUNT(column)
表示统计该列非 NULL 的行数。
SELECT COUNT(paid_at)
FROM orders;
如果有些订单还未支付,paid_at 为 NULL,那么这些行不会被 COUNT(paid_at) 统计进去。
3. 一个简单对比
| 写法 | 统计对象 |
|---|---|
COUNT(*) |
全部行数 |
COUNT(1) |
通常等价于全部行数 |
COUNT(paid_at) |
paid_at 不为 NULL 的行数 |
4. 实际使用建议
- 统计总记录数,优先写
COUNT(*) - 明确要统计某字段非空数量时,再写
COUNT(column)
不要把两者混用,否则统计口径会悄悄发生变化。
五、GROUP BY:把明细拆成多个统计组
1. 按城市统计订单数
SELECT city,
COUNT(*) AS order_count
FROM orders
GROUP BY city;
执行结果的含义是:
- 按
city的取值分组 - 每个城市各算一组
- 对每组统计订单条数
2. 按订单状态统计金额总和
SELECT order_status,
SUM(pay_amount) AS total_amount
FROM orders
GROUP BY order_status;
3. 多列分组
如果想看“每个城市下各状态的订单数”:
SELECT city,
order_status,
COUNT(*) AS order_count
FROM orders
GROUP BY city, order_status;
这时分组粒度就从“城市”变成了“城市 + 状态”的组合。
4. 怎么理解分组粒度
你可以把 GROUP BY city, order_status 理解成:
北京 + PAID是一组北京 + CANCELLED是一组上海 + PAID是一组上海 + CANCELLED是一组
分组字段越多,统计粒度通常越细。
六、SELECT 中为什么有些列不能随便写
很多初学者会写出这样的 SQL:
SELECT city,
order_no,
COUNT(*) AS order_count
FROM orders
GROUP BY city;
在 MySQL 8.0 默认启用 ONLY_FULL_GROUP_BY 的情况下,这通常会报错。原因是:
city是分组字段,可以出现在SELECT中COUNT(*)是聚合结果,也可以出现- 但
order_no既不是分组字段,也不是聚合结果 - 同一个城市下可能有很多订单号,数据库不知道该返回哪一个
正确原则
当使用 GROUP BY 时,SELECT 中的列通常应满足以下之一:
- 出现在
GROUP BY中 - 是聚合函数结果
- 在严格可证明函数依赖的特殊场景中可推导,但日常写法里不建议依赖这一点
正确改写示例
SELECT city,
COUNT(*) AS order_count
FROM orders
GROUP BY city;
如果你真的想看每个城市下最晚一笔订单号,就得先明确规则,例如取最新时间对应的订单号,而不是随手把 order_no 放进去。
七、WHERE 与 HAVING:两个过滤阶段,不是一回事
这是分组统计里最重要的概念之一。
1. WHERE:分组前过滤
SELECT city,
COUNT(*) AS paid_order_count
FROM orders
WHERE order_status = 'PAID'
GROUP BY city;
含义是:
- 先筛出已支付订单
- 再按城市分组统计
2. HAVING:分组后过滤
SELECT city,
COUNT(*) AS order_count
FROM orders
GROUP BY city
HAVING COUNT(*) >= 100;
含义是:
- 先按城市分组
- 再筛出订单数不少于 100 的城市
3. 一个对照表
| 子句 | 执行阶段 | 典型用途 |
|---|---|---|
WHERE |
分组前 | 过滤明细数据 |
HAVING |
分组后 | 过滤聚合结果 |
4. 常见组合写法
SELECT city,
COUNT(*) AS paid_order_count,
SUM(pay_amount) AS paid_total_amount
FROM orders
WHERE order_status = 'PAID'
GROUP BY city
HAVING SUM(pay_amount) >= 100000;
这里:
WHERE先把“未支付订单”排除HAVING再筛出支付总额超过 10 万的城市
八、条件聚合:报表统计里非常高频
很多运营报表并不是简单看总数,而是想在同一个结果集中看到多种状态的统计值。这时条件聚合非常有用。
1. 按城市统计总订单数、支付订单数、取消订单数
SELECT city,
COUNT(*) AS total_orders,
SUM(CASE WHEN order_status = 'PAID' THEN 1 ELSE 0 END) AS paid_orders,
SUM(CASE WHEN order_status = 'CANCELLED' THEN 1 ELSE 0 END) AS cancelled_orders
FROM orders
GROUP BY city;
2. 统计不同状态的金额
SELECT city,
SUM(CASE WHEN order_status = 'PAID' THEN pay_amount ELSE 0 END) AS paid_amount,
SUM(CASE WHEN order_status = 'REFUNDED' THEN pay_amount ELSE 0 END) AS refunded_amount
FROM orders
GROUP BY city;
3. 为什么条件聚合很实用
因为它能避免你为了不同状态反复查询多次,在同一条 SQL 中就完成结构化统计,非常适合:
- 运营看板
- 业务日报
- 后台概览页
- 财务口径拆分统计
九、按时间分组:日报、周报、月报的基础
1. 按天统计订单数
SELECT DATE(created_at) AS stat_date,
COUNT(*) AS order_count
FROM orders
GROUP BY DATE(created_at)
ORDER BY stat_date ASC;
2. 按月统计支付金额
SELECT DATE_FORMAT(paid_at, '%Y-%m') AS stat_month,
SUM(pay_amount) AS total_paid_amount
FROM orders
WHERE paid_at IS NOT NULL
GROUP BY DATE_FORMAT(paid_at, '%Y-%m')
ORDER BY stat_month ASC;
3. 时间分组的一个实践提醒
像 DATE(created_at)、DATE_FORMAT(paid_at, '%Y-%m') 这样的写法,表达很直观,但在大数据量场景下,如果你还要依赖索引做高性能过滤,通常要把:
- 时间范围过滤放在
WHERE - 时间格式化主要用于展示或分组层
例如:
SELECT DATE(created_at) AS stat_date,
COUNT(*) AS order_count
FROM orders
WHERE created_at >= '2026-06-01 00:00:00'
AND created_at < '2026-07-01 00:00:00'
GROUP BY DATE(created_at)
ORDER BY stat_date ASC;
这样通常比“先对整列做函数,再过滤”更稳一些。
十、DISTINCT 与聚合:去重统计要分清口径
1. 统计去重客户数
SELECT COUNT(DISTINCT customer_id) AS unique_customer_count
FROM orders;
这统计的是“有下单记录的不同客户数”,不是订单总数。
2. 分组后统计每个城市的去重客户数
SELECT city,
COUNT(DISTINCT customer_id) AS customer_count
FROM orders
GROUP BY city;
3. 为什么这个很重要
很多业务指标名字看起来很像,但含义完全不同:
- 订单数
- 下单人数
- 支付订单数
- 支付用户数
如果不把“是否去重”想清楚,统计结果就会偏差很大。
十一、字符串聚合:GROUP_CONCAT() 的实用场景
有时你不只是想看数字,还想把同组内的某些文本拼起来。
例如:按城市查看该城市出现过的订单状态。
SELECT city,
GROUP_CONCAT(DISTINCT order_status ORDER BY order_status SEPARATOR ', ') AS status_list
FROM orders
GROUP BY city;
GROUP_CONCAT() 的典型用途
- 拼接同组标签
- 拼接用户角色列表
- 拼接订单状态集合
- 辅助导出或调试分析
不过要注意,GROUP_CONCAT() 更适合展示型、辅助型场景,不适合作为严肃业务计算的唯一依据。
十二、分组后的排序与汇总行
1. 按统计结果排序
例如按支付金额从高到低查看各城市排行:
SELECT city,
SUM(pay_amount) AS total_amount
FROM orders
WHERE order_status = 'PAID'
GROUP BY city
ORDER BY total_amount DESC;
这里 ORDER BY 既可以写别名 total_amount,也可以直接写聚合表达式。
2. 使用 WITH ROLLUP 查看汇总行
MySQL 支持在分组结果后追加总计行。
SELECT city,
SUM(pay_amount) AS total_amount
FROM orders
WHERE order_status = 'PAID'
GROUP BY city WITH ROLLUP;
结果中通常会多出一行,city 为 NULL,表示整体汇总。
3. 什么时候适合用 WITH ROLLUP
- 快速查看分组总计
- 生成简单汇总报表
- 临时分析时省去额外再写一条总计 SQL
不过如果你要输出非常正式的报表,通常还是会在应用层对汇总行做更明确的格式化处理。
十三、常见坑:聚合统计最容易错在哪里
1. 把非分组字段随手写进 SELECT
这在 MySQL 8.0 默认严格模式下通常会直接报错,但如果团队有人关闭了相关模式,就可能出现“SQL 能跑但结果并不可靠”的情况。
2. COUNT(column) 和 COUNT(*) 混淆
如果 column 里有 NULL,统计结果就会小于总行数。很多“数量为什么少了”的问题,都是从这里来的。
3. WHERE 和 HAVING 用反
例如把聚合条件写到 WHERE 中:
WHERE COUNT(*) > 10
这是不对的。聚合后的条件应该放在 HAVING 中。
4. 忽略分组粒度
同样是统计金额:
- 按城市分组
- 按城市 + 月份分组
- 按城市 + 月份 + 状态分组
这三种结果完全不是一个粒度。粒度不同,业务含义就不同。
5. 明细 JOIN 后直接聚合,导致统计被放大
比如订单表 JOIN 订单明细表后再统计订单金额,如果一个订单有多条明细,订单金额可能会被重复累计。此时要先想清楚:
- 我要统计的是订单粒度,还是明细粒度?
- 是否应该先对明细表汇总,再和订单表关联?
这是多表统计里非常高频的错误来源。
6. 对时间列做函数处理后再过滤,影响查询效率
比如:
WHERE DATE(created_at) = '2026-06-09'
虽然可读,但通常不如范围过滤更稳:
WHERE created_at >= '2026-06-09 00:00:00'
AND created_at < '2026-06-10 00:00:00'
十四、实践建议:把统计 SQL 写得更接近业务口径
1. 先明确统计对象是什么
是:
- 订单数?
- 支付订单数?
- 下单人数?
- 支付用户数?
- 金额总和?
- 去重后的金额贡献用户数?
口径不清,再正确的 SQL 也可能统计错业务指标。
2. 先筛选,再分组,再聚合,再排序
很多统计 SQL 其实都可以按这条思路去组织:
FROM确定数据来源WHERE过滤明细GROUP BY定义统计粒度- 聚合函数计算指标
HAVING过滤分组结果ORDER BY做排序展示
这个顺序理顺后,写统计 SQL 会稳定很多。
3. 给指标起清晰别名
例如:
order_countpaid_order_counttotal_paid_amountavg_order_amount
比起模糊的 cnt、sum1、num2,清晰别名会极大提升 SQL 的可维护性。
4. 复杂报表优先拆层
如果一条统计 SQL 里既有多表 JOIN、又有多种聚合、还要做条件过滤和排序,建议考虑:
- 先用子查询或 CTE 产生中间结果
- 再在外层做最终聚合
这样比一口气把所有逻辑揉在一层里更不容易出错。
十五、小结
聚合函数与分组统计,是 SQL 从“查数据”走向“看数据、算数据、分析数据”的关键一步。这篇文章重点覆盖了:
COUNT、SUM、AVG、MAX、MIN、GROUP_CONCAT的基本作用- 没有
GROUP BY时如何对整体结果集做汇总 - 有
GROUP BY时如何按不同粒度做统计 COUNT(*)与COUNT(column)的差别WHERE与HAVING的职责分工- 条件聚合、时间分组、去重统计、汇总行等实战写法
- 聚合统计里最常见的逻辑错误与实践建议
真正写统计 SQL 时,最重要的从来不是“会不会某个函数”,而是:
- 统计口径是否清楚
- 分组粒度是否正确
- 数据是否被重复计算
- 过滤条件是在明细层还是分组层生效
把这些问题想明白,聚合函数就不再只是语法点,而会变成你理解和表达业务数据的一种工具。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!