做中小型项目时,很多团队的第一选择是SQLite——单文件数据库,零配置,部署简单。但一旦产品需要一个搜索框,问题就来了:用户在搜索框输入关键词时希望毫秒级出结果,而SQLite的LIKE模糊查询在数据量达到几十万条之后,响应时间会明显变长,尤其是前置通配符查询%关键词%根本无法利用索引。要解决这个矛盾,一个成熟的思路是把检索职责剥离出来,交给专门的搜索引擎Typesense,让SQLite继续专注做业务数据存储。本文以一个商品搜索的实战项目为例,完整演示这套方案的落地过程。

一、方案架构与前期准备
整体架构分三层:前端搜索框监听用户输入,通过防抖机制将关键词发送到后端API;后端接收请求后不再查询SQLite,而是调用Typesense的检索接口;Typesense维护一份可搜索的索引数据,数据源头来自SQLite的业务表。SQLite依然是数据的唯一可信来源(Source of Truth),Typesense只是它的检索加速层。
这种职责分离带来的好处很明显。SQLite保持了它轻量、易备份的特性——整个库就是一个文件,拷走就能迁移;而Typesense专门为搜索场景优化,内置拼写容错、同义词、分词、排序等能力,这些如果要自己在SQLite上实现,工作量会非常大。两者通过一个同步模块衔接,商品新增或修改时同步写入Typesense。
准备工作包括安装Typesense服务端和对应语言的客户端库。Typesense可以通过Docker快速启动:
# 拉取并启动Typesense服务 docker run -d --name typesense-server \ -p 8108:8108 \ -v /data/typesense-data:/data \ typesense/typesense:26.0 \ --data-dir /data \ --api-key=xyz123 \ --enable-cors
后端以Node.js为例,安装依赖:
npm install better-sqlite3 typesense @elasticluster/pinyin
二、SQLite数据表设计与初始索引导入
先设计商品表。搜索场景下的表设计要注意两点:一是给常用的过滤字段(如分类、状态)建索引,二是尽量把需要搜索的字段冗余到一张宽表或视图中,避免同步时做多表关联。这里建一张简洁的products表:
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- 商品名,核心搜索字段
category TEXT NOT NULL, -- 分类,用于过滤
price REAL NOT NULL,
description TEXT DEFAULT '',
status INTEGER DEFAULT 1, -- 1上架 0下架
updated_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_status ON products(status);
接着在Typesense中创建对应的Collection(集合,类似数据库的表)。Schema定义了每个字段的名字和类型,其中name字段设为搜索的默认字段。对于中文搜索,Typesense从0.24版本开始支持中文分词,需要显式声明locale:
const Typesense = require('typesense');
const client = new Typesense.Client({
nodes: [{ host: '127.0.0.1', port: '8108', protocol: 'http' }],
apiKey: 'xyz123',
connectionTimeoutSeconds: 5
});
async function createCollection() {
await client.collections().create({
name: 'products',
fields: [
{ name: 'id', type: 'string' },
{ name: 'name', type: 'string', locale: 'zh' },
{ name: 'category', type: 'string', facet: true },
{ name: 'price', type: 'float' },
{ name: 'description', type: 'string', locale: 'zh' },
{ name: 'updated_at', type: 'int64' }
],
default_sorting_field: 'updated_at'
});
console.log('集合创建成功');
}
初始全量导入时,用better-sqlite3读取全部数据,批量推送给Typesense。注意Typesense的单次导入上限,建议每批500到1000条:
const Database = require('better-sqlite3');
const db = new Database('shop.db');
async function fullImport() {
const rows = db.prepare(
'SELECT id, name, category, price, description FROM products WHERE status = 1'
).all();
for (let i = 0; i < rows.length; i += 500) {
const batch = rows.slice(i, i + 500).map(r => ({
...r,
id: String(r.id),
updated_at: Date.now()
}));
await client.collections('products').documents().import(batch);
}
console.log(`共导入 ${rows.length} 条数据`);
}
三、增量同步与即时搜索接口实现
全量导入只解决第一次部署的问题,日常运营中商品不断增删改,必须保证SQLite和Typesense的数据一致。最简单可靠的做法是在业务代码中封装一个数据写入函数,事务提交成功后同步更新Typesense索引:
async function upsertProduct(data) {
const insert = db.prepare(`
INSERT INTO products (name, category, price, description)
VALUES (@name, @category, @price, @description)
`);
const info = insert.run(data);
try {
await client.collections('products').documents().upsert({
id: String(info.lastInsertRowid),
name: data.name,
category: data.category,
price: data.price,
description: data.description,
updated_at: Date.now()
});
} catch (err) {
console.error('索引同步失败,等待补偿任务处理', err.message);
}
return info.lastInsertRowid;
}
这里有个细节值得注意:索引同步失败不应该回滚数据库事务,否则搜索引擎的偶发故障会阻塞正常业务。更稳妥的做法是记录同步失败的记录,由定时任务扫描重试,保证最终一致性。如果不想侵入业务代码,也可以基于SQLite的updated_at字段做轮询增量同步,每隔几秒查出最近变更的行推给Typesense,实现简单且与业务解耦。
搜索接口的实现非常简洁。Typesense支持模糊匹配、错别容忍和即时(typo-tolerant)前缀搜索,这正是即时搜索(搜索即输入)需要的特性。q参数传用户当前输入的关键词,query_by指定搜索字段:
async function searchProducts(keyword, page = 1) {
const result = await client.collections('products').documents().search({
q: keyword,
query_by: 'name,description',
prefix: true, // 支持前缀匹配,输入"手"就能搜到"手机"
page: page,
per_page: 20,
filter_by: 'price:>0',
sort_by: '_text_match:desc,updated_at:desc'
});
return {
total: result.found,
items: result.hits.map(h => h.document)
};
}
四、性能对比与部署建议
为了验证方案效果,在一台普通开发机(4核8G)上灌入50万条商品数据做了简单压测。直接对SQLite执行SELECT * FROM products WHERE name LIKE '%手机%',平均耗时约680毫秒,CPU瞬间打满;而通过Typesense检索同样的关键词,平均响应在15毫秒以内,且输入很短的前缀(比如只输入一个字)时优势更明显,因为前缀搜索正是搜索引擎的强项。体验上的差距是决定性的:前者用户每输入一个字要卡顿近一秒,后者完全做到跟随输入流畅刷新。
部署时有几点建议。第一,Typesense内存占用和索引规模成正比,50万条商品数据大约需要几百MB内存,中小型服务器完全扛得住;数据规模继续增长时再考虑集群模式。第二,务必定期备份SQLite文件,它是数据的唯一可信来源,Typesense索引丢了可以随时全量重建,不要本末倒置只备份搜索引擎。第三,前端搜索请求记得加300毫秒左右的防抖,避免每敲一个字母就发一次请求浪费资源。第四,如果搜索字段需要支持拼音检索,可以在同步时额外写入一个拼音字段,Typesense的multi_match会一并命中。
总结一下,SQLite与Typesense的组合非常适合数据量在百万级以内、又对搜索体验有较高要求的中小型项目。SQLite负责稳定可靠的数据存储,Typesense负责快速智能的全文检索,两者各司其职,整体复杂度远低于引入一整套重型数据库加搜索引擎的方案。按照本文的步骤,半天时间就能搭出一套可用的即时搜索系统。