聚合函数与分组查询

聚合的核心思想

普通 SELECT 返回的是一行行明细数据.聚合做的事情恰恰相反 - 它把多行"折叠"成单个值:

-- 明细:8 行数据
SELECT balance FROM users;

-- 聚合:1 个数字
SELECT SUM(balance) FROM users; -- 所有用户余额之和

类比理解:想象你有一叠发票,明细是逐张翻看每张金额,聚合 是拿计算器把它们加总.

SQL 执行顺序中聚合的位置

FROM → WHERE → GROUP BY → 聚合函数 → HAVING → SELECT → ORDER BY → LIMIT

先过滤行(WHERE),再分组(GROUP BY),然后才执行聚合计算. 理解这个顺序能帮助判断"条件该写在 WHERE 还是 HAVING".

五大聚合函数

函数 作用 NULL 处理
COUNT(expr) 计数非 NULL 的行 跳过 NULL
COUNT(*) 计数所有行(含 NULL) 不跳过
SUM(expr) 求和 跳过 NULL
AVG(expr) 平均值 跳过 NULL(分母不含 NULL 行)
MIN(expr) 最小值 跳过 NULL
MAX(expr) 最大值 跳过 NULL
-- 用户总数
SELECT COUNT(*) FROM users; -- 8

-- 用户平均余额
SELECT AVG(balance) FROM users; -- 3068.81(精确到分)

-- 最贵和最便宜的商品
SELECT MIN(price) AS cheapest, MAX(price) AS most_expensive FROM products;
-- 88.00, 14999.00

-- 所有已完成订单的总金额
SELECT SUM(total) FROM orders WHERE status = 'done'; -- 50543.00

COUNT(*) vs COUNT(column)

COUNT(*) 计算所有行数,COUNT(column) 只计算该列非 NULL 的行数. 当列可能含 NULL 时两者结果不同. 实际开发中,"统计总行数"用 COUNT(*),"统计某字段有值的行数"用 COUNT(column).

GROUP BY 分组

聚合函数单独使用时,把整张表视为"一个组".加上 GROUP BY 后,可以按某个维度将数据切成多个组,分别聚合:

-- 每个城市有多少用户?
SELECT city, COUNT(*) AS user_count
FROM users
GROUP BY city;

-- 结果:
-- 北京 3
-- 上海 2
-- 广州 2
-- 深圳 1

类比:GROUP BY 就像把一叠发票先按"客户"分成几叠,再分别计算每叠的合计.

-- 每个订单状态各有多少单?总金额是多少?
SELECT status, COUNT(*) AS cnt, SUM(total) AS total_amount
FROM orders
GROUP BY status
ORDER BY total_amount DESC;

-- done 7 50543.00
-- paid 1 14999.00
-- shipped 1 6999.00
-- pending 1 1799.00
-- cancelled 1 99.00

SELECT 列必须出现在 GROUP BY 中或被聚合包裹

这是最常见的报错:column "x" must appear in the GROUP BY clause or be used in an aggregate function.

规则很简单--GROUP BY 之后,SELECT 里只能写两种东西:

  1. 分组键(GROUP BY 的列)
  2. 聚合函数

否则 PostgreSQL 不知道该取哪一行的值.

HAVING 过滤分组

WHERE 过滤的是原始行(聚合之前),HAVING 过滤的是聚合结果(分组之后).

-- 哪些城市的用户数 >= 2?
SELECT city, COUNT(*) AS user_count
FROM users
GROUP BY city
HAVING COUNT(*) >= 2;

-- 北京 3
-- 上海 2
-- 广州 2

对比 WHERE 和 HAVING 的区别:

-- 在 VIP 用户中,哪些城市有 2 个及以上的 VIP?
SELECT city, COUNT(*) AS vip_count
FROM users
WHERE is_vip = true -- WHERE: 先筛选出 VIP 用户(行级过滤)
GROUP BY city
HAVING COUNT(*) >= 2; -- HAVING: 再筛选用户数 >= 2 的城市(组级过滤)

-- 结果:北京 3(alice, carol, grace 都是 VIP 且在北京)
-- 等等,grace 是北京的.alice, carol, grace 三个是北京 VIP -- 输出 北京 3

记忆口诀

WHERE 筛行,HAVING 筛组. 条件不涉及聚合函数,放 WHERE 更高效(减少参与分组的数据量);条件涉及 COUNT/SUM 等聚合结果,只能放 HAVING.

多列分组

GROUP BY 可以同时按多个列分组,形成更细的粒度:

-- 每个城市中,VIP 和非 VIP 用户各有多少?平均余额多少?
SELECT city, is_vip, COUNT(*) AS cnt, ROUND(AVG(balance), 2) AS avg_balance
FROM users
GROUP BY city, is_vip
ORDER BY city, is_vip DESC;

-- 北京 true 3 2666.67
-- 上海 true 1 15000.00
-- 上海 false 1 1200.50
-- 广州 false 2 375.00
-- 深圳 false 1 600.00
-- 每个分类下,各商品的平均评分
SELECT c.name AS category, p.name AS product,
ROUND(AVG(r.rating), 1) AS avg_rating,
COUNT(r.id) AS review_count
FROM products p
JOIN categories c ON c.id = p.category_id
LEFT JOIN reviews r ON r.product_id = p.id
GROUP BY c.name, p.name
HAVING COUNT(r.id) > 0
ORDER BY category, avg_rating DESC;

条件聚合

FILTER (WHERE ...)CASE WHEN 在一次查询中同时计算多个有条件的聚合值,避免写多个子查询:

-- 每个用户:总订单数,已完成订单数,已取消订单数
SELECT
u.username,
COUNT(o.id) AS total_orders,
COUNT(o.id) FILTER (WHERE o.status = 'done') AS done_orders,
COUNT(o.id) FILTER (WHERE o.status = 'cancelled') AS cancelled_orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.username
ORDER BY total_orders DESC;

FILTER 是 PostgreSQL 扩展

FILTER (WHERE ...) 是 SQL 标准(SQL:2003)语法,但 MySQL 不支持. 如果需要兼容 MySQL,用等价的 CASE WHEN 写法:COUNT(CASE WHEN status = 'done' THEN 1 END)

-- CASE WHEN 等价写法
SELECT
u.username,
COUNT(o.id) AS total_orders,
SUM(CASE WHEN o.status = 'done' THEN 1 ELSE 0 END) AS done_orders,
SUM(CASE WHEN o.status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.username;
-- 统计每个分类的:商品数,均价,最高价,库存总量
SELECT
c.name AS category,
COUNT(p.id) AS product_count,
ROUND(AVG(p.price), 2) AS avg_price,
MAX(p.price) AS max_price,
SUM(p.stock) AS total_stock
FROM categories c
LEFT JOIN products p ON p.category_id = c.id
GROUP BY c.name
ORDER BY avg_price DESC;

-- 电子产品 3 7932.33 14999.00 750
-- 服装 2 349.00 599.00 1200
-- 食品 2 128.00 168.00 800
-- 图书 3 118.67 139.00 240

DISTINCT 聚合

在聚合函数内加 DISTINCT 可以去重后再计算:

-- 有多少个不同城市的用户下过单?
SELECT COUNT(DISTINCT u.city) AS city_count
FROM users u
JOIN orders o ON o.user_id = u.id;
-- 4(北京,上海,广州,深圳都有用户下单)

-- 每个分类被多少个不同用户购买过?
SELECT c.name AS category, COUNT(DISTINCT o.user_id) AS buyer_count
FROM categories c
JOIN products p ON p.category_id = c.id
JOIN order_items oi ON oi.product_id = p.id
JOIN orders o ON o.id = oi.order_id
GROUP BY c.name
ORDER BY buyer_count DESC;

多表联合聚合

实际业务中,聚合往往需要 JOIN 多张表.关键是想清楚先 JOIN 再聚合还是先聚合再 JOIN:

-- 每个用户的总消费金额和平均订单金额(只统计已完成的订单)
SELECT
u.username,
u.city,
COUNT(o.id) AS order_count,
SUM(o.total) AS total_spent,
ROUND(AVG(o.total), 2) AS avg_order
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'done'
GROUP BY u.id, u.username, u.city
ORDER BY total_spent DESC NULLS LAST;

-- eve 上海 2 23064.00 11532.00
-- alice 北京 2 18597.00 9298.50
-- grace 北京 1 7266.00 7266.00
-- bob 上海 1 228.00 228.00
-- dave 广州 1 256.00 256.00
-- carol 北京 1 139.00 139.00
-- henry 广州 1 168.00 168.00
-- frank 深圳 0 (null) (null)

LEFT JOIN + 条件放在 ON vs WHERE

上面的 AND o.status = 'done' 放在 ON 子句中. 如果放在 WHERE 中,没有 done 订单的用户会被整行过滤掉(frank 就不会出现).

规则:LEFT JOIN 的额外过滤条件放 ON--保留左表全部行;放 WHERE--变成 INNER JOIN 效果.

-- 每个商品分类的平均评分和评价数
SELECT
c.name AS category,
COUNT(r.id) AS review_count,
ROUND(AVG(r.rating), 2) AS avg_rating
FROM categories c
JOIN products p ON p.category_id = c.id
LEFT JOIN reviews r ON r.product_id = p.id
GROUP BY c.name
ORDER BY avg_rating DESC NULLS LAST;

-- 图书 3 5.00
-- 电子产品 7 4.14
-- 食品 2 4.50
-- 服装 0 (null)

常见陷阱

陷阱 1:GROUP BY 与 JOIN 导致的"行膨胀"

-- 错误示例:想统计每个用户的订单数 AND 评价数
-- 如果一个用户有 2 单 × 3 条评价 = JOIN 后产生 6 行 → 两个 COUNT 都偏大

-- 正确做法:分别聚合后再 JOIN
SELECT u.username,
COALESCE(oc.cnt, 0) AS order_count,
COALESCE(rc.cnt, 0) AS review_count
FROM users u
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id) oc
ON oc.user_id = u.id
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM reviews GROUP BY user_id) rc
ON rc.user_id = u.id;

陷阱 2:AVG 对 NULL 的处理

-- 假设有 5 个用户,其中 2 个 balance 为 NULL
-- AVG(balance) 的分母是 3(非 NULL 行数),不是 5
-- 如果希望 NULL 当 0 计算:
SELECT AVG(COALESCE(balance, 0)) FROM users;

陷阱 3:WHERE 中不能使用聚合函数

-- 错误:WHERE 中不能用 COUNT
SELECT city FROM users WHERE COUNT(*) > 1 GROUP BY city;
-- ERROR: aggregate functions are not allowed in WHERE

-- 正确:用 HAVING
SELECT city FROM users GROUP BY city HAVING COUNT(*) > 1;

练习

  1. COUNT DISTINCT -- 统计商品表中共有多少个不同的分类(用聚合实现,不用 DISTINCT 子查询)
  2. AVG + WHERE -- 查询所有已完成(done)订单的平均金额
  3. GROUP BY 排序 -- 按城市统计用户的总余额,并按余额从高到低排序
  4. HAVING 过滤 -- 查询每个商品分类下的商品数量和平均价格,只显示平均价格超过 100 的分类
  5. DISTINCT 聚合 -- 统计每个用户购买的不同商品数量(通过 order_items 关联),按数量降序
  6. 条件聚合 -- 用 FILTER 或 CASE WHEN 在一条 SQL 中同时统计:总用户数,VIP 用户数,非 VIP 用户数
  7. WHERE + HAVING 组合 -- 找出下单总金额超过 5000 的用户,显示用户名,城市,订单数,总金额. 只统计 status 不是 cancelled 的订单
  8. 按月分组 -- 统计每个月(按 orders.created_at)的订单数和总金额,格式为 2024-01,按月份排序
  9. 子查询 HAVING -- 哪些商品的平均评分高于全部商品的总平均评分? 显示商品名,平均评分,评价数
参考答案
-- 1. 不同分类数
SELECT COUNT(DISTINCT category_id) FROM products;
-- 4

-- 2. 已完成订单平均金额
SELECT ROUND(AVG(total), 2) FROM orders WHERE status = 'done';
-- 7220.43

-- 3. 按城市统计余额
SELECT city, SUM(balance) AS total_balance
FROM users
GROUP BY city
ORDER BY total_balance DESC;
-- 上海 16200.50, 北京 8000.00, 广州 750.00, 深圳 600.00

-- 4. 分类统计,HAVING 过滤
SELECT c.name, COUNT(p.id) AS cnt, ROUND(AVG(p.price), 2) AS avg_price
FROM categories c
JOIN products p ON p.category_id = c.id
GROUP BY c.name
HAVING AVG(p.price) > 100
ORDER BY avg_price DESC;
-- 电子产品 3 7932.33, 图书 3 118.67, 食品 2 128.00

-- 5. 每个用户购买的不同商品数
SELECT u.username, COUNT(DISTINCT oi.product_id) AS product_count
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY u.username
ORDER BY product_count DESC;
-- eve:4, alice:2, bob:3, carol:2, dave:3, grace:3, frank:1, henry:1

-- 6. 条件聚合
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE is_vip = true) AS vip_count,
COUNT(*) FILTER (WHERE is_vip = false) AS non_vip_count
FROM users;
-- 8, 4, 4

-- 7. 下单总额超 5000 的用户(排除取消订单)
SELECT u.username, u.city,
COUNT(o.id) AS order_count,
SUM(o.total) AS total_spent
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status != 'cancelled'
GROUP BY u.id, u.username, u.city
HAVING SUM(o.total) > 5000
ORDER BY total_spent DESC;
-- eve(23064), alice(18597), carol(15138), grace(7266), bob(7227)

-- 8. 按月统计
SELECT
TO_CHAR(created_at, 'YYYY-MM') AS month,
COUNT(*) AS order_count,
SUM(total) AS total_amount
FROM orders
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month;
-- 2024-01: 3, 37436.00
-- 2024-02: 3, 2126.00
-- 2024-03: 4, 14661.00
-- 2024-04: 3, 16897.00

-- 9. 平均评分高于总平均的商品
SELECT p.name,
ROUND(AVG(r.rating), 2) AS avg_rating,
COUNT(r.id) AS review_count
FROM products p
JOIN reviews r ON r.product_id = p.id
GROUP BY p.id, p.name
HAVING AVG(r.rating) > (SELECT AVG(rating) FROM reviews)
ORDER BY avg_rating DESC;
-- 深入理解计算机系统: 5.00 (2条)
-- MacBook Pro 14: 5.00 (2条)
-- 坚果礼盒: 4.50 (2条)

第 1 题 COUNT 内部使用 DISTINCT 统计不重复值. 第 4 题 HAVING 跟在 GROUP BY 后面过滤聚合结果. 第 5 题 COUNT(DISTINCT ...) 确保同一商品被重复购买只算一次. 第 7 题 WHERE 排除 cancelled 行(行级),HAVING 过滤总额(组级). 第 8 题用 TO_CHAR 格式化时间戳为月份字符串分组. 第 9 题 HAVING 中可以放子查询做"聚合值对比全局值"的筛选.