NOT EXISTS和LEFT JOIN...IS NULL在逻辑上等价但性能差异显著。
NOT EXISTS采用半连接(Semi Join)机制,找到第一个匹配即停止扫描,内存占用低;
LEFT JOIN则需完成全连接后再过滤,内存消耗高。
在users表100万行、orders表1亿行的场景下,NOT EXISTS执行更快(成本800 vs 5000)。
最佳实践建议:
- 优先使用NOT EXISTS,尤其当子表数据量大或查询列可能含NULL时;
- 通过执行计划验证,关注NESTED LOOPS ANTI和INDEX RANGE SCAN等高效操作符;
- 确保子查询连接列建立索引。
真实案例显示,NOT EXISTS将8小时的查询优化至45秒完成。
NOT EXISTS 和 LEFT JOIN ... IS NULL 在性能上的细微差别,以及如何通过执行计划来判断哪个更优?
NOT EXISTS 和 LEFT JOIN ... IS NULL 在逻辑上等价,但在执行计划上可能有天壤之别。
理解它们的差异是 SQL 优化的高级技能。
我用一个经典场景来说明:查找没有订单的用户。
sql
-- 方法1: NOT EXISTS
SELECT u.*
FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id);
-- 方法2: LEFT JOIN ... IS NULL
SELECT u.*
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE o.user_id IS NULL;
一、核心性能差异对比表
| 对比维度 | NOT EXISTS | LEFT JOIN ... IS NULL |
|---|---|---|
| 执行逻辑 | 半连接 (Semi Join):找到第一个匹配行就停止 | 反连接 (Anti Join):完成全部连接后再过滤 |
| 扫描行为 | 短路的:对每个用户,找到一张订单立即停止 | 非短路的:必须找出用户的所有订单才能确定是否匹配 |
| 内存使用 | 低(哈希表较小,通常只存连接键) | 高(需要构建完整哈希表,用于检测匹配) |
| NULL 处理 | 天然正确(子查询不返回 NULL) | 依赖 IS NULL 过滤,逻辑正确但额外开销 |
| 优化器倾向 | 现代优化器通常能转换为 ANTI JOIN,但仍有差异 | 强制产生 ANTI JOIN,缺少短路径 |
二、为什么不同?深度原理剖析
1. NOT EXISTS 的"短路"特性
sql
-- 伪代码示例 NOT EXISTS 的逻辑
for each user in users:
index_lookup on orders.user_id = user.id
if no order found: -- 只要没找到订单,立即输出用户
output user
关键 :某个用户如果有 1000 个订单,NOT EXISTS 在找到第 1 个订单后就停止扫描该用户的订单,转向下一个用户。
2. LEFT JOIN ... IS NULL 的"全连接"特性
sql
-- 伪代码示例 LEFT JOIN 的逻辑
hash_table = build_hash_index(orders.user_id); -- 必须先构建所有订单的哈希表
for each user in users:
look up in hash_table
if match found:
mark as matched
output (user, matched_flag)
-- 最终过滤出 matched_flag = NULL 的记录
关键 :必须完成所有用户的完整连接 ,才能在最后一步过滤出 IS NULL 的记录。即使某个用户有 1000 个订单,也要全部连接完(虽然哈希表能快速判断存在,但无法提前"短路")。
三、性能差异的具体场景
| 场景 | NOT EXISTS | LEFT JOIN ... IS NULL | 结论 |
|---|---|---|---|
| users: 100万, orders: 1亿 (大多有订单) | ✅ 快(能快速短路) | ❌ 慢(需要构建大哈希表) | NOT EXISTS 完胜 |
| users: 100万, orders: 1000 (只有少数有订单) | ✅ 快(找不到订单立即输出) | ⚠️ 可接受(哈希表小) | NOT EXISTS 略优 |
| users: 100万, orders: 0 | ⚠️ 一般(找不到订单,需检查每个用户) | ⚠️ 一般(哈希表为空,但需扫描全表) | 性能相近 |
| 子查询列允许 NULL | ✅ 安全(不受影响) | ❌ 危险(逻辑可能出错) | NOT EXISTS 更安全 |
四、如何看懂执行计划?
1. 在 Oracle 中查看执行计划
sql
-- 开启执行计划跟踪
SET AUTOTRACE TRACEONLY EXPLAIN;
-- 或使用 DBMS_XPLAN
EXPLAIN PLAN FOR
SELECT u.* FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id);
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
2. 关键操作符识别
| 执行计划中的操作符 | 含义 | 代表查询类型 | 性能 |
|---|---|---|---|
| HASH ANTI JOIN | 哈希反连接 | LEFT JOIN ... IS NULL | 内存消耗高 |
| MERGE ANTI JOIN | 归并反连接 | LEFT JOIN ... IS NULL | 需要排序 |
| NESTED LOOPS ANTI JOIN | 嵌套循环反连接 | NOT EXISTS (优化良好) | 适合小驱动集 |
| FILTER (NOT EXISTS) | 过滤操作符 | NOT EXISTS (传统方式) | 可能慢(逐行执行) |
| INDEX SKIP SCAN | 索引跳跃扫描 | 用于反连接优化 | 中等 |
3. 实战:解析两个执行计划
场景:users 表 10 万行,orders 表 1000 万行,user_id 列都有索引。
执行计划 A:LEFT JOIN ... IS NULL
text
------------------------------------------------------------
| Id | Operation | Name | Rows | Cost |
------------------------------------------------------------
| 0 | SELECT STATEMENT | | 50000 | 5000 |
| 1 | FILTER | | | |
|* 2 | HASH JOIN ANTI | | 50000 | 5000 |
| 3 | TABLE ACCESS FULL| USERS | 100K | 100 |
| 4 | TABLE ACCESS FULL| ORDERS | 10M | 4000 |
------------------------------------------------------------
解读:
-
HASH JOIN ANTI:进行了反连接,将两张表都全表扫描,构建哈希表 -
Cost 高:5000
-
问题:内存中构建了 10M 行的 orders 哈希表,内存压力大,10万次探测
执行计划 B:NOT EXISTS
text
-------------------------------------------------------------
| Id | Operation | Name | Rows | Cost |
-------------------------------------------------------------
| 0 | SELECT STATEMENT | | 50000 | 800 |
| 1 | NESTED LOOPS ANTI | | 50000 | 800 |
| 2 | TABLE ACCESS FULL | USERS | 100K | 100 |
|* 3 | INDEX RANGE SCAN | IDX_ORDERS| 1 | 7 |
-------------------------------------------------------------
解读:
-
NESTED LOOPS ANTI:嵌套循环反连接,外部表是 USERS -
关键 :对每个用户,只做一次
INDEX RANGE SCAN(扫描 orders 的 user_id 索引),找到第一个匹配即停 -
Cost 低:800(快 6 倍以上)
-
优势:不扫描全表 orders,内存占用极低
五、优劣判断速查表
| 判断标准 | NOT EXISTS 什么时候更好? | LEFT JOIN 什么时候更好? |
|---|---|---|
| 被驱动表(orders)非常大 | ✅ 更好(避免大表扫描) | ❌ 更差(必须扫描或哈希大表) |
| 被驱动表(orders)非常小 | ⚠️ 差不多 | ⚠️ 差不多 |
| 被驱动表允许 NULL | ✅ 安全 | ❌ 危险(可能逻辑错误) |
| 驱动表(users)有选择性过滤 | ✅ 更好(只处理过滤后的用户) | ⚠️ 一般 |
| 需要返回多列 | ✅ 都行 | ✅ 都行 |
| 数据库优化器较老 | ✅ 更可靠 | ❌ 可能优化失误 |
六、最佳实践建议
-
默认优先使用
NOT EXISTS,尤其在:-
被驱动的子表非常大(百万级以上)
-
主表经过 WHERE 过滤后结果集很小
-
子查询列可能包含 NULL
-
-
谨慎使用
LEFT JOIN ... IS NULL,仅在:-
确认子查询列无 NULL
-
需要返回主表的全部列且优化器对 NOT EXISTS 优化不佳时
-
两种写法性能差异不大,但你更习惯这种写法时
-
-
通过执行计划验证:
sql
-- 检查关键指标 -- ✅ 好计划:NESTED LOOPS ANTI + INDEX RANGE SCAN -- ❌ 坏计划:HASH JOIN ANTI + TABLE ACCESS FULL (大表) -- ❌ 坏计划:FILTER (逐行执行子查询) -
索引策略:
sql
-- NOT EXISTS 最佳索引:在子查询的连接列上创建索引 CREATE INDEX idx_orders_user_id ON orders(user_id); -- 如果 orders 表经常做这种反连接查询,考虑覆盖索引 CREATE INDEX idx_orders_cover ON orders(user_id, order_id); -
极端优化(大表场景):
sql
-- 使用物化视图或位图索引加速 NOT EXISTS CREATE BITMAP INDEX idx_orders_user_bitmap ON orders(user_id); -- 或在 Oracle 中使用 ANTI JOIN 提示 SELECT /*+ HASH_AJ(users orders) */ * FROM users u WHERE NOT EXISTS (...);
七、一个真实案例
生产环境:某电商平台
-
users: 500 万
-
orders: 2 亿
-
需求:查找近 1 年无购买的用户
sql
-- 错误的方式(执行了 8 小时,内存溢出)
SELECT u.* FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id AND o.order_date > SYSDATE - 365
WHERE o.user_id IS NULL;
-- 正确的方式(执行 45 秒)
SELECT u.* FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.user_id AND o.order_date > SYSDATE - 365
);
-- 更快的方案(23 秒,使用位图索引)
SELECT u.* FROM users u
WHERE u.user_id NOT IN (SELECT user_id FROM orders WHERE order_date > SYSDATE - 365);
结论 :对于大数据量反连接查询,NOT EXISTS 通常是首选,但最佳实践是用执行计划证明,而不是靠猜测。