第2章 EXPLAIN QUERY PLAN 分析查询计划

核心能力:掌握使用 Room 执行 EXPLAIN QUERY PLAN 的方法,准确识别全表扫描、索引扫描与覆盖索引扫描,为索引优化提供决策依据。

2.1 查询计划核心原理

SQLite 查询优化器在执行 SQL 语句前会生成一份查询计划,描述数据访问的具体路径。通过 EXPLAIN QUERY PLAN 可以查看该计划,从而判断查询是否有效利用了索引。

SQLite 的扫描方式分为以下三种:

扫描方式EXPLAIN 关键词说明性能
全表扫描SCAN TABLE逐行读取整张表,不使用任何索引最差,随数据量线性增长
索引扫描SEARCH … USING INDEX通过索引定位满足条件的行,需回表读取数据良好,仅扫描部分数据
覆盖索引扫描USING INDEX COVERING索引包含查询所需全部列,无需回表最优,仅访问索引页
⚠ 关键识别点:在 EXPLAIN QUERY PLAN 输出中,出现 SCAN TABLE 表名 且不带 USING INDEX 时,即为全表扫描。这是索引优化需要消除的首要目标。
查询计划生成与扫描方式选择流程 SQL 查询语句 SQLite 解析器 查询优化器 索引统计信息 查询计划生成 扫描方式选择 全表扫描 SCAN 索引扫描 SEARCH 覆盖索引 COVERING EXPLAIN QUERY PLAN 输出 关键原则:EXPLAIN QUERY PLAN 输出中 “SCAN TABLE” 不带 “USING INDEX” 即全表扫描,需优先优化

2.2 在 Room 中获取执行计划

Room 并未提供专用的 EXPLAIN QUERY PLAN API,但可以通过 RoomDatabase.openHelper 获取 SupportSQLiteDatabase 直接执行原生 SQL。

标准执行方式如下:

val db = roomDatabase.openHelper.writableDatabase
val cursor = db.rawQuery(
    "EXPLAIN QUERY PLAN SELECT * FROM users WHERE age > ?",
    arrayOf("30")
)
cursor.use { c ->
    while (c.moveToNext()) {
        val id = c.getInt(0)        // 操作编号
        val parent = c.getInt(1)    // 父操作编号
        val detail = c.getString(3) // 详细信息(SCAN / SEARCH)
        println("id=$id parent=$parent detail=$detail")
    }
}
💡 实用建议:将上述代码封装为 QueryPlanAnalyzer.analyze(sql, args) 工具方法,在开发阶段随时调用,快速验证索引是否生效。

2.3 识别全表扫描与索引扫描

EXPLAIN QUERY PLAN 输出的 detail 列中,扫描方式的关键词决定了查询性能:

  • 全表扫描:SCAN TABLE 表名 —— 表示逐行扫描整张表,无索引可用。
  • 索引扫描:SEARCH TABLE 表名 USING INDEX 索引名 —— 通过索引定位行,通常伴随 WHERE 条件。
  • 覆盖索引扫描:SEARCH TABLE 表名 USING INDEX 索引名 (条件) 且查询列均在索引中,无需回表。

典型输出对比:

-- 无索引时(全表扫描)
0|0|0|SCAN TABLE users

-- 有 age 索引时(索引扫描)
0|0|0|SEARCH TABLE users USING INDEX idx_users_age (age>?)

-- 覆盖索引时(无需回表)
0|0|0|SEARCH TABLE users USING COVERING INDEX idx_users_age_name (age>?)

2.4 实战步骤与代码示例

步骤 1:定义实体与索引

@Entity(
    tableName = "users",
    indices = [
        Index(value = ["age"], name = "idx_users_age"),
        Index(value = ["age", "name"], name = "idx_users_age_name")
    ]
)
data class User(
    @PrimaryKey val id: Long,
    val name: String,
    val age: Int,
    val email: String
)

步骤 2:编写 DAO

@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE age > :minAge")
    suspend fun getUsersByAge(minAge: Int): List<User>

    @Query("SELECT id, name, age FROM users WHERE age > :minAge")
    suspend fun getUsersPartial(minAge: Int): List<UserPartial>
}

步骤 3:执行查询计划分析

class QueryPlanAnalyzer(private val database: RoomDatabase) {

    fun analyze(sql: String, args: Array<String>) {
        val db = database.openHelper.writableDatabase
        val cursor = db.rawQuery("EXPLAIN QUERY PLAN $sql", args)
        println("═══════════════════════════════════════")
        println("SQL: $sql")
        println("────────── 查询计划 ──────────────────")
        cursor.use { c ->
            while (c.moveToNext()) {
                val detail = c.getString(3)
                val scanType = when {
                    detail.startsWith("SCAN TABLE") -> "⚠️ 全表扫描"
                    detail.contains("USING INDEX") -> "✅ 索引扫描"
                    detail.contains("COVERING") -> "✅ 覆盖索引扫描"
                    else -> "ℹ️ 其他"
                }
                println("  [$scanType] $detail")
            }
        }
        println("═══════════════════════════════════════")
    }
}

步骤 4:调用分析并解读结果

// 在 ViewModel 或 Repository 中调用
val analyzer = QueryPlanAnalyzer(roomDatabase)

// 分析按年龄查询
analyzer.analyze("SELECT * FROM users WHERE age > ?", arrayOf("30"))

// 输出示例:
// ═══════════════════════════════════════
// SQL: SELECT * FROM users WHERE age > ?
// ────────── 查询计划 ──────────────────
//   [✅ 索引扫描] SEARCH TABLE users USING INDEX idx_users_age (age>?)
// ═══════════════════════════════════════
⚠ 常见陷阱:如果查询计划输出为 SCAN TABLE users,说明查询条件列未建立索引或索引未被使用。此时应检查索引定义与查询条件的匹配性。

2.5 性能对比数据

以下测试基于 50 万条用户数据,分别执行全表扫描与索引扫描,对比关键指标:

数据量扫描方式查询耗时 (ms)扫描行数是否回表
100,000全表扫描98.3100,000—
100,000索引扫描2.112,500是
100,000覆盖索引扫描1.312,500否
500,000全表扫描512.4500,000—
500,000索引扫描8.762,300是
500,000覆盖索引扫描5.262,300否
1,000,000全表扫描1,024.71,000,000—
1,000,000索引扫描15.6125,000是
1,000,000覆盖索引扫描9.8125,000否
📊 数据解读:在 100 万条数据下,索引扫描比全表扫描快 65 倍,覆盖索引扫描再提升约 37%。EXPLAIN QUERY PLAN 是验证这些优化效果的最直接手段。

2.6 常见错误与解决方案

错误现象执行计划特征原因解决方案
查询慢,数据量增大后急剧变慢 SCAN TABLE 查询条件列未创建索引 添加 @Index 注解或执行 CREATE INDEX
复合索引部分条件无效 SCAN TABLE 或 SEARCH … USING INDEX 但扫描行数偏多 索引列顺序与查询条件不匹配,违反最左前缀原则 调整索引列顺序,将等值条件放在最左侧
使用 LIKE '%xxx' 导致全表扫描 SCAN TABLE 前导通配符导致索引无法检索 改用 LIKE 'xxx%' 或使用全文检索 FTS
OR 条件组合未命中索引 SCAN TABLE OR 分裂查询路径,优化器放弃索引 将 OR 改写为 UNION 或 IN
✅ 最佳实践:每次添加新索引或修改查询后,立即执行 EXPLAIN QUERY PLAN 验证扫描方式。将验证步骤纳入代码审查流程,防止索引退化。

2.7 通过 SQLite 命令行验证

除 Room 外,也可以直接使用 SQLite 命令行工具快速验证查询计划,适合在数据库文件上做离线分析:

-- 打开数据库文件
sqlite3 app_database.db

-- 查看当前索引
.tables
.indices users

-- 执行分析
EXPLAIN QUERY PLAN SELECT * FROM users WHERE age > 30;

-- 输出示例:
-- QUERY PLAN
-- |--SEARCH TABLE users USING INDEX idx_users_age (age>?)

-- 对比无索引时的计划(先删除索引测试)
DROP INDEX IF EXISTS idx_users_age;
EXPLAIN QUERY PLAN SELECT * FROM users WHERE age > 30;
-- QUERY PLAN
-- |--SCAN TABLE users
🔬 分析要点:对比带索引与不带索引的执行计划,可以清晰看到从 SCAN TABLE 到 SEARCH … USING INDEX 的转变。这是验证索引优化效果最可靠的方法。