☰
SQL数据汇总核心语法:CASE WHEN实战技巧与场景解析
2026/10/20 14:49:14 网站建设 项目流程

做数据汇总写 SQL,如果只能挑一个语法留下来,我会选 CASE WHEN。这句话听起来有点夸张,但实际干活的时候你会发现,报表里那些“按状态统计”“分区间计数”“一行变多列”的需求,几乎都得靠它兜底。它不是一个炫技的写法,而是把“行的值”转换成“需要的维度”最直接的手段。

这篇文章不聊理论,直接拿一张订单表展开,从条件计数、条件求和到行转列、分层统计,每个场景都给出完整 SQL 和结果,最后整理几个我实际踩过的坑。适合刚学完 SQL 基础、开始碰汇总查询的同学,也适合天天写报表但想再理顺 CASE WHEN 细节的分析师。

1. 数据汇总为什么要用 CASE WHEN

1.1 先搞懂 CASE WHEN 的两种写法

很多人第一次接触 CASE WHEN 是在 SELECT 里做字段翻译:比如存的是 1 和 2,查出来要显示“男”和“女”。这是最简单的一种用法,但 CASE WHEN 能做的事情远不止翻译字段。

MySQL 里 CASE WHEN 有两种写法。

-- 写法一:简单 CASE 表达式,只能做等值匹配 SELECT CASE pay_status WHEN 1 THEN '未支付' WHEN 2 THEN '已支付' ELSE '未知' END AS status_name FROM sales_order; -- 写法二:搜索 CASE 表达式,可以写范围、逻辑运算 SELECT CASE WHEN order_amount >= 1000 THEN '高金额' WHEN order_amount >= 500 AND order_amount < 1000 THEN '中金额' ELSE '低金额' END AS amount_level FROM sales_order;

简单 CASE 的本质是等值比较,相当于一个隐式的=判断,所以它没法处理范围条件。搜索 CASE 则是逐行按顺序判断每个 WHEN 后面的条件表达式,条件成立就返回 THEN 的结果,并跳过后面的所有判断,都不成立就走 ELSE。

这里有一个很容易忽略的关键认知:CASE WHEN 整体是一个表达式,不是一个控制流语句。它和 Java、Python 里的if-else有本质区别。表达式的意思是,它最终必须返回一个值,这个值可以参与四则运算、比较运算,也可以被包进 SUM、MAX 这类聚合函数里当成参数。正是这个“可以返回值”的特性,让它成了数据汇总场景里最灵活的零件。

1.2 汇总场景的典型痛点

写汇总 SQL 最烦的是什么?不是单条查询写不出来,而是业务方要的报表往往长这样:同一张订单表,既要统计每个城市的总销售额,又要统计每个城市已支付订单的金额,还要看每个城市高金额订单的数量。如果按传统思路,这得写三条 SQL,然后用代码去合并,或者三个子查询嵌套,又长又难维护。

再常见一点的是交叉表:行是城市,列是支付状态——未支付、已支付、退款中各有多少订单。这种“行转列”的需求,GROUP BY只能按一个维度分组,没法直接产出一行多列的结构。没有 CASE WHEN 的时候,你只能写多个子查询再 JOIN,SQL 直接膨胀到没法看。

CASE WHEN 恰好解决这两类问题:

  • 它可以在聚合函数内部做条件判断,实现“扫一遍数据,按条件分别累加/计数”的效果,替代多条 SQL;
  • 它可以在 SELECT 里把某列的值转换为新的分类,配合GROUP BY去生成一个新的分组维度。

所以与其说 CASE WHEN 是一个语法,不如说它是数据汇总里的“维度生成器”和“条件容器”。理解了这层定位,后面所有写法都是顺理成章的事情。

2. 三种最常用的条件统计姿势

2.1 条件计数:COUNT 和 NULL 的微妙关系

先看一个最常见的需求:统计订单表中已支付、未支付、退款中的订单数各是多少。

最自然的想法是写三条 COUNT 带 WHERE 的查询,但用 CASE WHEN 一条 SQL 就能搞定:

SELECT COUNT(CASE WHEN pay_status = 2 THEN 1 END) AS paid_cnt, COUNT(CASE WHEN pay_status = 1 THEN 1 END) AS unpaid_cnt, COUNT(CASE WHEN pay_status = 3 THEN 1 END) AS refunded_cnt FROM sales_order;

注意这里THEN 1后面没有写ELSE。当条件不满足时,整个 CASE 表达式返回 NULL,而 COUNT 函数是跳过 NULL 值的,所以只有条件满足的行才会被计入。如果你写了ELSE 0,问题就来了——COUNT 会把 0 也当成非空值数进去,统计结果变成全表行数。

提醒:COUNT(CASE WHEN ... THEN 1 END)和COUNT(CASE WHEN ... THEN 1 ELSE NULL END)等价,但ELSE 0不等价。这是新手最容易踩的坑。

同理,如果你想统计“非某状态”的数量,直接写COUNT(CASE WHEN pay_status <> 2 THEN 1 END)是可行的,但在有 NULL 的情况下要小心<>对 NULL 不生效的问题,最好显式处理pay_status IS NULL OR pay_status <> 2,或者把 NULL 也当成一种状态单独统计。

2.2 条件求和:SUM 里的 CASE WHEN 才是精华

条件计数用 COUNT 很自然,但如果是“按条件求和”,就必须靠 SUM 配合 CASE WHEN 了。比如统计已支付订单的金额:

SELECT SUM(CASE WHEN pay_status = 2 THEN order_amount ELSE 0 END) AS paid_amount, SUM(CASE WHEN pay_status = 1 THEN order_amount ELSE 0 END) AS unpaid_amount FROM sales_order;

这里ELSE 0是必要的。和 COUNT 不同,SUM 会把 NULL 也当成值参与计算吗?不会。实际上 SUM 也会跳过 NULL,所以理论上ELSE 0和ELSE NULL在这段 SQL 里结果一样,因为 0 加进去不改变总和。

但我的习惯是写ELSE 0,有两个原因:

  • 语义更明确:告诉读代码的人“不符合条件的行按 0 算”,而不是“算不出来是 NULL”;
  • 防止后续有人把 SUM 改成 AVG,AVG 处理 NULL 和 0 的方式完全不同(下面会说)。

这里还有个细节:如果整列都是 NULL,SUM 返回 NULL,而不是 0。在报表里直接显示 NULL 不太好看,通常要IFNULL(SUM(...), 0)包裹一层。

2.3 条件求平均值和极值

AVG 配合 CASE WHEN 做条件均值的时候,要不要加ELSE 0结果差异非常大。看代码:

SELECT AVG(CASE WHEN pay_status = 2 THEN order_amount END) AS avg_paid_amount, AVG(CASE WHEN pay_status = 1 THEN order_amount END) AS avg_unpaid_amount FROM sales_order;

这里不能写ELSE 0,否则未支付订单的 0 金额会把平均值拉低。AVG 只对非 NULL 的值求平均,所以THEN order_amount后面的ELSE留空,等于自动跳过不满足条件的行。这是和 SUM 的写法完全相反的地方。

MIN、MAX 也类似。比如想统计“每个用户最早一笔已支付订单的时间”,可以这样写:

SELECT user_id, MIN(CASE WHEN pay_status = 2 THEN order_time END) AS first_paid_time FROM sales_order GROUP BY user_id;

由于 MIN 会忽略 NULL,未支付的行不会参与比较。这种写法比先过滤再分组要更灵活,尤其是当你想在同一个 GROUP BY 下同时取多个状态的最值的时候。

3. 实操案例:一张订单表解决四种常用汇总

3.1 准备演示数据

为了验证下面的 SQL,先建一张简单的订单表并插入数据,后面所有示例都基于这张表运行。

CREATE TABLE sales_order ( order_id INT, user_id INT, city VARCHAR(20), order_amount DECIMAL(10, 2), pay_status TINYINT COMMENT '1未支付 2已支付 3退款', order_time DATETIME ); INSERT INTO sales_order VALUES (1, 101, '上海', 1200.00, 2, '2025-01-10 10:00:00'), (2, 102, '北京', 300.00, 1, '2025-01-10 11:30:00'), (3, 101, '上海', 80.00, 2, '2025-01-11 09:20:00'), (4, 103, '广州', 560.00, 2, '2025-01-11 14:05:00'), (5, 104, '北京', 2400.00, 3, '2025-01-12 08:45:00'), (6, 105, '上海', 30.00, 1, '2025-01-12 19:10:00'), (7, 106, '广州', 760.00, 2, '2025-01-13 12:00:00'), (8, 107, '北京', 1500.00, 2, '2025-01-13 16:30:00'), (9, 108, '广州', 95.00, 3, '2025-01-14 10:10:00'), (10, 109, '上海', 1899.00, 2, '2025-01-14 21:00:00');

简单说明一下字段:pay_status用数字表示状态,2 是已支付;金额单位是元;城市有三个。所有示例都在 MySQL 8.0 环境跑过,如果你用的是 5.7 或 MariaDB,结果不会有什么区别。

3.2 场景一:支付状态汇总

这个场景最贴近日常报表——按状态统计订单数和支付金额,还要算一下未支付金额占比。

SELECT COUNT(*) AS total_orders, COUNT(CASE WHEN pay_status = 2 THEN 1 END) AS paid_orders, COUNT(CASE WHEN pay_status = 1 THEN 1 END) AS unpaid_orders, COUNT(CASE WHEN pay_status = 3 THEN 1 END) AS refunded_orders, SUM(CASE WHEN pay_status = 2 THEN order_amount ELSE 0 END) AS paid_amount, SUM(CASE WHEN pay_status = 1 THEN order_amount ELSE 0 END) AS unpaid_amount FROM sales_order;

执行结果:

total_orderspaid_ordersunpaid_ordersrefunded_orderspaid_amountunpaid_amount
106225999.00330.00

这条 SQL 的优点是:全表只扫描一次,一个GROUP BY都没有,却输出了六列统计指标。如果按传统写法,至少要六条独立查询,性能和可读性都差很多。实际业务中如果还要加上时间条件,直接在外面包一层 WHERE 就行,聚合逻辑完全不用动。

3.3 场景二:行转列实现城市交叉统计

再看一个典型的行转列需求:按城市分组,横向展示已支付、未支付、退款三个状态各自的订单数。用 CASE WHEN 加 GROUP BY 是最直接的做法。

SELECT city, COUNT(CASE WHEN pay_status = 2 THEN 1 END) AS paid_cnt, COUNT(CASE WHEN pay_status = 1 THEN 1 END) AS unpaid_cnt, COUNT(CASE WHEN pay_status = 3 THEN 1 END) AS refunded_cnt, COUNT(*) AS total_cnt FROM sales_order GROUP BY city ORDER BY city;

执行结果:

citypaid_cntunpaid_cntrefunded_cnttotal_cnt
上海3104
北京1113
广州2013

这里的思路是:GROUP BY city先把行按城市分成三组,然后每个COUNT(CASE WHEN ...)在分组内部做一次条件判断,满足状态条件的行计 1,不满足的计 NULL 被跳过。四个统计列在同一个分组内完成,不需要 JOIN 任何其他表。

这就是“行转列”的本质:用 CASE WHEN 把原来纵向分布在不同行的数据,通过条件判断映射到横向的固定列上。如果你需要在代码里按这个结果做展示,数据结构已经和前端表格完全对上了。

如果想看金额而不是订单数,把COUNT换成SUM,THEN 1换成THEN order_amount,逻辑完全一样。这种替换能力是 CASE WHEN 最强大的地方——同样的结构,换个聚合函数和取值列,就是一个新报表。

3.4 场景三:金额区间分层统计

业务方经常说“把订单按金额分几个档,看看每档有多少单、多少钱”。这里要用搜索 CASE 做范围判断,然后作为分组键。

SELECT CASE WHEN order_amount >= 1000 THEN '高额订单' WHEN order_amount >= 300 AND order_amount < 1000 THEN '中额订单' ELSE '小额订单' END AS amount_level, COUNT(*) AS order_cnt, SUM(order_amount) AS total_amount FROM sales_order GROUP BY CASE WHEN order_amount >= 1000 THEN '高额订单' WHEN order_amount >= 300 AND order_amount < 1000 THEN '中额订单' ELSE '小额订单' END ORDER BY total_amount DESC;

执行结果:

amount_levelorder_cnttotal_amount
高额订单47999.00
中额订单21320.00
小额订单4205.00

这里有两个容易踩的坑,我逐个说。

第一,GROUP BY后面的表达式必须和SELECT里的 CASE WHEN完全一致。MySQL 允许在 GROUP BY 里引用 SELECT 中的别名吗?不同版本行为不一致,最保险的写法是把整个 CASE WHEN 表达式重复一遍。虽然看起来很啰嗦,但这是标准 SQL 的推荐写法,能避免有些模式下的语法报错。

第二,CASE WHEN 的条件顺序很重要。这个例子中把>= 1000写在最前面,>= 300 AND < 1000写在第二,ELSE兜底。如果把第二个条件写成order_amount >= 300,逻辑上也正确,因为第一个条件已经过滤掉 1000 以上的了。但反过来——如果把>= 300放第一个,>= 1000放第二个,那 1200 的订单会先命中“中额订单”条件,因为 CASE WHEN 是顺序判断、命中即停的,结果就会完全错乱。

这个分层逻辑在业务上很常见,比如电商的 RFM 分析、用户的消费等级划分、客户分群,本质都是把连续值映射成离散标签。CASE WHEN 在这里承担的就是一个“离散化器”的角色。

3.5 场景四:用 CASE WHEN 做分组键二次加工

最后一个场景是“分组前先改造字段”。比如想按“用户是否在北京”来看订单情况,直接查 city 只能按具体城市分,这时先把城市统一映射成一个二分类标签,再分组。

SELECT CASE WHEN city = '北京' THEN '北京用户' ELSE '非北京用户' END AS user_region, COUNT(*) AS order_cnt, SUM(order_amount) AS total_amount, AVG(order_amount) AS avg_amount FROM sales_order GROUP BY CASE WHEN city = '北京' THEN '北京用户' ELSE '非北京用户' END;

执行结果:

user_regionorder_cnttotal_amountavg_amount
北京用户34200.001400.00
非北京用户73404.00486.29

这个写法的本质是在 GROUP BY 之前先经过一层表达式计算,把 city 的多个值收敛到两个标签上。和场景三的区别在于,场景三是按订单金额把每行数据分类,然后分组;这里也是同样的套路,只是分类依据是字段的组合或转换。业务里常见的月份提取(把时间变成“2025-01”这种月标签)、年龄段映射(把年龄变成“18-25”、“26-30”等区间)都是这个思路。

CASE WHEN 做分组键还有一个隐藏的方便之处:它可以同时处理多个列的条件。比如CASE WHEN city = '北京' AND order_amount > 1000 THEN '重点用户',这种复合逻辑在 WHERE 里过滤和在这里当分组键都行得通,灵活性比单独的字段分组高得多。

4. 常见问题与排查技巧实录

4.1 统计结果出现 NULL、偏少或偏多

我把实际工作里最容易出问题的三种情况列个表,遇到结果不对先对照自查:

现象常见原因解决方案
COUNT 结果等于整表行数ELSE 0写在 COUNT 的 CASE WHEN 里,0 被当成非空计数了去掉ELSE 0,或改ELSE NULL
SUM 结果为 NULL所有行的条件都不满足,CASE 返回了 NULL,SUM 汇总后是 NULL外层套IFNULL(SUM(...), 0)
统计数字比预期少CASE WHEN 条件顺序不对,先命中了更宽泛的 WHEN调换条件顺序,把最具体的条件放最前面
结果里出现“未知”等兜底值数据中存在未预料的取值,比如 NULL在 ELSE 分支里显式处理WHEN col IS NULL

这里特别提醒一个容易被忽视的点:NULL 不会命中任何比较条件。比如CASE WHEN pay_status = 2 THEN 1 ELSE 0 END,如果某行 pay_status 是 NULL,会走 ELSE 分支返回 0,而不是报错。所以当你发现统计结果总有一部分“消失”的时候,先查一下源数据有没有 NULL 值,再决定是在 CASE WHEN 里加WHEN pay_status IS NULL单独处理,还是在 ELSE 里兜底。

4.2 WHERE 子句里的 CASE WHEN 要慎用

有些同学喜欢在 WHERE 里写WHERE CASE WHEN 条件 THEN 字段 END = '某个值',MySQL 语法上允许,但在某些场景下会让索引失效。

举例:

-- 不推荐,索引失效风险极高 SELECT * FROM sales_order WHERE CASE WHEN pay_status = 1 THEN TIMESTAMPDIFF(DAY, order_time, NOW()) > 3 END = 1;

CASE WHEN包了一层表达式之后,MySQL 的优化器很难直接把它下推到索引扫描去。而且这种写法可读性差,没法直接利用 order_time 上的索引。

更推荐的做法是拆分条件:

SELECT * FROM sales_order WHERE pay_status = 1 AND order_time < DATE_SUB(NOW(), INTERVAL 3 DAY);

这不是说 WHERE 里完全不能用 CASE WHEN,如果过滤条件确实特别复杂且数据量小,用了也没事。但能用普通条件表达式解决的问题,别为了“炫技”去套 CASE WHEN,得不偿失。

4.3 CASE WHEN 和 IF 怎么选

MySQL 里有 IF 函数,可以写成IF(条件, 真值, 假值)。两者的核心区别在于 CASE WHEN 支持任意多个分支,而且是标准 SQL 语法;IF 只能处理二分支,且是 MySQL 私有的写法。

对比维度CASE WHENIF
分支数量不限制仅二分支
适用场景多级分段、复杂组合条件、聚合函数内部简单的二选一,如性别翻译
SQL 标准标准 SQLMySQL 专用
聚合函数内使用是,标准写法也可以,但可读性略差

我的经验是:单表查询偶尔用 IF 很方便,但涉及报表汇总、需要被别人维护的 SQL,一律写 CASE WHEN。它更通用,以后换数据库或者交接给别人都能直接看懂。

4.4 性能陷阱:表达式和索引的相爱相杀

CASE WHEN 本身不会让查询变慢,但是如果用在索引列上做了转换,就可能让优化器放弃索引。

-- 假设 city 有索引,这种写法会导致索引失效 SELECT * FROM sales_order WHERE CASE WHEN city = '北京' THEN 1 ELSE 0 END = 1; -- 更好的写法 SELECT * FROM sales_order WHERE city = '北京';

第一个查询里,索引是建立在 city 原始值上的,CASE WHEN 把它转换成了 0/1,MySQL 只能全表扫描再逐行算表达式。第二个查询直接命中索引。

但是要注意:性能问题出在 WHERE 过滤,而不是 SELECT 里的 CASE WHEN。在 SELECT 投影列或聚合函数里使用 CASE WHEN,因为结果集本来就要扫描出来计算,额外的表达式开销很小。换句话说,CASE WHEN 放在 SELECT 后面随便用,放在 WHERE 条件列上要警惕。

4.5 CASE WHEN 和 GROUP BY 的配合易错点

场景三里已经提到,GROUP BY 后跟的表达式要和 SELECT 保持一致。这里再补一个实际工作中的教训:如果你改了 SELECT 里的 CASE WHEN 分支,忘了同步改 GROUP BY,MySQL 会直接报错(在 ONLY_FULL_GROUP_BY 模式下)或产生意想不到的分组结果(在宽松模式下)。所以我平时都会把 CASE WHEN 分层标签单独提取成一个视图,或者在 SQL 里用注释标出“分组表达式必须同步修改”,避免两个人维护同一段 SQL 时出问题。

5. 进阶扩展:CASE WHEN 的更多应用

5.1 结合窗口函数做分组排名标记

CASE WHEN 不只能配合聚合函数,还能配合窗口函数做更复杂的分析。比如要标出每个城市金额最高的两笔订单,可以用 ROW_NUMBER 分区后,再用 CASE WHEN 把排名转成标记:

WITH ranked AS ( SELECT order_id, city, order_amount, ROW_NUMBER() OVER (PARTITION BY city ORDER BY order_amount DESC) AS rn FROM sales_order ) SELECT city, order_id, order_amount, CASE WHEN rn <= 2 THEN 'Top2' ELSE 'Other' END AS rank_label FROM ranked ORDER BY city, rn;

执行结果:

cityorder_idorder_amountrank_label
上海101899.00Top2
上海11200.00Top2
上海380.00Other
北京52400.00Top2
北京81500.00Top2
北京2300.00Other
广州7760.00Top2
广州4560.00Top2
广州995.00Other

这种“排名标注”在活动运营里很常用。窗口函数负责生成排名数值,CASE WHEN 负责把连续排名转成离散标签,两个能力拼起来就能做 Top N 圈选、分层运营等场景。

5.2 行转列时固定列数是最好的设计

有人会问:CASE WHEN 行转列时,列数必须写死,如果状态数量不固定怎么办?比如支付状态以后新增一个“部分退款”的状态,SQL 里没写这个条件,那结果就缺一列。

这个问题的答案不是“写动态 SQL”,而是提前和业务方约定好枚举值的范围。行转列报表本来就是面向固定枚举的场景,状态枚举是有限的、明确的。如果枚举真的会频繁变化,那更适合在前端代码里做行转列,而不是让 SQL 去动态拼列。

如果你确实需要用 SQL 动态生成列,MySQL 里可以用GROUP_CONCAT拼出列名,再借助预处理语句执行拼接 SQL。写出来的 SQL 很繁琐,性能也不尽如人意,而且拼字符串注入风险高,非必要不碰。我在上一个项目里就吃过这个亏,后来和业务方确认了枚举稳定,才改成固定 CASE WHEN 的写法,省了很多维护成本。

5.3 在条件统计里用子查询扩展维度

CASE WHEN 还可以和子查询组合,实现跨行跨表的条件判断。比如判断某订单是否超过该用户的平均订单金额:

SELECT order_id, user_id, order_amount, CASE WHEN order_amount > ( SELECT AVG(order_amount) FROM sales_order AS s2 WHERE s2.user_id = s1.user_id ) THEN '高于均值' ELSE '低于均值' END AS amount_vs_user_avg FROM sales_order AS s1;

执行结果:

order_iduser_idorder_amountamount_vs_user_avg
11011200.00高于均值
2102300.00低于均值
310180.00低于均值
4103560.00低于均值
51042400.00低于均值
610530.00低于均值
7106760.00低于均值
81071500.00低于均值
910895.00低于均值
101091899.00高于均值

这类需求在用户行为分析里经常出现:每个人一个均值,订单和均值比大小,同时还要保住明细行的粒度。CASE WHEN 的值可以从子查询计算结果里取,灵活性又上了一个台阶。

6. 写在最后的几个实操习惯

前面每个场景都给出了能直接跑的 SQL,这里再分享几个我在实际项目中养成的习惯,帮你少走弯路。

第一个习惯:CASE WHEN 的分支条件顺序,永远按“最具体到最宽泛”排列。把order_amount >= 1000放在order_amount >= 300前面,把pay_status = 2放在pay_status IS NOT NULL前面。CASE WHEN 是短路求值,一旦命中就不再往下走,顺序错了,数字就错了。

第二个习惯:COUNT 不写 ELSE,SUM 写 ELSE 0。这是我个人默认的规则,能减少很多无谓的 NULL 困扰。COUNT 场景里 NULL 自动忽略,正好满足条件计数;SUM 场景里写 ELSE 0 让语义更明确,也不会影响 AVG 类的直接改写。

第三个习惯:CASE WHEN 的 THEN 返回值类型要保持一致。别在第一个分支返回字符串,第二个分支返回数字,结果会让人困惑,也可能触发隐式转换。例如THEN '高'和THEN 2混用,MySQL 会尝试把字符串转数字或反之,结果不可控。

第四个习惯:写完之后用已知结果反推验证。比如先查SELECT COUNT(*) ... WHERE pay_status = 1拿到数字,再和 CASE WHEN 的汇总结果比对。我在一次做渠道转化报表时,就是靠这个对比发现忘了处理 NULL 城市导致明细数据漏记,当时如果直接看聚合结果,错误很难被发现。

CASE WHEN 不是什么高深语法,但它属于那种“用好了能省一半 SQL”的工具。把本文这几个场景跑一遍,你写汇总报表时会明显感觉下手顺了很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询