SQL IN 操作符深度解析:语法、NULL 陷阱与性能优化
2026/9/9 15:17:02 网站建设 项目流程

在数据库管理系统课程里,SQL 的 WHERE 子句是过滤查询的核心,而 IN 是 WHERE 子句中最常见的集合判断操作符之一。它要解决的问题可以用一句话概括:判断一个字段的取值是否落在给定的一组候选值里。与连续写出多个 OR 条件相比,IN 的写法更直观,候选值数量变大时也更可控。

如果只把 IN 当成 OR 的缩写,很快会在两个地方吃亏:一是列表或子查询中带有 NULL 时,查询结果为什么异常;二是列表变大后,执行计划为什么不再理想。这篇文章会从语法、语义、NULL 处理、子查询场景、执行计划和排错链路几个角度,把 IN 讲完整。文中使用的例子是学生班级和成绩表,适合先照着建表实验,再迁移到自己的业务表上。

1. 理解 IN 的语法语义与可替代写法

1.1 一句话理解 IN 的作用

IN 用于判断某个字段的值是否等于一组候选值中的任何一个。比如要查询 101 班和 103 班的学生,可以直接写出下面的条件:

SELECT student_id, student_name, class_id FROM student_score WHERE class_id IN (101, 103);

这段查询等价于:

SELECT student_id, student_name, class_id FROM student_score WHERE class_id = 101 OR class_id = 103;

为什么要用 IN 而不是 OR?主要有三个原因:

  • 写法更紧凑。候选值从 2 个变成 20 个时,OR 会变得很长,IN 只是列表变长。
  • 减少运算符优先级问题。OR 和 AND 混在一起时容易产生意想不到的优先级错误,IN 把判断整体收进一组括号里。
  • 语义更接近集合判断。IN 表达的是“成员身份”判断,代码阅读者更容易理解查询意图。

在逻辑上,IN 等价于一组连续相等的 OR 判断,但在实际执行时,数据库优化器不一定真的把它展开成多个 OR,这一点放到后面执行计划部分说明。

1.2 IN 的三种书写形态

IN 在 SQL 中主要有三种写法:

写法示例适用场景注意事项
静态值列表WHERE class_id IN (101, 103)候选值固定且数量少列表不能为空,空列表会直接报语法错误
子查询WHERE class_id IN (SELECT class_id FROM class_info)候选值来自另外一张表子查询结果过大时会影响性能
行值表达式WHERE (class_id, subject) IN ((101, '数据库'), (102, '操作系统'))需要同时判断多个字段不是所有数据库都支持,使用前要验证目标版本

子查询是 IN 最实用的价值所在。它能把“主查询字段”和“另一张表或聚合结果”关联起来,比如查出所有包含高分学生的班级里的学生,这种需求如果用 JOIN 写,往往需要额外去重,而用 IN 子查询语义更直接。

行值表达式的写法在标准 SQL 中有定义,但不同数据库实现差异较大。MySQL 对行值比较支持得比较早,其他数据库有的只支持单列。实际项目中如果要判断多个字段的组合匹配,建议先查目标数据库的版本文档,再决定是否使用。

1.3 IN 与 NULL 的语义:重要但常被忽略

NULL 在 SQL 中表示“未知值”,它不等于任何值,也不等于另一个 NULL。这个特性直接影响了 IN 的判断结果。

看三个典型场景:

-- 场景一:列值不是 NULL,列表里有 NULL SELECT student_id, student_name FROM student_score WHERE student_id IN (1, NULL);

这个查询只会返回student_id = 1的行。因为1 = NULL的结果不是 FALSE,而是 UNKNOWN,但列表中只要有一个值能匹配成功,整条判断就是 TRUE。

-- 场景二:列值本身是 NULL SELECT student_id, student_name FROM student_score WHERE student_id IN (1, 2, 3);

如果某行student_id为 NULL,那么NULL IN (1, 2, 3)的结果是 UNKNOWN,WHERE 只保留 TRUE 的结果,所以这行不会返回。也就是说,IN 不会把 NULL 列值当作一个可匹配的候选值。

-- 场景三:NOT IN 的列表中包含 NULL SELECT student_id, student_name, class_id FROM student_score WHERE class_id NOT IN (101, NULL);

这个查询的结果是空集。原因是NOT IN等价于把"不等于列表中的每一个值"做 AND 拼接,其中任何一次比较出现 NULL,最终结果都可能是 UNKNOWN,WHERE 就会过滤掉这一行。

结论可以记住:NOT IN一旦遇到 NULL,结果往往不是你想要的结果。业务字段允许为空时,优先考虑用NOT EXISTS代替NOT IN

1.4 常见的可替代写法

除了 OR 和 EXISTS,部分数据库还支持= ANY(数组)= SOME(子查询)这类等价判断。遇到具体数据库时,可以把它们看成 IN 的变体,但不要每个都追求使用。通用性和可读性最好的仍然是以列表和子查询形式出现的 IN。

如果把 IN 看成集合成员判断,那么它与 EXISTS、JOIN 的关系可以这样理解:

  • IN 更适合"候选值已经确定"或"候选值来自一个不相关的子查询"的场景。
  • EXISTS 更适合"需要引用外层查询的字段"的相关子查询场景,而且它对 NULL 的处理更直观。
  • JOIN 更适合"候选值需要同时返回其他字段"或"两个表的数据量都比较大的场景"。

2. 用学生成绩表跑通 IN 的完整用法

2.1 准备测试表和测试数据

先建立两张表:班级表和成绩表。下面的建表语句使用通用 SQL 语法,MySQL、SQL Server、PostgreSQL 等主流数据库基本都能直接执行,只有小数类型和索引语法需要按各自版本微调。

CREATE TABLE class_info ( class_id INT PRIMARY KEY, class_name VARCHAR(50), teacher_name VARCHAR(50) ); CREATE TABLE student_score ( student_id INT PRIMARY KEY, student_name VARCHAR(50), class_id INT, subject VARCHAR(30), score DECIMAL(5, 2) );

初始化数据:

INSERT INTO class_info (class_id, class_name, teacher_name) VALUES (101, '计算机1班', '王老师'), (102, '计算机2班', '李老师'), (103, '软件1班', '张老师'); INSERT INTO student_score (student_id, student_name, class_id, subject, score) VALUES (1, '张三', 101, '数据库', 92.00), (2, '李四', 101, '操作系统', 85.00), (3, '王五', 102, '计算机网络', 95.00), (4, '赵六', 102, '数据库', 78.00), (5, '孙七', 103, '软件工程', 88.00), (6, '周八', 103, '数据库', 60.00);

建表后,可以先执行一条 SELECT 确认数据是否正确写入。这一步在实验环境里看似多余,但实际排错时,很多"SQL 结果不对"的问题恰恰是测试数据和自己预期不一致造成的。

2.2 静态列表过滤及结果验证

查询 101 班和 103 班的学生:

SELECT student_id, student_name, class_id FROM student_score WHERE class_id IN (101, 103) ORDER BY student_id;

预期结果:

student_idstudent_nameclass_id
1张三101
2李四101
5孙七103
6周八103

这里要注意,IN 列表中的值可以是数字、字符串、日期等,只要与列的数据类型兼容。比如按科目过滤:

SELECT student_id, student_name, subject FROM student_score WHERE subject IN ('数据库', '操作系统');

返回结果为张三、李四、赵六、周八。字符串匹配时,不同数据库对大小写和中文排序规则的处理不一样,如果发现结果和预期不一致,优先检查数据库的排序规则,而不是怀疑 IN 本身。

2.3 子查询过滤:IN 最大的实用价值

现在要找出"所在班级里有人分数大于等于 90 分"的学生。先查出满足高分条件的班级:

SELECT DISTINCT class_id FROM student_score WHERE score >= 90;

结果是 101 和 102。接着用 IN 子查询合并成一条语句:

SELECT student_id, student_name, class_id, score FROM student_score WHERE class_id IN ( SELECT DISTINCT class_id FROM student_score WHERE score >= 90 ) ORDER BY student_id;

预期结果:

student_idstudent_nameclass_idscore
1张三10192.00
2李四10185.00
3王五10295.00
4赵六10278.00

这个例子展示了 IN 子查询的核心用法:内层子查询先算出一个集合,外层查询再判断class_id是否落在集合中。内层子查询不依赖外层字段,属于非相关子查询,通常可以执行一次,然后用结果集合去匹配外层数据。

实际项目中,这类子查询可能来自权限表、订单明细、用户分组等场景。只要集合规模可控,IN 子查询写出来的 SQL 比 JOIN 加 DISTINCT 更容易理解。

2.4 NOT IN 与 NULL 的实验

先执行一个看起来合理的NOT IN查询:找出不是王老师所带班级的学生。

SELECT student_id, student_name, class_id FROM student_score WHERE class_id NOT IN ( SELECT class_id FROM class_info WHERE teacher_name = '王老师' ) ORDER BY student_id;

当前数据中王老师带 101 班,子查询结果为 101,所以最终返回 102 班和 103 班的学生,即四行。

如果子查询条件改成teacher_name = '张老师',返回 101 班和 102 班学生,也没有问题。问题出在子查询结果包含 NULL 时:

SELECT student_id, student_name, class_id FROM student_score WHERE class_id NOT IN ( SELECT class_id FROM class_info WHERE teacher_name IS NULL );

只要子查询返回的集合中存在 NULL,class_id NOT IN (NULL)对整个结果集都可能产生 UNKNOWN,最终查询返回空集。这个行为不是数据库 Bug,而是 SQL 三值逻辑的必然结果。

更稳妥的写法是使用NOT EXISTS

SELECT s.student_id, s.student_name, s.class_id FROM student_score s WHERE NOT EXISTS ( SELECT 1 FROM class_info c WHERE c.class_id = s.class_id AND c.teacher_name = '王老师' ) ORDER BY s.student_id;

NOT EXISTS判断的是"是否存在满足条件的行",不涉及值跟 NULL 做等值比较,所以它的结果更符合业务直觉。

注意:只要子查询里的字段允许为 NULL,写NOT IN之前就要先想清楚"空集"和"包含 NULL 的集合"会导致什么结果。最好的防御方式是用WHERE column IS NOT NULL把子查询里的 NULL 剔除,或者直接改用NOT EXISTS

3. 从执行计划理解 IN 的性能边界

3.1 IN、OR 与 EXISTS 在逻辑上的等价和差异

逻辑上,WHERE col IN (1, 2, 3)WHERE col = 1 OR col = 2 OR col = 3完全等价。但数据库优化器不会傻乎乎地只做展开。它可能把 IN 转换成以下三种执行方式之一:

  • 对索引列做范围扫描。
  • 把 IN 子查询转换成 semi join(半连接),只返回外层匹配的行。
  • 把 IN 列表转换成哈希表,然后对外层数据逐行探测。

EXISTS 同样可能被优化器转换成 semi join。因此在大多数现代数据库中,IN 和 EXISTS 的差距没有教科书里写的那么大。真正拉开差距的是:相关子查询的执行次数、子查询结果集的大小、索引是否存在、表的数据分布。

下面用一个简化表格说明不同场景下的选择偏向:

场景推荐写法原因
静态值列表,数量少,列上有索引IN写法简洁,优化器容易识别
子查询结果集小,外层表大IN 子查询先算集合,再匹配外层
子查询需要引用外层字段EXISTS相关子查询使用 NOT EXISTS 更直观
子查询结果集很大,且需要带上候选表的其他字段JOIN避免构造超大临时集合
业务字段允许为 NULL,且要排除子查询中的 NULLNOT EXISTS规避 NULL 三值逻辑

这些规则只作为起点,不能当成绝对结论。真实场景必须用执行计划确认。

3.2 影响 IN 子查询性能的三个因素

第一个因素是索引。如果外层查询的列上有索引,IN 通常能走索引扫描或范围扫描。反过来,如果列上套了函数,比如DATE(create_time) IN (...),索引基本会失效。

第二个因素是子查询结果集大小。IN 子查询需要先计算出候选值集合,如果子查询返回几十万行,数据库可能会选择对子查询结果做哈希表,再对外层大表做探测。此时内存消耗和耗时都会上升。

第三个因素是数据分布。优化器依赖统计信息决定使用嵌套循环、哈希连接还是排序合并。如果统计信息过期,即使有索引,优化器也可能选择错误的执行计划。生产环境里单纯修改 IN 写法往往无效,真正要做的可能是更新统计信息。

3.3 用 EXPLAIN 验证 IN 是否走索引

在 MySQL 中,可以这样做:

CREATE INDEX idx_class_id ON student_score(class_id); EXPLAIN SELECT student_id, student_name, score FROM student_score WHERE class_id IN (101, 103);

执行计划中重点关注typekey两列。理想情况下typerangerefkey显示idx_class_id,表示查询用到了索引。

Oracle 使用EXPLAIN PLAN FOR或客户端的执行计划功能,SQL Server 可以直接显示实际执行计划。不同数据库查看入口不同,但观察点一致:IN 列表是否被识别为范围条件、是否命中索引、扫描行数是否比预期少。

如果在执行计划里看到全表扫描,同时列上有索引但条件列被函数包裹,或者查询条件发生了隐式类型转换,就要优先修条件写法,而不是急着改 IN 为 EXISTS。

3.4 大列表和频繁变化的列表怎么处理

应用层常见的一个错误是:把用户勾选的多选框直接拼成一个超长 IN 列表。列表从几十个涨到几千个后,SQL 文本变长,解析变慢,执行计划也可能不再稳定。

更稳妥的替代方案是使用临时表:

CREATE TEMPORARY TABLE tmp_class_ids ( class_id INT PRIMARY KEY ); INSERT INTO tmp_class_ids (class_id) VALUES (101), (102), (103); SELECT s.student_id, s.student_name, s.class_id FROM student_score s JOIN tmp_class_ids t ON t.class_id = s.class_id;

这个写法有几个好处:

  • 列表数据从应用层批量写入临时表,不拼长 SQL。
  • 临时表列上可以建索引,JOIN 有明确的连接路径。
  • 后续如果还要统计其他指标,可以直接扩展 SELECT 和 JOIN,不需要再生成第二份 IN 列表。

注意:临时表方案会增加额外的写入动作,适合列表大、查询频率高的场景。如果列表只有几个值,直接用 IN 反而更好,过度设计只会让简单查询变得笨重。

4. 常见问题排查链路和数据库差异

4.1 结果少一行或多一行的排查顺序

使用 IN 查询时,如果发现结果和预期不一致,不要马上改 SQL。先按顺序检查以下内容:

排查点检查方法常见结论
列表中有没有 NULL检查所有候选值是否都有明确值NOT IN 遇到 NULL 可能返回空集
子查询结果是否有 NULL单独跑子查询,查看结果替换为 NOT EXISTS 更安全
列类型是否与列表一致查看表结构定义隐含类型转换会导致索引失效或匹配异常
字符串大小写规则查询排序规则和实际值有些数据库大小写敏感
测试数据是否就是预期先确认两张表的基础数据数据问题经常被误判为 SQL 问题
ORM 有没有改写 SQL打开 SQL 日志查询可能被 ORM 改写成奇怪形式

按这个顺序排查,通常能覆盖绝大多数"为什么结果不对"的问题,而不至于从头查一遍 SQL 语法。

4.2 SQL 执行慢的定位顺序

慢查询排错要遵循从现象到根因的顺序。

先描述现象:查询在单条数据时很快,在某个业务高峰期变慢;还是同样的 SQL 在测试环境快,在线上慢。

然后看执行计划,确认是否走了扫描、索引是否生效、扫描行数是多少。

接着检查统计信息是否过期。如果表数据量变化很大,但统计信息还是旧的,优化器可能为 IN 选错计划。

最后检查列表规模。IN列表过大时,SQL 文本和解析成本都会上升。此时改成临时表 JOIN,或者拆分批次执行,效果通常更明显。

一个具体排查表格如下:

问题现象可能原因检查方式处理建议
IN 列表很小但走了全表扫描列上没有索引,或优化器误判EXPLAIN 查看 key 列创建合适索引,更新统计信息
子查询 IN 变慢子查询返回超大结果集单独执行子查询看耗时改用 JOIN 或临时表
加了索引仍然慢条件列存在隐式类型转换查看字段类型和参数类型统一参数类型,避免转换
NOT IN 返回 0 行子查询或列表中有 NULL检查数据库日志和结果使用 NOT EXISTS
ORM 生成的 IN 列表过长应用层动态拼接打印最终 SQL分批查询或使用临时表

4.3 不同数据库对 IN 的细微差异

SQL Server、MySQL、Oracle、PostgreSQL 都支持 IN,但在细节上有差异:

  • 列表长度限制不同。部分数据库对单个 IN 字面量列表的上限有限制,列表太长会报错或解析变慢。
  • 行值表达式的支持程度不同。MySQL 支持多列行值比较,其他数据库不一定。
  • 参数个数限制不同。应用层如果把 IN 列表参数化,每个参数都会占用一个绑定变量名额,达到上限后需要分批。
  • 优化器行为不同。同一张表、同一条 IN 查询,在不同数据库里可能选择 Hash Join、Nested Loop 或 Sort Merge Join。

因此,SQL 写好后不要只在开发库跑一遍。上线前要在目标数据库版本上验证执行计划,尤其是列表较长或子查询较复杂的场景。

5. 最佳实践与可复用清单

5.1 三个与 IN 强相关的坑

第一个坑是NOT IN遇到 NULL。错误写法是直接写WHERE class_id NOT IN (子查询),而子查询返回的集合中恰好含 NULL。最终结果就是空集,而且没有任何报错。推荐做法是先用WHERE 列 IS NOT NULL过滤子查询,或者换成NOT EXISTS

第二个坑是类型不匹配。比如列是 INT,列表里的值写成字符串,MySQL 可能会做隐式转换,某些数据库则会报错。隐式转换还可能导致索引失效。检查表结构,保持参数类型与列类型一致是最基本的要求。

第三个坑是把 IN 列表写得过大。很多开发同学在应用层拼接几十万个 ID 组成的 IN 列表,SQL 文本非常长,数据库解析耗时上升,执行计划也可能因为参数数量太多而偏离预期。推荐做法是使用临时表或表值参数,把集合交给数据库做 JOIN,而不是交给 SQL 文本。

5.2 IN 使用清单

写任何一条 IN 查询前,把下面这份清单过一遍:

  1. 列表里是否存在 NULL,是否使用 NOT IN,是否需要换成 NOT EXISTS。
  2. 列的数据类型是否与列表元素一致。
  3. 子查询返回的集合是否需要去重,子查询结果是不是可控大小。
  4. 外层列是否有索引,查询条件是否会被函数或类型转换破坏。
  5. 列表规模是否过大,是否需要临时表、JOIN 或分页分批处理。
  6. 是否在目标数据库版本上执行过 EXPLAIN,确认没有全表扫描。

这份清单适用于学习和生产环境。学习阶段主要帮助理解语义,生产阶段则能省掉很多线上事故。

5.3 学习环境与生产环境的区别

在本地学习时,数据量通常只有几十行,IN 怎么写都快。这时候重点验证逻辑,把INOREXISTSJOIN四种写法都试验一遍,对比结果是否一致。

进入生产环境后,关注点会完全不同:

  • 大表的统计信息必须及时更新。
  • 核心查询要纳入慢日志监控。
  • 列表数据来源如果是用户输入或外部接口,不能直接拼进 SQL 字符串,要通过参数化绑定或临时表方式交给数据库。
  • SQL 上线前要在预发布环境压测,确认执行计划和相应耗时符合预期。
  • 如果查询结果会变化,还要考虑缓存策略,避免每次请求都执行同样的超大 IN 查询。

学习阶段求正确,生产阶段求稳定。IN 语法本身很简单,真正考验经验的是根据数据量和执行计划做出合理选择。

对于刚学 SQL 的人来说,建议把本章的脚本自己建一遍,把三条语句改成 OR、改成 EXISTS、改成 JOIN,对比结果,再查看执行计划。真正理解 IN,不是把语法背下来,而是能回答两个问题:为什么结果是这样,以及为什么这条语句能跑得快。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询