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 索引。查询时直接走索引,不用全表扫描。
WHERE 或 JOIN 条件里,我就会考虑给它加索引。但别滥用——索引不是越多越好,写操作会变慢。
我在项目中遇到过一个问题:给 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 放在了复合索引的第一位,而查询条件里 userId 在 createTime 后面。调整索引列的顺序后,速度从 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. 知识体系总览
下面这张图,把索引和性能优化的核心逻辑串起来了。你可以把它当作一个检查清单:
6. 实战建议:什么时候该加索引?
最后,我分享几个实际项目中的判断标准:
- 数据量超过 1 万行:这时候索引的效果开始明显。低于这个量级,全表扫描可能更快。
- 查询频率高,但写操作少:比如配置表、用户信息表,适合加索引。
- 写操作频繁的表:比如日志表、埋点表,索引会拖慢插入速度。我一般只给
id和time加索引。 - 联合查询超过 2 张表:
JOIN的关联字段一定要有索引,否则性能灾难。
嗯,这一章的内容就到这里。索引这东西,用好了是神器,用不好是累赘。多跑几次 EXPLAIN QUERY PLAN,你心里就有数了。