上一章处理高级连接时,我们解决的是“哪些行应该相遇、哪些行必须保留”。连接完成之后,新的问题马上就来了:同一批结果里的行并不总要用同一种方式展示。
小满商店的客服想先看待支付订单,再看已支付订单;运营想把订单状态码翻译成中文;采购想按库存区间显示预警;店长还想在一张报表里同时看到订单总数、成交数和取消数。这些需求都带着同一个句式:如果满足某个条件,就返回一个值;否则,再看下一个条件。
这正是 CASE 表达式擅长的事。它不会替我们增加或删除原始行,而是根据当前行的数据算出一个结果。这个结果可以出现在 SELECT、ORDER BY、聚合函数、UPDATE 等需要表达式的位置。
先看一个最小例子:
SELECT
order_id,
status,
CASE status
WHEN '待支付' THEN '等待顾客付款'
WHEN '已支付' THEN '等待仓库发货'
WHEN '已发货' THEN '正在配送途中'
WHEN '已完成' THEN '交易已经完成'
WHEN '已取消' THEN '订单已经取消'
WHEN '已退款' THEN '款项已经退回'
ELSE '需要人工核对'
END AS status_label
FROM orders
WHERE order_id BETWEEN 101 AND 114
ORDER BY order_id;查询仍然返回七张订单,只是多算出了一列 status_label。这件事看起来简单,但后面讲条件排序、条件聚合和条件更新时,我们用的仍是同一个核心:每一行都从上到下判断,命中第一个成立的分支,然后返回该分支的结果。

本章继续使用“小满商店”在上一章出现过的顾客与订单。为了让后面的结果可以逐行核对,我们把用到的数据集中列出来。
customers 中有十位顾客。陆野还没有下过订单,白露和陆野没有填写手机号,乔木没有填写邮箱:
orders 中保留七张用于讲解的订单:
库存数据包含缺货、低库存、正常库存和已下架商品。stock 是非空列,因此“没盘点”不能靠 NULL 偷偷表达;要先有明确的数据模型才能保存这种状态:
支付表共有十笔成功支付和一笔退款记录。待支付订单 107 还没有支付行,这种“没有相关行”和“有一行但状态失败”也不能混为一谈:
后面的查询只使用课程统一模型中的这些字段:
customers(customer_id, customer_name, phone, email, city, registered_at, referrer_id)
categories(category_id, category_name, parent_id)
products(product_id, category_id, product_name, unit_price, stock, status, created_at)
orders(order_id, customer_id, order_date, status, total_amount)
order_items(order_item_id, order_id, product_id, quantity, unit_price, discount_rate)
payments(payment_id, order_id, paid_at, amount, payment_method, status)
departments(department_id, department_name)
employees(employee_id, department_id, manager_id, employee_name, job_title, salary, hire_date)
inventory_movements(movement_id, product_id, employee_id, movement_type, quantity, moved_at, note)这样做有一个好处:你不用在每个案例里重新猜表结构,可以把注意力留给条件本身。
许多编程语言用 if...else 控制接下来执行哪一段语句。SQL 查询里的 CASE 稍有不同:它首先是一个表达式,也就是“计算后得到一个值的结构”。
因此,下面这些位置都能接收 CASE 的结果:
SELECT 列表:算出标签、金额或标记;ORDER BY:算出排序优先级;SUM、COUNT 等函数的参数:决定哪些行参与统计;UPDATE ... SET:决定每一行的新值;WHERE 或 HAVING:虽然可以使用,但直接写布尔条件通常更清楚。它不能像存储过程中的流程语句那样,在一个分支里连续执行多条 UPDATE 或 INSERT。本章讲的是查询中以 END 结束的 CASE 表达式,不是以 END CASE 结束、只在特定存储程序语境中使用的 CASE 语句。
判断一个写法是不是 CASE 表达式,可以看它最终能不能替换成一个普通值。CASE ... END AS status_label 最终得到一列文本,所以它是表达式;“命中分支后再执行三条 SQL”已经超出一个表达式能承担的工作。
搜索型 CASE 的骨架如下:
CASE
WHEN condition_1 THEN result_1
WHEN condition_2 THEN result_2
ELSE default_result
END每个 WHEN 后面都可以放一个条件。范围比较、IS NULL、IN、EXISTS 以及用 AND、OR 组成的复合条件,都可以写在这里。
例如,采购同事想给商品库存打标签。库存未盘点、已经缺货、库存偏低和库存充足是四种不同状态:
SELECT
product_id,
product_name,
stock,
CASE
WHEN status = '下架' THEN '已经下架'
WHEN stock = 0 THEN '已缺货'
WHEN stock <= 10 THEN '库存偏低'
ELSE '库存充足'
END AS stock_label
FROM products
WHERE product_id BETWEEN 1 AND 12
ORDER BY product_id;这里必须用搜索型 CASE,因为我们判断的不只是某个值是否相等,还包含空值检查与数值区间。
当所有分支都在检查同一列“等于哪个值”时,可以使用简单型 CASE:
CASE expression
WHEN value_1 THEN result_1
WHEN value_2 THEN result_2
ELSE default_result
END订单状态码翻译正好符合这个结构:
SELECT
order_id,
status,
CASE status
WHEN '待支付' THEN '等待顾客付款'
WHEN '已支付' THEN '等待仓库发货'
WHEN '已发货' THEN '正在配送途中'
WHEN '已完成' THEN '交易已经完成'
WHEN '已取消' THEN '订单已经取消'
WHEN '已退款' THEN '款项已经退回'
ELSE '需要人工核对'
END AS status_label
FROM orders
WHERE
它等价于反复比较 status = '待支付'、status = '已支付'、status = '已发货' 等固定值。因此它不能直接表达 total_amount >= 200,也不能表达 status IS NULL。

可以用一句很实用的判断:分支之间变的是比较值,还是整个判断条件?
简单不等于更高级,搜索型也不等于性能更好。两者首先是表达意图的方式,选择能让下一位读者最快看懂的那一种。
搜索型 CASE 会从上到下检查 WHEN。某个条件为 TRUE 时,马上返回对应的 THEN 结果;后面的分支不再决定这一行的最终结果。如果所有条件都没有命中,才走 ELSE。
这意味着,重叠区间的先后顺序会改变答案。
下面这条查询语法完全正确,业务结果却错了:
SELECT
order_id,
total_amount,
CASE
WHEN total_amount >= 100 THEN '普通大额单'
WHEN total_amount >= 200 THEN '重点大额单'
ELSE '普通订单'
END AS amount_level
FROM orders
WHERE order_id BETWEEN 101 AND 114
ORDER BY order_id;订单 113 的金额是 227 元,理应属于“重点大额单”,却先命中了 >= 100。第二个分支永远没有机会处理任何 >= 200 的金额。
把更具体、更严格的条件放在前面:
SELECT
order_id,
total_amount,
CASE
WHEN total_amount >= 200 THEN '重点大额单'
WHEN total_amount >= 100 THEN '普通大额单'
ELSE '普通订单'
END AS amount_level
FROM orders
WHERE order_id BETWEEN 101 AND 114
ORDER BY order_id;审查区间 CASE 时,不要只问“每个条件是否正确”,还要问“上面的条件会不会提前吃掉下面的行”。对从高到低的等级规则,通常按门槛从高到低排列最稳妥。
数据库通常只需要计算决定当前结果所需的分支,所以 CASE WHEN denominator = 0 THEN NULL ELSE numerator / denominator END 常被用来避开除零。但查询优化器可能在运行前先处理常量或不可变表达式。把一个固定的 1 / 0 藏进理论上不会进入的分支,仍可能在规划或优化阶段报错。
更稳妥的做法有两个:
NULLIF,稍后会完整讲解。CASE 是业务分支,不应被当成清理任意危险表达式的万能护栏。
一条 CASE 可以有很多分支,但它在结果集中只占一列。因此数据库必须给这一列确定一个共同类型。
如果各个分支都返回文本,结果自然是文本;如果都返回数值,数据库会按照自己的类型合并规则确定精度与范围。例如整数和小数一起出现时,结果通常会向能够容纳小数的一侧靠拢。字面量 NULL 不会单独迫使结果变成某种类型。
真正容易出问题的是无意混用不相干的类型:
-- 不建议:同一列有时是数字,有时是文字
CASE
WHEN status IN ('已支付', '已发货', '已完成') THEN total_amount
ELSE '未支付'
END你看到的是“已支付时显示金额,否则显示提示”,数据库看到的却是“这一列到底是数值还是字符串”。不同数据库的隐式转换规则不完全相同;即使当前数据库愿意转换,客户端拿到的类型也可能不利于继续求和、排序或绑定参数。
把展示和计算拆开会清楚得多:
SELECT
order_id,
CASE
WHEN status IN ('已支付', '已发货', '已完成') THEN total_amount
ELSE CAST(0 AS DECIMAL(10, 2))
END AS paid_amount,
CASE
WHEN status IN ('已支付', '已发货', '已完成') THEN
paid_amount 始终是数值,后面可以继续求和;paid_note 始终是文本,专门给人阅读。必要时显式 CAST,相当于把列的契约写进 SQL。
手机号缺失时,下面的写法看起来方便:
CASE WHEN phone IS NULL THEN '没有手机号' ELSE phone END但它会把“手机号”和“状态说明”挤进同一列。更好的结果结构是:
SELECT
customer_id,
phone,
CASE
WHEN phone IS NULL THEN '手机号缺失'
ELSE '手机号已登记'
END AS phone_state
FROM customers
WHERE customer_id IN (2, 3)
ORDER BY customer_id;保留原始手机号列,调用方仍能完成拨号、脱敏和校验;另加一列状态用于展示,两种用途互不干扰。

ELSE 可以省略。省略之后,如果没有任何 WHEN 命中,整个 CASE 的结果就是 NULL。
看一条只标记待支付订单的查询:
SELECT
order_id,
status,
CASE
WHEN status = '待支付' THEN '需要跟进'
END AS follow_up_note
FROM orders
WHERE order_id IN (103, 107, 110)
ORDER BY order_id;这在条件聚合中很有用,因为许多聚合函数会忽略 NULL。但在给用户看的标签列里,省略 ELSE 往往意味着页面会出现空白。究竟要不要写 ELSE,取决于 NULL 在这个结果里有没有清楚的业务含义。
下面是一个很典型的误区:
SELECT
customer_id,
email,
CASE email
WHEN NULL THEN '邮箱缺失'
ELSE '邮箱已登记'
END AS email_state
FROM customers
WHERE customer_id IN (4, 5)
ORDER BY customer_id;CASE email WHEN NULL 本质上在做 email = NULL。而 NULL 表示未知,等值比较的结果不是 TRUE,所以这个分支不会命中。
应当改用搜索型 CASE 和 IS NULL:
SELECT
customer_id,
email,
CASE
WHEN email IS NULL THEN '邮箱缺失'
ELSE '邮箱已登记'
END AS email_state
FROM customers
WHERE customer_id IN (4, 5)
ORDER BY customer_id;给 NULL 补默认值时,先别急着全部补成 0 或“无”。
stock = 0:库存已经确认是零;email IS NULL:邮箱未知或尚未登记;email = '':字段里确实存了一个空字符串,它不是 NULL。小满商店的 products.stock 有 NOT NULL 约束,所以库存未知不能直接写成 NULL;真有“待盘点”需求,应增加明确的盘点状态或建立盘点记录。现有数据中,亚麻抱枕的 stock = 0 是已经确认缺货,乔木的 email IS NULL 才是未知联系方式。下面把两种事实分别展示,不强行塞进同一列:
SELECT
c.customer_name,
c.email,
CASE WHEN c.email IS NULL THEN '邮箱未知' ELSE '邮箱已知' END AS email_state,
p.product_name,
p.stock,
CASE WHEN p.stock = 0 THEN '确认缺货' ELSE '有库存' END AS stock_state
FROM customers AS c
CROSS JOIN products AS p
WHERE c.customer_id =
把“已支付”显示为“等待仓库发货”很适合 CASE。它只是结果层的翻译,并没有改变订单事实。但如果状态列表由运营后台维护、不同店铺有不同叫法,几十个页面都复制一份 CASE 就会很难同步。那时应把“状态—显示名称”放进配置表或统一的服务层,再通过连接或统一接口取值。
CASE 最舒服的位置,是规则短、局部、稳定,而且读者能在查询旁边看懂它。规则的维护责任如果已经属于另一张业务表,就不要用越来越长的 CASE 抢走那张表的工作。
默认按 status 排序时,数据库只认识字符顺序,不知道客服最该先处理哪一种订单。业务排序需要我们把状态翻译成权重。
SELECT
order_id,
status,
order_date,
CASE status
WHEN '待支付' THEN 1
WHEN '已支付' THEN 2
WHEN '已发货' THEN 3
WHEN '已完成' THEN 4
WHEN '已退款' THEN 5
WHEN '已取消' THEN 6
ELSE 7
END AS priority
FROM orders
排序逻辑可以拆成三个键:
order_date DESC,新订单在前;order_id DESC,让分页顺序稳定。查询展示了 priority,是为了便于学习和验算。正式页面如果不想展示这个辅助列,可以只在 ORDER BY 中保留 CASE。
不同数据库、不同升降序下,NULL 的默认位置可能不符合业务期待。客服资料队列要求“邮箱缺失放最前”,可以明确写出排序键:
SELECT
customer_id,
customer_name,
email,
registered_at
FROM customers
WHERE customer_id BETWEEN 1 AND 10
ORDER BY
CASE WHEN email IS NULL THEN 0 ELSE 1 END,
registered_at DESC,
customer_id DESC;这里的 0 和 1 不是业务数据,只是排序用的临时权重。

条件排序的重点不在 CASE 有多复杂,而在排序键是否完整。只写状态权重,同一状态下的行可能每次返回不同顺序;继续补上时间和唯一编号,分页和导出才稳定。
运营日报常常要在一行里同时显示“总订单数、已完成订单数、待支付订单数、已经关闭的订单数、有效销售额”。这里约定有效订单状态包括“已支付、已发货、已完成”,已经关闭包括“已取消、已退款”。如果为每个数字单独发一条查询,代码分散,筛选条件也容易不一致。把 CASE 放进聚合函数,可以在同一组数据上计算多个口径。
SELECT
COUNT(*) AS total_orders,
SUM(CASE WHEN status = '已完成' THEN 1 ELSE 0 END) AS completed_orders,
SUM(CASE WHEN status = '待支付' THEN 1 ELSE 0 END) AS pending_orders,
SUM(CASE
SUM(CASE WHEN status = '已完成' THEN 1 ELSE 0 END) 可以逐行理解:已完成行贡献 1,其他行贡献 0,最后把这些数加起来。
下面两种写法都能统计已完成订单:
SELECT
SUM(CASE WHEN status = '已完成' THEN 1 ELSE 0 END) AS completed_by_sum,
COUNT(CASE WHEN status = '已完成' THEN 1 END) AS completed_by_count
FROM orders;第二个 CASE 省略了 ELSE,未命中时返回 NULL;COUNT(expression) 只统计非 NULL,因此得到 8。千万不要写成下面这样:
SELECT
COUNT(CASE WHEN status = '已完成' THEN 1 ELSE 0 END) AS wrong_count
FROM orders;因为 0 也是非 NULL,十三行都会被 COUNT 计入。
上一章讲过,外连接会给没有订单的顾客补出一行右侧全为 NULL 的结果。要统计每位顾客的订单情况,应数右表真实存在的订单,不能数 *:
SELECT
c.customer_id,
c.customer_name,
COUNT(o.order_id) AS total_orders,
SUM(
CASE WHEN o.status IN ('已支付', '已发货', '已完成') THEN 1 ELSE 0 END
) AS effective_orders,
COALESCE(
SUM(
CASE
WHEN o.status IN
陆野虽然没有订单,LEFT JOIN 仍保留了他。COUNT(o.order_id) 看到的是 NULL,所以订单数为 0;SUM(CASE ... ELSE 0 END) 则让补出的那一行贡献 0。
“平均有效订单金额”应该只让已支付、已发货、已完成订单参与分子和分母。可以让其他行返回 NULL,利用 AVG 忽略 NULL:
SELECT
ROUND(
AVG(
CASE
WHEN status IN ('已支付', '已发货', '已完成') THEN total_amount
END
),
2
) AS avg_effective_amount
FROM orders;如果改成 ELSE 0,待支付、已取消和已退款订单会以 0 元进入分母,得到的就不是“有效订单平均金额”。条件聚合最容易错的地方,往往不是 CASE 语法,而是统计口径没有说清楚。
店长想给顾客打一个“是否有成功支付”标记。这个问题只关心相关行是否存在,不关心有几笔支付,也不需要把支付详情展示出来。EXISTS 很适合回答这类问题。
SELECT
c.customer_id,
c.customer_name,
CASE
WHEN EXISTS (
SELECT 1
FROM orders AS o
JOIN payments AS p
ON p.order_id = o.order_id
AND p.status = '成功'
WHERE o.customer_id = c.customer_id
) THEN '有成功支付'
ELSE '暂无成功支付'
END AS payment_state
这里的子查询与外层顾客通过 o.customer_id = c.customer_id 相关联。只要找到一行,EXISTS 就足以判定为真。SELECT 1 表达的是“我们不使用子查询返回的具体列,只关心有没有行”。
如果把顾客、订单、支付直接连接,苏小满会因为订单 101、105、111 出现多行;以后某张订单发生分次支付时,行数还会继续增加。再写 CASE WHEN p.payment_id IS NOT NULL 虽然每行标签都对,但“每位顾客一行”的结果契约已经被破坏了。
当然,也可以连接后 GROUP BY,再用条件聚合。但当需求真的只有“有或没有”,EXISTS 更直接,也不会先产生多余的明细行。
给没有任何订单的顾客显示“新客待转化”:
SELECT
c.customer_id,
c.customer_name,
CASE
WHEN NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
) THEN '新客待转化'
ELSE '已有订单'
END AS customer_stage
FROM customers AS c
WHERE c.customer_id BETWEEN 1 AND 10
ORDER BY c.customer_id;存在性只是一个布尔条件,CASE 负责把它翻译成业务可读的结果。
报表中常见的转化率、支付率和客单价都带除法。分母为零时,真正的问题不是“数据库会不会报错”,而是这个指标在业务上有没有定义。
假设我们要计算每位顾客的有效订单占比。先汇总订单总数和有效订单数;这里仍把已支付、已发货、已完成视为有效:
WITH customer_order_stats AS (
SELECT
c.customer_id,
c.customer_name,
COUNT(o.order_id) AS order_count,
SUM(
CASE WHEN o.status IN ('已支付', '已发货', '已完成') THEN 1 ELSE 0 END
) AS effective_count
FROM customers AS c
LEFT JOIN orders
NULLIF(order_count, 0) 的规则很简单:两个参数相等时返回 NULL,否则返回第一个参数。对陆野来说,NULLIF(0, 0) 得到 NULL,于是除法结果也是 NULL,准确表达“没有订单,有效率暂时没有定义”。
它等价于下面的 CASE:
CASE
WHEN order_count = 0 THEN NULL
ELSE order_count
ENDNULLIF 更短,而且一眼就能看出它在保护零分母。
如果页面产品经理明确要求“没有订单时显示 0.00%”,可以在外面再包一层 COALESCE:
WITH customer_order_stats AS (
SELECT
c.customer_id,
c.customer_name,
COUNT(o.order_id) AS order_count,
SUM(
CASE WHEN o.status IN ('已支付', '已发货', '已完成') THEN 1 ELSE 0 END
) AS effective_count
FROM customers AS c
LEFT JOIN orders
这三个动作的职责很清楚:
NULLIF 把不允许参与除法的零分母转成 NULL;COALESCE 根据展示约定决定是否补一个默认值。
COALESCE(..., 0) 是业务决定,不是固定模板。没有订单的有效率究竟应该是 0、NULL,还是显示“暂无数据”,要由指标定义决定。过早补零会让“真实的零”和“根本没有样本”变得无法区分。
顾客联系方式希望优先显示邮箱,没有邮箱时显示电话,两者都缺失时显示提示:
SELECT
customer_id,
customer_name,
COALESCE(email, phone, '未留联系方式') AS contact
FROM customers
WHERE customer_id IN (3, 5)
ORDER BY customer_id;COALESCE(a, b, c) 可以理解为:从左到右找第一个非 NULL。它与“先判断 a,再判断 b”的 CASE 很接近,但意图更集中。各参数仍要能够合并成共同类型。
到目前为止,CASE 都只在查询结果中生成临时值。把它放进 UPDATE ... SET 后,规则会写回表中,风险也随之改变。
运营准备做一次限时调价:厨房用品打九折,文具手账打九二折,其他分类不参与。这里不是给结果临时加标签,而是准备修改商品单价。
不要一上来就执行 UPDATE。先把主键、旧值和拟写入的新值查出来:
SELECT
product_id,
product_name,
category_id,
unit_price AS old_price,
CASE
WHEN category_id = 2 THEN ROUND(unit_price * 0.90, 2)
WHEN category_id = 3 THEN ROUND(unit_price * 0.92, 2)
ELSE unit_price
END
这张预演表让我们在改数据前回答三个问题:目标行是否正确、分支顺序是否正确、结果值是否符合字段约束。
UPDATE products
SET unit_price = CASE
WHEN category_id = 2 THEN ROUND(unit_price * 0.90, 2)
WHEN category_id = 3 THEN ROUND(unit_price * 0.92, 2)
ELSE unit_price
END
WHERE product_id BETWEEN 1
验证修改结果:
SELECT
product_id,
product_name,
category_id,
unit_price
FROM products
WHERE product_id BETWEEN 1 AND 4
ORDER BY product_id;WHERE 决定哪些行有资格被更新;ELSE 决定已经进入更新范围、但没有命中其他分支的行写成什么。
如果忘了 WHERE,整张商品表都会参与更新。即使 CASE 的 ELSE 返回原来的 unit_price,数据库仍可能检查、锁定或记录大量行。不要把 ELSE unit_price 当成范围保护。
如果省略 ELSE,没有命中任何分支的行会得到 NULL;当字段不允许 NULL 时会报错,允许 NULL 时则可能悄悄写入错误数据。
条件 UPDATE 至少做四次确认:先 SELECT 预演;WHERE 尽量使用主键或明确范围;执行后核对受影响行数;重要数据放进事务,在确认后提交。CASE 只负责算新值,不负责替你恢复误更新。

CASE 本身不难,难的是同一个计算被复制到多个分支里。比如给顾客分层时,我们既要算有效消费金额,又要算有效订单数,还要判断是否存在待支付订单。如果把所有子查询和聚合直接塞进 CASE,SQL 很快会变成一堵墙。
更好的方法是分两层:内层先把业务事实算成干净的列,外层再根据这些列分类。
WITH customer_facts AS (
SELECT
c.customer_id,
c.customer_name,
COUNT(o.order_id) AS order_count,
SUM(
CASE WHEN o.status IN ('已支付', '已发货', '已完成') THEN 1 ELSE 0 END
) AS effective_count,
SUM(
CASE
WHEN o.status
这一层次很值得留意:
customer_facts 只回答事实,例如“已支付金额是多少”;许多数据库不允许在同一层 SELECT 列表的另一个表达式里直接引用刚定义的别名。下面的写法通常不能工作:
SELECT
SUM(
CASE
WHEN status IN ('已支付', '已发货', '已完成') THEN total_amount
ELSE 0
END
) AS effective_amount,
CASE WHEN effective_amount >= 400 THEN '高价值' ELSE '普通' END AS level
FROM orders;SELECT 列表中的表达式在逻辑上属于同一层,effective_amount 不是一个可以依次赋值、随后引用的变量。把聚合放进 CTE 或派生表,外层就能安全使用这个别名。
下面几种信号说明规则可能已经超出一个内联 CASE 的舒适范围:
固定而短的显示映射可以留在 CASE;经常变化的等级区间可以放进规则表;跨系统的业务决策可以收进统一服务;多步数据修改则需要事务来保护一致性。
CASE 的可维护性边界不是“最多允许写几行”,而是读者能否只看当前查询就确认规则完整,并且这份规则是否只有一个可靠来源。
现在把连接、条件聚合、安全除法和条件排序放进同一张顾客看板。结果要求每位顾客一行,展示订单数、有效消费金额、有效订单率、联系方式和下一步动作;需要跟进的人排在前面。
WITH customer_stats AS (
SELECT
c.customer_id,
c.customer_name,
c.email,
c.phone,
COUNT(o.order_id) AS order_count,
SUM(
CASE WHEN o.status IN ('已支付', '已发货', '已完成') THEN 1 ELSE 0 END
) AS effective_count,
SUM(CASE
可以按下面的顺序读这条查询:
先确认一行代表一位顾客。customers 放在 LEFT JOIN 的保留侧,COUNT(o.order_id) 只数真实订单,因此没有下单的陆野仍然保留且订单数为 0。
customer_stats 用条件聚合把订单明细收成顾客级事实。这里不急着做最终展示,只产生 effective_count、pending_count 和 effective_amount 等可复用指标。
customer_actions 用 COALESCE 选择联系方式,用 NULLIF 保护零分母,再用 CASE 决定下一步动作。每个表达式只承担一种职责。
这里出现了两份结构相同的 CASE:一份返回文字,一份返回数字。规则很短时这样最直观;如果动作种类继续增加,可以在 CTE 中先算一个 action_code,外层再把代码映射成标签与顺序,减少两份规则漂移的风险。
下面的写法可以表达“只看有效订单”,但多绕了一层:
SELECT order_id, status, total_amount
FROM orders
WHERE CASE
WHEN status IN ('已支付', '已发货', '已完成') THEN 1
ELSE 0
END = 1;直接写条件更清楚:
SELECT order_id, status, total_amount
FROM orders
WHERE status IN ('已支付', '已发货', '已完成');两条查询会返回相同的十张有效订单:
CASE 适合“条件不同,返回值不同”。如果 WHERE 只需要一个普通布尔条件,就把条件直接交给 WHERE。
错误思路:
CASE email WHEN NULL THEN '缺失' ELSE '已填写' END正确思路:
CASE WHEN email IS NULL THEN '缺失' ELSE '已填写' END简单型 CASE 做等值比较,无法用 = NULL 得到 TRUE。
UPDATE products
SET status = CASE
WHEN stock = 0 THEN '缺货'
END;没有命中 stock = 0 的商品不会自动保留原状态,而会得到 NULL。若真要保留,应写 ELSE status,同时仍要用 WHERE 限制更新范围。
CASE
WHEN total_amount > 200 THEN '高'
WHEN total_amount < 200 THEN '低'
ELSE '其他'
END金额恰好等于 200 时会走 ELSE。边界到底属于哪一档,必须在业务规则中明确。连续区间通常只写单侧门槛,并按顺序覆盖:
CASE
WHEN total_amount >= 200 THEN '高'
ELSE '低'
ENDSUM(CASE WHEN ... THEN 1 ELSE 0 END):未命中贡献 0;COUNT(CASE WHEN ... THEN 1 END):未命中返回 NULL,COUNT 忽略它;COUNT(CASE WHEN ... THEN 1 ELSE 0 END):0 也会被计数,通常不是你想要的答案;AVG(CASE WHEN ... THEN amount END):只让命中行参与平均;AVG(CASE WHEN ... THEN amount ELSE 0 END):未命中行也进入分母。SELECT 中的 CASE 只改变结果长什么样,不会改变表里的状态码。UPDATE 中的 CASE 会持久改写数据。读查询和写查询即使使用同一套条件,也必须按不同风险对待。
一条四十个分支的 CASE 只是把文字集中到一个文件里,不代表规则有了统一来源。如果多个页面都复制它,修改仍然会漏。可维护性的关键是规则归谁管理、在哪里生效、怎样测试和追踪版本,而不是把所有内容挤进一个表达式。
下面的练习仍使用本章统一数据。建议先在纸上写出“结果一行代表什么”“NULL 表示什么”“分支先后顺序是什么”,再展开答案。
显示订单编号、金额和金额等级。金额不低于 200 元为“重点订单”,不低于 100 元为“普通大额单”,其余为“小额订单”。
让已下架商品排在最前,缺货商品第二,库存不超过 40 的商品第三,其余商品最后;同一组按商品编号升序。
只统计成功支付,计算微信、支付宝、银行卡三种方式的支付笔数与成功支付总额。
每位顾客一行。只要顾客存在订单并且邮箱为空,就标记为“下单后补邮箱”;存在订单且邮箱完整则标记“资料完整”;从未下单则标记“暂不处理”。
每位顾客一行,只以已支付、已发货、已完成订单计算平均金额。没有有效订单时保留 NULL,不要补成 0。
不要直接修改数据。先写一条 SELECT,展示商品编号、原价和调价后价格,只挑出价格会变化的商品。厨房用品打九折,文具手账打九二折。
写完 CASE 后,可以按这份清单快速自检:
= NULL 或 WHEN NULL 当成空值判断?THEN 与 ELSE 能否合并成清楚、稳定的同一类型?EXISTS 避免连接重复?NULLIF 保护零分母?外层 COALESCE 的默认值是否符合指标定义?CASE 把“如果……那么……”带进了 SQL,但它最终仍只返回一个值。只要把分支顺序、NULL 和结果类型想清楚,复杂报表就能被拆成一组可核对的小判断。
下一章会跨过一个明显的边界:从“为一行算出什么值”,走向“多条修改必须一起成功”。当库存扣减、订单状态改变和支付记录写入必须组成一个整体时,CASE 已经不够了。我们需要事务来决定这些修改何时提交、失败时怎样撤销,以及并发请求同时到来时如何保持数据一致。
动作标签和动作权重使用同样的分支顺序。最后按权重和顾客编号排序,让需要付款提醒的顾客稳定排在最前面。
| 小额订单 |
| 104 | 183.90 | 普通大额单 |
| 105 | 159.00 | 普通大额单 |
| 106 | 140.00 | 普通大额单 |
| 107 | 137.00 | 普通大额单 |
| 108 | 190.00 | 普通大额单 |
| 109 | 150.00 | 普通大额单 |
| 110 | 99.00 | 小额订单 |
| 111 | 99.90 | 小额订单 |
| 113 | 227.00 | 重点订单 |
| 114 | 86.00 | 小额订单 |
门槛从高到低排列,避免 >= 100 提前覆盖 227 元订单。
支付 4 的状态是“已退款”,不应进入任一成功口径。
更大的数据集可以先在 CTE 中统一算 has_order,避免在多个分支重复写相关子查询。
白露只有已取消订单,乔木只有待支付订单,陆野没有订单;三人的“有效订单客单价”都没有可用样本,因此保留 NULL。
在本章初始数据尚未执行更新时,结果为:
真正执行 UPDATE 前,还应核对目标行数、折扣规则和价格精度。