IN与EXISTS怎么选:语义、执行计划与NULL陷阱
IN 和 EXISTS 这两个关键字,我见过太多人用"哪个快用哪个"的方式来做选择,也见过有人在技术评审里张口就是"EXISTS 一定比 IN 快",然后被追问"在什么条件下"就答不上来了。真实情况是:这两者在语义上并不完全等价,在执行层面谁快谁慢,完全取决于优化器怎么改写、子查询结果集多大、外层表有多少行、索引长什么样、统计信息准不准。脱离这些条件谈优劣,跟问"锤子和扳手哪个好用"没什么区别。
我印象最深的一次是几年前接手一个报表任务,一条带IN (子查询)的 SQL 跑了将近四十分钟还没出结果,把它改成EXISTS之后五分钟内出数。当时我也以为找到了万能钥匙,结果同一个项目里另一条 SQL 反着改才变快,这就很打脸了。后来我把执行计划一条条拉出来对比,才把这件事的边界摸清楚。这篇就把我这些年积累下来的理解、实测数据和踩过的坑完整写出来,不管你是在写业务查询、做慢 SQL 优化,还是在准备技术面试,应该都能拿到能直接用的东西。
顺带提一句,标题里那个 "EXSIST" 是个常见手滑,正确拼写是EXISTS,少一个 I 都不行,我在命令行里打错过不止一次。
1. 把语义掰开看:一个在比"值",一个在探"行"
很多性能问题追到根上其实是语义理解不到位。在讨论谁快之前,得先把这两者到底在做什么讲清楚,否则后面的优化全是碰运气。
1.1 IN 是一次集合成员测试
IN的本质是集合成员判断。写成col IN (子查询),数据库要做的事情是:把子查询的结果当成一个集合,然后判断外层这一行的col值是否落在这个集合里。从标准 SQL 的角度,它等价于col = ANY (子查询)。
这个"集合"两个字很关键,它带来了两个隐含约束。第一,子查询返回的必须是一列,你写IN (SELECT a, b FROM t)会直接报错,因为集合的元素得是同一种类型。第二,集合是去重的概念——虽然结果集里有没有重复不影响成员判断的真假,但优化器在做物化的时候往往需要建唯一索引来去重,这个额外开销后面会详细讲。
还有一个更隐蔽的点:因为是"值比较",就必然要面对 NULL。如果外层col是 NULL,或者子查询结果集里混进了 NULL,整个判断就会进入三值逻辑的 UNKNOWN 状态。这是后面第四节那个经典事故的根源。
1.2 EXISTS 只关心"有没有一行"
EXISTS完全换了个思路。它接的是一个相关子查询,数据库要做的是:对外层每一行,把相关条件代入子查询跑一遍,只要子查询能返回至少一行,结果就是 TRUE,一行都返回不了就是 FALSE。
注意这里根本没发生值比较。EXISTS (SELECT 1 FROM t WHERE t.k = o.k)里面,t.k和o.k相等与否是通过 WHERE 条件判断的,EXISTS本身只看"有没有行"。所以它天然免疫 NULL 问题——子查询里的 NULL 只是让 WHERE 条件不成立,不会污染最终的真假结果。
这也解释了一个新手经常问的问题:EXISTS (SELECT 1 ...)和EXISTS (SELECT * ...)有区别吗?没有。SELECT 列表在 EXISTS 里是被完全忽略的,写1、写*、写NULL都一样,优化器压根不会去展开那些列。习惯写SELECT 1更多是给人看的,表达"我不关心具体值"这个意图。
1.3 一个容易忽略的行为差异:重复行
假设外层表orders里有一行,对应子查询表order_items里有五条明细。用IN和用EXISTS都不会让这行orders输出五次——两者都只输出一次。这一点上它们是一致的,都表现为半连接语义。
真正的差异出现在NOT IN和NOT EXISTS上。NOT EXISTS是标准的反连接,语义清晰:子查询没匹配到就保留。而NOT IN在子查询结果集为空时的行为,和NOT EXISTS也不完全一样(前者返回空集,后者返回全部外层行),这个细节在业务上经常导致"没数据时报表全空"这种诡异现象。
我一般跟团队里的新人这么总结:IN 是在问"这个值在不在这个名单里",EXISTS 是在问"这个条件下有没有记录"。前者是值域问题,后者是存在性问题。想清楚你要问的是哪一种,选型就有依据了。
2. 优化器动手脚的地方:半连接改写与物化开销
理解语义只是第一步。真正决定性能的,是数据库优化器拿到这条 SQL 之后怎么改写它。这块不搞清楚,你永远在靠猜。
2.1 子查询物化:把结果搬进临时表
以 MySQL 8.0 为例,当它遇到col IN (SELECT ...)且子查询是非相关的(子查询里不引用外层表的列),一个很自然的处理方式是物化:先把整个子查询跑完,结果写进一张临时表,然后在外层表上逐行去这张临时表里查。
物化本身不一定是坏事,如果子查询结果集不大、外层表很大,一次物化的成本远低于反复执行子查询。但代价也很明确:
- 临时表需要建索引,而
IN语义要求去重,所以建的往往是唯一索引,插入时每行都要做一次唯一性检查。 - 临时表默认可能落在磁盘上,结果集一大会触发落盘,性能断崖式下跌。
- 物化之后外层表还是得逐行去临时表里探,如果外层表本身没有合适的过滤条件,那就是一次变相的全表扫。
所以那种"子查询结果几百万行、外层表也几百万行"的场景,IN往往是最差的写法。
2.2 半连接改写:把子查询拉平成 JOIN
现代优化器还有另一条路:半连接(Semi-Join)改写。简单说就是把IN或EXISTS子查询转成一种特殊的 JOIN,匹配到就输出一次外层行,匹配到多行也不重复输出。
MySQL 从 5.6 开始支持这批策略,到 8.0 已经相当成熟了,常见的有:
| 策略 | 大致做法 | 适合的场景 |
|---|---|---|
| FirstMatch | 外层每行去子查询里找,找到第一条就停 | 子查询匹配率高,能快速短路 |
| LooseScan | 在子查询表的索引上做松散扫描,跳过重复键 | 子查询表上有高选择性的索引 |
| Materialization | 物化成带唯一索引的临时表 | 子查询结果集小、外层表大 |
| DuplicateWeedout | 用临时表记录已输出的外层行,防止重复 | 关联条件非唯一 |
这张表我建议记住一个直觉:优化器是在"短路"和"批量"之间做权衡。FirstMatch 靠的是尽早停止,Materialization 靠的是一次算完。到底选哪个,取决于它对数据分布的估算。
这里有个很多人不知道的点:EXISTS 在 8.0 里同样会被尝试改写成半连接。也就是说"IN 会被改写、EXISTS 不会被改写"这个说法在旧版本上还有点道理,在新版本上已经站不住了。如果你在 5.7 或更早的版本上测出 EXISTS 快很多,升级到 8.0 之后很可能两条 SQL 的执行计划一模一样。
2.3 相关子查询上的路径依赖
如果子查询里引用了外层表的列(相关子查询),情况就复杂了。优化器如果没能把它改写成半连接,就会退化成依赖子查询——外层每来一行,子查询就完整执行一次。这个代价是 O(外层行数 × 子查询单次成本),外层表一百万行的话基本就是灾难。
EXISTS因为语法上通常就是相关的,在这种退化场景里特别容易踩雷。我见过一条 SQL,外层表一百万行,EXISTS子查询里没走索引,执行计划里赫然写着每行都要扫一遍子查询表,跑了七个小时。而把它改写成IN之后,因为子查询是非相关的,优化器直接物化,几十秒出结果。
所以你看,谁快谁慢的答案跟语法没有绑定关系,绑定的是优化器能不能把它转成半连接,以及转成半连接之后选了哪种策略。
3. NULL 值这个坑:NOT IN 返回空结果的经典事故
如果要把 IN 和 EXISTS 的差异排个重要性顺序,NULL 处理绝对是第一位的。性能问题顶多让你多等一会儿,NULL 问题会让你拿到错误的业务结果,还悄无声息。
3.1 NOT IN 遇到 NULL 会返回空集
这是最经典的坑。x NOT IN (1, 2, NULL)从逻辑上等价于:
x <> 1 AND x <> 2 AND x <> NULL前两个比较结果可能是 TRUE 或 FALSE,但x <> NULL永远是UNKNOWN。而AND运算里只要有一个操作数是 UNKNOWN,整个表达式的结果就不可能是 TRUE(TRUE AND UNKNOWN = UNKNOWN,FALSE AND UNKNOWN = FALSE)。
也就是说,带 NULL 的那一项会把整个表达式的结果压成 UNKNOWN 或 FALSE,永远等不到 TRUE。最终查询返回空结果集。
更狠的地方在于,子查询里只要混进一行 NULL,整个NOT IN就废了。比如你要查"没有下过单的客户",写成WHERE id NOT IN (SELECT customer_id FROM orders),只要orders.customer_id里有一条 NULL,结果就是空——但业务方看到的是"所有客户都下过单",这个错误极其隐蔽。
3.2 NOT EXISTS 为什么天然安全
同样的需求改成NOT EXISTS:
SELECT c.id FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id );EXISTS只看子查询有没有行。即使o.customer_id里有 NULL,那行也只是在 WHERE 条件o.customer_id = c.id处被判为 UNKNOWN,不满足条件,不参与匹配,不会污染外层结果。语义干净利落。
所以我的经验规则是:只要是为了表达"不存在"这个语义,优先用 NOT EXISTS,除非你能百分之百确认子查询那一列有 NOT NULL 约束。
3.3 反连接改写的前提条件
这里还有一层技术细节。优化器想把NOT EXISTS改写成反连接(Anti-Join),也是有前提的:子查询的连接列必须是 NOT NULL 的,或者优化器能证明它在语义上非空。
PostgreSQL 在这方面比较保守——它的NOT IN子查询如果无法确认子查询列非空,会生成一种带 NULL 检查的哈希子计划,每次匹配都要额外判断 NULL。而一旦子查询列声明了NOT NULL,它就能放心改写成标准的反连接,性能差异可能有数倍。
MySQL 这边的处理更粗糙一些,NOT IN在遇到可能含 NULL 的子查询时,改写选项会大幅收窄,很多时候干脆退化成逐行探测。这又是一个"加个 NOT NULL 约束就能提速"的真实案例。
| 写法 | 子查询含 NULL 时的结果 | 能否改写成反连接 |
|---|---|---|
NOT IN | 返回空结果集 | 仅在子查询列确定非空时可以 |
NOT EXISTS | 正常返回,语义正确 | 大多数情况下可以 |
这半张表我在团队内部文档里贴了三年,每次 code review 看到NOT IN都会翻出来对一遍。
4. 反直觉的部分:有些场景 IN 就是比 EXISTS 快
前面说的这些容易让人形成"EXISTS 更优"的偏见。但实际上,我自己实测下来,在相当多的场景里IN反而更快。这些场景有共同特征,值得单独拎出来讲。
4.1 常量列表加索引,IN 直接把等值比较吃满
这条最容易被忽视。WHERE id IN (1, 2, 3, 100, 250)这种写法里,括号里是一串常量,根本没有子查询。优化器在解析阶段就会把它展开成若干等值条件的 OR,如果id上有索引,直接走多次索引查找或者一次 range 扫描。这种场景下写EXISTS反而是给自己找麻烦——你得构造一个临时表或者写一堆 OR,没有任何收益。
即便列表长一点,比如两百个 ID,MySQL 依然能走 range 优化。有个参数叫eq_range_index_dive_limit(默认 200),当 IN 列表的等值条件数量超过它时,优化器会放弃逐值做索引潜水,改用统计信息估算,这时候执行计划可能突然从 range 变成全表扫。这个"过了某个数量就变慢"的悬崖现象,我在批量删除、批量查询的场景里遇到过好几次。解决办法一般不是换语法,而是把大批量拆成小批,或者改成 JOIN 临时表。
4.2 非相关子查询可以做全局去重,EXISTS 做不到
IN的子查询如果不是相关的,优化器有能力先算一次、全局复用。而EXISTS天然是相关的,如果优化器没能把它转成半连接,就必须逐行执行。
举个具体例子。假设有一张blacklist表,只有几百行被拉黑的 ID,外层是几百万行的users表。写成:
SELECT * FROM users u WHERE u.id IN (SELECT user_id FROM blacklist);子查询几百行,物化成带唯一索引的临时表,成本可以忽略。然后优化器可能选择在users上全表扫,每行探一次临时表,或者反过来用临时表驱动、回表查users。两种都挺快。
换成EXISTS版本,如果优化器保守,可能就变成了对users每行执行一次SELECT 1 FROM blacklist WHERE user_id = ?。虽然blacklist.user_id上有索引的话单次很快,但几百万次函数调用和索引查找累加起来,时间差距是可以到几倍的。
4.3 统计信息失真,别急着怪语法
还有一种情况特别气人:两条 SQL 逻辑完全等价,执行计划却天差地别。这时候八成不是 IN 和 EXISTS 的问题,是统计信息不准。
优化器选择物化还是半连接、选择哪个表做驱动表,全靠它对行数的估算。如果表刚批量导入过数据没跑ANALYZE,或者某个字段的数据分布极度倾斜,估算值可能差好几个数量级,选出来的计划自然离谱。我在一个日志表上就踩过:实际只有 300 行匹配,优化器估算成了 30 万行,果断选了物化 + 全表扫,结果比逐行探测还慢。
| 场景 | 我更倾向的写法 | 理由 |
|---|---|---|
| 常量列表,长度可控 | IN | 直接展开成等值/range,无需额外结构 |
| 子查询小、非相关、外层大 | IN | 物化后全局复用,成本极低 |
| 子查询大、外层小 | EXISTS | 外层行数少,逐行探测成本可控,可短路 |
| 判断"不存在" | NOT EXISTS | 规避 NULL 语义陷阱 |
| 匹配列可空且数据量大 | 先补 NOT NULL 约束再谈 | 约束到位才能走反连接 |
判断顺序我一般是:先看语义要不要处理 NULL,再看两张表的行数比例,最后才看索引。语法本身排在最后一位。
5. 自己造数据跑一遍:一套可复现的对比实验
纸上谈兵没意义。这套实验我在不同项目里复用过很多次,成本很低,一小时能跑完,但能让你对自己环境里的优化器行为建立准确直觉。
5.1 建表与数据生成
用一张客户表和一张订单表,规模刻意做成不对称:
CREATE TABLE customers ( id BIGINT PRIMARY KEY, name VARCHAR(64) NOT NULL, city VARCHAR(32) NOT NULL ) ENGINE=InnoDB; CREATE TABLE orders ( id BIGINT PRIMARY KEY, customer_id BIGINT, amount DECIMAL(10,2) NOT NULL, KEY idx_customer (customer_id) ) ENGINE=InnoDB;注意我故意让orders.customer_id可空,这样后面能直接演示 NULL 陷阱。数据量用递归 CTE 或者脚本刷,客户表 10 万行,订单表 200 万行,其中故意插入 100 行customer_id IS NULL的记录。插完记得跑一次ANALYZE TABLE,否则统计信息不准,实验结论会失真。
5.2 三条等价 SQL 的执行计划对比
-- A: IN 子查询 EXPLAIN ANALYZE SELECT * FROM customers WHERE id IN (SELECT customer_id FROM orders WHERE amount > 500); -- B: EXISTS 相关子查询 EXPLAIN ANALYZE SELECT * FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.amount > 500); -- C: 显式 JOIN + DISTINCT(对照组) EXPLAIN ANALYZE SELECT DISTINCT c.* FROM customers c JOIN orders o ON o.customer_id = c.id WHERE o.amount > 500;EXPLAIN ANALYZE比EXPLAIN有价值得多,因为它会真的执行并给出实际行数和耗时。看执行计划的时候重点盯三处:驱动表是谁、用了哪种半连接策略、估算行数和实际行数差多少。第三点尤其重要,差一个数量级以上就说明统计信息有问题,这时候优化语法是白费力气。
5.3 换个数据分布再跑一次
同一个实验,把amount > 500换成amount > 0(几乎全表匹配),再跑一遍。你会发现执行计划变了——短路策略失效,因为几乎找不到"第一条就命中"的便宜可占。再把订单表缩到 1000 行,客户表放到 100 万行,结论又会翻转。
我建议至少跑四个组合:大表驱动小表、小表驱动大表、高匹配率、低匹配率。跑完之后你会得到一个非常具体的感受:优化器的选择是有规律的,但规律跟数据分布强相关,不是跟关键字强相关。这个直觉比背十条"最佳实践"管用得多。
提示:在共享的测试环境里跑
EXPLAIN ANALYZE要小心,它会真实执行 SQL。带写操作的语句千万别这么干,只对 SELECT 用。
6. 落地时的判断顺序和几条硬规矩
讲了这么多原理和实验,最后落到怎么写。我不太喜欢给"永远用 EXISTS"这种一刀切的规则,但确实有几条我一直在用的判断顺序和硬约束。
6.1 我的实际判断顺序
第一步永远是确认语义。你要的是"值在不在名单里"还是"存不存在匹配记录"?前者用 IN 更自然,后者用 EXISTS 更自然。如果涉及否定语义(不包含、没下过单、没有记录),默认走 NOT EXISTS,除非子查询列明确 NOT NULL。
第二步看子查询是不是相关的。如果子查询里引用了外层列,先看执行计划有没有被改写成半连接;如果退化成依赖子查询,优先考虑能不能通过改写把它变成非相关的。这一步的收益往往比换关键字大得多。
第三步才看行数比例。子查询结果小、外层表大,IN 物化通常占优;子查询大、外层表小,EXISTS 短路通常占优。但这个结论必须用EXPLAIN ANALYZE验证,别靠估。
第四步是检查基础设施:索引有没有、统计信息新不新、字段该不该加 NOT NULL 约束。我见过太多"调了半天语法,最后发现是索引建错了列顺序"的案例。
6.2 几条我基本不破的规矩
- 绝不写
NOT IN加可空子查询列。这条没有例外,加约束或者换写法,二选一。 - 常量 IN 列表超过几百个就分批。不是因为语法有问题,是因为优化器在长列表上的处理会退化,行为不可预测。
- 改完 SQL 一定对比执行计划,不看计划就宣称"优化了"是自欺欺人。同样的 SQL 在不同数据量下可能选不同计划,上线前用生产规模的样本数据验一遍。
- 别迷信
SELECT 1比SELECT *快。在 EXISTS 里两者等价;在 IN 子查询里,返回多列会直接报错,这跟性能无关。 - 注意
IN列表里混入 NULL 的影响。x IN (1, 2, NULL)在 x 等于 1 或 2 时返回 TRUE,没问题;但NOT IN版本直接废掉。这个不对称性坑过很多人。
6.3 一个容易被误判的细节
还有一种情况值得单独说:IN子查询里如果带了DISTINCT,很多人以为这是必须的。其实不是——IN的语义本身就是集合成员判断,重复值不影响结果。写DISTINCT反而可能阻止优化器选择某些半连接策略,因为它会影响对子查询结果基数的估算。
反过来,如果你用的是JOIN写法,DISTINCT才是必须的,因为 JOIN 会放大行数。这个差异经常被搞混:IN 和 EXISTS 天然不放大行数,JOIN 会。理解这一点,你就明白为什么很多"JOIN 改 IN"的优化能生效——不是因为 IN 更快,是因为省掉了去重那一步的开销。
我个人在实际操作中的体会是,这类问题的答案从来不在语法书里。同一个写法,在数据量小的时候、在数据倾斜的时候、在索引刚建好的时候,表现可能完全不同。真正有价值的不是记住"用哪个",而是养成拿到一条慢 SQL 先看执行计划、先看估算与实际行数偏差、再看数据分布的习惯。这套动作走熟了,IN 还是 EXISTS 这个选择本身,反而变成了最不需要纠结的一步。