【计算基础|MySQL03】【上手】多表查询与聚合

【计算基础|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 件事

  1. JOIN 不写 ON 就是笛卡尔积,行数=两表行数相乘,生产事故。
  2. INNER JOIN 只留匹配行;LEFT JOIN 左表全保留、右表补 NULL。
  3. LEFT JOIN 里右表过滤条件放 ON 才保留左表全部,放 WHERE 会退化成内连接。
  4. 子查询:标量子查询放 WHERE、IN 查集合、EXISTS 只判断存在性(大表可能更快)。
  5. COUNT(*) 含 NULL、COUNT(列) 不含 NULL;聚合函数都忽略 NULL。
  6. GROUP BY 分组后聚合;HAVING 分组后过滤且能用聚合函数;非聚合列必须出现在 GROUP BY。
  7. 经典套路:LEFT JOIN ... WHERE 右表.id IS NULL 找"从未匹配"的记录。
  8. 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 的窗口函数(ROW_NUMBER、每组 TOP N)与递归 CTE、视图的作用与限制,以及三大范式和从零设计一套博客系统表。这是上手篇的收官。

相关推荐
imDwAaY1 小时前
SQL 查询突然变慢了?一篇讲清慢 SQL 排查思路
后端·mysql
小羊没烦恼!1 小时前
T-SQL查询高级—SQL Server索引中的碎片和填充因子
开发语言·前端·javascript·数据库·后端·python·web安全
lv__pf1 小时前
redis【TL redis(6)】
数据库·redis·缓存
冰暮流星1 小时前
mysql索引失效的情形
数据库·笔记·sql·mysql
IvorySQL1 小时前
IvorySQL 校园行走进山东大学:从 PostgreSQL 到 AI,连接技术实践与职业成长
数据库·人工智能·postgresql
广州智造1 小时前
Radioss 材料本构的二次开发
运维·数据库·自动化·建模·后处理·前处理
闲云自留地2 小时前
从零造一台 Ubuntu 24.04:虚拟机、装系统、配 IP 到 root 远程登录一篇通
数据库·tcp/ip·ubuntu
Allen-Steven2 小时前
SQL查询专项:从完整书写到综合判断
运维·服务器·数据库
Navicat中国2 小时前
SQL 编辑器生产力:重构与实时错误检测
数据库·sql·重构·编辑器·navicat