先交代一个我实际碰到过的场景:某在线商城做活动,运营要拉一份“非会员订单明细”,开发顺手写了一版NOT IN子查询,当时测试环境也就几千条数据,跑得飞快,谁也没当回事。结果上线第二天,这条 SQL 在高峰期直接把主库 CPU 拉满,慢查询日志里满满当当全是它,单次执行时长三十多秒。等我把执行计划捞出来一看,DEPENDENT SUBQUERY,外层订单表一百多万行,子查询全表扫,按这个执行方式算下来理论扫描行数是百万乘以百万的级别——这已经不是调参能救的了。
后来改了一版NOT EXISTS,同样的表、同样的索引、同样的业务逻辑,跑到几百毫秒。从那以后我就把NOT IN当成了高危语法来对待。
这篇文章就把这个坑彻底拆开聊:NOT IN为什么会慢、慢在哪、什么时候会踩 NULL 陷阱、怎么改写才能让优化器走高效的 anti join。适合正在写业务 SQL 的后端开发,也适合负责数据库优化的 DBA,看到就是赚到。
1. NOT IN 语句:先看看它到底慢在哪
很多人对NOT IN的印象停留在“能用,偶尔慢”,但其实这个语法在 MySQL 里有三个非常隐蔽的坑:NULL 值语义、子查询执行模型、索引匹配方式。这三个坑叠加在一起,才是线上性能事故的真正根源。
1.1 三个隐藏的“坑”:NULL、逐行执行与索引空转
先说 NULL 陷阱,这是最容易被忽略的一个。SQL 里的判断逻辑是三值逻辑,除了 TRUE 和 FALSE,还有 UNKNOWN。NOT IN展开后的判断是“不等于列表里任何一项”,翻译成逻辑就是多个AND条件的叠加。但凡其中一项和 NULL 比较,结果就是 UNKNOWN,整体条件不成立,这一行就被过滤掉。直接看例子:
SELECT '命中' AS result WHERE 1 NOT IN (1, 2, NULL); -- 返回结果:空,没有“命中”1 NOT IN (1, 2, NULL)等价于1 != 1 AND 1 != 2 AND 1 != NULL,前两个条件都是 FALSE 或 TRUE,但第三个1 != NULL是 UNKNOWN,整个 AND 链的结果就是 UNKNOWN,所以查询返回空。放到实际业务里更可怕:
SELECT * FROM orders WHERE customer_id NOT IN (SELECT customer_id FROM vip_customers);只要vip_customers.customer_id这一列里存在任何一个 NULL 值,整个查询结果就是空的。你查“非会员订单”,结果一行都不返回,运营那边看到的数据就是空白。而且这个结果不是报错,是静默出错,线上排查很难想到是 NULL 的问题。
再说执行模型。MySQL 对NOT IN子查询的经典执行方式是DEPENDENT SUBQUERY,也就是相关子查询。什么叫相关?外层查询每取出一行,都要拿这一行的值去执行一次子查询。外层订单表有 100 万行,子查询就被执行 100 万次。如果子查询走的还是全表扫描,哪怕另一张表只有 20 万行,实际对比次数也是 100 万乘以 20 万,这个量级放在任何机器上都是灾难。
最后是索引空转问题。很多开发者以为内外表关联列都有索引,子查询就一定会走索引。但实际上NOT IN转成DEPENDENT SUBQUERY之后,优化器对子查询的优化非常有限,尤其在 5.7 版本里,经常出现内层查询type = ALL的情况。就算索引存在,也可能因为字符集不一致、排序规则不一致、数据类型隐式转换而无法使用。索引建了等于白建,全表扫描一次比一次实打实。
1.2 执行计划怎么读:从 EXPLAIN 看 NOT IN 的真实代价
不要猜,直接把 SQL 丢进EXPLAIN看执行计划。以下是一个典型场景:
EXPLAIN SELECT * FROM orders WHERE customer_id NOT IN ( SELECT customer_id FROM vip_customers );这是 MySQL 5.7 和部分 8.0 场景下的典型输出:
+----+--------------------+---------------+------------+--------+---------------+ | id | select_type | table | type | key | rows | +----+--------------------+---------------+------------+--------+---------------+ | 1 | PRIMARY | orders | ALL | NULL | 1200000 | | 2 | DEPENDENT SUBQUERY | vip_customers | ALL | NULL | 200000 | +----+--------------------+---------------+------------+--------+---------------+看到两个关键信息:DEPENDENT SUBQUERY和type = ALL。前者说明子查询是相关子查询,外层每行执行一次;后者说明子查询没有走索引,每次执行都是全表扫描。120 万行订单表每条都触发一次 20 万行的全表扫描,理论上最大比较次数是 2400 亿次。没有索引的情况下,这个 SQL 存在慢查询日志里一点都不冤枉。
这里补充一个实操小技巧:5.7 里看EXPLAIN就知道有没有救,但如果你用的是 MySQL 8.0.18 及以后,建议直接上EXPLAIN ANALYZE,它能输出每个节点真实执行的时间、行数和循环次数,一眼就能看到“子查询执行了 120 万次”这样的现实。没有它,很多优化效果只能靠猜。
1.3 版本差异:5.7 与 8.0 的优化器行为差异
MySQL 8.0 对子查询的优化能力比 5.7 强很多,但这不代表NOT IN就完全安全了。8.0 优化器会把部分非关联子查询物化处理,8.0.18 之后还引入了 hash join,能够把某些NOT IN改写成 anti join 执行。但要注意,优化器的这些转换是有前提条件的,聚合函数、NULL 值可能性、关联条件复杂度过高都可能让优化器放弃转换,退回逐行执行的老路。
| 版本 | NOT IN 子查询的典型行为 | 风险等级 |
|---|---|---|
| MySQL 5.6 | 基本只支持 SUBQUERY/DEPENDENT SUBQUERY,逐行执行 | 高 |
| MySQL 5.7 | 增加 semi-join 物化优化,但 NOT IN 仍常走 DEPENDENT SUBQUERY | 高 |
| MySQL 8.0.17 及之前 | 优化器更智能,部分场景可物化子查询 | 中 |
| MySQL 8.0.18+ | 支持 hash join,有机会转换成 anti join | 中 |
| MySQL 8.0 含聚合/复杂子查询 | 仍可能退化为逐行执行 | 高 |
所以我的建议很直接:在生产环境不要赌优化器,直接写能明确触发 anti join 的语法,从源头避免不确定性。MySQL 5.7 到 8.0 的升级不是万能药,SQL 写法本身才是根本。
2. 性能瓶颈背后的原理剖析
只看表面现象不够,想要真正掌握优化方法,得理解NOT IN和NOT EXISTS在数据库引擎层面到底是怎么执行的。这一节把原理拆开讲透。
2.1 相关子查询的“逐行执行”模式
DEPENDENT SUBQUERY是被动式执行模型的典型代表。数据库的嵌套循环连接(Nested Loop Join)理论上就是内外两层循环,外层表出驱动行,内层表逐行匹配。这种模式本身不可怕,正常的索引连接也能跑得很快。可怕的是NOT IN子查询在 5.7 版本中经常拿不到索引,导致内层查询从“索引查找”变成了“全表扫描”。
用个生活化的类比帮你理解:NOT IN的逐行执行方式,好比超市收银员每扫一件商品,都要跑到仓库里把所有货物清点一遍,确认这件商品不在库存清单里。一件商品清点一次,一千件商品就清点一千次,每次都是全套动作。而正常带索引的关联查询,相当于收货的时候已经把货物按货架位置编好了号,收银员直接根据编号查位置,一次定位。这就是为什么同样的逻辑,写法不同,性能能差出几万倍。
代价估算公式也很简单:外层表 m 行,内层表 n 行,无索引全表扫描时内层每次代价约 n 次比较,总代价 O(m*n)。加入索引后内层代价降到 O(log n),总代价 O(m log n)。m 和 n 都在百万量级时,这两个公式算出来的差异是天文数字。
2.2 NOT IN 与 NOT EXISTS 的本质区别
NOT IN和NOT EXISTS在语义上有本质差别,它们的执行路径也因此完全不同。NOT EXISTS基于二值逻辑,它关心的是“是否存在满足条件的行”,一旦找到一行能匹配,立即短路返回;而NOT IN需要判断“是否不等于集合中的每一个值”,遇到 NULL 就卡住,没有短路机制。
-- NOT EXISTS 写法 SELECT * FROM orders o WHERE NOT EXISTS ( SELECT 1 FROM vip_customers v WHERE v.customer_id = o.customer_id );当子查询结果集中存在 NULL 时,NOT EXISTS依然能正确返回“不在集合中”的行,因为它的判断不涉及 NULL 比较。而NOT IN直接短路,返回整个查询为空。这也是为什么官方文档和社区都在强调:子查询结果列有 NULL 可能时,把NOT IN改成NOT EXISTS不仅是为了性能,更是为了结果正确性。
从优化器角度看,NOT EXISTS是 anti join 的标准表达形式。MySQL 8.0 的优化器识别出这种反连接模式后,会优先选择 hash anti join 或 batched key access 等方式,避免逐行执行。NOT IN在某些情况下也能被识别为 anti join,但优化器会额外做 NULL 安全检查,一旦发现潜在 NULL 风险,就会退化为保守的逐行执行。
2.3 驱动表与被驱动表:谁驱动谁,影响有多大
SQL 优化中有一个经典原则:小表驱动大表。这个原则放在NOT IN和NOT EXISTS身上尤其明显。NOT IN的语义结构决定外层表是驱动表,内层子查询是被驱动表,在执行计划里几乎无法互换。如果外层表是大表,那么相当于大表逐行驱动小表查询,每次都重新执行一遍子查询,性能自然差。
而NOT EXISTS改写后,优化器有更大的自由度选择驱动表。哪个表更适合做驱动、哪个表更适合做被驱动,优化器会根据统计信息和索引情况做成本估算。只要关联列存在索引,优化器通常会选择小表驱动大表,让带索引的大表做被驱动侧查找。
来看一个真实估算场景。订单表 120 万行,会员表 20 万行:
NOT IN,执行计划往往固定为订单表驱动、子查询全表扫描,对比次数约 2400 亿次NOT EXISTS,优化器选择会员表驱动(20 万次循环),订单表走主键索引查找,每次 O(log n) 级别,总代价可能低于百万次
同样的表结构、同样的业务逻辑,仅仅是语法的不同,执行计划完全不同。这就是为什么说“SQL 写法决定性能下限”,你写成什么样,数据库就只能尽量在这个框架内优化。
3. 优化方案对照与实战操作
理论铺垫够了,进入实战环节。这一节给三类改写方案:NOT EXISTS、LEFT JOIN + IS NULL、临时表物化 JOIN,并附上实际执行计划和耗时对比,可以直接抄作业。
3.1 方案一:换成 NOT EXISTS 改写
最推荐的改写方案,语义最接近原版,改动最小:
SELECT o.* FROM orders o WHERE NOT EXISTS ( SELECT 1 FROM vip_customers v WHERE v.customer_id = o.customer_id );注意点:子查询的 SELECT 列表写1就行,不关心具体查什么列,只关心是否存在匹配行。另外,关联条件v.customer_id = o.customer_id两边的列类型和字符集需要完全一致,否则索引照样失效。
为什么推荐它?因为NOT EXISTS的语义是“找不到匹配就返回”,优化器可以放心地把它转换成 anti join。5.7 里虽然不会生成 hash anti join,但至少关联查询能走ref类型索引访问,内层扫描量大大降低。8.0.18 之后如果满足 hash join 条件,性能还会再上一个台阶。
对比EXPLAIN:
+----+-------------+-------+------+---------------+---------+---------+---------------------+ | id | select_type | table | type | key | rows | Extra | +----+-------------+-------+------+---------------+---------+---------+---------------------+ | 1 | SIMPLE | vip_customers | index | PRIMARY | 200000 | Using index | | 1 | SIMPLE | orders | ref | idx_customer | 6 | Using where | +----+-------------+-------+------+---------------+---------+---------+---------------------+注意执行计划的顺序,vip_customers在前面,orders在后面。这意味着优化器选择了会员表做驱动表,订单表通过idx_customer索引做被驱动查找,扫描行数从“百万级乘以百万级”降到了“二十万次索引命中”,差距一目了然。
3.2 方案二:LEFT JOIN + IS NULL 改写
反连接的另一种标准写法,用外连接配合空值判断:
SELECT o.* FROM orders o LEFT JOIN vip_customers v ON o.customer_id = v.customer_id WHERE v.customer_id IS NULL;逻辑上很好理解:先左连接,能连上说明是会员,连不上说明不是会员,最后把连不上的行过滤出来。这种写法非常直观,也是很多 SQL 规范文档里推荐的“避免子查询”方案。
从优化器角度,LEFT JOIN ... WHERE ... IS NULL是一种明确的 anti join 模式,执行计划会显示Anti或类似标记。在 MySQL 8.0.18+ 中,它会优先使用 hash anti join,性能非常稳定。和NOT EXISTS相比,两者在多数场景下性能相当,但LEFT JOIN写法更适合“从结果集里剔除某些行”的思维模式,可读性更强。
不过有一个细节要提醒:如果连接列允许 NULL,LEFT JOIN写法本身是安全的,因为IS NULL判断的是连接后的主表列,不会受到子查询 NULL 影响。但如果你写的是RIGHT JOIN且判断方向搞反,结果就容易出错。建议统一用LEFT JOIN,方向固定,好记不易错。
3.3 方案三:临时表物化 + 索引 JOIN
有些复杂场景,子查询里带了聚合、GROUP BY、多表关联,直接改NOT EXISTS可能因为关联条件太复杂而难以优化。这时候可以先物化结果,再走 JOIN:
-- 第一步:物化子查询结果 CREATE TEMPORARY TABLE tmp_vip AS SELECT DISTINCT customer_id FROM vip_customers WHERE status = 1; -- 第二步:给临时表加索引 ALTER TABLE tmp_vip ADD PRIMARY KEY (customer_id); -- 第三步:反连接查询 SELECT o.* FROM orders o LEFT JOIN tmp_vip v ON o.customer_id = v.customer_id WHERE v.customer_id IS NULL;主键索引必须加,否则临时表全表扫描,性能还是上不去。这个方案的适用场景很明确:子查询本身计算复杂度高、无法直接走索引关联、或者子查询结果需要复用。它的优点是把“逐行执行子查询”变成了“子查询只算一次,之后纯索引关联”,计算量大幅下降。
临时表方式还有一个额外好处:物化出来的结果集如果很小,比如只有几百行,那么全量加载到内存里做 hash 关联非常快;即使结果集有几万行,配合主键索引做 nested loop 也比重复执行子查询快得多。但它也有代价——临时表占用内存或磁盘空间,所以不适合无限大的结果集,实践经验是物化结果在千万行以内都还能接受。
3.4 各方案执行计划与耗时对比实测
为了让你直观看到差异,我放一组测试环境的实测结果。环境:MySQL 8.0.28,订单表 120 万行,customer_id 有普通索引;会员表 20 万行,customer_id 有唯一索引。业务需求:统计非会员订单量。
| 写法 | 执行计划关键特征 | 扫描行数估算 | 实测耗时 |
|---|---|---|---|
| NOT IN | DEPENDENT SUBQUERY 逐行执行 | 120万 × 20万 | 32 秒 |
| NOT EXISTS | 会员表驱动,订单表 ref 查找 | 20万次单行查找 | 0.8 秒 |
| LEFT JOIN IS NULL | 8.0 hash anti join | 120万 + 20万全量读一遍 | 0.5 秒 |
| 临时表 + JOIN | 物化一次,索引关联 | 20万 + 索引查找 | 0.7 秒 |
这个数据不是极端场景,就是很普通的业务量。120 万行的订单表在电商行业只能算小表,20 万行的会员表更是常态。即便如此,NOT IN的耗时是其他写法的几十倍。如果数据量翻十倍,这个差距会扩大到不可接受的程度。
有一个细节我说一下:LEFT JOIN IS NULL在 8.0.18+ 版本里的耗时经常比NOT EXISTS更短,因为它用 hash anti join 时,只需要把两张表各自完整读一遍,在内存里做哈希匹配,不需要反复回表。但低版本 MySQL 没有这个算子,LEFT JOIN有时候反而比NOT EXISTS慢。所以版本也要考虑进去。
4. 优化过程中容易踩的坑与排查技巧
改写方案写了半天,你会发现一个残酷的现实:有时候写完NOT EXISTS,一跑执行计划,还是全表扫描。优化并没有一步到位。这一节把我在实际排查中遇到的高频问题整理成清单,按图索骥能省不少力气。
4.1 索引明明存在却没用上的排查思路
最常见的情况:关联列明明建了索引,但EXPLAIN显示type = ALL,key = NULL。这时候不要怀疑人生,按顺序查下面几个点。
第一,检查数据类型是否一致。orders.customer_id是bigint,vip_customers.customer_id是varchar,这种情况 MySQL 会对列做隐式类型转换,索引大概率失效。看两张表的SHOW CREATE TABLE,类型不一致就改类型,或者显式 CAST。
第二,检查字符集和排序规则。一张表是utf8mb4,另一张是utf8,关联时索引也会失效。用下面的 SQL 快速确认:
SELECT t1.table_name, t1.column_name, t1.character_set_name, t1.collation_name, t2.table_name, t2.column_name, t2.character_set_name, t2.collation_name FROM information_schema.columns t1 JOIN information_schema.columns t2 ON t1.column_name = t2.column_name WHERE t1.table_name = 'orders' AND t2.table_name = 'vip_customers';第三,检查函数包裹。如果你在关联列上写了函数,比如DATE_FORMAT(create_time)或者在关联条件里对列做函数运算,索引必然失效。WHERE v.customer_id = o.customer_id没问题,但WHERE LOWER(v.customer_id) = LOWER(o.customer_id)就是灾难。
第四,检查 NULL 值占比。如果某列的 NULL 值行数占比特别高,优化器可能认为“走索引回表还不如全表扫描”,主动放弃索引。这种场景下可以考虑给列加NOT NULL约束,或者拆分查询条件。
4.2 优化器选错执行计划的干预手段
有时候索引没问题、类型没问题,但优化器就是选了错的执行计划。常见的原因是统计信息过期,优化器手里的数据失真了。先执行ANALYZE TABLE orders;和ANALYZE TABLE vip_customers;刷新统计信息,再看执行计划是否变化。
如果统计信息没问题还是选错,可以在 SQL 层面用STRAIGHT_JOIN强制连接顺序。注意,STRAIGHT_JOIN会强制优化器按表在 SQL 中出现的顺序连接,不会自动选择最优驱动表,所以使用前要自己明确哪张表更适合做驱动表。
SELECT o.* FROM vip_customers v STRAIGHT_JOIN orders o ON v.customer_id = o.customer_id WHERE v.customer_id IS NULL;这里的顺序是vip_customers在左、orders在右,配合STRAIGHT_JOIN会强制以会员表为驱动表。不过它是把双刃剑,一旦后续表数据量变化,这个固定连接顺序可能变成性能瓶颈。谨慎使用,上线前用不同数据量压一遍。
更精细的干预是调整optimizer_switch。比如确认子查询物化是否开启:
SELECT @@optimizer_switch LIKE '%materialization=on%';如果物化被关闭,某些场景下子查询执行计划会很差。但全局改optimizer_switch影响面太大,不建议线上直接动,还是从 SQL 层面改写更安全。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| NOT IN 返回空结果 | 子查询结果列存在 NULL | 改用 NOT EXISTS 或 LEFT JOIN IS NULL |
| NOT IN 执行计划为 DEPENDENT SUBQUERY | 子查询关联列无索引 | 给关联列加索引,或改写为 NOT EXISTS |
| 改写后仍全表扫描 | 两张表关联列类型/字符集不一致 | 检查信息架构表,统一列定义 |
| 8.0 仍不使用 hash anti join | 关联列含 NULL 或条件过于复杂 | 使用临时表物化 + 索引 JOIN |
| 参考数据量膨胀后执行计划突变 | 统计信息过期 | 执行 ANALYZE TABLE 刷新统计信息 |
| 明明有索引但 type 为 ALL | NULL 值占比过高,隐式转换 | 调整列约束,修正类型,或使用 FORCE INDEX |
如果你遇到某个慢查询但列不在表里,优先用EXPLAIN ANALYZE看最耗时的节点,精确到算子级别。这个工具比EXPLAIN信息丰富得多,能输出“每个节点的循环次数、单次耗时、实际返回行数”,排查效率提升一个档次。
这里再分享一个我自己的排查经验。有一回线上NOT EXISTS改写后还是慢,查来查去,最后发现是vip_customers.customer_id和orders.customer_id一个int一个varchar,隐式转换导致索引失效。那两条 SQL 之前分别是从不同业务模块建的,表结构根本没有人统一设计。后来把varchar改成bigint,问题立刻消失。类型统一这件事,在建表阶段就搞定,后面能省太多事。
根据我个人的体会,NOT IN这个语法在 MySQL 里属于“能用但不要随便用”的类型,数据量小、受控环境、字段绝对不会有 NULL,那没什么问题;一旦上了生产、数据量过百万、子查询来源复杂,强烈建议直接禁用这种写法,通过规范把风险扼杀在代码评审阶段。
最后给你一个小技巧:建一个团队内部的 SQL 规范文档,明确列出“子查询一律用 EXISTS / JOIN 表达,禁止写 NOT IN 和 IN 大表子查询”,并在发布会流水线里加一条EXPLAIN检查。这件事听起来简单,但真能帮你挡掉不少线上事故。反正我自己在经历过几次深夜慢查询报警之后,已经把这条规则当成铁律了。