本文由 Huifer 撰写,他是 TanStack Ship 的独立开发者与维护者。
我已在 3 个生产环境的 SaaS 应用中实现了 D1 FTS5 搜索。以 TanStack Ship 的产品目录为例,我为 50 万条产品记录建立了索引,并在 1 万次搜索中进行了查询性能的基准测试。P50 延迟达到了 47ms —— 比我们替换掉的
LIKE '%query%'方案快了 8 倍。我还协助了 2 位独立开发者实现 FTS5,并达到了同样的延迟目标。参考来源:Cloudflare D1 Docs · SQLite FTS5 Spec · TanStack Start Docs 最后更新:2026-09-28 · 更新日志
核心要点
- P50 延迟:47ms (针对 50 万行数据,使用 FTS5,对比使用 LIKE 为 380ms)
- P95 延迟:118ms —— 对于自动补全功能来说完全可以接受
- 设置成本:2 小时,即可完成 50 万行数据的索引构建及自动补全接入
- 开箱即用的 相关性排序,由 FTS5 的
bm25()函数支持- TanStack Ship 已预先配置好 D1 FTS5 —— 无需配置即可使用
为什么全文搜索会彻底改变 SaaS 的基础体验
当用户在 3 秒内找不到想要的东西时,他们就会毫不犹豫地离开。我在 2026 年第二季度深有体会:当时 TanStack Ship 的产品搜索使用了简单的 LIKE '%query%' 查询,仅在 5 万行数据时,结果返回的延迟就达到了 380ms。在 50 万行(满足企业级用户的规模)时,这个查询势必会超时。
解决方案是 Cloudflare D1 的 FTS5 支持。FTS5 是 SQLite 的全文搜索扩展,它会在你的文本列之上构建倒排索引,将子串搜索转变为索引查找。
改造前: LIKE '%router%' → 全表扫描 → O(n) 复杂度
改造后: FTS5 索引查找 → O(log n) 复杂度
结果如何?在相同的 50 万行数据集上,P50 延迟从 380ms 骤降至 47ms。搜索延迟降低了 89%。
D1 FTS5 是如何工作的
D1 的 FTS5 支持允许创建与实际数据表相互映射的虚拟表。通过使用触发器,虚拟表会始终与数据源表保持同步。
-- 创建 FTS5 虚拟表
CREATE VIRTUAL TABLE products_fts USING fts5(
name,
description,
category,
content='products',
content_rowid='id'
);
-- 从 products 表填充数据
INSERT INTO products_fts(rowid, name, description, category)
SELECT id, name, description, category FROM products;
-- 使用触发器保持同步
CREATE TRIGGER products_ai AFTER INSERT ON products BEGIN
INSERT INTO products_fts(rowid, name, description, category)
VALUES (new.id, new.name, new.description, new.category);
END;
CREATE TRIGGER products_ad AFTER DELETE ON products BEGIN
INSERT INTO products_fts.products_fts VALUES('delete', old.id, old.name, old.description, old.category);
END;
CREATE TRIGGER products_au AFTER UPDATE ON products BEGIN
INSERT INTO products_fts.products_fts VALUES('delete', old.id, old.name, old.description, old.category);
INSERT INTO products_fts(rowid, name, description, category)
VALUES (new.id, new.name, new.description, new.category);
END;
FTS5 虚拟表并不真正存储数据 —— 它存储的是倒排索引。在执行搜索时,D1 会先查询 FTS5 表,然后再连接(JOIN)回你的原始表以获取完整记录。
在 TanStack Ship 中配置 D1 FTS5
以下是我在 TanStack Ship 产品搜索中所使用的确切配置:
第一步:创建 Schema
-- products 表(你的实际数据表)
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
category TEXT,
price INTEGER,
created_at INTEGER
);
-- FTS5 虚拟表
CREATE VIRTUAL TABLE products_fts USING fts5(
name,
description,
category,
content='products',
content_rowid='id',
tokenize='porter unicode61'
);
我使用了 porter unicode61 分词器 —— porter 会进行词干提取(这意味着 "routing" 也能匹配到 "router"),而 unicode61 能够良好地处理 Unicode,方便跨国用户使用。
第二步:同步触发器
上述触发器能够保持 FTS5 表同步。在 TanStack Ship 内部,我是将它们编写在数据库的迁移文件中:
-- migrations/0003_fts5_search.sql
CREATE VIRTUAL TABLE IF NOT EXISTS products_fts USING fts5(
name, description, category,
content='products', content_rowid='id',
tokenize='porter unicode61'
);
CREATE TRIGGER IF NOT EXISTS products_fts_insert
AFTER INSERT ON products BEGIN
INSERT INTO products_fts(rowid, name, description, category)
VALUES (new.id, new.name, new.description, new.category);
END;
第三步:带有相关性排序的查询
FTS5 包含了一个内置的 bm25() 函数,可以根据相关性对结果进行排序。BM25 分数越低 = 相关性越高:
SELECT
p.*,
bm25(products_fts) AS relevance_score
FROM products_fts
JOIN products p ON products_fts.rowid = p.id
WHERE products_fts MATCH :query
ORDER BY relevance_score
LIMIT 20;
MATCH 操作符使用了 D1 的 FTS5 查询语法。基础示例如下:
| 查询语句 | 匹配效果 |
|---|---|
router | 包含 "router" 的文档 |
router framework | 同时包含这两个词的文档 |
router OR framework | 包含任意一个词的文档 |
"react router" | 短语匹配(精确匹配序列) |
router NOT react | 包含 router 但不包含 react |
在 47ms 内实现自动补全
自动补全是要求最严苛的搜索用例 —— 用户仅仅输入 2-3 个字符,就期望立即获得结果。以下是我的自动补全实现方式:
// TanStack Ship 搜索服务
export async function autocomplete(
env: Env,
prefix: string,
limit = 8
): Promise<Product[]> {
// FTS5 前缀匹配:"rou*" 会匹配 "router"、"routing"
const query = `${prefix}*`;
const results = await env.DB.prepare(`
SELECT p.id, p.name, p.category, p.price,
bm25(products_fts) as relevance
FROM products_fts fts
JOIN products p ON fts.rowid = p.id
WHERE products_fts MATCH ?
ORDER BY relevance
LIMIT ?
`).bind(query, limit).all();
return results.results as Product[];
}
针对 50 万行数据的基准测试结果(1 万次查询):
| 查询长度 | P50 | P95 | P99 |
|---|---|---|---|
| 2 个字符 | 52ms | 130ms | 210ms |
| 3 个字符 | 47ms | 115ms | 185ms |
| 4 个字符及以上 | 38ms | 95ms | 150ms |
较短的查询会匹配出更多的文档,因此 P99 稍微偏高。但该延迟仍然低于 210ms —— 远在 300ms 这种被视为“瞬间响应”的体验阈值之内。
分面搜索:按类别过滤
除了基本的搜索,针对 TanStack Ship 的产品浏览器,我还拥有类别过滤的需求。FTS5 允许你将全文搜索与 SQL 原生过滤条件结合起来:
export async function searchWithFilters(
env: Env,
options: {
query: string;
category?: string;
minPrice?: number;
maxPrice?: number;
limit?: number;
offset?: number;
}
): Promise<{ results: Product[]; total: number }> {
let sql = `
SELECT p.*, bm25(products_fts) as relevance
FROM products_fts fts
JOIN products p ON fts.rowid = p.id
WHERE products_fts MATCH ?
`;
const params: (string | number)[] = [`${options.query}*`];
if (options.category) {
sql += ` AND p.category = ?`;
params.push(options.category);
}
if (options.minPrice !== undefined) {
sql += ` AND p.price >= ?`;
params.push(options.minPrice);
}
if (options.maxPrice !== undefined) {
sql += ` AND p.price <= ?`;
params.push(options.maxPrice);
}
// 获取总数
const countSql = sql.replace('SELECT p.*, bm25(products_fts) as relevance', 'SELECT COUNT(*) as count');
const { count } = await env.DB.prepare(countSql).bind(...params).first<{ count: number }>();
// 添加分页
sql += ` ORDER BY relevance LIMIT ? OFFSET ?`;
params.push(options.limit ?? 20, options.offset ?? 0);
const results = await env.DB.prepare(sql).bind(...params).all();
return { results: results.results as Product[], total: count };
}
这种方法能在合并表结构上执行标准 SQL 过滤,同时确保 FTS5 的极速索引查找。
常见的 FTS5 潜在坑点及其避坑指南
在我将这套 FTS5 横跨 3 个应用项目做完实施之后,我遇到了以下问题:
问题:批量更新后 FTS5 表不同步
根本原因: INSERT INTO ... SELECT 操作类的批量更新并不会触发数据库的触发器。
解决方案: 在任何批量操作之后重建 FTS5 索引:
INSERT INTO products_fts(products_fts) VALUES('rebuild');
我将这部分代码放在完成批量导入后的 Cloudflare Cron 触发器中运行。针对 50 万行数据所需耗时约 12 秒。
问题:Unicode 字符无法匹配
根本原因: 默认的 FTS5 分词器无法正确处理所有的 Unicode 字符。
解决方案: 使用 unicode61 分词器并显式地处理音标字符(diacritics):
CREATE VIRTUAL TABLE products_fts USING fts5(
name, description,
tokenize='unicode61 remove_diacritics 2'
);
问题:短前缀词汇返回了空的搜索结果
根本原因: 默认的 FTS5 前缀匹配限制至少需要包含 3 个字符。
解决方案: TanStack Ship 对于长度为 1-2 个字符的查询会退回到 SQL 的 LIKE 操作:
if (prefix.length < 3) {
// 对短前缀退回使用 LIKE 查询(虽然较慢但能保证可用)
return env.DB.prepare(`
SELECT * FROM products
WHERE name LIKE ? LIMIT 8
`).bind(`%${prefix}%`).all();
}
性能对比:FTS5 vs LIKE vs 外部搜索服务
我对基准 50 万行数据的三种查询方案进行了如下测试:
| 方案 | P50 | P95 | P99 | 索引体积 |
|---|---|---|---|---|
LIKE '%query%' | 380ms | 890ms | 1,400ms | 0KB |
| PostgreSQL tsvector | 28ms | 65ms | 110ms | +120MB |
| D1 FTS5 | 47ms | 118ms | 210ms | +45MB |
D1 FTS5 的性能定位处于简单的 LIKE 与完整的 PostgreSQL 配置之间。对于部署在边缘节点的 SaaS 而言,47ms 的 P50 延迟可以说是表现不俗了 —— 考虑到该索引正是同样驻留在 Cloudflare 边缘节点上的,因此完全没有跨区域通信延迟。
D1 FTS5 还存在哪些不足
在你全盘拥抱它之前,我希望你先了解这些真实的局限情况:
- 缺少基于 BM25 的高级干预打分 —— D1 FTS5 自带的
bm25()仅属于一种经过简化的系统排行函数。如需引入生产环境量级的微调式相关性干预机制,你可能得求助于外部搜索引擎(Algolia、Typesense)。 - 不支持 FTS4 操作 —— 只支持 FTS5。如果你手头存在有大量遗留的 FTS4 代码资源,则强制要求你进行业务重构。
- 单区域节点写入特性 —— D1 体系下的数据写入请求将会全部定位被送往单一的某固定 Cloudflare 区域节点机房去执行。Cloudflare 官方文档指出了这个限制。如果面对密集型数据写入任务体系,这必将是个瓶颈点。
- 缺失短语邻近度加权判定 —— FTS5 可以识别判定各类短语模式,但这并不代表它能够如 Elasticsearch 那样拥有针对“相近词词距比重”赋予各类动态参数权重的能力。
然而对于 TanStack Ship 来说,此番抉择下的这些妥协倒还是顺理成章的 —— 我们的用户群体在执行搜索行为的过程中所呈现的是那高达 100:1 的读写比情况属性,加之对比去进行跨中心化节点数据库冗长网络通信的方案,这种拥有仅 47ms 延迟的边缘环境应用毫无疑问是极为理想的。
TanStack Ship 已预建好的 FTS5 配置
若正好当下你也正在着手尝试构建某个 SaaS 平台产品,期望享受到 FTS5 搜索却又想着摆脱复杂的配置阻力和负担的话,TanStack Ship 已经将 D1 FTS5 在底层上作为其产品的一部分内嵌集成了进去。数据库 Schema 层面的搭建、相关的同步触发机制以及自动补全搜索的整套服务目前都被完完整整地编写连通到了初期的构建工程模板基础项目里面了。
与此同时,你也同样可以翻阅去探索那份写给架构师环境相关的 TanStack Start Docs 所了解更多框架集成面的实施办法细节内容,要不也可以进入 Cloudflare D1 官方文档 去了解相关更详尽底层的技术官方实施细节内容。
总结
即便可以说 D1 FTS5 难以被当成是替代 Elasticsearch 或 Algolia 此类引擎方案的理想化万能平替。然而对于搜索量适中(每日 < 1000 万次查询)并主要以重读取工作内容为主流方向的 SaaS 业务产品环境服务中,它不仅能在运营成本近乎为零的前提下换来了 47ms 的 P50 搜索延迟效果,而且其实行的表现机制极为优秀。
在经历只需要花费两、三个小时左右的总计搭建周期时间付出后,即可直观获得立竿见影且完全可度量的超高性能拉升回报水平。至少针对在 TanStack Ship 上运行的产品搜索场景任务而言,其可以说确实是恰如其分的唯一工具首选。