如果你经常写 SQL,一定遇到过这种需求:同一个分组里,想把总和按不同维度拆成好几列。很多人第一反应是写多个子查询再 JOIN 起来,结果 SQL 又长又慢;等你真正看懂 SUM(CASE WHEN ...) 之后,会发现一条 GROUP BY 就能把线上金额、线下金额、已完成金额、已取消金额全部算出来。这篇文章就围绕“分组内不同维度的总和”展开,把条件聚合的写法、执行逻辑、实战案例和容易翻车的细节一次讲透,适合经常写统计查询、报表 SQL 的开发者和数据分析师。
1. 分组按维度拆总和:先把“要算什么”翻译成 SQL 逻辑
1.1 一个很常见的取数需求
我接过不少这类需求:运营要一张月度销售报表,看总销售额,但要拆成线上和线下,还要看已完成、已取消、待支付分别多少钱。表面上是“按月分组求和”,但每个分组内部还要再把 amount 按 channel、status 字段的不同值分成好几个统计口径。
把需求翻译成大白话:
- 分组维度是“月份”
- 每个月份里,行数据有各种业务标签
- 同一个
amount,要同时出现在多个统计结果里
比如一条渠道为“线上”、状态为“已完成”的订单,线上总额要算它,已完成总额也要算它,当月总额更要有它。这种“一行数据被多个统计口径复用”的需求,靠常规的简单 SUM 是做不到的,因为普通 SUM 只能对整列求和。
1.2 为什么说 WHERE 只能控制行,CASE WHEN 才能控制列
刚接触的人最容易犯的错,是想着用 WHERE channel = '线上' 分别查一次,再查一次 WHERE channel = '线下',最后拼起来。这样不是不行,但如果你要同时统计 5 个口径,就要写很多次几乎一样的查询,再想办法合并结果,维护成本很高。
这里有个关键认知:
WHERE是行级过滤,它决定“哪些行进入最终统计”,不符合条件的行直接被扔掉CASE WHEN是值级映射,它不丢行,只把当前行“归到”某个统计列里去累加
所以需求一旦是说“同一组数据里,按某个字段的不同值,分别在列上求和”,这就是条件聚合的典型场景。你用 WHERE 天然只能得到一行维度,很难在同一个结果集里同时展开成多列。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SUM(CASE WHEN ...) 的执行逻辑:逐行打标,分组合计
2.1 基础语法模板
条件聚合的标准写法并不复杂:
sql复制SELECT
分组字段,
SUM(CASE WHEN 条件1 THEN 要累加的字段 ELSE 0 END) AS 口径1,
SUM(CASE WHEN 条件2 THEN 要累加的字段 ELSE 0 END) AS 口径2,
SUM(CASE WHEN 条件3 THEN 要累加的字段 ELSE 0 END) AS 口径3
FROM 表名
GROUP BY 分组字段;
你甚至可以把它理解成“把一段小的 if-else 塞进了 SUM 的参数里”。当数据行的条件满足时,就把字段值交给 SUM 去累加;条件不满足时,交出 0 或者 NULL,不影响当前列的求和结果。
2.2 从执行顺序看,它发生在“行判断”到“组聚合”之间
只看语法可能还会觉得抽象,看一下完整的逻辑执行顺序就清楚了:
FROM:确定数据来源WHERE:过滤掉不需要的行GROUP BY:把剩余数据按分组字段分组SELECT阶段:对每一行分别计算CASE WHEN表达式,得到该行在某个统计口径下要贡献的值SUM聚合:把同一组内每一行贡献的值累加起来
换句话说,CASE WHEN 是行级表达式,作用在每一行数据上;SUM 是组级聚合,作用在一组数据上。这两者不是并列的关系,而是“先逐行计算 CASE,再对计算结果做 SUM”。
用程序员的思维类比一下:
text复制for (row in group) {
if (row.channel == '线上') {
onlineAmount += row.amount;
}
if (row.status == '已完成') {
completedAmount += row.amount;
}
}
同一行数据可以被多个 if 分支同时“命中”,因为它会分别进入到多个统计口径的累加逻辑里。这才是条件聚合能够“一个分组拆多个维度”的根本原因。
2.3 没有 ELSE 时 SUM 是怎么处理的
很多教材里写法是:
sql复制SUM(CASE WHEN channel = '线上' THEN amount END)
注意这里没有写 ELSE 0。CASE WHEN 在条件不满足时会返回 NULL,而 SUM 会忽略 NULL 值,所以两种写法的 SUM 结果是一样的。不过,ELSE 0 的写法更严谨,也更容易让人一眼看懂你想表达“不满足条件的行贡献为 0”。
需要注意,这个“NULL 不参与聚合”的特性在 COUNT 和 AVG 里的影响和 SUM 完全不同,后面第 5 章我会专门踩这个坑。
3. 实战拆解:用订单表一次统计多个金额口径
3.1 准备一张销售订单示例表
纸上谈兵没意思,先建一张销售订单表。为了不让示例纠缠在日期函数差异上,我在表里冗余一个 sale_month 字段,直接存“2025-01”这样的月份字符串。实际项目里这种字段常见于数据仓库的日期维度表,或者由上游加工好。
sql复制CREATE TABLE sales_order (
id INT PRIMARY KEY,
order_no VARCHAR(32),
order_date DATE,
sale_month VARCHAR(7),
channel VARCHAR(10),
status VARCHAR(10),
amount DECIMAL(10, 2)
);
插入一些测试数据,确保 1 月到 3 月都有线上、线下,以及已完成、已取消、待支付等状态:
sql复制INSERT INTO sales_order
VALUES
(1, 'SO001', '2025-01-05', '2025-01', '线上', '已完成', 100.00),
(2, 'SO002', '2025-01-08', '2025-01', '线上', '已取消', 50.00),
(3, 'SO003', '2025-01-12', '2025-01', '线下', '已完成', 80.00),
(4, 'SO004', '2025-01-15', '2025-01', '线下', '待支付', 30.00),
(5, 'SO005', '2025-02-02', '2025-02', '线上', '已完成', 200.00),
(6, 'SO006', '2025-02-06', '2025-02', '线上', '已完成', 60.00),
(7, 'SO007', '2025-02-11', '2025-02', '线下', '已取消', 40.00),
(8, 'SO008', '2025-02-18', '2025-02', '线下', '待支付', 90.00),
(9, 'SO009', '2025-03-03', '2025-03', '线上', '已完成', 150.00),
(10, 'SO010', '2025-03-09', '2025-03', '线下', '已完成', 70.00),
(11, 'SO011', '2025-03-20', '2025-03', '线下', '已取消', 20.00);
3.2 按月统计 7 个口径:单条 SQL 搞定
现在需求是:按月份分组,统计订单数、总金额、线上金额、线下金额、已完成金额、已取消金额、待支付金额。
sql复制SELECT
sale_month,
COUNT(*) AS order_cnt,
SUM(amount) AS total_amt,
SUM(CASE WHEN channel = '线上' THEN amount ELSE 0 END) AS online_amt,
SUM(CASE WHEN channel = '线下' THEN amount ELSE 0 END) AS offline_amt,
SUM(CASE WHEN status = '已完成' THEN amount ELSE 0 END) AS completed_amt,
SUM(CASE WHEN status = '已取消' THEN amount ELSE 0 END) AS cancelled_amt,
SUM(CASE WHEN status = '待支付' THEN amount ELSE 0 END) AS pending_amt
FROM sales_order
GROUP BY sale_month
ORDER BY sale_month;
查询结果:
| sale_month | order_cnt | total_amt | online_amt | offline_amt | completed_amt | cancelled_amt | pending_amt |
|---|---|---|---|---|---|---|---|
| 2025-01 | 4 | 260.00 | 150.00 | 110.00 | 180.00 | 50.00 | 30.00 |
| 2025-02 | 4 | 390.00 | 260.00 | 130.00 | 260.00 | 40.00 | 90.00 |
| 2025-03 | 3 | 240.00 | 150.00 | 90.00 | 220.00 | 20.00 | 0.00 |
检验一下数据能帮理解:1 月 total_amt = 260,线上 150 = 已完成 100 + 已取消 50;线下 110 = 已完成 80 + 待支付 30,两个渠道之和正好等于总额。原因就是每条订单的金额会同时进入“渠道口径”和“状态口径”的 SUM,SQL 引擎并不会因为一个 CASE WHEN 命中就让另外的 CASE WHEN 失效,它们彼此独立工作。
还有个小细节:3 月没有待支付订单,但由于写入的是 ELSE 0,查询结果中 pending_amt 显示为 0.00,而不是 NULL。如果不写 ELSE,这里大概率是 NULL,报表里处理起来会多一层 COALESCE。
3.3 如果不用条件聚合,这组 SQL 会长成什么样
对比一下传统的子查询写法。假设分开统计:
sql复制SELECT
a.sale_month,
a.total_amt,
b.online_amt,
c.offline_amt,
d.completed_amt
FROM
(SELECT sale_month, SUM(amount) AS total_amt
FROM sales_order GROUP BY sale_month) a
LEFT JOIN
(SELECT sale_month, SUM(amount) AS online_amt
FROM sales_order WHERE channel = '线上' GROUP BY sale_month) b
ON a.sale_month = b.sale_month
LEFT JOIN
(SELECT sale_month, SUM(amount) AS offline_amt
FROM sales_order WHERE channel = '线下' GROUP BY sale_month) c
ON a.sale_month = c.sale_month
LEFT JOIN
(SELECT sale_month, SUM(amount) AS completed_amt
FROM sales_order WHERE status = '已完成' GROUP BY sale_month) d
ON a.sale_month = d.sale_month
ORDER BY a.sale_month;
逻辑上没错,但已经有几个明显问题:
- 表要扫描多次,每次只取一种口径
- JOIN 次数会随着口径数量线性增长
- 后续加一个统计口径,就得再套一层子查询
- 一旦某些分组在某个子查询中不存在,LEFT JOIN 还会产生 NULL,需要额外处理
条件聚合的写法虽然也会在一条 SQL 里写多个 CASE WHEN,但数据源只扫一次,逻辑更集中,后面加口径也只是在 SELECT 后面追加一列,维护起来要轻松得多。
4. 条件聚合的高频变形:组合条件、去重统计、占比和同比
4.1 多条件组合:AND / OR 怎么用
实际业务里的口径往往不是一个条件,而是组合条件。比如“线上已完成订单金额”“线下大额订单金额”,直接在 CASE WHEN 里用 AND / OR 组合就行:
sql复制SELECT
sale_month,
SUM(CASE WHEN channel = '线上' AND status = '已完成'
THEN amount ELSE 0 END) AS online_completed_amt,
SUM(CASE WHEN channel = '线下' AND amount >= 100
THEN amount ELSE 0 END) AS offline_big_amt
FROM sales_order
GROUP BY sale_month
ORDER BY sale_month;
多条件组合的重点是:CASE WHEN 里可以写任意合法的布尔表达式,不局限于单个字段的等值判断。小于、大于、IN、LIKE、BETWEEN 都可以放进去。
4.2 用 COUNT(DISTINCT CASE WHEN ...) 统计满足条件的去重用户数
求金额总和是比较简单的场景,还有一个高频需求是“某个渠道里完成过订单的用户数”。因为同一用户可能有多个订单,如果直接 COUNT(*) 会重复统计,正确写法是用 COUNT(DISTINCT ...) 搭配条件聚合:
sql复制SELECT
sale_month,
COUNT(DISTINCT CASE WHEN channel = '线上' AND status = '已完成'
THEN user_id END) AS online_completed_uv
FROM sales_order
GROUP BY sale_month;
这个写法的原理是:满足条件的行返回 user_id,不满足条件的行返回 NULL。COUNT(DISTINCT user_id) 会忽略 NULL,只统计非空且不重复的用户 ID。
这里和 SUM 有一个微妙的差异:COUNT 对 NULL 是“看不见”的,所以 CASE WHEN 不满足时返回 NULL 反而是我们想要的。如果把上面改成 THEN user_id ELSE 'x' END,那么所有不满足条件的行也返回了一个非 NULL 的字符串,去重后就会多出一个垃圾值。
4.3 占比计算的坑:分母为 0 时怎么办
另一个常用场景是把条件聚合结果除以总聚合,算占比。
比如统计“线上已完成订单金额占总金额的比例”:
sql复制SELECT
sale_month,
SUM(CASE WHEN channel = '线上' AND status = '已完成'
THEN amount ELSE 0 END)
/ NULLIF(SUM(amount), 0) AS online_completed_ratio
FROM sales_order
GROUP BY sale_month
ORDER BY sale_month;
用 NULLIF(SUM(amount), 0) 是为了防止当月没有订单时,分母变成 0 导致除零错误。NULLIF(A, B) 的意思是:如果 A 等于 B,返回 NULL,否则返回 A。这样除法结果是 NULL,虽然不美观,但至少不会让 SQL 直接报错。这比在报表层面对 0 做判断要省事。
如果算的是订单数占比,还得注意整数除法的问题。很多数据库里 1 / 2 结果可能是 0,因为整数相除会舍弃小数,我给其中一个因子乘上 1.0,强制转成小数运算:
sql复制SELECT
sale_month,
COUNT(CASE WHEN status = '已完成' THEN 1 END) * 1.0 / COUNT(*) AS completed_order_ratio
FROM sales_order
GROUP BY sale_month
ORDER BY sale_month;
4.4 同比场景:将同一个月拆成“今年列”和“去年列”
条件聚合还有一个特别实用的场景是同比。假如原始表里只有订单日期,没有冗余月份字段,我要按月份对比今年与去年同月的销售额,可以先提取月份作为分组维度,再按年份用条件聚合拆成两列:
sql复制SELECT
EXTRACT(MONTH FROM order_date) AS month_no,
SUM(CASE WHEN EXTRACT(YEAR FROM order_date) = 2024
THEN amount ELSE 0 END) AS amt_2024,
SUM(CASE WHEN EXTRACT(YEAR FROM order_date) = 2025
THEN amount ELSE 0 END) AS amt_2025
FROM sales_order
WHERE order_date >= DATE '2024-01-01'
AND order_date < DATE '2026-01-01'
GROUP BY EXTRACT(MONTH FROM order_date)
ORDER BY month_no;
不同数据库的日期函数不完全一样,MySQL 常用 DATE_FORMAT / YEAR,SQL Server 常用 DATEPART / YEAR,PostgreSQL 和 Oracle 支持 EXTRACT。但核心思路不会变:先把日期字段“加工”成分组要用的粒度,再用 CASE WHEN 把年份维度变到列上。
提示:这种写法会把 GROUP BY 字段变成对 order_date 的函数运算,可能影响索引利用。数据量大时,比较稳妥的方案是在 WHERE 里先用原始日期字段把区间卡窄,再在 SELECT / GROUP BY 里放心提取。
5. 条件聚合容易踩的四个坑:从结果不对到查询慢
5.1 SUM 里的 ELSE 0 和 COUNT 里的 ELSE 0 不是一回事
这一点可以说是条件聚合初学者最容易踩的坑。
先看正确写法。用 SUM 统计满足条件的记录数时,很多人习惯写:
sql复制SUM(CASE WHEN status = '已完成' THEN 1 ELSE 0 END)
这个写法是正确的。因为 SUM 把满足条件的行累加 1,不满足条件的行累加 0,不会影响最终结果。
但有人想用 COUNT 达到同样效果时,会写:
sql复制COUNT(CASE WHEN status = '已完成' THEN 1 ELSE 0 END)
这就是一个隐蔽的错误。COUNT(expr) 不统计 NULL,但统计非 NULL。你的 CASE WHEN 无论条件满不满足都返回了一个值:满足时返回 1,不满足时返回 0。0 和 1 都是非 NULL,所以它会把整组所有行都数一遍,等价于 COUNT(*)。
正确写法是:
sql复制COUNT(CASE WHEN status = '已完成' THEN 1 END)
不满足条件时不走 THEN,直接返回 NULL;COUNT 忽略 NULL,数的才是真正满足条件的行数。
同样的问题也存在于 AVG。比如想统计已完成订单的平均金额:
AVG(CASE WHEN status = '已完成' THEN amount END):只对满足条件的行的 amount 求平均,不满足的行被忽略AVG(CASE WHEN status = '已完成' THEN amount ELSE 0 END):不满足条件的行也以 0 参与平均,会严重拉低结果
如果需求是“所有订单金额里已完成订单的平均占比”,那可能是第二种;如果需求是“已完成订单自身的平均金额”,必须用第一种。大部分时候业务要的是第一种。
5.2 CASE WHEN 分支顺序导致的“区间被吞”
CASE WHEN 是按书写顺序从上到下判断的,只要命中一个条件,就直接返回结果,后面的条件不再执行。这个特性和程序语言的 else-if 一样,但很多人会把不互斥的条件写在一起。
举个例子,统计订单金额分布:
sql复制SELECT
SUM(CASE WHEN amount < 1000 THEN 1 ELSE 0 END) AS small_order,
SUM(CASE WHEN amount < 5000 AND amount >= 1000 THEN 1 ELSE 0 END) AS medium_order,
SUM(CASE WHEN amount >= 5000 THEN 1 ELSE 0 END) AS large_order
FROM sales_order;
如果不小心把第一个条件写成 amount < 5000,第二个条件写成 amount >= 1000 AND amount < 5000,那么一笔 2000 元的订单在第一个分支就命中了,永远不会进入第二个分支,medium_order 会少统计。最终这三个类别的数量加总并不等于订单总数,这种问题很难一眼看出来。
排查建议是:任何统计结果出来后,先把多个互斥分类的数量加总,和 COUNT(*) 对一下。如果对不上,优先检查 CASE WHEN 的分支顺序。区间统计时,最保险的做法是让每个 CASE WHEN 条件都带上完整的下界和上界,不依赖顺序。
5.3 大表上条件聚合的性能隐患
条件聚合遍历的是分组后的数据行,如果源表很大,而且没有合理的过滤条件,一次查询可能把整张表都扫一遍。这不是条件聚合本身的缺点,任何复杂统计都可能有这个问题,但条件聚合往往会在一条 SQL 里堆很多 CASE WHEN,每个表达式都要对每一行做计算。SQL 引擎通常会做公共表达式优化,不会真的把相同条件的 CASE 重复计算好几次,但也不要把性能优化完全寄托在优化器上。
我实际面对过一张几亿行的订单流水表,刚开始直接对全表做条件聚合,一个报表查询跑了十分钟。后来做的事情很简单:
- 在
WHERE里把日期范围从“全表”收窄到“最近 90 天” - 把常用的渠道、状态维度,连同月份一起做成一张小粒度的汇总宽表,每天定时从明细表预聚合
- 报表直接查预聚合表,不再碰明细表
条件聚合适合在明细数据量可控的场景下使用。如果明细表已经到千万、亿级别,而且分析口径固定,请考虑用 ETL 预聚合或者物化视图,而不是每次都在报表请求时实时跑。
另外一个容易被忽略的点是:CASE WHEN 里面不要对带索引的字段做无谓的函数包裹。比如你已经把 sale_month 建了索引,却写成 SUM(CASE WHEN DATE_FORMAT(order_date, '%Y-%m') = '2025-01' ...),数据库在计算这个表达式时无法直接利用 order_date 上的索引。最稳妥的方案仍然是:过滤尽量用原始字段,加工尽量放在 SELECT 层。
5.4 不同数据库的兼容性:别名、GROUP BY 和 PIVOT
条件聚合的语法 SUM(CASE WHEN ... END) 是标准 SQL,MySQL、PostgreSQL、SQL Server、Oracle、SQLite 基本都支持,这点非常友好。当然,不同数据库还有一些细节差异。
第一个是 SELECT 列别名在 GROUP BY、HAVING、ORDER BY 中的可用性。MySQL 对语法比较宽松,允许在 GROUP BY / HAVING / ORDER BY 里直接引用 SELECT 中的别名;但 SQL Server、Oracle 在 HAVING 阶段通常不能直接引用 SELECT 别名,因为它们按逻辑顺序先执行 HAVING 再执行 SELECT。写跨数据库的 SQL 时,最好不依赖“别名可以在 HAVING 里用”这个特性。
错误示范(部分数据库会报错):
sql复制SELECT
sale_month,
SUM(CASE WHEN status = '已完成' THEN amount ELSE 0 END) AS completed_amt
FROM sales_order
GROUP BY sale_month
HAVING completed_amt > 200;
更稳妥的做法是完整写一遍条件表达式,或者在外面包一层子查询:
sql复制SELECT *
FROM (
SELECT
sale_month,
SUM(CASE WHEN status = '已完成' THEN amount ELSE 0 END) AS completed_amt
FROM sales_order
GROUP BY sale_month
) t
WHERE completed_amt > 200;
第二个是 GROUP BY 后面到底该写什么。如果你 SELECT 里面用了 sale_month 这个原始列,那么 GROUP BY sale_month 没问题;如果你 SELECT 里面用的是 EXTRACT(MONTH FROM order_date),那么 GROUP BY 1 虽然能跑,可读性很差,最好把表达式完整写出来,或者给表达式起别名后再分组。MySQL 在开启 ONLY_FULL_GROUP_BY 后,对 SELECT 中非聚合列的要求很严格,如果列没有出现在 GROUP BY 里会直接报错。
第三个是有些数据库提供了 PIVOT、FILTER 等更高级的写法,比如 SQL Server 的 PIVOT、PostgreSQL 的 SUM(amount) FILTER (WHERE ...)。但考虑到可读性和迁移成本,我在实际项目中仍然优先使用 CASE WHEN。它虽然写起来长一点,但语义足够直白,任何人接手都能快速看懂。
如果你的查询涉及行转列且维度非常多,也可以考虑用 PIVOT 或者 GROUP BY GROUPING SETS,但那是另一种场景了。条件聚合最大的价值,是让你在“维度数量可控、口径随时会变”的需求里,用最低成本完成统计,并且让每种口径都以独立的列名清楚呈现。
我自己的日常习惯是:涉及条件聚合的统计 SQL,一定会在 SELECT 后面给每个 CASE WHEN 表达式起一个能直接对应业务口径的别名,比如 completed_amt、online_completed_uv。这样做一方面是为了让报表工具能直接读取列名,另一方面是当业务方来问“你这个已完成金额的口径是什么”时,我能指着 SQL 里的 status = '已完成' 直接回答,而不是重新去翻设计文档。条件聚合并不玄乎,拆开来看就是在 SUM 前加了一段可审查的判断逻辑,把这段逻辑写清楚,报表就成功了一半。
