【计算基础|MySQL03】【上手】多表查询与聚合
单表查询练熟后,真实业务马上就撞上一个问题:订单表里只有 user_id,你要显示用户名怎么办?一个用户下了几单、总共花了多少钱怎么算?这些都不是单表 SELECT 能搞定的,得靠多表连接(JOIN)和分组聚合(GROUP BY + 聚合函数)。
这篇接着用 shop 库(users / products / orders),把 JOIN、子查询、聚合、GROUP BY/HAVING、UNION 全部跑一遍。每个查询都给真实输出。学完你能写"每个用户的订单总额""从未下单的用户""销量 TOP3"这类实际需求。
环境:Docker MySQL 8.0.46,库
shop,6 个用户、6 个商品、8 条订单。
一、为什么要 JOIN:数据拆到不同表
一句话:为了避免冗余,数据按主题拆表 。用户信息放 users、商品放 products、订单放 orders,订单里只存 user_id/product_id 这种外键引用,不把用户名、商品名重复抄一遍。查的时候再用 JOIN 把它们"拼"回来。
1.1 笛卡尔积:没写连接条件的灾难
两表直接 FROM users, orders 不写条件,会把两表每行两两组合:
sql
SELECT COUNT(*) AS cartesian_rows FROM users u, orders o;
text
cartesian_rows
48
6 个用户 × 8 条订单 = 48 行。这就是笛卡尔积 ,结果行数是两表行数相乘。⚠️ 生产里 JOIN 忘了写 ON 条件,大表一笛卡尔就是几百万行,直接把库跑挂。JOIN 必须写 ON。
二、INNER JOIN 与 LEFT JOIN
2.1 两种连接的区别
| 连接 | 结果 |
|---|---|
INNER JOIN |
只返回两表匹配上的行 |
LEFT JOIN |
左表全保留,右表匹配不上补 NULL |
2.2 INNER JOIN:订单拼用户名
sql
SELECT o.id AS order_id, u.username, o.amount, o.status
FROM orders o INNER JOIN users u ON o.user_id = u.id;
text
order_id username amount status
1 alice 399.00 1
2 alice 199.00 1
3 alice 1299.00 2
4 bob 1299.00 0
5 bob 149.00 1
6 carol 399.00 3
7 carol 1198.00 1
8 erin 99.50 0
8 条订单全出来了。注意 david、frank 没出现------他们没订单,INNER JOIN 只留匹配的。
2.3 LEFT JOIN:连"没下单的用户"也要
sql
SELECT u.username, o.id AS order_id, o.amount
FROM users u LEFT JOIN orders o ON u.id = o.user_id;
text
username order_id amount
alice 1 399.00
alice 2 199.00
alice 3 1299.00
bob 4 1299.00
bob 5 149.00
carol 6 399.00
carol 7 1198.00
david NULL NULL
erin 8 99.50
frank NULL NULL
左表 users 6 个人全在,david、frank 没有订单,右表字段补 NULL。这就是"以左表为准"。
🔴 语法要点:
ON写连接条件(两表怎么对应),WHERE写连接后的过滤。- 推荐显式写
JOIN ... ON,别用FROM a, b WHERE a.id=b.id这种隐式写法------读起来分不清连接条件和过滤条件。 - 两表连接列同名时可以用
USING(user_id)简写。
2.4 经典坑:LEFT JOIN 里把右表条件写进 WHERE
sql
-- ❌ 右表过滤写在 WHERE,结果退化成 INNER JOIN
SELECT u.username,o.amount FROM users u LEFT JOIN orders o ON u.id=o.user_id WHERE o.status=1;
text
username amount
alice 399.00
alice 199.00
bob 149.00
carol 1198.00
david、frank 没了,连 erin(她唯一的订单 status=0)也被过滤掉了。为什么?WHERE o.status=1 会把右表为 NULL 的行(没订单的用户)整行过滤掉,LEFT JOIN 白写了。
sql
-- ✅ 右表的过滤条件放 ON
SELECT u.username,o.amount FROM users u LEFT JOIN orders o ON u.id=o.user_id AND o.status=1;
text
username amount
alice 399.00
alice 199.00
bob 149.00
carol 1198.00
david NULL
erin NULL
frank NULL
✅ 这次左表 6 个人全保留,只是把 status≠1 的订单行显示成 NULL。想保留左表全部、只过滤右表的匹配行,条件放 ON;想连接完再整体筛选,才放 WHERE。
三、子查询:查询套查询
3.1 WHERE 里的子查询
sql
-- 比平均价格高的商品(标量子查询)
SELECT name,price FROM products WHERE price > (SELECT AVG(price) FROM products);
text
name price
4K显示器 1299.00
耳机 599.00
sql
-- IN 子查询:有下单记录的用户
SELECT username,city FROM users WHERE id IN (SELECT DISTINCT user_id FROM orders);
text
username city
alice 北京
bob 上海
carol 北京
erin 上海
3.2 EXISTS:只判断"有没有"
sql
SELECT username FROM users u WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
text
username
alice
bob
carol
erin
⚠️ IN vs EXISTS:
IN是把子查询结果拿出来做匹配;EXISTS只判断"子查询能不能找到一行",找到就停,不返回具体数据。子查询表大、外表小时,EXISTS 往往更快;反之 IN 可能更优。实际两表都走索引时差别不大。
四、聚合函数:COUNT / SUM / AVG / MAX / MIN
| 函数 | 作用 |
|---|---|
COUNT(*) |
统计行数,含 NULL |
COUNT(列) |
统计该列非 NULL 的行数 |
COUNT(DISTINCT 列) |
去重后计数 |
SUM(列) |
求和 |
AVG(列) |
平均值(忽略 NULL) |
MAX/MIN |
最大/最小 |
sql
SELECT COUNT(*) AS all_rows, COUNT(city) AS non_null_city FROM users;
text
all_rows non_null_city
6 5
🔴 表里 6 行,但 frank 的 city 是 NULL,所以 COUNT(city) 是 5,COUNT(*) 是 6。聚合函数自动忽略 NULL 行 ------AVG(age) 不会把 NULL 年龄算进去,这点要心里有数。
五、GROUP BY 分组与 HAVING 过滤
5.1 GROUP BY:按列分组后聚合
sql
-- 每个品类的商品数和均价
SELECT category, COUNT(*) AS cnt, ROUND(AVG(price),2) AS avg_price
FROM products GROUP BY category;
text
category cnt avg_price
外设 2 249.25
数码 2 949.00
配件 2 204.00
5.2 经典需求:每个用户的订单数和总金额
sql
SELECT u.username,
COUNT(o.id) AS order_cnt,
COALESCE(SUM(o.amount),0) AS total
FROM users u LEFT JOIN orders o ON u.id=o.user_id
GROUP BY u.id, u.username;
text
username order_cnt total
alice 3 1897.00
bob 2 1448.00
carol 2 1597.00
david 0 0.00
erin 1 99.50
frank 0 0.00
🔴 两个细节:
- 用
LEFT JOIN才能把没下单的 david、frank 也算出来(订单数 0);用 INNER JOIN 他们俩就消失了。 COALESCE(SUM(o.amount),0)把 NULL 转成 0,不然没订单的人 total 显示 NULL。
5.3 HAVING:分组后再过滤
sql
-- 下单数 >= 2 的用户
SELECT u.username, COUNT(o.id) AS cnt
FROM users u JOIN orders o ON u.id=o.user_id
GROUP BY u.id
HAVING cnt >= 2;
text
username cnt
alice 3
bob 2
carol 2
⚠️ WHERE vs HAVING:
| WHERE | HAVING | |
|---|---|---|
| 时机 | 分组前过滤行 | 分组后过滤组 |
| 能用聚合函数吗 | ❌ 不能 | ✅ 能 |
🔴 MySQL 默认开 ONLY_FULL_GROUP_BY :SELECT 里出现的非聚合列,必须都出现在 GROUP BY 里 ,否则报错。别再写 SELECT username, city, COUNT(*) FROM users GROUP BY username 这种 city 没分组的语句了。
🔴 性能原则:能在 WHERE 里过滤的条件就别放 HAVING------先把行过滤掉再分组,比分完组再淘汰组快。
六、两个经典综合查询
6.1 从未下单的用户
sql
SELECT u.username
FROM users u LEFT JOIN orders o ON u.id=o.user_id
WHERE o.id IS NULL;
text
username
david
frank
套路就是:LEFT JOIN 后找右表为 NULL 的行------这些就是"左表里有、右表里没有"的。
6.2 销量 TOP3 商品(按订单数量)
sql
SELECT p.name, SUM(o.quantity) AS sold
FROM products p JOIN orders o ON p.id=o.product_id
GROUP BY p.id, p.name
ORDER BY sold DESC LIMIT 3;
text
name sold
机械键盘 2
无线鼠标 3
4K显示器 2
(无线鼠标被 alice 和 erin 各买过,quantity 合计 3;机械键盘两单各 1 合计 2。)
七、UNION 与 UNION ALL:合并结果集
sql
-- 前两个用户 + 前两个商品,合并成一个名单
SELECT username AS name,'user' AS type FROM users WHERE id<=2
UNION ALL
SELECT name,'product' FROM products WHERE id<=2;
text
name type
alice user
bob user
机械键盘 product
无线鼠标 product
| UNION | UNION ALL | |
|---|---|---|
| 去重 | 去重(要排序去重,慢) | 不去重,快 |
| 要求 | 两边列数、类型必须一致 | 同左 |
⚠️ 不确定要去重就用 UNION ALL,性能好很多;ORDER BY 写在最后一个 SELECT 后面,对整个结果排序。
总结:你真正需要记住的 8 件事
- JOIN 不写 ON 就是笛卡尔积,行数=两表行数相乘,生产事故。
INNER JOIN只留匹配行;LEFT JOIN左表全保留、右表补 NULL。- LEFT JOIN 里右表过滤条件放 ON 才保留左表全部,放 WHERE 会退化成内连接。
- 子查询:标量子查询放 WHERE、
IN查集合、EXISTS只判断存在性(大表可能更快)。 COUNT(*)含 NULL、COUNT(列)不含 NULL;聚合函数都忽略 NULL。GROUP BY分组后聚合;HAVING分组后过滤且能用聚合函数;非聚合列必须出现在 GROUP BY。- 经典套路:
LEFT JOIN ... WHERE 右表.id IS NULL找"从未匹配"的记录。 UNION ALL不去重更快,列数和类型两边必须一致。
验证清单
- 能说清 INNER JOIN 和 LEFT JOIN 的区别,并解释笛卡尔积怎么产生
- 能复现"右表条件写 WHERE 导致 LEFT JOIN 退化",并改对到 ON
- 能写出"每个用户的订单数和总金额",并用 COALESCE 把 NULL 转 0
- 能用
LEFT JOIN ... WHERE o.id IS NULL查出从未下单的用户 - 能解释
COUNT(*)和COUNT(列)为什么结果不同 - 能说清 WHERE 和 HAVING 的执行时机区别
- 能写出销量 TOP3 的查询
参考资源
- MySQL 8.0 JOIN 语法:https://dev.mysql.com/doc/refman/8.0/en/join.html
- 聚合函数:https://dev.mysql.com/doc/refman/8.0/en/aggregate-functions.html
- GROUP BY 与 HAVING:https://dev.mysql.com/doc/refman/8.0/en/group-by-handling.html
- 子查询:https://dev.mysql.com/doc/refman/8.0/en/subqueries.html
下一篇《【上手】函数、视图与表设计》讲:字符串/日期/数值/条件函数速查、MySQL 8 的窗口函数(ROW_NUMBER、每组 TOP N)与递归 CTE、视图的作用与限制,以及三大范式和从零设计一套博客系统表。这是上手篇的收官。