性能优化的第一句话是:先量,再改,改完再量。 凭感觉调优,八成是在给自己加变量。 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 秒实验
- 把
WHERE val = 7换成WHERE val > 0,还能走索引吗?计划会变成什么? - 给
val建索引之后再插两万行,插入会变快还是变慢?为什么? - (先想再跑)
ORDER BY name LIMIT 10在name有索引时,计划里会出现什么?
常见误区
- 凭感觉加索引。 不看执行计划就动手,常常加了用不上、还拖慢写入。
- 在 WHERE 的列上套函数或做运算。 索引直接失效,改成把计算挪到等号右边。
- 循环里逐条查。 N+1 是线上慢的头号原因,一次 IN 批量取回来就解决。
自测三问(点开对答案)
1. EXPLAIN 里的 SCAN 和 SEARCH 有什么区别? —— SCAN 是整表逐行扫;SEARCH 是利用索引直接跳到目标行所在的位置。 2. 哪两种写法会让索引用不上? —— 在列上套函数或做运算; 以及查询的比较规则与索引的排序规则不一致(比如 LIKE 大小写敏感对不上)。 3. 为什么说 N+1 查询最该先修? —— 它把一次查询变成几百次,每次都要网络往返,延迟成倍放大;改成一次批量查询就能拿回几十倍。三十章到这里就结束了,这本书也读完了。 回头看这条线: 从 SELECT 一句查询开始,到建表、视图、事务、权限、维护、性能—— 每一章都只是把「数据」这两个字拧紧一圈。 SQL 的语法不多,难的是知道什么时候该用哪一句。 接下来最好的练习,是拿一份真实的表,把这本书的查询从头写一遍。
- ☐ (全书完)