全栈独立产品搜索体验复盘:从 LIKE 到 Elasticsearch 的升级路径
一、搜索体验的阶段性困境:LIKE 不等于搜索
独立产品最早期的搜索通常都用 SQL LIKE 实现:SELECT * FROM articles WHERE title LIKE '%关键词%' OR content LIKE '%关键词%'。在产品初期(数据量 < 1 万条、日搜索量 < 100 次),LIKE 查询完全够用------MySQL 在 title 列建立全文索引(FULLTEXT INDEX)后,1 万条数据内的查询延迟在 50ms 以内。没有人会因为搜索慢而流失。
但数据量和搜索量在跨过临界点后,LIKE 方案的体验会快速恶化:
- 性能临界点 :10 万条数据以上,单次
LIKE '%keyword%'的查询耗时 500ms~2s。这是因为前缀通配符%keyword%无法使用 B-Tree 索引,MySQL 必须做全表扫描。 - 相关性临界点 :
LIKE返回的结果是"包含关键词的所有记录",按数据写入时间排序。用户搜索"React 性能优化",返回的第一条是 2019 年的"React 入门教程"(因为文中恰好提到了"性能优化"一词),而真正相关的 2024 年"React 18 并发特性下的性能优化策略"排在第 17 条。 - 功能临界点 :用户期望的分词搜索(搜索"前端"能匹配"前端开发")、拼音搜索(输入"react"能匹配"React")、模糊匹配("prefomance"纠错为"performance")、高亮显示------这些在
LIKE方案下全部不可实现。
二、阶段一:LIKE 的快速起步与死亡螺旋
2.1 LIKE 方案的实现
第一阶段用 LIKE 实现搜索的核心代码非常简短------这也是为什么它总是被选择作为起点:
sql
-- 基础搜索
SELECT id, title, content, created_at
FROM articles
WHERE title LIKE CONCAT('%', ?, '%')
OR content LIKE CONCAT('%', ?, '%')
ORDER BY created_at DESC
LIMIT 20;
2.2 LIKE 方案的三大硬伤
随着数据量和搜索频率的增长,三个硬伤逐一显现:
-
全表扫描的不可扩展性 :
LIKE '%keyword%'的第一个%是最致命的。它告诉 MySQL"这个关键词可能出现在字段的任何位置",MySQL 无法使用 B-Tree 索引,只能逐行扫描。10 万条数据 ≈ 10 万次字符串比较,这是不可扩展的。 -
结果排序的无意义性:按创建时间排序意味着一个 3 年前的文章如果恰好包含关键词,会排在 3 天前的高质量文章前面。《MySQL 入门》里提到了一句"数据安全",就能排在专讲"数据安全最佳实践"的文章前面。这是糟糕的相关性。
-
无分词的语义断裂 :搜索"前端开发"时,
LIKE只会匹配包含连续字符串"前端开发"的记录,不会匹配"前端"+"开发"两个独立词分别出现在不同位置的情况。中分词的缺失让搜索精度极低。
三、阶段二:MySQL FULLTEXT 的过渡期
3.1 FULLTEXT INDEX 与 ngram 分词
MySQL 5.7+ 支持 FULLTEXT INDEX 和 ngram 分词器,可以解决 LIKE 的性能问题:
sql
-- 添加全文索引
ALTER TABLE articles ADD FULLTEXT INDEX ft_title_content (title, content) WITH PARSER ngram;
-- 全文搜索(BOOLEAN MODE)
SELECT
id, title, content, created_at,
MATCH(title, content) AGAINST(? IN BOOLEAN MODE) AS relevance
FROM articles
WHERE MATCH(title, content) AGAINST(? IN BOOLEAN MODE)
ORDER BY relevance DESC
LIMIT 20;
FULLTEXT INDEX 将文本切分为 n-gram(2字词组),并建立倒排索引。查询时不需要全表扫描,而是走索引查找。10 万条数据的全文搜索从 LIKE 的 500ms 降到 50ms 以内。
3.2 FULLTEXT 方案的局限
但 FULLTEXT INDEX 在以下场景中仍然不够用:
- 相关性打分粗糙 :
MATCH...AGAINST的评分算法基于词频(TF)和逆文档频率(IDF),但不考虑字段权重(标题匹配应该比正文匹配更相关)、词位置(关键词出现在开头比出现在末尾更相关)。 - 缺少高亮:用户期望在搜索结果中看到关键词被标黄高亮,FULLTEXT 本身不提供这个能力。
- 无聚合统计:搜索结果无法按分类、年份、作者聚合统计。
- 无拼音/纠错:不支持拼音搜索和拼写纠错。
这些是搜索引擎(Elasticsearch)才能解决的需求。
四、阶段三:Elasticsearch 的全面升级
4.1 索引结构的迁移
从 MySQL 迁移到 ES 的关键是数据同步策略。对于独立产品来说,不需要 Canal + Kafka 的 CDC(Change Data Capture)方案。更务实的做法是应用层双写:
typescript
/**
* 搜索服务:应用层双写 + Elasticsearch 查询
* 写操作同时写入 MySQL 和 ES,读操作走 ES
*/
interface SearchDocument {
id: string;
title: string;
content: string;
summary: string;
category: string;
tags: string[];
author: string;
createdAt: number;
updatedAt: number;
viewCount: number;
}
class SearchService {
private esHost: string;
private esIndex: string;
constructor(host: string, index: string) {
this.esHost = host;
this.esIndex = index;
}
/**
* 创建/更新文档
* 在应用层同时写入 MySQL 和 ES
*/
async indexDocument(doc: SearchDocument): Promise<boolean> {
try {
// 1. 先写 MySQL(主数据源)
await this.saveToMySQL(doc);
// 2. 再同步到 ES(允许失败,后续补同步)
const response = await fetch(
`${this.esHost}/${this.esIndex}/_doc/${doc.id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(doc),
}
);
if (!response.ok) {
console.error(`[ES] 索引文档失败: ${response.status}`);
// 标记为待同步
await this.markForResync(doc.id);
// ES 写入失败不影响主流程
}
return true;
} catch (err) {
console.error('[ES] 索引文档异常:', err);
await this.markForResync(doc.id);
return true; // 主流程(MySQL)已成功
}
}
/**
* 搜索文档
* 使用 ES 的多字段匹配 + 高亮 + 聚合
*/
async search(query: string, options: SearchOptions): Promise<SearchResult> {
try {
const esQuery = this.buildESQuery(query, options);
const response = await fetch(`${this.esHost}/${this.esIndex}/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(esQuery),
});
if (!response.ok) {
throw new Error(`ES search error: ${response.status}`);
}
const data = await response.json();
return this.parseESResponse(data);
} catch (err) {
console.error('[ES] 搜索失败,降级到 MySQL 搜索:', err);
// 降级:走 MySQL FULLTEXT 或 LIKE 兜底
return this.fallbackSearch(query, options);
}
}
/**
* 构建 ES 查询 DSL
*/
private buildESQuery(query: string, options: SearchOptions) {
return {
from: (options.page - 1) * options.pageSize,
size: options.pageSize,
query: {
bool: {
should: [
// 标题匹配权重最高
{
match: {
title: {
query,
boost: 3.0,
fuzziness: 'AUTO',
operator: 'and',
},
},
},
// 摘要匹配权重中
{
match: {
summary: {
query,
boost: 1.5,
},
},
},
// 正文匹配权重低
{
match: {
content: {
query,
boost: 1.0,
},
},
},
// 标签精确匹配
{
terms: {
tags: [query],
},
},
],
minimum_should_match: 1,
filter: this.buildFilters(options),
},
},
highlight: {
fields: {
title: { number_of_fragments: 0 },
summary: {
fragment_size: 150,
number_of_fragments: 1,
},
content: {
fragment_size: 200,
number_of_fragments: 1,
},
},
pre_tags: ['<mark>'],
post_tags: ['</mark>'],
},
aggs: {
categories: {
terms: { field: 'category', size: 20 },
},
},
sort: options.sortBy === 'relevance'
? [{ _score: 'desc' }]
: [{ createdAt: 'desc' }],
};
}
/**
* 构建过滤条件
*/
private buildFilters(options: SearchOptions): Record<string, unknown>[] {
const filters: Record<string, unknown>[] = [];
if (options.category) {
filters.push({ term: { category: options.category } });
}
if (options.tags && options.tags.length > 0) {
filters.push({ terms: { tags: options.tags } });
}
return filters;
}
/**
* 解析 ES 返回结果
*/
private parseESResponse(data: any): SearchResult {
return {
total: data.hits.total.value,
items: data.hits.hits.map((hit: any) => ({
id: hit._id,
score: hit._score,
source: hit._source,
highlights: {
title: hit.highlight?.title?.[0],
summary: hit.highlight?.summary?.[0],
content: hit.highlight?.content?.[0],
},
})),
aggregations: {
categories: data.aggregations?.categories?.buckets?.map(
(b: any) => ({ key: b.key, count: b.doc_count })
) ?? [],
},
};
}
/**
* ES 不可用时的降级搜索
*/
private async fallbackSearch(
query: string,
options: SearchOptions
): Promise<SearchResult> {
// 降级:走 MySQL 全文搜索
const sql = `
SELECT id, title, content, category, tags, created_at
FROM articles
WHERE MATCH(title, content) AGAINST(? IN BOOLEAN MODE)
ORDER BY created_at DESC
LIMIT ?, ?
`;
// 返回简化版结果(无高亮、无聚合)
return { total: 0, items: [], aggregations: { categories: [] } };
}
/**
* 补同步:将标记为待同步的文档重新索引
*/
async resyncStaleDocuments(): Promise<void> {
const staleIds = await this.getStaleDocumentIds();
for (const id of staleIds) {
const doc = await this.getDocumentFromMySQL(id);
if (doc) {
await this.indexDocument(doc);
}
}
}
// Stub 方法
private async saveToMySQL(_doc: SearchDocument): Promise<void> {}
private async markForResync(_id: string): Promise<void> {}
private async getStaleDocumentIds(): Promise<string[]> { return []; }
private async getDocumentFromMySQL(_id: string): Promise<SearchDocument | null> { return null; }
}
interface SearchOptions {
query: string;
page: number;
pageSize: number;
category?: string;
tags?: string[];
sortBy: 'relevance' | 'date';
}
interface SearchResult {
total: number;
items: SearchResultItem[];
aggregations: {
categories: { key: string; count: number }[];
};
}
interface SearchResultItem {
id: string;
score: number;
source: SearchDocument;
highlights: {
title?: string;
summary?: string;
content?: string;
};
}
4.2 前端搜索 UI 的配套升级
ES 的搜索结果包含了前端可以消费的丰富数据:
- 高亮片段 :
<mark>关键词</mark>直接渲染在搜索结果中。 - 聚合数据:按分类聚合的结果数,在搜索框下方以标签形式展示("前端开发(42)"、"后端开发(18)")。
- 相关性分数 :用于决定搜索结果是否展示"无相关结果"的提示(
score低于阈值时)。
4.3 ES 方案的成本控制
独立产品引入 ES 最常见的顾虑是"太重了"------ES 本身需要 512MB~2GB 内存,对于一个小服务器来说是巨大的开销。折中方案:
- 使用 ES 云服务(如 Elastic Cloud、阿里云 ES):按量付费,最低配置每月约 100~200 元。
- 使用 Meilisearch 替代:Meilisearch 是一个轻量级的开源搜索引擎,Rust 编写,内存占用 100~200MB,内置了中文分词、容错、高亮等功能,部署成本远低于 ES。对于独立产品来说,Meilisearch 往往是比 ES 更务实的选择。
五、总结
搜索体验的升级是一个"三段跳"的过程:
第一阶段(LIKE):适用于数据量 < 1 万条的场景。实现简单,一个 SQL 语句搞定。超出临界点后性能和相关性迅速恶化。
第二阶段(MySQL FULLTEXT):用 ngram 分词 + 倒排索引解决 LIKE 的全表扫描性能问题。10 万条数据的查询从 500ms 降到 50ms。但相关性打分粗糙,缺少高亮和聚合。
第三阶段(Elasticsearch/Meilisearch):多字段权重打分(标题 3x、摘要 1.5x、正文 1x)、IK 中文分词、高亮、聚合、拼音搜索、拼写纠错。搜索结果的相关性和丰富度质变。
落地建议:如果数据量还没到 5 万条,先用 MySQL FULLTEXT 兜底。当搜索成为核心功能时(日搜索量 > 500 次),优先考虑 Meilisearch(部署成本低、内置中文支持),而不是直接上 ES。