SQLite 索引智能构建:从每次启动30秒到秒开

前言

大家好呀,我是一诺。

前面两篇文章解决了数据导入和查询的性能问题。但新的问题又来了:每次启动应用都要等30秒。

打开应用,黑屏30秒,然后才能使用。用户肯定受不了。

通过日志发现,启动时在重建索引:

复制代码
🔄 开始重建优化索引...
✅ 已创建索引: idx_daily_new_optimized (耗时 8234ms)
✅ 已创建索引: idx_daily_rising_optimized (耗时 7891ms)
✅ 已创建索引: idx_weekly_new_optimized (耗时 6755ms)
✅ 已创建索引: idx_weekly_rising_optimized (耗时 7234ms)
⏱️  索引重建完成,总耗时: 30114ms

每次启动都要重建所有索引,300万条数据需要30秒。这显然不合理。

这篇文章记录我是如何解决这个问题的,以及索引到底是什么、为什么能提高查询速度。

二、索引是什么?

在讲解决方案之前,先说说索引是什么。如果你已经很了解索引,可以直接跳到第三节。

2.1 生活中的例子

想象你有一本1000页的字典,要找"Apple"这个单词。

没有索引的情况:你只能从第1页开始,一页一页翻,直到找到"Apple"。平均要翻500页。

有索引的情况:字典按字母顺序排序,你知道"Apple"在"A"开头的部分,直接翻到那里。可能只需要翻10页。

这就是索引的作用:通过预先排序和组织数据,让查找变得更快。

2.2 数据库中的索引

在数据库里,索引的原理类似:

复制代码
-- 没有索引
SELECT * FROM keywords WHERE reportDate = '2025-01-15';
-- SQLite 必须扫描整个表(全表扫描),逐行检查 reportDate

-- 有索引
CREATE INDEX idx_report_date ON keywords(reportDate);
SELECT * FROM keywords WHERE reportDate = '2025-01-15';
-- SQLite 使用索引快速定位,只读取匹配的行

2.3 索引的结构

SQLite 的索引使用 B-Tree(平衡树) 结构:

复制代码
          [2025-01-15]
         /            \
   [2025-01-10]    [2025-01-20]
    /      \          /      \
  ...      ...      ...      ...

这种树状结构让查找复杂度从 O(n)(全表扫描)降到 O(log n)(树查找)。

举个例子:

  • 100万条记录,全表扫描需要检查 100万 次
  • 用B-Tree索引,只需要检查 20 次左右(log₂(1000000) ≈ 20)

这就是为什么索引能让查询从 9秒 降到 300毫秒。

2.4 复合索引

复合索引是多个字段组合成的索引:

复制代码
CREATE INDEX idx_multi ON keywords(reportDate, rank);

这个索引相当于先按 reportDate 排序,相同日期内再按 rank 排序。

适合这样的查询:

复制代码
SELECT * FROM keywords
WHERE reportDate = '2025-01-15'
ORDER BY rank ASC;

SQLite 可以直接用索引返回结果,不需要额外排序。

但要注意顺序!

复制代码
-- 好:能用上索引
WHERE reportDate = '2025-01-15' ORDER BY rank

-- 不好:只能用到 reportDate 部分
WHERE rank = 1 ORDER BY reportDate

复合索引的第一个字段最重要,必须出现在查询条件里才能用上索引。

2.5 索引的代价

索引不是免费的,有两个成本:

1. 空间成本

每个索引都要占用磁盘空间。我的项目里,4个优化索引大约占用数据库 15-20% 的空间。

2. 写入成本

插入数据时,不仅要写主表,还要更新所有索引。

示例:

复制代码
// 测试:导入100万条数据

// 有4个索引:耗时 60-80 秒
// 无索引:耗时 15-20 秒

这就是为什么我采用了"导入时删除索引,导入后重建"的策略。

三、启动慢的原因

回到问题本身。为什么每次启动都要重建索引?

看看最初的代码:

复制代码
// 启动时检查并重建索引
rebuildQueryIndexes() {
  console.log('🔄 开始检查优化索引...');

  // 获取现有索引
  const existingIndexes = this.db.prepare(`
    SELECT name FROM sqlite_master
    WHERE type='index'
  `).all().map(row => row.name);

  // 定义需要的索引
  const optimizedIndexes = [
    {
      name: 'idx_daily_new_optimized',
      sql: 'CREATE INDEX IF NOT EXISTS idx_daily_new_optimized ...'
    },
    // ... 其他索引
  ];

  // 重建索引
  for (const indexDef of optimizedIndexes) {
    if (existingIndexes.includes(indexDef.name)) {
      console.log(`🔄 索引已存在,删除后重建: ${indexDef.name}`);
      this.db.exec(`DROP INDEX IF EXISTS ${indexDef.name}`);
    }

    console.log(`🏗️  创建索引: ${indexDef.name}`);
    this.db.exec(indexDef.sql);
  }
}

问题在这里:

复制代码
if (existingIndexes.includes(indexDef.name)) {
  // 如果索引存在,删除它!
  this.db.exec(`DROP INDEX IF EXISTS ${indexDef.name}`);
}

// 然后重新创建
this.db.exec(indexDef.sql);

逻辑反了!索引存在的话,应该跳过创建,而不是删除重建。

结果就是:每次启动,先删除所有索引,再重建,白白浪费30秒。

四、第一版优化:版本号管理

4.1 思路

我的第一个想法是:给索引加个版本号。

  • 如果数据库里的索引版本和代码里的一致,跳过重建
  • 如果版本不一致,说明索引结构变了,需要重建

4.2 实现

创建一个版本表:

复制代码
// 创建索引版本表
this.db.exec(`
  CREATE TABLE IF NOT EXISTS index_versions (
    index_name TEXT PRIMARY KEY,
    version INTEGER NOT NULL,
    created_at TEXT NOT NULL
  )
`);

重建逻辑:

复制代码
rebuildQueryIndexes() {
  const currentVersion = 1; // 代码里的索引版本

  for (const indexDef of optimizedIndexes) {
    // 检查数据库里的版本
    const dbVersion = this.db.prepare(`
      SELECT version FROM index_versions WHERE index_name = ?
    `).get(indexDef.name);

    if (dbVersion && dbVersion.version === currentVersion) {
      console.log(`✅ 索引已是最新版本,跳过: ${indexDef.name}`);
      continue;
    }

    // 删除旧索引
    this.db.exec(`DROP INDEX IF EXISTS ${indexDef.name}`);

    // 创建新索引
    console.log(`🏗️  创建索引: ${indexDef.name}`);
    this.db.exec(indexDef.sql);

    // 记录版本
    this.db.prepare(`
      INSERT OR REPLACE INTO index_versions (index_name, version, created_at)
      VALUES (?, ?, datetime('now'))
    `).run(indexDef.name, currentVersion);
  }
}

4.3 效果

启动日志:

复制代码
🔄 开始检查优化索引...
✅ 索引已是最新版本,跳过: idx_daily_new_optimized
✅ 索引已是最新版本,跳过: idx_daily_rising_optimized
✅ 索引已是最新版本,跳过: idx_weekly_new_optimized
✅ 索引已是最新版本,跳过: idx_weekly_rising_optimized
⏱️  索引检查完成,总耗时: 15ms

从30秒降到15毫秒!

4.4 问题

这个方案有个问题:如果用户导入新数据呢?

导入数据后,索引需要更新。但因为版本号没变,启动时会跳过重建,导致索引缺失新数据。

虽然 SQLite 会自动维护索引(插入数据时同步更新索引),但如果用户在导入时没有索引(我们在导入前删除了索引),导入后又没有重建,就会出现查询结果不全的Bug。

五、第二版优化:在导入时构建(最终方案)

5.1 新思路

你的一句话点醒了我:

"在导入CSV的时候能不能也同步给构建下索引呢"

这个想法太棒了!因为:

  1. 导入是唯一需要重建索引的时机:平时查询不会修改数据,只有导入会批量插入数据
  2. 导入本来就慢:用户已经有心理预期,等几分钟很正常
  3. 简化逻辑:不需要版本管理,不需要启动检查

新的策略:

  • 启动时:只检查索引是否存在,不重建
  • 导入时:先删除索引 → 导入数据 → 重建索引

5.2 实现

启动时的检查(简化版):

复制代码
// 启动时:只检查索引是否存在
checkOptimizedIndexes() {
  console.log('🔍 检查优化索引...');

  const existingIndexes = this.db.prepare(`
    SELECT name FROM sqlite_master WHERE type='index'
  `).all().map(row => row.name);

  const optimizedIndexes = [
    'idx_daily_new_optimized',
    'idx_daily_rising_optimized',
    'idx_weekly_new_optimized',
    'idx_weekly_rising_optimized'
  ];

  const missingIndexes = optimizedIndexes.filter(
    name => !existingIndexes.includes(name)
  );

  if (missingIndexes.length === 0) {
    console.log('✅ 所有优化索引已存在');
  } else {
    console.log(`💡 缺失索引: ${missingIndexes.join(', ')}`);
    console.log('💡 索引将在下次导入数据时自动创建');
  }
}

导入时的重建:

复制代码
// 导入数据
async importFromParsedData(analysisResult, progressCallback) {
  const dataType = analysisResult.dataType; // 'daily' 或 'weekly'

  // ⭐ 第一步:删除索引(加速导入)
  console.log('🗑️  删除索引以加速导入...');
  if (dataType === 'daily') {
    this.db.exec('DROP INDEX IF EXISTS idx_daily_new_optimized');
    this.db.exec('DROP INDEX IF EXISTS idx_daily_rising_optimized');
  } else {
    this.db.exec('DROP INDEX IF EXISTS idx_weekly_new_optimized');
    this.db.exec('DROP INDEX IF EXISTS idx_weekly_rising_optimized');
  }

  // ⭐ 第二步:导入数据(快速)
  console.log('📥 开始导入数据...');
  const result = await this.batchInsert(records, progressCallback);

  // ⭐ 第三步:同步 FTS 全文搜索表
  console.log('🔄 同步全文搜索索引...');
  this.syncFTSTable(dataType, minId, maxId);

  // ⭐ 第四步:重建索引
  console.log('🏗️  重建优化索引...');
  this.rebuildOptimizedIndexesForImport(dataType, progressCallback);

  console.log('✅ 导入完成!');
  return result;
}

索引重建函数:

复制代码
rebuildOptimizedIndexesForImport(dataType, progressCallback) {
  const indexDefinitions = dataType === 'daily' ? [
    {
      name: 'idx_daily_new_optimized',
      sql: `CREATE INDEX IF NOT EXISTS idx_daily_new_optimized
            ON daily_keywords(yesterdayRank, reportDate DESC, rank ASC)`
    },
    {
      name: 'idx_daily_rising_optimized',
      sql: `CREATE INDEX IF NOT EXISTS idx_daily_rising_optimized
            ON daily_keywords(yesterdayRank, reportDate DESC, rankChange DESC)
            WHERE yesterdayRank IS NOT NULL`
    }
  ] : [
    {
      name: 'idx_weekly_new_optimized',
      sql: `CREATE INDEX IF NOT EXISTS idx_weekly_new_optimized
            ON weekly_keywords(lastWeekRank, weekEndDate DESC, rank ASC)`
    },
    {
      name: 'idx_weekly_rising_optimized',
      sql: `CREATE INDEX IF NOT EXISTS idx_weekly_rising_optimized
            ON weekly_keywords(lastWeekRank, weekEndDate DESC, rankChange DESC)
            WHERE lastWeekRank IS NOT NULL`
    }
  ];

  for (const indexDef of indexDefinitions) {
    const startTime = Date.now();
    console.log(`🏗️  创建索引: ${indexDef.name}`);

    this.db.exec(indexDef.sql);

    const elapsed = Date.now() - startTime;
    console.log(`✅ 索引创建完成: ${indexDef.name} (耗时 ${elapsed}ms)`);

    // 可选:报告进度
    if (progressCallback) {
      progressCallback({
        phase: 'building-indexes',
        message: `已创建索引: ${indexDef.name}`
      });
    }
  }

  console.log('✅ 所有索引创建完成');
}

5.3 效果对比

启动时间:

|-----------|------|
| 方案 | 启动耗时 |
| 优化前(每次重建) | 30秒 |
| 版本号方案 | 15ms |
| 导入时构建方案 | 15ms |

两个方案启动速度一样快!

导入时间:

测试:导入100万条数据

|---------|------------|------------|
| 阶段 | 有索引 | 无索引 → 重建 |
| 数据插入 | 60-80秒 | 15-20秒 |
| 索引重建 | - | 8-12秒 |
| 总耗时 | 60-80秒 | 25-30秒 |

导入反而快了!因为:

  • 插入时没有索引,写入速度快4倍
  • 一次性构建索引比边插入边更新索引要高效

5.4 智能识别机制

现在的机制非常智能:

场景1:首次使用(数据库为空)

  • 启动:检测到没有索引 → 提示"索引将在首次导入时创建"
  • 导入:创建索引
  • 结果:✅ 正常

场景2:已有数据(索引存在)

  • 启动:检测到索引存在 → 跳过
  • 导入:删除旧索引 → 导入 → 重建新索引
  • 结果:✅ 正常

场景3:清空数据

  • 清空时:删除所有数据和索引
  • 启动:检测到没有索引 → 提示"索引将在下次导入时创建"
  • 结果:✅ 正常

场景4:只查询,不导入

  • 启动:索引存在 → 跳过
  • 查询:使用现有索引
  • 结果:✅ 正常

完全自动化,不需要用户干预!

六、为什么这个方案更好?

对比三个方案:

6.1 方案A:每次启动重建

复制代码
// 启动时
rebuildAllIndexes(); // 30秒

❌ 启动慢

✅ 逻辑简单

✅ 索引永远是最新的

6.2 方案B:版本号管理

复制代码
// 启动时
if (indexVersion !== currentVersion) {
  rebuildIndexes(); // 30秒
}

// 导入时
importData(); // 边导入边更新索引

✅ 启动快(通常)

❌ 逻辑复杂

❌ 需要管理版本

⚠️ 导入慢(有索引时)

6.3 方案C:导入时构建(最终方案)

复制代码
// 启动时
checkIndexes(); // 15ms

// 导入时
dropIndexes();
importData(); // 快!
rebuildIndexes(); // 一次性重建

✅ 启动快

✅ 导入快

✅ 逻辑简单

✅ 完全自动化

这就是为什么选择方案C。

七、代码细节

7.1 清空数据时删除索引

复制代码
clearAllData() {
  console.log('🗑️  清空所有数据...');

  const transaction = this.db.transaction(() => {
    // 删除数据
    this.db.prepare('DELETE FROM daily_keywords').run();
    this.db.prepare('DELETE FROM weekly_keywords').run();
    this.db.prepare('DELETE FROM blocked_keywords').run();
    this.db.prepare('DELETE FROM import_history').run();

    // 删除 FTS 表
    this.db.prepare('DELETE FROM daily_keywords_fts').run();
    this.db.prepare('DELETE FROM weekly_keywords_fts').run();

    // ⭐ 删除索引(下次导入时会重建)
    this.db.exec('DROP INDEX IF EXISTS idx_daily_new_optimized');
    this.db.exec('DROP INDEX IF EXISTS idx_daily_rising_optimized');
    this.db.exec('DROP INDEX IF EXISTS idx_weekly_new_optimized');
    this.db.exec('DROP INDEX IF EXISTS idx_weekly_rising_optimized');
  });

  transaction();

  // 执行 VACUUM 回收空间
  console.log('🔧 优化数据库...');
  this.db.exec('VACUUM');
  console.log('✅ 数据清空完成');
}

为什么要删除索引?

  • 数据都没了,索引也没用了
  • 下次导入会重建,不需要保留空索引

7.2 FTS 表的同步

FTS 表也需要在导入时同步:

复制代码
syncFTSTable(dataType, minId, maxId) {
  const tableName = dataType === 'daily' ? 'daily_keywords' : 'weekly_keywords';
  const ftsTableName = `${tableName}_fts`;

  console.log(`🔄 同步 ${ftsTableName}...`);

  // 删除 FTS 表中对应范围的数据
  this.db.prepare(`
    DELETE FROM ${ftsTableName}
    WHERE id >= ? AND id <= ?
  `).run(minId, maxId);

  // 从主表同步新数据到 FTS 表
  this.db.prepare(`
    INSERT INTO ${ftsTableName}(id, keyword)
    SELECT id, keyword FROM ${tableName}
    WHERE id >= ? AND id <= ?
  `).run(minId, maxId);

  console.log(`✅ ${ftsTableName} 同步完成`);
}

这样保证 FTS 表和主表的数据一致。

八、总结

这次优化的核心思想:在正确的时机做正确的事。

  • 启动时:快速检查,不做重活
  • 导入时:一次性处理所有重活(索引、FTS)

最终效果:

  • 启动时间:从 30秒 → 15毫秒(快 2000 倍)
  • 导入时间:从 60-80秒 → 25-30秒(快 2-3 倍)
  • 代码更简单:删除了版本管理的复杂逻辑
  • 用户体验更好:启动秒开,导入也更快

索引的本质:用空间换时间,用写入成本换查询速度。

智能构建的关键:理解数据的生命周期,在合适的时机更新索引。

如果你的项目也有类似的性能问题,可以试试这个思路。

相关推荐
AlienZHOU4 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
躺不平的理查德6 小时前
SQL Lite 备忘录2
sqlite
我要见SA姐17 小时前
告别 Copilot?Codex 本地化部署指南
运维·数据库·机器学习·oracle·回归
Captaincc7 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
我要见SA姐18 小时前
用 Claude Code 重构遗留系统:从评估到落地的完整实践指南
数据库·ide·vscode·oracle·编辑器
计算机魔术师8 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen9 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒9 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
这个DBA有点耶10 小时前
异构数据集成怎么做?5 种同步方案对比 + 金融级 CDC 实战解析
数据库·oracle·架构
前端snow10 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端