📑 本页目录(点开跳转)
09 · 执行计划里的连接,和写法怎么毁掉索引
⏱ 104 分钟 | ⚠️ 实测:索引在、计划里也写着 COVERING INDEX,它照样是 SCAN 全表
🎯 一句话
执行计划里只有两个动词要认:SCAN = 从头读一遍,SEARCH = 直接定位过去。
⭐ 索引名出现在计划里,不代表索引在起作用 —— 判据是动词,不是索引名。
本章只讲两件让 SQL 从「对」变成「快」的事:连接顺序,和写法怎么把已经建好的索引废掉。
🧭 一、先划清这一章不讲什么
01 章第四节留了一句话:逻辑顺序回答「结果为什么是这样」,物理计划回答「为什么这么慢」, 后半句欠到这里。但「慢」这个话题很大,本教程只拿走其中跟怎么写 SQL 有关的那一半:
| 话题 | 在哪 |
|---|---|
| 索引该不该建、怎么建、复合索引的列序 | AI 全栈 06 关系数据库(那边有一组 200 次查询 3.802 秒 → 0.0019 秒的实测) |
| 覆盖索引该不该建、为什么别把大字段塞进索引 | 同上。⭐ 本章只教你在计划里认出它,不教你建 |
COUNT(*) 在大表上的成本、游标分页怎么用 |
AI 全栈 06b 列表接口与分页 |
| 表怎么设计、事务、连接池、扩容 | 不在本教程范围内 |
| ⭐ 连接顺序为什么会差十倍 | 本章第三节 |
| ⭐ 写法怎么让已有的索引用不上 | 本章第四到六节 |
⚠️⚠️ 还有一条免责声明必须写在最前面:执行计划的长相是每个数据库自己的方言。
本章用 SQLite(EXPLAIN QUERY PLAN),Postgres 是 EXPLAIN (ANALYZE, BUFFERS)、
MySQL 是 EXPLAIN FORMAT=TREE,输出的字面完全不一样。
⭐ 可迁移的是判据(「有没有做全表扫」「索引用上没有」「有没有额外排序」),不可迁移的是字面。
所以下面每一处我都会先说结论、再给 SQLite 的原文,⚠️ 别去背原文。
🔤 二、怎么读一份计划:两个动词,一个陷阱
一张 20 万行的事件表,user_id 上有索引。六种查询各看一次计划:
import sqlite3, random
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE ev (id INTEGER PRIMARY KEY, user_id TEXT, ts TEXT, amount REAL)")
random.seed(3)
db.executemany("INSERT INTO ev VALUES (?,?,?,?)", [
(i, "u%05d" % random.randint(1, 50000),
"2026-03-%02d" % random.randint(1, 30), random.random() * 100)
for i in range(1, 200001)])
db.execute("CREATE INDEX ix_user ON ev(user_id)")
db.commit()
db.execute("ANALYZE")
def plan(sql):
for r in db.execute("EXPLAIN QUERY PLAN " + sql):
print(" ", r[3])
print("① 没有条件 —— 整张表都要读")
plan("SELECT * FROM ev")
print("② 条件用上了索引 —— SEARCH")
plan("SELECT * FROM ev WHERE user_id = 'u00123'")
print("③ 只要索引里有的列 —— COVERING INDEX,不回表")
plan("SELECT user_id FROM ev WHERE user_id = 'u00123'")
print("④ 多要一列 amount —— 索引里没有,必须回表")
plan("SELECT user_id, amount FROM ev WHERE user_id = 'u00123'")
print("⑤ 💀 计划里写着 COVERING INDEX,动作却是 SCAN")
plan("SELECT COUNT(*) FROM ev WHERE substr(user_id, 2) = '00123'")
print("⑥ ORDER BY 能不能白嫖索引的顺序")
plan("SELECT user_id FROM ev ORDER BY user_id LIMIT 10")
plan("SELECT user_id FROM ev ORDER BY amount LIMIT 10")
实测输出(Python 3.13.14 / SQLite 3.50.4):
① 没有条件 —— 整张表都要读
SCAN ev
② 条件用上了索引 —— SEARCH
SEARCH ev USING INDEX ix_user (user_id=?)
③ 只要索引里有的列 —— COVERING INDEX,不回表
SEARCH ev USING COVERING INDEX ix_user (user_id=?)
④ 多要一列 amount —— 索引里没有,必须回表
SEARCH ev USING INDEX ix_user (user_id=?)
⑤ 💀 计划里写着 COVERING INDEX,动作却是 SCAN
SCAN ev USING COVERING INDEX ix_user
⑥ ORDER BY 能不能白嫖索引的顺序
SCAN ev USING COVERING INDEX ix_user
SCAN ev
USE TEMP B-TREE FOR ORDER BY
逐个拆:
| 计划里出现 | 意思 | 好还是坏 |
|---|---|---|
SCAN 表名 |
把这个东西从头到尾读一遍 | ⚠️ 大表上通常是坏消息 |
SEARCH 表名 USING INDEX ix (col=?) |
拿索引直接定位,只读要的那几行 | ✅ 通常是好消息 |
USING COVERING INDEX |
要的列全在索引里,连表都不用碰 | ✅ 更好一档 |
USE TEMP B-TREE FOR ORDER BY |
结果没法按索引顺序出来,得先全捞出来再排一遍 | ⚠️ 数据一大就是主要开销 |
AUTOMATIC …INDEX |
⭐ 数据库临时给你造了个索引 —— 等于它在说「这儿缺一个」 | ⚠️ 见下一节 |
💀💀 ⑤ 是本章的卖点,也是最容易被计划骗到的一次。
它写着 COVERING INDEX ix_user,索引名清清楚楚,看起来「索引用上了」。
⚠️ 但动词是 SCAN —— 它把 ix_user 这个索引从头到尾读了一遍,20 万行一行不落,
只是省掉了回表而已。这不叫用上了索引,这叫在索引上做全表扫。
⭐ 判据:先看动词,再看索引名。SCAN … USING … INDEX 依然是全扫。
⚠️ 反过来也一样:SEARCH 不等于快。
第四节会看到 WHERE user_id IS NOT NULL 实测是 SEARCH ev USING COVERING INDEX ix_user (user_id>?) ——
动词是 SEARCH,耗时却是 2.5847 ms(同表命中一个 user_id 只要 0.0028 ms)。
因为它「定位」到的范围里装着几乎全部的行。
⭐ 动词只告诉你「怎么找」,不告诉你「找到多少」。
③④ 那一对值得单独说一句:同一个 WHERE,只多要了一列 amount,
计划就从 COVERING INDEX 退回 INDEX —— 因为 amount 不在索引里,必须回表拿。
⭐ 这正是 01 章说「SELECT * 让覆盖索引永远不可能生效」的完整版。
⚠️ 但请别倒过来读成「那就把所有列塞进索引」——AI 全栈 06 明确否掉了这条路(那等于把整张表再复制一遍)。
⑥ 是很多人不知道索引还能干的事:ORDER BY user_id 不需要排序,因为索引本来就是按 user_id 有序存的,
计划里干干净净;换成 ORDER BY amount 就冒出 USE TEMP B-TREE FOR ORDER BY。
⭐ ORDER BY + LIMIT 的分页查询要是慢,先去计划里找这一行。
🔀 三、连接顺序:同一对表,谁在外层差十倍
02 章讲了 JOIN 的语义,欠着代价。代价的核心只有一句话:
⭐ 最基础的连接算法是「嵌套循环」:外层表每出一行,就拿连接键去内层表找一次。 总代价 ≈ 外层的行数 × 内层每次查找的代价。 于是「谁在外层」和「内层能不能用索引」这两件事,直接决定快慢。
2000 个用户、20 万条事件,⭐ SQLite 里 CROSS JOIN 的作用就是禁止优化器重排顺序,正好拿来手工指定谁在外层:
import sqlite3, random, time
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, city TEXT)")
db.execute("CREATE TABLE events (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL)")
random.seed(7)
db.executemany("INSERT INTO users VALUES (?,?)",
[(i, random.choice("ABCDE")) for i in range(1, 2001)])
db.executemany("INSERT INTO events VALUES (?,?,?)",
[(i, random.randint(1, 2000), random.random() * 100)
for i in range(1, 200001)])
db.commit()
print("users 2000 行,events 200000 行")
def run(label, sql):
p = [r[3] for r in db.execute("EXPLAIN QUERY PLAN " + sql)]
db.execute(sql).fetchall() # 预热
t = time.perf_counter()
for _ in range(5):
db.execute(sql).fetchall()
print("%-14s %7.4f s" % (label, (time.perf_counter() - t) / 5))
for line in p:
print(" ", line)
# ⭐ SQLite 里 CROSS JOIN 的作用就是【禁止优化器重排顺序】,用它来手工指定谁在外层
A = "SELECT COUNT(*) FROM users u CROSS JOIN events e ON e.user_id=u.id WHERE u.city='A'"
B = "SELECT COUNT(*) FROM events e CROSS JOIN users u ON u.id=e.user_id WHERE u.city='A'"
print("\n===== 没有任何索引 =====")
run("users 在外层", A)
run("events 在外层", B)
db.execute("CREATE INDEX ix_ev_user ON events(user_id)")
print("\n===== 给 events(user_id) 建了索引之后 =====")
run("users 在外层", A)
run("events 在外层", B)
print("\n===== 换成普通 JOIN,让优化器自己选 =====")
run("优化器自选", "SELECT COUNT(*) FROM users u JOIN events e ON e.user_id=u.id WHERE u.city='A'")
实测输出:
users 2000 行,events 200000 行
===== 没有任何索引 =====
users 在外层 0.2380 s
SCAN u
BLOOM FILTER ON e (user_id=?)
SEARCH e USING AUTOMATIC COVERING INDEX (user_id=?)
events 在外层 0.0440 s
SCAN e
SEARCH u USING INTEGER PRIMARY KEY (rowid=?)
===== 给 events(user_id) 建了索引之后 =====
users 在外层 0.0019 s
SCAN u
SEARCH e USING COVERING INDEX ix_ev_user (user_id=?)
events 在外层 0.0140 s
SCAN e USING COVERING INDEX ix_ev_user
SEARCH u USING INTEGER PRIMARY KEY (rowid=?)
===== 换成普通 JOIN,让优化器自己选 =====
优化器自选 0.0019 s
SCAN u
SEARCH e USING COVERING INDEX ix_ev_user (user_id=?)
⚠️ 绝对耗时随机器和当时的机器负载变(本机复跑四次,第一格在 0.2380 ~ 0.4739 秒之间跳)。 ⭐ 但四次跑出来的计划一字不差,下面这三条关系也每次都一样 —— 它们是结构性的,不是噪声。
① 没索引时,大表在外层反而快(0.0440 s vs 0.2380 s,约 5 倍)。
users 在外层时,内层的 events 没有可用索引,数据库只好现造一个——
计划里那句 SEARCH e USING AUTOMATIC COVERING INDEX 就是它在说:「这儿缺一个索引,我先临时建一个用用。」
⭐ 看到 AUTOMATIC 就该去 AI 全栈 06 补一个真索引 —— 临时索引每次查询都要重建一遍。
(旁边那句 BLOOM FILTER 是 SQLite 3.38+ 的优化:先用一个便宜的过滤器挡掉大部分注定连不上的行。)
② 建完索引之后,最优顺序反了过来(0.0019 s vs 0.0140 s,约 7 倍,方向和上一条相反)。
现在 users 在外层才是对的:先用 city='A' 把外层缩到约 400 行,每行拿索引去 events 精准定位。
⭐ 同一对表、同一个问题,最优连接顺序会因为「有没有索引」而反转 ——
这就是不要手工钉死连接顺序的理由。
③ 交给优化器,它选对了。
把 CROSS JOIN 换回普通 JOIN,它给出的计划和手工最优那条一字不差(SCAN u + SEARCH e USING COVERING INDEX)。
⭐ 实践判据:先看计划、再考虑改写,而不是先改写。 手工调顺序(
CROSS JOIN、MySQL 的STRAIGHT_JOIN、各种 hint)是最后一招—— 它把「今天的数据分布」和「今天的索引」焊进了 SQL 里,⚠️ 而这两样都会变。
⚠️ 🗓️ 未实跑 —— SQLite 只有嵌套循环这一种连接算法。 Postgres / MySQL 8.0 还有 Hash Join(把小表做成哈希表,适合大表连大表且没有合适索引) 和 Merge Join(两边都已排好序时像拉链一样合并)。 ⭐ 你只需要知道:看到计划里写着 Hash / Merge,说明它没在走「逐行去索引里找」那条路, 剩下的细节属于数据库调优,不在本教程范围内。
🛑 读到这里可以停 —— 前半章讲完了(约 34 分钟)。 后半章还有:sargability:六种把索引废掉的写法 ·
OR的三种命运,和一个LIKE的意外 · 一条查询慢了,按什么顺序查 回来的时候不用重读,直接从下一节接着看就行。
🧨 四、sargability:六种把索引废掉的写法
前面都是「索引够不够」,这一节反过来 —— ⭐ 索引明明在,是你的写法让它用不上。
这件事有个专门的词:sargable(Search ARGument ABLE,「能当搜索条件用的」)。 ⭐ 判据只有一句:索引列必须自己一个人待在比较符的一边,不能被任何东西包住。
20 万行,user_id 和 ts 上都有索引:
import sqlite3, random, time
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE ev (id INTEGER PRIMARY KEY, user_id TEXT, ts TEXT, amount REAL)")
random.seed(3)
db.executemany("INSERT INTO ev VALUES (?,?,?,?)", [
(i, "u%05d" % random.randint(1, 50000),
"2026-03-%02d %02d:00:00" % (random.randint(1, 30), random.randint(0, 23)),
random.random() * 100)
for i in range(1, 200001)])
db.execute("CREATE INDEX ix_user ON ev(user_id)")
db.execute("CREATE INDEX ix_ts ON ev(ts)")
db.commit()
db.execute("ANALYZE")
def show(label, sql):
p = [r[3] for r in db.execute("EXPLAIN QUERY PLAN " + sql)]
db.execute(sql).fetchall() # 预热
t = time.perf_counter()
for _ in range(20):
db.execute(sql).fetchone()
ms = (time.perf_counter() - t) / 20 * 1000
print("%-24s %9.4f ms %s" % (label, ms, p[0]))
print("== ① 函数包住列 ==")
show("substr(ts,1,10)='...'", "SELECT COUNT(*) FROM ev WHERE substr(ts,1,10)='2026-03-05'")
show("date(ts)='...'", "SELECT COUNT(*) FROM ev WHERE date(ts)='2026-03-05'")
show("✅ 半开区间", "SELECT COUNT(*) FROM ev WHERE ts>='2026-03-05' AND ts<'2026-03-06'")
print("== ② CAST 包住列 ==")
show("CAST(user_id AS TEXT)", "SELECT COUNT(*) FROM ev WHERE CAST(user_id AS TEXT)='u00123'")
show("✅ 直接比", "SELECT COUNT(*) FROM ev WHERE user_id='u00123'")
print("== ③ 列上做算术 ==")
show("amount*2 > 199", "SELECT COUNT(*) FROM ev WHERE amount*2 > 199")
show("user_id||'' = 'u00123'", "SELECT COUNT(*) FROM ev WHERE user_id||''='u00123'")
print("== ④ 前导通配 ==")
show("LIKE '%00123'", "SELECT COUNT(*) FROM ev WHERE user_id LIKE '%00123'")
show("✅ 范围改写", "SELECT COUNT(*) FROM ev WHERE user_id>='u00123' AND user_id<'u00124'")
print("== ⑤ 否定条件 ==")
show("user_id <> 'u00123'", "SELECT COUNT(*) FROM ev WHERE user_id<>'u00123'")
show("user_id IS NOT NULL", "SELECT COUNT(*) FROM ev WHERE user_id IS NOT NULL")
print("== ⑥ OR 一侧没索引 ==")
show("OR amount>99.9", "SELECT COUNT(*) FROM ev WHERE user_id='u00123' OR amount>99.9")
show("OR 两侧都有索引", "SELECT COUNT(*) FROM ev WHERE user_id='u00123' OR ts>='2026-03-30'")
实测输出:
== ① 函数包住列 ==
substr(ts,1,10)='...' 16.2629 ms SCAN ev USING COVERING INDEX ix_ts
date(ts)='...' 26.2926 ms SCAN ev USING COVERING INDEX ix_ts
✅ 半开区间 0.2750 ms SEARCH ev USING COVERING INDEX ix_ts (ts>? AND ts<?)
== ② CAST 包住列 ==
CAST(user_id AS TEXT) 10.5548 ms SCAN ev USING COVERING INDEX ix_user
✅ 直接比 0.0028 ms SEARCH ev USING COVERING INDEX ix_user (user_id=?)
== ③ 列上做算术 ==
amount*2 > 199 11.7823 ms SCAN ev
user_id||'' = 'u00123' 12.0084 ms SCAN ev USING COVERING INDEX ix_user
== ④ 前导通配 ==
LIKE '%00123' 14.0207 ms SCAN ev USING COVERING INDEX ix_user
✅ 范围改写 0.0042 ms SEARCH ev USING COVERING INDEX ix_user (user_id>? AND user_id<?)
== ⑤ 否定条件 ==
user_id <> 'u00123' 9.9813 ms SCAN ev USING COVERING INDEX ix_user
user_id IS NOT NULL 2.5847 ms SEARCH ev USING COVERING INDEX ix_user (user_id>?)
== ⑥ OR 一侧没索引 ==
OR amount>99.9 21.2903 ms SCAN ev
OR 两侧都有索引 0.6862 ms MULTI-INDEX OR
⚠️ 绝对毫秒数随机器和缓存状态变,量级差异是结构性的。 一张表读完:
| # | 写法 | 计划动词 | 实测 | 为什么 | ✅ 怎么改 |
|---|---|---|---|---|---|
| ① | substr(ts,1,10)='2026-03-05' |
SCAN |
16.2629 ms | 索引存的是 ts 的值,不是 substr(ts) 的值 |
⭐ 半开区间 ts>='2026-03-05' AND ts<'2026-03-06',0.2750 ms(约 59 倍) |
| ① | date(ts)='2026-03-05' |
SCAN |
26.2926 ms | 同上,而且函数更贵 | 同上 |
| ② | CAST(user_id AS TEXT)='u00123' |
SCAN |
10.5548 ms | ⭐ CAST 也是函数 |
直接比,0.0028 ms(约 3800 倍) |
| ③ | amount*2 > 199 |
SCAN |
11.7823 ms | 列上做了算术 | 把算术挪到另一边:amount > 99.5 |
| ③ | user_id 拼上空串再比(上面代码块第三段) |
SCAN |
12.0084 ms | 字符串拼接也是把列包住了 | 把那个拼接去掉,直接比 |
| ④ | user_id LIKE '%00123' |
SCAN |
14.0207 ms | 索引按前缀有序,不知道后缀在哪 | ⚠️ 没有等价改写。真要按后缀查就换工具(全文索引 / 反转列) |
| ⑤ | user_id <> 'u00123' |
SCAN |
9.9813 ms | 「不等于」意味着几乎所有行都要,索引帮不上忙 | 通常没法改 —— ⭐ 这条本来就该全扫 |
| ⑤ | user_id IS NOT NULL |
SEARCH |
2.5847 ms | ⭐ 动词是 SEARCH,但范围覆盖几乎全表 |
⚠️ SEARCH ≠ 快,见第二节 |
| ⑥ | … OR amount>99.9 |
SCAN |
21.2903 ms | amount 没索引,一侧塌了整条就塌 |
见下一节 |
⭐ ①②③ 是同一个错误的三种衣服:substr()、date()、CAST()、*2、|| '' ——
只要索引列被任何东西包住,索引就用不上了,因为索引里存的是列本身的值,
而数据库没法从「substr(ts,1,10) 等于某值」反推出「ts 落在哪个区间」。
⭐ 改法几乎永远是同一句:把变形从列这边挪到常量那边。
date(ts) = '2026-03-05'→ts >= '2026-03-05' AND ts < '2026-03-06'amount * 2 > 199→amount > 99.5YEAR(created) = 2026→created >= '2026-01-01' AND created < '2027-01-01'⚠️ 注意右端一律用
<不用<=(半开区间)—— 用<= '2026-03-05 23:59:59'会漏掉那一秒里的毫秒。
⚠️ 另一条路是给表达式本身建索引(Postgres/SQLite 的表达式索引、MySQL 的生成列索引)。 它有效,但属于「索引怎么建」,是 AI 全栈 06 的题目。 ⭐ 本教程的立场:能改写就改写 —— 改写不用加对象、不占空间、不用维护,而且改写之后换个库照样快。
🛑 第二个休息点 —— 中段讲完了(约 23 分钟)。 最后一段还有:
OR的三种命运,和一个LIKE的意外 · 一条查询慢了,按什么顺序查 这一章确实长,分三次读完全没问题 —— 回来直接从下一节接着看。
🛑 读到这里可以停 —— 已经读了约 57 分钟。 最后一段还有(约 26 分钟):
OR的三种命运,和一个LIKE的意外 · 一条查询慢了,按什么顺序查 回来的时候不用重读,直接从下一节接着看就行。
🔧 五、OR 的三种命运,和一个 LIKE 的意外
OR 值得单独一节,因为它是唯一一个「同一个写法,命运取决于另一侧」的坑:
import sqlite3, random, time
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE ev (id INTEGER PRIMARY KEY, user_id TEXT, uid_int INTEGER, amount REAL)")
random.seed(11)
db.executemany("INSERT INTO ev VALUES (?,?,?,?)",
[(i, "u%05d" % random.randint(1, 50000), random.randint(1, 50000),
random.random() * 100) for i in range(1, 200001)])
db.execute("CREATE INDEX ix_user ON ev(user_id)") # TEXT 列
db.execute("CREATE INDEX ix_uidint ON ev(uid_int)") # INTEGER 列
db.commit()
db.execute("ANALYZE")
def show(label, sql):
p = [r[3] for r in db.execute("EXPLAIN QUERY PLAN " + sql)]
db.execute(sql).fetchall()
t = time.perf_counter()
for _ in range(20):
db.execute(sql).fetchall()
print("%-22s %8.4f ms" % (label, (time.perf_counter() - t) / 20 * 1000))
for line in p:
print(" ", line)
print("== OR 的三种命运(20 万行)==")
show("① 两侧都有索引", "SELECT id FROM ev WHERE user_id='u00123' OR uid_int=7")
show("② 改写成 UNION",
"SELECT id FROM ev WHERE user_id='u00123' UNION SELECT id FROM ev WHERE uid_int=7")
show("③ 一侧没有索引", "SELECT id FROM ev WHERE user_id='u00123' OR amount>99.9")
print("\n== 类型对不上时,SQLite 会不会丢索引 ==")
show("TEXT 列 = 整数字面量", "SELECT COUNT(*) FROM ev WHERE user_id = 123")
show("INTEGER 列 = 字符串", "SELECT COUNT(*) FROM ev WHERE uid_int = '123'")
print("\n== LIKE 前缀能不能用索引,取决于一个 PRAGMA ==")
show("LIKE 'u00123%'(默认)", "SELECT COUNT(*) FROM ev WHERE user_id LIKE 'u00123%'")
db.execute("PRAGMA case_sensitive_like=ON")
show("同一句,PRAGMA 打开后", "SELECT COUNT(*) FROM ev WHERE user_id LIKE 'u00123%'")
show("范围改写(永远有效)", "SELECT COUNT(*) FROM ev WHERE user_id>='u00123' AND user_id<'u00124'")
实测输出:
== OR 的三种命运(20 万行)==
① 两侧都有索引 0.0066 ms
MULTI-INDEX OR
INDEX 1
SEARCH ev USING INDEX ix_user (user_id=?)
INDEX 2
SEARCH ev USING INDEX ix_uidint (uid_int=?)
② 改写成 UNION 0.0179 ms
COMPOUND QUERY
LEFT-MOST SUBQUERY
SEARCH ev USING COVERING INDEX ix_user (user_id=?)
UNION USING TEMP B-TREE
SEARCH ev USING COVERING INDEX ix_uidint (uid_int=?)
③ 一侧没有索引 15.7034 ms
SCAN ev
== 类型对不上时,SQLite 会不会丢索引 ==
TEXT 列 = 整数字面量 0.0024 ms
SEARCH ev USING COVERING INDEX ix_user (user_id=?)
INTEGER 列 = 字符串 0.0022 ms
SEARCH ev USING COVERING INDEX ix_uidint (uid_int=?)
== LIKE 前缀能不能用索引,取决于一个 PRAGMA ==
LIKE 'u00123%'(默认) 10.3474 ms
SCAN ev USING COVERING INDEX ix_user
同一句,PRAGMA 打开后 0.0033 ms
SEARCH ev USING COVERING INDEX ix_user (user_id>? AND user_id<?)
范围改写(永远有效) 0.0034 ms
SEARCH ev USING COVERING INDEX ix_user (user_id>? AND user_id<?)
三件事:
① OR 两侧都有索引时,现代数据库能各走各的索引再合并(计划里那句 MULTI-INDEX OR,实测 0.0066 ms)。
⭐ 所以「OR 一定用不上索引」是个过时的说法。
🗓️ 未实跑:MySQL 管这叫 index_merge,Postgres 叫 BitmapOr,⚠️ 三家的触发条件都不一样。
② 只要有一侧没索引,整条就塌成 SCAN(实测 15.7034 ms,比①慢约 2400 倍)。
⭐ 这是 OR 真正的危险之处:它不是「OR 慢」,是「OR 的速度由最差的那一侧决定」。
💀 于是加一个看起来无害的过滤条件,能让一条一直很快的查询突然变慢一千倍,而 SQL 只多了七个字。
⚠️ 手工改写成 UNION 在本机反而更慢(0.0179 ms,因为多了一步去重的 TEMP B-TREE)——
⭐ 别背「OR 要改 UNION」这条口诀,先看计划。
③ 两个和直觉相反的实测,都值得单独记住:
| 现象 | 实测 | ⭐ 该记住的 |
|---|---|---|
user_id(TEXT 列)= 123(整数) |
仍然 SEARCH,0.0024 ms |
SQLite 有「类型亲和」,会把字面量转成列的类型再比。⚠️ 它不丢索引 |
user_id LIKE 'u00123%'(前缀,没有前导通配) |
💀 默认是 SCAN,10.3474 ms |
因为 SQLite 的 LIKE 默认不区分大小写,而索引是区分的,对不上就不敢用 |
同一句,PRAGMA case_sensitive_like=ON 之后 |
SEARCH,0.0033 ms(约 3100 倍) |
⭐ 同一句 SQL、同一个索引,快慢由一个连接级开关决定 |
⚠️⚠️ ②③ 合起来给出本节最该带走的一条: 「这个写法能不能用上索引」不是 SQL 语言的性质,是你这个数据库这一版这一套设置下的性质。 ⭐ 所以判据永远是去看计划,不是背清单。上面那张清单是给你猜方向用的,不是给你下结论用的。
⭐ 关于类型,02 章那条结论仍然成立且更重要:
02 章实测过 '1' 和 1 在 SQLite 里 JOIN 连不上(结果少一半、不报错),
而 MySQL 会隐式转换连上、🗓️ 代价是索引失效。
⭐ 两边加起来的结论是:别指望数据库替你转类型 —— 有的库转了会错,有的库转了会慢。数据进库之前把类型统一掉。
🧯 六、一条查询慢了,按什么顺序查
⭐ 这一节是前面所有内容的使用说明。
第 0 步:先确认慢的真是这条 SQL。 ⚠️ 「接口慢」里有相当一部分根本不在数据库:连接池排队、一次取回十万行的序列化、 ⭐ 以及在循环里发了 200 条查询(N+1)—— 每条都是 0.1 ms,加起来 20 ms 全花在往返上, 而你去看单条计划,每条都完美。先看「跑了几条」,再看「每条多快」。
第 1 步:EXPLAIN,只找三样东西。
| 找什么 | 看到了意味着 |
|---|---|
有没有 SCAN 大表 |
全表扫。⚠️ SCAN … USING … INDEX 也算(第二节 ⑤) |
有没有 TEMP B-TREE |
在做额外的排序或去重 |
有没有 AUTOMATIC INDEX |
⭐ 数据库在明说「这儿缺一个索引」 |
第 2 步:看到 SCAN 大表,按顺序问三句。
- ⭐
WHERE里那个列被什么东西包住了吗?(第四节的六种)——这一条最常中,而且改起来最便宜。 - 这个列上到底有没有索引? 没有就去 AI 全栈 06,那是它的题目。
- ⭐ 是不是本来就该全扫? 「统计全表」「取走大半张表」时,
SCAN是正确答案 —— ⚠️ 逐行走索引再回表反而更慢。优化器算过这笔账,别硬掰它。
第 3 步:改完再看一次计划,并且量一次。 ⭐ 两样都要:只看计划可能计划变漂亮了但更慢,只看耗时可能只是缓存热了。
⚠️ 两条容易忘的:
- 优化器是靠统计信息猜的。 本章脚本里那句
ANALYZE就是在更新统计。 💀 表刚灌完数据、统计还停留在「这表是空的」时,它会做出荒唐的选择 —— 计划突然变差,先想想统计是不是过期了。 - 计划会变。 数据量变了、索引加了、统计更新了,同一条 SQL 的计划就可能换一个。 ⭐ 这是好事(第三节实测过:最优连接顺序会因为有没有索引而反转),⚠️ 也意味着今天量到的快,不等于下季度还快。
💀 最后一条,也是最容易的一次自我欺骗:「计划里有索引名」和「这条查询快」是两件事。 本章第二节 ⑤ 那条
SCAN ev USING COVERING INDEX ix_user, 拿去给任何一个只扫一眼的人看,都会得到「索引用上了呀」的结论。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 01-一条查询是怎么跑的.html | ⭐ 它欠的就是这一章:「逻辑顺序答为什么是这样,物理计划答为什么这么慢」。它讲的 SELECT * 让覆盖索引失效,在本章第二节 ③④ 有完整实测 |
| 02-JOIN的几种语义.html | 那一章讲连接的语义(含扇出把 150 变成 250),本章补代价:谁在外层、内层能不能走索引 |
| 05-子查询的三种形状.html | IN / EXISTS / JOIN 选哪个,最终也要落到计划上看 —— ⭐ 那一章给语义判据,本章给验证手段 |
| 04-NULL与三值逻辑.html | IS NOT NULL 实测是 SEARCH 却要 2.5847 ms —— ⚠️ 想清楚 NULL 的语义之后,还得看它到底筛掉了多少行 |
| 08-窗口帧.html | 上一章。那里的问题是「数对不对」,这一章的问题是「快不快」—— ⭐ 顺序别反:先把数弄对 |
| 10-拿SQL审问一份数据.html | 下一章把前九章的东西合起来用在一份真数据上 |
| AI 全栈 06 关系数据库 | ⭐ 索引怎么建全在那边(复合索引列序、覆盖索引该不该建、200 次查询 3.802 秒 → 0.0019 秒)。本章看到 AUTOMATIC INDEX 或「这列压根没索引」时,就是去那一章的时候 |
| AI 全栈 06b 列表接口与分页 | 那一章实测过 COUNT(*) 走覆盖索引 0.031 ms、命不中索引的 LIKE '%..%' 要 30.7 ms —— ⭐ 两条都是 SCAN,用本章第二节的动词判据正好能读懂它那张表 |
✅ 检查点
- 执行计划里必须认的两个动词是什么?各代表什么动作?
- ⭐ 看到
SCAN ev USING COVERING INDEX ix_user,该怎么判断?为什么它是本章的卖点? - 同一个
WHERE,SELECT user_id和SELECT user_id, amount的计划为什么不一样?这解释了 01 章的哪句话? USE TEMP B-TREE FOR ORDER BY什么时候出现、什么时候不出现?- ⭐ 2000 用户 / 20 万事件那组实测:没索引时谁在外层快?建完索引之后呢?这个反转说明了什么实践判据?
- 计划里出现
AUTOMATIC COVERING INDEX是什么意思?该怎么办? - sargable 的判据是哪一句话?
substr(ts,1,10)='2026-03-05'为什么用不上索引、该怎么改、实测快了多少? amount * 2 > 199和YEAR(created) = 2026分别该怎么改写?为什么右端要用<不用<=?- ⭐
OR的三种命运各是什么?实测差多少?为什么说「别背『OR要改UNION』」? user_id LIKE 'u00123%'没有前导通配,为什么实测还是SCAN?这件事说明判据应该是什么?- 一条查询慢了,第 0 步先确认什么?看到
SCAN大表之后按顺序问哪三句?
👀 答案
SCAN= 把这个东西从头到尾读一遍;SEARCH= 拿索引直接定位过去,只读要的那几行。判据是动词,不是索引名。- 动词是
SCAN,所以它把ix_user这个索引整个读了一遍(20 万行一行不落),只是省掉了回表 —— 这叫在索引上做全表扫,不叫用上了索引。它是卖点因为「索引名清清楚楚出现在计划里」会让人一眼判成「索引生效了」。⚠️ 反过来SEARCH也不等于快:IS NOT NULL是SEARCH却要 2.5847 ms(命中单个user_id只要 0.0028 ms),因为它定位到的范围里装着几乎所有行。动词只说「怎么找」,不说「找到多少」。 - 只要
user_id时索引里什么都有 →SEARCH … USING COVERING INDEX;多要一列amount时索引里没有、必须回表 → 退回SEARCH … USING INDEX。这就是 01 章那句「SELECT *让覆盖索引永远不可能生效」的完整版。⚠️ 但别倒过来把所有列塞进索引(AI 全栈 06 否掉了这条路)。 ORDER BY user_id时不出现(索引本来就按它有序);ORDER BY amount时出现(得先全捞出来再排)。ORDER BY+LIMIT的分页慢,先去计划里找这一行。- 没索引时
events在外层快(0.0440 s vs 0.2380 s,约 5 倍);建完events(user_id)索引后反过来,users在外层快(0.0019 s vs 0.0140 s,约 7 倍)。说明最优连接顺序取决于有没有索引,所以不要手工钉死连接顺序(CROSS JOIN/ hint 是最后一招,它把今天的数据分布和索引焊进了 SQL)。换回普通JOIN让优化器自己选,它给出的计划和手工最优那条一字不差。 - 数据库在说「这儿缺一个索引,我先临时建一个」—— 而临时索引每次查询都要重建。看到它就该去 AI 全栈 06 补一个真索引。
- 索引列必须自己一个人待在比较符的一边,不能被任何东西包住。
substr()把列包住了,而索引里存的是ts本身的值,数据库没法反推出ts落在哪个区间。✅ 改成半开区间ts>='2026-03-05' AND ts<'2026-03-06':16.2629 ms → 0.2750 ms,约 59 倍。(CAST也是函数,实测 10.5548 ms → 0.0028 ms。) amount*2 > 199→amount > 99.5;YEAR(created)=2026→created >= '2026-01-01' AND created < '2027-01-01'。改法永远是把变形从列这边挪到常量那边。右端用<是因为<= '2026-03-05 23:59:59'会漏掉那一秒里的毫秒。- ① 两侧都有索引 →
MULTI-INDEX OR,各走各的索引再合并,0.0066 ms;② 手工改UNION→ 0.0179 ms,反而更慢(多了去重的TEMP B-TREE);③ 一侧没索引 → 整条塌成SCAN,15.7034 ms,比①慢约 2400 倍。所以OR的真正危险不是「OR慢」,是「速度由最差的那一侧决定」—— 加一个看起来无害的条件就能让查询慢一千倍。口诀不能背,因为①②的对比表明改写有可能更慢,先看计划。 - 因为 SQLite 的
LIKE默认不区分大小写,而索引是区分的,对不上就不敢用。打开PRAGMA case_sensitive_like=ON之后同一句变成SEARCH,10.3474 ms → 0.0033 ms,约 3100 倍。说明「能不能用上索引」不是 SQL 语言的性质,是这个库这一版这套设置下的性质 —— 判据永远是去看计划,清单只用来猜方向。(✅ 范围改写>= AND <不受这个开关影响,永远有效。) - 第 0 步:先确认慢的真是这条 SQL —— 连接池排队、一次取十万行的序列化、尤其是循环里发了 200 条查询(N+1,每条 0.1 ms 单看都完美)。看到
SCAN大表后按顺序问:①WHERE里的列被包住了吗(最常中、最便宜)② 这列到底有没有索引(有就去 AI 全栈 06)③ 是不是本来就该全扫(统计全表、取走大半张表时SCAN就是正确答案,别硬掰优化器)。改完要同时看计划和量耗时。
🛑 可以停在这里
⚡ 走神救援
⭐ 执行计划里只有两个动词要认:
SCAN是从头读一遍,SEARCH是直接定位过去。 💀 索引名出现在计划里,并不代表索引在起作用——判据是动词不是索引名。 本章卖点就是那条「SCAN … USING COVERING INDEX」:索引名清清楚楚,动词却是SCAN,它把索引整个读了一遍,这叫在索引上全表扫。⚠️ 反过来
SEARCH也不等于快:定位到的范围里如果装着几乎所有行,照样慢——动词只说「怎么找」,不说「找到多少」。另外三个记号:
USING COVERING INDEX(要的列全在索引里,连表都不碰——多要一列就退回回表,⭐ 这正是「SELECT *让覆盖索引永远不可能生效」的完整版)、USE TEMP B-TREE FOR ORDER BY(分页慢先找它)、AUTOMATIC INDEX(⭐ 数据库在说「这儿缺一个索引」)。🔗 连接的代价就一句话:嵌套循环,代价 ≈ 外层行数 × 内层每次查找的代价。 ⭐ 同一对表、同一个问题,最优连接顺序会因为有没有索引而反转——所以别手工钉死顺序,那等于把今天的数据分布焊进 SQL。
🧨 全章最实用的是 sargability,判据只有一句:索引列必须自己一个人待在比较符的一边,不能被任何东西包住。 因为索引里存的是列本身的值。函数包住、类型转换、算术、拼接——全都让索引失效,实测差几十到几千倍。⭐ 改法永远是把变形从列那边挪到常量这边,右端用
<不用<=(半开区间,否则会漏掉边界那一秒的毫秒)。⚠️ 前缀通配的LIKE '%…'没有等价改写。🔧 ⚠️
OR的速度由最差的那一侧决定:两侧都有索引时很快,一侧没索引整条就塌成全扫——加一个看似无害的条件就能慢上千倍。
下一节 👉 10-拿SQL审问一份数据.md