
如何在 SQL 中使用聚合函数统计分组数据
统计报表通常不是逐行展示数据,而是回答“每个部门有多少员工”“每个月销售额是多少”“每个用户平均下了几笔订单”等问题。SQL 聚合函数可以把多行数据归纳为一个结果;配合 GROUP BY,则可以按一个或多个维度分别计算。
聚合查询看起来简短,但容易在边界处出错:COUNT(*) 和 COUNT(列) 的结果可能不同,WHERE 与 HAVING 过滤的阶段不同,表连接还可能意外放大计数。本文使用 PostgreSQL 语法演示核心用法,也会说明需要留意的数据库差异。
一、先准备一组可运行的数据
下面的建表和插入语句可在 PostgreSQL 中直接运行。订单金额使用 NUMERIC,适合需要精确十进制表示的金额;实际项目还应根据业务确定精度和小数位数。
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
department TEXT NOT NULL,
status TEXT NOT NULL,
amount NUMERIC(10, 2),
created_at TIMESTAMP NOT NULL
);
INSERT INTO orders
(order_id, customer_id, department, status, amount, created_at)
VALUES
(1, 101, '书籍', 'paid', 120.00, '2025-01-03 10:00:00'),
(2, 101, '书籍', 'paid', 80.00, '2025-01-10 12:00:00'),
(3, 102, '数码', 'pending', NULL, '2025-01-11 09:30:00'),
(4, 103, '数码', 'paid', 500.00, '2025-02-02 15:20:00'),
(5, 104, '书籍', 'cancelled', 40.00, '2025-02-05 16:00:00'),
(6, 102, '数码', 'paid', 300.00, '2025-02-18 08:10:00'),
(7, 105, '家居', 'paid', 220.00, '2025-03-01 11:45:00');
二、常用聚合函数做什么
COUNT:统计行数或某列非NULL的值数量。SUM:计算数值列的合计。AVG:计算数值列的平均值。MIN、MAX:取最小值和最大值。除数字外,也可用于日期或可排序类型;具体可用类型由数据库决定。
先看不分组的汇总:整个查询结果集被视作一组,通常只返回一行。
SELECT
COUNT(*) AS order_rows,
COUNT(amount) AS orders_with_amount,
SUM(amount) AS amount_sum,
AVG(amount) AS amount_average,
MIN(amount) AS amount_min,
MAX(amount) AS amount_max
FROM orders;
样例数据中,COUNT(*) 是 7,COUNT(amount) 是 6,因为有一行的 amount 为 NULL。其他几个金额聚合也会忽略这个空值。不要把 COUNT 的不同写法混为一谈:
COUNT(*)统计输入结果中的行数,不根据某一列是否为空来排除行。COUNT(amount)统计amount非NULL的行数。COUNT(DISTINCT customer_id)统计非空且去重后的客户编号数。
在 SQL 中,NULL 表示缺失或未知值,不等同于零、空字符串或“没有这一行”。聚合函数一般忽略输入中的 NULL;COUNT(*) 是重要例外,因为它统计行而不是列值。
三、用 GROUP BY 按维度分组
GROUP BY 将指定列值相同的行归为一组,再对每组分别计算聚合结果。下面统计每个部门的订单数和金额:
SELECT
department,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
AVG(amount) AS average_amount
FROM orders
GROUP BY department
ORDER BY department;
结果中每个部门一行。这里 COUNT(*) 会把金额为空的订单也计入订单数,而 SUM(amount) 和 AVG(amount) 不会把空金额当成数值参与计算。
可以按多个字段分组。例如统计每个部门、每种状态的订单情况:
SELECT
department,
status,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
GROUP BY department, status
ORDER BY department, status;
分组键是字段组合:只有 department 和 status 都相同的行才会归为一组。分组字段越多,通常结果组数越多;选择哪些字段,应由报表需要回答的问题决定。
四、WHERE、GROUP BY、HAVING 的分工
聚合查询的常见逻辑顺序可以概括为:先从 FROM 读取数据,经 WHERE 过滤行,再按 GROUP BY 分组,计算聚合,最后由 HAVING 筛选组。这个顺序有助于判断条件应该放在哪里。
WHERE:聚合前过滤明细行
例如只统计已支付订单,再按部门汇总:
SELECT
department,
COUNT(*) AS paid_order_count,
SUM(amount) AS paid_amount
FROM orders
WHERE status = 'paid'
GROUP BY department
ORDER BY department;
WHERE 条件作用于单行,不能直接在同一查询层级写 WHERE SUM(amount) > 100,因为此时分组聚合还没有完成。
HAVING:聚合后过滤分组
若要保留已支付金额超过 300 的部门,应使用 HAVING:
SELECT
department,
SUM(amount) AS paid_amount
FROM orders
WHERE status = 'paid'
GROUP BY department
HAVING SUM(amount) > 300
ORDER BY paid_amount DESC;
这里先排除未支付订单,再对剩余记录分组,最后只保留合计金额大于 300 的组。没有聚合条件时,优先用 WHERE 筛选明细,通常更直观,也可减少后续需要处理的行。
五、NULL、空结果与计数边界
聚合值为空时的结果
对一组输入行计算 SUM、AVG、MIN 或 MAX 时,如果没有非空输入值,结果通常为 NULL。而在没有 GROUP BY 的聚合查询中,即使筛选后没有任何行,COUNT(*) 仍返回 0;其他上述聚合通常返回 NULL。
SELECT
COUNT(*) AS row_count,
SUM(amount) AS amount_sum
FROM orders
WHERE status = 'not_a_real_status';
若业务要求把空合计显示为零,可以显式使用 COALESCE:
SELECT
COALESCE(SUM(amount), 0) AS amount_sum
FROM orders
WHERE status = 'not_a_real_status';
是否应该把 NULL 转成 0,需要根据含义决定。金额缺失不一定代表金额为零;过早替换可能掩盖数据质量问题。类似地,AVG(COALESCE(amount, 0)) 会把空金额当作零纳入平均值,通常与 AVG(amount) 的业务含义不同。
去重计数并不等于行数
COUNT(DISTINCT customer_id) 常用于统计去重后的实体数量,但它忽略 NULL。若要统计每个部门有多少名产生过订单的不同客户:
SELECT
department,
COUNT(DISTINCT customer_id) AS customer_count
FROM orders
GROUP BY department
ORDER BY department;
只有当“客户”由一个字段唯一标识时,这种写法才直接适用。若唯一键由多个字段组成,复合去重的语法和可用能力可能因数据库而异,不宜假定所有数据库都接受相同写法。
六、按日期分组和分桶
按时间统计时,通常先把时间戳转换为日期或月份,再分组。下面的 PostgreSQL 示例按月份统计已支付金额:
SELECT
DATE_TRUNC('month', created_at) AS month_start,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month_start;
DATE_TRUNC 是 PostgreSQL 函数,不是所有数据库通用的标准写法。MySQL 常见写法是按 YEAR(created_at) 和 MONTH(created_at) 分组,或用 DATE_FORMAT 生成月份键;SQL Server、Oracle 等也有各自的日期函数。迁移数据库时,应确认函数、时区处理和返回类型。
时间戳还涉及时区边界。若数据以 UTC 保存,而报表按当地自然月统计,应先明确转换时区的规则,否则接近午夜的记录可能被归入不同日期或月份。筛选时间范围时,推荐使用左闭右开区间,例如 created_at >= 起始时刻 AND created_at < 下一周期起始时刻,避免通过“结束日 23:59:59”漏掉更高精度的时间值。
七、条件聚合:一次查询统计多个指标
条件聚合适合在同一分组中并列计算多类指标。PostgreSQL 支持聚合函数的 FILTER 子句:
SELECT
department,
COUNT(*) AS all_orders,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
SUM(amount) FILTER (WHERE status = 'paid') AS paid_amount,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled_orders
FROM orders
GROUP BY department
ORDER BY department;
在其他数据库中,可以用 CASE 配合聚合函数实现类似效果。例如:
SELECT
department,
COUNT(CASE WHEN status = 'paid' THEN 1 END) AS paid_orders,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_amount
FROM orders
GROUP BY department;
这类写法的细节仍取决于数据和目标数据库。比如条件金额全部为空时,SUM 的结果可能是 NULL;需要零值时,可在外层使用 COALESCE。不要把 ELSE 0 机械套用到所有指标,因为它会改变空值参与计算的方式。
八、JOIN 后计数被放大的常见原因
聚合错误不一定来自聚合函数本身。假设一个订单可以有多条明细记录,订单表与明细表是一对多关系。连接后,一张订单会重复出现多次;此时直接 COUNT(orders.order_id) 统计的是连接结果中的行数,而非订单数。
SELECT
o.department,
COUNT(o.order_id) AS joined_rows
FROM orders AS o
JOIN order_items AS i
ON i.order_id = o.order_id
GROUP BY o.department;
若一个订单有三条明细,它可能贡献三行。要统计订单数,常见做法是使用 COUNT(DISTINCT o.order_id),但还要确认连接条件和“订单数”的定义。若同时要汇总订单金额,连接造成的重复也可能让 SUM(o.amount) 被放大;简单改成 SUM(DISTINCT o.amount) 并不可靠,因为不同订单可能恰好金额相同。
更稳妥的做法通常是先分别聚合到合适的粒度,再连接汇总结果;或者先检查每张表的主键、关系基数和目标统计粒度。每个聚合指标都应问一句:“一条业务实体在当前结果中会出现几次?”
九、分组查询的语法约束与可读性
分组查询的 SELECT 列表通常只能包含分组键、聚合表达式,以及数据库能够确认由分组键函数依赖确定的列。为了跨数据库保持清晰,最稳妥的习惯是:非聚合列显式写进 GROUP BY;其余列放在聚合函数中。
例如,不能随意把 customer_id 放入只按 department 分组的查询,因为一个部门通常有多个客户,数据库无法知道应该返回哪一个客户编号。不同数据库对函数依赖和别名的接受程度不完全一致。尽量不要依赖某个数据库的宽松行为,也不要把未分组的明细字段混进汇总结果。
ORDER BY 用来排序最终结果,不决定聚合如何计算。聚合结果没有默认顺序;需要稳定展示时,应明确指定排序键,必要时添加第二个排序键以处理并列值。
十、数值类型、精度和参数限制
聚合函数的参数类型与结果类型由数据库的类型规则决定,不同产品之间可能不同。对金额、税率等精确十进制数据,优先选用适合业务精度的定点数类型,例如 PostgreSQL 的 NUMERIC,并核实总位数和小数位数;浮点类型是近似数值,直接比较或累计时可能出现舍入差异。
AVG 的结果不保证与输入列类型完全相同;类型转换、溢出范围和小数精度要查目标数据库的文档。大量整数求和也可能超过原列类型的范围。数据库是否自动扩展结果类型、何时溢出以及如何显式转换,都有产品差异,不能假定一种写法适用于所有数据库。
聚合函数也有各自的参数形式:例如常见的 COUNT(*) 不接收列参数,COUNT(expression) 统计表达式的非空值;SUM、AVG 通常用于数值表达式,MIN、MAX 则要求参数类型具备相应的比较能力。可接受的类型、是否支持多参数或特殊扩展,要以具体数据库及版本文档为准。不要为了绕过类型错误而随意强制转换,转换可能改变精度或语义。
十一、参数绑定与 SQL 安全
聚合查询同样应使用参数化查询处理外部输入。参数绑定可以避免把用户提供的值直接拼入 SQL 文本,从而降低 SQL 注入风险;它不负责验证业务含义,也不会让查询自动变快。
例如,下面是概念性示例。占位符写法会随驱动程序变化,不能把 $1 直接复制到所有语言或数据库连接库中:
SELECT department, SUM(amount) AS total_amount
FROM orders
WHERE status = $1
GROUP BY department;
应用程序应通过数据库驱动提供的绑定接口,将状态值作为参数传入,而不是用字符串拼接生成条件。参数通常用于数据值,不可直接代替表名、列名、排序方向等 SQL 标识符;这些动态结构应采用允许列表映射到固定 SQL 片段,而不是接受任意输入后拼接。占位符语法、参数类型推断和可绑定对象范围取决于驱动与数据库。
聚合函数本身不是安全机制。它不会清理输入、授权访问或防止注入;安全边界仍依赖参数化查询、权限控制、输入规则和正确的动态 SQL 构造。只读统计查询也应使用符合最小权限原则的数据库账号。
十二、性能与排查思路
- 先限定数据范围。 能在分组前筛选的条件尽量放在
WHERE,例如状态和时间范围。筛选是否能利用索引,取决于查询、索引、数据分布和优化器,不能仅凭 SQL 外观保证。 - 只取需要的列。 汇总查询通常无需
SELECT *。明确列出分组键和指标,更容易检查统计口径。 - 检查连接粒度。 先确认连接前后行数和键的唯一性,再核对计数或求和是否被一对多关系重复放大。
- 用小样本验证边界。 测试空表、全为
NULL、筛选后无行、重复键和极大数值等情况,确认返回值符合业务预期。 - 用执行计划定位瓶颈。 聚合可能需要扫描、排序或哈希分组;具体计划由数据库决定。通过目标数据库的执行计划工具观察实际访问路径,不要为了“优化”而省略必要的筛选或改变统计口径。
总结
使用聚合函数统计分组数据,可以按以下步骤组织查询:
- 明确统计粒度:每行结果代表一个部门、一个月份,还是一个部门与状态的组合。
- 明确过滤阶段:筛选明细行用
WHERE,筛选聚合后的组用HAVING。 - 明确空值口径:区分
COUNT(*)、COUNT(列)和COUNT(DISTINCT 列),确认金额缺失是否应参与计算。 - 检查连接是否改变行数,避免重复计数和重复求和。
- 确认目标数据库的类型、日期函数、参数占位符和聚合行为,并用参数绑定处理外部值。
只要统计口径、分组键和数据粒度定义清楚,COUNT、SUM、AVG、MIN、MAX 就能组成可靠的汇总查询。真正容易出错的,往往不是函数名称,而是对空值、连接关系、筛选时机和数值类型的误解。








