两张明细表一关联,SUM 为什么翻倍:先聚合再连接
一张客户报表要同时显示订单总额和退款总额。订单明细按客户关联,退款明细也按客户关联,最后 GROUP BY 客户,看起来顺理成章,金额却突然翻倍,甚至变成三倍。
遇到这种情况,先不要怀疑 SUM。可以先问:聚合前的一行,究竟代表一笔订单、一笔退款,还是两者的一种组合?
本文用独立构造的小数据,在 SQLite 中比较三种写法:直接关联后求和、按金额 DISTINCT 求和,以及两张明细表分别聚合后再连接。所有金额均使用整数“分”,输出也按分显示。
一、两张明细表,各自都没有重复记录
假设需要按客户统计某个选定范围内的订单和退款。两类记录都带有客户编号;这里的关联键是客户编号,并没有把某笔退款与某笔订单配对。
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
amount_cents INTEGER NOT NULL CHECK (amount_cents >= 0)
);
CREATE TABLE refunds (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
amount_cents INTEGER NOT NULL CHECK (amount_cents >= 0)
);
INSERT INTO customers VALUES
(1, '小林'), (2, '小周'), (3, '小陈'), (4, '小吴');
INSERT INTO orders VALUES
(101, 1, 10000), (102, 1, 20000), (103, 2, 5000);
INSERT INTO refunds VALUES
(201, 1, 1000), (202, 1, 2000),
(203, 1, 3000), (204, 3, 500);
小林有两笔订单、三笔退款。直接数原表可知,他的订单总额是 30000 分,退款总额是 6000 分。小周只有订单,小陈只有退款,小吴两边都没有。
这些状态用于检验报表是否保留了所有客户,以及空侧是否按约定显示为零。示例的两类汇总是客户级统计,不据此判断单笔退款归属,也不由两项相减推断真实结算余额。
二、先把 JOIN 的中间行展示出来
在写汇总之前,先查看明细组合:
SELECT c.id AS customer_id,
o.id AS order_id, o.amount_cents AS order_cents,
r.id AS refund_id, r.amount_cents AS refund_cents
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
LEFT JOIN refunds r ON r.customer_id = c.id
WHERE c.id = 1
ORDER BY o.id, r.id;
结果共有六行。订单 101 分别和退款 201、202、203 组合;订单 102 也分别和这三笔退款组合。每笔订单出现三次,每笔退款出现两次。
在这条特定查询里,同一客户有 n 笔订单、m 笔退款,如果两边都非空,组合行数就是 n 乘 m。由于使用了 LEFT JOIN,空侧仍会补一行,因此包括空侧时,可按 max(1,n) 乘 max(1,m) 推导行数。
这不是说数据库里新增了重复订单,也不是说退款表的数据脏了。原表主键仍然唯一,重复发生在连接结果中。对于这个客户,连接结果的一行已经变成“某笔订单与某笔退款的组合”。
三、GROUP BY 不会自动恢复明细粒度
现在按照常见写法汇总:
SELECT c.id, c.name,
COALESCE(SUM(o.amount_cents), 0) AS order_total,
COALESCE(SUM(r.amount_cents), 0) AS refund_total,
COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
LEFT JOIN refunds r ON r.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;
小林得到订单 90000 分、退款 12000 分、订单计数 6。原本的 30000 分订单被加了三遍,6000 分退款被加了两遍。SUM 正确地累加了输入行,只是这些行已经比业务明细更细了。
GROUP BY 客户只是在连接结果上分组,不会知道哪些订单金额是由关联引起的重复。将查询改成 DISTINCT 客户,或者在结果外面再套一层相同分组,也不能让金额自行回到原值。
COUNT 也受同一问题影响。这里的 COUNT(o.id) 数的是连接后非空的订单编号出现次数,不是原表订单数量。按唯一订单主键去重可以解决这一项计数,但不能据此认为其他金额汇总也已经正确。
四、SUM(DISTINCT 金额) 为什么不可靠
一种看似有效的修补是给金额加 DISTINCT:
SELECT c.id,
COALESCE(SUM(DISTINCT o.amount_cents), 0) AS order_total,
COALESCE(SUM(DISTINCT r.amount_cents), 0) AS refund_total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
LEFT JOIN refunds r ON r.customer_id = c.id
GROUP BY c.id
ORDER BY c.id;
在最初的数据中,小林恰好得到正确金额,因为他的订单金额彼此不同,退款金额也彼此不同。但 DISTINCT 处理的是传给聚合函数的金额值,不是业务记录主键。
只要两笔不同记录恰好有相同金额,这个修补就会少算。给原表做两处修改:
UPDATE orders SET amount_cents = 10000 WHERE id = 102;
UPDATE refunds SET amount_cents = 1000 WHERE id = 202;
这时小林的真实订单总额为 20000 分,退款总额为 5000 分。按金额 DISTINCT 却只算出 10000 分和 4000 分。两笔 10000 分的订单有不同编号,都是真实明细,不能因为金额相等就只算一笔。
检验这类 SQL 时,同金额、不同主键应该成为专门的边界数据。只用所有金额都不同的样例,很容易误以为 DISTINCT 已经修复问题。
五、先把两侧都收敛到客户粒度
需求是一名客户一行,因此可以先让两张明细表分别形成“每个客户至多一行”的结果,再关联客户表:
WITH order_summary AS (
SELECT customer_id,
SUM(amount_cents) AS order_total,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
), refund_summary AS (
SELECT customer_id,
SUM(amount_cents) AS refund_total
FROM refunds
GROUP BY customer_id
)
SELECT c.id, c.name,
COALESCE(o.order_total, 0) AS order_total,
COALESCE(r.refund_total, 0) AS refund_total,
COALESCE(o.order_count, 0) AS order_count
FROM customers c
LEFT JOIN order_summary o ON o.customer_id = c.id
LEFT JOIN refund_summary r ON r.customer_id = c.id
ORDER BY c.id;
在修改金额后的数据中,小林得到 20000 分、5000 分和两笔订单;小周得到 5000 分、零退款和一笔订单;小陈得到零订单和 500 分退款;小吴的两项金额和订单数都为零。
订单汇总的每个客户至多一行,退款汇总也至多一行,两个结果关联到客户时便不会再次形成订单与退款的组合。COALESCE 的作用是把没有汇总记录时的 NULL 显示为约定的零;它本身并不能消除扇出造成的重复。
如果报表还要区分月份,汇总粒度就可能变成“客户加月份”,相应连接键也必须包含月份。只改 GROUP BY,却仍然只按客户连接,会重新引入跨月份组合。先聚合再连接的关键是粒度与连接键一致,不是 CTE 这种语法形式本身。
六、把筛选和验证一起带上
实际查询通常还有日期、支付状态和客户范围。订单日期条件应在订单汇总中处理,退款条件应在退款汇总中处理,并先明确是按退款发生日期还是原订单日期归属。不能因为两边都有日期字段,就默认它们表达相同业务范围。
至少准备五类数据检查结果:两侧都有多笔;两笔不同记录金额相同;只有订单;只有退款;两边都没有。还应从原始明细分别求和,对照最终客户汇总,而不只检查结果行数是否等于客户数。一客户一行的输出,也可能携带已经放大的金额。
性能需要另行验证。提前聚合有时能减少连接输入,但是否更快取决于过滤范围、索引、数据分布和数据库执行计划。本文只验证结果语义,不把这组内存样例当作性能结论。
本文代码使用独立 SQLite 内存数据库逐段执行,验证默认金额、相同金额记录和空明细表。没有连接生产数据库。金额值保持在安全的小整数范围,真实系统还需核对币种、精度、数据类型及整数范围。
参考出处:SQLite 官方文档《Built-in Aggregate Functions》中的 DISTINCT、COUNT 与 SUM 说明;PostgreSQL 官方文档《7.2. Table Expressions》中的连接与分组说明。正文不设置外部可点击链接,以适应社区的链接白名单要求。样例数据和对照实验为独立构造。
- 点赞
- 收藏
- 关注作者
评论(0)