🏠 总目录📚 本教程 09 · 计划里的连接与索引失效 ← →
📑 本页目录(点开跳转)

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 落在哪个区间」。

⭐ 改法几乎永远是同一句:把变形从列这边挪到常量那边。

⚠️ 注意右端一律用 < 不用 <=(半开区间)—— 用 <= '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 大表,按顺序问三句。

  1. ⭐ WHERE 里那个列被什么东西包住了吗?(第四节的六种)——这一条最常中,而且改起来最便宜。
  2. 这个列上到底有没有索引? 没有就去 AI 全栈 06,那是它的题目。
  3. ⭐ 是不是本来就该全扫? 「统计全表」「取走大半张表」时,SCAN 是正确答案 —— ⚠️ 逐行走索引再回表反而更慢。优化器算过这笔账,别硬掰它。

第 3 步:改完再看一次计划,并且量一次。 ⭐ 两样都要:只看计划可能计划变漂亮了但更慢,只看耗时可能只是缓存热了。

⚠️ 两条容易忘的:

💀 最后一条,也是最容易的一次自我欺骗:「计划里有索引名」和「这条查询快」是两件事。 本章第二节 ⑤ 那条 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,用本章第二节的动词判据正好能读懂它那张表

✅ 检查点

  1. 执行计划里必须认的两个动词是什么?各代表什么动作?
  2. ⭐ 看到 SCAN ev USING COVERING INDEX ix_user,该怎么判断?为什么它是本章的卖点?
  3. 同一个 WHERE,SELECT user_id 和 SELECT user_id, amount 的计划为什么不一样?这解释了 01 章的哪句话?
  4. USE TEMP B-TREE FOR ORDER BY 什么时候出现、什么时候不出现?
  5. ⭐ 2000 用户 / 20 万事件那组实测:没索引时谁在外层快?建完索引之后呢?这个反转说明了什么实践判据?
  6. 计划里出现 AUTOMATIC COVERING INDEX 是什么意思?该怎么办?
  7. sargable 的判据是哪一句话?substr(ts,1,10)='2026-03-05' 为什么用不上索引、该怎么改、实测快了多少?
  8. amount * 2 > 199 和 YEAR(created) = 2026 分别该怎么改写?为什么右端要用 < 不用 <=?
  9. ⭐ OR 的三种命运各是什么?实测差多少?为什么说「别背『OR 要改 UNION』」?
  10. user_id LIKE 'u00123%' 没有前导通配,为什么实测还是 SCAN?这件事说明判据应该是什么?
  11. 一条查询慢了,第 0 步先确认什么?看到 SCAN 大表之后按顺序问哪三句?
👀 答案
  1. SCAN = 把这个东西从头到尾读一遍;SEARCH = 拿索引直接定位过去,只读要的那几行。判据是动词,不是索引名。
  2. 动词是 SCAN,所以它把 ix_user 这个索引整个读了一遍(20 万行一行不落),只是省掉了回表 —— 这叫在索引上做全表扫,不叫用上了索引。它是卖点因为「索引名清清楚楚出现在计划里」会让人一眼判成「索引生效了」。⚠️ 反过来 SEARCH 也不等于快:IS NOT NULL 是 SEARCH 却要 2.5847 ms(命中单个 user_id 只要 0.0028 ms),因为它定位到的范围里装着几乎所有行。动词只说「怎么找」,不说「找到多少」。
  3. 只要 user_id 时索引里什么都有 → SEARCH … USING COVERING INDEX;多要一列 amount 时索引里没有、必须回表 → 退回 SEARCH … USING INDEX。这就是 01 章那句「SELECT * 让覆盖索引永远不可能生效」的完整版。⚠️ 但别倒过来把所有列塞进索引(AI 全栈 06 否掉了这条路)。
  4. ORDER BY user_id 时不出现(索引本来就按它有序);ORDER BY amount 时出现(得先全捞出来再排)。ORDER BY + LIMIT 的分页慢,先去计划里找这一行。
  5. 没索引时 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 让优化器自己选,它给出的计划和手工最优那条一字不差。
  6. 数据库在说「这儿缺一个索引,我先临时建一个」—— 而临时索引每次查询都要重建。看到它就该去 AI 全栈 06 补一个真索引。
  7. 索引列必须自己一个人待在比较符的一边,不能被任何东西包住。 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。)
  8. amount*2 > 199 → amount > 99.5;YEAR(created)=2026 → created >= '2026-01-01' AND created < '2027-01-01'。改法永远是把变形从列这边挪到常量那边。右端用 < 是因为 <= '2026-03-05 23:59:59' 会漏掉那一秒里的毫秒。
  9. ① 两侧都有索引 → MULTI-INDEX OR,各走各的索引再合并,0.0066 ms;② 手工改 UNION → 0.0179 ms,反而更慢(多了去重的 TEMP B-TREE);③ 一侧没索引 → 整条塌成 SCAN,15.7034 ms,比①慢约 2400 倍。所以 OR 的真正危险不是「OR 慢」,是「速度由最差的那一侧决定」—— 加一个看起来无害的条件就能让查询慢一千倍。口诀不能背,因为①②的对比表明改写有可能更慢,先看计划。
  10. 因为 SQLite 的 LIKE 默认不区分大小写,而索引是区分的,对不上就不敢用。打开 PRAGMA case_sensitive_like=ON 之后同一句变成 SEARCH,10.3474 ms → 0.0033 ms,约 3100 倍。说明「能不能用上索引」不是 SQL 语言的性质,是这个库这一版这套设置下的性质 —— 判据永远是去看计划,清单只用来猜方向。(✅ 范围改写 >= AND < 不受这个开关影响,永远有效。)
  11. 第 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

打卡记录保存在你的浏览器里,首页能看到总进度