我以前整理SQL学习清单时,把“没有下过订单的客户”记成了一个很直观的办法:客户ID不在订单的客户ID集合里,就选出来。
这个入口没有问题,但它省略了一个条件:订单表里的客户ID能不能为NULL?
下面只用三名客户。客户1已经下单,客户2、3没有下单。再放入一条没有关联客户的导入记录,你会发现:原本应该返回2、3的查询,突然一行也不返回。
我们不把这解释成“SQL坏了”,而是跟着每个比较看清它到底回答了什么。本文是从旧学习清单补出的独立实验,不是线上事故,也不声称复现了某道平台题的全部约束。
1. 三名客户,两条订单
用MySQL测试库里的同一个连接执行下面各段。两张表都是临时表,只在当前连接中存在,不创建或删除业务表。若当前连接已有同名临时表,换一组名字,不要直接删除未知表。
CREATE TEMPORARY TABLE blog_null_customers ( id INT PRIMARY KEY ); CREATE TEMPORARY TABLE blog_null_orders ( id INT PRIMARY KEY, customer_id INT NULL, memo VARCHAR(32) NULL, INDEX (customer_id) );INSERT INTO blog_null_customers VALUES (1), (2), (3); INSERT INTO blog_null_orders VALUES (10, 1, NULL), (11, NULL, 'imported');| 客户ID | 是否存在客户ID等于它的订单 | 应不应该输出 |
|---|---|---|
| 1 | 是,订单10 | 否 |
| 2 | 否 | 是 |
| 3 | 否 | 是 |
订单11的customer_id未知,不意味着它同时属于所有客户。在本实验的业务定义里,只把ID相等的订单视作该客户的订单。
2. 看起来正确的NOT IN,为什么输出为空?
SELECT c.id FROM blog_null_customers AS c WHERE c.id NOT IN ( SELECT o.customer_id FROM blog_null_orders AS o ) ORDER BY c.id;子查询从订单10取出1,从订单11取出NULL,得到1、NULL。客户2要“不在里面”,就要同时满足:与第一项不同,也与第二项不同。先把这两个比较分开:
2 <> 1 AND 2 <> NULL TRUE AND UNKNOWN -> UNKNOWN第一项可以确认:2确实不是1。但第二项没有一个已知客户ID可比。问“这个未知ID是不是2”,比较所需的信息不够,普通不等号不能给出TRUE;SQL把这个结果记为UNKNOWN,MySQL用NULL表示它。NULL不是另一个普通编号,也不是与所有编号都不相等的特殊客户。
再看AND:它要求两项都为TRUE。一项确认不同,另一项还未知,不能说“两项都已确认不同”,所以整体仍是UNKNOWN。WHERE只收下条件确定为TRUE的行,客户2就没有被留下。
客户1又不一样:第一项1 <> 1已经是FALSE,不管另一项怎样,都不可能“两项都为TRUE”。因此整体是FALSE,不是UNKNOWN。把三个客户放在一起核对:
| 客户 | 与1不相等 | 与NULL不相等 | 合起来 | WHERE保留 |
|---|---|---|---|---|
| 1 | FALSE | 未知 | FALSE | 否 |
| 2 | TRUE | 未知 | 未知 | 否 |
| 3 | TRUE | 未知 | 未知 | 否 |
这张表也解释了一个容易说过头的结论:不是“遇到NULL,NOT IN的结果全部变成NULL”。客户1已经确定匹配,结果仍是FALSE。在本例的过滤查询中,所有行都不满足TRUE,才得到空结果。MySQL比较运算文档
图只跟客户2这一行走,不提前拿另一种写法的结果代替解释。这里不是没执行查询,而是执行后没有任何行满足TRUE。
3. 第一种修正:先说明这个集合不包含NULL
SELECT c.id FROM blog_null_customers AS c WHERE c.id NOT IN ( SELECT o.customer_id FROM blog_null_orders AS o WHERE o.customer_id IS NOT NULL ) ORDER BY c.id;现在参与比较的集合只有1,输出2、3。
这里有两个前提:客户id是主键,因此不为空;业务允许忽略客户ID未知的订单。不是随手加一个IS NOT NULL,就能把所有查询都修好。如果未知订单本身就是数据质量问题,应另外统计或处理,不能靠查询把异常藏起来。
实际题目若明确保证订单客户ID非空,原NOT IN写法也可以正确;先读约束,不需要为了展示技巧强行改写。
4. 第二种写法:直接问有没有匹配订单
SELECT c.id FROM blog_null_customers AS c WHERE NOT EXISTS ( SELECT 1 FROM blog_null_orders AS o WHERE o.customer_id = c.id ) ORDER BY c.id;对每个客户,把相等的订单筛出来:有匹配行就不输出,没有匹配行才输出。比如检查客户2时,订单11产生的比较是NULL = 2,结果仍是UNKNOWN,不是FALSE。子查询的WHERE也只留下TRUE,因此这条未知客户的订单不会构成匹配;不是把NULL当成了一个确定不同的编号。
EXISTS关注的是子查询有没有返回行,不是SELECT列表里的值是不是NULL。SELECT 1表达“不需要订单的内容”,不是魔法常量。也不要漏掉o.customer_id = c.id:若完全去掉相关条件,只要订单表非空,就会让所有客户都被排除。MySQL EXISTS文档
5. LEFT JOIN也可以,但检查哪一列很重要
SELECT c.id FROM blog_null_customers AS c LEFT JOIN blog_null_orders AS o ON o.customer_id = c.id WHERE o.id IS NULL ORDER BY c.id;左连接让没有匹配订单的客户也保留下来,右侧列补成NULL。订单id是非空主键:真实匹配行的id不可能为空,因此o.id IS NULL能识别“右侧没有匹配”。
不能把判断随便改成下面这样:
SELECT c.id FROM blog_null_customers AS c LEFT JOIN blog_null_orders AS o ON o.customer_id = c.id WHERE o.memo IS NULL ORDER BY c.id;它会错误输出1、2、3。客户1有订单,但订单10的备注恰好为空。“没有匹配行”和“匹配行里的某个字段为空”不是同一个事实。
这里用o.id是因为它的约束明确。对这个特定等值连接,检查o.customer_id IS NULL也能成立:左侧id非空,匹配成功的右侧customer_id必然等于它;但这种推理依赖连接条件,不能外推成任何可空列都能当匹配标记。
6. 两个边界,避免只记一句口诀
可以在前面建好表的连接中执行:
SELECT 1 NOT IN (1, NULL) AS matched_value, 2 NOT IN (1, NULL) AS unmatched_value, NULL NOT IN (1) AS null_left_value, NULL NOT IN ( SELECT customer_id FROM blog_null_orders WHERE 1 = 0 ) AS null_left_empty_set;本次输出依次为0、NULL、NULL、1。
最后一列提醒我们:子查询为空与子查询含一行NULL不同,不能都叫“里面没有值”。即使左侧是NULL,对空子查询的NOT IN也能为TRUE。
另外,若外层键允许NULL,过滤掉子查询NULL的NOT IN与普通等值相关的NOT EXISTS也不总等价:右侧非空时,前者可能得到未知,后者因找不到相等行而为真。本文客户主键非空,才避免了这个差异。是否应该输出未知客户,必须先定业务语义,不能先选语法再补解释。
7. 回到原来的“没下单客户”
原来的直觉可以保留:把已经下单的客户排除。但实现前需要补三个问题:
- 被比较的两侧,是否可能为NULL?
- 未知客户ID的订单,在业务上应该如何处理?
- 如果用连接判断“没有匹配”,被检查的列是否真的能标识匹配?
真正值得带走的不是“以后不用NOT IN”,而是一个排除查询是否正确,取决于空值约束和匹配语义,不只取决于它看起来像不像集合运算。
附录:怎样验证这些结论
本地验证从这篇正文提取SQL,运行隔离MySQL实例,只监听本地Unix socket,不连业务数据库。客户固定为1、2、3,订单客户ID从1、2、3、4、NULL中选,枚举长度0到3的有序列表,共1+5+25+125=156组。
这些样本包含空表、只有NULL、重复订单、部分客户匹配、全部客户匹配以及不存在的客户4。验证程序用独立的集合判断计算预期:收集已知订单客户ID,再列出不在集合里的客户。
对每组数据,过滤NULL的NOT IN、NOT EXISTS、正确LEFT JOIN都必须与预期一致。原NOT IN也被检查,但它的预期来自三值语义:已经匹配为假,没有匹配且存在NULL为未知,否则为真。我们不是把错误写法偶尔输出正确当成它已经安全。
还做两个故意破坏的对照:漏掉NOT EXISTS的相关条件,以及把LEFT JOIN的非空主键检查改为可空备注检查。固定样例必须拒绝这两个错误。最后单独检查上一节四个表达式的结果。
156组只是这个明确小范围的穷举,不是所有类型转换、字符排序规则、多列比较和数据库版本的兼容性证明。没有测性能,不给NOT EXISTS或JOIN贴“永远更快”的标签;性能要在实际数据、索引和执行计划下另行核对。MySQL子查询优化