分组(GROUP BY / HAVING)
GROUP BY 把结果集按指定列切成若干组,聚合函数在每个组内独立计算;HAVING 则用于过滤分组后的结果。本节讲解分组规则、WHERE 与 HAVING 的执行顺序与性能差异、聚合函数对 NULL 的处理,以及多维报表的实战写法。
前置知识
阅读本节前,建议先了解:去重(DISTINCT)
基础概念
术语对照
| 中文 | English | 说明 |
|---|---|---|
| 分组 | GROUP BY | 按列取值把行划分为若干组 |
| 分组键 | Grouping Key | 参与分组的列或表达式 |
| 聚合函数 | Aggregate Function | 把一组值归约为单个值的函数 |
| 分组过滤 | HAVING | 对分组结果做筛选 |
| 行过滤 | WHERE | 在分组前对原始行做筛选 |
| 分组集 | GROUPING SETS | 一次查询产出多个分组维度的并集 |
| 条件聚合 | Conditional Aggregate | 用 FILTER 或 CASE 只聚合部分行 |
分组与聚合的基本语义
SELECT 分组键, 聚合函数(列) [, ...]
FROM 表
[WHERE 行条件]
GROUP BY 分组键 [, ...]
[HAVING 分组条件]
[ORDER BY ...];逻辑执行顺序:
| 顺序 | 子句 | 作用对象 | 说明 |
|---|---|---|---|
| 1 | FROM / JOIN | 原始行 | 组装数据集 |
| 2 | WHERE | 原始行 | 分组前过滤,不能引用聚合 |
| 3 | GROUP BY | 原始行 | 划分组 |
| 4 | 聚合函数 | 每组 | 计算组内聚合值 |
| 5 | HAVING | 分组 | 分组后过滤,可引用聚合 |
| 6 | SELECT | 结果行 | 输出列与别名 |
| 7 | ORDER BY | 结果行 | 排序 |
| 8 | LIMIT / OFFSET | 结果行 | 截断 |
关键结论:WHERE 不能使用聚合函数,HAVING 可以使用。因为 WHERE 执行时分组还不存在。
分组列规则
SELECT 列表中的非聚合表达式,必须出现在 GROUP BY 中(SQL 标准要求)。PostgreSQL 允许 SELECT 使用分组键的函数依赖列(当分组键是主键时),但为可移植性起见,建议显式列出。
-- 正确:category 出现在 GROUP BY 中
SELECT category, count(*) FROM products GROUP BY category;
-- 错误!price 未聚合也未分组
SELECT category, price, count(*) FROM products GROUP BY category;ERROR: column "products.price" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT category, price, count(*) FROM products GROUP BY cate...语法与用法
示例数据
后续示例统一使用 demo 库中的 orders 表与 order_items 表:
CREATE TABLE orders (
order_no varchar(20) PRIMARY KEY,
user_name varchar(50) NOT NULL,
city varchar(50),
amount numeric(10,2) NOT NULL,
status varchar(10) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO orders (order_no, user_name, city, amount, status, created_at) VALUES
('SO-1001', 'alice', '北京', 1200.00, 'paid', '2026-01-05 10:00:00+08'),
('SO-1002', 'bob', '上海', 350.50, 'paid', '2026-01-06 11:30:00+08'),
('SO-1003', 'alice', '北京', 880.00, 'pending', '2026-01-07 09:15:00+08'),
('SO-1004', 'carol', '广州', 1200.00, 'paid', '2026-01-08 14:20:00+08'),
('SO-1005', 'bob', '上海', 220.00, 'canceled', '2026-01-09 16:45:00+08'),
('SO-1006', 'dave', '北京', 450.00, 'pending', '2026-01-10 08:05:00+08'),
('SO-1007', 'carol', '广州', 1200.00, 'shipped', '2026-01-11 12:40:00+08'),
('SO-1008', 'eve', '深圳', 760.00, 'paid', '2026-01-12 15:10:00+08');
CREATE TABLE order_items (
id bigserial PRIMARY KEY,
order_no varchar(20) NOT NULL REFERENCES orders(order_no),
product varchar(50) NOT NULL,
qty integer NOT NULL,
unit_price numeric(10,2) NOT NULL
);
INSERT INTO order_items (order_no, product, qty, unit_price) VALUES
('SO-1001', '显示器', 1, 1200.00),
('SO-1002', '键盘', 2, 150.00),
('SO-1002', '鼠标', 1, 50.50),
('SO-1003', '显示器', 1, 880.00),
('SO-1004', '主机', 1, 1200.00),
('SO-1005', '鼠标', 4, 55.00),
('SO-1006', '键盘', 3, 150.00),
('SO-1007', '主机', 1, 1200.00),
('SO-1008', '耳机', 2, 380.00); order_no | product | qty | unit_price
----------+---------+-----+------------
SO-1001 | 显示器 | 1 | 1200.00
SO-1002 | 键盘 | 2 | 150.00
SO-1002 | 鼠标 | 1 | 50.50
SO-1003 | 显示器 | 1 | 880.00
SO-1004 | 主机 | 1 | 1200.00
SO-1005 | 鼠标 | 4 | 55.00
SO-1006 | 键盘 | 3 | 150.00
SO-1007 | 主机 | 1 | 1200.00
SO-1008 | 耳机 | 2 | 380.00
(9 rows)常用聚合函数
| 函数 | 作用 | 忽略 NULL |
|---|---|---|
count(*) | 统计行数 | 不适用 |
count(col) | 统计非 NULL 值个数 | 是 |
sum(col) | 求和 | 是 |
avg(col) | 平均值 | 是 |
min(col) / max(col) | 最值 | 是 |
string_agg(col, sep) | 字符串拼接 | 是 |
array_agg(col) | 聚合为数组 | 是 |
jsonb_agg(col) | 聚合为 JSON 数组 | 是 |
stddev(col) / variance(col) | 标准差 / 方差 | 是 |
bool_and(col) / bool_or(col) | 逻辑与 / 或 | 是 |
详细说明
单列分组
SELECT city, count(*) AS order_cnt, sum(amount) AS total_amount
FROM orders
GROUP BY city
ORDER BY total_amount DESC; city | order_cnt | total_amount
--------+-----------+--------------
北京 | 3 | 2530.00
广州 | 2 | 2400.00
深圳 | 1 | 760.00
上海 | 2 | 570.50
(4 rows)count(*) 统计该组行数,sum(amount) 累加该组金额。
多列分组
SELECT city, status, count(*) AS cnt, sum(amount) AS total
FROM orders
GROUP BY city, status
ORDER BY city, status; city | status | cnt | total
--------+----------+-----+---------
上海 | canceled | 1 | 220.00
上海 | paid | 1 | 350.50
北京 | paid | 1 | 1200.00
北京 | pending | 2 | 1330.00
广州 | paid | 1 | 1200.00
广州 | shipped | 1 | 1200.00
深圳 | paid | 1 | 760.00
(7 rows)多列分组的组数等于各列取值组合数。注意与 DISTINCT city, status 的结果对比——组合完全相同,但 GROUP BY 额外给出了每组统计量。
表达式分组
GROUP BY 的键可以是表达式,如时间截断、条件分支:
-- 按月统计订单额
SELECT to_char(created_at, 'YYYY-MM') AS month,
count(*) AS cnt,
sum(amount) AS total
FROM orders
GROUP BY to_char(created_at, 'YYYY-MM')
ORDER BY month; month | cnt | total
---------+-----+---------
2026-01 | 8 | 6260.50
(1 row)注意:GROUP BY 必须重复表达式,或使用 GROUP BY 1(按序号)——但不推荐后者,因为序号含义脆弱。
下列写法利用别名分组会报错(GROUP BY 阶段 SELECT 的别名尚未生效):
-- 错误!GROUP BY 不能引用 SELECT 别名
SELECT to_char(created_at, 'YYYY-MM') AS month, count(*)
FROM orders
GROUP BY month;HAVING 过滤分组
-- 只看订单数大于 1 的城市
SELECT city, count(*) AS cnt
FROM orders
GROUP BY city
HAVING count(*) > 1
ORDER BY cnt DESC; city | cnt
--------+-----
北京 | 3
上海 | 2
广州 | 2
(3 rows)HAVING 条件可以直接写聚合函数,也可以引用分组键:
-- 等价写法:过滤条件放在 HAVING 中,引用分组键
SELECT city, count(*) AS cnt
FROM orders
GROUP BY city
HAVING city <> '深圳';WHERE 与 HAVING 的执行顺序与性能
两者的差异不只是"能不能用聚合",更体现在执行时机与代价上:
| 对比项 | WHERE | HAVING |
|---|---|---|
| 执行时机 | 分组之前 | 分组之后 |
| 作用对象 | 原始行 | 分组结果 |
| 能否用聚合 | 不能 | 能 |
| 能否走索引 | 能(配合索引条件下推) | 一般不能(在分组结果上过滤) |
| 代价 | 低,越早过滤越省 | 高,需先完整分组 |
| 能否省掉分组 | 部分组可整体跳过 | 分组已计算完毕才丢弃 |
因此一条重要原则是:能在 WHERE 里过滤的条件,不要放到 HAVING。
-- 不推荐:先分组再丢弃,白白计算了 canceled 组
SELECT city, count(*) AS cnt
FROM orders
GROUP BY city
HAVING status <> 'canceled';
-- 推荐:分组前先过滤,组数与输入行数都减少
SELECT city, count(*) AS cnt
FROM orders
WHERE status <> 'canceled'
GROUP BY city;用执行计划可以直观看出差异:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT city, count(*) FROM orders GROUP BY city HAVING status <> 'canceled'; QUERY PLAN
---------------------------------------------------
GroupAggregate (actual time=0.040..0.043 rows=4 loops=1)
Group Key: city
Filter: ((status)::text <> 'canceled'::text)
-> Sort (actual time=0.030..0.032 rows=8 loops=1)
Sort Key: city
-> Seq Scan on orders (actual time=0.010..0.014 rows=8 loops=1)
Planning Time: 0.120 ms
Execution Time: 0.080 ms
(7 rows)可以看到 Filter 挂在 GroupAggregate 之上——说明先扫描 8 行、再排序、再分组,status 条件完全没用上。
聚合函数与 NULL
所有标准聚合函数(sum、avg、min、max、count(col))都忽略 NULL。这会带来一个经典陷阱:
-- 若 city 列存在 NULL:count(*) 计入 NULL 行,count(city) 不计
SELECT count(*) AS total_rows,
count(city) AS city_filled,
count(*) - count(city) AS city_null,
round(avg(amount), 2) AS avg_amount,
sum(amount) AS total_amount
FROM orders; total_rows | city_filled | city_null | avg_amount | total_amount
------------+-------------+-----------+------------+--------------
8 | 8 | 0 | 782.56 | 6260.50
(1 row)| 情形 | count(*) | count(col) | avg(col) |
|---|---|---|---|
| 组内全为 NULL | 返回 N | 返回 0 | 返回 NULL |
| 组内部分 NULL | 计全部行 | 只计非 NULL | 只对非 NULL 求平均 |
| 空表 / 无匹配组 | 返回 0(无 GROUP BY) | 返回 0 | 返回 NULL |
空表且带 GROUP BY 时不返回任何行;不带 GROUP BY 的聚合(如 SELECT count(*) FROM t)永远返回一行。
用 FILTER 做条件聚合(行转列)
FILTER (WHERE ...) 让每个聚合函数只处理满足条件的行,是实现"行转列"的标准手段(PostgreSQL 9.4+):
SELECT city,
count(*) AS total_cnt,
count(*) FILTER (WHERE status = 'paid') AS paid_cnt,
count(*) FILTER (WHERE status = 'pending') AS pending_cnt,
count(*) FILTER (WHERE status = 'canceled') AS canceled_cnt,
sum(amount) FILTER (WHERE status = 'paid') AS paid_amount
FROM orders
GROUP BY city
ORDER BY city; city | total_cnt | paid_cnt | pending_cnt | canceled_cnt | paid_amount
--------+-----------+----------+-------------+--------------+-------------
上海 | 2 | 1 | 0 | 1 | 350.50
北京 | 3 | 1 | 2 | 0 | 1200.00
广州 | 2 | 1 | 0 | 0 | 1200.00
深圳 | 1 | 1 | 0 | 0 | 760.00
(4 rows)若数据库版本不支持 FILTER,可用 CASE 模拟:
-- 等价写法:聚合内嵌 CASE
SELECT city,
sum(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_amount
FROM orders
GROUP BY city
ORDER BY city;GROUPING SETS / ROLLUP / CUBE
当报表需要"同时看明细维度与汇总行"时,逐个维度跑 UNION ALL 很啰嗦。GROUPING SETS、ROLLUP、CUBE 是 SQL 标准提供的一次性多维分组语法(PostgreSQL 9.5+):
| 语法 | 生成的维度组合 | 适用场景 |
|---|---|---|
GROUPING SETS ((a), (b), ()) | 显式列出各组 | 任意自定义维度组合 |
ROLLUP (a, b) | (a,b)、(a)、() | 层级汇总(年→月→总计) |
CUBE (a, b) | (a,b)、(a)、(b)、() | 全维度交叉汇总 |
-- ROLLUP:城市 × 状态明细 + 城市小计 + 总计
SELECT coalesce(city, '总计') AS city,
coalesce(status, '小计') AS status,
count(*) AS cnt,
sum(amount) AS total
FROM orders
GROUP BY ROLLUP (city, status)
ORDER BY GROUPING(city), city NULLS LAST, GROUPING(status), status NULLS LAST; city | status | cnt | total
--------+----------+-----+---------
上海 | canceled | 1 | 220.00
上海 | paid | 1 | 350.50
上海 | 小计 | 2 | 570.50
北京 | paid | 1 | 1200.00
北京 | pending | 2 | 1330.00
北京 | 小计 | 3 | 2530.00
广州 | paid | 1 | 1200.00
广州 | shipped | 1 | 1200.00
广州 | 小计 | 2 | 2400.00
深圳 | paid | 1 | 760.00
深圳 | 小计 | 1 | 760.00
总计 | 小计 | 8 | 6260.50
(12 rows)小计行的 status 被替换为"小计",总计行的 city 被替换为"总计"。若需要区分"真实的 NULL 值"和"汇总产生的 NULL",用 GROUPING(col) 判断:
SELECT city, status,
GROUPING(city) AS g_city, -- 1 表示该列被汇总(非真实分组)
GROUPING(status) AS g_status,
count(*) AS cnt
FROM orders
GROUP BY ROLLUP (city, status)
ORDER BY g_city, g_status, city, status;聚合结果排序
GROUP BY 不保证输出顺序,需要顺序必须显式 ORDER BY。此外,ORDER BY 可以引用聚合值或其别名:
SELECT city, sum(amount) AS total
FROM orders
GROUP BY city
ORDER BY total DESC; city | total
--------+---------
北京 | 2530.00
广州 | 2400.00
深圳 | 760.00
上海 | 570.50
(4 rows)实战示例
需求:为运营团队产出一份"城市经营看板",包含城市汇总、状态分布、热销商品、月度趋势与总体占比。步骤 1~8。
步骤 1:城市级汇总
SELECT city,
count(*) AS order_cnt,
count(DISTINCT user_name) AS user_cnt,
sum(amount) AS gmv,
round(avg(amount), 2) AS avg_order,
min(amount) AS min_amount,
max(amount) AS max_amount
FROM orders
GROUP BY city
ORDER BY gmv DESC; city | order_cnt | user_cnt | gmv | avg_order | min_amount | max_amount
--------+-----------+----------+---------+-----------+------------+------------
北京 | 3 | 2 | 2530.00 | 843.33 | 450.00 | 1200.00
广州 | 2 | 1 | 2400.00 | 1200.00 | 1200.00 | 1200.00
深圳 | 1 | 1 | 760.00 | 760.00 | 760.00 | 760.00
上海 | 2 | 1 | 570.50 | 285.25 | 220.00 | 350.50
(4 rows)步骤 2:状态分布(行转列)
SELECT city,
count(*) FILTER (WHERE status = 'paid') AS paid,
count(*) FILTER (WHERE status = 'pending') AS pending,
count(*) FILTER (WHERE status = 'shipped') AS shipped,
count(*) FILTER (WHERE status = 'canceled') AS canceled
FROM orders
GROUP BY city
ORDER BY city; city | paid | pending | shipped | canceled
--------+------+---------+---------+----------
上海 | 1 | 0 | 0 | 1
北京 | 1 | 2 | 0 | 0
广州 | 1 | 0 | 1 | 0
深圳 | 1 | 0 | 0 | 0
(4 rows)步骤 3:热销商品排名
SELECT product,
sum(qty) AS total_qty,
sum(qty * unit_price) AS sales_amount,
count(DISTINCT order_no) AS order_cnt
FROM order_items
GROUP BY product
ORDER BY sales_amount DESC; product | total_qty | sales_amount | order_cnt
---------+-----------+--------------+-----------
主机 | 2 | 2400.00 | 2
显示器 | 2 | 2080.00 | 2
耳机 | 2 | 760.00 | 1
键盘 | 5 | 750.00 | 2
鼠标 | 5 | 270.50 | 2
(5 rows)步骤 4:用 HAVING 筛出高价值分组
-- 只看销售额超过 1000 的商品
SELECT product, sum(qty * unit_price) AS sales_amount
FROM order_items
GROUP BY product
HAVING sum(qty * unit_price) > 1000
ORDER BY sales_amount DESC; product | sales_amount
---------+--------------
主机 | 2400.00
显示器 | 2080.00
(2 rows)步骤 5:WHERE 与 HAVING 组合的标准姿势
"先过滤行、再分组、最后筛组":
-- 排除已取消订单,再按城市统计,最后只保留订单额超过 1000 的城市
SELECT city, count(*) AS cnt, sum(amount) AS total
FROM orders
WHERE status <> 'canceled' -- 分组前过滤原始行
GROUP BY city
HAVING sum(amount) > 1000 -- 分组后过滤
ORDER BY total DESC; city | cnt | total
--------+-----+---------
北京 | 3 | 2530.00
广州 | 2 | 2400.00
(2 rows)上海 因去掉 canceled 的 220 后仅剩 350.50,深圳 的 760 也未过线,二者被 HAVING 剔除。
步骤 6:按月 × 状态的双维报表
SELECT to_char(created_at, 'YYYY-MM') AS month,
city,
count(*) AS cnt,
sum(amount) AS total
FROM orders
GROUP BY to_char(created_at, 'YYYY-MM'), city
ORDER BY month, total DESC; month | city | cnt | total
---------+--------+-----+---------
2026-01 | 北京 | 3 | 2530.00
2026-01 | 广州 | 2 | 2400.00
2026-01 | 深圳 | 1 | 760.00
2026-01 | 上海 | 2 | 570.50
(4 rows)步骤 7:ROLLUP 产出含小计与总计的报表
SELECT coalesce(city, '全部城市') AS city,
count(*) AS cnt,
sum(amount) AS total
FROM orders
GROUP BY ROLLUP (city)
ORDER BY GROUPING(city), city; -- 明细按城市排序,总计行固定排在最后 city | cnt | total
------------+-----+---------
上海 | 2 | 570.50
北京 | 3 | 2530.00
广州 | 2 | 2400.00
全部城市 | 8 | 6260.50
(4 rows)ROLLUP (city) 等价于 GROUPING SETS ((city), ()),最后一行是总计。
步骤 8:占比计算(窗口函数配合)
分组结果想算"每个城市占总量的百分比",可在分组结果之上套一层窗口函数:
SELECT city,
total,
round(100.0 * total / sum(total) OVER (), 2) AS pct
FROM (
SELECT city, sum(amount) AS total
FROM orders
GROUP BY city
) t
ORDER BY total DESC; city | total | pct
--------+---------+-------
北京 | 2530.00 | 40.41
广州 | 2400.00 | 38.34
深圳 | 760.00 | 12.14
上海 | 570.50 | 9.11
(4 rows)注意 GROUP BY 与窗口函数不能在同一层混用:窗口函数在分组之后计算,因此需要先分组、再在外层用窗口函数。
注意事项
SELECT中的非聚合列必须出现在GROUP BY中。 否则报错;不要依赖 PostgreSQL 对主键函数依赖的宽容行为,显式列出更安全。WHERE不能用聚合,HAVING能用。 这是由逻辑执行顺序决定的,不是语法糖。
-- 错误!WHERE 中不能出现聚合函数
SELECT city, count(*) FROM orders WHERE count(*) > 1 GROUP BY city;GROUP BY不能引用SELECT别名(ORDER BY、HAVING可以)。要么重复表达式,要么外面套一层子查询。GROUP BY不保证输出顺序。 要顺序就写ORDER BY。聚合函数忽略 NULL,
count(*)例外。avg的分母是非 NULL 计数,NULL 多时平均值会偏高。空输入的行为差异。 有
GROUP BY时无组则无行;无GROUP BY的聚合返回一行(count为 0,其余为 NULL)。GROUPING SETS产生的 NULL 与真实 NULL 无法直接区分,需用GROUPING()函数判别。GROUP BY列上有索引时可走GroupAggregate免排序,但若选择HashAggregate则可能因内存不足落盘(Batches > 1)。
最佳实践
- 过滤条件尽量前移到
WHERE。 缩小输入行数是提升分组性能最直接的手段。
SELECT city, count(*) FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY city;- 为分组键建索引,让分组免排序。
-- 支撑 GROUP BY city(可用 GroupAggregate 复用索引顺序)
CREATE INDEX idx_orders_city ON orders (city);- 行转列优先用
FILTER,兼容性要求高时用CASE。
SELECT city,
count(*) FILTER (WHERE status = 'paid') AS paid
FROM orders GROUP BY city;- 多维报表用
GROUPING SETS/ROLLUP/CUBE替代多个UNION ALL。 只需扫一遍表。
SELECT coalesce(city, '总计') AS city, sum(amount)
FROM orders GROUP BY ROLLUP (city);- 计算占比、排名、累计时用窗口函数包裹分组结果,不要试图在同一层混用。
SELECT city, total, rank() OVER (ORDER BY total DESC) AS rk
FROM (SELECT city, sum(amount) AS total FROM orders GROUP BY city) t;- 大表分组前确认
work_mem充足,避免HashAggregate落盘。
-- 会话级临时提升
SET work_mem = '128MB';count(DISTINCT col)在大表上代价高,若只是为过滤用HAVING count(DISTINCT ...) > 1,可考虑先GROUP BY去重再统计。
SELECT count(*) FROM (SELECT user_name FROM orders GROUP BY user_name HAVING count(*) > 1) t;- 用
EXPLAIN (ANALYZE, BUFFERS)确认分组实现方式与是否落盘,关注HashAggregate的Batches与Memory Usage。
下一节
继续学习:行锁查询(FOR UPDATE)