聚合查询与分组
本节讲解如何用聚合函数(Aggregate Function)对多行数据做汇总计算,用 GROUP BY 分组统计,用 HAVING 过滤分组结果,以及 FILTER、GROUPING SETS、ROLLUP、CUBE 等进阶能力。学完本节,你将能独立完成报表类统计查询。
前置知识
阅读本节前,建议先了解:条件查询与排序
基础概念
聚合函数(Aggregate Function) 把多行输入折叠成一个输出值。它与普通函数的区别在于:普通函数一行进一行出,聚合函数多行进一行出。
| 概念 | 术语 | 说明 |
|---|---|---|
| 聚合函数 | Aggregate Function | 把多行合并为一个值,如 sum、count、avg |
| 分组 | Grouping | 按某列的值把行划分成若干组,每组产出一行 |
| 分组键 | Grouping Key | GROUP BY 中指定的列或表达式 |
| 分组过滤 | HAVING | 对分组结果(而非原始行)做过滤 |
| 聚合过滤器 | FILTER | 只把符合条件的行送入聚合函数 |
| 分组集 | GROUPING SETS | 一次查询中计算多组不同维度的分组 |
| 有序集聚合 | Ordered-Set Aggregate | 依赖排序位置的聚合,如中位数 percentile_cont |
| 窗口函数 | Window Function | 不折叠行的"聚合",每行都保留并附带计算结果 |
| 统计信息 | Statistics | 优化器用来估算数据分布的数据,影响聚合计划 |
WHERE 与 HAVING 的区别是最核心的概念,必须在动手前弄清:
| 对比项 | WHERE | HAVING |
|---|---|---|
| 作用对象 | 原始行 | 分组后的组 |
| 执行时机 | 分组之前 | 分组之后 |
| 能否用聚合函数 | 不能 | 能 |
能否用 SELECT 别名 | 不能 | 可以(PostgreSQL 允许) |
| 典型用途 | 缩小参与统计的数据范围 | 过滤掉统计结果不符合要求的组 |
标准 SQL 中 SELECT 列表里出现的非聚合列,必须出现在 GROUP BY 中;反之 GROUP BY 中的列可以不出现在 SELECT 中。
-- 合法:category 在 GROUP BY 中
SELECT category, count(*) FROM products GROUP BY category;
-- 错误!name 既不在 GROUP BY 中,也没有被聚合
SELECT category, name, count(*) FROM products GROUP BY category;
-- 报错:column "products.name" must appear in the GROUP BY clause
-- or be used in an aggregate function
-- 合法但语义特殊:PostgreSQL 允许按主键分组后选取该行的其他列
SELECT id, sku, name, count(*) FROM products GROUP BY id;语法与用法
聚合查询的基本骨架:
SELECT 分组键, 聚合函数(列) [AS 别名]
FROM 表名
[WHERE 行过滤条件]
GROUP BY 分组键
[HAVING 组过滤条件]
[ORDER BY 排序键]
[LIMIT 行数];准备本节贯穿使用的示例表:
-- 订单明细表:用于演示各类聚合统计
CREATE TABLE orders (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL,
customer TEXT NOT NULL,
region TEXT NOT NULL,
channel TEXT NOT NULL, -- 下单渠道
sku TEXT NOT NULL,
category TEXT NOT NULL,
qty INTEGER NOT NULL CHECK (qty > 0),
unit_price NUMERIC(10, 2) NOT NULL CHECK (unit_price >= 0),
discount NUMERIC(10, 2), -- 可空:部分订单无折扣
is_paid BOOLEAN NOT NULL DEFAULT false,
created_at DATE NOT NULL
);
INSERT INTO orders (order_no, customer, region, channel, sku, category, qty, unit_price, discount, is_paid, created_at) VALUES
('SO-1001', 'alice', 'east', 'web', 'SKU-1001', 'peripherals', 1, 349.00, 20.00, true, DATE '2026-01-05'),
('SO-1001', 'alice', 'east', 'web', 'SKU-1002', 'peripherals', 2, 129.00, 10.00, true, DATE '2026-01-05'),
('SO-1002', 'bob', 'north', 'app', 'SKU-1003', 'accessories', 1, 199.00, NULL, true, DATE '2026-01-08'),
('SO-1003', 'carol', 'south', 'web', 'SKU-1001', 'peripherals', 3, 349.00, NULL, false, DATE '2026-01-12'),
('SO-1004', 'dave', 'east', 'store', 'SKU-1005', 'cable', 5, 39.00, 5.00, true, DATE '2026-02-02'),
('SO-1005', 'alice', 'east', 'app', 'SKU-1004', 'accessories', 2, 259.00, NULL, true, DATE '2026-02-09'),
('SO-1006', 'bob', 'north', 'web', 'SKU-1006', 'cable', 10, 49.00, 0.00, true, DATE '2026-02-15'),
('SO-1007', 'carol', 'south', 'store', 'SKU-1003', 'accessories', 1, 199.00, NULL, false, DATE '2026-02-20'),
('SO-1008', 'dave', 'east', 'web', 'SKU-1009', 'storage', 1, 899.00, NULL, true, DATE '2026-03-01'),
('SO-1009', 'eve', 'west', 'app', 'SKU-1010', 'accessories',20, 29.00, 3.00, true, DATE '2026-03-05'),
('SO-1010', 'eve', 'west', 'web', 'SKU-1007', 'peripherals', 1, 429.00, NULL, true, DATE '2026-03-11');最常用的五个聚合函数:
-- 总行数与总金额
SELECT count(*) AS order_cnt, sum(qty * unit_price) AS gmv FROM orders;
-- 平均值、最大值、最小值
SELECT
round(avg(unit_price), 2) AS avg_price, -- round 需指定小数位
max(unit_price) AS max_price,
min(unit_price) AS min_price
FROM orders;
-- 按分组键统计
SELECT region, count(*) AS cnt, sum(qty) AS total_qty
FROM orders
GROUP BY region
ORDER BY total_qty DESC;PostgreSQL 内置的常用聚合函数一览:
| 函数 | 说明 | 典型用途 |
|---|---|---|
count(*) | 统计行数(含 NULL 行) | 订单数、记录数 |
count(列) | 统计该列非 NULL 的行数 | 去空统计 |
count(DISTINCT 列) | 统计不同值个数 | 去重客户数 |
sum(列) | 求和 | GMV、总数量 |
avg(列) | 平均值 | 客单价 |
max(列) / min(列) | 最大值 / 最小值 | 首末订单、价格区间 |
string_agg(列, 分隔符) | 字符串拼接 | 汇总标签、拼接名称 |
array_agg(列) | 聚合成数组 | 一单多商品的列表 |
bool_and / bool_or | 布尔与 / 或 | 是否全部付款 |
stddev / variance | 标准差 / 方差 | 数据波动分析 |
percentile_cont(0.5) | 连续分位数(中位数) | 价格中位数 |
详细说明
常用聚合函数与 NULL 的处理
聚合函数普遍忽略 NULL,count(*) 是唯一的例外:
-- 对比 count(*) 与 count(列)
SELECT
count(*) AS all_rows, -- 所有行
count(discount) AS non_null_rows, -- 只统计 discount 非 NULL 的行
count(DISTINCT sku) AS distinct_sku -- 不同 SKU 数(NULL 不计入)
FROM orders;
-- NULL 被忽略导致平均值的分母变小,可能出现"平均单价高于任何单价"的错觉
SELECT
count(*) AS rows_all,
count(discount) AS rows_with_discount,
avg(discount) AS avg_discount, -- 分母是 rows_with_discount
avg(COALESCE(discount, 0)) AS avg_discount_all -- 分母是 rows_all
FROM orders;string_agg 与 array_agg 把多行折叠为字符串或数组,常与 GROUP BY 搭配:
-- 每个客户买过的所有 SKU 拼成一个字符串
SELECT customer, string_agg(DISTINCT sku, ', ' ORDER BY sku) AS skus
FROM orders
GROUP BY customer
ORDER BY customer;
-- 每个订单的商品列表聚合成数组
SELECT order_no, array_agg(sku ORDER BY sku) AS sku_list, sum(qty) AS total_qty
FROM orders
GROUP BY order_no
ORDER BY order_no; order_no | sku_list | total_qty
----------+-----------------------------------+-----------
SO-1001 | {SKU-1001,SKU-1002} | 3
SO-1002 | {SKU-1003} | 1
SO-1003 | {SKU-1001} | 3bool_and / bool_or 用于布尔聚合:
-- 每个订单是否全部付款完成
SELECT order_no, bool_and(is_paid) AS all_paid, count(*) AS item_cnt
FROM orders
GROUP BY order_no
ORDER BY order_no;GROUP BY 的多列与表达式分组
分组键可以是多列、表达式、甚至 SELECT 中的别名:
-- 多列分组:地区 + 渠道
SELECT region, channel, count(*) AS cnt, sum(qty * unit_price) AS gmv
FROM orders
GROUP BY region, channel
ORDER BY region, channel;
-- 按表达式分组:按月统计
SELECT to_char(created_at, 'YYYY-MM') AS ym, count(*) AS cnt, sum(qty * unit_price) AS gmv
FROM orders
GROUP BY to_char(created_at, 'YYYY-MM')
ORDER BY ym;
-- 使用 SELECT 别名分组(PostgreSQL 允许)
SELECT to_char(created_at, 'YYYY-MM') AS ym, count(*) AS cnt
FROM orders
GROUP BY ym
ORDER BY ym;
-- 按序号分组(不推荐,可读性差)
SELECT to_char(created_at, 'YYYY-MM'), count(*) FROM orders GROUP BY 1 ORDER BY 1;也可以按分组键的集合分组,例如把所有渠道合为"线上/线下"两类:
-- 用 CASE 归一化后再分组
SELECT
CASE WHEN channel IN ('web', 'app') THEN '线上' ELSE '门店' END AS channel_type,
count(DISTINCT order_no) AS order_cnt,
sum(qty * unit_price) AS gmv
FROM orders
GROUP BY channel_type
ORDER BY gmv DESC; channel_type | order_cnt | gmv
--------------+-----------+---------
线上 | 8 | 4769.00
门店 | 2 | 394.00GROUP BY 与 ORDER BY 都可以使用序号,但分组的数值优先级:当 GROUP BY 中的名称同时匹配列名与输出列别名时,优先按输入列名解析。因此别名与列名重名时行为会令人意外。
-- 危险:SELECT 别名 与 输入列同名时,GROUP BY 优先按输入列解释
SELECT upper(category) AS category, count(*)
FROM orders
GROUP BY category; -- 实际按 orders.category 分组,不是按 upper(category)HAVING:过滤分组结果
HAVING 在分组之后执行,用于筛掉不满足条件的组。它与 WHERE 分工明确:
-- WHERE 过滤行,HAVING 过滤组
SELECT customer, count(DISTINCT order_no) AS order_cnt, sum(qty * unit_price) AS gmv
FROM orders
WHERE is_paid -- 先排除未付款的明细行
GROUP BY customer
HAVING sum(qty * unit_price) > 500 -- 再筛掉消费额不足的客户
ORDER BY gmv DESC; customer | order_cnt | gmv
----------+-----------+---------
alice | 2 | 1125.00
dave | 2 | 1094.00
eve | 2 | 1009.00
bob | 2 | 689.00
(4 rows)HAVING 中也可以使用 SELECT 别名与序号:
SELECT customer, sum(qty * unit_price) AS gmv
FROM orders
GROUP BY customer
HAVING sum(qty * unit_price) > 500
ORDER BY gmv DESC;
-- 等价写法:使用别名(PostgreSQL 允许)
SELECT customer, sum(qty * unit_price) AS gmv
FROM orders
GROUP BY customer
HAVING gmv > 500
ORDER BY gmv DESC;WHERE 中不能写聚合函数,这是最常见的一类语法错误:
-- 错误!WHERE 在分组前执行,聚合函数尚不可用
SELECT customer, sum(qty * unit_price)
FROM orders
WHERE sum(qty * unit_price) > 500
GROUP BY customer;
-- 正确:条件放 HAVING
SELECT customer, sum(qty * unit_price)
FROM orders
GROUP BY customer
HAVING sum(qty * unit_price) > 500;FILTER:条件聚合
FILTER 子句让同一个聚合函数只统计满足指定条件的行,是 pivot(行列转换)类报表的利器,比 CASE WHEN 写法更简洁。
-- PostgreSQL 9.4+
-- 一次扫描得到多个口径的统计
SELECT
region,
count(*) AS item_cnt,
count(*) FILTER (WHERE is_paid) AS paid_cnt,
count(*) FILTER (WHERE NOT is_paid) AS unpaid_cnt,
sum(qty * unit_price) FILTER (WHERE is_paid) AS paid_gmv,
sum(qty * unit_price) FILTER (WHERE channel = 'web') AS web_gmv,
count(DISTINCT customer) FILTER (WHERE qty >= 5) AS bulk_customers
FROM orders
GROUP BY region
ORDER BY region; region | item_cnt | paid_cnt | unpaid_cnt | paid_gmv | web_gmv | bulk_customers
--------+----------+----------+------------+----------+---------+----------------
east | 5 | 5 | 0 | 2219.00 | 1506.00 | 1
north | 2 | 2 | 0 | 689.00 | 490.00 | 1
south | 2 | 0 | 2 | | 1047.00 | 0
west | 2 | 2 | 0 | 1009.00 | 429.00 | 1FILTER 与 CASE WHEN 的等价关系:
-- 写法一:FILTER(推荐,语义清晰)
SELECT count(*) FILTER (WHERE is_paid) AS paid_cnt FROM orders;
-- 写法二:CASE WHEN(老版本兼容写法)
SELECT count(CASE WHEN is_paid THEN 1 END) AS paid_cnt FROM orders;
-- 注意!以下写法错误:sum 会累加所有行(不满足的行取 0 会参与求和)
SELECT sum(CASE WHEN is_paid THEN 1 ELSE 1 END) AS wrong_cnt FROM orders;用 FILTER 实现条件求和时,需要给不满足条件的行返回 NULL 而非 0(因为 sum 忽略 NULL):
-- 正确:不满足条件返回 NULL,sum 会忽略
SELECT sum(qty * unit_price) FILTER (WHERE is_paid) AS paid_gmv FROM orders;
-- 等价正确写法
SELECT sum(CASE WHEN is_paid THEN qty * unit_price END) AS paid_gmv FROM orders;
-- 错误:else 0 虽然结果相同(加 0 不影响和),但若用 avg 会摊薄分母
SELECT avg(CASE WHEN is_paid THEN unit_price ELSE 0 END) AS wrong_avg FROM orders;GROUPING SETS、ROLLUP 与 CUBE
多维度报表需要"总计 + 分维度小计 + 明细"混排。若用普通 GROUP BY 需要写多条查询再 UNION ALL。PostgreSQL 提供了 GROUPING SETS 一族语法,一次扫描输出多个分组层次。
-- PostgreSQL 9.5+:一次查询输出 (region, channel) 明细 + region 小计 + channel 小计 + 总计
-- 用 GROUPING() 排序,把各类小计与总计排在明细之后
SELECT
coalesce(region, '(全部)') AS region,
coalesce(channel, '(全部)') AS channel,
sum(qty * unit_price) AS gmv
FROM orders
GROUP BY GROUPING SETS (
(region, channel), -- 报表主体
(region), -- 按地区小计
(channel), -- 按渠道小计
() -- 总计
)
ORDER BY GROUPING(region), region, GROUPING(channel), channel; region | channel | gmv
--------+---------+---------
east | app | 518.00
east | store | 195.00
east | web | 1506.00
north | app | 199.00
north | web | 490.00
south | store | 199.00
south | web | 1047.00
west | app | 580.00
west | web | 429.00
east | (全部) | 2219.00
north | (全部) | 689.00
south | (全部) | 1246.00
west | (全部) | 1009.00
(全部) | app | 1297.00
(全部) | store | 394.00
(全部) | web | 3472.00
(全部) | (全部) | 5163.00
(17 rows)ROLLUP 与 CUBE 是 GROUPING SETS 的语法糖:
-- PostgreSQL 9.5+
-- ROLLUP:按列顺序逐层汇总(适合层级维度:年 → 月 → 日)
SELECT
extract(year FROM created_at)::int AS yr,
to_char(created_at, 'MM') AS mon,
sum(qty * unit_price) AS gmv
FROM orders
GROUP BY ROLLUP (extract(year FROM created_at), to_char(created_at, 'MM'))
ORDER BY yr NULLS LAST, mon;
-- CUBE:对列的所有组合都汇总(适合交叉分析)
SELECT
coalesce(region, '(全部)') AS region,
coalesce(channel, '(全部)') AS channel,
count(*) AS cnt
FROM orders
GROUP BY CUBE (region, channel)
ORDER BY region, channel;判断某行是否为小计行,可以用 GROUPING() 函数(返回 1 表示该列是汇总产生的 NULL),避免与真实 NULL 混淆:
-- GROUPING 区分"真 NULL"与"汇总产生的 NULL"
SELECT
CASE WHEN GROUPING(region) = 1 THEN '【合计】' ELSE region END AS region,
count(*) AS cnt,
sum(qty * unit_price) AS gmv
FROM orders
GROUP BY ROLLUP (region)
ORDER BY GROUPING(region), region;三种语法的关系与适用场景:
| 语法 | 生成的分组集 | 行数(n 列) | 适用场景 |
|---|---|---|---|
GROUP BY a, b | (a,b) | 1 组 | 单维度明细 |
GROUP BY GROUPING SETS ((a,b),(a),()) | 自定义 | 按需 | 精确控制汇总层次 |
GROUP BY ROLLUP (a,b) | (a,b),(a),() | n+1 组 | 层级汇总(时间、地区树) |
GROUP BY CUBE (a,b) | (a,b),(a),(b),() | 2ⁿ 组 | 交叉分析(全部组合) |
有序集聚合与分位数
普通聚合不关心行的顺序,而有序集聚合(Ordered-Set Aggregate) 依赖排序位置,最典型的是中位数与分位数。
-- 中位数(连续分位数 0.5):比 avg 更抗极端值
SELECT
avg(unit_price) AS mean_price,
percentile_cont(0.5) WITHIN GROUP (ORDER BY unit_price) AS median_price
FROM orders;
-- 价格的四分位数
SELECT
percentile_cont(0.25) WITHIN GROUP (ORDER BY unit_price) AS p25,
percentile_cont(0.50) WITHIN GROUP (ORDER BY unit_price) AS p50,
percentile_cont(0.75) WITHIN GROUP (ORDER BY unit_price) AS p75,
percentile_cont(0.90) WITHIN GROUP (ORDER BY unit_price) AS p90
FROM orders;
-- 每组的中位数
SELECT category,
count(*) AS cnt,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY unit_price), 2) AS median_price
FROM orders
GROUP BY category
ORDER BY median_price DESC;
-- mode():众数
SELECT mode() WITHIN GROUP (ORDER BY channel) AS most_used_channel FROM orders; category | cnt | median_price
-------------+-----+--------------
storage | 1 | 899.00
peripherals | 4 | 349.00
accessories | 4 | 199.00
cable | 2 | 44.00聚合查询的执行顺序与优化器限制
完整执行顺序如下,理解它就能解释绝大多数报错:
FROM / JOIN → WHERE → GROUP BY → 聚合函数 → HAVING → SELECT → DISTINCT
→ ORDER BY → LIMIT / OFFSET因此存在以下限制:
| 想做的事 | 错误写法 | 正确做法 |
|---|---|---|
| 用聚合结果过滤 | WHERE sum(x) > 100 | HAVING sum(x) > 100 |
| 对分组结果排序 | ORDER BY sum(x) 可以 | 直接写,ORDER BY 在最后 |
| 对聚合结果再聚合 | 嵌套聚合函数 | 用子查询 / CTE |
引用 SELECT 别名 | WHERE 别名 > 1 | 重复表达式或用子查询 |
-- 错误!聚合函数不能嵌套
SELECT max(sum(qty)) FROM orders GROUP BY customer;
-- 正确:先分组聚合,再在外层取最大值
SELECT max(gmv) AS max_customer_gmv
FROM (
SELECT customer, sum(qty * unit_price) AS gmv
FROM orders
GROUP BY customer
) t;
-- 用 CTE 写法更清晰(PostgreSQL 8.4+)
WITH customer_gmv AS (
SELECT customer, sum(qty * unit_price) AS gmv
FROM orders
GROUP BY customer
)
SELECT max(gmv) AS max_customer_gmv,
min(gmv) AS min_customer_gmv
FROM customer_gmv;实战示例
下面用一段完整流程,从简单统计逐步做到多维报表。
-- 步骤 1:全局概览
SELECT
count(*) AS item_cnt,
count(DISTINCT order_no) AS order_cnt,
count(DISTINCT customer) AS customer_cnt,
sum(qty) AS total_qty,
sum(qty * unit_price) AS gmv
FROM orders; item_cnt | order_cnt | customer_cnt | total_qty | gmv
----------+-----------+--------------+-----------+---------
11 | 10 | 5 | 47 | 5163.00
(1 row)-- 步骤 2:按分类分组,并计算各分类占总额的比例
SELECT
category,
count(*) AS item_cnt,
sum(qty) AS total_qty,
sum(qty * unit_price) AS gmv,
round(100 * sum(qty * unit_price) / sum(sum(qty * unit_price)) OVER (), 2) AS pct
FROM orders
GROUP BY category
ORDER BY gmv DESC; category | item_cnt | total_qty | gmv | pct
-------------+----------+-----------+---------+-------
peripherals | 4 | 7 | 2083.00 | 40.35
accessories | 4 | 24 | 1496.00 | 28.98
storage | 1 | 1 | 899.00 | 17.41
cable | 2 | 15 | 685.00 | 13.27
(4 rows)-- 步骤 3:按地区 + 渠道分组,只看 GMV 超过 300 的组合
SELECT region, channel, sum(qty * unit_price) AS gmv
FROM orders
WHERE is_paid -- 只统计已付款
GROUP BY region, channel
HAVING sum(qty * unit_price) > 300
ORDER BY gmv DESC; region | channel | gmv
--------+---------+---------
east | web | 1506.00
west | app | 580.00
east | app | 518.00
north | web | 490.00
west | web | 429.00
(5 rows)-- 步骤 4:条件聚合 —— 一张表同时给出付款/未付款双口径
SELECT
region,
count(DISTINCT order_no) AS orders,
count(*) FILTER (WHERE is_paid) AS paid_items,
coalesce(sum(qty * unit_price) FILTER (WHERE is_paid), 0) AS paid_gmv,
coalesce(sum(qty * unit_price) FILTER (WHERE NOT is_paid), 0) AS unpaid_gmv
FROM orders
GROUP BY region
ORDER BY paid_gmv DESC; region | orders | paid_items | paid_gmv | unpaid_gmv
--------+--------+------------+----------+------------
east | 4 | 5 | 2219.00 | 0.00
west | 2 | 2 | 1009.00 | 0.00
north | 2 | 2 | 689.00 | 0.00
south | 2 | 0 | 0.00 | 1246.00
(4 rows)-- 步骤 5:用 ROLLUP 生成"月度 + 总计"报表
SELECT
CASE WHEN GROUPING(ym) = 1 THEN '【总计】' ELSE ym END AS ym,
count(DISTINCT order_no) AS order_cnt,
sum(qty) AS total_qty,
sum(qty * unit_price) AS gmv
FROM (
SELECT order_no, qty, unit_price, to_char(created_at, 'YYYY-MM') AS ym
FROM orders
) t
GROUP BY ROLLUP (ym)
ORDER BY GROUPING(ym), ym; ym | order_cnt | total_qty | gmv
----------+-----------+-----------+---------
2026-01 | 3 | 7 | 1853.00
2026-02 | 4 | 18 | 1402.00
2026-03 | 3 | 22 | 1908.00
【总计】 | 10 | 47 | 5163.00
(4 rows)-- 步骤 6:找出"下单金额高于平均客单价"的客户
WITH customer_gmv AS (
SELECT customer, sum(qty * unit_price) AS gmv
FROM orders
WHERE is_paid
GROUP BY customer
)
SELECT customer, gmv
FROM customer_gmv
WHERE gmv > (SELECT avg(gmv) FROM customer_gmv)
ORDER BY gmv DESC; customer | gmv
----------+---------
alice | 1125.00
dave | 1094.00
eve | 1009.00
(3 rows)-- 步骤 7:客户分层统计(按消费额分档后再聚合)
WITH customer_gmv AS (
SELECT customer, sum(qty * unit_price) AS gmv
FROM orders
GROUP BY customer
)
SELECT
CASE
WHEN gmv >= 1000 THEN 'A 高价值'
WHEN gmv >= 500 THEN 'B 中价值'
ELSE 'C 普通'
END AS tier,
count(*) AS customer_cnt,
sum(gmv) AS tier_gmv,
round(avg(gmv), 2) AS avg_gmv
FROM customer_gmv
GROUP BY tier
ORDER BY tier; tier | customer_cnt | tier_gmv | avg_gmv
-------------+--------------+----------+---------
A 高价值 | 4 | 4474.00 | 1118.50
B 中价值 | 1 | 689.00 | 689.00
(2 rows)-- 步骤 8:用 FILTER 做渠道 × 付款状态的透视表
SELECT
channel,
count(*) AS total,
count(*) FILTER (WHERE is_paid) AS paid,
count(*) FILTER (WHERE NOT is_paid) AS unpaid,
round(100.0 * count(*) FILTER (WHERE is_paid) / count(*), 1) AS paid_rate_pct
FROM orders
GROUP BY channel
ORDER BY paid_rate_pct DESC; channel | total | paid | unpaid | paid_rate_pct
---------+-------+------+--------+---------------
app | 3 | 3 | 0 | 100.0
web | 6 | 5 | 1 | 83.3
store | 2 | 1 | 1 | 50.0
(3 rows)注意事项
WHERE中不能使用聚合函数。聚合发生在WHERE之后,因此聚合条件必须写进HAVING。count(*)与count(列)结果可能不同。后者忽略NULL;统计"有效数据行数"时应明确用哪者。count(DISTINCT a, b)的括号语义是"多列组合去重",不是函数嵌套:
-- 正确:统计 (customer, order_no) 组合数
SELECT count(DISTINCT (customer, order_no)) FROM orders;
-- 错误!DISTINCT 不是函数,不能这样写
SELECT count(DISTINCT customer, order_no) FROM orders;avg的分母是"非NULL行数",容易造成误导性结论。需要把NULL当 0 参与时,先用COALESCE转换:
-- 错误认知:以为 avg 除以总行数
SELECT avg(discount) FROM orders; -- 分母 = discount 非 NULL 的行数
-- 明确语义
SELECT avg(COALESCE(discount, 0)) FROM orders; -- 分母 = 所有行GROUP BY中的别名可能被列名覆盖。当输出别名与输入列同名时,PostgreSQL 优先按输入列解析,行为可能不符合直觉。避免别名与列名重名。ROLLUP/CUBE会产生大量行。CUBE的分组集数量是 2ⁿ,列数多时行数会爆炸式增长,且产生大量NULL汇总行。务必用GROUPING()区分汇总行与真实NULL。- 简单查询不要盲目用
GROUP BY替代DISTINCT。两者语义不同:DISTINCT去重保持原行,GROUP BY折叠行并允许附加聚合。 GROUP BY会隐式排序(旧版本)。PostgreSQL 中GROUP BY不保证输出顺序,需要顺序时显式写ORDER BY。- 在
GROUP BY中对列使用函数会阻止索引利用。按月份分组时,若希望用索引加速,可改为对列做范围过滤而非函数包裹。
-- 不推荐(大表上无法利用 created_at 索引做分组)
GROUP BY to_char(created_at, 'YYYY-MM')
-- 更优:直接用 date_trunc,并对时间列做范围过滤
SELECT date_trunc('month', created_at)::date AS mon, count(*)
FROM orders
WHERE created_at >= DATE '2026-01-01' AND created_at < DATE '2027-01-01'
GROUP BY date_trunc('month', created_at)
ORDER BY mon;HAVING条件尽量能提前到WHERE。能在分组前排除的行,先排除可以减少聚合计算量。- 超大数据集的精确
count(DISTINCT)代价高。它需要保存去重集合;近似计数场景可考虑pg_stat_statements或 HyperLogLog 类扩展。
最佳实践
WHERE缩小输入,HAVING筛选输出。 先想清楚条件属于"行级"还是"组级",这决定了它该写在哪个子句。- 多口径统计用
FILTER而非多次查询。 一次全表扫描产出多个指标,既高效又能保证口径一致(同一时间点同一份数据)。
-- 一次扫描产出多指标
SELECT
region,
count(*) AS orders,
count(*) FILTER (WHERE is_paid) AS paid_orders,
sum(qty * unit_price) FILTER (WHERE is_paid) AS paid_gmv,
count(DISTINCT customer) FILTER (WHERE qty >= 5) AS bulk_customers
FROM orders
GROUP BY region;- 报表的中位数、分位数用
percentile_cont,不要用avg代替。价格、响应时间、停留时长等偏态分布数据,平均值极易被极端值带偏。 - 多维度汇总用
GROUPING SETS/ROLLUP,并始终配合GROUPING()区分汇总行与真实NULL,避免报表出现"看起来是空值实际是合计"的错误。 - 为
GROUP BY与HAVING涉及的列建索引。 等值分组列在前、范围过滤列在后的复合索引通常最优;纯聚合查询可考虑覆盖索引(INCLUDE)实现 index-only scan。
-- 支撑按 region 分组、按 created_at 过滤的报表
CREATE INDEX idx_orders_region_created ON orders (region, created_at) INCLUDE (qty, unit_price);- 复杂统计拆成 CTE 分层书写。 与把子查询层层嵌套相比,CTE 让每一层的聚合语义独立可读,也便于逐层验证。
-- 分层:明细 → 订单级 → 客户级 → 分档统计
WITH order_level AS (
SELECT order_no, customer, region,
sum(qty * unit_price) AS order_gmv
FROM orders
WHERE is_paid
GROUP BY order_no, customer, region
),
customer_level AS (
SELECT customer, count(*) AS order_cnt, sum(order_gmv) AS customer_gmv
FROM order_level
GROUP BY customer
)
SELECT
CASE WHEN customer_gmv >= 1000 THEN 'A' WHEN customer_gmv >= 500 THEN 'B' ELSE 'C' END AS tier,
count(*) AS customer_cnt,
sum(customer_gmv) AS tier_gmv
FROM customer_level
GROUP BY tier
ORDER BY tier;- 用
EXPLAIN (ANALYZE, BUFFERS)观察聚合计划。 关注是否出现HashAggregate(通常优于GroupAggregate的排序开销)、Sort是否可被索引消除,以及是否存在Seq Scan全表扫描。
EXPLAIN (ANALYZE, BUFFERS)
SELECT category, sum(qty * unit_price) AS gmv
FROM orders
GROUP BY category
ORDER BY gmv DESC;- 大表统计考虑预聚合或物化视图。 对实时性要求不高的报表,用物化视图(Materialized View)定期刷新,把秒级聚合降为毫秒级查询。
-- 物化视图:定期刷新的日维度报表
CREATE MATERIALIZED VIEW mv_daily_gmv AS
SELECT created_at AS stat_date,
category,
count(*) AS item_cnt,
sum(qty * unit_price) AS gmv
FROM orders
GROUP BY created_at, category;
CREATE UNIQUE INDEX ON mv_daily_gmv (stat_date, category);
-- 刷新(并发刷新需唯一索引,且不会阻塞查询)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_gmv;下一节
继续学习:表连接基础