🛢️ 第9章 数据查询
📄 一句话概括

子查询按位置分四种:WHERE 里、FROM 里、SELECT 列表里、HAVING 里。 NOT IN 的子查询里只要有一个空值,结果必然是 0 行——这不是 bug,是三值逻辑。 相关子查询「外层一行跑一次内层」,代价等于外层行数乘以单次内层代价。

📝 本章和前面几章的关系

9.5 列了五种写法:IN、比较运算符、EXISTS、ANY、ALL。 前三种沙箱能直接跑,后两种得改写成 MIN/MAX。 这一篇和上一篇是一对:连接是「把两张表并排放」,子查询是「把一条语句套进另一条」。 两者经常能互相改写,选哪个看可读性和代价。

一、标量子查询与多行子查询

子查询先算自己那一段,算完把结果交给外面用。 只返回一行一列的叫标量子查询,凡是能写一个值的地方都能放它。 教材的典型例子是「查价格高于平均价的商品」:先算平均价,再拿它当门槛。 没有子查询就得跑两趟,先查出一个数,再拼一句带数字的 SQL。 多行子查询返回一整列,这时外面不能用 =,得用 IN、EXISTS 或者 ANY/ALL。 这是最容易错的地方:写成 = 一般会报错,但也有引擎会默默取第一行。 沙箱就属于后面那种:WHERE price > (SELECT price FROM tb_goods) 不报错, 它取了第一行当那个值,于是给出 4980 行。 MySQL 会直接报 Subquery returns more than 1 row,不替你挑一行。 所以同一句写错的 SQL,沙箱里看着跑通了,换到 MySQL 立刻炸。

二、IN 与 EXISTS

IN 的语义是「值在这个集合里」,EXISTS 的语义是「子查询找得到记录」。 两者在很多场景下能互相改写,结果一样。 区别在「什么时候停」:EXISTS 找到第一条就可以停,IN 通常要把集合算完。 所以子查询结果集很大时,EXISTS 往往更快;结果集很小,两者差不多。 这一条只是方向,别当定律。 现代优化器会把 IN 改写成半连接,两边可能给出完全一样的计划,只能看执行计划确认。 EXISTS 还有一个副产品:它不关心子查询 SELECT 后面写什么。 写 SELECT 1、SELECT *、SELECT 一整列 都一样,只判断「有没有行」。

三、NOT IN 的空集陷阱

这一节是整章最值钱的地方。 NOT IN 的子查询里只要有一个空值,整句就返回 0 行。 原因是 NOT IN 会被展开成「不等于第一个、也不等于第二个、也不等于空」。 前两个比得出来,第三个比不出来——x <> NULL 的结果是「未知」,不是「真」。 只要有一行「未知」,整个 AND 就不再为真,于是这一行被丢掉。 结果就是:一行都不剩,而语句不报错。

写法 结果行数
NOT IN (子查询) 原样 0
NOT IN 加 IS NOT NULL 3891
NOT EXISTS (子查询) 4026

第二行加了 IS NOT NULL 就正常了:3891 行, 等于 5000 减 974(两个被拉黑类别)再减 135(类别为空)。 第三行比第二行多 135 行,多的是「类别为空」那批商品。 它们类别是空的,NOT EXISTS 判「拉黑表里找不到对应记录」,算真。 而 NOT IN 判的是「不等于」,还是那个「未知」,所以那 135 行反而被丢掉了。 同一批数据,三种写法给出三个数,只有你要的那个才叫对。 结论:子查询里可能有空值时,NOT IN 一律换成 NOT EXISTS。 这一条比记住语法重要得多,生产上的「查不出数据」有一大半出在这里。

四、FROM 里的派生表,与 SELECT 列表里的相关子查询

FROM 后面跟子查询,相当于临时造出一张表,这种表叫派生表。 外面那句可以对它再筛、再排、再连,跟真表一样。 沙箱里不给别名也能跑,但 MySQL 会报 Every derived table must have its own alias。 所以写的时候一律带上别名,省得换库就炸。 派生表的价值是「分两步算」:先算出一个中间结果,再对它二次筛选。 很多「先分组再筛组」的需求,写成派生表比写 HAVING 更好读。 SELECT 列表里放子查询是另一个用法,它给每一行算一个附加列。 这种子查询引用了外面的列,所以外面每行都得重跑一次,这就是相关子查询。 代价是「外层行数乘以内层单次代价」。 内层是 11 行的类别表时看不出来,内层是 2000 行的订单表时就差了一百多倍。 能改成连接的就改成连接:连接是一趟扫完,相关子查询是一行一趟。

五、ANY 与 ALL 的等价改写

教材还讲了 ANY 和 ALL,但沙箱的 SQLite 没有这两个关键字,照抄直接语法错。 它们的语义可以完全用 MIN/MAX 表达,记住这张对照就不会被绕晕。

教材写法 等价改写
> ANY (子查询) > (SELECT MIN(...))
> ALL (子查询) > (SELECT MAX(...))
< ANY (子查询) < (SELECT MAX(...))
< ALL (子查询) < (SELECT MIN(...))

> ANY 的意思是「比其中某一个大就行」,所以门槛是那一列的最小值。 > ALL 是「比每一个都大」,门槛是最大值。 换成小于号方向正好对调:< ANY 的门槛是最大值,< ALL 的门槛是最小值。 因为「小于某一个」只要小于最大的那个就算,而「小于全部」得小于最小的那个。 还有一对对应关系:= ANY 就是 IN,<> ALL 就是 NOT IN。 所以上一篇那个空值陷阱,写成 <> ALL 一样会踩。

六、动手 Demo:子查询的四种位置,和 NOT IN 那个空集陷阱

为了把空值陷阱造干净,这里额外建了一张三行的「拉黑表」:类别 3、类别 7、还有一个空值。 那个空值就是整段的引信。

import sqlite3

c = sqlite3.connect(u"d040.db")
c.execute(u"CREATE TABLE tb_black (cat_id INTEGER)")
c.executemany(u"INSERT INTO tb_black VALUES (?)", [(3,), (7,), (None,)])

# ① 标量子查询
print(c.execute(u"SELECT AVG(price) FROM tb_goods").fetchone())
print(c.execute(u"SELECT COUNT(*) FROM tb_goods WHERE price > "
                u"(SELECT AVG(price) FROM tb_goods)").fetchone())

# ② 空集陷阱:三种写法三个数
for sql in [u"SELECT COUNT(*) FROM tb_goods WHERE cat_id NOT IN "
            u"(SELECT cat_id FROM tb_black)",
            u"SELECT COUNT(*) FROM tb_goods WHERE cat_id NOT IN "
            u"(SELECT cat_id FROM tb_black WHERE cat_id IS NOT NULL)",
            u"SELECT COUNT(*) FROM tb_goods WHERE NOT EXISTS "
            u"(SELECT 1 FROM tb_black b WHERE b.cat_id = tb_goods.cat_id)"]:
    print(c.execute(sql).fetchone()[0])

# ③ 相关子查询 vs 连接
c.execute(u"SELECT id, (SELECT COUNT(*) FROM tb_order o "
          u"WHERE o.goods_id = tb_goods.id) FROM tb_goods").fetchall()
c.execute(u"SELECT g.id, COUNT(o.id) FROM tb_goods g "
          u"LEFT JOIN tb_order o ON o.goods_id = g.id GROUP BY g.id").fetchall()

实跑输出:

--------------------------------------------------------------------
第四十课:子查询的四种位置,和 NOT IN 那个空集陷阱
--------------------------------------------------------------------

  **教材 9.5 讲子查询。子查询就是「一条 `SELECT` 套在另一条里面」。**
  **它按放的位置分四种,用法各不相同。**
  **这一课四种都跑一遍,最后专讲 `NOT IN` 遇上空值会给出 0 行这件事。**

  一、标量子查询:里面只返回一个值
  --------------------------------------------------------------------

  项                  值
  -------------------------
  全表平均价          99.55
  高于平均价的商品数  2466
  全表行数            5000
  **子查询先算出一个数,外面那句再把它当常量用。**
  **没有子查询就得跑两趟:先查平均数,再拼一句带数字的 SQL。**
  **这种「只返回一行一列」的子查询叫标量子查询,凡是能写一个值的地方都能放它。**
  **顺手验一件事:如果括号里返回的不是一个值,而是一整列呢?**
  **沙箱给了 4980 行,没报错——它默默取了第一行当那个「值」。**
  **MySQL 在这里会报 `Subquery returns more than 1 row`,不会替你挑一行。**
  **所以同一句写错的 SQL,沙箱里看着跑通了,换到 MySQL 立刻炸。**

  二、多行子查询:IN 与 EXISTS
  --------------------------------------------------------------------
  cat_id
  ------
  3
  7
  NULL
  3 rows in set

  写法               结果行数  含义
  -------------------------------------------------
  `IN (子查询)`      974       类别在拉黑表里的商品
  `EXISTS (子查询)`  974       同一批商品,写法不同
  **这张拉黑表里只有三条:类别 3、类别 7,还有一条是空的。**
  **第三条那个空值是故意留的——它马上要把下面一句打回 0 行。**

  三、NOT IN 的空集陷阱
  --------------------------------------------------------------------

  写法                                结果行数
  --------------------------------------------
  `NOT IN (子查询)` 原样              0
  `NOT IN (子查询)` 加 `IS NOT NULL`  3891
  `NOT EXISTS (子查询)`               4026
  **第一行给 0 行,而拉黑表里只拉黑两个类别,本该有几千行。**
  **原因是那句 `NOT IN` 要逐行比「这个类别不等于 3、也不等于 7、也不等于空」。**
  **前两个比得出来,第三个比不出来——`x <> NULL` 的结果是「未知」,不是「真」。**
  **只要有一行「未知」,整个 `AND` 就不再为真,于是这一行被丢掉。**
  **所以 `NOT IN` 的子查询里只要有一个空值,结果必然是 0 行。**
  **这不是 bug,是三值逻辑算出来的必然结果。**
  **第二行加了 `IS NOT NULL` 就正常了:3891 行。**
  **它等于 5000 减去 974 行(两个被拉黑类别),再减去 135 行(类别为空)。**
  **第三行 `NOT EXISTS` 又多了 135 行——多的是「类别为空」那批商品。**
  **因为它们类别是空的,`NOT EXISTS` 判「拉黑表里找不到对应记录」,算真。**
  **而 `NOT IN` 判的是「不等于」,还是那个「未知」。**
  **结论:子查询里可能有空值时,`NOT IN` 一律换成 `NOT EXISTS`。**
  **这一条比记住语法重要得多,生产上的「查不出数据」有一大半出在这里。**

  四、FROM 里的子查询:派生表
  --------------------------------------------------------------------
  cat_id n
  ----------
  1      487
  3      487
  4      487
  7      487
  10     487
  5 rows in set
  **`FROM` 后面跟一个子查询,相当于临时造出一张表,这种表叫派生表。**
  **外面那句可以对它再筛、再排、再连,跟真表一样。**
  **沙箱里不给别名也能跑,但 MySQL 会报 `Every derived table must have its own alias`。**
  **所以写的时候一律带上 `t`,省得换库就炸。**

  五、SELECT 列表里的相关子查询,和它的代价
  --------------------------------------------------------------------
  id cat
  -------
  1  数码
  2  家电
  3  服饰
  3 rows in set

  内层那张表             相关子查询    改成连接
  ----------------------------------------------
  `tb_cat`(11 行)      6.35 毫秒     5.56 毫秒
  `tb_order`(2000 行)  1123.40 毫秒  8.96 毫秒
  **第一行差得不多,第二行差了 125 倍。同一个写法,差距由内层表的规模决定。**
  **原因很简单:这种子查询引用了外面的列,所以外层每一行都得把它重跑一次。**
  **外层 5000 行,内层就要跑 5000 趟,内层多大就乘多少。**
  **所以它叫相关子查询——「相关」就是指它和外面那一行绑在一起,没法提前算完。**
  **反过来,不引用外层列的写法叫不相关子查询,那种只算一次,代价小得多。**
  **能改成连接的都改成连接。**
  **连接是一趟扫完,相关子查询是一行一趟,这就是那个倍数的来源。**

  六、ANY 和 ALL:教材有,沙箱没有
  --------------------------------------------------------------------
  **照抄会报错:`near "SELECT": syntax error`——SQLite 没有 `ANY` 和 `ALL` 这两个关键字。**

  教材写法          等价改写               结果行数
  -------------------------------------------------
  `> ANY (子查询)`  `> (SELECT MIN(...))`  4988
  `> ALL (子查询)`  `> (SELECT MAX(...))`  13
  **`> ANY` 的意思是「比其中某一个大就行」,所以门槛是那一列的最小值。**
  **`> ALL` 是「比每一个都大」,门槛是最大值。**
  **换成小于号方向正好对调:`< ANY` 的门槛是最大值,`< ALL` 的门槛是最小值。**
  **因为「小于某一个」只要小于最大的那个就算,而「小于全部」得小于最小的那个。**
  **顺带一个对应关系:`= ANY` 就是 `IN`,`<> ALL` 就是 `NOT IN`。**
  **所以 `NOT IN` 那个空值陷阱,`<> ALL` 一样有。**

陷阱那一段的三个数是 0、3891、4026,这是这一课的核心。 第一行那个 0 会让人以为「数据有问题」,其实数据一点问题都没有。 第二行和第三行差的 135,正是「类别为空」那批商品。 它们在 NOT IN 里被判成「未知」丢掉,在 NOT EXISTS 里被判成「没被拉黑」留下。 相关子查询那一段给了两组对比:内层是 11 行的类别表时是 6.47 对 5.32 毫秒, 内层是 2000 行的订单表时是 1137.70 对 9.75 毫秒。 同一个写法,一个差两成,一个差 117 倍。 所以「相关子查询慢」这句话要带上条件:慢在内层也被放大。 ANY/ALL 那一段照抄会报 near "SELECT": syntax error,这是 SQLite 语法层面的空缺, 不是函数没注册。

30 秒实验

  1. 把拉黑表里的空值删掉,看第一行从 0 变成 3891。
  2. 把 NOT EXISTS 换成 NOT IN,确认它给出 0。
  3. 把相关子查询的内层换成 tb_order,看耗时从毫秒跳到秒。

常见误区

  1. 用 NOT IN 查「不在某个集合里」,碰上带空值的子查询,拿到 0 行还以为没数据。
  2. 在 SELECT 列表里写相关子查询,表一大就慢到没法用,其实一句连接就够。
  3. 照抄 ANY/ALL,没意识到换个引擎就不认这两个关键字。
自测三问(点开对答案) 1. `NOT IN` 的子查询里有一个空值,为什么结果一定是 0 行? —— 因为 `NOT IN` 展开后包含「不等于空」这一项,而 `x <> NULL` 的结果是「未知」。 只要有一行未知,整个 `AND` 就不为真,那一行被丢掉,全表扫完就一行不剩。 2. 派生表为什么一定要给别名? —— 因为 `FROM` 后面得有个「表名」供外面引用,子查询本身没有名字。 沙箱里不给也能跑,但 MySQL 会直接报错。 3. `> ANY` 和 `> ALL` 分别等价于什么? —— `> ANY` 等价 `> (SELECT MIN(...))`,`> ALL` 等价 `> (SELECT MAX(...))`。小于号方向反过来。

下一篇 9.6 讲把两段查询的结果竖着接起来:UNION,和去重的那一笔开销。

相关笔记