写SQL统计报表是件挺有意思的活儿。上周运营丢给我一张订单表,问我能不能在同一个报表里同时看到每月的总销售额、线上销售额和线下销售额,最好还能顺带切出新客户、老客户的维度。我第一反应是写三条GROUP BY再合并结果,但写了两行就放弃了——这种"分组内再按维度拆分汇总"的需求,用CASE WHEN做条件聚合,一遍查询就能把宽表直接怼出来,效率高到离谱。这篇文章就把这套方法彻底讲透,从语法思路到实操案例,再到我踩过的大大小小的坑,不只能让初学者少走弯路,就算你是写过几年SQL的老手,也值得收藏一份当速查手册。
1. 为什么需要条件聚合:从一次真实需求说起
1.1 普通分组统计的局限
先看典型场景。订单表里存着每天的销售记录,字段大致是order_id、order_date、channel(线上/线下)、customer_type(新客/老客)、amount。要统计每个月每个渠道的总销售额,常规写法是:
sql复制SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
channel,
SUM(amount) AS total_amount
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m'), channel
ORDER BY month;
这个写法对不对?完全正确。但有个问题:返回结果是"长表"格式,一个渠道一行。假如老板想看的是:第一列是月份,第二列是线上总额,第三列是线下总额——这种"宽表"格式。这时候普通GROUP BY就有点力不从心了,要么拿到结果后再在程序里做行转列,要么写多个子查询再JOIN,都不优雅。
还有一种更麻烦的情况:需求不是简单按渠道分组,而是要在一个分组里同时看到"新客线上订单金额"、"老客线上订单金额"这类交叉维度。这时候如果还用GROUP BY channel, customer_type,你就得处理三种维度组合的笛卡尔乘积,然后回程序里慢慢拼。写SQL的人都知道,这是最烦的活。
1.2 条件聚合的本质:把"行"拆成"列"
条件聚合的思路恰恰是把"维度"从行里拿出来,变成查询里的条件。每个CASE WHEN判断一行数据属于哪个维度,命中就返回该行对应的金额,没命中就返回0,然后用SUM把这些值累计起来。等于在分组汇总的同时,顺手把每一行归类到了不同的列。
理解这个原理之后,你会发现它不只是"行转列"这么简单,它还能解决:分组内多个口径同时统计、同一个分组里算占比、处理异类数据的归类汇总等一连串问题。你可以把它理解成SQL版的"数据透视表"——把维度字段往列方向拖,把聚合字段往值方向拖,剩下的交给CASE WHEN和SUM来完成。
1.3 什么场景下该用条件聚合
结合我实际接到的需求,下面这些场景出现频率最高:
- 时间段汇总:全年每一天的数据,按月统计总和,顺便把每个月的工作日、周末分开算。
- 渠道拆分:同一个报表里,按月份统计线上、线下、分销各自销售额。
- 用户分层:按年龄段分组统计消费人数和金额,年龄分组条件写在CASE WHEN里。
- 状态统计:按订单状态(已完成、待发货、已退款)分组统计单量和金额。
- 占比计算:需要算某个分组占总体的百分比,这种在原生GROUP BY里很难直接算,但条件聚合一步到位。
你一旦习惯了这种写法,碰到"分组内分维度"的需求,第一反应就不会是写多个SQL再拼,而是直接在SELECT里加一行SUM(CASE WHEN ...)。这才是条件聚合的正确打开方式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CASE WHEN条件聚合的核心思路与语法
2.1 从一条最基础的写法开始
先给一个最小示例。统计每月总销售额,同时区分线上和线下:
sql复制SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
SUM(CASE WHEN channel = 'online' THEN amount ELSE 0 END) AS online_amount,
SUM(CASE WHEN channel = 'offline' THEN amount ELSE 0 END) AS offline_amount,
SUM(amount) AS total_amount
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY month;
注意几个细节,这些细节决定了你这个SQL能不能在生产环境安全跑起来:
- CASE WHEN的THEN后面跟的是要累加的字段,这里是amount,也就是订单金额;ELSE 0表示不满足条件时不计入,这个0非常关键,后面我在第4章专门讲NULL、0、1的区别。
- 外层SUM负责对CASE的返回值做累加,所以CASE WHEN一定要放在聚合函数里面,不是放在WHERE里。
- GROUP BY只保留一个分组维度"月份",因为渠道这个维度已经被"提"到列里了,不需要再出现在GROUP BY里。
- 最后加了一个SUM(amount) AS total_amount作为对应月份的全渠道总金额,方便后续算占比,也方便肉眼核对"线上+线下=总金额"是否成立。
2.2 为什么用SUM配合CASE,而不是写多个子查询
有人可能问:我直接写三个子查询再JOIN不也一样吗?确实能算出来,但差距巨大。
第一,扫描次数。三个子查询等于三遍查表,优化器就算再聪明,也会增加IO成本;条件聚合版本只扫描一次表,性能下限低得多。在百万行级别的订单表上,差别非常明显。
第二,维护成本。子查询版本每增加一个维度,就得在FROM里多挂一个JOIN,字段和别名一多,阅读起来非常费劲。条件聚合版本只是多写一行SUM(CASE WHEN ...),结构扁平,清晰直接。我和同事合作维护报表SQL时,最怕就是看到一段又一段的子查询嵌套,改起来像在拆炸弹。
第三,可扩展性。如果后续还要区分"手机端APP渠道"、"小程序渠道"、"第三方平台渠道",子查询版本要改整体结构,条件聚合版本在SELECT里加行即可。实际业务里统计口径变化太频繁了,我今天写完明天就要加维度,所以我会优先选择更容易应对变化的写法。
2.3 不只是SUM:COUNT、AVG、MAX都能配
SUM是最常用的,但条件聚合绝不局限于求和。实际工作中,我还用过:
sql复制-- 统计满足条件的记录数(注意这里不用ELSE,因为COUNT忽略NULL)
COUNT(CASE WHEN amount > 1000 THEN 1 END) AS high_order_cnt,
-- 计算某条件下的平均订单金额
AVG(CASE WHEN channel = 'online' THEN amount END) AS online_avg_amount,
-- 取满足特定条件的最大、最小值
MAX(CASE WHEN customer_type = 'new' THEN amount END) AS new_customer_max_amount
统一的思路是:把"条件"变成"列"的筛选器,把"聚合函数"变成"列"的加工器。想统计什么口径,就把对应的聚合函数塞到CASE WHEN外层,条件和函数职责分离,逻辑一目了然。
3. 实操案例:统计分组内不同维度的总和
为了让你能直接复制运行,我用一个虚构的电商订单表作为基础,但数据结构尽量贴近真实场景。假设表结构如下:
sql复制CREATE TABLE orders (
order_id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
order_date DATETIME NOT NULL,
channel VARCHAR(20) NOT NULL, -- online: 线上, offline: 线下
customer_type VARCHAR(20) NOT NULL, -- new: 新客, old: 老客
amount DECIMAL(10,2) NOT NULL DEFAULT 0
);
再插入一些模拟数据,覆盖三个月、两个渠道、两种客户类型:
sql复制INSERT INTO orders (user_id, order_date, channel, customer_type, amount) VALUES
(101, '2024-01-05 10:12:30', 'online', 'new', 200.00),
(102, '2024-01-11 20:45:16', 'offline', 'old', 150.50),
(103, '2024-01-20 09:30:00', 'online', 'old', 380.00),
(104, '2024-02-02 14:20:11', 'offline', 'new', 90.00),
(105, '2024-02-15 11:08:43', 'online', 'new', 760.20),
(106, '2024-02-21 16:32:55', 'online', 'old', 45.00),
(107, '2024-03-03 12:12:12', 'offline', 'old', 300.00),
(108, '2024-03-12 18:04:38', 'online', 'new', 540.10);
3.1 案例一:按月统计总额并按线上/线下分列
目标:输出一张宽表,每一行是一个月,列包含月份、线上总额、线下总额、全渠道总额。这对应标题里"统计分组内不同维度的总和"最直接的需求。
sql复制SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
SUM(CASE WHEN channel = 'online' THEN amount ELSE 0 END) AS online_amt,
SUM(CASE WHEN channel = 'offline' THEN amount ELSE 0 END) AS offline_amt,
SUM(amount) AS total_amt
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY month;
运行结果:
| month | online_amt | offline_amt | total_amt |
|---|---|---|---|
| 2024-01 | 580.00 | 150.50 | 730.50 |
| 2024-02 | 805.20 | 90.00 | 895.20 |
| 2024-03 | 540.10 | 300.00 | 840.10 |
你可以在Excel里快速核对:online_amt + offline_amt 恒等于 total_amt。这个等式是检验条件聚合有没有写错的最好方法,尤其是当线上和线下的case条件写反时,这个核对表能让你一眼发现。
这里有个非常容易被忽略但很关键的细节:GROUP BY的字段和SELECT里的非聚合字段必须完全一致。下面这种写法在MySQL的ONLY_FULL_GROUP_BY模式下直接报错:
sql复制-- 错误示例
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
channel, -- channel没在GROUP BY里,也没被聚合函数包裹,报错
SUM(CASE WHEN channel = 'online' THEN amount ELSE 0 END) AS online_amt
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m');
SELECT里的channel既没出现在GROUP BY里,也没被聚合函数包裹,在严格模式下一律报错。这也是新手最容易踩的坑,而且从MySQL 5.7开始,ONLY_FULL_GROUP_BY默认是开启的,旧代码在生产环境很容易被这个规则卡住。
3.2 案例二:统计分组内占比(百分比)
条件聚合最关键的优势之一就是能直接在SQL里算占比,不需要把数据搬到Excel再算。比如统计每个月线上销售占全渠道的比例,注意看分母和分子的写法:
sql复制SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
SUM(CASE WHEN channel = 'online' THEN amount ELSE 0 END) AS online_amt,
SUM(amount) AS total_amt,
ROUND(
SUM(CASE WHEN channel = 'online' THEN amount ELSE 0 END)
/ NULLIF(SUM(amount), 0) * 100,
2
) AS online_percent
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY month;
关键点在于:
- NULLIF(SUM(amount), 0):当总金额为0时,NULLIF会返回NULL,避免除零错误。这是我在实际生产里踩过坑之后养成的习惯。因为分母一旦为0,整个查询直接报错,断掉后续所有调度任务,这在定时报表里是很严重的故障。
- ROUND(..., 2)保留两位小数,展示更友好。
- 同一个SUM表达式在占比里又用了一遍,在SQL标准里这叫"重复表达式",优化器一般会自动识别,但如果业务很复杂、SQL很长,还是建议用子查询或CTE让可读性更好。我在长SQL里通常会先写成子查询,把聚合结果先算出来,外层再算占比,这样排查问题方便得多。
运行结果应该是:
| month | online_amt | total_amt | online_percent |
|---|---|---|---|
| 2024-01 | 580.00 | 730.50 | 79.40 |
| 2024-02 | 805.20 | 895.20 | 89.95 |
| 2024-03 | 540.10 | 840.10 | 64.29 |
3.3 案例三:按年龄分组统计用户数
这对应热词里的"年龄分组分类"。假设有一张用户表,我们按照年龄段统计各渠道的活跃用户数。年龄字段用age直接表示,实际开发中有人用birthday,需要再套一层TIMESTAMPDIFF计算,但核心思路一样:
sql复制SELECT
CASE
WHEN age < 18 THEN '18岁以下'
WHEN age >= 18 AND age < 30 THEN '18-29岁'
WHEN age >= 30 AND age < 45 THEN '30-44岁'
WHEN age >= 45 AND age < 60 THEN '45-59岁'
ELSE '60岁及以上'
END AS age_group,
SUM(CASE WHEN channel = 'online' THEN 1 ELSE 0 END) AS online_users,
SUM(CASE WHEN channel = 'offline' THEN 1 ELSE 0 END) AS offline_users,
COUNT(*) AS total_users
FROM users
GROUP BY age_group
ORDER BY age_group;
这里要注意:
- CASE WHEN在GROUP BY里不能直接用别名,MySQL 8.0支持按别名分组,但SQL Server和老版本MySQL不允许在GROUP BY里使用SELECT别名,所以最稳妥的做法是在GROUP BY里重复整个CASE表达式。这个细节我后面会再强调。
- SUM(CASE WHEN channel = 'online' THEN 1 ELSE 0 END) 和 COUNT(CASE WHEN channel = 'online' THEN 1 END) 在这个场景下结果等价,但语义不同:前者是"命中则加1,未命中加0",后者是"命中则计数,未命中忽略"。两者我都见过,个人更推荐SUM这种写法,因为它不依赖NULL的隐式处理,读起来更明确。
3.4 案例四:多表JOIN下的条件聚合
现实中几乎不会只有一张表。再举个例子:订单表关联商品表,统计每个商品类别里"促销订单"和"正常订单"的总金额。为了演示JOIN下的条件聚合,我们加一张商品表:
sql复制SELECT
p.category AS category,
SUM(CASE WHEN o.promotion_flag = 1 THEN o.amount ELSE 0 END) AS promo_amount,
SUM(CASE WHEN o.promotion_flag = 0 THEN o.amount ELSE 0 END) AS normal_amount,
SUM(o.amount) AS total_amount
FROM orders o
INNER JOIN products p ON o.product_id = p.product_id
WHERE o.order_date >= '2024-01-01'
GROUP BY p.category
ORDER BY p.category;
这里的关键是JOIN不会破坏条件聚合的逻辑。JOIN会把订单行和对应的商品行拼接成一个"超级行",CASE依然在每个"超级行"上判断,然后SUM把结果汇总到对应的类别分组里。只要确保JOIN关联字段有索引,再配合条件聚合,性能一般都很稳。
但也要提醒一句:JOIN之后一定要确认关联字段没有一对多膨胀的问题,否则金额会被放大。这个坑我在第5章专门讲排查方法。
4. 条件聚合的扩展用法与常见坑
4.1 与DISTINCT配合:统计去重后的用户数
上面的案例统计的是订单数或金额,但有时需要统计"分组里满足某条件的去重用户数"。比如统计每个月通过线上渠道下单的去重用户数:
sql复制SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
COUNT(DISTINCT CASE WHEN channel = 'online' THEN user_id END) AS online_users,
COUNT(DISTINCT CASE WHEN channel = 'offline' THEN user_id END) AS offline_users
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY month;
关键在于:CASE WHEN里命中了才返回user_id,没命中返回NULL;COUNT(DISTINCT 列)会自动忽略NULL值,所以只有满足条件的user_id才会被计数。这个组合非常实用,但也容易搞混。很多人误以为还需要在外层再包一层去重,其实不用。这里我用的是"THEN user_id"而不是"THEN 1",就是为了配合DISTINCT去重,这一点和前面的SUM写法完全不同,千万别套错。
4.2 与窗口函数搭配:分组内同时看汇总和明细
条件聚合可以和窗口函数绑定使用。比如我们想知道每个用户每月的订单总额,同时看看这个用户当月线上和线下的金额分别占比多少:
sql复制SELECT
user_id,
DATE_FORMAT(order_date, '%Y-%m') AS month,
SUM(amount) OVER (PARTITION BY user_id, DATE_FORMAT(order_date, '%Y-%m')) AS user_month_total,
SUM(CASE WHEN channel = 'online' THEN amount ELSE 0 END)
OVER (PARTITION BY user_id, DATE_FORMAT(order_date, '%Y-%m')) AS user_month_online,
SUM(CASE WHEN channel = 'offline' THEN amount ELSE 0 END)
OVER (PARTITION BY user_id, DATE_FORMAT(order_date, '%Y-%m')) AS user_month_offline
FROM orders
ORDER BY user_id, month;
这样输出的每一行依然是原始订单明细,但每一行旁边都带上了用户当月的汇总数据,非常适合做报表时需要同时展示明细和汇总的场景。窗口函数的OVER子句里写CASE WHEN,语法上没有任何障碍,因为OVER作用于聚合函数的外层,CASE只是聚合函数的入参而已。
4.3 NULL、0、1的使用细节
条件聚合里最后那个ELSE,我见过太多人写错。比如:
sql复制-- 错误示例:ELSE写成了1,金额被莫名放大
SUM(CASE WHEN channel = 'online' THEN amount ELSE 1 END)
这种写法一旦某行是offline,会加上1而不是0,结果天然偏大,还很难发现。常规经验是:
- 统计金额时,ELSE补0;
- 统计记录数时,ELSE可以不写,因为COUNT忽略NULL;
- 统计去重用户数时,ELSE也建议不写,让非命中行返回NULL;
- 如果你不确定该用0还是NULL,就看这个聚合结果是要"加总"还是"计数"——加总补0,计数用NULL。
另外用COUNT时还有个小细节:很多人写COUNT(CASE WHEN condition THEN 1 END),这在逻辑上和COUNT(CASE WHEN condition THEN 'anything' END)等价,只要THEN后面不是NULL就行。我习惯写成1,因为很多人会误把COUNT里的表达式当成"值"而不是"存在性标记",写1能让这个"只关心有没有"的意图更清楚。
4.4 不同数据库的语法兼容性
条件聚合是标准SQL语法,主要数据库都支持,但细节上有些差异:
| 数据库 | 支持情况 | 补充说明 |
|---|---|---|
| MySQL/MariaDB | 良好 | 严格模式下GROUP BY必须包含所有非聚合字段 |
| PostgreSQL | 非常标准 | 还支持SUM(amount) FILTER (WHERE ...),写法更简洁 |
| SQL Server | 良好 | GROUP BY里不能引用SELECT别名,需要重复CASE表达式 |
| Oracle | 良好 | 也常用DECODE函数,但CASE WHEN更标准 |
| SQLite | 良好 | 语法兼容,适合做本地数据分析 |
如果团队同时维护多个数据库,建议统一用CASE WHEN组合条件聚合,可移植性最强。PostgreSQL的FILTER语法虽然优雅,但换到MySQL就要重写,维护成本更高。
5. 常见问题与排查技巧实录
5.1 结果多了一倍:JOIN产生笛卡尔积
条件聚合最常见的翻车现场之一:JOIN一张一对多的表后,金额被成倍放大。原因很简单——一行订单被JOIN出了多个匹配行,SUM就把每个匹配行的值都加了进去。比如商品表里每个商品有多个SKU,你拿商品ID去JOIN,就会出现一行订单对应多个SKU记录,金额翻倍。
排查技巧:先注释掉条件聚合,只SELECT原始表和JOIN表的最小字段,数一下行数有没有膨胀;也可以用COUNT(*)和COUNT(DISTINCT order_id)对比,如果相差很大,基本就是JOIN导致的行膨胀。解决方法是先对明细表做子查询去重,或者改成关联子查询确保一对一。
5.2 条件聚合结果不对:把判断条件放到了WHERE里
有人把CASE WHEN条件直接写在WHERE里,然后外层GROUP BY,这种写法会把不满足条件的记录提前过滤掉,最后得到的是"剔除了部分数据后再聚合"的结果,完全不是"按维度分列"的效果。正确用法是:条件判断必须出现在SELECT的聚合函数内部,而不是WHERE。
sql复制-- 错误:先把所有online订单过滤掉了,查的不是分列,而是online一个渠道的汇总
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, SUM(amount) AS online_amt
FROM orders
WHERE channel = 'online'
GROUP BY DATE_FORMAT(order_date, '%Y-%m');
-- 正确:保留全部数据,在SUM内部按维度拆分
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month,
SUM(CASE WHEN channel = 'online' THEN amount ELSE 0 END) AS online_amt,
SUM(CASE WHEN channel = 'offline' THEN amount ELSE 0 END) AS offline_amt
FROM orders
GROUP BY DATE_FORMAT(order_date, '%Y-%m');
判断方式很简单:如果需求是"同时看线上和线下两个数",那WHERE里绝对不能出现channel的过滤条件。WHERE是全局过滤,CASE是列内分流,两者职责完全不同。
5.3 语法报错:GROUP BY与SELECT字段不一致
MySQL默认不是严格模式时能勉强跑通,一旦上线或迁移到严格模式就会报错。避免办法:
- SELECT里只放GROUP BY的字段和聚合函数;
- 不要使用SELECT * 叠加GROUP BY;
- 如果有别名,尽量在GROUP BY里重复写原始表达式而不是依赖别名;
- 如果GROUP BY的表达式很长(比如CASE WHEN嵌套),可以先在子查询里算好字段,外层再分组。这不算作弊,反而更清晰。
5.4 慢查询优化
条件聚合本身不会让查询变慢,真正慢的是:
- 大表没有索引;
- JOIN关联字段没有索引;
- 在WHERE或GROUP BY里对字段套函数(比如DATE_FORMAT),导致索引失效。
实际建议:
- 对order_date这类筛选字段,能存DATE或DATETIME就不要再套函数。如果业务必须按月份查询,可以加一个冗余的月份字段,或者在表设计时单独存一列month。
- 对channel、customer_type这类选择性低的维度字段,单独建索引意义不大,但可以建复合索引(order_date, channel),配合条件聚合效果更佳。
- 如果统计口径固定,可以考虑物化视图或定时汇总表,避免每次实时聚合全量数据。毕竟复用计算结果比每次从头跑一遍快得多。
- 在MySQL里执行EXPLAIN,看type列是不是range或ref,如果出现ALL全表扫描,优先检查WHERE和JOIN字段的索引。
5.5 条件聚合结果的校验清单
最后分享一个我自己一直在用的校验清单,每次改完条件聚合SQL,我都会按这几点过一遍:
- 线上金额 + 线下金额 = 总金额,等式不成立说明条件写错或漏了数据;
- 和上一周期数据对比,增长率异常时先检查CASE条件是否写反;
- 用COUNT(*)和COUNT(DISTINCT 主键)对比,排查行膨胀;
- 看一眼执行计划,确认没有全表扫描;
- 上线前用一小段已知结果的测试数据验证,别拿生产大表直接试。
最后再分享一个小经验:条件聚合在写的时候很爽,但维护起来依赖可读性。我一般会把每个维度的CASE WHEN都写成独立一行,缩进对齐,并在顶部用注释标明每个列的含义和管理口径。这样过两个月回头看,还能一眼看懂当初的业务逻辑。数据统计这件事,除了跑得快,更关键的是让团队所有人都能放心地改、可靠地用。当你把每个分组内的维度都拆得清清楚楚,报表的维护成本会低到你不敢相信。
