10、SQLite性能优化:索引原理与创建、EXPLAIN查询计划分析、批量插入优化(事务+编译)、查询缓存策略、数据库大小控制

说实话,SQLite 在移动端的表现,很多时候被低估了。

我见过不少项目,数据库就几十张表,数据量也不大,但用户一操作就卡顿。问题出在哪?不是 SQLite 不行,而是我们没把它「伺候好」。今天这一章,我就把压箱底的优化经验掏出来,跟你聊聊索引、查询计划、批量插入、缓存和数据库瘦身这些硬核话题。

10.1 索引原理与创建

索引这东西,说白了就是数据库的「目录」。你想想看,一本几百页的书,如果没有目录,你想找某个关键词,只能一页一页翻。有了目录,直接定位到页码,效率天差地别。

SQLite 默认用 B-Tree 结构来组织索引。每次插入、删除、更新数据时,索引树都要同步维护。所以索引不是越多越好——它是一把双刃剑。

核心原则:索引加速查询,但拖慢写入。高频查询的字段建索引,高频写入的字段慎建索引。

什么时候该建索引?

  • WHERE 子句中频繁使用的字段——比如按用户ID查询
  • JOIN 操作的关联字段——比如外键
  • ORDER BY 或 GROUP BY 的字段——避免文件排序
  • 唯一性约束的字段——UNIQUE 约束会自动创建索引

什么时候不该建索引?

  • 数据量很小(几百条以内),全表扫描比索引查找还快
  • 字段值重复率极高,比如性别字段(只有男/女)
  • 频繁写入的表,索引维护成本太高

我个人习惯是:先跑业务,再通过 EXPLAIN 分析慢查询,最后针对性建索引。不要一开始就拍脑袋建一堆索引。

-- 创建单列索引
CREATE INDEX idx_user_name ON users(name);

-- 创建复合索引(注意字段顺序)
CREATE INDEX idx_user_status_date ON users(status, created_at);

-- 唯一索引
CREATE UNIQUE INDEX idx_user_email ON users(email);
小技巧:复合索引遵循「最左前缀原则」。比如索引 (status, created_at),查询 WHERE status = ? 会走索引,但 WHERE created_at = ? 不会。把区分度高的字段放前面。

10.2 EXPLAIN 查询计划分析

你写的 SQL 到底怎么执行的?SQLite 不会骗你,它会告诉你查询计划。用 EXPLAIN QUERY PLAN 就能看到。

我曾经接手过一个项目,用户反馈列表加载越来越慢。我一看 SQL,发现没有索引,每次都是全表扫描。加上索引后,查询时间从 800ms 降到了 5ms。嗯,这就是 EXPLAIN 的价值。

-- 查看查询计划
EXPLAIN QUERY PLAN SELECT * FROM users WHERE name = '张三';

-- 输出示例
-- |--SEARCH TABLE users USING INDEX idx_user_name (name=?)

常见的查询计划类型:

计划类型 含义 优化建议
SCAN TABLE 全表扫描 考虑加索引
SEARCH TABLE USING INDEX 走索引查找 良好,检查是否覆盖所有查询条件
SEARCH TABLE USING COVERING INDEX 覆盖索引(无需回表) 最优,数据直接从索引获取
USE TEMP B-TREE FOR ORDER BY 排序用了临时表 考虑建复合索引包含排序字段
注意:EXPLAIN 和 EXPLAIN QUERY PLAN 不一样。前者输出底层虚拟机指令,后者输出人类可读的计划。日常调试用后者就够了。

10.3 批量插入优化(事务 + 编译)

批量插入是性能杀手。我见过有人循环 1000 次 insert,每次单独提交,耗时十几秒。优化后不到 100ms。差距在哪?两个关键点:事务和预编译。

事务的作用

默认情况下,每条 INSERT 语句都是一个独立事务。每次提交都要写磁盘、同步日志。把多条插入包在一个事务里,只提交一次,性能提升几十倍。

db.beginTransaction();
try {
    for (User user : userList) {
        db.execSQL("INSERT INTO users(name, age) VALUES(?, ?)", 
                   new Object[]{user.name, user.age});
    }
    db.setTransactionSuccessful();
} finally {
    db.endTransaction();
}

预编译(Prepared Statement)

每次执行 SQL,SQLite 都要解析、编译、优化。预编译只做一次,后面重复执行,省掉了解析开销。

String sql = "INSERT INTO users(name, age) VALUES(?, ?)";
SQLiteStatement stmt = db.compileStatement(sql);

db.beginTransaction();
try {
    for (User user : userList) {
        stmt.bindString(1, user.name);
        stmt.bindLong(2, user.age);
        stmt.executeInsert();
        stmt.clearBindings();
    }
    db.setTransactionSuccessful();
} finally {
    db.endTransaction();
    stmt.close();
}
实测数据:插入 10000 条记录,逐条提交约 12 秒;加事务后约 800ms;再加预编译后约 300ms。优化了近 40 倍。

10.4 查询缓存策略

SQLite 本身有页面缓存(默认 2MB),但那是底层机制。应用层我们也可以做缓存,减少重复查询。

我常用的策略是「内存缓存 + 失效标记」。比如用户列表,第一次查询后缓存到 LruCache 里,当数据变更时清空缓存。

public class UserCache {
    private static final int MAX_SIZE = 100;
    private LruCache<String, List<User>> cache = new LruCache<>(MAX_SIZE);
    
    public List<User> getUsers(String key) {
        return cache.get(key);
    }
    
    public void putUsers(String key, List<User> users) {
        cache.put(key, users);
    }
    
    public void invalidate() {
        cache.evictAll();
    }
}
经验之谈:缓存不是万能的。数据实时性要求高的场景(比如聊天消息),缓存反而会带来数据不一致。我一般只缓存「配置类」或「静态列表」数据。

10.5 数据库大小控制

数据库文件越来越大,查询自然越来越慢。控制数据库大小,是长期维护的必修课。

定期清理无用数据

-- 删除过期日志
DELETE FROM logs WHERE created_at < date('now', '-30 days');

-- 注意:DELETE 不会释放磁盘空间,只是标记为可重用
-- 需要执行 VACUUM 才能真正回收空间
VACUUM;

VACUUM 的原理

VACUUM 会重建整个数据库文件,把空闲空间释放掉。但注意,它需要两倍磁盘空间来执行。我建议在应用空闲时(比如 App 进入后台)执行。

坑:VACUUM 会重建所有索引,大表上可能耗时较长。我曾经在用户操作高峰期执行 VACUUM,导致界面卡顿好几秒。后来改成只在凌晨或应用切后台时执行。

使用 auto_vacuum

SQLite 支持自动回收空间,但默认是关闭的。

-- 开启自动回收(需要在建库前设置)
PRAGMA auto_vacuum = 1;  -- 0=关闭, 1=完整, 2=增量

-- 查看当前设置
PRAGMA auto_vacuum;

auto_vacuum = 1 时,每次删除数据都会触发页面回收,但会有性能开销。我一般用默认的关闭状态,然后定期手动 VACUUM。

数据库文件大小监控

File dbFile = new File(db.getPath());
long sizeInMB = dbFile.length() / (1024 * 1024);
Log.d("DB_SIZE", "数据库大小: " + sizeInMB + " MB");

我习惯在 App 启动时检查数据库大小,如果超过 100MB 就触发一次 VACUUM 或清理任务。

本章核心总结:
  • 索引是双刃剑,按需创建,用 EXPLAIN 验证效果
  • 批量插入用「事务 + 预编译」,性能提升几十倍
  • 应用层缓存减少重复查询,注意数据一致性
  • 定期 VACUUM 控制数据库大小,避免文件膨胀
SQLite 性能优化知识体系 索引原理与创建 EXPLAIN 查询计划 批量插入优化 查询缓存策略 数据库大小控制 B-Tree 结构 复合索引最左前缀 唯一索引 SCAN vs SEARCH 覆盖索引 TEMP B-TREE 排序 事务包裹 预编译 SQLiteStatement clearBindings() LruCache 内存缓存 失效标记策略 数据一致性控制 DELETE + VACUUM auto_vacuum 文件大小监控 优化效果对比 批量插入 10000 条:逐条提交 12s → 事务+预编译 300ms(提升 40 倍) 全表扫描 800ms → 加索引后 5ms(提升 160 倍)

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