# GROUP BY 之后，一行到底代表什么

从“非分组列为什么不能裸着 SELECT”讲起，理清分组、聚合与 WHERE/HAVING 的分工

> 分组聚合 · HAVING · 数据取数 · 约 8 分钟 · 09 月 15 日

## 本篇要点

1. GROUP BY 按分组键的值把输入行分进桶，输出行数等于桶的个数（去掉 HAVING 过滤掉的那些桶），与输入行数没有固定比例关系。
2. 结果集的一行一列只能放一个值，所以 SELECT 列表里的每一项必须由本组唯一确定：要么是分组键的一部分，要么被聚合函数包起来，要么由前两者组合而成。
3. GROUP BY 的键里 NULL 被视为彼此相等，会聚成单独一组；这和 WHERE 里 `= NULL` 永远不成立是两套规则。
4. 逻辑处理顺序是 FROM → WHERE → GROUP BY → 聚合 → HAVING → SELECT → ORDER BY，聚合发生在分组之后，所以 WHERE 的位置上每组的聚合值尚不存在，`WHERE COUNT(*) > 3` 无法计算，只能用 HAVING。
5. WHERE 的条件会改变聚合函数的输入（例如让 COUNT 变小、让某个用户整组消失），HAVING 只按聚合结果删掉整个组，不会改变计数。这是“先过滤再统计”与“统计后再过滤”不可互换的原因。
6. COUNT(*) 数行，COUNT(col) 只数 col 非 NULL 的行，SUM 和 AVG 忽略 NULL，因此 AVG 的分母是非 NULL 行数；NULL 占比高时用 AVG 会系统性高估。
7. 整数列相除在多数数据库会向下取整，求比率时要显式乘 1.0 或 CAST。
8. 不带 GROUP BY 的聚合视为整表一个桶，永远输出一行：空输入时 COUNT 给 0，SUM/AVG/MAX 给 NULL，需要时用 COALESCE 兜底。
9. PostgreSQL 允许裸 SELECT 函数依赖于分组键的列（例如按主键分组），MySQL 开启 ONLY_FULL_GROUP_BY 后会拒绝，宽松模式则随便挑一行输出、结果不确定，因此不要依赖这种写法。

---

你写下了这样一句：

```sql
SELECT user_id, status, COUNT(*)
FROM orders
GROUP BY user_id;
```

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

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

## 分组做的事：把行分进桶，一行输出对应一个桶

假设 `orders` 表有六行：

| order_id | user_id | amount | status |
|---|---|---|---|
| 101 | 1 | 30.00 | paid |
| 102 | 1 | 50.00 | refunded |
| 103 | 2 | 20.00 | paid |
| 104 | 2 | NULL | paid |
| 105 | 3 | 99.00 | paid |
| 106 | 1 | 40.00 | paid |

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

然后：

```sql
SELECT user_id, COUNT(*) AS n
FROM orders
GROUP BY user_id;
```

| user_id | n |
|---|---|
| 1 | 3 |
| 2 | 2 |
| 3 | 1 |

输入六行，输出三行。**输出行数等于桶的个数**，也就是 `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 的分工

把逻辑处理顺序写出来：

```mermaid
flowchart LR
  A["FROM：取出所有行"] --> B["WHERE：按行过滤"]
  B --> C["GROUP BY：分桶"]
  C --> D["聚合：每组算出一个值"]
  D --> E["HAVING：按组过滤"]
  E --> F["SELECT：生成输出行"]
  F --> G["ORDER BY / LIMIT"]
```

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

关键是把 WHERE 和 HAVING 放在这张图的不同位置上：WHERE 在分桶之前，作用的单位是**行**；HAVING 在聚合之后，作用的单位是**组**。

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

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

```sql
-- A：先剔掉退款行，再统计
SELECT user_id, COUNT(*) AS n
FROM orders
WHERE status = 'paid'
GROUP BY user_id;
```

| user_id | n |
|---|---|
| 1 | 2 |
| 2 | 2 |
| 3 | 1 |

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

```sql
-- B：统计全部订单，再只留单数 ≥ 2 的用户
SELECT user_id, COUNT(*) AS n
FROM orders
GROUP BY user_id
HAVING COUNT(*) >= 2;
```

| user_id | n |
|---|---|
| 1 | 3 |
| 2 | 2 |

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

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

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

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

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

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

**一、AVG 的分母是“非 NULL 行数”，不是总行数。** 看这句：

```sql
SELECT user_id,
       COUNT(*)      AS n,
       COUNT(amount) AS n_amount,
       SUM(amount)   AS total,
       AVG(amount)   AS avg_amount
FROM orders
GROUP BY user_id;
```

| user_id | n | n_amount | total | avg_amount |
|---|---|---|---|---|
| 1 | 3 | 3 | 120.00 | 40.000000 |
| 2 | 2 | 1 | 20.00 | 20.000000 |
| 3 | 1 | 1 | 99.00 | 99.000000 |

user 2 有两单，但其中一单 `amount` 是 NULL。`COUNT(*)` 数行，给 2；`COUNT(amount)` 只数非 NULL 的值，给 1。`SUM` 和 `AVG` 都忽略 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，就不用再猜了。

## 来源

1. [PostgreSQL 官方教程：聚合函数与 GROUP BY/HAVING，含按列分组与 NULL 被聚合忽略的示例](https://www.postgresql.org/docs/current/tutorial-agg.html)
2. [PostgreSQL SELECT 语法参考：HAVING 在分组与聚合之后生效，以及函数依赖下非分组列的合法性](https://www.postgresql.org/docs/current/sql-select.html)
3. [MySQL GROUP BY 处理与 ONLY_FULL_GROUP_BY：非严格模式下未聚合列取值不确定](https://dev.mysql.com/doc/refman/8.4/en/group-by-handling.html)

---

原文：https://pangzhengboyin.com/articles/what-each-row-means-after-group-by-7cb0ad80

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
