COUNT / SUM / AVG / MAX / MIN
count、sum、avg、max、min 是 SQL 中最基础的五类聚合函数,但在真实报表里它们几乎每一处都会踩到 NULL 语义与类型提升的坑:avg 的分母是「非 NULL 计数」而非总行数,全 NULL 的组 sum 返回 NULL 而非 0,sum(int4) 会悄悄变成 bigint 再变成 numeric。本节把这五个函数的语义、NULL 行为、类型规则与条件聚合写法讲透,并给出一次扫描产出多维指标的标准写法。
前置知识
阅读本节前,建议先了解:OVERLAPS 时间重叠
基础概念
聚合函数(Aggregate Function) 把一组输入行归约为单个值。与标量函数「一行进一行出」不同,聚合函数「多行进一行出」,输出行数由 GROUP BY 决定。
| 概念 | 术语 | 说明 |
|---|---|---|
| 聚合函数 | Aggregate Function | 把多行归约为一个值的函数,如 sum / avg |
| 组 | Group | GROUP BY 划分出的行集合,聚合在组内独立计算 |
| 分组键 | Grouping Key | 参与分组的列或表达式 |
| 计数星号 | count(*) | 统计行数,含 NULL 行,是唯一不忽略 NULL 的计数 |
| 条件计数 | count(col) | 统计该列非 NULL 的个数 |
| 去重计数 | count(DISTINCT col) | 统计该列不同非 NULL 值的个数 |
| 条件聚合 | Conditional Aggregate | 用 FILTER 或 CASE 只聚合满足条件的行 |
| 类型提升 | Type Promotion | sum(int4) → bigint、sum(bigint) → numeric |
| 忽略 NULL | NULL-Ignoring | 标准聚合函数跳过 NULL,不参与计算 |
| 空组结果 | Empty Group Result | 全 NULL 组中 sum / avg / max / min 返回 NULL |
五者的语义签名与 NULL 行为:
| 函数 | 返回类型 | 忽略 NULL | 全 NULL 组返回 | 空表(无 GROUP BY) |
|---|---|---|---|---|
count(*) | bigint | 不适用(计所有行) | 0 | 0 |
count(expr) | bigint | 是 | 0 | 0 |
sum(expr) | 见下文类型表 | 是 | NULL | NULL |
avg(expr) | numeric / double precision | 是 | NULL | NULL |
max(expr) / min(expr) | 输入类型 | 是 | NULL | NULL |
一句话记忆:只有 count 返回 0 而不是 NULL。这是「sum 加空组得到 NULL 而不是 0」这一经典陷阱的根源。
语法与用法
-- 聚合函数通用形态
聚合函数(expr) -- 普通聚合
聚合函数(expr) FILTER (WHERE 条件) -- 条件聚合(PostgreSQL 9.4+)
聚合函数(DISTINCT expr) -- 去重聚合(仅 count / sum / avg 支持)
-- 典型查询骨架
SELECT 分组键, count(*) AS 行数, sum(amount) AS 合计
FROM t
WHERE 行级条件
GROUP BY 分组键
HAVING 组级条件
ORDER BY 合计 DESC;最小可运行示例,覆盖五个函数与 NULL 行为:
SELECT
count(*) AS all_rows, -- 计所有行
count(city) AS city_filled, -- 跳过 NULL
count(DISTINCT city) AS city_uniq, -- 去重后计数
sum(amount) AS total_amount,
round(avg(amount), 2) AS avg_amount,
min(amount) AS min_amount,
max(amount) AS max_amount
FROM (VALUES
('北京', 100.00),
('北京', 200.00),
('上海', 50.00),
(NULL, 150.00) -- city 为 NULL,amount 有效
) AS t(city, amount); all_rows | city_filled | city_uniq | total_amount | avg_amount | min_amount | max_amount
----------+-------------+-----------+--------------+------------+------------+------------
4 | 3 | 2 | 500.00 | 125.00 | 50.00 | 200.00
(1 row)count(*) 是 4(含 city 为 NULL 的那行),count(city) 是 3——两者之差就是 NULL 行数。
详细说明
count(*) 与 count(col)
这是最常用的两个计数,差别只在 NULL:
| 写法 | 统计对象 | NULL 行是否计入 |
|---|---|---|
count(*) | 全部行 | 计入 |
count(1) / count(0) | 全部行(常量非 NULL) | 计入 |
count(col) | col IS NOT NULL 的行 | 不计入 |
count(DISTINCT col) | col 的非 NULL 不同值 | 不计入 |
-- 三者在同一份数据上的差异
SELECT
count(*) AS star,
count(1) AS one,
count(city) AS col,
count(DISTINCT city) AS dist,
count(*) - count(city) AS null_rows
FROM (VALUES ('北京'), ('北京'), ('上海'), (NULL), (NULL)) AS t(city); star | one | col | dist | null_rows
------+-----+-----+------+-----------
5 | 5 | 3 | 2 | 2
(1 row)count(1) 与 count(*) 语义与计划完全相同(优化器都展开为 count(*) 节点),但 count(1) 会让读者猜测用意,推荐一律写 count(*)。
count(*) 在大表上必须全扫,代价是 O(行数)。PostgreSQL 的 MVCC 让「行数」不便于增量维护,因此没有 MySQL InnoDB 那种缓存计数。近似值可以从统计信息拿:
-- 精确计数:需要全扫
SELECT count(*) FROM orders;
-- 近似计数:读 pg_class 的统计估算,O(1),误差通常 < 5%
SELECT reltuples::BIGINT AS approx_rows
FROM pg_class
WHERE oid = 'orders'::REGCLASS;需要精确总数时也有技巧:用窗口函数一次查出「数据 + 总数」:
-- count(*) OVER () 在过滤后的结果集上算总数,与明细同时返回
SELECT id, order_no, count(*) OVER () AS total_rows
FROM orders
ORDER BY id
LIMIT 20;avg 的分母是「非 NULL 计数」
这是统计类报表最经典的陷阱。avg(x) 的等价定义是:
avg(x) ≡ sum(x) / count(x) -- 注意分母是 count(x),不是 count(*)| 写法 | 分母 | 语义 |
|---|---|---|
avg(amount) | count(amount) 即非 NULL 行数 | 只对填了金额的行求平均 |
sum(amount) / count(*) | 总行数 | 把 NULL 当作缺失分母,结果偏小 |
sum(amount) / nullif(count(amount), 0) | 非 NULL 行数 | 与 avg 等价,且能防除零 |
-- 5 行数据,其中 2 行 amount 为 NULL
SELECT
count(*) AS total_rows,
count(amount) AS amount_rows,
avg(amount) AS avg_ignores_null,
sum(amount) / count(*) AS avg_includes_null,
sum(amount) / nullif(count(amount), 0) AS avg_explicit
FROM (VALUES
(100.00), (200.00), (300.00), (NULL), (NULL)
) AS t(amount); total_rows | amount_rows | avg_ignores_null | avg_includes_null | avg_explicit
------------+-------------+------------------+-------------------+--------------
5 | 3 | 200.00 | 120.00 | 200.00
(1 row)avg(amount) 得 200.00,而 sum/count(*) 得 120.00。当业务口径是「所有订单的平均客单价」时,要先决定 NULL 金额是否算作 0——这是需求层面必须问清的问题,不是 SQL 能替你决定的。
加权平均不能直接用 avg,必须手写:
-- 错误!avg(price) 是算术平均,不等于按销量加权的平均成交价
SELECT avg(unit_price) AS wrong_weighted
FROM order_items;
-- 正确:加权平均 = sum(单价 × 数量) / sum(数量)
SELECT sum(unit_price * qty) / nullif(sum(qty), 0) AS weighted_avg_price
FROM order_items;sum 的全 NULL 返回 NULL 与类型提升
sum 有两个必须记住的行为。
行为一:全 NULL 或空组返回 NULL,不是 0。
-- 空集与全 NULL 的 sum 都是 NULL
SELECT
sum(amount) AS raw_sum, -- NULL
coalesce(sum(amount), 0) AS safe_sum -- 0
FROM (VALUES (NULL::NUMERIC), (NULL::NUMERIC)) AS t(amount); raw_sum | safe_sum
---------+----------
| 0.00
(1 row)报表里凡是「没有数据时应该显示 0」的位置,都必须包 coalesce(sum(x), 0)。左侧 /、avg 等对 NULL 的处理也一致。
行为二:类型提升,且不会溢出。
| 输入类型 | sum 返回类型 | 说明 |
|---|---|---|
smallint / integer | bigint | 提升一级,避免 32 位溢出 |
bigint | numeric | 提升为 numeric,避免 64 位溢出 |
numeric | numeric | 保持 |
real / double precision | double precision | 浮点累加,有精度损失 |
money | money | — |
interval | interval | 间隔累加 |
SELECT
pg_typeof(sum(x_int)) AS sum_int4, -- bigint
pg_typeof(sum(x_bigint)) AS sum_int8, -- numeric
pg_typeof(sum(x_num)) AS sum_numeric, -- numeric
pg_typeof(sum(x_float)) AS sum_float8 -- double precision
FROM (VALUES (1, 1::BIGINT, 1::NUMERIC, 1::DOUBLE PRECISION)) AS t(x_int, x_bigint, x_num, x_float); sum_int4 | sum_int8 | sum_numeric | sum_float8
------------+----------+-------------+------------
bigint | numeric | numeric | double precision
(1 row)结论:整数求和不会溢出,因为 PostgreSQL 自动升为 bigint / numeric。但 double precision 求和仍会有浮点误差,金融场景务必用 numeric。
max / min 的比较规则与索引优化
max / min 对文本按排序规则(Collation) 比较,不是按字节:
SELECT
max(name) AS max_name,
min(name) AS min_name
FROM (VALUES ('apple'), ('Banana'), ('cherry'), ('_under')) AS t(name); max_name | min_name
----------+----------
_under | apple
(1 row)apple < Banana 是因为 en_US.UTF-8 排序规则忽略大小写差异(a 在 B 之前),而下划线 _ 的排序权重最低。需要字节序比较时显式指定 COLLATE "C":
SELECT max(name COLLATE "C") AS byte_max FROM (VALUES ('apple'), ('Banana')) AS t(name); byte_max
----------
apple
(1 row)SELECT max(id) FROM t 可以走 B-tree 索引,只读索引最右端一个元组,代价 O(log n):
CREATE INDEX idx_orders_id ON orders (id);
EXPLAIN (COSTS OFF) SELECT max(id) FROM orders; QUERY PLAN
--------------------------------------
Result
InitPlan 1 (returns $0)
-> Limit
-> Index Only Scan Backward using idx_orders_id on orders
Index Cond: (id IS NOT NULL)
(5 rows)对比 count(*) 必须走 Aggregate 全扫——这解释了为什么「取最新一条」用 ORDER BY id DESC LIMIT 1 很快,而「看总数」很慢。
两种分组物理计划:HashAggregate 与 GroupAggregate
带 GROUP BY 的聚合有两种物理实现,EXPLAIN 会直接写出来,读计划时必须能分辨:
| 计划节点 | 是否要求输入有序 | 内存特征 | 适合场景 |
|---|---|---|---|
HashAggregate | 否,先扫一遍建哈希表 | 分组数多时占内存,超 work_mem 会落盘 | 分组数适中(几千到几十万) |
GroupAggregate | 是,输入须按分组键有序 | 逐组流式累加,内存占用小 | 分组数极大,或分组键上已有索引 |
-- 分组数少:优化器通常选 HashAggregate
EXPLAIN (COSTS OFF) SELECT city, count(*) FROM orders GROUP BY city; QUERY PLAN
------------------------------------
HashAggregate
Group Key: city
-> Seq Scan on orders
(3 rows)-- 分组键上建了索引后,可能改走 GroupAggregate,省掉显式排序
CREATE INDEX idx_orders_city ON orders (city);
EXPLAIN (COSTS OFF) SELECT city, count(*) FROM orders GROUP BY city; QUERY PLAN
---------------------------------------------------
GroupAggregate
Group Key: city
-> Index Only Scan using idx_orders_city on orders
(3 rows)若分组键上没有索引,GroupAggregate 前面会多出一个 Sort 节点——此时它比 HashAggregate 更贵,优化器多半不会选它。反过来,当分组数极大(例如按 user_id 分组上百万组)时,HashAggregate 的哈希表可能撑爆 work_mem,日志里出现 Sort Method: external merge Disk 或哈希批次落盘,这时用索引喂给 GroupAggregate 反而更稳。
HashAggregate 还支持并行:计划里会出现 Partial HashAggregate + Finalize HashAggregate 两级,各 worker 先局部聚合,再由领导者合并。
聚合与 WHERE / GROUP BY / HAVING 的执行顺序
| 顺序 | 子句 | 作用对象 | 能否引用聚合 |
|---|---|---|---|
| 1 | FROM / JOIN | 原始行 | 否 |
| 2 | WHERE | 原始行 | 否 |
| 3 | GROUP BY | 原始行 | 否 |
| 4 | 聚合函数 | 每组 | — |
| 5 | HAVING | 分组 | 能 |
| 6 | SELECT | 结果行 | 能 |
| 7 | ORDER BY | 结果行 | 能 |
-- 错误!WHERE 中不能出现聚合函数
SELECT city, count(*) FROM orders WHERE count(*) > 1 GROUP BY city;ERROR: aggregate functions are not allowed in WHERE
LINE 1: SELECT city, count(*) FROM orders WHERE count(*) > 1 GROUP BY...需要在分组前过滤行,把条件放进 WHERE;需要过滤分组,放进 HAVING。条件能前移就前移——先在 WHERE 减少输入行,分组代价随之下降。
FILTER 做条件聚合
FILTER (WHERE cond) 让单个聚合只处理满足条件的行,是「一次扫描产出多列指标」的核心手段(PostgreSQL 9.4+):
-- 一次扫描拿到多个维度的统计
SELECT
count(*) AS total_cnt,
count(*) FILTER (WHERE status = 'paid') AS paid_cnt,
sum(amount) FILTER (WHERE status = 'paid') AS paid_amount,
count(*) FILTER (WHERE status = 'refunded') AS refunded_cnt
FROM orders;旧版本可用 CASE 模拟,但必须补 ELSE 兜底,否则 sum 会因 NULL 得出错误结果:
-- 等价但更啰嗦,且必须写 ELSE 0
SELECT
count(CASE WHEN status = 'paid' THEN 1 END) AS paid_cnt,
sum(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_amount
FROM orders;聚合函数不能嵌套
sum(count(*)) 这类写法直接报错,因为聚合的输入必须是原始行:
-- 错误!aggregate function calls cannot be nested
SELECT sum(count(*)) FROM orders GROUP BY city;ERROR: aggregate function calls cannot be nested
LINE 1: SELECT sum(count(*)) FROM orders GROUP BY city;需要「对聚合结果再聚合」时,先分层:用子查询或 CTE 把内层聚合固化,再在外层聚合。
-- 正确:先按城市算订单数,再对外层求平均
WITH city_cnt AS (
SELECT city, count(*) AS cnt FROM orders GROUP BY city
)
SELECT round(avg(cnt), 2) AS avg_orders_per_city, sum(cnt) AS total_orders
FROM city_cnt;实战示例
场景:电商订单看板。要求一条 SQL 从同一张表产出行数、去重用户数、GMV、平均客单价、最大最小金额,并在城市维度上分组。
第 1 步:建表并造数
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL UNIQUE,
user_name TEXT NOT NULL,
city TEXT, -- 允许 NULL:未填写城市
amount NUMERIC(12, 2), -- 允许 NULL:待定价订单
status TEXT NOT NULL CHECK (status IN ('paid', 'pending', 'canceled', 'refunded')),
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', TIMESTAMPTZ '2026-01-05 10:00:00+08'),
('SO-1002', 'bob', '上海', 350.50, 'paid', TIMESTAMPTZ '2026-01-06 11:30:00+08'),
('SO-1003', 'alice', '北京', 880.00, 'pending', TIMESTAMPTZ '2026-01-07 09:15:00+08'),
('SO-1004', 'carol', '广州', 1200.00, 'paid', TIMESTAMPTZ '2026-01-08 14:20:00+08'),
('SO-1005', 'bob', '上海', 220.00, 'canceled', TIMESTAMPTZ '2026-01-09 16:45:00+08'),
('SO-1006', 'dave', '北京', 450.00, 'refunded', TIMESTAMPTZ '2026-01-10 08:05:00+08'),
('SO-1007', 'carol', '广州', 1200.00, 'paid', TIMESTAMPTZ '2026-01-11 12:40:00+08'),
('SO-1008', 'eve', '深圳', 760.00, 'paid', TIMESTAMPTZ '2026-01-12 15:10:00+08'),
('SO-1009', 'frank', NULL, NULL, 'pending', TIMESTAMPTZ '2026-01-13 10:00:00+08');INSERT 0 9注意第 9 行:city 与 amount 都是 NULL,用来演示 NULL 对各个聚合的影响。
第 2 步:整体指标(含 NULL 陷阱对照)
SELECT
count(*) AS 订单数, -- 9,含 NULL 行
count(city) AS 已填城市数, -- 8
count(amount) AS 已定价数, -- 8
count(DISTINCT user_name) AS 下单用户数, -- 6
sum(amount) AS 已定价总额,
coalesce(sum(amount), 0) AS 总额_安全, -- NULL 兜底
round(avg(amount), 2) AS 平均额_非空,
round(sum(amount) / nullif(count(*), 0), 2) AS 平均额_含空
FROM orders; 订单数 | 已填城市数 | 已定价数 | 下单用户数 | 已定价总额 | 总额_安全 | 平均额_非空 | 平均额_含空
--------+------------+----------+------------+------------+-----------+-------------+-------------
9 | 8 | 8 | 6 | 6260.50 | 6260.50 | 782.56 | 695.61
(1 row)平均额_非空 = 6260.50 / 8 = 782.56,平均额_含空 = 6260.50 / 9 = 695.61。同一份数据,两种口径差 87 元——报表口径必须先统一。
第 3 步:按城市分组 + 条件聚合
SELECT
coalesce(city, '【未填写】') AS 城市,
count(*) AS 订单数,
count(DISTINCT user_name) AS 用户数,
coalesce(sum(amount), 0) AS 总额,
round(avg(amount), 2) AS 平均额,
min(amount) AS 最小额,
max(amount) AS 最大额,
count(*) FILTER (WHERE status = 'paid') AS 已付款数,
coalesce(sum(amount) FILTER (WHERE status = 'paid'), 0) AS 已付款额
FROM orders
GROUP BY city
ORDER BY 总额 DESC; 城市 | 订单数 | 用户数 | 总额 | 平均额 | 最小额 | 最大额 | 已付款数 | 已付款额
------------+--------+--------+--------+--------+--------+--------+----------+----------
北京 | 3 | 2 |2530.00 | 843.33 | 450.00 |1200.00 | 1 | 1200.00
广州 | 2 | 1 |2400.00 |1200.00 |1200.00 |1200.00 | 2 | 2400.00
深圳 | 1 | 1 | 760.00 | 760.00 | 760.00 | 760.00 | 1 | 760.00
上海 | 2 | 1 | 570.50 | 285.25 | 220.00 | 350.50 | 1 | 350.50
【未填写】 | 1 | 1 | 0.00 | | | | 0 | 0.00
(5 rows)看最后一行:金额 为 NULL 的那组,sum 经 coalesce 变 0.00,而 avg / min / max 全为空白(NULL)。这直观地展示了「只有 count 与兜底后的 sum 能安全显示」。
第 4 步:验证 count(*) 与 count(col) 的差异来源
SELECT
count(*) AS 全部行,
count(city) AS 有城市,
count(amount) AS 有金额,
count(*) - count(city) AS 城市为空行,
count(*) - count(amount) AS 金额为空行
FROM orders; 全部行 | 有城市 | 有金额 | 城市为空行 | 金额为空行
--------+--------+--------+------------+------------
9 | 8 | 8 | 1 | 1第 5 步:用 EXPLAIN 看近似计数与精确计数的差异
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM orders; QUERY PLAN
---------------------------------------
Aggregate (actual time=0.045..0.045 rows=1 loops=1)
-> Seq Scan on orders (actual time=0.010..0.014 rows=9 loops=1)
Planning Time: 0.090 ms
Execution Time: 0.070 ms表越大,Seq Scan 的代价越明显——这正是需要精确总数时应改用「维护计数表」而非实时 count(*) 的原因。
第 6 步:清理
DROP TABLE IF EXISTS orders;注意事项
avg的分母是count(expr)而非count(*)。NULL 多时平均值会偏高,务必与业务确认口径:
-- 这两种写法结果不同,必须显式决定用哪一种
SELECT avg(amount), sum(amount) / count(*) FROM orders;- 全 NULL 的组
sum返回 NULL,不是 0。用于减法、除法、比较前必须兜底:
-- 错误!没有数据时得到 NULL,前端显示为空白
SELECT sum(amount) FROM orders WHERE status = 'no_such_status';
-- 推荐:显式兜底
SELECT coalesce(sum(amount), 0) FROM orders WHERE status = 'no_such_status';count(1)与count(*)等价但不推荐:语义相同、计划相同,但会让阅读者误以为有特殊意图。count(col)忽略 NULL,count(*)不忽略。若用count(col)判断「是否有数据」,NULL 行会被漏计。聚合函数不能出现在
WHERE中,只能用HAVING或外层子查询。聚合函数不能嵌套,
sum(count(*))直接报错,必须分层用 CTE 或子查询。max/min对文本按排序规则比较,跨语言环境结果可能与预期不符。需要确定性字节序时加COLLATE "C"。sum(double precision)有浮点误差,金融金额一律用numeric:
-- 不推荐:浮点累加
SELECT sum(amount::DOUBLE PRECISION) FROM orders;
-- 推荐:十进制精确求和
SELECT sum(amount) FROM orders;count(DISTINCT ...)在大表上代价高(需排序或哈希去重),且不支持FILTER之外的多列去重语法糖(多列需count(DISTINCT (a, b))行构造器写法)。聚合结果无顺序保证,报表必须显式
ORDER BY。
最佳实践
- 报表里凡是「求和」都包
coalesce,这是唯一能保证「无数据时显示 0」的做法:
SELECT city, coalesce(sum(amount), 0) AS total
FROM orders
GROUP BY city
ORDER BY total DESC;avg与「总额 / 单数」二选一时,先确认分母。若业务口径是「所有订单」,用sum(amount) / nullif(count(*), 0);若是「有效金额订单」,直接用avg(amount)并写注释说明。多维指标用
FILTER一条 SQL 完成,避免同一张表被扫多遍:
SELECT
count(*) AS 订单数,
count(*) FILTER (WHERE status = 'paid') AS 已付款,
sum(amount) FILTER (WHERE status = 'paid') AS 已付款额,
count(*) FILTER (WHERE status = 'refunded') AS 已退款,
round(avg(amount) FILTER (WHERE status = 'paid'), 2) AS 平均客单价
FROM orders;- 精确总数用计数表或物化视图维护,不要在每次写入时对全表
count(*)现算。千万级表上的count(*)需要走索引全扫,高并发下会成为热点:
-- 用触发器或应用逻辑维护计数表
CREATE TABLE order_stats (
stat_date date PRIMARY KEY,
order_cnt bigint NOT NULL DEFAULT 0
);
-- 报表优先读计数表
SELECT coalesce(sum(order_cnt), 0) AS 累计订单数 FROM order_stats;max/min走索引即是最优解。若查询形如SELECT max(created_at) FROM orders WHERE user_id = ?,建(user_id, created_at)复合索引可让优化器直接读索引末端,无需扫描整组:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);count(DISTINCT ...)尽量换成GROUP BY子查询或EXISTS。当只需要「有多少个不同值」且业务可接受近似时,pg_statistic或 HLL 扩展比精确去重便宜得多;必须精确时,把它拆到独立子查询避免与其它聚合争抢排序资源。给聚合结果起英文或中文别名时保持全库一致。本项目统一使用中文列别名(如
AS 订单数),便于报表层直接映射;混用中英文会让下游字段名难以对齐。用
EXPLAIN (ANALYZE, BUFFERS)验证「一次扫描」是否成立。若执行计划出现多次Seq Scan,说明优化器没把多个聚合合并,应改写为FILTER形式或检查统计信息是否过期(ANALYZE orders)。