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,其中一笔订单还没支付。
SELECT SUM(o.amount)
FROM orders o JOIN order_items i ON i.order_id = o.id; -- 880,正确是 480连接结果有 8 行,订单只有 5 笔。有 2 行明细的订单,金额被加了 2 次。
连接两个子表时更糟:
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明细和支付之间没有关系,它们只是都属于同一笔订单。连在一起之后,每行明细和每笔支付两两组合:支付金额被明细行数放大,明细小计被支付笔数放大。结果 9 行、4 笔订单,那笔没有支付的订单被内连接丢掉,所以明细合计 630 里还少了它的 50。
检查方法很简单:在同一个连接上比较 COUNT(*) 与 COUNT(DISTINCT o.id)。上面两个查询分别是「8 行 / 5 笔订单」和「9 行 / 4 笔订单」。两个数不相等,说明一行已经不是一笔订单,任何对订单属性的 SUM、AVG 都不能直接用;订单数比原表少,说明有订单被连接丢掉了。
二、DISTINCT 修不了
看到数字翻倍,第一反应常常是加 DISTINCT:
SELECT SUM(DISTINCT o.amount)
FROM orders o JOIN order_items i ON i.order_id = o.id; -- 380,正确是 480DISTINCT 按值去重,不按订单去重。数据里有两笔金额都是 100 的订单,它们被当成同一个值,只加了一次。真实数据里金额相同的订单很多,这种写法的误差会比翻倍更难发现:数字看起来差不多。
SELECT DISTINCT 整行去重也一样,它只在「重复的行恰好每一列都相同」时才碰巧得到正确结果。
三、先聚合到同一个粒度,再连接
正确的写法是让参加连接的每个输入都是「一笔订单一行」:
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三个输入都是订单粒度,连接是一对一,行数还是 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:
-- 写法一:只剩 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.5 | 4 笔订单 |
| 先按用户汇总,再求平均 | 150 | 3 个下过单的用户 |
| 所有用户(没下单的算 0) | 90 | 5 个用户 |
第一种其实是「笔均」:订单多的用户权重更大。连接之后直接 AVG,得到的永远是按行平均。后两种的区别在于没下单的用户算不算,这是业务定义,SQL 不会替你决定,但报表上要写清楚。
七、NOT IN 遇到 NULL
找出没有下过单的用户:
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,最后一项的结果是「未知」,整个条件永远不为真,于是一行都不返回。没有报错,结果是空的。
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) -- 2NOT 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」在报表上是两件事。
九、检查清单
- 写下结果的一行代表什么。
- 每个连接:被连接的表在连接键上是否唯一;不唯一就先聚合。
- 对比
COUNT(*)与COUNT(DISTINCT 主体 id),再与主体表的行数对比。 - 不用
DISTINCT修正合计。 - 外连接右表的条件放
ON。 - 平均值、比例:写明分母是什么,分子分母同一粒度。
NOT IN的子查询列不能有 NULL,否则用NOT EXISTS。- 空集、NULL、零分母各自返回什么。
- 拿一小份数据手算一遍,和 SQL 的结果对比。用另一条 SQL 验证,两条可能错得一样。查询由模型生成时,这一步更不能省,见模型写的 SQL 能跑通,离放它去查库还差几道检查。
时间范围和窗口函数的语义问题,见统计到月底那天,数据少了一天。
配套实验
- codesphere-labs/storage/sql-result-grain:MySQL 8.4.11 上 30 条查询的结果与手算期望值逐行对比,期望值附算式(验证记录)
参考资料