ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL CASE WHEN完全指南:从基础语法到高级实战场景

SQL CASE WHEN完全指南:从基础语法到高级实战场景 一直觉得SQL里最容易被低估的语法就是CASE WHEN它长得不起眼不像JOIN那样撑起复杂查询的骨架也不像窗口函数那样自带高级感但它几乎是所有“把数据库里的原始数据变成业务结论”的查询里绕不开的一环。初学的时候以为它只是if-else的SQL版用来算个分类字段用久了才发现很多看起来复杂得不得了的需求本质上都是在用CASE WHEN变着法子组合。这篇文章就把CASE WHEN从语法细节到实战场景完整梳理一遍重点讲清楚“什么时候用它替代程序代码里的if判断”“什么时候必须靠它才能写出高效SQL”以及几个这些年踩过的坑。不管是刚接触MySQL的新手还是写了好几年SQL想系统补一补的老手应该都能从里面捞到点有用的东西。1. 先搞清楚CASE WHEN到底是干什么的1.1 从一段最朴素的用法说起很多人的第一个CASE WHEN长这样SELECT name, salary, CASE WHEN salary 5000 THEN 低薪 WHEN salary 10000 THEN 中等 ELSE 高薪 END AS salary_level FROM employee;这确实解决了“按条件生成新字段”的需求。但如果你只是把它当成三元运算符的替代品那真有点浪费了。CASE WHEN本质上是一个表达式不是一条语句这意味着它可以用在SELECT列表、WHERE子句、ORDER BY子句、GROUP BY的聚合逻辑里甚至可以直接参与运算。我之前带过一个小组新同事写需求时遇到“不同城市不同折扣率”的业务规则第一反应是先把所有订单查出来然后在Java里写if-else判断。那当然也没错但数据量一上来网络传输的开销、程序里循环判断的开销全出来了。实际上一条SQL用CASE WHEN就能在数据库端把折扣字段算好查出结果直接就是成品数据。能用数据库算完的就别把数据拖到应用层再去折腾。1.2 两种写法简单CASE表达式 vs 搜索CASE表达式CASE WHEN存在两种语法形式很多人混着用不太区分它们的适用边界。简单CASE表达式写法是CASE column_name WHEN value1 THEN result1 WHEN value2 THEN result2 ELSE result END注意这里CASE后面直接跟的是字段名WHEN后面跟的是值MySQL会拿字段值和这些值一个一个比较走的是等值匹配。比如CASE status WHEN 1 THEN 待支付 WHEN 2 THEN 已支付 END就等价于比较status 1、status 2。搜索CASE表达式写法是CASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ELSE result ENDWHEN后面跟的是完整的条件表达式可以是大于小于、LIKE、IN、IS NULL这些判断灵活性高得多。业务系统里九成以上场景其实都适合用搜索CASE表达式因为现实中很少出现那种“只需要严格按某字段的固定值做映射”的需求总是会有区间判断、模糊匹配甚至多字段组合条件。这两种写法在MySQL内部最终的执行逻辑是一样的都属于“顺序匹配、命中即返回”但简单CASE只能做等值比较搜索CASE什么都能做。我的习惯是只要逻辑稍微复杂一点哪怕是一个区间判断直接上搜索CASE表达式免得写到一半发现简单写法兜不住。1.3 执行顺序的隐含逻辑从上到下命中即停CASE WHEN的条件判断顺序是从上往下的一旦某个WHEN条件成立后面的分支就不会再继续判断了。这个特性看起来不起眼但实际写SQL时影响很大。比如上面那个工资分级的例子顺序是 salary 5000 在前、salary 10000 在后这条逻辑没问题。但如果顺序写反了把 salary 10000 写在前面那所有薪资低于10000的人都进了“中等”后面的“低薪”分支永远没有机会执行等于白写。这就是典型的“条件顺序即优先级”问题。尤其在处理状态流转、优先级判断这类场景时多个条件本来就是有先后语义的写反了不会报错但结果就是错的。而且这种错极其隐蔽查数的时候不仔细看根本不发现。另外还有一个性能层面的考虑。既然命中即返回那出现概率最高的条件应该尽量往前放。虽然CASE WHEN对单条记录的判断成本微乎其微但放在几百万行的表上做全表扫描时多一次比较就是多一次CPU开销这部分虽然不至于造成瓶颈但属于积少成多的优化细节。2. 实战中最常见的几类CASE WHEN使用场景2.1 行转列把一列的值拆成多列展示这是CASE WHEN最经典的用途之一也是很多人第一次感受到它威力的地方。假设有一个学生成绩表结构很简单CREATE TABLE score ( student_name VARCHAR(50), subject VARCHAR(20), score INT );数据大概是语文、数学、英语各占一行现在要输出一个“每个学生一行语文/数学/英语各占一列”的宽表就需要行转列SELECT student_name, MAX(CASE WHEN subject 语文 THEN score ELSE 0 END) AS chinese, MAX(CASE WHEN subject 数学 THEN score ELSE 0 END) AS math, MAX(CASE WHEN subject 英语 THEN score ELSE 0 END) AS english FROM score GROUP BY student_name;这个写法的核心逻辑分两层第一层CASE WHEN负责把目标科目的分数“拎出来”非目标科目置为0或NULL第二层用聚合函数把同一个学生的多行结果压成一行。因为分组后每个学生只保留一行MAX取到的就是对应科目的分数。要把这个思路真正吃透关键是理解CASE WHEN负责值映射、聚合函数负责行压缩这两步是分开的。很多人初学行转列觉得懵原因就是把两步揉在一起看越看越晕。实际拆开来看先执行内层SELECT可以看到每个学生每个科目生成一行语文行里有语文分数、数学为0数学行则相反然后GROUP BY一压MAX一取宽表就出来了。如果是MySQL 8.0以上这种场景也可以考虑用窗口函数配合条件聚合但核心仍然是CASE WHEN做映射这个逻辑定死了。2.2 自定义排序ORDER BY里的CASE WHEN订单列表常见的状态有待支付、已支付、已发货、已完成、已取消。产品经理通常希望列表按业务状态优先级排序处理中的放前面已取消的沉底。直接ORDER BY status的话排序按数字大小走不符合业务预期。这时CASE WHEN就派上用场了SELECT id, status, create_time FROM orders ORDER BY CASE status WHEN 1 THEN 0 -- 待支付排最前 WHEN 2 THEN 1 -- 已支付 WHEN 3 THEN 2 -- 已发货 WHEN 4 THEN 3 -- 已完成 WHEN 5 THEN 4 -- 已取消沉底 ELSE 99 END, create_time DESC;这种玩法本质上是在ORDER BY子句里用表达式生成一个新的排序键原字段的值是什么不重要重要的是映射出来的那个数字给了谁。把业务优先级翻译成一组连续整数执行计划里ORDER BY按这个表达式的结果排效果跟按一列排序是完全一样的。有个小tips如果这个自定义排序经常要用而且表数据量大可以考虑把这个CASE映射做成一个持久化字段比如status_sort建索引时带上查询直接ORDER BY status_sort性能比每次都算表达式好很多。不过这是后续优化的事灵活性和性能往往要做一个权衡。2.3 分组统计里的条件计数统计报表里有一类需求在同一个查询里对某个维度下的不同条件分别计数。比如统计每个销售渠道的订单量、支付订单量、退款订单量SELECT channel, COUNT(*) AS total_orders, SUM(CASE WHEN status PAID THEN 1 ELSE 0 END) AS paid_orders, SUM(CASE WHEN is_refunded 1 THEN 1 ELSE 0 END) AS refunded_orders FROM orders GROUP BY channel;SUM一个值为1或0的CASE WHEN表达式效果等同于“满足条件的行数”比“先在WHERE里筛一遍再COUNT再UNION”高效得多。这种写法还有个额外优势一次扫描出多个统计口径不用为每个指标跑一遍全表或一遍索引。同样逻辑也可以写成COUNT(CASE WHEN condition THEN 1 END)注意这里ELSE可以省略因为COUNT忽略NULL不满足条件的行返回NULL自然不计入。用SUM还是COUNT本质一样看个人习惯。我更喜欢SUM(IF(condition, 1, 0))这种风格直观清晰。这种多口径统计的应用场景很广按渠道统计不同支付方式的订单量、按商品统计不同来源的流量、按用户统计不同行为类型的次数本质上都是一个模子。2.4 数据清洗与字段映射把编码翻译成可读文案业务数据库里存的一般都是数字状态码比如订单状态1、2、3用户类型1、2、3前端展示时需要在应用层翻译成文案。但有些场合比如直接导出报表、做数据同步、生成临时分析表应用层没有翻译逻辑就只能在SQL里兜底做转换。SELECT user_id, user_type, CASE user_type WHEN 1 THEN 普通用户 WHEN 2 THEN VIP用户 WHEN 3 THEN 企业用户 ELSE 未知类型 END AS user_type_desc, register_time FROM users WHERE create_date CURRENT_DATE;ELSE这个分支值得多说一句。如果不加ELSE那所有没匹配上的记录这个字段的值就是NULL。有时候NULL是符合预期的比如某些字段“不知道就是不知道”。但如果业务上希望“未匹配的都归为其他”就必须显式加ELSE来兜底。我在数据清洗时习惯性加ELSE因为NULL值在后续数据处理链条里经常引发诡异问题计SUM变NULL、JOIN匹配不上、程序里NPE任何一个都够让人头疼两小时。3. 进阶用法CASE WHEN与聚合、窗口和存储过程的组合3.1 多条CASE WHEN生成多列后再聚合上面讲了SUM CASE WHEN做单指标统计再来一个更复杂的在同一个GROUP BY里同时生成多个维度的统计列而且是不同维度交叉的那种。比如统计“每个区域、每个时间段上午/下午/晚间的订单量”SELECT region, SUM(CASE WHEN HOUR(create_time) BETWEEN 6 AND 11 THEN 1 ELSE 0 END) AS morning_orders, SUM(CASE WHEN HOUR(create_time) BETWEEN 12 AND 17 THEN 1 ELSE 0 END) AS afternoon_orders, SUM(CASE WHEN HOUR(create_time) BETWEEN 18 AND 23 THEN 1 ELSE 0 END) AS evening_orders, COUNT(*) AS total FROM orders WHERE create_time DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY region;这种“一条SQL出整个报表”的能力在生产环境太常用了。报表系统如果逐项去查十多个指标就是十多次查询、十多次网络往返索引和缓冲池的利用率也低。用CASE WHEN把所有指标压缩进一次查询数据库只要扫一遍匹配范围内的数据然后在内存里做聚合效率和直观性都上了个层次。需要注意HOUR(create_time)这种写法会导致create_time上的索引失效因为对字段做了函数运算。如果表很大建议要么换范围条件用原生比较比如create_time 2025-01-01 06:00:00 AND create_time 2025-01-01 12:00:00要么在表和查询之间做个折中接受扫描成本把月份窗口收窄。3.2 在窗口函数里配合CASE WHEN做分段分析MySQL 8.0开始有窗口函数CASE WHEN和窗口函数组合起来能做很多从前不敢想的事——比如在分组内部做“条件排名”。举一个具体场景查每个部门里“高绩效员工”的薪资排名。高绩效先要定义一个规则比如绩效评级是A或者绩效分数大于90。CASE WHEN先生成一个标记字段标记谁属于高绩效然后再用RANK()在这些标记过的员工内排名SELECT department, emp_name, salary, RANK() OVER ( PARTITION BY department ORDER BY CASE WHEN performance_grade A THEN salary ELSE 0 END DESC ) AS high_perf_salary_rank FROM employee;这里ORDER BY后面放CASE WHEN表达式的意思是只有绩效A的员工按薪资参与排名其他员工这一项都是0天然排在最后面。注意窗口函数里的排序键是在分区内计算的CASE WHEN每行都会算一次但这部分开销在常规数据量下可以忽略。窗口函数的强大之处在于它能在不缩减行数的前提下做计算而CASE WHEN正好负责描述“计算要作用在哪些行身上”或者“排序的权值怎么定”。两者组合后几乎可以覆盖像“每个类目下卖得最好的打折商品”“每个客户最近一笔非退款订单”这类分析需求。3.3 在存储过程中配合流程控制MySQL的存储过程里有专门的IF-THEN-ELSE流程控制语句但CASE WHEN同样能出现在存储过程里用来做“基于查询结果的值映射”。比如写一个简单的存储过程传入门店ID返回该门店的业绩评级DELIMITER $$ CREATE PROCEDURE get_store_level(IN store_id INT, OUT store_level VARCHAR(20)) BEGIN DECLARE total_sales DECIMAL(10,2); SELECT SUM(amount) INTO total_sales FROM orders WHERE store_id store_id AND pay_status 1; SET store_level CASE WHEN total_sales 100000 THEN S级 WHEN total_sales 50000 THEN A级 WHEN total_sales 10000 THEN B级 ELSE C级 END; END$$ DELIMITER ;注意这里CASE WHEN是被当作“一个返回值的表达式”放在SET语句里的它和IF语句最大的区别是CASE WHEN是表达式返回一个值IF是语句控制一段流程。存储过程里两种都能用但赋值场景下用CASE WHEN明显更简洁不需要写一堆THEN/END IF。还有个容易踩的坑如果存储过程中的查询没有匹配任何行SELECT INTO的结果是NULL而NULL和数字比较的结果全是NULL所以total_sales 100000这个判断会整体变成NULLCASE WHEN会一路落到ELSE返回C级。这有时候不是业务想要的结果。稳妥做法是先用IFNULL把空值兜住再进CASE WHEN。4. 几个容易踩的坑与执行性能问题4.1 条件分支顺序错误导致逻辑永远走不到前面提过CASE WHEN是顺序匹配、命中即停。逻辑分支顺序不对结果不会报错只会悄悄错。一个典型的翻车案例是判断成绩等级的SQLSELECT score, CASE WHEN score 60 THEN 及格 WHEN score 80 THEN 优秀 WHEN score 90 THEN 非常优秀 ELSE 不及格 END AS grade FROM exam_result;这个SQL里score95的记录会命中“score 60”的条件返回“及格”后面的“非常优秀”分支根本不会执行。写SQL的人一眼就能看出问题但真实交付的SQL里这种低级错误还真不少见尤其是条件比较多的复杂逻辑写着写着就忘了几条条件之间的包含关系。判断包含顺序通用的原则是先写范围小的条件再写范围大的条件或者反过来从小往大写也行但必须全程保持一个方向。比如等级判断应该是先判断优秀和非常优秀再落到合格最后ELSE兜底。养成“条件顺序和逻辑重叠度同步检查”的习惯能省掉不少核对时间。4.2 NULL参与CASE判断时的坑SQL里NULL和任何值比较结果都是NULL而不是TRUE或FALSE。这个特性会让CASE WHEN出现“意料之外但逻辑正确”的行为。比如想根据邮箱判断用户类型CASE WHEN email LIKE %company.com THEN 内部用户 WHEN email IS NULL THEN 无邮箱用户 ELSE 外部用户 END如果email为NULL第一个条件是NULL因为NULL判断的结果不是TRUE所以不会命中第二个条件写了IS NULL所以能正确走进去。开起来没问题。但如果第二行没写NULL记录的最终结果会掉到ELSE变成“外部用户”那就是业务错了。所以处理NULL的原则只有一条想匹配NULL就显式写IS NULL不要指望等值判断或者LIKE能兜住。还有个细节CASE WHEN的WHEN条件里如果写了NULL NULL这种比较结果永远是NULL条件永远不会成立这是新手最容易写出来的bug。4.3 CASE WHEN在WHERE条件里的索引问题CASE WHEN出现在SELECT字段列表里时不影响索引的使用因为这只是对每行结果做一个计算。但如果出现在WHERE子句里情况就复杂了。比如SELECT * FROM orders WHERE CASE WHEN pay_type 1 THEN amount 100 ELSE amount 500 END;这种写法数据库无法对这个表达式建立索引匹配比如amount上建了索引也白搭因为每行都要先计算CASE WHEN才能决定比较逻辑最终基本就是全表扫描。更麻烦的是这种写法可读性也差同等的逻辑完全可以用括号改写SELECT * FROM orders WHERE (pay_type 1 AND amount 100) OR (pay_type ! 1 AND amount 500);改写后的SQL只要保证OR分支各自能用索引执行计划大概率会走索引或动态选择更好的计划。MySQL 8.0的优化器对OR条件的处理比旧版本强了不少但仍建议先跑EXPLAIN确认一下。4.4 大量分支时考虑用字典表代替长CASE如果某个状态字段需要映射的文案有几十种CASE WHEN会变成一个长得吓人的“面条代码”。这时候用JOIN关联一张字典表更合适-- 字典表 CREATE TABLE dict_order_status ( status INT PRIMARY KEY, desc_text VARCHAR(50) ); SELECT o.id, o.status, d.desc_text FROM orders o LEFT JOIN dict_order_status d ON o.status d.status;字典表的好处是业务文案维护在数据里改描述不用改SQL多个系统天然共享。坏处是多一次JOIN开销。但如果状态枚举确实很多、且改动频繁数据量也不至于让JOIN成瓶颈的话字典表明显更优。这个选择跟CASE WHEN本身不冲突但要心里有数CASE WHEN适合分支少、逻辑嵌套深、跟其他条件交集多的场景分支多、只是做纯查询的编码翻译交给字典表。5. 一个完整的综合案例从需求到SQL5.1 业务需求拆解拿一个宠物店的订单分析需求来练手。表结构如下CREATE TABLE pet_orders ( id INT PRIMARY KEY AUTO_INCREMENT, store_city VARCHAR(20), pet_type VARCHAR(20), order_amount DECIMAL(10,2), order_status INT COMMENT 1待支付 2已支付 3已发货 4已完成 5已取消, create_time DATETIME );需求按城市统计总订单量、支付订单量、宠物食品销售额、宠物用品销售额。按宠物类型统计平均客单价和最高客单价。按城市给出“高价订单占比”排名高价定义为订单金额500。5.2 SQL实现与逐段解析第一问SELECT store_city, COUNT(*) AS total_orders, SUM(CASE WHEN order_status IN (2,3,4) THEN 1 ELSE 0 END) AS paid_orders, SUM(CASE WHEN pet_type 食品 THEN order_amount ELSE 0 END) AS food_sales, SUM(CASE WHEN pet_type 用品 THEN order_amount ELSE 0 END) AS supply_sales FROM pet_orders GROUP BY store_city;第二问SELECT pet_type, ROUND(AVG(order_amount), 2) AS avg_amount, MAX(order_amount) AS max_amount FROM pet_orders WHERE order_status IN (2,3,4) GROUP BY pet_type;第三问SELECT store_city, ROUND( SUM(CASE WHEN order_amount 500 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2 ) AS high_amount_ratio_percent FROM pet_orders WHERE order_status IN (2,3,4) GROUP BY store_city ORDER BY high_amount_ratio_percent DESC;这个案例里CASE WHEN出现的密度很高但每个位置承担的职责不同第一问里的CASE WHEN一是“条件计数”二是“按类型拆分销售额再交给SUM聚合”第三问里的CASE WHEN是“生成占比指标的分母”。同样一个语法在一条SQL里可以扮演三种角色这其实是CASE WHEN最值得琢磨的地方——它不负责连接表、不负责过滤行它就是纯粹地“把每行的值改造成后续算子需要的样子”。5.3 拆解的通用方法论如果你遇到一个复杂的统计需求不知道怎么用CASE WHEN组织SQL试试这个三层拆解法第一层确定分组维度也就是GROUP BY后面的字段。维度是要展示的“行”是什么。第二层确定统计口径也就是需要哪些指标。每个指标要么是COUNT计数要么是SUM求值要么是AVG平均而这些指标的筛选条件就是CASE WHEN里WHEN的条件。第三层确定是否需要行转列或对比展示。如果多个指标需要并列成不同列展示就用多个CASE WHEN生成不同列。这个方法我从实际经验里总结出来处理90%以上的报表查询都够用。思路清楚以后写SQL就不再是“想起一个函数试一个”而是先搭框架再填条件效率高不少。6. 经验沉淀什么时候用CASE WHEN什么时候别用6.1 适合用CASE WHEN的信号需要在查询结果里生成一个“按当前行数据计算出来的附加字段”需要把一列的多个枚举值翻译成另一组枚举或数值需要对不同分组口径分别统计且这些口径定义是对同一份基础数据做不同条件筛选需要实现自定义排序规则但不想大动表结构增加排序列。6.2 不适合用CASE WHEN的信号分支数量特别多比如超过15个明显是在硬编码一份本来应该存在字典表里的映射关系CASE WHEN被套在WHERE条件里且无法改写让索引大量失效程序代码里已经有现成的枚举翻译逻辑SQL里再写一份会形成双份维护负担加大两边不一致的风险。6.3 给维护同事留条活路SQL代码和Java代码一样写的时候爽维护的人想骂人。CASE WHEN写多了以后代码会变得特别长格式不好好排的话隔三个月再看自己写的都费劲。我的习惯是每个WHEN单独一行条件对齐多层逻辑嵌套时加上括号并在注释里说明每个分支的业务含义分支较多的CASE WHEN放SELECT列表时尽量把业务含义映射做成注释或者视图。比如SELECT id, order_status, CASE WHEN order_status 1 THEN 待支付 WHEN order_status 2 THEN 已支付 WHEN order_status 3 THEN 已发货 WHEN order_status 4 THEN 已完成 WHEN order_status 5 THEN 已取消 ELSE 未知 END AS status_text FROM orders;这种一眼能看懂的CASE WHEN维护成本远远低于那种动辄30行、条件互相嵌套、没有注释的“豪华版”。在真实项目里这种可读性带来的收益往往比那点性能优化更重要。6.4 我对CASE WHEN的长期看法CASE WHEN最让我觉得舒服的一点是它把“条件判断”变成了“数据计算”。在SQL里CASE WHEN不再是一个流程控制的概念而是一个纯函数式的值转换器。输入一行数据输出一个值不依赖上下文、没有副作用这在写复杂报表时非常省心你不需要去记“这个变量现在等于多少”只需要关注每一行会变成什么值剩下的聚合逻辑交给数据库。这也是为什么在面试时如果让我出一道SQL题我最喜欢出的就是围绕CASE WHEN的行转列和条件聚合因为它真能筛出一个人对SQL的理解深度——是只会背语法还是能把语法当成积木一样组合出想要的结果。如果你能顺着这篇文章的思路把CASE WHEN从“会用”变成“用得灵活”那我相信绝大多数业务分析类的SQL需求都难不倒你了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进