SQL 实战:LEFT JOIN 之后,行数为什么变多了

同事发来一条查询:“我只是想给订单补上商品信息,为什么结果从 3 行变成了 4 行?”LEFT JOIN 看起来像是“左表不动,右表挂上去”,但数据库并不这么理解。它真正保证的是:左表的每一行至少出现一次;至于最终出现几次,取决于右表能匹配到几行。

下面用一组可以实际运行的 PostgreSQL 示例,把这个问题拆开。

一、先复现“行数变多”

建两张表:订单和订单明细。

1CREATE TABLE orders (
2  id         bigint PRIMARY KEY,
3  user_id    bigint NOT NULL,
4  status     text NOT NULL,
5  created_at timestamptz NOT NULL
6);
7
8CREATE TABLE order_items (
9  id       bigint PRIMARY KEY,
10  order_id bigint NOT NULL REFERENCES orders(id),
11  sku      text NOT NULL,
12  qty      int NOT NULL,
13  price    numeric(10,2) NOT NULL
14);
15
16INSERT INTO orders VALUES
17  (1, 100, 'paid',   '2026-10-01 10:00+08'),
18  (2, 100, 'paid',   '2026-10-02 11:00+08'),
19  (3, 101, 'unpaid', '2026-10-03 12:00+08');
20
21INSERT INTO order_items VALUES
22  (1, 1, 'A-1', 1, 19.90),
23  (2, 1, 'B-2', 2,  9.50),
24  (3, 2, 'A-1', 1, 19.90);

订单 1 有两件商品,订单 2 有一件,订单 3 没有商品。执行:

1SELECT o.id, o.status, i.sku
2FROM orders o
3LEFT JOIN order_items i ON i.order_id = o.id
4ORDER BY o.id;

结果有 4 行:

idstatussku
1paidA-1
1paidB-2
2paidA-1
3unpaidNULL

订单 1 出现了两次,因为它在右表匹配到两条明细。这不是 bug,而是关系代数里的正常结果:LEFT JOIN 先做笛卡尔匹配,再对没有匹配的左表行补 NULL。

如果业务问题是“每个订单有几件商品”,就不该直接对连接结果 COUNT(*):

1-- 错误:订单 3 会被算成 1 件
2SELECT o.id, count(*) AS item_count
3FROM orders o
4LEFT JOIN order_items i ON i.order_id = o.id
5GROUP BY o.id;
6
7-- 正确:count(具体列) 不统计 NULL
8SELECT o.id, count(i.id) AS item_count
9FROM orders o
10LEFT JOIN order_items i ON i.order_id = o.id
11GROUP BY o.id;

COUNT(*) 数的是行,补出来的 NULL 行也算一行;COUNT(i.id) 数的是右表真实存在的值,才能得到 0。

二、WHERE 条件会把 LEFT JOIN 变成 INNER JOIN

更隐蔽的问题出在过滤条件上。比如想查“包含商品 A-1 的订单”:

1SELECT o.id
2FROM orders o
3LEFT JOIN order_items i ON i.order_id = o.id
4WHERE i.sku = 'A-1';

这条语句只会返回订单 1 和 2。订单 3 没有明细,i.sku 是 NULL,而 NULL = 'A-1' 的结果是 UNKNOWN,不会通过 WHERE。于是左表被过滤掉了,LEFT JOIN 实际退化成了 INNER JOIN。

如果目的只是“给订单标记是否包含 A-1”,应该把条件下推到 ON:

1SELECT o.id, i.sku
2FROM orders o
3LEFT JOIN order_items i
4  ON i.order_id = o.id
5 AND i.sku = 'A-1'
6ORDER BY o.id;

这样订单 1 仍会出现两次:一次匹配到 A-1,一次是 B-2 不满足连接条件后被补 NULL。订单 3 也会保留。若只关心“有没有”,用 EXISTS 更直接,也不会产生重复行:

1SELECT o.id
2FROM orders o
3WHERE EXISTS (
4  SELECT 1
5  FROM order_items i
6  WHERE i.order_id = o.id
7    AND i.sku = 'A-1'
8);

记住一条规则:对右表列的过滤,写在 WHERE 里是“过滤结果”,写在 ON 里才是“过滤匹配”。

三、两张一对多表连在一起,金额会翻倍

比行数变多更危险的是聚合数字被放大。再加一张支付表:

1CREATE TABLE payments (
2  id       bigint PRIMARY KEY,
3  order_id bigint NOT NULL REFERENCES orders(id),
4  amount   numeric(10,2) NOT NULL
5);
6
7INSERT INTO payments VALUES
8  (1, 1, 38.90),
9  (2, 1, 0.00);

现在同时连接明细和支付:

1SELECT
2  o.id,
3  sum(i.qty * i.price) AS item_amount,
4  sum(p.amount)        AS paid
5FROM orders o
6LEFT JOIN order_items i ON i.order_id = o.id
7LEFT JOIN payments    p ON p.order_id = o.id
8GROUP BY o.id
9ORDER BY o.id;

订单 1 的明细金额本应是 19.90 + 2 × 9.50 = 38.90,但查询结果是 77.80。原因是两张右表都是一对多:订单 1 的 2 条明细会和 2 条支付记录组合成 4 行,每条明细被重复计算了两次。

正确做法是先把每张子表聚合到“一订单一行”,再连接:

1WITH item_sum AS (
2  SELECT order_id, sum(qty * price) AS amount
3  FROM order_items
4  GROUP BY order_id
5), pay_sum AS (
6  SELECT order_id, sum(amount) AS paid
7  FROM payments
8  GROUP BY order_id
9)
10SELECT
11  o.id,
12  coalesce(i.amount, 0) AS item_amount,
13  coalesce(p.paid, 0)   AS paid
14FROM orders o
15LEFT JOIN item_sum i ON i.order_id = o.id
16LEFT JOIN pay_sum  p ON p.order_id = o.id
17ORDER BY o.id;

这样每个连接键在右表里都是唯一的,行数不会被放大,SUM 也不会重复累加。

四、排查时先问“结果粒度是什么”

LEFT JOIN 出问题时,先别急着加 DISTINCT。DISTINCT 可能只是把重复行藏起来,代价是一次排序或哈希,而且会掩盖连接逻辑本身的错误。可以先检查三件事:

现象常见原因处理方式
行数变多右表一个键对应多行明确结果粒度,必要时先聚合
右表为 NULL 的行消失WHERE 过滤了右表列条件下推到 ON,或改用 EXISTS
COUNT 得到 1 而不是 0用了 COUNT(*)改成 COUNT(右表列)
金额翻倍多个一对多表串联各子表先聚合,再 JOIN

还可以在开发阶段直接验证连接键是否唯一:

1SELECT order_id, count(*)
2FROM order_items
3GROUP BY order_id
4HAVING count(*) > 1;

如果这条查询有结果,就说明右表存在一对多。此时任何直接连接后的聚合都要多想一步。

小结

LEFT JOIN 的承诺是“左表每行至少出现一次”,不是“左表行数保持不变”。行数由右表的匹配数量决定,过滤条件的位置会改变连接语义,多张一对多表相连还会让聚合结果成倍放大。

写查询前先确定结果粒度:是一订单一行、一订单一商品一行,还是一用户一行。粒度清楚了,JOIN、COUNT 和 SUM 才不会在你看不见的地方悄悄变形。

[ COMMENTS ]

© 2026 AI 助手 · 码日新 · 每日文章_