SQL 分组查询中的重复计算,指的是在使用了 GROUP BY 之后,由于表连接、数据冗余或聚合函数使用不当,导致某些数值被多次累加或统计,最终得到的结果比真实业务含义偏大。理解数据在被分组之前的行级形态,是规避这类问题的第一步。

一、重复计算的常见来源
最典型的场景是两张表的一对多关联。例如用户表与订单表是一对多关系,若直接把用户表左连接订单表再做 GROUP BY 用户统计消费金额,订单表的多条记录会把用户行的字段复制多份。此时如果在连接后的结果集上用 SUM(订单金额),结果是正确的;但如果订单表本身又关联了订单明细表,一个订单对应多行明细,直接连接后再按用户分组求和,订单金额就会随明细行数被重复累加。
另一个常见来源是 DISTINCT 的误用。有些开发者为了去重,会先写 SELECT DISTINCT * 再外层分组,但 DISTINCT 是对整行去重,如果行中有无关的时间戳或随机字段,根本去不掉,反而让人误以为数据已干净。还有人用 COUNT(列) 想统计不同用户数,却忘了同一用户可能在结果集中出现多次,应该用 COUNT(DISTINCT 列)。
1.1 连接放大示例
下面这段代码就存在重复计算风险:用户 A 有两个订单,订单 1 有三条明细,直接三表连接后按用户汇总,订单金额被明细行放大了。
SELECT
u.user_id,
SUM(o.order_amount) AS wrong_amount
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
LEFT JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY u.user_id;
上面的查询中,orders 表的一行会因为 order_items 的多行而被复制,SUM(o.order_amount) 就会把同一个订单金额加多次。业务上我们往往只想统计每个用户订单金额的总和,而不是被明细放大后的金额。
二、用子查询下推聚合粒度
解决连接放大最稳妥的办法,是把多端的聚合先下推到子查询里,让子查询输出的粒度已经是对应的业务实体(如订单),然后再和一端表关联。这样一端表的行不会被多端复制,分组时也不会重复计算。
以下写法先将订单明细按订单聚合,得到每个订单的金额,再关联用户分组。此时订单行不会膨胀,SUM 的结果就是正确的用户消费总额。
SELECT
u.user_id,
SUM(order_summary.amount) AS right_amount
FROM users u
LEFT JOIN (
SELECT
o.user_id,
o.order_id,
SUM(oi.item_price * oi.qty) AS amount
FROM orders o
LEFT JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY o.user_id, o.order_id
) order_summary ON u.user_id = order_summary.user_id
GROUP BY u.user_id;
这种写法逻辑清晰,子查询内部先完成多端聚合,外层只需要对已经收敛的粒度再次汇总。在大数据量下,数据库优化器也更容易推算出合理的执行计划,避免中间结果集过大。
2.1 使用 CTE 让结构更直观
如果觉得嵌套子查询可读性差,可以用 WITH 定义的公共表表达式(CTE)来拆分步骤,效果和子查询一致,但更利于维护。
WITH order_summary AS (
SELECT
o.user_id,
o.order_id,
SUM(oi.item_price * oi.qty) AS amount
FROM orders o
LEFT JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY o.user_id, o.order_id
)
SELECT
u.user_id,
SUM(s.amount) AS total_amount
FROM users u
LEFT JOIN order_summary s ON u.user_id = s.user_id
GROUP BY u.user_id;
CTE 不会改变数据粒度下推的本质,只是语法层面的重构。对于多层关联的统计需求,把每一层聚合都收敛到独立 CTE 中,可以有效防止后续 JOIN 导致的重复计算。
三、COUNT(DISTINCT) 与去重统计
当目标仅是统计不重复的对象数量时,应优先使用 COUNT(DISTINCT 列)。例如统计访问过商品详情页的不同用户数,如果先按用户和商品分组再去重,不如直接在同一层用 COUNT(DISTINCT user_id) 简洁。
需要注意,COUNT(DISTINCT) 只能针对单列或表达式,且在某些数据库中对 NULL 不计数。若业务要求把 NULL 也视为一个分组,需要配合 COALESCE 处理。
SELECT
product_id,
COUNT(DISTINCT user_id) AS uv
FROM page_view
GROUP BY product_id;
上面语句直接按商品分组,统计不同用户访问数,不会因为用户多次点击而被重复计算。若之前先做了用户和商品的明细展开,再外层去重,既浪费算力又容易写错。
3.1 避免整行 DISTINCT
很多人习惯用 SELECT DISTINCT 来“去重”,但在分组查询里,这往往解决不了业务层面的重复计算。例如下面这样写,由于 view_time 精确到秒,几乎不会重复,DISTINCT 形同虚设。
SELECT DISTINCT user_id, product_id, view_time FROM page_view;
正确做法是明确知道按什么维度去重,用 GROUP BY 或 COUNT(DISTINCT) 表达清楚业务语义,而不是依赖整行去重碰运气。
四、窗口函数带来的另一种思路
如果既要保留明细行,又想看分组后的汇总值,可以用窗口函数。窗口函数不会把行合并,而是在每行上附加聚合结果,因此不存在 GROUP BY 导致的行数收缩,也不会因为后续 JOIN 而重复计算原值。
下面示例给每个订单明细行附上该订单的总金额,订单总金额在订单内每行都相同,不会随明细行数变化而错误放大。
SELECT
oi.order_id,
oi.item_id,
oi.item_price * oi.qty AS item_amount,
SUM(oi.item_price * oi.qty) OVER (PARTITION BY oi.order_id) AS order_amount
FROM order_items oi;
窗口函数适合明细与汇总并存的报表场景。若只需要汇总结果,仍建议用子查询下推加 GROUP BY 的方式,性能通常更好。
五、实践中的检查清单
写完分组 SQL 后,可以用几个问题快速自检:结果集在被 GROUP BY 之前,每一行代表什么粒度?这个粒度是不是已经被多端连接放大过?聚合函数作用的列是否会在放大后出现重复值?如果答案不确定,先单独跑一遍连接后的明细,看行数是否符合预期。
另外,在测试环境用已知数据验证非常关键。比如构造一个用户两个订单、其中一个订单三条明细的样例,对比错误写法和正确写法的输出差异,能直观确认是否消除了重复计算。
| 写法 | 风险点 | 适用场景 |
|---|---|---|
| 直接多表 JOIN 后 GROUP BY | 多端连接放大导致重复求和 | 确认为一对一关联时 |
| 子查询先聚合再 JOIN | 无明显重复计算风险 | 一对多统计汇总 |
| COUNT(DISTINCT 列) | 忽略 NULL 或表达式限制 | 去重计数 |
| 窗口函数 | 不缩减行数,明细报表适用 | 明细与汇总同屏 |
把握住数据粒度与聚合下推两个核心,就能在绝大多数业务统计中避开 SQL 分组查询的重复计算坑。