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. 动手试试:综合练习
基于当前三表数据,完成以下查询:
-
找出哪些商品从未被购买过(用子查询,提示:NOT IN 或 NOT EXISTS)。
-
查询购买过最贵商品(单价最高)的用户姓名(用标量子查询)。
-
将"北京"用户和"深圳"用户的名字用 UNION 合并,且去重。
-
用 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 思维 !