1. 先把场景摆出来:CASE WHEN 到底在解决什么问题
我在数据岗上待的这些年,写得最多的一句 SQL 不是SELECT *,也不是各种 JOIN,而是SQL CASE WHEN。它不是什么高深语法,甚至有点笨——无非就是 if-else 换个写法。但正因为笨,它才通用。几乎所有关系型数据库都支持它,几乎所有的取数需求到最后都能用它兜住。它的本质是在行级别上做一次条件求值,然后返回一个值,而这个值可以出现在 SELECT 里、GROUP BY 里、ORDER BY 里,甚至 UPDATE 的赋值语句里。
换句话说,CASE WHEN 是把"业务规则"翻译成"数据字段"的那层胶水。订单金额要不要分档、用户是不是高价值、状态该不该算作有效、这一行该归到哪个渠道——这些判断原本躺在产品文档和运营脑子里,用了 CASE WHEN,它们就变成了表里一个一个能聚合、能画图、能导出的字段。
这篇内容适合三类人:刚学会 SELECT 和 WHERE、正在被"这份报表要怎么分类"卡住的新人;写了半年 SQL、总觉得 CASE 写得很长很乱、想找重构思路的中级取数人;以及需要把业务规则沉淀成数据模型、关心性能和可维护性的分析师。我会从场景拆到语法细节,再到打标签、行转列、条件聚合这几个高频实操,最后聊性能坑和排查表。所有代码都是通用写法,你换任何主流数据库基本都能跑。
2. 从需求到写法:CASE WHEN 的选型与思路拆解
2.1 一次典型的取数需求,为什么最后落到 CASE WHEN
举个我经常遇到的场景。某公司后台有一张订单表,运营要一份"按金额分档的订单分布",分档规则是:0 到 99 元算小额,100 到 499 元算中额,500 到 1999 元算大额,2000 元及以上算超大额,另外还有一类是"退款单"要单独拎出来。
新人第一反应往往是导出来丢进表格软件里手动分类,或者写四条 SQL 分别WHERE amount BETWEEN 0 AND 99、WHERE amount BETWEEN 100 AND 499各查一遍再拼起来。这个做法在小数据量下能用,但一旦要按天、按渠道、按城市交叉,四个查询会迅速膨胀成几十个查询,而且每次改分档规则都要重写全部 SQL。
用 CASE WHEN,这件事就变成一句话:在查询里造一个新列,每一行数据经过一次判断,得到自己所属的档位。之后你要按档位分组、算占比、做同比,全都在这一个新列上操作。业务规则只写一遍,改动也只改一处。
这就是我想强调的第一个思路:CASE WHEN 的价值不在于"能判断",而在于把判断结果物化成可复用的维度。你写的是表达式,产出的是一列数据,后续所有聚合、筛选、排序都站在这一列上做文章。想清楚这一点,很多"这个需求该不该用 CASE"的纠结就消失了——只要你的需求里出现了"分类""归属""标记""是否"这类词,CASE 大概率就是答案。
2.2 三种高频用途,决定了你该怎么组织 SQL
我把 CASE WHEN 的用法归成三类,每类的写法重心完全不一样,混着用就容易乱。
第一类是打标签:给每一行输出一个离散的类别。这类 CASE 通常放在 SELECT 列表里,产出的是一个文本或枚举列,比如用户等级、订单档位、风险标签。它的关键点是分档边界要严丝合缝,不能出现某一行的金额同时满足两个条件,也不能出现"漏网"的金额。
第二类是条件聚合:配合 SUM、COUNT、AVG 使用,把"满足条件的行"筛出来做计算,本质上是在聚合函数内部做一次过滤。这类 CASE 的重点是 ELSE 到底写 0 还是不写,两者语义完全不同,后面会细讲。
第三类是控制行为:不产出新列,而是影响 SQL 的执行顺序或赋值结果,典型的就是ORDER BY里自定义排序、UPDATE里批量修正字段值。这类 CASE 最容易被忽略,但在做数据修复和报表排序时特别好用。
我的经验是,一个复杂查询里如果这三类混在一起,读起来会非常痛苦。比较稳的组织方式是:先用子查询或 CTE 把标签列算出来,外层再做聚合和排序。这样每一层只承担一件事,出问题也好定位。
2.3 简单 CASE 和搜索 CASE,我为什么几乎只用后者
CASE 有两种写法,名字听起来像孪生兄弟,实际差别不小。
-- 简单 CASE:只做等值比较 CASE status WHEN 1 THEN '待付款' WHEN 2 THEN '已付款' WHEN 3 THEN '已发货' ELSE '其他' END -- 搜索 CASE:每个 WHEN 后面是一个完整条件 CASE WHEN status = 1 THEN '待付款' WHEN status = 2 THEN '已付款' WHEN status = 3 THEN '已发货' ELSE '其他' END简单 CASE 看上去更短,但它有个硬伤:表达式只求值一次,然后逐个和 WHEN 的值做等值比较。这意味着它做不了范围判断、做不了多列联合判断,更要命的是处理不了 NULL——因为NULL = NULL的结果是未知而不是真,所以当 status 为 NULL 时,哪怕你写了WHEN NULL THEN ...,也不会命中,最终走 ELSE。
搜索 CASE 就没这些限制,每个 WHEN 都是独立的布尔条件,可以写范围、可以写IS NULL、可以多列组合。唯一的代价是稍微啰嗦一点。我在实际项目里基本只用搜索 CASE,理由很简单:可维护性比字符数重要。半年后你回头看代码,搜索 CASE 的条件一目了然,简单 CASE 还得在脑子里做一次代入。而且团队里如果有人从简单 CASE 改成搜索 CASE 加条件,改动量也更小。
3. 语法骨架与最容易翻车的细节
3.1 骨架本身很简单,难的是求值顺序
CASE 的结构可以概括成一句话:从上往下依次判断,第一个为真的分支立即返回,后面的不再求值。
CASE WHEN 条件1 THEN 结果1 WHEN 条件2 THEN 结果2 ... ELSE 兜底结果 END这种"短路"特性带来两个直接后果,都必须记住。
一是顺序即优先级。如果你先写WHEN amount >= 0 THEN '小额',再写WHEN amount >= 500 THEN '大额',那么所有金额都会在第一条命中,后面的判断形同虚设。我见过不止一次这样的 bug,报表上所有订单都被打成同一档,排查半天才发现是条件顺序写反了。稳妥的写法是从最严格的条件往最宽松的条件排,或者干脆用区间上下界都写清楚的方式。
二是后面的分支不会执行。这一点在条件里带函数调用或子查询时很有意义,短路能省掉不必要的计算。但反过来说,你不能指望"多个 WHEN 都命中"这种事发生,CASE 永远只返回一个值。
3.2 区间边界:开闭区间写错,数据就会重复或漏掉
打标签最容易出错的地方不是语法,是边界。还用刚才的金额分档,很多人会这样写:
CASE WHEN amount BETWEEN 0 AND 100 THEN '小额' WHEN amount BETWEEN 100 AND 500 THEN '中额' WHEN amount BETWEEN 500 AND 2000 THEN '大额' ELSE '超大额' END问题在于BETWEEN是闭区间,100 同时满足第一条和第二条。虽然短路会让它只返回"小额",表面上没错,但一旦有人调整顺序,或者把这个 CASE 复制到别的地方做统计,就会出现同一笔订单在不同报表里归属不同档位的情况。
我的固定写法是左闭右开,用>=和<手工写清楚,看起来啰嗦,但绝对不会重叠:
CASE WHEN amount >= 0 AND amount < 100 THEN '小额' WHEN amount >= 100 AND amount < 500 THEN '中额' WHEN amount >= 500 AND amount < 2000 THEN '大额' WHEN amount >= 2000 THEN '超大额' ELSE '异常值' END注意最后那个 ELSE。很多人觉得金额不可能为负,就省掉了,结果一旦有测试数据、脏数据或者退款产生的负数,这些行会直接返回 NULL,然后在 GROUP BY 里形成一个你看不见但确实存在的 NULL 分组,占比算出来永远差一点点。给 CASE 留一个兜底分支,是数据质量的第一道防线。
3.3 NULL 与类型:两个不报错但会让你算错的地方
NULL 在 CASE 里的行为值得单独说。第一,任何和 NULL 做等值或大小比较的结果都是"未知",不会命中 WHEN,所以判断空值必须写IS NULL或IS NOT NULL,不能写= NULL。第二,如果整条 CASE 没有 ELSE,未命中的行返回 NULL,而 NULL 在聚合里的表现很微妙:COUNT(列)不数 NULL,SUM忽略 NULL,AVG直接把 NULL 从分母里剔除。
最后这点在做"条件平均值"时特别关键。假设你要算"已支付订单的平均金额",写法是AVG(CASE WHEN status = 'paid' THEN amount END),未支付的行返回 NULL 被 AVG 自动跳过,分母正好是已支付订单数,结果正确。但如果你手滑写成ELSE 0,那些未支付的行会以 0 的身份进入分母,平均值瞬间被拉低一大截。该返回 NULL 的地方就不要给 0,这是我在代码评审里最常说的一句话。
类型问题同样隐蔽。CASE 各分支返回的值需要类型兼容,有些数据库会做隐式转换,有些直接报错。当一个分支返回数字、另一个返回字符串时,转换规则可能和你想的不一样;当分支里有整数和小数混用,结果精度也可能被截断。稳妥的做法是让所有 THEN 返回同一种类型,需要展示成文本就统一转成文本,需要计算就统一用数值。
4. 实操:把 CASE WHEN 真正用在业务上
4.1 打标签实战:用户分层与阶梯规则
先看一个完整的打标签例子。假设有一张用户消费汇总表,字段是user_id、total_amount、order_cnt、last_order_days(距上次下单天数),要按 RFM 思路给用户分层。
SELECT user_id, total_amount, order_cnt, CASE WHEN last_order_days IS NULL THEN '从未下单' WHEN last_order_days <= 30 AND total_amount >= 1000 THEN '高价值活跃' WHEN last_order_days <= 30 THEN '普通活跃' WHEN last_order_days <= 90 AND total_amount >= 1000 THEN '高价值沉睡' WHEN last_order_days <= 90 THEN '普通沉睡' ELSE '流失风险' END AS user_level FROM user_summary;这段代码有两个设计取舍值得说。
第一个是IS NULL放在最前面。从未下单的用户,last_order_days是空的,如果不先拦掉,后面的<= 30判断对它永远是未知,最终会掉进 ELSE 被误判成"流失风险"。空值永远优先处理,这是我给所有新人的第一条建议。
第二个是把"高价值"的判断嵌在活跃度判断之内,而不是单开一条。因为业务上"高价值活跃"和"高价值沉睡"是两个不同的人群,运营触达策略完全不同,必须拆开。如果你的规则更复杂,比如还要看品类偏好,那就别硬塞进一条 CASE,拆成多个字段分别打标签,后期组合分析会灵活得多。
这里还有一个实操细节:分档规则最好写进配置表。当分层规则只有五六条时,硬编码 CASE 没问题;一旦超过十条,或者运营每个月都要调阈值,就该建一张level_rule表,存上下界和标签名,然后用 JOIN 或子查询去匹配。代价是 SQL 复杂一点,收益是改规则不用发版。
4.2 行转列的经典写法:一条 SQL 出透视表
CASE WHEN 最出名的用途就是行转列。假设有一张按月记录的销售明细,要变成"每个渠道一行、每个月一列"的宽表。
SELECT channel, SUM(CASE WHEN month = '2024-01' THEN amount ELSE 0 END) AS amt_01, SUM(CASE WHEN month = '2024-02' THEN amount ELSE 0 END) AS amt_02, SUM(CASE WHEN month = '2024-03' THEN amount ELSE 0 END) AS amt_03 FROM sales_detail GROUP BY channel;原理其实很朴素:GROUP BY 把每个渠道的行归成一组,组内每一行都过一遍三个 CASE,只有对应当前月份的那一列会拿到金额,其余列拿到 0,最后 SUM 把它们加总。相当于手动把"行方向的数据"搬运到"列方向"。
这里的 ELSE 0 是必须的,不能省。因为 SUM 会忽略 NULL,如果你的数据里某渠道在某个月完全没有记录,那一列的结果是 NULL 而不是 0,导到报表里会显示成空白,做环比计算时还会传染。给个 0 更稳妥。当然如果你就是想让"无数据"和"零销售"区分开,那就保留 NULL,这是业务语义的选择,不是语法对错。
写这类 SQL 有个小技巧:列名和 CASE 条件要能对上。我习惯用amt_01对应month = '2024-01',人工核对时一眼就能扫出来。如果列名是m1、m2而条件是完整日期,改起来容易错位。
另外要提醒的是,行转列本质上是把列数写死在 SQL 里。月份一多,SQL 会长得没法看,而且加一个月就得改代码。所以这种写法适合列数固定且不多的场景,比如固定几个渠道、固定几个状态。如果是时间序列,更合适的是在 BI 工具里做透视,或者在应用层处理。
4.3 条件聚合:一次扫描算出多个指标
比起行转列,我更常用的其实是条件聚合。它能在一次表扫描里同时算出好几个口径的指标,这在数据量大时非常划算。
SELECT channel, COUNT(*) AS total_cnt, COUNT(CASE WHEN status = 'paid' THEN 1 END) AS paid_cnt, SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_amt, SUM(CASE WHEN status = 'refund' THEN amount ELSE 0 END) AS refund_amt, AVG(CASE WHEN status = 'paid' THEN amount END) AS avg_paid_amt FROM orders GROUP BY channel;注意paid_cnt用的是COUNT(... THEN 1 END),没有 ELSE。因为 COUNT 只数非空值,未支付的行返回 NULL 被跳过,正好数出已支付订单数。如果写成ELSE 0,那些行会以 0 的身份被 COUNT 计入,结果变成总行数,直接算错。而avg_paid_amt同理,不写 ELSE 才能保证分母只有已支付订单。
反过来,paid_amt和refund_amt用的是 SUM 加ELSE 0。SUM 本身会忽略 NULL,所以这里写不写 0 结果数值是一样的,但写 0 能让语义更明确——"这个渠道在退款口径上是 0 元",而不是"没有数据"。如果你要把多个这样的指标做加减运算,0 比 NULL 安全得多,NULL 参与算术会让整个表达式变成 NULL。
这套写法的性能优势来自"只扫一遍表"。如果用四个独立查询分别统计,表要被读四次,在大表上差距很明显。当然,如果某个条件能大幅过滤数据,独立查询配合索引反而更快,这需要看具体执行计划,不能一概而论。
4.4 排序与更新:CASE 的控制类用法
有些场景下你不想多一列,只想改变行为。最典型的是自定义排序。业务上经常要求状态按"待处理 → 处理中 → 已完结 → 已取消"这个业务顺序排,而不是按字母或 ID 排。
SELECT order_id, status FROM orders ORDER BY CASE status WHEN 'pending' THEN 1 WHEN 'processing' THEN 2 WHEN 'done' THEN 3 WHEN 'cancelled' THEN 4 ELSE 99 END, created_at DESC;这里 ELSE 给 99 是个小技巧,把未知状态统一甩到最后,避免它们混在正常业务顺序里干扰阅读。同理,GROUP BY里也可以直接用 CASE 表达式做分组,效果等同于先算出标签再分组,只是少写一层子查询。
UPDATE里的用法更直接,一次批量修正多个值:
UPDATE orders SET risk_flag = CASE WHEN amount > 10000 AND user_age_days < 7 THEN 'high' WHEN amount > 10000 THEN 'medium' ELSE 'low' END WHERE risk_flag IS NULL;这个写法在做数据修复时很好用,但务必先加 WHERE 限制范围,并且先在 SELECT 里跑一遍看结果。我踩过的坑是忘了写 WHERE,全表被重算,虽然逻辑上结果一样,但触发了大量行锁,影响了线上业务。养成习惯:把 UPDATE 的 CASE 先当 SELECT 跑一次,确认每一档的行数和预期一致,再改成 UPDATE。
5. 性能与可维护性:那些年踩过的坑
5.1 CASE 写在 WHERE 里,索引基本就废了
这是最容易被忽视的性能问题。CASE 是表达式,一旦你把它包在 WHERE 的列上,数据库就没法用索引去定位数据了,只能全表扫描逐行求值。
-- 不推荐:对列做了包装,索引很难生效 SELECT * FROM orders WHERE CASE WHEN status = 'paid' THEN amount ELSE 0 END > 100; -- 推荐:把条件展开,让列裸奔 SELECT * FROM orders WHERE status = 'paid' AND amount > 100;后者不仅能用上 status 和 amount 的索引,语义也更清楚。原则就是:能在 WHERE 里用普通条件表达的,不要用 CASE。CASE 更适合出现在 SELECT 列表和聚合函数内部,那里本来就是逐行计算,加个 CASE 不会额外损失什么。
如果逻辑实在复杂,必须要用 CASE 筛选,那也有个折中办法:先在子查询里把标签列算出来,外层再用 WHERE 过滤这个标签列。这样至少内层的原始条件还能走索引,外层过滤的是已经缩小的结果集。
5.2 嵌套太深就该重构了
我见过最夸张的一条 SQL,CASE 里套 CASE,套了五层,一共两百多行。写的人当时觉得逻辑很完整,三个月后他自己都不敢改,因为改一个分支不知道会牵连哪里。
我的经验阈值是:CASE 的分支超过八条,或者嵌套超过两层,就该考虑重构。重构方向有三个。
一是拆成多个字段。一个 CASE 同时判断金额、时间、渠道三件事,那就拆成三个 CASE,每个只判断一件事,各自输出一列。分析时用多个维度组合,比用一个大而全的标签灵活得多。
二是抽成映射表。把条件和结果存成一张配置表,用 JOIN 或关联子查询去匹配。规则变了改数据不改代码,非技术人员也能维护。
三是用 CTE 分层。第一层算原始标签,第二层基于标签再做分组判断,每层逻辑都简单,读起来像流水账,反而清楚。
还有一种情况是用 CASE 做"空值填充",比如CASE WHEN col IS NULL THEN 0 ELSE col END。这种完全可以换成 COALESCE 或 IFNULL,一个函数就搞定,没必要写五行的 CASE。同理,单纯判断两值二选一的场景,很多数据库提供了更短的函数,可读性更好。CASE 应该留给"多分支、有条件逻辑"的地方,别把它当万能胶到处涂。
5.3 方言差异:CASE 是最保险的那一个
各种数据库在函数命名上各有各的想法,日期函数、字符串函数的写法经常对不上。但 CASE 属于 SQL 标准的一部分,主流数据库全都支持,写法也一致。这一点在跨库迁移时价值很大——我做过一次从一种数据库迁到另一种的项目,几百条 SQL 里几乎不用改的就是 CASE 相关的部分,出问题的全是那些方言函数。
不过还是有几个细节需要留意。THEN 分支返回的类型最好显式统一,特别是数字和文本混用时,某些数据库会直接报类型不匹配的错。另外,条件里的字符串比较在某些配置下可能区分大小写,做标签匹配时最好确保写入和判断用的是同一套大小写规范,否则会出现明明有数据却匹配不上的诡异现象。
6. 常见问题速查与排查思路
6.1 问题速查表
下面这些是我和同事实际遇到过、并且反复出现的问题,整理成表方便对照。
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 所有行都返回同一个标签 | WHEN 条件顺序写反,宽松条件在前 | 从严格到宽松重排,或补齐区间上下界 |
| 部分行标签为空 | 没有 ELSE 且条件未命中 | 补 ELSE 兜底,或先用 SELECT 查出未命中行 |
| 某些行同时落入两个档位 | 区间用 BETWEEN 导致重叠 | 改成左闭右开,用>=和< |
| 平均值明显偏低 | 条件聚合误用ELSE 0,把无效行计入分母 | 条件 AVG 去掉 ELSE,让未命中的返回 NULL |
| 计数结果等于总行数 | COUNT(CASE ... ELSE 0 END) | 改成COUNT(CASE ... THEN 1 END) |
| 判断空值不生效 | 写了= NULL或简单 CASE 里写WHEN NULL | 改用IS NULL,或改写成搜索 CASE |
| 查询变慢、扫描行数暴增 | CASE 写在 WHERE 条件里,索引失效 | 展开成普通条件,或先算子查询再过滤 |
| 报类型不匹配错误 | 各 THEN 分支返回类型不一致 | 统一转换类型,文本统一转文本、数值统一转数值 |
| 更新影响面超出预期 | UPDATE 缺少 WHERE 范围限制 | 先以 SELECT 验证行数,再加条件执行 |
6.2 排查顺序:从数据到语法,别一上来就怀疑写法
遇到 CASE 相关的问题,我的排查顺序是固定的,基本能覆盖九成情况。
第一步,把 CASE 单独拎出来跑。去掉 GROUP BY 和聚合,只 SELECT 主键、原始字段和这个 CASE,LIMIT一百行看看。很多时候问题一眼就能看出来,比如全是 NULL,或者全是同一个值。
第二步,数一遍每个分支的行数。把 CASE 包成子查询,外层按标签 GROUP BY 计数。如果某个分支是 0 行,说明条件写错了或者数据里根本没有;如果某个分支数量异常大,通常是前面的条件太宽,把后面的都吃掉了。
第三步,查有没有漏网的行。把所有 WHEN 条件取反,用 AND 连起来做一次筛选,看有没有既不属于任何一档、也没有被 ELSE 接住的行。这一步能揪出负数金额、空时间、异常状态这类脏数据。
第四步,才去看执行计划和性能。如果结果对了但慢,再去看是不是 CASE 放在了 WHERE 里、是不是聚合前没做过滤、是不是同一个 CASE 在查询里重复计算了多次。
提示:做数据修复类的 UPDATE 之前,一定先用同样的 CASE 写一条 SELECT,把主键导出来核对数量。这一步花两分钟,能省掉后面两小时的追责。
7. 我个人的几点体会
写了这么多年,我对 CASE WHEN 的感情挺复杂。它足够简单,简单到任何人学半小时就能用;它又足够危险,危险到一个条件顺序写错就能让整份报表失真,而且不报错,你只能靠交叉验证发现。
如果只能给三条建议,我会说:第一,永远写 ELSE。哪怕你觉得不可能出现其他情况,兜底分支就是你的数据质量报警器,出现"其他"就说明上游有问题。第二,区间一律左闭右开。这个习惯能消灭掉一大半的分类错误,而且换个人接手也不用重新推敲边界。第三,标签超过八条就搬进配置表。硬编码的 CASE 是技术债,早晚要还,早还比晚还便宜。
还有一个小技巧我一直在用:给每个 CASE 起一个和业务语义完全一致的名字,比如user_level、amount_band,不要起flag1、type_new这种。字段名是未来的你唯一能看到的线索,起得清楚,半年后回来改代码能少骂自己几句。
至于那些看起来更"高级"的写法,比如用窗口函数、用 CTE 递归,它们在特定场景下确实更强,但 CASE WHEN 始终是我工具箱里第一个伸手去拿的那把。它不挑数据库、不挑数据量、不挑使用者的水平,能把业务规则稳稳地落成一行一行的数据。这种朴素的能力,用久了才知道有多值钱。