一、查询慢的问题
大家好呀,我是一诺。
上篇文章 SQLite 大批量数据导入优化:从百万卡死到千万级支持 解决了数据导入的问题,可以顺利导入千万级数据了。但新的问题出现了:查询太慢。
具体表现是,在周数据页面(300多万条记录),点击"查询"按钮后,页面卡住,Loading转了9秒才显示结果。用户体验很差。
更糟糕的是,添加了屏蔽词功能后,查询时间更长了。屏蔽一个词,查询从9秒变成了10秒+。
这篇文章记录我是怎么把查询时间从9秒优化到300毫秒的。
二、定位问题
2.1 查询日志
我在查询函数里加了详细的日志:
getWeeklyNewKeywords(weekStartDate, page = 1, pageSize = 100, searchKeyword = '') {
console.log(`\n🔍 [DB] getWeeklyNewKeywords 开始执行`);
const startTime = Date.now();
// 构建查询条件
let whereClause = 'WHERE lastWeekRank IS NULL';
let params = [];
if (weekStartDate) {
whereClause = 'WHERE weekStartDate = ? AND lastWeekRank IS NULL';
params = [weekStartDate];
}
// 获取屏蔽词列表
const t1 = Date.now();
const blockedKeywords = this.getBlockedKeywords();
const t2 = Date.now();
console.log(`[DB] 获取屏蔽词: ${t2-t1}ms (共 ${blockedKeywords.length} 个)`);
// 构建屏蔽词过滤条件
if (blockedKeywords.length > 0) {
const blockedConditions = blockedKeywords
.map(() => 'LOWER(keyword) NOT LIKE ?')
.join(' AND ');
whereClause += ` AND ${blockedConditions}`;
params.push(...blockedKeywords.map(kw => `%${kw.toLowerCase()}%`));
}
// 执行 COUNT 查询
const t3 = Date.now();
const countResult = this.db.prepare(`
SELECT COUNT(*) as total FROM weekly_keywords ${whereClause}
`).get(...params);
const t4 = Date.now();
console.log(`[DB] COUNT查询: ${t4-t3}ms (总数: ${countResult.total})`);
// 执行分页查询
const t5 = Date.now();
const data = this.db.prepare(`
SELECT rank, keyword, category1, brand1, clickShare1, weekStartDate
FROM weekly_keywords
${whereClause}
ORDER BY weekStartDate DESC, rank ASC
LIMIT ? OFFSET ?
`).all(...params, pageSize, (page - 1) * pageSize);
const t6 = Date.now();
console.log(`[DB] 分页查询: ${t6-t5}ms (返回 ${data.length} 条)`);
const totalTime = Date.now() - startTime;
console.log(`[DB] ⭐ 总耗时: ${totalTime}ms\n`);
return {
data: data,
total: countResult.total,
page,
pageSize,
totalPages: Math.ceil(countResult.total / pageSize)
};
}
运行后,日志显示:
[DB] 获取屏蔽词: 0ms (共 1 个)
[DB] COUNT查询: 9340ms (总数: 3138642)
[DB] 分页查询: 2ms (返回 100 条)
[DB] ⭐ 总耗时: 9343ms
问题找到了:COUNT 查询用了9340毫秒。
2.2 执行计划
SQLite 可以用 EXPLAIN QUERY PLAN 查看查询的执行计划:
const plan = this.db.prepare(`
EXPLAIN QUERY PLAN
SELECT COUNT(*) as total
FROM weekly_keywords
WHERE lastWeekRank IS NULL AND LOWER(keyword) NOT LIKE '%butter%'
`).all();
console.log('执行计划:', plan.map(p => p.detail).join(' | '));
输出:
SEARCH weekly_keywords USING INDEX idx_weekly_last_week_rank (lastWeekRank=?)
执行计划显示,查询使用了索引 idx_weekly_last_week_rank,可以快速找到 lastWeekRank IS NULL 的记录(约300万条)。
但是,后面的 LOWER(keyword) NOT LIKE '%butter%' 是无法使用索引的。SQLite 需要对这300万条记录逐行检查,每条都要执行字符串小写转换和模糊匹配。
这就是慢的原因。
三、优化方案
3.1 方案一:在 SQL 层面过滤屏蔽词
最初我想过几种方案:
方案A:取更多数据,在 JavaScript 里过滤
// 取1000条,过滤后可能只剩100条
const data = db.prepare('SELECT ... LIMIT 1000').all();
const filtered = data.filter(item =>
!blockedKeywords.some(kw => item.keyword.toLowerCase().includes(kw))
);
问题:不知道要取多少条才能保证够100条。
方案B:预先把屏蔽词的ID找出来,用 NOT IN 排除
// 先查出所有包含屏蔽词的记录ID
const blockedIds = [];
for (const kw of blockedKeywords) {
const ids = db.prepare(
'SELECT id FROM keywords WHERE LOWER(keyword) LIKE ?'
).all(`%${kw}%`);
blockedIds.push(...ids.map(r => r.id));
}
// 用 NOT IN 排除
const data = db.prepare(`
SELECT ... WHERE id NOT IN (${blockedIds.join(',')})
`).all();
问题:屏蔽词太多时,每个都要执行一次查询,还是慢。
方案C:把 NOT LIKE 展开成多个 AND 条件
// 构建条件:AND LOWER(keyword) NOT LIKE ? AND LOWER(keyword) NOT LIKE ? ...
const conditions = blockedKeywords
.map(() => 'LOWER(keyword) NOT LIKE ?')
.join(' AND ');
whereClause += ` AND ${conditions}`;
params.push(...blockedKeywords.map(kw => `%${kw.toLowerCase()}%`));
这个方案比 NOT EXISTS 子查询要快一些,因为避免了相关子查询的开销。但本质上还是要对每条记录执行字符串匹配。
3.2 方案二:使用 FTS5 全文搜索
SQLite 有一个 FTS5(Full-Text Search)扩展,专门用于文本搜索。它的原理是建立一个单独的索引表,把文本分词后存储,查询时可以快速匹配。
FTS5 的匹配性能比 LIKE '%keyword%' 快几百倍。
第一步:创建 FTS5 表
// 创建 FTS5 虚拟表
this.db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS weekly_keywords_fts USING fts5(
id UNINDEXED,
keyword,
tokenize='unicode61'
);
`);
第二步:从主表填充数据
// 把主表的数据复制到 FTS 表
this.db.exec(`
INSERT INTO weekly_keywords_fts(id, keyword)
SELECT id, keyword FROM weekly_keywords
`);
这个操作在300万数据上大约需要30-60秒,但只需要执行一次。
第三步:在导入数据时同步更新 FTS 表
每次导入新数据时,也要更新 FTS 表:
// 插入主表
insertStmt.run(record.rank, record.keyword, ...);
// 同时插入 FTS 表
ftsInsertStmt.run(record.id, record.keyword);
但这样会让导入变慢。我采用的方案是:导入时不更新 FTS,应用启动时检查并自动同步。
第四步:查询时使用 FTS
// 用 FTS 快速找出包含屏蔽词的记录ID
const blockedIds = new Set();
for (const keyword of blockedKeywords) {
const results = this.db.prepare(`
SELECT id FROM weekly_keywords_fts
WHERE keyword MATCH ?
`).all(keyword.toLowerCase());
results.forEach(r => blockedIds.add(r.id));
}
// 用 NOT IN 排除
if (blockedIds.size > 0) {
whereClause += ` AND id NOT IN (${Array.from(blockedIds).join(',')})`;
}
注意,这里没有参数化 NOT IN 列表,因为 ID 数量可能很多。但因为 ID 是数字,不存在 SQL 注入风险。
完整代码:
getWeeklyNewKeywords(weekStartDate, page = 1, pageSize = 100) {
let whereClause = 'WHERE lastWeekRank IS NULL';
let params = [];
if (weekStartDate) {
whereClause = 'WHERE weekStartDate = ? AND lastWeekRank IS NULL';
params = [weekStartDate];
}
// 使用 FTS 过滤屏蔽词
const blockedKeywords = this.getBlockedKeywords();
if (blockedKeywords.length > 0) {
const ftsTableExists = this.db.prepare(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='weekly_keywords_fts'
`).get();
if (ftsTableExists) {
// 用 FTS 查找屏蔽词的ID
let blockedIds = new Set();
for (const keyword of blockedKeywords) {
const results = this.db.prepare(`
SELECT id FROM weekly_keywords_fts
WHERE keyword MATCH ?
`).all(keyword.toLowerCase());
results.forEach(r => blockedIds.add(r.id));
}
if (blockedIds.size > 0) {
whereClause += ` AND id NOT IN (${Array.from(blockedIds).join(',')})`;
}
} else {
// FTS表不存在,回退到 LIKE 方式
const conditions = blockedKeywords
.map(() => 'LOWER(keyword) NOT LIKE ?')
.join(' AND ');
whereClause += ` AND ${conditions}`;
params.push(...blockedKeywords.map(kw => `%${kw.toLowerCase()}%`));
}
}
// COUNT 查询
const countResult = this.db.prepare(`
SELECT COUNT(*) as total FROM weekly_keywords ${whereClause}
`).get(...params);
// 分页查询
const data = this.db.prepare(`
SELECT rank, keyword, category1, brand1, clickShare1, weekStartDate
FROM weekly_keywords
${whereClause}
ORDER BY weekStartDate DESC, rank ASC
LIMIT ? OFFSET ?
`).all(...params, pageSize, (page - 1) * pageSize);
return {
data: data,
total: countResult.total,
page,
pageSize,
totalPages: Math.ceil(countResult.total / pageSize)
};
}
3.3 关键词搜索也用 FTS
除了屏蔽词,用户搜索关键词时也有性能问题。原因相同:LIKE '%keyword%' 无法使用索引。
解决方案也是用 FTS:
if (searchKeyword && searchKeyword.trim()) {
const ftsResults = this.db.prepare(`
SELECT id FROM weekly_keywords_fts
WHERE keyword MATCH ?
`).all(searchKeyword.trim());
const ftsIds = ftsResults.map(r => r.id);
if (ftsIds.length === 0) {
// 没有匹配结果
return { data: [], total: 0, page, pageSize, totalPages: 0 };
}
whereClause += ` AND id IN (${ftsIds.join(',')})`;
}
四、效果对比
4.1 屏蔽词过滤性能
测试场景:300万条记录,屏蔽1个词("butter")
|----------|------------|----------|
| 操作 | 优化前 | 优化后 |
| 获取屏蔽词列表 | 0ms | 0ms |
| COUNT 查询 | 9340ms | 15ms |
| 分页查询 | 2ms | 2ms |
| 总耗时 | 9343ms | 17ms |
性能提升:550倍
4.2 关键词搜索性能
测试场景:300万条记录,搜索"bluetooth"
|--------------------|---------|
| 方案 | 耗时 |
| LIKE '%bluetooth%' | 8-10秒 |
| FTS5 MATCH | 10-50毫秒 |
性能提升:200-800倍
4.3 实际使用体验
优化前:
- 无屏蔽词查询:2秒
- 有屏蔽词查询:9-10秒
- 关键词搜索:8-10秒
优化后:
- 无屏蔽词查询:300毫秒
- 有屏蔽词查询:300毫秒
- 关键词搜索:100-500毫秒
用户体验显著提升,基本实现了"即时搜索"。
五、FTS5 的局限性
FTS5 不是万能的,有一些限制:
- 只适合文本匹配:不能用在数值、日期等字段上
- 需要额外空间:FTS 表大约占用主表 30-50% 的空间
- 需要同步:插入、更新、删除数据时要同时更新 FTS 表
- 分词问题:中英文混合时,分词效果可能不理想
在我的项目里,因为主要是英文关键词搜索,FTS5 很合适。
六、其他优化
6.1 复合索引
对于常见的查询模式,建立复合索引可以提升性能:
// 新词查询:WHERE lastWeekRank IS NULL ORDER BY weekStartDate DESC, rank ASC
CREATE INDEX idx_weekly_new_optimized
ON weekly_keywords(lastWeekRank, weekStartDate DESC, rank ASC);
// 上升词查询:WHERE lastWeekRank IS NOT NULL ORDER BY change DESC
CREATE INDEX idx_weekly_rising_optimized
ON weekly_keywords(lastWeekRank, weekStartDate DESC, rank);
这些索引可以让 SQLite 完全避免排序操作,直接按索引顺序返回结果。
6.2 减少返回字段
只查询需要的字段,不要 SELECT *:
// 不好
SELECT * FROM keywords WHERE ...
// 好
SELECT rank, keyword, clickShare1, weekStartDate FROM keywords WHERE ...
数据量大时,这能节省不少网络传输和内存占用。
七、总结
这次优化主要做了两件事:
- 使用 FTS5 加速文本搜索:从 LIKE 的全表扫描变成 FTS 的索引查找
- 建立复合索引:覆盖常见查询模式,避免排序
效果很明显:查询时间从9秒降到300毫秒,性能提升30倍。
如果你也在做 SQLite 相关的项目,遇到类似的性能问题,可以试试这些方案。