索引用存储和写入成本,换取减少读取工作的机会。表很小,或者谓词选中大部分数据时,全表扫描可能更快,但行数与选择性都不能单独决定胜负。查询需要哪些列、排序要求、缓存状态、行宽和可用访问路径,都会改变比较结果。首先明确实际查询必须返回什么,再判断哪种访问方法做了更多有用工作。

看清两条路径实际做什么

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 甚至可能表示覆盖索引扫描,而不一定是表扫描。要阅读完整计划,并比较等价查询的结果,不能把某一个关键词变成正确性判据,更不能因为计划没有按预想翻转,就宣布数据库违反了契约。

全表扫描直接读取行;非覆盖 tenant 索引先定位并遍历范围,再回表获取 payload。独立 SQLite 实验要求自动、扫描和索引路径返回相等结果,不预定胜者;历史时间没有重测。
tenant 索引缺少 payload,因此范围路径需要回表。实验三条路径必须返回相等结果,不预定胜者。历史耗时未复测。

快速验证

保存为 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 流程,并为较昂贵的分析选择合适时机,避免把一次性实验里的维护命令不加判断地放进每个请求。

实验预热数据,只测一个进程,没有竞争写者,因此不能建立冷存储延迟、生产并发或长期索引成本结论。性能测试应保留代表性分布和实际投影,并随表增长重复检查。今天可以接受的扫描,可能在没有任何正确性错误的情况下变贵;要追踪的是业务所需结果的成本,而不只是某个计划名字是否保持不变。

来源