上一章讲子查询时,我们一直在做一件事:先得到一个结果集,再让外层查询继续使用它。连接其实是同一套思路的另一面。子查询回答“先算出哪批数据”,连接回答“几批数据相遇时,哪些行必须留下”。
这两个问题看着接近,写错后的表现却完全不同。子查询写错,常见结果是条件太宽或太窄;外连接写错,数据可能像凭空消失,或者一笔金额突然翻成两倍。SQL 没有随机丢行,也没有擅自复制金额。真正发生的是:我们没有把“保留谁”“怎么匹配”“匹配后还要筛掉谁”这三件事分开。
这一章仍然围绕“小满商店”。我们会把没有订单的顾客留在报表里,把两个月份互相缺失的顾客对齐,把分类表连回自己,还会主动用笛卡尔积造出“商品 × 日期”的完整网格。遇到 MySQL 和 PostgreSQL 写法不同的地方,会直接给出两套可以落地的写法。
先记住本章最重要的一句话:
写连接之前,先用一句业务语言说清楚“结果中一个都不能少的对象是谁”。这个对象应该站在外连接的保留侧。连接条件放在 ON,最终结果条件放在 WHERE;两者不能凭感觉互换。
为了让后面的每一张结果表都能手工核对,我们继续使用前面章节已经建立的小满商店固定数据。字段和编号都不另起一套,读者可以把本章查询直接接在前面的练习后面运行。
customers 中有十位顾客,其中陆野还没有下过订单:
orders 中既有已支付订单,也有待支付和已取消订单:
执行本章示例时,可以用下面的查询观察原始行。显式写出列名,是为了让你一眼看出后续结果中的列来自哪里:
SELECT
customer_id,
customer_name,
city,
registered_at
FROM customers
WHERE customer_id BETWEEN 1 AND 10
ORDER BY customer_id;
SELECT
order_id,
customer_id,
order_date,
status,
total_amount
FROM orders
WHERE order_id BETWEEN 101 AND 114
ORDER BY order_id;固定数据中的订单与支付是一对一示例,但商品 5 同时拥有四条订单明细和三条库存流水。稍后把两个一对多分支同时连接到商品时,会形成 4 × 3 = 12 行,正好用来观察重复数量从哪里出现。
所谓行级契约,就是在写 SQL 前先回答结果一行代表什么。比如:
NULL 或 0 补齐。后面遇到复杂查询时,我们都会回到这份契约。很多所谓的连接难题,实际上只是“结果一行代表什么”没有说清楚。
先看最熟悉的内连接:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.status
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE c.customer_id BETWEEN 1 AND 10
AND o
内连接只保留 ON 条件为真的配对。陆野在 orders 中没有对应行,所以结果中完全看不到他。此时结果一行代表“一个已经发生的顾客—订单配对”,不是“一位顾客”。
如果报表的标题是“全部顾客及其订单”,那么“全部顾客”就是不能少的对象。把 customers 放左边,再使用 LEFT JOIN:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.status
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.order_id BETWEEN 101 AND 114
WHERE c
这一结果可以拆成两个阶段理解:
customer_id 相等且订单编号在范围内的行。NULL。所以,LEFT JOIN 的“左表全留”不等于“左表每行只出现一次”。苏小满有三张订单,她自然出现三次。外连接保证的是保留资格,不是唯一性。

现在把行级契约改成“每位顾客一行”,就要聚合:
SELECT
c.customer_id,
c.customer_name,
COUNT(o.order_id) AS order_count
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.order_id BETWEEN 101 AND 114
WHERE
这里必须数 o.order_id,不能随手写 COUNT(*)。对陆野来说,左连接仍然产生一行,只不过订单列全是 NULL。COUNT(*) 会把这行算作 1,COUNT(o.order_id) 会忽略 NULL,才得到正确的 0。
-- 这条写法会把没有订单的顾客数成 1
SELECT
c.customer_id,
COUNT(*) AS wrong_order_count
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE c.customer_id = 7
GROUP BY c.customer_id;外连接后的 COUNT(*) 数的是结果行,COUNT(右表非空主键) 数的才是匹配到的右表记录。只要报表需要显示 0,这个区别就不能含糊。
RIGHT JOIN 与 LEFT JOIN 的逻辑完全对称。下面这条查询仍然保留全部顾客,只是顾客表写在右边:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.status
FROM orders AS o
RIGHT JOIN customers AS c
ON o.customer_id = c.customer_id
AND o.order_id BETWEEN 101 AND 114
WHERE c
它可以机械地改写成上一节的左连接:交换两张表的位置,把 RIGHT JOIN 改成 LEFT JOIN,ON 中的等值关系不变。
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_idMySQL 8.4 和 PostgreSQL 都支持 RIGHT JOIN,但在团队代码里统一使用 LEFT JOIN 往往更容易读。你只需要沿着 FROM 从左往右看,就能持续追踪哪一个数据集拥有保留资格。改写也有利于迁移到只重点支持左连接的查询引擎。
不过不要形成“RIGHT JOIN 永远不能用”的机械规则。读已有 SQL 时,仍要能准确判断:右边那张表会完整保留,左表不匹配的列会补 NULL。
全外连接的使用场景通常不是“主数据连业务数据”,而是“两份名单对账”。例如,我们先用子查询分别得到 2025 年和 2026 年下过订单的顾客:
-- PostgreSQL:原生 FULL OUTER JOIN
WITH buyers_2025 AS (
SELECT DISTINCT customer_id
FROM orders
WHERE order_date >= TIMESTAMP '2025-01-01 00:00:00'
AND order_date < TIMESTAMP '2026-01-01 00:00:00'
),
buyers_2026 AS (
SELECT DISTINCT customer_id
FROM orders
WHERE order_date >= TIMESTAMP '2026-01-01 00:00:00'
AND order_date < TIMESTAMP '2027-01-01 00:00:00'
为什么选择 COALESCE(y2025.customer_id, y2026.customer_id)?只在 2025 年下单的白露,右侧编号是 NULL;只在 2026 年下单的夏初,左侧编号是 NULL。COALESCE 从两边取第一个非 NULL 值,让结果始终有统一的顾客编号。陆野两年都没有订单,因此不会出现在这两份买家名单的全外连接里;全外连接只能保留输入中存在的行,不能凭空创造两边都没有的对象。
MySQL 8.4 没有原生 FULL OUTER JOIN 语法。不能把关键字硬写进去,也不要误以为 UNION 本身就是全外连接。可靠的兼容思路是:
buyers_2025 LEFT JOIN buyers_2026。UNION ALL 把两段拼起来。WITH buyers_2025 AS (
SELECT DISTINCT customer_id
FROM orders
WHERE order_date >= '2025-01-01'
AND order_date < '2026-01-01'
),
buyers_2026 AS (
SELECT DISTINCT customer_id
FROM orders
WHERE order_date >= '2026-01-01'
AND order_date < '2027-01-01'
)
SELECT
y2025.customer_id AS
结果与 PostgreSQL 版本表达的是同一件事。在 MySQL 客户端中,布尔表达式通常显示为 1 和 0:

第二段的 WHERE y2025.customer_id IS NULL 是整个兼容写法的关键。如果把它删掉,两年都下过单的苏小满、顾言和江行会在两段中各出现一次。UNION 虽然可能去掉完全相同的行,但两段经常带有不同标记、金额或来源列,不能把正确性寄托在自动去重上。这里明确排除重复区域,再使用 UNION ALL,意图最清楚。
MySQL 的全外连接兼容写法必须用一列“匹配成功时确定非 NULL”的键做反匹配判断,通常是被连接表的主键。不要拿本来就允许 NULL 的业务列判断,否则真正匹配成功的行也可能被误认成“未匹配”。
很多外连接事故都来自同一种改动:有人觉得条件放在 ON 或 WHERE 都是筛选,于是把它挪了个位置。对内连接来说,这两种写法常能得到相同结果;对外连接来说,位置决定了补出来的 NULL 行能不能活到最后。
需求是:“列出全部顾客,并展示他们的有效订单;没有有效订单的顾客也要出现。”本课程把 已支付、已发货、已完成 视为有效销售状态。正确写法把这组订单状态放在 ON:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.status
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status IN ('已支付', '已发货',
白露有一张已取消订单,乔木有一张待支付订单,他们都没有有效订单;陆野则完全没有订单。对这条查询来说,三个人都属于“右表找不到满足 ON 条件的行”,因此各保留一条 NULL 补位行。这正符合业务要求。
再看容易写错的版本:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.status
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.order_id BETWEEN 101 AND 114
WHERE c
这里先按顾客编号连接所有订单,再让 WHERE 检查每个结果行。白露的订单是 已取消,乔木的订单是 待支付,都不满足;陆野的补位行拿 NULL 去做 IN 判断,结果为 UNKNOWN。WHERE 只保留 TRUE,所以三个人都消失了。这个左连接在结果上退化得像内连接。

你可以用下面的顺序来判断条件放哪:
先只看业务名词。题目说“全部顾客”,所以 customers 是保留侧,先确定 LEFT JOIN。
再问右表中什么样的行算“匹配”。这里只有有效订单算匹配,所以订单状态集合属于 ON。
最后问成形的结果还要排除什么。比如只看杭州顾客,这是对最终顾客范围的限制,放在 WHERE 的 c.city = '杭州'。
如果需求本来就是“只列出至少有一张有效订单的顾客”,那么把状态条件放在 WHERE 并非语法错误,它表达的业务就是要删除没有匹配的顾客。只是在这种情况下,直接写 INNER JOIN 更坦白:
SELECT DISTINCT
c.customer_id,
c.customer_name
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.status IN ('已支付', '已发货', '已完成');问题从来不是“WHERE 不能写右表列”,而是你一边声称要保留左表,一边又用一个拒绝 NULL 的条件把补位行删掉。
有人发现顾客消失后,会把条件改成:
WHERE o.status IN ('已支付', '已发货', '已完成')
OR o.status IS NULL它能救回完全没有订单的陆野,却救不回白露和乔木。原因是后两位已经各自匹配到一张订单,状态都不是 NULL;左连接不会再为他们额外制造全空行。已有订单被 WHERE 删除后,两位顾客仍然不出现。
这正说明 ON 和 WHERE 不是靠补一个 OR 就能互换。题目要的是“只让已支付订单参与匹配”,应当从源头把状态条件放进 ON。
上一章用 NOT EXISTS 找过“没有订单的顾客”。外连接也能表达同一件事,通常叫反连接模式:
SELECT
c.customer_id,
c.customer_name
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL
ORDER BY c.customer_id;左连接先让所有顾客获得保留资格。有订单的顾客会匹配到非空 order_id,没有订单的顾客只能得到补位的 NULL。最后保留 o.order_id IS NULL,就把“匹配失败”变成了可查询的状态。
这一写法与 NOT EXISTS 通常都很清楚。选择时先看团队习惯和执行计划,不必因为语法更短就武断地认为其中一个永远更快。真正不能妥协的是:用于判定未匹配的列必须能可靠地区分补位行,右表主键最合适。
两张表时,我们还能在脑中画出左右两边。连接三张、四张表后,真正危险的不是语法变长,而是每一步的结果都会成为下一步的左侧数据集。前一步已经丢掉的行,后一步救不回来;前一步已经复制的行,后一步还可能继续放大。
需求是:“列出全部顾客、他们的订单,以及每张订单的支付记录。”最直接的写法是连续左连接:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.status AS order_status,
p.payment_id,
p.amount AS payment_amount,
p.status AS payment_status
FROM customers AS c
LEFT JOIN orders AS o
ON
这条连接链表达了两层保留:
LEFT JOIN 保留全部顾客。没有订单的陆野得到一行订单列为 NULL 的记录。LEFT JOIN 把“顾客与订单的中间结果”当作左侧。没有支付记录的订单也会保留,支付列补 NULL。固定样例中每张已付款订单恰好只有一条支付记录,但表结构允许一张订单存在多次支付尝试、失败后重试或退款记录。只要产品经理要的是“每张订单一行”,就不应依赖当前数据碰巧一对一,仍应先把支付汇总成订单粒度。
WITH payment_summary AS (
SELECT
order_id,
SUM(CASE WHEN status = '成功' THEN amount ELSE 0 END) AS paid_amount,
COUNT(CASE WHEN status = '成功' THEN 1 END) AS success_payment_count
FROM payments
GROUP BY order_id
)
SELECT
c
上一章的子查询在这里重新登场:payment_summary 不是为了让 SQL 显得高级,而是先把“多条支付”压成“每张订单一行”。连接前后粒度一致,结果才不会无意间扩张。
假设我们还要显示商品明细。下面两个 ON 看起来只差一个别名,含义却不同:
-- 正常:订单明细属于订单,按 order_id 连接
LEFT JOIN order_items AS oi
ON oi.order_id = o.order_id
-- 错误:把明细接到支付是否存在上
LEFT JOIN order_items AS oi
ON oi.order_id = p.order_id第二种写法让明细依赖 p.order_id。一张订单即使有明细,只要还没有支付,p.order_id 就是外连接补出来的 NULL,明细也跟着匹配失败。数据本身没缺,错误的连接锚点却让它看起来缺了。
画关系时要沿真实外键走:
customers.customer_id
↓
orders.customer_id
├── orders.order_id → order_items.order_id
└── orders.order_id → payments.order_idorder_items 和 payments 是订单下的两个并列“一对多”分支,不是先后依赖关系。把它们直接一起接到订单后面,会引出另一个问题:扇出。
固定数据里,每张订单暂时只有一条支付,不容易直接看到订单下两个分支的乘法。我们换到商品:5 号商品“65W 氮化镓充电器”在 order_items 中出现四次,在 inventory_movements 中有三条流水。两个分支都按 product_id 直接连接:
SELECT
p.product_id,
p.product_name,
oi.order_item_id,
oi.quantity AS item_quantity,
movement.movement_id,
movement.quantity AS movement_quantity
FROM products AS p
LEFT JOIN order_items AS oi
ON oi.product_id = p
这十二行不是数据库重复读取了数据,而是十二种合法配对:每条订单明细都能和每条库存流水匹配。若此时直接求和:
SELECT
p.product_id,
COUNT(*) AS joined_row_count,
SUM(oi.quantity) AS item_quantity_after_join,
SUM(movement.quantity) AS movement_quantity_after_join
FROM products AS p
LEFT JOIN order_items AS oi
ON oi.product_id = p.
真实订单明细数量是 1 + 1 + 1 + 1 = 4,却因为三条库存流水被重复成 12;三条库存流水净变化是 -1 + 1 + 20 = 20,却因为四条订单明细被重复成 80。两个错误数字都可以由乘法关系解释。

修复方法不是在最外层写 SUM(DISTINCT ...)。这个例子中每条明细数量刚好都是 1,SUM(DISTINCT oi.quantity) 只会得到 1,更加错误。我们应该分别把两个分支汇总到 product_id,再连接:
WITH item_summary AS (
SELECT
product_id,
SUM(quantity) AS ordered_quantity,
COUNT(*) AS item_line_count
FROM order_items
GROUP BY product_id
),
movement_summary AS (
SELECT
product_id,
SUM(quantity) AS net_movement_quantity,
COUNT(*) AS movement_count
FROM inventory_movements
这次每个数字都只由自己的事实表计算一次。ordered_quantity 是历史订单明细中的件数;net_movement_quantity 是这三条库存流水的净变化,它不等于商品当前库存,因为固定数据只展示了一部分历史流水,当前库存还受更早的初始库存影响。我们修复的是连接重复,不是在强行证明两个业务口径相等。
连接两个一对多分支时,先把每个分支聚合到共同主键,再连接。判断标准不是“SQL 能否运行”,而是每个中间结果是否已经满足最终行级契约。
下面这条查询声称要列出全部顾客及其成功支付,实际只会留下有成功支付的人:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
p.payment_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
LEFT JOIN payments AS p
ON p.order_id = o
p.status = '成功' 对第二次左连接是拒绝 NULL 的条件,它直接让第二个外连接退化。更隐蔽的是,它也会间接删除没有订单的顾客,因为这些顾客的 o.order_id 与 p.status 都是 NULL。
如果需求是“顾客一个不少,只展示成功支付”,条件应该进入对应的 ON:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
p.payment_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
LEFT JOIN payments AS p
ON p.order_id = o
如果连“没有成功支付的订单”也不想显示,但顾客仍然必须保留,那么问题已经不只是挪条件。可以先把“有成功支付的订单”整理成一个结果集,再左连顾客:
WITH paid_orders AS (
SELECT DISTINCT
o.order_id,
o.customer_id
FROM orders AS o
INNER JOIN payments AS p
ON p.order_id = o.order_id
AND p.status = '成功'
)
SELECT
c.customer_id,
c
先由内连接定义“什么是有成功支付的订单”,再由左连接负责“顾客一个不少”。两个层次各做一件事,读者不必在一长串 ON 和 WHERE 之间猜意图。
只有内连接时,优化器通常可以自由调整连接顺序,很多括号不改变结果。但外连接引入了保留侧,下面两种分组不保证等价:
-- 先把顾客与订单连接,再保留这个中间结果去找支付
(customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id)
LEFT JOIN payments AS p
ON p.order_id = o.order_id-- 先形成“订单与支付”的整体,再把它作为右侧连接给顾客
customers AS c
LEFT JOIN (
orders AS o
INNER JOIN payments AS p
ON p.order_id = o.order_id
AND p.status = '成功'
)
ON o.customer_id = c.customer_id第二种写法把“必须有成功支付”限定在右侧整体内部,却仍由最外层左连接保留全部顾客。这在表达嵌套业务关系时很有用。它也提醒我们:混合内连接和外连接时,不要只按换行猜分组;用括号或 CTE 把业务单元画清楚。
分类表没有单独的“父分类表”。每一行既可能是一条普通分类,又可能被另一行当成父分类。要在一行中同时显示“子分类”和“父分类”,就把 categories 引用两次,并给两份引用不同别名。
本节使用的分类数据如下:
SELECT
child.category_id,
child.category_name,
parent.category_id AS parent_category_id,
parent.category_name AS parent_category_name
FROM categories AS child
LEFT JOIN categories AS parent
ON parent.category_id = child.parent_id
WHERE child.category_id IN (
child 和 parent 不是两张物理表,它们是同一张表在当前查询中的两个角色。child.parent_id 保存父级编号,所以它应该等于 parent.category_id。别名如果写成 a、b 也能执行,但几个月后很难一眼看出箭头方向;角色名更适合层级查询。
这里必须使用左连接。“居家生活”“文具手账”“数码配件”“户外出行”的 parent_id 都是 NULL,用内连接会让这些根分类消失。层级表里的 NULL 往往不是脏数据,而是在明确表达“没有更上一级”。

同一张表仍然引用两次,但这次左边是候选父分类,右边去找它的孩子:
SELECT
parent.category_id,
parent.category_name
FROM categories AS parent
LEFT JOIN categories AS child
ON child.parent_id = parent.category_id
WHERE parent.category_id IN (1, 2, 3, 4, 5)
AND child.
这是前面反连接模式在层级数据中的应用。没有孩子的分类无法在右侧匹配,child.category_id 被补成 NULL,所以它们是叶子节点。
普通自连接一次只能跨一层。固定数据目前最深只有“居家生活 → 厨房用品”,但商品分类以后还可能继续细分。若要支持不固定层数的完整路径,递归 CTE 比预先写死三次、四次自连接更合适:
WITH RECURSIVE category_tree AS (
SELECT
category_id,
category_name,
parent_id,
0 AS depth,
CAST(category_name AS CHAR(500)) AS category_path
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT
child.category_id,
child.category_name,
MySQL 可使用上面的 CHAR(500)。PostgreSQL 把它改为 VARCHAR(500) 即可,其余结构相同。
自连接和递归 CTE 不互相替代。页面只显示“分类 / 父分类”两列时,自连接短而直接;需要任意深度、完整路径或整棵子树时,递归 CTE 才是合适工具。
如果数据库有可靠的自引用外键,正常写入时不会出现 parent_id 指向不存在分类的情况。但历史导入、临时关闭约束或外部同步仍可能留下断点。下面的检查会找出“声称有父级,却找不到父级”的分类:
SELECT
child.category_id,
child.category_name,
child.parent_id AS missing_parent_id
FROM categories AS child
LEFT JOIN categories AS parent
ON parent.category_id = child.parent_id
WHERE child.parent_id IS NOT NULL
AND parent.category_id IS NULL无结果才是理想状态。注意两个条件缺一不可:只写 parent.category_id IS NULL 会把合法根节点也查出来;加上 child.parent_id IS NOT NULL,才把“主动没有父级”和“父级断开”区分开。
同理,员工表 employees 可以用 employee.manager_id = manager.employee_id 显示直属上级:
SELECT
employee.employee_id,
employee.employee_name,
manager.employee_name AS manager_name
FROM employees AS employee
LEFT JOIN employees AS manager
ON manager.employee_id = employee.manager_id
ORDER BY employee.employee_id;最高负责人没有上级,manager_name 为 NULL。这不是查询失败,而是层级顶点的自然表示。
前面的连接都在寻找“能够匹配的行”。CROSS JOIN 不需要匹配条件,它把左边每一行和右边每一行组合。左边有 3 行、右边有 4 行,结果就是 12 行。
第一次看到笛卡尔积,人们常把它当成忘写 ON 的事故。这个警觉没错,但完整组合本身也很有用。报表需要显示没有销量的日期,商品配置需要列出所有可选组合,测试数据需要覆盖每个状态,这些任务都要先造出“应该存在的格子”。
先选三件商品,再生成 8 月 1 日到 8 月 4 日四个日期。MySQL 8.4 可以用递归 CTE 生成日期:
WITH RECURSIVE dates AS (
SELECT DATE('2025-06-18') AS report_date
UNION ALL
SELECT report_date + INTERVAL 1 DAY
FROM dates
WHERE report_date < '2025-06-21'
),
selected_products AS (
SELECT product_id, product_name
FROM products
WHERE product_id IN (1,
PostgreSQL 内置了序列生成函数,日期部分可以写得更短:
WITH dates AS (
SELECT report_time::date AS report_date
FROM generate_series(
TIMESTAMP '2025-06-18 00:00:00',
TIMESTAMP '2025-06-21 00:00:00',
INTERVAL '1 day'
) AS series(report_time)
),
selected_products AS (
SELECT product_id, product_name
FROM products
WHERE product_id IN (1, 2,
两种写法得到同一个 12 行骨架。MySQL 的递归部分必须有明确终止条件;PostgreSQL 的 generate_series 则由起点、终点和步长确定边界。

假设 inventory_movements 记录商品出入库流水。直接按流水分组,只能得到“发生过流水的商品—日期”。想让没有出库的日期也显示 0,就把刚才的完整网格放左边,再连接每日汇总。
MySQL 8.4 版本:
WITH RECURSIVE dates AS (
SELECT DATE('2025-06-18') AS report_date
UNION ALL
SELECT report_date + INTERVAL 1 DAY
FROM dates
WHERE report_date < '2025-06-21'
),
selected_products AS (
SELECT product_id, product_name
FROM products
WHERE product_id IN (1, 2
固定流水中,1 号白瓷马克杯和 2 号原木托盘都在 6 月 18 日销售出库 1 件,其余组合没有销售出库。由于出库流水用负数保存,SUM(-quantity) 把报表中的出库件数显示为正数:
整个过程可以概括成三步:“生成维度范围 → CROSS JOIN 造骨架 → LEFT JOIN 填事实”。如果一开始就从 inventory_movements 出发,没有流水的日期根本不存在,后面再写 COALESCE 也造不出缺失的行。COALESCE 只能替换已有行里的 NULL,不能创造一行。
组合不一定来自实体表。假设三件在售商品都要评估两种包装,包装选项可以先写成一个很小的派生表:
WITH package_options AS (
SELECT '普通包装' AS package_name
UNION ALL
SELECT '礼盒包装'
)
SELECT
p.product_id,
p.product_name,
option_set.package_name
FROM products AS p
CROSS JOIN package_options AS option_set
WHERE p.product_id IN (1,
三件商品与两种包装得到六个候选组合。这里的 CROSS JOIN 是在表达产品规则,不是连接键缺失。
CROSS JOIN 的风险也很直接。1 万件商品乘 365 天是 365 万行;再乘 50 个员工,就是 1.825 亿行。查询还没开始填业务数据,骨架已经大得难以处理。
写之前先分别数两个集合:
SELECT COUNT(*) AS product_count
FROM products
WHERE status = '在售';
SELECT DATEDIFF('2025-06-30', '2025-06-01') + 1 AS date_count;MySQL 的第二条结果是 31。把两个数字相乘,就是组合行数的下界。常见控制方法包括缩小日期范围、先过滤商品、按月分批生成,以及只为确实需要补零的维度造网格。
看到 CROSS JOIN 不要条件反射地删除,也不要毫无估算地保留。先问它是否在构造业务上“应该存在的全部组合”,再计算两侧行数乘积是否可控。
NATURAL JOIN 会检查两侧结果中所有同名列,并自动把它们全部当作等值连接条件。它看起来很贴心:不用写 ON,公共列在 SELECT * 中通常也只出现一次。
SELECT
customer_id,
customer_name,
order_id
FROM customers
NATURAL JOIN orders;在当前字段中,两张表只有 customer_id 同名,这条查询相当于按 customer_id 内连接。问题是,连接规则藏在表结构里,SQL 文本没有把规则写出来。
先看两个派生结果。product_list 有 product_id、product_name、status;movement_flags 有 product_id 和 movement_type。两边只有 product_id 同名:
WITH product_list AS (
SELECT
product_id,
product_name,
status
FROM products
),
movement_flags AS (
SELECT
product_id,
movement_type
FROM inventory_movements
)
SELECT *
FROM product_list
NATURAL JOIN movement_flags;后来有人为了统一接口,把 movement_type 起了一个别名 status:
WITH product_list AS (
SELECT
product_id,
product_name,
status
FROM products
),
movement_flags AS (
SELECT
product_id,
movement_type AS status
FROM inventory_movements
)
SELECT *
FROM product_list
NATURAL JOIN movement_flags;SQL 仍然合法,却已经从:
ON product_list.product_id = movement_flags.product_id暗中变成:
ON product_list.product_id = movement_flags.product_id
AND product_list.status = movement_flags.status商品状态可能是 在售、缺货、下架,库存流水类型则是 采购入库、销售出库、顾客退货、盘点调整。两个概念只是碰巧都叫 status,值不可能相等,结果会全部消失,而且代码审查只看最后三行很难发现原因。
如果两张表确实按同名的 customer_id 连接,可以写:
SELECT
customer_id,
c.customer_name,
o.order_id
FROM customers AS c
JOIN orders AS o USING (customer_id);USING (customer_id) 明确列出了连接键。相比 NATURAL JOIN,以后新增同名列不会自动改变条件。它与下面的 ON 在“哪些行匹配”这件事上相同:
FROM customers AS c
JOIN orders AS o
ON o.customer_id = c.customer_id但二者在输出列上不是完全相同。使用 USING 时,公共连接列被合并成一列;使用 ON 再写 SELECT * 时,两侧的 customer_id 都会出现在结果中。外连接时,合并列可理解为从两侧取非空值。
这也是为什么生产查询应显式列出需要的字段,不要长期依赖 SELECT *。表增加一列后,结果接口、列顺序和数据导出都可能变化,即使连接条件没有变化。
NATURAL JOIN 把最需要审查的业务规则藏进“同名列”里。练习语法可以认识它,生产代码应优先显式写 ON;连接键确实同名且含义稳定时,也可以使用 USING 并明确列出键。
连接类型的名字很多,但选择过程并不需要背一整页定义。先确定结果一行代表什么,再确定哪些对象不能少,答案通常就出来了。
可以把决策压缩成下面五个问题:
结果一行是什么?先说“每位顾客一行”“每张订单一行”或“每条明细一行”,不要先写 FROM。
只要真实匹配,还是一侧没有匹配也要显示?只要匹配用内连接;一侧要完整保留就把它放到左边使用 LEFT JOIN。
两侧各自独有的行都要保留吗?支持原生全外连接的数据库用 FULL OUTER JOIN;MySQL 按“左侧全部 + 右侧独有”拼接。
结果需要的是已有关系,还是所有理论组合?已有关系按键连接;理论组合先 CROSS JOIN,并估算乘积。
“表关系放 ON,业务条件放 WHERE”是一个好起点,但外连接需要再精确一点:右表什么样的行有资格参与匹配,也应该放 ON。
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status IN ('已支付', '已发货', '已完成')
WHERE c.city = '杭州'这里三段分别回答:
o.customer_id = c.customer_id:两表怎样建立关系。c.city = '杭州':最后只交付哪一部分顾客。如果业务改成“全国范围内至少有一张有效订单的顾客”,状态条件可以跟着内连接放在 ON,也可以放在 WHERE,但查询应明确改用 INNER JOIN,不要留下一个已经失去保留作用的左连接迷惑读者。
“全部商品及其销量”和“有销量的商品明细”只差几个字,主语却不同:
-- 全部商品,零销量也要保留
FROM products AS p
LEFT JOIN sales_summary AS s
ON s.product_id = p.product_id-- 只看已经形成的销售汇总及商品信息
FROM sales_summary AS s
INNER JOIN products AS p
ON p.product_id = s.product_id不要先想“哪个表大”“哪个表放左边性能更好”。逻辑方向首先由业务保留要求决定,物理访问顺序交给优化器和执行计划。外连接确实会限制一部分重排空间,但不能为了猜优化器偏好而改变查询含义。
连接出错时,最没有帮助的描述是“结果怪怪的”。把问题分成两类:该有的行没了,或者一行变成了多行。两类问题有不同的检查顺序。
固定数据的几个基线很简单:
SELECT COUNT(*) AS customer_rows FROM customers;
SELECT COUNT(*) AS order_rows FROM orders;
SELECT COUNT(*) AS product_rows FROM products;
SELECT COUNT(*) AS order_item_rows FROM order_items;如果目标是“每位顾客一行”,最终行数应该是 10;如果目标是“全部顾客及原始订单”,在当前数据里应该是 14 行:13 个真实顾客—订单配对,加上陆野的一条补位行。先写下这个预期,调试时才知道多了还是少了。
遇到顾客消失,可以从最小查询开始:
-- 第一步:保留侧有 10 行
SELECT customer_id, customer_name
FROM customers;
-- 第二步:加订单,但先不加 WHERE 中的右表条件
SELECT c.customer_id, c.customer_name, o.order_id, o.status
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id;
-- 第三步:只检查补位行是否还在
第三步应该得到陆野。如果第二步有陆野,加入业务条件后却没有,问题几乎一定在后加的 WHERE。检查是否出现 o.status = ...、p.amount > ...、oi.quantity > ... 这类对右表 NULL 补位行不可能为真的条件。
另一种缺失来自连接键写错。把诊断列暂时放进结果:
SELECT
o.order_id,
o.customer_id AS order_customer_id,
c.customer_id AS matched_customer_id,
c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
ON c.customer_id = o.customer_id
ORDER BY o.order_id;不要只选择 customer_name。同时显示两侧连接键,才能区分“键值不同”“右表根本没有这条键”和“匹配成功但展示列本来就是 NULL”。
先验证你以为唯一的键真的唯一:
SELECT
order_id,
COUNT(*) AS row_count
FROM orders
GROUP BY order_id
HAVING COUNT(*) > 1;主键约束存在时,这条查询应该没有结果。但连接使用的未必总是主键。若有人用 customer_name 或 product_name 连接,就应对那些列做同样检查;重名会制造多次匹配。
再检查每个一对多分支的数量:
SELECT product_id, COUNT(*) AS item_rows
FROM order_items
WHERE product_id = 5
GROUP BY product_id;
SELECT product_id, COUNT(*) AS movement_rows
FROM inventory_movements
WHERE product_id = 5
GROUP BY product_id;两条结果分别是 4 和 3。只要把两个原始分支同时连接到商品,商品 5 就会扩成 12 行。这个乘积比“数据库产生了重复”更准确:每个结果配对都是真的,只是它们不符合你想要的最终粒度。
复杂查询可以暂时把业务列删掉,只保留主键和计数:
SELECT
p.product_id,
COUNT(*) AS joined_rows,
COUNT(DISTINCT oi.order_item_id) AS distinct_item_rows,
COUNT(DISTINCT movement.movement_id) AS distinct_movement_rows
FROM products AS p
LEFT JOIN order_items AS oi
ON oi.product_id =
这组数字直接暴露扇出。COUNT(DISTINCT ...) 在这里是诊断工具,用来观察原始实体有多少条;它不代表最终金额也应该用 SUM(DISTINCT amount) 修复。
SELECT DISTINCT 只会删除输出列完全相同的行。如果你没有选择明细主键,两条不同明细可能碰巧显示成相同价格与数量,于是被误删;如果你选择了主键,它们又不会重复,扇出仍在。
更严重的是,DISTINCT 无法挽回已经聚合错的金额:
SUM(DISTINCT oi.unit_price)它的语义是“每种不同单价只加一次”,不是“每条订单明细只加一次”。两件不同商品价格同为 39 元时,其中一件会消失。正确修复仍是先按目标键汇总,再连接。
只看到 NULL,不一定能判断缺失来自哪里。可以把右表非空主键变成匹配标志:
SELECT
c.customer_id,
c.customer_name,
o.order_id,
CASE
WHEN o.order_id IS NULL THEN '没有匹配订单'
ELSE '匹配成功'
END AS match_state
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c
这里提前露出了一点下一章的 CASE:它把连接是否成功翻译成可读标签。之所以检查 o.order_id,是因为订单主键本身不允许 NULL;一旦它是 NULL,就能确定来自外连接补位。
COUNT(*) 和关键主键。WHERE 中引用可空侧的条件,判断是否拒绝补位 NULL。连接调试最有效的方法是让行数变化可见。只盯最终表格,很容易把“数据缺失”“条件过滤”“一对多展开”和“多分支扇出”混成一个问题。
现在把本章方法合在一起。目标很明确:每位顾客一行,显示全部订单数、有效订单数、有效订单金额和成功支付金额;从未下单的陆野也必须出现,所有计数与金额显示 0。
直接把 customers → orders → payments 连起来再分组,在当前数据上可能碰巧正确,因为每张订单只有一条支付;但表结构允许多条支付记录,未来数据一变,订单金额就会重复。稳妥写法先让每个 CTE 都回到“每位顾客一行”:
WITH all_order_summary AS (
SELECT
customer_id,
COUNT(*) AS total_order_count
FROM orders
GROUP BY customer_id
),
valid_order_summary AS (
SELECT
customer_id,
COUNT(*) AS valid_order_count,
SUM(total_amount) AS valid_order_amount
FROM orders
WHERE status IN (
逐层检查这条查询:
all_order_summary 一位下过单的顾客一行,不负责有效状态。valid_order_summary 先过滤有效订单,再按顾客汇总。successful_payment_summary 先按支付状态认定成功金额,再按订单所属顾客汇总。customers 出发,三份汇总都只能补列,不能删除顾客。COALESCE 只负责把已经存在的顾客行中的 NULL 显示为 0;真正创造陆野那一行的是最外层左连接。白露和乔木都不是“没有订单”:她们的 total_order_count 是 1,只是有效订单数为 0。陆野才是从未下单。这正是保留缺失数据的价值——如果从有效订单出发做内连接,三个人都会消失,报表无法区分三种业务状态。
下面的练习都沿用小满商店固定数据。建议先在纸上写一句“结果一行代表什么、谁不能少”,再展开答案。
下面查询为什么会把陆野的订单数显示成 1?怎样修复?
SELECT
c.customer_id,
c.customer_name,
COUNT(*) AS order_count
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.customer_name;写出“全部订单及成功支付金额”的查询。没有支付、支付失败或已退款的订单也要保留,金额显示 0。
把下面查询改写成等价的左连接:
SELECT p.product_id, p.product_name, oi.order_item_id
FROM order_items AS oi
RIGHT JOIN products AS p
ON p.product_id = oi.product_id;在 2025 与 2026 买家名单的 MySQL 兼容全外连接结果上,怎样只保留“只在某一年下过单”的顾客?
要找没有子分类的分类,下面哪个 ON 正确?
用五个部门与三个候选岗位“数据分析”“内容运营”“售后支持”生成 15 个组合。这个问题不依赖现有员工是否已经担任这些岗位。
一条查询同时从商品连接订单明细和库存流水,发现销量与库存变化都被放大。能否用 SUM(DISTINCT quantity) 修复?
上一节综合经营表已经有 total_order_count。请在结果中增加一列:大于 0 显示“已下单”,否则显示“未下单”。
到这里,我们已经能控制几张表相遇后的三件事:用外连接保留缺失,用自连接解释层级,用交叉连接构造原本不存在的完整组合。更重要的是,我们会在查询前声明粒度,也会用行数、主键和反连接定位丢行与重复。
但连接只能决定一行是否出现、旁边补哪些列。它还不能直接回答“库存低于多少算紧张”“不同订单状态应该显示什么提示”“一个顾客该被归到哪种经营标签”。这些问题都需要查询根据当前行的条件选择不同结果。
下一章就从综合报表里的 order_state 开始,学习 CASE 条件逻辑:让 SQL 不只把数据连接起来,还能按照业务规则为每一行作出清楚判断。
同一实体是否在扮演不同角色?员工与经理、子分类与父分类都要给每份表引用清楚的角色别名。
先汇总支付可以守住每张订单一行;从 orders 左连接,待支付的 107、已取消且没有支付的 103 等订单都不会消失。
departments 有 5 行,候选岗位有 3 行,因此结果稳定为 15 行。这里不写 ON 是有意生成所有组合。