你写下了这样一句:

1SELECT user_id, status, COUNT(*)
2FROM orders
3GROUP BY user_id;

数据库报错,说 status 必须出现在 GROUP BY 里,或者被聚合函数包起来。很多人到这一步的处理方式是“把 status 加到 GROUP BY 后面”,报错消失,查询能跑,但结果对不对完全没底。

这个错不是数据库在刁难你,它背后是一个必须想清楚的问题:GROUP BY 之后结果集里的每一行,到底代表什么?

分组做的事:把行分进桶,一行输出对应一个桶

假设 orders 表有六行:

order_iduser_idamountstatus
101130.00paid
102150.00refunded
103220.00paid
1042NULLpaid
105399.00paid
106140.00paid

GROUP BY user_id 做的事,是按 user_id 的值把六行分到三个桶里:桶 1 装 101、102、106;桶 2 装 103、104;桶 3 装 105。

然后:

1SELECT user_id, COUNT(*) AS n
2FROM orders
3GROUP BY user_id;
user_idn
13
22
31

输入六行,输出三行。输出行数等于桶的个数,也就是 user_id 有多少个不同的值(这里说了“有多少行”和“有多少个用户”是两件事,这正是聚合的价值)。这和 DISTINCT user_id 得到的行数一样,区别在于 DISTINCT 只能给你去重后的键,而 GROUP BY 让你在每组上再算东西。

物理上未必真的建了三个桶——数据库可能先排序、可能用哈希表——但逻辑上的这层“分桶”是理解后面一切的地基。GROUP BY a, b 就是按 (a, b) 这个组合分桶,桶的个数等于不同组合的个数。

为什么一个组只能出一行:每个格子必须由“这一组”唯一确定

结果集本身是一张表。表的每一行、每一列只能放 一个值,不能放一串值。这句话看似废话,但它直接推出了 GROUP BY 的规则。

桶 1 里有三行,status 分别是 paid、refunded、paid。现在你想在输出里写一列 status,问题是:这一格填什么?填 paid 吗?那 102 那行的 refunded 去哪了?填 refunded 呢?填一个都算是在撒谎。

于是就有了那条规则:SELECT 列表里的每一列,必须能由“这一组”唯一确定。 满足这个条件只有两条路——要么这一列本身就是分组键的一部分(同一个桶里当然只有一个 user_id 值),要么它被聚合函数包起来(聚合函数的作用就是把一组值压成一个值)。这就是 status 被拒的原因:它既不在分组键里,又没被压成单值,在桶 1 里有两个不同的取值。

推开来看,GROUP BY user_id 之后的 SELECT 列表里,合法的东西只有三类:分组键表达式本身(user_id)、聚合函数(COUNT(*)SUM(amount))、由前两者组合出来的表达式(SUM(amount) / COUNT(*))。其余一律非法。

这条规则不是所有数据库的执行力度都一样,这值得知道,因为它会造成“同一句 SQL 在不同地方结果不同”:

  • PostgreSQL 从 9.1 起会检查函数依赖:如果你 GROUP BY 的是主键(或能唯一确定整行的键),那么该表其它列由它唯一决定,可以裸着 SELECT。GROUP BY order_id 时写 SELECT order_id, user_id, amount 是合法的,因为每个 order_id 只有一个 user_id。
  • MySQL 历史上默认宽松,遇到不合法的列会 随便挑一行 输出,结果不确定——同一句查询今天跑出 paid,明天可能跑出 refunded。MySQL 5.7.5 起默认开启 ONLY_FULL_GROUP_BY,把这种查询直接拒绝。

“随便挑一行”听上去像省事,其实是把不确定性埋进报表里。宁可报错,也不要一个每天数字在变的结果。

聚合在分完组之后算,这条顺序决定了 WHERE 和 HAVING 的分工

把逻辑处理顺序写出来:

正在绘制图表

(这是逻辑顺序,数据库实际执行的顺序可以完全不同,优化器有自己的打算。但它们必须保证结果与这个逻辑顺序等价。)

关键是把 WHERE 和 HAVING 放在这张图的不同位置上:WHERE 在分桶之前,作用的单位是 ;HAVING 在聚合之后,作用的单位是

所以 WHERE COUNT(*) > 3 报错,不是语法上禁用聚合函数,而是那个位置上每组的聚合值 还不存在。等聚合算完,行已经变成组了,位置也过了,只能用 HAVING。

顺序一明确,几个取数场景的区别就清楚了。看这两句:

1-- A:先剔掉退款行,再统计
2SELECT user_id, COUNT(*) AS n
3FROM orders
4WHERE status = 'paid'
5GROUP BY user_id;
user_idn
12
22
31

桶 1 里 102 那行在分桶前就被删了,所以 COUNT(*) 算的是 2。

1-- B:统计全部订单,再只留单数 ≥ 2 的用户
2SELECT user_id, COUNT(*) AS n
3FROM orders
4GROUP BY user_id
5HAVING COUNT(*) >= 2;
user_idn
13
22

这里 COUNT(*) 是 3 和 2,因为筛掉 102 这件事没发生过;HAVING 只负责删掉整个桶 3,不会把桶 1 的计数改小。

两者叠加(WHERE status = 'paid'HAVING COUNT(*) >= 2)得到 user 1 的 2 和 user 2 的 2。

这个差别在实际取数里非常要紧:WHERE 里的条件会改变聚合函数的输入,HAVING 里的条件不会。 所以“按条件过滤后再统计”和“统计后再按结果过滤”,是两个不同的问题,不能互换。等到你要“订单总数照常算,但只显示已支付单数达到 2 的用户”时,两种都做不到,得用条件聚合:

1SELECT user_id,
2       COUNT(*) AS total_orders,
3       SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_orders
4FROM orders
5GROUP BY user_id
6HAVING SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) >= 2;

注意 CASE WHEN 被包在 SUM 里——它仍然是聚合,所以合法。这也顺手说明了为什么 HAVING 不能写 HAVING status = 'paid'status 不是分组键,在一个桶里可能有多个值,这个比较根本没有唯一的答案。

从这条规则里导出的三个具体坑

一、AVG 的分母是“非 NULL 行数”,不是总行数。 看这句:

1SELECT user_id,
2       COUNT(*)      AS n,
3       COUNT(amount) AS n_amount,
4       SUM(amount)   AS total,
5       AVG(amount)   AS avg_amount
6FROM orders
7GROUP BY user_id;
user_idnn_amounttotalavg_amount
133120.0040.000000
22120.0020.000000
31199.0099.000000

user 2 有两单,但其中一单 amount 是 NULL。COUNT(*) 数行,给 2;COUNT(amount) 只数非 NULL 的值,给 1。SUMAVG 都忽略 NULL,所以 AVG 是 20 / 1 = 20,不是 20 / 2 = 10。如果这个 AVG 是“客单价”的一个环节,分母选错就会系统性高估。真正意义上的客单价要么是 SUM(amount) / COUNT(DISTINCT order_id)(统计订单总额除以订单数),要么用 SUM(amount) / COUNT(amount) 明确表示“平均每笔有金额的订单多少钱”——写成 AVG(amount) 时要清楚自己拿到的是后者。

顺带一句整数除法:SUM(amount) / COUNT(*)amount 是整数时,多数数据库会做整数除法向下取整,20 / 3 得到 6。要么把列类型搞清楚,要么乘 1.0,要么显式 CAST

二、分组键里的 NULL 会自成一组。 GROUP BY user_id 时,所有 user_id IS NULL 的行会被聚成一个桶。GROUP BY 内部把 NULL 视为彼此相等,这和 WHERE user_id = NULL 永远不成立(必须写 IS NULL)是两套规则。取数时如果关联字段没打通,你会得到一个 user_id 为 NULL 的桶,它常常就是那批“没匹配上”的数据,值得专门看一眼,而不是当成脏数据删掉。

三、没有 GROUP BY 的聚合,是“整张表一个桶”。 SELECT COUNT(*) FROM orders 不带 GROUP BY,逻辑上是把全部行分到一个桶里,所以输出恰好一行。这也解释了一个反直觉的现象:SELECT SUM(amount) FROM orders WHERE 1 = 0 不会返回零行,而是返回一行 NULL——桶是空的,SUM 没有值可算,只能给 NULL(COUNT 例外,它数的是个数,空桶给 0)。报表里拿这行 NULL 去做后续计算会一路污染下去,需要时用 COALESCE(SUM(amount), 0) 兜住。

最后回到开头那句报错的 SQL。它真正想表达的可能是“每个用户的订单数,顺便看看最新一单的状态”,或者“每个用户的状态有哪几种”。第一种需要窗口函数,第二种需要把 status 也放进 GROUP BY(分组键变成 (user_id, status),桶数和行数都会变)。报错的价值就在于逼你先回答“这一行代表什么”——只要你能说清每一行是“一个用户的汇总”,还是“一个用户加一种状态的汇总”,GROUP BY 该写什么、哪些列能裸着 SELECT,就不用再猜了。