数据库索引入门 从联合索引到 SQLite 查询计划
知道“给字段加索引能加速”还不够。为什么有索引仍然扫描?为什么两个单列索引不等于一个联合索引?为什么查询只多取一列,访问路径就可能变了?
本文用同一张订单表逐步回答这些问题。先手工排列索引条目,再看 SQLite 的查询计划,最后运行一个不需要安装数据库服务的 Python 实验。核心不是尽量让计划出现 INDEX,而是减少不必要的候选访问、取行和排序,同时保持结果正确。
建议先了解表、行以及简单的 SELECT。全文讨论普通 SQLite rowid 表;不会把某个数据库引擎的存储细节直接推广到所有系统。
先明确要优化的查询
假设要查某个用户从指定日期开始的前五笔订单,按日期升序排列;同一天按 id 升序,确保顺序唯一。
1 | SELECT id, created_at, amount_cents |
这里的 created_at 是从统一起点计算的整数日编号,便于手算,不是把现实中的时区问题消除了。金额以“分”为单位存储成整数,避免本实验引入浮点金额比较。问号表示绑定值,不是让读者把问号拼成字符串。
| 字段 | 本例的含义 | 为什么需要 |
|---|---|---|
| id | 唯一订单编号,INTEGER PRIMARY KEY | 识别行,并打破同日排序平局 |
| user_id | 用户编号 | 等值筛选 |
| created_at | 非空整数日编号 | 范围筛选与排序 |
| amount_cents | 非空整数金额,单位为分 | 返回结果 |
| note | 非空备注文本 | 模拟查询并不需要的额外列 |
查询同时有三种工作:定位指定用户、排除较早日期、按稳定顺序返回前几条。索引设计要一起考虑这些工作,而不只是数 WHERE 后面出现了几个字段。
flowchart TD A["输入用户、起始日和条数"] --> B["找到候选订单"] B --> C["检查筛选条件"] C --> D["满足日期与编号的顺序"] D --> E["取出所需列并返回前几条"]
这张图表示逻辑职责,不是承诺引擎一定先过滤完全部行再排序。合适的访问路径可以把几步合在一起,甚至在得到足够结果后提前结束。
索引是额外组织而不是免费标签
无索引时,朴素做法是逐行检查。索引则把选定的键按特定顺序组织起来,使“某一段键值在哪里”变得容易回答。代价是多占空间,插入和修改时也要维护相应结构。
SQLite 的数据库文件使用 B 树页组织表和索引;索引页与表页的载荷安排并不完全相同。因此,本文用“多路有序树”建立直觉,不把所有引擎的实现都画成同一种 B+ 树。SQLite 文件格式中的 B 树页
| 模型 | 一次导航保留的候选 | 适合建立什么直觉 |
|---|---|---|
| 线性扫描 | 一条条排除 | 工作量随被检查的数据增长 |
| 二分查找 | 根据中点排除约一半 | 有序性使定位不必从头开始 |
| 多路树导航 | 根据多个分隔键选子页 | 一个页里放多个键,减少树的层数 |
做一个仅用于手算的理想模型:若每个内部页有 100 个子页、每个叶页有 100 条记录,则一个根页连接 100 个内部页、每个内部页连接 100 个叶页时,可以容纳 100 × 100 × 100 = 100 万条记录。到一个叶页只经过三层;但这不等于真实查询恰好发生三次物理读盘,页缓存、页填充、溢出数据与返回行数都会改变代价。
本例的 id 声明是 INTEGER PRIMARY KEY,在这种普通 rowid 表中它是 rowid 的别名。二级索引可以携带行定位信息,用来找到表里的完整记录。这个结论有明确范围:不要把 WITHOUT ROWID 表或其他引擎的主键组织方式也套进来。SQLite rowid 表说明
手工排列一次联合索引
考虑下面七条自造数据,查询参数是用户 7、起始日 180、最多 3 条:
| id | user_id | created_at | amount_cents |
|---|---|---|---|
| 1 | 7 | 179 | 900 |
| 2 | 8 | 180 | 1500 |
| 3 | 7 | 180 | 1200 |
| 4 | 7 | 181 | 800 |
| 5 | 7 | 180 | 2000 |
| 6 | 8 | 182 | 700 |
| 7 | 7 | 183 | 1600 |
给三列建立有序联合索引:
1 | CREATE INDEX idx_user_time ON orders(user_id, created_at, id); |
按这个键的字典序排列,先比较用户,相同再比较日期,再相同才比较编号。下表是便于理解的键序列,不是 SQLite 页字节的转储。
| 顺序 | 索引键 user_id created_at id | 本次是否满足条件 |
|---|---|---|
| 1 | 7,179,1 | 日期太早 |
| 2 | 7,180,3 | 第 1 条 |
| 3 | 7,180,5 | 第 2 条 |
| 4 | 7,181,4 | 第 3 条,达到 LIMIT |
| 5 | 7,183,7 | 满足条件,但本次不必返回 |
| 6 | 8,180,2 | 其他用户 |
| 7 | 8,182,6 | 其他用户 |
因此结果为 (3,180,1200)、(5,180,2000)、(4,181,800)。日期相同的 id=3 与 id=5 不会因为访问路径变化而交换,因为 ORDER BY 明确写出了 id。
这种“相同左侧键聚在一起”的性质,解释了为什么这个索引能同时适配用户等值、日期范围和结果顺序。SQLite 联合索引与查询排序
flowchart TD A["联合键先按 user_id 排列"] --> B["定位 user_id 等于 7 的连续段"] B --> C["进入 created_at 不小于 180 的位置"] C --> D["沿 created_at、id 的顺序读取"] D --> E["得到 3 条结果后停止"]
最左前缀不是背一句绝对规则
对索引 (user_id, created_at, id),最直接的访问模式是先约束左侧用户,再在该用户内部限定日期范围。WHERE 条件的书写顺序不需要和索引字段顺序相同;决定结构的是 CREATE INDEX 中的顺序。
| 条件或排序 | 该索引能直接提供的结构性帮助 |
|---|---|
| user_id = 7 | 一个用户的连续区域 |
| user_id = 7 且 created_at >= 180 | 用户区域中的日期后缀 |
| user_id = 7 且按 created_at、id 排序 | 等值用户内的键序 |
| 只有 created_at >= 180 | 日期范围分散在各用户区域,不是单一全局日期段 |
| user_id = 7 且 created_at >= 180 且 id = 9 | 日期范围之后的 id 条件通常不能继续缩成同一种连续边界,仍需检查 |
最后两行不等于“整个索引完全没用”。引擎还可能扫描较窄的索引、利用覆盖信息,或在满足条件时采用 skip-scan 等策略。SQLite 的优化器文档明确给出了常规前缀约束与例外;应读实际计划,而不是把“最左前缀”背成永不变化的判决。SQLite WHERE 分析与 skip-scan
本篇还比较相反顺序的 (created_at, user_id, id, amount_cents)。它把同一天的不同用户放在一起:日期范围容易描述,但用户 7 的条目会夹在其他用户之间。对这条“某个用户的最近几笔”查询,可能要跳过更多候选;对另一个“所有用户某日订单”的查询,权衡又可能不同。
两个单列索引分别保存两种顺序,并不会自动变成一份 (user_id, created_at) 的复合排序。能否组合多个访问结果取决于具体优化器与查询形式,不应把两个概念等同。
覆盖索引省去的是哪一步
上面的联合索引可以确定哪些订单该返回,但本次还需要 amount_cents。若索引里没有这个金额,就需要沿行定位信息再访问表记录,通常称为“回表”。
把查询需要的金额放到索引末尾:
1 | CREATE INDEX idx_cover |
| 本例索引 | 能否按用户与日期定位 | 能否满足本例排序 | 是否包含所有返回列 |
|---|---|---|---|
| user_id | 能先找用户 | 仍需处理日期顺序 | 否 |
| user_id、created_at、id | 能 | 能 | 缺 amount_cents |
| user_id、created_at、id、amount_cents | 能 | 能 | 是 |
覆盖是索引相对于某条查询的属性,不是固定的索引种类。如果改成 SELECT * 或额外返回 note,同一索引就不再覆盖。这里把金额放在 id 之后,是为了不把同日订单的 id 顺序变成“先按金额再按 id”;不应随意改变字段顺序。SQLite 覆盖索引说明
flowchart TD
A["按索引找到符合条件的条目"] --> B{"所需列是否都在索引里"}
B -->|"否"| C["使用行定位信息读取表记录"]
B -->|"是"| D["直接从索引取得返回列"]
C --> E["形成查询结果"]
D --> E
覆盖并不代表把整张表都复制到索引里最划算。索引越宽,占用越多,也可能降低每页能容纳的条目数。本文实验只比较读取工作,不给写入和存储成本下结论。
读懂查询计划但不要把它当计时器
SQLite 的 EXPLAIN QUERY PLAN 提供高层访问策略。重点看访问对象、可用于搜索的约束、是否覆盖以及是否需要额外排序,不必记住节点编号。
| 常见提示 | 可以读出什么 | 不能单凭它断言什么 |
|---|---|---|
| SCAN orders | 以扫描方式访问表 | 不等于任何数据量下都很慢 |
| SEARCH orders USING INDEX | 使用索引约束缩小访问范围 | 不等于实际返回很少或没有回表 |
| USING COVERING INDEX | 此访问可以从索引提供所需数据 | 不等于零 I/O 或零 CPU 成本 |
| USE TEMP B-TREE FOR ORDER BY | 为排序安排临时结构 | 不等于必定写入磁盘 |
SCAN 也可能伴随 USING INDEX,表示按索引顺序扫描,而不是先由搜索约束截出一个小范围。另一个重要限制是:官方把此输出定位为交互式调试信息,格式可能随 SQLite 版本变化;不要把完整文本当成生产程序的稳定接口。SQLite EXPLAIN QUERY PLAN
因此,先验证结果,再解释计划,再测量真实工作负载。计划不包含本次运行的磁盘等待时间,也不能取代线上延迟观测。
可运行的 Python 内存实验
下面是完整脚本,使用 Python 标准库 sqlite3,不访问真实数据库文件。五种索引配置分别创建独立内存数据库,数据相同,没有删表、删索引或清理文件的步骤。保存后直接用 Python 运行即可。
脚本用普通 Python 筛选与排序生成参考答案,再逐配置核对 SQL 结果。参数限定为整数,LIMIT 非负;查询值始终通过占位符绑定。set_progress_handler 的回调计数只用于观察 SQLite 虚拟机工作量,不是秒数或读盘次数;回调每条指令执行会带来额外开销,因此本脚本不据此做耗时排名。Python sqlite3 参数绑定与进度回调
1 | import random |
| 配置 | 变化的部分 | 观察问题 |
|---|---|---|
| none | 不建二级索引 | 多少工作用于排除其他用户 |
| user | 只建 user_id | 缩小用户范围后,排序还在不在 |
| composite | 用户、日期、编号 | 能否连贯地定位并按序返回 |
| covering | 在末尾加入金额 | 是否减少表记录访问 |
| time_first | 改为日期优先 | 目标用户分散时,是否检查更多候选 |
这里显式执行 ANALYZE,是为了让各份教学数据库都具有自己的统计信息,不是建议每个查询前都分析一次。实际应用应按引擎版本和生命周期管理统计;SQLite 当前文档推荐通过 PRAGMA optimize 适时触发必要分析,其中较新的自动限额行为有版本要求。SQLite 统计信息维护
实验结论怎样写才不越界
本机使用 Python 3.11.0、SQLite 3.38.4。脚本会自行打印 SQLite 版本和各阶段的实际计划;系统上 Python 的版本号不能替代它所链接的 SQLite 版本号。官方文档于 2026-10-03 核对,不代表本机运行库已经是最新版本。
在当前实验中应首先检查五份 rows 完全相同,再观察访问策略和 vm_callbacks 的变化。不要要求别的版本打印相同的计划节点编号,也不要把“回调次数降低”改写成“生产数据库快了同样倍数”。
下面是本机这次实际输出的摘要,数据量 20,000,随机种子 45892,参数为 (7,180,5)。五份结果的订单编号依次都是 17617、1404、10004、19234、2959。
| 配置 | 当前环境观察到的策略 | 虚拟机回调次数 |
|---|---|---|
| none | 扫描表,额外排序 | 60667 |
| user | 按用户搜索,额外排序 | 1024 |
| composite | 按用户和日期搜索 | 55 |
| covering | 覆盖搜索,无需取得表中金额 | 49 |
| time_first | 覆盖索引上的 skip-scan | 1870 |
最后一行很值得注意:本机计划出现 ANY(created_at) AND user_id=?,说明优化器采用了 skip-scan,并非只能从日期开头逐项走到底。这正好说明最左前缀有适用范围,不能用一句口诀替代实际观察。计划中日期约束显示为 created_at>?,也不要据此把原 SQL 的 >= 改成 >;完整结果已按原查询语义独立核验。
| 指标 | 本实验是否检查 | 为什么有这个边界 |
|---|---|---|
| 返回值与排序 | 是 | 可以与独立参考实现逐项比较 |
| 查询计划 | 打印并在当前环境检查 | 优化器和文本格式有版本差异 |
| 虚拟机进度回调 | 是 | 仅为同环境下的工作量线索 |
| 磁盘读写、缓存命中 | 否 | 数据库在内存中 |
| 并发、锁等待、网络延迟 | 否 | 本实验没有多个客户端 |
| 写入成本与长期空间增长 | 否 | 需要另外设计工作负载 |
不能只记住索引成功的例子
假设 created_at 上有常规索引,但查询把列放进算术表达式:
1 | SELECT id |
在本例“非空且取值很小的整数日编号”范围内,可以改写为 created_at >= 180。两者逻辑等价,但优化器不一定为任意表达式做这种代数转换。若业务本就查询某个表达式,也可评估匹配的表达式索引;SQLite 要求查询中的表达式与索引表达式对应,不能只因为数学上等价就假定匹配。SQLite 表达式索引
这个改写有适用条件:不要将它直接推广到可能溢出的整数、浮点数、NULL 或时区转换。优化之前必须先证明业务语义不变。
| 常见误区 | 更稳妥的做法 |
|---|---|
| 每列都建一个索引就够了 | 从查询的筛选、排序与返回列共同设计 |
| 范围条件右边的列彻底失效 | 区分搜索边界、后续过滤、排序和覆盖用途 |
| WHERE 写法顺序决定联合索引顺序 | 检查索引键顺序与约束类型 |
| 看见 SCAN 就强制改成索引 | 先看表大小、返回比例和估算代价 |
| 覆盖索引越宽越好 | 将读取收益与空间、写入成本一起评估 |
| LIMIT 一样,返回前几条就应一样 | 用唯一的完整排序键消除平局 |
| EXPLAIN 文本可以当通用断言 | 将结果不变量与版本相关观察分开 |
| 拼接用户输入形成 SQL | 使用参数绑定;标识符选择使用固定允许列表 |
把验证和排障接起来
本站验证程序提取上面的完整 Python 代码直接执行,再用另一份基于字典记录的筛选排序实现核对查询。测试包含空表、单行、重复日期、零条 LIMIT、找不到用户、边界日期,以及随机数据上的多种索引配置。比较的是完整有序结果,不只是行数。
验证脚本为 tests/verify-sqlite-index-article.cjs。本次通过 104 组小数据集和五种索引配置的测试,加上固定的 20,000 行实验,共完成 14,045 次结果比较,其中主查询逐项检查了 42,100 条有序结果;另有 2,080 次非法参数拒绝检查。表达式改写与增加非覆盖列也经过独立核对。查询计划文本不作为跨版本固定断言。
flowchart TD A["固定查询语义与代表性参数"] --> B["验证返回值和排序"] B --> C["观察访问、取行与排序策略"] C --> D["在相同数据上比较候选索引"] D --> E["再测试真实读写与并发负载"] E --> F["根据收益与成本决定是否保留"]
从网络请求与 TCP看,数据库查询只是一次请求中的一个环节;页面慢还可能来自连接、传输或应用计算。反过来,NumPy 数组与广播处理的是读入内存后的数据布局与运算,不能把它的数组下标与数据库索引混为一谈。
下一次遇到“加了索引为什么没变快”,先问四件事:条件是否缩小了候选、顺序是否能直接提供、是否需要回表、实际瓶颈是否真的在查询。返回计算机基础专题或资源索引继续阅读。