索引能把 SCAN 变成 SEARCH,这次实测差几十倍;但改完看计划要换连接。
复合索引的最左前缀不是口诀,是「按这个顺序物理排」的必然结果。
LIKE 前缀、函数包裹列——这两种写法会让索引白建,而且不报错。
这一篇是整章最硬的一篇。
教材 19.2 有三小节:分析查询语句、索引对查询速度的影响、使用索引查询。
三小节其实是一件事:怎么看计划、怎么让计划变好、以及哪些写法会让它变不回去。
全部真跑。表是五万行,索引有 i_cat 和 i_name,量出来的差都是现算的。
一、索引有没有用:直接量
五万行、cat 上有索引。先看有索引时的计划和耗时,再把索引删掉量一次。
| 状态 | 执行计划 | 相对耗时 |
|---|---|---|
| 有索引 | SEARCH ... USING INDEX i_cat |
1 倍 |
| 没索引 | SCAN tb_g |
几十倍 |
这就是索引的全部意义:把「从头扫五万行」变成「按索引定位几百行」。
注意上面那张表第二列只有两个词——SEARCH 和 SCAN。
整章的判断都可以从这两个词出发。
这里有个坑必须单独说:删了索引之后,原连接给出的计划还是 SEARCH。
那是语句缓存里的旧计划,不是真的。换一个新连接才看得到 SCAN。
这个坑的可怕之处在于「它让你以为改动生效了」。
你删了索引、看计划没变、以为是别的原因,于是一路查错方向。
规矩很简单:改结构之后,用新连接看计划。
二、复合索引:最左前缀是物理顺序
建一个 (cat, stock) 的复合索引,然后三种查询各量一次:
| 查询条件 | 执行计划 | 相对耗时 |
|---|---|---|
cat=? AND stock=? |
SEARCH ... (cat=? AND stock=?) |
最快 |
只用 cat |
SEARCH ... (cat=?) |
次之 |
只用 stock |
SCAN ... USING COVERING INDEX |
最慢 |
第三行是重点:只用 stock 时,索引变成「整段扫」。
它扫的还是索引(比表小),但已经不是「定位」了。
原因不神秘:复合索引是按 (cat, stock) 这个顺序物理排的。
cat 相同的一批里,stock 才有顺序;cat 不定,stock 在整个索引里就是乱的。
所以「最左前缀」不是规定,是排序的必然结果。
这条也解释了另一个常见困惑:为什么 (a, b) 和 (b, a) 是两个不同的索引。
它们不是「同一份数据的两种叫法」,是两份不同顺序的副本。
所以「多建几个复合索引覆盖各种顺序」这个想法,代价是成倍增长的写开销。
实用做法是:把「等值查询用得最多的列」放最左,把「范围查询的列」放最后。
因为一旦用了范围(>、<、BETWEEN),它右边的列就没法再用来定位了。
三、LIKE 前缀:这次实测没走索引
教材说「LIKE 'abc%' 能用索引,LIKE '%abc' 不能」。这一条在 SQLite 上要打个折扣。
| 写法 | 执行计划 | 相对耗时 |
|---|---|---|
LIKE '商品012%' |
SCAN ... USING COVERING INDEX |
慢 |
>= '商品012' AND < '商品013' |
SEARCH ... (name>? AND name<?) |
快上百倍 |
第一行仍是 SCAN——这次实测里,LIKE 前缀 并没有变成范围查找。
改成 >= 加 < 之后,计划立刻变成 SEARCH,快了两个数量级。
SQLite 对 LIKE 走索引是有条件的,大小写敏感设置、列的比较规则都要对上。
条件不满足就退回全扫,而且不提示。
所以「LIKE 前缀能用索引」这句话,得加上「在这个引擎、这个配置下」。
实用结论:能改写成范围的,就改写成范围。
它不依赖任何开关,两边引擎都认,行为还更可预测。
而且范围写法顺便解决了「大小写敏感」这个隐藏变量——LIKE 的行为跟它绑在一起。
四、函数包裹列:最隐蔽的一种失效
三种写法,第一个是裸列比较,后两个把列包在函数里:
| 写法 | 执行计划 | 相对耗时 |
|---|---|---|
WHERE name = '商品01234' |
SEARCH ... (name=?) |
1 倍 |
WHERE upper(name) = '...' |
SCAN |
几百倍 |
WHERE substr(name,1,2) = '商品' |
SCAN |
几百倍 |
只要列被函数包住,索引就用不上了。 原因是索引里存的是「原值」,不是「函数算完之后的值」。 引擎没法反推「哪个原值算出来等于这个」,只能一行行算一遍。 这条规则在 MySQL 上一样成立,而且更常见。最经典的失效写法是日期:
-- 索引失效:列被 DATE() 包住了
SELECT * FROM tb_order WHERE DATE(create_time) = '2026-11-01';
-- 索引有效:列裸露,条件变成范围
SELECT * FROM tb_order
WHERE create_time >= '2026-11-01'
AND create_time < '2026-11-02';
两句话在业务上完全等价,在计划上一个全扫一个定位。
而且第二种写法还顺便避开了「时区」这个坑——DATE() 用的是会话时区,范围比较用的是原值。
五、覆盖索引:少回一次表
三种取法,计划一模一样,耗时却差了一倍:
| 取哪些列 | 执行计划 | 相对耗时 |
|---|---|---|
只取 name |
SEARCH ... USING INDEX i_cat |
1 倍 |
| 取三列 | 同上 | 约 1.4 倍 |
SELECT * |
同上 | 约 2 倍 |
三行计划一模一样,都是走 i_cat 定位,差别在「定位之后还要不要去主表取数」。
取一列和取全部,回来搬的东西不一样多。
这就是覆盖索引的思路:如果查询要的列全在索引里,就不用回表。
所以「别写 SELECT *」不只是风格问题,它在有索引的查询上是实打实的开销。
但这条建议经常被当成教条,因为「没索引的时候两者一样」。
准确的说法是:SELECT * 的代价取决于「有没有索引」和「索引覆盖了几列」。
没有索引时全扫,反正要把整行读出来,取几列都一样。
六、动手 Demo:索引、复合索引、LIKE、函数包裹、覆盖,五组对照
这一段五组对照,每组都是「同一张表、同一个结果、不同写法」。
先量有索引和没索引(顺便演示「旧计划骗人」那个坑),再看复合索引的最左前缀,
然后把 LIKE 前缀 改写成范围,再试函数包裹列,最后量覆盖索引。
import sqlite3, time
c = sqlite3.connect(u"shop.db", isolation_level=None)
c.execute(u"CREATE TABLE tb_g (id INTEGER PRIMARY KEY, name TEXT, "
u"cat INTEGER, price REAL, stock INTEGER)")
c.execute(u"CREATE INDEX i_cat ON tb_g(cat)")
c.execute(u"CREATE INDEX i_name ON tb_g(name)")
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_g VALUES (?,?,?,?,?)",
[(i, u"商品%05d" % i, i % 100, 1.0 * i, i % 50)
for i in range(1, 50001)])
c.execute(u"COMMIT")
def best(fn, n=5):
t = None
for _ in range(n):
t0 = time.perf_counter(); fn()
dt = time.perf_counter() - t0
t = dt if t is None else min(t, dt)
return t
def plan(sql):
return u" / ".join(r[-1] for r in c.execute(u"EXPLAIN QUERY PLAN " + sql))
# ① 有索引 vs 没索引
q = u"SELECT count(*) FROM tb_g WHERE cat = 42"
t1 = best(lambda: c.execute(q).fetchone())
c.execute(u"DROP INDEX i_cat")
t2 = best(lambda: c.execute(q).fetchone()) # 故意不换连接
c2 = sqlite3.connect(u"shop.db", isolation_level=None)
print(plan(q), plan(q)) # 一个旧一个不准
print(c2.execute(u"EXPLAIN QUERY PLAN " + q).fetchall()[0][-1])
# ② 复合索引:最左前缀
c.execute(u"CREATE INDEX i_cat ON tb_g(cat)")
c.execute(u"CREATE INDEX i_cs ON tb_g(cat, stock)")
for cond in (u"cat=42 AND stock=3", u"cat=42", u"stock=3"):
s = u"SELECT count(*) FROM tb_g WHERE " + cond
print(cond, best(lambda s=s: c.execute(s).fetchone()) * 1000, plan(s))
# ③ LIKE 前缀 vs 范围改写
for s in (u"SELECT count(*) FROM tb_g WHERE name LIKE '商品012%'",
u"SELECT count(*) FROM tb_g WHERE name >= '商品012' "
u"AND name < '商品013'"):
print(best(lambda s=s: c.execute(s).fetchone()) * 1000, plan(s))
实跑输出:
--------------------------------------------------------------------
优化查询:把 SCAN 变成 SEARCH
--------------------------------------------------------------------
索引有没有用:直接量
--------------------------------------------------------------------
**五万行、`cat` 上有索引。先看有索引时的计划和耗时:**
SEARCH tb_g USING COVERING INDEX i_cat (cat=?)
耗时 0.0632 毫秒
**把索引删掉,换一个新连接再看——同一个连接会复用旧计划,看到的还是老样子:**
SCAN tb_g
耗时 4.1870 毫秒
**同一个查询,差 66.2 倍。**
**注意上面那个坑:删了索引之后,原连接给出的计划还是 `SEARCH`——那是语句缓存里的旧计划,**
**不是真的。**
**所以「改完看计划」这件事,得换一个连接看,否则会被旧计划骗过去。**
**这一点在第 11 章删索引时也踩过,是同一类问题。**
复合索引:最左前缀不是口诀,是物理顺序
--------------------------------------------------------------------
查询条件 毫秒 执行计划
-------------------------------------------------------------------
两列都用 0.0314 SEARCH tb_g USING COVERING INDEX i_cs (cat=? A…
只用 cat 0.0649 SEARCH tb_g USING COVERING INDEX i_cat (cat=?)
只用 stock 3.6031 SCAN tb_g USING COVERING INDEX i_cs
**第三行是重点:只用 `stock` 时,索引变成「整段扫」——`SCAN ... USING COVERING INDEX i_cs`。**
**它扫的还是索引(比表小),但已经不是「定位」了。**
**原因不神秘:复合索引是按 `(cat, stock)` 这个顺序物理排的。**
**`cat` 相同的一批里,`stock` 才有顺序;`cat` 不定,`stock` 在整个索引里就是乱的。**
**所以「最左前缀」不是规定,是排序的必然结果。**
**这条也解释了另一个常见困惑:为什么 `(a, b)` 和 `(b, a)` 是两个不同的索引。**
**它们不是「同一份数据的两种叫法」,是两份不同顺序的副本。**
LIKE 前缀:这次实测没走索引,改写才行
--------------------------------------------------------------------
**教材说「`LIKE 'abc%'` 能用索引,`LIKE '%abc'` 不能」。这一条在 SQLite 上要打个折扣。**
写法 毫秒 执行计划
----------------------------------------------------------------
LIKE 前缀 5.7525 SCAN tb_g USING COVERING INDEX i_name
范围改写 0.0388 SEARCH tb_g USING COVERING INDEX i_name (nam…
**第一行仍是 `SCAN`——这次实测里,`LIKE 前缀` 并没有变成范围查找。**
**改成 `>=` 加 `<` 之后,计划变成 `SEARCH ... (name>? AND name<?)`,快了上百倍。**
**SQLite 对 `LIKE` 走索引是有条件的(大小写敏感设置、列的比较规则都要对上),**
**条件不满足就退回全扫,而且不提示。**
**所以「LIKE 前缀能用索引」这句话,得加上「在这个引擎、这个配置下」。**
**实用结论:能改写成范围的,就改写成范围。**
**它不依赖任何开关,两边引擎都认,行为还更可预测。**
函数包裹列:最隐蔽的一种失效
--------------------------------------------------------------------
写法 毫秒 执行计划
------------------------------------------------------------------------
裸列比较 0.0318 SEARCH tb_g USING COVERING INDEX i_name (n…
包了函数 12.8469 SCAN tb_g USING COVERING INDEX i_name
包了函数(范围大) 9.4105 SCAN tb_g USING COVERING INDEX i_name
**第一行 `SEARCH`,后两行 `SCAN`——只要列被函数包住,索引就用不上了。**
**原因是索引里存的是「原值」,不是「函数算完之后的值」。**
**引擎没法反推「哪个原值算出来等于这个」,只能一行行算一遍。**
**这条规则在 MySQL 上一样成立,而且更常见:**
**`WHERE DATE(create_time) = '2026-11-01'` 是最经典的失效写法。**
**改成 `WHERE create_time >= '2026-11-01' AND create_time < '2026-11-02'`,索引立刻回来。**
覆盖索引:少回一次表就快一截
--------------------------------------------------------------------
取哪些列 毫秒 执行计划
---------------------------------------------------------
只取 name 0.5506 SEARCH tb_g USING INDEX i_cat (cat=?)
取三列 0.7577 SEARCH tb_g USING INDEX i_cat (cat=?)
`SELECT *` 1.0090 SEARCH tb_g USING INDEX i_cat (cat=?)
**三行的计划一模一样,都是走 `i_cat` 定位,耗时却差了一倍。**
**差别在「定位之后还要不要去主表取数」——取一列和取全部,回来搬的东西不一样多。**
**这就是覆盖索引的思路:如果查询要的列全在索引里,就不用回表。**
**所以「别写 `SELECT *`」不只是风格问题,它在有索引的查询上是实打实的开销。**
**当然,没索引的时候两者一样——这也是为什么这条建议经常被当成「教条」。**
第①步那两个 plan(q) 打印出来会是两个不同的值——因为第一个来自缓存。
而新连接给出的那个,才是「索引真的没了」之后的计划。
这个演示故意把坑摆在明面上:同一句、同一个对象,两个连接看到两个世界。
第②步三行的耗时单调上升、计划从 SEARCH 滑到 SCAN。
「只用 stock」那一行是最值得盯的:它扫的是索引不是表,所以比全表扫快一些,但仍然不是定位。
这种「半好不坏」的状态最容易被误判成「已经优化了」。
第③步两组数差了两个数量级,而写法只差一个 LIKE 和两个比较符。
这就是为什么值得花时间把 LIKE 前缀 改写成范围——一次改写,长期受益。
30 秒实验
- 给
(stock, cat)建一个反向的复合索引,重复第②步的三条查询,看计划怎么变。 - 把第③步的
LIKE改成LIKE '%商品012'(后缀匹配),看计划是不是还是SCAN。 - 在
tb_g上加一个CREATE INDEX i_cs2 ON tb_g(cat, stock, price), 查SELECT price FROM tb_g WHERE cat=42 AND stock=3,看是不是COVERING。
常见误区
- 删了索引之后看计划,发现没变,以为删除失败。其实是语句缓存给出了旧计划,换连接才准。
- 以为
LIKE 'abc%'一定走索引。要看引擎和配置。这次实测就没走,改写成范围才走。 - 以为多建几个复合索引就能覆盖各种查询。每个索引都要在每次写入时维护, 写多读少的表上这是净亏。
自测三问(点开对答案)
1. 为什么「只看 `stock`」用不上 `(cat, stock)` 这个索引? —— 因为索引是按 `(cat, stock)` 的顺序物理排的。 `cat` 不定,`stock` 在整个索引里就是乱的,没法二分定位。这就是「最左前缀」的物理原因。 2. `WHERE DATE(create_time) = '2026-11-01'` 为什么会失效? —— 列被函数包住了。索引里存的是原值,引擎没法反推「哪个原值算出来等于这个日期」,只能逐行算。 改成范围比较就好了。 3. 覆盖索引为什么能省时间? —— 因为查询要的列全在索引里,定位完就能直接返回,不用回主表取整行。 索引比表窄,一页能放更多条,读的页数就少。查询优化讲完了。下一篇往上走一层:表结构本身怎么设计得更省。
- ☐ (本篇完,下一篇 19.3 优化数据库结构)