在 SQLite 中,覆盖索引不是一种需要单独创建的特殊索引,而是查询优化器对索引命中状态的一种执行策略描述。它表示查询需要的所有列都能从某个索引的 B-tree 中直接取得,无需再根据索引记录的 rowid 回到主表读取完整数据行。回表是随机 I/O 的主要来源之一,覆盖索引能让查询只做一次索引扫描就返回结果。

回表产生的原因与 rowid 结构直接相关。SQLite 普通表默认使用 rowid 组织数据,二级索引的叶子节点保存索引列值和 rowid。只有查询请求的列全部位于索引键中,优化器才会选择覆盖索引扫描,执行计划中会显示 USING COVERING INDEX。
回表怎么产生的:rowid 表与普通索引
SQLite 中,如果没有使用 WITHOUT ROWID 子句建表,每张表都会有一个 64 位有符号的 rowid。如果表里定义了 INTEGER PRIMARY KEY 列,这一列就是 rowid 的别名,不会额外占用独立键空间;否则 SQLite 会隐式生成 rowid 来定位每一行。
普通二级索引,例如 CREATE INDEX idx_orders_user_status ON orders(user_id, status),其 B-tree 叶子节点中存储的是键值和对应行的 rowid。当一条查询执行时,优化器先在索引中定位满足条件的键,若还需要读取 amount、created_at 等未包含在索引中的列,就必须拿 rowid 再进入主表 B-tree 搜索一次。这个第二次访问就是回表。
回表的成本不只是多一次查找。如果结果集分散在不同数据页中,可能触发大量随机读,远慢于索引树上的顺序扫描。因此,在设计高频查询时,让索引尽可能覆盖住查询所需列,是降低 SQLite 查询延迟的有效手段。
如何确认查询是否使用覆盖索引
可以用 EXPLAIN QUERY PLAN 观察查询计划。先创建一张订单表,并建立复合索引。
-- 创建订单表 CREATE TABLE orders ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, status TEXT NOT NULL, amount REAL ); -- 创建组合索引 CREATE INDEX idx_orders_user_status ON orders(user_id, status); -- 查看查询计划 EXPLAIN QUERY PLAN SELECT user_id, status FROM orders WHERE user_id = 1024 AND status = 'paid';
执行计划会输出类似 SEARCH orders USING COVERING INDEX idx_orders_user_status 的内容。这里的 COVERING 说明 SQLite 认为查询列全部包含在索引中,不会再回表读取订单主表。
如果把查询改成 SELECT *,或者增加读取 amount 列,执行计划通常会变为 USING INDEX,表示仍然走索引查找,但还需要额外回表。日常优化时,只要条件列和返回列始终覆盖在同一个组合索引里,就应当争取看到 USING COVERING INDEX。
QUERY PLAN |--SEARCH orders USING COVERING INDEX idx_orders_user_status (user_id=? AND status=?)
需要注意的是,覆盖索引扫描并不代表索引一定越小越好。覆盖索引依赖查询列完全命中索引键,如果业务经常需要按 user_id 查询并展示 status,建立 (user_id, status) 索引就能覆盖;如果还要展示 amount,则需要把 amount 也加入索引,但这会增加索引体积和写放大。
设计组合索引时如何覆盖更多查询
组合索引遵循最左前缀原则。对 (user_id, status) 索引而言,WHERE user_id = ? 和 WHERE user_id = ? AND status = ? 都能有效利用它,而 WHERE status = ? 通常不能直接使用该索引。想让覆盖索引生效,SELECT 列表中的所有列也必须出现在同一个索引中,并且 WHERE 条件要符合索引键前缀。
例如分页查询 SELECT id, user_id, status FROM orders WHERE user_id = ? ORDER BY id LIMIT 10,如果 id 是 INTEGER PRIMARY KEY,它等同于 rowid,二级索引中天然带有 rowid,因此索引 (user_id, status) 就可以覆盖 id、user_id、status 三列。这个查询不但能避免回表,还能借助 ORDER BY id 与索引尾部隐含 rowid 的顺序减少文件排序。
如果应用里习惯使用 SELECT *,覆盖索引几乎不可能命中,因为后续新增列会让查询再次回表。建议在 OLTP 高频路径中显式列出需要的列,并为这些列建立次序合理的复合索引。也可以考虑为读多写少的表建立稍宽的组合索引,用少量写入成本换取大幅降低的读取延迟。
表达式索引与部分索引的覆盖场景
SQLite 支持表达式索引,例如 CREATE INDEX idx_users_lower_email ON users(lower(email))。当查询为 SELECT lower(email) FROM users WHERE lower(email) = ? 时,SQLite 可以直接从索引读取 lower(email) 的结果,避免进入 users 表回表。这类技巧在大小写不敏感邮箱、归一化 URL、日期格式化场景中很常见。
-- 表达式索引 CREATE INDEX idx_users_lower_email ON users(lower(email)); EXPLAIN QUERY PLAN SELECT lower(email) FROM users WHERE lower(email) = 'test@ipipp.com';
部分索引则可以用 WHERE 子句只索引满足条件的行,例如只对未删除订单建立覆盖索引,进一步减小索引体积。对于大表来说,一个更小但能覆盖核心查询的索引,往往比一个宽大而难以被完整利用的索引更有效。编写查询时同样要保证 WHERE 中携带部分索引的过滤条件,否则优化器无法选中该索引。
还需要注意,覆盖索引并不是免费的。每次 INSERT、UPDATE、DELETE 都会同步维护索引,组合索引键越大,写入代价越高。实际操作中应结合 ANALYZE 统计信息、查询计划输出和慢查询日志,挑出回表最频繁的少数 SQL 建立覆盖索引。无法覆盖全部列时,至少将高筛选列、排序列和常用返回列放入索引,是 SQLite 查询优化里性价比很高的做法。