SQL 聚合前为什么要先对子表预汇总

顾言
顾言
发布时间:2026-08-24| 201人浏览过
原创

前言

在 SQL 里,`LEFT JOIN` 后直接做 `SUM`、`COUNT` 是最常见、也最容易被忽略的统计陷阱之一。本文从“为什么会翻倍”讲到“为什么预汇总更快”,再补上 `DISTINCT` 的误区、过滤条件该放哪、CTE 和子查询怎么选,帮助你判断什么场景必须先把子表压到正确粒度再做关联。

很多人第一次遇到 LEFT JOIN 后 SUM 翻倍,都会怀疑是不是 SQL 写法有坑,或者数据库算错了。其实问题出在聚合粒度:只要主表一行能匹配子表多行,JOIN 之后参与计算的就已经不是“原始业务行”,而是被展开过的中间结果集。要想把统计结果算准,同时控制中间数据量,关键做法就是先对子表按关联键预汇总,再把结果回连到主表。

为什么 LEFT JOIN 后 SUM 会翻倍

根本原因很简单:当主表的一行关联到子表的 N 行时,JOIN 会先把这一行复制成 N 行,再交给后面的聚合函数处理。于是 SUM、COUNT 统计的其实是“物理展开后的行数”,结果自然会被放大。

这不是数据库 bug,而是关系代数的正常行为。比如要查“每个用户的订单总金额”,如果你在 users LEFT JOIN orders 之后直接写 SUM(orders.amount),看起来像是在按用户求和,实际上是在对 JOIN 后的明细行求和。只要后续还接了别的明细表,放大效应会更明显。

为什么会出现重复累加

可以把它理解成两步:

展示 LEFT JOIN 后明细行扩张以及预汇总后按 user_id 回连的对比信息图
先 JOIN 再聚合 vs 先预汇总再 JO先汇总再 JOIN,核心是把子表压到与统计口径一致的粒度,再参与关联。
  1. 先做 JOIN,得到展开后的结果集。
  2. 再做 SUM、COUNT 等聚合。

也就是说,聚合函数并不知道哪一行是“业务上重复出来的”,它只会老老实实对当前结果集逐行计算。

正确做法:先对子表预汇总再 JOIN

避免重复统计的常用方式,是先在子查询里对子表按关联键聚合,让每个逻辑单位只保留一行,再去和主表做 LEFT JOIN。这样外层 JOIN 时,子表已经不是明细表,而是“每个用户一行”的汇总结果。

SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id

这个子查询的意义在于:先把 orders 压缩到 user_id 粒度,再交给外层查询使用。外层如果要保留没有订单的用户,还需要配合 COALESCE 把空值补成 0。

SELECT
  u.id,
  COALESCE(o.total_amount, 0) AS total_amount
FROM users u
LEFT JOIN (
  SELECT user_id, SUM(amount) AS total_amount
  FROM orders
  GROUP BY user_id
) AS o
  ON u.id = o.user_id;

这类子查询要满足的几个条件

  • 子查询只返回“分组键 + 聚合值”,例如:SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id
  • 外层 JOIN 键必须严格对应这个分组键
  • 子查询必须带别名,MySQL 中常见的 Error Code: 1248,很多时候就是漏了 AS t

过滤条件为什么要写在子查询内部

如果只统计已支付订单,像 WHERE status = 'paid' 这种条件,应该写在预汇总子查询里。原因是它决定了“参与聚合的明细范围”,属于子表内部逻辑。

展示 DISTINCT 与预汇总在统计语义和性能上的差异信息图
DISTINCT 的适用边界`DISTINCT` 只能解决部分重复计数问题,金额汇总场景仍应回到预汇总。
SELECT
  u.id,
  COALESCE(o.total_amount, 0) AS total_amount
FROM users u
LEFT JOIN (
  SELECT user_id, SUM(amount) AS total_amount
  FROM orders
  WHERE status = 'paid'
  GROUP BY user_id
) AS o
  ON u.id = o.user_id;

如果把这个条件挪到外层 WHERE,就会把原本用于“保留主表全部记录”的 LEFT JOIN 语义破坏掉,实际效果更接近 INNER JOIN。

为什么预汇总还能明显降低开销

预汇总不仅是为了算对结果,也常常直接决定 SQL 能不能在大表场景下跑得动。

假设三张都是百万级的明细表,直接 JOIN 之后,中间结果可能膨胀到上千万行;但聚合本身往往只需要一次扫描。把 SUM 放在 JOIN 之后,相当于先让数据库把所有组合都拼出来,再去统计。反过来,如果先按 user_id 或 order_id 汇总,每个逻辑单位只保留一行,中间结果集就会从千万级压缩到百万级甚至更少。

所以“先汇总再 JOIN”本质上是在主动控制聚合粒度和中间数据规模,而不是单纯多写几行 SQL。

为什么 DISTINCT 救不了 SUM 和 AVG

很多人遇到重复统计后,会第一时间想到 DISTINCT。但它只对少数场景有效,不能把所有聚合问题都修正回来。

COUNT(DISTINCT id) 的确可以绕过重复计数,因为它统计的是“去重后的标识数”。可一旦换成金额类指标,问题就完全不同了:SUM(DISTINCT amount) 去重的是金额值本身,而不是订单行。两个不同订单只要金额相同,就只会被加一次,逻辑上显然是错的。

  • 适用:COUNT(DISTINCT orders.id) 统计每个客户下了几个订单
  • 不适用:SUM(DISTINCT orders.amount) 计算总销售额,它会把相同金额只累计一次
  • 真正需要的仍然是:先在子查询里 GROUP BY user_id 算好每人总额,再回到主查询

另外在大数据量下,DISTINCT 往往还要做哈希去重,通常比预汇总更慢,也未必能很好利用索引。

CTE 和子查询该怎么选

写预汇总时,既可以用嵌套子查询,也可以用 CTE。两者执行计划通常接近,差别更多在可读性、复用性和优化器处理方式上。

适合用 CTE 的场景

如果同一张表的预汇总结果要被多次引用,CTE 通常更清晰。比如同一张 order_items 表,既要算总金额,又要算商品数,用 CTE 可以把这层逻辑单独命名,避免重复写两次。

这类写法更适合逻辑分层明确、同一聚合结果需要多处复用的查询。

适合用子查询的场景

如果只是一次性引用,子查询通常更轻量,写起来也更直接。在某些场景下,尤其明细表已有覆盖索引时,子查询未必比 CTE 差,还可能避免额外的物化开销。

版本和数据库差异

  • SQLite 不支持写入式 CTE,但查询类 CTE 可用
  • MySQL 8.0+、PostgreSQL 12+ 一般都推荐优先用 CTE 显式表达查询意图

结论:预汇总是在主动控制聚合粒度

预汇总不是“多写几行 SQL”的形式主义,而是把聚合粒度控制权拿回到查询设计者手里。只要你的统计口径是“每个用户一条汇总结果”,那就应该先把子表压到这个粒度,再参与 JOIN。

否则,数据库到底是在对多少行做 SUM,往往连你自己都很难一眼看清。这也是为什么一旦涉及一对多 JOIN 后再聚合,先对子表预汇总几乎总是更稳妥的写法。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。