news 2026/9/16 1:52:04

MySQL聚合函数详解:COUNT、SUM、AVG与GROUP BY实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL聚合函数详解:COUNT、SUM、AVG与GROUP BY实战指南

处理这种“统计一下订单数、汇总个金额、算个平均值”的操作,MySQL里最离不开的就是聚合函数。我几乎每天都要在查询里写COUNT、SUM、AVG这些函数,如果你是刚接触数据库或者写SQL总感觉不顺手,那这篇内容就是对症的。我会把这几个聚合函数的用法、原理、坑点拆开揉碎讲清楚,从最基础的单个函数,到配合GROUP BY做分组统计,再带上HAVING做分组后的条件过滤,最后说几个我实际踩过的性能问题和细节陷阱,尽量让你看完就能直接用到自己的查询里去。

1. 聚合函数到底是干什么的——先建立感觉

1.1 从一个真实的需求场景说起

假设你手上有一张订单表,里面存了几千条用户的购买记录。老板突然丢给你一句话:“把这个月的订单情况捋一下,总共有多少单、总销售额多少、客单价多少、最高一单多少钱、最低一单多少钱。”

这种需求如果不用聚合函数,你要么先把几千条数据全查出来,再用代码一行行累加;要么得写好几条SQL分别查询。麻烦不说,效率也低。聚合函数就是专门解决这类“对一竖列数据做归纳整理”的需求的,它们接收一整列的值,经过计算后给你一个单独的结果。

对应上面老板的需求,五个最常用的聚合函数刚好全部命中:

  • COUNT():统计行数,解决“总共有多少单”
  • SUM():计算总和,解决“总销售额”
  • AVG():计算平均值,解决“客单价”
  • MAX():找最大值,解决“最高一单”
  • MIN():找最小值,解决“最低一单”

读到这里你应该有感觉了,聚合函数做的是“纵向计算”,不是“横向计算”。普通函数比如CONCATUPPER是对每一行单独处理,每一行返回一个结果;聚合函数则是把一大堆行压缩成一行汇总结果。这个认知到位了,后面学什么都很顺。

1.2 聚合函数的两条铁律

用聚合函数之前,有两条铁律你心里得先立住,不然后面很容易栽跟头。

铁律一:聚合函数作用于一组行,返回单个结果。

单说概念你可能觉得抽象,我给你打个比方。你有一箱子苹果,SUM就是把这些苹果的总重量称出来,COUNT就是数箱子里有几个苹果,AVG就是把总重量除以个数。不管箱子里最初有多少个苹果,最后的答案永远是单一数值。所以当你只写SELECT COUNT(*) FROM users的时候,结果表只有一行一列。

这个性质决定了聚合函数在SQL里有一种特殊的“身份”——一旦SELECT列表里出现了聚合函数,那么其他普通列要么得出现在GROUP BY子句里,要么就得被别的聚合函数包住。这个限制我在第3章会详细展开,现在你先有个印象。

铁律二:聚合函数一般会跳过NULL值,但COUNT(*)除外。

这是新手最容易懵的一点。SUMAVGMAXMIN在计算时,会自动忽略值为NULL的行,因为它们没法拿“空值”参与数学运算,忽略是最合理的策略。但COUNT(*)比较特殊,它数的是“行数”,哪怕这一行所有字段全是NULL,它也算数。而COUNT(某一列)则是数“这一列非NULL的行数”。

我见过不少同学统计数量时纠结到底用COUNT(*)还是COUNT(id),这两者只要id列没有NULL,结果就一模一样,所以习惯上用COUNT(*)最省心。至于其他函数遇NULL的细节,后面第2章讲每个函数的时候再逐个说。

2. 五大基础聚合函数逐一拆解

2.1 COUNT:数行数也有讲究

COUNT的语法特别简单,就两种常见用法:

SELECT COUNT(*) FROM orders; SELECT COUNT(order_amount) FROM orders;

第一行数的是orders表总共有多少行;第二行数的是order_amount这一列里非NULL的值有多少个。

为什么要特别强调这个区别?我之前帮同事排查过一个Bug:订单表里的优惠金额字段允许为空,同事用COUNT(discount_amount)统计优惠单数,结果发现比实际订单数少了一截。原因就是有相当一部分订单没有优惠,这个字段是NULL,直接不参与计数。

所以这就有个判断标准:你想数“物理行数”,永远用COUNT(*);你想数“某个字段填了值的行数”,才用COUNT(字段名)。另外还有种写法是COUNT(1),很多人纠结它和COUNT(*)谁快,实际在MySQL里两者基本没区别,优化器早就帮你处理了,别在这种地方浪费决策时间。

2.2 SUM:求和时先想清楚NULL

SUM用来计算数值列的总和,比如总销售额、总积分。最基本的写法:

SELECT SUM(order_amount) AS total_amount FROM orders;

如果订单表一条数据都没有,SUM返回的不是0,而是NULL。这跟直觉正好相反,我心里一直记着这个特点,因为很多报表程序拿到NULL后没做判断,页面上直接显示“空”而不是“0元”,被客户质疑过好几次。稳妥的做法是配合IFNULL处理:

SELECT IFNULL(SUM(order_amount), 0) AS total_amount FROM orders;

另外一个注意点是SUM只对数值类型有意义,虽然MySQL在字符串列上也能执行SUM,但这事极不靠谱,碰到非数字字符会告警甚至报错。作为DBA的基本素养就是:求和前先看一眼字段类型,确保是INTDECIMAL这类数值类型再来谈SUM

2.3 AVG:平均数背后的“分子分母”

AVG是求平均值的,写法毫无悬念:

SELECT AVG(order_amount) AS avg_amount FROM orders;

但背后的逻辑值得你细品。AVG的官方语义是“非NULL值的平均值”,这就意味着它的分母是“非NULL的行数”,不是“总行数”。举个例子,5个订单里有一个订单金额是NULL,那AVG(order_amount)算出来是另外4单金额相加除以4,不是除以5。

这个行为大多数时候反而符合业务预期,因为谁也不想平均数莫名被一个空值拉低。但如果你的业务逻辑是“金额没填就当0参与计算”,那就不能直接用AVG,得写SUM / COUNT手动算,或者先把NULL转成0再求平均:

SELECT AVG(IFNULL(order_amount, 0)) FROM orders;

从上面这条SQL你会发现,聚合函数里套普通函数完全没问题,这也是后续组合玩法的核心思路。

2.4 MAX与MIN:除了数字还能比字符串和日期

MAXMIN这对兄弟最省心,一个取最大一个取最小。除了数字,它们也能作用于字符串和日期类型。字符串按字典序比较,日期按时间先后比较。

我实际用得比较多的是这几个场景:

-- 最大/最小订单金额 SELECT MAX(order_amount), MIN(order_amount) FROM orders; -- 最早/最晚的注册时间 SELECT MIN(created_at), MAX(created_at) FROM users;

有个小细节:MAXMIN同样会忽略NULL值,如果列里全是NULL,结果也是NULL。处理手法跟SUM一样,根据业务需求决定要不要IFNULL兜底。

2.5 一张表把五脏六腑理清楚

这五个函数的信息量密集铺开容易乱,我直接整理出一个速查表,方便你贴在手边随时看。

函数作用NULL值处理返回类型常见误用
COUNT(*)统计总行数不过滤NULL行数值误以为COUNT(列)和它等价
COUNT(列)统计某列非NULL数量只有非NULL参与数值列中NULL较多时结果偏小
SUM(列)计算数值总和忽略NULL数值(可能为NULL)表空时结果不是0
AVG(列)计算非NULL均值忽略NULL数值(可能为NULL)分母错当成总行数
MAX(列)求最大值忽略NULL与列类型一致对字符串列做语义比较
MIN(列)求最小值忽略NULL与列类型一致对字符串列做语义比较

当你把这行表里的要点吃透了,单个聚合函数你就不算新手了,接下来真正的重头戏是让它们跟GROUP BY配合起来。

3. 分组聚合——GROUP BY才是聚合函数的标配

3.1 GROUP BY的语法逻辑:把一个大组拆成多个小组

单独用聚合函数相当于把整张表看成一个大组,一次性汇总。但现实业务很少只要一个总数,更多时候是“每个用户的总消费”“每个商品类别的销量”“每个月的订单数量”。这种“按某个维度拆开再分别聚合”的需求,就得靠GROUP BY

语法逻辑非常简单,我当初理解它的方式是这样的:先把表按照GROUP BY后面指定的列值分组,值相同的行归进同一个小组,然后聚合函数在每一个小组内部各自计算。最后结果里,每个小组输出一行。

举个例子,我想统计每个用户的订单数和总金额:

SELECT user_id, COUNT(*) AS order_cnt, SUM(order_amount) AS total_amount FROM orders GROUP BY user_id;

这条语句的执行过程是把订单按user_id分组,user_id为1的所有订单聚成一组,算出该组的订单数和金额总和,然后输出一行;user_id为2同理,最后结果里每个用户各占一行。

这里有个很容易踩的坑:很多新手会直接写SELECT user_id, order_no, COUNT(*) FROM orders GROUP BY user_id,感觉没啥问题,结果MySQL直接报错。在默认的ONLY_FULL_GROUP_BY模式下,SELECT列表里的普通列如果没出现在GROUP BY里,SQL就是不合法的。因为order_no在组里有多个不同值,数据库压根不知道该展示哪一个,所以干脆拒绝执行。如果你确实想清楚地看到组内所有订单号,请用GROUP_CONCAT聚合函数,那是另一种玩法了。

3.2 按多个字段分组:给统计增加一个维度

有时候一个维度不够用,想同时看“每个月每个商品的销量”,这时候GROUP BY后面可以跟多个列,逗号分隔即可:

SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, product_id, COUNT(*) AS sales_cnt, SUM(order_amount) AS total_amount FROM orders GROUP BY month, product_id;

我的理解里,多列分组的本质就是把“多个字段值都一样”的行合并成一组。month是2025-01且product_id是1001的行,全部归到一起;month变成2025-02,哪怕product_id还是1001,也得分到不同组。这种“组合维度”的统计方式在报表里非常常见。

3.3 分组后条件过滤:HAVING和WHERE到底谁管谁

先看一个使用场景:我想统计“下了3单以上的用户”,并且想知道每个用户的总消费金额。直觉上会这么写:

SELECT user_id, COUNT(*) AS order_cnt, SUM(order_amount) AS total_amount FROM orders WHERE COUNT(*) >= 3 GROUP BY user_id;

这段SQL一执行就报错,因为WHERE不能直接用聚合函数做过滤,它处理的是“行”,而聚合结果此时还没算出来。分组之后的过滤条件应该用HAVING,正确写法是:

SELECT user_id, COUNT(*) AS order_cnt, SUM(order_amount) AS total_amount FROM orders GROUP BY user_id HAVING COUNT(*) >= 3;

WHEREHAVING的执行顺序我得给你掰扯清楚:WHERE是先过滤原始数据行,把不满足条件的行排除掉,然后才进入分组和聚合阶段;HAVING则是在分组和聚合计算都做完之后,对分组结果做过滤,这时候聚合函数当然就能用了。所以“先WHERE后分组,再HAVING过滤分组”,这个顺序记牢了就不会再走弯路。

4. 完整实操演示——从建表到查询一次跑通

4.1 准备一张订单表和演示数据

为了让你能跟着这篇文章亲手跑一遍,我准备好了一套最简单的建表语句和测试数据。你拿去在MySQL里执行一下,后续的聚合查询全部能直接跑通。

CREATE DATABASE IF NOT EXISTS demo_db DEFAULT CHARSET utf8mb4; USE demo_db; DROP TABLE IF EXISTS orders; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_name VARCHAR(50) NOT NULL, order_amount DECIMAL(10,2) NOT NULL, discount_amount DECIMAL(10,2) NULL, created_at DATETIME NOT NULL ); INSERT INTO orders (user_id, product_name, order_amount, discount_amount, created_at) VALUES (1, '手机', 3500.00, 200.00, '2025-01-01 10:00:00'), (1, '耳机', 500.00, 50.00, '2025-01-02 12:00:00'), (2, '手机', 3500.00, NULL, '2025-01-03 14:00:00'), (2, '充电器', 100.00, 10.00, '2025-01-05 09:00:00'), (2, '数据线', 50.00, NULL, '2025-01-06 20:00:00'), (3, '键盘', 800.00, 80.00, '2025-02-01 11:00:00'), (3, '鼠标', 300.00, NULL, '2025-02-02 16:00:00');

试想一下,这7条数据就是你实际工作中会遇到的一张普通业务表。有users,有产品,有金额,有部分为空的优惠金额字段,非常适合演示聚合函数在真实场景中的各种行为。

4.2 基础聚合函数在真实数据上的表现

先把最简单的五个聚合查询跑一遍,直接看输出结果:

SELECT COUNT(*) AS total_orders, SUM(order_amount) AS total_amount, AVG(order_amount) AS avg_amount, MAX(order_amount) AS max_amount, MIN(order_amount) AS min_amount FROM orders;

执行结果如下:

total_orderstotal_amountavg_amountmax_amountmin_amount
78750.001250.003500.0050.00

数据量小,你可以心算验证:7单加起来的总额正是8750.00,平均金额1250.00正好是8750除以7。结果符合直觉,没有任何意外。注意这里AVG的分母是7,因为order_amount没有任何NULL值。如果有一行订单金额是NULL,你就能直观发现在AVG和SUM上出现我前面说的那些微妙差异了。

再看看discount_amountCOUNT的配合效果:

SELECT COUNT(*) AS total_orders, COUNT(discount_amount) AS has_discount_orders FROM orders;
total_ordershas_discount_orders
74

7笔订单里只有3笔的优惠金额字段是NULL,所以COUNT(discount_amount)只有4。这就是我前面强调的COUNT(*)COUNT(列)的区别。如果你用COUNT(discount_amount)去统计“订单总数”,这一下就少算了3笔。

4.3 分组统计:每个用户下了多少单

接下来是最核心的分组聚合演示,统计每个用户的订单量、总消费和平均每单金额:

SELECT user_id, COUNT(*) AS order_cnt, SUM(order_amount) AS total_amount, AVG(order_amount) AS avg_amount FROM orders GROUP BY user_id;

执行结果:

user_idorder_cnttotal_amountavg_amount
124000.002000.00
233650.001216.67
321100.00550.00

农历肉眼验证一下:user_id为1的用户有两笔订单,3500加500正好4000,平均2000,分毫不差。user_id为2三笔,3500加100加50等于3650,平均1216.67是四舍五入后的结果。

这就是聚合函数在真实业务中的核心用法。从这张表你可以直接看出哪些用户是高价值用户,哪些用户虽然单量大但客单价偏低,已经是初步的数据分析了。

再加一个HAVING过滤下“大客户”,订单量大于等于3的用户:

SELECT user_id, COUNT(*) AS order_cnt FROM orders GROUP BY user_id HAVING COUNT(*) >= 3;

跑出来的结果只有user_id为2的用户,因为他正好有3笔订单。从这一刻开始,你手里的工具已经从“普通查询”进化成了“能出统计报表”的状态。

5. 几个提升效率的高级玩法

5.1 条件聚合:CASE WHEN配合SUM做行转列统计

前面讲的分组都是把某个字段的值拆成不同行,但有时候我想把某列的不同值变成不同列,形成交叉报表。这就要用到条件聚合的套路。

比如我想统计每个用户有优惠的订单金额和没优惠的订单金额,传统写法可能得分两条SQL查两次,但写成条件聚合一步到位:

SELECT user_id, SUM(CASE WHEN discount_amount IS NOT NULL THEN order_amount ELSE 0 END) AS discounted_amount, SUM(CASE WHEN discount_amount IS NULL THEN order_amount ELSE 0 END) AS full_amount FROM orders GROUP BY user_id;

执行结果:

user_iddiscounted_amountfull_amount
14000.000.00
23600.0050.00
31100.000.00

这种写法的精髓在于:CASE WHEN在每一行上先做判断,判断结果作为某个数值传入SUM,最终实现“把符合条件行的金额累加到一起”。我经常用它做按月、按渠道、按状态的横向统计,比多次查询再在Java/Python里拼接要干净得多。

5.2 去重统计:COUNT(DISTINCT 列)的正确姿势

另一个高频需求是统计“有多少个不同的用户下了单”,这时候COUNT(DISTINCT user_id)直接安排:

SELECT COUNT(DISTINCT user_id) AS user_cnt FROM orders;

执行结果为3,因为我们数据里只有1、2、3三个用户。COUNT(DISTINCT 列)会先对该列去重,再统计去重后的行数。这是COUNT里最容易和其他函数混淆的用法,也是写用户活跃报表时不可缺的工具。

注意,COUNT右侧写DISTINCT后,括号里的列如果含NULL,NULL是不参与计数的,跟普通COUNT(列)保持一致。

5.3 空值转0:IFNULL配合聚合解决“算出来是NULL”的尴尬

前面提到,没有任何行时,SUMAVG会返回NULL。这在报表程序里经常导致展示异常。我的习惯是凡是对外输出数值型汇总,一律用IFNULL包一层:

SELECT user_id, IFNULL(SUM(order_amount), 0) AS total_amount FROM orders WHERE user_id = 999 GROUP BY user_id;

如果user_id为999的用户根本不存在,找不到任何一行,SUM结果是NULL,IFNULL把它转成0,返回的结果就是0.00。程序拿到0后展示成“0元”,体验就正常了。别小看这一层保护,这个细节我确实是有过生产教训的——老系统就因为没有这一行处理,出现过报表页面上整块空白的事故。

5.4 聚合结果排序与LIMIT:统计完还要见分晓

聚合计算完成后,结果一样可以继续ORDER BY排序。比如查每个用户的消费总额,从高到低排列,只看前2名:

SELECT user_id, SUM(order_amount) AS total_amount FROM orders GROUP BY user_id ORDER BY total_amount DESC LIMIT 2;

执行结果前两行是user_id为1(4000)和user_id为2(3650)。逻辑上ORDER BY在分组聚合之后执行,所以它能引用SUM(order_amount)的别名total_amount,这跟在普通列上排序的写法没区别。

6. 常见问题与避坑指南

6.1 聚合结果精度问题

涉及到AVG或者除法运算时,精度问题很常见。MySQL的AVG函数在整数列上返回的可能是DECIMAL类型,但如果你在原表上再叠加其他运算,比如想算“客单价占比”,你可能会写出:

SELECT user_id, SUM(order_amount) / COUNT(*) AS avg_amount FROM orders GROUP BY user_id;

这本身没问题,但SUM/COUNT的结果是小数,你得留意字段类型到底是DECIMAL还是DOUBLE。不同的数据库驱动在返回类型上可能给你意外惊喜。稳妥的做法是在最终查询里用ROUND显式控制小数位:

SELECT user_id, ROUND(SUM(order_amount) / COUNT(*), 2) AS avg_amount FROM orders GROUP BY user_id;

我一般对外输出的金额统一保留两位小数,室内计算时保持原始精度,展示层再格式化。

6.2 ONLY_FULL_GROUP_BY引发的报错

这个报错在MySQL 5.7以上版本默认开启,只要SELECT列表里出现了“既不在GROUP BY里,也没被聚合函数包裹”的普通列,马上报错。报错信息大致是“which isn't in GROUP BY”。解决办法有两个方向:把报错的列加进GROUP BY,或者把报错的列用ANY_VALUE()包起来。比如:

SELECT user_id, ANY_VALUE(product_name) AS sample_product, COUNT(*) AS order_cnt FROM orders GROUP BY user_id;

ANY_VALUE是从组内随便挑一个值填充,适合你明确知道组内值都一样、只是需要展示出来的场景。注意如果同一个用户买过不同产品,ANY_VALUE返回哪个完全不确定,所以不能依赖它做业务判断。

6.3 数据量大的性能感测

聚合查询在数据量大的表上性能容易失控。我见过的典型低效写法是:在一个大表上直接GROUP BY user_id,而user_id上没有索引,导致数据库不得不建临时表做分组。碰到这种情况,优先考虑给分组字段和WHERE条件字段建联合索引。比如我们的orders表,如果高频查询是“按用户统计订单”,建这样的索引会有明显改善:

ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);

另外能用WHERE过滤掉的记录,一定提前用WHERE过滤,别让聚合函数处理无用数据。数据量到百万级以上时,连上面的条件聚合都可能变成重量级操作,那时候我会考虑用报表系统、定时汇总表或者干脆上OLAP引擎,而不是让业务数据库硬扛。

回归到最后,聚合函数这套东西,真不是背几个函数名就行。它考验的是你对“行、列、分组、过滤顺序”这些SQL底层逻辑的理解。我最初学这段时也是靠天天写报表练出来的,从单个函数跑到分组统计,再到条件聚合,踩过数不清的NULL和GROUP BY的坑之后,才慢慢形成肌肉记忆。写好聚合查询的关键就两条:一是时刻留意NULL值的行为差异,二是搞清楚WHERE、GROUP BY、HAVING、ORDER BY的执行顺序。这两件事想通了,后面写出来的SQL基本就有了形。建议你拿到我这份建表语句,把每个查询都实际跑一遍,把结果对照着看,比干读十遍文章都管用。真遇到聚合函数的诡异行为,别急着怀疑数据库,先把数据里的NULL和分组逻辑捋一遍,八成答案就在里头。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 1:51:45

基于FPGA的闹钟系统设计:Verilog时序与状态机实战

简介:基于FPGA的闹钟系统设计完整工程包,适用于数字逻辑、EDA技术等课程设计场景,帮助学习者完成带闹钟功能的24小时计时器。设计包含七段数码管显示、按键输入、时间设置与闹钟比较、扬声器驱动等模块,覆盖从RTL编码到上板验证的…

作者头像 李华
网站建设 2026/9/16 1:51:41

Zynq Linux启动文件全解析:从RAR解压到SD卡跑通

简介:在Zynq SoC平台的Linux开发中,由于芯片同时集成ARM Cortex-A9与FPGA,驱动与硬件交互复杂,调试宏头文件便成为定位问题的利器;一份极简压缩包聚焦调试宏头文件设计,面向嵌入式驱动开发、系统优化及底层…

作者头像 李华
网站建设 2026/9/16 1:51:17

文本分类入门实战:基于TF-IDF与机器学习模型的完整指南

来了,作为带过不少NLP入门项目的从业者,我太清楚“LSTM调参调到头秃、结果还没传统模型高”是什么滋味了。这也是为什么我一直觉得,NLP-Beginner系列任务一设置的“基于机器学习的文本分类”特别适合作为第一站——它没有复杂的网络结构&…

作者头像 李华
网站建设 2026/9/16 1:51:14

DVT Eclipse保姆级安装教程:从零搭建SystemVerilog验证环境

1. 为什么验证老兵都推荐DVT Eclipse先交代下背景。我在芯片验证这行干了快十年,从最初用GVim写SystemVerilog,到后来被同事安利了DVT Eclipse,说实话有点“相见恨晚”的感觉。如果你平时写验证环境主要靠VSCode加插件,或者还在用…

作者头像 李华
网站建设 2026/9/16 1:50:16

Lamb波频散曲线:理论、Python求解与频散补偿应用

简介:这是一份基于MATLAB的Lamb波频散曲线计算与绘制资料,面向从事结构健康监测、无损检测及相关研究的工程师与科研人员。内容围绕薄板中Lamb波的传播特性,涵盖相速度与群速度的频散求解思路,并通过可视化图形直观展示波速随频率…

作者头像 李华
网站建设 2026/9/16 1:50:10

LLM应用开发实战地图:RAG、AI Agents与开源框架工程化指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华