🐬 第30章 改善性能
📄 一句话概括

性能优化的第一句话是:先量,再改,改完再量。 凭感觉调优,八成是在给自己加变量。 Demo 里同一句查询:没索引 1.583 毫秒,建了索引 0.062 毫秒,差 25 倍; 列被函数包住之后 3.270 毫秒,比裸列慢 34 倍,执行计划从 SEARCH 退回 SCAN; 两百次单查 11.95 毫秒,一条 IN 查询 0.42 毫秒。

📝 本章和前面几章的关系

这是全书的最后一章。 前面二十九章教「怎么写对」,这一章教「怎么写快」。 而它们的关系是:先写对,再写快。 这一章没有新语法,全是「同一件事换个写法,差几十倍」的实测。

一、第一步永远是看执行计划

EXPLAIN 告诉你引擎打算怎么查:扫全表还是走索引、用哪个索引。 MySQL 写 EXPLAIN SELECT ...,SQLite 写 EXPLAIN QUERY PLAN ...。 两个关键词要认:SCAN 是整表扫,两万行就读两万行; SEARCH ... USING INDEX 是跳到目标那一段,只读几行。 COVERING INDEX 是更好的情况:要的列全在索引里,连数据页都不用回表读。 排查顺序:先 EXPLAIN 看计划,再量耗时确认,最后才动手改。 反过来做(先加索引再说)最常见的后果是:索引加了一堆,写入慢了一倍。

二、让索引失效的两种写法

一是在列上套函数:substr(name, 1, 8) = 'item0001'。 列被包住之后,索引里存的原始值跟表达式对不上,只能逐行算。 改写方向:把计算挪到等号右边,左边留裸列。 二是比较规则对不上:LIKE 'item0001%' 默认不区分大小写, 而普通索引是按字节排的,两边规则不一致就用不了。 给索引标上 COLLATE NOCASE 之后,计划立刻从 SCAN 变成 SEARCH。 这两条的共同点: 索引能不能用,不只看「建没建」,还看「查询里的比较方式跟索引的排序方式对不对得上」。 MySQL 里对应的是排序规则(collation)要匹配。

三、N+1 查询:最容易被忽略的慢

典型形态:列表查一次拿两百条,然后每条再查一次详情——两百零一次。 Demo 里两百次单查 11.95 毫秒,一条 IN 查询 0.42 毫秒,差 28.2 倍。 而且这还是本机内存库。 真实环境每一次查询都要网络往返, 两百次往返的延迟能到几秒。 这一条几乎是「线上慢」的头号原因。 改法只有一句:批量查。 一次 IN (...) 把需要的行全取回来, 在应用里做关联。 代价是要多写几行代码,换回来的是几十倍。

四、十条能马上用的建议

一,先 EXPLAIN 再优化。 别凭感觉加索引。 二,别写 SELECT 星号,只取要的列。 三,WHERE 里别给列套函数,把计算挪到右边。 四,批量代替循环,一条 IN 顶两百条单查。 五,索引不是越多越好:每个索引都让写入变慢、还占空间。 六,只取需要的行,加 LIMIT, 别把全表拉回应用里筛。 七,大事务拆小,长事务会长时间占锁。 八,慢查询日志开着,用数据找慢点。 九,连接池,别每次请求都新建连接。 十,先量再改,改完再量。 用同一个基准对比,否则你不知道有没有变快。 这一条比前九条都重要——没有基准的优化,只是换了一种写法。

五、小结

教材小结:用 EXPLAIN 看执行计划、为 WHERE 与 ORDER BY 的列建索引、避免 SELECT 星号、 用 LIMIT 限制行数、批量操作代替逐条、定期分析表。 本课补三条:一是索引失效的两大原因是「列上套函数」与「比较规则不匹配」; 二是 N+1 是线上慢的头号原因,改法就是批量查;三是没有基准的优化不算优化。

六、动手 Demo:从 SCAN 到 SEARCH,四组实测

Demo 六段:造两万行,看没索引时的执行计划;建索引再看一次;然后测两个「索引失效」的典型写法; 最后量 N+1 与批量的差距。 每段都有执行计划和毫秒数两个证据—— 计划说明它打算怎么查,毫秒说明实际差多少。 两个都要看。

import sqlite3, time
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE big (id INTEGER PRIMARY KEY, name TEXT, val INTEGER)")
con.executemany("INSERT INTO big (name, val) VALUES (?, ?)",
                 [("item%06d" % i, i % 97) for i in range(20000)])

# 1) 没索引:整表扫
con.execute("EXPLAIN QUERY PLAN SELECT * FROM big WHERE val = 7")
# 2) 建索引:直接跳过去
con.execute("CREATE INDEX idx_val ON big(val)")
con.execute("EXPLAIN QUERY PLAN SELECT * FROM big WHERE val = 7")

def bench(sql, n=20, args=()):
    best = None
    for _ in range(n):
        t0 = time.perf_counter()
        con.execute(sql, args).fetchall()
        dt = time.perf_counter() - t0
        best = dt if best is None else min(best, dt)
    return best * 1000

# 3) 列上套函数:索引失效
con.execute("CREATE INDEX idx_name ON big(name)")
con.execute("EXPLAIN QUERY PLAN SELECT COUNT(*) FROM big WHERE name = 'item000100'")
con.execute("EXPLAIN QUERY PLAN SELECT COUNT(*) FROM big "
            "WHERE substr(name, 1, 8) = 'item0001'")

# 4) LIKE 前缀 + 大小写:比较规则对不上也用不了索引
con.execute("CREATE INDEX idx_name_nc ON big(name COLLATE NOCASE)")
con.execute("EXPLAIN QUERY PLAN SELECT COUNT(*) FROM big WHERE name LIKE 'item0001%'")

实跑输出:

--------------------------------------------------------------------
第三十课:改善性能
--------------------------------------------------------------------

  【一】先造两万行,再问一句「它怎么查的」
  SQL> EXPLAIN QUERY PLAN SELECT * FROM big WHERE val = 7
  id     parent     notused     detail
  ---------------------------------------
  2      0          216         SCAN big
  (1 行)
  **`SCAN big` 的意思是「整表扫一遍」。** 两万行全读,一行行比 val。
  SQL> CREATE INDEX idx_val ON big(val)
  完成
  SQL> EXPLAIN QUERY PLAN SELECT * FROM big WHERE val = 7
  id     parent     notused     detail
  ---------------------------------------------------------------------
  3      0          61          SEARCH big USING INDEX idx_val (val=?)
  (1 行)
  **变成 `SEARCH big USING INDEX idx_val (val=?)`**——
  **直接跳到 val 等于 7 的那一块,不用扫全表。**
  **`EXPLAIN` 是性能排查的第一步**,MySQL 里写 `EXPLAIN SELECT ...`,
  **先看它走不走索引,再谈优化。**

  【二】量一下:差多少倍
  **有索引 0.062 毫秒,没索引 1.583 毫秒,差 25 倍。**
  **注意我取的是一段时间里的最小值**:第一次跑还要加载、预热,
  **取最小值才接近引擎的真实速度。** 这是测性能的基本纪律。

  【三】让索引失效的最常见写法:在列上套函数
  SQL> EXPLAIN QUERY PLAN SELECT COUNT(*) FROM big WHERE name = 'item000100'
  id     parent     notused     detail
  --------------------------------------------------------------------------------
  3      0          56          SEARCH big USING COVERING INDEX idx_name (name=?)
  (1 行)
  **`name = 'item000100'`:0.096 毫秒。** `substr(name, 1, 8) = ...`:**3.270 毫秒。**
  **差了 34 倍。** 原因不是函数慢,而是——**列被函数包住之后,索引就没法用了**,
  引擎只能逐行算一遍再比。
  SQL> EXPLAIN QUERY PLAN SELECT COUNT(*) FROM big WHERE substr(name, 1, 8) = 'item0001'
  id     parent     notused     detail
  ---------------------------------------------------------------------
  3      0          215         SCAN big USING COVERING INDEX idx_name
  (1 行)
  **`SCAN` 而不是 `SEARCH`,证据就在这一行。**
  **改写方向:把函数挪到等号右边**,比如 `name LIKE 'item0001%'`,
  **左边保持是裸列,索引才有机会。**

  【四】LIKE 的前缀能不能走索引:要看校对规则
  SQL> EXPLAIN QUERY PLAN SELECT COUNT(*) FROM big WHERE name LIKE 'item0001%'
  id     parent     notused     detail
  ---------------------------------------------------------------------
  3      0          215         SCAN big USING COVERING INDEX idx_name
  (1 行)
  **还是 SCAN。** 因为默认的 LIKE 不区分大小写,**而 name 上的索引是区分大小写的,对不上。**
  SQL> CREATE INDEX idx_name_nc ON big(name COLLATE NOCASE)
  完成
  SQL> EXPLAIN QUERY PLAN SELECT COUNT(*) FROM big WHERE name LIKE 'item0001%'
  id     parent     notused     detail
  ----------------------------------------------------------------------------------------------
  3      0          155         SEARCH big USING COVERING INDEX idx_name_nc (name>? AND name<?)
  (1 行)
  **给索引标上 NOCASE 之后,变成 `SEARCH ... COVERING INDEX`,耗时 0.062 毫秒。**
  **这一条说明:索引能不能用,不只是「有没有建」,还取决于「比较规则对不上」。**
  MySQL 里对应的是排序规则(collation)要匹配。

  【五】N+1 查询:最容易被忽略的慢
  **取两百行:循环发两百次单查 11.95 毫秒,一条 IN 查询 0.42 毫秒,差 28.2 倍。**
  **而且这还只是本机内存库**,真实网络里每一次都要往返一次,**差距会大到没法看。**
  **这就是「N+1 查询」**:列表查一次、每条再查一次。**改成一次批量查,是最便宜的优化。**

  【六】十条能马上用的建议
  **一,先 EXPLAIN 再优化**,别凭感觉加索引。
  **二,别写 `SELECT *`**,只取要的列——**取 1 列和取 5 列的开销不一样。**
  **三,WHERE 里别给列套函数**,把计算挪到等号右边。
  **四,批量代替循环**,一条 IN 顶两百条单查。
  **五,索引不是越多越好**:**每个索引都让写入变慢,还会占空间。**
  **六,只取需要的行**,加 LIMIT;**别把全表拉回来在应用里筛。**
  **七,大事务拆小**,长事务会长时间占锁。
  **八,慢查询日志开着**,用数据找慢点,别猜。
  **九,连接池**,别每次请求都新建连接。
  **十,先量再改**:**改完再量一次**,用同一个基准对比,否则你不知道有没有变快。

第一段没索引:SCAN big。 两万行一行行比,没有捷径。 第二段建索引:SEARCH big USING INDEX idx_val (val=?)。 同一个查询,计划换了。 第三段量耗时:0.062 对 1.583 毫秒,25 倍。 注意取的是多次里的最小值——第一次要预热, 取最小值才接近真实速度。 第四段列上套函数。 裸列 0.096 毫秒(还是覆盖索引),substr(...) 3.270 毫秒,34 倍; 计划从 SEARCH 退回 SCAN。 第五段 LIKE 前缀。 默认还是 SCAN;给索引加 COLLATE NOCASE 之后变成 SEARCH,0.062 毫秒。 第六段 N+1。 两百次单查 11.95 毫秒,一条 IN 查询 0.42 毫秒,28.2 倍, 而且这还是在内存里、没有网络。

30 秒实验

  1. 把 WHERE val = 7 换成 WHERE val > 0,还能走索引吗?计划会变成什么?
  2. 给 val 建索引之后再插两万行,插入会变快还是变慢?为什么?
  3. (先想再跑)ORDER BY name LIMIT 10 在 name 有索引时,计划里会出现什么?

常见误区

  1. 凭感觉加索引。 不看执行计划就动手,常常加了用不上、还拖慢写入。
  2. 在 WHERE 的列上套函数或做运算。 索引直接失效,改成把计算挪到等号右边。
  3. 循环里逐条查。 N+1 是线上慢的头号原因,一次 IN 批量取回来就解决。
自测三问(点开对答案) 1. EXPLAIN 里的 SCAN 和 SEARCH 有什么区别? —— SCAN 是整表逐行扫;SEARCH 是利用索引直接跳到目标行所在的位置。 2. 哪两种写法会让索引用不上? —— 在列上套函数或做运算; 以及查询的比较规则与索引的排序规则不一致(比如 LIKE 大小写敏感对不上)。 3. 为什么说 N+1 查询最该先修? —— 它把一次查询变成几百次,每次都要网络往返,延迟成倍放大;改成一次批量查询就能拿回几十倍。

三十章到这里就结束了,这本书也读完了。 回头看这条线: 从 SELECT 一句查询开始,到建表、视图、事务、权限、维护、性能—— 每一章都只是把「数据」这两个字拧紧一圈。 SQL 的语法不多,难的是知道什么时候该用哪一句。 接下来最好的练习,是拿一份真实的表,把这本书的查询从头写一遍。

相关笔记