写逻辑函数的文章,如果只列一堆函数名和例子,那是说明书,不是实战。先讲一个我几乎每个月都会遇到的SQL问题:业务表里有个逻辑删除字段 deleted,如果没特殊处理,这列是允许为 NULL 的。同事写查询时图省事,条件是 WHERE deleted <> 1,结果列表里有一批已删除用户死活查不出来。他第一反应是数据被清了,最后发现是 deleted IS NULL 的那些行全被过滤掉了。
类似的坑在 MySQL 里太常见了,而根源往往不是函数不会写,而是没把 SQL 的三值逻辑和“逻辑函数是什么时候派上用场”想清楚。这篇我会把 MySQL 逻辑函数(IF、IFNULL、NULLIF、CASE WHEN 等)逐个拆开,配套报表、更新、排序、NOT IN 排错这些真实场景讲,重点放在为什么这样写、哪些地方会翻车。适合正在学 MySQL 函数的人,也适合被 NULL 条件坑过、想彻底搞懂条件的同学。
1. SQL里的“逻辑”和代码逻辑,差别就在NULL上
很多写了两三年 SQL 的人,被问“哪个值既不等于1也不等于2,同时也不等于任何值”,反应都是愣一下。这个问题的答案是 NULL。
1.1 一张真值表说清三值逻辑
常规编程语言里,条件判断通常只有 true 和 false 两个值。但 SQL 是处理数据的,数据天然存在“缺失”和“未知”,所以 SQL 的逻辑判断有三个结果:TRUE、FALSE、UNKNOWN(在 MySQL 里 UNKNOWN 和 NULL 在许多语境下是同一个结果)。下面是 MySQL 中 AND 和 OR 的真值表,这个表值得死死记住:
| A AND B | TRUE | FALSE | NULL |
|---|---|---|---|
| TRUE | TRUE | FALSE | NULL |
| FALSE | FALSE | FALSE | FALSE |
| NULL | NULL | FALSE | NULL |
| A OR B | TRUE | FALSE | NULL |
|---|---|---|---|
| TRUE | TRUE | TRUE | TRUE |
| FALSE | TRUE | FALSE | NULL |
| NULL | TRUE | NULL | NULL |
NOT 也遵循三值逻辑:NOT TRUE 是 FALSE,NOT FALSE 是 TRUE,NOT NULL 仍然是 NULL。
为什么这条这么重要?因为很多“看着很对”的查询条件,一旦遇到 NULL,结果就不是你预期的。比如 NOT IN、<>、NOT EXISTS 这些语法,在普通程序员脑子里是“不是这个就是那个”,但在 SQL 里因为 NULL 的存在,会静默地丢掉数据。
1.2 为什么说 “= NULL” 是初学者最经典的错误
WHERE name = NULL 不会报错,但永远查不出任何一行。原因是 NULL = NULL 的结果不是 TRUE,而是 NULL(UNKNOWN)。NULL 不代表空字符串,也不代表 0,它代表“这个值是未知的”。一个未知值跟另一个未知值比较,结果当然还是未知。
我见过有人为了让这个条件“生效”,把写法改成 WHERE name = 'NULL',那是在用字符串匹配,一旦列里真的有 'NULL' 字样,又会产生脏数据。正确的判空写法只有两种:
sql复制WHERE name IS NULL;
WHERE name IS NOT NULL;
MySQL 还有一个安全等于 <=>,它专门处理 NULL 比较:
sql复制SELECT 1 <=> 1; -- 1
SELECT NULL <=> NULL; -- 1
SELECT NULL = NULL; -- NULL
<=> 的执行效率在普通场景下没有明显差异,它的真正意义是能让你在表达式里直接处理 NULL,而不需要把 IS NULL 和等值判断拆成两段。
1.3 WHERE 子句对 UNKNOWN 的处理,是决定“漏数据”的关键
WHERE 子句有个很隐蔽的规则:只返回条件结果为 TRUE 的行。条件结果是 FALSE 或 NULL(UNKNOWN)的行都会被过滤掉。
所以 WHERE deleted <> 1 想表达“没有删除的数据”,看起来没问题,但当某一行的 deleted 是 NULL 时,NULL <> 1 的结果是 NULL,这一行就被静默过滤了。如果你再写成 WHERE NOT (deleted = 1),同样有问题,因为 NOT NULL 还是 NULL。
想查“未删除”的数据,严谨写法是:
sql复制WHERE deleted = 0 OR deleted IS NULL;
或者干脆在建表时把 deleted 设计成 NOT NULL DEFAULT 0,后面所有查询都不需要再处理 NULL 这个分支。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. IF、IFNULL、NULLIF、CASE WHEN到底怎么分工
在 MySQL 中,逻辑函数做的事情本质上是“根据某个条件是 TRUE、FALSE 还是 NULL,返回不同的值”。把这些函数放一起对比,才能用得顺手。
2.1 IF()函数:条件表达式里的三目运算符
MySQL 的 IF() 是表达式,不是流程控制语句,语法如下:
sql复制IF(expr1, expr2, expr3)
如果 expr1 为 TRUE(在 MySQL 中,非 0 且非 NULL 视为 TRUE),返回 expr2,否则返回 expr3。它很像 Java 里的三元运算符:
sql复制SELECT name, IF(gender = 1, '男', '女') AS gender_name
FROM user;
一个非常容易踩的坑是拿 NULL 当条件本身:
sql复制-- 这样写永远返回 expr3
SELECT IF(remark = NULL, '为空', '不为空') FROM t;
-- 必须这样写
SELECT IF(remark IS NULL, '为空', '不为空') FROM t;
IF(NULL, 1, 0) 返回的是 0,不要误以为 NULL 会被当成 TRUE。除了这个,还要区分 IF() 函数和存储过程里的 IF 语句:
sql复制IF v_count > 0 THEN
SELECT '有数据';
END IF;
一个是返回值的表达式,一个是流程控制的语句,代码里混着用会看得很别扭。如果只是查询里做简单二选一,IF 函数足够;分支超过两个,就别再套娃了,直接转 CASE WHEN。
2.2 IFNULL()与COALESCE():空值兜底不要总想依赖函数
IFNULL(expr1, expr2) 的逻辑很简单:如果 expr1 是 NULL,返回 expr2,否则返回 expr1。最典型的使用场景是展示层不希望出现 NULL。
sql复制SELECT user_name, IFNULL(email, '未填写') AS email
FROM user;
但如果你有多个字段要依次取第一个非 NULL 值,IFNULL 嵌套三层就开始难看了:
sql复制SELECT IFNULL(phone, IFNULL(email, IFNULL(wechat, '无联系方式')))
FROM user;
这种场景直接用标准 SQL 的 COALESCE:
sql复制SELECT COALESCE(phone, email, wechat, '无联系方式')
FROM user;
COALESCE 会从左到右返回第一个非 NULL 的值,如果所有参数都是 NULL,返回 NULL。跨数据库迁移时,COALESCE 和 CASE 的兼容性比 IF 和 IFNULL 好,这也是我在公共代码里更倾向标准函数的原因。
2.3 NULLIF():构造“除数保护”和判断重复值
NULLIF(expr1, expr2) 的规则是:如果 expr1 = expr2 返回 NULL,否则返回 expr1。光看定义有点绕,作用却很经典——防除零。
sql复制SELECT total_amount / NULLIF(order_count, 0) AS avg_amount
FROM seller_stat;
当 order_count 为 0 时,NULLIF 返回 NULL,除法结果就是 NULL,不会报“division by 0”错误。要是有业务需求,后面再包一层 IFNULL 把 NULL 显示成 0:
sql复制SELECT IFNULL(total_amount / NULLIF(order_count, 0), 0) AS avg_amount
FROM seller_stat;
NULLIF 还有一个比较巧的用法:统计两列值不一样的记录数。
sql复制-- 当 department 和 old_department 一致时,NULLIF 返回 NULL,COUNT 不统计
SELECT COUNT(NULLIF(department, old_department)) AS changed_count
FROM employee;
注意 NULLIF 内部比较时遵循的是普通 = 的规则,如果两个参数一个数字一个字符串,会走隐式转换。别为了图省事把类型完全不同的值拿来比较,容易得到和你预期不一样的结果。
2.4 CASE WHEN表达式:可读性最强的复杂分支
当条件分支多了以后,IF 嵌套的阅读体验非常差,比如:
sql复制IF(score >= 90, 'A', IF(score >= 80, 'B', IF(score >= 70, 'C', 'D')))
一旦超过两层,缩进再漂亮也难维护。CASE WHEN 是标准 SQL 写法,推荐所有多分支判断用它。它有两种形式。
简单 CASE 只做“等值判断”:
sql复制SELECT
CASE status
WHEN 0 THEN '待支付'
WHEN 1 THEN '已支付'
WHEN 2 THEN '已退款'
ELSE '未知'
END AS status_text
FROM order_t;
搜索 CASE 支持更复杂的条件,也支持范围判断和 NULL 判断:
sql复制SELECT
CASE
WHEN score >= 90 THEN '优秀'
WHEN score >= 80 THEN '良好'
WHEN score >= 60 THEN '及格'
WHEN score IS NULL THEN '缺考'
ELSE '不及格'
END AS level_text
FROM exam;
两个常犯错误:忘记写 END,以及以为简单 CASE 可以做范围判断。简单 CASE 本质是拿 CASE 后面的列逐行和 WHEN 后面的值做 =,你写 CASE score WHEN > 90 THEN ... 一定会报语法错误。
下表把几个函数放一起,方便按需选用:
| 函数/表达式 | 作用 | 常见误用 |
|---|---|---|
| IF() | 条件二选一 | 条件里写 = NULL,或嵌套太多层 |
| IFNULL() | 第一个参数为 NULL 时返回第二个参数 | 以为空字符串也会被处理 |
| COALESCE() | 多个参数中返回第一个非 NULL 的值 | 和 IFNULL 对比时忽略标准兼容性 |
| NULLIF() | 两个参数相等返回 NULL,否则返回第一个 | 在返回结果上继续做普通判断 |
| CASE WHEN | 多分支条件表达式 | 漏掉 END,简单 CASE 用来做范围判断 |
3. 业务报表里最常见的组合套路:条件计数、行转列、条件更新
逻辑函数单独拎出来都会用,真正体现价值的是组合进聚合函数里。条件统计是数据库报表最常做的事情。
3.1 SUM + IF 完成分数段统计和行转列
先看一个最常见的条件计数需求:统计每个班成绩大于等于 60 分的人数。新手最喜欢写:
sql复制SELECT COUNT(IF(score >= 60, 1, 0)) FROM student;
这个写法是错的。COUNT() 只统计非 NULL 值,IF 在不满足条件时返回了 0,0 是非 NULL,所以它照样被 COUNT 进去,最终统计的是全表行数。正确的写法是让不满足条件的返回 NULL:
sql复制SELECT
class_id,
COUNT(IF(score >= 60, 1, NULL)) AS pass_count,
COUNT(*) AS total_count
FROM student
GROUP BY class_id;
等价写法可以用 CASE:
sql复制SUM(CASE WHEN score >= 60 THEN 1 ELSE 0 END)
这里用 SUM 不存在 COUNT(NULL) 的问题,因为 ELSE 0 正好不增加求和结果。还有更简洁的 SUM(score >= 60),MySQL 会把布尔结果转成 0/1,但这种写法太依赖 MySQL 的行为,如果未来项目要支持别的数据库,建议尽量少写。
考试分段统计再来一个:
sql复制SELECT
SUM(CASE WHEN score >= 90 THEN 1 ELSE 0 END) AS excellent,
SUM(CASE WHEN score BETWEEN 80 AND 89 THEN 1 ELSE 0 END) AS good,
SUM(CASE WHEN score < 80 THEN 1 ELSE 0 END) AS need_improve
FROM exam;
行转列也经常用 CASE WHEN 配合 SUM 或 MAX 做。比如这种销售流水表:
sql复制CREATE TABLE order_flow (
order_id INT,
province VARCHAR(32),
order_month VARCHAR(7),
order_amount DECIMAL(10,2)
);
要把 2024 年每个月的订单金额横向展开成一列一个月:
sql复制SELECT
province,
SUM(CASE WHEN order_month = '2024-01' THEN order_amount ELSE 0 END) AS m202401,
SUM(CASE WHEN order_month = '2024-02' THEN order_amount ELSE 0 END) AS m202402,
SUM(CASE WHEN order_month = '2024-03' THEN order_amount ELSE 0 END) AS m202403
FROM order_flow
WHERE order_month BETWEEN '2024-01' AND '2024-03'
GROUP BY province;
核心逻辑就是:对每个目标月份,用 CASE 把非本月的金额置成 0,再用 SUM 累加。字段是动态生成时,需要靠存储过程拼 SQL,行转列的思路不变,只是把固定的 CASE 子句生成为字符串。
3.2 UPDATE里用IF做字段标准化
有时候要把表里的冗余字段刷一遍,比如根据积分更新等级:
sql复制UPDATE user_account
SET level_name = CASE
WHEN points >= 10000 THEN '钻石'
WHEN points >= 5000 THEN '黄金'
WHEN points >= 1000 THEN '白银'
ELSE '普通'
END
WHERE level_name <> CASE
WHEN points >= 10000 THEN '钻石'
WHEN points >= 5000 THEN '黄金'
WHEN points >= 1000 THEN '白银'
ELSE '普通'
END;
WHERE 里保留条件是为了避免无意义的行更新,减少 binlog 和主从同步压力。这个写法里最大的坑是 points 为 NULL。points >= 10000 的结果是 NULL,不是 FALSE,CASE 会跳到 ELSE,于是会把积分缺失的人也更新成“普通”。积分缺失和积分不足是两个不同的业务状态,如果业务要求“积分缺失不更新等级”,就要加一层判断:
sql复制UPDATE user_account
SET level_name = CASE
WHEN points IS NULL THEN level_name
WHEN points >= 10000 THEN '钻石'
ELSE '普通'
END;
CASE WHEN 是顺序匹配的,第一个匹配成功就不再往下走,所以把 points IS NULL 放最前面,能防止误覆盖。
3.3 HAVING和视图场景里的逻辑判断
HAVING 里同样可以用逻辑表达式完成“组内条件”的判断。比如想找“微信支付金额超过 1000 的用户”:
sql复制SELECT
user_id,
SUM(amount) AS total_amount
FROM payment
GROUP BY user_id
HAVING SUM(CASE WHEN pay_type = 'wechat' THEN amount ELSE 0 END) > 1000;
这里不能直接写 WHERE pay_type = 'wechat',因为它会把其他支付类型的记录也在分组前过滤掉,那统计出来的用户就不是“总金额”,而是“微信金额”。需要同时保留总金额和条件金额时,条件聚合写在哪里取决于你对粒度的定义。
视图里用逻辑函数也非常频繁。比如把 0 和 NULL 统一处理成可读文案:
sql复制CREATE VIEW v_user_contact AS
SELECT
user_id,
COALESCE(NULLIF(TRIM(phone), ''), '无手机号') AS phone_display
FROM user;
NULLIF(TRIM(phone), '') 先把空白字符串变成 NULL,COALESCE 再把它替换成文案。这样无论是真正的 NULL 还是纯空格,都不会把空值漏到前端。
4. NOT IN和NOT EXISTS的排查实录:一条查询为什么少返回一半数据
这一节是我最想写的,因为逻辑函数的坑不只是“函数写错”,更多是底层真值逻辑被忽略。
4.1 从一次“少了一半数据”的查询说起
有两张表,会员表和黑名单表:
sql复制CREATE TABLE member (
id INT PRIMARY KEY,
name VARCHAR(50)
);
CREATE TABLE blacklist (
id INT PRIMARY KEY,
member_id INT
);
需求很简单:找出所有没进黑名单的会员。大部分人第一反应是:
sql复制SELECT id, name
FROM member
WHERE id NOT IN (SELECT member_id FROM blacklist);
我当时这张黑名单表有 91、92 两个会员 ID,还有个从旧系统导入的历史数据,member_id 是 NULL。执行后发现,预期能查出来 8 个会员,实际只返回了 3 个。少了的那 5 个会员,ID 和黑名单里的 91、92 完全不沾边。
4.2 定位问题的完整链路
第一步,单独执行子查询,看集合里到底有什么:
sql复制SELECT member_id FROM blacklist;
-- 结果:91, 92, NULL
第二步,用一个会员 ID 手工验证 NOT IN 的行为,比如会员 ID 是 99:
sql复制SELECT 99 NOT IN (91, 92, NULL); -- 结果是 NULL
SELECT 99 NOT IN (91, 92); -- 结果是 1
99 既不等于 91,也不等于 92,本来应该返回 TRUE。但由于集合里有 NULL,SQL 会认为“99 不等于 91”是 TRUE,“99 不等于 92”是 TRUE,“99 不等于 NULL”呢?是 NULL。三个条件做 AND:TRUE AND TRUE AND NULL,最终结果是 NULL。WHERE 只认 TRUE,于是 99 这条会员记录就被过滤掉了。NULL 像一个无法验证的未知数,把整个 NOT IN 判断变成了“未知”。
第三步,检查表中到底有多少 NULL 记录:
sql复制SELECT
COUNT(*) AS total_cnt,
COUNT(member_id) AS not_null_cnt,
COUNT(*) - COUNT(member_id) AS null_cnt
FROM blacklist;
COUNT(字段) 会自动忽略 NULL,用 COUNT(*) 减去 COUNT(字段),可以快速确认脏数据有多少。
4.3 用EXISTS重构并验证
改成 NOT EXISTS 之后,语义更贴近“存在才算黑名单”,不依赖结果集合里是否包含 NULL:
sql复制SELECT id, name
FROM member m
WHERE NOT EXISTS (
SELECT 1
FROM blacklist b
WHERE b.member_id = m.id
);
EXISTS 只关心子查询有没有返回行,不关心返回的行里的列是不是 NULL。当 member_id 为 NULL 时,b.member_id = m.id 的结果是 UNKNOWN,这个行不会被选中,于是 NOT EXISTS 会把它忽略,结果正好是“没进黑名单的人”。
如果因为某些原因只能保留 NOT IN,也可以先清洗掉子查询里的 NULL:
sql复制SELECT id, name
FROM member
WHERE id NOT IN (
SELECT member_id
FROM blacklist
WHERE member_id IS NOT NULL
);
但这要求你时刻记得“子查询中不能有 NULL”,一旦后续别人往里灌了 NULL,问题又会回来。NOT EXISTS 对 NULL 的容忍度更高,表达能力也更强,我后来遇到“取补集”的场景,默认都写 NOT EXISTS。
和 NOT IN 同源的还有 <> 条件。比如查“不是已退款状态”的订单,如果 refund_status 允许 NULL,WHERE refund_status <> 'refunded' 会把 refund_status 为 NULL 的订单丢掉。正确的补漏写法是:
sql复制SELECT *
FROM order_t
WHERE refund_status <> 'refunded'
OR refund_status IS NULL;
排查逻辑类 SQL 问题,我的检查顺序固定是:先采样数据看 NULL 分布,再逐条判断表达式结果是否为 NULL,最后再怀疑函数本身写错。很多人一上来就改 WHERE 条件的函数写法,反而把问题带偏了。
5. 排序、索引与逻辑函数:要功能也要性能
逻辑函数不只是用在 WHERE 和 SELECT 里,还常见于排序。使用起来一旦没注意 NULL,排序结果会让人摸不着头脑。
5.1 用IF和CASE实现自定义排序
业务上有时需要把指定状态排在最前面,比如“处理中优先、待处理其次、其他最后”,一个常规做法是:
sql复制SELECT id, status, create_time
FROM task
ORDER BY
CASE status
WHEN 'processing' THEN 0
WHEN 'pending' THEN 1
ELSE 2
END,
create_time DESC;
CASE 在这里产生一个排序用的临时数字列,数据库按这个数字排序,不参与业务功能。但如果 status 有 NULL,它会落到 ELSE 2,和其他未知状态混在一起。想让 NULL 永远排在最后,可以显式加一个分支:
sql复制ORDER BY
CASE
WHEN status IS NULL THEN 2
WHEN status = 'processing' THEN 0
WHEN status = 'pending' THEN 1
ELSE 1
END,
create_time DESC;
使用 IF 也能实现,比如“已处理状态不置顶,其他情况置顶”:
sql复制ORDER BY IF(status = 'finished', 1, 0), create_time DESC;
注意 IF 条件遇到 NULL 时走的是 else 分支。如果你没有把 NULL 单独考虑,它会和“应置顶”的行拿到同样的数字,排序效果可能错位。排序需求里只要涉及逻辑表达式,建议把 NULL 分支显式列出来,不要靠运气。
5.2 条件列被函数包裹,索引很容易废掉
这一点太容易被忽略。比如有个手机号字段,你想查“手机号不是空的用户”,为了兼容历史 NULL,写了:
sql复制SELECT *
FROM user
WHERE IFNULL(mobile, '') <> '';
从功能上它能查出来,但从性能上,mobile 上的索引大概率用不上。优化器对列做了函数处理之后,无法直接走普通索引,只能全表扫描。这类条件在数据量大时能把查询拖到线上报警。
更推荐的做法是回到列本身的语义上,把判断拆开:
sql复制SELECT *
FROM user
WHERE mobile IS NOT NULL AND mobile <> '';
再比如想查“未删除且名字为李四”的人,写成:
sql复制WHERE IF(deleted = 0, name, '') = '李四'
这种表达方式也会阻碍索引利用,可维护性也差。直接写成:
sql复制WHERE deleted = 0 AND name = '李四'
索引判断一下:name 和 deleted 上如果有合适索引,MySQL 可以用更优的执行计划。逻辑函数看似只是把一个普通的条件包了一层,实际上把自己变成了优化器很难拆解的“黑盒”。
MySQL 从 8.0.13 开始支持函数索引,确实可以给表达式建索引,例如:
sql复制ALTER TABLE user ADD INDEX idx_mobile_trim ((TRIM(mobile)));
但这属于“字段设计已经不够规范时”的补偿方案。如果能从源头把手机号列设计成 NOT NULL,或者在写入时做约束,远比到处兜底函数靠谱。逻辑函数适合用在 SELECT 展示、报表计算和存储过程里,但不该成为掩盖表结构缺陷的常备工具。
6. 一些没人明说但很实用的判断习惯
最后分享几个我自己处理 MySQL 逻辑判断时沉淀下来的习惯,算不上高深,但能省不少排查时间。
第一个习惯是建表阶段就把“空”和“没有值”分清楚。允许 NULL 的列在查询里总会带来多一重判断。业务上不允许为空的字段直接 NOT NULL,需要“未填写”这类业务值就用默认空字符串或默认 0 表达,然后加注释说明。这样后续查询不需要到处写 OR IS NULL 兜底,整个团队的 SQL 都会干净很多。
第二个习惯是写复杂逻辑前,先在 SELECT 里把中间判断结果输出来看一眼。比如想用 NOT IN,先跑一遍子查询看集合里有没有 NULL;想用 CASE WHEN 判断分数,先看分数列有没有 NULL。SQL 是集合思维,一条 SQL 处理的是成千上万行,任何一行出现意外 NULL,结果都可能差之千里。
第三个习惯是跨数据库或写公共模块时,优先用标准 SQL 的表达式。CASE WHEN 和 COALESCE 是 SQL 标准,而 IF 和 IFNULL 是 MySQL 的扩展。如果项目后期要从 MySQL 迁到其他数据库,标准 SQL 的代码几乎不用改。既然 CASE WHEN 可读性也不差,就没必要贪图 IF 那一点字符量。
第四个习惯是用 COUNT(*) 和 COUNT(列) 的差值快速定位 NULL。COUNT(列) 自动忽略 NULL,遇到查询结果不对时,这一个差值能帮你快速判断是不是 NULL 在作怪。
MySQL 逻辑函数本身不复杂,关键是建立“NULL 是未知值”的意识。IF 和 CASE WHEN 只是帮你在 TRUE、FALSE、UNKNOWN 之间做显式路由的表达式。写条件时多想一步:这一列有没有可能为 NULL?NULL 应该落到哪个分支?把这个问题想明白,比背下一百个函数用法都管用。
