Menu

SQLite 复合索引:列顺序与最左前缀原则

SQLite 中多列索引如何工作、为什么列顺序很重要,以及复合索引什么时候能提速、什么时候只是白占空间。

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

一个索引,多个列

复合索引也叫多列索引,是建立在两个或更多列上的单个索引。创建时按顺序列出这些列即可:

索引 idx_orders_customer_status 中的条目先按 customer_id 排序,同一个客户内部再按 status 排序。排序方式就是全部的关键,复合索引的其他特性都由它而来。

心智模型:排好序的电话簿

想象一本老式电话簿。条目按姓排序,同姓的再按名排序。这正是 (last_name, first_name) 上的索引的样子。

有些查找很快,有些则不然:

  • “找出所有姓 Patel 的人”:很快,所有 Patel 都挨在一起。
  • “找 Priya Patel”:很快,先跳到 Patel,再往下找到 Priya。
  • “找出所有叫 Priya 的人”:很慢,你得翻遍每一页,因为叫 Priya 的人分散在各个姓里。

SQLite 的复合索引也是这样工作的。第一列是主排序键;第二列只对第一列值相同的条目排序。

最左前缀原则

只有当 WHERE 子句约束的是索引列的“最左前缀”时,SQLite 才能用复合索引执行查询。对于 (a, b, c) 上的索引:

  • 过滤 a:会用到索引。
  • 过滤 a 和 b:会用到索引。
  • 过滤 a、b 和 c:会用到索引。
  • 只过滤 b、只过滤 c,或者过滤 b 和 c:不会用到索引。

你可以直接用 EXPLAIN QUERY PLAN 验证:

第一个查询计划显示 SEARCH events USING INDEX idx_events_user_kind_time。第二个则退回到 SCAN events:只过滤 kind 跳过了排在最前面的 user_id 列,所以这个索引对该查询毫无用处。

列顺序是一个设计决策

既然最左前缀这么重要,CREATE INDEX 里列的排列顺序就是一个实打实的选择,而不只是写法风格。有两条经验法则:

  1. 把最常用来过滤的列放在最前面。 这一列能让索引适用于最多的查询。
  2. 等值列放在范围列前面。 SQLite 可以先用 = 在索引中精确定位,再用 <、> 或 BETWEEN 扫描一段连续范围,但范围扫描只能用在最后一个被使用的列上。

查询计划显示 SEARCH sales USING INDEX idx_sales_region_time (region=? AND sold_at>?)。SQLite 直接跳到 region = 'EU',然后沿日期范围向后走。如果把列顺序换成 (sold_at, region),同一条查询就得扫描日期范围内的每一行,并逐行重新检查 region。

复合索引与多个单列索引

一个常见的问题:应该在 (a, b) 上建一个索引,还是在 a 和 b 上分别建两个索引?

对于组合过滤,复合索引更快:SQLite 直接走到匹配的 (project_id, state) 条目。如果是两个单列索引,SQLite 通常只挑其中一个来缩小行的范围,再对每个匹配的行检查另一列。它有时能把两个索引取交集,但当这些列是“一起”查询时,复合索引才是更干净的方案。

如果 project_id 和 state 也会被单独查询,你可能两种都需要:复合索引用于组合过滤,再加一个 state 上的单列索引,给只按它过滤的查询用。

覆盖索引

当一个索引包含了查询需要的每一列(过滤用的列和选出的列)时,SQLite 完全不用访问表就能得出结果。这就是覆盖索引,也是查询能达到的最快速度。

查询计划显示 USING COVERING INDEX idx_invoices_cover。查询直接从索引中读出 issued_at 和 total,用不到 notes 和 id,所以表本身根本不会被打开。如果某条查询一直在跑,单纯为了覆盖它而往复合索引里加一列,是很划算的交换。

复合 UNIQUE 约束

复合索引还能保证多列组合的唯一性。当单独哪一列都不唯一、但组合必须唯一时,这很有用:

第三条插入会报错 UNIQUE constraint failed: enrollments.student_id, enrollments.course_id。索引中已经有同样的组合,所以 SQLite 拒绝了这条重复数据。

值得注意的陷阱

  • 非前导列之间的 OR 会让索引失效。 对于 (a, b) 上的索引,WHERE a = 1 OR b = 2 通常完全用不上它,因为 SQLite 需要分别考虑两个分支。
  • 在索引列上套函数会让索引失效。 WHERE lower(email) = 'x' 用不上 email 上的索引。改为给表达式建索引,或者在插入时就把数据规范化。
  • 索引不是免费的。 每次 INSERT、UPDATE(更新了索引列时)和 DELETE 都要更新每一个索引。在写入频繁的表上建三个复合索引,写入开销可能主要都花在它们身上。
  • 建好索引后运行 ANALYZE。 SQLite 的查询规划器靠 ANALYZE 收集的统计信息在候选索引之间做选择。没有这些统计信息,它只能退回到启发式规则,而这些规则并不总是最优的。

实用工作流程

优化一条慢查询时,流程通常是这样的:

  1. 对这条查询运行 EXPLAIN QUERY PLAN,看看 SQLite 现在是怎么执行的。
  2. 如果它在全表扫描,就看看 WHERE 子句:等值条件是哪一列?范围条件是哪一列?选出了哪些列?
  3. 建一个复合索引,等值列在前、范围列在后;如果覆盖能带来好处,再把选出的列追加到后面。
  4. 运行 ANALYZE。
  5. 再次运行 EXPLAIN QUERY PLAN,确认计划变了,并且用上了索引。
  6. 在有代表性的数据上,对比查询优化前后的耗时。

跳过第 6 步要自担风险。一个在查询计划里“看起来”没问题的索引,在实际中仍可能更慢,比如表很小,或者规划器选了另一条路径。

下一篇:部分索引

复合索引覆盖表中的每一行。但很多时候,真正重要的只是一小部分行:未关闭的工单、尚未处理的任务、没有删除的记录。“部分”索引让你只给这些行建索引,把一个 WHERE 子句直接写进索引本身。这就是下一页的内容。

常见问题

SQLite 中的复合索引是什么?

复合索引是覆盖两个或更多列的单个索引,用 CREATE INDEX idx_name ON table(col_a, col_b) 创建。SQLite 存储索引条目时先按 col_a 排序,在 col_a 相同的条目里再按 col_b 排序,就像电话簿先按姓排、再按名排一样。

SQLite 复合索引的列顺序重要吗?

非常重要。只有当 WHERE 子句过滤的是索引列的最左前缀时,SQLite 才能用复合索引执行查询。(a, b, c) 上的索引能帮到过滤 a、过滤 a 和 b、或者三列都过滤的查询,但帮不了只过滤 b 或只过滤 c 的查询。

什么时候该用复合索引,而不是几个单列索引?

如果查询经常同时按同一组列过滤或排序,就用复合索引。如果每一列都是单独查询的,单列索引就够了。运行 EXPLAIN QUERY PLAN 看看 SQLite 实际选了哪个索引,这是唯一可靠的反馈。

SQLite 中的覆盖索引是什么?

覆盖索引包含查询需要的每一列,所以 SQLite 直接从索引就能得出结果,完全不用访问表。出现这种情况时,EXPLAIN QUERY PLAN 会显示 USING COVERING INDEX。为了覆盖一条高频查询而往复合索引里额外加列,是很常见的优化手段。

Coddy 编程语言插图

和 Coddy 一起学编程

开始