两张明细表一关联,SUM 为什么翻倍:先聚合再连接

举报
yd_239005675 发表于 2026/10/20 00:34:44 2026/10/20
【摘要】 用订单与退款明细复现一对多关联导致的金额扇出,对比直接 SUM、SUM(DISTINCT 金额) 和先聚合再连接,包含同金额记录、单侧为空及无明细客户的可执行 SQL。

一张客户报表要同时显示订单总额和退款总额。订单明细按客户关联,退款明细也按客户关联,最后 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》中的连接与分组说明。正文不设置外部可点击链接,以适应社区的链接白名单要求。样例数据和对照实验为独立构造。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。