Menu

SQLite 部分索引:CREATE INDEX ... WHERE 用法

SQLite 中部分索引的工作原理:只为你真正查询的行建索引,以及让它物有所值的几种模式(软删除、部分唯一、热点子集)。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

部分索引只覆盖一部分行

普通索引为表中的每一行都保存一个条目。部分索引只为符合你在创建时给出的 WHERE 子句的行保存条目。索引更小,要遍历的页更少,那些不涉及被索引部分的插入和更新也都省了工作。

语法就是普通的 CREATE INDEX 后面加一个 WHERE:

idx_orders_pending 只包含 status = 'pending' 的行的条目。已发货、已取消和已退款的订单根本不在里面。如果你的 orders 表 95% 都是历史数据,而你主要查询未完成的订单,那么索引就能小 20 倍,查询速度却一样。

查询规划器什么时候真正会用它

只有当 SQLite 能够证明你的查询限定在索引覆盖的那些行之内时,部分索引才能被使用。最清楚的做法是在查询里重复索引的 WHERE 子句:

查询计划里应该会出现 USING INDEX idx_orders_pending。把查询中的 status = 'pending' 去掉,规划器就会退回到全表扫描,因为它没办法知道这个查询是否仍在被索引的子集之内。

经验法则是:查询的 WHERE 必须能推出索引的 WHERE。对同一列、同一个值做等值比较是最安全、最明显的情况。不等式和 OR 就复杂多了,请用 EXPLAIN QUERY PLAN 检查。

为什么值得用:三大好处

部分索引物有所值的三个具体理由:

  1. 占用磁盘更少。 只存储匹配的行。对于“表中只有 1% 是热点数据”的场景,索引大约只有完整索引的 1%。
  2. 写入更便宜。 只有当行符合过滤条件时,插入和更新才会碰到索引。在上面的表里插入一条 status = 'shipped' 的记录,完全不会碰到 idx_orders_pending。
  3. 查找速度不变。 B 树查找的耗时与索引大小呈对数关系。索引更小,查找会稍快一点,但更大的收益在于周边的一切:更少的缓存未命中,更少的 I/O。

如果某一列的数据分布高度倾斜,大多数行都是同一个值,而你只关心少数其他值,那就是部分索引的教科书式用例。

部分唯一索引(杀手级功能)

普通的 UNIQUE 约束适用于每一行。一旦引入软删除,这就成了问题:

-- Fails: there are two rows with email = 'a@x.com', even though one is deleted.
CREATE UNIQUE INDEX idx_users_email ON users(email);

部分唯一索引让你只对真正重要的那些行强制唯一:

三行数据,邮箱相同,却没有违反约束,因为只有 deleted_at IS NULL 的那一行参与唯一性检查。试着再插入一条邮箱相同的有效记录,SQLite 就会报 UNIQUE constraint failed。

这种模式随处可见:每位客户只有一个有效订阅,每个用户只有一个主地址,每个订单只有一张未结发票。部分唯一索引可以直接表达这些规则。

围绕 NULL 建索引

NULL 和索引之间的相互作用有些古怪。一个常见的需求是“完全忽略 NULL”:比如你有一个稀疏的 external_id 列,大多数行都是 NULL,但有值的那些必须唯一:

两个 NULL 可以和平共处;EXT-001 和 EXT-002 这两行则保证唯一。索引也更小,因为 NULL 行根本不会被存储,所以即使表变大,按 external_id 查找依然很快。

过滤条件能引用什么

部分索引的 WHERE 子句限制很多。它可以引用:

  • 被索引的表中的列。
  • 字面量常量。
  • 少数确定性的内置函数。

它不能引用:

  • 其他表。
  • 子查询。
  • 非确定性函数,比如 random() 或 CURRENT_TIMESTAMP。
  • 参数或变量。

这是有道理的:SQLite 必须在每次插入和更新行时计算这个过滤条件,而且结果必须稳定。所以下面这样是可以的:

但 WHERE created_at > date('now') 就不行:date('now') 会随时间变化,被索引的行集合就会在 SQLite 不知情的情况下发生变化。

一套自检流程

添加部分索引时,按这三项逐一检查:

查询 1 应该使用 idx_jobs_runnable。查询 2 和 3 应该退回到扫描(如果你有别的索引,也可能用那个)。如果规划器在你没预料到的查询上选了这个部分索引,就再读一遍过滤条件,它可能比你以为的范围更宽。

什么时候不该用

部分索引是把锋利的工具。以下情况可以不用:

  • 过滤条件匹配了表中的大部分行。 如果“活跃”的行占了 90%,部分索引就只是多绕了几步的普通索引。直接给那一列建索引就好。
  • 你的查询没有原样包含过滤条件。 如果你的代码使用 ORM,生成的是 WHERE status IN (?, ?, ?),或者动态计算过滤条件,规划器往往认不出它们是匹配的。用 EXPLAIN QUERY PLAN 测试,不要想当然。
  • 热点子集会随时间变化。 给“最近 30 天的订单”建部分索引听起来很诱人,但没法表达出来,因为过滤条件必须是确定性的。你得重建索引,或者换一种表结构(单独建一张 recent_orders 表,或者加一个每晚翻转的 archived 布尔列)。

当过滤条件稳定、而且只匹配一张大表中的一小部分时,部分索引是你在 SQLite 中能做的回报最高的调优手段之一。

下一篇:读懂查询计划

这一页大量依赖 EXPLAIN QUERY PLAN 来确认索引确实被用上了。这个工具值得单独用一页来讲:如何读懂它的输出、各个关键字是什么意思,以及如何分辨顺利的索引查找和偷偷摸摸的全表扫描。这就是下一页的内容。

常见问题

SQLite 中的部分索引是什么?

部分索引只为符合创建时给定的 WHERE 子句的行建立索引。你写 CREATE INDEX name ON table(col) WHERE condition,SQLite 就只为条件为真的行存储索引条目。索引更小、写入更快,而对于符合过滤条件的查询,查找速度不变。

什么时候该用部分索引,而不是完整索引?

当你反复查询一张大表中的一小部分时,比如待处理的订单、活跃用户、尚未处理的任务。只为这一部分建索引,能让索引保持很小,对其他行的写入也可以完全绕过它。如果你的查询不包含和索引相同的 WHERE 条件,查询规划器就用不上它。

部分索引能保证唯一性吗?

能。CREATE UNIQUE INDEX ... WHERE ... 只对符合过滤条件的行强制唯一。经典用法是“每个用户只有一条有效记录”:软删除的行被排除在外,所以同一个键可以有多条已删除的记录,但只能有一条有效记录。

Coddy 编程语言插图

和 Coddy 一起学编程

开始