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

WHERE 的条件归成五类:比较、范围、集合、模糊、空。 LIKE 的前缀匹配在沙箱里也用不上索引,因为 LIKE 默认不区分大小写。 深翻页越翻越慢,OFFSET 是「数过去」而不是「跳过去」。

📝 本章和前面几章的关系

9.2 是全书最长的一节,十三个小节。拆开看只有五类条件加三个坑。 这一篇里的 LIKE 和索引,第 10 章讲索引时会再回来一次,那时从原理上讲为什么。

一、五类条件

十三个小节听着多,归纳起来 WHERE 只干五件事。 每一类跑一条,把结果行数记下来,后面调 SQL 时心里有数。

类别 写法 结果
比较 price > 100 2453 行
范围 BETWEEN 10 AND 20 260 行
集合 cat_id IN (1,2,3) 1460 行
模糊 name LIKE '图书%' 500 行
空 cat_id IS NULL 135 行

最容易被写错的是第五行。 如果写成 WHERE cat_id = NULL,这里给 0 行,不是 135 行。 因为 = 比较遇上空值,结果是「未知」而不是「真」,未知的行一律不返回。 同一批数据,换个写法从 135 行变成 0 行,这就是三值逻辑的实际后果。 判断空只能用 IS NULL 和 IS NOT NULL,这一条没有例外。

二、LIKE 的两个通配符和三个坑

LIKE 只有两个通配符:% 代表任意多个字符,_ 代表恰好一个字符。 * 和 ? 在这里没有特殊含义,那是正则的写法,别混。 第一个坑是位置。 '图书%' 是前缀匹配,'%图书%' 两边都有通配符,'%图书' 是后缀匹配。 只有前缀匹配有可能用上索引,因为索引是按「开头」排好序的。 另外两种开头不确定,没法在索引里定位到起点,只能一行行看。 第二个坑是锚点。 正则里的 ^ 和 $ 在 LIKE 里不存在,LIKE 天生就是「整串匹配」。 所以 LIKE 'G0001' 是「整串等于 G0001」,不是「以它开头」。 要「以它开头」得写 LIKE 'G0001%',那个 % 就是「后面随便」。 少了这个 %,结果会从几百行掉成一行,而它不报错。 第三个坑是大小写,而且它和索引绑在一起。 沙箱里 code LIKE 'G0001%' 的执行计划是 SCAN,不是 SEARCH——有索引也没用上。 原因是 LIKE 默认不区分大小写,'g0001%' 也算命中。 大小写折叠之后,命中的行在索引里不再是连续的一段,区间查找就失效了。 打开 PRAGMA case_sensitive_like,同一个查询立刻变成 SEARCH,走索引区间。 所以「前缀匹配能用索引」这句话是有前提的:比较得按字节序来,不做大小写折叠。 MySQL 没有这个限制:LIKE '前缀%' 在大小写不敏感的排序规则下照样能用索引。 它直接按排序规则算区间,不先折叠字符。 反过来就是一条实用的提醒:同一条 SQL 在 MySQL 上很快,搬到 SQLite 上可能变全表扫。 判断「用没用上索引」只能看执行计划,不能凭常识。

三、DISTINCT、ORDER BY 与深翻页

DISTINCT 和 GROUP BY 在底层是同一件事——按列分组。 两者结果一样,耗时也接近,选哪个只看可读性。 ORDER BY 要记住两件事:默认是升序,可以写多列。 多列排序是「第一列相同才看第二列」,写反了结果完全不同。 第三个坑是深翻页。LIMIT 10 OFFSET 4000 取十行,但它得先「数过」前 4000 行。 所以翻得越深越慢,这是 OFFSET 的实现方式决定的,不是优化不够。 正确做法是记住上一页最后一个 id,下一页用 WHERE id > 上次的 id 去取。 这样不管翻到第几页,代价都一样——它不需要「数过去」。 代价是不能跳到任意页,只能一页页往后翻。 这在「列表页」里根本不是问题,因为用户就是这么翻的。

四、动手 Demo:单表查询的五类条件,与三个坑

数据是 5000 个商品,cat_id 每 37 行给一个空值(一共 135 行)。 后面所有「空」相关的结论都靠这 135 行撑起来。

import sqlite3

c = sqlite3.connect(u"d037.db")

# ① 五类条件各跑一条
for sql in [u"SELECT COUNT(*) FROM tb_goods WHERE price > 100",
            u"SELECT COUNT(*) FROM tb_goods WHERE price BETWEEN 10 AND 20",
            u"SELECT COUNT(*) FROM tb_goods WHERE cat_id IN (1, 2, 3)",
            u"SELECT COUNT(*) FROM tb_goods WHERE name LIKE '图书%'",
            u"SELECT COUNT(*) FROM tb_goods WHERE cat_id IS NULL"]:
    print(c.execute(sql).fetchone()[0])

# ② 前缀 LIKE 到底走不走索引
print(c.execute(u"EXPLAIN QUERY PLAN SELECT id FROM tb_goods "
                u"WHERE code LIKE 'G0001%'").fetchone()[-1])
c.execute(u"PRAGMA case_sensitive_like = ON")
print(c.execute(u"EXPLAIN QUERY PLAN SELECT id FROM tb_goods "
                u"WHERE code LIKE 'G0001%'").fetchone()[-1])

# ③ 深翻页与 keyset 翻页
c.execute(u"SELECT id FROM tb_goods ORDER BY id LIMIT 10 OFFSET 4000").fetchall()
c.execute(u"SELECT id FROM tb_goods WHERE id > 4000 ORDER BY id LIMIT 10").fetchall()

实跑输出:

--------------------------------------------------------------------
第三十七课:单表查询的五类条件,与三个坑
--------------------------------------------------------------------

  **教材 9.2 是全书最长的一节,十一个小节。**
  **拆开看只有五类条件:比较、范围、集合、模糊、空。**
  **这一课把五类各跑一遍,再专挑三个真会出事的坑:`LIKE` 的位置、`DISTINCT` 的代价、深翻页。**

  一、查全部与查指定列
  --------------------------------------------------------------------
  id code   name      price
  -------------------------
  1  G00001 数码商品1 1.7
  2  G00002 家电商品2 2.4
  3  G00003 服饰商品3 3.1
  3 rows in set
  **指定列的时候,输出顺序就按你写的顺序来,和表里的列顺序无关。**
  **所以「第几列是什么」不能靠位置记,要按列名取。**

  二、五类条件各跑一条
  --------------------------------------------------------------------

  类别  写法                             结果
  ----------------------------------------------
  比较  `WHERE price > 100`              2453 行
  范围  `WHERE price BETWEEN 10 AND 20`  260 行
  集合  `WHERE cat_id IN (1, 2, 3)`      1460 行
  模糊  `WHERE name LIKE '图书%'`        500 行
  空    `WHERE cat_id IS NULL`           135 行
  **五行合起来覆盖了 `WHERE` 的绝大多数用法。**
  **第五行那个「空」要特别注意:它数出来是 135 行。**
  **如果写成 `WHERE cat_id = NULL`,这里会给 0 行。**
  **同一批数据,换个写法从「135 行」变成「0 行」,这就是三值逻辑坑的实际后果。**

  三、LIKE 的三个坑
  --------------------------------------------------------------------
  **`LIKE` 只有两个通配符:`%` 代表任意多个字符,`_` 代表恰好一个字符。**
  **别的都不是通配符,`*` 和 `?` 在这里没有特殊含义。**

  写法             命中行数  类型
  -----------------------------------------
  `LIKE '图书%'`   500       前缀匹配
  `LIKE '%图书%'`  500       两边都有通配符
  `LIKE '%图书'`   0         后缀匹配
  `LIKE 'G0001_'`  10        `_` 占一位
  **四行里只有第一行有可能用上索引,因为只有它「开头是确定的」。**
  **后三行开头是通配符,索引是按前缀排序的,没法从中间找。**

  查询(默认设置)      执行计划
  ---------------------------------------------------------------
  `code LIKE 'G0001%'`  SCAN tb_goods USING COVERING INDEX i_code
  `code LIKE '%0001'`   SCAN tb_goods USING COVERING INDEX i_code
  **两行都是 `SCAN`,连前缀匹配那一行也没走成 `SEARCH`。**
  **这和「前缀能用索引」的常识相反,得说清为什么。**
  **因为 SQLite 的 `LIKE` 默认不区分大小写,`'g0001%'` 也算命中。**
  **大小写折叠之后,命中的行在索引里不再是连续的一段,区间查找就失效了。**

  查询(打开区分大小写)  执行计划
  ---------------------------------------------------------------------------------------
  `code LIKE 'G0001%'`    SEARCH tb_goods USING COVERING INDEX i_code (code>? AND code<?)
  **同一个查询,改一个开关就变成 `SEARCH`,走的是索引区间。**
  **所以「前缀匹配能用索引」有前提:比较得按字节序来,不做大小写折叠。**
  **换成区间写法 `code >= 'G0001' AND code < 'G0002'`,默认设置下就走索引:**
  **`SEARCH tb_goods USING COVERING INDEX i_code (code>? AND code<?)`。**
  **MySQL 在这里比 SQLite 宽松:`LIKE '前缀%'` 在大小写不敏感的排序规则下也能用索引。**
  **它直接按排序规则算区间,不先折叠字符,所以没这个限制。**
  **反过来就是一条实用的提醒:同一条 SQL 在 MySQL 上很快,搬到 SQLite 上可能变全表扫。**
  **判断「用没用上索引」只能看执行计划,不能凭常识。**

  四、DISTINCT 与 ORDER BY
  --------------------------------------------------------------------

  写法                      结果行数  耗时
  ---------------------------------------------
  `SELECT DISTINCT cat_id`  11        747 微秒
  `GROUP BY cat_id`         11        2.06 毫秒
  **两者结果一样,耗时这一格 `DISTINCT` 反而快了一倍多。**
  **但别把这个方向当规律——它们在底层就是同一件事「按列分组」,差别只是优化器怎么摆。**
  **所以「去重」和「分组」是一件事的两种写法,选哪个只看可读性。**
  **`ORDER BY` 要记住两件事:默认是升序(`ASC`),可以写多列。**
  **多列排序是「第一列相同才看第二列」,顺序反了结果就完全不同。**
  id   cat_id price
  -----------------
  1998 NULL   199.6
  3996 NULL   198.2
  851  NULL   196.7
  3 rows in set

  五、深翻页:LIMIT 的 offset 越大越慢
  --------------------------------------------------------------------
  **`LIMIT 10 OFFSET 0` 和 `LIMIT 10 OFFSET 4000` 取的都是十行,代价差很多。**
  **因为「跳过前 4000 行」这件事,数据库是真的一行一行数过去的。**

  写法                    耗时
  --------------------------------
  `LIMIT 10 OFFSET 0`     56 微秒
  `LIMIT 10 OFFSET 2000`  102 微秒
  `LIMIT 10 OFFSET 4000`  145 微秒
  **三行数据量一样,耗时随 offset 增长。**
  **这张表只有 5000 行,差别还不算大;上了百万行,第 90 万页会慢到没法用。**
  **正确做法是「记住上一页最后一个 id」,下一页用 `WHERE id > 上次的 id` 来取。**
  **这样不管翻到第几页,代价都一样——因为它不需要「数过去」。**

  写法               耗时      翻到第几页都一样吗
  -----------------------------------------------
  `OFFSET 4000`      145 微秒  不,越深越慢
  `WHERE id > 4000`  55 微秒   是,代价恒定
  **第二行的写法不需要「跳过」,它直接从 id 大于 4000 的地方开始取。**
  **所以翻第 1 页和第 900 页一样快。**
  **它的代价是不能「跳到任意页」,只能一页一页往后翻。**
  **这个限制在「列表页」里完全不是问题,因为用户就是这么翻的。**

执行计划那两行是这一课的重点。 默认设置下两行都是 SCAN ... USING COVERING INDEX i_code——连前缀匹配都没走成 SEARCH。 「COVERING INDEX」的意思是「只读索引就够,不用回表」,它确实用上了索引, 但用的是「整条索引扫一遍」,不是「区间定位」。 打开区分大小写之后才变成 SEARCH ... (code>? AND code<?),那才是真正的区间查找。 深翻页那三档是 58、105、171 微秒,随 offset 单调上升。 表只有 5000 行,所以差距只有三倍;上了百万行,这个差距会变成几百倍。 keyset 那一句是 57 微秒,和 OFFSET 0 差不多——它压根不需要数。 这就是「翻第 1 页和翻第 900 页一样快」的意思。

30 秒实验

  1. 把 LIKE '图书%' 改成 LIKE '%图书',行数会变成 0,想想为什么是 0 而不是 500。
  2. 把深翻页的 OFFSET 从 4000 改成 4900,看耗时继续涨到什么程度。
  3. 把 WHERE cat_id IS NULL 换成 WHERE cat_id = NULL,确认它给 0 行。

常见误区

  1. 把 LIKE 'G0001' 当成「以 G0001 开头」,忘了那个 %,结果从十行掉成一行还不报错。
  2. 以为「有索引就一定走索引」,不看执行计划,结果全表扫了还以为很快。
  3. 用 OFFSET 做深翻页,前几页很快,数据一多就慢到没法用。
自测三问(点开对答案) 1. 判断空值为什么要用 `IS NULL`,不能用 `= NULL`? —— 因为 `=` 遇上空值算出来是「未知」,不是「真」。 `WHERE` 只保留结果为真的行,所以 `= NULL` 一行都留不下。`IS NULL` 是专门判断空值的运算符。 2. `LIKE '前缀%'` 在沙箱里为什么没走成 `SEARCH`? —— 因为 `LIKE` 默认不区分大小写,命中的行在索引里不是连续的一段,区间查找失效。 打开 `case_sensitive_like` 之后才变成 `SEARCH`。 3. 深翻页为什么越翻越慢,怎么改? —— `OFFSET n` 的实现是「先数过 n 行再取」,n 越大数得越多。 改成 keyset 翻页——记住上一页最后一个 id,用 `WHERE id > ?` 取下一页。

条件筛完了,下一篇 9.3 讲把行变成数的五个聚合函数,和它们的两个分母陷阱。

相关笔记