🪟 第9章 SQL数据查询进阶
📄 一句话概括

IN 是「等于其中任意一个」,等价于一串 OR;EXISTS 是「有没有行」。 NOT IN 的子查询里只要出现一个 NULL,整条语句静默返回空集——这是 SQL 里最坑的一处。 > ANY 等价于 > MIN,> ALL 等价于 > MAX; = ANY 就是 IN,<> ALL 就是 NOT IN。

📝 本章和前面几章的关系

9.1a 讲的是「子查询长什么样」,这一节讲「用什么运算符把它接进来」。四个运算符, 各有各的语义,也各有各的坑。 教材 9.1 的后四小节就是这四个。其中 NOT IN 与 NULL 那一段是全文最该动手验一次的。 演示里除了跑语义,还做了一次对比计时:20 万行大表分别用 IN、EXISTS、 JOIN 去对一张 3 行小表。结果和很多老文章说的不一样,原因写在实测那一段。

一、IN:等于其中任意一个

id IN (5, 8, 13) 就是 id = 5 OR id = 8 OR id = 13。 子查询版就是把那一串值换成一条查询的结果。

SELECT sname FROM tb_student
WHERE id IN (SELECT student_id FROM tb_score WHERE subject = '高等数学')

语义上它问的是「这个值在不在那一列里」。 所以能一句话说清「在里面」的时候, 用 IN 比 EXISTS 更贴人话。 子查询返回的必须是一列。 IN (SELECT a, b FROM ...) 会报错—— IN 只跟一个值比,多一列它不知道比哪个。

二、NOT IN 的 NULL 陷阱

这一段请务必自己跑一遍,光看结论记不住。

-- 正常情况:没有成绩的学生被挑出来
SELECT sname FROM tb_student
WHERE id NOT IN (SELECT student_id FROM tb_score)      -- 赵敏

-- 子查询里混进一个 NULL,结果立刻变空
SELECT sname FROM tb_student
WHERE id NOT IN (SELECT student_id FROM tb_score
                 UNION ALL SELECT NULL)                 -- 0 行

为什么会这样? NOT IN 会被展开成 id <> a AND id <> b AND id <> NULL。 而任何值和 NULL 比,结果都是「未知」。 「未知」再参与 AND,整条就是未知。 WHERE 只留「真」,未知一律丢掉——所以整张表都过不去。 不报错、不警告,就是干干净净的 0 行。 两条出路: 子查询里加 WHERE student_id IS NOT NULL,或者干脆换成 NOT EXISTS。 后者更省心,因为它压根不是「比大小」的语义,不受 NULL 影响。

三、EXISTS:只问有没有

EXISTS 只关心一件事:这个子查询能不能捞出至少一行。 捞到就是真,捞不到就是假。

SELECT sname FROM tb_student st WHERE EXISTS
    (SELECT 1 FROM tb_score sc WHERE sc.student_id = st.id)

它不看 SELECT 后面写了什么。 演示里注册了一个「一被调用就抛错」的函数塞进去, 结果一条错都没报——EXISTS 根本没求值那个表达式。 这也解释了为什么 EXISTS (SELECT *) 和 EXISTS (SELECT 1) 一样快。 老文章里说「SELECT * 在 EXISTS 里更慢」是错的——它压根不读。 EXISTS 天然是相关子查询(要跟外层挂上钩),但它的执行方式是「捞到一行就停」, 所以在「只要判断有没有」的场景里,它往往比 IN 更快,因为它不用把整列算完。

四、ANY/SOME 与 ALL

ANY 是「比其中任意一个大就行」,ALL 是「比每一个都大」。换成聚合函数就是一句话的事:

写法 等价于 记法
> ANY (...) > (SELECT MIN(...)) 比小的那个还大
> ALL (...) > (SELECT MAX(...)) 比大的那个还大
= ANY (...) IN (...) 完全同一件事
<> ALL (...) NOT IN (...) 完全同一件事

SOME 和 ANY 是同义词。 标准 SQL 里两个词都留着, 纯属历史遗留——写哪个都行,但别在同一份代码里混着用。 本沙箱不支持 ANY/ALL 关键字,演示里走的是等价的 MIN/MAX 写法, 两条结果完全一致。 这也顺带证明了一件事:这四条等价关系是真的,不是背下来的。 用的时候要留心 NULL。 > ANY 遇上全是 NULL 的子查询,结果也是未知; > ALL 的逻辑更绕:<> ALL 才是「都不等于」,写错一个字意思全反。

五、三者在数据量上的差别

老文章里有一句流传很广的话:EXISTS 比 IN 快。 这句话有来历,但今天不能照搬。

写法(20 万行对 3 行小表) 最快一次 相对 JOIN
IN 子查询 43.49 ms 慢 28%
EXISTS 子查询 36.63 ms 慢 8%
直接 JOIN 33.89 ms 基准

实测里 IN 确实最慢,EXISTS 和 JOIN 基本并列。 但别急着下结论—— 这三个数是在 SQLite 上测的,优化器的改写策略跟 SQL Server 不一样。 SQL Server 从 2005 起就会把 IN 改写成半连接,两者的执行计划常常一模一样。 当年「EXISTS 更快」的结论,是 SQL Server 2000 时代留下的。 所以写哪个应该看语义哪个清楚,不是看谁快。 如果要真的比性能,唯一靠谱的办法是在自己的库、自己的数据上跑执行计划看。

六、动手 Demo:NOT IN 的 NULL 陷阱与三者的实测差别

这一段先用六表样例库跑语义:IN 正常、NOT IN 正常, 然后往子查询里塞一个 NULL,看结果怎么被毒死。 接着验证 EXISTS 不读选择列表(用一个会抛错的函数证明),最后跑 ANY/ALL 的等价写法, 再在 20 万行大表上把 IN、EXISTS、JOIN 各测一遍。

import sqlite3, time

c = sqlite3.connect(u":memory:")
# ... 建六表、装样例数据,另插一个没成绩的学生 ...

# 1) NOT IN 正常
print(c.execute(u"SELECT sname FROM tb_student WHERE id NOT IN "
                u"(SELECT student_id FROM tb_score)").fetchall())

# 2) 子查询混进 NULL
print(c.execute(u"SELECT sname FROM tb_student WHERE id NOT IN "
                u"(SELECT student_id FROM tb_score UNION ALL SELECT NULL)").fetchall())


# 3) EXISTS 不读选择列表:塞个会抛错的函数进去
def boom():
    raise sqlite3.OperationalError(u"boom 被求值了")


c.create_function(u"boom", 0, boom)
print(c.execute(u"SELECT COUNT(*) FROM tb_student st WHERE EXISTS "
                u"(SELECT boom() FROM tb_score sc WHERE sc.student_id = st.id)"
                ).fetchone())

# 4) 20 万行大表上比 IN / EXISTS / JOIN
c.execute(u"CREATE TABLE big (id INTEGER PRIMARY KEY, sid INTEGER, v REAL)")
c.execute(u"WITH RECURSIVE t(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM t WHERE x < 200000) "
          u"INSERT INTO big SELECT x, (x % 6) + 1, (x % 97) FROM t")
for tag, q in ((u"IN", q_in), (u"EXISTS", q_ex), (u"JOIN", q_jn)):
    print(tag, u"%.2f ms" % clock(q))

实跑输出:

--------------------------------------------------------------------
IN、EXISTS、ANY 与 ALL 子查询
--------------------------------------------------------------------


  ① 使用 IN 的子查询
  --------------------------------------------------------------------

  1> SELECT sname
     FROM tb_student
     WHERE id IN (SELECT student_id FROM tb_score WHERE subject = '高等数学')
  sname
  -----
  张伟
  王芳
  李娜
  刘强
  杨洋
  (5 行受影响)
  IN 的意思是「等于这一列里的任意一个」,等价于一串 OR。

  NOT IN 有个坑,先看正常情况:

  1> SELECT sname FROM tb_student WHERE id NOT IN (SELECT student_id FROM tb_score)
  sname
  -----
  赵敏
  (1 行受影响)
  赵敏是刚插进来的第 7 个学生,一条成绩都没有,所以被 NOT IN 挑出来了。

  现在往子查询里塞一个 NULL,看会发生什么:

  1> SELECT sname
     FROM tb_student
     WHERE id NOT IN (SELECT student_id FROM tb_score UNION ALL SELECT NULL)
  sname
  -----
  (0 行受影响)
  ! 0 行。不是数据没了,是 NOT IN 的语义被 NULL 毒死了。
  NOT IN 展开成 id <> a AND id <> b AND id <> NULL。
  任何值和 NULL 比,结果是「未知」;「未知」再参与 AND,整条就是未知。
  WHERE 只留「真」,未知一律丢掉——所以整张表都过不去。
  这就是为什么真实项目里 NOT IN 后面要跟 IS NOT NULL,或者干脆换 NOT EXISTS。

  1> SELECT sname
     FROM tb_student
     WHERE id NOT IN (SELECT student_id FROM tb_score WHERE student_id IS NOT NULL)
  sname
  -----
  赵敏
  (1 行受影响)

  ② 使用 EXISTS 的子查询
  --------------------------------------------------------------------
  EXISTS 只问一句:这个子查询有没有行?有就是真,没有就是假。
  它不看子查询 SELECT 后面写了什么,写 1、写 *、写常量都一样。

  1> SELECT sname
     FROM tb_student st
     WHERE EXISTS (SELECT 1 FROM tb_score sc WHERE sc.student_id = st.id)
  sname
  -----
  张伟
  王芳
  李娜
  刘强
  陈静
  杨洋
  (6 行受影响)
  第 7 个学生赵敏没成绩,EXISTS 拿不到行,所以没出现在结果里。

  证明「不看返回什么」:注册一个只会抛错的函数,塞进 EXISTS 的选择列表。

  1> SELECT COUNT(*) AS 人数
     FROM tb_student st
     WHERE EXISTS (SELECT boom() FROM tb_score sc WHERE sc.student_id = st.id)
  人数
  ----
  6
  (1 行受影响)
  没有报错。boom() 写在 SELECT 后面,但 EXISTS 一次都没调用它。
  它只关心「内层能不能捞出至少一行」,捞到就立刻返回真,剩下的列一律不算。
  这也解释了为什么 EXISTS 里写 SELECT * 和写 SELECT 1 性能一样——都一样不读。

  NOT EXISTS 是 NOT IN 的安全替代:

  1> SELECT sname
     FROM tb_student st
     WHERE NOT EXISTS (SELECT 1 FROM tb_score sc WHERE sc.student_id = st.id)
  sname
  -----
  赵敏
  (1 行受影响)
  结果和那条加了 IS NOT NULL 的 NOT IN 一样,但不需要你去操心 NULL。

  ③ 使用 ANY/SOME 的子查询
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | SELECT gname, price FROM tb_goods
  | WHERE price > ANY (SELECT price FROM tb_goods WHERE type_id = 2);

  ! SQLite 没有 ANY/SOME 关键字,上面这条在这里会报语法错。
  ANY 的意思是「比其中任意一个大就行」,等价于比最小值大。

  1> SELECT gname, price
     FROM tb_goods
     WHERE price > (SELECT MIN(price) FROM tb_goods WHERE type_id = 2)
  gname       price
  -----------------
  联想笔记本  4999
  戴尔显示器  1299
  机械键盘    399
  移动硬盘    549
  打印机      899
  (5 行受影响)
  对比一下 = ANY 与 IN:

  1> SELECT gname FROM tb_goods WHERE price = ANY (SELECT price FROM tb_goods WHERE type_id = 2)
  消息 102,级别 15,状态 1
  near "SELECT": syntax error
  报错了。换成 IN 写法结果如下,两者在标准 SQL 里是同一件事。

  1> SELECT gname, price
     FROM tb_goods
     WHERE price IN (SELECT price FROM tb_goods WHERE type_id = 2)
  gname     price
  ---------------
  罗技鼠标  89.9
  机械键盘  399
  移动硬盘  549
  (3 行受影响)

  ④ 使用 ALL 的子查询
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | SELECT gname, price FROM tb_goods
  | WHERE price > ALL (SELECT price FROM tb_goods WHERE type_id = 2);

  ALL 是「比每一个都大」,等价于比最大值还大。

  1> SELECT gname, price
     FROM tb_goods
     WHERE price > (SELECT MAX(price) FROM tb_goods WHERE type_id = 2)
  gname       price
  -----------------
  联想笔记本  4999
  戴尔显示器  1299
  打印机      899
  (3 行受影响)
| 写法 | 等价于 | 记法 |
|---|---|---|
| > ANY (...) | > (SELECT MIN(...)) | 比小的那个还大 |
| > ALL (...) | > (SELECT MAX(...)) | 比大的那个还大 |
| = ANY (...) | IN (...) | 完全同一件事 |
| <> ALL (...) | NOT IN (...) | 完全同一件事 |

  ANY 和 SOME 是同义词,标准 SQL 里两个词都留着,纯属历史遗留。

  ⑤ IN、EXISTS、JOIN 三种写法在数据量上的差别:实测
  --------------------------------------------------------------------
| 写法(20 万行大表对 3 行小表) | 最快一次(毫秒) |
|---|---:|
| IN 子查询 | 43.44 |
| EXISTS 子查询 | 35.24 |
| 直接 JOIN | 33.94 |

  这三个数里 IN 最慢,比 JOIN 多花约 28%;EXISTS 和 JOIN 基本并列。
  原因是 SQLite 把 IN 子查询按「建临时结果集再逐行比对」来跑,多一道工。
  但不能拿这组数去说「EXISTS 一定比 IN 快」——那是 SQL Server 老版本流传下来的旧结论。
  现代优化器普遍会把 IN 改写成半连接,两者执行计划常常一模一样。
  写哪个应该看语义哪个清楚:能一句话说清「在里面」就用 IN,「存在」才用 EXISTS。

第 2 段那个「0 行」是本篇的核心。 它不报错、不警告, 就是安安静静地告诉你「没有符合条件的数据」——而实际上有。 这种错最难查,因为它看起来像个正常结果。 排查的时候可以先单独跑一下子查询, 看里面有没有 NULL;或者直接把 NOT IN 换成 NOT EXISTS 对比结果条数。 第 3 段那个 boom() 是真正的证据。 如果 EXISTS 会求值选择列表,这里一定抛异常; 它没抛,说明那句话从头到尾没被执行过。 最后那三个数(43.49 / 36.63 / 33.89)连在一起看才有意思。IN 多花了约一成到三成, 这个差距在 SQLite 上稳定存在,在 SQL Server 上未必—— 所以别把它当成通用结论,要当成「又一次提醒:性能得自己测」。

30 秒实验

  1. 把 NOT IN 换成 NOT EXISTS,两个结果对比一次。 一条是 0 行,一条是 1 行——差的就是那个 NULL。
  2. 把演示里 EXISTS 的 SELECT 1 换成 SELECT *,看计时变不变。不变。它压根不读选择列表。
  3. 写一条 > ANY 和一条 > (SELECT MIN(...)),把结果行数对比。 两条必须一样——如果不一样,说明你 ANY 那一条的条件写错了。

常见误区

  1. 「NOT IN 和 NOT EXISTS 只是写法不同」。碰上 NULL 天差地别。 NOT IN 静默返回空集,NOT EXISTS 该给什么给什么。 子查询里可能有 NULL 的时候,一律用 NOT EXISTS。
  2. 「EXISTS 里写 SELECT * 比写 SELECT 1 慢」。老的优化器可能有区别, 现代数据库里两者一模一样——因为选择列表整个被丢弃了。
  3. 「EXISTS 一定比 IN 快」。这是 SQL Server 2000 时代的经验。 现代优化器会改写,执行计划常常一样。 真要比较,得在自己的数据上看执行计划。
自测三问(点开对答案) 1. `NOT IN` 的子查询里有一个 `NULL`,结果会怎样? —— **返回空集,而且不报错。** 因为 `id <> NULL` 是「未知」,未知参与 `AND` 还是未知, **`WHERE` 只留真,未知全丢。** 2. `EXISTS` 会读 `SELECT` 后面的表达式吗? —— **不会。** 它只判断「能不能捞到至少一行」。**演示里塞了个会抛错的函数,一条错都没报。** **所以 `SELECT *` 和 `SELECT 1` 在 `EXISTS` 里性能一样。** 3. `> ANY` 和 `> ALL` 分别等价于什么? —— **`> ANY` 等价于 `> MIN`,`> ALL` 等价于 `> MAX`。** **记法:ANY 是「比小的那个还大」,ALL 是「比大的那个还大」。**

下一篇 9.2 讲连接查询。子查询和连接在功能上有很多重叠,但连接是「一次把两张表摆在一起」, 效率通常更好——而且外连接是子查询做不到的。

相关笔记