上一章我们一直在一张表里做筛选:用 WHERE 留下需要的行,用 AND、OR 和括号表达条件,再小心处理 NULL。可真实业务很少把所有信息塞进一张表。小满商店的一笔订单只保存客户编号,客户姓名放在 customers;订单里买了什么放在 order_items;商品名称和当前标价又放在 products。如果我们要回答“苏小满买过哪些商品”,只会过滤还不够,还得把分散的行重新拼起来。
这件事就是连接。连接不神秘,它做的事情可以压缩成一句话:拿左边的一行,按照连接条件去右边找匹配行,再把匹配行的列并排放到同一行中。 真正容易出错的地方,不是记住 JOIN 这个单词,而是弄清楚“为什么这两行应该相遇”“一次会相遇几行”以及“结果变多或变少是不是业务本意”。
本章只集中讲最常用的 INNER JOIN,同时把三表以上连接、派生表、自连接、非等值连接和重复行排查串起来。读完后,你应该能先画出关系路径,再写出连接条件,并能解释结果集中的每一行从哪里来。
我们先沿用全课统一的小满商店数据。下面只列出本章会频繁使用的列;其余列仍然存在,只是当前查询暂时用不到。
你可以把这些表分成两类。customers、products、categories、employees 和 departments 描述相对稳定的对象;orders、order_items、payments、inventory_movements 记录发生过的业务动作。连接通常就是沿着编号,把“动作”与“是谁、是什么、属于哪里”拼在一起。

customers.customer_id 是客户表的主键。主键值在表内不能重复,所以 customer_id = 1 最多定位一位客户。类似地,orders.order_id 唯一确定一张订单,products.product_id 唯一确定一件商品。
主键的唯一性会直接影响连接后的行数。订单表中的一行拿着 customer_id = 1 去客户表查找时,最多找到一行。这种“很多订单指向一位客户”的关系,连接后通常不会把每张订单复制成多行。
orders.customer_id 保存下单客户的编号,它指向 customers.customer_id。前者是关系的起点,后者是被查找的目标。写成连接条件就是:
ON o.customer_id = c.customer_id这里的 o 和 c 是表别名,稍后会详细讲。先关注等号两边:它们都表达“客户编号”,数据类型也应兼容。列名是否相同不是关键,业务含义一致才是关键。例如 employees.manager_id 指向的不是另一个 manager 表,而是同一张 employees 表中的 employee_id。
外键约束能帮助数据库阻止无效编号进入表中,但“能连接”不等于“必须声明外键”。历史系统中有些关系只存在于业务约定里。写查询时仍要根据数据含义确认连接键,不能看到两个同名列就直接相等。
所谓粒度,就是“结果中的一行代表什么”。这是多表查询最实用的起点。
如果你原本希望“一张订单一行”,却连接了可能有多次记录的 payments,结果出现重复订单并不是数据库在复制数据。结果的粒度已经被支付记录改变了。先写下“一行代表什么”,会比查询结束后再用 DISTINCT 补洞可靠得多。
小满商店的 9 张表已经在前面的建表与填充步骤中准备好。本章不再另造一套临时数据,而是先用计数确认当前规模:
SELECT
(SELECT COUNT(*) FROM customers) AS customer_count,
(SELECT COUNT(*) FROM products) AS product_count,
(SELECT COUNT(*) FROM orders) AS order_count,
(SELECT COUNT(*) FROM order_items) AS item_count,
(SELECT COUNT(*) FROM payments) AS payment_count;其余四张表中,categories 有 5 行,departments 有 5 行,employees 有 8 行,inventory_movements 有 12 行。
这些数字马上就会派上用场:13 张订单连接 22 条明细后,结果粒度会变成明细;10 位客户与 13 张订单做交叉连接,会得到 130 种组合。
数据中还保留了几处故意“不整齐”的业务事实:陆野从未下单;订单 107 仍待支付;订单 103 已取消;订单 105 已退款;亚麻抱枕缺货;旧版周计划本已经下架;林知夏没有直属经理。它们会帮助我们观察内连接怎样淘汰未匹配行,以及一对多关系怎样改变结果数量。
还有一个必须保持统一的金额口径:order_items.discount_rate 保存折扣率,0 表示不打折,0.1124 表示减去原价的 11.24%。因此明细折后金额应写成:
quantity * unit_price * (1 - discount_rate)订单明细中的 unit_price 是下单时成交单价,products.unit_price 是商品当前标价。列名相同不代表含义相同,写查询时必须分清属于哪张表。
很多人的第一个多表查询会写成这样:
SELECT c.customer_name, o.order_id
FROM customers AS c
CROSS JOIN orders AS o
ORDER BY c.customer_id, o.order_id;CROSS JOIN 不寻找客户编号,它会把每位客户与每张订单都组合一次。当前有 10 位客户、13 张订单,因此结果有 130 行。
前 10 行看起来是这样:
这里只展示按客户编号、订单编号排序后的前 10 行,规律已经很清楚:苏小满先被配上所有订单,接着顾言也会被配上所有订单。只有其中少数组合碰巧符合真实关系。
若左表有 行,右表有 行,交叉连接的结果行数就是:
当 customers 有 20 万行、orders 有 300 万行时,理论组合数会膨胀到 6000 亿。数据库不一定真的把所有中间行完整写到磁盘上,但这条查询表达的逻辑范围就是如此巨大。
下面这种逗号写法与交叉连接表达了相同的组合意图:
SELECT c.customer_name, o.order_id
FROM customers AS c, orders AS o;还有一种更隐蔽的情况:查询已经写了一个正确连接,却漏掉了第三张表的连接条件。
SELECT o.order_id, c.customer_name, p.product_name
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
CROSS JOIN products AS p;订单与客户的部分没有问题,但每张订单又与 12 件商品全部组合,13 张订单会扩成 156 行。多表查询出现“行数突然大很多”时,不要只盯着最后一个 WHERE 条件,先检查是否每加入一张表,就随之写了清楚的连接条件。

交叉连接本身不是错误。生成日期与时段的全部组合、制作尺码与颜色矩阵时,它很有用。危险的是无意中得到笛卡尔积,却把它当成业务关系继续计算。
遇到可疑查询,可以暂时不选业务列,先数行:
SELECT COUNT(*) AS row_count
FROM customers AS c
CROSS JOIN orders AS o;再分别数两张表:
SELECT
(SELECT COUNT(*) FROM customers) AS customer_count,
(SELECT COUNT(*) FROM orders) AS order_count;10 × 13 = 130,这就说明结果是完整组合,而不是正常的一对多展开。
我们真正想要的是“订单属于哪位客户”。订单表已经保存 customer_id,因此连接条件应比较两边的客户编号:
SELECT
o.order_id,
o.order_date,
c.customer_name,
c.city,
o.total_amount
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
ORDER BY o.order_id;
数据库可以把这个过程理解为:取订单 101 的 customer_id = 1,在客户表中找到 customer_id = 1 的苏小满;取订单 102 的 customer_id = 2,找到顾言;如此继续。陆野在客户表里存在,但没有订单行来与他匹配,因此不会出现在结果中。
INNER JOIN 只保留连接条件结果为真的行对。左侧有、右侧没有,或者右侧有、左侧没有,都不会被补进结果。它取的是两边关系相交的那一部分。
假设历史数据中出现一张 customer_id = 99 的订单,而客户表没有 99 号客户,这张订单也会从上面的内连接结果中消失。内连接回答的是“哪些订单能找到客户”,不保证“订单表每一行都出现”。当前数据有外键约束,所以这种孤立订单无法正常写入;旧数据迁移或暂时关闭约束时仍要懂得这种行为。
这与上一章讲的 NULL 也连得上。如果 orders.customer_id 为 NULL,条件 o.customer_id = c.customer_id 不会得到真值,自然匹配不到任何客户。
INNER 可以省略,但意图不要省略下面两种写法在这里含义相同:
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_idFROM orders AS o
JOIN customers AS c
ON o.customer_id = c.customer_id我建议学习阶段保留 INNER。当一条查询同时出现内连接和外连接时,明确写出连接类型更容易检查。团队已有统一规范时,再按规范选择即可。
ON 可以包含多个连接条件有些关系只靠一列无法准确识别。虽然小满商店统一结构里的主外键大多只需一列,我们仍要知道 ON 可以使用 AND 组合多个条件:
下面的 table_a、table_b 只是用来推导复合连接键写法的抽象占位符,不是小满商店新增的业务表。
SELECT ...
FROM table_a AS a
INNER JOIN table_b AS b
ON a.key_part_1 = b.key_part_1
AND a.key_part_2 = b.key_part_2;复合连接键少写一部分,往往不会报语法错误,只会让一行匹配到更多行。这类错误比漏写整个 ON 更难发现,所以看到复合主键或复合唯一键时,要逐列核对。
多表查询里经常有同名列。orders 和 payments 都有 order_id、status,order_items 和 products 都有 unit_price。如果只写:
SELECT order_id, status
FROM orders
INNER JOIN payments
ON order_id = order_id;这段 SQL 至少有两个问题。数据库无法确定 SELECT 中的 order_id 和 status 来自哪张表;ON order_id = order_id 也没有表达左右两张表的关系。某些环境会直接报告列名歧义,即使侥幸执行,意图也完全不可审查。
给表起别名后,关系就清楚了:
SELECT
o.order_id,
o.status AS order_status,
p.payment_id,
p.status AS payment_status
FROM orders AS o
INNER JOIN payments AS p
ON o.order_id = p.order_id
WHERE o.order_id IN (101, 105, 110)
ORDER BY o.order_id, p.payment_id;短别名没有唯一标准,但要让读者能快速反推表名。
有两点比别名长短更重要:同一条语句中不要让一个别名承担两个角色;一旦声明别名,后续就始终用别名限定列。
SELECT o.order_id, c.customer_name
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id;不要前面定义 orders AS o,后面又写 orders.customer_id。别名生效后,它就是这次查询中该表引用的名字。
表别名解决“输入列来自哪里”,列别名解决“输出列表示什么”。订单状态与支付状态都叫 status,如果原样输出,应用程序或导出的表格很难区分。因此应写成:
o.status AS order_status,
pay.status AS payment_status连接越复杂,越不要依赖 SELECT *。明确列出所需字段,既能防止同名列冲突,也能避免表结构增加一列后接口结果悄悄改变。
现在回答开头的问题:“每张订单买了什么,购买者是谁?” 所需信息分布在四张表:
customers → orders → order_items → products连接路径不是按表名猜出来的,而是由键一段一段接起来:
customers.customer_id = orders.customer_id
orders.order_id = order_items.order_id
order_items.product_id = products.product_id
把这三段关系翻译成 SQL:
SELECT
o.order_id,
c.customer_name,
p.product_name,
oi.quantity,
oi.unit_price,
oi.discount_rate,
ROUND(
oi.quantity * oi.unit_price * (1 - oi.discount_rate),
2
) AS line_amount
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
INNER JOIN products AS p
ON oi.product_id = p.product_id
WHERE o.order_id IN (101, 108, 114)
ORDER BY o.order_id, oi.order_item_id;这里有 3 张订单,却得到 5 行,因为粒度已经从“一张订单”变成“一条订单明细”。订单 101 与 108 各有两条明细,所以自然各出现两次。客户姓名跟随每条明细重复,也是正确结果的一部分。
下面这段条件看似都在比较编号,实际上关系是错的:
SELECT o.order_id, p.product_name
FROM orders AS o
INNER JOIN products AS p
ON o.order_id = p.product_id;order_id 和 product_id 都是整数,不代表它们可以相等。即使数据里恰好同时出现某个相同数字,那也只是编号碰巧相同,不是业务关系。订单与商品是多对多关系:一张订单能有多件商品,一件商品也能出现在多张订单中。order_items 正是把这两个方向连接起来的中间表。
不要一口气写完六张表再执行。更稳的方式是每加一张表就验证一次:
先连接 orders 与 customers,确认一张订单最多对应一位客户,并观察订单数是否符合预期。
再加入 order_items,预期行数变成明细数。若订单重复,先确认这是不是一单多明细造成的正常展开。
最后连接 products,由于 product_id 在商品表中唯一,每条明细应最多找到一件商品,行数通常不再增加。
可以用下面的计数查询辅助验证:
SELECT COUNT(*) AS joined_rows
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id;它与当前 order_items 的 22 行一致。若连接商品后突然超过 22 行,就要检查 products.product_id 是否真的唯一,或者 ON 是否误用了 category_id。
若还要展示商品分类,只需从商品继续连接分类:
SELECT
o.order_id,
c.customer_name,
p.product_name,
cat.category_name,
oi.quantity
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
INNER JOIN products AS p
ON oi.product_id = p.product_id
INNER JOIN categories AS cat
ON p.category_id = cat.category_id
WHERE o.order_id IN (101, 108, 114)
ORDER BY o.order_id, oi.order_item_id;这里 WHERE 只留下示范用的三张订单。连接条件回答表怎么拼,过滤条件回答拼好以后要哪些行。如果改成过滤订单状态,连接路径本身不需要改变。
FROM 后面不一定只能写真实表,也可以放一个带括号的 SELECT。这个临时查询结果叫派生表。它只在当前语句中存在,但在外层查询看来,它和普通表一样有行、有列,也能参与连接。
假设运营想查看“单次购买至少两件的明细”,并补上订单客户与商品名称。可以先在派生表中筛选明细:
SELECT
o.order_id,
c.customer_name,
p.product_name,
bulk_item.quantity,
bulk_item.unit_price
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
INNER JOIN (
SELECT
order_id,
product_id,
quantity,
unit_price
FROM order_items
WHERE quantity >= 2
) AS bulk_item
ON o.order_id = bulk_item.order_id
INNER JOIN products AS p
ON bulk_item.product_id = p.product_id
ORDER BY o.order_id;外层查询需要一个名字来引用派生结果,因此括号后要写别名:
(
SELECT ...
) AS bulk_item如果省掉 bulk_item,外层的 ON 与 SELECT 就没有稳定的限定名。派生表内部选出的列也要避免重名,因为外层会把它们当作一张表的列来使用。
上面的逻辑也可以直接把 oi.quantity >= 2 写在外层 WHERE 中,结果相同:
SELECT
o.order_id,
c.customer_name,
p.product_name,
oi.quantity,
oi.unit_price
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
INNER JOIN products AS p
ON oi.product_id = p.product_id
WHERE oi.quantity >= 2
ORDER BY o.order_id;所以,派生表不是“写了就一定更快”的技巧。数据库可能把它合并进外层查询,也可能采用中间结果。使用它的主要理由应是让查询的阶段更清楚,或者先完成一个必须独立计算的分组、去重、排名,再与其他表连接。
不要把每张表都包进派生表。层层括号会隐藏关系路径,让列来自哪里更难追踪。能用直接连接清楚表达时,就保持直接。
下面的派生表没有输出 product_id,外层却拿它连接商品:
SELECT p.product_name, bulk_item.quantity
FROM (
SELECT order_id, quantity
FROM order_items
WHERE quantity >= 2
) AS bulk_item
INNER JOIN products AS p
ON bulk_item.product_id = p.product_id;数据库会报告 bulk_item.product_id 不存在。派生表是一道边界:内部表有多少列不重要,外层只能看到内部 SELECT 明确输出的列。排查这类错误时,先看派生表的选择列表,不要只看原始表结构。
小满商店的 customers.referrer_id 保存推荐人的客户编号。它指向同一张表的 customer_id。若要显示“谁由谁推荐”,必须把 customers 放入查询两次:一次代表新客户,一次代表推荐人。
SELECT
new_customer.customer_name AS new_customer_name,
referrer.customer_name AS referrer_name
FROM customers AS new_customer
INNER JOIN customers AS referrer
ON new_customer.referrer_id = referrer.customer_id
ORDER BY new_customer.customer_id;这就是自连接。数据库没有把表复制一份;两个别名只是让同一张表在同一条语句中扮演两个可区分的角色。
如果写成:
SELECT c.customer_name
FROM customers AS c
INNER JOIN customers AS c
ON c.referrer_id = c.customer_id;两个表实例都叫 c,数据库无法判断每个 c 指向哪一边。正确做法是用角色命名:new_customer 与 referrer 比 c1 与 c2 更容易阅读。
employees.manager_id 指向 employees.employee_id。查询员工和直属上级时,可以写:
SELECT
e.employee_name AS employee_name,
m.employee_name AS manager_name,
e.job_title
FROM employees AS e
INNER JOIN employees AS m
ON e.manager_id = m.employee_id
ORDER BY e.employee_id;这里仍然是内连接,所以 manager_id 为 NULL 的最高负责人不会出现。不是这位员工被删除了,而是他找不到满足 e.manager_id = m.employee_id 的另一行。后续学习外连接时,我们会解决“左边没有匹配也要保留”的需求。
categories.parent_id 也指向本表的 category_id。我们可以列出子分类与父分类:
SELECT
child.category_name AS child_category,
parent.category_name AS parent_category
FROM categories AS child
INNER JOIN categories AS parent
ON child.parent_id = parent.category_id
ORDER BY child.category_id;根分类的 parent_id 为 NULL,因此不会出现在内连接结果中。自连接很适合查“一层关系”;若要一次展开不确定深度的整棵层级树,则需要后面课程中的递归查询思路。
我们目前写过的关系大多使用等号:
ON o.customer_id = c.customer_id这种连接叫等值连接。主键与外键天然适合等值连接,因为我们要找的就是编号相同的那一行。
等号只规定“如何匹配”,并不保证“一边只有一行”。例如一张订单可以拥有多条订单明细:
SELECT o.order_id, oi.order_item_id, oi.product_id
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_id = 101
ORDER BY oi.order_item_id;订单 101 会匹配两条明细。连接条件明明是等号,结果仍然是一对多。是否展开取决于连接列在两侧是不是唯一,不取决于用了哪个比较符。
连接条件也可以使用 <、>、<=、>= 或范围表达式。最直观的例子是生成不重复的商品对比组合。我们想把每两件在售商品配成一组,但不允许商品和自己配对,也不希望“咖啡—茶”与“茶—咖啡”重复出现:
SELECT
cheaper.product_name AS cheaper_product,
cheaper.unit_price AS cheaper_price,
dearer.product_name AS dearer_product,
dearer.unit_price AS dearer_price
FROM products AS cheaper
INNER JOIN products AS dearer
ON cheaper.unit_price < dearer.unit_price
WHERE cheaper.status = '在售'
AND dearer.status = '在售'
ORDER BY cheaper.unit_price, dearer.unit_price;部分结果如下:
cheaper.unit_price < dearer.unit_price 一次解决了两个问题:相同商品的价格不小于自己,因此不会与自己配对;每对商品只保留“低价在左、高价在右”的方向,因此不会出现反向重复。
如果两个商品同价,上面的条件不会把它们配成一组。若业务要包含同价但不同商品,可以把关系写得更精确:
ON cheaper.unit_price < dearer.unit_price
OR (
cheaper.unit_price = dearer.unit_price
AND cheaper.product_id < dearer.product_id
)非等值连接的匹配范围通常比主外键连接更宽,一行可能与许多行相遇。动手前最好估算结果规模,并确认相应的范围是否重叠。
把它们记成两句话就够了:
ON 说明两侧的行怎样才算有关联。WHERE 说明关联完成后,最终想留下哪些行。例如查找已支付订单及客户:
SELECT
o.order_id,
c.customer_name,
o.total_amount
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
WHERE o.status = '已支付'
ORDER BY o.order_id;o.customer_id = c.customer_id 是关系,因此放在 ON;o.status = '已支付' 是业务筛选,因此放在 WHERE。
下面把订单状态也放进 ON:
SELECT
o.order_id,
c.customer_name,
o.total_amount
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
AND o.status = '已支付'
ORDER BY o.order_id;对于当前内连接,它与上一条查询返回相同的一行。因为内连接最终只保留匹配成功的行,优化器也可能对条件重新安排。但是“结果碰巧一样”不代表两种写法同样清楚。第一种一眼就能区分关系和业务筛选,加入更多表后更容易维护。
看下面这个错误:
SELECT o.order_id, p.product_name
FROM orders AS o
INNER JOIN products AS p
ON p.status = '在售';p.status = '在售' 只筛选商品,没有说明订单与商品怎样关联。每张订单会与每件在售商品组合,本质上是过滤后的笛卡尔积。正确路径必须经过 order_items:
SELECT o.order_id, p.product_name
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
INNER JOIN products AS p
ON oi.product_id = p.product_id
WHERE p.status = '在售';ON 中出现条件,不代表它就是连接条件。判断标准不是放在哪个子句,而是它有没有描述左右两边的关系。只引用一侧表的条件通常是筛选;如果整个 ON 没有把两侧列联系起来,就要警惕笛卡尔积。
内连接里某些条件放 ON 或 WHERE 结果相同,但到了外连接,两者会改变“未匹配行是否保留”。因此从现在就养成职责分离的习惯:关系条件写在对应的 ON,最终结果过滤写在 WHERE。这样学习外连接时,不需要推翻现有写法。
连接基数描述的是左边一行与右边多少行匹配。我们不用先背理论名词,可以直接问两个问题:
若两边连接列都唯一,一行最多匹配一行,连接通常不会增加行数。统一数据中没有专门拆分的一对一扩展表,但“订单与仅保留每单一条的派生结果”可以形成一对一关系。
order_items.product_id 可以重复,因为很多明细能购买同一商品;products.product_id 是主键,不能重复。因此每条明细最多匹配一件商品:
SELECT COUNT(*) AS item_rows
FROM order_items;SELECT COUNT(*) AS joined_rows
FROM order_items AS oi
INNER JOIN products AS p
ON oi.product_id = p.product_id;在没有孤立外键的前提下,连接前后都是 22 行。
orders.order_id 唯一,order_items.order_id 可重复。一张订单会展开为它拥有的每条明细。订单 101 因此出现两行,订单 111 会出现三行。
真实多对多关系通常通过中间表表达,例如订单与商品由 order_items 连接。也可能因为选错键,无意间制造多对多。
下面错误地用 category_id 连接订单明细与商品:
SELECT oi.order_item_id, p.product_id, p.product_name
FROM order_items AS oi
INNER JOIN products AS p
ON oi.product_id = p.category_id;当前商品编号与分类编号都从较小整数开始,所以这条错误语句甚至能返回一些行:例如明细中的 product_id = 2 会匹配所有 category_id = 2 的商品。结果看起来有商品名,却没有一件是依据明细真正购买的商品匹配出来的。连接键检查不能只看结果有没有数据。
多表查询一出现重复,很多人会立刻加 DISTINCT:
SELECT DISTINCT o.order_id, c.customer_name
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id;这确实会把订单 101 的两条明细压回一行,但也把“为什么会重复”藏起来了。若查询本来就只需要订单编号和客户名,那么根本不必连接 order_items;若后面要计算件数或金额,提前 DISTINCT 还可能让结果失真。
查询每张订单在连接后出现几次:
SELECT
o.order_id,
COUNT(*) AS rows_after_join
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
GROUP BY o.order_id
HAVING COUNT(*) > 1
ORDER BY o.order_id;再看明细表本身:
SELECT
order_id,
COUNT(*) AS item_count
FROM order_items
GROUP BY order_id
HAVING COUNT(*) > 1;两边计数一致,说明重复来自正常的一单多明细,不是连接条件错误。
若你认为 payments.order_id 每单只会有一行,先验证:
SELECT
order_id,
COUNT(*) AS payment_count
FROM payments
GROUP BY order_id
HAVING COUNT(*) > 1;当前查询不返回任何行,说明这批数据中每张有支付记录的订单恰好只有一条支付。不过,payments.order_id 没有唯一约束,未来完全可能因失败重试而出现多行。数据现状是唯一的,不等于表结构保证它永远唯一。
如果报告只想看成功支付,仍应把需求明确写进查询:
SELECT
o.order_id,
c.customer_name,
pay.payment_method,
pay.paid_at
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
INNER JOIN payments AS pay
ON o.order_id = pay.order_id
WHERE pay.status = '成功'
AND o.order_id IN (101, 108, 114)
ORDER BY o.order_id;这里结果一单一行,是因为样例中每单恰好只有一条成功记录。若业务允许退款后再次支付,或者数据异常产生两条成功记录,仍可能重复。真正要求“每单最新一条成功支付”时,需要先在派生表中明确选出最新记录,而不是假设状态过滤后必然唯一。
这是最容易被误判的一类重复。商品 5 是“65W 氮化镓充电器”,它出现在 4 条订单明细中,也有 3 条库存流水。如果同时把明细与库存流水直接连接到商品:
SELECT
p.product_id,
oi.order_item_id,
im.movement_id
FROM products AS p
INNER JOIN order_items AS oi
ON p.product_id = oi.product_id
INNER JOIN inventory_movements AS im
ON p.product_id = im.product_id
WHERE p.product_id = 5
ORDER BY oi.order_item_id, im.movement_id;4 条明细乘 3 条库存流水,得到 12 行。数据库无法自动知道“某条销售明细应该对应哪条库存流水”,因为统一结构里二者只有共同的商品编号,没有逐条对应的业务键。
如果直接对这 12 行求销售金额,每条明细会被重复计算 3 次;若统计库存变动,每条流水又会重复 4 次。解决方向通常有三个:先分别汇总到“一件商品一行”再连接;只保留业务上需要的某个子集;或者拆成两个查询分别展示不同粒度的信息。选哪一个取决于报表想让一行代表什么。
DISTINCT 只能删除最终选择列表完全相同的行,不能修复错误的连接关系。看到重复时先检查基数、连接键和目标粒度,最后才判断是否真的需要去重。
多表查询结果不对,通常表现为三种情况:行太多、行太少、列值看似正常但来自错误对象。可以按下面的顺序排查。
例如要查“库存变动由谁操作、涉及什么商品、员工属于哪个部门”,路径应是:
products ← inventory_movements → employees → departments对应连接键:
inventory_movements.product_id = products.product_id
inventory_movements.employee_id = employees.employee_id
employees.department_id = departments.department_id如果画不出路径,说明业务关系还没弄清楚。此时继续堆 SQL 只会增加偶然得到错误结果的概率。
SELECT COUNT(*)
FROM inventory_movements AS im
INNER JOIN products AS p
ON im.product_id = p.product_id;确认合理后再加员工:
SELECT COUNT(*)
FROM inventory_movements AS im
INNER JOIN products AS p
ON im.product_id = p.product_id
INNER JOIN employees AS e
ON im.employee_id = e.employee_id;每加一张表都记录行数变化。哪一步发生异常,问题通常就在那一步新加入的关系上。
业务报表可能只展示名称,但排错时应把编号暴露出来:
SELECT
im.movement_id,
im.product_id AS movement_product_id,
p.product_id AS matched_product_id,
im.employee_id AS movement_employee_id,
e.employee_id AS matched_employee_id
FROM inventory_movements AS im
INNER JOIN products AS p
ON im.product_id = p.product_id
INNER JOIN employees AS e
ON im.employee_id = e.employee_id;这样可以直接检查等号两边是否符合预期。只看商品名和员工名,很容易被“碰巧像对的”结果骗过去。
如果连接后行数异常增加,对两侧连接列分别执行:
SELECT product_id, COUNT(*) AS duplicate_count
FROM products
GROUP BY product_id
HAVING COUNT(*) > 1;主键列正常时不应返回任何行。再检查允许重复的业务列:
SELECT product_id, COUNT(*) AS movement_count
FROM inventory_movements
GROUP BY product_id
HAVING COUNT(*) > 1;这里返回多行不一定是问题,因为同一商品本来就可能发生多次库存变动。关键是你是否预期结果粒度变成“一次库存变动”。
内连接只保留两边都能匹配的行。可以分别计数:
SELECT COUNT(*) AS order_count
FROM orders;SELECT COUNT(*) AS matched_order_count
FROM orders AS o
INNER JOIN payments AS pay
ON o.order_id = pay.order_id;后一条不是“有支付的订单数”,而是“订单与支付的匹配行数”;一次订单有两次支付就会计两行。若要确认有多少不同订单匹配到支付,可写:
SELECT COUNT(DISTINCT o.order_id) AS paid_attempt_order_count
FROM orders AS o
INNER JOIN payments AS pay
ON o.order_id = pay.order_id;总共 13 张订单,只有 11 张至少有一条支付记录。已取消的订单 103 与待支付的订单 107 没有支付记录,所以在内连接中消失。这是连接类型的行为,不一定是数据丢失。
键、基数、行数都确认以后,再加入名称、金额、表达式和 ORDER BY。排错阶段越小,越容易定位;完整查询应该是验证后的关系逐步累积出来的结果。
SELECT ...
FROM order_items AS oi
INNER JOIN products AS p
ON oi.unit_price = p.unit_price;两张表都有 unit_price,但一个是成交单价,一个是当前标价。价格相等不能证明该明细购买的就是这件商品,而且调价后原本的商品还可能匹配不上。正确的关系键是 oi.product_id = p.product_id。
SELECT ...
FROM customers AS c
INNER JOIN employees AS e
ON c.customer_name = e.employee_name;姓名会重复,也会修改,更没有客户与员工之间的直接业务关系。除非需求明确要求做同名匹配,否则这不是可靠连接。
SELECT o.order_id, p.product_name
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
INNER JOIN products AS p
ON o.order_id = oi.order_id;第二个 ON 仍在重复订单与明细的条件,没有把新加入的 products AS p 接进来。由于条件对商品表没有限制,每条已匹配明细会与所有商品组合。正确条件是 oi.product_id = p.product_id。
SELECT
oi.quantity * p.unit_price AS line_amount
FROM order_items AS oi
INNER JOIN products AS p
ON oi.product_id = p.product_id;连接本身是对的,计算语义却可能错。订单金额通常要使用下单时记录在 order_items.unit_price 的成交价,而不是商品表中的当前价格。正确表达应根据业务规则使用:
oi.quantity * oi.unit_price * (1 - oi.discount_rate)多表查询不只要防“拼错行”,还要防“从正确的行里拿错列”。
如果 ON 错了,随后增加更多日期、状态过滤,只会让错行变少,不会让关系变对。正确顺序是先验证连接键与基数,再验证业务过滤。
下面练习都沿用本章的小满商店表与样例数据。建议先写下“结果一行代表什么”和关系路径,再展开答案。
查询所有已支付订单,显示订单编号、客户姓名、城市、订单金额,按订单编号升序排列。
显示已支付订单的订单编号、客户姓名、商品名称、数量与明细金额。
查询成功支付的订单编号、客户姓名、支付方式和支付时间。
列出有父分类的分类名称及其父分类名称。
下面查询为什么让订单 101 出现两行?
SELECT o.order_id, oi.order_item_id, oi.product_id
FROM orders AS o
INNER JOIN order_items AS oi
ON o.order_id = oi.order_id
WHERE o.order_id = 101;下面查询有什么问题?
SELECT c.customer_name, o.order_id, p.product_name
FROM customers AS c
INNER JOIN orders AS o
ON c.customer_id = o.customer_id
INNER JOIN products AS p
ON c.customer_id = o.customer_id;使用派生表先找出购买数量至少为 2 的明细,再连接订单与客户,显示订单编号、客户姓名和数量。
这一章处理的问题有一个共同特点:相关信息分散在不同表的不同列里。我们沿着主键和外键把它们横向拼到一行,用 INNER JOIN 留下匹配成功的行;表多了就逐段连接;同一张表承担两个角色时使用自连接;结果变多时回到粒度与连接基数,而不是急着去重。
下一类问题看起来相似,方向却不同。比如小满商店要把“杭州客户名单”和“上海客户名单”叠成一份名单,两边返回的是相同结构的行,我们不是继续向右增加列,而是把第二个查询的行接到第一个查询下面。这个动作不靠连接键,而要使用 UNION、UNION ALL 等集合操作。
进入下一章前,先记住这条分界线:JOIN 主要解决列分散的问题,集合操作主要解决行分散的问题。 判断清楚要横向拼列还是纵向叠行,SQL 的方向就不会走偏。