Skip to content

03 · 计算、汇总与多表查询 ​

对应第 10~18 章,使用第一单元的数据。先执行 USE showdocs_mysql_book;。本单元把问题从「查出几行」推进到「这几行代表什么、为什么算出来是这个数」。

先记住数据的粒度:learners 一行是一位学员,courses 一行是一门课程,enrollments 一行是一位学员对一门课程的报名。报名明细共有 3 行,但只有 2 位不同学员。连接、汇总或去重时,一旦忘记这个区别,就容易写出能执行却算错钱的 SQL。

第 10~11 章:表达式与函数 ​

计算列只改变查询结果 ​

假设页面要显示「课程名 / 分类」和九折价格,不需要给表新增两列。可以在读取时计算:

sql
SELECT course_id, CONCAT(title, ' / ', category) AS label,
       ROUND(price * 0.90, 2) AS discounted_price
FROM courses ORDER BY course_id;

SELECT learner_id, COALESCE(email, '未填写') AS contact
FROM learners ORDER BY learner_id;

第一条的结果是:

course_idlabeldiscounted_price
101SQL 入门 / 数据库35.10
102MySQL 事务 / 数据库71.10
103Linux 入门 / 运维44.10
104SQL 报表 / 数据库53.10

price * 0.90 对每一行进行乘法,ROUND(..., 2) 四舍五入到两位小数,AS 给结果列命名。查询结束后,101 在表中的价格仍是 39.00。要保存新价格,必须显式执行 UPDATE。

别名方便显示和后续排序,但同一层查询的 WHERE 不能直接引用刚定义的计算列别名。筛选九折价不超过 40 元,可以在 WHERE 中重复表达式,或先用派生表生成这一列:

sql
SELECT course_id, discounted_price
FROM (
  SELECT course_id, ROUND(price * 0.90, 2) AS discounted_price
  FROM courses
) AS discounts
WHERE discounted_price <= 40
ORDER BY course_id;

结果只有 101 / 35.10。括号里的查询产生临时的关系结果,discounts 是它在外层的名字,不是新建了一张持久表。

字符串函数遇到 NULL 怎么办 ​

第二条查询的 COALESCE(email, '未填写') 从左到右返回第一个非 NULL 的值,因此林二显示「未填写」。它只改变显示结果,没有把占位文字写进邮箱列。

sql
SELECT CONCAT('邮箱:', NULL) AS missing_label,
       CONCAT('邮箱:', COALESCE(NULL, '未填写')) AS readable_label,
       CONCAT_WS(' / ', '林二', NULL, '学员') AS joined_label,
       COALESCE('', '未填写') AS empty_text;

四列依次为 NULL、邮箱:未填写、林二 / 学员、空字符串。CONCAT 的任一参数为 NULL 时结果为 NULL;CONCAT_WS 在分隔符非 NULL 的前提下跳过后续 NULL 参数;COALESCE 不会把空字符串当作 NULL。如果业务同时把空串视作缺失,可先用 NULLIF(email, '') 转成 NULL。

数值、日期和条件表达式各有用途 ​

任务常用表达式应注意的含义
展示两位小数ROUND(price, 2)舍入是显示或业务规则,不等于浮点数会变精确;金额列仍应使用 DECIMAL
按月展示日期DATE_FORMAT(enrolled_at, '%Y-%m')返回格式化文本,可用于展示或分组
计算日期差DATEDIFF('2026-10-03', '2026-10-01')返回 2,表示日期部分之间相差两天
加一天DATE_ADD('2026-10-01', INTERVAL 1 DAY)比拼接日期字符串可靠
避免除零numerator / NULLIF(denominator, 0)分母为零时结果变为 NULL,是否再显示为零由业务决定

CASE 可以把数值映射为业务标签:

sql
SELECT course_id, price,
       CASE
         WHEN price < 50 THEN '低于50元'
         WHEN price < 70 THEN '50至不足70元'
         ELSE '70元及以上'
       END AS price_band
FROM courses ORDER BY course_id;

101、103 属于第一档,104 属于第二档,102 属于第三档。条件从上到下判断,命中后就采用该分支;没有 ELSE 且没有条件成立时返回 NULL。

筛选十月报名宜写 enrolled_at >= '2026-10-01' AND enrolled_at < '2026-11-01'。这直接对原列做范围比较,也能在以后改为带时间字段时保留正确边界。DATE_FORMAT(enrolled_at, '%Y-%m') = '2026-10' 虽容易理解,却给每行包了一层函数,通常不能直接用普通日期索引做同样的范围定位。

第 12 章:先确定统计口径 ​

sql
SELECT COUNT(*) AS learners, COUNT(email) AS known_emails FROM learners;
SELECT COUNT(*) AS registrations, COUNT(DISTINCT learner_id) AS learners,
       SUM(paid) AS revenue, AVG(paid) AS average_paid FROM enrollments;

第一条为 3 人、2 个已知邮箱。第二条为 3 条报名、2 位报名学员、收入 157.00、平均每次报名实付约 52.333333。这里的平均数是 (39 + 79 + 39) / 3,不是每位付费学员的平均消费。

COUNT 数的究竟是什么 ​

表达式本例结果统计口径
COUNT(*),查询 learners3所有学员行,邮箱为空也计数
COUNT(email),查询 learners2邮箱非 NULL 的行数,不是不同邮箱数
COUNT(*),查询 enrollments3报名次数
COUNT(DISTINCT learner_id),查询 enrollments2至少有一次报名的不同学员数
AVG(paid),查询 enrollments约 52.333333所有非 NULL 实付记录的平均数
AVG(DISTINCT paid),查询 enrollments59.00先把金额值去重,再对 39、79 求平均

最后一行通常不是想要的口径:两个不同学员恰好都支付 39 元,不应该因此只保留一笔。DISTINCT 去重的是选定值,不能代替业务去重。

每位付费学员平均消费应写成收入除以不同学员数:

sql
SELECT SUM(paid) / NULLIF(COUNT(DISTINCT learner_id), 0)
       AS revenue_per_paying_learner
FROM enrollments;

结果是 78.50。如果想算「所有注册学员的人均收入」,分母就应为 learners 中的 3 人,而非报名表中的 2 人;SQL 无法替你决定这两个业务指标哪个正确。

没有匹配行不等于结果一定是零 ​

sql
SELECT COUNT(*) AS registrations, SUM(paid) AS raw_revenue,
       COALESCE(SUM(paid), 0) AS display_revenue,
       AVG(paid) AS average_paid
FROM enrollments WHERE course_id = 104;

得到一行:0 / NULL / 0 / NULL。不带 GROUP BY 的聚合即使输入为空也会给出一行;COUNT 返回零,SUM、AVG 返回 NULL。加上 GROUP BY course_id 后,因为没有任何组,结果则是零行。报表中「无数据」和「金额为零」是否等价,要先确定再使用 COALESCE。

第 13 章:分组前过滤与分组后过滤 ​

sql
SELECT course_id, COUNT(*) AS registrations, SUM(paid) AS revenue
FROM enrollments
WHERE enrolled_at >= '2026-10-01'
GROUP BY course_id
HAVING COUNT(*) >= 2
ORDER BY revenue DESC, course_id;

预期只返回 101,报名人数 2、收入 78.00。可以把这条查询拆成六步理解;这是理解结果的逻辑顺序,不表示优化器必须按这个物理步骤执行:

  1. FROM 读报名明细,一行代表一次报名。
  2. WHERE 先筛选日期,保留三条符合条件的明细。
  3. GROUP BY course_id 把两条 101 放进一组,一条 102 放进另一组。
  4. 每组计算 COUNT(*)、SUM(paid),得到 101 / 2 / 78 和 102 / 1 / 79。
  5. HAVING COUNT(*) >= 2 去掉只有一条报名的 102 组。
  6. ORDER BY 给剩下的结果排序。

WHERE COUNT(*) >= 2 不合法,因为此时还没有组可数。反过来,虽然有些原始列条件能放在 HAVING 中表达,筛选明细的条件仍应尽量写在 WHERE,把两种责任分清楚。

为什么不能随便选一列 ​

下面是一个错误思路,请不要作为正常示例执行:

text
SELECT course_id, learner_id, SUM(paid)
FROM enrollments GROUP BY course_id;

101 这一组既有学员 1 又有学员 2,应显示哪一个 learner_id?开启默认的 ONLY_FULL_GROUP_BY 时,这种没有确定答案的选择会被拒绝。关闭模式可能让查询通过,但不会让业务含义变正确。

正确方法取决于问题:想看每门课收入,就不选择学员;想看每门课中每位学员的收入,就按两个字段分组。MySQL 也能识别部分函数依赖,例如由主键唯一确定的同表字段,但不能据此假设任意非分组列都能选。

sql
SELECT course_id, learner_id, SUM(paid) AS paid_by_learner
FROM enrollments
GROUP BY course_id, learner_id
ORDER BY course_id, learner_id;

结果三行,分组键不同意味着统计粒度不同。GROUP BY 不保证结果顺序;即使本次输出恰好有序,展示时也应明确 ORDER BY。

第 14 章:用存在性表达筛选 ​

sql
SELECT c.course_id, c.title FROM courses AS c
WHERE NOT EXISTS (
  SELECT 1 FROM enrollments AS e WHERE e.course_id = c.course_id
)
ORDER BY c.course_id;

预期返回无人报名的 103、104。相关子查询通过 c.course_id 引用外层行。可以逐门课推导:101 找到两条报名,EXISTS 为真,NOT EXISTS 为假;102 同样被排除;103、104 找不到报名,因此保留。SELECT 1 表示只检查有无记录,数字 1 不参与报名条件。

三种子查询解决不同形状的问题 ​

类型常见写法对结果形状的要求
标量子查询price > (SELECT AVG(price) ...)一列、至多一行;零行按 NULL 处理,多行会报错
集合子查询course_id IN (SELECT course_id ...)此处需要一列,可有多行
存在性子查询EXISTS (SELECT 1 ...)只判断是否存在行,不把行值当作单个数使用
sql
SELECT course_id, title, price
FROM courses
WHERE price > (SELECT AVG(price) FROM courses)
ORDER BY course_id;

SELECT course_id, title FROM courses
WHERE course_id IN (
  SELECT course_id FROM enrollments WHERE learner_id = 1
)
ORDER BY course_id;

第一条课程均价为 (39 + 79 + 49 + 59) / 4 = 56.50,因此返回 102 和 104。第二条返回学员 1 报名的 101、102。

用 = (SELECT course_id FROM enrollments WHERE learner_id = 1) 会因为返回两行而报错。不要随意加 LIMIT 1 隐藏错误:那会把「学员报名的所有课」变成「其中一门课」,业务问题已经改变。

NOT EXISTS 与 NOT IN 的选择 ​

上一单元已经解释,x NOT IN (101, NULL) 对很多 x 得到 UNKNOWN,行会被过滤。本例 enrollments.course_id 声明为 NOT NULL,因此使用 NOT IN 也能得到未报名课程;面对允许空值的子查询结果时,应明确排除 NULL,或用 NOT EXISTS 直接表达「找不到匹配行」。

相关子查询只是逻辑上依赖外层行,不能仅凭写法断言它一定逐行执行且很慢。优化器可能改写执行方式;性能比较应看执行计划和真实数据。

第 15~16 章:连接与零记录 ​

sql
SELECT l.learner_name, c.title, e.paid
FROM enrollments AS e
JOIN learners AS l ON l.learner_id = e.learner_id
JOIN courses AS c ON c.course_id = e.course_id
ORDER BY e.learner_id, e.course_id;

SELECT c.course_id, c.title, COUNT(e.learner_id) AS registrations,
       COALESCE(SUM(e.paid), 0) AS revenue
FROM courses AS c
LEFT JOIN enrollments AS e ON e.course_id = c.course_id
GROUP BY c.course_id, c.title
ORDER BY c.course_id;

内连接把关联的事实拼起来 ​

第一条返回:

learner_nametitlepaid
陈一SQL 入门39.00
陈一MySQL 事务79.00
林二SQL 入门39.00

以报名为起点,沿 learner_id 找到一位学员,沿 course_id 找到一门课。两个被连接的键都是主键,所以每条报名恰好匹配一位学员、一门课程,最终仍是三行。这里出现两个「陈一」是因为他报了两门课,不是连接出错。

如果没有关联条件而做 learners CROSS JOIN courses,三位学员与四门课会得到 12 种组合。这可用于生成「所有可能组合」,但不是已经发生的报名。发现行数膨胀时,先检查连接键和关系基数,不要立即加 DISTINCT 掩盖问题。

外连接为什么能保留零报名课程 ​

第二条以 courses 为左表。101 匹配两行,102 匹配一行,103、104 各产生一行右表字段全部为 NULL 的补位记录。聚合前一共五行,聚合后四行:

course_idregistrationsrevenue
101278.00
102179.00
10300.00
10400.00

COUNT(*) 会把补位行也数成 1;COUNT(e.learner_id) 不数 NULL,因此能得到零。选右表的非空键来计数,比选择本来就可为空的业务字段可靠,否则真实记录也可能被漏掉。

ON 和 WHERE 放错位置会改变结果 ​

只统计 10 月 2 日及之后的报名,同时保留所有课程:

sql
SELECT c.course_id, COUNT(e.learner_id) AS registrations
FROM courses AS c
LEFT JOIN enrollments AS e
  ON e.course_id = c.course_id
 AND e.enrolled_at >= '2026-10-02'
GROUP BY c.course_id
ORDER BY c.course_id;

结果为 101→1、102→1、103→0、104→0。日期在 ON 中定义「哪些报名允许匹配」;无法匹配的左表课程仍会留下。如果把该日期条件搬到 WHERE,103、104 的日期是 NULL,比较结果为 UNKNOWN,补位行会被删掉。这里的外连接便失去了保留零报名的作用。

若只想显示「数据库」课程,则 WHERE c.category = '数据库' 是合理的:它明确筛选需要输出的左表课程,与右表报名是否存在无关。

自连接是给同一张表分配两个角色 ​

找出同类别的课程组合,每一对只出现一次:

sql
SELECT a.course_id AS first_course, b.course_id AS second_course
FROM courses AS a
JOIN courses AS b
  ON a.category = b.category AND a.course_id < b.course_id
ORDER BY first_course, second_course;

得到 (101,102)、(101,104)、(102,104)。a、b 指同一张表的两个逻辑角色。< 同时排除了课程与自身配对,以及 (102,101) 这样的镜像重复。

显式 ON 更方便审查业务关系。NATURAL JOIN 会自动使用两表所有同名列作为连接条件;将来增加同名字段可能悄悄改变结果,不适合用来省去对连接条件的思考。

多个一对多关系怎样避免重复汇总 ​

假设以后又有课程评论表,一门课两条报名、三条评论,直接同时连接两张明细表会产生六行,报名金额可能被重复三次。解决办法通常是先把各自明细汇总成「每课一行」,再连接课程表。当前数据可先练习一侧预聚合:

sql
SELECT c.course_id, COALESCE(r.registrations, 0) AS registrations,
       COALESCE(r.revenue, 0) AS revenue
FROM courses AS c
LEFT JOIN (
  SELECT course_id, COUNT(*) AS registrations, SUM(paid) AS revenue
  FROM enrollments GROUP BY course_id
) AS r ON r.course_id = c.course_id
ORDER BY c.course_id;

得到相同的四行统计。关键不是「子查询总比连接好」,而是进入下一次连接前,先把每份数据的粒度固定下来。

第 17 章:组合兼容的结果集 ​

sql
SELECT course_id FROM courses WHERE category = '数据库'
UNION
SELECT course_id FROM courses WHERE price < 50
ORDER BY course_id;

第一个分支得到 101、102、104,第二个得到 101、103;UNION 去掉重复编号,最终得到 101~104。改成 UNION ALL 后,101 会出现两次,总共五行。

连接通常横向补充列,UNION 则把兼容结果纵向叠放。两个分支需要相同列数,并按列的位置组合;列名相同不代表会自动对齐列。如果第一列一个分支是编号、另一个分支是名称,即使 MySQL 能转换类型,业务含义也很可能错了。

sql
SELECT course_id, '数据库分类' AS reason
FROM courses WHERE category = '数据库'
UNION
SELECT course_id, '价格低于50' AS reason
FROM courses WHERE price < 50
ORDER BY course_id, reason;

这次即使用 UNION,101 仍会出现两行,因为 reason 不同,完整结果行不重复。去重范围是所有输出列,不能把它理解成「按第一列去重」。结果列名由第一个查询决定;末尾的 ORDER BY 排整个结果,并不保证先显示第一分支的行。

不需要去重时,UNION ALL 能清楚表达保留全部记录的意图,也避免不必要的去重工作。但不能为了速度把确实需要去重的业务改成 ALL。

第 18 章:全文检索有专门的索引 ​

在练习库中创建一次全文索引;重复执行创建语句会报索引已存在:

sql
ALTER TABLE courses ADD FULLTEXT INDEX ft_summary (summary);
SELECT course_id, title FROM courses
WHERE MATCH(summary) AGAINST('+queries' IN BOOLEAN MODE)
ORDER BY course_id;

默认全文检索配置下命中 101。MATCH(summary) 指定参与检索的列,AGAINST 提供检索词与模式;这里的列集合与全文索引相匹配。布尔模式中 +queries 表示必须包含这个词。

sql
SELECT course_id, title,
       MATCH(summary) AGAINST('queries' IN NATURAL LANGUAGE MODE) AS score
FROM courses
WHERE MATCH(summary) AGAINST('queries' IN NATURAL LANGUAGE MODE)
ORDER BY score DESC, course_id;

SELECT course_id, title FROM courses
WHERE MATCH(summary) AGAINST('+sql -reports' IN BOOLEAN MODE)
ORDER BY course_id;

两条在本练习数据下均命中 101。第二条要求包含 sql,同时排除包含 reports 的 104。自然语言模式计算相关度,具体分值依赖当前文档集合,不应硬编码成稳定业务值;布尔模式允许表达必须包含、排除等条件,也不能假设输出自动按相关度排序。

检索方式主要问题注意点
LIKE '%queries%'文本中是否有这段字符不理解词语,前导通配符通常不利于普通 B-tree 索引定位
自然语言全文检索哪些文档与这些词相关使用词项与相关度,结果依赖分词及索引配置
布尔全文检索哪些文档满足词项条件+、- 等运算符有专门语义
查询扩展根据初次结果补充相关词再次检索可能增加召回,也可能带入不想要的语义

全文检索受最小词长、停用词和解析器影响。用很短的词或停用词测试却没有结果,不必立即认定 SQL 有误。这里特意使用英文简介;中文连续文本需要评估官方 ngram 解析器,不能把英文分词效果直接套过去。

完成后清理本单元增加的索引,便于从头重复实验:

sql
ALTER TABLE courses DROP INDEX ft_summary;

练习与核对 ​

练习一:包括没有报名的人 ​

输出所有学员及报名课程数,结果应为 陈一→2、林二→1、周三→0。再改成只统计课程 101,仍保留三位学员,结果应为 1、1、0。

参考答案与推导
sql
SELECT l.learner_id, l.learner_name, COUNT(e.course_id) AS registrations
FROM learners AS l
LEFT JOIN enrollments AS e ON e.learner_id = l.learner_id
GROUP BY l.learner_id, l.learner_name
ORDER BY l.learner_id;

SELECT l.learner_id, l.learner_name, COUNT(e.course_id) AS registrations
FROM learners AS l
LEFT JOIN enrollments AS e
  ON e.learner_id = l.learner_id AND e.course_id = 101
GROUP BY l.learner_id, l.learner_name
ORDER BY l.learner_id;

以学员为保留侧,数报名表的非空键;限制报名匹配范围的条件放在 ON。WHERE e.course_id = 101 会把周三过滤掉。

练习二:找出累计消费超过 100 元的学员 ​

结果应只有陈一,总消费 118.00。这是筛选单条明细,还是筛选已经汇总后的组?

参考答案与推导
sql
SELECT l.learner_id, l.learner_name, SUM(e.paid) AS total_paid
FROM learners AS l
JOIN enrollments AS e ON e.learner_id = l.learner_id
GROUP BY l.learner_id, l.learner_name
HAVING SUM(e.paid) > 100
ORDER BY total_paid DESC, l.learner_id;

必须在 HAVING 中判断累计金额。若写 WHERE e.paid > 100,三条报名都会被排除,因为没有单笔超过 100 元。

练习三:课程收入是不是课程标价之和 ​

101 的当前价格为 39 元,报名两次;102 的当前价格为 79 元,报名一次。本数据中 SUM(c.price) 与 SUM(e.paid) 恰巧相等。这个等式能长期成立吗?

答案是不能。课程调价、优惠、历史订单都可能使当前标价与实际收款不同。收入应由报名记录中的 paid 计算;当前课程价格描述的是现在的商品,不是过去支付的事实。

练习四:解释一行 NULL 与零行的区别 ​

查询没有人报名的 104,单独 SUM(paid) 得到一行 NULL,按 course_id 分组则没有组、返回零行。若产品要求所有课程都显示零值,应从 courses 出发外连接,再对聚合结果做 COALESCE,不能只给报名表加一个 GROUP BY 就期望自动出现缺失课程。

官方 GROUP BY 规则 · 官方全文检索

返回导读 · 下一单元:写入与表结构

继续阅读