MySQL 系列:第8篇 子查询与集合操作

IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章,助你少走弯路。


前面我们学会了 JOIN 让多张表横向关联,但 SQL 还有一种"嵌套查询"的玩法------在一个查询内部嵌入另一个查询。这就是子查询 。再加上能把多个查询结果纵向合并的 UNION,你的 SQL 武器库就更加完整了。今天用 Python 配合电商场景,把这些知识一次性讲透。

1. 准备数据:用户、商品、订单三张表

沿用第 7 篇的三表结构,稍微调整数据以便演示。

bash 复制代码
import mysql.connector

conn = mysql.connector.connect(
    host="127.0.0.1", port=3306,
    user="root", password="MyNewPass123!",
    database="shop"
)
cursor = conn.cursor()

# 确保表存在(如已存在则保留)
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    city VARCHAR(20),
    vip_level TINYINT DEFAULT 0
) ENGINE=InnoDB
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS products (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    category VARCHAR(30)
) ENGINE=InnoDB
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS orders (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    product_id INT NOT NULL,
    quantity INT NOT NULL,
    order_date DATE NOT NULL
) ENGINE=InnoDB
""")

# 清空旧数据,重新灌入
cursor.execute("TRUNCATE users")
cursor.execute("TRUNCATE products")
cursor.execute("TRUNCATE orders")

users = [
    (1, "张三", "北京", 2),
    (2, "李四", "上海", 1),
    (3, "王五", "深圳", 0),
    (4, "赵六", "杭州", 1),
    (5, "孙七", "北京", 0),
]
cursor.executemany("INSERT INTO users (id,name,city,vip_level) VALUES (%s,%s,%s,%s)", users)

products = [
    (1, "机械键盘", 399.00, "电脑外设"),
    (2, "蓝牙耳机", 259.00, "音频设备"),
    (3, "显示器", 1899.00, "电脑外设"),
    (4, "鼠标", 99.00, "电脑外设"),
    (5, "数据线", 29.90, "配件"),
]
cursor.executemany("INSERT INTO products (id,title,price,category) VALUES (%s,%s,%s,%s)", products)

orders = [
    (1, 1, 1, 2, "2025-07-01"),
    (2, 2, 2, 1, "2025-07-05"),
    (3, 3, 1, 1, "2025-07-10"),
    (4, 1, 3, 1, "2025-07-12"),
    (5, 2, 1, 1, "2025-07-15"),
]
cursor.executemany("INSERT INTO orders (id,user_id,product_id,quantity,order_date) VALUES (%s,%s,%s,%s,%s)", orders)
conn.commit()
print("✅ 测试数据准备完毕(5 位用户,5 件商品,5 笔订单)")

2. 子查询的分类

子查询按其返回结果的形式,可分为三种:

2.1 标量子查询

返回单个值的子查询,可以当作一个常量来用。比如:查询单价高于平均价格的商品。

bash 复制代码
cursor.execute("""
SELECT title, price
FROM products
WHERE price > (SELECT AVG(price) FROM products)
ORDER BY price DESC
""")
print("📊 高于平均价的商品:")
for row in cursor.fetchall():
    print(f"  {row[0]} - ¥{row[1]}")

预期输出

bash 复制代码
📊 高于平均价的商品:
  显示器 - ¥1899.00
  机械键盘 - ¥399.00
  蓝牙耳机 - ¥259.00

解读:子查询 (SELECT AVG(price) FROM products) 返回了 ≈ 537.18,外层查询就变成了 WHERE price > 537.18

在 SELECT 列表中使用标量子查询

bash 复制代码
# 每件商品旁边显示它与平均价的差额
cursor.execute("""
SELECT title, price,
       price - (SELECT AVG(price) FROM products) AS diff_from_avg
FROM products
ORDER BY diff_from_avg DESC
""")
print("\n📊 与平均价的差额:")
for row in cursor.fetchall():
    print(f"  {row[0]:<10} ¥{row[1]:<8} 差额: {row[2]:.2f}")

注意事项 :标量子查询必须确保只返回一行一列,如果返回多行会报错 Subquery returns more than 1 row

2.2 行子查询

行子查询返回一行多列,通常与行构造器 (col1, col2) = (subquery) 配合使用。比如查找与"张三"同城市且同 VIP 等级的用户。

bash 复制代码
cursor.execute("""
SELECT name, city, vip_level
FROM users
WHERE (city, vip_level) = (
    SELECT city, vip_level FROM users WHERE name = '张三'
)
AND name != '张三'
""")
print("\n👥 与张三同城同级别的用户:")
for row in cursor.fetchall():
    print(f"  {row[0]} - {row[1]} VIP{row[2]}")

预期输出(数据中没有同城同级的其他人,可能为空,我们可以插入一条验证):

bash 复制代码
👥 与张三同城同级别的用户:
  (如有匹配则显示)

你可以试着在数据中加一条 ('李四','北京',2) 来观察结果。

2.3 表子查询(派生表)

表子查询放在 FROM 子句中,相当于临时表,必须起别名。我们用它先算出每个用户的总消费,再从派生表中过滤出高价值客户。

bash 复制代码
cursor.execute("""
SELECT name, total_spent
FROM (
    SELECT u.name, SUM(p.price * o.quantity) AS total_spent
    FROM users u
    INNER JOIN orders o ON u.id = o.user_id
    INNER JOIN products p ON o.product_id = p.id
    GROUP BY u.id, u.name
) AS user_stats
WHERE total_spent > 500
ORDER BY total_spent DESC
""")
print("\n💰 总消费超过 500 的客户:")
for row in cursor.fetchall():
    print(f"  {row[0]} - ¥{row[1]}")

预期输出

bash 复制代码
💰 总消费超过 500 的客户:
  张三 - ¥2697.00
  李四 - ¥658.00

3. EXISTS vs IN:关联子查询的选择

3.1 IN:判断值是否在子查询结果集中

IN 后面接一个返回值列表的子查询(通常是某列),用于检查某个值是否存在于列表中。

bash 复制代码
# 查询购买过"电脑外设"类商品的用户
cursor.execute("""
SELECT name
FROM users
WHERE id IN (
    SELECT DISTINCT user_id
    FROM orders
    WHERE product_id IN (
        SELECT id FROM products WHERE category = '电脑外设'
    )
)
""")
print("\n🖥️ 购买过电脑外设的用户:")
for row in cursor.fetchall():
    print(f"  {row[0]}")

预期输出

IN 的局限 :当子查询返回大量数据时,性能可能较差(具体取决于 MySQL 优化器是否将 IN 转为 EXISTS)。另外,NULL 值可能导致预期之外的逻辑。

3.2 EXISTS:检查子查询是否有结果

EXISTS 返回布尔值,只要子查询返回至少一行即为 TRUE,通常比 IN 更高效,尤其是在关联子查询中。

bash 复制代码
# 相同需求用 EXISTS 改写
cursor.execute("""
SELECT name
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    INNER JOIN products p ON o.product_id = p.id
    WHERE o.user_id = u.id AND p.category = '电脑外设'
)
""")
print("\n🖥️ 用 EXISTS 重写的结果:")
for row in cursor.fetchall():
    print(f"  {row[0]}")

EXISTS 的优势

  • 遇到匹配行立刻返回 TRUE,无需扫描完整个子查询

  • 子查询的 SELECT 列表可以随便写(习惯写 SELECT 1),MySQL 不关心列内容

  • 关联子查询时表现优异

IN vs EXISTS 选型指南

NULL 陷阱演示

bash 复制代码
# 假设有一个包含 NULL 的集合
cursor.execute("SELECT 1 IN (NULL, 2, 3)")   # 结果为 NULL,不是 0
print("1 IN (NULL,2,3) ->", cursor.fetchone()[0])

cursor.execute("SELECT 1 IN (2, 3)")         # 结果为 0
print("1 IN (2,3) ->", cursor.fetchone()[0])

如果子查询可能返回 NULL,外层 NOT IN 可能导致整个结果为空,此时用 NOT EXISTS 更安全。

4. 集合操作:UNION 与 UNION ALL

UNION 将多个 SELECT 的结果纵向合并(行数相加)。要求各查询的列数相同,对应列类型兼容。

4.1 UNION:去重合并

bash 复制代码
# 查询所有"北京用户"和"VIP 用户"的姓名(去重)
cursor.execute("""
SELECT name FROM users WHERE city = '北京'
UNION
SELECT name FROM users WHERE vip_level >= 1
ORDER BY name
""")
print("\n🔗 UNION(去重):")
for row in cursor.fetchall():
    print(f"  {row[0]}")

预期输出

bash 复制代码
🔗 UNION(去重):
  张三
  孙七
  李四
  赵六

注意:"张三"既是北京人又是 VIP,但只出现一次。

4.2 UNION ALL:保留所有重复行

bash 复制代码
cursor.execute("""
SELECT name FROM users WHERE city = '北京'
UNION ALL
SELECT name FROM users WHERE vip_level >= 1
""")
print("\n🔗 UNION ALL(保留重复):")
for row in cursor.fetchall():
    print(f"  {row[0]}")

预期输出

bash 复制代码
🔗 UNION ALL(保留重复):
  张三
  孙七
  张三
  李四
  赵六

性能差异UNION 需要额外的排序去重操作,所以 UNION ALL 更快。如果你明确不需要去重,应始终使用 UNION ALL

4.3 实战:合并不同来源的销售数据

假设我们还有一张线下订单表 offline_orders

bash 复制代码
cursor.execute("""
CREATE TABLE IF NOT EXISTS offline_orders (
    id INT PRIMARY KEY AUTO_INCREMENT,
    customer VARCHAR(50),
    product VARCHAR(100),
    amount DECIMAL(10,2)
)
""")
cursor.execute("TRUNCATE offline_orders")
cursor.executemany(
    "INSERT INTO offline_orders (customer, product, amount) VALUES (%s,%s,%s)",
    [("张三", "鼠标垫", 19.90),
     ("李四", "键盘手托", 89.00)]
)
conn.commit()

# 合并线上 + 线下所有销售记录
cursor.execute("""
SELECT user_id AS customer_id, product_id, quantity, order_date AS sale_date, 'online' AS channel
FROM orders
UNION ALL
SELECT 0, id, 1, CURDATE(), 'offline'
FROM offline_orders
""")
print("\n📊 全渠道销售记录:")
for row in cursor.fetchall():
    print(row)

通过添加一个固定列 channel,可以区分记录来源,这正是数据仓库中经常用到的模式。

5. 子查询与 JOIN 的选择

很多子查询可以用 JOIN 改写,通常 JOIN 在优化器下有更多优化空间。但子查询在表达"先聚合再过滤"时更自然。比如:

bash 复制代码
-- 子查询:找出总消费 > 500 的用户
SELECT name FROM users
WHERE id IN (SELECT user_id FROM orders GROUP BY user_id HAVING SUM(...) > 500)

-- 等价 JOIN
SELECT DISTINCT u.name
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN products p ON o.product_id = p.id
GROUP BY u.id, u.name
HAVING SUM(p.price * o.quantity) > 500

没有绝对好坏,要根据可读性和执行计划来判断。建议:先用子查询写出清晰意图,再用 EXPLAIN 验证性能,必要时改为 JOIN。

6. 动手试试:综合练习

基于当前三表数据,完成以下查询:

  1. 找出哪些商品从未被购买过(用子查询,提示:NOT IN 或 NOT EXISTS)。

  2. 查询购买过最贵商品(单价最高)的用户姓名(用标量子查询)。

  3. 将"北京"用户和"深圳"用户的名字用 UNION 合并,且去重

  4. 用 EXISTS 找出至少下过一单的用户,列出名字和订单数量

提示

  • 第 1 题注意 NULL 陷阱,推荐用 NOT EXISTS

  • 第 4 题外层 SELECT 接子查询或直接 JOIN 均可,但练习时请用 EXISTS 实现"是否存在订单"的判断。

参考思路(第 1 题):

bash 复制代码
cursor.execute("""
SELECT title FROM products p
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.product_id = p.id
)
""")
print("从未被购买的商品:")
for row in cursor.fetchall():
    print(f"  {row[0]}")

7. 总结

今天我们掌握了子查询的三种形态和集合操作:

  • 标量子查询:返回单个值,用在 SELECT 或 WHERE 中。

  • 行子查询:返回一行多列,配合行构造器。

  • 表子查询:作为派生表,必须起别名。

  • IN vs EXISTS:IN 用于匹配列表,EXISTS 检测是否存在行,处理 NULL 更安全。

  • UNION / UNION ALL:纵向合并结果集,能去重则用 UNION,否则用 UNION ALL 提速。

子查询让 SQL 表达力更强,但也容易写出"意大利面条"式的嵌套。记住:清晰度优先,性能用 EXPLAIN 验证 。下一篇我们将学习视图,给复杂查询穿上一件简洁的外衣。

想了解更多还可以去各个平台搜索「IT策士」,一起升级 IT 思维 !

相关推荐
XovH1 小时前
MySQL 系列:第10篇 存储过程与自定义函数
后端
XovH1 小时前
MySQL 系列:第9篇 视图——定制化数据窗口
后端
vortex51 小时前
新手前后端开发学习指南:从Flask框架到全栈实践
后端·python·flask
leeyi2 小时前
Retriever 组件:让 Agent 学会「翻资料」的统一接口
人工智能·后端·agent
一个做软件开发的牛马2 小时前
MyBatis 从零实战:完整搭建可运行 Demo,注解与 XML 双模式开发详解
java·后端
砍材农夫2 小时前
python环境|conda安装和使用(1)
开发语言·后端·python·conda
用户298698530142 小时前
Java 实践:查找与提取 Word 文档超链接
java·后端
Rust研习社2 小时前
Rust 错误处理的黄金搭档:一个定义错误,一个传播错误
后端·rust·编程语言
Moment2 小时前
从多人编辑到 Agent 写文档,Hocuspocus v4 正在改写协同系统 😍😍😍
前端·后端·面试