索引是一场下注:你先付出写入与存储的代价,换来读取时能跳过表的大部分。可当一条查询真的需要表的大部分时,这场下注就输了——引擎会为每一行都下一次 B 树,而不是把那些页按顺序读一遍。
优化器给两条路线都算了账,所以真正有意思的问题不是"原则上谁更快",而是交叉点落在哪里。表小并不等于工作量小,你在 1000 行时看到的计划,也不是 1000 万行时该期待的计划。
真正的货币是页,不是行
顺序扫描按物理顺序读表的页,一页能拿到很多行。PostgreSQL 用 seq_page_cost(默认 1.0)对 random_page_cost(默认 4.0)来给这件事定价:非顺序取到的页按顺序页的四倍计价,而把 random_page_cost 相对 seq_page_cost 调低,正是让优化器开始偏好索引扫描的那个开关。文档也指出,如果整个数据库都能装进缓存,把两者设成相等是合理的。
机械盘上一次随机寻道以毫秒计,而顺序读完一整条磁道几乎免费;NVMe 把这个差距压到微秒级,但页数本身并没有变,所以代价模型里「页数」这一项始终在。既然货币是页,行数就是一个很差的规模代理。同样是 1000 行的两张表:4 字节负载的那张是 12 页,4000 字节负载的那张是 1010 页。在 sqlite 3.53 上配 2 MB 页缓存,命中约 90% 行的宽谓词查询在窄表上 0.02 ms 跑完,在宽表上要 1.30 ms——行数完全一样,差了 65 倍。只有其中一张算"小"。
索引从哪里开始不划算
把两条访问路径分别钉死,交叉点就露出来了。表是 events(id, tenant, payload),1000 个不同 tenant,tenant 上有索引,谓词命中约 90% 的行:
- 1000 行、21 页:扫描 0.04 ms,索引 0.05 ms,打平。这个规模上索引什么也没买到,而每次写入都还要维护它。
- 50000 行、767 页:扫描 1.79 ms,索引 6.61 ms,扫描赢 3.7 倍。
- 1000000 行、15474 页(63 MB):扫描 44.37 ms,索引 531.18 ms,扫描赢 12 倍;采七次样本,比值最低也有 11.3 倍。
还有一层细节:如果查询只需要索引里已经有的列,引擎就完全不必碰表,这时候索引在宽谓词上也依然便宜。上面的测试刻意用 sum(length(payload)) 而不是 count(*),就是为了把数据行拉进来,让两条路径真正可比。覆盖索引会把整个交叉点挪走,代价是索引本身变大、每次写入更贵。
同一个索引在同样这张百万行表上,遇到选择性谓词却是 29 倍的优势:tenant = 7 只覆盖约 0.1% 的行,走索引 0.61 ms,扫描 17.88 ms。索引本身不坏,它只是对这个谓词坏。决定权在选择性手上,而且表越大,它决定得越狠。
让优化器做主,但把它的输入喂诚实
三种规模上,sqlite 都为宽谓词选了扫描,为选择性谓词选了索引。这是正确的默认值;当计划看起来不对时,该动的杠杆是统计信息,不是 hint。把强制索引当成诊断证据而不是长期策略:先钉死两条路径各量一次,再回头去修数据或统计。
统计信息会过期,计划跟着一起过期。把百万行的表 ANALYZE 一遍,然后把所有行都改成 tenant = 5 而不再分析:sqlite_stat1 仍然写着每个键 1000 行,于是优化器继续为一个命中全表 100% 的谓词选 SEARCH ... USING INDEX。再跑一次 ANALYZE,统计变成每个键 1000000 行,计划当场翻成 SCAN。这里错误计划只贵了 1.2 倍,原因是 sqlite 的表 b 树按 rowid 排序,索引游走取页时仍接近物理顺序;在 PostgreSQL 里,每一次索引命中都是一次按 random_page_cost 计价的独立堆取页,同样的错误要贵得多。
坏掉的样子
最经典的是统计信息陈旧导致的计划翻转。夜间任务灌进来一个逐渐占满全表的 tenant,直方图还描述着上周;原本走索引查找的看板查询,现在变成透过索引读掉五分之一张表。没有任何报错。延迟就那么抬了一级,而且恰好抬在没人盯着的那些查询上。
第二种失败与形状无关,只与规模有关。21 页上花 0.03 ms 的那次扫描,在 767 页上是 1.88 ms,在 15474 页上是 41.34 ms,因为代价对页数是线性的。今天还行的表,长一年之后就变成付不起的全表扫描。解法不是 hint,而是让优化器真正会选的那个索引,或者一条返回更少行的查询。
快速验证
先确认这张表在页数上确实小,再看优化器是否和现实一致:
sqlite3 events.db "PRAGMA page_count; PRAGMA page_size;"
sqlite3 events.db "EXPLAIN QUERY PLAN SELECT sum(length(payload)) FROM events WHERE tenant < 900;"
sqlite3 events.db "EXPLAIN QUERY PLAN SELECT sum(length(payload)) FROM events NOT INDEXED WHERE tenant < 900;"
sqlite3 events.db "ANALYZE; SELECT stat FROM sqlite_stat1 WHERE idx='events_tenant';"
printf '.timer on\nSELECT sum(length(payload)) FROM events NOT INDEXED WHERE tenant < 900;\n' | sqlite3 events.db
第一条给出页数,那才是关键数字。中间两条对宽谓词应该报出同一条路径:如果优化器说 SEARCH ... USING INDEX,而被钉死的 SCAN 明显更快,嫌疑就在统计信息上。sqlite_stat1 给出每个键的平均行数——当它仍旧写着 1000、而实际已有一个键占满全表时,重跑 ANALYZE 再看一次计划。
来源
- PostgreSQL:优化器代价常量,
seq_page_cost与random_page_cost,已核验 2026-09-16。 - PostgreSQL:使用 EXPLAIN,如何读计划与行数估计,已核验 2026-09-16。
- SQLite:EXPLAIN QUERY PLAN,扫描与查找步骤如何呈现,已核验 2026-09-16。