索引用存储和写入成本,换取减少读取工作的机会。表很小,或者谓词选中大部分数据时,全表扫描可能更快,但行数与选择性都不能单独决定胜负。查询需要哪些列、排序要求、缓存状态、行宽和可用访问路径,都会改变比较结果。首先明确实际查询必须返回什么,再判断哪种访问方法做了更多有用工作。
看清两条路径实际做什么
B 树范围扫描并不是每匹配一个索引条目,都重新从根节点下降。定位范围起点后,可以沿相邻条目继续前进。非覆盖索引还可能需要回表取得缺少的列;宽谓词下,这部分工作可能成为主要成本。覆盖索引能够省去这些读取,因此,即使查询命中多数行,索引也仍有可能更划算,不能把宽谓词与索引无用直接画等号。
页很重要,但不是唯一成本。PostgreSQL 默认的 seq_page_cost 与 random_page_cost 是优化器使用的相对估计,不是毫秒,也不是每返回一行就机械收取一次的费用。缓存、数据相关性、CPU 工作、位图扫描与仅索引扫描,都影响计划选择。SQLite 普通表把行放在按 rowid 组织的 B 树中,与 PostgreSQL 堆表存储不同,不能直接把一种引擎测得的性能比例套到另一种引擎。
SQLite 的 PRAGMA page_count 返回整个数据库文件的页数,包括索引和其他对象,不能当作某个指定表的独立页数。逻辑页遍历也不保证磁盘上的连续物理读取。比较时应保持统计对象范围一致,记录页大小、模式、负载宽度和缓存条件,不能只报告行数,再把其他成本差异全部归因于扫描或索引。
保留历史测量的适用范围
此前笔记记录了 SQLite 3.53、二兆字节页缓存下的结果。宽谓词的扫描与索引时间,在一千行时为 0.04 与 0.05 毫秒,在五万行时为 1.79 与 6.61 毫秒,在百万行时为 44.37 与 531.18 毫秒。同一百万行表的高选择性查询,索引为 0.61 毫秒,扫描为 17.88 毫秒。相同行数的窄、宽负载样本还分别记录了十二与一千零十页,以及 0.02 与 1.30 毫秒。
这些是历史观察,不是通用阈值,也不是本轮重新取得的测量。此次审阅没有此前的数据生成器及完整基准环境,因此下面提供独立、可复现的比较,并输出实际运行时版本。它不声称重现历史时间、页数或自动优化器选择。记录旧观察与建立新实验可以同时进行,但不能把新实验的成功当作旧数字已经复核。
坏掉的样子
某个租户持续增长,可能让旧选择性估计失效,而所有查询仍然返回正确结果。统计信息只是一个调查方向,不是原因已经确定:参数变化、覆盖关系、缓存状态或竞争负载,也可能让同一计划变慢。强制长期使用某条路径之前,应比较估计工作量与实际工作量,确认问题出在哪里,再决定是否需要改索引、查询或维护策略。
新统计也不保证所有宽范围谓词都选择扫描。SQLite 构建时可能启用不同的可选统计支持,计划文本也可能随版本改变。SCAN 甚至可能表示覆盖索引扫描,而不一定是表扫描。要阅读完整计划,并比较等价查询的结果,不能把某一个关键词变成正确性判据,更不能因为计划没有按预想翻转,就宣布数据库违反了契约。
快速验证
保存为 scans.py 后执行 python3 scans.py。脚本使用 Python 自带的 SQLite 绑定,在自己创建的临时目录建立数据库,不会打开现有应用数据。三种路径对宽谓词都必须返回二百八十八万,对高选择性谓词都必须返回三千二百。七轮交替顺序的热缓存样本报告中位数,但不会断言哪条路径一定更快,避免把环境相关时序当成功能要求。
from pathlib import Path
from tempfile import TemporaryDirectory
from statistics import median
from time import perf_counter
import sqlite3
with TemporaryDirectory(prefix="scan-crossover-") as directory:
db = sqlite3.connect(Path(directory) / "fixture.db")
try:
db.executescript("""CREATE TABLE events(id INTEGER PRIMARY KEY, tenant INTEGER, payload TEXT);
CREATE INDEX events_tenant ON events(tenant);""")
db.executemany("INSERT INTO events VALUES(?,?,?)",
((i, i % 1000, "x" * 64) for i in range(50000)))
db.commit()
db.execute("ANALYZE")
print("SQLite", sqlite3.sqlite_version)
print("database pages", db.execute("PRAGMA page_count").fetchone()[0],
"page size", db.execute("PRAGMA page_size").fetchone()[0])
for predicate, expected in [("tenant < 900", 2880000), ("tenant = 7", 3200)]:
base = "SELECT sum(length(payload)) FROM events {} WHERE " + predicate
paths = {"chosen": "", "scan": "NOT INDEXED", "index": "INDEXED BY events_tenant"}
queries = {name: base.format(clause) for name, clause in paths.items()}
samples = {name: [] for name in queries}
for name, query in queries.items():
assert db.execute(query).fetchone()[0] == expected
print(predicate, name, db.execute("EXPLAIN QUERY PLAN " + query).fetchall())
for repeat in range(7):
names = list(queries) if repeat % 2 == 0 else list(reversed(queries))
for name in names:
start = perf_counter()
result = db.execute(queries[name]).fetchone()[0]
samples[name].append((perf_counter() - start) * 1000)
assert result == expected
print("warm median ms", {k: round(median(v), 3) for k, v in samples.items()})
old = db.execute("SELECT stat FROM sqlite_stat1 WHERE idx='events_tenant'").fetchone()[0]
db.execute("UPDATE events SET tenant=5")
db.commit()
stale = db.execute("SELECT stat FROM sqlite_stat1 WHERE idx='events_tenant'").fetchone()[0]
assert stale == old
db.execute("ANALYZE")
fresh = db.execute("SELECT stat FROM sqlite_stat1 WHERE idx='events_tenant'").fetchone()[0]
assert fresh.split()[:2] == ["50000", "50000"] and fresh != old
print("statistics before/stale/fresh", old, stale, fresh)
try:
db.execute("SELECT * FROM events INDEXED BY absent_index")
except sqlite3.OperationalError as error:
assert "no such index" in str(error), error
else:
raise AssertionError("missing required index was silently ignored")
print("missing INDEXED BY target rejected")
finally:
db.close()
负载表达式需要 tenant 索引中没有的数据,因此这里比较的是非覆盖路径。INDEXED BY 要求使用指定索引,并非温和建议;缺少索引的负向对照会明确失败。此处 NOT INDEXED 禁用次级索引,但在别的查询中并不禁止 rowid 查找。这些控制用于诊断,不应未经理由就复制到生产查询中,以免将某次测量得到的选择永久固定。
数据被改成同一个租户以后,sqlite_stat1 在 ANALYZE 之前保持旧值,更新后记录五万行共享同一个键。这个断言验证统计维护,而不是保证计划一定翻转。生产中应遵循当前 SQLite 版本建议的 PRAGMA optimize 流程,并为较昂贵的分析选择合适时机,避免把一次性实验里的维护命令不加判断地放进每个请求。
实验预热数据,只测一个进程,没有竞争写者,因此不能建立冷存储延迟、生产并发或长期索引成本结论。性能测试应保留代表性分布和实际投影,并随表增长重复检查。今天可以接受的扫描,可能在没有任何正确性错误的情况下变贵;要追踪的是业务所需结果的成本,而不只是某个计划名字是否保持不变。
来源
- SQLite 查询规划,范围遍历、回表和覆盖索引,已核验 2026-10-15。
- SQLite EXPLAIN QUERY PLAN,SCAN/SEARCH 含义和不稳定的展示格式,已核验 2026-10-15。
- SQLite PRAGMA page_count,数据库整体页数,已核验 2026-10-15。
- SQLite ANALYZE,统计信息与建议的 optimize 流程,已核验 2026-10-15。
- SQLite INDEXED BY,索引要求与 NOT INDEXED 范围,已核验 2026-10-15。
- PostgreSQL 优化器代价常量,相对成本与缓存假设,已核验 2026-10-15。
- PostgreSQL 使用 EXPLAIN,行数估计、位图扫描与仅索引扫描,已核验 2026-10-15。