Skip to content

JOIN 之后,合计从 480 变成了 880 ​

5 笔订单,金额合计 480。为了在同一张报表里带上商品信息,把订单表和明细表连了一下,SUM(o.amount) 变成 880。再连上支付表,已支付金额从 430 变成 830。SQL 没有报错,每一行数据也都是真的。

报表数字对不上,原因多数不在聚合函数,而在聚合之前:连接改变了「一行代表什么」。订单表的一行是一笔订单;连上明细之后,一行是「一笔订单的一行明细」,订单金额跟着明细行重复出现。之后对它求和、计数、求平均,算的都是另一个东西。

本文用一组小到可以手算的合成数据(5 个用户、5 笔订单、8 行明细、5 笔支付),在 MySQL 8.4.11 上逐个执行这些查询,结果与手算的答案对比。这些是 SQL 的语义,不是 MySQL 特有的行为。实验见文末。

一、扇出:一行变成多行 ​

数据的正确答案(在各自的表上直接求和):订单金额合计 480,明细小计合计 480,已支付合计 430,其中一笔订单还没支付。

sql
SELECT SUM(o.amount)
FROM orders o JOIN order_items i ON i.order_id = o.id;      -- 880,正确是 480

连接结果有 8 行,订单只有 5 笔。有 2 行明细的订单,金额被加了 2 次。

连接两个子表时更糟:

sql
SELECT SUM(p.amount), SUM(i.qty * i.price)
FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN payments    p ON p.order_id = o.id;                    -- 830 和 630,正确是 430 和 480
orders(1 行)订单 2200order_items(2 行)订单 260订单 2140payments(2 行)订单 2150订单 250连接结果(4 行)订单金额明细小计支付金额20060150200605020014015020014050SUM800400400正确200200200×4:行数 = 2 × 2×2:被支付行数放大×2:被明细行数放大连接结果的一行,既不是一笔订单,也不是一行明细或一笔支付检查:COUNT(*) 与 COUNT(DISTINCT 订单 id) 不相等,说明粒度已经变了没有支付的订单在内连接里直接消失,合计还会再少一块
图 1 · 订单 2 金额 200,有 2 行明细、2 笔支付。同时连接两个子表后,每行明细和每笔支付两两组合,一笔订单变成 4 行:订单金额被加了 4 次,明细小计和支付金额各被加了 2 次。三列的合计都不对,而且错的倍数各不相同

明细和支付之间没有关系,它们只是都属于同一笔订单。连在一起之后,每行明细和每笔支付两两组合:支付金额被明细行数放大,明细小计被支付笔数放大。结果 9 行、4 笔订单,那笔没有支付的订单被内连接丢掉,所以明细合计 630 里还少了它的 50。

检查方法很简单:在同一个连接上比较 COUNT(*) 与 COUNT(DISTINCT o.id)。上面两个查询分别是「8 行 / 5 笔订单」和「9 行 / 4 笔订单」。两个数不相等,说明一行已经不是一笔订单,任何对订单属性的 SUM、AVG 都不能直接用;订单数比原表少,说明有订单被连接丢掉了。

二、DISTINCT 修不了 ​

看到数字翻倍,第一反应常常是加 DISTINCT:

sql
SELECT SUM(DISTINCT o.amount)
FROM orders o JOIN order_items i ON i.order_id = o.id;      -- 380,正确是 480

DISTINCT 按值去重,不按订单去重。数据里有两笔金额都是 100 的订单,它们被当成同一个值,只加了一次。真实数据里金额相同的订单很多,这种写法的误差会比翻倍更难发现:数字看起来差不多。

SELECT DISTINCT 整行去重也一样,它只在「重复的行恰好每一列都相同」时才碰巧得到正确结果。

三、先聚合到同一个粒度,再连接 ​

正确的写法是让参加连接的每个输入都是「一笔订单一行」:

sql
SELECT SUM(o.amount), SUM(i.subtotal), SUM(p.paid)
FROM orders o
LEFT JOIN (SELECT order_id, SUM(qty * price) AS subtotal
           FROM order_items GROUP BY order_id) i ON i.order_id = o.id
LEFT JOIN (SELECT order_id, SUM(amount) AS paid
           FROM payments GROUP BY order_id) p ON p.order_id = o.id;   -- 480、480、430
order_items8 行,明细粒度payments5 行,支付粒度GROUP BY order_id5 行:每笔订单的小计GROUP BY order_id4 行:每笔订单的已付orders5 行,订单粒度LEFT JOIN 的结果5 行:一笔订单一行订单金额 480明细小计 480已支付 430
图 2 · 把明细和支付各自按订单聚合成「每笔订单一行」,再和订单表连接。三个输入都是订单粒度,连接是一对一,行数不变;没有明细或没有支付的订单用左连接保留下来,对应的列是 NULL

三个输入都是订单粒度,连接是一对一,行数还是 5。用 LEFT JOIN 是为了保留没有支付的订单,它的 paid 是 NULL,求和时被忽略;如果要逐行展示,用 COALESCE(p.paid, 0)。

写查询之前先回答一句话:结果的一行代表什么。「每笔订单一行」「每个用户每天一行」。然后检查每个要连接的表,在连接键上是不是最多一行;不是,就先聚合。这句话也适合写在报表的定义里,比 SQL 本身更容易评审。

子查询在大表上的执行计划要单独看,读法见读懂 Explain 执行计划。先保证结果正确,再谈快慢。

四、计数:数的是哪一种行 ​

订单左连接支付(有一笔订单有 2 笔支付,一笔订单没有支付):

写法结果数的是
COUNT(*)6连接结果的行数
COUNT(p.id)5支付笔数(NULL 不计)
COUNT(DISTINCT o.id)5订单数
COUNT(DISTINCT p.order_id)4有支付的订单数

四个数字都「对」,只是回答的问题不同。「支付订单数」如果写成 COUNT(*),会把分期支付的订单多数一次,又把未支付的订单也数进去。

同一张表内也有这个问题。明细表里某个商品有 3 行,但包含它的订单只有 2 笔(同一笔订单里出现了两行):COUNT(*) 回答「卖了几行」,COUNT(DISTINCT order_id) 回答「几笔订单买了它」。计算「买了 A 的订单中有多少也买了 B」这类比例时,分子分母都要按订单去重,否则重复的明细行会让比例超过 100%。

五、外连接之后,过滤条件放在哪 ​

要一张「每个用户的已支付金额」,没有支付的用户显示 0:

sql
-- 写法一:只剩 2 个用户
SELECT u.id, SUM(o.amount)
FROM users u LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'PAID'
GROUP BY u.id;

-- 写法二:5 个用户,其中 3 个是 0
SELECT u.id, COALESCE(SUM(o.amount), 0)
FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'PAID'
GROUP BY u.id;

LEFT JOIN 为没有匹配的用户补上一行,右表的列全是 NULL。WHERE o.status = 'PAID' 在连接之后执行,NULL 与任何值比较都不为真,补出来的行被过滤掉,外连接退化成了内连接。只有未支付订单的用户也一起消失。

对右表的过滤写进 ON,对左表的过滤写在 WHERE。

六、「人均」除以的是谁 ​

同一份数据,三种「人均订单金额」:

算法结果分母
AVG(o.amount)(用户连订单)112.54 笔订单
先按用户汇总,再求平均1503 个下过单的用户
所有用户(没下单的算 0)905 个用户

第一种其实是「笔均」:订单多的用户权重更大。连接之后直接 AVG,得到的永远是按行平均。后两种的区别在于没下单的用户算不算,这是业务定义,SQL 不会替你决定,但报表上要写清楚。

七、NOT IN 遇到 NULL ​

找出没有下过单的用户:

sql
SELECT COUNT(*) FROM users
WHERE id NOT IN (SELECT user_id FROM orders);                -- 0,正确是 2

订单表里有一笔游客订单,user_id 是 NULL。id NOT IN (1, 2, NULL) 等价于 id <> 1 AND id <> 2 AND id <> NULL,最后一项的结果是「未知」,整个条件永远不为真,于是一行都不返回。没有报错,结果是空的。

sql
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)     -- 2

NOT EXISTS 不受 NULL 影响。子查询的列可能为 NULL 时用它,或者在子查询里显式过滤 IS NOT NULL。

八、空集和零 ​

没有任何行满足条件时:

表达式结果
SUM(amount)NULL
COUNT(*)0
SUM(amount) / NULLIF(COUNT(*), 0)NULL

SUM 在空集上是 NULL 而不是 0,应用层直接取数会拿到空值;要 0 就写 COALESCE(SUM(amount), 0)。比值的分母可能为 0 时用 NULLIF 把它变成 NULL,得到「没有数据」,而不是报错或者一个误导的 0。「没有数据」和「是 0」在报表上是两件事。

九、检查清单 ​

  1. 写下结果的一行代表什么。
  2. 每个连接:被连接的表在连接键上是否唯一;不唯一就先聚合。
  3. 对比 COUNT(*) 与 COUNT(DISTINCT 主体 id),再与主体表的行数对比。
  4. 不用 DISTINCT 修正合计。
  5. 外连接右表的条件放 ON。
  6. 平均值、比例:写明分母是什么,分子分母同一粒度。
  7. NOT IN 的子查询列不能有 NULL,否则用 NOT EXISTS。
  8. 空集、NULL、零分母各自返回什么。
  9. 拿一小份数据手算一遍,和 SQL 的结果对比。用另一条 SQL 验证,两条可能错得一样。查询由模型生成时,这一步更不能省,见模型写的 SQL 能跑通,离放它去查库还差几道检查。

时间范围和窗口函数的语义问题,见统计到月底那天,数据少了一天。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权