在业务系统里,我们经常会遇到一张表同时记录多种状态的数据,比如订单有待支付、已发货、已完成、已取消等。如果想知道每种状态分别有多少条记录,最直观的思路是写多条查询,或者用group by状态字段。但在实际报表场景中,往往需要将不同状态的数量作为并列字段输出,这时候条件聚合就是最优雅的解决方案。

什么是条件聚合
条件聚合指的是在聚合函数(如sum、count、avg)内部嵌入条件判断,通常配合case when表达式使用。它的核心思想是:在一条SQL中,根据某一列的值是否满足指定条件,决定该行是否参与当前聚合计算。以统计状态数量为例,我们可以让sum函数在状态匹配时加1,不匹配时加0,从而得到该状态的记录数。
这种方式与先group by再外层拼装相比,最大优势是只扫描一次数据表。数据库引擎在执行时,对每一行做一次case when判断并累加,无需临时分组和多次回表。对于百万级数据的报表查询,性能差异非常明显。同时,条件聚合让结果以宽表形式呈现,每一个状态对应一个字段,前端直接取值即可,不需要再做行列转换。
基础实战:单表多状态计数
假设有一张订单表orders,包含字段id、status、created_at。status取值为:pending(待支付)、shipped(已发货)、finished(已完成)、canceled(已取消)。我们要统计各状态的数量,用条件聚合可以写成如下SQL:
select sum(case when status = 'pending' then 1 else 0 end) as pending_cnt, sum(case when status = 'shipped' then 1 else 0 end) as shipped_cnt, sum(case when status = 'finished' then 1 else 0 end) as finished_cnt, sum(case when status = 'canceled' then 1 else 0 end) as canceled_cnt, count(*) as total_cnt from orders;
上面这段SQL中,每一行数据都会经过四个case when判断。例如某行status是shipped,那么shipped_cnt对应的表达式返回1,其余三个返回0,最终sum汇总出各自的总数。count(*)则统计总行数,方便计算占比。
这种写法的可扩展性很好。如果新增了状态refunding(退款中),只需要在select列表里加一行sum(case when status = 'refunding' then 1 else 0 end) as refunding_cnt即可,底层扫描逻辑完全不变。比起分别写四条select count(*) where status = ?再join,代码量少且易于阅读。
结合过滤与分组的高级用法
条件聚合不仅能用在没有group by的整表统计中,也可以和group by配合,按维度(如日期、地区)分别统计状态。例如按天统计每日各状态订单量:
select date(created_at) as order_date, sum(case when status = 'pending' then 1 else 0 end) as pending_cnt, sum(case when status = 'finished' then 1 else 0 end) as finished_cnt, sum(case when status = 'canceled' then 1 else 0 end) as canceled_cnt from orders where created_at >= '2023-01-01' group by date(created_at) order by order_date;
这里where子句先过滤了时间范围,减少参与计算的数据量;group by按天分组后,每个分组内部再走条件聚合逻辑。这样可以直接生成看板所需的每日趋势数据。注意,在MySQL中date()函数提取日期部分,其他数据库可用trunc或cast替代。
有时候我们还需要计算状态占比,可以在同一层SQL中嵌套使用。比如已完成率:sum(case when status='finished' then 1 else 0 end) * 1.0 / count(*)。乘1.0是为了把整数除法转为浮点,避免只得到0或1。如果数据中存在null状态,case when默认null不参与sum,所以不用担心脏数据干扰。
count与sum的选择细节
除了sum(case when ... then 1 else 0 end),也有人写count(case when status='finished' then 1 else null end)。两者结果一致,但原理略有不同:count只统计非null值,所以当条件满足时给1(非null)就会被计入;不满足给null就不计。sum则是把1和0相加。从执行计划看,sum在多数引擎中略快,因为不需要处理null判断逻辑。
-- 两种写法对比 select count(case when status = 'finished' then 1 else null end) as cnt_by_count, sum(case when status = 'finished' then 1 else 0 end) as cnt_by_sum from orders;
在使用count写法时,务必把else分支写成null而不是0。如果写成else 0,那么不满足条件的行也会返回0,count(0)依然会计数,导致结果变成总行数。这是一个非常常见的错误,调试时往往发现所有状态数量都等于总记录数,就是因为这个细节。
另一个注意点是,当状态字段本身可能为null时,case when status = 'xxx'在status为null时自然走到else分支,不会报错。但如果用count(status)来试图统计某状态,null值行会被直接忽略,和条件聚合的语义不同,需要按业务确认是否要包含null状态。
性能与索引建议
条件聚合虽然只扫一次表,但如果表很大,依然建议对过滤列和状态列建立复合索引。以上面按天统计为例,在(created_at, status)上建索引,可以让where范围和group by都命中索引,部分数据库甚至能走索引覆盖,完全不回表。执行explain时,应关注rows和extra字段,确认没有using filesort或临时表。
如果状态枚举固定,还可以考虑将status设为枚举类型或小的字典表外键,减少存储和比较开销。对于超高并发的实时统计,条件聚合SQL可做成物化视图或定时跑批写入统计表,避免每次请求都算一遍。总体来看,条件聚合是兼顾开发效率和运行效率的优选方案,比动态拼SQL或应用层循环查询要可靠得多。
常见误区总结
不少人在初学时会把条件聚合和行转列混淆。条件聚合是在行层面做判断后聚合成列;而行转列(pivot)通常是数据库专属语法,可读性因引擎而异。标准SQL的条件聚合跨数据库通用,迁移成本低。另外,不要为了“优雅”把过多状态塞进一个查询,若状态超过十五个,select列表会很长,此时可考虑用group by status常规写法,应用层再映射,保持SQL可维护性。
条件聚合的本质,是用表达式控制聚合函数的输入值,从而在同一分组内算出多个维度的指标。
掌握了上述写法,你在写报表、做数据看板、输出业务日报时,都可以用一条SQL把不同状态的数量、比例、趋势一次性查出来。这比分散查询再拼装更不容易出错,也更容易让其他开发者看懂你的统计逻辑。