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 倍)
  • 代码更简单:删除了版本管理的复杂逻辑
  • 用户体验更好:启动秒开,导入也更快

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

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

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

相关推荐
她说彩礼65万2 小时前
CSS 相对定位与绝对定位
前端·css
mon_star°2 小时前
《疯狂动物城2》主题网页设计之旅
前端
一只爱吃糖的小羊2 小时前
Vue 3 vs React 19:响应式系统的“自动挡“与“手动挡“之争
前端·vue.js·react.js
AC赳赳老秦2 小时前
使用PbootCMS制作网站如何免费做好防护
前端·数据库·黑客·网站建设·网站制作·防挂马·网站防黑
weixin_462446232 小时前
利用qoder开发React + HanziWriter 实现幼儿园汉字描红(支持笔顺演示 / 判错 / 拼音 / 组词)
前端·react.js·前端框架
张较瘦_2 小时前
前端 | CSS animation 与 transform 协同使用完全教程
前端·css
黎明初时2 小时前
React基础框架搭建1-计划:react+router+redux+axios+Tailwind+webpack
前端·react.js·webpack·架构
啃火龙果的兔子2 小时前
edge浏览器设置深色模式
前端·edge
网络风云2 小时前
HTTP协议与Web通信原理
前端·网络协议·http