第三单元 · 空值、多人分组、排序细节 · 第 7 / 21 节
两个 COUNT 不一样:空值、分组、排序
建本节自己的表 lesson03_students(8 行、3 处空值),看清 COUNT(*) 与 COUNT(列) 差在哪、平均值按几个人算;让空值单独成组,用 HAVING 筛出“一个城市两个人”的组;最后核对 NULL 在升序/降序中的位置、多列排序,以及中文排序由列的排序规则决定、不是拼音。
约 22 分钟。这一节只查不改:先弄清为什么“数行”和“数城市”差 1,再把“一个城市两个人”真正分出来,最后把中文和日期的排序看准。
$ / mysql> 提示符和输出。已完成 0 / 7 步
你现在在哪个提示符
应该在 mysql>。断了就先回到学习终端(PowerShell):
ssh root@8.148.204.152
mysql-learning
进去以后确认当前库:
SELECT DATABASE();
+------------+
| DATABASE() |
+------------+
| itcast |
+------------+
1 row in set (0.00 sec)
显示 itcast 就对了。如果是 NULL,执行 USE itcast;——它只切换当前连接用哪个库,不动数据。
第一步:建表,8 行里留 3 个空
第三单元用一张自己的表 lesson03_students:第二单元那 5 个人,再加 3 个新同事。先确认没有同名表:
SHOW TABLES LIKE 'lesson03_students';
Empty set (0.00 sec)
Empty set 说明可以建。如果显示了一行,说明你已经建过,先跳到本节「常见问题」核对起点,不用再建一次。
CREATE TABLE lesson03_students (
id INT PRIMARY KEY COMMENT '学号',
name VARCHAR(20) NOT NULL COMMENT '姓名',
age TINYINT UNSIGNED COMMENT '年龄',
city VARCHAR(20) COMMENT '所在城市',
joined DATE COMMENT '加入日期'
) COMMENT='本教程第三单元练习表,与视频示例无关';
Query OK, 0 rows affected (0.03 sec)
INSERT INTO lesson03_students (id, name, age, city, joined) VALUES
(1, '张伟', 23, '北京', '2024-03-02'),
(2, '李娜', 21, '上海', '2024-03-05'),
(3, '王强', 25, '广州', '2024-02-20'),
(4, '赵敏', 22, '深圳', '2024-03-09'),
(5, '陈晨', 24, '杭州', '2024-03-12'),
(6, '孙悦', NULL, '上海', '2024-04-01'),
(7, '周舟', 23, '北京', '2024-02-28'),
(8, '吴迪', 27, NULL, NULL);
Query OK, 8 rows affected (0.00 sec)
Records: 8 Duplicates: 0 Warnings: 0
表里已经有这 8 行时,再把这段 INSERT 跑一遍会报 ERROR 1062 (23000): Duplicate entry '1' for key 'lesson03_students.PRIMARY'——主键在拦重复,原来的 8 行不会被改坏;用下面的 SELECT … ORDER BY id 核对即可。
SELECT id, name, age, city, joined FROM lesson03_students ORDER BY id;
+----+--------+------+--------+------------+
| id | name | age | city | joined |
+----+--------+------+--------+------------+
| 1 | 张伟 | 23 | 北京 | 2024-03-02 |
| 2 | 李娜 | 21 | 上海 | 2024-03-05 |
| 3 | 王强 | 25 | 广州 | 2024-02-20 |
| 4 | 赵敏 | 22 | 深圳 | 2024-03-09 |
| 5 | 陈晨 | 24 | 杭州 | 2024-03-12 |
| 6 | 孙悦 | NULL | 上海 | 2024-04-01 |
| 7 | 周舟 | 23 | 北京 | 2024-02-28 |
| 8 | 吴迪 | 27 | NULL | NULL |
+----+--------+------+--------+------------+
8 rows in set (0.00 sec)
8 行里有 3 处是故意空的:id = 6 的孙悦没填年龄,id = 8 的吴迪没填城市、也没有加入日期。空值在 SQL 里写作 NULL,读作“不知道”——它不等于 0,也不等于空字符串。这一节剩下的时间基本都在和它打交道。
为什么另建一张表,而不是往 lesson02_students 里加人:那张表是你的练习现场,可能已经被改过;这一节又要放三个空值,混进第二单元的验收状态,两边的“应该是几行”就都说不清了。新表不读、也不写前两个单元的表。
第二步:COUNT(*) 和 COUNT(city) 差 1
四个数一起看:
SELECT COUNT(*) AS 行数, COUNT(age) AS 有年龄, COUNT(city) AS 有城市, COUNT(joined) AS 有日期
FROM lesson03_students;
+--------+-----------+-----------+-----------+
| 行数 | 有年龄 | 有城市 | 有日期 |
+--------+-----------+-----------+-----------+
| 8 | 7 | 7 | 7 |
+--------+-----------+-----------+-----------+
1 row in set (0.00 sec)
COUNT(*) 是 8,另外三个都是 7。差别只来自空值:COUNT(*) 数“有几行”,COUNT(列) 数“这一列有几行不是 NULL”。孙悦没有年龄,COUNT(age) 少数一个;吴迪没有城市和日期,COUNT(city)、COUNT(joined) 各少数一个。
讲义 2.6.4 对这一点的原话是“NULL 值是不参与所有聚合函数运算的”(p45)。这条规则不只管 COUNT:
SELECT ROUND(AVG(age), 1) AS 平均年龄, ROUND(SUM(age) / COUNT(*), 1) AS 总和除以行数
FROM lesson03_students;
+--------------+--------------------+
| 平均年龄 | 总和除以行数 |
+--------------+--------------------+
| 23.6 | 20.6 |
+--------------+--------------------+
1 row in set (0.00 sec)
同一列年龄,两种算法差 3.0。AVG(age) 的分母是 7 个填了年龄的人;SUM(age) / COUNT(*) 把 8 行都算进分母,等于默认“没填的那个人年龄是 0”。所以看到平均值,先问一句:它是按几个人算的。
提示 卡住:想确认分母到底是几
把 AVG(age) 和 COUNT(age) 写在同一条 SELECT 里,两个数并排出现,分母就不是猜的了。
第三步:一个城市两个人,只把这两组挑出来
先按城市分组,每组的行数一次看全:
SELECT city AS 城市, COUNT(*) AS 人数 FROM lesson03_students
GROUP BY city ORDER BY 人数 DESC, 城市;
+--------+--------+
| 城市 | 人数 |
+--------+--------+
| 上海 | 2 |
| 北京 | 2 |
| NULL | 1 |
| 广州 | 1 |
| 杭州 | 1 |
| 深圳 | 1 |
+--------+--------+
6 rows in set (0.00 sec)
6 行:北京、上海 各 2 人,广州、杭州、深圳 各 1 人,第一行 NULL 也是一组——吴迪没填城市,MySQL 把它单独当成一组,而不是悄悄丢掉。这一点讲义没有写,是本机 8.0.46 上实测的结果。
要把“人多的组”留下,条件写在 HAVING 里:
SELECT city AS 城市, COUNT(*) AS 人数 FROM lesson03_students
GROUP BY city HAVING COUNT(*) >= 2
ORDER BY 人数 DESC, 城市;
+--------+--------+
| 城市 | 人数 |
+--------+--------+
| 上海 | 2 |
| 北京 | 2 |
+--------+--------+
2 rows in set (0.01 sec)
只剩 上海 和 北京。HAVING 筛的是“组”,比较对象是每一组汇总出来的那个数(这里是人数 2),不是某一行。
同样是“只要填了城市的”,还能用 WHERE 先把吴迪那行去掉:
SELECT city AS 城市, COUNT(*) AS 人数 FROM lesson03_students
WHERE city IS NOT NULL GROUP BY city ORDER BY 城市;
+--------+--------+
| 城市 | 人数 |
+--------+--------+
| 上海 | 2 |
| 北京 | 2 |
| 广州 | 1 |
| 杭州 | 1 |
| 深圳 | 1 |
+--------+--------+
5 rows in set (0.00 sec)
WHERE 和 HAVING 的分工,讲义 2.6.5 讲得很清楚(p46):WHERE 在分组之前过滤行,HAVING 在分组之后过滤组。反过来说,WHERE 里不能判断聚合值——它执行的时候还没有“组”。
说明 常见报错:WHERE 里写 COUNT(*)
SELECT city, COUNT(*) AS 人数 FROM lesson03_students WHERE COUNT(*) > 1 GROUP BY city;
ERROR 1111 (HY000): Invalid use of group function
1111 的意思是聚合函数用错了地方。要筛组,把条件搬到 HAVING。
第 6 节里那个 1064 不是同一回事:那次是把 WHERE 整段写到了 GROUP BY 后面,属于子句顺序错;这次 WHERE 的位置是对的,错在它里面放了 COUNT。
说明 常见报错:分组后还输出没分组的列
SELECT city, name, COUNT(*) AS 人数 FROM lesson03_students GROUP BY city;
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'itcast.lesson03_students.name' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
1055:name 既不是分组列,也不是聚合值,MySQL 不知道该给你这一组里的哪一个人。第 6 节见过的 1140 是它的“没有 GROUP BY”版本,两个错误码说的是同一条规则。
第四步:组里也藏着空值
分组之后再同时看人数和平均年龄:
SELECT city AS 城市, COUNT(*) AS 人数, COUNT(age) AS 有年龄, ROUND(AVG(age), 1) AS 平均年龄
FROM lesson03_students GROUP BY city ORDER BY 城市;
+--------+--------+-----------+--------------+
| 城市 | 人数 | 有年龄 | 平均年龄 |
+--------+--------+-----------+--------------+
| NULL | 1 | 1 | 27.0 |
| 上海 | 2 | 1 | 21.0 |
| 北京 | 2 | 2 | 23.0 |
| 广州 | 1 | 1 | 25.0 |
| 杭州 | 1 | 1 | 24.0 |
| 深圳 | 1 | 1 | 22.0 |
+--------+--------+-----------+--------------+
6 rows in set (0.00 sec)
上海 有 2 个人,但“有年龄”只有 1,平均 21.0——这个平均值只代表李娜一个人,孙悦没参与。北京 是 2 个人、2 个年龄,平均 23.0。同一列 平均年龄,两个组的“按几个人算”并不一样。
这就是这一节最实用的一句话:**人数用 COUNT(*),平均用 AVG(列),两者分母不同;要放在一起解释时,把 COUNT(列) 也查出来。**
第五步:按日期排序,空值排在哪
SELECT id, name, joined FROM lesson03_students ORDER BY joined DESC;
+----+--------+------------+
| id | name | joined |
+----+--------+------------+
| 6 | 孙悦 | 2024-04-01 |
| 5 | 陈晨 | 2024-03-12 |
| 4 | 赵敏 | 2024-03-09 |
| 2 | 李娜 | 2024-03-05 |
| 1 | 张伟 | 2024-03-02 |
| 7 | 周舟 | 2024-02-28 |
| 3 | 王强 | 2024-02-20 |
| 8 | 吴迪 | NULL |
+----+--------+------------+
8 rows in set (0.00 sec)
从新到旧,最后一行是吴迪——他的 joined 是 NULL。MySQL 把 NULL 当成“最小的值”:降序时它落在最后,升序时它落在最前。
多列排序:
SELECT id, name, age FROM lesson03_students ORDER BY age DESC, id ASC;
+----+--------+------+
| id | name | age |
+----+--------+------+
| 8 | 吴迪 | 27 |
| 3 | 王强 | 25 |
| 5 | 陈晨 | 24 |
| 1 | 张伟 | 23 |
| 7 | 周舟 | 23 |
| 4 | 赵敏 | 22 |
| 2 | 李娜 | 21 |
| 6 | 孙悦 | NULL |
+----+--------+------+
8 rows in set (0.00 sec)
张伟 和 周舟 都是 23 岁,第一列分不出先后,才用第二列 id 升序决定谁在前。DESC 只跟紧邻的那一列,不会延续到后面的列(讲义 2.6.6,p47)。
第六步:中文排序不是拼音
SELECT id, name, city FROM lesson03_students ORDER BY city ASC;
+----+--------+--------+
| id | name | city |
+----+--------+--------+
| 8 | 吴迪 | NULL |
| 2 | 李娜 | 上海 |
| 6 | 孙悦 | 上海 |
| 1 | 张伟 | 北京 |
| 7 | 周舟 | 北京 |
| 3 | 王强 | 广州 |
| 5 | 陈晨 | 杭州 |
| 4 | 赵敏 | 深圳 |
+----+--------+--------+
8 rows in set (0.00 sec)
第一行是 NULL(升序,空值最前),接着是 上海,然后才是 北京。按拼音,北京(B)该在上海(S)前面;这里反了。这一列用的是排序规则 utf8mb4_0900_ai_ci——UCA 9.0.0 的通用规则,没有中文拼音定制,汉字按这套规则的权重排;这两个字恰好与码点 U+4E0A < U+5317 同序。决定顺序的是列的排序规则,不是把两段 UTF-8 字节直接比大小:同一列换成中文拼音规则 utf8mb4_zh_0900_as_cs,北京 就会排到 上海 前面(B < S)。
所以中文列排出来的顺序,不能当成“字母顺序”或“拼音顺序”来读;换一种排序规则结果也可能不同。真要按拼音排得另外转换,不在本节范围。
反方向看一遍:
SELECT id, name, city FROM lesson03_students ORDER BY city DESC;
+----+--------+--------+
| id | name | city |
+----+--------+--------+
| 4 | 赵敏 | 深圳 |
| 5 | 陈晨 | 杭州 |
| 3 | 王强 | 广州 |
| 1 | 张伟 | 北京 |
| 7 | 周舟 | 北京 |
| 2 | 李娜 | 上海 |
| 6 | 孙悦 | 上海 |
| 8 | 吴迪 | NULL |
+----+--------+--------+
8 rows in set (0.00 sec)
NULL 又回到最后一行。空值的位置只由升序/降序决定,跟这一列存的是什么无关。
说明 常见报错:WHERE 里用 SELECT 起的别名
SELECT city AS 城市, COUNT(*) AS 人数 FROM lesson03_students WHERE 城市 = '上海' GROUP BY city;
ERROR 1054 (42S22): Unknown column '城市' in 'where clause'
1054:WHERE 在 SELECT 之前执行,那时“城市”这个别名还不存在。条件里写原始列名 city 就好。别名可以用在 ORDER BY 和 HAVING 里,因为它们排在 SELECT 之后。
第七步:一条查询把这一节连起来
要一份“人多的城市、人数、平均年龄”的清单:
SELECT city AS 城市, COUNT(*) AS 人数, ROUND(AVG(age), 1) AS 平均年龄
FROM lesson03_students
WHERE city IS NOT NULL
GROUP BY city
HAVING COUNT(*) >= 2
ORDER BY 人数 DESC, 城市;
+--------+--------+--------------+
| 城市 | 人数 | 平均年龄 |
+--------+--------+--------------+
| 上海 | 2 | 21.0 |
| 北京 | 2 | 23.0 |
+--------+--------+--------------+
2 rows in set (0.00 sec)
一句里五个子句各干一件事:WHERE 去掉没有城市的那行,GROUP BY 按城市分堆,HAVING 只留人数 ≥ 2 的堆,SELECT 挑列并算平均,ORDER BY 先按人数降序、人数相同再按城市。书写顺序和执行顺序不是一回事(讲义 2.6.9,p49,第 6 节也讲过),这里是把它们一次用全。
常见问题:断线、重来、回学习终端
说明 SSH 断了 / 网页关了,怎么接着学
学习终端是服务器的 SSH,不是网页。重新打开 PowerShell:
ssh root@8.148.204.152
mysql-learning
USE itcast;
Database changed
然后核对这一节的起点(表在不在、几行):
SELECT COUNT(*) AS 行数, COUNT(age) AS 有年龄 FROM lesson03_students;
+--------+-----------+
| 行数 | 有年龄 |
+--------+-----------+
| 8 | 7 |
+--------+-----------+
1 row in set (0.00 sec)
8 和 7 就对了,可以直接做练习。密码由你自己保管,网页和本教程都不保存。
说明 想从本节开头重做
这一节只建一张表、只做查询,不改任何数据,所以“重做”=把查询再跑一遍即可,不需要删表。
如果 SHOW TABLES LIKE 'lesson03_students'; 是空的(换了实例,或者第一次没建成功),回到第一步重新建:CREATE TABLE 和 INSERT 那两段就是全部起点。
不要 DROP DATABASE itcast、不要 TRUNCATE、不要“重置整个库”:itcast 里还有课程的数据和你前两个单元的练习表。也不要动 lesson01_students、lesson02_students,本节完全不依赖它们。
常见报错怎么办
| 看到 | 意思 | 怎么办 |
|---|---|---|
1054 Unknown column | 列名或别名找不到 | 用 DESC 表名 核对列名;WHERE 里不要用 SELECT 的别名 |
1055 ... not in GROUP BY | 分组后输出了没分组的列 | 去掉那列,或加进 GROUP BY,或套一个聚合函数 |
1111 Invalid use of group function | 聚合函数放错位置 | 筛组用 HAVING,筛行用 WHERE |
1146 Table ... doesn't exist | 表不在当前库 | SELECT DATABASE(); 看是不是 itcast,再 SHOW TABLES LIKE 'lesson03_students'; |
练习
先自己做,再展开提示和对答案。前两题是基础,接着两题要组合,最后一题只问概念。
基础 1. 有几个人没填城市?用一条查询说明。
提示 卡住时再看
“没填”就是这一列是 NULL:COUNT(*) 数行,COUNT(city) 数填了的,两者相减。
参考答案 参考答案
SELECT COUNT(*) AS 行数, COUNT(city) AS 有城市, COUNT(*) - COUNT(city) AS 没填城市
FROM lesson03_students;
+--------+-----------+--------------+
| 行数 | 有城市 | 没填城市 |
+--------+-----------+--------------+
| 8 | 7 | 1 |
+--------+-----------+--------------+
1 row in set (0.00 sec)
8 − 7 = 1,只有吴迪没填。
基础 2. 人数恰好 1 人的城市有几个?NULL 那一组算不算?
提示 卡住时再看
条件写 HAVING COUNT(*) = 1。先不加条件,数一数上面分组结果有几行。
参考答案 参考答案
SELECT city AS 城市, COUNT(*) AS 人数 FROM lesson03_students
GROUP BY city HAVING COUNT(*) = 1 ORDER BY 城市;
+--------+--------+
| 城市 | 人数 |
+--------+--------+
| NULL | 1 |
| 广州 | 1 |
| 杭州 | 1 |
| 深圳 | 1 |
+--------+--------+
4 rows in set (0.00 sec)
4 行:NULL、广州、杭州、深圳。NULL 单独成组,也按 1 人计算——想排除它,就在 WHERE 里写 city IS NOT NULL。
进阶 3. 每个城市的人数与平均年龄,只保留平均年龄 ≥ 23 的组,按平均年龄从高到低。
提示 卡住时再看
HAVING 里可以直接写 AVG(age);排序用 SELECT 里起的别名。
参考答案 参考答案
SELECT city AS 城市, COUNT(*) AS 人数, ROUND(AVG(age), 1) AS 平均年龄
FROM lesson03_students
GROUP BY city
HAVING AVG(age) >= 23
ORDER BY 平均年龄 DESC, 城市;
+--------+--------+--------------+
| 城市 | 人数 | 平均年龄 |
+--------+--------+--------------+
| NULL | 1 | 27.0 |
| 广州 | 1 | 25.0 |
| 杭州 | 1 | 24.0 |
| 北京 | 2 | 23.0 |
+--------+--------+--------------+
4 rows in set (0.00 sec)
NULL 那组(吴迪 27 岁)也满足条件,所以出现在第一行。上海 是 21.0,被筛掉——这也说明“人数 2”不代表“平均年龄算的是 2 个人”。
进阶 4. 按加入日期从早到晚排,第一行是谁?为什么?
提示 卡住时再看
升序时 NULL 在最前。
参考答案 参考答案
SELECT id, name, joined FROM lesson03_students ORDER BY joined ASC;
+----+--------+------------+
| id | name | joined |
+----+--------+------------+
| 8 | 吴迪 | NULL |
| 3 | 王强 | 2024-02-20 |
| 7 | 周舟 | 2024-02-28 |
| 1 | 张伟 | 2024-03-02 |
| 2 | 李娜 | 2024-03-05 |
| 4 | 赵敏 | 2024-03-09 |
| 5 | 陈晨 | 2024-03-12 |
| 6 | 孙悦 | 2024-04-01 |
+----+--------+------------+
8 rows in set (0.00 sec)
第一行是吴迪,joined 为 NULL。他没有加入日期,MySQL 把 NULL 当最小的值,升序就排在最前。想让没填日期的排最后,得显式调整(本节不展开)。
概念 5. 这张表 8 行,为什么说“平均年龄 23.6”不是 8 个人的平均?
参考答案 参考答案
AVG(age) 只把填了年龄的 7 行放进分母(讲义 2.6.4:NULL 不参与聚合)。硬按 8 行算,等于把孙悦的空年龄当成 0,结果是 20.6——那是另一种口径,不是这张表的平均年龄。回答这类问题,先说清分母是几。
本单元验收
做到下面 5 条,这一节就完成了。每条都给出你应该看到的真实结果。
SELECT COUNT(*), COUNT(age) FROM lesson03_students;得到8和7。GROUP BY city得到 6 组,北京、上海各 2 人,NULL自成一组。HAVING COUNT(*) >= 2只剩上海、北京两行。- 分组后
上海是 2 人、COUNT(age)为 1、平均 21.0——能解释为什么。 ORDER BY city ASC时第一行是NULL,且上海排在北京前面——能说出这是本表排序规则(utf8mb4_0900_ai_ci)的结果、不是拼音;换排序规则顺序会变。
做到这里
做到这里今天可以停。第三单元只有这一节:你现在能分清“数行”和“数列”,能让空值单独成组,也能在分组后按汇总值筛组、按中文和日期排序。
下一节的方向是把这张表和另一张表对起来看(课程后面的多表查询)。页面做好后会接在这里,不提供空链接。
说明 这一节和视频课、讲义是什么关系(可选读)
主题对应黑马程序员《MySQL 数据库入门到精通》基础篇的 P17 聚合函数、P18 分组查询、P19 排序查询、P22 执行顺序(BV1Kr4y1i7ru,平台 part 17–19、22,在 P01–P50 范围内)。视频只作主题对照,本页不嵌入、不播放,也不声称逐句对应讲师讲解。
文字底稿核对的是同版讲义《MySQL-基础篇》的公开镜像(95 页 PDF,SHA256 bc5e2916…c08510,仓库 shuhongfan/MySql_Demo02,commit e131315…):2.6.4 聚合函数 p45(NULL 不参与聚合、COUNT(*) 与 COUNT(字段) 的区别)、2.6.5 分组查询 p46(WHERE/HAVING 分工、多字段分组)、2.6.6 排序查询 p47(ASC/DESC、多字段排序)、2.6.9 执行顺序 p49。该镜像不是官方发布渠道,正文版权属原出版方;本页只做主题核对,不转载讲义原文。
讲义没有写到的三处——NULL 在 GROUP BY 里单独成组、NULL 在升序/降序中的位置、中文在本表排序规则下的顺序(非拼音)——都是在本机 MySQL 8.0.46 上实测后补充的,并按实测结果写;排序规则与 UCA 权重的定义见 MySQL 8.0 手册 12.10.1 Unicode Character Sets。
本页的表名、8 行数据、查询和练习均为本教程原创;字段沿用讲义 emp 的语义(姓名、年龄、工作城市、入职日期),但没有使用讲师的 emp 数据,也没有复制课堂 SQL。页面里每一段输出都在本机隔离实例上逐条实跑(与学习服务器同版本号,不是同一台);0.00 sec 是测试机耗时,你的屏幕可能不同,行列内容一致。