15、索引与性能优化:@Index索引、复合索引、查询优化、EXPLAIN QUERY PLAN

数据库用久了,数据一多,查询就慢。这是绕不过去的坎。

我刚开始用 Room 的时候,也觉得 ORM 嘛,自动帮我搞定一切。直到有一次,用户反馈列表加载要等好几秒……嗯,那感觉就像被人当面泼了杯冷水。后来一查,就是缺了索引。

这一章,咱们就把索引这件事彻底聊透。说白了,索引就是数据库的「目录」。没有目录,查数据就得一页页翻,有了目录,直接定位到那一页。

1. 单列索引:最基础的加速器

先看一个最简单的例子。假设我们有个用户表,经常按 email 查询:

@Entity(
    indices = [Index(value = ["email"], unique = true)]
)
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "full_name") val name: String?,
    val email: String
)

加上 @Index 之后,Room 会在 SQLite 层面为 email 列建一个 B-Tree 索引。查询时直接走索引,不用全表扫描。

💡 我的习惯: 只要某个字段出现在 WHEREJOIN 条件里,我就会考虑给它加索引。但别滥用——索引不是越多越好,写操作会变慢。

我在项目中遇到过一个问题:给 status 字段加了索引,但查询还是很慢。为什么?因为 status 只有「已支付」「未支付」两种值,区分度太低。索引对低基数列效果很差,甚至可能比全表扫描还慢。你想想看,索引要维护一棵树,结果树上挂的每个节点都指向一半的数据,那还不如直接扫表呢。

2. 复合索引:多条件查询的利器

实际业务中,查询条件往往不止一个。比如按 userId + createTime 排序:

@Entity(
    indices = [
        Index(value = ["user_id", "create_time"], orders = [Index.Order.ASC, Index.Order.DESC])
    ]
)
data class Order(
    @PrimaryKey val orderId: Long,
    @ColumnInfo(name = "user_id") val userId: Long,
    @ColumnInfo(name = "create_time") val createTime: Long,
    val amount: Double
)

复合索引遵循「最左前缀原则」。也就是说,查询条件必须从索引的最左边开始匹配,索引才能生效。

⚠️ 避坑指南: 我曾经写过一个查询 WHERE createTime > ? AND userId = ?,结果索引没走。因为我把 createTime 放在了复合索引的第一位,而查询条件里 userIdcreateTime 后面。调整索引列的顺序后,速度从 800ms 降到了 20ms。

复合索引的列顺序怎么定?我的经验是:等值条件放前面,范围条件放后面。比如 userId = ? 是等值,createTime > ? 是范围,那就把 userId 放在索引的第一列。

3. 查询优化:写 SQL 的「潜规则」

索引建好了,SQL 写不对,照样白搭。我总结了几个 Room 中常见的优化点:

  • 避免在索引列上使用函数WHERE LOWER(email) = ? 会让索引失效。可以存小写,或者用 COLLATE NOCASE
  • 不要用 LIKE '%xxx':前导通配符无法利用索引。如果必须用,考虑全文搜索(FTS)。
  • 只查需要的列:别写 SELECT *,Room 会帮你映射,但底层传输的数据量还是大。用 @Query("SELECT name, age FROM user") 更高效。
  • 使用 LIMIT:分页查询一定要加 LIMIT,否则 Room 会把所有数据加载到内存。
💡 一个小技巧: 如果你需要查「是否存在」,用 SELECT EXISTS(SELECT 1 FROM user WHERE email = ?),比 COUNT(*) 快得多,因为查到第一条就返回了。

4. EXPLAIN QUERY PLAN:看穿 SQLite 的执行计划

优化不能靠猜。Room 底层是 SQLite,我们可以用 EXPLAIN QUERY PLAN 查看查询的执行计划。

在 Room 中,你可以通过 @Query 直接执行:

@Query("EXPLAIN QUERY PLAN SELECT * FROM user WHERE email = :email")
fun explainQuery(email: String): List<ExplainRow>

data class ExplainRow(
    val selectid: Int,
    val order: Int,
    val from: Int,
    val detail: String
)

输出结果类似这样:

selectid order from detail
0 0 0 SEARCH TABLE user USING INDEX index_email (email=?)

看到 USING INDEX 就说明索引生效了。如果显示 SCAN TABLE,那就是全表扫描,得赶紧优化。

⚠️ 注意: EXPLAIN QUERY PLAN 的结果是 SQLite 内部格式,不同版本可能略有差异。建议在开发阶段多跑几次,确认索引是否被正确使用。

我个人习惯在每次写复杂查询后,都跑一遍 EXPLAIN QUERY PLAN。尤其是 JOIN 多张表的时候,很容易出现「明明有索引,但没走」的情况。比如 JOIN 的列类型不匹配(INTEGER vs TEXT),索引就会失效。

5. 知识体系总览

下面这张图,把索引和性能优化的核心逻辑串起来了。你可以把它当作一个检查清单:

索引与性能优化知识体系 性能优化 单列索引 复合索引 查询优化 EXPLAIN QUERY PLAN WHERE / JOIN 条件 唯一约束 最左前缀原则 等值 + 范围顺序 避免函数 / 前导通配符 只查必要列 + LIMIT 检查 USING INDEX 避免 SCAN TABLE 索引不是银弹,写操作频繁的列要谨慎加索引

6. 实战建议:什么时候该加索引?

最后,我分享几个实际项目中的判断标准:

  • 数据量超过 1 万行:这时候索引的效果开始明显。低于这个量级,全表扫描可能更快。
  • 查询频率高,但写操作少:比如配置表、用户信息表,适合加索引。
  • 写操作频繁的表:比如日志表、埋点表,索引会拖慢插入速度。我一般只给 idtime 加索引。
  • 联合查询超过 2 张表JOIN 的关联字段一定要有索引,否则性能灾难。
📌 核心总结: 索引是性能优化的第一把钥匙。建好索引,写好 SQL,再用 EXPLAIN 验证——这三步走完,90% 的慢查询都能解决。剩下的 10%,可能需要考虑缓存、异步加载或者数据库分库分表了。

嗯,这一章的内容就到这里。索引这东西,用好了是神器,用不好是累赘。多跑几次 EXPLAIN QUERY PLAN,你心里就有数了。


公众号:蓝海资料掘金营,微信deep3321