NOT EXISTS 是最安全的“不存在”语义表达,只判断子查询是否返回结果,不关心内容、NULL 或重复,但必须正确关联外层表且右表建合适索引。

NOT EXISTS 是最安全的“不存在”语义表达
当你想查“左表某行在右表中完全找不到匹配”,NOT EXISTS 是首选。它不关心右表有多少条匹配、字段是否为 NULL、有没有重复,只判断“是否存在至少一条满足条件的记录”。一旦子查询里漏掉外层关联(比如 r.order_id = o.id),逻辑就全错——要么全返回,要么全不返回。
常见错误现象:NOT EXISTS 返回空结果或全部数据,八成是子查询里没写对外层表的引用;性能差,多半是右表缺索引。
- 子查询必须写
SELECT 1(别用SELECT NULL) - 关联条件(如
b.order_id = a.id)必须出现在子查询的WHERE中,不能省 - 业务过滤(如
b.status = 'cancelled')也放子查询WHERE里,和关联条件并列 - 右表建议建联合索引,例如
order_items(order_id, status),前导列必须是关联字段
LEFT JOIN + IS NULL 容易误用但调试直观
这种写法适合你明确知道右表主键/外键字段非空,且想边写边验证中间结果。但它对条件位置极其敏感:过滤逻辑若错放 WHERE 而非 ON,会把本该保留的左表行意外筛掉。
典型翻车场景:查“注册满30天但从未发帖的用户”,却把 p.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) 写在 WHERE 里——结果连刚注册的用户都进不来,因为 p.id IS NULL 和时间条件形成矛盾。
- 右表的业务过滤(如时间范围、状态)必须写在
ON子句中,不是WHERE -
WHERE里只留p.id IS NULL(或等效的非空主键判空),别加其他条件 - 确保右表关联字段有索引,否则
LEFT JOIN易退化为嵌套循环+全表扫描 - 如果右表该字段本身允许
NULL,还得额外加AND p.id IS NOT NULL做清洗,NOT EXISTS则天然规避这点
为什么 NOT IN 应该被绕开
NOT IN 看似简洁,但只要子查询结果里出现任意一个 NULL,整条查询就返回空集——这不是bug,是三值逻辑的必然结果。而且它无法利用索引加速,优化器通常只能走哈希或排序合并,大数据量时性能断崖式下跌。
比如 SELECT * FROM orders WHERE customer_id NOT IN (SELECT id FROM customers),只要 customers.id 有一行是 NULL,哪怕其他十万行都正常,结果也是空。
- 永远不要在子查询中使用可能含
NULL的字段(如允许为空的外键、SELECT DISTINCT后未WHERE IS NOT NULL的字段) - 即使加了
WHERE id IS NOT NULL,执行计划仍大概率无法走索引,不如直接换NOT EXISTS - 如果只是排除固定几个 ID,用
NOT IN (1, 2, 3)是安全的,但超过几十个就该考虑临时表 +NOT EXISTS
EXCEPT / MINUS 只适合简单主键排除
EXCEPT(SQL Server、PostgreSQL、MySQL 8.0+)或 MINUS(Oracle)本质是集合差运算,要求左右两边字段类型、数量、顺序严格一致。它自动去重,不保留原始顺序,也不能带额外过滤条件。
比如你想排除“被标记为高风险且创建于本周的用户”,EXCEPT 就无能为力——它只能比对整行或指定列,没法嵌套业务逻辑。
- 仅适用于“纯ID列表排除”或“结构完全一致的两表取差”这类简单场景
- MySQL 5.7 或更早版本不支持
EXCEPT,必须降级为LEFT JOIN或NOT EXISTS - 如果右表数据量大,
EXCEPT可能触发临时表和排序,而NOT EXISTS配合索引可走索引查找 - 注意字段隐式转换:比如左表
id是BIGINT,右表是VARCHAR,EXCEPT可能静默失败或结果异常
复杂点在于:NOT EXISTS 的子查询看似简单,但每行外层数据都会触发一次独立执行;一旦索引没建对、关联条件写错、或用了函数包装字段(如 DATE(p.created_at)),就立刻失去索引能力,变成全表扫描。这比写错 LEFT JOIN 更难排查——因为执行计划里它可能显示“已用索引”,实则因隐式转换失效。

















