SQLite 查询优化实战:从9秒到300毫秒

一、查询慢的问题

大家好呀,我是一诺。

上篇文章 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 不是万能的,有一些限制:

  1. 只适合文本匹配:不能用在数值、日期等字段上
  2. 需要额外空间:FTS 表大约占用主表 30-50% 的空间
  3. 需要同步:插入、更新、删除数据时要同时更新 FTS 表
  4. 分词问题:中英文混合时,分词效果可能不理想

在我的项目里,因为主要是英文关键词搜索,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 ...

数据量大时,这能节省不少网络传输和内存占用。

七、总结

这次优化主要做了两件事:

  1. 使用 FTS5 加速文本搜索:从 LIKE 的全表扫描变成 FTS 的索引查找
  2. 建立复合索引:覆盖常见查询模式,避免排序

效果很明显:查询时间从9秒降到300毫秒,性能提升30倍。

如果你也在做 SQLite 相关的项目,遇到类似的性能问题,可以试试这些方案。

相关推荐
贺今宵2 小时前
安装sqlite3报错找不到c++/python/nodegyp错误,electron-vite,下载Visual Studio,配置vc环境变量
electron·sqlite·node.js
码农水水2 小时前
宇树科技Java被问:数据库连接池的工作原理
java·数据库·后端·oracle
朱穆朗2 小时前
electron升级到33.0.x版本后,devtools字体的修改方法
前端·javascript·electron
yangmf20402 小时前
INFINI Gateway 助力联想集团 ES 迁移升级
大数据·数据库·elasticsearch·搜索引擎·gateway·全文检索
码农阿豪2 小时前
MySQL 亿级大表(1.35亿条)安全添加字段实战指南
数据库·mysql·安全
典龙3302 小时前
推荐一款开源免费的AI智能数据库工具,支持连接mysql,oracle,postgresql,ssh,redis
数据库·mysql
怎么没有名字注册了啊2 小时前
VMware 完整版安装 Debian 纯命令行系统(无图形化、超详细全程教程)
linux·服务器·网络·数据库·debian
此生只爱蛋2 小时前
【Redis】Zset 有序集合
数据库·redis·缓存
睿思达DBA_WGX2 小时前
Oracle 服务器 ORA-12516 错误的处理过程
服务器·数据库·oracle