前言
大家好呀,我是一诺。
前面两篇文章解决了数据导入和查询的性能问题。但新的问题又来了:每次启动应用都要等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的时候能不能也同步给构建下索引呢"
这个想法太棒了!因为:
- 导入是唯一需要重建索引的时机:平时查询不会修改数据,只有导入会批量插入数据
- 导入本来就慢:用户已经有心理预期,等几分钟很正常
- 简化逻辑:不需要版本管理,不需要启动检查
新的策略:
- 启动时:只检查索引是否存在,不重建
- 导入时:先删除索引 → 导入数据 → 重建索引
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 倍)
- 代码更简单:删除了版本管理的复杂逻辑
- 用户体验更好:启动秒开,导入也更快
索引的本质:用空间换时间,用写入成本换查询速度。
智能构建的关键:理解数据的生命周期,在合适的时机更新索引。
如果你的项目也有类似的性能问题,可以试试这个思路。