A同学前两天在社区里贴了一段SQL,想把订单按金额分档统计,第一版写了两屏的UNION ALL,看得人头疼。我当时的建议很直接:这种条件判断,用MySQL里的CASE WHEN来写,一屏不到就收工。CASE WHEN本质上就是把程序里的if-else搬进SQL,既能生成计算列,也能配合聚合函数做统计、行转列、自定义排序和批量更新。这篇文章我打算把它的两种语法、四类高频场景、边界陷阱和性能影响一次讲透,适合刚接触SQL的新手,也适合已经写了几年业务SQL、想系统梳理这块知识的朋友。
1. 两种语法形态:简单CASE与搜索CASE的边界划分
很多同学上手就写,但并不知道CASE WHEN其实有两种语法形态,它们底层逻辑完全不同。搞混了这个,后面踩坑概率会大很多。
1.1 简单CASE的等值匹配逻辑
简单CASE的写法长这样:
CASE 表达式 WHEN 值 THEN 结果 WHEN 值 THEN 结果 ... ELSE 结果 END它的执行逻辑是:把CASE后面的表达式取出来,依次和每个WHEN后面的值做等值比较,第一个相等的就返回对应的THEN结果。说穿了,等价于拿一个值去匹配多个候选值,类似编程语言里的switch分支。
举一个最常见的状态映射例子。业务表里订单状态存的是数字,报表里要显示中文,直接这样写:
SELECT order_id, order_status, CASE order_status WHEN 1 THEN '待支付' WHEN 2 THEN '已支付' WHEN 3 THEN '已发货' WHEN 4 THEN '已完成' ELSE '未知状态' END AS status_text FROM orders;这段SQL的执行顺序很清楚:每行记录取order_status字段,先和1比,相等就返回"待支付"并结束;不相等再和2比,以此类推。如果4个值都比完了还没命中,就走ELSE返回"未知状态"。
注意,简单CASE只适合等值判断。如果你要比较大小、区间、多个条件组合,它根本写不了,这时候必须用搜索CASE。这是很多新手第一个容易犯迷糊的地方。
1.2 搜索CASE的区间与复合条件
搜索CASE的写法长这样:
CASE WHEN 条件1 THEN 结果1 WHEN 条件2 THEN 结果2 ... ELSE 结果 END它不指定比较对象,而是直接写判断条件,每个WHEN后面可以放任何合法的布尔表达式。执行时从上到下逐条判断,命中第一条就返回结果,后面的分支不再执行。
比如按销售金额打等级:
SELECT employee_name, sales_amount, CASE WHEN sales_amount >= 100000 THEN 'S级' WHEN sales_amount >= 50000 THEN 'A级' WHEN sales_amount >= 20000 THEN 'B级' ELSE 'C级' END AS sales_level FROM sales_records;这段的逻辑是:金额达到10万就是S级,没到10万但到了5万就是A级,以此类推。因为判断是顺序执行的,从大到小写条件就能自然省略下界,不需要每个分支都写>= 50000 AND < 100000这种冗余区间。
但同样的写法,如果你把条件顺序反过来:
CASE WHEN sales_amount >= 20000 THEN 'B级' WHEN sales_amount >= 50000 THEN 'A级' ... END那金额8万的记录会先命中>= 20000,被错误地分到B级。搜索CASE的分支顺序就是逻辑优先级,这个特性在后面第6节的排查部分还会专门展开。
1.3 别忽略ELSE:缺失时结果是NULL
还有一个百分之百会被坑到的点:CASE表达式如果所有WHEN都不满足,又没写ELSE,那么返回结果是NULL,不是空字符串,也不是0。
SELECT order_status, CASE order_status WHEN 1 THEN '待支付' WHEN 2 THEN '已支付' END AS status_text FROM orders;如果order_status是3、4或其他值,status_text这一列就是NULL。报表里一片空,排查半天才发现是漏了ELSE。
更隐蔽的是和聚合函数配合时的问题。看这段:
-- 想统计已支付订单数 SELECT COUNT(CASE WHEN order_status = 2 THEN 1 ELSE 0 END) AS paid_cnt FROM orders;这段SQL的统计结果永远等于订单总数。原因在于COUNT是数"非NULL"行,而你ELSE返回了0,每行都有值,自然全部被数进去。
正确写法应该是:
SELECT COUNT(CASE WHEN order_status = 2 THEN 1 END) AS paid_cnt, COUNT(CASE WHEN order_status = 2 THEN order_id END) AS paid_cnt_v2 FROM orders;让不满足条件的行返回NULL,COUNT自动忽略NULL,这才是"条件计数"的正确姿势。这个区别在实际报表里非常重要,后面还会再遇到。
2. 行转列、条件统计、排序与批量更新:四个高频应用场景
CASE WHEN不会只待在SELECT里做简单的列映射。它真正的威力在于和GROUP BY、ORDER BY、UPDATE组合使用,解决一类"按条件变形"的问题。下面四个场景,是业务开发里出现频率最高的。
2.1 行转列:用MAX聚合剔除空值
先看数据结构。成绩表student_scores是这样的:
| student_id | subject | score |
|---|---|---|
| 1001 | 语文 | 88 |
| 1001 | 数学 | 92 |
| 1001 | 英语 | 79 |
| 1002 | 语文 | 91 |
| 1002 | 数学 | 85 |
| 1002 | 英语 | 88 |
现在要出宽表:每个学生一行,语文、数学、英语各一列。不写存过的同学可能先想到自联结,但CASE WHEN配合GROUP BY两行就够:
SELECT student_id, MAX(CASE WHEN subject = '语文' THEN score END) AS chinese_score, MAX(CASE WHEN subject = '数学' THEN score END) AS math_score, MAX(CASE WHEN subject = '英语' THEN score END) AS english_score FROM student_scores GROUP BY student_id;结果是这样的:
| student_id | chinese_score | math_score | english_score |
|---|---|---|---|
| 1001 | 88 | 92 | 79 |
| 1002 | 91 | 85 | 88 |
很多新手看不懂为什么要包一层MAX。其实逻辑是:按学生分组后,每个学生组内有3行数据。处理chinese_score这列时,只有subject='语文'的那一行CASE返回88,其余两行因为不匹配且没有ELSE,返回NULL。组内3个值分别是88、NULL、NULL,MAX在取最大值时忽略NULL,自然就把88摘出来了。用MIN同样可以,SUM在只有一个非NULL值时也等价,但惯例上大家习惯用MAX,语义也更准确。
2.2 聚合统计中的条件计数与求和
报表里最常见的需求是"一天之内,各种状态的订单各有多少"。不用CASE WHEN,你可能得写好几条查询再在程序里合并。用CASE WHEN则可以一次扫描搞定:
SELECT DATE(created_at) AS order_day, COUNT(*) AS total_cnt, SUM(CASE WHEN order_status = 1 THEN 1 ELSE 0 END) AS pending_cnt, SUM(CASE WHEN order_status = 2 THEN 1 ELSE 0 END) AS paid_cnt, SUM(CASE WHEN order_status = 4 AND refund_flag = 0 THEN 1 ELSE 0 END) AS completed_cnt FROM orders WHERE created_at >= '2024-06-01' GROUP BY DATE(created_at) ORDER BY order_day;这里把SUM(CASE WHEN 条件 THEN 1 ELSE 0 END)当成了一个可以放进聚合公式的"条件开关":满足条件贡献1,不满足贡献0,再整体求和,就是满足条件的行数。第2个条件order_status = 4 AND refund_flag = 0也顺带演示了搜索CASE对复合条件的支持,这里用简单CASE是写不了的。
这种写法最大的价值是只扫一遍表,就把多个指标全部算出来。数据量小的时候感觉不到,上了百万行再对比多次查询+内存合并的方案,差距会非常明显。
2.3 自定义排序与分桶分组
ORDER BY默认按字典序或数值序排列,但业务上经常有自定义的优先级顺序。比如任务表里的优先级是P0、P1、P2,字典序排出来是P0、P1、P2,这碰巧对了;如果优先级是"高、中、低",字典序排出来就是"中、低、高",完全不是你想要的效果。
用CASE WHEN给排序加权重:
SELECT id, priority, title FROM tasks ORDER BY CASE priority WHEN 'P0' THEN 1 WHEN 'P1' THEN 2 WHEN 'P2' THEN 3 ELSE 4 END, id;这样P0永远在最上面,未知优先级排最后,同优先级再按id升序。注意,ORDER BY里的CASE结果是数值权重,也可以直接返回字符串,但数值写法的排序规则最好控制。
分组统计也一样。要对年龄分桶统计人数,CASE WHEN直接在GROUP BY里造桶:
SELECT CASE WHEN age < 18 THEN '未成年' WHEN age BETWEEN 18 AND 30 THEN '18-30' WHEN age BETWEEN 31 AND 50 THEN '31-50' ELSE '50+' END AS age_group, COUNT(*) AS user_cnt FROM users GROUP BY CASE WHEN age < 18 THEN '未成年' WHEN age BETWEEN 18 AND 30 THEN '18-30' WHEN age BETWEEN 31 AND 50 THEN '31-50' ELSE '50+' END ORDER BY age_group;MySQL允许GROUP BY后面直接写别名age_group,很多开源BI工具也支持,但如果你需要考虑跨数据库兼容,或者未来可能迁移到其他数据库,最好把整个CASE表达式原样重复一遍。这个习惯能让SQL的移植性高不少。
2.4 UPDATE批量更新:一条语句搞定分档调价
更新场景里,CASE WHEN的价值是"按行判断、逐行独立"。比如要根据订单金额给订单设置折扣率,传统做法可能是三条UPDATE依次执行,但用一条UPDATE加CASE WHEN更干净,也天然是原子的:
UPDATE orders SET discount = CASE WHEN amount >= 5000 THEN 0.90 WHEN amount >= 2000 THEN 0.95 ELSE 1.00 END WHERE created_at >= '2024-06-01';这条语句会遍历符合WHERE条件的每一行,按各自的amount计算折扣。行与行之间互不影响,不存在"先改的覆盖后改的"这类问题。相比之下,三条UPDATE不仅代码啰嗦,中间任何一条失败都可能留下半更新状态,还得靠事务包裹,成本和风险都更高。
3. 嵌套判断、NULL参与比较与隐式类型转换:三个边界问题
语法和业务场景都顺了之后,真正区分"会写"和"写得好"的,是对边界问题的处理。这三个问题我在代码评审里经常看到。
3.1 嵌套CASE的写法与可读性管理
当业务条件出现"大类下面再分小类"时,CASE可以嵌套。比如会员订单的优惠逻辑:VIP用户和非VIP用户的满减门槛不一样。
SELECT user_id, order_amount, CASE WHEN user_type = 'VIP' THEN CASE WHEN order_amount >= 200 THEN 'VIP满额优惠' ELSE 'VIP普通' END ELSE CASE WHEN order_amount >= 100 THEN '普通满额优惠' ELSE '普通' END END AS order_tier FROM orders;这种写法表达能力很强,但嵌套超过两层之后,缩进和配对就非常考验眼力了。我见过三层嵌套写错END位置导致编译失败,也见过逻辑上漏了分支导致某一类用户拿到错误等级的情况。
我的建议是:嵌套超过两层,果断考虑换方案。可以用维度表或规则表放这些映射关系,然后JOIN。比如建一张payment_rule表,每行一个区间和对应结果,SQL变成一张普通的JOIN查询,规则调整时改表不发布代码,反而更灵活。CASE WHEN不是唯一答案,它是"规则少且稳定"时的最优解,规则一旦膨胀,就该换工具。
3.2 NULL陷阱:等值判断永远匹配不上NULL
这是CASE WHEN里最经典的一个坑。先看错误写法:
-- 期望:status为NULL时显示"无状态" SELECT status, CASE status WHEN NULL THEN '无状态' ELSE '有状态' END AS status_text FROM orders;这条SQL跑出来,所有status为NULL的行,status_text依然是"有状态"。原因很简单:简单CASE底层是用=做比较,而NULL = NULL的结果不是TRUE,而是NULL。在SQL里,NULL是一个"未知"值,跟任何值做等值比较都是NULL,不会命中任何WHEN。所以CASE NULL WHEN NULL永远不成立,只能落到ELSE。
正确写法是搜索CASE,显式用IS NULL判断:
SELECT status, CASE WHEN status IS NULL THEN '无状态' ELSE '有状态' END AS status_text FROM orders;同理,在搜索CASE里写WHEN status = NULL也一样是错的,必须写WHEN status IS NULL。这是SQL语言本身的三值逻辑决定的,和CASE语法无关,但CASE因为写法灵活,最容易在这里翻车。判断非空值也别写WHEN status != NULL,要写WHEN status IS NOT NULL。
3.3 隐式类型转换:字段类型不一致时的意外结果
CASE WHEN里如果比较的两个值类型不一致,MySQL会自动做隐式转换,这个转换经常导致看似正确的SQL产出错误结果。
比如vip_level字段定义的是VARCHAR,里面存的是'1'、'2'这样的字符串,你的CASE这么写:
CASE vip_level WHEN 1 THEN '普通会员' WHEN 2 THEN '白银会员' WHEN 3 THEN '黄金会员' END因为简单CASE拿vip_level去和数值1、2、3比较,MySQL会把字符串转成数值比较,'1'转成1能命中。表面看结果是对的,但如果数据里有'01'、' 1'这类带前导零或空格的值,转换成数值后同样是1,也会错误命中。更麻烦的是,vip_level一旦参与索引列的比较,隐式转换还会让这列上的索引失效,这是第4节要展开的内容。
还有一种情况是多个THEN分支返回不同类型。CASE的结果列会统一成一个类型,MySQL按"最宽"的类型转换。比如一个分支返回字符串,另一个分支返回数字,数字会被转成字符串;反过来,如果字符串是'abc',转换时就可能变成0。这种隐藏的类型合并,在ORDER BY里排序时会让你看到完全不符合直觉的顺序。
4. 性能视角:这些写法会让MySQL走不了索引
前面讲的都是"能不能写对",这一节讲"会不会变慢"。CASE WHEN本身不是一个昂贵操作,它的性能问题几乎都出在"放错了位置"——尤其是放进WHERE条件、GROUP BY表达式和高基数派生列时。
4.1 在WHERE条件里包CASE:索引友好度分析
先看这段SQL:
SELECT * FROM orders WHERE CASE WHEN order_status = 1 THEN amount >= 1000 ELSE amount >= 10 END;从结果上看,它等价于"状态为1的订单看金额是否大于等于1000,其他状态看金额是否大于等于10"。但问题是,MySQL的优化器面对这种任意CASE表达式,几乎不可能把它拆成可用的索引区间。它更常见的做法是老老实实全表扫描,每一行都计算一遍CASE再判断。如果orders表有几百万行,这条查询的耗时就是灾难。
安全的等价改写是用AND/OR组合:
SELECT * FROM orders WHERE (order_status = 1 AND amount >= 1000) OR (order_status <> 1 AND amount >= 10);这样两个分支各自能利用(order_status, amount)联合索引进行范围扫描。注意改写后的NULL语义,如果order_status本身允许为NULL,那么原CASE把NULL状态归入ELSE分支(因为NULL = 1不成立),而改写后的(order_status <> 1 AND ...)对NULL行判断为不满足,两个写法结果会不一致。所以改写时如果字段可空,要显式把NULL情况补进去。
拿任何一条SQL写完都养成EXPLAIN看一眼的习惯,重点关注type列和key列。如果看到ALL全表扫描,而你原本期待走索引,那十有八九是表达式把索引列包住了。
4.2 GROUP BY表达式与临时表内存消耗
GROUP BY CASE WHEN ... END这种按桶分组的写法,是把行归到若干个结果桶里。MySQL的做法是建立临时表做分组统计。桶数量少时没问题,但如果CASE表达式算出来的取值非常"散",比如每个分组结果都有上万种可能,临时表就会膨胀,内存不够还得落盘。
有一种常见误用是为了分组而把多个字段拼进CASE:
-- 不推荐:把user_type和channel拼成高基数桶 SELECT CASE WHEN user_type = 'VIP' AND channel = 'app' THEN CONCAT(user_type, '-', channel) ELSE user_type END AS biz_group, COUNT(*) FROM orders GROUP BY CASE WHEN user_type = 'VIP' AND channel = 'app' THEN CONCAT(user_type, '-', channel) ELSE user_type END;这里表面是"分组",实际上CASE输出值和直接用多个字段GROUP BY几乎一样散,没有任何聚合价值,反而增加了表达式计算成本。如果确实需要多维度分组,直接GROUP BY user_type, channel更清晰,还能走索引避免Sort和临时表。CASE WHEN在GROUP BY里只应该用来"压缩取值数量",用它做"字段拼接"是在帮倒忙。
4.3 派生表与计算列的过滤顺序
还有一个很常见的写法是先在子查询里算出一列带业务含义的字段,外层再对字段过滤:
SELECT id, order_status, order_type FROM ( SELECT id, order_status, CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS order_type FROM orders ) t WHERE t.order_type = '大单';纸上谈兵看没什么问题,但MySQL在处理这种派生表时,通常需要先把内层查询的结果物化成临时表,外层再过滤。本来amount > 1000可以直接在orders表上走索引范围扫描,现在因为包了一层CASE和子查询,优化器很可能放弃索引。
正确做法是过滤条件下沉到内层:
SELECT id, order_status, CASE WHEN amount > 1000 THEN '大单' ELSE '小单' END AS order_type FROM orders WHERE amount > 1000;两个原则可以记住:能用原始列直接过滤的,绝不先套CASE;派生表里的计算列如果只在过滤时用到,几乎总能下沉成WHERE条件。
5. 别迷信CASE WHEN:IF函数与UNION方案的取舍
CASE WHEN很万能,但它不是唯一答案。实际评审代码时,我反而经常建议某些场景别用它。这一节把几个常见替代方案掰开对比。
5.1 IF()与CASE WHEN的适用边界
MySQL里有个更轻量的IF()函数,语法是IF(expr, true_result, false_result)。做简单的二选一映射时,它比CASE WHEN短很多:
SELECT id, IF(order_status = 1, '待支付', '已完成或其他') AS status_text FROM orders;CASE WHEN写同样逻辑会长不少。但是IF()只有三个参数,天然不支持三分支以上。你如果要写IF(条件, ... , IF(条件, ..., IF(...)))这种嵌套,可读性会断崖式下跌,这时候就应该回到CASE WHEN。
我的选型标准很简单:二选一、条件只判断一次,用IF();三分支以上,或者分支里还有复合条件,统一用CASE WHEN。另外IFNULL(col, default)是专门处理NULL值的函数,和条件分支没关系,别混为一谈。
5.2 行转列:UNION ALL方案与单次扫描
新手做行转列时,第一反应经常是UNION ALL:
-- 每个科目一条聚合查询,再纵向合并 SELECT '语文' AS subject, AVG(score) AS avg_score FROM student_scores WHERE subject = '语文' UNION ALL SELECT '数学' AS subject, AVG(score) AS avg_score FROM student_scores WHERE subject = '数学' UNION ALL SELECT '英语' AS subject, AVG(score) AS avg_score FROM student_scores WHERE subject = '英语';这个写法不是不能用,但三个科目就要扫三遍表。如果成绩表有几十万行,且subject上没有索引,消耗会线性放大。更重要的是,结果结构是"长表",每科目一行,想在程序里变成宽表还得自己转一遍。
CASE WHEN方案只需要一次扫描:
SELECT AVG(CASE WHEN subject = '语文' THEN score END) AS chinese_avg, AVG(CASE WHEN subject = '数学' THEN score END) AS math_avg, AVG(CASE WHEN subject = '英语' THEN score END) AS english_avg FROM student_scores;两种方案的核心差别我整理成一张表:
| 对比维度 | UNION ALL方案 | CASE WHEN方案 |
|---|---|---|
| 表扫描次数 | 每个分支一次,N个科目扫N遍 | 一次扫描 |
| 扩展科目成本 | 每加一科新增一段查询 | 增一个分支即可 |
| 返回结构 | 长表,需二次处理 | 直接是宽表 |
| 可读性 | 分支多时冗长 | 一个表达式内集中呈现 |
所以只要结果需要的是"一行多列",CASE WHEN在逻辑和性能上都更优。UNION ALL的长处是纵向追加结果集,语义完全不同,不要用错方向。
5.3 WHERE里的CASE WHEN:大部分可以改写成AND/OR
我把这种写法单拎出来说,因为它是耦合了第4.1节的性能问题,又叠加了可读性问题的高频代码反模式。
-- 原写法:WHERE里套CASE SELECT * FROM orders WHERE CASE WHEN category = 'A' THEN price >= 100 ELSE price >= 10 END;这段的意图是:A类商品查大于等于100的,其他商品查大于等于10的。改成AND/OR:
SELECT * FROM orders WHERE (category = 'A' AND price >= 100) OR ((category <> 'A' OR category IS NULL) AND price >= 10);改写后的SQL清晰得多,而且每个分支都能独立利用索引。写这个例子的重点是最后那行category IS NULL:如果不加,原CASE中NULL类别会走ELSE查询价格大于等于10的,改写后NULL类别两个分支都不成立,结果就不一致了。
业务逻辑能用布尔运算符表达时,WHERE里就不用CASE。CASE在WHERE里真正适用的场景很少,如果确实遇到,我会先质疑需求本身,再质疑优化器能不能处理。一句话:CASE WHEN是"生成列"的工具,不是"过滤行"的首选。
6. 排查CASE WHEN异常的常见路径
最后分享一些实打实的排查经验。CASE WHEN写错了,SQL一般不会报错,它只是安静地返回错误结果。这种"逻辑错但不报错"的问题最难查,有一个系统的排查路径会省很多时间。
6.1 结果整列为NULL:先查ELSE与数据覆盖
如果查询出来的新列整列都是NULL,第一步永远是检查分支是否覆盖了所有值,以及是不是漏了ELSE。我见过最冤的一次排查,是字段里带着看不见的尾随空格,WHEN '待支付'匹配不上实际存的'待支付 '。
排查时可以先用一条DISTINCT查询把真实值拉出来:
SELECT DISTINCT order_status FROM orders;如果发现值和你CASE里写的不一致,那就是数据质量或者隐式转换的问题。如果值本身没问题,再把CASE表达式单独放到SELECT里逐行看结果,不要直接放进聚合函数里。聚合函数会掩盖中间过程,单独看才看得清。
6.2 分支顺序颠倒导致错误分类
搜索CASE是顺序执行的,先命中的分支先返回。最常见的分支顺序错误是"条件叠罗汉":
-- 错误:金额大于等于100的单子会先命中第一支,永远不会走到第二支 CASE WHEN amount >= 50 THEN '中单' WHEN amount >= 100 THEN '大单' ELSE '小单' END因为amount >= 100必然也满足amount >= 50,在第一个分支就被拦下了。修正有两种思路,一种是从宽到窄排:
CASE WHEN amount >= 100 THEN '大单' WHEN amount >= 50 THEN '中单' ELSE '小单' END另一种是每个分支都写完整区间,消除顺序依赖:
CASE WHEN amount >= 50 AND amount < 100 THEN '中单' WHEN amount >= 100 THEN '大单' ELSE '小单' END第二种写法更啰嗦,但在规则频繁调整时不容易被后续维护的人改坏。判断标准很简单:如果你的CASE分支条件之间有重叠,顺序就是逻辑的一部分,排错时优先检查分支的先后。
6.3 一个建议:先把CASE表达式单独SELECT出来验证
这是我自己多年养成的习惯。凡是CASE WHEN参与UPDATE或复杂聚合前,先用一条SELECT把它算出来,配几条已知答案的样本数据核对。
比如要写UPDATE前,先跑:
SELECT order_id, amount, CASE WHEN amount >= 5000 THEN 0.90 WHEN amount >= 2000 THEN 0.95 ELSE 1.00 END AS new_discount FROM orders WHERE order_id IN (100001, 100002, 100003);看到三行结果符合预期,再把它平移到UPDATE里执行。这比直接在UPDATE上"一次到位"安全得多——UPDATE一旦跑了,错了还得再拿一条反向UPDATE修复,成本和风险完全不在一个量级。
格式上我还有一个习惯:分支和结果严格对齐,每个WHEN一行,THEN的结果同列缩进,ELSE放在最后。CASE表达式长了以后,缩进混乱的代码错误率远高于格式规整的代码。格式不是审美问题,是排查效率问题。
另外,条件分支里能加ELSE尽量加。哪怕你觉得"这个分支永远不会走到",写一个ELSE 'UNKNOWN'当哨兵值,等哪天数据里混入了预料之外的值,你能立刻看到一片"UNKNOWN",而不是安静的错误分类。排查的成本从几小时降到几分钟,这个习惯稳赚不亏。