同一张订单表,COUNT(*)是 6,COUNT(amount)却只有 4;这不是数据库算错,而是空值被跳过了。
看完这篇,你能把计数、求和、平均值、极值、分组过滤和空值处理一次核对清楚。
一、业务场景:对账为什么先差在计数
运营要三个指标:订单总数、已支付订单金额、每个城市的已支付客单价。这三个指标分别对应COUNT、SUM、AVG,看起来只是基础统计,但只要明细列里有空值,口径就容易偏。
这三个指标看似相近,但统计对象不同:
- 订单总数回答“有多少条明细”
- 已支付金额回答“实际收到多少钱”
- 客单价回答“平均每笔已支付订单是多少钱”
| order_id | user_id | city | amount | status | paid_at |
|---|---|---|---|---|---|
| 1 | 101 | 北京 | 100.00 | paid | 2026-09-01 09:00:00 |
| 2 | 101 | 北京 | 200.00 | paid | 2026-09-01 10:00:00 |
| 3 | 102 | 上海 | NULL | unpaid | NULL |
| 4 | 103 | 广州 | 300.00 | paid | 2026-09-02 11:00:00 |
| 5 | 104 | 上海 | 50.00 | refunded | NULL |
| 6 | 105 | 广州 | NULL | unpaid | NULL |
这三个指标看起来只是简单计算,为什么一到对账就容易偏?
二、踩坑现象:同一张表为什么算出三个口径
我先跑了一条不带过滤条件的汇总 SQL,想确认全表底数。
SELECTCOUNT(*)ASorder_count,COUNT(amount)ASamount_count,SUM(amount)AStotal_amount,AVG(amount)ASavg_amount,MAX(amount)ASmax_amount,MIN(amount)ASmin_amountFROMorders;| order_count | amount_count | total_amount | avg_amount | max_amount | min_amount |
|---|---|---|---|---|---|
| 6 | 4 | 650.00 | 162.5000 | 300.00 | 50.00 |
如果按650 / 6计算,平均值是 108.33;数据库返回的是 162.5。差额来自两条amount为空值的订单,它们被COUNT(*)统计,却没有进入SUM(amount)和AVG(amount)。
我第一次排查这类问题时,盯着SUM看了很久,后来先查COUNT(amount),才发现真正的问题是非空行数和总行数不一致。
为什么SUM会跳过空值,而COUNT(*)没有跳过?
三、底层原理:聚合什么时候发生,空值什么时候被跳过
聚合函数(Aggregate Function)对一组行进行计算并返回单个值。没有GROUP BY时,整张结果集被视为一个组;有GROUP BY时,每个分组独立计算。
FROM 取表 ↓ WHERE 过滤原始行 ↓ GROUP BY 划分分组 ↓ 聚合函数逐组计算 ↓ HAVING 过滤分组结果 ↓ SELECT 输出列 ↓ ORDER BY 排序上面的顺序是逻辑执行顺序,用来判断条件该放在哪一层;数据库优化器实际选择的物理执行路径可能不同。
| 写法 | 统计口径 | 空值处理 | 无匹配行返回 |
|---|---|---|---|
| COUNT(*) | 统计分组内所有行 | 不跳过 | 0 |
| COUNT(1) | 统计分组内所有行 | 不跳过 | 0 |
| COUNT(列) | 统计该列非空值数量 | 跳过空值 | 0 |
| SUM(列) | 对该列非空值求和 | 跳过空值 | NULL |
| AVG(列) | SUM(列) / COUNT(列) | 跳过空值 | NULL |
| MAX(列) | 返回非空值中的最大值 | 跳过空值 | NULL |
| MIN(列) | 返回非空值中的最小值 | 跳过空值 | NULL |
MAX、MIN可用于数值、日期和字符串。日期场景中,MAX表示最新,MIN表示最早;字符串结果取决于数据库排序规则。
我后来固定排查顺序:先看COUNT(*),再看COUNT(列),再看SUM和AVG。前两个数能判断空值数量,后两个数能判断分母是否被改。
记住:COUNT(*)数行,COUNT(列)数非空值;空值参与前者,不参与后者。
原理清楚后,怎么把业务口径准确写成 SQL?
四、解决方案:把业务口径翻译成聚合 SQL
4.1 全表指标和条件指标分开算
全表订单数用COUNT(*);已支付金额要先识别status,再对amount求和。没有匹配行时,SUM返回空值,需要用COALESCE转成 0。
SELECTCOUNT(*)ASorder_count,COUNT(CASEWHENstatus='paid'THEN1END)ASpaid_order_count,COALESCE(SUM(CASEWHENstatus='paid'THENamountEND),0)ASpaid_amount,AVG(CASEWHENstatus='paid'THENamountEND)ASpaid_avg_amountFROMorders;| order_count | paid_order_count | paid_amount | paid_avg_amount |
|---|---|---|---|
| 6 | 3 | 600.00 | 200.0000 |
CASE不匹配时默认返回空值,SUM、AVG会跳过这些行。条件计数在命中时返回 1,不匹配时不要写ELSE 0;条件平均同样不要写ELSE 0,否则未支付订单会进入平均值分母。
记住:条件计数返回 1,不匹配返回空值;条件平均不要写ELSE 0。
4.2 分组统计与分组过滤
按城市统计时,GROUP BY负责划分分组,聚合函数负责计算每个城市的指标。
SELECTcity,COUNT(*)ASorder_count,COUNT(CASEWHENstatus='paid'THEN1END)ASpaid_order_count,COALESCE(SUM(CASEWHENstatus='paid'THENamountEND),0)ASpaid_amount,AVG(CASEWHENstatus='paid'THENamountEND)ASpaid_avg_amount,MAX(paid_at)ASlatest_paid_atFROMordersGROUPBYcityORDERBYpaid_amountDESC,city;| city | order_count | paid_order_count | paid_amount | paid_avg_amount | latest_paid_at |
|---|---|---|---|---|---|
| 北京 | 2 | 2 | 300.00 | 200.0000 | 2026-09-01 10:00:00 |
| 广州 | 2 | 1 | 300.00 | 300.0000 | 2026-09-02 11:00:00 |
| 上海 | 2 | 0 | 0.00 | NULL | NULL |
如果只保留存在已支付订单的城市,在末尾增加HAVING。
HAVINGCOUNT(CASEWHENstatus='paid'THEN1END)>0| 业务需求 | 放置位置 | 原因 |
|---|---|---|
| 只统计已支付订单 | WHERE | 聚合前排除无关行 |
| 只统计指定时间范围 | WHERE | 条件来自原始行 |
| 只保留支付金额大于 200 的城市 | HAVING | 条件依赖SUM结果 |
| 只保留已支付订单数大于 1 的城市 | HAVING | 条件依赖COUNT结果 |
记住:WHERE过滤行,HAVING过滤组;依赖聚合结果的条件,只能放在HAVING。
4.3 极值要和整行信息一起取
MAX(amount)只返回一个数值,不会自动告诉你对应订单号。要取最高金额对应的整行,可以用子查询。
SELECTorder_id,user_id,amountFROMordersWHEREstatus='paid'ANDamount=(SELECTMAX(amount)FROMordersWHEREstatus='paid');如果最高金额有多条订单,子查询会返回多行。只保留一条可以用排名函数;要保留并列结果则用RANK。
WITHrankedAS(SELECTorder_id,user_id,amount,RANK()OVER(ORDERBYamountDESC)ASrnkFROMordersWHEREstatus='paid')SELECTorder_id,user_id,amountFROMrankedWHERErnk=1;4.4 去重、平均口径与窗口函数
| 写法 | 用途 | 提醒 |
|---|---|---|
| COUNT(DISTINCT user_id) | 统计去重用户数 | 常见写法 |
| SUM(DISTINCT amount) | 去重后求和 | 业务口径少见,慎用 |
| AVG(DISTINCT amount) | 去重后求平均 | 容易偏离业务含义 |
| MAX(DISTINCT amount) | 与 MAX(amount) 结果相同 | 不写 DISTINCT |
| MIN(DISTINCT amount) | 与 MIN(amount) 结果相同 | 不写 DISTINCT |
| 业务口径 | 写法 |
|---|---|
| 空值按 0 参与平均 | SUM(COALESCE(score, 0)) / COUNT(*) |
| 加权平均 | SUM(score * weight) / SUM(weight) |
| 避免整数平均值 | AVG(CAST(score AS DECIMAL(10,2))) |
窗口函数(Window Function)保留明细行,适合在订单明细旁追加用户累计金额。
SELECTorder_id,user_id,amount,SUM(amount)OVER(PARTITIONBYuser_id)ASuser_totalFROMorders;GROUP BY会把每组合并成一行;OVER(PARTITION BY user_id)不会减少行数,只是在每行旁边追加窗口范围内的聚合结果。
五、实操验证与总结延伸
以下脚本以 MySQL 8.x 为例。你可以先建表并插入示例数据,再执行前面的汇总和分组查询。
DROPTABLEIFEXISTSorders;CREATETABLEorders(order_idINTPRIMARYKEY,user_idINTNOTNULL,cityVARCHAR(20)NOTNULL,amountDECIMAL(10,2),statusVARCHAR(20)NOTNULL,paid_atDATETIME);INSERTINTOorders(order_id,user_id,city,amount,status,paid_at)VALUES(1,101,'北京',100.00,'paid','2026-09-01 09:00:00'),(2,101,'北京',200.00,'paid','2026-09-01 10:00:00'),(3,102,'上海',NULL,'unpaid',NULL),(4,103,'广州',300.00,'paid','2026-09-02 11:00:00'),(5,104,'上海',50.00,'refunded',NULL),(6,105,'广州',NULL,'unpaid',NULL);执行验证查询。
SELECTCOUNT(*)ASorder_count,COUNT(amount)ASamount_count,COALESCE(SUM(CASEWHENstatus='paid'THENamountEND),0)ASpaid_amount,AVG(CASEWHENstatus='paid'THENamountEND)ASpaid_avg_amount,MAX(amount)ASmax_amount,MIN(amount)ASmin_amountFROMorders;预期结果如下,不同数据库的小数位数可能不同。
order_count | amount_count | paid_amount | paid_avg_amount | max_amount | min_amount 6 | 4 | 600.00 | 200.0000 | 300.00 | 50.00分组查询预期结果如下。
city | order_count | paid_order_count | paid_amount | paid_avg_amount 北京 | 2 | 2 | 300.00 | 200.0000 广州 | 2 | 1 | 300.00 | 300.0000 上海 | 2 | 0 | 0.00 | NULL记住:验证聚合结果时,同时核对行数、非空数、分母和空集返回值;只看 SQL 执行成功没有意义。
聚合函数解决的是一组行到一个值的问题。COUNT回答多少,SUM回答总量,AVG回答平均水平,MAX和MIN回答边界。真正决定结果的不是函数名,而是统计口径。
- 数所有行用
COUNT(*),数非空值用COUNT(列) - 空集计数是 0,空集的
SUM、AVG、MAX、MIN是空值 - 条件聚合用
CASE,条件平均不要写ELSE 0 - 行过滤用
WHERE,分组过滤用HAVING - 极值要带整行时,用子查询、窗口函数或排名函数
- 大表分组检查分组列和过滤列的联合索引
COUNT(DISTINCT)有排序或哈希开销,固定报表可考虑汇总表或物化视图- 用
EXPLAIN确认是否走索引、是否出现临时表或排序
术语速查表
| 术语 | 说明 |
|---|---|
| 聚合函数(Aggregate Function) | 对一组行计算并返回单个值 |
| 空值(NULL) | 表示未知,不等同于 0 或空字符串 |
| 分组(GROUP BY) | 按指定列把行划分为多个集合 |
| 分组过滤(HAVING) | 对聚合后的分组结果进行过滤 |
| 去重(DISTINCT) | 对重复值先去重再计算 |
| 窗口函数(Window Function) | 在保留明细行的基础上计算窗口范围内的聚合值 |
| 条件聚合(Conditional Aggregation) | 通过CASE控制哪些行进入聚合函数 |
参考链接
- MySQL 8.4 Reference Manual:Aggregate Function Descriptions
- PostgreSQL Documentation:Aggregate Functions
- SQLite Documentation:Built-in Aggregate Functions